From 8d8c1e1248ed2ce4c32eed9a4a060eb675bbf0f6 Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Sat, 31 Oct 2026 22:31:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(coordination):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=B7=A8=E6=9C=BA=E8=AE=B8=E5=8F=AF=E7=9A=84=E5=8F=97=E7=AE=A1?= =?UTF-8?q?=20CLI=20=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨机许可的库能力此前已在 origin/main(批次签发与验签、登记型锚点域、 跨机登记与取钥路径),但没有可调用的受管入口:bin/cli.js 里没有任何 permit 派发,引用这些模块的文件只有模块自身,零调用方。既有真机实测 走的是直接 require 产品模块的探针,不是产品 CLI。 本次补上产品入口与它依赖的编排层: - lib/commands/permit-managed-cli.js:薄适配层,五个子命令 issue/register/key/verify/revoke,零判定逻辑;直接调用被拒绝, 必须在已认证的受管会话内经 executor 通道执行。 - lib/coordination/permit-authority-service.js:受保护服务侧的批次密钥 持有与签发,私钥不落盘、不进参数、不进环境变量、不传给子进程。 - lib/coordination/permit-target-service.js:目标侧事实取自 pin 域, 不接受命令行断言作为已观测事实。 - lib/coordination/permit-revocation-signing.js:撤销列表以签名信封签发。 - lib/coordination/permit-batch-session.js、permit-managed-session.js: 批次编排与受管会话接入。 - lib/governed/managed-caller-{session,supervisor}.js:应答方向绑定与 事实来源 fail-closed 的补强。 安全约束(不可放宽):公钥只从 pin 域取;时钟中毒即拒;撤销强制; deadline 必须从 reserve() 返回值读回而非自行推导;代码与命令不含任何 实战项目标识。 验证:全量 616 个测试文件跑完,8 项失败与 6 项超时经逐项甄别均非本次 引入——4 项为并行资源竞争(单独串行复跑全部通过),2 项在未改动的 origin/main 基线上同样失败(受保护事务存储限定 Linux 平台),2 项为磁盘 空间耗尽所致。专项串行通过:permit-authority-admission 99.4s、 permit-authority-signing-gate 39.2s、permit-witness-worker-isolation 5.0s、 permit-legacy-archive-evidence 77.7s、permit-phase-a-sqlite 18.3s。 门禁 check-framework-generality、check-l3-boundary、check-template-parity、 check-portable-paths 全部通过。 本批不含原生 witness 包、legacy 迁移与 SQLite 线——它们的真实 Windows 服务身份与证书/密钥 ACL 尚无实测验收证据,须另行评审后单独交付。 本批也不主张任何跨机许可断言在真实受保护部署上成立。 Refs M-044, MS-004 --- bin/cli.js | 11 +- lib/cli/contract.js | 1 + lib/commands/permit-managed-cli.js | 140 ++ lib/coordination/cross-machine-permit.js | 24 +- lib/coordination/permit-authority-service.js | 48 + lib/coordination/permit-batch-session.js | 60 + lib/coordination/permit-delivery.js | 66 +- lib/coordination/permit-managed-session.js | 22 + lib/coordination/permit-revocation-signing.js | 43 + lib/coordination/permit-target-service.js | 138 ++ lib/coordination/protected-permit-store.js | 1476 ++++++++++++++++- lib/coordination/protected-pin-store.js | 501 +++--- lib/governed/managed-caller-session.js | 23 +- lib/governed/managed-caller-supervisor.js | 207 ++- .../fixtures/permit-batch-reserve.js | 17 + .../permit-authority-service.test.js | 52 + .../coordination/permit-batch-session.test.js | 193 +++ tests/coordination/permit-managed-cli.test.js | 126 ++ .../coordination/permit-managed-flow.test.js | 117 ++ .../permit-managed-session.test.js | 44 + .../permit-revocation-signing.test.js | 49 + .../permit-target-service.test.js | 389 +++++ .../protected-permit-store.test.js | 81 + .../coordination/protected-pin-store.test.js | 308 +++- .../dispatch/phase-zero-source-manifest.json | 2 +- tests/governed/executor-response-auth.test.js | 57 + tests/governed/fixtures/handshake-no-reply.js | 9 + tests/governed/handshake-cleanup.test.js | 59 + .../managed-caller-facts-fail-closed.test.js | 50 + tests/governed/managed-caller-session.test.js | 4 +- .../permit-supervisor-adapter.test.js | 98 ++ 31 files changed, 4043 insertions(+), 372 deletions(-) create mode 100644 lib/commands/permit-managed-cli.js create mode 100644 lib/coordination/permit-authority-service.js create mode 100644 lib/coordination/permit-batch-session.js create mode 100644 lib/coordination/permit-managed-session.js create mode 100644 lib/coordination/permit-revocation-signing.js create mode 100644 lib/coordination/permit-target-service.js create mode 100644 tests/coordination/fixtures/permit-batch-reserve.js create mode 100644 tests/coordination/permit-authority-service.test.js create mode 100644 tests/coordination/permit-batch-session.test.js create mode 100644 tests/coordination/permit-managed-cli.test.js create mode 100644 tests/coordination/permit-managed-flow.test.js create mode 100644 tests/coordination/permit-managed-session.test.js create mode 100644 tests/coordination/permit-revocation-signing.test.js create mode 100644 tests/coordination/permit-target-service.test.js create mode 100644 tests/governed/executor-response-auth.test.js create mode 100644 tests/governed/fixtures/handshake-no-reply.js create mode 100644 tests/governed/handshake-cleanup.test.js create mode 100644 tests/governed/managed-caller-facts-fail-closed.test.js create mode 100644 tests/governed/permit-supervisor-adapter.test.js diff --git a/bin/cli.js b/bin/cli.js index 4a720cfd..2054bee9 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -660,7 +660,7 @@ async function initModeGeneral() { await upgrade(l1Ctx); // Respect --dry-run: state-githooks installer copies templates to disk, // which would violate the zero-write contract. Skip when dryRun is set. - if (!l1Ctx.options.dryRun) installStateGithooks({ cwd, lang }); + if (!l1Ctx.options.dryRun && !process.exitCode) installStateGithooks({ cwd, lang }); break; case "update": // Help must be a zero-write operation. In particular, do not enter @@ -677,7 +677,7 @@ async function initModeGeneral() { await upgrade(l1Ctx); // Respect --dry-run: state-githooks installer copies templates to disk, // which would violate the zero-write contract. Skip when dryRun is set. - if (!l1Ctx.options.dryRun) installStateGithooks({ cwd, lang }); + if (!l1Ctx.options.dryRun && !process.exitCode) installStateGithooks({ cwd, lang }); break; case "track": trackAgent(ctx); break; case "untrack": untrackAgent(ctx); break; @@ -693,7 +693,11 @@ async function initModeGeneral() { case "bridge": bridge(l1Ctx); break; case "topology": topology(l1Ctx); break; case "automation": automation(l1Ctx); break; - case "local-publish-validate": localPublishValidate(ctx); break; + case "local-publish-validate": { + const code = localPublishValidate(ctx); + if (code !== 0) process.exitCode = code; + break; + } case "proposal-share": proposalShare(ctx); break; case "skill": skillBrowse(ctx); break; case "runs": runs(ctx); break; @@ -748,6 +752,7 @@ async function initModeGeneral() { break; } case "lease": lease(ctx); break; + case "permit": await require("../lib/commands/permit-managed-cli").permitManagedCli(ctx); break; case "notification": await notification(ctx); break; case "mcp": await mcpCommand(ctx); break; case "motion": await motionCommand(ctx); break; diff --git a/lib/cli/contract.js b/lib/cli/contract.js index d0b2836f..34025435 100644 --- a/lib/cli/contract.js +++ b/lib/cli/contract.js @@ -24,6 +24,7 @@ const commands = [ command("task", "task [options]", "Read or update Coordination Tasks through the Coordination Application Service. create/assign/accept accept restricted fields; raw event envelopes remain a legacy compatibility path."), command("event", "event [options]", "List coordination events or acknowledge delivery without changing task state."), command("lease", "lease [options]", "Acquire and manage fenced task ownership leases through the durable Coordination LeaseManager. It never starts a host or creates a task.", { mode: "coordination_lease", restricted: true }), + command("permit", "permit --input [--output ]", "Permit operations require an authenticated managed session; direct invocation is refused. Host integration and deployment validation remain pending.", { mode: "managed_session", restricted: true, pending_integration: false }), command("notification", "notification [options]", "Run, watch, inspect, or stop the Agent Coordination Notification Pump against an adapter whitelist. Never accepts event.command / event.executable from the CLI surface.", { mode: "adapter", thin: true, pending_integration: false }), command("runs", "runs ", "Read or update Run state through explicit actions."), command("queues", "queues ", "Read or update Queue state through explicit actions."), diff --git a/lib/commands/permit-managed-cli.js b/lib/commands/permit-managed-cli.js new file mode 100644 index 00000000..f83aedc6 --- /dev/null +++ b/lib/commands/permit-managed-cli.js @@ -0,0 +1,140 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { CHANNEL_MESSAGE, SIGNED_MESSAGE_KIND } = require("../governed/managed-caller-session"); +const { readSessionSecret, createExecutorEndpoint } = require("../governed/managed-caller-supervisor"); +const { signResponse } = require("../governed/managed-caller-signing"); + +const ACTIONS = new Set(["issue", "register", "key", "verify", "revoke"]); +const USAGE = "Usage: cortex-agent permit --input [--output ] (managed session required)"; + +function parseArgs(args) { + const action = args[1]; + if (!ACTIONS.has(action)) throw new Error("invalid arguments"); + const options = {}; + for (let i = 2; i < args.length; i += 2) { + const key = args[i]; + if (!["--input", "--output"].includes(key) || options[key] !== undefined || + typeof args[i + 1] !== "string" || args[i + 1].startsWith("--")) throw new Error("invalid arguments"); + options[key] = args[i + 1]; + } + if (!options["--input"] || (action === "issue") !== Boolean(options["--output"])) throw new Error("invalid arguments"); + return { action, input: options["--input"], output: options["--output"] }; +} + +function readInput(file) { + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + let raw; + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("invalid input"); + raw = fs.readFileSync(fd, "utf8"); + } finally { fs.closeSync(fd); } + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(raw)) throw new Error("private material refused"); + const value = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid input"); + return value; +} + +function writeIssue(fd, result) { + if (!result || typeof result !== "object" || !result.signedPermit || + typeof result.fingerprint !== "string" || typeof result.publicKeyPem !== "string" || + /PRIVATE KEY|privateKeyRef/.test(JSON.stringify(result))) throw new Error("invalid result"); + fs.writeFileSync(fd, JSON.stringify(result) + "\n"); + fs.fsyncSync(fd); +} + +function waitForSupervisorClose() { + if (!process.connected) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(done, 30000); + function done() { + clearTimeout(timer); + process.off("message", onMessage); + process.off("disconnect", done); + resolve(); + } + function onMessage(message) { + if (message && message.type === CHANNEL_MESSAGE.BYE) done(); + } + process.on("message", onMessage); + process.once("disconnect", done); + }); +} + +async function permitManagedCli(ctx) { + if (ctx.args.includes("--help") || ctx.args.includes("-h")) { console.log(USAGE); return; } + let options; + try { options = parseArgs(ctx.args); } + catch { console.error("permit: invalid arguments"); process.exitCode = 2; return; } + if (!process.channel || typeof process.send !== "function") { + console.error("permit: managed session required"); process.exitCode = 3; return; + } + let secret; + let endpoint; + let outputFd; + let started = false; + const queued = []; + const start = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("managed session timeout")), 30000); + function handle(message) { + if (!secret) { + if (queued.length >= 32) { clearTimeout(timer); reject(new Error("managed session refused")); return; } + queued.push(message); + return; + } + if (!message || typeof message !== "object") return; + if (message.type === CHANNEL_MESSAGE.HELLO) { + const body = { nonce: message.nonce }; + process.send({ type: CHANNEL_MESSAGE.RESPONSE, requestId: message.requestId, + sessionId: message.sessionId, nonce: message.nonce, body, + mac: signResponse(secret, { kind: SIGNED_MESSAGE_KIND.HELLO, requestId: message.requestId, + seq: message.seq, operation: "hello", body }) }); + } else if (!started && message.type === CHANNEL_MESSAGE.REQUEST && message.operation === "permit.start") { + try { endpoint.answer(message, { ready: true }); } + catch { clearTimeout(timer); reject(new Error("managed session refused")); return; } + started = true; + clearTimeout(timer); + process.off("message", handle); + resolve(); + } + } + process.on("message", handle); + readSessionSecret(process).then((value) => { + secret = value; + endpoint = createExecutorEndpoint(process, secret); + for (const message of queued.splice(0)) handle(message); + }, () => { clearTimeout(timer); reject(new Error("managed session refused")); }); + }); + try { + await start; + const input = readInput(path.resolve(ctx.cwd, options.input)); + // Reserve the output before issuing so an existing destination cannot + // cause a committed reservation whose result has nowhere to be written. + if (options.action === "issue") { + outputFd = fs.openSync(path.resolve(ctx.cwd, options.output), "wx", 0o600); + } + const result = await endpoint.request(`permit.${options.action}`, input); + if (/PRIVATE KEY|privateKeyRef/.test(JSON.stringify(result))) throw new Error("private material refused"); + if (options.action === "issue") { + writeIssue(outputFd, result); + console.log(JSON.stringify({ ok: true, action: "issue", written: true })); + } else { + console.log(JSON.stringify({ ok: true, action: options.action, result })); + } + } catch { + console.error("permit: managed operation rejected"); + process.exitCode = 3; + } finally { + if (outputFd !== undefined) fs.closeSync(outputFd); + if (started && process.connected) { + const close = waitForSupervisorClose(); + process.send({ type: "permit.result-ready" }); + await close; + } + if (process.connected) process.disconnect(); + } +} + +module.exports = { permitManagedCli }; diff --git a/lib/coordination/cross-machine-permit.js b/lib/coordination/cross-machine-permit.js index 4d893fad..a7a7bd6a 100644 --- a/lib/coordination/cross-machine-permit.js +++ b/lib/coordination/cross-machine-permit.js @@ -412,6 +412,7 @@ function pullAndApplyRevocation(options) { pull, nowMs, pullTimeoutMs, + verifyPermit, } = options; if (!pinStore || typeof pinStore.criticalSection !== "function") { fail("ERR_PERMIT_INPUT_INVALID"); @@ -465,11 +466,28 @@ function pullAndApplyRevocation(options) { nowMs, }); - // The revoked set and the applied high-water mark advance together. + // On successful permit verification, commit its high-water mark with the + // freshly authenticated revocation state. A rejected permit still applies + // the valid revocation list, without advancing the permit high-water mark. + let verified; + let verificationError; + if (verifyPermit !== undefined) { + if (typeof verifyPermit !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + // Do not hand the verifier the state being committed: a callback must + // not be able to alter the authenticated revocation set after checking. + const verificationState = { ...state, revokedFingerprints: new Set(state.revokedFingerprints) }; + try { verified = verifyPermit(verificationState); } catch (error) { verificationError = error; } + if (!verificationError && (!verified || verified.ok !== true || !Number.isSafeInteger(verified.sequence))) { + verificationError = new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + } pinStore.commitRevocation({ - sequence, - revokedFingerprints: list.revoked_fingerprints.slice().sort(), + sequence: applied.appliedRevocationSequence, + revokedFingerprints: [...state.revokedFingerprints].sort(), + ...(!verificationError && verified ? { permitSequence: verified.sequence } : {}), }); + if (verificationError) throw verificationError; + if (verifyPermit !== undefined) return verified; return applied; }); } diff --git a/lib/coordination/permit-authority-service.js b/lib/coordination/permit-authority-service.js new file mode 100644 index 00000000..869935a1 --- /dev/null +++ b/lib/coordination/permit-authority-service.js @@ -0,0 +1,48 @@ +"use strict"; + +const { CoordinationError } = require("./errors"); + +function freezeRequest(value) { + if (value && typeof value === "object") { + for (const child of Object.values(value)) freezeRequest(child); + Object.freeze(value); + } + return value; +} + +// The transport owns peer authentication and must supply an opaque verified +// context; this function neither creates a listener nor authenticates a peer. +function createPermitAuthorityService({ authorize, batchSession, targetService } = {}) { + if (typeof authorize !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + + function dispatch(peer, action, args) { + if (!["issue", "register", "key", "verify", "revoke"].includes(action) || + !args || typeof args !== "object" || Array.isArray(args)) { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + // Authorize and execute the same detached request, including its resource + // bindings. The host still has to compare those bindings to trusted state. + let request; + try { request = freezeRequest(JSON.parse(JSON.stringify(args))); } + catch { throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); } + // Promise-like and non-boolean results are deliberately not accepted. + let authorized; + try { authorized = authorize(peer, action, request); } + catch { throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); } + if (authorized !== true) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + if (action === "issue") { + if (!batchSession || typeof batchSession.issue !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + return batchSession.issue({ lease: request.lease, permit: request.permit }); + } + if (!targetService || typeof targetService[action] !== "function") { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + if (action === "key") return targetService.key(request.fingerprint); + if (action === "revoke") return targetService.revoke(); + return targetService[action](request); + } + + return Object.freeze({ dispatch }); +} + +module.exports = { createPermitAuthorityService }; diff --git a/lib/coordination/permit-batch-session.js b/lib/coordination/permit-batch-session.js new file mode 100644 index 00000000..464a8dca --- /dev/null +++ b/lib/coordination/permit-batch-session.js @@ -0,0 +1,60 @@ +"use strict"; + +const delivery = require("./permit-delivery"); +const { signRevocationList } = require("./permit-revocation-signing"); +const { CoordinationError } = require("./errors"); +const { validateReservationInput } = require("./protected-permit-store"); + +// Process-local primitive, not an authenticated service or a CLI endpoint. +// The caller owns the protected store, authorization and restart handling. +function createPermitBatchSession({ store, clock = Date.now } = {}) { + if (!store || typeof store.acquire !== "function" || typeof store.reserve !== "function" || + typeof clock !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + let key = delivery.createBatchKeyPair(); + delivery.assertNoPrivateKeyLeak({ privateKeyRef: key.privateKeyRef }); + + function currentKey() { + if (!key) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + return key; + } + + function issue({ lease, permit } = {}) { + const batch = currentKey(); + if (!lease || !permit || typeof lease !== "object" || typeof permit !== "object") { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + // An idempotent reserve may return a permit from an earlier batch. + // Never sign it unless the store atomically binds the batch identity. + if (typeof store.acquireAndReserveBoundToBatch !== "function") { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + const initialTime = clock(); + if (!Number.isSafeInteger(initialTime) || initialTime < 0) throw new CoordinationError("ERR_CLOCK_INVALID"); + validateReservationInput(permit); + return store.acquireAndReserveBoundToBatch(lease, permit, batch.fingerprint, (reserved) => { + const now = clock(); + if (!Number.isSafeInteger(now) || now < 0) throw new CoordinationError("ERR_CLOCK_INVALID"); + if (!reserved || reserved.status !== "reserved" || !Number.isSafeInteger(reserved.deadline)) { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + if (now >= reserved.deadline) throw new CoordinationError("ERR_PERMIT_DEADLINE_EXPIRED"); + const signed = delivery.buildSignedPermit({ privateKeyRef: batch.privateKeyRef, + permit: reserved, issuedAtMs: now, nowMs: now }); + return { fingerprint: batch.fingerprint, publicKeyPem: batch.publicKeyPem, signedPermit: signed }; + }); + } + + function signRevocation({ state, fingerprints } = {}) { + const batch = currentKey(); + return signRevocationList({ privateKeyRef: batch.privateKeyRef, publicKeyPem: batch.publicKeyPem, + state, fingerprints, issuedAtMs: clock() }); + } + + return Object.freeze({ + issue, + signRevocation, + close() { key = null; }, + }); +} + +module.exports = { createPermitBatchSession }; diff --git a/lib/coordination/permit-delivery.js b/lib/coordination/permit-delivery.js index fbc03d24..4484e3eb 100644 --- a/lib/coordination/permit-delivery.js +++ b/lib/coordination/permit-delivery.js @@ -240,10 +240,10 @@ function createBatchKeyPair() { return { publicKeyPem, fingerprint, privateKeyRef }; } -// Signed permit. -function buildSignedPermit(options) { - const { privateKeyRef, permit, issuedAtMs, nowMs } = options; - if (!privateKeyRef) fail("ERR_PERMIT_INPUT_INVALID"); +// Construct the exact bytes before invoking a signer. This pure preparation +// does not reserve a permit, establish a durable gate or authorize signing. +function preparePermitSigning(options) { + const { permit, issuedAtMs, nowMs } = options; const permitId = requireText(permit.permitId); const sequence = requireSafeInteger(permit.sequence); // The deadline is read back from the permit that reserve() produced. The @@ -261,20 +261,30 @@ function buildSignedPermit(options) { message.issued_at_ms = issued; message.deadline_ms = deadlineMs; message.permit_id = permitId; + assertCoversBinding(message); // The signature covers the canonical bytes of the whole message, so the // field set is what is signed -- an attacker cannot drop a field from the // message without breaking the signature. const bytes = canonicalBytes(message); - const signature = privateKeyRef.sign(bytes); + return { message, sequence, deadline: deadlineMs, issuedAt: issued, + bytes: bytes.toString("base64"), nowMs: nowMs === undefined ? issued : requireSafeInteger(nowMs) }; +} + +// Signed permit. +function buildSignedPermit(options) { + const { privateKeyRef } = options; + if (!privateKeyRef) fail("ERR_PERMIT_INPUT_INVALID"); + const prepared = preparePermitSigning(options); + const signature = privateKeyRef.sign(Buffer.from(prepared.bytes, "base64")); return { - message, + message: prepared.message, signature: signature.toString("base64"), - sequence, - deadline: deadlineMs, - issuedAt: issued, - bytes: bytes.toString("base64"), - nowMs: nowMs === undefined ? issued : requireSafeInteger(nowMs), + sequence: prepared.sequence, + deadline: prepared.deadline, + issuedAt: prepared.issuedAt, + bytes: prepared.bytes, + nowMs: prepared.nowMs, }; } @@ -303,9 +313,24 @@ function assertCoversBinding(message) { const signed = new Set(Object.keys(message)); for (const [, signedName] of BINDING_FIELD_MAP) { if (!signed.has(signedName)) fail("ERR_PERMIT_BINDING_UNCOVERED"); + const value = message[signedName]; + if (["service_epoch", "fencing", "sequence"].includes(signedName)) { + if (!Number.isSafeInteger(value) || value < 1) fail("ERR_PERMIT_INPUT_INVALID"); + } else if (signedName === "generation" && Number.isSafeInteger(value) && value >= 0) { + continue; + } else if (["target_digest", "params_digest"].includes(signedName)) { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) fail("ERR_PERMIT_INPUT_INVALID"); + } else if (typeof value !== "string" || !value) { + fail("ERR_PERMIT_INPUT_INVALID"); + } } for (const name of ADDITIONAL_SIGNED_FIELDS) { if (!signed.has(name)) fail("ERR_PERMIT_BINDING_UNCOVERED"); + if (name === "permit_id") { + if (typeof message[name] !== "string" || !message[name]) fail("ERR_PERMIT_INPUT_INVALID"); + } else if (!Number.isSafeInteger(message[name]) || message[name] < 0) { + fail("ERR_PERMIT_INPUT_INVALID"); + } } } @@ -387,7 +412,7 @@ function verifyPermitSignature(options) { // // The clock is also checked for being a PLAUSIBLE instant. A clock // reading that is not a real instant turns the expiry verdict into a - // tautology: `now > deadline` is false for every deadline once now is + // tautology: `now >= deadline` is false for every deadline once now is // zero or below, so a permit that expired decades ago would verify. // // The protected permit store refuses a NEGATIVE clock the same way @@ -483,7 +508,7 @@ function verifyPermitSignature(options) { if (now + effectiveToleranceMs < issuedAt) fail("ERR_PERMIT_INPUT_INVALID"); // The expiry judgement is NOT widened. The effective window is exactly // the window the signer granted. - if (now > deadline) fail("ERR_PERMIT_DEADLINE_EXPIRED"); + if (now >= deadline) fail("ERR_PERMIT_DEADLINE_EXPIRED"); // The signed deadline has to be the deadline the store holds. The // signature alone does not prove that: a signer that computed its own // deadline could sign a perfectly valid, self-consistent message whose @@ -541,6 +566,15 @@ function applyRevocationList(options) { const sequence = requireSafeInteger(list.revocation_sequence); const fingerprints = list.revoked_fingerprints; if (!Array.isArray(fingerprints)) fail("ERR_PERMIT_INPUT_INVALID"); + // Copy indexed values once. The exact same values are signed and applied; + // a custom iterator on a caller-owned array must not change the latter. + const normalizedFingerprints = []; + for (let i = 0; i < fingerprints.length; i += 1) { + const fingerprint = fingerprints[i]; + if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) fail("ERR_PERMIT_INPUT_INVALID"); + normalizedFingerprints.push(fingerprint); + } + normalizedFingerprints.sort(); // The chain must continue exactly where the local state left off. A gap, // a repeat or a rewind is a broken chain and the list is refused. if (sequence !== state.appliedRevocationSequence + 1) fail("ERR_REVOCATION_SEQUENCE_BROKEN"); @@ -579,7 +613,7 @@ function applyRevocationList(options) { if (state.revokedFingerprints.has(signerFingerprint)) fail("ERR_REVOCATION_SIGNER_INVALID"); const message = { revocation_sequence: sequence, - revoked_fingerprints: fingerprints.slice().sort(), + revoked_fingerprints: normalizedFingerprints, issued_at_ms: requireSafeInteger(list.issued_at_ms), prev_revocation_sequence: expectedPrev, batch_public_key_fingerprint: claimedSigner, @@ -588,7 +622,7 @@ function applyRevocationList(options) { if (!crypto.verify(null, bytes, signingKey, decodeSignature(list.signature))) { fail("ERR_PERMIT_SIGNATURE_INVALID"); } - const incoming = new Set(fingerprints); + const incoming = new Set(normalizedFingerprints); for (const known of state.revokedFingerprints) { // The local set only grows. A list that drops a fingerprint is refused // and nothing advances. @@ -741,6 +775,8 @@ module.exports = { MAX_SKEW_TOLERANCE_MS, canonicalize, canonicalBytes, + preparePermitSigning, + publicKeyFingerprint, createBatchKeyPair, buildSignedPermit, verifyPermitSignature, diff --git a/lib/coordination/permit-managed-session.js b/lib/coordination/permit-managed-session.js new file mode 100644 index 00000000..1ddab589 --- /dev/null +++ b/lib/coordination/permit-managed-session.js @@ -0,0 +1,22 @@ +"use strict"; + +const { startManagedCallerSession } = require("../governed/managed-caller-supervisor"); +const { CoordinationError } = require("./errors"); + +const OPERATIONS = Object.freeze(["permit.issue", "permit.register", "permit.key", "permit.verify", "permit.revoke"]); + +// Internal host entry. modulePath and authority must come from trusted host +// configuration; an untrusted request must not select either of them. +function startPermitManagedSession({ modulePath, args = [], authority, owner } = {}) { + if (typeof modulePath !== "string" || !modulePath || !Array.isArray(args) || + !authority || typeof authority.dispatch !== "function") { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + return startManagedCallerSession({ + modulePath, args, owner, allowedOperations: OPERATIONS, + onAuthorizedRequest: ({ identity, operation, payload }) => + authority.dispatch(identity, operation.slice("permit.".length), payload), + }); +} + +module.exports = { OPERATIONS, startPermitManagedSession }; diff --git a/lib/coordination/permit-revocation-signing.js b/lib/coordination/permit-revocation-signing.js new file mode 100644 index 00000000..7763e4d4 --- /dev/null +++ b/lib/coordination/permit-revocation-signing.js @@ -0,0 +1,43 @@ +"use strict"; + +const { canonicalBytes, publicKeyFingerprint } = require("./permit-delivery"); +const { CoordinationError } = require("./errors"); +const crypto = require("node:crypto"); + +// This only signs an envelope; a protected service must own the key and +// provide an authenticated, current revocation state. +function signRevocationList({ privateKeyRef, publicKeyPem, state, fingerprints, issuedAtMs }) { + if (!privateKeyRef || typeof privateKeyRef.sign !== "function" || + !state || !Number.isSafeInteger(state.appliedRevocationSequence) || + state.appliedRevocationSequence < 0 || + !Number.isSafeInteger(state.appliedRevocationSequence + 1) || + !(state.revokedFingerprints instanceof Set) || !Array.isArray(fingerprints) || + !Number.isSafeInteger(issuedAtMs) || issuedAtMs < 0) { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + const signer = publicKeyFingerprint(publicKeyPem); + if (state.revokedFingerprints.has(signer)) throw new CoordinationError("ERR_REVOCATION_SIGNER_INVALID"); + const incoming = new Set(fingerprints); + if (incoming.size !== fingerprints.length || + fingerprints.some((fingerprint) => typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint))) { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + for (const fingerprint of state.revokedFingerprints) { + if (!incoming.has(fingerprint)) throw new CoordinationError("ERR_REVOCATION_SET_NOT_MONOTONIC"); + } + const message = { + revocation_sequence: state.appliedRevocationSequence + 1, + revoked_fingerprints: [...incoming].sort(), + issued_at_ms: issuedAtMs, + prev_revocation_sequence: state.appliedRevocationSequence, + batch_public_key_fingerprint: signer, + }; + const bytes = canonicalBytes(message); + const signature = privateKeyRef.sign(bytes); + if (!Buffer.isBuffer(signature) || !crypto.verify(null, bytes, publicKeyPem, signature)) { + throw new CoordinationError("ERR_REVOCATION_SIGNER_INVALID"); + } + return { ...message, signature: signature.toString("base64") }; +} + +module.exports = { signRevocationList }; diff --git a/lib/coordination/permit-target-service.js b/lib/coordination/permit-target-service.js new file mode 100644 index 00000000..5f91c38f --- /dev/null +++ b/lib/coordination/permit-target-service.js @@ -0,0 +1,138 @@ +"use strict"; + +const crossMachine = require("./cross-machine-permit"); +const delivery = require("./permit-delivery"); +const { CoordinationError } = require("./errors"); +const fs = require("node:fs"); +const path = require("node:path"); + +// Internal composition only. The host must authenticate callers and supply +// trusted revocation and current-state providers; this is not an IPC endpoint. +function createPermitTargetService({ pinStore, pullRevocation, resolvePermitState, observeTarget, bootstrap, clock = Date.now } = {}) { + if (!pinStore || typeof pinStore.criticalSection !== "function" || + typeof pullRevocation !== "function" || typeof resolvePermitState !== "function" || + typeof clock !== "function") throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + + function now() { + const value = clock(); + if (!Number.isSafeInteger(value) || value < 0) throw new CoordinationError("ERR_CLOCK_INVALID"); + return value; + } + + function key(fingerprint) { + return pinStore.criticalSection(() => crossMachine.retrieveVerificationKey({ pinStore, fingerprint })); + } + + function revoke() { + return crossMachine.pullAndApplyRevocation({ pinStore, pull: pullRevocation, nowMs: now() }); + } + + function register({ batchIdentity, publicKeyPem, sourceDigest, deploymentDigest, + runId, generation, validFrom, validUntil } = {}) { + if (typeof observeTarget !== "function" || typeof pinStore.pinDir !== "string") { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + // createPublicKey can derive a public key from a private PEM. Never let + // such input reach the persistent pin entry, even if it parses as a key. + if (typeof publicKeyPem !== "string" || !/^-----BEGIN PUBLIC KEY-----\r?\n/.test(publicKeyPem) || + /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(publicKeyPem)) { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + return pinStore.criticalSection(() => { + const observation = observeTarget(batchIdentity); + if (!observation || typeof observation.targetHasProcess !== "boolean" || + typeof observation.targetHasPriorRun !== "boolean") { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + const write = (entry, fingerprint) => { + if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + let fd; + try { + fd = fs.openSync(path.join(pinStore.pinDir, `pin-${fingerprint}.json`), "wx", 0o600); + fs.writeFileSync(fd, JSON.stringify(entry) + "\n"); + fs.fsyncSync(fd); + return true; + } catch (error) { + if (error.code === "EEXIST") return false; + throw error; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + }; + return crossMachine.registerOnTarget({ pinStore, batchIdentity, publicKeyPem, + targetHasProcess: observation.targetHasProcess, targetHasPriorRun: observation.targetHasPriorRun, + sourceDigest, deploymentDigest, runId, generation, validFrom, validUntil, + bootstrap, write, nowMs: now() }); + }); + } + + function verify(input = {}) { + let fingerprint, message, signature; + try { ({ fingerprint, message, signature } = structuredClone(input)); } + catch { throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); } + return pinStore.criticalSection(() => { + if (typeof pinStore.assertPermitReplayState !== "function") throw new CoordinationError("ERR_PIN_STATE_CORRUPT"); + const startedAt = now(); + const verified = crossMachine.pullAndApplyRevocation({ pinStore, pull: pullRevocation, nowMs: startedAt, + verifyPermit: (revocationState) => { + const highestAccepted = pinStore.assertPermitReplayState(); + if (typeof pinStore.observePermitClock !== "function") throw new CoordinationError("ERR_CLOCK_INVALID"); + pinStore.observePermitClock(startedAt); + let state; + try { state = structuredClone(resolvePermitState(message && message.permit_id)); } + catch { throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); } + const checkedAt = now(); + pinStore.observePermitClock(checkedAt); + const expectedDeadlineMs = requireCurrentPermit(state, message, fingerprint, checkedAt); + if (revocationState.revokedFingerprints.has(fingerprint)) { + throw new CoordinationError("ERR_REVOCATION_SIGNER_INVALID"); + } + const { publicKeyPem } = crossMachine.retrieveVerificationKey({ pinStore, fingerprint }); + const verifiedAt = now(); + pinStore.observePermitClock(verifiedAt); + const finalDeadlineMs = requireCurrentPermit(state, message, fingerprint, verifiedAt); + if (finalDeadlineMs !== expectedDeadlineMs) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + const result = delivery.verifyPermitSignature({ message, signature, publicKeyPem, + expectedFingerprint: fingerprint, revokedFingerprints: revocationState.revokedFingerprints, + expectedDeadlineMs, nowMs: verifiedAt }); + if (result.sequence <= highestAccepted) throw new CoordinationError("ERR_PERMIT_SEQUENCE_REPLAY"); + return result; + } }); + // This verifies a snapshot and advances local replay state; it does not + // atomically consume the authority permit and must not grant execution. + return { ok: verified.ok, executionAuthorized: false, fingerprint: verified.fingerprint, + sequence: verified.sequence, deadline: verified.deadline }; + }); + } + + return Object.freeze({ register, key, revoke, verify }); +} + +// The host must fetch this snapshot from an authenticated authoritative store, +// never from the request. This is a check at one instant, not a cross-host +// transaction: revocation can race a later effect and requires consume(). +function requireCurrentPermit(state, message, fingerprint, nowMs) { + const permit = state && state.permits && message && state.permits[message.permit_id]; + const lease = state && state.lease; + if (permit && Number.isSafeInteger(permit.deadline) && permit.deadline <= nowMs) { + throw new CoordinationError("ERR_PERMIT_DEADLINE_EXPIRED"); + } + if (!permit || !lease || permit.permitId !== message.permit_id || permit.status !== "reserved" || + permit.batchFingerprint !== fingerprint || lease.status !== "active" || + !Number.isSafeInteger(lease.expiresAt) || lease.expiresAt <= nowMs || + !Number.isSafeInteger(permit.deadline) || permit.deadline < 0 || + permit.serviceEpoch !== state.service_epoch || permit.fencing !== state.fencing || + lease.serviceEpoch !== state.service_epoch || lease.fencing !== state.fencing || + !state.desktop || state.desktop.status !== "held" || state.desktop.key !== permit.desktopKey) { + throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + for (const [storage, signed] of delivery.BINDING_FIELD_MAP) { + if (permit[storage] !== message[signed]) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + for (const field of ["leaseId", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId"]) { + if (permit[field] !== lease[field]) throw new CoordinationError("ERR_PERMIT_INPUT_INVALID"); + } + return permit.deadline; +} + +module.exports = { createPermitTargetService }; diff --git a/lib/coordination/protected-permit-store.js b/lib/coordination/protected-permit-store.js index d134a9d4..b9db4e8f 100644 --- a/lib/coordination/protected-permit-store.js +++ b/lib/coordination/protected-permit-store.js @@ -9,7 +9,491 @@ const anchor = require("./protected-anchor"); // Version 2 requires the cross-storage coordination protocol. Version 1 writers // must refuse this state; an online mixed-version rollout is not supported. const SCHEMA_VERSION = 2; +// Explicitly provisioned, development-only authority stores fence old writers. +// No production activation or transport authentication exists here. PRODUCTION +// BLOCKER: the mode label and unsigned executionAuthorized wrapper do NOT isolate +// development signatures from other trust domains. The unchanged 14+3 signature +// can verify elsewhere if its key is trusted there. Trusted deployment/key-domain +// enforcement is still required; no request flag can establish that boundary. +const AUTHORITY_SCHEMA_VERSION = 3; +// Admission-only development stores fence schema-3 writers: they do not know +// about persistent reservations. No automatic migration or production mode. +const ADMISSION_SCHEMA_VERSION = 4; +// Explicit development-only preparation/call-gate format; no signer/T4 exists. +const SIGNING_GATE_SCHEMA_VERSION = 5; +function isAdmissionSchema(version) { + return version === ADMISSION_SCHEMA_VERSION || version === SIGNING_GATE_SCHEMA_VERSION; +} +function configuredSchema(authority) { + return authority?.signingGateMode ? SIGNING_GATE_SCHEMA_VERSION : authority?.admissionMode ? ADMISSION_SCHEMA_VERSION : null; +} +function isAuthoritySchema(version) { + return version === AUTHORITY_SCHEMA_VERSION || isAdmissionSchema(version); +} const coordination = require("./protected-store-lock"); +const witness = require("./permit-witness-contract"); + +function authorityOptions(options) { + if (options.witnessAuthority === undefined) return null; + const value = options.witnessAuthority; + if (!value || value.mode !== "development-only" || + Object.keys(value).some((key) => !["mode", "authorityId", "admissionMode", "signingGateMode", "prepareSigning", "authorize", "authorizeHistory", "observeTarget", "signPermit", "pullRevocation"].includes(key)) || + (value.signingGateMode !== undefined && (value.signingGateMode !== "development-only" || + value.admissionMode !== "development-only" || typeof value.prepareSigning !== "function")) || + (value.prepareSigning !== undefined && value.signingGateMode === undefined) || + (value.admissionMode !== undefined && value.admissionMode !== "development-only") || + (value.authorizeHistory !== undefined && typeof value.authorizeHistory !== "function") || + ["authorize", "observeTarget", "signPermit", "pullRevocation"].some((name) => typeof value[name] !== "function")) { + fail("ERR_WITNESS_AUTHORITY_DISABLED"); + } + requireText(value.authorityId); + return Object.freeze({ ...value }); +} +function dataRequest(value, fields) { + const copy = JSON.parse(witness.canonicalBytes(value).toString("utf8")); + if (!copy || Array.isArray(copy) || typeof copy !== "object" || + Object.keys(copy).length !== fields.length || fields.some((key) => !Object.hasOwn(copy, key))) fail("ERR_INVALID_INPUT"); + return copy; +} +function freezeData(value) { + if (value && typeof value === "object") { + Object.values(value).forEach(freezeData); + Object.freeze(value); + } + return value; +} +function requireMapKey(value) { + requireText(value); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value) || + ["__proto__", "constructor", "prototype"].includes(value)) fail("ERR_INVALID_INPUT"); +} +function own(map, key) { return Object.hasOwn(map, key) ? map[key] : undefined; } +// Admission format v1 uses the existing bounded canonical UTF-8 encoder. +// Resource/business identities MUST be normalized and authorized by the host; +// neither caller labels nor peer properties establish identity or scope. +const ADMISSION_FIELDS = ["requestKey", "action", "resourceScope", "businessKey", "request"]; +// Storage budget, independent of online canonical message limits. Charged as +// exact UTF-8 JSON bytes of request_admission, including escaped request_bytes, +// metadata and all indexes. No eviction/compaction of unresolved admissions. +const ADMISSION_MAX_ATTEMPTS = 1024; +const ADMISSION_MAX_BYTES = 8 * 1024 * 1024; +// Complete schema-4 body: up to 8 MiB admission plus 1 MiB of explicitly +// shaped metadata (including its JSON escaping). Legacy schemas retain their +// existing reader contract. This bounds transfer to JS, NOT SQLite allocations, +// page-cache/RSS, integrity checks, or the copies used by full-state validation. +const ADMISSION_METADATA_MAX_BYTES = 1024 * 1024; +const ADMISSION_BODY_MAX_BYTES = ADMISSION_MAX_BYTES + ADMISSION_METADATA_MAX_BYTES; +const SIGNING_MAX_BYTES = 2 * 1024 * 1024; +const SIGNING_BODY_MAX_BYTES = ADMISSION_BODY_MAX_BYTES + SIGNING_MAX_BYTES; +function readProtectedRow(db, admissionSchema, allowAuthority = true) { + // Preserve the public constructor's legacy in-memory adapter contract. Those + // values already reside in JS; this is NOT a SQLite reader or an admission + // fallback. Every native DatabaseSync (even with legacy configuration) takes + // the guarded query below. Admission adapters must supply that query contract. + if (!admissionSchema && !(db instanceof sqliteRuntime())) { + return db.prepare("SELECT body, sha256 FROM protected_state WHERE id = 1").get(); + } + // Version/encoding/type/byte checks and payload projection share ONE SQLite + // statement snapshot. Never SELECT body first or check a length in a separate + // statement. Configuration is trusted; neither DB PRAGMA nor JSON claims can + // turn an explicitly configured admission reader into an unbounded legacy one. + // BLOB length counts UTF-8 bytes only after requiring the provisioned encoding. + const allowed = admissionSchema + ? !allowAuthority ? "0" : `v.user_version = ${admissionSchema} AND e.encoding = 'UTF-8' + AND typeof(s.body) = 'text' AND length(CAST(s.body AS BLOB)) <= ${admissionSchema === SIGNING_GATE_SCHEMA_VERSION ? SIGNING_BODY_MAX_BYTES : ADMISSION_BODY_MAX_BYTES} + AND typeof(s.sha256) = 'text' AND length(CAST(s.sha256 AS BLOB)) = 64` + : `v.user_version IN (${SCHEMA_VERSION}, ${AUTHORITY_SCHEMA_VERSION})`; + const row = db.prepare(`SELECT v.user_version AS version, + CASE WHEN ${allowed} THEN s.body ELSE NULL END AS body, + CASE WHEN ${allowed} THEN s.sha256 ELSE NULL END AS sha256 + FROM protected_state AS s CROSS JOIN pragma_user_version AS v CROSS JOIN pragma_encoding AS e + WHERE s.id = 1`).get(); + if (!row || ![SCHEMA_VERSION, AUTHORITY_SCHEMA_VERSION, ADMISSION_SCHEMA_VERSION, SIGNING_GATE_SCHEMA_VERSION].includes(row.version)) fail("ERR_STATE_INVALID"); + if (isAdmissionSchema(row.version) && !allowAuthority) fail("ERR_WITNESS_RECOVERY_UNSUPPORTED"); + if ((isAdmissionSchema(row.version) ? row.version : null) !== admissionSchema) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + if (row.body === null || row.sha256 === null) fail("ERR_STATE_INVALID"); + return row; +} +function assertAdmissionMetadata(state) { + // Only flat, typed metadata and the explicitly empty business collections are + // supported in this admission-only format. No arbitrary extension/padding bag. + admissionObject(state, ["schema_version", "scope", "decision_id", "witness_authority", "service_epoch", "fencing", + "lease_seq", "permit_seq", "last_clock_ms", "lease", "lease_idempotency", "permits", "idempotency", "desktop", + "tombstones", "anchor_sequence", "store_id", "anchor_mode", "service_identity"]); + const authority = admissionObject(state.witness_authority, + ["mode", "authority_id", "clock_poisoned", "targets", "issuance_bindings", "request_admission", + ...(state.schema_version === SIGNING_GATE_SCHEMA_VERSION ? ["signing_gate"] : [])]); + admissionObject(state.desktop, ["status", "key"]); + for (const map of [state.lease_idempotency, state.permits, state.idempotency, state.tombstones, + authority.targets, authority.issuance_bindings]) admissionObject(map, []); + const metadata = { ...state, witness_authority: { ...authority, request_admission: null, + ...(state.schema_version === SIGNING_GATE_SCHEMA_VERSION ? { signing_gate: null } : {}) } }; + if (Buffer.byteLength(JSON.stringify(metadata), "utf8") > ADMISSION_METADATA_MAX_BYTES) fail("ERR_STATE_INVALID"); +} +function admissionObject(value, fields) { + if (!value || typeof value !== "object" || Array.isArray(value) || + ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail("ERR_STATE_INVALID"); + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string") || + (fields && (keys.length !== fields.length || fields.some((key) => !Object.hasOwn(value, key))))) fail("ERR_STATE_INVALID"); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, "value")) fail("ERR_STATE_INVALID"); + } + return value; +} +function admissionCapacity(value, code) { + if (Object.keys(value.attempts).length > ADMISSION_MAX_ATTEMPTS || + Buffer.byteLength(JSON.stringify(value), "utf8") > ADMISSION_MAX_BYTES) fail(code); +} +function admissionRequest(input) { + const request = dataRequest(input, ADMISSION_FIELDS); + for (const key of ADMISSION_FIELDS.slice(0, 4)) requireText(request[key]); + if (!request.request || typeof request.request !== "object" || Array.isArray(request.request)) fail("ERR_INVALID_INPUT"); + return freezeData(request); +} +function admissionGrant(value, request) { + if (value && typeof value.then === "function") { + Promise.resolve(value).catch(() => {}); + fail("ERR_WITNESS_NOT_AUTHORIZED"); + } + if (!value || typeof value !== "object") fail("ERR_WITNESS_NOT_AUTHORIZED"); + // Do not wrap resources in another online message: its bytes, node count and + // nesting were budgeted at the request boundary, without this extra envelope. + const authorization = admissionObject(value, ["principal", "grant", "resources"]); + const principal = dataRequest(authorization.principal, ["subjectId", "issuer", "tenant"]); + for (const key of ["subjectId", "issuer", "tenant"]) requireText(principal[key]); + const grant = dataRequest(authorization.grant, ["id", "version"]); + requireText(grant.id); requirePositiveMs(grant.version); + if (!witness.canonicalBytes(authorization.resources).equals(witness.canonicalBytes(request))) fail("ERR_WITNESS_NOT_AUTHORIZED"); + return freezeData({ principal, grant }); // detached from provider-owned mutable objects +} +function admissionKeys(state, principal, input) { + return { + association: digest(witness.canonicalBytes({ authorityId: state.witness_authority.authority_id, + principal, action: input.action, resourceScope: input.resourceScope, requestKey: input.requestKey })), + // Deliberately not scoped by subject/run/attempt/requestKey. The host must + // map aliases and generations onto stable authority-wide resource identities. + business: digest(witness.canonicalBytes(input.businessKey)), + resource: digest(witness.canonicalBytes(input.resourceScope)), + }; +} +function assertAdmissionState(state) { + try { + const a = admissionObject(state.witness_authority.request_admission, + ["format_version", "sequence", "attempts", "associations", "business_keys", "resources"]); + if (a.format_version !== 1 || !Number.isSafeInteger(a.sequence) || a.sequence < 0 || + a.sequence > ADMISSION_MAX_ATTEMPTS) fail("ERR_STATE_INVALID"); + for (const map of [a.attempts, a.associations, a.business_keys, a.resources]) { + admissionObject(map); + if (Object.keys(map).length !== a.sequence) fail("ERR_STATE_INVALID"); + } + const associations = {}, business = {}, resources = {}, sequences = new Set(); + for (const [id, entry] of Object.entries(a.attempts)) { + const record = admissionObject(entry, ["attempt_id", "sequence", "encoding_version", "request_bytes", "request_digest", + "principal", "grant", "service_epoch", "admitted_at_ms", "status"]); + const prefix = `admission:${state.store_id}:`; + if (id !== record.attempt_id || !id.startsWith(prefix) || !/^[a-f0-9]{32}$/.test(id.slice(prefix.length)) || + record.encoding_version !== 1 || record.status !== "admitted" || + !Number.isSafeInteger(record.sequence) || record.sequence < 1 || record.sequence > a.sequence || + sequences.has(record.sequence) || record.service_epoch !== state.service_epoch || + !Number.isSafeInteger(record.admitted_at_ms) || record.admitted_at_ms < 0 || + record.admitted_at_ms > state.last_clock_ms || typeof record.request_bytes !== "string" || + Buffer.byteLength(record.request_bytes, "utf8") > witness.LIMITS.bytes || + digest(record.request_bytes) !== record.request_digest) fail("ERR_STATE_INVALID"); + const request = admissionRequest(JSON.parse(record.request_bytes)); + if (witness.canonicalBytes(request).toString("utf8") !== record.request_bytes) fail("ERR_STATE_INVALID"); + admissionGrant({ principal: record.principal, grant: record.grant, resources: request }, request); + const keys = admissionKeys(state, record.principal, request); + if (Object.hasOwn(associations, keys.association) || Object.hasOwn(business, keys.business) || + Object.hasOwn(resources, keys.resource)) fail("ERR_STATE_INVALID"); + associations[keys.association] = id; business[keys.business] = id; resources[keys.resource] = id; + sequences.add(record.sequence); + } + // Maps contain only validated digest/attempt IDs, not arbitrary data. Compare + // directly, without a second message/entries/size budget on the collection. + for (const [actual, expected] of [[a.associations, associations], [a.business_keys, business], [a.resources, resources]]) { + if (Object.keys(actual).some((key) => !Object.hasOwn(expected, key) || actual[key] !== expected[key])) fail("ERR_STATE_INVALID"); + } + if (sequences.size !== a.sequence) fail("ERR_STATE_INVALID"); + admissionCapacity(a, "ERR_STATE_INVALID"); + } catch { fail("ERR_STATE_INVALID"); } +} +// Schema 5 is a local development reservation, NOT active-ready registration, +// authentication, signer authority, or an R5 publication certificate. Its only +// capability consumer below abandons the gate as unknown; T4 is absent. +// Private control flow: conflict safety state must commit before refusal. The +// singleton cannot be supplied by a provider's result/error code or caller. +class SigningReservationConflict extends Error {} +const SIGNING_CONFLICT_RESULT = Object.freeze({}); +const SIGNING_FACT_FIELDS = ["storeGeneration", "lifecycleVersion", "ownerIncarnation", "signerId", "validUntilMs"]; +function signingFacts(value) { + if (value && typeof value.then === "function") { + Promise.resolve(value).catch(() => {}); fail("ERR_WITNESS_SIGNING_NOT_AUTHORIZED"); + } + const facts = dataRequest(value, SIGNING_FACT_FIELDS); + for (const name of SIGNING_FACT_FIELDS.slice(0, 4)) requireText(facts[name]); + requirePositiveMs(facts.validUntilMs); + return freezeData(facts); +} +function signingRequest(input, state, facts) { + if (input.action !== "issue") fail("ERR_WITNESS_SIGNING_NOT_AUTHORIZED"); + const r = dataRequest(input.request, HISTORY_FIELDS.issue); + requireMapKey(r.targetId); + historyCheckpoint(r.checkpoint, state.witness_authority.authority_id, r.targetId, facts.storeGeneration); + const lease = dataRequest(r.lease, [...HISTORY_LEASE, "idempotencyKey", "ttlMs"]); + for (const key of [...HISTORY_LEASE, "idempotencyKey"]) requireText(lease[key]); + requirePositiveMs(lease.ttlMs); + const permit = dataRequest(r.permit, [...HISTORY_PERMIT, "idempotencyKey", "ttlMs"]); + requireText(permit.idempotencyKey); validateReservationInput(permit); + if (requireDigest(permit.targetDigest) !== permit.targetDigest || requireDigest(permit.paramsDigest) !== permit.paramsDigest || + requireDigest(r.batchFingerprint) !== r.batchFingerprint) fail("ERR_INVALID_INPUT"); + return r; +} +function signingPermit(state, input, facts, sequence, now) { + const r = signingRequest(input, state, facts); + const deadline = Math.min(now + r.lease.ttlMs, now + r.permit.ttlMs, facts.validUntilMs); + if (!Number.isSafeInteger(deadline) || deadline <= now) fail("ERR_WITNESS_SIGNING_EXPIRED"); + return { ...Object.fromEntries(HISTORY_LEASE.slice(1).map((name) => [name, r.lease[name]])), + leaseId: `${PROTECTED_LEASE_PREFIX}sign-${state.store_id}-${sequence}`, serviceEpoch: state.service_epoch, fencing: sequence, + operation: r.permit.operation, targetDigest: r.permit.targetDigest, paramsDigest: r.permit.paramsDigest, + permitId: `P-PERMIT-sign-${state.store_id}-${sequence}`, sequence, deadline }; +} +function signingCapacity(gate, code) { + if (Buffer.byteLength(JSON.stringify(gate), "utf8") > SIGNING_MAX_BYTES) fail(code); +} +function assertSigningState(state) { + try { + const gate = admissionObject(state.witness_authority.signing_gate, ["format_version", "sequence", "preparations"]); + admissionObject(gate.preparations); + if (gate.format_version !== 1 || !Number.isSafeInteger(gate.sequence) || gate.sequence < 0 || + gate.sequence > ADMISSION_MAX_ATTEMPTS || Object.keys(gate.preparations).length !== gate.sequence || + state.lease_seq !== gate.sequence || state.permit_seq !== gate.sequence || state.fencing !== gate.sequence) fail("ERR_STATE_INVALID"); + const seen = new Set(); + for (const [id, value] of Object.entries(gate.preparations)) { + const record = admissionObject(value, ["attempt_id", "prepare_version", "sequence", "prepared_at_ms", "request_digest", + "facts", "fingerprint", "permit", "signing_bytes", "signing_digest", "status", "call"]); + const original = own(state.witness_authority.request_admission.attempts, id); + if (!original || record.attempt_id !== id || record.prepare_version !== 1 || record.request_digest !== original.request_digest || + !Number.isSafeInteger(record.sequence) || record.sequence < 1 || record.sequence > gate.sequence || seen.has(record.sequence) || + !Number.isSafeInteger(record.prepared_at_ms) || record.prepared_at_ms < original.admitted_at_ms || + record.prepared_at_ms > state.last_clock_ms || + !["sign-prepared", "sign-call-possible", "sign-outcome-unknown"].includes(record.status)) fail("ERR_STATE_INVALID"); + const facts = signingFacts(record.facts); + const input = admissionRequest(JSON.parse(original.request_bytes)); + const expectedPermit = signingPermit(state, input, facts, record.sequence, record.prepared_at_ms); + const expected = require("./permit-delivery").preparePermitSigning({ permit: expectedPermit, + issuedAtMs: record.prepared_at_ms, nowMs: record.prepared_at_ms }); + if (record.fingerprint !== input.request.batchFingerprint || + !witness.canonicalBytes(record.permit).equals(witness.canonicalBytes(expectedPermit)) || + record.signing_bytes !== expected.bytes || + record.signing_digest !== digest(Buffer.from(expected.bytes, "base64"))) fail("ERR_STATE_INVALID"); + if (record.status === "sign-prepared") { + if (record.call !== null) fail("ERR_STATE_INVALID"); + } else { + const call = dataRequest(record.call, ["owner", "service_epoch", "input_digest", "anchor_sequence", "at_ms"]); + if (!/^[a-f0-9]{32}$/.test(call.owner) || call.service_epoch !== state.service_epoch || + call.input_digest !== record.signing_digest || !Number.isSafeInteger(call.anchor_sequence) || + call.anchor_sequence < 1 || call.anchor_sequence > state.anchor_sequence || + !Number.isSafeInteger(call.at_ms) || call.at_ms < record.prepared_at_ms || call.at_ms > state.last_clock_ms) fail("ERR_STATE_INVALID"); + } + seen.add(record.sequence); + } + signingCapacity(gate, "ERR_STATE_INVALID"); + } catch { fail("ERR_STATE_INVALID"); } +} +function checkpointBinding(checkpoint) { + const { authority_id, target_id, store_generation, revision } = checkpoint; + return { authority_id, target_id, store_generation, revision }; +} + +// History accepts the ORIGINAL request, not caller-supplied facts. Authorizers +// must independently authenticate the peer and grant this entire resource +// envelope. A frame UUID or a write grant is never a historical permission. +const HISTORY_FIELDS = Object.freeze({ + register: ["targetId", "requestId", "checkpoint", "pin"], + issue: ["targetId", "checkpoint", "lease", "permit", "batchFingerprint"], + consume: ["targetId", "checkpoint", "message", "signature"], +}); +const HISTORY_LEASE = ["owner", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId"]; +const HISTORY_PERMIT = ["operation", "targetDigest", "paramsDigest"]; +const HISTORY_PIN = ["decision_id", "instance_id", "fingerprint", "public_key_pem", "source_digest", + "deployment_digest", "run_id", "generation", "valid_from", "valid_until"]; +function historyCheckpoint(input, authorityId, targetId, generation) { + const cp = dataRequest(input, ["format_version", "authority_id", "target_id", "store_generation", "revision", "full_state_digest"]); + if (cp.format_version !== 1 || cp.authority_id !== authorityId || cp.target_id !== targetId || + typeof cp.store_generation !== "string" || !cp.store_generation || + (generation !== null && cp.store_generation !== generation) || + !Number.isSafeInteger(cp.revision) || cp.revision < 1 || + typeof cp.full_state_digest !== "string" || !/^[a-f0-9]{64}$/.test(cp.full_state_digest)) fail("ERR_WITNESS_HISTORY_BINDING"); + return cp; +} +function historyMessage(input) { + const delivery = require("./permit-delivery"); + const message = dataRequest(input, [...delivery.BINDING_FIELD_MAP.map(([, name]) => name), + "issued_at_ms", "deadline_ms", "permit_id"]); + for (const [name, value] of Object.entries(message)) { + if (["service_epoch", "fencing", "sequence"].includes(name)) requirePositiveMs(value); + else if (["issued_at_ms", "deadline_ms"].includes(name)) { + if (!Number.isSafeInteger(value) || value < 0) fail("ERR_INVALID_INPUT"); + } else if (["target_digest", "params_digest"].includes(name)) { + if (requireDigest(value) !== value) fail("ERR_INVALID_INPUT"); + } else requireText(value); + } + requireMapKey(message.permit_id); + if (message.deadline_ms <= message.issued_at_ms) fail("ERR_INVALID_INPUT"); + return message; +} +function historyRequest(input) { + const query = dataRequest(input, ["operation", "decisionId", "authorityId", "issuerStoreId", "targetGeneration", "resources", "request"]); + if (!Object.hasOwn(HISTORY_FIELDS, query.operation)) fail("ERR_INVALID_INPUT"); + for (const key of ["decisionId", "authorityId", "issuerStoreId"]) requireText(query[key]); + const r = dataRequest(query.request, HISTORY_FIELDS[query.operation]); + const resources = dataRequest(query.resources, ["fingerprint", "lease", "permit"]); + if (requireDigest(resources.fingerprint) !== resources.fingerprint) fail("ERR_INVALID_INPUT"); + if (query.operation !== "register") { + const lease = dataRequest(resources.lease, HISTORY_LEASE); + HISTORY_LEASE.forEach((key) => requireText(lease[key])); + const permit = dataRequest(resources.permit, HISTORY_PERMIT); + requireText(permit.operation); requireDigest(permit.targetDigest); requireDigest(permit.paramsDigest); + } else if (resources.lease !== null || resources.permit !== null) fail("ERR_WITNESS_HISTORY_BINDING"); + requireMapKey(r.targetId); + if (query.targetGeneration === null) { + if (query.operation !== "register" || r.checkpoint !== null) fail("ERR_WITNESS_HISTORY_BINDING"); + } else requireText(query.targetGeneration); + if (r.checkpoint !== null) historyCheckpoint(r.checkpoint, query.authorityId, r.targetId, query.targetGeneration); + else if (query.operation !== "register") fail("ERR_WITNESS_HISTORY_BINDING"); + if (query.operation === "register") { + requireMapKey(r.requestId); + const pin = dataRequest(r.pin, HISTORY_PIN); + const normalized = witness.normalizeTargetState({ schema_version: 3, pins: [{ ...pin, registered_at_ms: 0 }], + revoked_fingerprints: [], applied_revocation_sequence: 0, highest_seen_revocation_sequence: 0, + highest_accepted_permit_sequence: 0, last_observed_clock_ms: 0, clock_poisoned: false }); + const delivery = require("./permit-delivery"); + if (normalized.pins.length !== 1 || pin.decision_id !== query.decisionId || pin.instance_id !== r.targetId || + crypto.createPublicKey(pin.public_key_pem).asymmetricKeyType !== "ed25519" || + delivery.publicKeyFingerprint(pin.public_key_pem) !== pin.fingerprint || + resources.fingerprint !== pin.fingerprint) fail("ERR_WITNESS_HISTORY_BINDING"); + } else if (query.operation === "issue") { + const lease = dataRequest(r.lease, [...HISTORY_LEASE, "idempotencyKey", "ttlMs"]); + HISTORY_LEASE.forEach((key) => requireText(lease[key])); + requireMapKey(lease.idempotencyKey); requirePositiveMs(lease.ttlMs); + const permit = dataRequest(r.permit, ["idempotencyKey", "operation", "targetDigest", "paramsDigest", "ttlMs"]); + requireMapKey(permit.idempotencyKey); validateReservationInput(permit); + if (requireDigest(r.batchFingerprint) !== r.batchFingerprint) fail("ERR_INVALID_INPUT"); + if (resources.fingerprint !== r.batchFingerprint || !matches(lease, resources.lease, HISTORY_LEASE) || + !matches(permit, resources.permit, HISTORY_PERMIT)) fail("ERR_WITNESS_HISTORY_BINDING"); + } else { + const delivery = require("./permit-delivery"); + const message = historyMessage(r.message); + requireText(r.signature); + if (Buffer.from(r.signature, "base64").length !== 64 || + Buffer.from(r.signature, "base64").toString("base64") !== r.signature) fail("ERR_INVALID_INPUT"); + for (const [storage, signed] of delivery.BINDING_FIELD_MAP) { + const expected = Object.hasOwn(resources.lease, storage) ? resources.lease : resources.permit; + if (Object.hasOwn(expected, storage)) { + const value = ["targetDigest", "paramsDigest"].includes(storage) ? requireDigest(expected[storage]) : expected[storage]; + if (message[signed] !== value) fail("ERR_WITNESS_HISTORY_BINDING"); + } + } + } + return freezeData(query); +} + +function historyEffect(state, query) { + const r = query.request, authority = state.witness_authority; + const target = own(authority.targets, r.targetId); + if (target && query.targetGeneration !== null && target.current.checkpoint.store_generation !== query.targetGeneration) { + fail("ERR_WITNESS_HISTORY_BINDING"); + } + if (query.operation === "register") { + if (!target || !Object.hasOwn(target.registrations, r.requestId)) return null; + const receipt = own(target.registrations, r.requestId); + const row = dataRequest(receipt, ["request_digest", "result"]); + if (row.request_digest !== digest(witness.canonicalBytes(r))) fail("ERR_WITNESS_HISTORY_BINDING"); + const result = dataRequest(row.result, ["registered", "reused", "checkpoint"]); + const cp = historyCheckpoint(result.checkpoint, query.authorityId, r.targetId, target.current.checkpoint.store_generation); + if (typeof result.registered !== "boolean" || result.reused !== !result.registered || + cp.revision > target.current.checkpoint.revision || + !target.current.state.pins.some((pin) => pin.fingerprint === r.pin.fingerprint)) fail("ERR_STATE_INVALID"); + return { kind: "registration", registered: result.registered, reused: result.reused, checkpoint: cp, + requestDigest: row.request_digest, requestMatch: "full-request-digest", historicalPrincipal: "not-recorded" }; + } + const id = query.operation === "issue" ? own(state.idempotency, r.permit.idempotencyKey) : r.message.permit_id; + if (query.operation === "issue" && Object.hasOwn(state.idempotency, r.permit.idempotencyKey)) requireMapKey(id); + if (!id) { + if (Object.values(state.permits).some((permit) => permit.idempotencyKey === r.permit.idempotencyKey)) fail("ERR_STATE_INVALID"); + return null; + } + const permit = own(state.permits, id), binding = own(authority.issuance_bindings, id); + if (!permit && !binding) return null; + if (!target || !permit || !binding || binding.target_id !== r.targetId || + binding.target_generation !== query.targetGeneration || binding.fingerprint !== query.resources.fingerprint) { + fail("ERR_WITNESS_HISTORY_BINDING"); + } + // Query-owner authentication is distinct from an old lease's recorded owner. + // Require that persisted ownership/binding; never infer it from a signed label + // or substitute the current lease when reading an older permit. + const leases = Object.values(state.lease_idempotency).filter((lease) => lease && lease.leaseId === permit.leaseId); + if (leases.length !== 1 || !matches(leases[0], query.resources.lease, HISTORY_LEASE) || + leases[0].serviceEpoch !== permit.serviceEpoch || leases[0].fencing !== permit.fencing) fail("ERR_WITNESS_HISTORY_BINDING"); + const delivery = require("./permit-delivery"); + const signed = permit.signedIssuance.signedPermit; + const message = historyMessage(signed.message); + const pin = target.current.state.pins.find((entry) => entry.fingerprint === binding.fingerprint); + if (!pin || delivery.canonicalBytes(message).toString("base64") !== binding.message_bytes || + message.permit_id !== id || message.deadline_ms !== permit.deadline || + !Number.isSafeInteger(message.issued_at_ms) || message.issued_at_ms < 0 || message.issued_at_ms >= permit.deadline || + delivery.BINDING_FIELD_MAP.some(([storage, field]) => message[field] !== permit[storage]) || + Buffer.from(binding.signature, "base64").length !== 64 || + Buffer.from(binding.signature, "base64").toString("base64") !== binding.signature || + !crypto.verify(null, delivery.canonicalBytes(message), pin.public_key_pem, Buffer.from(binding.signature, "base64"))) { + fail("ERR_STATE_INVALID"); + } + if (query.operation === "issue") { + const lease = own(state.lease_idempotency, r.lease.idempotencyKey); + const expectedDigest = digest(witness.canonicalBytes({ targetId: r.targetId, lease: r.lease, + permit: r.permit, fingerprint: r.batchFingerprint })); + if (!lease || lease.leaseId !== permit.leaseId || !matches(lease, r.lease, [...HISTORY_LEASE, "ttlMs"]) || + !matches(permit, r.lease, HISTORY_LEASE.filter((key) => key !== "owner")) || + permit.idempotencyKey !== r.permit.idempotencyKey || permit.operation !== r.permit.operation || + permit.targetDigest !== requireDigest(r.permit.targetDigest) || permit.paramsDigest !== requireDigest(r.permit.paramsDigest) || + permit.requestedTtlMs !== r.permit.ttlMs || binding.fingerprint !== r.batchFingerprint || + binding.request_digest !== expectedDigest) fail("ERR_WITNESS_HISTORY_BINDING"); + return { kind: "issuance", permitId: id, status: binding.status, requestDigest: binding.request_digest, + requestMatch: "business-effect-only", inputCheckpoint: "not-recorded", historicalPrincipal: "not-recorded" }; + } + if (delivery.canonicalBytes(r.message).toString("base64") !== binding.message_bytes || r.signature !== binding.signature) { + fail("ERR_WITNESS_HISTORY_BINDING"); + } + if (binding.status !== "consumed") { + if (Object.hasOwn(binding, "consumption_record")) fail("ERR_STATE_INVALID"); + return null; + } + const record = dataRequest(binding.consumption_record, ["permit_id", "at_ms", "checkpoint"]); + const cp = historyCheckpoint(record.checkpoint, query.authorityId, r.targetId, query.targetGeneration); + if (record.permit_id !== id || !Number.isSafeInteger(record.at_ms) || record.at_ms < 0 || + record.at_ms !== permit.consumedAt || cp.revision > target.current.checkpoint.revision || + target.current.state.highest_accepted_permit_sequence < permit.sequence) fail("ERR_STATE_INVALID"); + return { kind: "consumption", permitId: id, atMs: record.at_ms, checkpoint: cp, + requestMatch: "business-effect-only", inputCheckpoint: "not-recorded", fullRequestDigest: "not-recorded", + historicalPrincipal: "not-recorded" }; +} +function poisonAuthorityState(state) { + // This marker is durable only after the enclosing DB/anchor commit succeeds. + // A precommit persistence failure cannot preserve the observation across a + // restart. External witnessing or isolated recovery must govern resumption; + // a recovered local clock alone is not evidence that it is safe to resume. + const authority = state.witness_authority; + authority.clock_poisoned = true; + for (const target of Object.values(authority.targets)) { + const current = target.current; + if (!current.state.clock_poisoned) { + target.current = witness.createCheckpoint({ ...checkpointBinding(current.checkpoint), + revision: current.checkpoint.revision + 1 }, { ...current.state, clock_poisoned: true }); + } + } +} class ProtectedStoreError extends Error { constructor(code) { @@ -20,6 +504,13 @@ class ProtectedStoreError extends Error { } function fail(code) { throw new ProtectedStoreError(code); } +function nativePermissionOptions(options) { + if (process.platform !== "win32") return options; + // Deployment options cannot replace native Windows evidence or opt out of + // the strict contract. Keep simulation/injected adapters on other hosts. + anchor.assertKnownAnchorMode(options.anchorMode); + return { ...options, anchorMode: anchor.ANCHOR_MODE.strict, platform: "windows", adapter: undefined }; +} function requireText(value) { if (typeof value !== "string" || !value) fail("ERR_INVALID_INPUT"); return value; @@ -32,6 +523,12 @@ function requirePositiveMs(value) { if (!Number.isSafeInteger(value) || value <= 0) fail("ERR_INVALID_INPUT"); return value; } +function validateReservationInput(fields) { + if (!fields || typeof fields !== "object" || Array.isArray(fields)) fail("ERR_INVALID_INPUT"); + for (const key of ["idempotencyKey", "operation", "targetDigest", "paramsDigest"]) requireText(fields[key]); + return { targetDigest: requireDigest(fields.targetDigest), paramsDigest: requireDigest(fields.paramsDigest), + ttlMs: requirePositiveMs(fields.ttlMs) }; +} function digest(value) { return crypto.createHash("sha256").update(value).digest("hex"); } @@ -47,9 +544,14 @@ function checkFile(filePath) { const stat = fs.lstatSync(filePath); if (!stat.isFile() || stat.isSymbolicLink()) fail("ERR_UNSAFE_PATH"); } -function initialState(now, decisionId, storeId, anchorMode, serviceIdentity) { +function initialState(now, decisionId, storeId, anchorMode, serviceIdentity, authority = null) { return { - schema_version: SCHEMA_VERSION, scope: PROTECTED_SCOPE, decision_id: decisionId, + schema_version: authority ? (configuredSchema(authority) || AUTHORITY_SCHEMA_VERSION) : SCHEMA_VERSION, scope: PROTECTED_SCOPE, decision_id: decisionId, + ...(authority ? { witness_authority: { mode: "development-only", authority_id: authority.authorityId, + clock_poisoned: false, targets: {}, issuance_bindings: {}, + ...(authority.admissionMode ? { request_admission: { format_version: 1, sequence: 0, + attempts: {}, associations: {}, business_keys: {}, resources: {} } } : {}), + ...(authority.signingGateMode ? { signing_gate: { format_version: 1, sequence: 0, preparations: {} } } : {}) } } : {}), service_epoch: 1, fencing: 0, lease_seq: 0, permit_seq: 0, last_clock_ms: now, lease: null, lease_idempotency: {}, permits: {}, idempotency: {}, desktop: { status: "free", key: null }, tombstones: {}, @@ -72,7 +574,7 @@ function initialState(now, decisionId, storeId, anchorMode, serviceIdentity) { }; } function assertState(state) { - if (!state || state.schema_version !== SCHEMA_VERSION || state.scope !== PROTECTED_SCOPE || + if (!state || ![SCHEMA_VERSION, AUTHORITY_SCHEMA_VERSION, ADMISSION_SCHEMA_VERSION, SIGNING_GATE_SCHEMA_VERSION].includes(state.schema_version) || state.scope !== PROTECTED_SCOPE || typeof state.decision_id !== "string" || !state.decision_id || !Number.isSafeInteger(state.service_epoch) || state.service_epoch < 1 || !Number.isSafeInteger(state.fencing) || state.fencing < 0 || @@ -107,7 +609,58 @@ function assertState(state) { if (!permit || typeof permit !== "object" || typeof permit.permitId !== "string" || !["reserved", "consumed", "finished", "failed", "unknown", "revoked", "expired"].includes(permit.status) || !Number.isSafeInteger(permit.sequence) || !Number.isSafeInteger(permit.deadline) || - !Number.isSafeInteger(permit.serviceEpoch) || !Number.isSafeInteger(permit.fencing)) fail("ERR_STATE_INVALID"); + !Number.isSafeInteger(permit.serviceEpoch) || !Number.isSafeInteger(permit.fencing) || + (permit.requestedTtlMs !== undefined && + (!Number.isSafeInteger(permit.requestedTtlMs) || permit.requestedTtlMs <= 0)) || + (permit.batchFingerprint !== undefined && + (typeof permit.batchFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(permit.batchFingerprint)))) fail("ERR_STATE_INVALID"); + } + if (state.schema_version === SCHEMA_VERSION) { + if (Object.hasOwn(state, "witness_authority")) fail("ERR_STATE_INVALID"); + } else { + const authority = state.witness_authority; + if (!authority || authority.mode !== "development-only" || typeof authority.authority_id !== "string" || + !authority.authority_id || typeof authority.clock_poisoned !== "boolean" || + !authority.targets || Array.isArray(authority.targets) || typeof authority.targets !== "object" || + !authority.issuance_bindings || Array.isArray(authority.issuance_bindings) || typeof authority.issuance_bindings !== "object") fail("ERR_STATE_INVALID"); + for (const [targetId, target] of Object.entries(authority.targets)) { + requireMapKey(targetId); + if (!target || !target.current || !target.registrations || typeof target.registrations !== "object" || + Array.isArray(target.registrations)) fail("ERR_STATE_INVALID"); + const current = witness.assertCheckpointMatches(target.current, target.current.checkpoint); + if (current.checkpoint.authority_id !== authority.authority_id || current.checkpoint.target_id !== targetId || + current.checkpoint.store_generation === state.store_id) fail("ERR_STATE_INVALID"); + } + const generations = Object.values(authority.targets).map((target) => target.current.checkpoint.store_generation); + if (new Set(generations).size !== generations.length) fail("ERR_STATE_INVALID"); + for (const [permitId, binding] of Object.entries(authority.issuance_bindings)) { + const permit = own(state.permits, permitId); + const target = own(authority.targets, binding && binding.target_id); + if (!binding || !permit || !target || binding.authority_id !== authority.authority_id || + binding.issuer_store_id !== state.store_id || binding.permit_id !== permitId || + binding.target_generation !== target.current.checkpoint.store_generation || + binding.fingerprint !== permit.batchFingerprint || !permit.signedIssuance || + binding.message_bytes !== permit.signedIssuance.signedPermit.bytes || + binding.signature !== permit.signedIssuance.signedPermit.signature || + typeof binding.request_digest !== "string" || + !["issued", "consumed", "invalidated"].includes(binding.status) || + (binding.status === "issued" && permit.status !== "reserved") || + (binding.status === "consumed" && (permit.status !== "consumed" || !binding.consumption_record)) || + (binding.status === "invalidated" && permit.status !== "expired")) fail("ERR_STATE_INVALID"); + } + if (Object.keys(state.permits).some((id) => !own(authority.issuance_bindings, id))) fail("ERR_STATE_INVALID"); + if (isAdmissionSchema(state.schema_version)) { + // This slice has no business continuation. A writer must not inject an + // issuance or lease behind the admission/call gate and still open here. + if (state.lease !== null || state.desktop.status !== "free" || + (state.schema_version === ADMISSION_SCHEMA_VERSION && (state.lease_seq !== 0 || state.permit_seq !== 0)) || + Object.keys(state.lease_idempotency).length || Object.keys(state.permits).length || + Object.keys(state.idempotency).length || Object.keys(authority.targets).length || + Object.keys(authority.issuance_bindings).length) fail("ERR_STATE_INVALID"); + assertAdmissionMetadata(state); + assertAdmissionState(state); + if (state.schema_version === SIGNING_GATE_SCHEMA_VERSION) assertSigningState(state); + } else if (Object.hasOwn(authority, "request_admission") || Object.hasOwn(authority, "signing_gate")) fail("ERR_STATE_INVALID"); } } function matches(record, fields, names) { @@ -143,7 +696,7 @@ function activeLease(state, fields, now) { const lease = state.lease; if (!lease || lease.status !== "active" || lease.expiresAt <= now || lease.fencing !== state.fencing || lease.serviceEpoch !== state.service_epoch || - !matches(lease, fields, ["leaseId", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId"])) { + !matches(lease, fields, ["leaseId", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId", "serviceEpoch", "fencing"])) { fail("ERR_LEASE_NOT_CURRENT"); } if (state.desktop.status !== "held" || state.desktop.key !== lease.desktopKey) fail("ERR_DESKTOP_DRAINING"); @@ -161,8 +714,16 @@ class ProtectedPermitStore { #poisoned = false; #lockIdentity = null; #permissionOptions; + #authority; + #authorityDurability; + #historyActive = false; + #admissionActive = false; + #prepareTickets = new WeakMap(); + #callCapabilities = new WeakMap(); + #signingBlocked = false; + #signingOwner = crypto.randomBytes(16).toString("hex"); - constructor(db, options = {}) { + constructor(db, options = {}, authorityDurability) { // A store without an anchor would silently skip the rollback detection, // so the anchor is required at construction as well. The public entry // points are initialize() and open(); both of them bind the anchor, and @@ -170,7 +731,10 @@ class ProtectedPermitStore { // store that runs without one. if (!options.anchorFile || !options.anchorDir || !options.storeId) fail("ERR_ANCHOR_REQUIRED"); this.#db = db; - this.#permissionOptions = options; + this.#permissionOptions = nativePermissionOptions(options); + this.#authority = authorityOptions(options); + if (this.#authority && authorityDurability === undefined) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + this.#authorityDurability = authorityDurability; this.#clock = options.clock || Date.now; this.#verifyRecovery = options.verifyRecovery || null; this.#anchorFile = options.anchorFile || null; @@ -179,10 +743,15 @@ class ProtectedPermitStore { } static initialize(filePath, options = {}) { + options = nativePermissionOptions(options); + const authority = authorityOptions(options); // Refuse unsupported modes before provisioning anything. + if (authority) options = { ...options, witnessAuthority: authority }; sqliteRuntime(); requireText(options.decisionId); if (!options.anchorDir) fail("ERR_ANCHOR_REQUIRED"); const dir = anchor.anchorDirFor(options.anchorDir); + const capability = authority ? anchor.preflightAuthorityDurability(dir) : undefined; + if (authority && capability === undefined) fail("ERR_WITNESS_AUTHORITY_DISABLED"); anchor.assertSeparateDomains(dir, filePath); anchor.assertAnchorPermissionsWithIdentity(dir, anchor.assertKnownAnchorMode(options.anchorMode), options.serviceIdentity, options); @@ -192,7 +761,7 @@ class ProtectedPermitStore { let store; try { return coordination.withLock(dir, (identity) => { - store = this.#initializeUnlocked(filePath, options); + store = this.#initializeUnlocked(filePath, options, capability); store.#lockIdentity = identity; return store; }, { create: true }); @@ -204,7 +773,7 @@ class ProtectedPermitStore { // Validate the recorded authorization contract before touching the lock domain. // Re-read all state under the lock in the existing implementation below. - static #withExistingLock(filePath, options, action) { + static #withExistingLock(filePath, options, action, allowAuthority = false) { const DatabaseSync = sqliteRuntime(); requireText(options.expectedDecisionId); if (!options.anchorDir) fail("ERR_ANCHOR_REQUIRED"); @@ -213,10 +782,21 @@ class ProtectedPermitStore { anchor.assertSeparateDomains(dir, filePath); const db = new DatabaseSync(path.resolve(filePath), { readOnly: true, timeout: 5000 }); try { - const row = db.prepare("SELECT body, sha256 FROM protected_state WHERE id = 1").get(); + const row = readProtectedRow(db, configuredSchema(authorityOptions(options)), allowAuthority); if (!row || digest(row.body) !== row.sha256) fail("ERR_STATE_INVALID"); const state = JSON.parse(row.body); + if (state.schema_version !== row.version) fail("ERR_STATE_INVALID"); assertState(state); + if (isAuthoritySchema(state.schema_version) && !allowAuthority) fail("ERR_WITNESS_RECOVERY_UNSUPPORTED"); + // The read-only discovery must not acquire a lock or write an audit for + // an authority store opened without its matching explicit configuration. + if (allowAuthority) { + const configured = authorityOptions(options); + if (isAuthoritySchema(state.schema_version) !== Boolean(configured) || + (isAdmissionSchema(state.schema_version) ? state.schema_version : null) !== configuredSchema(configured)) { + fail("ERR_WITNESS_AUTHORITY_DISABLED"); + } + } anchor.assertAnchorPermissionsWithIdentity(dir, anchor.assertNoAnchorModeDowngrade(state.anchor_mode, options.anchorMode), state.service_identity, options); @@ -225,21 +805,29 @@ class ProtectedPermitStore { } static open(filePath, options = {}) { + options = nativePermissionOptions(options); + const authority = authorityOptions(options); + if (authority) options = { ...options, witnessAuthority: authority }; if (!Number.isSafeInteger(options.minimumEpoch) || options.minimumEpoch < 1) fail("ERR_INVALID_INPUT"); + if (authority && !options.anchorDir) fail("ERR_ANCHOR_REQUIRED"); + // Explicit authority bootstraps preflight even before read-only SQLite + // discovery, lock acquisition or permission-audit side effects. + const capability = authority ? anchor.preflightAuthorityDurability(options.anchorDir) : undefined; + if (authority && capability === undefined) fail("ERR_WITNESS_AUTHORITY_DISABLED"); let store; try { return this.#withExistingLock(filePath, options, (identity) => { - store = this.#openUnlocked(filePath, options); + store = this.#openUnlocked(filePath, options, capability); store.#lockIdentity = identity; return store; - }); + }, true); } catch (error) { try { if (store) store.close(); } catch (cleanupError) { error.cleanupError = cleanupError; } throw error; } } - static #initializeUnlocked(filePath, options = {}) { + static #initializeUnlocked(filePath, options = {}, capability) { requireText(options.decisionId); // The anchor is mandatory: without it the store cannot detect a // same-epoch rollback, which would make VC-044-007 fail open. @@ -272,19 +860,22 @@ class ProtectedPermitStore { // written into the database body so that a journal copied from another // instance is rejected on open, instead of being silently adopted. const storeId = crypto.randomBytes(16).toString("hex"); - const body = JSON.stringify(initialState(now, options.decisionId, storeId, options.anchorMode, options.serviceIdentity)); + const state = initialState(now, options.decisionId, storeId, options.anchorMode, options.serviceIdentity, authorityOptions(options)); + if (isAdmissionSchema(state.schema_version)) assertState(state); + const body = JSON.stringify(state); db.prepare("INSERT INTO protected_state (id, body, sha256) VALUES (1, ?, ?)").run(body, digest(body)); - db.exec(`PRAGMA user_version=${SCHEMA_VERSION}`); + db.exec(`PRAGMA user_version=${state.schema_version}`); + const store = new ProtectedPermitStore(db, { ...options, anchorFile: journalFile, anchorDir, storeId }, capability); if (journalFile) { - anchor.appendEntry(journalFile, { type: "intent", sequence: 1, body_digest: digest(body), store_id: storeId, attempt: 1 }); - anchor.appendEntry(journalFile, { type: "commit", sequence: 1, body_digest: digest(body), store_id: storeId, attempt: 1 }); - anchor.appendHighWater(anchorDir, { sequence: 1, body_digest: digest(body), store_id: storeId, attempt: 1 }); + store.#appendAnchorEntry({ type: "intent", sequence: 1, body_digest: digest(body), store_id: storeId, attempt: 1 }); + store.#appendAnchorEntry({ type: "commit", sequence: 1, body_digest: digest(body), store_id: storeId, attempt: 1 }); + store.#appendAnchorHighWater({ sequence: 1, body_digest: digest(body), store_id: storeId, attempt: 1 }); } - return new ProtectedPermitStore(db, { ...options, anchorFile: journalFile, anchorDir, storeId }); + return store; } catch (error) { db.close(); throw error; } } - static #openUnlocked(filePath, options = {}) { + static #openUnlocked(filePath, options = {}, capability) { const DatabaseSync = sqliteRuntime(); requireText(options.expectedDecisionId); if (!Number.isSafeInteger(options.minimumEpoch) || options.minimumEpoch < 1) fail("ERR_INVALID_INPUT"); @@ -308,8 +899,9 @@ class ProtectedPermitStore { const db = new DatabaseSync(path.resolve(filePath), { timeout: 5000 }); try { db.exec("PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;"); + const version = db.prepare("PRAGMA user_version").get().user_version; if (db.prepare("PRAGMA integrity_check").get().integrity_check !== "ok" || - db.prepare("PRAGMA user_version").get().user_version !== SCHEMA_VERSION) fail("ERR_STATE_INVALID"); + ![SCHEMA_VERSION, AUTHORITY_SCHEMA_VERSION, ADMISSION_SCHEMA_VERSION, SIGNING_GATE_SCHEMA_VERSION].includes(version)) fail("ERR_STATE_INVALID"); // The store identity is bound to the journal on first use and inherited // from it on every later open, so entries written by any process share // the same identity and a journal from another instance is not adopted. @@ -318,9 +910,10 @@ class ProtectedPermitStore { if (journal.entries.length === 0) fail("ERR_ANCHOR_MISSING"); if (journal.storeIds.size !== 1) fail("ERR_ANCHOR_CORRUPT"); storeId = [...journal.storeIds][0]; - const store = new ProtectedPermitStore(db, { ...options, anchorFile: journalFile, anchorDir, storeId }); + const store = new ProtectedPermitStore(db, { ...options, anchorFile: journalFile, anchorDir, storeId }, capability); const raw = store.#readRaw(); const snapshot = raw.state; + if (snapshot.schema_version !== version) fail("ERR_STATE_INVALID"); if (snapshot.decision_id !== options.expectedDecisionId) fail("ERR_DECISION_MISMATCH"); if (snapshot.service_epoch < options.minimumEpoch) fail("ERR_EPOCH_ROLLBACK"); // The recorded permission mode is the contract the store was created @@ -347,8 +940,22 @@ class ProtectedPermitStore { } } if (journalFile) store.#verifyAnchor(journalFile, snapshot, raw.body); + if (isAuthoritySchema(snapshot.schema_version) && declared.mark !== snapshot.anchor_sequence) { + // A completed journal entry without its high-water barrier is still + // an incomplete authority commit, not permission to resume on reopen. + fail("ERR_CRASH_WINDOW_RECOVERABLE"); + } const now = (options.clock || Date.now)(); - if (!Number.isSafeInteger(now) || now < snapshot.last_clock_ms) fail("ERR_CLOCK_ROLLBACK"); + if (!Number.isSafeInteger(now)) fail("ERR_CLOCK_ROLLBACK"); + if (now < snapshot.last_clock_ms) { + if (isAuthoritySchema(snapshot.schema_version)) { + // open() already holds the coordination lock: do not acquire it again. + // Revalidate the DB/anchor, using the captured rollback observation. + // A second clock read may throw/return NaN and must not erase it. + store.#transactionUnlocked((state) => { poisonAuthorityState(state); }, true, now); + } + fail("ERR_CLOCK_ROLLBACK"); + } return store; } catch (error) { db.close(); throw error; } } @@ -363,16 +970,25 @@ class ProtectedPermitStore { if (this.#closed) fail("ERR_STORE_CLOSED"); if (this.#poisoned) fail("ERR_COMMIT_OUTCOME_UNKNOWN"); let row; - try { row = this.#db.prepare("SELECT body, sha256 FROM protected_state WHERE id = 1").get(); } - catch { fail("ERR_STATE_INVALID"); } + try { row = readProtectedRow(this.#db, configuredSchema(this.#authority)); } + catch (error) { + if (error instanceof ProtectedStoreError) throw error; + fail("ERR_STATE_INVALID"); + } if (!row || digest(row.body) !== row.sha256) fail("ERR_STATE_INVALID"); let state; try { state = JSON.parse(row.body); } catch { fail("ERR_STATE_INVALID"); } + if (row.version !== undefined && state.schema_version !== row.version) fail("ERR_STATE_INVALID"); assertState(state); + if (isAuthoritySchema(state.schema_version)) { + if (!this.#authority || this.#authority.authorityId !== state.witness_authority.authority_id || + (isAdmissionSchema(state.schema_version) ? state.schema_version : null) !== configuredSchema(this.#authority)) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + } else if (this.#authority) fail("ERR_WITNESS_AUTHORITY_DISABLED"); return { state, body: row.body }; } snapshot() { + if (this.#admissionActive) fail("ERR_WITNESS_ADMISSION_ACTIVE"); return coordination.withLock(this.#anchorDir, () => { const raw = this.#readRaw(); this.#assertJournalStillValid(raw.state, raw.body); @@ -380,6 +996,138 @@ class ProtectedPermitStore { }, { expectedIdentity: this.#lockIdentity }); } + // Trusted in-process, development-only diagnostic surface. Not routed by the + // existing witness host/wire, and never an unblocking/retry/recovery operation. + // authorizeHistory(peer, "history.", frozenQuery) must synchronously + // recheck identity and return { principal, resources: }. + // It must derive that grant independently of request claims. Returning true, + // a Promise, a write permission, or a partial resource grant is insufficient. + queryWitnessHistory(peer, input) { + if (this.#admissionActive) fail("ERR_WITNESS_ADMISSION_ACTIVE"); + if (this.#authority && this.#authority.admissionMode) fail("ERR_WITNESS_ADMISSION_ONLY"); + if (!this.#authority || typeof this.#authority.authorizeHistory !== "function") fail("ERR_WITNESS_HISTORY_NOT_AUTHORIZED"); + if (this.#closed) fail("ERR_STORE_CLOSED"); + if (this.#poisoned) fail("ERR_COMMIT_OUTCOME_UNKNOWN"); + if (this.#historyActive || this.#lockIdentity === null) fail("ERR_WITNESS_HISTORY_UNAVAILABLE"); + const query = historyRequest(input); + const queryDigest = digest(witness.canonicalBytes(query)); + const authorize = () => { + const value = this.#authority.authorizeHistory(peer, "history." + query.operation, query); + if (value && typeof value.then === "function") { + Promise.resolve(value).catch(() => {}); + fail("ERR_WITNESS_HISTORY_NOT_AUTHORIZED"); + } + if (!value || typeof value !== "object") fail("ERR_WITNESS_HISTORY_NOT_AUTHORIZED"); + const grant = dataRequest(value, ["principal", "resources"]); + requireText(grant.principal); + if (digest(witness.canonicalBytes(grant.resources)) !== queryDigest) fail("ERR_WITNESS_HISTORY_NOT_AUTHORIZED"); + return digest(grant.principal); + }; + this.#historyActive = true; + try { + const principalDigest = authorize(); // Before reading any protected history. + const reauthorize = () => { + if (authorize() !== principalDigest) fail("ERR_WITNESS_HISTORY_NOT_AUTHORIZED"); + }; + let result; + try { + result = coordination.withLock(this.#anchorDir, () => { + reauthorize(); + this.#db.exec("BEGIN"); + let observation; + try { + const raw = this.#readRaw(), state = raw.state; + if (state.schema_version !== AUTHORITY_SCHEMA_VERSION || + this.#db.prepare("PRAGMA user_version").get().user_version !== AUTHORITY_SCHEMA_VERSION || + state.witness_authority.mode !== "development-only") fail("ERR_WITNESS_AUTHORITY_DISABLED"); + if (state.decision_id !== query.decisionId || state.witness_authority.authority_id !== query.authorityId || + state.store_id !== query.issuerStoreId) fail("ERR_WITNESS_HISTORY_BINDING"); + const journal = this.#assertJournalStillValid(state, raw.body); + const declared = anchor.readHighWater(this.#anchorDir); + // Validate the declaration's identity even when it lags the journal. + if (declared.storeId !== state.store_id) fail("ERR_ANCHOR_STORE_MISMATCH"); + const effect = historyEffect(state, query); + const target = own(state.witness_authority.targets, query.request.targetId); + observation = freezeData({ status: effect ? "found" : "not-found-at-boundary", operation: query.operation, + mode: "development-only", executionAuthorized: false, resumeAllowed: false, + originalRequestOutcome: "unknown", effect, + boundary: { decisionId: state.decision_id, authorityId: query.authorityId, issuerStoreId: state.store_id, + serviceEpoch: state.service_epoch, anchorSequence: state.anchor_sequence, bodyDigest: digest(raw.body), + journalSequence: journal.committed, highWaterSequence: declared.mark }, + currentCheckpoint: target ? historyCheckpoint(target.current.checkpoint, query.authorityId, + query.request.targetId, target.current.checkpoint.store_generation) : null, + clockPoisoned: state.witness_authority.clock_poisoned || Boolean(target && target.current.state.clock_poisoned) }); + this.#db.exec("COMMIT"); + } catch (error) { + try { this.#db.exec("ROLLBACK"); } catch { this.#poisoned = true; } + throw error; + } + reauthorize(); + // Records an observation, not proof of response delivery, of the + // original actor, or of a business transaction having not committed. + this.#writeHistoryAudit({ event: "witness-history-observation", operation: query.operation, + query_digest: queryDigest, query_principal_digest: principalDigest, status: observation.status, + boundary: observation.boundary, executionAuthorized: false, resumeAllowed: false }); + reauthorize(); + return observation; + }, { expectedIdentity: this.#lockIdentity }); + reauthorize(); // withLock also verifies identity and completes cleanup before release. + } catch (error) { + // Once protected inspection starts, failed identity/integrity/audit or + // lock cleanup is terminal for this handle. Never clear an existing latch. + this.#poisoned = true; + throw error; + } + return result; + } finally { this.#historyActive = false; } + } + + #writeHistoryAudit(record) { + let fd, dir, failure, cleanupError; + try { + fd = fs.openSync(path.join(this.#anchorDir, "protected-anchor-audit.log"), + fs.constants.O_RDWR | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600); + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.nlink !== 1 || (process.platform !== "win32" && + ((stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid())))) { + fail("ERR_AUDIT_WRITE_FAILED"); + } + if (stat.size > 0) { + const tail = Buffer.alloc(1); + if (fs.readSync(fd, tail, 0, 1, stat.size - 1) !== 1 || tail[0] !== 10) fail("ERR_AUDIT_WRITE_FAILED"); + } + const bytes = Buffer.from(JSON.stringify({ schema_version: 1, at: new Date().toISOString(), store_id: this.#storeId, ...record }) + "\n"); + if (fs.writeSync(fd, bytes) !== bytes.length) fail("ERR_AUDIT_WRITE_FAILED"); + fs.fsyncSync(fd); + // close may release its descriptor before throwing. Relinquish it + // before the attempt so cleanup cannot close a reused numeric fd. + const fileToClose = fd; fd = undefined; + fs.closeSync(fileToClose); + // Persist creation as well as contents. If the platform cannot provide + // this evidence, fail closed rather than claim a durable observation. + dir = fs.openSync(this.#anchorDir, "r"); + fs.fsyncSync(dir); + const dirToClose = dir; dir = undefined; + fs.closeSync(dirToClose); + } catch (error) { failure = error; } + finally { + for (const descriptor of [fd, dir]) { + if (descriptor === undefined) continue; + try { fs.closeSync(descriptor); } + catch (error) { + if (!failure) failure = error; + else if (!cleanupError) cleanupError = error; + } + } + } + if (failure) { + const error = new ProtectedStoreError("ERR_AUDIT_WRITE_FAILED"); + error.cause = failure; + if (cleanupError) error.cleanupError = cleanupError; + throw error; + } + } + // The anchor records the high-water mark outside the database rollback // domain. A database whose recorded sequence is behind the anchor was // replaced by an older snapshot, so the service must refuse to start. @@ -409,12 +1157,24 @@ class ProtectedPermitStore { if (!committed || committed.body_digest !== bodyDigest) fail("ERR_STATE_TAMPERED"); } - #transaction(change) { - return coordination.withLock(this.#anchorDir, () => this.#transactionUnlocked(change), + #appendAnchorEntry(entry) { + if (this.#authority) return anchor.appendEntry(this.#anchorFile, entry, this.#authorityDurability); + return anchor.appendEntry(this.#anchorFile, entry); + } + + #appendAnchorHighWater(entry) { + if (this.#authority) return anchor.appendHighWater(this.#anchorDir, entry, this.#authorityDurability); + return anchor.appendHighWater(this.#anchorDir, entry); + } + + #transaction(change, authorityAccess = false) { + if (this.#admissionActive && !authorityAccess) fail("ERR_WITNESS_ADMISSION_ACTIVE"); + if (this.#historyActive) fail("ERR_WITNESS_HISTORY_ACTIVE"); + return coordination.withLock(this.#anchorDir, () => this.#transactionUnlocked(change, authorityAccess), { expectedIdentity: this.#lockIdentity }); } - #transactionUnlocked(change) { + #transactionUnlocked(change, authorityAccess, observedNow) { if (this.#closed) fail("ERR_STORE_CLOSED"); if (this.#poisoned) fail("ERR_COMMIT_OUTCOME_UNKNOWN"); this.#db.exec("BEGIN IMMEDIATE"); @@ -425,6 +1185,8 @@ class ProtectedPermitStore { try { const raw = this.#readRaw(); const state = raw.state; + // Every legacy writer (including lifecycle/recovery helpers) is fenced. + if (isAuthoritySchema(state.schema_version) && !authorityAccess) fail("ERR_WITNESS_LEGACY_ENTRY_DISABLED"); // An already-open store must re-check the journal before it advances // it. Otherwise a deleted or rolled-back journal would let the next // transaction silently lower the high-water mark. @@ -433,10 +1195,12 @@ class ProtectedPermitStore { // for every entry that is appended. let journal = null; if (this.#anchorFile) journal = this.#assertJournalStillValid(raw.state, raw.body); - const now = this.#clock(); - if (!Number.isSafeInteger(now) || now < state.last_clock_ms) fail("ERR_CLOCK_ROLLBACK"); + // observedNow is private to the already-locked open rollback path; it is + // never a caller/request clock override or a substitute for authentication. + const now = observedNow === undefined ? this.#clock() : observedNow; + if (!Number.isSafeInteger(now) || (!authorityAccess && (now < 0 || now < state.last_clock_ms))) fail("ERR_CLOCK_ROLLBACK"); const result = change(state, now); - state.last_clock_ms = now; + state.last_clock_ms = Math.max(now, state.last_clock_ms); state.anchor_sequence += 1; assertState(state); const body = JSON.stringify(state); @@ -447,7 +1211,7 @@ class ProtectedPermitStore { // Intent first: it records the digest the database is about to have, // so a crash between the database commit and the journal commit entry // is provable and not confusable with a rollback. - anchor.appendEntry(this.#anchorFile, { type: "intent", sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); + this.#appendAnchorEntry({ type: "intent", sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); } // The audit record is written before the database commits. Writing it // afterwards would leave a committed state without any trace of how it @@ -457,8 +1221,8 @@ class ProtectedPermitStore { commitAttempted = true; this.#db.exec("COMMIT"); if (this.#anchorFile) { - anchor.appendEntry(this.#anchorFile, { type: "commit", sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); - anchor.appendHighWater(this.#anchorDir, { sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); + this.#appendAnchorEntry({ type: "commit", sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); + this.#appendAnchorHighWater({ sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); } return result; } catch (error) { @@ -468,7 +1232,7 @@ class ProtectedPermitStore { // Recording the abort keeps the journal explicit about the sequence // never having been committed, and keeps the sequence number reusable. if (!commitAttempted && this.#anchorFile && sequence !== null) { - try { anchor.appendEntry(this.#anchorFile, { type: "abort", sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); } + try { this.#appendAnchorEntry({ type: "abort", sequence, body_digest: bodyDigest, store_id: this.#storeId, attempt }); } catch { this.#poisoned = true; } } throw error; @@ -485,6 +1249,66 @@ class ProtectedPermitStore { store_id: this.#storeId, ...record, }; + if (this.#authority) { + // Only the explicitly provisioned development authority uses this + // stronger barrier. Legacy writers retain their existing audit contract. + let fd, dir, failure, cleanupError; + try { + // No verified native Windows directory persistence backend exists here. + // Never turn a missing barrier into a successful authority transaction. + if (process.platform === "win32" || !Number.isInteger(fs.constants.O_NOFOLLOW) || + !Number.isInteger(fs.constants.O_DIRECTORY)) { + fail("ERR_AUDIT_DURABILITY_UNSUPPORTED"); + } + dir = fs.openSync(this.#anchorDir, + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); + if (!fs.fstatSync(dir).isDirectory()) fail("ERR_AUDIT_WRITE_FAILED"); + fd = fs.openSync(path.join(this.#anchorDir, "protected-anchor-audit.log"), + fs.constants.O_RDWR | fs.constants.O_APPEND | fs.constants.O_CREAT | + fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, 0o600); + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || + (typeof process.getuid === "function" && stat.uid !== process.getuid())) fail("ERR_AUDIT_WRITE_FAILED"); + if (stat.size > 0) { + const tail = Buffer.alloc(1); + if (fs.readSync(fd, tail, 0, 1, stat.size - 1) !== 1 || tail[0] !== 10) fail("ERR_AUDIT_WRITE_FAILED"); + } + const bytes = Buffer.from(JSON.stringify(entry) + "\n"); + for (let offset = 0; offset < bytes.length;) { + const written = fs.writeSync(fd, bytes, offset, bytes.length - offset); + if (!Number.isSafeInteger(written) || written <= 0 || written > bytes.length - offset) fail("ERR_AUDIT_WRITE_FAILED"); + offset += written; + } + fs.fsyncSync(fd); + // A failed close may already have released its fd: never retry that fd. + const fileToClose = fd; fd = undefined; + fs.closeSync(fileToClose); + // Also sync existing files' directory: no prior creation barrier is assumed. + fs.fsyncSync(dir); + const dirToClose = dir; dir = undefined; + fs.closeSync(dirToClose); + } catch (error) { failure = error; } + finally { + for (const descriptor of [fd, dir]) { + if (descriptor === undefined) continue; + try { fs.closeSync(descriptor); } + catch (error) { + if (!failure) failure = error; + else if (!cleanupError) cleanupError = error; + } + } + } + if (failure) { + // The DB has not attempted COMMIT, but append/sync/cleanup may be + // uncertain. Refuse reuse of this handle; this is not durable poison. + this.#poisoned = true; + const error = new ProtectedStoreError("ERR_AUDIT_WRITE_FAILED"); + error.cause = failure; + if (cleanupError) error.cleanupError = cleanupError; + throw error; + } + return; + } try { fs.appendFileSync(path.join(this.#anchorDir, "protected-anchor-audit.log"), JSON.stringify(entry) + "\n", { mode: 0o600 }); @@ -529,13 +1353,466 @@ class ProtectedPermitStore { const declared = anchor.readHighWater(this.#anchorDir); if (state.anchor_sequence < declared.mark) fail("ERR_STATE_ROLLED_BACK"); if (state.anchor_sequence === declared.mark && declared.digest !== digest(rawBody)) fail("ERR_STATE_TAMPERED"); + if (isAuthoritySchema(state.schema_version) && declared.mark !== state.anchor_sequence) fail("ERR_CRASH_WINDOW_RECOVERABLE"); return journal; } + // Host-only development composition. The callbacks are trusted deployment + // dependencies, NOT request-supplied facts, certificate verification or an RPC + // authentication implementation. The unsigned result flag is advisory only; + // cross-trust-domain signature isolation remains a production blocker above. + #authorityTransaction(peer, operation, input, fields, change) { + if (!this.#authority) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + // Do not let old development issue/register/consume bypass admission. There + // is deliberately no signer continuation until the independent durable + // call gate, private one-shot owner and final business transaction exist. + if (this.#authority.admissionMode) fail("ERR_WITNESS_ADMISSION_ONLY"); + const request = freezeData(dataRequest(input, fields)); + const result = this.#transaction((state, now) => { + if (state.schema_version !== AUTHORITY_SCHEMA_VERSION) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + const authority = state.witness_authority; + if (this.#authority.authorize(peer, operation, request, freezeData(structuredClone(state))) !== true) { + fail("ERR_WITNESS_NOT_AUTHORIZED"); + } + if (now < state.last_clock_ms) { + poisonAuthorityState(state); + } + if (authority.clock_poisoned) return { denied: "ERR_WITNESS_CLOCK" }; + return change(state, now, request); + }, true); + // Business refusals that advance safety state must be thrown AFTER commit. + if (result.denied) fail(result.denied); + return { ...structuredClone(result), executionAuthorized: false }; + } + + // Development admission/lookup only: no observation, signature, business + // checkpoint, execution claim, result publication or recovery capability. + // authorize is a trusted deployment dependency, not a caller callback. It + // must derive stable subject/issuer/tenant from its private authenticated + // context and independently grant the COMPLETE envelope (including aliases). + admitAuthorityRequest(peer, input) { + return this.#admissionTransaction(peer, input, false); + } + + lookupAuthorityRequest(peer, input) { + return this.#admissionTransaction(peer, input, true); + } + + #admissionTransaction(peer, input, lookup) { + if (!this.#authority || this.#authority.admissionMode !== "development-only") fail("ERR_WITNESS_AUTHORITY_DISABLED"); + if (this.#admissionActive) fail("ERR_WITNESS_ADMISSION_ACTIVE"); + const request = admissionRequest(input); + const bytes = witness.canonicalBytes(request).toString("utf8"); + const requestDigest = digest(bytes); + this.#admissionActive = true; + try { + const result = this.#transaction((state, now) => { + if (!isAdmissionSchema(state.schema_version)) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + // Both checks see the same immutable DB snapshot; keep one detached + // copy rather than retaining two full histories during authorization. + const authorizationState = freezeData(structuredClone(state)); + const authorize = () => admissionGrant(this.#authority.authorize(peer, + lookup ? "request.lookup" : "request.admit", request, authorizationState), request); + const authorization = authorize(); + // Require stable authenticated facts throughout the local transaction; + // no identity is obtained from peer, request or boolean legacy grants. + if (!witness.canonicalBytes(authorization).equals(witness.canonicalBytes(authorize()))) fail("ERR_WITNESS_NOT_AUTHORIZED"); + if (now < state.last_clock_ms) poisonAuthorityState(state); + if (state.witness_authority.clock_poisoned) return { denied: "ERR_WITNESS_CLOCK" }; + const a = state.witness_authority.request_admission; + const keys = admissionKeys(state, authorization.principal, request); + const id = own(a.associations, keys.association); + if (id) { + const original = a.attempts[id]; + if (original.request_bytes !== bytes) return { denied: "ERR_WITNESS_REQUEST_CONFLICT" }; + return { attemptId: id, status: original.status, requestDigest: original.request_digest }; + } + if (lookup) return { status: "not-found", requestDigest }; + // Every unresolved admission holds both reservations indefinitely. + // No TTL, new subject/key/epoch or missing result can release them. + if (own(a.business_keys, keys.business) || own(a.resources, keys.resource)) { + return { denied: "ERR_WITNESS_REQUEST_BLOCKED" }; + } + if (a.sequence >= ADMISSION_MAX_ATTEMPTS) fail("ERR_WITNESS_ADMISSION_CAPACITY"); + // A rolled-back sequence must not reuse an internally allocated ID on + // retry. IDs are opaque, independently random, and collision-checked. + const attemptId = `admission:${state.store_id}:${crypto.randomBytes(16).toString("hex")}`; + if (Object.hasOwn(a.attempts, attemptId)) fail("ERR_STATE_INVALID"); + const record = { attempt_id: attemptId, sequence: a.sequence + 1, encoding_version: 1, + request_bytes: bytes, request_digest: requestDigest, principal: authorization.principal, + grant: authorization.grant, service_epoch: state.service_epoch, admitted_at_ms: now, status: "admitted" }; + // Preflight exact stored cost before changing history or allocator state. + // Existing retries/lookups/conflicts above remain available at capacity. + admissionCapacity({ ...a, sequence: a.sequence + 1, + attempts: { ...a.attempts, [attemptId]: record }, + associations: { ...a.associations, [keys.association]: attemptId }, + business_keys: { ...a.business_keys, [keys.business]: attemptId }, + resources: { ...a.resources, [keys.resource]: attemptId } }, "ERR_WITNESS_ADMISSION_CAPACITY"); + a.sequence++; + a.attempts[attemptId] = record; + a.associations[keys.association] = attemptId; + a.business_keys[keys.business] = attemptId; + a.resources[keys.resource] = attemptId; + return { attemptId, status: "admitted", requestDigest }; + }, true); + if (result.denied) fail(result.denied); + return { ...result, executionAuthorized: false, resumeAllowed: false, originalRequestOutcome: "unknown" }; + } finally { this.#admissionActive = false; } + } + + // Explicit schema-5 development slice. The ticket/capability are opaque + // object identities owned by THIS handle; snapshots and wire bytes cannot + // reconstitute them. No method invokes signPermit or produces a signature. + prepareAuthoritySigning(peer, input) { + return this.#signingTransaction(peer, input, "sign.prepare", (state, now, request, original, authorization) => { + const gate = state.witness_authority.signing_gate; + // The current authority has ONE lease/fence domain, irrespective of + // caller resource labels. Every prepared/call-possible/unknown record is + // unresolved, including this attempt's duplicate prepare. Never mint a + // replacement ticket/capability; no release/recovery writer exists. Refuse + // before changing numbering/fence or invoking the preparation provider. + if (Object.keys(gate.preparations).length) throw new SigningReservationConflict(); + const snapshot = freezeData(structuredClone(state)); + const facts = signingFacts(this.#authority.prepareSigning(request, snapshot)); + const signingInput = signingRequest(request, state, facts); + if (signingInput.lease.owner !== authorization.principal.subjectId) fail("ERR_WITNESS_SIGNING_NOT_AUTHORIZED"); + const sequence = gate.sequence + 1; + const permit = signingPermit(state, request, facts, sequence, now); + const prepared = require("./permit-delivery").preparePermitSigning({ permit, issuedAtMs: now, nowMs: now }); + const record = { attempt_id: original.attempt_id, prepare_version: 1, sequence, prepared_at_ms: now, + request_digest: original.request_digest, facts, fingerprint: signingInput.batchFingerprint, permit, + signing_bytes: prepared.bytes, signing_digest: digest(Buffer.from(prepared.bytes, "base64")), status: "sign-prepared", call: null }; + // Reserve future gate/unknown growth now: reaching capacity after prepare + // cannot become permission to drop reservation metadata. + const reserved = Object.fromEntries(Object.entries({ ...gate.preparations, [original.attempt_id]: record }).map(([id, item]) => + [id, { ...item, status: "sign-outcome-unknown", call: { owner: "0".repeat(32), service_epoch: state.service_epoch, + input_digest: item.signing_digest, anchor_sequence: Number.MAX_SAFE_INTEGER, at_ms: Number.MAX_SAFE_INTEGER } }])); + signingCapacity({ ...gate, sequence, preparations: reserved }, "ERR_WITNESS_SIGNING_CAPACITY"); + gate.sequence = sequence; gate.preparations[original.attempt_id] = record; + state.lease_seq = state.permit_seq = state.fencing = sequence; + return { attemptId: original.attempt_id, signingDigest: record.signing_digest }; + }, (result) => { + const ticket = Object.freeze(Object.create(null)); + this.#prepareTickets.set(ticket, { attemptId: result.attemptId, signingDigest: result.signingDigest }); + return { ...result, ticket, executionAuthorized: false, resumeAllowed: false }; + }); + } + + claimAuthoritySigningCall(peer, input, ticket) { + this.#signingPreflight(); + const owned = this.#prepareTickets.get(ticket); + if (!owned) fail("ERR_WITNESS_SIGNING_CAPABILITY"); + // Burn before validation/transaction: even a precommit failure cannot use + // this process's ticket again. Restart never reconstructs a prepare ticket. + this.#prepareTickets.delete(ticket); + return this.#signingTransaction(peer, input, "sign.call-gate", (state, now, request, original) => { + const record = own(state.witness_authority.signing_gate.preparations, original.attempt_id); + if (owned.attemptId !== original.attempt_id || !record || record.status !== "sign-prepared" || + record.permit.serviceEpoch !== state.service_epoch || record.permit.fencing !== state.fencing || + owned.signingDigest !== record.signing_digest) return { denied: "ERR_WITNESS_SIGNING_UNKNOWN" }; + const facts = signingFacts(this.#authority.prepareSigning(request, freezeData(structuredClone(state)))); + if (!witness.canonicalBytes(facts).equals(witness.canonicalBytes(record.facts))) return { denied: "ERR_WITNESS_SIGNING_NOT_AUTHORIZED" }; + if (now >= record.permit.deadline) return { denied: "ERR_WITNESS_SIGNING_EXPIRED" }; + record.status = "sign-call-possible"; + record.call = { owner: this.#signingOwner, service_epoch: state.service_epoch, input_digest: record.signing_digest, + anchor_sequence: state.anchor_sequence + 1, at_ms: now }; + return { attemptId: original.attempt_id, signingDigest: record.signing_digest }; + }, (result) => { + // #transaction includes coordination COMMIT AND finally/close. Reaching + // here proves only those existing local barriers, not an R5 B certificate. + const capability = Object.freeze(Object.create(null)); + this.#callCapabilities.set(capability, { attemptId: result.attemptId, signingDigest: result.signingDigest }); + return { ...result, capability, executionAuthorized: false, resumeAllowed: false }; + }); + } + + abandonAuthoritySigningCall(peer, input, capability) { + this.#signingPreflight(); + const owned = this.#callCapabilities.get(capability); + if (!owned) fail("ERR_WITNESS_SIGNING_CAPABILITY"); + this.#callCapabilities.delete(capability); // One use, including failed use. + return this.#signingTransaction(peer, input, "sign.abandon", (state, now, request, original) => { + const record = own(state.witness_authority.signing_gate.preparations, original.attempt_id); + if (owned.attemptId !== original.attempt_id || !record || record.status !== "sign-call-possible" || + record.call.owner !== this.#signingOwner || record.signing_digest !== owned.signingDigest) { + return { denied: "ERR_WITNESS_SIGNING_UNKNOWN" }; + } + record.status = "sign-outcome-unknown"; + return { attemptId: original.attempt_id, status: record.status, executionAuthorized: false, resumeAllowed: false }; + }); + } + + #signingPreflight() { + // Private fields only: no caller data, clock, provider or external I/O before + // consuming a ticket/capability. A rejected reentry never burns authority. + if (this.#admissionActive) fail("ERR_WITNESS_ADMISSION_ACTIVE"); + if (this.#historyActive) fail("ERR_WITNESS_HISTORY_ACTIVE"); + if (this.#closed) fail("ERR_STORE_CLOSED"); + if (this.#signingBlocked) fail("ERR_WITNESS_SIGNING_UNKNOWN"); + if (this.#poisoned) fail("ERR_COMMIT_OUTCOME_UNKNOWN"); + if (!this.#authority?.signingGateMode) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + } + + #signingTransaction(peer, input, operation, change, after = (result) => result) { + this.#signingPreflight(); + this.#admissionActive = true; + let conflictCompleted = false; + try { + const request = admissionRequest(input); + const result = this.#transaction((state, now) => { + if (state.schema_version !== SIGNING_GATE_SCHEMA_VERSION) fail("ERR_WITNESS_AUTHORITY_DISABLED"); + const snapshot = freezeData(structuredClone(state)); + const authorize = () => admissionGrant(this.#authority.authorize(peer, operation, request, snapshot), request); + const authorization = authorize(); + if (!witness.canonicalBytes(authorization).equals(witness.canonicalBytes(authorize()))) fail("ERR_WITNESS_SIGNING_NOT_AUTHORIZED"); + const key = admissionKeys(state, authorization.principal, request).association; + const original = own(state.witness_authority.request_admission.attempts, + own(state.witness_authority.request_admission.associations, key)); + if (!original || original.request_bytes !== witness.canonicalBytes(request).toString("utf8") || + !witness.canonicalBytes(original.grant).equals(witness.canonicalBytes(authorization.grant))) fail("ERR_WITNESS_SIGNING_NOT_AUTHORIZED"); + if (now < state.last_clock_ms) poisonAuthorityState(state); + if (state.witness_authority.clock_poisoned) return { denied: "ERR_WITNESS_CLOCK" }; + let changed, conflict; + try { changed = change(state, now, request, original, authorization); } + catch (error) { + if (!(error instanceof SigningReservationConflict)) throw error; + conflict = error; + } + // Business conflicts still commit authorized clock safety increments. + // Recheck current host authorization after the trusted local provider. + if (!witness.canonicalBytes(authorization).equals(witness.canonicalBytes(authorize()))) fail("ERR_WITNESS_SIGNING_NOT_AUTHORIZED"); + const observed = this.#clock(); + if (!Number.isSafeInteger(observed) || observed < 0) { + // An unusable review sample cannot erase the valid first observation. + state.last_clock_ms = Math.max(state.last_clock_ms, now); + poisonAuthorityState(state); return { denied: "ERR_CLOCK_INVALID" }; + } + const previousClock = state.last_clock_ms; + state.last_clock_ms = Math.max(previousClock, now, observed); + if (observed < now || observed < previousClock) { + poisonAuthorityState(state); return { denied: "ERR_WITNESS_CLOCK" }; + } + const prepared = own(state.witness_authority.signing_gate.preparations, original.attempt_id); + if (operation !== "sign.abandon" && prepared && observed >= prepared.permit.deadline) { + return { denied: "ERR_WITNESS_SIGNING_EXPIRED" }; + } + if (conflict) return SIGNING_CONFLICT_RESULT; // commit safety state, no new authority + return changed; + }, true); + if (result === SIGNING_CONFLICT_RESULT) { + // #transaction returned through DB/audit/anchor AND lock commit/close. + // Only this completed path may refuse without burning existing tickets. + conflictCompleted = true; + fail("ERR_WITNESS_SIGNING_CONFLICT"); + } + if (result.denied) fail(result.denied); + return after(result); // only after business transaction AND coordination release + } catch (error) { + if (conflictCompleted) throw error; + // Failure/uncertainty before complete local publication is handle-local + // unknown. No cross-handle persistent poison is implied by this latch. + // No automatic retry, including failures before COMMIT. Persistent + // reservations remain, and fresh handles cannot recover opaque tickets. + this.#signingBlocked = true; + this.#prepareTickets = new WeakMap(); this.#callCapabilities = new WeakMap(); + throw error; + } finally { this.#admissionActive = false; } + } + + #witnessTarget(state, targetId, checkpoint) { + requireMapKey(targetId); + const target = own(state.witness_authority.targets, targetId); + if (!target) fail("ERR_WITNESS_TARGET_NOT_REGISTERED"); + witness.assertCheckpointMatches(target.current, checkpoint); + if (target.current.state.clock_poisoned) fail("ERR_WITNESS_CLOCK"); + return target; + } + + registerWitnessTarget(peer, input) { + return this.#authorityTransaction(peer, "register", input, ["targetId", "requestId", "checkpoint", "pin"], (state, now, request) => { + const { targetId, requestId } = request; + requireMapKey(targetId); + requireMapKey(requestId); + const pin = { ...dataRequest(request.pin, ["decision_id", "instance_id", "fingerprint", "public_key_pem", + "source_digest", "deployment_digest", "run_id", "generation", "valid_from", "valid_until"]), registered_at_ms: now }; + if (pin.decision_id !== state.decision_id || pin.instance_id !== targetId) fail("ERR_BINDING_MISMATCH"); + const authority = state.witness_authority; + const existing = own(authority.targets, targetId); + const requestDigest = digest(witness.canonicalBytes(request)); + const receipt = existing && own(existing.registrations, requestId); + if (receipt) { + if (receipt.request_digest !== requestDigest) fail("ERR_IDEMPOTENCY_MISMATCH"); + return receipt.result; + } + let current; + if (existing) current = this.#witnessTarget(state, targetId, request.checkpoint).current; + else { + if (request.checkpoint !== null) fail("ERR_WITNESS_CHECKPOINT_CONFLICT"); + current = witness.createCheckpoint({ authority_id: authority.authority_id, target_id: targetId, + store_generation: crypto.randomBytes(32).toString("hex"), revision: 1 }, { + schema_version: 3, pins: [], revoked_fingerprints: [], applied_revocation_sequence: 0, + highest_seen_revocation_sequence: 0, highest_accepted_permit_sequence: 0, + last_observed_clock_ms: now, clock_poisoned: false, + }); + } + // Validate even reconnect metadata, but never rewrite the original pin. + witness.normalizeTargetState({ ...current.state, pins: [pin] }); + if (crypto.createPublicKey(pin.public_key_pem).asymmetricKeyType !== "ed25519") fail("ERR_INVALID_INPUT"); + const oldPin = current.state.pins.find((entry) => entry.fingerprint === pin.fingerprint); + if (oldPin && !matches(oldPin, pin, ["decision_id", "instance_id", "fingerprint"])) fail("ERR_BINDING_MISMATCH"); + if (!oldPin) { + const observation = this.#authority.observeTarget(targetId, request, freezeData(structuredClone(current))); + if (!observation || observation.targetHasProcess !== false || observation.targetHasPriorRun !== false) { + fail("ERR_WITNESS_TARGET_NOT_FRESH"); + } + current = witness.deriveTransition(current, { operation: "register_pin", checkpoint: current.checkpoint }, { pin }); + } + const target = existing || { registrations: {} }; + target.current = current; + const result = { registered: !oldPin, reused: Boolean(oldPin), checkpoint: current.checkpoint }; + target.registrations[requestId] = { request_digest: requestDigest, result: structuredClone(result) }; + authority.targets[targetId] = target; + return result; + }); + } + + issueWitnessPermit(peer, input) { + return this.#authorityTransaction(peer, "issue", input, ["targetId", "checkpoint", "lease", "permit", "batchFingerprint"], (state, now, request) => { + const target = this.#witnessTarget(state, request.targetId, request.checkpoint); + const lease = dataRequest(request.lease, ["owner", "sessionId", "hostId", "workerId", "runId", "generation", + "desktopKey", "ownedAppId", "idempotencyKey", "ttlMs"]); + for (const key of Object.keys(lease).filter((name) => name !== "ttlMs")) requireText(lease[key]); + requireMapKey(lease.idempotencyKey); + const leaseTtl = requirePositiveMs(lease.ttlMs); + if (!Number.isSafeInteger(now + leaseTtl)) fail("ERR_INVALID_INPUT"); + const permit = dataRequest(request.permit, ["idempotencyKey", "operation", "targetDigest", "paramsDigest", "ttlMs"]); + requireMapKey(permit.idempotencyKey); + const validated = validateReservationInput(permit); + if (!Number.isSafeInteger(now + validated.ttlMs)) fail("ERR_INVALID_INPUT"); + const fingerprint = request.batchFingerprint; + if (!target.current.state.pins.some((pin) => pin.fingerprint === fingerprint) || + target.current.state.revoked_fingerprints.includes(fingerprint)) fail("ERR_WITNESS_SIGNER"); + const requestDigest = digest(witness.canonicalBytes({ targetId: request.targetId, lease, permit, fingerprint })); + const bindings = state.witness_authority.issuance_bindings; + const oldId = own(state.idempotency, permit.idempotencyKey); + if (oldId) { + const binding = own(bindings, oldId); + if (!binding || binding.target_id !== request.targetId || + binding.target_generation !== target.current.checkpoint.store_generation || + binding.request_digest !== requestDigest) fail("ERR_IDEMPOTENCY_MISMATCH"); + } + // Signing can observe rollback and then return malformed data or throw. + // Keep all candidate lease/permit/signature writes off the committed state + // until the post-sign clock has been checked, including those error paths. + const candidate = structuredClone(state); + let issuance; + let signingError; + try { + issuance = this.#issueInState(candidate, now, lease, permit, fingerprint, + (reserved) => this.#authority.signPermit(fingerprint, freezeData(reserved), now), leaseTtl, validated); + } catch (error) { signingError = error; } + const signedAt = this.#clock(); + if (Number.isSafeInteger(signedAt) && signedAt < now) { + poisonAuthorityState(state); + return { denied: "ERR_WITNESS_CLOCK" }; + } + if (signingError) throw signingError; + if (!Number.isSafeInteger(signedAt) || signedAt < 0 || signedAt >= issuance.signedPermit.deadline || + issuance.signedPermit.issuedAt > signedAt) fail("ERR_CLOCK_INVALID"); + candidate.last_clock_ms = signedAt; + const id = issuance.signedPermit.message.permit_id; + const candidateBindings = candidate.witness_authority.issuance_bindings; + if (!own(candidateBindings, id)) candidateBindings[id] = { + authority_id: state.witness_authority.authority_id, issuer_store_id: state.store_id, + target_id: request.targetId, target_generation: target.current.checkpoint.store_generation, + permit_id: id, fingerprint, message_bytes: issuance.signedPermit.bytes, + signature: issuance.signedPermit.signature, request_digest: requestDigest, status: "issued", + }; + Object.assign(state, candidate); + return issuance; + }); + } + + consumeWithCheckpoint(peer, input) { + return this.#authorityTransaction(peer, "consume", input, ["targetId", "checkpoint", "message", "signature"], (state, now, request) => { + const target = this.#witnessTarget(state, request.targetId, request.checkpoint); + const current = target.current; + const delivery = require("./permit-delivery"); + const message = dataRequest(request.message, [...delivery.BINDING_FIELD_MAP.map(([, name]) => name), + "issued_at_ms", "deadline_ms", "permit_id"]); + const permit = own(state.permits, message.permit_id); + const binding = own(state.witness_authority.issuance_bindings, message.permit_id); + if (!permit || !binding || binding.target_id !== request.targetId || + binding.target_generation !== current.checkpoint.store_generation) fail("ERR_WITNESS_ISSUANCE_MISMATCH"); + if (binding.status !== "issued") fail("ERR_PERMIT_NOT_CONSUMABLE"); + // The next fresh signed list is supplied by a trusted host provider, never + // by client facts. Authentication/freshness of that provider remains a host + // integration requirement; this development core verifies its signature too. + const list = dataRequest(this.#authority.pullRevocation(request.targetId, freezeData(structuredClone(current)), now), + ["revocation_sequence", "prev_revocation_sequence", "revoked_fingerprints", "issued_at_ms", "batch_public_key_fingerprint", "signature"]); + const signer = current.state.pins.find((pin) => pin.fingerprint === list.batch_public_key_fingerprint); + if (!signer || !Number.isSafeInteger(list.issued_at_ms) || list.issued_at_ms < 0 || list.issued_at_ms > now) fail("ERR_WITNESS_SIGNER"); + const revocation = { appliedRevocationSequence: current.state.applied_revocation_sequence, + highestSeenRevocationSequence: current.state.highest_seen_revocation_sequence, + revokedFingerprints: new Set(current.state.revoked_fingerprints) }; + delivery.applyRevocationList({ state: revocation, list, publicKeyPem: signer.public_key_pem, + pinnedFingerprints: new Set(current.state.pins.map((pin) => pin.fingerprint)), nowMs: now }); + const facts = { sequence: revocation.appliedRevocationSequence, + revoked_fingerprints: [...revocation.revokedFingerprints].sort() }; + // A trusted provider can still take time. Never use its pre-pull time to + // accept an already expired lease/permit, or miss a mid-call clock rollback. + const checkedAt = this.#clock(); + if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) fail("ERR_CLOCK_INVALID"); + if (checkedAt < now) { + state.witness_authority.clock_poisoned = true; + const revoked = witness.deriveTransition(current, + { operation: "apply_revocation", checkpoint: current.checkpoint }, facts); + target.current = witness.createCheckpoint({ ...checkpointBinding(revoked.checkpoint), + revision: revoked.checkpoint.revision + 1 }, { ...revoked.state, clock_poisoned: true }); + return { denied: "ERR_WITNESS_CLOCK" }; + } + now = checkedAt; + state.last_clock_ms = Math.max(now, state.last_clock_ms); + try { + if (delivery.canonicalBytes(message).toString("base64") !== binding.message_bytes || request.signature !== binding.signature) { + fail("ERR_WITNESS_ISSUANCE_MISMATCH"); + } + const pin = current.state.pins.find((entry) => entry.fingerprint === binding.fingerprint); + if (!pin) fail("ERR_WITNESS_SIGNER"); + delivery.verifyPermitSignature({ message, signature: request.signature, publicKeyPem: pin.public_key_pem, + expectedFingerprint: binding.fingerprint, revokedFingerprints: revocation.revokedFingerprints, + expectedDeadlineMs: permit.deadline, nowMs: now }); + const next = witness.deriveTransition(current, { operation: "accept_permit", checkpoint: current.checkpoint }, + { now_ms: now, permit_sequence: permit.sequence, fingerprint: binding.fingerprint, revocation: facts }); + const fields = { permitId: message.permit_id, + ...Object.fromEntries(delivery.BINDING_FIELD_MAP.map(([name, signed]) => [name, message[signed]])) }; + const consumed = this.#consumeInState(state, now, fields); + if (consumed.status !== "consumed") fail("ERR_PERMIT_NOT_CONSUMABLE"); + target.current = next; + binding.status = "consumed"; + binding.consumption_record = { permit_id: permit.permitId, at_ms: now, checkpoint: next.checkpoint }; + return { status: "consumed", permitId: permit.permitId, checkpoint: next.checkpoint }; + } catch (error) { + if (!(error instanceof ProtectedStoreError || error instanceof witness.PermitWitnessContractError || + error instanceof require("./errors").CoordinationError)) throw error; + if (permit.status === "expired") binding.status = "invalidated"; + target.current = witness.deriveTransition(current, { operation: "apply_revocation", checkpoint: current.checkpoint }, facts); + if (now !== target.current.state.last_observed_clock_ms) { + target.current = witness.deriveTransition(target.current, + { operation: "observe_clock", checkpoint: target.current.checkpoint }, { now_ms: now }); + } + return { denied: error.key || error.code }; + } + }); + } + acquire(fields) { for (const key of ["owner", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId", "idempotencyKey"]) requireText(fields[key]); const ttlMs = requirePositiveMs(fields.ttlMs); - return this.#transaction((state, now) => { + return this.#transaction((state, now) => this.#acquireInState(state, now, fields, ttlMs)); + } + + #acquireInState(state, now, fields, ttlMs) { const old = state.lease_idempotency[fields.idempotencyKey]; if (old) { const names = ["owner", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId", "ttlMs"]; @@ -557,7 +1834,76 @@ class ProtectedPermitStore { state.lease_idempotency[fields.idempotencyKey] = structuredClone(lease); state.desktop = { status: "held", key: fields.desktopKey }; return structuredClone(lease); - }); + } + + acquireAndReserveBoundToBatch(leaseFields, permitFields, batchFingerprint, finalize) { + if (!leaseFields || typeof leaseFields !== "object" || Array.isArray(leaseFields)) fail("ERR_INVALID_INPUT"); + for (const key of ["owner", "sessionId", "hostId", "workerId", "runId", "generation", "desktopKey", "ownedAppId", "idempotencyKey"]) requireText(leaseFields[key]); + const leaseTtl = requirePositiveMs(leaseFields.ttlMs); + const validated = validateReservationInput(permitFields); + if (typeof batchFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(batchFingerprint)) fail("ERR_INVALID_INPUT"); + if (typeof finalize !== "function") fail("ERR_INVALID_INPUT"); + return this.#transaction((state, now) => + this.#issueInState(state, now, leaseFields, permitFields, batchFingerprint, finalize, leaseTtl, validated)); + } + + #issueInState(state, now, leaseFields, permitFields, batchFingerprint, finalize, leaseTtl, validated) { + const lease = this.#acquireInState(state, now, leaseFields, leaseTtl); + const fields = { ...permitFields, leaseId: lease.leaseId, sessionId: lease.sessionId, + hostId: lease.hostId, workerId: lease.workerId, runId: lease.runId, generation: lease.generation, + desktopKey: lease.desktopKey, ownedAppId: lease.ownedAppId, serviceEpoch: lease.serviceEpoch, + fencing: lease.fencing }; + // A historical idempotency result is not itself an active authority. + activeLease(state, fields, now); + const previouslyReserved = Object.prototype.hasOwnProperty.call(state.idempotency, fields.idempotencyKey); + const reserved = this.#reserveInState(state, now, fields, batchFingerprint, validated); + if (reserved.status !== "reserved" || reserved.deadline <= now) fail("ERR_LEASE_NOT_CURRENT"); + const storedPermit = state.permits[reserved.permitId]; + if (previouslyReserved) { + // An older reservation without the original signed bytes cannot be + // safely reissued: a fresh timestamp would create a second message. + if (!storedPermit.signedIssuance) fail("ERR_IDEMPOTENCY_MISMATCH"); + return structuredClone(storedPermit.signedIssuance); + } + // The trusted signer executes before commit. An exception rolls back the + // lease and permit together; no key material is stored in the database. + const candidate = finalize(structuredClone(reserved)); + if (candidate && typeof candidate.then === "function") fail("ERR_INVALID_INPUT"); + let result; + try { result = structuredClone(candidate); } catch { fail("ERR_INVALID_INPUT"); } + // Persist only the known public wire fields. Validate the actual key, + // signature and every binding before committing the issuance record. + const delivery = require("./permit-delivery"); + if (!result || result.fingerprint !== batchFingerprint || + typeof result.publicKeyPem !== "string" || + !/^-----BEGIN PUBLIC KEY-----\r?\n/.test(result.publicKeyPem) || + !result.signedPermit || typeof result.signedPermit !== "object") fail("ERR_INVALID_INPUT"); + const signed = result.signedPermit; + const message = signed.message; + let canonicalPublicKey; + try { canonicalPublicKey = crypto.createPublicKey(result.publicKeyPem).export({ type: "spki", format: "pem" }); } + catch { fail("ERR_INVALID_INPUT"); } + if (result.publicKeyPem !== canonicalPublicKey || typeof signed.signature !== "string" || + Buffer.from(signed.signature, "base64").length !== 64 || + Buffer.from(signed.signature, "base64").toString("base64") !== signed.signature || + !message || typeof message !== "object" || Array.isArray(message) || + !Number.isSafeInteger(message.issued_at_ms) || message.issued_at_ms < now || + message.issued_at_ms >= reserved.deadline) fail("ERR_INVALID_INPUT"); + delivery.verifyPermitSignature({ message, signature: signed.signature, publicKeyPem: result.publicKeyPem, + expectedFingerprint: batchFingerprint, revokedFingerprints: new Set(), + expectedDeadlineMs: reserved.deadline, nowMs: message.issued_at_ms }); + if (message.permit_id !== reserved.permitId || + delivery.BINDING_FIELD_MAP.some(([field, name]) => message[name] !== reserved[field]) || + Object.keys(message).length !== delivery.BINDING_FIELD_MAP.length + 3 || + signed.sequence !== reserved.sequence || signed.deadline !== reserved.deadline || + signed.issuedAt !== message.issued_at_ms || signed.nowMs !== message.issued_at_ms || + signed.bytes !== delivery.canonicalBytes(message).toString("base64")) fail("ERR_INVALID_INPUT"); + const issuance = { fingerprint: batchFingerprint, publicKeyPem: result.publicKeyPem, + signedPermit: { message: structuredClone(message), signature: signed.signature, + sequence: signed.sequence, deadline: signed.deadline, issuedAt: signed.issuedAt, + bytes: signed.bytes, nowMs: signed.nowMs } }; + storedPermit.signedIssuance = issuance; + return structuredClone(issuance); } renew(fields) { @@ -571,11 +1917,22 @@ class ProtectedPermitStore { } reserve(fields) { - for (const key of ["idempotencyKey", "operation", "targetDigest", "paramsDigest"]) requireText(fields[key]); - const targetDigest = requireDigest(fields.targetDigest); - const paramsDigest = requireDigest(fields.paramsDigest); - const ttlMs = requirePositiveMs(fields.ttlMs); - return this.#transaction((state, now) => { + return this.#reserve(fields, null); + } + + reserveBoundToBatch(fields, batchFingerprint) { + if (typeof batchFingerprint !== "string" || !/^[a-f0-9]{64}$/.test(batchFingerprint)) { + fail("ERR_INVALID_INPUT"); + } + return this.#reserve(fields, batchFingerprint); + } + + #reserve(fields, batchFingerprint) { + const validated = validateReservationInput(fields); + return this.#transaction((state, now) => this.#reserveInState(state, now, fields, batchFingerprint, validated)); + } + + #reserveInState(state, now, fields, batchFingerprint, { targetDigest, paramsDigest, ttlMs }) { const binding = { leaseId: fields.leaseId, sessionId: fields.sessionId, hostId: fields.hostId, workerId: fields.workerId, runId: fields.runId, generation: fields.generation, @@ -587,6 +1944,8 @@ class ProtectedPermitStore { if (oldId) { const old = state.permits[oldId]; if (!old || !matches(old, binding, Object.keys(binding))) fail("ERR_IDEMPOTENCY_MISMATCH"); + if (batchFingerprint !== null && old.batchFingerprint !== batchFingerprint) fail("ERR_IDEMPOTENCY_MISMATCH"); + if (batchFingerprint !== null && old.requestedTtlMs !== ttlMs) fail("ERR_IDEMPOTENCY_MISMATCH"); return structuredClone(old); } const lease = activeLease(state, fields, now); @@ -614,14 +1973,22 @@ class ProtectedPermitStore { idempotencyKey: fields.idempotencyKey, sequence: state.permit_seq, deadline: Math.min(now + ttlMs, lease.expiresAt), }; + if (batchFingerprint !== null) { + permit.batchFingerprint = batchFingerprint; + permit.requestedTtlMs = ttlMs; + } state.permits[permit.permitId] = permit; state.idempotency[fields.idempotencyKey] = permit.permitId; return structuredClone(permit); - }); } consume(fields) { - const result = this.#transaction((state, now) => { + const result = this.#transaction((state, now) => this.#consumeInState(state, now, fields)); + if (result.status === "expired") fail("ERR_PERMIT_NOT_CONSUMABLE"); + return result; + } + + #consumeInState(state, now, fields) { const permit = state.permits[fields.permitId]; if (!permit) fail("ERR_PERMIT_NOT_FOUND"); if (!matches(permit, fields, CONSUME_BINDING_FIELDS)) fail("ERR_BINDING_MISMATCH"); @@ -645,7 +2012,7 @@ class ProtectedPermitStore { if (sameAction.some(isLivePermit)) fail("ERR_ACTION_IN_FLIGHT"); if (sameAction.some(isDispatchedPermit)) fail("ERR_ACTION_ALREADY_DISPATCHED"); if (Object.values(state.permits).some((other) => other.status === "consumed" && other.desktopKey === permit.desktopKey)) fail("ERR_ACTION_IN_FLIGHT"); - if (now > permit.deadline) { + if (now >= permit.deadline) { permit.status = "expired"; state.tombstones[permit.permitId] = { reason: "deadline", at: now }; return { status: "expired" }; @@ -654,9 +2021,6 @@ class ProtectedPermitStore { permit.status = "consumed"; permit.consumedAt = now; return structuredClone(permit); - }); - if (result.status === "expired") fail("ERR_PERMIT_NOT_CONSUMABLE"); - return result; } finish(fields) { @@ -732,6 +2096,9 @@ class ProtectedPermitStore { } close() { + if (this.#admissionActive) fail("ERR_WITNESS_ADMISSION_ACTIVE"); + if (this.#historyActive) fail("ERR_WITNESS_HISTORY_ACTIVE"); + this.#prepareTickets = new WeakMap(); this.#callCapabilities = new WeakMap(); if (!this.#closed) { this.#db.close(); this.#closed = true; } } @@ -745,6 +2112,7 @@ class ProtectedPermitStore { // The caller must supply an explicit audit record. The recovery is not // reachable from the normal start path; it is an administrator action. static recoverFromAnchorCrash(filePath, options = {}) { + options = nativePermissionOptions(options); return this.#withExistingLock(filePath, options, () => this.#recoverFromAnchorCrashUnlocked(filePath, options)); } @@ -762,11 +2130,12 @@ class ProtectedPermitStore { const db = new DatabaseSync(path.resolve(filePath), { timeout: 5000 }); try { db.exec("BEGIN IMMEDIATE"); - const row = db.prepare("SELECT body, sha256 FROM protected_state WHERE id = 1").get(); + const row = readProtectedRow(db, configuredSchema(authorityOptions(options)), false); if (!row || digest(row.body) !== row.sha256) fail("ERR_STATE_INVALID"); let state; try { state = JSON.parse(row.body); } catch { fail("ERR_STATE_INVALID"); } assertState(state); + if (isAuthoritySchema(state.schema_version)) fail("ERR_WITNESS_RECOVERY_UNSUPPORTED"); if (state.decision_id !== options.expectedDecisionId) fail("ERR_DECISION_MISMATCH"); // The permission mode of the anchor domain is a property of the // deployment. The caller may state it explicitly, and otherwise the @@ -841,6 +2210,7 @@ class ProtectedPermitStore { // carried over. The database must therefore be at least at that sequence, // and the caller has to name the database as the truth explicitly. static rebuildAnchor(filePath, options = {}) { + options = nativePermissionOptions(options); return this.#withExistingLock(filePath, options, () => this.#rebuildAnchorUnlocked(filePath, options)); } @@ -859,11 +2229,12 @@ class ProtectedPermitStore { const db = new DatabaseSync(path.resolve(filePath), { timeout: 5000 }); try { db.exec("BEGIN IMMEDIATE"); - const row = db.prepare("SELECT body, sha256 FROM protected_state WHERE id = 1").get(); + const row = readProtectedRow(db, configuredSchema(authorityOptions(options)), false); if (!row || digest(row.body) !== row.sha256) fail("ERR_STATE_INVALID"); let state; try { state = JSON.parse(row.body); } catch { fail("ERR_STATE_INVALID"); } assertState(state); + if (isAuthoritySchema(state.schema_version)) fail("ERR_WITNESS_RECOVERY_UNSUPPORTED"); if (state.decision_id !== options.expectedDecisionId) fail("ERR_DECISION_MISMATCH"); // The permission mode of the anchor domain is a property of the // deployment. The caller may state it explicitly, and otherwise the @@ -1027,6 +2398,7 @@ class ProtectedPermitStore { // Nothing is carried over. A permit that existed before the loss does not // exist afterwards, so there is nothing that could be consumed twice. static resetAfterAnchorLoss(filePath, options = {}) { + options = nativePermissionOptions(options); return this.#withExistingLock(filePath, options, () => this.#resetAfterAnchorLossUnlocked(filePath, options)); } @@ -1044,11 +2416,12 @@ class ProtectedPermitStore { const db = new DatabaseSync(path.resolve(filePath), { timeout: 5000 }); try { db.exec("BEGIN IMMEDIATE"); - const row = db.prepare("SELECT body, sha256 FROM protected_state WHERE id = 1").get(); + const row = readProtectedRow(db, configuredSchema(authorityOptions(options)), false); if (!row || digest(row.body) !== row.sha256) fail("ERR_STATE_INVALID"); let state; try { state = JSON.parse(row.body); } catch { fail("ERR_STATE_INVALID"); } assertState(state); + if (isAuthoritySchema(state.schema_version)) fail("ERR_WITNESS_RECOVERY_UNSUPPORTED"); if (state.decision_id !== options.expectedDecisionId) fail("ERR_DECISION_MISMATCH"); // The permission mode of the anchor domain is a property of the // deployment. The caller may state it explicitly, and otherwise the @@ -1148,5 +2521,6 @@ module.exports = { ProtectedStoreError, PROTECTED_SCOPE, CONSUME_BINDING_FIELDS, + validateReservationInput, consumeBindingFields: () => CONSUME_BINDING_FIELDS.slice(), }; diff --git a/lib/coordination/protected-pin-store.js b/lib/coordination/protected-pin-store.js index 9ababbd5..0021827b 100644 --- a/lib/coordination/protected-pin-store.js +++ b/lib/coordination/protected-pin-store.js @@ -47,16 +47,22 @@ const path = require("node:path"); const crypto = require("node:crypto"); const anchor = require("./protected-anchor"); +const coordination = require("./protected-store-lock"); const { CoordinationError } = require("./errors"); function fail(code) { throw new CoordinationError(code); } const STATE_FILE = "pin-state.json"; -// The marker that records that this domain has ever held state. See -// domainWasInitialised() for why it exists. +// Format 3 fences both legacy state readers (formats 1 and 2) and binds the +// fixed SQLite lock inode. Directory separation is NOT rollback protection: +// whole-machine rollback requires the machine-external trusted witness. const DOMAIN_MARKER_FILE = "pin-domain.json"; -const STATE_SCHEMA_VERSION = 1; -const DEFAULT_LOCK_TTL_MS = 30_000; +const STATE_SCHEMA_VERSION = 3; +const LEGACY_LOCK_FILE = ".pin-critical-section.lock"; +// A failed poison write must not make another store handle in this process +// authorize the same domain. Cross-process/restart safety still requires the +// independently witnessed durable state. +const CLOCK_POISONED_DOMAINS = new Set(); // The batch identity is the triple that decides whether an existing pin // belongs to THIS batch (design section 2.2.3 R3-B3 ruling 1). The fields @@ -161,6 +167,8 @@ function emptyState() { // The highest sequence that has been SEEN. This may run ahead of the // applied one while a pull is in flight. highest_seen_revocation_sequence: 0, + highest_accepted_permit_sequence: 0, + last_observed_clock_ms: 0, // The clock-poisoning marker. A poisoned domain refuses every new // permit until it is cleared by the procedure the design defines. clock_poisoned: false, @@ -171,104 +179,73 @@ function stateFilePath(pinDir) { return path.join(pinDir, STATE_FILE); } -// The domain-initialisation marker. -// -// Its only job is to make one question answerable: has this domain ever -// held state? Without it, a missing state file reads as "this domain is -// empty", and a domain that lost its state file after revoking a key -// would report that nothing was ever revoked -- and the revoked key -// would be authorised again. -// -// The marker is written with create-if-absent semantics and is never -// removed: a domain that was initialised stays initialised. That is the -// point -- once a domain has held revocation state, its loss is a fault -// to be refused, not a fresh start to be tolerated. function domainMarkerPath(pinDir) { return path.join(pinDir, DOMAIN_MARKER_FILE); } -function domainWasInitialised(pinDir) { +function readDomainMarker(pinDir) { + let marker; try { - fs.statSync(domainMarkerPath(pinDir)); - return true; - } catch { - return false; + marker = JSON.parse(fs.readFileSync(domainMarkerPath(pinDir), "utf8")); + } catch { fail("ERR_PIN_STATE_CORRUPT"); } + if (!marker || marker.domain_schema_version !== STATE_SCHEMA_VERSION || + typeof marker.lock_identity !== "string" || !marker.lock_identity || + typeof marker.legacy_lock_identity !== "string" || !marker.legacy_lock_identity) { + fail("ERR_PIN_STATE_CORRUPT"); } + return marker; } -function markDomainInitialised(pinDir) { - const marker = domainMarkerPath(pinDir); - try { - const handle = fs.openSync(marker, "wx", 0o600); - try { - fs.writeFileSync(handle, JSON.stringify({ - domain_schema_version: STATE_SCHEMA_VERSION, - initialised_at_ms: Date.now(), - })); - fs.fsyncSync(handle); - } finally { - fs.closeSync(handle); - } - } catch (error) { - // The marker already existing is the normal case on every write after - // the first one. Anything else means the marker could not be created, - // and a domain whose initialisation cannot be recorded must not be - // allowed to proceed -- otherwise the very next lost state file - // would be indistinguishable from a fresh domain. - if (!error || error.code !== "EEXIST") fail("ERR_PIN_STATE_CORRUPT"); +function legacyFenceIdentity(pinDir) { + let stat; + try { stat = fs.lstatSync(path.join(pinDir, LEGACY_LOCK_FILE)); } + catch { fail("ERR_PIN_STATE_CORRUPT"); } + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || + (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || + (typeof process.getuid === "function" && stat.uid !== process.getuid())))) { + fail("ERR_PIN_STATE_CORRUPT"); } + if (process.platform === "win32") { + coordination.assertWindowsFilePermissions(path.join(pinDir, LEGACY_LOCK_FILE)); + } + return [stat.dev, stat.ino].join(":"); } -function readState(pinDir) { - let raw; +// A permanent O_EXCL fence blocks the non-reclaiming legacy implementation. +// Older reclaiming implementations are also fenced by state format 3. Never +// remove this file, including after a failed initialization/migration. +function createLegacyFence(pinDir) { + const fd = fs.openSync(path.join(pinDir, LEGACY_LOCK_FILE), "wx", 0o600); try { - raw = fs.readFileSync(stateFilePath(pinDir), "utf8"); - } catch (error) { - // A state file that does not exist is a domain that has never been - // written to, and that is a legitimate empty domain. - // - // EVERY OTHER read failure is a domain whose state could not be read, - // and that is NOT an empty domain. Treating it as one is the worst - // possible reading of the state: the revocation high-water mark, the - // revoked fingerprint set and the registered pins all come back as - // empty, so a domain that had revoked a key now reports that nothing - // was ever revoked -- and the revoked key is authorised again. - // - // So the distinction is made explicitly here rather than by letting a - // catch-all return the empty state. The distinction that matters is - // "there is no state yet" versus "the state could not be read". - if (error && error.code === "ENOENT") { - // A missing state file is an empty domain only if the domain was - // NEVER INITIALISED. A domain that was initialised and then lost - // its state file has lost the applied revocation high-water mark, - // the revoked fingerprint set and the registered pins -- and the - // design says a failed recovery is a refusal, not a reason to - // continue with the high-water mark at zero - // (design section 2.3.1, "recovery failure means refusal"). - // - // The initialisation marker is what tells the two apart. It is - // written BEFORE the first state file, so a domain that ever had - // state always has the marker, and a deleted state file is then a - // loss rather than a fresh start. - if (domainWasInitialised(pinDir)) fail("ERR_PIN_STATE_CORRUPT"); - return emptyState(); - } - fail("ERR_PIN_STATE_CORRUPT"); - } + fs.writeFileSync(fd, JSON.stringify({ owner: "pin-format-3", pid: process.pid, + token: crypto.randomBytes(16).toString("hex") })); + fs.fsyncSync(fd); + } finally { fs.closeSync(fd); } + return legacyFenceIdentity(pinDir); +} + +function readState(pinDir, { legacy = false } = {}) { let parsed; - try { parsed = JSON.parse(raw); } + try { parsed = JSON.parse(fs.readFileSync(stateFilePath(pinDir), "utf8")); } catch { fail("ERR_PIN_STATE_CORRUPT"); } if (!parsed || typeof parsed !== "object") fail("ERR_PIN_STATE_CORRUPT"); - if (parsed.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + // Format 1 has no accepted-permit/clock baseline. It cannot be migrated by + // inventing zeroes; an independently verified recovery procedure is needed. + if (parsed.schema_version !== (legacy ? 2 : STATE_SCHEMA_VERSION)) fail("ERR_PIN_STATE_CORRUPT"); if (!Array.isArray(parsed.pins)) fail("ERR_PIN_STATE_CORRUPT"); if (!Array.isArray(parsed.revoked_fingerprints)) fail("ERR_PIN_STATE_CORRUPT"); requireSafeInteger(parsed.applied_revocation_sequence, "ERR_PIN_STATE_CORRUPT"); requireSafeInteger(parsed.highest_seen_revocation_sequence, "ERR_PIN_STATE_CORRUPT"); + if (!Number.isSafeInteger(parsed.highest_accepted_permit_sequence) || parsed.highest_accepted_permit_sequence < 0 || + !Number.isSafeInteger(parsed.last_observed_clock_ms) || parsed.last_observed_clock_ms < 0) { + fail("ERR_PIN_STATE_CORRUPT"); + } if (typeof parsed.clock_poisoned !== "boolean") fail("ERR_PIN_STATE_CORRUPT"); for (const pin of parsed.pins) { if (!pin || typeof pin !== "object") fail("ERR_PIN_STATE_CORRUPT"); for (const field of BATCH_IDENTITY_FIELDS) requireText(pin[field], "ERR_PIN_STATE_CORRUPT"); requireText(pin.public_key_pem, "ERR_PIN_STATE_CORRUPT"); + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pin.public_key_pem)) fail("ERR_PIN_STATE_CORRUPT"); } return parsed; } @@ -278,22 +255,26 @@ function readState(pinDir) { // reader looks at, and it is removed if the write fails, so a crashed // write leaves the previous state intact rather than a truncated one. function writeState(pinDir, state) { - // The marker is written BEFORE the state, so a domain that ever held - // state always has the marker. The order matters: if the state write - // succeeded and the marker write had not, a later lost state file would - // look like a fresh domain. - markDomainInitialised(pinDir); - const body = JSON.stringify(state); - const target = stateFilePath(pinDir); - const temporary = path.join(pinDir, "." + STATE_FILE + ".tmp-" + process.pid); + writeJson(pinDir, STATE_FILE, state); +} + +function writeJson(pinDir, name, value) { + const body = JSON.stringify(value); + const target = path.join(pinDir, name); + const temporary = path.join(pinDir, "." + name + ".tmp-" + process.pid + "-" + crypto.randomBytes(12).toString("hex")); const handle = fs.openSync(temporary, "wx", 0o600); try { - fs.writeFileSync(handle, body); - fs.fsyncSync(handle); - } finally { - fs.closeSync(handle); + try { + fs.writeFileSync(handle, body); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + fs.renameSync(temporary, target); + } catch (error) { + try { fs.unlinkSync(temporary); } catch { /* preserve the original failure */ } + throw error; } - fs.renameSync(temporary, target); } // The public key fingerprint is derived from the key material itself, so a @@ -325,50 +306,116 @@ function sameBatchIdentity(pin, batchIdentity) { class ProtectedPinStore { #pinDir; - #decisionId; - #serviceIdentity; - #anchorMode; - #platform; - #lockTtlMs; + #options; + #lockIdentity = null; #lockDepth = 0; constructor(pinDir, options = {}) { this.#pinDir = pinDir; - this.#decisionId = options.decisionId; - this.#serviceIdentity = options.serviceIdentity; - this.#anchorMode = options.anchorMode; - this.#platform = options.platform; - // The lock's own expiry. It is a backstop for a holder that died: a - // live holder always wins, and a dead one is reclaimed immediately - // rather than after the expiry elapses. - this.#lockTtlMs = Number.isSafeInteger(options.lockTtlMs) && options.lockTtlMs > 0 - ? options.lockTtlMs - : DEFAULT_LOCK_TTL_MS; + this.#options = { ...options }; } - // open() brings the domain up. The permission contract is checked BEFORE - // anything is read or written, so a domain whose writable set is not - // exactly the service identity is refused rather than operated in. - static open(pinDir, options = {}) { + static #directory(pinDir, options, { requireEmpty = false } = {}) { requireText(options.decisionId, "ERR_PERMIT_INPUT_INVALID"); if (!pinDir) fail("ERR_PERMIT_INPUT_INVALID"); const resolved = path.resolve(pinDir); + // Reject redirected Windows ancestors before the permission check can + // write an audit record, or provisioning can create the legacy fence. + if (process.platform === "win32") coordination.assertAnchorPathIsNotRedirected(resolved); // The pin directory must exist, must not be a symlink, and must not be // a file. anchorDirFor checks exactly those properties and normalises // the canonical path. const anchorDir = anchor.anchorDirFor(resolved); - // The permission judgement surface is the permit store's. The strict mode - // requires the service identity to be declared on Windows, because - // there the calling account is not necessarily the service -- without - // the declaration the comparison degenerates into comparing the caller - // with itself. + // The strict Windows ancestor check can create its audit log. Establish + // freshness first; pre-existing audit files are not an empty domain. + if (requireEmpty && fs.readdirSync(anchorDir).length !== 0) fail("ERR_PIN_STATE_CORRUPT"); + // Production Windows always uses native evidence and the strict contract, + // including the declared service identity and ancestor audit. Callers may + // not turn an adapter/platform override or permissive mode into evidence. + // Other hosts retain the existing development/simulation surface. + const nativeWindows = process.platform === "win32"; + const permissionOptions = nativeWindows + ? { platform: "windows", controlledAncestorPath: options.controlledAncestorPath } + : options; + const mode = anchor.assertKnownAnchorMode(options.anchorMode); anchor.assertAnchorPermissionsWithIdentity( anchorDir, - anchor.assertKnownAnchorMode(options.anchorMode), + nativeWindows ? anchor.ANCHOR_MODE.strict : mode, options.serviceIdentity, - options, + permissionOptions, ); - return new ProtectedPinStore(anchorDir, options); + return anchorDir; + } + + // Explicit provisioning only, in an empty directory isolated from legacy + // services until this call completes. open() never provisions or repairs. + static initialize(pinDir, options = {}) { + const dir = this.#directory(pinDir, options, { requireEmpty: true }); + return this.#provision(dir, options, emptyState()); + } + + // This is an OFFLINE deployment operation, not rolling-upgrade support. + // The operator must stop/disable all old services and drain their handles + // before asserting offline:true, and deploy only format-3 readers afterward. + // PID inspection cannot prove that precondition. Existing legacy locks, + // partial migrations and format-1 state are refused without cleanup/reset. + static migrateOffline(pinDir, options = {}) { + if (options.offline !== true) fail("ERR_PERMIT_INPUT_INVALID"); + const dir = this.#directory(pinDir, options); + let marker; + try { marker = JSON.parse(fs.readFileSync(domainMarkerPath(dir), "utf8")); } + catch { fail("ERR_PIN_STATE_CORRUPT"); } + if (!marker || marker.domain_schema_version !== 2 || + fs.existsSync(path.join(dir, coordination.FILE))) fail("ERR_PIN_STATE_CORRUPT"); + const state = readState(dir, { legacy: true }); + return this.#provision(dir, options, { ...state, schema_version: STATE_SCHEMA_VERSION }, marker); + } + + static #provision(dir, options, state, legacyMarker = null) { + const fenceIdentity = createLegacyFence(dir); + return coordination.withLock(dir, (identity) => { + // Re-read after taking the legacy namespace and the stable lock. No + // cached legacy state is allowed to overwrite a completed old write. + if (legacyMarker) state = { ...readState(dir, { legacy: true }), schema_version: STATE_SCHEMA_VERSION }; + const marker = { ...legacyMarker, domain_schema_version: STATE_SCHEMA_VERSION, + initialised_at_ms: legacyMarker ? legacyMarker.initialised_at_ms : Date.now(), + lock_identity: identity, legacy_lock_identity: fenceIdentity }; + writeJson(dir, DOMAIN_MARKER_FILE, marker); + writeState(dir, state); + const store = new ProtectedPinStore(dir, options); + store.#lockIdentity = identity; + return store; + }, { create: true }); + } + + static open(pinDir, options = {}) { + const dir = this.#directory(pinDir, options); + const marker = readDomainMarker(dir); + return coordination.withLock(dir, (identity) => { + const store = new ProtectedPinStore(dir, options); + store.#lockIdentity = identity; + store.#assertDomain(); + readState(dir); + return store; + }, { expectedIdentity: marker.lock_identity }); + } + + #assertDomain() { + ProtectedPinStore.#directory(this.#pinDir, this.#options); + const marker = readDomainMarker(this.#pinDir); + if (this.#lockIdentity === null || marker.lock_identity !== this.#lockIdentity || + legacyFenceIdentity(this.#pinDir) !== marker.legacy_lock_identity) fail("ERR_PIN_STATE_CORRUPT"); + // withLock checks around the outer action; nested calls must also refuse + // replacement before doing any work under the previously acquired lock. + const dir = fs.lstatSync(this.#pinDir); + const lock = fs.lstatSync(path.join(this.#pinDir, coordination.FILE)); + if ([dir.dev, dir.ino, lock.dev, lock.ino].join(":") !== this.#lockIdentity) { + throw Object.assign(new Error("ERR_COORDINATION_LOCK_REPLACED"), { code: "ERR_COORDINATION_LOCK_REPLACED" }); + } + } + + #readState() { + return this.criticalSection(() => readState(this.#pinDir)); } // The permit database's directory must not contain this domain and this @@ -388,7 +435,7 @@ class ProtectedPinStore { // 2.3.1 R3-B2 ruling 1). It does NOT cover the Mac-side permit store and // it does NOT cover the network connection. // - // The lock is a file lock taken on the domain itself. It is deliberately + // The lock is a fixed SQLite transaction taken on the domain itself. It is deliberately // not shared with the permit store's SQLite transaction: those are two separate // locks on two separate machines, and the only thing that passes between // them is a signed revocation list. @@ -396,7 +443,7 @@ class ProtectedPinStore { // The caller injects the body; the body runs with the lock held, and the // lock is released whether the body returns or throws. // - // The section is REENTRANT within one process. The cross-machine path + // The section is REENTRANT on this handle. The cross-machine path // takes the section around the whole registration -- the exclusivity // check, the pin write and the snapshot advance are one atomic step -- // and that path calls into the store's own registerPin, which takes the @@ -405,28 +452,26 @@ class ProtectedPinStore { // lock less often": the outer section is what makes the three steps one // step. // - // Reentrancy is safe here because the lock exists to keep a SECOND - // PROCESS from interleaving. Within one process the code is already - // sequential, so a nested entry has nothing to exclude. + // Callbacks must be synchronous. Another handle, even in this process, + // acquires the SQLite lock independently and receives bounded contention. criticalSection(body) { if (typeof body !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + if (body.constructor.name === "AsyncFunction") fail("ERR_PERMIT_INPUT_INVALID"); + this.#assertDomain(); if (this.#lockDepth > 0) { - this.#lockDepth += 1; - try { - return body(); - } finally { - this.#lockDepth -= 1; - } - } - const lockPath = path.join(this.#pinDir, ".pin-critical-section.lock"); - const release = acquireLock(lockPath, this.#lockTtlMs); - this.#lockDepth = 1; - try { return body(); - } finally { - this.#lockDepth = 0; - release(); } + return coordination.withLock(this.#pinDir, () => { + this.#assertDomain(); + readState(this.#pinDir); + this.#lockDepth = 1; + try { + const result = body(); + if (result && typeof result.then === "function") fail("ERR_PERMIT_INPUT_INVALID"); + return result; + } + finally { this.#lockDepth = 0; } + }, { expectedIdentity: this.#lockIdentity }); } // Register a pin. @@ -445,6 +490,7 @@ class ProtectedPinStore { const { batchIdentity, publicKeyPem, write } = options; for (const field of BATCH_IDENTITY_FIELDS) requireText(batchIdentity[field], "ERR_PERMIT_INPUT_INVALID"); requireText(publicKeyPem, "ERR_PERMIT_INPUT_INVALID"); + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(publicKeyPem)) fail("ERR_PERMIT_INPUT_INVALID"); if (typeof write !== "function") fail("ERR_PERMIT_INPUT_INVALID"); // The registration action carries the design's field table (section // 2.2). All six are required here: a registration that cannot say what @@ -474,7 +520,7 @@ class ProtectedPinStore { const fingerprint = publicKeyFingerprint(publicKeyPem); if (batchIdentity.fingerprint !== fingerprint) fail("ERR_PIN_FINGERPRINT_MISMATCH"); return this.criticalSection(() => { - const state = readState(this.#pinDir); + const state = this.#readState(); const existing = findPin(state, fingerprint); if (existing) { // A pin for this key already exists. If it belongs to THIS batch @@ -515,14 +561,14 @@ class ProtectedPinStore { // The pins that are registered on this machine. This is the ONLY source // of public keys the verification path accepts. listPins() { - const state = readState(this.#pinDir); + const state = this.#readState(); return state.pins.map((pin) => ({ ...pin })); } // The fingerprint set of the registered pins. A revocation list is // refused unless the key that signed it is in this set. pinnedFingerprints() { - const state = readState(this.#pinDir); + const state = this.#readState(); return new Set(state.pins.map((pin) => pin.fingerprint)); } @@ -530,14 +576,14 @@ class ProtectedPinStore { // here", which the verification path treats as a refusal -- it never // falls back to a key carried by the list itself. publicKeyFor(fingerprint) { - const state = readState(this.#pinDir); + const state = this.#readState(); const pin = findPin(state, fingerprint); return pin ? pin.public_key_pem : null; } // The local revocation state, as the verification path consumes it. revocationState() { - const state = readState(this.#pinDir); + const state = this.#readState(); return { appliedRevocationSequence: state.applied_revocation_sequence, highestSeenRevocationSequence: state.highest_seen_revocation_sequence, @@ -545,6 +591,37 @@ class ProtectedPinStore { }; } + // A legacy domain has no trustworthy history of accepted permits. It may + // retain and update revocations, but cannot accept permits until recovered + // from an independently verified baseline. + assertPermitReplayState() { + const state = this.#readState(); + if (state.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + return state.highest_accepted_permit_sequence; + } + + // A clock rollback poisons the target domain persistently before refusing + // verification. A trusted host must govern any subsequent marker clearing. + observePermitClock(nowMs) { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail("ERR_CLOCK_INVALID"); + return this.criticalSection(() => { + const state = this.#readState(); + if (state.schema_version !== STATE_SCHEMA_VERSION || state.clock_poisoned || + CLOCK_POISONED_DOMAINS.has(this.#pinDir)) fail("ERR_CLOCK_INVALID"); + if (nowMs < state.last_observed_clock_ms) { + CLOCK_POISONED_DOMAINS.add(this.#pinDir); + state.clock_poisoned = true; + writeState(this.#pinDir, state); + fail("ERR_CLOCK_INVALID"); + } + if (nowMs > state.last_observed_clock_ms) { + state.last_observed_clock_ms = nowMs; + writeState(this.#pinDir, state); + } + return nowMs; + }); + } + // Commit an applied revocation list. // // The revoked set and the applied high-water mark advance TOGETHER. The @@ -557,11 +634,19 @@ class ProtectedPinStore { // from the revoked set. This function only commits what passed those // checks; it never decides whether a list is acceptable. commitRevocation(options) { - const { revokedFingerprints, sequence } = options; + const { revokedFingerprints, sequence, permitSequence } = options; requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); if (!Array.isArray(revokedFingerprints)) fail("ERR_PERMIT_INPUT_INVALID"); return this.criticalSection(() => { - const state = readState(this.#pinDir); + const state = this.#readState(); + if (!Number.isSafeInteger(state.applied_revocation_sequence + 1) || + sequence !== state.applied_revocation_sequence + 1) fail("ERR_REVOCATION_SEQUENCE_BROKEN"); + if (permitSequence !== undefined) { + if (state.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + requireSafeInteger(permitSequence, "ERR_PERMIT_INPUT_INVALID"); + if (permitSequence <= state.highest_accepted_permit_sequence) fail("ERR_PERMIT_SEQUENCE_REPLAY"); + state.highest_accepted_permit_sequence = permitSequence; + } // The applied set only grows. A commit that would drop a fingerprint // is refused and nothing advances. const incoming = new Set(revokedFingerprints); @@ -586,7 +671,7 @@ class ProtectedPinStore { noteRevocationSeen(sequence) { requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); return this.criticalSection(() => { - const state = readState(this.#pinDir); + const state = this.#readState(); if (sequence > state.highest_seen_revocation_sequence) { state.highest_seen_revocation_sequence = sequence; writeState(this.#pinDir, state); @@ -612,7 +697,11 @@ class ProtectedPinStore { requireText(fingerprint, "ERR_PERMIT_INPUT_INVALID"); requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); return this.criticalSection(() => { - const state = readState(this.#pinDir); + const state = this.#readState(); + if (!Number.isSafeInteger(state.applied_revocation_sequence + 1) || + sequence !== state.applied_revocation_sequence + 1) { + fail("ERR_REVOCATION_SEQUENCE_BROKEN"); + } const incoming = new Set(state.revoked_fingerprints); incoming.add(fingerprint); state.revoked_fingerprints = Array.from(incoming).sort(); @@ -631,13 +720,13 @@ class ProtectedPinStore { // The clock-poisoning marker. A poisoned domain refuses every new // permit until the clearing procedure the design defines has run. isClockPoisoned() { - return readState(this.#pinDir).clock_poisoned; + return CLOCK_POISONED_DOMAINS.has(this.#pinDir) || this.#readState().clock_poisoned; } setClockPoisoned(poisoned) { if (typeof poisoned !== "boolean") fail("ERR_PERMIT_INPUT_INVALID"); return this.criticalSection(() => { - const state = readState(this.#pinDir); + const state = this.#readState(); state.clock_poisoned = poisoned; writeState(this.#pinDir, state); return { clockPoisoned: state.clock_poisoned }; @@ -648,7 +737,7 @@ class ProtectedPinStore { // An unregistered or unreadable pin yields null, which the reconnect // logic treats as undecidable -- and undecidable means the route stops. batchIdentityFor(fingerprint) { - const pin = findPin(readState(this.#pinDir), fingerprint); + const pin = findPin(this.#readState(), fingerprint); if (!pin) return null; return { decision_id: pin.decision_id, @@ -658,123 +747,11 @@ class ProtectedPinStore { } } -// The exclusive lock. -// -// Node has no advisory file lock in its standard library, so the lock is an -// O_EXCL lock FILE with an owner and an expiry, and it follows the same -// shape the coordination journal already uses (journal.js, section 8.1): -// exactly one holder creates the file, a holder that dies leaves a lock -// that is reclaimed by moving it out of the lock namespace atomically, and -// a lock that was renewed under a live owner is put back rather than -// stolen. -// -// The lock is not the only thing standing between two registrants: the -// atomic create-if-absent write of the pin entry is. The lock exists so -// that the read-decide-write sequence is not interleaved with another -// registrant's, and so the revocation commit is a single atomic step. -function acquireLock(lockPath, ttlMs) { - const owner = "pin-pid" + process.pid + "-" + Date.now().toString(36); - const token = crypto.randomBytes(16).toString("hex"); - const payload = () => JSON.stringify({ - owner, - token, - acquiredAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + ttlMs).toISOString(), - pid: process.pid, - }); - - const tryCreate = () => { - // O_EXCL: the create either succeeds or reports that somebody else - // already holds the lock. There is no window in which two holders both - // believe they created it. - const fd = fs.openSync(lockPath, "wx", 0o600); - try { - fs.writeFileSync(fd, payload(), "utf8"); - fs.fsyncSync(fd); - return fd; - } catch (error) { - fs.closeSync(fd); - throw error; - } - }; - - let fd; - try { - fd = tryCreate(); - } catch (error) { - if (!error || error.code !== "EEXIST") throw error; - // Somebody holds the lock. If that holder is alive the section is - // refused rather than waited on: the design wants the revocation pull - // to carry its own explicit timeout, and a silent wait here would let - // the lock be held for an unbounded time. - let existing = null; - try { existing = JSON.parse(fs.readFileSync(lockPath, "utf8")); } - catch { existing = null; } - if (existing && isProcessAlive(existing.pid)) { - fail("ERR_LEASE_CONFLICT"); - } - // The holder is gone. Move the stale lock out of the lock namespace - // atomically. The file is never unlinked in place: another contender - // may already have replaced it, and unlinking would leave two live - // writers. - const stalePath = lockPath + ".stale." + process.pid + "." + crypto.randomBytes(4).toString("hex"); - try { - fs.renameSync(lockPath, stalePath); - } catch (reclaimError) { - fail("ERR_LEASE_CONFLICT"); - } - try { - let moved = null; - try { moved = JSON.parse(fs.readFileSync(stalePath, "utf8")); } - catch { moved = null; } - // The moved file has to be the one that was inspected. If it is not, - // somebody replaced the lock while it was being reclaimed, and the - // safe answer is to refuse rather than to take the lock. - if (!moved || moved.owner !== (existing && existing.owner)) { - try { fs.renameSync(stalePath, lockPath); } catch { /* fail closed below */ } - fail("ERR_LEASE_CONFLICT"); - } - // A live owner always wins, even if its expiry has not elapsed. - if (isProcessAlive(moved.pid)) { - try { fs.renameSync(stalePath, lockPath); } catch { /* fail closed below */ } - fail("ERR_LEASE_CONFLICT"); - } - fd = tryCreate(); - } catch (inner) { - if (inner && inner.code === "EEXIST") fail("ERR_LEASE_CONFLICT"); - throw inner; - } finally { - try { fs.unlinkSync(stalePath); } catch { /* best effort */ } - } - } - - return () => { - try { - // The lock is only released if this holder still owns it. A lock - // that was reclaimed and re-taken belongs to somebody else now, and - // deleting it would revoke THEIR lock. - const current = JSON.parse(fs.readFileSync(lockPath, "utf8")); - if (current.token === token) fs.unlinkSync(lockPath); - } catch { /* already released or replaced */ } - try { fs.closeSync(fd); } catch { /* already closed */ } - }; -} - -function isProcessAlive(pid) { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - // EPERM means the process exists but belongs to another account. - return Boolean(error && error.code === "EPERM"); - } -} - module.exports = { ProtectedPinStore, STATE_FILE, DOMAIN_MARKER_FILE, + LEGACY_LOCK_FILE, STATE_SCHEMA_VERSION, BATCH_IDENTITY_FIELDS, REGISTRATION_CARRIED_FIELDS, diff --git a/lib/governed/managed-caller-session.js b/lib/governed/managed-caller-session.js index 12f5edc7..f704476e 100644 --- a/lib/governed/managed-caller-session.js +++ b/lib/governed/managed-caller-session.js @@ -441,8 +441,13 @@ class ManagedCallerSession { // that swapped its descriptor would be audited on the new socket and // pass, while the channel the session established was abandoned. const current = this.#collectFacts(this.#child); - if (current.socketEndpoint && endpoint.inode !== null - && current.socketEndpoint.inode !== endpoint.inode) { + const observed = current.socketEndpoint; + if (!observed) return { ok: false, reason: "socket_address_unknown", holders: [] }; + const endpointChanged = Number.isInteger(endpoint.inode) + ? observed.inode !== endpoint.inode + : observed.address !== endpoint.address || + (endpoint.peerAddress && observed.peerAddress !== endpoint.peerAddress); + if (endpointChanged) { return { ok: false, reason: "channel_endpoint_changed", @@ -458,7 +463,7 @@ class ManagedCallerSession { // The socket was located but nothing holds it — the channel is gone even // though the handle still looks alive. That is a degraded environment, // not a clean one. - if (!holders.length) { + if (!holders.includes(this.#child.pid)) { return { ok: false, reason: "socket_holders_unknown", holders }; } return { ok: true, holders }; @@ -490,8 +495,10 @@ class ManagedCallerSession { if (current.startToken !== this.childIdentity.startToken) { return { ok: false, reason: "process_replaced_pid_reuse" }; } - if (this.childIdentity.commandDigest && current.commandDigest - && current.commandDigest !== this.childIdentity.commandDigest) { + if (!this.childIdentity.commandDigest || !current.commandDigest) { + return { ok: false, reason: "command_digest_unavailable" }; + } + if (current.commandDigest !== this.childIdentity.commandDigest) { return { ok: false, reason: "process_command_changed" }; } return { ok: true }; @@ -523,7 +530,11 @@ class ManagedCallerSession { this.closedReason = reason || "closed"; try { - this.#child.send({ type: CHANNEL_MESSAGE.BYE, sessionId: this.sessionId }); + // A disconnected channel can report failure asynchronously. Supplying a + // callback prevents an unhandled child error during session teardown. + if (this.#child.connected !== false) { + this.#child.send({ type: CHANNEL_MESSAGE.BYE, sessionId: this.sessionId }, () => {}); + } } catch (_) { // The channel may already be gone; the session is closed either way. } diff --git a/lib/governed/managed-caller-supervisor.js b/lib/governed/managed-caller-supervisor.js index 499fcdcd..39e494a8 100644 --- a/lib/governed/managed-caller-supervisor.js +++ b/lib/governed/managed-caller-supervisor.js @@ -40,8 +40,7 @@ const { newSessionId, SIGNED_MESSAGE_KIND, } = require("./managed-caller-session"); -const { signResponse } = require("./managed-caller-signing"); -const { digestPayload } = require("./managed-caller-signing"); +const { signResponse, verifyResponse, digestPayload } = require("./managed-caller-signing"); /** * Operations the supervisor itself understands. The executor may only ask @@ -69,9 +68,56 @@ const SECRET_FD = 4; * @param {object} [options.spawnOptions] Escape hatch for advanced callers. * @param {object} [options.factSource] Replacement for the OS-level fact * source. Tests use it to reproduce a degraded environment honestly. + * @param {function} [options.onAuthorizedRequest] Trusted in-process handler + * for allowlisted operations after identity and replay checks. + * @param {function} [options.onIdentityUnavailable] Synchronous trusted local + * notification to revoke dependent contexts when an inbound audit fails. * @returns {Promise<{ session: ManagedCallerSession, child: object }>} */ +// Synchronous ticket: observing the direct child does not imply that any +// descendants have exited. No external callback runs during child registration. +function beginManagedCallerSession(options) { + let resolveExit; + let registered = false; + let forkAttempted = false; + const directExit = new Promise((resolve) => { resolveExit = resolve; }); + function registerChild(child) { + registered = true; + let spawned = false; + child.once("spawn", () => { spawned = true; }); + child.once("exit", (code, signal) => { + resolveExit(Object.freeze({ kind: "exited", code, signal })); + }); + // A failed spawn has no exit event. Errors after a successful spawn do not + // establish exit and must leave the ticket pending until the real event. + child.on("error", (error) => { + if (!spawned && child.pid === undefined) { + resolveExit(Object.freeze({ kind: "not-started", error })); + } + }); + // Session teardown can remove its channel listener before asynchronous + // errors arrive. Keep a guard on this channel for its entire lifetime. + if (child.channel) child.channel.on("error", () => {}); + } + const ready = startManagedCallerSessionInternal(options, registerChild, () => { forkAttempted = true; }); + // A caller may keep only directExit. Mark ready observed immediately while + // returning the original Promise and its original rejection to other callers. + ready.catch((error) => { + // fork can throw after OS spawn (e.g. AbortSignal subscription failure), + // without returning the handle. No registered child is NOT proof of no + // process: once fork was entered, leave this unobservable outcome pending. + if (!registered && !forkAttempted) resolveExit(Object.freeze({ kind: "not-started", error })); + }); + return Object.freeze({ ready, directExit }); +} + async function startManagedCallerSession(options) { + // Keep the legacy caller's rejection independent of the ticket's observed + // ready Promise. Ignoring this Promise must still fail under strict policy. + return beginManagedCallerSession(options).ready; +} + +async function startManagedCallerSessionInternal(options, registerChild, beforeFork) { const { modulePath, args = [], @@ -82,6 +128,8 @@ async function startManagedCallerSession(options) { requestTimeoutMs, spawnOptions = {}, factSource, + onAuthorizedRequest, + onIdentityUnavailable, } = options || {}; if (typeof modulePath !== "string" || !modulePath) { @@ -90,6 +138,12 @@ async function startManagedCallerSession(options) { if (!Array.isArray(allowedOperations) || !allowedOperations.length) { throw new TypeError("allowedOperations must be a non-empty array"); } + if (onAuthorizedRequest !== undefined && typeof onAuthorizedRequest !== "function") { + throw new TypeError("onAuthorizedRequest must be a function"); + } + if (onIdentityUnavailable !== undefined && typeof onIdentityUnavailable !== "function") { + throw new TypeError("onIdentityUnavailable must be a function"); + } const permitted = new Set(allowedOperations); const sessionId = newSessionId(); @@ -104,13 +158,33 @@ async function startManagedCallerSession(options) { delete spawnOptionsWithTrustedKeys.stdio; delete spawnOptionsWithTrustedKeys.detached; - const child = fork(path.resolve(modulePath), args, { + const childModule = path.resolve(modulePath); + const childOptions = { env: { ...process.env, ...env }, ...spawnOptionsWithTrustedKeys, // The trust-critical keys are applied last, and deliberately not // overridable by the escape hatch above. stdio: ["ignore", "pipe", "pipe", "ipc", "pipe"], detached: false, + }; + // Everything we can prepare without entering fork is complete. A synchronous + // exception inside fork cannot reliably prove that no OS child was created. + beforeFork(); + const child = fork(childModule, args, childOptions); + registerChild(child); + + // A spawn failure is emitted asynchronously; the child may not have an IPC + // channel at all. Observe it before constructing the session so an invalid + // runtime is returned as a rejection instead of an unhandled process error. + const spawnErrorGuard = () => {}; + let childClosed = false; + child.on("error", spawnErrorGuard); + child.once("close", () => { childClosed = true; child.removeListener("error", spawnErrorGuard); }); + await new Promise((resolve, reject) => { + function onSpawn() { child.removeListener("error", onError); resolve(); } + function onError(error) { child.removeListener("spawn", onSpawn); reject(error); } + child.once("spawn", onSpawn); + child.once("error", onError); }); // The secret goes down the dedicated pipe and nowhere else. It is never @@ -118,33 +192,94 @@ async function startManagedCallerSession(options) { // cannot read it and a descendant that inherits only the IPC handle does // not have it either. try { + child.stdio[SECRET_FD].on("error", () => {}); child.stdio[SECRET_FD].write(`${secret}\n`); child.stdio[SECRET_FD].end(); } catch (_) { // Handshake will fail closed below if the child never got the secret. } - const session = new ManagedCallerSession({ - child, - sessionId, - owner, - secret, - handshakeTimeoutMs, - requestTimeoutMs, - factSource, - }); + let session; + try { + session = new ManagedCallerSession({ + child, + sessionId, + owner, + secret, + handshakeTimeoutMs, + requestTimeoutMs, + factSource, + }); - session.onRequest(async ({ operation }) => { - if (!permitted.has(operation)) { - throw new Error(`operation_not_in_batch:${operation}`); + let lastInboundSeq = 0; + let identityUnavailable = false; + function requireCurrentIdentity() { + if (!identityUnavailable) { + try { + if (session.auditChannelHolders().ok === true && session.auditProcessContinuity().ok === true) return; + } catch { + // Unavailable OS facts revoke authority just like a negative audit. + } + identityUnavailable = true; + try { if (onIdentityUnavailable) onIdentityUnavailable(); } catch { + // A notification failure must never restore this session's authority. + } + } + throw new Error("managed_caller_identity_unavailable"); } - if (operation === SUPERVISOR_OPERATIONS.PING) { - return { pong: true, sessionId }; + session.onRequest(async ({ operation, payload, seq }) => { + if (!permitted.has(operation)) { + throw new Error(`operation_not_in_batch:${operation}`); + } + if (operation === SUPERVISOR_OPERATIONS.PING) { + return { pong: true, sessionId }; + } + if (onAuthorizedRequest) { + if (!Number.isSafeInteger(seq) || seq <= lastInboundSeq) throw new Error("request_sequence_invalid"); + requireCurrentIdentity(); + lastInboundSeq = seq; + try { + const result = await onAuthorizedRequest(Object.freeze({ identity: session.childIdentity, operation, payload, seq })); + requireCurrentIdentity(); + return result; + } catch { + throw new Error("managed_operation_rejected"); + } + } + throw new Error(`operation_not_implemented:${operation}`); + }); + await session.handshake(); + } catch (error) { + // The caller never receives the child on a failed handshake. Terminate + // only the child we started, so it cannot outlive an unauthenticated + // session or require a process-name sweep by the caller. + let cleanupError = null; + const captureError = (failure) => { cleanupError ||= failure; }; + const channel = child.channel; + if (!childClosed) { + child.on("error", captureError); + if (channel) channel.on("error", captureError); + child.once("close", () => { + child.removeListener("error", captureError); + if (channel) channel.removeListener("error", captureError); + }); } - throw new Error(`operation_not_implemented:${operation}`); - }); - - await session.handshake(); + const closed = new Promise((resolve) => { + if (childClosed) { resolve(true); return; } + function onClose() { clearTimeout(timer); resolve(true); } + const timer = setTimeout(() => { child.removeListener("close", onClose); resolve(false); }, 3000); + child.once("close", onClose); + }); + try { if (session) await session.close("handshake_failed"); } catch (failure) { cleanupError ||= failure; } + try { + if (child.exitCode === null && child.signalCode === null && !child.kill("SIGKILL")) { + cleanupError ||= new Error("managed child termination failed"); + } + } catch (failure) { cleanupError ||= failure; } + if (!await closed) cleanupError ||= new Error("managed child termination timed out"); + if (cleanupError && error && typeof error === "object") error.cleanupError = cleanupError; + throw error; + } return { session, child }; } @@ -209,6 +344,7 @@ function createExecutorEndpoint(child, secret) { const pending = new Map(); let seq = 0; + let lastOutboundSeq = 0; child.on("message", (message) => { if (!message || message.type !== CHANNEL_MESSAGE.RESPONSE) return; @@ -216,6 +352,17 @@ function createExecutorEndpoint(child, secret) { if (!entry) return; pending.delete(message.requestId); clearTimeout(entry.timer); + if (message.seq !== entry.seq || !verifyResponse(secret, { + kind: SIGNED_MESSAGE_KIND.REQUEST_IN_RESPONSE, + requestId: message.requestId, + seq: entry.seq, + operation: entry.operation, + payloadDigest: entry.payloadDigest, + body: message.body === undefined ? null : message.body, + }, message.mac)) { + entry.reject(new Error("executor response failed signature verification")); + return; + } const body = message.body || {}; if (body.error) entry.reject(new Error(body.error)); else entry.resolve(body.result === undefined ? null : body.result); @@ -229,6 +376,21 @@ function createExecutorEndpoint(child, secret) { * @param {*} result The result to return. */ function answer(message, result) { + if (!message || message.type !== CHANNEL_MESSAGE.REQUEST || + !Number.isSafeInteger(message.seq) || message.seq <= lastOutboundSeq || + typeof message.requestId !== "string" || typeof message.operation !== "string" || + message.payloadDigest !== digestPayload(message.payload) || + !verifyResponse(secret, { + kind: SIGNED_MESSAGE_KIND.REQUEST_OUT, + requestId: message.requestId, + seq: message.seq, + operation: message.operation, + payloadDigest: message.payloadDigest, + body: null, + }, message.mac)) { + throw new Error("executor request failed signature verification"); + } + lastOutboundSeq = message.seq; const body = { seq: message.seq, result }; child.send({ type: CHANNEL_MESSAGE.RESPONSE, @@ -263,7 +425,7 @@ function createExecutorEndpoint(child, secret) { pending.delete(requestId); reject(new Error(`executor request timed out after ${timeoutMs}ms`)); }, timeoutMs); - pending.set(requestId, { resolve, reject, timer }); + pending.set(requestId, { resolve, reject, timer, seq, operation, payloadDigest }); child.send({ type: CHANNEL_MESSAGE.REQUEST, requestId, @@ -290,6 +452,7 @@ function createExecutorEndpoint(child, secret) { } module.exports = { + beginManagedCallerSession, startManagedCallerSession, createExecutorEndpoint, readSessionSecret, diff --git a/tests/coordination/fixtures/permit-batch-reserve.js b/tests/coordination/fixtures/permit-batch-reserve.js new file mode 100644 index 00000000..360d4c87 --- /dev/null +++ b/tests/coordination/fixtures/permit-batch-reserve.js @@ -0,0 +1,17 @@ +"use strict"; + +const { ProtectedPermitStore } = require("../../../lib/coordination/protected-permit-store"); +const [file, anchorDir, fieldsJson, fingerprint, nowText] = process.argv.slice(2); +let store; +try { + store = ProtectedPermitStore.open(file, { + clock: () => Number(nowText), expectedDecisionId: "D-test-init", minimumEpoch: 1, anchorDir, + }); + const permit = store.reserveBoundToBatch(JSON.parse(fieldsJson), fingerprint); + process.stdout.write(JSON.stringify({ ok: true, permitId: permit.permitId, fingerprint: permit.batchFingerprint })); +} catch (error) { + process.stdout.write(JSON.stringify({ ok: false, code: error.code || error.name })); + process.exitCode = 2; +} finally { + if (store) store.close(); +} diff --git a/tests/coordination/permit-authority-service.test.js b/tests/coordination/permit-authority-service.test.js new file mode 100644 index 00000000..6ae30540 --- /dev/null +++ b/tests/coordination/permit-authority-service.test.js @@ -0,0 +1,52 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createPermitAuthorityService } = require("../../lib/coordination/permit-authority-service"); + +test("unauthorized and asynchronous authorization never invoke a permit operation", () => { + let calls = 0; + const targetService = { key: () => { calls += 1; } }; + for (const authorize of [() => false, () => Promise.resolve(true), () => undefined, + () => { throw new Error("private authorization detail"); }]) { + const service = createPermitAuthorityService({ authorize, targetService }); + assert.throws(() => service.dispatch({}, "key", { fingerprint: "x" }), (error) => + error.key === "ERR_PERMIT_INPUT_INVALID" && !error.message.includes("private authorization detail")); + } + assert.equal(calls, 0); +}); + +test("authorization receives immutable resource bindings used by the operation", () => { + const original = { lease: { sessionId: "allowed" }, permit: { operation: "read" } }; + let executed = 0; + const service = createPermitAuthorityService({ + authorize: (peer, action, request) => { + assert.equal(action, "issue"); + assert.ok(Object.isFrozen(request.lease)); + original.lease.sessionId = "other"; + return request.lease.sessionId === "allowed" && request.permit.operation === "read"; + }, + batchSession: { issue: (request) => { executed += 1; assert.equal(request.lease.sessionId, "allowed"); } }, + }); + service.dispatch({}, "issue", original); + assert.equal(executed, 1); + assert.throws(() => service.dispatch({}, "issue", original), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(executed, 1); +}); + +test("authorized operations are routed and unsupported operations are refused", () => { + const peer = Object.freeze({ identity: "verified" }); + const calls = []; + const targetService = { + key: (fingerprint) => { calls.push(["key", fingerprint]); return { fingerprint }; }, + revoke: () => { calls.push(["revoke"]); return { applied: true }; }, + }; + const batchSession = { issue: (args) => { calls.push(["issue", args]); return { ok: true }; } }; + const service = createPermitAuthorityService({ authorize: (actual) => actual === peer, targetService, batchSession }); + assert.deepEqual(service.dispatch(peer, "key", { fingerprint: "f" }), { fingerprint: "f" }); + assert.deepEqual(service.dispatch(peer, "revoke", { ignored: true }), { applied: true }); + assert.deepEqual(service.dispatch(peer, "issue", { lease: { id: 1 }, permit: { id: 2 }, extra: "ignored" }), { ok: true }); + assert.deepEqual(calls[2], ["issue", { lease: { id: 1 }, permit: { id: 2 } }]); + assert.throws(() => service.dispatch(peer, "other", {}), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.throws(() => service.dispatch({}, "key", { fingerprint: "f" }), { key: "ERR_PERMIT_INPUT_INVALID" }); +}); diff --git a/tests/coordination/permit-batch-session.test.js b/tests/coordination/permit-batch-session.test.js new file mode 100644 index 00000000..d6a91cc9 --- /dev/null +++ b/tests/coordination/permit-batch-session.test.js @@ -0,0 +1,193 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { ProtectedPermitStore } = require("../../lib/coordination/protected-permit-store"); +const { createPermitBatchSession } = require("../../lib/coordination/permit-batch-session"); +const delivery = require("../../lib/coordination/permit-delivery"); + +test("invalid signing clock refuses issuance before acquiring or reserving", () => { + let mutations = 0; + const store = { + acquire() { mutations += 1; }, + reserve() { mutations += 1; }, + reserveBoundToBatch() { mutations += 1; }, + acquireAndReserveBoundToBatch() { mutations += 1; }, + }; + const batch = createPermitBatchSession({ store, clock: () => -1 }); + try { + assert.throws(() => batch.issue({ lease: {}, permit: {} }), { key: "ERR_CLOCK_INVALID" }); + assert.equal(mutations, 0); + } finally { batch.close(); } +}); + +test("invalid permit input does not acquire a lease", () => { + let acquired = false; + const store = { acquire() { acquired = true; }, reserve() {}, acquireAndReserveBoundToBatch() { acquired = true; } }; + const batch = createPermitBatchSession({ store, clock: () => 1000 }); + try { + assert.throws(() => batch.issue({ lease: {}, permit: { idempotencyKey: "id", operation: "read", + targetDigest: "invalid", paramsDigest: "b".repeat(64), ttlMs: 1000 } }), { code: "ERR_INVALID_INPUT" }); + assert.equal(acquired, false); + } finally { batch.close(); } +}); + +test("batch binds issuance to the protected store and rejects a new batch on retry", { + skip: Number(process.versions.node.split(".")[0]) < 24, +}, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-batch-test-")); + const dbDir = path.join(root, "db"); + const anchorDir = path.join(root, "anchor"); + fs.mkdirSync(dbDir); + fs.mkdirSync(anchorDir); + let store; + try { + let now = 1_000_000; + store = ProtectedPermitStore.initialize(path.join(dbDir, "state.sqlite"), { + decisionId: "test-decision", anchorDir, anchorMode: "permissive", clock: () => now, + }); + const batch = createPermitBatchSession({ store, clock: () => now }); + const request = { + lease: { owner: "owner", sessionId: "session", hostId: "host", workerId: "worker", + runId: "run", generation: "1", desktopKey: "desktop", ownedAppId: "app", + idempotencyKey: "lease", ttlMs: 5000 }, + permit: { idempotencyKey: "permit", operation: "test", targetDigest: "a".repeat(64), + paramsDigest: "b".repeat(64), ttlMs: 10000, fencing: -1 }, + }; + assert.throws(() => batch.issue({ ...request, permit: { ...request.permit, targetDigest: "invalid" } }), + { code: "ERR_INVALID_INPUT" }); + assert.equal(store.snapshot().desktop.status, "free"); + const output = batch.issue(request); + assert.equal(output.signedPermit.message.deadline_ms, now + 5000); + now += 50; + const retry = batch.issue(request); + assert.deepEqual(retry, output); + retry.signedPermit.message.operation = "tampered"; + assert.deepEqual(batch.issue(request), output); + const reopened = ProtectedPermitStore.open(path.join(dbDir, "state.sqlite"), { + expectedDecisionId: "test-decision", minimumEpoch: 1, anchorDir, + anchorMode: "permissive", clock: () => now, + }); + try { + assert.deepEqual(reopened.snapshot().permits[output.signedPermit.message.permit_id].signedIssuance, output); + } finally { reopened.close(); } + assert.throws(() => batch.issue({ ...request, permit: { ...request.permit, ttlMs: 1 } }), + { code: "ERR_IDEMPOTENCY_MISMATCH" }); + assert.equal(store.snapshot().permits[output.signedPermit.message.permit_id].batchFingerprint, output.fingerprint); + delivery.verifyPermitSignature({ message: output.signedPermit.message, + signature: output.signedPermit.signature, publicKeyPem: output.publicKeyPem, + expectedFingerprint: output.fingerprint, revokedFingerprints: new Set(), + expectedDeadlineMs: output.signedPermit.message.deadline_ms, nowMs: now }); + const state = delivery.createRevocationState(); + const list = batch.signRevocation({ state, fingerprints: ["a".repeat(64)] }); + assert.doesNotMatch(JSON.stringify(list), /PRIVATE KEY|privateKeyRef/); + const replacement = createPermitBatchSession({ store, clock: () => now }); + assert.throws(() => replacement.issue(request), { code: "ERR_IDEMPOTENCY_MISMATCH" }); + replacement.close(); + now = output.signedPermit.message.deadline_ms; + assert.throws(() => batch.issue(request), { code: "ERR_LEASE_NOT_CURRENT" }); + now += 1; + assert.throws(() => batch.issue(request), { code: "ERR_LEASE_NOT_CURRENT" }); + batch.close(); + assert.throws(() => batch.issue({}), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.throws(() => batch.signRevocation({}), { key: "ERR_PERMIT_INPUT_INVALID" }); + } finally { + if (store) store.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("reservation conflict rolls back a newly acquired lease in the same transaction", { + skip: Number(process.versions.node.split(".")[0]) < 24, +}, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-atomic-test-")); + const dbDir = path.join(root, "db"); + const anchorDir = path.join(root, "anchor"); + fs.mkdirSync(dbDir); + fs.mkdirSync(anchorDir); + let store; + try { + store = ProtectedPermitStore.initialize(path.join(dbDir, "state.sqlite"), { + decisionId: "test-decision", anchorDir, anchorMode: "permissive", clock: () => 1000, + verifyRecovery: () => true, + }); + const batch = createPermitBatchSession({ store, clock: () => 1000 }); + try { + const lease = { owner: "owner", sessionId: "session", hostId: "host", workerId: "worker", + runId: "run", generation: "1", desktopKey: "desktop", ownedAppId: "app", + idempotencyKey: "first-lease", ttlMs: 5000 }; + const permit = { idempotencyKey: "permit", operation: "test", targetDigest: "a".repeat(64), + paramsDigest: "b".repeat(64), ttlMs: 1000 }; + let reads = 0; + const invalidClockBatch = createPermitBatchSession({ store, clock: () => (++reads === 1 ? 1000 : -1) }); + const initial = store.snapshot(); + assert.throws(() => invalidClockBatch.issue({ lease, permit }), { key: "ERR_CLOCK_INVALID" }); + assert.deepEqual(store.snapshot(), initial); + invalidClockBatch.close(); + batch.issue({ lease, permit }); + store.release(store.snapshot().lease); + store.recover({}); + const before = store.snapshot(); + assert.throws(() => batch.issue({ lease: { ...lease, idempotencyKey: "second-lease" }, permit }), + { code: "ERR_IDEMPOTENCY_MISMATCH" }); + assert.deepEqual(store.snapshot(), before); + assert.equal(store.snapshot().desktop.status, "free"); + } finally { batch.close(); } + } finally { + if (store) store.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("issuance refuses forged signer output and rolls back the reservation", { + skip: Number(process.versions.node.split(".")[0]) < 24, +}, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-signer-test-")); + const dbDir = path.join(root, "db"), anchorDir = path.join(root, "anchor"); + fs.mkdirSync(dbDir); fs.mkdirSync(anchorDir); + let store; + try { + store = ProtectedPermitStore.initialize(path.join(dbDir, "state.sqlite"), { + decisionId: "test-decision", anchorDir, anchorMode: "permissive", clock: () => 1000, + }); + const lease = { owner: "owner", sessionId: "session", hostId: "host", workerId: "worker", + runId: "run", generation: "1", desktopKey: "desktop", ownedAppId: "app", idempotencyKey: "lease", ttlMs: 5000 }; + const permit = { idempotencyKey: "permit", operation: "test", targetDigest: "a".repeat(64), + paramsDigest: "b".repeat(64), ttlMs: 1000 }; + const key = delivery.createBatchKeyPair(); + const before = store.snapshot(); + assert.throws(() => store.acquireAndReserveBoundToBatch(lease, permit, key.fingerprint, (reserved) => { + reserved.operation = "other"; + return { fingerprint: key.fingerprint, publicKeyPem: key.publicKeyPem, + signedPermit: delivery.buildSignedPermit({ privateKeyRef: key.privateKeyRef, permit: reserved, + issuedAtMs: 1000 }) }; + }), { code: "ERR_INVALID_INPUT" }); + assert.deepEqual(store.snapshot(), before); + for (const tamper of [ + (result) => { result.publicKeyPem += "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----\n"; }, + (result) => { result.signedPermit.signature += "-----BEGIN PRIVATE KEY-----secret"; }, + (result) => { + const message = Object.assign([], result.signedPermit.message); + const bytes = delivery.canonicalBytes(message); + result.signedPermit.message = message; + result.signedPermit.signature = key.privateKeyRef.sign(bytes).toString("base64"); + result.signedPermit.bytes = bytes.toString("base64"); + }, + ]) { + assert.throws(() => store.acquireAndReserveBoundToBatch(lease, permit, key.fingerprint, (reserved) => { + const result = { fingerprint: key.fingerprint, publicKeyPem: key.publicKeyPem, + signedPermit: delivery.buildSignedPermit({ privateKeyRef: key.privateKeyRef, permit: reserved, + issuedAtMs: 1000 }) }; + tamper(result); + return result; + }), { code: "ERR_INVALID_INPUT" }); + assert.deepEqual(store.snapshot(), before); + } + } finally { + if (store) store.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/coordination/permit-managed-cli.test.js b/tests/coordination/permit-managed-cli.test.js new file mode 100644 index 00000000..fe6f965a --- /dev/null +++ b/tests/coordination/permit-managed-cli.test.js @@ -0,0 +1,126 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { spawnSync } = require("node:child_process"); +const { startPermitManagedSession } = require("../../lib/coordination/permit-managed-session"); + +const cli = path.join(__dirname, "../../bin/cli.js"); + +test("permit commands refuse direct invocation without a managed session", () => { + const result = spawnSync(process.execPath, [cli, "permit", "key", "--input", "missing.json"], { encoding: "utf8" }); + assert.equal(result.status, 3); + assert.match(result.stderr, /managed session required/); +}); + +test("managed CLI sends an authenticated request and returns the public result", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-cli-test-")); + const input = path.join(root, "input.json"); + fs.writeFileSync(input, JSON.stringify({ fingerprint: "test" }), { mode: 0o600 }); + let session; + let child; + try { + ({ session, child } = await startPermitManagedSession({ modulePath: cli, + args: ["permit", "key", "--input", input], + authority: { dispatch: (identity, action, payload) => { + assert.ok(identity.pid > 0); + assert.equal(action, "key"); + return { fingerprint: payload.fingerprint }; + } }, + })); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (part) => { output += part; }); + const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code))); + const ready = new Promise((resolve) => child.on("message", (message) => { + if (message && message.type === "permit.result-ready") resolve(); + })); + await session.request("permit.start", {}); + await ready; + await session.close("test_complete"); + assert.equal(await exited, 0); + assert.deepEqual(JSON.parse(output), { ok: true, action: "key", result: { fingerprint: "test" } }); + } finally { + if (session) await session.close("test_cleanup"); + if (child && child.exitCode === null) child.kill(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("managed issuance creates an exclusive private output before issuing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-cli-test-")); + const input = path.join(root, "input.json"); + const output = path.join(root, "permit.json"); + fs.writeFileSync(input, JSON.stringify({ lease: {}, permit: {} }), { mode: 0o600 }); + let calls = 0; + const authority = { dispatch: (_identity, action) => { + assert.equal(action, "issue"); + calls += 1; + return { fingerprint: "f", publicKeyPem: "public", signedPermit: { signature: "signed" } }; + } }; + async function run() { + const { session, child } = await startPermitManagedSession({ modulePath: cli, + args: ["permit", "issue", "--input", input, "--output", output], authority }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (part) => { stdout += part; }); + const exited = new Promise((resolve) => child.once("exit", resolve)); + const ready = new Promise((resolve) => child.on("message", (message) => { + if (message && message.type === "permit.result-ready") resolve(); + })); + try { + await session.request("permit.start", {}); + await ready; + await session.close("test_complete"); + return { code: await exited, stdout }; + } finally { + await session.close("test_cleanup"); + if (child.exitCode === null) child.kill(); + } + } + try { + const first = await run(); + assert.equal(first.code, 0); + assert.equal(fs.statSync(output).mode & 0o777, 0o600); + const original = fs.readFileSync(output, "utf8"); + assert.match(original, /signed/); + assert.doesNotMatch(first.stdout, /signed|PRIVATE KEY/); + const second = await run(); + assert.equal(second.code, 3); + assert.equal(calls, 1); + assert.equal(fs.readFileSync(output, "utf8"), original); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("private PEM input is refused before any managed operation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-cli-test-")); + const input = path.join(root, "input.json"); + fs.writeFileSync(input, JSON.stringify({ value: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" })); + let calls = 0; + let session; + let child; + try { + ({ session, child } = await startPermitManagedSession({ modulePath: cli, + args: ["permit", "key", "--input", input], authority: { dispatch: () => { calls += 1; } } })); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (part) => { stderr += part; }); + const exited = new Promise((resolve) => child.once("exit", resolve)); + const ready = new Promise((resolve) => child.on("message", (message) => { + if (message && message.type === "permit.result-ready") resolve(); + })); + await session.request("permit.start", {}); + await ready; + await session.close("test_complete"); + assert.equal(await exited, 3); + assert.equal(calls, 0); + assert.doesNotMatch(stderr, /secret|PRIVATE KEY/); + } finally { + if (session) await session.close("test_cleanup"); + if (child && child.exitCode === null) child.kill(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/coordination/permit-managed-flow.test.js b/tests/coordination/permit-managed-flow.test.js new file mode 100644 index 00000000..4f5b7861 --- /dev/null +++ b/tests/coordination/permit-managed-flow.test.js @@ -0,0 +1,117 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { ProtectedPermitStore } = require("../../lib/coordination/protected-permit-store"); +const { ProtectedPinStore } = require("../../lib/coordination/protected-pin-store"); +const { createPermitBatchSession } = require("../../lib/coordination/permit-batch-session"); +const { createPermitTargetService } = require("../../lib/coordination/permit-target-service"); +const { createPermitAuthorityService } = require("../../lib/coordination/permit-authority-service"); +const { startPermitManagedSession } = require("../../lib/coordination/permit-managed-session"); +const { BINDING_FIELD_MAP } = require("../../lib/coordination/permit-delivery"); + +test("synthetic managed CLI exercises issue, registration, key, verify and revoke", { + skip: process.platform === "win32" || Number(process.versions.node.split(".")[0]) < 24, + timeout: 30000, +}, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-flow-test-")); + const dbDir = path.join(root, "db"); + const anchorDir = path.join(root, "anchor"); + const pinDir = path.join(root, "pin"); + for (const directory of [dbDir, anchorDir, pinDir]) fs.mkdirSync(directory); + let store; + let batch; + try { + const now = 1_000_000; + store = ProtectedPermitStore.initialize(path.join(dbDir, "state.sqlite"), { + decisionId: "D-test", anchorDir, anchorMode: "permissive", clock: () => now, + }); + batch = createPermitBatchSession({ store, clock: () => now }); + const pinStore = ProtectedPinStore.initialize(pinDir, { decisionId: "D-test", anchorMode: "permissive" }); + let revokeFingerprint = null; + const target = createPermitTargetService({ pinStore, clock: () => now, + observeTarget: () => ({ targetHasProcess: false, targetHasPriorRun: false }), + resolvePermitState: () => store.snapshot(), + pullRevocation: () => { + const state = pinStore.revocationState(); + const fingerprints = [...state.revokedFingerprints]; + if (revokeFingerprint && !fingerprints.includes(revokeFingerprint)) fingerprints.push(revokeFingerprint); + return batch.signRevocation({ state, fingerprints }); + }, + }); + const authority = createPermitAuthorityService({ authorize: () => true, batchSession: batch, targetService: target }); + let counter = 0; + async function run(action, payload, output) { + const input = path.join(root, `input-${counter++}.json`); + fs.writeFileSync(input, JSON.stringify(payload), { mode: 0o600 }); + const args = ["permit", action, "--input", input]; + if (output) args.push("--output", output); + const { session, child } = await startPermitManagedSession({ + modulePath: path.join(__dirname, "../../bin/cli.js"), args, authority, + }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (part) => { stdout += part; }); + const exited = new Promise((resolve) => child.once("exit", resolve)); + const ready = new Promise((resolve) => child.on("message", (message) => { + if (message && message.type === "permit.result-ready") resolve(); + })); + try { + await session.request("permit.start", {}); + await ready; + await session.close("test_complete"); + const code = await exited; + return { code, stdout }; + } finally { + await session.close("test_cleanup"); + if (child.exitCode === null) child.kill(); + } + } + const output = path.join(root, "permit.json"); + const issue = await run("issue", { + lease: { owner: "owner", sessionId: "session", hostId: "host", workerId: "worker", runId: "run", + generation: "1", desktopKey: "desktop", ownedAppId: "app", idempotencyKey: "lease", ttlMs: 5000 }, + permit: { idempotencyKey: "permit", operation: "op", targetDigest: "a".repeat(64), + paramsDigest: "b".repeat(64), ttlMs: 10000 }, + }, output); + assert.equal(issue.code, 0); + assert.doesNotMatch(issue.stdout, /signature|PRIVATE KEY/); + assert.equal(fs.statSync(output).mode & 0o777, 0o600); + const issued = JSON.parse(fs.readFileSync(output, "utf8")); + const register = await run("register", { batchIdentity: { decision_id: "D-test", instance_id: "instance", + fingerprint: issued.fingerprint }, publicKeyPem: issued.publicKeyPem, + sourceDigest: "sha256:" + "a".repeat(64), deploymentDigest: "sha256:" + "b".repeat(64), + runId: "run", generation: "1", validFrom: 1, validUntil: 2_000_000 }); + assert.equal(register.code, 0); + assert.equal((await run("key", { fingerprint: issued.fingerprint })).code, 0); + const request = { fingerprint: issued.fingerprint, message: issued.signedPermit.message, + signature: issued.signedPermit.signature }; + const verified = await run("verify", request); + assert.equal(verified.code, 0); + assert.doesNotMatch(verified.stdout, /signature|bytes/); + const secondOutput = path.join(root, "permit-second.json"); + assert.equal((await run("issue", { + lease: { owner: "owner", sessionId: "session", hostId: "host", workerId: "worker", runId: "run", + generation: "1", desktopKey: "desktop", ownedAppId: "app", idempotencyKey: "lease", ttlMs: 5000 }, + permit: { idempotencyKey: "permit-second", operation: "other", targetDigest: "a".repeat(64), + paramsDigest: "b".repeat(64), ttlMs: 10000 }, + }, secondOutput)).code, 0); + const second = JSON.parse(fs.readFileSync(secondOutput, "utf8")); + store.release(store.snapshot().lease); + const consumeFields = { permitId: issued.signedPermit.message.permit_id, + ...Object.fromEntries(BINDING_FIELD_MAP.map(([storage, signed]) => [storage, issued.signedPermit.message[signed]])) }; + assert.throws(() => store.consume(consumeFields), { code: "ERR_PERMIT_NOT_CONSUMABLE" }); + assert.equal((await run("verify", { fingerprint: second.fingerprint, + message: second.signedPermit.message, signature: second.signedPermit.signature })).code, 3); + revokeFingerprint = issued.fingerprint; + assert.equal((await run("revoke", {})).code, 0); + assert.equal((await run("verify", request)).code, 3); + } finally { + if (batch) batch.close(); + if (store) store.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/coordination/permit-managed-session.test.js b/tests/coordination/permit-managed-session.test.js new file mode 100644 index 00000000..b0b81059 --- /dev/null +++ b/tests/coordination/permit-managed-session.test.js @@ -0,0 +1,44 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { startPermitManagedSession } = require("../../lib/coordination/permit-managed-session"); + +test("managed permit session refuses an unauthorized action and serves its allowlisted action", async () => { + let calls = 0; + const authority = { dispatch: (identity, action, payload) => { + assert.ok(identity.pid > 0); + calls += 1; + return { action, fingerprint: payload.fingerprint }; + } }; + const { session, child } = await startPermitManagedSession({ + modulePath: path.join(__dirname, "..", "governed", "fixtures", "m044-ms002-executor.js"), + args: ["--mode=proxy"], authority, + }); + function request(operation, payload) { + return new Promise((resolve, reject) => { + const requestId = `${operation}-${Math.random()}`; + const timer = setTimeout(() => { child.off("message", listener); reject(new Error("request timeout")); }, 5000); + function listener(message) { + if (!message || message.type !== "proxy-result" || message.requestId !== requestId) return; + clearTimeout(timer); + child.off("message", listener); + resolve(message); + } + child.on("message", listener); + child.send({ type: "proxy", requestId, operation, payload }); + }); + } + try { + const result = await request("permit.key", { fingerprint: "abc" }); + assert.equal(result.ok, true); + assert.deepEqual(result.result, { action: "key", fingerprint: "abc" }); + const denied = await request("permit.other", {}); + assert.equal(denied.ok, false); + assert.equal(calls, 1); + } finally { + await session.close("test_cleanup"); + if (child.exitCode === null) child.kill(); + } +}); diff --git a/tests/coordination/permit-revocation-signing.test.js b/tests/coordination/permit-revocation-signing.test.js new file mode 100644 index 00000000..4376c841 --- /dev/null +++ b/tests/coordination/permit-revocation-signing.test.js @@ -0,0 +1,49 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const delivery = require("../../lib/coordination/permit-delivery"); +const { signRevocationList } = require("../../lib/coordination/permit-revocation-signing"); + +function input() { + const key = delivery.createBatchKeyPair(); + return { key, options: { privateKeyRef: key.privateKeyRef, publicKeyPem: key.publicKeyPem, + state: delivery.createRevocationState(), fingerprints: ["a".repeat(64)], issuedAtMs: 1000 } }; +} + +test("signed envelope is accepted by the existing revocation verifier", () => { + const { key, options } = input(); + const list = signRevocationList(options); + delivery.applyRevocationList({ state: options.state, list, publicKeyPem: key.publicKeyPem, + pinnedFingerprints: new Set([key.fingerprint]), nowMs: 1000 }); + assert.equal(options.state.appliedRevocationSequence, 1); + assert.ok(options.state.revokedFingerprints.has("a".repeat(64))); + assert.ok(!JSON.stringify(list).includes("PRIVATE KEY")); +}); + +test("modified envelope is rejected without advancing revocation state", () => { + const { key, options } = input(); + const list = signRevocationList(options); + list.revoked_fingerprints = ["b".repeat(64)]; + assert.throws(() => delivery.applyRevocationList({ state: options.state, list, + publicKeyPem: key.publicKeyPem, pinnedFingerprints: new Set([key.fingerprint]), nowMs: 1000 })); + assert.equal(options.state.appliedRevocationSequence, 0); +}); + +test("signing refuses a truncated set, invalid sequence, and revoked signer", () => { + const { key, options } = input(); + options.state.revokedFingerprints.add("b".repeat(64)); + assert.throws(() => signRevocationList(options), { key: "ERR_REVOCATION_SET_NOT_MONOTONIC" }); + options.state.revokedFingerprints.clear(); + options.state.appliedRevocationSequence = Number.MAX_SAFE_INTEGER; + assert.throws(() => signRevocationList(options), { key: "ERR_PERMIT_INPUT_INVALID" }); + options.state.appliedRevocationSequence = 0; + options.state.revokedFingerprints.add(key.fingerprint); + assert.throws(() => signRevocationList(options), { key: "ERR_REVOCATION_SIGNER_INVALID" }); +}); + +test("a key handle that does not match the declared public key is refused", () => { + const { options } = input(); + options.privateKeyRef = delivery.createBatchKeyPair().privateKeyRef; + assert.throws(() => signRevocationList(options), { key: "ERR_REVOCATION_SIGNER_INVALID" }); +}); diff --git a/tests/coordination/permit-target-service.test.js b/tests/coordination/permit-target-service.test.js new file mode 100644 index 00000000..892a9d1c --- /dev/null +++ b/tests/coordination/permit-target-service.test.js @@ -0,0 +1,389 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const test = require("node:test"); +const { ProtectedPinStore } = require("../../lib/coordination/protected-pin-store"); +const delivery = require("../../lib/coordination/permit-delivery"); +const { signRevocationList } = require("../../lib/coordination/permit-revocation-signing"); +const { createPermitTargetService } = require("../../lib/coordination/permit-target-service"); + +function fixture(revoked) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-target-test-")); + const pinDir = path.join(root, "pin"); + fs.mkdirSync(pinDir); + const pinStore = ProtectedPinStore.initialize(pinDir, { decisionId: "D-test", anchorMode: "permissive" }); + const batch = delivery.createBatchKeyPair(); + pinStore.registerPin({ batchIdentity: { decision_id: "D-test", instance_id: "instance", + fingerprint: batch.fingerprint }, publicKeyPem: batch.publicKeyPem, nowMs: 1000, + sourceDigest: "sha256:" + "a".repeat(64), deploymentDigest: "sha256:" + "b".repeat(64), + runId: "run", generation: "1", validFrom: 1, validUntil: 5000, + write: () => { const fd = fs.openSync(path.join(pinDir, "marker"), "wx", 0o600); fs.closeSync(fd); return true; } }); + const signed = delivery.buildSignedPermit({ privateKeyRef: batch.privateKeyRef, issuedAtMs: 1000, + permit: { permitId: "p", leaseId: "l", sessionId: "s", hostId: "h", workerId: "w", + runId: "run", generation: "1", desktopKey: "d", ownedAppId: "a", serviceEpoch: 1, + fencing: 1, operation: "op", targetDigest: "a".repeat(64), paramsDigest: "b".repeat(64), + sequence: 1, deadline: 2000 } }); + const list = signRevocationList({ privateKeyRef: batch.privateKeyRef, publicKeyPem: batch.publicKeyPem, + state: delivery.createRevocationState(), fingerprints: revoked ? [batch.fingerprint] : [], issuedAtMs: 1000 }); + return { root, pinStore, batch, signed, list, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; +} + +function currentState(f, overrides = {}) { + const message = f.signed.message; + const permit = { permitId: message.permit_id, status: "reserved", batchFingerprint: f.batch.fingerprint, + deadline: message.deadline_ms, ...Object.fromEntries(delivery.BINDING_FIELD_MAP.map(([storage, signed]) => [storage, message[signed]])), + ...overrides }; + const lease = { ...permit, status: "active", expiresAt: 5000 }; + return { permits: { [permit.permitId]: permit }, lease, service_epoch: permit.serviceEpoch, + fencing: permit.fencing, desktop: { status: "held", key: permit.desktopKey } }; +} + +test("verification first applies a signed fresh revocation list", () => { + const f = fixture(false); + try { + const service = createPermitTargetService({ pinStore: f.pinStore, pullRevocation: () => f.list, + resolvePermitState: () => currentState(f), clock: () => 1000 }); + assert.equal(service.key(f.batch.fingerprint).publicKeyPem, f.batch.publicKeyPem); + const result = service.verify({ fingerprint: f.batch.fingerprint, message: f.signed.message, signature: f.signed.signature }); + assert.equal(result.ok, true); + assert.equal(result.executionAuthorized, false); + assert.equal(Object.hasOwn(result, "bytes"), false); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 1); + } finally { f.cleanup(); } +}); + +test("accepted permit sequence survives reopening and rejects replay", () => { + const f = fixture(false); + try { + const makeService = (pinStore) => createPermitTargetService({ pinStore, clock: () => 1000, + resolvePermitState: () => currentState(f), pullRevocation: () => signRevocationList({ + privateKeyRef: f.batch.privateKeyRef, publicKeyPem: f.batch.publicKeyPem, + state: pinStore.revocationState(), fingerprints: [], issuedAtMs: 1000 }) }); + const request = { fingerprint: f.batch.fingerprint, message: f.signed.message, signature: f.signed.signature }; + assert.equal(makeService(f.pinStore).verify(request).ok, true); + const reopened = ProtectedPinStore.open(f.pinStore.pinDir, { decisionId: "D-test", anchorMode: "permissive" }); + assert.throws(() => makeService(reopened).verify(request), { key: "ERR_PERMIT_SEQUENCE_REPLAY" }); + assert.equal(reopened.assertPermitReplayState(), 1); + assert.equal(reopened.revocationState().appliedRevocationSequence, 2); + } finally { f.cleanup(); } +}); + +test("legacy pin state is not treated as an empty permit history", () => { + const f = fixture(false); + try { + const statePath = path.join(f.pinStore.pinDir, "pin-state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + state.schema_version = 1; + delete state.highest_accepted_permit_sequence; + fs.writeFileSync(statePath, JSON.stringify(state)); + let pulled = false; + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + resolvePermitState: () => currentState(f), pullRevocation: () => { pulled = true; return f.list; } }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.equal(pulled, false); + assert.deepEqual(JSON.parse(fs.readFileSync(statePath, "utf8")), state); + } finally { f.cleanup(); } +}); + +test("a rejected permit advances only a valid revocation list", () => { + const f = fixture(false); + try { + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + resolvePermitState: () => currentState(f), pullRevocation: () => f.list }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: "invalid" })); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 1); + assert.equal(f.pinStore.assertPermitReplayState(), 0); + } finally { f.cleanup(); } +}); + +test("only the authenticated revocation set is committed if the source object changes", () => { + const f = fixture(false); + try { + const injected = "c".repeat(64); + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => { + f.list.revoked_fingerprints.push(injected); + return currentState(f); + } }); + assert.equal(service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }).ok, true); + assert.equal(f.pinStore.revocationState().revokedFingerprints.has(injected), false); + } finally { f.cleanup(); } +}); + +test("custom iteration cannot change the signed revocation set", () => { + const f = fixture(false); + try { + const injected = "c".repeat(64); + f.list.revoked_fingerprints[Symbol.iterator] = function* () { yield injected; }; + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => currentState(f) }); + assert.equal(service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }).ok, true); + assert.equal(f.pinStore.revocationState().revokedFingerprints.has(injected), false); + } finally { f.cleanup(); } +}); + +test("the committed revocation sequence is the authenticated sequence", () => { + const f = fixture(false); + try { + let reads = 0; + Object.defineProperty(f.list, "revocation_sequence", { enumerable: true, get: () => (++reads === 1 ? 1000 : 1) }); + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => currentState(f) }); + assert.equal(service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }).ok, true); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 1); + assert.throws(() => f.pinStore.commitRevocation({ sequence: 3, revokedFingerprints: [] }), + { key: "ERR_REVOCATION_SEQUENCE_BROKEN" }); + } finally { f.cleanup(); } +}); + +test("verification uses a detached authoritative snapshot", () => { + const f = fixture(false); + try { + const state = currentState(f); + let reads = 0; + const service = createPermitTargetService({ pinStore: f.pinStore, pullRevocation: () => f.list, + resolvePermitState: () => state, clock: () => { + if (++reads === 3) state.permits.p.deadline = 1500; + return 1200; + } }); + assert.equal(service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }).ok, true); + } finally { f.cleanup(); } +}); + +test("verification uses a detached request when a provider mutates the caller object", () => { + const f = fixture(false); + try { + const state = currentState(f); + const request = { fingerprint: f.batch.fingerprint, message: f.signed.message, signature: f.signed.signature }; + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => { + request.message.operation = "other"; + return state; + } }); + assert.equal(service.verify(request).ok, true); + assert.equal(request.message.operation, "other"); + } finally { f.cleanup(); } +}); + +test("undefined signed bindings are refused even when the snapshot has the same value", () => { + const f = fixture(false); + try { + const message = { ...f.signed.message, worker_identity: undefined }; + const signature = f.batch.privateKeyRef.sign(delivery.canonicalBytes(message)).toString("base64"); + const state = currentState(f); + state.permits.p.workerId = undefined; + state.lease.workerId = undefined; + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => state }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, message, signature }), + { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(f.pinStore.assertPermitReplayState(), 0); + } finally { f.cleanup(); } +}); + +test("lease expiry during verification is refused", () => { + const f = fixture(false); + try { + let reads = 0; + const state = currentState(f); + state.lease.expiresAt = 1001; + const service = createPermitTargetService({ pinStore: f.pinStore, + clock: () => (++reads < 3 ? 1000 : 1001), pullRevocation: () => f.list, + resolvePermitState: () => state }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_PERMIT_INPUT_INVALID" }); + } finally { f.cleanup(); } +}); + +test("version 2 pin state missing its permit high-water mark is refused", () => { + const f = fixture(false); + try { + const statePath = path.join(f.pinStore.pinDir, "pin-state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + delete state.highest_accepted_permit_sequence; + fs.writeFileSync(statePath, JSON.stringify(state)); + assert.throws(() => f.pinStore.assertPermitReplayState(), { key: "ERR_PIN_STATE_CORRUPT" }); + } finally { f.cleanup(); } +}); + +test("a failed state replacement leaves no temporary file and can be retried", () => { + const f = fixture(false); + const originalRename = fs.renameSync; + try { + fs.renameSync = (source, target) => { + if (target === path.join(f.pinStore.pinDir, "pin-state.json")) { + const error = new Error("injected write failure"); + error.code = "EIO"; + throw error; + } + return originalRename(source, target); + }; + assert.throws(() => f.pinStore.commitRevocation({ sequence: 1, revokedFingerprints: [] }), { code: "EIO" }); + fs.renameSync = originalRename; + assert.equal(fs.readdirSync(f.pinStore.pinDir).some((name) => name.startsWith(".pin-state.json.tmp-")), false); + f.pinStore.commitRevocation({ sequence: 1, revokedFingerprints: [] }); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 1); + } finally { fs.renameSync = originalRename; f.cleanup(); } +}); + +test("fresh revocation and unavailable pulls both refuse verification", () => { + const f = fixture(true); + try { + const request = { fingerprint: f.batch.fingerprint, message: f.signed.message, signature: f.signed.signature }; + const unavailable = createPermitTargetService({ pinStore: f.pinStore, pullRevocation: () => { throw new Error("offline"); }, + resolvePermitState: () => currentState(f), clock: () => 1000 }); + assert.throws(() => unavailable.verify(request), { key: "ERR_REVOCATION_PULL_FAILED" }); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 0); + const revoked = createPermitTargetService({ pinStore: f.pinStore, pullRevocation: () => f.list, + resolvePermitState: () => currentState(f), clock: () => 1000 }); + assert.throws(() => revoked.verify(request), { key: "ERR_REVOCATION_SIGNER_INVALID" }); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 1); + } finally { f.cleanup(); } +}); + +test("a deadline differing from the trusted store is refused", () => { + const f = fixture(false); + try { + const service = createPermitTargetService({ pinStore: f.pinStore, pullRevocation: () => f.list, + resolvePermitState: () => currentState(f, { deadline: 1999 }), clock: () => 1000 }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_PERMIT_DEADLINE_MISMATCH" }); + } finally { f.cleanup(); } +}); + +test("revoked, rebound or unavailable authoritative state rejects the permit", () => { + for (const alter of [ + (state) => { state.permits.p.status = "revoked"; }, + (state) => { state.lease.status = "released"; }, + (state) => { state.service_epoch += 1; }, + (state) => { state.fencing += 1; }, + (state) => { state.permits.p.workerId = "other"; }, + (state) => { state.permits.p.batchFingerprint = "0".repeat(64); }, + ]) { + const f = fixture(false); + try { + const state = currentState(f); + alter(state); + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => state }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(f.pinStore.assertPermitReplayState(), 0); + } finally { f.cleanup(); } + } + const f = fixture(false); + try { + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => 1000, + pullRevocation: () => f.list, resolvePermitState: () => null }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(f.pinStore.assertPermitReplayState(), 0); + } finally { f.cleanup(); } +}); + +test("verification checks the deadline again after the revocation pull", () => { + const f = fixture(false); + try { + let currentTime = 1000; + const service = createPermitTargetService({ pinStore: f.pinStore, + pullRevocation: () => { currentTime = 100000; return f.list; }, + resolvePermitState: () => currentState(f), clock: () => currentTime }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_PERMIT_DEADLINE_EXPIRED" }); + } finally { f.cleanup(); } +}); + +test("invalid clock is refused before applying revocation", () => { + const f = fixture(false); + try { + const service = createPermitTargetService({ pinStore: f.pinStore, pullRevocation: () => f.list, + resolvePermitState: () => currentState(f), clock: () => -1 }); + assert.throws(() => service.verify({ fingerprint: f.batch.fingerprint, + message: f.signed.message, signature: f.signed.signature }), { key: "ERR_CLOCK_INVALID" }); + assert.equal(f.pinStore.revocationState().appliedRevocationSequence, 0); + } finally { f.cleanup(); } +}); + +test("clock rollback poisons the domain and stays poisoned after reopening", () => { + const f = fixture(false); + try { + let time = 1000; + const service = createPermitTargetService({ pinStore: f.pinStore, clock: () => time, + resolvePermitState: () => currentState(f), pullRevocation: () => signRevocationList({ + privateKeyRef: f.batch.privateKeyRef, publicKeyPem: f.batch.publicKeyPem, + state: f.pinStore.revocationState(), fingerprints: [], issuedAtMs: 1000 }) }); + const request = { fingerprint: f.batch.fingerprint, message: f.signed.message, signature: f.signed.signature }; + assert.equal(service.verify(request).ok, true); + time = 999; + assert.throws(() => service.verify(request), { key: "ERR_CLOCK_INVALID" }); + const reopened = ProtectedPinStore.open(f.pinStore.pinDir, { decisionId: "D-test", anchorMode: "permissive" }); + assert.equal(reopened.isClockPoisoned(), true); + assert.equal(reopened.assertPermitReplayState(), 1); + } finally { f.cleanup(); } +}); + +test("a failed rollback-marker write still poisons other in-process handles", () => { + const f = fixture(false); + const originalRename = fs.renameSync; + try { + f.pinStore.observePermitClock(1000); + fs.renameSync = (source, target) => { + if (target === path.join(f.pinStore.pinDir, "pin-state.json")) { + const error = new Error("injected poison write failure"); + error.code = "EIO"; + throw error; + } + return originalRename(source, target); + }; + assert.throws(() => f.pinStore.observePermitClock(999), { code: "EIO" }); + fs.renameSync = originalRename; + const reopened = ProtectedPinStore.open(f.pinStore.pinDir, { decisionId: "D-test", anchorMode: "permissive" }); + assert.equal(reopened.isClockPoisoned(), true); + assert.throws(() => reopened.observePermitClock(1000), { key: "ERR_CLOCK_INVALID" }); + } finally { fs.renameSync = originalRename; f.cleanup(); } +}); + +test("registration uses the host observer and creates a private exclusive pin", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "permit-register-test-")); + try { + const pinStore = ProtectedPinStore.initialize(root, { decisionId: "D-test", anchorMode: "permissive" }); + const batch = delivery.createBatchKeyPair(); + const request = { batchIdentity: { decision_id: "D-test", instance_id: "instance", fingerprint: batch.fingerprint }, + publicKeyPem: batch.publicKeyPem, sourceDigest: "sha256:" + "a".repeat(64), + deploymentDigest: "sha256:" + "b".repeat(64), runId: "run", generation: "1", + validFrom: 1, validUntil: 5000, targetHasProcess: false, targetHasPriorRun: false }; + const options = { pinStore, pullRevocation: () => null, resolvePermitState: () => null, clock: () => 1000 }; + const privatePem = crypto.generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }); + const privateInput = createPermitTargetService({ ...options, + observeTarget: () => ({ targetHasProcess: false, targetHasPriorRun: false }) }); + assert.throws(() => privateInput.register({ ...request, publicKeyPem: privatePem }), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(pinStore.listPins().length, 0); + const unconfigured = createPermitTargetService(options); + assert.throws(() => unconfigured.register(request), { key: "ERR_PERMIT_INPUT_INVALID" }); + const unknown = createPermitTargetService({ ...options, + observeTarget: () => ({ targetHasProcess: null, targetHasPriorRun: false }) }); + assert.throws(() => unknown.register(request), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(pinStore.listPins().length, 0); + const rejected = createPermitTargetService({ ...options, + observeTarget: () => ({ targetHasProcess: true, targetHasPriorRun: false }) }); + assert.throws(() => rejected.register(request), { key: "ERR_PIN_NOT_EXCLUSIVE" }); + assert.equal(pinStore.listPins().length, 0); + const accepted = createPermitTargetService({ ...options, + observeTarget: () => ({ targetHasProcess: false, targetHasPriorRun: false }) }); + assert.equal(accepted.register(request).registered, true); + const file = path.join(root, `pin-${batch.fingerprint}.json`); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).fingerprint, batch.fingerprint); + assert.throws(() => accepted.register(request), { key: "ERR_PIN_NOT_EXCLUSIVE" }); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/coordination/protected-permit-store.test.js b/tests/coordination/protected-permit-store.test.js index 072075a5..b5416f3d 100644 --- a/tests/coordination/protected-permit-store.test.js +++ b/tests/coordination/protected-permit-store.test.js @@ -112,6 +112,74 @@ test("MS-003 initialization is explicit and a missing or malformed store never r } }); +test("batch-bound reservation refuses legacy and different-batch idempotent retries", { skip: !supportsSqlite }, () => { + const f = fixture(); + try { + const lease = f.store.acquire(leaseFields()); + const fields = permitFields(lease); + const original = f.store.reserve(fields); + assert.equal(original.batchFingerprint, undefined); + assert.throws(() => f.store.reserveBoundToBatch(fields, "a".repeat(64)), { code: "ERR_IDEMPOTENCY_MISMATCH" }); + assert.deepEqual(f.store.reserve(fields), original); + } finally { f.cleanup(); } +}); + +test("legacy batch reservation without stored issuance is not re-signed", { skip: !supportsSqlite }, () => { + const f = fixture(); + try { + const leaseRequest = leaseFields(); + const lease = f.store.acquire(leaseRequest); + const request = permitFields(lease); + const fingerprint = "a".repeat(64); + f.store.reserveBoundToBatch(request, fingerprint); + const before = f.store.snapshot(); + let signerInvoked = false; + assert.throws(() => f.store.acquireAndReserveBoundToBatch(leaseRequest, request, fingerprint, () => { + signerInvoked = true; + return {}; + }), { code: "ERR_IDEMPOTENCY_MISMATCH" }); + assert.equal(signerInvoked, false); + assert.deepEqual(f.store.snapshot(), before); + } finally { f.cleanup(); } +}); + +test("lease mutations reject a stale epoch or fencing value", { skip: !supportsSqlite }, () => { + const f = fixture(); + try { + const lease = f.store.acquire(leaseFields()); + const before = f.store.snapshot(); + for (const method of ["renew", "release", "revoke"]) { + for (const fields of [{ ...lease, serviceEpoch: lease.serviceEpoch + 1 }, + { ...lease, fencing: lease.fencing + 1 }, { ...lease, fencing: undefined }]) { + assert.throws(() => f.store[method](fields), { code: "ERR_LEASE_NOT_CURRENT" }); + assert.deepEqual(f.store.snapshot(), before); + } + } + } finally { f.cleanup(); } +}); + +test("competing processes cannot bind the same permit to different batches", { skip: !supportsSqlite }, async () => { + const f = fixture(); + try { + const lease = f.store.acquire(leaseFields()); + const fields = permitFields(lease); + const run = (fingerprint) => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, "fixtures", "permit-batch-reserve.js"), + f.file, f.anchorDir, JSON.stringify(fields), fingerprint, "1000000"], { stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (part) => { output += part; }); + child.on("error", reject); + child.on("close", () => { try { resolve(JSON.parse(output)); } catch (error) { reject(error); } }); + }); + const results = await Promise.all([run("a".repeat(64)), run("b".repeat(64))]); + assert.equal(results.filter((item) => item.ok).length, 1); + assert.equal(results.find((item) => !item.ok).code, "ERR_IDEMPOTENCY_MISMATCH"); + const winner = results.find((item) => item.ok); + assert.equal(f.store.snapshot().permits[winner.permitId].batchFingerprint, winner.fingerprint); + } finally { f.cleanup(); } +}); + test("MS-003 reserve and consume bind the exact Worker, target and parameters once", { skip: !supportsSqlite }, () => { const f = fixture(); try { @@ -160,6 +228,19 @@ test("MS-003 an elapsed permit is durably expired and cannot be replayed", { ski } finally { f.cleanup(); } }); +test("a permit cannot be consumed at its exact deadline", { skip: !supportsSqlite }, () => { + const f = fixture(); + try { + const lease = f.store.acquire(leaseFields()); + const permit = f.store.reserve(permitFields(lease, { ttlMs: 100 })); + f.advance(100); + assert.throws(() => f.store.consume(consumeFields(permit)), { code: "ERR_PERMIT_NOT_CONSUMABLE" }); + const state = f.store.snapshot(); + assert.equal(state.permits[permit.permitId].status, "expired"); + assert.equal(state.tombstones[permit.permitId].reason, "deadline"); + } finally { f.cleanup(); } +}); + test("MS-003 an in-flight step blocks another reserve and consume", { skip: !supportsSqlite }, () => { const f = fixture(); try { diff --git a/tests/coordination/protected-pin-store.test.js b/tests/coordination/protected-pin-store.test.js index 81a1fb43..0381e350 100644 --- a/tests/coordination/protected-pin-store.test.js +++ b/tests/coordination/protected-pin-store.test.js @@ -1,10 +1,14 @@ "use strict"; const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); +const { spawn } = require("node:child_process"); +const readline = require("node:readline"); +const coordination = require("../../lib/coordination/protected-store-lock"); const { ProtectedPinStore, @@ -38,11 +42,26 @@ test("the carried registration fields are NOT part of the batch identity", () => assert.deepEqual(BATCH_IDENTITY_FIELDS, ["decision_id", "instance_id", "fingerprint"]); }); +test("a private PEM is refused before any pin is written", () => { + const f = fixture(); + try { + const pair = crypto.generateKeyPairSync("ed25519"); + const privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" }); + const publicKeyPem = pair.publicKey.export({ type: "spki", format: "pem" }); + let wrote = false; + assert.throws(() => f.store.registerPin({ batchIdentity: { + decision_id: "D-test-pin", instance_id: "instance", fingerprint: publicKeyFingerprint(publicKeyPem), + }, publicKeyPem: privatePem, write: () => { wrote = true; return true; } }), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(wrote, false); + assert.deepEqual(f.store.listPins(), []); + } finally { f.cleanup(); } +}); + function fixture(options = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coord-pin-")); const pinDir = path.join(dir, "pin"); fs.mkdirSync(pinDir, { recursive: true }); - const store = ProtectedPinStore.open(pinDir, { + const store = ProtectedPinStore.initialize(pinDir, { decisionId: options.decisionId || "D-test-pin", // The development environment has no separate service identity, so the // permission contract is opted out of explicitly here. @@ -217,6 +236,21 @@ test("a concurrent registrant that loses the race does not overwrite the winner" } }); +test("an existing stale-looking lock is not moved or reclaimed", () => { + const f = fixture(); + try { + const lockPath = path.join(f.pinDir, ".pin-critical-section.lock"); + const content = JSON.stringify({ owner: "old", pid: 2147483647, token: "old", expiresAt: "2000-01-01T00:00:00Z" }); + fs.renameSync(lockPath, path.join(f.pinDir, "original-fence")); + fs.writeFileSync(lockPath, content, { flag: "wx", mode: 0o600 }); + let ran = false; + assert.equal(codeOf(() => f.store.criticalSection(() => { ran = true; })), "ERR_PIN_STATE_CORRUPT"); + assert.equal(ran, false); + assert.equal(fs.readFileSync(lockPath, "utf8"), content); + assert.equal(fs.readdirSync(f.pinDir).some((name) => name.includes(".stale.")), false); + } finally { f.cleanup(); } +}); + test("the fingerprint is derived from the key, not taken from the caller", () => { // Design counterexample 15: a fingerprint that disagrees with the // snapshot registration is refused. A self-reported fingerprint would @@ -326,6 +360,23 @@ test("the direct revocation registration advances the chain by one", () => { } }); +test("direct revocation rejects gaps and replay without changing state", () => { + const f = fixture(); + try { + const fingerprint = "c".repeat(64); + assert.throws(() => f.store.registerDirectRevocation({ fingerprint, sequence: 2 }), + { key: "ERR_REVOCATION_SEQUENCE_BROKEN" }); + assert.equal(f.store.revocationState().appliedRevocationSequence, 0); + assert.equal(f.store.revocationState().revokedFingerprints.size, 0); + f.store.registerDirectRevocation({ fingerprint, sequence: 1 }); + assert.throws(() => f.store.registerDirectRevocation({ fingerprint: "d".repeat(64), sequence: 1 }), + { key: "ERR_REVOCATION_SEQUENCE_BROKEN" }); + const state = f.store.revocationState(); + assert.equal(state.appliedRevocationSequence, 1); + assert.deepEqual([...state.revokedFingerprints], [fingerprint]); + } finally { f.cleanup(); } +}); + test("the verification path only trusts the keys in the pin", () => { // Design section 2.3.1 R3-B2 ruling 2: Windows side trusts ONLY the // public key in the pin. A key carried by a list itself is never @@ -433,7 +484,7 @@ test("on Windows the service identity must be declared, or the domain is refused ); // With the declaration the domain opens. - const store = ProtectedPinStore.open(pinDir, { + const store = ProtectedPinStore.initialize(pinDir, { decisionId: "D-test-strict", anchorMode: anchorModule.ANCHOR_MODE.strict, platform: "windows", @@ -488,13 +539,13 @@ test("a declared service identity that is not the caller is refused", () => { // being unreadable and the state file not existing are now different // facts, and only the second one is an empty domain. -test("a domain that never held state reads as empty", () => { +test("an explicitly initialized domain contains durable empty state", () => { const f = fixture(); try { assert.equal(f.store.listPins().length, 0); assert.equal(f.store.revocationState().appliedRevocationSequence, 0); - // No state was ever written, so no initialisation marker exists yet. - assert.equal(fs.existsSync(path.join(f.pinDir, DOMAIN_MARKER_FILE)), false); + assert.equal(fs.existsSync(path.join(f.pinDir, DOMAIN_MARKER_FILE)), true); + assert.equal(fs.existsSync(path.join(f.pinDir, "pin-state.json")), true); } finally { f.cleanup(); } @@ -536,11 +587,11 @@ test("a deleted state file is refused, and the revoked set does not reset to emp write: atomicCreate(path.join(f.dir, "pin.marker")), }); f.store.commitRevocation({ - sequence: 4, + sequence: 1, revokedFingerprints: ["a".repeat(64)], }); assert.equal(f.store.revocationState().revokedFingerprints.size, 1); - assert.equal(f.store.revocationState().appliedRevocationSequence, 4); + assert.equal(f.store.revocationState().appliedRevocationSequence, 1); // The domain was initialised, so the marker is present. assert.equal(fs.existsSync(path.join(f.pinDir, DOMAIN_MARKER_FILE)), true); @@ -572,3 +623,246 @@ test("the initialisation marker is written before the first state file", () => { f.cleanup(); } }); + +function processFixture(t) { + const f = fixture(), children = []; + t.after(async () => { + for (const child of children) if (child.proc.exitCode === null) child.proc.kill("SIGKILL"); + await Promise.all(children.map(child => child.done)); + f.cleanup(); + }); + function child(mode, version) { + const proc = spawn(process.execPath, [path.join(__dirname, "fixtures/protected-pin-store-process.js"), + f.pinDir, mode, path.join(f.dir, "release"), ...(version ? [version] : [])], { stdio: ["ignore", "pipe", "pipe"] }); + const events = [], waiters = []; + let stderr = "", closed = false; + proc.stderr.on("data", chunk => { stderr += chunk; }); + readline.createInterface({ input: proc.stdout }).on("line", line => { + const event = JSON.parse(line); events.push(event); + for (const waiter of [...waiters]) if (waiter.name === event.event) { + waiters.splice(waiters.indexOf(waiter), 1); clearTimeout(waiter.timer); waiter.resolve(event); + } + }); + const done = new Promise((resolve, reject) => { + proc.once("error", reject); + proc.once("close", (code, signal) => { + closed = true; + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); waiter.reject(new Error(`missing ${waiter.name}: ${code}/${signal} ${stderr}`)); + } + resolve({ code, signal }); + }); + }); + const result = { proc, done, events, wait(name) { + const event = events.find(event => event.event === name); + if (event) return Promise.resolve(event); + if (closed) return Promise.reject(new Error(`child closed: ${stderr}`)); + return new Promise((resolve, reject) => { + const waiter = { name, resolve, reject }; + waiter.timer = setTimeout(() => { + waiters.splice(waiters.indexOf(waiter), 1); reject(new Error(`deadline: ${name} ${stderr}`)); + }, 12000); + waiters.push(waiter); + }); + } }; + children.push(result); return result; + } + return { ...f, child, release() { fs.writeFileSync(path.join(f.dir, "release"), "go"); } }; +} + +test("two real writers serialize read-decide-write and reject the losing replay", async t => { + const f = processFixture(t), first = f.child("hold"); + await first.wait("paused"); + const second = f.child("write"); await second.wait("starting"); + f.release(); + assert.equal((await first.wait("result")).ok, true); + assert.deepEqual(await second.wait("result"), { event: "result", ok: false, code: "ERR_REVOCATION_SEQUENCE_BROKEN" }); + assert.equal(f.store.revocationState().appliedRevocationSequence, 1); + assert.equal(f.store.assertPermitReplayState(), 1); + assert.deepEqual([...f.store.revocationState().revokedFingerprints], ["revoked"]); +}); + +test("contention times out without running business work or deleting either lock", async t => { + const f = processFixture(t), lockPath = path.join(f.pinDir, coordination.FILE); + const inode = fs.statSync(lockPath).ino, writer = f.child("hold"); + await writer.wait("paused"); + const contender = f.child("write"), started = Date.now(); + assert.deepEqual(await contender.wait("result"), { event: "result", ok: false, code: "ERR_COORDINATION_BUSY" }); + assert.ok(Date.now() - started < 10000); + assert.equal(fs.statSync(lockPath).ino, inode); + assert.equal(fs.existsSync(path.join(f.pinDir, ".pin-critical-section.lock")), true); + f.release(); assert.equal((await writer.wait("result")).ok, true); +}); + +test("SIGKILL releases the same SQLite inode and keeps the previous state", async t => { + const f = processFixture(t), lockPath = path.join(f.pinDir, coordination.FILE); + const inode = fs.statSync(lockPath).ino; + f.store.noteRevocationSeen(7); + const writer = f.child("hold"); await writer.wait("paused"); writer.proc.kill("SIGKILL"); + assert.equal((await writer.done).signal, "SIGKILL"); + const store = ProtectedPinStore.open(f.pinDir, { decisionId: "D-test-pin", anchorMode: "permissive" }); + assert.equal(store.revocationState().highestSeenRevocationSequence, 7); + store.commitRevocation({ sequence: 1, revokedFingerprints: ["retained"], permitSequence: 8 }); + assert.equal(store.assertPermitReplayState(), 8); + assert.equal(fs.statSync(lockPath).ino, inode); +}); + +for (const kind of ["missing", "replaced", "symlink", "hardlink", "mode", "corrupt"]) { + test(`SQLite lock ${kind} refuses reads, writes and reopen without repair`, () => { + const f = fixture(), file = path.join(f.pinDir, coordination.FILE), backup = path.join(f.dir, "backup"); + try { + const state = fs.readFileSync(path.join(f.pinDir, "pin-state.json")); + if (kind === "missing") fs.unlinkSync(file); + if (kind === "replaced") { fs.renameSync(file, backup); fs.copyFileSync(backup, file); } + if (kind === "symlink") { fs.renameSync(file, backup); fs.symlinkSync(backup, file); } + if (kind === "hardlink") fs.linkSync(file, backup); + if (kind === "mode") fs.chmodSync(file, 0o666); + if (kind === "corrupt") fs.writeFileSync(file, "broken"); + assert.throws(() => f.store.listPins()); + let ran = false; + assert.throws(() => f.store.criticalSection(() => { ran = true; })); + assert.equal(ran, false); + assert.throws(() => ProtectedPinStore.open(f.pinDir, { decisionId: "D-test-pin", anchorMode: "permissive" })); + assert.deepEqual(fs.readFileSync(path.join(f.pinDir, "pin-state.json")), state); + assert.equal(fs.existsSync(file), kind !== "missing"); + } finally { f.cleanup(); } + }); +} + +test("replaced lock during an outer section fences nested business work", () => { + const f = fixture(); + try { + let ran = false; + assert.throws(() => f.store.criticalSection(() => { + const file = path.join(f.pinDir, coordination.FILE), backup = path.join(f.dir, "backup"); + fs.renameSync(file, backup); fs.copyFileSync(backup, file); + f.store.criticalSection(() => { ran = true; }); + }), { code: "ERR_COORDINATION_LOCK_REPLACED" }); + assert.equal(ran, false); + } finally { f.cleanup(); } +}); + +test("same-handle nesting and thrown bodies release the stable lock", () => { + const f = fixture(); + try { + const file = path.join(f.pinDir, coordination.FILE), inode = fs.statSync(file).ino, primary = new Error("PRIMARY"); + assert.equal(f.store.criticalSection(() => f.store.criticalSection(() => 42)), 42); + assert.throws(() => f.store.criticalSection(() => { throw primary; }), error => error === primary); + assert.equal(f.store.criticalSection(() => true), true); + assert.equal(fs.statSync(file).ino, inode); + let ran = false; + assert.throws(() => f.store.criticalSection(async () => { ran = true; }), { key: "ERR_PERMIT_INPUT_INVALID" }); + assert.equal(ran, false); + } finally { f.cleanup(); } +}); + +function legacyStore(version = 2) { + const Module = require("node:module"), filename = path.resolve(__dirname, "../../lib/coordination/protected-pin-store.js"); + const compiled = new Module(filename, module); + compiled.filename = filename; + compiled.paths = Module._nodeModulePaths(path.dirname(filename)); + compiled._compile(fs.readFileSync(path.join(__dirname, `fixtures/protected-pin-store-v${version}-source.txt`), "utf8"), filename); + return compiled.exports.ProtectedPinStore; +} + +test("open never provisions an empty, legacy or partially lost domain", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pin-open-refuse-")); + const options = { decisionId: "D-test-pin", anchorMode: "permissive" }; + try { + assert.throws(() => ProtectedPinStore.open(root, options), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.deepEqual(fs.readdirSync(root), []); + const old = legacyStore().open(root, options); + old.commitRevocation({ sequence: 1, revokedFingerprints: ["retained"], permitSequence: 9 }); + const before = fs.readFileSync(path.join(root, "pin-state.json")); + assert.throws(() => ProtectedPinStore.open(root, options), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.throws(() => ProtectedPinStore.initialize(root, options), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.equal(fs.existsSync(path.join(root, coordination.FILE)), false); + assert.deepEqual(fs.readFileSync(path.join(root, "pin-state.json")), before); + fs.unlinkSync(path.join(root, "pin-state.json")); + assert.throws(() => ProtectedPinStore.migrateOffline(root, { ...options, offline: true }), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.equal(fs.existsSync(path.join(root, coordination.FILE)), false); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("explicit offline format-2 migration retains pins, revocations, replay and clock baselines", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pin-migrate-")); + const options = { decisionId: "D-test-pin", anchorMode: "permissive" }; + try { + const old = legacyStore().open(root, options), key = keyPair(); + old.registerPin({ ...carried(), batchIdentity: batchIdentityFor(key), publicKeyPem: key.publicKeyPem, + nowMs: 1000, write: () => true }); + old.commitRevocation({ sequence: 1, revokedFingerprints: [key.fingerprint], permitSequence: 19 }); + old.noteRevocationSeen(7); old.observePermitClock(12345); old.setClockPoisoned(true); + const before = JSON.parse(fs.readFileSync(path.join(root, "pin-state.json"))); + assert.throws(() => ProtectedPinStore.migrateOffline(root, options), { key: "ERR_PERMIT_INPUT_INVALID" }); + const migrated = ProtectedPinStore.migrateOffline(root, { ...options, offline: true }); + const after = JSON.parse(fs.readFileSync(path.join(root, "pin-state.json"))); + assert.deepEqual(after, { ...before, schema_version: 3 }); + assert.equal(migrated.assertPermitReplayState(), 19); + assert.equal(migrated.isClockPoisoned(), true); + assert.equal(migrated.listPins().length, 1); + assert.throws(() => old.listPins(), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.throws(() => old.commitRevocation({ sequence: 2, revokedFingerprints: [] }), { key: "ERR_LEASE_CONFLICT" }); + assert.throws(() => ProtectedPinStore.migrateOffline(root, { ...options, offline: true }), { key: "ERR_PIN_STATE_CORRUPT" }); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("a live legacy holder prevents migration without touching state or either lock", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pin-live-legacy-")); + const options = { decisionId: "D-test-pin", anchorMode: "permissive" }; + try { + const old = legacyStore().open(root, options); + old.commitRevocation({ sequence: 1, revokedFingerprints: ["retained"] }); + const before = fs.readFileSync(path.join(root, "pin-state.json")); + old.criticalSection(() => { + const lock = fs.readFileSync(path.join(root, ".pin-critical-section.lock")); + assert.throws(() => ProtectedPinStore.migrateOffline(root, { ...options, offline: true }), { code: "EEXIST" }); + assert.deepEqual(fs.readFileSync(path.join(root, ".pin-critical-section.lock")), lock); + assert.equal(fs.existsSync(path.join(root, coordination.FILE)), false); + }); + assert.deepEqual(fs.readFileSync(path.join(root, "pin-state.json")), before); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +for (const version of ["1", "2"]) test(`real legacy format-${version} process refuses new state before writing`, async t => { + const f = processFixture(t), before = fs.readFileSync(path.join(f.pinDir, "pin-state.json")); + const reader = f.child("read", version); + assert.deepEqual(await reader.wait("result"), { event: "result", ok: false, code: "ERR_PIN_STATE_CORRUPT" }); + const writer = f.child("write", version), result = await writer.wait("result"); + assert.equal(result.ok, false); + assert.ok(["ERR_LEASE_CONFLICT", "ERR_PIN_STATE_CORRUPT"].includes(result.code)); + assert.deepEqual(fs.readFileSync(path.join(f.pinDir, "pin-state.json")), before); +}); + +test("legacy format 1 cannot migrate by guessing missing replay and clock history", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pin-v1-blocked-")); + const options = { decisionId: "D-test-pin", anchorMode: "permissive" }; + try { + legacyStore(1).open(root, options).commitRevocation({ sequence: 1, revokedFingerprints: ["retained"] }); + const before = fs.readFileSync(path.join(root, "pin-state.json")); + assert.throws(() => ProtectedPinStore.migrateOffline(root, { ...options, offline: true }), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.deepEqual(fs.readFileSync(path.join(root, "pin-state.json")), before); + assert.equal(fs.existsSync(path.join(root, coordination.FILE)), false); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +for (const point of ["marker", "state"]) test(`failed ${point} provisioning stays closed and is never silently reset`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pin-init-fault-")); + const options = { decisionId: "D-test-pin", anchorMode: "permissive" }, rename = fs.renameSync; + const target = path.join(root, point === "marker" ? "pin-domain.json" : "pin-state.json"); + try { + fs.renameSync = function(from, to) { + if (to === target) throw Object.assign(new Error("injected"), { code: "EIO" }); + return rename.call(this, from, to); + }; + assert.throws(() => ProtectedPinStore.initialize(root, options), { code: "EIO" }); + } finally { fs.renameSync = rename; } + try { + const inode = fs.statSync(path.join(root, coordination.FILE)).ino; + assert.throws(() => ProtectedPinStore.open(root, options)); + assert.throws(() => ProtectedPinStore.initialize(root, options), { key: "ERR_PIN_STATE_CORRUPT" }); + assert.equal(fs.statSync(path.join(root, coordination.FILE)).ino, inode); + assert.equal(fs.existsSync(path.join(root, ".pin-critical-section.lock")), true); + assert.equal(fs.readdirSync(root).some(name => name.includes(".tmp-")), false); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/dispatch/phase-zero-source-manifest.json b/tests/dispatch/phase-zero-source-manifest.json index 300233c1..b8cb61e2 100644 --- a/tests/dispatch/phase-zero-source-manifest.json +++ b/tests/dispatch/phase-zero-source-manifest.json @@ -275,7 +275,7 @@ "templates/_shared/.agent/waitpoints/waitpoint.schema.json" ], "protected_source_blobs": { - "bin/cli.js": "4a720cfd018133b15b1409e93a00d9b7b15c1853", + "bin/cli.js": "2054bee968f32a9ffcf9bb822d749d06cff34f41", "lib/dispatch/execute.js": "ad89e6bd06ce20005b9d1037195dfde657dedb57" }, "builtins": [ diff --git a/tests/governed/executor-response-auth.test.js b/tests/governed/executor-response-auth.test.js new file mode 100644 index 00000000..504feedc --- /dev/null +++ b/tests/governed/executor-response-auth.test.js @@ -0,0 +1,57 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const { createExecutorEndpoint } = require("../../lib/governed/managed-caller-supervisor"); +const { CHANNEL_MESSAGE, SIGNED_MESSAGE_KIND } = require("../../lib/governed/managed-caller-session"); +const { signResponse, digestPayload } = require("../../lib/governed/managed-caller-signing"); + +function child() { + const emitter = new EventEmitter(); + emitter.channel = {}; + emitter.send = (message) => { emitter.sent = message; }; + return emitter; +} + +test("executor rejects a forged response on an inherited channel", async () => { + const channel = child(); + const endpoint = createExecutorEndpoint(channel, "test-secret"); + const pending = endpoint.request("permit.key", { fingerprint: "f" }); + const request = channel.sent; + channel.emit("message", { type: CHANNEL_MESSAGE.RESPONSE, requestId: request.requestId, + seq: request.seq, body: { seq: request.seq, result: { authorized: true } }, mac: "forged" }); + await assert.rejects(pending, /signature verification/); +}); + +test("executor accepts only a correctly signed matching response", async () => { + const channel = child(); + const secret = "test-secret"; + const endpoint = createExecutorEndpoint(channel, secret); + const pending = endpoint.request("permit.key", { fingerprint: "f" }); + const request = channel.sent; + const body = { seq: request.seq, result: { fingerprint: "f" } }; + const mac = signResponse(secret, { kind: SIGNED_MESSAGE_KIND.REQUEST_IN_RESPONSE, + requestId: request.requestId, seq: request.seq, operation: request.operation, + payloadDigest: request.payloadDigest, body }); + channel.emit("message", { type: CHANNEL_MESSAGE.RESPONSE, requestId: request.requestId, + seq: request.seq, body, mac }); + assert.deepEqual(await pending, { fingerprint: "f" }); +}); + +test("executor refuses forged and replayed coordinator requests before signing a response", () => { + const channel = child(); + const secret = "test-secret"; + const endpoint = createExecutorEndpoint(channel, secret); + const message = { type: CHANNEL_MESSAGE.REQUEST, requestId: "r", seq: 1, + operation: "permit.key", payload: { fingerprint: "f" } }; + message.payloadDigest = digestPayload(message.payload); + message.mac = "forged"; + assert.throws(() => endpoint.answer(message, { ok: true }), /signature verification/); + assert.equal(channel.sent, undefined); + message.mac = signResponse(secret, { kind: SIGNED_MESSAGE_KIND.REQUEST_OUT, + requestId: message.requestId, seq: message.seq, operation: message.operation, + payloadDigest: message.payloadDigest, body: null }); + endpoint.answer(message, { ok: true }); + assert.throws(() => endpoint.answer(message, { ok: true }), /signature verification/); +}); diff --git a/tests/governed/fixtures/handshake-no-reply.js b/tests/governed/fixtures/handshake-no-reply.js new file mode 100644 index 00000000..eb2dd8a5 --- /dev/null +++ b/tests/governed/fixtures/handshake-no-reply.js @@ -0,0 +1,9 @@ +"use strict"; + +// Test fixture: either ignore HELLO or disconnect while remaining alive. +if (process.argv.includes("--disconnect")) { + process.once("message", () => { process.disconnect(); }); + setInterval(() => {}, 1000); +} else { + process.on("message", () => {}); +} diff --git a/tests/governed/handshake-cleanup.test.js b/tests/governed/handshake-cleanup.test.js new file mode 100644 index 00000000..8f253d87 --- /dev/null +++ b/tests/governed/handshake-cleanup.test.js @@ -0,0 +1,59 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); +const test = require("node:test"); +const childProcess = require("node:child_process"); + +test("a timed-out handshake reaps its child and captures asynchronous cleanup errors", async () => { + let child; + let childClosed = false; + const originalFork = childProcess.fork; + childProcess.fork = (...args) => { + childClosed = false; + child = originalFork(...args); + child.once("close", () => { childClosed = true; }); + const send = child.send.bind(child); + child.send = (message, ...rest) => { + if (message && message.type === "bye") { + queueMicrotask(() => child.emit("error", new Error("injected cleanup error"))); + return true; + } + return send(message, ...rest); + }; + return child; + }; + try { + const { startManagedCallerSession } = require("../../lib/governed/managed-caller-supervisor"); + await assert.rejects(startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "handshake-no-reply.js"), + handshakeTimeoutMs: 1000, + }), (error) => /timed out/.test(error.message) && error.cleanupError?.message === "injected cleanup error"); + assert.ok(child); + assert.ok(child.exitCode !== null || child.signalCode !== null, "child has not exited"); + assert.equal(childClosed, true, "child streams have not closed"); + await assert.rejects(startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "handshake-no-reply.js"), + spawnOptions: { execPath: path.join(__dirname, "nonexistent-node-executable") }, + handshakeTimeoutMs: 1000, + }), { code: "ENOENT" }); + await assert.rejects(startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "m044-ms002-executor.js"), + args: ["--mode=exit-early"], handshakeTimeoutMs: 1000, + }), /session|channel|closed|timed out|signature/); + assert.equal(childClosed, true, "early-exit child streams have not closed"); + await assert.rejects(startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "handshake-no-reply.js"), + factSource: {}, handshakeTimeoutMs: 1000, + }), TypeError); + assert.equal(childClosed, true, "initialization-failure child streams have not closed"); + await assert.rejects(startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "handshake-no-reply.js"), + args: ["--disconnect"], handshakeTimeoutMs: 300, + }), /session|channel|closed|timed out/); + assert.equal(childClosed, true, "disconnected child streams have not closed"); + } finally { + if (child && child.pid && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + childProcess.fork = originalFork; + } +}); diff --git a/tests/governed/managed-caller-facts-fail-closed.test.js b/tests/governed/managed-caller-facts-fail-closed.test.js new file mode 100644 index 00000000..fe52fee6 --- /dev/null +++ b/tests/governed/managed-caller-facts-fail-closed.test.js @@ -0,0 +1,50 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { startManagedCallerSession } = require("../../lib/governed/managed-caller-supervisor"); + +test("unverifiable caller facts refuse dispatch before writing a request", async () => { + for (const scenario of ["missing-endpoint", "changed-inode", "changed-address", "changed-peer", + "parent-only", "missing-initial-digest", "missing-current-digest"]) { + let established = false; + let pid; + const mac = scenario === "changed-address" || scenario === "changed-peer"; + const endpoint = mac ? { inode: null, address: "local", peerAddress: "peer" } : { inode: 123, address: null }; + const facts = { + collectProcessFacts(child) { + pid = child.pid; + let currentEndpoint = endpoint; + if (established && scenario === "missing-endpoint") currentEndpoint = null; + if (established && scenario === "changed-inode") currentEndpoint = { inode: 124, address: null }; + if (established && scenario === "changed-address") currentEndpoint = { ...endpoint, address: "other" }; + if (established && scenario === "changed-peer") currentEndpoint = { ...endpoint, peerAddress: "other" }; + const noDigest = (!established && scenario === "missing-initial-digest") || + (established && scenario === "missing-current-digest"); + return { pid: child.pid, startToken: "precise", commandDigest: noDigest ? null : "digest", + socketAddress: endpoint.address, socketEndpoint: currentEndpoint }; + }, + readSocketHolders: () => scenario === "parent-only" ? [process.pid] : [pid], + isPreciseStartToken: () => true, + }; + const { session, child } = await startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "m044-ms002-executor.js"), factSource: facts, + }); + try { + established = true; + let requests = 0; + const send = child.send.bind(child); + child.send = (message, ...args) => { if (message.type === "request") requests += 1; return send(message, ...args); }; + const reason = scenario === "missing-endpoint" ? "socket_address_unknown" + : scenario.startsWith("changed-") ? "channel_endpoint_changed" + : scenario === "parent-only" ? "socket_holders_unknown" : "command_digest_unavailable"; + await assert.rejects(session.request("ping", { value: "must-not-send" }), new RegExp(reason), scenario); + assert.equal(requests, 0, scenario); + assert.equal(session.closed, true, scenario); + } finally { + await session.close("test_cleanup"); + if (child.exitCode === null && child.signalCode === null) child.kill(); + } + } +}); diff --git a/tests/governed/managed-caller-session.test.js b/tests/governed/managed-caller-session.test.js index f5740583..c928496b 100644 --- a/tests/governed/managed-caller-session.test.js +++ b/tests/governed/managed-caller-session.test.js @@ -868,7 +868,7 @@ describe("governed/managed-caller-session - identity does not come from self-rep startToken: replaced ? "linux:the-process-that-replaced-it" : "linux:the-process-that-was-established", - commandDigest: null, + commandDigest: "stable-digest", socketAddress: null, socketEndpoint: null, holders: [], @@ -917,7 +917,7 @@ describe("governed/managed-caller-session - identity does not come from self-rep startToken: replaced ? "linux:the-process-that-replaced-it" : "linux:the-process-that-was-established", - commandDigest: null, + commandDigest: "stable-digest", socketAddress: null, socketEndpoint: null, holders: [], diff --git a/tests/governed/permit-supervisor-adapter.test.js b/tests/governed/permit-supervisor-adapter.test.js new file mode 100644 index 00000000..4fd08d8c --- /dev/null +++ b/tests/governed/permit-supervisor-adapter.test.js @@ -0,0 +1,98 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { startManagedCallerSession } = require("../../lib/governed/managed-caller-supervisor"); + +function proxy(child, operation, payload) { + return new Promise((resolve, reject) => { + const requestId = `request-${Math.random()}`; + const timer = setTimeout(() => { child.off("message", receive); reject(new Error("proxy timed out")); }, 5000); + function receive(message) { + if (!message || message.type !== "proxy-result" || message.requestId !== requestId) return; + clearTimeout(timer); + child.off("message", receive); + resolve(message); + } + child.on("message", receive); + child.send({ type: "proxy", requestId, operation, payload }); + }); +} + +test("managed channel scopes and audits requests before the trusted handler", async () => { + let invalidIdentity = false; + let calls = 0; + let revocations = 0; + let observedPid; + const factSource = { + collectProcessFacts: (child) => { observedPid = child.pid; return { pid: child.pid, startToken: invalidIdentity ? "changed" : "original", + commandDigest: "test", socketAddress: "test", socketEndpoint: { inode: 123, address: "test" } }; }, + readSocketHolders: () => [observedPid], + isPreciseStartToken: () => true, + }; + const { session, child } = await startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "m044-ms002-executor.js"), args: ["--mode=proxy"], + allowedOperations: ["permit.key"], factSource, + onIdentityUnavailable: () => { revocations += 1; throw new Error("notification failed"); }, + onAuthorizedRequest: ({ identity, operation, payload }) => { + calls += 1; + assert.equal(identity.pid, child.pid); + return { operation, fingerprint: payload.fingerprint }; + }, + }); + try { + const allowed = await proxy(child, "permit.key", { fingerprint: "f" }); + assert.equal(allowed.ok, true); + assert.deepEqual(allowed.result, { operation: "permit.key", fingerprint: "f" }); + const outside = await proxy(child, "permit.issue", {}); + assert.equal(outside.ok, false); + invalidIdentity = true; + const changed = await proxy(child, "permit.key", { fingerprint: "f" }); + assert.equal(changed.ok, false); + assert.match(changed.error, /managed_caller_identity_unavailable/); + assert.equal(revocations, 1); + invalidIdentity = false; + assert.equal(session.auditChannelHolders().ok, true); + assert.equal(session.auditProcessContinuity().ok, true); + assert.equal((await proxy(child, "permit.key", { fingerprint: "f" })).ok, false); + assert.equal(revocations, 1); + assert.equal(calls, 1); + } finally { + await session.close("test_cleanup"); + if (child.exitCode === null) child.kill(); + } +}); + +test("identity loss during a handler prevents a successful response", async () => { + let changed = false; + let calls = 0; + let revocations = 0; + let observedPid; + const factSource = { + collectProcessFacts: (child) => { observedPid = child.pid; return { pid: child.pid, startToken: changed ? "changed" : "original", + commandDigest: "test", socketAddress: "test", socketEndpoint: { inode: 123, address: "test" } }; }, + readSocketHolders: () => [observedPid], + isPreciseStartToken: () => true, + }; + const { session, child } = await startManagedCallerSession({ + modulePath: path.join(__dirname, "fixtures", "m044-ms002-executor.js"), args: ["--mode=proxy"], + allowedOperations: ["permit.key"], factSource, + onIdentityUnavailable: () => { revocations += 1; }, + onAuthorizedRequest: () => { calls += 1; changed = true; return { ok: true }; }, + }); + try { + const result = await proxy(child, "permit.key", { fingerprint: "f" }); + assert.equal(result.ok, false); + assert.equal(revocations, 1); + changed = false; + assert.equal(session.auditChannelHolders().ok, true); + assert.equal(session.auditProcessContinuity().ok, true); + assert.equal((await proxy(child, "permit.key", { fingerprint: "f" })).ok, false); + assert.equal(revocations, 1); + assert.equal(calls, 1); + } finally { + await session.close("test_cleanup"); + if (child.exitCode === null) child.kill(); + } +}); From 1b11a3f55d761c41aa60ca0b4a3ef4485c2f0937 Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Sun, 1 Nov 2026 12:25:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(coordination):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=8F=97=E7=AE=A1=E8=AE=B8=E5=8F=AF=E5=85=A5=E5=8F=A3=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E7=9A=84=E8=A7=81=E8=AF=81=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E9=94=81=E5=9F=9F=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一批把跨机许可的受管入口交付到产品 CLI,但它的依赖闭包没有 完整入库,干净 checkout 下 CI 的 full-test 因此失败: - lib/coordination/protected-permit-store.js 新增 34 处 witness 调用 (canonicalBytes / deriveTransition / createCheckpoint / normalizeTargetState / assertCheckpointMatches / LIMITS),却引用了 从未提交过的 lib/coordination/permit-witness-contract.js。 - lib/coordination/protected-pin-store.js 改为以格式 3 的域标记绑定 固定 SQLite 锁 inode,调用 coordination.assertWindowsFilePermissions 与 coordination.assertAnchorPathIsNotRedirected,但已提交的 protected-store-lock.js 尚未导出这两个成员。 - protected-pin-store 的 open()/initialize() 语义已分离,配套测试仍 以 open() 打开未初始化目录,并缺少三个 fixture (protected-pin-store-process.js、protected-pin-store-v1-source.txt、 protected-pin-store-v2-source.txt)。 本次按已核对的字节内容补齐上述六个文件。它们与第二批交付清单 记录的 SHA-256 逐字节一致,不引入新的实现,只修复第一批的交付边界。 影响面: - permit-witness-contract.js 仅依赖 node 内置模块,不触网、不读环境 凭据、不做认证或授权判定;其头部自述为「候选状态契约与离线编解码, 不是见证服务,也不是授权」。 - protected-store-lock.js 的两个新成员只做锚点路径与 Windows ACL 的 可写性检查:祖先链遇符号链接即 fail-closed(原先是 break 继续), 并按 Windows 原生适配器判定写者集合。 - 三个 fixture 是测试夹具(一个子进程夹具与两份 legacy 格式源码快照)。 安全约束(不可放宽):不新增任何跨机许可断言;不主张任何断言在真实 受保护部署上成立;第一批仍不含原生 witness 包、legacy 迁移与 SQLite 线——它们的真实 Windows 服务身份与证书/密钥 ACL 尚无实测验收证据, 须另行评审后单独交付。 验证:干净检出(8d8c1e12 + 本次六个文件)上,先前因缺模块失败的 八个测试文件全部通过——cross-machine-permit、permit-batch-session、 permit-delivery、permit-managed-flow、permit-revocation-signing、 permit-target-service、protected-permit-store、protected-pin-store。 全量 558 个测试文件跑完,554 通过、4 失败;四项均为 governance/github-control-store、governance/git-transaction-store、 runtime-port/strict-entry、runtime-state/runtime-admission,在未改动的 origin/main 基线上同样失败(createGitTransactionStore 限定 Linux 平台), 非本次引入,CI 的 ubuntu 运行时不受影响。 Refs M-044, MS-004 --- lib/coordination/permit-witness-contract.js | 539 ++++++++++++ lib/coordination/protected-store-lock.js | 55 +- .../coordination/cross-machine-permit.test.js | 2 +- .../fixtures/protected-pin-store-process.js | 38 + .../protected-pin-store-v1-source.txt | 783 +++++++++++++++++ .../protected-pin-store-v2-source.txt | 785 ++++++++++++++++++ 6 files changed, 2187 insertions(+), 15 deletions(-) create mode 100644 lib/coordination/permit-witness-contract.js create mode 100644 tests/coordination/fixtures/protected-pin-store-process.js create mode 100644 tests/coordination/fixtures/protected-pin-store-v1-source.txt create mode 100644 tests/coordination/fixtures/protected-pin-store-v2-source.txt diff --git a/lib/coordination/permit-witness-contract.js b/lib/coordination/permit-witness-contract.js new file mode 100644 index 00000000..330db20a --- /dev/null +++ b/lib/coordination/permit-witness-contract.js @@ -0,0 +1,539 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +// Candidate state contract/offline codec, NOT a witness service or authorization. +// The future authority must load current from its database and obtain facts +// from authenticated, authorized server state (including signature, issuance, +// lease/epoch/fencing/desktop and global consumption checks). Never pass request +// data as facts. It must commit the returned full state/checkpoint together with +// permit consumption in ONE serializable transaction before authorizing anything. +// These functions provide no authentication, authority persistence, CAS exclusion, history +// retention, baseline verification, registration protocol or execution receipt. +// Offline migration below preserves an ALREADY VERIFIED complete baseline; it +// cannot establish one, upgrade incomplete legacy schemas, or activate a store. +const FORMAT_VERSION = 1; +const LIMITS = Object.freeze({ bytes: 1024 * 1024, stringBytes: 65536, + depth: 16, nodes: 20000, entries: 1024 }); +const MIGRATION_CHUNK_BYTES = 16384; +const OPERATIONS = Object.freeze([ + "register_pin", "apply_revocation", "note_revocation_seen", "observe_clock", "accept_permit", +]); +const LONGEST_OPERATION = OPERATIONS.reduce((longest, operation) => + Buffer.byteLength(JSON.stringify(operation)) > Buffer.byteLength(JSON.stringify(longest)) ? operation : longest, ""); +const STATE_FIELDS = Object.freeze([ + "schema_version", "pins", "revoked_fingerprints", "applied_revocation_sequence", + "highest_seen_revocation_sequence", "highest_accepted_permit_sequence", + "last_observed_clock_ms", "clock_poisoned", +]); +const PIN_FIELDS = Object.freeze([ + "decision_id", "instance_id", "fingerprint", "public_key_pem", "registered_at_ms", + "source_digest", "deployment_digest", "run_id", "generation", "valid_from", "valid_until", +]); +const TOKEN_FIELDS = Object.freeze([ + "format_version", "authority_id", "target_id", "store_generation", "revision", "full_state_digest", +]); + +class PermitWitnessContractError extends Error { + constructor(code) { super(code); this.name = "PermitWitnessContractError"; this.code = code; } +} +function fail(code = "ERR_WITNESS_INPUT") { throw new PermitWitnessContractError(code); } +function integer(value, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum || Object.is(value, -0)) fail(); +} +function text(value) { + if (typeof value !== "string" || !value) fail(); +} +function hash(value) { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) fail(); +} +function exact(value, fields) { + if (!value || Array.isArray(value) || typeof value !== "object" || + ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail(); + const keys = Reflect.ownKeys(value); + if (keys.length !== fields.length || keys.some((key) => !fields.includes(key))) fail(); + for (const key of keys) { + const desc = Object.getOwnPropertyDescriptor(value, key); + if (!desc.enumerable || !Object.hasOwn(desc, "value")) fail(); + } +} + +// Encoding v1: UTF-8 JSON, no whitespace/BOM/newline, object keys sorted by +// UTF-16 code units; array order preserved; safe integers only, no negative zero. +// Strings retain Unicode spelling (no NFC normalization); JSON escapes lone +// surrogates so legacy UTF-16 data round-trips without replacement. No omitted +// values, coercion, toJSON, accessors or exotic objects. +// Limits and domain prefix below are versioned parts of this candidate format. +function canonicalBytes(value) { + let nodes = 0; + let bytes = 0; + const active = new Set(); + function charge(size) { + bytes += size; + if (bytes > LIMITS.bytes) fail(); + } + function literal(item) { + charge(Buffer.byteLength(item, "utf8")); + return item; + } + function string(item) { + if (Buffer.byteLength(item, "utf8") > LIMITS.stringBytes) fail(); + return literal(JSON.stringify(item)); + } + function encode(item, depth) { + if (++nodes > LIMITS.nodes || depth > LIMITS.depth) fail(); + if (item === null) return literal("null"); + if (typeof item === "string") return string(item); + if (typeof item === "boolean") return literal(String(item)); + if (typeof item === "number") { + if (!Number.isSafeInteger(item) || Object.is(item, -0)) fail(); + return literal(String(item)); + } + if (!item || typeof item !== "object" || active.has(item)) fail(); + active.add(item); + let result; + if (Array.isArray(item)) { + if (Object.getPrototypeOf(item) !== Array.prototype || item.length > LIMITS.entries || + Reflect.ownKeys(item).length !== item.length + 1) fail(); + charge(item.length ? item.length + 1 : 2); // Brackets and commas. + const parts = []; + for (let i = 0; i < item.length; i += 1) { + const desc = Object.getOwnPropertyDescriptor(item, String(i)); + if (!desc || !desc.enumerable || !Object.hasOwn(desc, "value")) fail(); + parts.push(encode(desc.value, depth + 1)); + } + result = "[" + parts.join(",") + "]"; + } else { + if (![Object.prototype, null].includes(Object.getPrototypeOf(item))) fail(); + const keys = Reflect.ownKeys(item); + if (keys.length > LIMITS.entries || keys.some((key) => typeof key !== "string")) fail(); + charge(keys.length ? keys.length * 2 + 1 : 2); // Braces, colons and commas. + result = "{" + keys.sort().map((key) => { + const desc = Object.getOwnPropertyDescriptor(item, key); + if (!desc.enumerable || !Object.hasOwn(desc, "value")) fail(); + return string(key) + ":" + encode(desc.value, depth + 1); + }).join(",") + "}"; + } + active.delete(item); + return result; + } + const encoded = encode(value, 0); + return Buffer.from(encoded, "utf8"); +} + +function copy(value) { return JSON.parse(canonicalBytes(value).toString("utf8")); } +// Trusted authority/offline collections are not network messages. Validate +// their shape without imposing the online history/string/body ceilings. Total +// memory is NOT bounded independently of history: copies, sorting and digest +// validation require memory proportional to the complete trusted state. +function dataArray(value) { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || + Reflect.ownKeys(value).length !== value.length + 1) fail(); + const result = []; + for (let i = 0; i < value.length; i += 1) { + const desc = Object.getOwnPropertyDescriptor(value, String(i)); + if (!desc || !desc.enumerable || !Object.hasOwn(desc, "value")) fail(); + result.push(desc.value); + } + return result; +} +function fingerprints(values) { + values = dataArray(values); + values.forEach(hash); + if (new Set(values).size !== values.length) fail(); + return [...values].sort(); +} +function pinEntry(pin) { + exact(pin, PIN_FIELDS); + // ProtectedPinStore carries these labels as nonempty text, often `sha256:...`. + // Preserve them verbatim; only the actual key fingerprint is a fixed hex hash. + for (const name of ["decision_id", "instance_id", "run_id", "generation", "source_digest", "deployment_digest"]) text(pin[name]); + hash(pin.fingerprint); + // Historical registration records accept signed safe integers without an + // interval-order constraint. Current validity belongs to server policy. + for (const name of ["registered_at_ms", "valid_from", "valid_until"]) integer(pin[name], Number.MIN_SAFE_INTEGER); + text(pin.public_key_pem); + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pin.public_key_pem)) fail(); + let key; + try { key = crypto.createPublicKey(pin.public_key_pem); } catch { fail(); } + // Match key material, retaining the original PEM spelling and algorithm. + // Eligibility to sign a current permit is a separate server-side decision. + if (crypto.createHash("sha256").update(key.export({ type: "spki", format: "der" })) + .digest("hex") !== pin.fingerprint) fail(); + return { ...pin }; +} + +// Internal only: input has already been schema-validated and copied. Emit the +// SAME canonical v1 bytes in bounded pieces, including strings larger than a +// message. Never split a surrogate pair before JSON escaping; lone surrogates +// retain their exact UTF-16 spelling. No public switch disables online limits. +function* trustedBytes(value) { + if (typeof value === "string") { + yield Buffer.from('"'); + for (let start = 0; start < value.length;) { + let end = Math.min(start + 4096, value.length); + if (end < value.length && /[\ud800-\udbff]/.test(value[end - 1]) && + /[\udc00-\udfff]/.test(value[end])) end -= 1; + yield Buffer.from(JSON.stringify(value.slice(start, end)).slice(1, -1), "utf8"); + start = end; + } + yield Buffer.from('"'); + } else if (value === null || typeof value !== "object") { + yield Buffer.from(JSON.stringify(value)); + } else if (Array.isArray(value)) { + yield Buffer.from("["); + for (let i = 0; i < value.length; i += 1) { + if (i) yield Buffer.from(","); + yield* trustedBytes(value[i]); + } + yield Buffer.from("]"); + } else { + yield Buffer.from("{"); + let first = true; + for (const key of Object.keys(value).sort()) { + if (!first) yield Buffer.from(","); + first = false; + yield* trustedBytes(key); + yield Buffer.from(":"); + yield* trustedBytes(value[key]); + } + yield Buffer.from("}"); + } +} +function normalizedDigest(state) { + const digest = crypto.createHash("sha256").update("cortex-permit-witness-target-state-v1\n"); + for (const bytes of trustedBytes(state)) digest.update(bytes); + return digest.digest("hex"); +} + +// The complete pin-state body of the current format-3 target, not an import or +// proof of its baseline. AUTHORITY/OFFLINE INPUT ONLY, never request state. +// Missing legacy replay/clock fields are never defaulted. +// Set-like arrays have a fixed order; duplicates and unknown fields are refused. +function normalizeTargetState(input) { + exact(input, STATE_FIELDS); + const state = { ...input }; + if (state.schema_version !== 3 || !Array.isArray(state.pins) || + typeof state.clock_poisoned !== "boolean") fail("ERR_WITNESS_STATE"); + for (const name of STATE_FIELDS.slice(3, 7)) integer(state[name]); + if (state.highest_seen_revocation_sequence < state.applied_revocation_sequence) fail("ERR_WITNESS_STATE"); + state.pins = dataArray(state.pins).map(pinEntry); + if (new Set(state.pins.map((pin) => pin.fingerprint)).size !== state.pins.length) fail("ERR_WITNESS_STATE"); + state.pins.sort((a, b) => a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0); + state.revoked_fingerprints = fingerprints(state.revoked_fingerprints); + return state; +} +function targetStateDigest(state) { + return normalizedDigest(normalizeTargetState(state)); +} +function token(value) { + exact(value, TOKEN_FIELDS); + if (value.format_version !== FORMAT_VERSION) fail(); + for (const name of ["authority_id", "target_id", "store_generation"]) text(value[name]); + integer(value.revision, 1); + hash(value.full_state_digest); + // A checkpoint must fit in every supported bounded envelope, including after + // a revision grows to its largest representation. The manifest is checked + // with the largest possible safe count and length; intent is its strict + // subset. The longest JSON-encoded operation covers the others. Check actual + // JSON escaping, not source string lengths. + const future = { ...value, revision: Number.MAX_SAFE_INTEGER }; + canonicalBytes({ operation: LONGEST_OPERATION, checkpoint: future }); + canonicalBytes({ format_version: 1, checkpoint: future, chunk_bytes: MIGRATION_CHUNK_BYTES, + chunks: Number.MAX_SAFE_INTEGER, bytes: Number.MAX_SAFE_INTEGER, chain_digest: "f".repeat(64) }); +} + +// Authority-only construction from a VERIFIED baseline and provisioned identity. +// store_generation is separate from the signed run's `generation` field. +function createCheckpoint(binding, input) { + binding = copy(binding); + exact(binding, ["authority_id", "target_id", "store_generation", "revision"]); + // Reject unusable bindings before scanning a potentially very large state. + token({ format_version: FORMAT_VERSION, ...binding, full_state_digest: "0".repeat(64) }); + const state = normalizeTargetState(input); + const checkpoint = { format_version: FORMAT_VERSION, ...binding, full_state_digest: normalizedDigest(state) }; + token(checkpoint); + return { checkpoint, state }; +} +function validatedCurrent(input) { + exact(input, ["checkpoint", "state"]); + const current = { checkpoint: copy(input.checkpoint), state: normalizeTargetState(input.state) }; + token(current.checkpoint); + if (normalizedDigest(current.state) !== current.checkpoint.full_state_digest) fail("ERR_WITNESS_STATE"); + return current; +} +function assertCheckpointMatches(currentInput, expectedInput) { + const expected = copy(expectedInput); + token(expected); + exact(currentInput, ["checkpoint", "state"]); + const checkpoint = copy(currentInput.checkpoint); + token(checkpoint); + // A client conflict must not trigger a scan of arbitrarily large trusted history. + if (!canonicalBytes(checkpoint).equals(canonicalBytes(expected))) fail("ERR_WITNESS_CHECKPOINT_CONFLICT"); + return validatedCurrent({ checkpoint, state: currentInput.state }); +} +function applyRevocation(state, entry) { + exact(entry, ["sequence", "revoked_fingerprints"]); + integer(entry.sequence, 1); + if (!Number.isSafeInteger(state.applied_revocation_sequence + 1) || + entry.sequence !== state.applied_revocation_sequence + 1) fail("ERR_WITNESS_REVOCATION_CHAIN"); + const incoming = fingerprints(entry.revoked_fingerprints); + const incomingSet = new Set(incoming); + if (state.revoked_fingerprints.some((fingerprint) => !incomingSet.has(fingerprint))) { + fail("ERR_WITNESS_REVOCATION_REGRESSION"); + } + state.revoked_fingerprints = incoming; + state.applied_revocation_sequence = entry.sequence; + state.highest_seen_revocation_sequence = Math.max(state.highest_seen_revocation_sequence, entry.sequence); +} + +// request is the ENTIRE client-controlled input, with an exact allowlist. +// facts is a separate authority-only argument, not a verified:true client flag. +// Returns a proposed complete state, never an executable authorization. A clock +// rollback proposes a poisoned state for the caller to commit before refusal. +// The host must commit observe_clock even when a separate accept_permit fails; +// rejecting acceptance alone does not persist poison or a valid revocation. +function deriveTransition(currentInput, requestInput, factsInput) { + const request = copy(requestInput); + exact(request, ["operation", "checkpoint"]); + // Keep the envelope bound and accepted operations tied to the same list. + if (!OPERATIONS.includes(request.operation)) fail("ERR_WITNESS_OPERATION"); + const current = assertCheckpointMatches(currentInput, request.checkpoint); + // Like current, facts come ONLY from authenticated authority state. Each + // operation checks exact data properties before reading them. In particular, + // a cumulative revocation set may exceed a network message after migration. + const facts = factsInput; + const next = current.state; + switch (request.operation) { + case "register_pin": { + exact(facts, ["pin"]); + const pin = pinEntry(facts.pin); + if (next.pins.some((existing) => existing.fingerprint === pin.fingerprint)) fail("ERR_WITNESS_PIN_EXISTS"); + next.pins.push(pin); + break; + } + case "apply_revocation": + applyRevocation(next, facts); + break; + case "note_revocation_seen": + exact(facts, ["sequence"]); + integer(facts.sequence, 1); + if (facts.sequence <= next.highest_seen_revocation_sequence) fail("ERR_WITNESS_NO_CHANGE"); + next.highest_seen_revocation_sequence = facts.sequence; + break; + case "observe_clock": + exact(facts, ["now_ms"]); + integer(facts.now_ms); + if (facts.now_ms < next.last_observed_clock_ms) next.clock_poisoned = true; + else next.last_observed_clock_ms = facts.now_ms; + break; + case "accept_permit": + exact(facts, ["now_ms", "permit_sequence", "fingerprint", "revocation"]); + integer(facts.now_ms); + integer(facts.permit_sequence, 1); + hash(facts.fingerprint); + if (next.clock_poisoned || facts.now_ms < next.last_observed_clock_ms) fail("ERR_WITNESS_CLOCK"); + if (facts.permit_sequence <= next.highest_accepted_permit_sequence) fail("ERR_WITNESS_PERMIT_REPLAY"); + // Even an unchanged revoked set requires the next authenticated chain entry. + applyRevocation(next, facts.revocation); + if (next.highest_seen_revocation_sequence !== next.applied_revocation_sequence) fail("ERR_WITNESS_REVOCATION_CHAIN"); + if (!next.pins.some((pin) => pin.fingerprint === facts.fingerprint) || + next.revoked_fingerprints.includes(facts.fingerprint)) fail("ERR_WITNESS_SIGNER"); + next.highest_accepted_permit_sequence = facts.permit_sequence; + next.last_observed_clock_ms = facts.now_ms; + break; + default: fail("ERR_WITNESS_OPERATION"); + } + if (targetStateDigest(next) === current.checkpoint.full_state_digest) fail("ERR_WITNESS_NO_CHANGE"); + const { authority_id, target_id, store_generation, revision } = current.checkpoint; + integer(revision + 1, 1); + return createCheckpoint({ authority_id, target_id, store_generation, revision: revision + 1 }, next); +} + +// OFFLINE ONLY. The operator must quiesce all writers and provide an exclusively +// owned directory on a trusted filesystem, outside request-controlled paths. +// The caller must enforce exclusive access and directory permissions; these +// helpers have no lock and do not make concurrent readers/writers safe. +// expectedCheckpoint MUST come from independent trusted authority evidence, not +// from this directory. Neither JSON validity nor a local digest proves a legacy +// baseline. These helpers do not change registration, activation or authority +// history, and do not claim whole-volume rollback or Windows durability safety. +// +// intent.json binds every restart to the same identity/revision/full digest. +// chunk-N.bin contains the next <=16 KiB canonical state bytes (not base64). +// A domain-separated chain binds checkpoint, index, length, predecessor and +// bytes. manifest.json records exact count/length/final chain and is written +// LAST. Readers require all records and the independent state digest before +// returning anything. No partial state is ever returned or treated as current. +// +// Resume REGENERATES bytes from the complete verified source. An existing file +// must be an exact prefix; only its missing suffix may be appended. Mismatches, +// foreign files and overlong files are refused without overwriting them. This +// handles crashes even during intent/chunk/manifest writes or fsync: complete +// files are rechecked and fsynced, missing bytes are regenerated, never guessed. +// Restoring a finished bundle needs no original source or in-memory cursor. +function migrationFile(index) { return "chunk-" + String(index).padStart(12, "0") + ".bin"; } +function syncDirectory(directory) { + const fd = fs.openSync(directory, "r"); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } +} +function migrationDirectory(directory, create) { + if (create) { + try { + fs.mkdirSync(directory, { mode: 0o700 }); + } catch (error) { if (error.code !== "EEXIST") throw error; } + } + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) fail("ERR_WITNESS_MIGRATION"); + if (create) syncDirectory(path.dirname(path.resolve(directory))); +} +function regularFile(fd) { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.nlink !== 1) fail("ERR_WITNESS_MIGRATION"); + return stat; +} +function readMigrationFile(filename, maximum) { + const fd = fs.openSync(filename, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const { size } = regularFile(fd); + if (size > maximum) fail("ERR_WITNESS_MIGRATION"); + const bytes = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const read = fs.readSync(fd, bytes, offset, size - offset, offset); + if (!read) fail("ERR_WITNESS_MIGRATION"); + offset += read; + } + if (fs.readSync(fd, Buffer.alloc(1), 0, 1, size)) fail("ERR_WITNESS_MIGRATION"); + return bytes; + } finally { fs.closeSync(fd); } +} +function persistMigrationFile(directory, name, bytes) { + const filename = path.join(directory, name); + const fd = fs.openSync(filename, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600); + try { + const { size } = regularFile(fd); + if (size > bytes.length || !readMigrationFile(filename, bytes.length).equals(bytes.subarray(0, size))) { + fail("ERR_WITNESS_MIGRATION_CONFLICT"); + } + let offset = size; + while (offset < bytes.length) { + const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, offset); + if (!written) fail("ERR_WITNESS_MIGRATION"); + offset += written; + } + fs.fsyncSync(fd); + } finally { fs.closeSync(fd); } + syncDirectory(directory); +} +function migrationIntent(checkpoint) { + return { format_version: 1, checkpoint, chunk_bytes: MIGRATION_CHUNK_BYTES }; +} +function chainStart(checkpoint) { + return crypto.createHash("sha256").update("cortex-permit-witness-migration-v1\n") + .update(canonicalBytes(checkpoint)).digest("hex"); +} +function chainNext(previous, index, bytes) { + return crypto.createHash("sha256").update("cortex-permit-witness-migration-chunk-v1\n") + .update(canonicalBytes({ previous, index, bytes: bytes.length })).update(bytes).digest("hex"); +} +function* migrationChunks(state) { + let chunk = Buffer.alloc(MIGRATION_CHUNK_BYTES); + let used = 0; + for (const bytes of trustedBytes(state)) { + for (let offset = 0; offset < bytes.length;) { + const size = Math.min(bytes.length - offset, chunk.length - used); + bytes.copy(chunk, used, offset, offset + size); + offset += size; + used += size; + if (used === chunk.length) { + yield chunk; + chunk = Buffer.alloc(MIGRATION_CHUNK_BYTES); + used = 0; + } + } + } + if (used) yield chunk.subarray(0, used); +} +function migrationFiles(directory, count, completed) { + const names = fs.readdirSync(directory); + // Refuse forged enormous counts before allocating/iterating from metadata. + if (completed && names.length !== count + 2) fail("ERR_WITNESS_MIGRATION"); + const expected = new Set(["intent.json", "manifest.json"]); + for (let index = 0; index < count; index += 1) expected.add(migrationFile(index)); + if (names.some((name) => !expected.has(name))) { + fail("ERR_WITNESS_MIGRATION"); + } +} +function writeTrustedCheckpointMigration(directory, currentInput, expectedCheckpoint) { + const current = assertCheckpointMatches(currentInput, expectedCheckpoint); + migrationDirectory(directory, true); + const intent = migrationIntent(current.checkpoint); + persistMigrationFile(directory, "intent.json", canonicalBytes(intent)); + let count = 0; + let length = 0; + let chain = chainStart(current.checkpoint); + for (const bytes of migrationChunks(current.state)) { + persistMigrationFile(directory, migrationFile(count), bytes); + chain = chainNext(chain, count, bytes); + count += 1; + length += bytes.length; + integer(count, 1); + integer(length, 1); + } + migrationFiles(directory, count, false); + const manifest = { ...intent, chunks: count, bytes: length, chain_digest: chain }; + persistMigrationFile(directory, "manifest.json", canonicalBytes(manifest)); + return manifest; +} +function readMigrationRecord(directory, name) { + const bytes = readMigrationFile(path.join(directory, name), LIMITS.bytes); + let record; + try { record = JSON.parse(bytes.toString("utf8")); } catch { fail("ERR_WITNESS_MIGRATION"); } + if (!canonicalBytes(record).equals(bytes)) fail("ERR_WITNESS_MIGRATION"); + return record; +} +function restoreTrustedCheckpointMigration(directory, expectedInput) { + const expected = copy(expectedInput); + token(expected); + migrationDirectory(directory, false); + const intent = readMigrationRecord(directory, "intent.json"); + if (!canonicalBytes(intent).equals(canonicalBytes(migrationIntent(expected)))) fail("ERR_WITNESS_MIGRATION_CONFLICT"); + const manifest = readMigrationRecord(directory, "manifest.json"); + exact(manifest, ["format_version", "checkpoint", "chunk_bytes", "chunks", "bytes", "chain_digest"]); + if (!canonicalBytes({ format_version: manifest.format_version, checkpoint: manifest.checkpoint, + chunk_bytes: manifest.chunk_bytes }).equals(canonicalBytes(intent))) fail("ERR_WITNESS_MIGRATION_CONFLICT"); + integer(manifest.chunks, 1); + integer(manifest.bytes, 1); + hash(manifest.chain_digest); + if (manifest.chunks !== Math.ceil(manifest.bytes / MIGRATION_CHUNK_BYTES)) fail("ERR_WITNESS_MIGRATION"); + migrationFiles(directory, manifest.chunks, true); + let chain = chainStart(expected); + const digest = crypto.createHash("sha256").update("cortex-permit-witness-target-state-v1\n"); + const chunks = []; + for (let index = 0; index < manifest.chunks; index += 1) { + const size = Math.min(MIGRATION_CHUNK_BYTES, manifest.bytes - index * MIGRATION_CHUNK_BYTES); + const bytes = readMigrationFile(path.join(directory, migrationFile(index)), size); + if (bytes.length !== size) fail("ERR_WITNESS_MIGRATION"); + chain = chainNext(chain, index, bytes); + digest.update(bytes); + chunks.push(bytes); + } + if (chain !== manifest.chain_digest || digest.digest("hex") !== expected.full_state_digest) { + fail("ERR_WITNESS_MIGRATION"); + } + // This final materialization is deliberately offline. Chunks, concatenated + // bytes, decoded JSON, parsed objects and validated copies can coexist: total + // memory is proportional to the entire history, with no fixed upper bound; + // runtime Buffer/string limits and available memory may still prevent restore. + // Online requests must never call this restore helper. + let state; + try { state = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { fail("ERR_WITNESS_MIGRATION"); } + return assertCheckpointMatches({ checkpoint: expected, state }, expected); +} + +module.exports = { FORMAT_VERSION, LIMITS, PermitWitnessContractError, canonicalBytes, + normalizeTargetState, targetStateDigest, createCheckpoint, assertCheckpointMatches, deriveTransition, + MIGRATION_CHUNK_BYTES, writeTrustedCheckpointMigration, restoreTrustedCheckpointMigration }; diff --git a/lib/coordination/protected-store-lock.js b/lib/coordination/protected-store-lock.js index 5d946fa2..f1852f8c 100644 --- a/lib/coordination/protected-store-lock.js +++ b/lib/coordination/protected-store-lock.js @@ -15,26 +15,50 @@ function fail(code) { throw Object.assign(new Error(code), { code }); } // under the temporary directory and the domain would be refused for a reason // that has nothing to do with an attack. // -// The anchor directory itself must be a real directory and not a symbolic -// link, and no component BELOW the platform namespace may be one either. -// The components above that point ("/tmp" and "/var" on macOS) belong to the -// platform, not to the protected path, and are absorbed rather than -// reported. This is the same rule `protected-anchor.js` already applies in -// `anchorDirFor`, so the two files agree on what a legitimate anchor is. +// Walk the original path with dirname so drive letters and UNC share roots +// remain intact. On POSIX only, preserve the platform namespace exception +// ("/tmp" and "/var" on macOS); Windows ancestors have no such exception. // // Redirecting the lock file itself remains detected: the identity is built // from the device and inode of the directory and of the lock file, so a // replaced domain is caught by `expectedIdentity` even when the path still // resolves. function assertAnchorPathIsNotRedirected(dir) { - const resolved = path.resolve(dir); - const parts = resolved.split(path.sep).filter(Boolean); - for (let i = parts.length - 1; i >= 1; i -= 1) { - const component = path.sep + parts.slice(0, i + 1).join(path.sep); + let component = path.resolve(dir); + // path.win32.parse treats \\?\UNC\ as the root. The filesystem root is + // the share; walking above it would stat an incomplete server-only path. + const uncRoot = process.platform === "win32" && /^\\\\\?\\UNC\\[^\\]+\\[^\\]+(?=\\|$)/i.exec(component); + const root = uncRoot ? uncRoot[0] : path.parse(component).root; + while (component !== root) { + const parent = path.dirname(component); + if (process.platform !== "win32" && parent === root) break; let componentStat; try { componentStat = fs.lstatSync(component); } - catch { break; } - if (componentStat.isSymbolicLink()) fail("ERR_COORDINATION_LOCK_UNSAFE"); + catch { fail("ERR_COORDINATION_LOCK_UNSAFE"); } + if (!componentStat.isDirectory() || componentStat.isSymbolicLink()) fail("ERR_COORDINATION_LOCK_UNSAFE"); + component = parent; + } +} + +// Windows mode bits do not describe the DACL. Read the native adapter rather +// than accepting a caller's platform/adapter override as evidence that a lock +// is private. The store separately checks the declared service identity. +function assertWindowsFilePermissions(filePath) { + const authz = require("./anchor-authorization"); + const adapter = authz.adapterFor("windows"); + const caller = adapter.currentIdentity(); + const owner = adapter.ownerIdentity(filePath); + const writers = adapter.writableIdentities(filePath); + if (!caller || !owner || !(writers instanceof Set)) fail("ERR_ANCHOR_AUTHORIZATION_UNKNOWN"); + if (owner !== caller) fail("ERR_ANCHOR_OWNERSHIP"); + if (writers.has(authz.ANY_IDENTITY)) fail("ERR_ANCHOR_PERMISSIONS"); + if (!writers.has(caller)) fail("ERR_ANCHOR_WRITER_SET"); + const extra = [...writers].filter((identity) => identity !== caller); + if (extra.some((identity) => !authz.isServiceVirtualAccountSid(identity))) fail("ERR_ANCHOR_WRITER_SET"); + if (extra.length > 0) { + const groups = adapter.currentGroups(); + if (!(groups instanceof Set)) fail("ERR_ANCHOR_AUTHORIZATION_UNKNOWN"); + if (extra.some((identity) => !groups.has(identity))) fail("ERR_ANCHOR_WRITER_SET"); } } @@ -43,9 +67,11 @@ function identity(dir) { const file = fs.lstatSync(path.join(dir, FILE)); if (!parent.isDirectory() || parent.isSymbolicLink() || !file.isFile() || file.isSymbolicLink() || file.nlink !== 1 || - (file.mode & 0o077) !== 0 || (typeof process.getuid === "function" && file.uid !== process.getuid())) { + (process.platform !== "win32" && ((file.mode & 0o077) !== 0 || + (typeof process.getuid === "function" && file.uid !== process.getuid())))) { fail("ERR_COORDINATION_LOCK_UNSAFE"); } + if (process.platform === "win32") assertWindowsFilePermissions(path.join(dir, FILE)); assertAnchorPathIsNotRedirected(dir); return [parent.dev, parent.ino, file.dev, file.ino].join(":"); } @@ -53,6 +79,7 @@ function withLock(anchorDir, action, { create = false, expectedIdentity = null } const dir = path.resolve(anchorDir); const file = path.join(dir, FILE); if (create) { + assertAnchorPathIsNotRedirected(dir); const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600); fs.closeSync(fd); } @@ -94,4 +121,4 @@ function withLock(anchorDir, action, { create = false, expectedIdentity = null } } } } -module.exports = { withLock, FILE, TIMEOUT_MS }; +module.exports = { withLock, FILE, TIMEOUT_MS, assertWindowsFilePermissions, assertAnchorPathIsNotRedirected }; diff --git a/tests/coordination/cross-machine-permit.test.js b/tests/coordination/cross-machine-permit.test.js index 1622171e..405c7e0c 100644 --- a/tests/coordination/cross-machine-permit.test.js +++ b/tests/coordination/cross-machine-permit.test.js @@ -18,7 +18,7 @@ function fixture() { const snapshotDir = path.join(dir, "snapshot"); fs.mkdirSync(pinDir, { recursive: true }); fs.mkdirSync(snapshotDir, { recursive: true }); - const store = ProtectedPinStore.open(pinDir, { + const store = ProtectedPinStore.initialize(pinDir, { decisionId: "D-test-cross", anchorMode: anchorModule.ANCHOR_MODE.permissive, }); diff --git a/tests/coordination/fixtures/protected-pin-store-process.js b/tests/coordination/fixtures/protected-pin-store-process.js new file mode 100644 index 00000000..e5fc7365 --- /dev/null +++ b/tests/coordination/fixtures/protected-pin-store-process.js @@ -0,0 +1,38 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const Module = require("node:module"); +const [dir, mode, release, version] = process.argv.slice(2); +function event(value) { fs.writeSync(1, JSON.stringify(value) + "\n"); } +let Store = require("../../../lib/coordination/protected-pin-store").ProtectedPinStore; +if (version) { + const filename = path.resolve(__dirname, "../../../lib/coordination/protected-pin-store.js"); + const compiled = new Module(filename, module); + compiled.filename = filename; + compiled.paths = Module._nodeModulePaths(path.dirname(filename)); + compiled._compile(fs.readFileSync(path.join(__dirname, `protected-pin-store-v${version}-source.txt`), "utf8"), filename); + Store = compiled.exports.ProtectedPinStore; +} +try { + event({ event: "starting" }); + const store = Store.open(dir, { decisionId: "D-test-pin", anchorMode: "permissive" }); + if (mode === "read") { + event({ event: "result", ok: true, sequence: store.revocationState().appliedRevocationSequence }); + } else { + store.criticalSection(() => { + if (mode === "hold") { + event({ event: "paused" }); + const deadline = Date.now() + 15000; + while (!fs.existsSync(release)) { + if (Date.now() > deadline) throw new Error("release deadline exceeded"); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + } + store.commitRevocation({ sequence: 1, revokedFingerprints: ["revoked"], permitSequence: 1 }); + }); + event({ event: "result", ok: true }); + } +} catch (error) { + event({ event: "result", ok: false, code: error.key || error.code || error.message }); +} diff --git a/tests/coordination/fixtures/protected-pin-store-v1-source.txt b/tests/coordination/fixtures/protected-pin-store-v1-source.txt new file mode 100644 index 00000000..9ababbd5 --- /dev/null +++ b/tests/coordination/fixtures/protected-pin-store-v1-source.txt @@ -0,0 +1,783 @@ +// The REGISTERED anchor domain (design document section 4.2.1). +// +// This is NOT the witnessing anchor domain. It stores the batch public +// key pin and nothing else: it carries no journal, witnesses no database +// body digest, and takes no part in rollback detection. Reusing +// ProtectedPermitStore for it would graft the "witnesses the database body" +// semantics onto a directory that only has to hold a pin. +// +// What it DOES reuse is the permit store's permission judgement surface: the +// writable-identity set enumeration and comparison, the null-is-fail-closed +// rule, and the mandatory service identity declaration. The contract this +// domain adds is the same one the permit store's own domain carries -- the writable +// set must be EXACTLY the service identity -- and it is checked with the +// same code, so the two cannot drift apart. +// +// The domain lives on the VERIFIER's machine (Windows side). The permit +// database stays on the Mac side. The two are on different machines, so +// their separation is automatic there, but the check is still made +// explicitly: a deployment shape change must not be allowed to quietly +// place the pin inside the permit database's directory. +// +// Two rules govern what may be written here, and they are different in +// kind: +// +// 1. Pin entries only ever GROW. A pin records a registration fact (who +// was registered as a verifiable key). Revocation does not remove a +// pin entry -- revocation semantics are carried entirely by the +// fingerprint-level revoked set (design section 2.3.1 R4-B4 ruling 1). +// Removing the entry would also deadlock the revocation protocol: a +// revoked key could then never sign the list that revokes it. +// 2. The revoked fingerprint set also only ever GROWS, and it is a set of +// facts rather than a list of intentions, so a list that drops a +// fingerprint is refused. +// +// The state file is the domain's whole content. It is written atomically: +// the bytes go to a temporary file in the same directory, are flushed and +// fsynced, and are then renamed over the target. A reader therefore sees +// either the previous state or the next one, never a half-written file. +// The rename is atomic within a directory on the same filesystem, which is +// why the temporary file is created in the domain itself and not in the +// system temp directory. + +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); + +const anchor = require("./protected-anchor"); +const { CoordinationError } = require("./errors"); + +function fail(code) { throw new CoordinationError(code); } + +const STATE_FILE = "pin-state.json"; +// The marker that records that this domain has ever held state. See +// domainWasInitialised() for why it exists. +const DOMAIN_MARKER_FILE = "pin-domain.json"; +const STATE_SCHEMA_VERSION = 1; +const DEFAULT_LOCK_TTL_MS = 30_000; + +// The batch identity is the triple that decides whether an existing pin +// belongs to THIS batch (design section 2.2.3 R3-B3 ruling 1). The fields +// that are deliberately NOT part of it are listed in the design table: +// run_id and generation would kill a legitimate reconnect, and +// valid_from / valid_until / source_digest / deployment_digest do not +// distinguish one batch from another. +const BATCH_IDENTITY_FIELDS = Object.freeze(["decision_id", "instance_id", "fingerprint"]); + +// The fields the registration action CARRIES (design section 2.2's field +// table). They are recorded on the pin entry, and they are deliberately +// NOT part of the batch identity: +// +// run_id / generation -- participating in the identity comparison would +// kill a legitimate reconnect (R3-B3 ruling 1). +// valid_from / valid_until -- they change on renewal, so they are unstable. +// source_digest / deployment_digest -- same-batch and cross-batch may +// share them, so they do not tell batches apart. +// +// Recording them is a different obligation from comparing them: the +// comparison rule stays the triple, and the entry keeps the record of what +// this registration actually carried. +const REGISTRATION_CARRIED_FIELDS = Object.freeze([ + "source_digest", + "deployment_digest", + "run_id", + "generation", + "valid_from", + "valid_until", +]); + +// The carried fields must never enter the identity comparison. +// +// This is asserted here rather than left to a comment, because the two +// lists are the whole point of the distinction and a comment cannot +// fail: a later change that added, say, `run_id` to +// BATCH_IDENTITY_FIELDS would compile, pass every existing test that +// only re-registers the same values, and silently start killing +// legitimate reconnects. +// +// The two sets are disjoint by construction: the carried fields are the +// design's section 2.2 field table minus the batch identity triple. +for (const field of REGISTRATION_CARRIED_FIELDS) { + if (BATCH_IDENTITY_FIELDS.includes(field)) { + // A carried field that is also an identity criterion is exactly the + // regression this assertion exists to catch. + fail("ERR_PERMIT_INPUT_INVALID"); + } +} + +function requireText(value, key) { + if (typeof value !== "string" || !value) fail(key || "ERR_PERMIT_INPUT_INVALID"); + return value; +} + +function requireSafeInteger(value, key) { + if (!Number.isSafeInteger(value)) fail(key || "ERR_PERMIT_INPUT_INVALID"); + return value; +} + +// The generation of a run, in the form the SIGNED BINDING LAYER actually +// produces. +// +// The permit store's acquire()/reserve() take `generation` as a TEXT +// value (protected-permit-store.js requires it with requireText), and the +// signature layer carries that value through unchanged: the signed +// message's `generation` is the string the permit store held. +// +// The registration action carries that same binding value through +// unchanged -- it is NOT a new field and NOT a re-typed one. Requiring a +// safe integer here would therefore REJECT the very value the wiring is +// supposed to pass through: an integrator who reads the permit's +// `generation` and hands it to registerOnTarget verbatim would get +// ERR_PERMIT_INPUT_INVALID for a value that is perfectly valid upstream. +// +// So the accepted shape is the upstream one: a non-empty string. A +// number is accepted too and is normalised to its decimal text, because +// the upstream value for a given run is always the same string and the +// entry must record exactly what was carried. +function requireGenerationText(value, key) { + if (typeof value === "number" && Number.isSafeInteger(value)) return String(value); + if (typeof value === "string" && value) return value; + fail(key || "ERR_PERMIT_INPUT_INVALID"); + return null; +} + +function sha256Hex(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function emptyState() { + return { + schema_version: STATE_SCHEMA_VERSION, + // The pins that have been registered on this machine. Each entry is a + // registration fact and is never removed. + pins: [], + // The fingerprints that have been revoked. Only ever grows. + revoked_fingerprints: [], + // The revocation sequence that has been APPLIED locally. The revocation + // list whose sequence is applied+1 is the only one accepted next. + applied_revocation_sequence: 0, + // The highest sequence that has been SEEN. This may run ahead of the + // applied one while a pull is in flight. + highest_seen_revocation_sequence: 0, + // The clock-poisoning marker. A poisoned domain refuses every new + // permit until it is cleared by the procedure the design defines. + clock_poisoned: false, + }; +} + +function stateFilePath(pinDir) { + return path.join(pinDir, STATE_FILE); +} + +// The domain-initialisation marker. +// +// Its only job is to make one question answerable: has this domain ever +// held state? Without it, a missing state file reads as "this domain is +// empty", and a domain that lost its state file after revoking a key +// would report that nothing was ever revoked -- and the revoked key +// would be authorised again. +// +// The marker is written with create-if-absent semantics and is never +// removed: a domain that was initialised stays initialised. That is the +// point -- once a domain has held revocation state, its loss is a fault +// to be refused, not a fresh start to be tolerated. +function domainMarkerPath(pinDir) { + return path.join(pinDir, DOMAIN_MARKER_FILE); +} + +function domainWasInitialised(pinDir) { + try { + fs.statSync(domainMarkerPath(pinDir)); + return true; + } catch { + return false; + } +} + +function markDomainInitialised(pinDir) { + const marker = domainMarkerPath(pinDir); + try { + const handle = fs.openSync(marker, "wx", 0o600); + try { + fs.writeFileSync(handle, JSON.stringify({ + domain_schema_version: STATE_SCHEMA_VERSION, + initialised_at_ms: Date.now(), + })); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + } catch (error) { + // The marker already existing is the normal case on every write after + // the first one. Anything else means the marker could not be created, + // and a domain whose initialisation cannot be recorded must not be + // allowed to proceed -- otherwise the very next lost state file + // would be indistinguishable from a fresh domain. + if (!error || error.code !== "EEXIST") fail("ERR_PIN_STATE_CORRUPT"); + } +} + +function readState(pinDir) { + let raw; + try { + raw = fs.readFileSync(stateFilePath(pinDir), "utf8"); + } catch (error) { + // A state file that does not exist is a domain that has never been + // written to, and that is a legitimate empty domain. + // + // EVERY OTHER read failure is a domain whose state could not be read, + // and that is NOT an empty domain. Treating it as one is the worst + // possible reading of the state: the revocation high-water mark, the + // revoked fingerprint set and the registered pins all come back as + // empty, so a domain that had revoked a key now reports that nothing + // was ever revoked -- and the revoked key is authorised again. + // + // So the distinction is made explicitly here rather than by letting a + // catch-all return the empty state. The distinction that matters is + // "there is no state yet" versus "the state could not be read". + if (error && error.code === "ENOENT") { + // A missing state file is an empty domain only if the domain was + // NEVER INITIALISED. A domain that was initialised and then lost + // its state file has lost the applied revocation high-water mark, + // the revoked fingerprint set and the registered pins -- and the + // design says a failed recovery is a refusal, not a reason to + // continue with the high-water mark at zero + // (design section 2.3.1, "recovery failure means refusal"). + // + // The initialisation marker is what tells the two apart. It is + // written BEFORE the first state file, so a domain that ever had + // state always has the marker, and a deleted state file is then a + // loss rather than a fresh start. + if (domainWasInitialised(pinDir)) fail("ERR_PIN_STATE_CORRUPT"); + return emptyState(); + } + fail("ERR_PIN_STATE_CORRUPT"); + } + let parsed; + try { parsed = JSON.parse(raw); } + catch { fail("ERR_PIN_STATE_CORRUPT"); } + if (!parsed || typeof parsed !== "object") fail("ERR_PIN_STATE_CORRUPT"); + if (parsed.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + if (!Array.isArray(parsed.pins)) fail("ERR_PIN_STATE_CORRUPT"); + if (!Array.isArray(parsed.revoked_fingerprints)) fail("ERR_PIN_STATE_CORRUPT"); + requireSafeInteger(parsed.applied_revocation_sequence, "ERR_PIN_STATE_CORRUPT"); + requireSafeInteger(parsed.highest_seen_revocation_sequence, "ERR_PIN_STATE_CORRUPT"); + if (typeof parsed.clock_poisoned !== "boolean") fail("ERR_PIN_STATE_CORRUPT"); + for (const pin of parsed.pins) { + if (!pin || typeof pin !== "object") fail("ERR_PIN_STATE_CORRUPT"); + for (const field of BATCH_IDENTITY_FIELDS) requireText(pin[field], "ERR_PIN_STATE_CORRUPT"); + requireText(pin.public_key_pem, "ERR_PIN_STATE_CORRUPT"); + } + return parsed; +} + +// The state is written atomically: a temporary file in the same directory, +// then a rename over the target. The temporary file carries a name that no +// reader looks at, and it is removed if the write fails, so a crashed +// write leaves the previous state intact rather than a truncated one. +function writeState(pinDir, state) { + // The marker is written BEFORE the state, so a domain that ever held + // state always has the marker. The order matters: if the state write + // succeeded and the marker write had not, a later lost state file would + // look like a fresh domain. + markDomainInitialised(pinDir); + const body = JSON.stringify(state); + const target = stateFilePath(pinDir); + const temporary = path.join(pinDir, "." + STATE_FILE + ".tmp-" + process.pid); + const handle = fs.openSync(temporary, "wx", 0o600); + try { + fs.writeFileSync(handle, body); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + fs.renameSync(temporary, target); +} + +// The public key fingerprint is derived from the key material itself, so a +// pin entry cannot claim a fingerprint that does not belong to the key it +// carries. The fingerprint is what the design compares, so it has to be +// the derived one and not a value the caller supplied. +function publicKeyFingerprint(publicKeyPem) { + const key = crypto.createPublicKey(publicKeyPem); + const der = key.export({ type: "spki", format: "der" }); + return sha256Hex(der); +} + +function findPin(state, fingerprint) { + return state.pins.find((pin) => pin.fingerprint === fingerprint) || null; +} + +// The batch identity comparison is field-by-field equality on the triple +// (design section 2.2.3 R3-B3 ruling 1). A pin that is missing or +// unreadable is "undecidable", and an undecidable fact is handled as NOT +// satisfied -- the route stops. It is never treated as "different batch +// but harmless", because the two would lead to the same action here. +function sameBatchIdentity(pin, batchIdentity) { + if (!pin) return false; + for (const field of BATCH_IDENTITY_FIELDS) { + if (pin[field] !== batchIdentity[field]) return false; + } + return true; +} + +class ProtectedPinStore { + #pinDir; + #decisionId; + #serviceIdentity; + #anchorMode; + #platform; + #lockTtlMs; + #lockDepth = 0; + + constructor(pinDir, options = {}) { + this.#pinDir = pinDir; + this.#decisionId = options.decisionId; + this.#serviceIdentity = options.serviceIdentity; + this.#anchorMode = options.anchorMode; + this.#platform = options.platform; + // The lock's own expiry. It is a backstop for a holder that died: a + // live holder always wins, and a dead one is reclaimed immediately + // rather than after the expiry elapses. + this.#lockTtlMs = Number.isSafeInteger(options.lockTtlMs) && options.lockTtlMs > 0 + ? options.lockTtlMs + : DEFAULT_LOCK_TTL_MS; + } + + // open() brings the domain up. The permission contract is checked BEFORE + // anything is read or written, so a domain whose writable set is not + // exactly the service identity is refused rather than operated in. + static open(pinDir, options = {}) { + requireText(options.decisionId, "ERR_PERMIT_INPUT_INVALID"); + if (!pinDir) fail("ERR_PERMIT_INPUT_INVALID"); + const resolved = path.resolve(pinDir); + // The pin directory must exist, must not be a symlink, and must not be + // a file. anchorDirFor checks exactly those properties and normalises + // the canonical path. + const anchorDir = anchor.anchorDirFor(resolved); + // The permission judgement surface is the permit store's. The strict mode + // requires the service identity to be declared on Windows, because + // there the calling account is not necessarily the service -- without + // the declaration the comparison degenerates into comparing the caller + // with itself. + anchor.assertAnchorPermissionsWithIdentity( + anchorDir, + anchor.assertKnownAnchorMode(options.anchorMode), + options.serviceIdentity, + options, + ); + return new ProtectedPinStore(anchorDir, options); + } + + // The permit database's directory must not contain this domain and this + // domain must not contain it. On the two-machine deployment the two are + // on different hosts and the constraint holds automatically; the check is + // made explicitly so that a deployment change cannot quietly violate it. + assertSeparateFromPermitDatabase(databasePath) { + anchor.assertSeparateDomains(this.#pinDir, databasePath); + } + + get pinDir() { return this.#pinDir; } + + // The local critical section. + // + // "A transaction" in this design means the LOCAL critical section on the + // registered anchor domain on the verifier's machine (design section + // 2.3.1 R3-B2 ruling 1). It does NOT cover the Mac-side permit store and + // it does NOT cover the network connection. + // + // The lock is a file lock taken on the domain itself. It is deliberately + // not shared with the permit store's SQLite transaction: those are two separate + // locks on two separate machines, and the only thing that passes between + // them is a signed revocation list. + // + // The caller injects the body; the body runs with the lock held, and the + // lock is released whether the body returns or throws. + // + // The section is REENTRANT within one process. The cross-machine path + // takes the section around the whole registration -- the exclusivity + // check, the pin write and the snapshot advance are one atomic step -- + // and that path calls into the store's own registerPin, which takes the + // section again. A non-reentrant lock would make the outer section fail + // against its own inner call, and the fix for that must NOT be "take the + // lock less often": the outer section is what makes the three steps one + // step. + // + // Reentrancy is safe here because the lock exists to keep a SECOND + // PROCESS from interleaving. Within one process the code is already + // sequential, so a nested entry has nothing to exclude. + criticalSection(body) { + if (typeof body !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + if (this.#lockDepth > 0) { + this.#lockDepth += 1; + try { + return body(); + } finally { + this.#lockDepth -= 1; + } + } + const lockPath = path.join(this.#pinDir, ".pin-critical-section.lock"); + const release = acquireLock(lockPath, this.#lockTtlMs); + this.#lockDepth = 1; + try { + return body(); + } finally { + this.#lockDepth = 0; + release(); + } + } + + // Register a pin. + // + // The write is atomic create-if-absent with O_EXCL semantics: of two + // concurrent registrants only the first succeeds, the second gets EEXIST + // and does NOT overwrite (design section 2.2.3). That property is what + // makes the registration exclusive without a separate check-then-act + // window. + // + // The caller supplies the write as a function, and the whole + // decision-and-write runs inside the protected critical section, so the + // "there is no existing pin" half of the exclusivity guarantee is not a + // window between a check and a write. + registerPin(options) { + const { batchIdentity, publicKeyPem, write } = options; + for (const field of BATCH_IDENTITY_FIELDS) requireText(batchIdentity[field], "ERR_PERMIT_INPUT_INVALID"); + requireText(publicKeyPem, "ERR_PERMIT_INPUT_INVALID"); + if (typeof write !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + // The registration action carries the design's field table (section + // 2.2). All six are required here: a registration that cannot say what + // it carried cannot be audited later, and the design lists them as + // part of the registration action, not as optional annotations. + // + // This is a CARRIED obligation, not an identity criterion. The batch + // comparison below still uses the triple; see + // REGISTRATION_CARRIED_FIELDS for why those two are different things. + requireText(options.sourceDigest, "ERR_PERMIT_INPUT_INVALID"); + requireText(options.deploymentDigest, "ERR_PERMIT_INPUT_INVALID"); + // The string conversion is only for the numeric-looking run ids. It is + // applied AFTER the presence check: String(undefined) is the string + // "undefined", which would sail through a text check and record a + // run id that was never supplied. + if (options.runId === undefined || options.runId === null) { + fail("ERR_PERMIT_INPUT_INVALID"); + } + requireText(String(options.runId), "ERR_PERMIT_INPUT_INVALID"); + // The generation is the signed binding value, in the form the + // upstream permit store holds it (a text value). See + // requireGenerationText for why an integer requirement here would + // reject the very value the wiring is supposed to pass through. + const generation = requireGenerationText(options.generation, "ERR_PERMIT_INPUT_INVALID"); + requireSafeInteger(options.validFrom, "ERR_PERMIT_INPUT_INVALID"); + requireSafeInteger(options.validUntil, "ERR_PERMIT_INPUT_INVALID"); + const fingerprint = publicKeyFingerprint(publicKeyPem); + if (batchIdentity.fingerprint !== fingerprint) fail("ERR_PIN_FINGERPRINT_MISMATCH"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + const existing = findPin(state, fingerprint); + if (existing) { + // A pin for this key already exists. If it belongs to THIS batch + // the route is a reconnect: the pin is reused, not rewritten. + // If it belongs to a DIFFERENT batch the route stops -- the pin is + // never overwritten with a new batch. + if (sameBatchIdentity(existing, batchIdentity)) { + return { registered: false, reused: true, fingerprint, pin: existing }; + } + fail("ERR_PIN_BATCH_MISMATCH"); + } + const entry = { + decision_id: batchIdentity.decision_id, + instance_id: batchIdentity.instance_id, + fingerprint, + public_key_pem: publicKeyPem, + registered_at_ms: requireSafeInteger(options.nowMs, "ERR_PERMIT_INPUT_INVALID"), + // The carried fields are recorded on the entry. They are not read + // back for the batch comparison -- that stays the triple -- so a + // renewal that changes valid_until does not change the batch. + source_digest: options.sourceDigest, + deployment_digest: options.deploymentDigest, + run_id: String(options.runId), + generation, + valid_from: options.validFrom, + valid_until: options.validUntil, + }; + // The create-if-absent write is the atomicity guarantee. A concurrent + // creator that loses the race gets EEXIST and does not overwrite. + const created = write(entry, fingerprint); + if (created === false) fail("ERR_PIN_ALREADY_EXISTS"); + state.pins.push(entry); + writeState(this.#pinDir, state); + return { registered: true, reused: false, fingerprint, pin: entry }; + }); + } + + // The pins that are registered on this machine. This is the ONLY source + // of public keys the verification path accepts. + listPins() { + const state = readState(this.#pinDir); + return state.pins.map((pin) => ({ ...pin })); + } + + // The fingerprint set of the registered pins. A revocation list is + // refused unless the key that signed it is in this set. + pinnedFingerprints() { + const state = readState(this.#pinDir); + return new Set(state.pins.map((pin) => pin.fingerprint)); + } + + // The public key for a fingerprint, or null. Null means "not registered + // here", which the verification path treats as a refusal -- it never + // falls back to a key carried by the list itself. + publicKeyFor(fingerprint) { + const state = readState(this.#pinDir); + const pin = findPin(state, fingerprint); + return pin ? pin.public_key_pem : null; + } + + // The local revocation state, as the verification path consumes it. + revocationState() { + const state = readState(this.#pinDir); + return { + appliedRevocationSequence: state.applied_revocation_sequence, + highestSeenRevocationSequence: state.highest_seen_revocation_sequence, + revokedFingerprints: new Set(state.revoked_fingerprints), + }; + } + + // Commit an applied revocation list. + // + // The revoked set and the applied high-water mark advance TOGETHER. The + // design's local invariant B is exactly that: on exit from the critical + // section, either both advanced or neither did (design section 2.3.1 + // R3-B2 ruling 3). + // + // The caller has already verified the list -- the signature, the chain + // position, the signer's presence in the pin and the signer's absence + // from the revoked set. This function only commits what passed those + // checks; it never decides whether a list is acceptable. + commitRevocation(options) { + const { revokedFingerprints, sequence } = options; + requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); + if (!Array.isArray(revokedFingerprints)) fail("ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + // The applied set only grows. A commit that would drop a fingerprint + // is refused and nothing advances. + const incoming = new Set(revokedFingerprints); + for (const known of state.revoked_fingerprints) { + if (!incoming.has(known)) fail("ERR_REVOCATION_SET_NOT_MONOTONIC"); + } + state.revoked_fingerprints = Array.from(incoming).sort(); + state.applied_revocation_sequence = sequence; + if (sequence > state.highest_seen_revocation_sequence) { + state.highest_seen_revocation_sequence = sequence; + } + writeState(this.#pinDir, state); + return { + appliedRevocationSequence: state.applied_revocation_sequence, + revokedFingerprints: state.revoked_fingerprints.length, + }; + }); + } + + // Record that a revocation list was SEEN, without applying it. The seen + // mark may run ahead of the applied one while a pull is in flight. + noteRevocationSeen(sequence) { + requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + if (sequence > state.highest_seen_revocation_sequence) { + state.highest_seen_revocation_sequence = sequence; + writeState(this.#pinDir, state); + } + return { highestSeenRevocationSequence: state.highest_seen_revocation_sequence }; + }); + } + + // The direct revocation registration (design section 2.3.1 R4-B4 + // ruling 5). + // + // This is the ONE exception path that does not depend on a cross-machine + // signature: it is how a batch's revocation is conveyed when no valid + // batch key is left to sign a list with. It writes the revoked + // fingerprint set directly, and it advances the revocation sequence by + // one so the chain does not break. + // + // It does NOT relax the rule that the signer of a list must still be + // valid -- this path never verifies a signature at all, so that rule does + // not apply to it. + registerDirectRevocation(options) { + const { fingerprint, sequence } = options; + requireText(fingerprint, "ERR_PERMIT_INPUT_INVALID"); + requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + const incoming = new Set(state.revoked_fingerprints); + incoming.add(fingerprint); + state.revoked_fingerprints = Array.from(incoming).sort(); + state.applied_revocation_sequence = sequence; + if (sequence > state.highest_seen_revocation_sequence) { + state.highest_seen_revocation_sequence = sequence; + } + writeState(this.#pinDir, state); + return { + appliedRevocationSequence: state.applied_revocation_sequence, + revokedFingerprints: state.revoked_fingerprints.length, + }; + }); + } + + // The clock-poisoning marker. A poisoned domain refuses every new + // permit until the clearing procedure the design defines has run. + isClockPoisoned() { + return readState(this.#pinDir).clock_poisoned; + } + + setClockPoisoned(poisoned) { + if (typeof poisoned !== "boolean") fail("ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + state.clock_poisoned = poisoned; + writeState(this.#pinDir, state); + return { clockPoisoned: state.clock_poisoned }; + }); + } + + // The batch identity of a registered pin, for the reconnect comparison. + // An unregistered or unreadable pin yields null, which the reconnect + // logic treats as undecidable -- and undecidable means the route stops. + batchIdentityFor(fingerprint) { + const pin = findPin(readState(this.#pinDir), fingerprint); + if (!pin) return null; + return { + decision_id: pin.decision_id, + instance_id: pin.instance_id, + fingerprint: pin.fingerprint, + }; + } +} + +// The exclusive lock. +// +// Node has no advisory file lock in its standard library, so the lock is an +// O_EXCL lock FILE with an owner and an expiry, and it follows the same +// shape the coordination journal already uses (journal.js, section 8.1): +// exactly one holder creates the file, a holder that dies leaves a lock +// that is reclaimed by moving it out of the lock namespace atomically, and +// a lock that was renewed under a live owner is put back rather than +// stolen. +// +// The lock is not the only thing standing between two registrants: the +// atomic create-if-absent write of the pin entry is. The lock exists so +// that the read-decide-write sequence is not interleaved with another +// registrant's, and so the revocation commit is a single atomic step. +function acquireLock(lockPath, ttlMs) { + const owner = "pin-pid" + process.pid + "-" + Date.now().toString(36); + const token = crypto.randomBytes(16).toString("hex"); + const payload = () => JSON.stringify({ + owner, + token, + acquiredAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttlMs).toISOString(), + pid: process.pid, + }); + + const tryCreate = () => { + // O_EXCL: the create either succeeds or reports that somebody else + // already holds the lock. There is no window in which two holders both + // believe they created it. + const fd = fs.openSync(lockPath, "wx", 0o600); + try { + fs.writeFileSync(fd, payload(), "utf8"); + fs.fsyncSync(fd); + return fd; + } catch (error) { + fs.closeSync(fd); + throw error; + } + }; + + let fd; + try { + fd = tryCreate(); + } catch (error) { + if (!error || error.code !== "EEXIST") throw error; + // Somebody holds the lock. If that holder is alive the section is + // refused rather than waited on: the design wants the revocation pull + // to carry its own explicit timeout, and a silent wait here would let + // the lock be held for an unbounded time. + let existing = null; + try { existing = JSON.parse(fs.readFileSync(lockPath, "utf8")); } + catch { existing = null; } + if (existing && isProcessAlive(existing.pid)) { + fail("ERR_LEASE_CONFLICT"); + } + // The holder is gone. Move the stale lock out of the lock namespace + // atomically. The file is never unlinked in place: another contender + // may already have replaced it, and unlinking would leave two live + // writers. + const stalePath = lockPath + ".stale." + process.pid + "." + crypto.randomBytes(4).toString("hex"); + try { + fs.renameSync(lockPath, stalePath); + } catch (reclaimError) { + fail("ERR_LEASE_CONFLICT"); + } + try { + let moved = null; + try { moved = JSON.parse(fs.readFileSync(stalePath, "utf8")); } + catch { moved = null; } + // The moved file has to be the one that was inspected. If it is not, + // somebody replaced the lock while it was being reclaimed, and the + // safe answer is to refuse rather than to take the lock. + if (!moved || moved.owner !== (existing && existing.owner)) { + try { fs.renameSync(stalePath, lockPath); } catch { /* fail closed below */ } + fail("ERR_LEASE_CONFLICT"); + } + // A live owner always wins, even if its expiry has not elapsed. + if (isProcessAlive(moved.pid)) { + try { fs.renameSync(stalePath, lockPath); } catch { /* fail closed below */ } + fail("ERR_LEASE_CONFLICT"); + } + fd = tryCreate(); + } catch (inner) { + if (inner && inner.code === "EEXIST") fail("ERR_LEASE_CONFLICT"); + throw inner; + } finally { + try { fs.unlinkSync(stalePath); } catch { /* best effort */ } + } + } + + return () => { + try { + // The lock is only released if this holder still owns it. A lock + // that was reclaimed and re-taken belongs to somebody else now, and + // deleting it would revoke THEIR lock. + const current = JSON.parse(fs.readFileSync(lockPath, "utf8")); + if (current.token === token) fs.unlinkSync(lockPath); + } catch { /* already released or replaced */ } + try { fs.closeSync(fd); } catch { /* already closed */ } + }; +} + +function isProcessAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to another account. + return Boolean(error && error.code === "EPERM"); + } +} + +module.exports = { + ProtectedPinStore, + STATE_FILE, + DOMAIN_MARKER_FILE, + STATE_SCHEMA_VERSION, + BATCH_IDENTITY_FIELDS, + REGISTRATION_CARRIED_FIELDS, + publicKeyFingerprint, + sameBatchIdentity, +}; diff --git a/tests/coordination/fixtures/protected-pin-store-v2-source.txt b/tests/coordination/fixtures/protected-pin-store-v2-source.txt new file mode 100644 index 00000000..99f1a8d9 --- /dev/null +++ b/tests/coordination/fixtures/protected-pin-store-v2-source.txt @@ -0,0 +1,785 @@ +// The REGISTERED anchor domain (design document section 4.2.1). +// +// This is NOT the witnessing anchor domain. It stores the batch public +// key pin and nothing else: it carries no journal, witnesses no database +// body digest, and takes no part in rollback detection. Reusing +// ProtectedPermitStore for it would graft the "witnesses the database body" +// semantics onto a directory that only has to hold a pin. +// +// What it DOES reuse is the permit store's permission judgement surface: the +// writable-identity set enumeration and comparison, the null-is-fail-closed +// rule, and the mandatory service identity declaration. The contract this +// domain adds is the same one the permit store's own domain carries -- the writable +// set must be EXACTLY the service identity -- and it is checked with the +// same code, so the two cannot drift apart. +// +// The domain lives on the VERIFIER's machine (Windows side). The permit +// database stays on the Mac side. The two are on different machines, so +// their separation is automatic there, but the check is still made +// explicitly: a deployment shape change must not be allowed to quietly +// place the pin inside the permit database's directory. +// +// Two rules govern what may be written here, and they are different in +// kind: +// +// 1. Pin entries only ever GROW. A pin records a registration fact (who +// was registered as a verifiable key). Revocation does not remove a +// pin entry -- revocation semantics are carried entirely by the +// fingerprint-level revoked set (design section 2.3.1 R4-B4 ruling 1). +// Removing the entry would also deadlock the revocation protocol: a +// revoked key could then never sign the list that revokes it. +// 2. The revoked fingerprint set also only ever GROWS, and it is a set of +// facts rather than a list of intentions, so a list that drops a +// fingerprint is refused. +// +// The state file is the domain's whole content. It is written atomically: +// the bytes go to a temporary file in the same directory, are flushed and +// fsynced, and are then renamed over the target. A reader therefore sees +// either the previous state or the next one, never a half-written file. +// The rename is atomic within a directory on the same filesystem, which is +// why the temporary file is created in the domain itself and not in the +// system temp directory. + +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); + +const anchor = require("./protected-anchor"); +const { CoordinationError } = require("./errors"); + +function fail(code) { throw new CoordinationError(code); } + +const STATE_FILE = "pin-state.json"; +// The marker that records that this domain has ever held state. See +// domainWasInitialised() for why it exists. +const DOMAIN_MARKER_FILE = "pin-domain.json"; +const STATE_SCHEMA_VERSION = 2; +const DEFAULT_LOCK_TTL_MS = 30_000; +// A failed poison write must not make another store handle in this process +// authorize the same domain. Cross-process/restart safety still requires the +// independently witnessed durable state. +const CLOCK_POISONED_DOMAINS = new Set(); + +// The batch identity is the triple that decides whether an existing pin +// belongs to THIS batch (design section 2.2.3 R3-B3 ruling 1). The fields +// that are deliberately NOT part of it are listed in the design table: +// run_id and generation would kill a legitimate reconnect, and +// valid_from / valid_until / source_digest / deployment_digest do not +// distinguish one batch from another. +const BATCH_IDENTITY_FIELDS = Object.freeze(["decision_id", "instance_id", "fingerprint"]); + +// The fields the registration action CARRIES (design section 2.2's field +// table). They are recorded on the pin entry, and they are deliberately +// NOT part of the batch identity: +// +// run_id / generation -- participating in the identity comparison would +// kill a legitimate reconnect (R3-B3 ruling 1). +// valid_from / valid_until -- they change on renewal, so they are unstable. +// source_digest / deployment_digest -- same-batch and cross-batch may +// share them, so they do not tell batches apart. +// +// Recording them is a different obligation from comparing them: the +// comparison rule stays the triple, and the entry keeps the record of what +// this registration actually carried. +const REGISTRATION_CARRIED_FIELDS = Object.freeze([ + "source_digest", + "deployment_digest", + "run_id", + "generation", + "valid_from", + "valid_until", +]); + +// The carried fields must never enter the identity comparison. +// +// This is asserted here rather than left to a comment, because the two +// lists are the whole point of the distinction and a comment cannot +// fail: a later change that added, say, `run_id` to +// BATCH_IDENTITY_FIELDS would compile, pass every existing test that +// only re-registers the same values, and silently start killing +// legitimate reconnects. +// +// The two sets are disjoint by construction: the carried fields are the +// design's section 2.2 field table minus the batch identity triple. +for (const field of REGISTRATION_CARRIED_FIELDS) { + if (BATCH_IDENTITY_FIELDS.includes(field)) { + // A carried field that is also an identity criterion is exactly the + // regression this assertion exists to catch. + fail("ERR_PERMIT_INPUT_INVALID"); + } +} + +function requireText(value, key) { + if (typeof value !== "string" || !value) fail(key || "ERR_PERMIT_INPUT_INVALID"); + return value; +} + +function requireSafeInteger(value, key) { + if (!Number.isSafeInteger(value)) fail(key || "ERR_PERMIT_INPUT_INVALID"); + return value; +} + +// The generation of a run, in the form the SIGNED BINDING LAYER actually +// produces. +// +// The permit store's acquire()/reserve() take `generation` as a TEXT +// value (protected-permit-store.js requires it with requireText), and the +// signature layer carries that value through unchanged: the signed +// message's `generation` is the string the permit store held. +// +// The registration action carries that same binding value through +// unchanged -- it is NOT a new field and NOT a re-typed one. Requiring a +// safe integer here would therefore REJECT the very value the wiring is +// supposed to pass through: an integrator who reads the permit's +// `generation` and hands it to registerOnTarget verbatim would get +// ERR_PERMIT_INPUT_INVALID for a value that is perfectly valid upstream. +// +// So the accepted shape is the upstream one: a non-empty string. A +// number is accepted too and is normalised to its decimal text, because +// the upstream value for a given run is always the same string and the +// entry must record exactly what was carried. +function requireGenerationText(value, key) { + if (typeof value === "number" && Number.isSafeInteger(value)) return String(value); + if (typeof value === "string" && value) return value; + fail(key || "ERR_PERMIT_INPUT_INVALID"); + return null; +} + +function sha256Hex(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function emptyState() { + return { + schema_version: STATE_SCHEMA_VERSION, + // The pins that have been registered on this machine. Each entry is a + // registration fact and is never removed. + pins: [], + // The fingerprints that have been revoked. Only ever grows. + revoked_fingerprints: [], + // The revocation sequence that has been APPLIED locally. The revocation + // list whose sequence is applied+1 is the only one accepted next. + applied_revocation_sequence: 0, + // The highest sequence that has been SEEN. This may run ahead of the + // applied one while a pull is in flight. + highest_seen_revocation_sequence: 0, + highest_accepted_permit_sequence: 0, + last_observed_clock_ms: 0, + // The clock-poisoning marker. A poisoned domain refuses every new + // permit until it is cleared by the procedure the design defines. + clock_poisoned: false, + }; +} + +function stateFilePath(pinDir) { + return path.join(pinDir, STATE_FILE); +} + +// The domain-initialisation marker. +// +// Its only job is to make one question answerable: has this domain ever +// held state? Without it, a missing state file reads as "this domain is +// empty", and a domain that lost its state file after revoking a key +// would report that nothing was ever revoked -- and the revoked key +// would be authorised again. +// +// The marker is written with create-if-absent semantics and is never +// removed: a domain that was initialised stays initialised. That is the +// point -- once a domain has held revocation state, its loss is a fault +// to be refused, not a fresh start to be tolerated. +function domainMarkerPath(pinDir) { + return path.join(pinDir, DOMAIN_MARKER_FILE); +} + +function domainWasInitialised(pinDir) { + try { + fs.statSync(domainMarkerPath(pinDir)); + return true; + } catch { + return false; + } +} + +function markDomainInitialised(pinDir) { + const marker = domainMarkerPath(pinDir); + try { + const handle = fs.openSync(marker, "wx", 0o600); + try { + fs.writeFileSync(handle, JSON.stringify({ + domain_schema_version: STATE_SCHEMA_VERSION, + initialised_at_ms: Date.now(), + })); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + } catch (error) { + // The marker already existing is the normal case on every write after + // the first one. Anything else means the marker could not be created, + // and a domain whose initialisation cannot be recorded must not be + // allowed to proceed -- otherwise the very next lost state file + // would be indistinguishable from a fresh domain. + if (!error || error.code !== "EEXIST") fail("ERR_PIN_STATE_CORRUPT"); + } +} + +function readState(pinDir) { + let raw; + try { + raw = fs.readFileSync(stateFilePath(pinDir), "utf8"); + } catch (error) { + // A state file that does not exist is a domain that has never been + // written to, and that is a legitimate empty domain. + // + // EVERY OTHER read failure is a domain whose state could not be read, + // and that is NOT an empty domain. Treating it as one is the worst + // possible reading of the state: the revocation high-water mark, the + // revoked fingerprint set and the registered pins all come back as + // empty, so a domain that had revoked a key now reports that nothing + // was ever revoked -- and the revoked key is authorised again. + // + // So the distinction is made explicitly here rather than by letting a + // catch-all return the empty state. The distinction that matters is + // "there is no state yet" versus "the state could not be read". + if (error && error.code === "ENOENT") { + // A missing state file is an empty domain only if the domain was + // NEVER INITIALISED. A domain that was initialised and then lost + // its state file has lost the applied revocation high-water mark, + // the revoked fingerprint set and the registered pins -- and the + // design says a failed recovery is a refusal, not a reason to + // continue with the high-water mark at zero + // (design section 2.3.1, "recovery failure means refusal"). + // + // The initialisation marker is what tells the two apart. It is + // written BEFORE the first state file, so a domain that ever had + // state always has the marker, and a deleted state file is then a + // loss rather than a fresh start. + if (domainWasInitialised(pinDir)) fail("ERR_PIN_STATE_CORRUPT"); + return emptyState(); + } + fail("ERR_PIN_STATE_CORRUPT"); + } + let parsed; + try { parsed = JSON.parse(raw); } + catch { fail("ERR_PIN_STATE_CORRUPT"); } + if (!parsed || typeof parsed !== "object") fail("ERR_PIN_STATE_CORRUPT"); + if (parsed.schema_version !== 1 && parsed.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + if (!Array.isArray(parsed.pins)) fail("ERR_PIN_STATE_CORRUPT"); + if (!Array.isArray(parsed.revoked_fingerprints)) fail("ERR_PIN_STATE_CORRUPT"); + requireSafeInteger(parsed.applied_revocation_sequence, "ERR_PIN_STATE_CORRUPT"); + requireSafeInteger(parsed.highest_seen_revocation_sequence, "ERR_PIN_STATE_CORRUPT"); + if (parsed.schema_version === STATE_SCHEMA_VERSION && + (!Number.isSafeInteger(parsed.highest_accepted_permit_sequence) || parsed.highest_accepted_permit_sequence < 0 || + !Number.isSafeInteger(parsed.last_observed_clock_ms) || parsed.last_observed_clock_ms < 0)) { + fail("ERR_PIN_STATE_CORRUPT"); + } + if (typeof parsed.clock_poisoned !== "boolean") fail("ERR_PIN_STATE_CORRUPT"); + for (const pin of parsed.pins) { + if (!pin || typeof pin !== "object") fail("ERR_PIN_STATE_CORRUPT"); + for (const field of BATCH_IDENTITY_FIELDS) requireText(pin[field], "ERR_PIN_STATE_CORRUPT"); + requireText(pin.public_key_pem, "ERR_PIN_STATE_CORRUPT"); + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pin.public_key_pem)) fail("ERR_PIN_STATE_CORRUPT"); + } + return parsed; +} + +// The state is written atomically: a temporary file in the same directory, +// then a rename over the target. The temporary file carries a name that no +// reader looks at, and it is removed if the write fails, so a crashed +// write leaves the previous state intact rather than a truncated one. +function writeState(pinDir, state) { + // The marker is written BEFORE the state, so a domain that ever held + // state always has the marker. The order matters: if the state write + // succeeded and the marker write had not, a later lost state file would + // look like a fresh domain. + markDomainInitialised(pinDir); + const body = JSON.stringify(state); + const target = stateFilePath(pinDir); + const temporary = path.join(pinDir, "." + STATE_FILE + ".tmp-" + process.pid + "-" + crypto.randomBytes(12).toString("hex")); + const handle = fs.openSync(temporary, "wx", 0o600); + try { + try { + fs.writeFileSync(handle, body); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + fs.renameSync(temporary, target); + } catch (error) { + try { fs.unlinkSync(temporary); } catch { /* preserve the original failure */ } + throw error; + } +} + +// The public key fingerprint is derived from the key material itself, so a +// pin entry cannot claim a fingerprint that does not belong to the key it +// carries. The fingerprint is what the design compares, so it has to be +// the derived one and not a value the caller supplied. +function publicKeyFingerprint(publicKeyPem) { + const key = crypto.createPublicKey(publicKeyPem); + const der = key.export({ type: "spki", format: "der" }); + return sha256Hex(der); +} + +function findPin(state, fingerprint) { + return state.pins.find((pin) => pin.fingerprint === fingerprint) || null; +} + +// The batch identity comparison is field-by-field equality on the triple +// (design section 2.2.3 R3-B3 ruling 1). A pin that is missing or +// unreadable is "undecidable", and an undecidable fact is handled as NOT +// satisfied -- the route stops. It is never treated as "different batch +// but harmless", because the two would lead to the same action here. +function sameBatchIdentity(pin, batchIdentity) { + if (!pin) return false; + for (const field of BATCH_IDENTITY_FIELDS) { + if (pin[field] !== batchIdentity[field]) return false; + } + return true; +} + +class ProtectedPinStore { + #pinDir; + #decisionId; + #serviceIdentity; + #anchorMode; + #platform; + #lockTtlMs; + #lockDepth = 0; + + constructor(pinDir, options = {}) { + this.#pinDir = pinDir; + this.#decisionId = options.decisionId; + this.#serviceIdentity = options.serviceIdentity; + this.#anchorMode = options.anchorMode; + this.#platform = options.platform; + // The expiry remains informational; an existing lock is not reclaimed + // automatically, even after its owner has died or the expiry elapsed. + this.#lockTtlMs = Number.isSafeInteger(options.lockTtlMs) && options.lockTtlMs > 0 + ? options.lockTtlMs + : DEFAULT_LOCK_TTL_MS; + } + + // open() brings the domain up. The permission contract is checked BEFORE + // anything is read or written, so a domain whose writable set is not + // exactly the service identity is refused rather than operated in. + static open(pinDir, options = {}) { + requireText(options.decisionId, "ERR_PERMIT_INPUT_INVALID"); + if (!pinDir) fail("ERR_PERMIT_INPUT_INVALID"); + const resolved = path.resolve(pinDir); + // The pin directory must exist, must not be a symlink, and must not be + // a file. anchorDirFor checks exactly those properties and normalises + // the canonical path. + const anchorDir = anchor.anchorDirFor(resolved); + // The permission judgement surface is the permit store's. The strict mode + // requires the service identity to be declared on Windows, because + // there the calling account is not necessarily the service -- without + // the declaration the comparison degenerates into comparing the caller + // with itself. + anchor.assertAnchorPermissionsWithIdentity( + anchorDir, + anchor.assertKnownAnchorMode(options.anchorMode), + options.serviceIdentity, + options, + ); + return new ProtectedPinStore(anchorDir, options); + } + + // The permit database's directory must not contain this domain and this + // domain must not contain it. On the two-machine deployment the two are + // on different hosts and the constraint holds automatically; the check is + // made explicitly so that a deployment change cannot quietly violate it. + assertSeparateFromPermitDatabase(databasePath) { + anchor.assertSeparateDomains(this.#pinDir, databasePath); + } + + get pinDir() { return this.#pinDir; } + + // The local critical section. + // + // "A transaction" in this design means the LOCAL critical section on the + // registered anchor domain on the verifier's machine (design section + // 2.3.1 R3-B2 ruling 1). It does NOT cover the Mac-side permit store and + // it does NOT cover the network connection. + // + // The lock is a file lock taken on the domain itself. It is deliberately + // not shared with the permit store's SQLite transaction: those are two separate + // locks on two separate machines, and the only thing that passes between + // them is a signed revocation list. + // + // The caller injects the body; the body runs with the lock held, and the + // lock is released whether the body returns or throws. + // + // The section is REENTRANT within one process. The cross-machine path + // takes the section around the whole registration -- the exclusivity + // check, the pin write and the snapshot advance are one atomic step -- + // and that path calls into the store's own registerPin, which takes the + // section again. A non-reentrant lock would make the outer section fail + // against its own inner call, and the fix for that must NOT be "take the + // lock less often": the outer section is what makes the three steps one + // step. + // + // Reentrancy is safe here because the lock exists to keep a SECOND + // PROCESS from interleaving. Within one process the code is already + // sequential, so a nested entry has nothing to exclude. + criticalSection(body) { + if (typeof body !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + if (this.#lockDepth > 0) { + this.#lockDepth += 1; + try { + return body(); + } finally { + this.#lockDepth -= 1; + } + } + const lockPath = path.join(this.#pinDir, ".pin-critical-section.lock"); + const release = acquireLock(lockPath, this.#lockTtlMs); + this.#lockDepth = 1; + try { + return body(); + } finally { + this.#lockDepth = 0; + release(); + } + } + + // Register a pin. + // + // The write is atomic create-if-absent with O_EXCL semantics: of two + // concurrent registrants only the first succeeds, the second gets EEXIST + // and does NOT overwrite (design section 2.2.3). That property is what + // makes the registration exclusive without a separate check-then-act + // window. + // + // The caller supplies the write as a function, and the whole + // decision-and-write runs inside the protected critical section, so the + // "there is no existing pin" half of the exclusivity guarantee is not a + // window between a check and a write. + registerPin(options) { + const { batchIdentity, publicKeyPem, write } = options; + for (const field of BATCH_IDENTITY_FIELDS) requireText(batchIdentity[field], "ERR_PERMIT_INPUT_INVALID"); + requireText(publicKeyPem, "ERR_PERMIT_INPUT_INVALID"); + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(publicKeyPem)) fail("ERR_PERMIT_INPUT_INVALID"); + if (typeof write !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + // The registration action carries the design's field table (section + // 2.2). All six are required here: a registration that cannot say what + // it carried cannot be audited later, and the design lists them as + // part of the registration action, not as optional annotations. + // + // This is a CARRIED obligation, not an identity criterion. The batch + // comparison below still uses the triple; see + // REGISTRATION_CARRIED_FIELDS for why those two are different things. + requireText(options.sourceDigest, "ERR_PERMIT_INPUT_INVALID"); + requireText(options.deploymentDigest, "ERR_PERMIT_INPUT_INVALID"); + // The string conversion is only for the numeric-looking run ids. It is + // applied AFTER the presence check: String(undefined) is the string + // "undefined", which would sail through a text check and record a + // run id that was never supplied. + if (options.runId === undefined || options.runId === null) { + fail("ERR_PERMIT_INPUT_INVALID"); + } + requireText(String(options.runId), "ERR_PERMIT_INPUT_INVALID"); + // The generation is the signed binding value, in the form the + // upstream permit store holds it (a text value). See + // requireGenerationText for why an integer requirement here would + // reject the very value the wiring is supposed to pass through. + const generation = requireGenerationText(options.generation, "ERR_PERMIT_INPUT_INVALID"); + requireSafeInteger(options.validFrom, "ERR_PERMIT_INPUT_INVALID"); + requireSafeInteger(options.validUntil, "ERR_PERMIT_INPUT_INVALID"); + const fingerprint = publicKeyFingerprint(publicKeyPem); + if (batchIdentity.fingerprint !== fingerprint) fail("ERR_PIN_FINGERPRINT_MISMATCH"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + const existing = findPin(state, fingerprint); + if (existing) { + // A pin for this key already exists. If it belongs to THIS batch + // the route is a reconnect: the pin is reused, not rewritten. + // If it belongs to a DIFFERENT batch the route stops -- the pin is + // never overwritten with a new batch. + if (sameBatchIdentity(existing, batchIdentity)) { + return { registered: false, reused: true, fingerprint, pin: existing }; + } + fail("ERR_PIN_BATCH_MISMATCH"); + } + const entry = { + decision_id: batchIdentity.decision_id, + instance_id: batchIdentity.instance_id, + fingerprint, + public_key_pem: publicKeyPem, + registered_at_ms: requireSafeInteger(options.nowMs, "ERR_PERMIT_INPUT_INVALID"), + // The carried fields are recorded on the entry. They are not read + // back for the batch comparison -- that stays the triple -- so a + // renewal that changes valid_until does not change the batch. + source_digest: options.sourceDigest, + deployment_digest: options.deploymentDigest, + run_id: String(options.runId), + generation, + valid_from: options.validFrom, + valid_until: options.validUntil, + }; + // The create-if-absent write is the atomicity guarantee. A concurrent + // creator that loses the race gets EEXIST and does not overwrite. + const created = write(entry, fingerprint); + if (created === false) fail("ERR_PIN_ALREADY_EXISTS"); + state.pins.push(entry); + writeState(this.#pinDir, state); + return { registered: true, reused: false, fingerprint, pin: entry }; + }); + } + + // The pins that are registered on this machine. This is the ONLY source + // of public keys the verification path accepts. + listPins() { + const state = readState(this.#pinDir); + return state.pins.map((pin) => ({ ...pin })); + } + + // The fingerprint set of the registered pins. A revocation list is + // refused unless the key that signed it is in this set. + pinnedFingerprints() { + const state = readState(this.#pinDir); + return new Set(state.pins.map((pin) => pin.fingerprint)); + } + + // The public key for a fingerprint, or null. Null means "not registered + // here", which the verification path treats as a refusal -- it never + // falls back to a key carried by the list itself. + publicKeyFor(fingerprint) { + const state = readState(this.#pinDir); + const pin = findPin(state, fingerprint); + return pin ? pin.public_key_pem : null; + } + + // The local revocation state, as the verification path consumes it. + revocationState() { + const state = readState(this.#pinDir); + return { + appliedRevocationSequence: state.applied_revocation_sequence, + highestSeenRevocationSequence: state.highest_seen_revocation_sequence, + revokedFingerprints: new Set(state.revoked_fingerprints), + }; + } + + // A legacy domain has no trustworthy history of accepted permits. It may + // retain and update revocations, but cannot accept permits until recovered + // from an independently verified baseline. + assertPermitReplayState() { + const state = readState(this.#pinDir); + if (state.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + return state.highest_accepted_permit_sequence; + } + + // A clock rollback poisons the target domain persistently before refusing + // verification. A trusted host must govern any subsequent marker clearing. + observePermitClock(nowMs) { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail("ERR_CLOCK_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + if (state.schema_version !== STATE_SCHEMA_VERSION || state.clock_poisoned || + CLOCK_POISONED_DOMAINS.has(this.#pinDir)) fail("ERR_CLOCK_INVALID"); + if (nowMs < state.last_observed_clock_ms) { + CLOCK_POISONED_DOMAINS.add(this.#pinDir); + state.clock_poisoned = true; + writeState(this.#pinDir, state); + fail("ERR_CLOCK_INVALID"); + } + if (nowMs > state.last_observed_clock_ms) { + state.last_observed_clock_ms = nowMs; + writeState(this.#pinDir, state); + } + return nowMs; + }); + } + + // Commit an applied revocation list. + // + // The revoked set and the applied high-water mark advance TOGETHER. The + // design's local invariant B is exactly that: on exit from the critical + // section, either both advanced or neither did (design section 2.3.1 + // R3-B2 ruling 3). + // + // The caller has already verified the list -- the signature, the chain + // position, the signer's presence in the pin and the signer's absence + // from the revoked set. This function only commits what passed those + // checks; it never decides whether a list is acceptable. + commitRevocation(options) { + const { revokedFingerprints, sequence, permitSequence } = options; + requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); + if (!Array.isArray(revokedFingerprints)) fail("ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + if (!Number.isSafeInteger(state.applied_revocation_sequence + 1) || + sequence !== state.applied_revocation_sequence + 1) fail("ERR_REVOCATION_SEQUENCE_BROKEN"); + if (permitSequence !== undefined) { + if (state.schema_version !== STATE_SCHEMA_VERSION) fail("ERR_PIN_STATE_CORRUPT"); + requireSafeInteger(permitSequence, "ERR_PERMIT_INPUT_INVALID"); + if (permitSequence <= state.highest_accepted_permit_sequence) fail("ERR_PERMIT_SEQUENCE_REPLAY"); + state.highest_accepted_permit_sequence = permitSequence; + } + // The applied set only grows. A commit that would drop a fingerprint + // is refused and nothing advances. + const incoming = new Set(revokedFingerprints); + for (const known of state.revoked_fingerprints) { + if (!incoming.has(known)) fail("ERR_REVOCATION_SET_NOT_MONOTONIC"); + } + state.revoked_fingerprints = Array.from(incoming).sort(); + state.applied_revocation_sequence = sequence; + if (sequence > state.highest_seen_revocation_sequence) { + state.highest_seen_revocation_sequence = sequence; + } + writeState(this.#pinDir, state); + return { + appliedRevocationSequence: state.applied_revocation_sequence, + revokedFingerprints: state.revoked_fingerprints.length, + }; + }); + } + + // Record that a revocation list was SEEN, without applying it. The seen + // mark may run ahead of the applied one while a pull is in flight. + noteRevocationSeen(sequence) { + requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + if (sequence > state.highest_seen_revocation_sequence) { + state.highest_seen_revocation_sequence = sequence; + writeState(this.#pinDir, state); + } + return { highestSeenRevocationSequence: state.highest_seen_revocation_sequence }; + }); + } + + // The direct revocation registration (design section 2.3.1 R4-B4 + // ruling 5). + // + // This is the ONE exception path that does not depend on a cross-machine + // signature: it is how a batch's revocation is conveyed when no valid + // batch key is left to sign a list with. It writes the revoked + // fingerprint set directly, and it advances the revocation sequence by + // one so the chain does not break. + // + // It does NOT relax the rule that the signer of a list must still be + // valid -- this path never verifies a signature at all, so that rule does + // not apply to it. + registerDirectRevocation(options) { + const { fingerprint, sequence } = options; + requireText(fingerprint, "ERR_PERMIT_INPUT_INVALID"); + requireSafeInteger(sequence, "ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + if (!Number.isSafeInteger(state.applied_revocation_sequence + 1) || + sequence !== state.applied_revocation_sequence + 1) { + fail("ERR_REVOCATION_SEQUENCE_BROKEN"); + } + const incoming = new Set(state.revoked_fingerprints); + incoming.add(fingerprint); + state.revoked_fingerprints = Array.from(incoming).sort(); + state.applied_revocation_sequence = sequence; + if (sequence > state.highest_seen_revocation_sequence) { + state.highest_seen_revocation_sequence = sequence; + } + writeState(this.#pinDir, state); + return { + appliedRevocationSequence: state.applied_revocation_sequence, + revokedFingerprints: state.revoked_fingerprints.length, + }; + }); + } + + // The clock-poisoning marker. A poisoned domain refuses every new + // permit until the clearing procedure the design defines has run. + isClockPoisoned() { + return CLOCK_POISONED_DOMAINS.has(this.#pinDir) || readState(this.#pinDir).clock_poisoned; + } + + setClockPoisoned(poisoned) { + if (typeof poisoned !== "boolean") fail("ERR_PERMIT_INPUT_INVALID"); + return this.criticalSection(() => { + const state = readState(this.#pinDir); + state.clock_poisoned = poisoned; + writeState(this.#pinDir, state); + return { clockPoisoned: state.clock_poisoned }; + }); + } + + // The batch identity of a registered pin, for the reconnect comparison. + // An unregistered or unreadable pin yields null, which the reconnect + // logic treats as undecidable -- and undecidable means the route stops. + batchIdentityFor(fingerprint) { + const pin = findPin(readState(this.#pinDir), fingerprint); + if (!pin) return null; + return { + decision_id: pin.decision_id, + instance_id: pin.instance_id, + fingerprint: pin.fingerprint, + }; + } +} + +// The exclusive lock. +// +// O_EXCL excludes concurrent holders. An existing lock is never reclaimed +// automatically: moving a stale-looking path can race replacement by a live +// holder and create two lock owners. A crash may require managed recovery; +// production needs a reviewed stable lock protocol with offline migration. +// +// The lock is not the only thing standing between two registrants: the +// atomic create-if-absent write of the pin entry is. The lock exists so +// that the read-decide-write sequence is not interleaved with another +// registrant's, and so the revocation commit is a single atomic step. +function acquireLock(lockPath, ttlMs) { + const owner = "pin-pid" + process.pid + "-" + Date.now().toString(36); + const token = crypto.randomBytes(16).toString("hex"); + const payload = () => JSON.stringify({ + owner, + token, + acquiredAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttlMs).toISOString(), + pid: process.pid, + }); + + const tryCreate = () => { + // O_EXCL: the create either succeeds or reports that somebody else + // already holds the lock. There is no window in which two holders both + // believe they created it. + const fd = fs.openSync(lockPath, "wx", 0o600); + try { + fs.writeFileSync(fd, payload(), "utf8"); + fs.fsyncSync(fd); + return fd; + } catch (error) { + fs.closeSync(fd); + throw error; + } + }; + + let fd; + try { + fd = tryCreate(); + } catch (error) { + if (!error || error.code !== "EEXIST") throw error; + fail("ERR_LEASE_CONFLICT"); + } + + return () => { + try { + // Release only the lock token created by this holder. + const current = JSON.parse(fs.readFileSync(lockPath, "utf8")); + if (current.token === token) fs.unlinkSync(lockPath); + } catch { /* already released or replaced */ } + try { fs.closeSync(fd); } catch { /* already closed */ } + }; +} + +module.exports = { + ProtectedPinStore, + STATE_FILE, + DOMAIN_MARKER_FILE, + STATE_SCHEMA_VERSION, + BATCH_IDENTITY_FIELDS, + REGISTRATION_CARRIED_FIELDS, + publicKeyFingerprint, + sameBatchIdentity, +}; From 5ea7fdbcb097d71ac8fea87575605ed7ee68f513 Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Sun, 1 Nov 2026 12:31:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?ci(governance):=20=E6=9B=B4=E6=96=B0=20feed?= =?UTF-8?q?back=20=E5=A4=8D=E5=AE=A1=E9=97=AD=E5=8C=85=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E8=87=B3=E7=8B=AC=E7=AB=8B=E5=A4=8D=E6=A0=B8=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立复核者对 1b11a3f55d761c41aa60ca0b4a3ef4485c2f0937 裁定 PASS, 并给出确切的新基线值对: reviewed = 1b11a3f55d761c41aa60ca0b4a3ef4485c2f0937 baseline.sha = 26705201071c014805979e9f093fefde4dee99ae tree SHA 由复核者独立执行 git rev-parse '1b11a3f5^{tree}' 得出;实施方 独立复算一致。 复核依据(见 .agent/missions/M-044/evidence/ ms004-batch1-closure-independent-review-r2-2026-11-01.md): - 277 文件闭包相对旧复审基线 274 项字节一致、3 项不同,mode 差异 0; 三项变化与上一轮冻结提交字节完全一致。 - 静态相对依赖扫描与 d1bfad49 对照未发现新增缺失;witness 模块已在提交树内, protected-permit-store.js 可独立加载。 - 两组指定测试分别 146/146 与 70/70 通过。 - 五个无受管会话子命令均退出 3,permit --help 退出 0,目录无变化。 - 六个补入文件的安全边界已复核。 本 workflow 文件不在 277 文件闭包内,更新这两处值不产生自引用。 Refs M-044, MS-004 --- .github/workflows/canonical-control-offline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/canonical-control-offline.yml b/.github/workflows/canonical-control-offline.yml index dfe38b0a..2a3c636f 100644 --- a/.github/workflows/canonical-control-offline.yml +++ b/.github/workflows/canonical-control-offline.yml @@ -179,9 +179,9 @@ jobs: with: script: | const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),assert=require('node:assert/strict'); - const {owner,repo}=context.repo,head=process.env.SOURCE_HEAD,reviewed='c42ce5a5a30c4b7aa7981d86b12831928e30f9cb';assert.match(head||'',/^[a-f0-9]{40}$/); + const {owner,repo}=context.repo,head=process.env.SOURCE_HEAD,reviewed='1b11a3f55d761c41aa60ca0b4a3ef4485c2f0937';assert.match(head||'',/^[a-f0-9]{40}$/); async function treeFor(sha){const {data:c}=await github.rest.git.getCommit({owner,repo,commit_sha:sha});assert.equal(c.sha,sha);const {data:t}=await github.rest.git.getTree({owner,repo,tree_sha:c.tree.sha,recursive:'true'});assert.equal(t.truncated,false);assert.equal(t.sha,c.tree.sha);return t;} - const tree=await treeFor(head),baseline=await treeFor(reviewed);assert.equal(baseline.sha,'9e3e1b9693e7f79a800dc15cc3e7b515dfe7bb23'); + const tree=await treeFor(head),baseline=await treeFor(reviewed);assert.equal(baseline.sha,'26705201071c014805979e9f093fefde4dee99ae'); const proof={source_head:head,tree:tree.sha,reviewed_head:reviewed,expected_github_api_calls:282,sources:[]},root=fs.mkdtempSync(path.join(fs.realpathSync(process.env.RUNNER_TEMP),'cortex-feedback-')); core.exportVariable('CORTEX_FEEDBACK_ROOT',root);core.info('Expected GitHub API requests: 282; any API failure fails closed; no alternate credentials or downloader');fs.chmodSync(root,0o700);for(const dir of ['source','fixtures'])fs.mkdirSync(path.join(root,dir),{mode:0o700}); async function bytesFor(relative){