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
38 changes: 34 additions & 4 deletions scripts/check-authority-staging.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -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)=>{
Expand Down Expand Up @@ -112,15 +139,18 @@ 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,'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');
Expand Down
7 changes: 6 additions & 1 deletion services/authority/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
90 changes: 90 additions & 0 deletions services/authority/approvalVerifier.ts
Original file line number Diff line number Diff line change
@@ -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<ReviewProof> {
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<unknown> {
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])
])
Comment on lines +70 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind the approved workflow run to the requested PR revision

When the verifier token is configured, an authenticated caller can supply an already-approved run ID from another PR or an older revision, obtain a fresh STEP_UP receipt using that ID, and reauthorize the current PR without a new human approval. These lookups independently verify that the run has an approval and that the PR matches the claimed head, but never establish that the approved run belongs to that PR revision. The prior-receipt checks only preserve the caller-supplied association. Fetch and validate authoritative run/event metadata linking the approval to the requested PR revision before returning a verified proof; include a negative fixture with an approved run unrelated to the otherwise matching PR.

Useful? React with 👍 / 👎.

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" }
}
}
35 changes: 31 additions & 4 deletions services/authority/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -51,9 +52,18 @@ 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<string, ExecutionReceipt>()
// Single-process replay guard; production needs a transactional persistent store.
const consumedStepUpReceipts = new Set<string>()
let lastHash: string | null = null

function json(res: http.ServerResponse, status: number, body: unknown): void {
Expand All @@ -69,7 +79,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.
Expand Down Expand Up @@ -148,12 +158,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 }
}

Expand Down Expand Up @@ -215,7 +242,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<ExecutionReceipt, "signature" | "chainHash"> = {
receiptId: `er-${randomUUID()}`,
Expand Down
Loading