Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
- name: Typecheck governed authority service
run: ./reference-implementations/trust-authority/node_modules/.bin/tsc -p services/authority/tsconfig.json

- name: Test governed authority using synthetic loopback HTTP scenarios
run: node scripts/check-authority-staging.mjs

- name: Go verifier regression and concurrency tests
run: |
go version
Expand Down
132 changes: 132 additions & 0 deletions scripts/check-authority-staging.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env node
// Synthetic local staging harness: no cloud calls, no real GitHub approvals.
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import {spawn,spawnSync} from 'node:child_process';
import net from 'node:net';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const tmp=fs.mkdtempSync(path.join(os.tmpdir(),'ttp-cfav-authority-'));
const token='synthetic-token-'+crypto.randomBytes(32).toString('hex');
const key='synthetic-hmac-'+crypto.randomBytes(32).toString('hex');
const now=new Date();
const verifiedAt=new Date(now.getTime()-15000).toISOString();
const validUntil=new Date(now.getTime()+1800000).toISOString();
const expired=new Date(now.getTime()-60000).toISOString();
const grant=(updates={})=>({
subject:'wi://github/actions/runner',repo:'BlockSiFr/ttp-protocol',
action:'pull_request.execute',branch:'cfav-synthetic-branch',
trustScore:0.95,verifiedAt,expiresAt:validUntil,...updates
});
const grants={
'grant-valid':grant(),
'grant-revoked':grant({revoked:true}),
'grant-expired':grant({expiresAt:expired}),
'grant-stale':grant({verifiedAt:new Date(now.getTime()-1000000).toISOString()}),
'grant-other-tenant':grant({repo:'other/tenant'})
};
const config=path.join(root,'services/authority/tsconfig.json');
const tsc=path.join(root,'reference-implementations/trust-authority/node_modules/.bin/tsc');
const compiled=path.join(tmp,'compiled');
const build=spawnSync(tsc,['-p',config,'--noEmit','false','--outDir',compiled],
{cwd:root,encoding:'utf8',timeout:120000});
if(build.status!==0)throw Error('cannot compile authority: '+build.stderr+build.stdout);
const binary=path.join(compiled,'index.js');
assert.ok(fs.existsSync(binary),'compiled authority entry missing');

const probe=net.createServer();
const port=await new Promise((resolve,reject)=>{
probe.once('error',reject);
probe.listen(0,'127.0.0.1',()=>resolve(probe.address().port));
});
await new Promise(resolve=>probe.close(resolve));
let server;
try{
server=spawn(process.execPath,[binary],{
cwd:root,stdio:['ignore','pipe','pipe'],
env:{...process.env,PORT:String(port),RUNTIME_AUTH_TOKEN:token,
RECEIPT_HMAC_SECRET:key,RUNTIME_AUTH_GRANTS_JSON:JSON.stringify(grants)}
});
let stderr='';
server.stderr.on('data',chunk=>{stderr+=chunk.toString().slice(0,2000)});
const url='http://127.0.0.1:'+port;
let healthy=false;
for(let i=0;i<70;i++){
if(server.exitCode!==null)throw Error('authority exited: '+stderr);
try {
const result=await fetch(url+'/healthz',{signal:AbortSignal.timeout(600)});
if(result.status===200){healthy=true;break}
}catch{}
await new Promise(resolve=>setTimeout(resolve,100));
}
assert.ok(healthy,'synthetic authority did not listen');

const base={
subject:'wi://github/actions/runner',action:'pull_request.execute',
resource:'repo:BlockSiFr/ttp-protocol:pr/100',repo:'BlockSiFr/ttp-protocol',
branch:'cfav-synthetic-branch',pathsTouched:['docs/README.md'],
commitSha:'a'.repeat(40),invokingActor:'synthetic-contributor',
workflowRunId:'111222333',attestationRef:'synthetic-only',
authorityGrantRef:'grant-valid',context:{event:'pull_request'}
};
async function request(body,suppliedToken=token){
const r=await fetch(url+'/re/authorize',{
method:'POST',
headers:{'content-type':'application/json',
...(suppliedToken?{authorization:'Bearer '+suppliedToken}:{})},
body:JSON.stringify(body)
});
return {status:r.status,json:await r.json()};
}
for(const auth of ['','forged-caller']){
const r=await request(base,auth);
assert.equal(r.status,401,'invalid bearer');
}
for(const ref of ['absent','grant-revoked','grant-expired','grant-other-tenant']){
const r=await request({...base,authorityGrantRef:ref,trustScore:1,freshnessSeconds:0});
assert.equal(r.json.decision,'DENY','missing, revoked, expired or mismatched grant');
}
const stale=await request({...base,authorityGrantRef:'grant-stale',trustScore:1,freshnessSeconds:0});
assert.notEqual(stale.json.decision,'PERMIT','stale grant cannot permit');
const allowed=await request({...base,trustScore:0,freshnessSeconds:999999});
assert.equal(allowed.status,200);
assert.equal(allowed.json.decision,'PERMIT','must rely on operator grant');
assert.equal(allowed.json.receipt.commitSha,base.commitSha);
assert.equal(allowed.json.receipt.pathsTouched[0],base.pathsTouched[0]);
assert.equal(allowed.json.receipt.workflowRunId,base.workflowRunId);
const {signature,...unsigned}=allowed.json.receipt;
const digest='hmac-sha256:'+crypto.createHmac('sha256',key).update(JSON.stringify(unsigned)).digest('hex');
assert.equal(signature,digest,'receipt HMAC verified using provisioned fixture key');

const protectedReq={...base,pathsTouched:['.github/workflows/ci.yml']};
const stepup=await request(protectedReq);
assert.equal(stepup.json.decision,'STEP_UP');
const reauth={...protectedReq,action:'merge request reauthorize',
priorReceiptId:stepup.json.receiptId,
approval:{environment:'protected-execution',approved:true,approvedBy:'independent-synthetic-reviewer'}};
assert.equal((await request({...reauth,priorReceiptId:allowed.json.receiptId})).json.decision,'DENY');
assert.equal((await request({...reauth,commitSha:'b'.repeat(40)})).json.decision,'DENY');
assert.equal((await request({...reauth,pathsTouched:['.github/workflows/other.yml']})).json.decision,'DENY');
assert.equal((await request({...reauth,approval:undefined})).json.decision,'DENY');
const approved=await request(reauth);
assert.equal(approved.json.decision,'PERMIT','synthetic post-step-up permit');
assert.notEqual(approved.json.receiptId,stepup.json.receiptId);
assert.equal(approved.json.receipt.priorReceiptId,stepup.json.receiptId);
const bad=await fetch(url+'/re/authorize',{
method:'POST',headers:{authorization:'Bearer '+token,'content-type':'application/json'},body:'{broken-json'});
assert.equal(bad.status,400);
console.log('CFAV synthetic authority HTTP PASS: authentication, scoped grants, freshness, bound receipts, STEP_UP, reauthorization and negative controls.');
console.log('Not a live GitHub reviewer approval or production deployment.');
}finally{
if(server){
server.kill('SIGTERM');
await Promise.race([new Promise(resolve=>server.once('exit',resolve)),
new Promise(resolve=>setTimeout(resolve,1500))]);
if(server.exitCode===null)server.kill('SIGKILL');
}
fs.rmSync(tmp,{force:true,recursive:true});
}
6 changes: 5 additions & 1 deletion services/authority/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Set these values in a trusted server-side secret store, not in the GitHub reposi
- `RUNTIME_AUTH_TOKEN`: unique bearer credential of at least 32 bytes, shared only with the trusted GitHub workflow; never expose to PR code.
- `RECEIPT_HMAC_SECRET`: independently provisioned signing key of at least 32 bytes. The former development signing fallback has been removed.
- `RUNTIME_AUTH_GRANTS_JSON`: operator-provisioned mapping from opaque grant IDs to `{subject, repo, action, branch?, expiresAt, trustScore, verifiedAt, revoked?}`. Grant state must be obtained from independently authenticated issuer/operator evidence, not request text. `verifiedAt` must be fresh within 900 seconds.
- `PORT`: trusted listener port (default `8080`). Terminate TLS and authenticate workload identity with an approved fronting gateway.
- `PORT`: trusted listener port (default `8080`). The service listens only on `127.0.0.1`, not on a public interface. Terminate TLS and authenticate workload identity with an approved same-host fronting gateway.

An example **synthetic** grant structure, without credentials:

Expand Down Expand Up @@ -38,3 +38,7 @@ The workflow additionally requires GitHub Actions secrets `RUNTIME_AUTH_URL`, `R
Caller-supplied `trustScore`, `freshnessSeconds` or claimed grant IDs never establish trust by themselves. Unknown or stale grants deny or escalate; they do not permit. Step-up reauthorization requires prior receipt continuity, freshness and independently authenticated GitHub approval evidence.

**Remaining P0 integration:** independently verified GitHub/OIDC workload identity, live negative/positive HTTPS tests, durable signed receipt storage, production-grade issuer and revocation registry, KMS/HSM key custody, tenant separation and target-side completion verification. HMAC receipts are not publicly verifiable Ed25519 receipts. Keep this service inaccessible from untrusted networks until those gates are met.

## Local synthetic HTTP acceptance evidence

`node scripts/check-authority-staging.mjs` compiles and starts this service locally with **throwaway, synthetic credentials and grants** and executes a full positive/negative HTTP matrix. GitHub CI runs this by default. This tests service behavior but does **not** authenticate the GitHub Approvals API, validate actual OIDC identity or satisfy any production deployment requirement.
10 changes: 7 additions & 3 deletions services/authority/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ const server = http.createServer(async (req, res) => {
})

const port = Number(process.env.PORT ?? "8080")
server.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`[authority] listening on :${port}`)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT must be an integer in the range 1 to 65535")
}
// Safe default: bearer credentials must not traverse an exposed plaintext HTTP
// socket. Terminate TLS at an approved local fronting proxy/sidecar.
server.listen(port, "127.0.0.1", () => {
console.log(`[authority] local listener on 127.0.0.1:${port}; use approved TLS fronting gateway`)
})
6 changes: 6 additions & 0 deletions tests/governed-workflow-security.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ test('CFAV: authority starter cannot accept unauthenticated users or default sig
assert.doesNotMatch(signer,/dev-only-secret/);
assert.match(signer,/RECEIPT_HMAC_SECRET/);
});

test('CFAV: starter authority never exposes bearer authentication on an unrestricted HTTP listener',()=>{
const authority=fs.readFileSync('services/authority/index.ts','utf8');
assert.match(authority,/server\.listen\(port, "127\.0\.0\.1"/);
assert.match(authority,/PORT must be an integer/);
});
Loading