From 261e54cb0b50821c4971d3352cae0a822bdd79f8 Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Mon, 9 Nov 2026 08:52:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(permit):=20=E5=A2=9E=E5=8A=A0=20desktop=5F?= =?UTF-8?q?authority=20=E6=8E=88=E6=9D=83=E8=AE=B8=E5=8F=AF=E7=9A=84?= =?UTF-8?q?=E7=AD=BE=E5=8F=91=E5=8E=9F=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6 要求签发侧能按 D-AUTH-001 v1.2.0 §3.2 签发授权类许可。分发许可的字段集 就是它的绑定集(consume() 逐字段比较),授权许可的字段集按契约是另一套且 明确禁用 operation / target_digest / params_digest(INV-02)。把两套字段合并 成一个「覆盖即可」的校验,会让缺少动作绑定的消息也通过 —— 所以新增独立原语, 两种形态互不顶替。 改动: - 新增 lib/coordination/permit-authority-signing.js: · buildDesktopAuthorityMessage:字段集 = §3.2 九项 + issued_at_ms; 三类动作绑定字段存在即硬拒(ERR_PERMIT_AUTHORITY_FIELD_FORBIDDEN); 未知字段同样拒绝(消息就是它签的字段集); · TTL 上下界(10 s ≤ ttl ≤ 120 s,§5.1); · signDesktopAuthorityPermit:用批次签名句柄签规范字节; · verifyDesktopAuthorityPermit:验签 + 指纹 pin + 撤销集合 + 双向时钟容差 (固定 2 s,允许值只吸收「本机时钟早于签发时刻」,不延长 deadline)。 - 新增 tests/coordination/permit-authority-signing.test.js(11 例):字段集、 INV-02 硬拒、未知字段、类别固定、TTL 上下界、整数下限、验签、篡改、撤销、 过期、时钟超差与「容差不超 2 s」。 跨语言验证:签发样例用 SamHMI 侧 AutomationPermitCanonicalizer 规范化后 逐字节相同,Ed25519 验签通过、指纹一致(证据见 M-053 证据目录)。 未做(记录在案):契约 §3.2 字段表漏列 issued_at_ms(§6/§2.1.1 需要它), 本次纳入签名并记录缺口;Windows 消费侧尚无 permit_category 分支。 任务:M-053 R6 --- lib/coordination/permit-authority-signing.js | 224 ++++++++++++++++++ .../permit-authority-signing.test.js | 179 ++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 lib/coordination/permit-authority-signing.js create mode 100644 tests/coordination/permit-authority-signing.test.js diff --git a/lib/coordination/permit-authority-signing.js b/lib/coordination/permit-authority-signing.js new file mode 100644 index 00000000..0638946c --- /dev/null +++ b/lib/coordination/permit-authority-signing.js @@ -0,0 +1,224 @@ +"use strict"; + +// ─── Desktop Authority Permit Signing (M-053 / R6) ──────────────────────── +// +// WHAT THIS IS +// The signing primitive for the authorization-class permit defined by +// D-AUTH-001 v1.2.0 §3.2: a `desktop_authority` permit, signed by the +// issuing batch key, that lifts G1 (the desktop authority gate) for one +// short window. It does NOT sign per-action (dispatch) permits — those +// keep their existing shape and their existing reservation path. +// +// WHY IT IS A SEPARATE MODULE +// The dispatch permit's field set IS its binding set: the store's +// consume() compares every field, and `permit-delivery.assertCoversBinding` +// refuses a message that misses one. An authority permit has a DIFFERENT +// field set on purpose (§3.2 lists nine fields and §3.3 forbids the +// action-binding trio). Widening the dispatch field set to "cover" both +// would silently weaken the dispatch check — a message that omits +// operation/target/params would still pass a looser coverage assertion. +// So the two shapes are validated separately, and neither can stand in +// for the other. +// +// WHY operation / target_digest / params_digest ARE HARD-REFUSED +// INV-02: an authority permit must not be usable for any concrete action. +// The prohibition is on the FIELDS, not on their values — a sentinel +// operation would still be an operation the Worker could compare. A +// message carrying any of the three is refused here, before signing, so +// the issuing side cannot mint one by accident. +// +// WHY issued_at_ms IS PART OF THE MESSAGE +// §3.2's field table lists deadline_ms but not issued_at_ms, while §6 +// requires the consumer to judge the signing clock against its own and +// §2.1.1's projection carries issuedAtMs. A message without issued_at_ms +// cannot be time-judged at all, so the field is included here and the +// contract's §3.2 table is recorded as needing the field (see the R6 +// delivery note). Emitting it is the fail-closed choice: a consumer that +// does not know the field still refuses the message as unknown/extra +// rather than accepting it without a clock judgement. + +const crypto = require("node:crypto"); +const delivery = require("./permit-delivery"); + +const PERMIT_CATEGORY = "desktop_authority"; + +// The signed field set of an authority permit (D-AUTH-001 §3.2 + issued_at_ms). +const AUTHORITY_SIGNED_FIELDS = Object.freeze([ + "permit_id", + "permit_category", + "session_id", + "desktop_key", + "generation", + "service_epoch", + "fencing_token", + "issued_at_ms", + "deadline_ms", + "sequence", +]); + +// The dispatch-only fields an authority permit must never carry (INV-02). +const FORBIDDEN_SIGNED_FIELDS = Object.freeze([ + "operation", + "target_digest", + "params_digest", +]); + +// §5.1: the authority TTL ceiling is 120 s, and the contract sets a 10 s +// floor so that a briefly jittering issuer cannot produce an unusable grant. +const AUTHORITY_MAX_TTL_MS = 120_000; +const AUTHORITY_MIN_TTL_MS = 10_000; + +// §6: the fixed cross-machine clock allowance. Not a tuning knob. +const AUTHORITY_SKEW_TOLERANCE_MS = 2_000; + +function fail(code) { + throw Object.assign(new Error(code), { code }); +} + +function requireText(value, name) { + if (typeof value !== "string" || value !== value.trim() || !value) { + fail("ERR_PERMIT_INPUT_INVALID: " + name); + } + return value; +} + +function requireInteger(value, name, minimum) { + if (!Number.isSafeInteger(value) || value < minimum) { + fail("ERR_PERMIT_INPUT_INVALID: " + name); + } + return value; +} + +/** + * Validate the authority fields and build the signed message. + * + * @param {object} fields The §3.2 field values (snake_case). + * @returns {object} The frozen message, ready for canonical bytes. + */ +function buildDesktopAuthorityMessage(fields) { + if (!fields || typeof fields !== "object" || Array.isArray(fields)) { + fail("ERR_PERMIT_INPUT_INVALID"); + } + // A forbidden field is refused even when it is present with a harmless + // value: the prohibition is about the field existing in the signed set. + for (const name of FORBIDDEN_SIGNED_FIELDS) { + if (Object.hasOwn(fields, name)) fail("ERR_PERMIT_AUTHORITY_FIELD_FORBIDDEN: " + name); + } + // Any other unknown field is refused too: a message is exactly its signed + // field set, and an extra field is a field nobody agreed to verify. + for (const name of Object.keys(fields)) { + if (!AUTHORITY_SIGNED_FIELDS.includes(name)) fail("ERR_PERMIT_INPUT_INVALID: " + name); + } + + const message = { + permit_id: requireText(fields.permit_id, "permit_id"), + permit_category: requireText(fields.permit_category, "permit_category"), + session_id: requireText(fields.session_id, "session_id"), + desktop_key: requireText(fields.desktop_key, "desktop_key"), + generation: requireInteger(fields.generation, "generation", 0), + service_epoch: requireInteger(fields.service_epoch, "service_epoch", 1), + fencing_token: requireInteger(fields.fencing_token, "fencing_token", 0), + issued_at_ms: requireInteger(fields.issued_at_ms, "issued_at_ms", 1), + deadline_ms: requireInteger(fields.deadline_ms, "deadline_ms", 1), + sequence: requireInteger(fields.sequence, "sequence", 1), + }; + + if (message.permit_category !== PERMIT_CATEGORY) { + fail("ERR_PERMIT_INPUT_INVALID: permit_category"); + } + + const ttl = message.deadline_ms - message.issued_at_ms; + if (ttl <= 0) fail("ERR_PERMIT_INPUT_INVALID: window"); + if (ttl > AUTHORITY_MAX_TTL_MS) fail("ERR_PERMIT_AUTHORITY_TTL_TOO_LONG"); + if (ttl < AUTHORITY_MIN_TTL_MS) fail("ERR_PERMIT_AUTHORITY_TTL_TOO_SHORT"); + + return Object.freeze(message); +} + +/** + * Sign an authority permit with the issuing batch key. + * + * @param {object} options + * @param {object} options.privateKeyRef The batch signing handle (never exported). + * @param {object} options.fields The §3.2 field values. + * @returns {object} `{ message, signature, bytes }` — the sample a consumer verifies. + */ +function signDesktopAuthorityPermit(options = {}) { + const { privateKeyRef, fields } = options; + if (!privateKeyRef || typeof privateKeyRef.sign !== "function") { + fail("ERR_PERMIT_INPUT_INVALID: privateKeyRef"); + } + const message = buildDesktopAuthorityMessage(fields); + const bytes = delivery.canonicalBytes(message); + const signature = privateKeyRef.sign(bytes).toString("base64"); + return Object.freeze({ + message, + signature, + bytes: bytes.toString("base64"), + }); +} + +/** + * Verify an authority permit (the consumer half, used for test-domain evidence + * and by the SamHMI side once it grows the category branch). + * + * @param {object} options + * @returns {object} `{ ok, fingerprint, deadline }` + */ +function verifyDesktopAuthorityPermit(options = {}) { + const { + message, signature, publicKeyPem, expectedFingerprint, revokedFingerprints, + nowMs, skewToleranceMs, expectedDeadlineMs, + } = options; + + // Re-validate the shape: a verifier that only re-checks the signature + // would accept a message with an extra binding field nobody looked at. + const normalized = buildDesktopAuthorityMessage(message); + if (!signature || typeof signature !== "string") fail("ERR_PERMIT_INPUT_INVALID: signature"); + if (typeof publicKeyPem !== "string" || !publicKeyPem) fail("ERR_PERMIT_INPUT_INVALID: publicKeyPem"); + if (!revokedFingerprints || typeof revokedFingerprints.has !== "function") { + fail("ERR_REVOCATION_SIGNER_INVALID"); + } + const now = requireInteger(nowMs, "nowMs", 1); + + const key = crypto.createPublicKey(publicKeyPem); + const bytes = delivery.canonicalBytes(normalized); + if (!crypto.verify(null, bytes, key, Buffer.from(signature, "base64"))) { + fail("ERR_PERMIT_SIGNATURE_INVALID"); + } + + const fingerprint = delivery.publicKeyFingerprint(publicKeyPem); + if (typeof expectedFingerprint !== "string" || expectedFingerprint !== fingerprint) { + fail("ERR_PERMIT_SIGNATURE_INVALID"); + } + if (revokedFingerprints.has(fingerprint)) fail("ERR_REVOCATION_SIGNER_INVALID"); + + if (expectedDeadlineMs !== undefined && expectedDeadlineMs !== normalized.deadline_ms) { + fail("ERR_PERMIT_DEADLINE_MISMATCH"); + } + + const tolerance = skewToleranceMs === undefined ? 0 : requireInteger(skewToleranceMs, "skewToleranceMs", 0); + if (tolerance > AUTHORITY_SKEW_TOLERANCE_MS) fail("ERR_PERMIT_INPUT_INVALID: skewToleranceMs"); + + // The window is exactly the signer's grant (§5.1): the allowance absorbs a + // slow local clock before issuance, never extends the deadline. + const lifetime = normalized.deadline_ms - normalized.issued_at_ms; + const effective = Math.min(tolerance, lifetime); + if (now + effective < normalized.issued_at_ms) fail("ERR_PERMIT_CLOCK_SKEW_EXCEEDED"); + if (now >= normalized.deadline_ms) fail("ERR_PERMIT_DEADLINE_EXPIRED"); + + return Object.freeze({ ok: true, fingerprint, deadline: normalized.deadline_ms }); +} + +module.exports = Object.freeze({ + AUTHORITY_MAX_TTL_MS, + AUTHORITY_MIN_TTL_MS, + AUTHORITY_SIGNED_FIELDS, + AUTHORITY_SKEW_TOLERANCE_MS, + FORBIDDEN_SIGNED_FIELDS, + PERMIT_CATEGORY, + buildDesktopAuthorityMessage, + signDesktopAuthorityPermit, + verifyDesktopAuthorityPermit, +}); + diff --git a/tests/coordination/permit-authority-signing.test.js b/tests/coordination/permit-authority-signing.test.js new file mode 100644 index 00000000..74181df3 --- /dev/null +++ b/tests/coordination/permit-authority-signing.test.js @@ -0,0 +1,179 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const delivery = require("../../lib/coordination/permit-delivery"); +const { + AUTHORITY_MAX_TTL_MS, + AUTHORITY_MIN_TTL_MS, + AUTHORITY_SKEW_TOLERANCE_MS, + FORBIDDEN_SIGNED_FIELDS, + PERMIT_CATEGORY, + buildDesktopAuthorityMessage, + signDesktopAuthorityPermit, + verifyDesktopAuthorityPermit, +} = require("../../lib/coordination/permit-authority-signing"); + +const ISSUED = 1_800_000_000_000; + +function fields(overrides = {}) { + return { + permit_id: "permit-authority-1", + permit_category: PERMIT_CATEGORY, + session_id: "session-1", + desktop_key: "desktop-1", + generation: 7, + service_epoch: 3, + fencing_token: 42, + issued_at_ms: ISSUED, + deadline_ms: ISSUED + 60_000, + sequence: 9, + ...overrides, + }; +} + +test("the signed message carries exactly the §3.2 field set", () => { + const message = buildDesktopAuthorityMessage(fields()); + assert.deepEqual(Object.keys(message).sort(), [ + "deadline_ms", + "desktop_key", + "fencing_token", + "generation", + "issued_at_ms", + "permit_category", + "permit_id", + "sequence", + "service_epoch", + "session_id", + ]); + assert.equal(message.permit_category, "desktop_authority"); +}); + +test("an authority permit refuses every dispatch-only field (INV-02)", () => { + for (const name of FORBIDDEN_SIGNED_FIELDS) { + assert.throws( + () => buildDesktopAuthorityMessage(fields({ [name]: "x" })), + { code: "ERR_PERMIT_AUTHORITY_FIELD_FORBIDDEN: " + name }, + ); + } +}); + +test("an unknown signed field is refused rather than carried", () => { + assert.throws( + () => buildDesktopAuthorityMessage(fields({ extra_binding: "x" })), + { code: "ERR_PERMIT_INPUT_INVALID: extra_binding" }, + ); +}); + +test("the category cannot be anything but desktop_authority", () => { + assert.throws( + () => buildDesktopAuthorityMessage(fields({ permit_category: "dispatch" })), + { code: "ERR_PERMIT_INPUT_INVALID: permit_category" }, + ); +}); + +test("the TTL ceiling and floor are enforced at signing time", () => { + assert.throws( + () => buildDesktopAuthorityMessage(fields({ deadline_ms: ISSUED + AUTHORITY_MAX_TTL_MS + 1 })), + { code: "ERR_PERMIT_AUTHORITY_TTL_TOO_LONG" }, + ); + assert.throws( + () => buildDesktopAuthorityMessage(fields({ deadline_ms: ISSUED + AUTHORITY_MIN_TTL_MS - 1 })), + { code: "ERR_PERMIT_AUTHORITY_TTL_TOO_SHORT" }, + ); + assert.doesNotThrow(() => + buildDesktopAuthorityMessage(fields({ deadline_ms: ISSUED + AUTHORITY_MAX_TTL_MS }))); +}); + +test("every integer field is validated as a safe integer with its floor", () => { + for (const [name, minimum] of [["generation", 0], ["service_epoch", 1], ["fencing_token", 0], + ["issued_at_ms", 1], ["deadline_ms", 1], ["sequence", 1]]) { + assert.throws( + () => buildDesktopAuthorityMessage(fields({ [name]: minimum - 1 })), + { code: "ERR_PERMIT_INPUT_INVALID: " + name }, + ); + assert.throws( + () => buildDesktopAuthorityMessage(fields({ [name]: String(minimum) })), + { code: "ERR_PERMIT_INPUT_INVALID: " + name }, + ); + } +}); + +test("a signed authority permit verifies with the batch public key", () => { + const batch = delivery.createBatchKeyPair(); + const signed = signDesktopAuthorityPermit({ privateKeyRef: batch.privateKeyRef, fields: fields() }); + const result = verifyDesktopAuthorityPermit({ + message: signed.message, + signature: signed.signature, + publicKeyPem: batch.publicKeyPem, + expectedFingerprint: batch.fingerprint, + revokedFingerprints: new Set(), + nowMs: ISSUED + 1_000, + skewToleranceMs: AUTHORITY_SKEW_TOLERANCE_MS, + }); + assert.equal(result.ok, true); + assert.equal(result.fingerprint, batch.fingerprint); + assert.equal(result.deadline, ISSUED + 60_000); +}); + +test("a tampered field breaks the signature", () => { + const batch = delivery.createBatchKeyPair(); + const signed = signDesktopAuthorityPermit({ privateKeyRef: batch.privateKeyRef, fields: fields() }); + const tampered = { ...signed.message, sequence: 10 }; + assert.throws(() => verifyDesktopAuthorityPermit({ + message: tampered, + signature: signed.signature, + publicKeyPem: batch.publicKeyPem, + expectedFingerprint: batch.fingerprint, + revokedFingerprints: new Set(), + nowMs: ISSUED + 1_000, + }), { code: "ERR_PERMIT_SIGNATURE_INVALID" }); +}); + +test("a revoked signing key is refused even with a valid signature", () => { + const batch = delivery.createBatchKeyPair(); + const signed = signDesktopAuthorityPermit({ privateKeyRef: batch.privateKeyRef, fields: fields() }); + assert.throws(() => verifyDesktopAuthorityPermit({ + message: signed.message, + signature: signed.signature, + publicKeyPem: batch.publicKeyPem, + expectedFingerprint: batch.fingerprint, + revokedFingerprints: new Set([batch.fingerprint]), + nowMs: ISSUED + 1_000, + }), { code: "ERR_REVOCATION_SIGNER_INVALID" }); +}); + +test("an expired authority permit is refused, and the allowance never extends the deadline", () => { + const batch = delivery.createBatchKeyPair(); + const signed = signDesktopAuthorityPermit({ privateKeyRef: batch.privateKeyRef, fields: fields() }); + assert.throws(() => verifyDesktopAuthorityPermit({ + message: signed.message, + signature: signed.signature, + publicKeyPem: batch.publicKeyPem, + expectedFingerprint: batch.fingerprint, + revokedFingerprints: new Set(), + nowMs: ISSUED + 60_000, + skewToleranceMs: AUTHORITY_SKEW_TOLERANCE_MS, + }), { code: "ERR_PERMIT_DEADLINE_EXPIRED" }); +}); + +test("a local clock behind the issue instant beyond the fixed allowance is refused", () => { + const batch = delivery.createBatchKeyPair(); + const signed = signDesktopAuthorityPermit({ privateKeyRef: batch.privateKeyRef, fields: fields() }); + const verify = (nowMs, skewToleranceMs) => verifyDesktopAuthorityPermit({ + message: signed.message, + signature: signed.signature, + publicKeyPem: batch.publicKeyPem, + expectedFingerprint: batch.fingerprint, + revokedFingerprints: new Set(), + nowMs, + skewToleranceMs, + }); + assert.throws(() => verify(ISSUED - AUTHORITY_SKEW_TOLERANCE_MS - 1, AUTHORITY_SKEW_TOLERANCE_MS), + { code: "ERR_PERMIT_CLOCK_SKEW_EXCEEDED" }); + assert.equal(verify(ISSUED - AUTHORITY_SKEW_TOLERANCE_MS, AUTHORITY_SKEW_TOLERANCE_MS).ok, true); + // A wider allowance than the fixed 2 s bound is refused, not clipped. + assert.throws(() => verify(ISSUED, AUTHORITY_SKEW_TOLERANCE_MS + 1), + { code: "ERR_PERMIT_INPUT_INVALID: skewToleranceMs" }); +}); +