diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcacce0..49b551a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/scripts/check-authority-staging.mjs b/scripts/check-authority-staging.mjs new file mode 100644 index 0000000..ce4d1f5 --- /dev/null +++ b/scripts/check-authority-staging.mjs @@ -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}); +} diff --git a/services/authority/README.md b/services/authority/README.md index 3227f4e..627dddd 100644 --- a/services/authority/README.md +++ b/services/authority/README.md @@ -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: @@ -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. diff --git a/services/authority/index.ts b/services/authority/index.ts index b1bdf33..25acb62 100644 --- a/services/authority/index.ts +++ b/services/authority/index.ts @@ -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`) }) diff --git a/tests/governed-workflow-security.test.mjs b/tests/governed-workflow-security.test.mjs index c3a4f26..ed38f98 100644 --- a/tests/governed-workflow-security.test.mjs +++ b/tests/governed-workflow-security.test.mjs @@ -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/); +});