From ce21166fac73b25d52af1944f2dd4d6d4acfe804 Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:40:22 -0500 Subject: [PATCH 1/4] security: add owner-private atomic on-disk STEP_UP replay ledger that survives service restart --- services/authority/replayLedger.ts | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 services/authority/replayLedger.ts diff --git a/services/authority/replayLedger.ts b/services/authority/replayLedger.ts new file mode 100644 index 0000000..cbfc04d --- /dev/null +++ b/services/authority/replayLedger.ts @@ -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. + } +} From 620b4fbddc2374ecfbcdbaab03a2e52cbac939a6 Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:40:39 -0500 Subject: [PATCH 2/4] security: refuse post-step-up permits until a durable atomic replay ledger commits redemption --- services/authority/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/services/authority/index.ts b/services/authority/index.ts index a4ee043..64cb055 100644 --- a/services/authority/index.ts +++ b/services/authority/index.ts @@ -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) { @@ -62,8 +63,7 @@ function safeRequest(body: unknown): body is AuthorizeRequest { )) } const receipts = new Map() -// Single-process replay guard; production needs a transactional persistent store. -const consumedStepUpReceipts = new Set() +// 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 { @@ -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 } } From 7076612b6730f0e23c97a5c4b435b45912738ded Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:41:01 -0500 Subject: [PATCH 3/4] test: prove atomic replay denial across independent service processes and invalid storage --- scripts/check-authority-staging.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/check-authority-staging.mjs b/scripts/check-authority-staging.mjs index 8ba2aa8..dd668ab 100644 --- a/scripts/check-authority-staging.mjs +++ b/scripts/check-authority-staging.mjs @@ -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', From ad09882420f2a1be11161c73599df26ea6e647bd Mon Sep 17 00:00:00 2001 From: Maurice Witten <244264730+blocksifrdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:41:13 -0500 Subject: [PATCH 4/4] docs: describe fail-closed durable STEP_UP replay directory and single-host limitations --- services/authority/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/authority/README.md b/services/authority/README.md index 9fc0c36..693259b 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. +- `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. @@ -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.