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
14 changes: 14 additions & 0 deletions scripts/check-authority-staging.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ 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 {consumeStepUpReceipt}=require(path.join(compiled,'replayLedger.js'));
const replayDir=path.join(tmp,'durable-ledger');
assert.equal(consumeStepUpReceipt('step-up-fixture',replayDir).accepted,true,
'first step-up redemption can be durably recorded');
assert.equal(consumeStepUpReceipt('step-up-fixture',replayDir).reason,'STEP_UP_ALREADY_CONSUMED',
'second attempt is denied');
assert.equal(consumeStepUpReceipt('step-up-other',replayDir).accepted,true);
assert.equal(consumeStepUpReceipt('step-up-new','relative/path').accepted,false,
'relative or operator-unconfigured store denied');
const restartProbe=spawnSync(process.execPath,['-e',
'const r=require(process.argv[1]); if(r.consumeStepUpReceipt("step-up-fixture",process.argv[2]).accepted) process.exit(7)',
path.join(compiled,'replayLedger.js'),replayDir],{cwd:root,timeout:10000});
assert.equal(restartProbe.status,0,'a separate process cannot redeem the same STEP_UP receipt');

const claimed={
repo:'BlockSiFr/ttp-protocol',workflowRunId:'111222333',
commitSha:'a'.repeat(40),branch:'cfav-synthetic-branch',
Expand Down
3 changes: 2 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.
- `RUNTIME_AUTH_REPLAY_LEDGER_DIR`: absolute path to an operator-controlled persistent private directory (0700 on POSIX), on a trusted, durable single-node volume. Post-step-up PERMIT is denied if this directory cannot be securely written. The replay file name is a SHA-256 digest of an opaque prior receipt ID. Multi-node transactional deployments require a separate centralized replay ledger and review.
- `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 @@ -36,7 +37,7 @@ 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 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.
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. Atomic, on-disk single-host STEP_UP replay suppression survives process restart; multi-host replay coordination and independent storage audit are still outstanding.

**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.

Expand Down
16 changes: 8 additions & 8 deletions services/authority/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AuthorizeRequest, Decision, DecisionContext, ExecutionReceipt } from ".
import { finalizeReceipt } from "./receiptSigner"
import { isProtectedPath, loadProtectedPolicy } from "./policyLoader"
import { verifyIndependentGithubApproval } from "./approvalVerifier"
import { consumeStepUpReceipt } from "./replayLedger"

const runtimeToken = process.env.RUNTIME_AUTH_TOKEN
if (!runtimeToken || Buffer.byteLength(runtimeToken) < 32) {
Expand Down Expand Up @@ -62,8 +63,7 @@ function safeRequest(body: unknown): body is AuthorizeRequest {
))
}
const receipts = new Map<string, ExecutionReceipt>()
// Single-process replay guard; production needs a transactional persistent store.
const consumedStepUpReceipts = new Set<string>()
// Post-approval redemption uses an operator-controlled, persistent, atomic ledger.
let lastHash: string | null = null

function json(res: http.ServerResponse, status: number, body: unknown): void {
Expand Down Expand Up @@ -172,15 +172,15 @@ async function decide(req: AuthorizeRequest, protectedPaths: string[]): Promise<
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)
// Never acknowledge a new permit until the prior STEP_UP nonce has been
// spent through the configured, restart-stable single-host atomic ledger.
const spend = consumeStepUpReceipt(req.priorReceiptId)
if (!spend.accepted) {
return { decision: "DENY", reason: spend.reason, context }
}
return { decision: "PERMIT", reason: "step_up_approved_reauthorize_permit", context }
}

Expand Down
53 changes: 53 additions & 0 deletions services/authority/replayLedger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fs from "node:fs"
import path from "node:path"
import crypto from "node:crypto"

export interface SpendResult {
accepted: boolean
reason: string
}

// Host-local, cross-process atomic replay barrier for STEP_UP redemption.
// The operator must provision a persisted private directory. This is not a
// distributed consensus/transactional store for multi-region production.
export function consumeStepUpReceipt(
opaqueReceiptId: string, directory = process.env.RUNTIME_AUTH_REPLAY_LEDGER_DIR
): SpendResult {
if (!directory || !path.isAbsolute(directory)) {
return { accepted: false, reason: "PERSISTENT_REPLAY_LEDGER_UNCONFIGURED" }
}
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(opaqueReceiptId)) {
return { accepted: false, reason: "INVALID_PRIOR_RECEIPT_ID" }
}
let fd: number | undefined
try {
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
const stat = fs.lstatSync(directory)
if (!stat.isDirectory() || stat.isSymbolicLink() ||
(process.platform !== "win32" && ((stat.mode & 0o077) !== 0 ||
(typeof process.getuid === "function" && stat.uid !== process.getuid())))) {
return { accepted: false, reason: "UNTRUSTED_REPLAY_LEDGER_DIRECTORY" }
}
const digest = crypto.createHash("sha256").update(opaqueReceiptId).digest("hex")
const record = path.join(directory, digest + ".spent")
fd = fs.openSync(record, fs.constants.O_WRONLY | fs.constants.O_CREAT |
fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600)
fs.writeSync(fd, new Date().toISOString() + "\n")
fs.fsyncSync(fd)
fs.closeSync(fd)
fd = undefined
// Sync the directory entry before acknowledging authorization.
if (process.platform !== "win32") {
const dirfd = fs.openSync(directory, fs.constants.O_RDONLY)
try { fs.fsyncSync(dirfd) } finally { fs.closeSync(dirfd) }
}
return { accepted: true, reason: "PRIOR_RECEIPT_CONSUMED" }
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "EEXIST") return { accepted: false, reason: "STEP_UP_ALREADY_CONSUMED" }
return { accepted: false, reason: "REPLAY_LEDGER_UNAVAILABLE" }
} finally {
if (fd !== undefined) fs.closeSync(fd)
// Do not delete partial records on an error; uncertain writes fail closed.
}
}
Loading