From 0d112100bcc61df5824299f65491b3408877a14c Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:35:17 -0500 Subject: [PATCH 1/6] security: independently verify GitHub environment approval, reviewer and live PR head before post-step-up authority --- services/authority/approvalVerifier.ts | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 services/authority/approvalVerifier.ts diff --git a/services/authority/approvalVerifier.ts b/services/authority/approvalVerifier.ts new file mode 100644 index 0000000..5fa540f --- /dev/null +++ b/services/authority/approvalVerifier.ts @@ -0,0 +1,90 @@ +// Independent GitHub-side approval proof. Caller assertions never count as +// reviewer evidence. Network lookups always use the fixed api.github.com host. +export type ApprovalEntry = { + state?: string + environments?: Array<{ name?: string }> + user?: { login?: string } +} +export interface ReviewClaim { + repo: string + workflowRunId: string + commitSha: string + branch: string + resource: string + invokingActor: string + reviewer: string +} +export interface ReviewProof { + verified: boolean + reason: string + reviewer?: string +} +export function assessApprovalHistory( + entries: unknown, claim: ReviewClaim, author: string, sha: string, branch: string +): ReviewProof { + if (!Array.isArray(entries)) return { verified: false, reason: "INVALID_GITHUB_APPROVAL_HISTORY" } + if (sha !== claim.commitSha || branch !== claim.branch) { + return { verified: false, reason: "GITHUB_PR_HEAD_CHANGED" } + } + const expected = claim.reviewer.toLowerCase() + const blocked = [author, claim.invokingActor].map(s => s.toLowerCase()) + if (!expected || blocked.includes(expected)) return { verified: false, reason: "SELF_APPROVAL" } + const matches = (entries as ApprovalEntry[]).filter(entry => + entry && typeof entry === "object" && + entry.state?.toLowerCase() === "approved" && + entry.user?.login?.toLowerCase() === expected && + Array.isArray(entry.environments) && + entry.environments.some(e => e?.name === "protected-execution") + ) + return matches.length === 1 + ? { verified: true, reason: "GITHUB_APPROVAL_VERIFIED", reviewer: claim.reviewer } + : { verified: false, reason: "INDEPENDENT_APPROVAL_NOT_VERIFIED" } +} +export async function verifyIndependentGithubApproval(claim: ReviewClaim): Promise { + const token = process.env.GITHUB_APPROVAL_VERIFY_TOKEN + if (!token || token.length < 20) return { verified: false, reason: "GITHUB_APPROVAL_VERIFIER_UNCONFIGURED" } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(claim.repo) || + !/^[0-9]+$/.test(claim.workflowRunId) || + !/^[0-9a-fA-F]{40}$/.test(claim.commitSha) || + !claim.reviewer || !claim.invokingActor) { + return { verified: false, reason: "INVALID_GITHUB_APPROVAL_CLAIM" } + } + const match = /^repo:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+):pr\/([0-9]+)$/.exec(claim.resource) + if (!match || match[1].toLowerCase() !== claim.repo.toLowerCase()) { + return { verified: false, reason: "INVALID_PR_RESOURCE" } + } + const headers = { + Accept: "application/vnd.github+json", + Authorization: "Bearer " + token, + "X-GitHub-Api-Version": "2022-11-28" + } + async function readJson(endpoint: string): Promise { + const response = await fetch("https://api.github.com" + endpoint, { + headers, signal: AbortSignal.timeout(8000), redirect: "error" + }) + if (!response.ok) throw new Error("GitHub API error") + return response.json() + } + try { + const base = "/repos/" + claim.repo + const [history, pr] = await Promise.all([ + readJson(base + "/actions/runs/" + claim.workflowRunId + "/approvals"), + readJson(base + "/pulls/" + match[2]) + ]) + if (!pr || typeof pr !== "object") return { verified:false, reason:"PR_NOT_VERIFIED" } + const snapshot = pr as { + state?: string + user?: { login?: string } + head?: { sha?: string, ref?: string } + base?: { repo?: { full_name?: string } } + } + if (snapshot.state !== "open" || !snapshot.user?.login || + snapshot.base?.repo?.full_name?.toLowerCase() !== claim.repo.toLowerCase()) { + return { verified: false, reason: "GITHUB_PR_IDENTITY_MISMATCH" } + } + return assessApprovalHistory(history, claim, snapshot.user.login, + snapshot.head?.sha ?? "", snapshot.head?.ref ?? "") + } catch { + return { verified: false, reason: "GITHUB_APPROVAL_LOOKUP_UNAVAILABLE" } + } +} From 14e30164eeb40ef4ea21bbee3114800eca222042 Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:35:49 -0500 Subject: [PATCH 2/6] security: deny self-asserted GitHub approvals and require independent live API proof before reauthorization --- services/authority/index.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/services/authority/index.ts b/services/authority/index.ts index 25acb62..56fd6dd 100644 --- a/services/authority/index.ts +++ b/services/authority/index.ts @@ -3,6 +3,7 @@ import { randomUUID, timingSafeEqual } from "node:crypto" import { AuthorizeRequest, Decision, DecisionContext, ExecutionReceipt } from "./schemas" import { finalizeReceipt } from "./receiptSigner" import { isProtectedPath, loadProtectedPolicy } from "./policyLoader" +import { verifyIndependentGithubApproval } from "./approvalVerifier" const runtimeToken = process.env.RUNTIME_AUTH_TOKEN if (!runtimeToken || Buffer.byteLength(runtimeToken) < 32) { @@ -54,6 +55,8 @@ function safeRequest(body: unknown): body is AuthorizeRequest { r.pathsTouched.every(p => typeof p === "string" && p.length > 0 && p.length < 4096) } const receipts = new Map() +// Single-process replay guard; production needs a transactional persistent store. +const consumedStepUpReceipts = new Set() let lastHash: string | null = null function json(res: http.ServerResponse, status: number, body: unknown): void { @@ -69,7 +72,7 @@ function riskClass(req: AuthorizeRequest, protectedPaths: string[]): DecisionCon return "low" } -function decide(req: AuthorizeRequest, protectedPaths: string[]): { decision: Decision; reason: string; context: DecisionContext } { +async function decide(req: AuthorizeRequest, protectedPaths: string[]): Promise<{ decision: Decision; reason: string; context: DecisionContext }> { const protectedAction = req.pathsTouched.some(p => isProtectedPath(p, protectedPaths)) // Never trust caller-supplied scores or freshness. Resolve the exact grant // from operator-provisioned state. Missing/stale/revoked grants fail closed. @@ -148,12 +151,29 @@ function decide(req: AuthorizeRequest, protectedPaths: string[]): { decision: De Date.parse(prior.timestamp) > Date.now()) { return { decision: "DENY", reason: "prior_receipt_binding_or_freshness_mismatch", context } } - if (!req.approval?.approved || !req.approval.approvedBy) { + if (!req.approval?.approved || !req.approval.approvedBy || + req.approval.environment !== "protected-execution") { return { decision: "DENY", reason: "step_up_approval_missing", context } } + // GitHub's independently retrieved review history and current PR identity + // must corroborate caller claims before issuing any new permit. + const approvalProof = await verifyIndependentGithubApproval({ + repo: req.repo, workflowRunId: req.workflowRunId, + commitSha: req.commitSha, branch: req.branch, resource: req.resource, + invokingActor: req.invokingActor, reviewer: req.approval.approvedBy + }) + if (!approvalProof.verified) { + return { decision: "DENY", reason: approvalProof.reason, context } + } + if (consumedStepUpReceipts.has(req.priorReceiptId)) { + return { decision: "DENY", reason: "STEP_UP_ALREADY_CONSUMED", context } + } if (!context.trust.attestationValid) { return { decision: "ESCALATE", reason: "stale_attestation_after_step_up", context } } + // Atomic against concurrent requests in this process: no await after check. + // A persistent transactional implementation remains a production gate. + consumedStepUpReceipts.add(req.priorReceiptId) return { decision: "PERMIT", reason: "step_up_approved_reauthorize_permit", context } } @@ -215,7 +235,7 @@ const server = http.createServer(async (req, res) => { let body: unknown try { body = await parseBody(req) } catch { return json(res, 400, { error: "INVALID_JSON" }) } if (!safeRequest(body)) return json(res, 400, { error: "INVALID_REQUEST" }) - const { decision, reason, context } = decide(body, protectedPaths) + const { decision, reason, context } = await decide(body, protectedPaths) const receiptBase: Omit = { receiptId: `er-${randomUUID()}`, From 503d16e2c566bbcfaa4d14e78e3a7a8e2cafc7ab Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:36:20 -0500 Subject: [PATCH 3/6] test: reject unverified HTTP step-up approvals and exercise independent GitHub history proof vectors --- scripts/check-authority-staging.mjs | 36 +++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/scripts/check-authority-staging.mjs b/scripts/check-authority-staging.mjs index ce4d1f5..f0dc1b7 100644 --- a/scripts/check-authority-staging.mjs +++ b/scripts/check-authority-staging.mjs @@ -8,6 +8,7 @@ import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {createRequire} from 'node:module'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); const tmp=fs.mkdtempSync(path.join(os.tmpdir(),'ttp-cfav-authority-')); @@ -37,6 +38,32 @@ const build=spawnSync(tsc,['-p',config,'--noEmit','false','--outDir',compiled], 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 require=createRequire(import.meta.url); +const {assessApprovalHistory}=require(path.join(compiled,'approvalVerifier.js')); +const claimed={ + repo:'BlockSiFr/ttp-protocol',workflowRunId:'111222333', + commitSha:'a'.repeat(40),branch:'cfav-synthetic-branch', + resource:'repo:BlockSiFr/ttp-protocol:pr/100', + invokingActor:'synthetic-contributor',reviewer:'independent-synthetic-reviewer' +}; +const history=[{state:'approved',user:{login:claimed.reviewer}, + environments:[{name:'protected-execution'}]}]; +assert.equal(assessApprovalHistory(history,claimed,'synthetic-pr-author', + claimed.commitSha,claimed.branch).verified,true, + 'independent positive fixture requires correct GitHub payload'); +for(const [entries,author,sha,ref] of [ + [[], 'synthetic-pr-author',claimed.commitSha,claimed.branch], + [history,claimed.reviewer,claimed.commitSha,claimed.branch], + [history,'synthetic-pr-author','b'.repeat(40),claimed.branch], + [history,'synthetic-pr-author',claimed.commitSha,'other-branch'], + [[{...history[0],state:'rejected'}],'synthetic-pr-author',claimed.commitSha,claimed.branch], + [[{...history[0],environments:[{name:'another-environment'}]}], + 'synthetic-pr-author',claimed.commitSha,claimed.branch] +]){ + assert.equal(assessApprovalHistory(entries,claimed,author,sha,ref).verified,false, + 'fake, wrong, revoked or mismatched approval must deny'); +} + const probe=net.createServer(); const port=await new Promise((resolve,reject)=>{ @@ -113,14 +140,15 @@ try{ 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.decision,'DENY', + 'caller-supplied approval cannot permit without independent GitHub API verification'); + assert.equal(approved.json.reason,'GITHUB_APPROVAL_VERIFIER_UNCONFIGURED'); 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.'); + console.log('CFAV synthetic authority HTTP PASS: credentials, scoped grants, STEP_UP, fake approval denial and evidence binding.'); + console.log('Pure GitHub history fixtures include positive and negative assertions; live authorization remains unverified.'); }finally{ if(server){ server.kill('SIGTERM'); From 095ef998ecd8fa3e2f6a654eeb96a80f12acc5d6 Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:37:55 -0500 Subject: [PATCH 4/6] docs: require server-side GitHub approval verifier credential and distinguish live review from fixtures --- services/authority/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/services/authority/README.md b/services/authority/README.md index 627dddd..9fc0c36 100644 --- a/services/authority/README.md +++ b/services/authority/README.md @@ -8,6 +8,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. +- `GITHUB_APPROVAL_VERIFY_TOKEN`: separate server-side GitHub App installation or fine-grained token with permission to read the repository's workflow-run review history and pull request metadata. The authority verifies approvals directly from GitHub. Never accept an API caller's approval assertion as proof. - `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`). 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. @@ -35,10 +36,14 @@ The workflow additionally requires GitHub Actions secrets `RUNTIME_AUTH_URL`, `R - `GET /receipts/{id}`: authenticated; receipts are process-local and not a production ledger. - `GET /healthz`: unauthenticated, minimal status only. -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. +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 a **server-side GitHub API read** of the actual `protected-execution` approval, non-author reviewer, and current PR SHA/branch. Without `GITHUB_APPROVAL_VERIFY_TOKEN`, reauthorization is always denied. In-process replay suppression is provisional and not durable. **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. + +## Staging acceptance distinction + +A synthetic GitHub history object can test matching rules, but cannot establish a real reviewer decision. The server only issues post-step-up PERMIT after a successful independent HTTPS request to GitHub. Run [Issue #80](https://github.com/BlockSiFr/ttp-protocol/issues/80) positive and negative cases with an administrator-provisioned verifier token; capture redacted evidence. Do not store any credential in the repository. From d9670233c418971d1ff2e7baebc780408f82bbc1 Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:38:22 -0500 Subject: [PATCH 5/6] security: validate approval claim field types before invoking GitHub reviewer verification --- services/authority/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/authority/index.ts b/services/authority/index.ts index 56fd6dd..a4ee043 100644 --- a/services/authority/index.ts +++ b/services/authority/index.ts @@ -52,7 +52,14 @@ function safeRequest(body: unknown): body is AuthorizeRequest { typeof r.workflowRunId === "string" && /^[0-9]+$/.test(r.workflowRunId) && typeof r.invokingActor === "string" && Array.isArray(r.pathsTouched) && r.pathsTouched.length > 0 && r.pathsTouched.length < 3001 && - r.pathsTouched.every(p => typeof p === "string" && p.length > 0 && p.length < 4096) + r.pathsTouched.every(p => typeof p === "string" && p.length > 0 && p.length < 4096) && + (r.approval == null || ( + typeof r.approval === "object" && + typeof r.approval.approved === "boolean" && + typeof r.approval.approvedBy === "string" && + r.approval.approvedBy.length > 0 && r.approval.approvedBy.length <= 128 && + typeof r.approval.environment === "string" + )) } const receipts = new Map() // Single-process replay guard; production needs a transactional persistent store. From 3b8a435e2db35e63c6d7065bc32eace00d130bf4 Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:38:34 -0500 Subject: [PATCH 6/6] test: malformed reviewer claim denied with HTTP 400, not authority crash --- scripts/check-authority-staging.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/check-authority-staging.mjs b/scripts/check-authority-staging.mjs index f0dc1b7..8ba2aa8 100644 --- a/scripts/check-authority-staging.mjs +++ b/scripts/check-authority-staging.mjs @@ -139,6 +139,8 @@ try{ 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 malformedApproval=await request({...reauth,approval:{approved:true,approvedBy:123,environment:'protected-execution'}}); + assert.equal(malformedApproval.status,400,'malformed GitHub reviewer claim must not crash the service'); const approved=await request(reauth); assert.equal(approved.json.decision,'DENY', 'caller-supplied approval cannot permit without independent GitHub API verification');