diff --git a/scripts/check-authority-staging.mjs b/scripts/check-authority-staging.mjs index dd668ab..8ddb87f 100644 --- a/scripts/check-authority-staging.mjs +++ b/scripts/check-authority-staging.mjs @@ -41,6 +41,10 @@ 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 {openReceiptLedger}=require(path.join(compiled,'receiptLedger.js')); +const receiptFile=path.join(tmp,'signed-receipts','receipts.jsonl'); +process.env.RECEIPT_HMAC_SECRET=key; + const replayDir=path.join(tmp,'durable-ledger'); assert.equal(consumeStepUpReceipt('step-up-fixture',replayDir).accepted,true, 'first step-up redemption can be durably recorded'); @@ -90,7 +94,8 @@ 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)} + RECEIPT_HMAC_SECRET:key,RUNTIME_AUTH_GRANTS_JSON:JSON.stringify(grants), + RUNTIME_AUTH_RECEIPT_LEDGER_PATH:receiptFile} }); let stderr=''; server.stderr.on('data',chunk=>{stderr+=chunk.toString().slice(0,2000)}); @@ -163,6 +168,28 @@ try{ 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); + // A gracefully restarted service must recover signed prior decisions from + // the same persisted ledger. Corrupting that ledger must deny startup. + server.kill('SIGTERM'); + await new Promise((resolve,reject)=>{ + if(server.exitCode!==null){resolve();return} + server.once('exit',resolve); + setTimeout(()=>reject(new Error('staging authority did not stop cleanly')),3000); + }); + server=undefined; + const restored=openReceiptLedger(receiptFile); + assert.ok(restored.count()>=4,'every authority decision must be durable'); + assert.equal(restored.get(allowed.json.receiptId)?.decision,'PERMIT'); + assert.equal(restored.get(stepup.json.receiptId)?.decision,'STEP_UP'); + assert.equal(restored.get(approved.json.receiptId)?.decision,'DENY'); + assert.ok(restored.lastHash()?.startsWith('sha256:')); + assert.throws(()=>openReceiptLedger(receiptFile),/EEXIST/, + 'two authority services cannot fork the same signed chain'); + restored.close(); + const persisted=fs.readFileSync(receiptFile,'utf8'); + fs.writeFileSync(receiptFile,persisted.replace('"decision":"PERMIT"','"decision":"DENY"')); + assert.throws(()=>openReceiptLedger(receiptFile),/RECEIPT_LEDGER_(CHAIN_BROKEN|SIGNATURE_INVALID)/, + 'tampered receipt chain cannot authorize after restart'); 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{ diff --git a/services/authority/index.ts b/services/authority/index.ts index 64cb055..431d7d7 100644 --- a/services/authority/index.ts +++ b/services/authority/index.ts @@ -5,6 +5,7 @@ import { finalizeReceipt } from "./receiptSigner" import { isProtectedPath, loadProtectedPolicy } from "./policyLoader" import { verifyIndependentGithubApproval } from "./approvalVerifier" import { consumeStepUpReceipt } from "./replayLedger" +import { openReceiptLedger } from "./receiptLedger" const runtimeToken = process.env.RUNTIME_AUTH_TOKEN if (!runtimeToken || Buffer.byteLength(runtimeToken) < 32) { @@ -62,9 +63,8 @@ function safeRequest(body: unknown): body is AuthorizeRequest { typeof r.approval.environment === "string" )) } -const receipts = new Map() -// Post-approval redemption uses an operator-controlled, persistent, atomic ledger. -let lastHash: string | null = null +const receiptLedger = openReceiptLedger() +// Both receipt issuance and post-approval redemption require durable, private storage. function json(res: http.ServerResponse, status: number, body: unknown): void { res.writeHead(status, { "content-type": "application/json" }) @@ -139,7 +139,7 @@ async function decide(req: AuthorizeRequest, protectedPaths: string[]): Promise< if (!req.priorReceiptId) { return { decision: "DENY", reason: "missing_prior_receipt", context } } - const prior = receipts.get(req.priorReceiptId) + const prior = receiptLedger.get(req.priorReceiptId) if (!prior) { return { decision: "DENY", reason: "prior_receipt_not_found", context } } @@ -268,9 +268,11 @@ const server = http.createServer(async (req, res) => { approvalReviewer: body.approval?.approvedBy } - const receipt = finalizeReceipt(receiptBase, lastHash) - lastHash = receipt.chainHash - receipts.set(receipt.receiptId, receipt) + const receipt = finalizeReceipt(receiptBase, receiptLedger.lastHash()) + // No PERMIT or STEP_UP is acknowledged unless its receipt has been written, + // chain-verified and fsynced to operator-controlled durable storage. + try { receiptLedger.append(receipt) } + catch { return json(res, 503, { error: "DURABLE_RECEIPT_LEDGER_UNAVAILABLE" }) } return json(res, 200, { decision, @@ -286,7 +288,7 @@ const server = http.createServer(async (req, res) => { if (req.method === "GET" && url.pathname.startsWith("/receipts/")) { const id = url.pathname.split("/").pop() ?? "" - const receipt = receipts.get(id) + const receipt = receiptLedger.get(id) if (!receipt) return json(res, 404, { error: "NOT_FOUND" }) return json(res, 200, receipt) } @@ -303,3 +305,11 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { server.listen(port, "127.0.0.1", () => { console.log(`[authority] local listener on 127.0.0.1:${port}; use approved TLS fronting gateway`) }) +function shutdown(): void { + server.close(() => { + try { receiptLedger.close() } + catch { process.exitCode = 1 } + }) +} +process.once("SIGTERM", shutdown) +process.once("SIGINT", shutdown) diff --git a/services/authority/receiptLedger.ts b/services/authority/receiptLedger.ts new file mode 100644 index 0000000..ee288bb --- /dev/null +++ b/services/authority/receiptLedger.ts @@ -0,0 +1,126 @@ +import fs from "node:fs" +import path from "node:path" +import crypto from "node:crypto" +import type { ExecutionReceipt } from "./schemas" +import { chainHash, signReceipt } from "./receiptSigner" + +function secureMode(stat: fs.Stats): boolean { + return process.platform === "win32" || + ((stat.mode & 0o077) === 0 && + (typeof process.getuid !== "function" || stat.uid === process.getuid())) +} +function trustedDirectory(dir: string): void { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const st = fs.lstatSync(dir) + if (!st.isDirectory() || st.isSymbolicLink() || !secureMode(st)) { + throw new Error("UNTRUSTED_RECEIPT_LEDGER_DIRECTORY") + } +} +function verifyRecord(record: ExecutionReceipt, previous: string | null): void { + if (!record || typeof record !== "object" || typeof record.receiptId !== "string" || + !/^er-[0-9a-f-]{36}$/i.test(record.receiptId) || + typeof record.signature !== "string" || typeof record.chainHash !== "string") { + throw new Error("INVALID_RECEIPT_LEDGER_RECORD") + } + const { signature, chainHash: claimedHash, ...partial } = record + const hash = chainHash(previous, partial) + if (hash !== claimedHash) throw new Error("RECEIPT_LEDGER_CHAIN_BROKEN") + const expected = Buffer.from(signReceipt({ ...partial, chainHash: claimedHash })) + const supplied = Buffer.from(signature) + if (supplied.length !== expected.length || + !crypto.timingSafeEqual(supplied, expected)) { + throw new Error("RECEIPT_LEDGER_SIGNATURE_INVALID") + } +} + +export interface ReceiptLedger { + get(id: string): ExecutionReceipt | undefined + append(receipt: ExecutionReceipt): void + lastHash(): string | null + count(): number + close(): void +} + +// Exact single-process, host-local, signed append-only receipt ledger. +// A live exclusive lock prevents independent processes from forking the chain. +// Unclean termination intentionally requires operator-reviewed lock recovery. +export function openReceiptLedger(file = process.env.RUNTIME_AUTH_RECEIPT_LEDGER_PATH): ReceiptLedger { + if (!file || !path.isAbsolute(file)) throw new Error("PERSISTENT_RECEIPT_LEDGER_UNCONFIGURED") + trustedDirectory(path.dirname(file)) + const lockFile = file + ".lock" + let lockFd: number | undefined + let fd: number | undefined + let released = false + const previousRecords = new Map() + let last: string | null = null + let bytes = 0 + try { + lockFd = fs.openSync(lockFile, fs.constants.O_WRONLY | fs.constants.O_CREAT | + fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600) + fs.writeSync(lockFd, String(process.pid) + "\n") + fs.fsyncSync(lockFd) + fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_APPEND | + fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0), 0o600) + const fileStat = fs.fstatSync(fd) + if (!fileStat.isFile() || !secureMode(fileStat)) throw new Error("UNTRUSTED_RECEIPT_LEDGER_FILE") + const raw = fs.readFileSync(fd, "utf8") + const size = Buffer.byteLength(raw) + if (size !== fileStat.size || (size > 0 && !raw.endsWith("\n"))) { + throw new Error("TRUNCATED_OR_CHANGED_RECEIPT_LEDGER") + } + for (const line of raw.split("\n").filter(Boolean)) { + let receipt: ExecutionReceipt + try { receipt = JSON.parse(line) as ExecutionReceipt } + catch { throw new Error("MALFORMED_RECEIPT_LEDGER") } + if (previousRecords.has(receipt.receiptId)) throw new Error("DUPLICATE_RECEIPT_ID") + verifyRecord(receipt, last) + previousRecords.set(receipt.receiptId, receipt) + last = receipt.chainHash + } + bytes = size + } catch (error) { + if (fd !== undefined) fs.closeSync(fd) + if (lockFd !== undefined) { + fs.closeSync(lockFd) + fs.unlinkSync(lockFile) + } + throw error + } + + const fileFd = fd! + const ownedLockFd = lockFd! + function close(): void { + if (released) return + released = true + fs.closeSync(fileFd) + fs.closeSync(ownedLockFd) + fs.unlinkSync(lockFile) + } + return { + get: id => previousRecords.get(id), + lastHash: () => last, + count: () => previousRecords.size, + append(receipt: ExecutionReceipt): void { + if (released) throw new Error("RECEIPT_LEDGER_CLOSED") + if (previousRecords.has(receipt.receiptId)) throw new Error("DUPLICATE_RECEIPT_ID") + const stat = fs.fstatSync(fileFd) + if (!stat.isFile() || !secureMode(stat) || stat.size !== bytes) { + throw new Error("RECEIPT_LEDGER_CHANGED_EXTERNALLY") + } + verifyRecord(receipt, last) + const buffer = Buffer.from(JSON.stringify(receipt) + "\n") + const written = fs.writeSync(fileFd, buffer) + if (written !== buffer.length) throw new Error("SHORT_RECEIPT_LEDGER_WRITE") + fs.fsyncSync(fileFd) + // A newly opened file's directory entry must be durable as well. + if (process.platform !== "win32") { + const parentFd = fs.openSync(path.dirname(file), fs.constants.O_RDONLY) + try { fs.fsyncSync(parentFd) } finally { fs.closeSync(parentFd) } + } + bytes += buffer.length + last = receipt.chainHash + previousRecords.set(receipt.receiptId, receipt) + }, + close + } +}