@synoi/gateway-lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1755 @@
1
+ "use strict";
2
+ /**
3
+ * verify-router.ts
4
+ *
5
+ * Public Decision Receipt verification endpoint.
6
+ *
7
+ * GET /verify/:receipt_id
8
+ * — Returns full receipt + signature validity + chain summary
9
+ * — No auth required — this is the public verification surface
10
+ * — Anyone with a receipt_id can verify it was signed by SynOI
11
+ *
12
+ * GET /verify/:receipt_id/raw
13
+ * — Returns the raw signed payload (the exact bytes that were signed)
14
+ * — Enables independent verification with any Ed25519 library
15
+ *
16
+ * Signature scheme (published at /verify/scheme):
17
+ * 1. Canonical payload = JSON.stringify({ receipt_id, tenant_id, decision,
18
+ * action_class, risk_level, oid_hex, recorded_at }, sorted keys)
19
+ * 2. Signed with Ed25519 using SYNOI_SIGNING_KEY (private)
20
+ * 3. Signature hex stored in receipts.db alongside signer_key_id
21
+ * 4. Public key published at /verify/pubkey — anyone can verify offline
22
+ *
23
+ * GET /verify/pubkey
24
+ * — Returns the current Ed25519 public key (hex) for offline verification
25
+ *
26
+ * GET /verify/scheme
27
+ * — Returns the full signature scheme documentation as JSON
28
+ */
29
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ var desc = Object.getOwnPropertyDescriptor(m, k);
32
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
33
+ desc = { enumerable: true, get: function() { return m[k]; } };
34
+ }
35
+ Object.defineProperty(o, k2, desc);
36
+ }) : (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ o[k2] = m[k];
39
+ }));
40
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
41
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
42
+ }) : function(o, v) {
43
+ o["default"] = v;
44
+ });
45
+ var __importStar = (this && this.__importStar) || (function () {
46
+ var ownKeys = function(o) {
47
+ ownKeys = Object.getOwnPropertyNames || function (o) {
48
+ var ar = [];
49
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
50
+ return ar;
51
+ };
52
+ return ownKeys(o);
53
+ };
54
+ return function (mod) {
55
+ if (mod && mod.__esModule) return mod;
56
+ var result = {};
57
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
58
+ __setModuleDefault(result, mod);
59
+ return result;
60
+ };
61
+ })();
62
+ Object.defineProperty(exports, "__esModule", { value: true });
63
+ exports.CANONICAL_FIELDS = exports.K1_HYBRID_CUTOVER_MS = void 0;
64
+ exports.initSigningKeys = initSigningKeys;
65
+ exports.getSignerKeyId = getSignerKeyId;
66
+ exports.__setSigningBundleForTest = __setSigningBundleForTest;
67
+ exports.getSigningPubkey = getSigningPubkey;
68
+ exports.signReceiptPayload = signReceiptPayload;
69
+ exports.verifyReceiptSignature = verifyReceiptSignature;
70
+ exports.getMlDsaPubkey = getMlDsaPubkey;
71
+ exports.signReceiptPayloadHybrid = signReceiptPayloadHybrid;
72
+ exports.signDssePayload = signDssePayload;
73
+ exports.signDsseWithBundle = signDsseWithBundle;
74
+ exports.deriveMlDsaKeyEphemeral = deriveMlDsaKeyEphemeral;
75
+ exports.verifyReceiptSignatureHybrid = verifyReceiptSignatureHybrid;
76
+ exports.signCanonicalBytesHybrid = signCanonicalBytesHybrid;
77
+ exports.verifyCanonicalBytesHybrid = verifyCanonicalBytesHybrid;
78
+ exports.grantSigningCanonical = grantSigningCanonical;
79
+ exports.verifyCanonicalBytesEd = verifyCanonicalBytesEd;
80
+ exports.isPostCutoverDowngrade = isPostCutoverDowngrade;
81
+ exports.schemeLabel = schemeLabel;
82
+ exports.canonicalPayload = canonicalPayload;
83
+ const express_1 = require("express");
84
+ const crypto_1 = require("crypto");
85
+ // Node 18 doesn't expose globalThis.crypto.getRandomValues by default; @noble
86
+ // libraries expect it. Idempotent polyfill — no-op on Node 19+. Must run
87
+ // BEFORE the @noble/post-quantum import below so ml_dsa65's module init
88
+ // sees a populated globalThis.crypto.
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ if (!globalThis.crypto)
91
+ globalThis.crypto = crypto_1.webcrypto;
92
+ const ml_dsa_js_1 = require("@noble/post-quantum/ml-dsa.js");
93
+ const native_mldsa_1 = require("./native-mldsa");
94
+ const sraid_1 = require("@synoi/sraid");
95
+ const receipt_store_1 = require("./receipt-store");
96
+ const key_provider_1 = require("./key-provider");
97
+ const router = (0, express_1.Router)();
98
+ /**
99
+ * Defensively decode a receipt/grant id that may have been percent-encoded
100
+ * more than once by an intermediate proxy or CDN. Idempotent and safe on an
101
+ * already-decoded id; falls back to the raw value on a malformed sequence.
102
+ */
103
+ function normalizeReceiptId(raw) {
104
+ let id = raw;
105
+ for (let i = 0; i < 2 && id.includes('%'); i++) {
106
+ let decoded;
107
+ try {
108
+ decoded = decodeURIComponent(id);
109
+ }
110
+ catch {
111
+ break;
112
+ }
113
+ if (decoded === id)
114
+ break;
115
+ id = decoded;
116
+ }
117
+ return id;
118
+ }
119
+ // CORS for the public verification surface. The demo frontend fetches
120
+ // /verify/pubkey and /verify/:id from the browser (the tamper test and the
121
+ // verify kit), so those cross-origin reads need the headers. Verification is
122
+ // public + read-only, so this is safe to expose to the demo origins.
123
+ const VERIFY_ALLOWED_ORIGINS = new Set([
124
+ 'https://demo.synoi.systems',
125
+ 'http://localhost:5173',
126
+ 'http://localhost:4173',
127
+ ]);
128
+ router.use((req, res, next) => {
129
+ const origin = req.header('origin');
130
+ if (origin && VERIFY_ALLOWED_ORIGINS.has(origin)) {
131
+ res.setHeader('Access-Control-Allow-Origin', origin);
132
+ res.setHeader('Vary', 'Origin');
133
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
134
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
135
+ }
136
+ if (req.method === 'OPTIONS') {
137
+ res.sendStatus(204);
138
+ return;
139
+ }
140
+ next();
141
+ });
142
+ // ── Persistent signing key management ────────────────────────────────────────
143
+ //
144
+ // Keys are loaded from a KeyProvider (LocalEncryptedFileKeyProvider in most
145
+ // deployments; AwsKmsKeyProvider stub for future AWS-native infra sprint).
146
+ //
147
+ // FAIL-CLOSED rule: if NODE_ENV=production and no provider is configured (or
148
+ // the provider throws), the process exits with a non-zero code. Ephemeral keys
149
+ // are only allowed in non-production environments, and even then only if
150
+ // SYNOI_ALLOW_EPHEMERAL_KEYS=true is explicitly set.
151
+ //
152
+ // Historical verification: every loaded bundle is appended to an in-memory
153
+ // key history (key-provider.ts). verifyReceiptSignature(Hybrid) searches the
154
+ // history so receipts signed with a prior key remain verifiable after rotation.
155
+ //
156
+ // See key-provider.ts for the full provider interface, AWS KMS stub, and file
157
+ // provider implementation.
158
+ let _currentBundle = null;
159
+ /**
160
+ * Load the current key bundle synchronously from the module-level cache, or
161
+ * throw if the key has not been initialized yet. Call initSigningKeys() at
162
+ * startup before any signing or verification.
163
+ */
164
+ function currentBundle() {
165
+ if (_currentBundle)
166
+ return _currentBundle;
167
+ // This path is hit if signing is attempted before initSigningKeys() completed.
168
+ throw new Error('[verify-router] Signing keys not initialized. Call initSigningKeys() at startup.');
169
+ }
170
+ /**
171
+ * Initialize persistent signing keys. Must be awaited at gateway startup
172
+ * before any signing operation. Exported so index.ts / tests can call it.
173
+ *
174
+ * Fail-closed behaviour:
175
+ * - production: any error propagates — caller must abort startup.
176
+ * - non-production: if provider fails AND SYNOI_ALLOW_EPHEMERAL_KEYS=true,
177
+ * falls back to ephemeral keys with a loud warning. Otherwise throws.
178
+ */
179
+ async function initSigningKeys() {
180
+ const isProduction = process.env['NODE_ENV'] === 'production';
181
+ const allowEphemeral = process.env['SYNOI_ALLOW_EPHEMERAL_KEYS'] === 'true';
182
+ try {
183
+ const provider = (0, key_provider_1.resolveKeyProvider)();
184
+ const bundle = await provider.loadCurrentKey();
185
+ _currentBundle = bundle;
186
+ (0, key_provider_1.appendKeyHistory)(bundle);
187
+ (0, key_provider_1.auditKeyLoad)(bundle.keyId);
188
+ console.info(`[verify-router] Signing keys loaded. key_id=${bundle.keyId}`);
189
+ }
190
+ catch (err) {
191
+ if (isProduction) {
192
+ // Hard fail — do not start with ephemeral keys in production.
193
+ throw new Error(`[verify-router] FATAL: signing key initialization failed in production. ` +
194
+ `Refusing to start. Original error: ${err.message}`);
195
+ }
196
+ if (!allowEphemeral) {
197
+ // Non-production but ephemeral not explicitly enabled — also throw.
198
+ throw new Error(`[verify-router] Signing key initialization failed. ` +
199
+ `Set SYNOI_ALLOW_EPHEMERAL_KEYS=true to permit ephemeral keys in non-production environments. ` +
200
+ `Original error: ${err.message}`);
201
+ }
202
+ // Non-production + explicitly opted in: fall back to ephemeral.
203
+ console.warn('[verify-router] WARNING: signing key initialization failed; falling back to EPHEMERAL keys. ' +
204
+ 'Receipts will NOT survive restart. Do NOT use this in production.');
205
+ const { generateKeyPairSync, randomBytes } = await Promise.resolve().then(() => __importStar(require('crypto')));
206
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519', {
207
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
208
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
209
+ });
210
+ const seed = new Uint8Array(randomBytes(32));
211
+ const ephemeralBundle = {
212
+ keyId: 'synoi-v1-ephemeral',
213
+ ed25519: { privateKeyPem: privateKey, publicKeyPem: publicKey },
214
+ mlDsa: { seed },
215
+ valid_from_ms: Date.now(),
216
+ };
217
+ _currentBundle = ephemeralBundle;
218
+ (0, key_provider_1.appendKeyHistory)(ephemeralBundle);
219
+ }
220
+ }
221
+ function getSignerKeyId() {
222
+ return _currentBundle?.keyId ?? process.env['SYNOI_SIGNING_KEY_ID'] ?? 'synoi-v1-ephemeral';
223
+ }
224
+ /**
225
+ * Internal signing-bundle mutator.
226
+ *
227
+ * SECURITY (Finding 1, CRITICAL, iter-10): direct bundle injection is a
228
+ * forge-any-GateDecision primitive. There is exactly ONE in-process mutation
229
+ * seam and it is HARD-GATED: it throws if NODE_ENV === 'production'. The
230
+ * friendly test alias `_testInjectKeyBundle` is NOT defined in this production
231
+ * module (it lives only in the test tree, test/helpers/test-key-inject.ts),
232
+ * so the production bundle exposes no `_testInjectKeyBundle` symbol and no
233
+ * ungated injection path.
234
+ *
235
+ * Production code never calls this. Production keys are loaded only through
236
+ * initSigningKeys() -> KeyProvider. This seam exists solely so deterministic
237
+ * test/round-trip harnesses (e.g. test/live-gate-test-server.ts) can supply a
238
+ * known keypair in non-production processes.
239
+ */
240
+ function __setSigningBundleForTest(bundle) {
241
+ if (process.env['NODE_ENV'] === 'production') {
242
+ throw new Error('[verify-router] __setSigningBundleForTest is TEST-ONLY and must not be ' +
243
+ 'called in production. Production keys load via initSigningKeys() -> KeyProvider.');
244
+ }
245
+ _currentBundle = bundle;
246
+ (0, key_provider_1.appendKeyHistory)(bundle);
247
+ }
248
+ /** Public Ed25519 verification key as raw hex (32 bytes). Used by /status
249
+ * for health checks; the same value is published at /verify/pubkey for
250
+ * offline receipt verification. */
251
+ function getSigningPubkey() {
252
+ const { ed25519 } = currentBundle();
253
+ const keyObj = (0, crypto_1.createPublicKey)({ key: ed25519.publicKeyPem, format: 'pem' });
254
+ const der = keyObj.export({ format: 'der', type: 'spki' });
255
+ return der.subarray(der.length - 32).toString('hex');
256
+ }
257
+ // Ed25519 in Node uses crypto.sign(null, ...) — the digest algorithm is
258
+ // implicit (Ed25519 internally does its own SHA-512 over the message). Passing
259
+ // 'SHA512' to createSign with an Ed25519 key produces a non-standard signature
260
+ // that no other Ed25519 library can verify. This caused the receipts signed
261
+ // here to be incompatible with sentinel.ts (which uses sign(null, ...)).
262
+ //
263
+ // Sprint 11 (2026-05-20) — `signReceiptPayload` returns only the Ed25519
264
+ // signature for backward compatibility with existing call sites. New code
265
+ // should call `signReceiptPayloadHybrid` to also obtain the ML-DSA-65
266
+ // signature (recommended; both signatures are stored on the receipt).
267
+ function signReceiptPayload(payload) {
268
+ const { ed25519 } = currentBundle();
269
+ const canonical = canonicalPayload(payload);
270
+ const keyObj = (0, crypto_1.createPrivateKey)({ key: ed25519.privateKeyPem, format: 'pem' });
271
+ const sigBuf = (0, crypto_1.sign)(null, Buffer.from(canonical, 'utf8'), keyObj);
272
+ return sigBuf.toString('hex');
273
+ }
274
+ function verifyReceiptSignature(payload, signatureHex) {
275
+ // Attempt verification against the current key first, then fall back through
276
+ // the key history so receipts signed with a rotated-away key still verify.
277
+ const canonical = canonicalPayload(payload);
278
+ const canonicalBytes = Buffer.from(canonical, 'utf8');
279
+ for (const bundle of (0, key_provider_1.getKeyHistory)()) {
280
+ try {
281
+ const keyObj = (0, crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
282
+ if ((0, crypto_1.verify)(null, canonicalBytes, keyObj, Buffer.from(signatureHex, 'hex')))
283
+ return true;
284
+ }
285
+ catch {
286
+ // Key object construction failed for this bundle; try the next.
287
+ }
288
+ }
289
+ return false;
290
+ }
291
+ // ─── Post-quantum hybrid signing (FIPS 204 / ML-DSA-65) ───────────────────────
292
+ //
293
+ // Per POST_QUANTUM_SPEC.md and the customer-visible promise in the IHC
294
+ // briefing: every Decision Receipt carries TWO signatures over the same
295
+ // canonical bytes:
296
+ //
297
+ // 1. Ed25519 (classical) — verifiable today with any standard library
298
+ // 2. ML-DSA-65 (FIPS 204, "Dilithium-3") — quantum-resistant
299
+ //
300
+ // Both must verify for the receipt to count as authentic. Existing
301
+ // Ed25519-only receipts (pre-Sprint 11) still verify under the classical
302
+ // path — verifyReceiptSignature(payload, ed25519Hex) above. New receipts
303
+ // add the post-quantum signature; verifiers gain quantum resistance
304
+ // without invalidating prior signatures.
305
+ //
306
+ // Why hybrid not pure replacement:
307
+ // - ML-DSA is new (FIPS 204 finalized August 2024). Real-world attacks
308
+ // against lattice-based crypto could emerge; Ed25519 stays as a
309
+ // second-source assurance during the transition window.
310
+ // - Cryptographic agility: if a library bug surfaces in either path,
311
+ // the other still authenticates.
312
+ // - Backward compatibility: existing verifier code paths keep working
313
+ // with the Ed25519 signature unchanged.
314
+ //
315
+ // Size note: ML-DSA-65 signatures are ~3309 bytes (vs 64 bytes for
316
+ // Ed25519). Stored as base64 in a separate column (~4400 chars). Receipt
317
+ // total size grows from ~600 bytes JSON to ~5000 bytes JSON. Cost is
318
+ // real but worthwhile for 7-25 year compliance retention horizons.
319
+ // ML-DSA keypair cache: maps key-id to generated keypair. Generated
320
+ // deterministically from the seed stored in the KeyBundle, so the same seed
321
+ // always produces the same keypair. This avoids re-running keygen on every sign.
322
+ const _mlDsaCache = new Map();
323
+ function getMlDsaKey(bundle) {
324
+ const b = bundle ?? currentBundle();
325
+ const cached = _mlDsaCache.get(b.keyId);
326
+ if (cached)
327
+ return cached;
328
+ const derived = ml_dsa_js_1.ml_dsa65.keygen(b.mlDsa.seed);
329
+ _mlDsaCache.set(b.keyId, derived);
330
+ return derived;
331
+ }
332
+ /** ML-DSA-65 public key, base64. Published at /verify/pubkey alongside Ed25519. */
333
+ function getMlDsaPubkey() {
334
+ return Buffer.from(getMlDsaKey(currentBundle()).publicKey).toString('base64');
335
+ }
336
+ /**
337
+ * Sign the canonical payload with BOTH Ed25519 and ML-DSA-65. Returns
338
+ * both signatures; receipt-store persists both. Verification requires
339
+ * both to pass.
340
+ */
341
+ function signReceiptPayloadHybrid(payload) {
342
+ const canonical = canonicalPayload(payload);
343
+ const canonicalBytes = Buffer.from(canonical, 'utf8');
344
+ const bundle = currentBundle();
345
+ // Ed25519 — same as classical path
346
+ const edKeyObj = (0, crypto_1.createPrivateKey)({ key: bundle.ed25519.privateKeyPem, format: 'pem' });
347
+ const edSig = (0, crypto_1.sign)(null, canonicalBytes, edKeyObj);
348
+ // ML-DSA-65 — randomized signature; deterministic input -> different output
349
+ // each call, but verifier doesn't care (only the verify check matters).
350
+ const { secretKey } = getMlDsaKey(bundle);
351
+ const mlDsaSig = ml_dsa_js_1.ml_dsa65.sign(new Uint8Array(canonicalBytes), secretKey);
352
+ return {
353
+ ed25519: edSig.toString('hex'),
354
+ ml_dsa: Buffer.from(mlDsaSig).toString('base64'),
355
+ };
356
+ }
357
+ /**
358
+ * K1 — produce a DSSE AttestationEnvelope over a canonical payload string.
359
+ *
360
+ * Signs PAE(payloadType, payload) with BOTH Ed25519 and ML-DSA-65 using the
361
+ * current key bundle. The `payloadType` is bound into the signed bytes via the
362
+ * DSSE PAE, closing the cross-type confused-deputy hazard (SRAID F7 / A4).
363
+ *
364
+ * Used by signGapReceiptV2 (agp/receipt-sign.ts) to produce the DSSE
365
+ * attestation envelope on receipt.attestation. Not called by the v1 path.
366
+ */
367
+ function signDssePayload(payloadType, payload) {
368
+ return signDsseWithBundle(payloadType, payload, currentBundle());
369
+ }
370
+ /**
371
+ * K1 core: produce a DSSE AttestationEnvelope over a canonical payload string
372
+ * using an EXPLICIT key bundle rather than the process-global signing key.
373
+ *
374
+ * This is the single hybrid DSSE signing path. `signDssePayload` above is the
375
+ * thin wrapper that binds it to `currentBundle()` (the in-process KeyProvider
376
+ * chain); the Vault/KMS signing oracle passes a bundle it fetched from Secrets
377
+ * Manager for the signing call frame only. Keeping ONE signer here means the
378
+ * PAE construction, the always-both-signatures shape, and the keyid binding are
379
+ * byte-identical no matter which custody tier produced the receipt (the ladder
380
+ * invariant). There is no single-signature return path.
381
+ *
382
+ * The `mlDsaKey` override lets the KMS oracle pass an already-derived ML-DSA
383
+ * keypair so it never has to touch the process-global `_mlDsaCache` (which is
384
+ * keyed by key-id and would otherwise cache the operator key material for the
385
+ * process lifetime). When omitted, the process cache is used (default tier).
386
+ */
387
+ function signDsseWithBundle(payloadType, payload, bundle, mlDsaKey) {
388
+ // PAE: DSSEv1 LEN(type) type LEN(body) body
389
+ const enc = new TextEncoder();
390
+ const typeBytes = enc.encode(payloadType);
391
+ const bodyBytes = enc.encode(payload);
392
+ const prefix = enc.encode(`DSSEv1 ${typeBytes.length} ${payloadType} ${bodyBytes.length} `);
393
+ const paeBytes = new Uint8Array(prefix.length + bodyBytes.length);
394
+ paeBytes.set(prefix, 0);
395
+ paeBytes.set(bodyBytes, prefix.length);
396
+ // Ed25519
397
+ const edKeyObj = (0, crypto_1.createPrivateKey)({ key: bundle.ed25519.privateKeyPem, format: 'pem' });
398
+ const edSig = (0, crypto_1.sign)(null, Buffer.from(paeBytes), edKeyObj);
399
+ // ML-DSA-65
400
+ const { secretKey } = mlDsaKey ?? getMlDsaKey(bundle);
401
+ const mlDsaSig = ml_dsa_js_1.ml_dsa65.sign(new Uint8Array(paeBytes), secretKey);
402
+ return {
403
+ payloadType,
404
+ payload,
405
+ signatures: [
406
+ {
407
+ alg: 'ed25519',
408
+ sig: Buffer.from(edSig).toString('base64'),
409
+ keyid: bundle.keyId,
410
+ },
411
+ {
412
+ alg: 'ml-dsa-65',
413
+ sig: Buffer.from(mlDsaSig).toString('base64'),
414
+ keyid: bundle.keyId,
415
+ },
416
+ ],
417
+ };
418
+ }
419
+ /**
420
+ * Derive the ML-DSA-65 keypair for a bundle WITHOUT touching the process-global
421
+ * `_mlDsaCache`. The KMS oracle uses this so operator key material is confined
422
+ * to the signing call frame and garbage-collected after, never retained for the
423
+ * process lifetime. Same deterministic keygen as `getMlDsaKey`, no side effect.
424
+ */
425
+ function deriveMlDsaKeyEphemeral(bundle) {
426
+ return ml_dsa_js_1.ml_dsa65.keygen(bundle.mlDsa.seed);
427
+ }
428
+ /**
429
+ * Decode an Ed25519 signature string to raw bytes, tolerating BOTH encodings
430
+ * the codebase produces:
431
+ * - hex (128 lowercase/uppercase hex chars) — the inline / legacy receipt path
432
+ * (signReceiptPayloadHybrid returns hex, anthropic-receipt-sign stores it).
433
+ * - base64url (86 chars, no padding) — the GAP CDRO path (signGapReceipt writes
434
+ * `Buffer.from(sigs.ed25519,'hex').toString('base64url')` per GAP spec §2.1,
435
+ * commit 439f147).
436
+ *
437
+ * A 64-byte Ed25519 signature is ALWAYS exactly 128 chars in hex and 86 chars in
438
+ * base64url, so length + hex-alphabet is an unambiguous discriminator. Before this,
439
+ * verifyReceiptSignatureHybrid parsed hex-only, silently mangling a base64url
440
+ * signature (ed=false) if a GAP receipt's signature was ever fed to it directly.
441
+ */
442
+ function decodeEdSignatureBytes(sig) {
443
+ if (sig.length === 128 && /^[0-9a-fA-F]+$/.test(sig)) {
444
+ return Buffer.from(sig, 'hex');
445
+ }
446
+ return Buffer.from(sig, 'base64url');
447
+ }
448
+ function verifyReceiptSignatureHybrid(payload, signatures) {
449
+ // Try each key in history (newest first) so receipts signed with a prior key
450
+ // still verify after rotation. Both Ed25519 AND ML-DSA must verify with the
451
+ // SAME bundle for the receipt to be considered authentic (ok=true).
452
+ //
453
+ // Diagnostic reporting: return the per-algorithm results from the BEST
454
+ // bundle attempt (i.e., the bundle that produced the highest partial match).
455
+ // This preserves the diagnostic surface: callers that tamper only Ed25519
456
+ // or only ML-DSA can still see which algorithm failed. The same bundle is
457
+ // used for both checks so cross-key forgery (Ed25519 from key A, ML-DSA
458
+ // from key B) is still rejected even if each individually would verify.
459
+ const canonical = canonicalPayload(payload);
460
+ const canonicalBytes = Buffer.from(canonical, 'utf8');
461
+ // Best partial result seen so far, for diagnostic reporting when no bundle
462
+ // produces ok=true.
463
+ let bestEd = false;
464
+ let bestMl = false;
465
+ for (const bundle of (0, key_provider_1.getKeyHistory)()) {
466
+ let edOk = false;
467
+ let mlDsaOk = false;
468
+ try {
469
+ const edKeyObj = (0, crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
470
+ edOk = (0, crypto_1.verify)(null, canonicalBytes, edKeyObj, decodeEdSignatureBytes(signatures.ed25519));
471
+ }
472
+ catch { /* fail-closed for this bundle */ }
473
+ try {
474
+ const { publicKey: mlDsaPub } = getMlDsaKey(bundle);
475
+ mlDsaOk = (0, native_mldsa_1.verifyMlDsa65)(new Uint8Array(Buffer.from(signatures.ml_dsa, 'base64')), new Uint8Array(canonicalBytes), mlDsaPub);
476
+ }
477
+ catch { /* fail-closed for this bundle */ }
478
+ if (edOk && mlDsaOk)
479
+ return { ok: true, ed25519: true, ml_dsa: true };
480
+ // Track the best partial result for diagnostics.
481
+ if (edOk || mlDsaOk) {
482
+ bestEd = edOk || bestEd;
483
+ bestMl = mlDsaOk || bestMl;
484
+ }
485
+ }
486
+ return { ok: false, ed25519: bestEd, ml_dsa: bestMl };
487
+ }
488
+ // ─── Raw-canonical-bytes hybrid sign/verify ───────────────────────────────────
489
+ //
490
+ // signReceiptPayloadHybrid / verifyReceiptSignatureHybrid run their input
491
+ // through canonicalPayload(), whose CANONICAL_FIELDS allow-list is tuned for
492
+ // Decision-Receipts and DROPS every field it does not recognise. That is wrong
493
+ // for a CapabilityGrant CDRO: its content (grantee, scopes, predicate, expiry)
494
+ // lives under keys that allow-list does not contain, so the grant signature
495
+ // would bind almost nothing. Per the GAP spec a CDRO signature MUST cover the
496
+ // canonical envelope, so grants are signed/verified over
497
+ // canonicalize(cdroContentCore(grant)) -- the full content core string -- via
498
+ // these helpers instead.
499
+ function signCanonicalBytesHybrid(canonical) {
500
+ const canonicalBytes = Buffer.from(canonical, 'utf8');
501
+ const bundle = currentBundle();
502
+ const edKeyObj = (0, crypto_1.createPrivateKey)({ key: bundle.ed25519.privateKeyPem, format: 'pem' });
503
+ const edSig = (0, crypto_1.sign)(null, canonicalBytes, edKeyObj);
504
+ const { secretKey } = getMlDsaKey(bundle);
505
+ const mlDsaSig = ml_dsa_js_1.ml_dsa65.sign(new Uint8Array(canonicalBytes), secretKey);
506
+ return { ed25519: edSig.toString('hex'), ml_dsa: Buffer.from(mlDsaSig).toString('base64') };
507
+ }
508
+ function verifyCanonicalBytesHybrid(canonical, signatures) {
509
+ const canonicalBytes = Buffer.from(canonical, 'utf8');
510
+ let bestEd = false;
511
+ let bestMl = false;
512
+ for (const bundle of (0, key_provider_1.getKeyHistory)()) {
513
+ let edOk = false;
514
+ let mlDsaOk = false;
515
+ try {
516
+ const edKeyObj = (0, crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
517
+ edOk = (0, crypto_1.verify)(null, canonicalBytes, edKeyObj, Buffer.from(signatures.ed25519, 'hex'));
518
+ }
519
+ catch { /* fail-closed for this bundle */ }
520
+ try {
521
+ const { publicKey: mlDsaPub } = getMlDsaKey(bundle);
522
+ mlDsaOk = (0, native_mldsa_1.verifyMlDsa65)(new Uint8Array(Buffer.from(signatures.ml_dsa, 'base64')), new Uint8Array(canonicalBytes), mlDsaPub);
523
+ }
524
+ catch { /* fail-closed for this bundle */ }
525
+ if (edOk && mlDsaOk)
526
+ return { ok: true, ed25519: true, ml_dsa: true };
527
+ if (edOk || mlDsaOk) {
528
+ bestEd = edOk || bestEd;
529
+ bestMl = mlDsaOk || bestMl;
530
+ }
531
+ }
532
+ return { ok: false, ed25519: bestEd, ml_dsa: bestMl };
533
+ }
534
+ /**
535
+ * Canonical bytes a CapabilityGrant signature covers: the content core minus
536
+ * EVERY signature-carrying field. cdroContentCore strips oid/signature/
537
+ * attestation but NOT ml_dsa_signature or signature_key_id, which are unset at
538
+ * sign time and set at verify time; dropping them here keeps the sign-time and
539
+ * verify-time bytes byte-identical. Used by BOTH signGrantCdro (engine) and
540
+ * tryVerifyGrant so they stay symmetric.
541
+ */
542
+ function grantSigningCanonical(grant) {
543
+ const core = (0, sraid_1.cdroContentCore)(grant);
544
+ delete core['ml_dsa_signature'];
545
+ delete core['signature_key_id'];
546
+ return (0, sraid_1.canonicalize)(core);
547
+ }
548
+ /** Ed25519-only verify over a raw canonical string (legacy single-signature grants). */
549
+ function verifyCanonicalBytesEd(canonical, signatureHex) {
550
+ const canonicalBytes = Buffer.from(canonical, 'utf8');
551
+ for (const bundle of (0, key_provider_1.getKeyHistory)()) {
552
+ try {
553
+ const keyObj = (0, crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
554
+ if ((0, crypto_1.verify)(null, canonicalBytes, keyObj, Buffer.from(signatureHex, 'hex')))
555
+ return true;
556
+ }
557
+ catch { /* try next bundle */ }
558
+ }
559
+ return false;
560
+ }
561
+ // F10 — K1 hybrid-signature cutover. Receipts / grants recorded at or after
562
+ // this instant MUST carry an ml_dsa_signature; an Ed25519-only artifact dated
563
+ // >= K1 is a downgrade (ML-DSA stripped, or forged on the classical path) and
564
+ // hard-fails. Pre-K1 artifacts were correctly signed Ed25519-only and are
565
+ // immutable valid history, so they continue to verify classically. This is the
566
+ // standard crypto-agility cutover discipline: do not ban the old algorithm
567
+ // globally, ban it only after the instant you committed to stop emitting it.
568
+ // Overridable for tests / staged rollout via SYNOI_HYBRID_CUTOVER (ISO-8601).
569
+ exports.K1_HYBRID_CUTOVER_MS = Date.parse(process.env['SYNOI_HYBRID_CUTOVER'] ?? '2026-06-27T00:00:00Z');
570
+ /**
571
+ * F10 — true when an Ed25519-only artifact must be REJECTED as a downgrade
572
+ * because it is dated at/after the K1 hybrid cutover. `recordedAtMs` is the
573
+ * artifact's recorded_at / created_at_ms (epoch milliseconds) or null/undefined.
574
+ * Fail-closed: a missing or non-finite date cannot be proven pre-cutover, so it
575
+ * is treated as in-scope and rejected. (All genuine pre-K1 receipts in the store
576
+ * carry a real recorded_at, so this does not harm valid history.)
577
+ */
578
+ function isPostCutoverDowngrade(recordedAtMs, hasMlDsa) {
579
+ if (hasMlDsa)
580
+ return false; // hybrid present — never a downgrade
581
+ return !Number.isFinite(recordedAtMs) || recordedAtMs >= exports.K1_HYBRID_CUTOVER_MS;
582
+ }
583
+ /**
584
+ * F10 — single source of truth for the verification `scheme` label so every
585
+ * route (legacy receipt, GAP receipt, grant; JSON and HTML) reports the cutover
586
+ * state identically. A post-K1 Ed25519-only artifact is labelled REJECTED.
587
+ */
588
+ function schemeLabel(recordedAtMs, hasMlDsa) {
589
+ if (hasMlDsa)
590
+ return 'hybrid Ed25519 + ML-DSA-65';
591
+ if (isPostCutoverDowngrade(recordedAtMs, hasMlDsa)) {
592
+ return 'REJECTED: post-K1 Ed25519-only (hybrid required)';
593
+ }
594
+ return 'Ed25519 (legacy, pre-cutover)';
595
+ }
596
+ /**
597
+ * CANONICAL_FIELDS is the complete set of receipt fields that participate in
598
+ * the Ed25519 + ML-DSA-65 signature seal. This is the single source of
599
+ * truth; the /scheme endpoint and all example code derive from it.
600
+ *
601
+ * Omit-when-absent rule: if a field is undefined or null in the stored
602
+ * receipt it is excluded from the canonical form. Adding a new optional
603
+ * field never changes the canonical bytes for older receipts.
604
+ *
605
+ * Field notes (inline with omit-when-absent semantics):
606
+ * action_payload_hash #101: SHA-256 hex of action payload
607
+ * cited_oracle_inputs S1.1: oracle values cited by the gate (omit-when-absent)
608
+ * gateway_manifest_sha256 Sprint 1.2 (2026-05-19): RE-protection
609
+ * grant_oid / intent_oid / rule_id / subject_oid T14 (#274): authority context
610
+ * policy_versions #101: JSON array of {id,version} tuples
611
+ * prev T15 (#277): lineage edge of a Decision-Receipt state change
612
+ * replayed_after S2.3: denial->execution Merkle edge (omit-when-absent)
613
+ * sequence #101: per-tenant monotonic counter
614
+ * session_id #101: GAP actor_session_id (null on direct path)
615
+ * state_sha256 #124: SHA-256 of canonical policy-pack state (omit-when-null)
616
+ * status T15: GAP receipt status
617
+ * target_class #124: category of authorized target (omit-when-null)
618
+ * target_id #124: correlation key — authorized target identifier (omit-when-null)
619
+ */
620
+ exports.CANONICAL_FIELDS = [
621
+ 'action_class',
622
+ 'action_payload_hash',
623
+ 'cited_oracle_inputs',
624
+ 'decision',
625
+ 'decision_subject_oid',
626
+ 'degraded',
627
+ 'gateway_manifest_sha256',
628
+ 'grant_oid',
629
+ 'intent_oid',
630
+ 'oid_hex',
631
+ 'policy_versions',
632
+ 'prev',
633
+ 'receipt_id',
634
+ 'recorded_at',
635
+ 'replayed_after',
636
+ 'risk_level',
637
+ 'rule_id',
638
+ 'sequence',
639
+ 'session_id',
640
+ 'state_sha256',
641
+ 'status',
642
+ 'subject_oid',
643
+ 'target_class',
644
+ 'target_id',
645
+ 'tenant_id',
646
+ ];
647
+ function canonicalPayload(payload) {
648
+ const obj = {};
649
+ for (const key of [...exports.CANONICAL_FIELDS].sort()) {
650
+ // Omit keys that are absent OR explicitly null — null authority fields
651
+ // (no grant / intent / rule / subject for this receipt) must not be
652
+ // included in the canonical form. A receipt signed without a grant_oid
653
+ // must verify identically whether grant_oid is absent or null in the
654
+ // store. Only the pre-T14 8-field receipts omit all four new keys;
655
+ // T14 receipts include only the keys that are non-null.
656
+ if (payload[key] !== undefined && payload[key] !== null)
657
+ obj[key] = payload[key];
658
+ }
659
+ return JSON.stringify(obj);
660
+ }
661
+ // ── Routes ────────────────────────────────────────────────────────────────────
662
+ // GET /verify/pubkey — public verification keys (Ed25519 + ML-DSA-65)
663
+ router.get('/pubkey', (_req, res) => {
664
+ const { ed25519 } = currentBundle();
665
+ const edPub = ed25519.publicKeyPem;
666
+ res.json({
667
+ key_id: getSignerKeyId(),
668
+ scheme: 'hybrid Ed25519 + ML-DSA-65 (FIPS 204), enforced for receipts recorded on/after 2026-06-27 (K1)',
669
+ ed25519: {
670
+ public_key: edPub,
671
+ algorithm: 'Ed25519',
672
+ encoding: 'PEM/SPKI',
673
+ note: 'Use any standard Ed25519 library. Classical signature.',
674
+ },
675
+ ml_dsa: {
676
+ public_key: getMlDsaPubkey(),
677
+ algorithm: 'ML-DSA-65',
678
+ standard: 'NIST FIPS 204',
679
+ encoding: 'base64 raw public-key bytes (1952 bytes decoded)',
680
+ note: 'Use FIPS 204 ML-DSA-65 / "Dilithium-3". Post-quantum signature.',
681
+ },
682
+ note: 'Hybrid: BOTH signatures must verify. Receipts recorded before K1 (2026-06-27) may carry only Ed25519 and verify classically; receipts recorded on/after K1 MUST be hybrid — an Ed25519-only receipt dated on/after K1 is rejected as a downgrade.',
683
+ });
684
+ });
685
+ // GET /verify/pubkey/ed25519 — legacy single-algorithm endpoint
686
+ router.get('/pubkey/ed25519', (_req, res) => {
687
+ const { ed25519 } = currentBundle();
688
+ res.json({
689
+ key_id: getSignerKeyId(),
690
+ public_key: ed25519.publicKeyPem,
691
+ algorithm: 'Ed25519',
692
+ encoding: 'PEM/SPKI',
693
+ });
694
+ });
695
+ // GET /verify/pubkey/ml-dsa — raw ML-DSA pubkey for verifier libraries
696
+ router.get('/pubkey/ml-dsa', (_req, res) => {
697
+ res.json({
698
+ key_id: getSignerKeyId(),
699
+ public_key: getMlDsaPubkey(),
700
+ algorithm: 'ML-DSA-65',
701
+ standard: 'NIST FIPS 204',
702
+ encoding: 'base64',
703
+ });
704
+ });
705
+ // GET /verify/scheme — published signature scheme
706
+ router.get('/scheme', (_req, res) => {
707
+ res.json({
708
+ name: 'SynOI Decision Receipt Signature Scheme v2 (Hybrid)',
709
+ version: '2',
710
+ algorithm: 'hybrid Ed25519 + ML-DSA-65',
711
+ enforcement: {
712
+ hybrid_required_after: '2026-06-27T00:00:00Z',
713
+ cutover_name: 'K1',
714
+ ed25519_only: 'rejected as a downgrade for artifacts recorded on/after the cutover; permitted only for pre-cutover legacy artifacts',
715
+ },
716
+ classical: {
717
+ algorithm: 'Ed25519',
718
+ signature_encoding: 'hex (raw 64-byte signature)',
719
+ pubkey_endpoint: '/verify/pubkey/ed25519',
720
+ },
721
+ post_quantum: {
722
+ algorithm: 'ML-DSA-65',
723
+ standard: 'NIST FIPS 204',
724
+ signature_encoding: 'base64 (raw ~3309-byte signature)',
725
+ pubkey_endpoint: '/verify/pubkey/ml-dsa',
726
+ },
727
+ canonical_fields: [...exports.CANONICAL_FIELDS].sort(),
728
+ canonical_form: 'JSON.stringify({ each field above THAT IS PRESENT AND NON-NULL in the receipt }, keys sorted alphabetically, no spaces). Omit any field absent or null -- e.g. legacy receipts without gateway_manifest_sha256 canonicalize over the remaining 7; receipts without session_id (direct-inference path) omit that field.',
729
+ pubkey_endpoint: '/verify/pubkey',
730
+ verification_steps: [
731
+ '1. Fetch the receipt from GET /verify/:receipt_id',
732
+ '2. Extract the canonical_fields from the receipt payload',
733
+ '3. Sort the keys alphabetically and serialize to JSON (no spaces)',
734
+ '4. Fetch both public keys from GET /verify/pubkey',
735
+ '5. Verify receipt.signature (Ed25519, hex) against the canonical bytes using any Ed25519 library',
736
+ '6. Verify receipt.ml_dsa_signature (ML-DSA-65, base64) against the canonical bytes using a FIPS 204 library',
737
+ '7. Receipt is authentic IFF BOTH verifications pass (hybrid). Receipts recorded before K1 (2026-06-27) without ml_dsa_signature verify under Ed25519 alone; an Ed25519-only receipt recorded on/after K1 is rejected as a downgrade.',
738
+ ],
739
+ example_code: {
740
+ node: `
741
+ const { verify, createPublicKey } = require('crypto')
742
+ const receipt = await fetch('/verify/RECEIPT_ID').then(r => r.json())
743
+ const { public_key } = await fetch('/verify/pubkey').then(r => r.json())
744
+ const fields = ${JSON.stringify([...exports.CANONICAL_FIELDS].sort())}
745
+ const present = fields.filter(k => receipt.receipt[k] != null).sort()
746
+ const canonical = JSON.stringify(Object.fromEntries(present.map(k => [k, receipt.receipt[k]])))
747
+ const keyObj = createPublicKey({ key: public_key, format: 'pem' })
748
+ const valid = verify(null, Buffer.from(canonical), keyObj, Buffer.from(receipt.receipt.signature, 'hex'))
749
+ console.log('Valid:', valid)
750
+ `.trim(),
751
+ python: `
752
+ import json, base64
753
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
754
+ # pip install cryptography requests
755
+ import requests
756
+ receipt = requests.get('/verify/RECEIPT_ID').json()['receipt']
757
+ pubkey_pem = requests.get('/verify/pubkey').json()['public_key'].encode()
758
+ fields = sorted([f for f in ['action_class','decision','degraded','gateway_manifest_sha256','grant_oid','intent_oid','oid_hex','receipt_id','recorded_at','risk_level','rule_id','subject_oid','tenant_id'] if receipt.get(f) is not None])
759
+ canonical = json.dumps({k: receipt[k] for k in fields}, separators=(',',':')).encode()
760
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
761
+ pub = load_pem_public_key(pubkey_pem)
762
+ sig = bytes.fromhex(receipt['signature'])
763
+ pub.verify(sig, canonical) # raises if invalid
764
+ print('Valid')
765
+ `.trim(),
766
+ },
767
+ });
768
+ });
769
+ // POST /verify/state-absence — verify a signed provable-absence statement.
770
+ //
771
+ // DEFECT [12] closer: absence.ts MINTS a signed negative but nothing re-checked
772
+ // it, so the digest-pinning + distinct payloadType were decorative. This is the
773
+ // independent verifier. It is a POST (not GET-by-OID) because absence statements
774
+ // are minted inline in the drift response and are NOT persisted server-side, so
775
+ // the caller presents the full statement here, optionally alongside the
776
+ // authorizing-receipt corpus they hold (`corpus_oids`) to pin the snapshot.
777
+ //
778
+ // Body: { statement: StateAbsenceStatement, corpus_oids?: string[] }
779
+ //
780
+ // A decision-receipt (or any non-absence) envelope presented here is rejected
781
+ // with reason 'wrong-payload-type' so the two attestation classes are never
782
+ // interchangeable. Must be declared BEFORE the '/:receipt_id' catch-all so the
783
+ // literal path is not swallowed by the param route.
784
+ router.post('/state-absence', (req, res) => {
785
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
786
+ // `any` deliberately, not `typeof import('./gap/state/absence-verify')`: that
787
+ // whole-module type reference pulls gap/state/absence.ts, and through it
788
+ // gap/signing-oracle.ts (the KMS-hybrid managed-custody tier), into ANY
789
+ // program that type-checks this file -- including src/daemon.ts (the lite
790
+ // entrypoint), even though this handler is dead code from daemon.ts's
791
+ // perspective (it lives on the verifyRouter default export, which daemon.ts
792
+ // never mounts). Runtime behavior is unchanged: the lazy require() still
793
+ // resolves the real function; only the static type-graph edge is dropped.
794
+ // Enforced by test/daemon-dep-graph.test.ts.
795
+ const { verifyStateAbsence } = require('./gap/state/absence-verify');
796
+ const body = (req.body ?? {});
797
+ const statement = body.statement;
798
+ if (!statement || typeof statement !== 'object') {
799
+ res.status(400).json({
800
+ valid: false,
801
+ error: { message: '`statement` (a signed absence statement) is required', type: 'validation_error' },
802
+ });
803
+ return;
804
+ }
805
+ const corpus_oids = Array.isArray(body.corpus_oids) && body.corpus_oids.every(o => typeof o === 'string')
806
+ ? body.corpus_oids
807
+ : undefined;
808
+ const outcome = verifyStateAbsence({
809
+ statement,
810
+ ...(corpus_oids !== undefined ? { presentedCorpusOids: corpus_oids } : {}),
811
+ });
812
+ // Fail-closed verification: a signature/type/corpus failure is a 200 with
813
+ // valid=false (the verification RAN and returned a verdict), not an error.
814
+ res.json({
815
+ verified: outcome.valid,
816
+ receipt_class: 'gap:state_absence',
817
+ reasons: outcome.reasons,
818
+ payload_type: outcome.payload_type ?? null,
819
+ ed25519_valid: outcome.ed25519_valid,
820
+ ml_dsa_valid: outcome.ml_dsa_valid,
821
+ corpus_checked: outcome.corpus_checked,
822
+ signed_examined_receipt_digest: outcome.signed_examined_receipt_digest ?? null,
823
+ recomputed_examined_receipt_digest: outcome.recomputed_examined_receipt_digest ?? null,
824
+ signed_claim: outcome.signed_claim ?? null,
825
+ pubkey_url: '/verify/pubkey',
826
+ scheme_url: '/verify/scheme',
827
+ });
828
+ });
829
+ // GET /verify/:receipt_id — verify a specific receipt
830
+ // Content-negotiated: Accept: text/html → human page, Accept: application/json
831
+ // (default) → the JSON shape we've always returned.
832
+ router.get('/:receipt_id', (req, res) => {
833
+ // GAP OIDs carry a literal colon in their first path segment
834
+ // ("sha256:<hex>"); some proxies/CDNs in front of a caller leave that colon
835
+ // percent-encoded ("%3A") even though Express already decodes the route
836
+ // param once. Decode again defensively so a double-encoded id still
837
+ // resolves — this protects every caller of /verify, not just our own
838
+ // portal (which normalizes on its side too).
839
+ const receipt_id = normalizeReceiptId(req.params.receipt_id);
840
+ const receipt = receipt_store_1.receiptStore.getReceipt(receipt_id);
841
+ if (!receipt) {
842
+ // GAP CDRO fallback. The inline path's Sentinel `receipts` table is only one
843
+ // of two receipt stores. The GAP engine (and the demo router) emit
844
+ // `agp:decision_receipt` CDROs into gap_receipts, hybrid-signed over
845
+ // canonicalReceiptProjection (agp/receipt-sign), NOT the inline canonical
846
+ // form this route reads first. Resolve and verify them under their own
847
+ // canonical form so a /verify link minted by the GAP path (e.g. the Steve
848
+ // demo's verify_url) resolves and verifies instead of 404ing.
849
+ const agp = tryVerifyAgpReceipt(receipt_id);
850
+ if (agp) {
851
+ if (wantsHtml(req)) {
852
+ res.type('html').send(renderAgpReceiptHtml(agp));
853
+ return;
854
+ }
855
+ res.json({
856
+ verified: agp.signatureValid,
857
+ receipt_id,
858
+ signer_key_id: agp.receipt.signature_key_id || getSignerKeyId(),
859
+ receipt: {
860
+ ...agp.projection,
861
+ subject_kind: agp.receipt.body.subject_kind,
862
+ created_at_ms: agp.receipt.created_at_ms,
863
+ signature: agp.receipt.signature ?? null,
864
+ ml_dsa_signature: agp.receipt.ml_dsa_signature ?? null,
865
+ cited_oracle_inputs: agp.receipt.body.cited_oracle_inputs ?? null,
866
+ // Demo body extras surfaced so the verify surface can render context
867
+ // (e.g. WHO signed an NDA). Omit-when-absent keeps other receipts clean.
868
+ ...(((b) => {
869
+ const out = {};
870
+ for (const k of ['detail', 'predicate_evaluation', 'nda_signer', 'nda_version', 'nda_sha256', 'operator_signature', 'operator_pubkey_hex']) {
871
+ if (b[k] !== undefined && b[k] !== null)
872
+ out[k] = b[k];
873
+ }
874
+ return out;
875
+ })(agp.receipt.body)),
876
+ },
877
+ verification: {
878
+ scheme: schemeLabel(agp.receipt.created_at_ms, agp.hasMlDsa),
879
+ ed25519_valid: agp.edValid,
880
+ ml_dsa_valid: agp.mlDsaValid,
881
+ canonical_payload: agp.canonicalString,
882
+ receipt_class: 'gap:decision_receipt',
883
+ pubkey_url: '/verify/pubkey',
884
+ scheme_url: '/verify/scheme',
885
+ },
886
+ });
887
+ return;
888
+ }
889
+ // Grant CDRO fallback. The grant-update lineage shown in the demo
890
+ // (parent -> current) are grant OIDs, not receipts. Grants are hybrid-signed
891
+ // (signGrantCdro) and content-addressed, so resolve + verify them here too,
892
+ // otherwise clicking a grant version 404s as "Receipt not found".
893
+ const grantOutcome = tryVerifyGrant(receipt_id);
894
+ if (grantOutcome) {
895
+ if (wantsHtml(req)) {
896
+ res.type('html').send(renderGrantHtml(grantOutcome));
897
+ return;
898
+ }
899
+ res.json({
900
+ verified: grantOutcome.signatureValid,
901
+ oid: grantOutcome.grant.oid,
902
+ object_class: 'gap:capability_grant',
903
+ signer_key_id: grantOutcome.grant.signature_key_id || getSignerKeyId(),
904
+ grant: {
905
+ tenant_id: grantOutcome.grant.tenant_id,
906
+ created_at_ms: grantOutcome.grant.created_at_ms,
907
+ created_by: grantOutcome.grant.created_by,
908
+ supersedes: grantOutcome.grant.supersedes ?? null,
909
+ body: grantOutcome.grant.body,
910
+ signature: grantOutcome.grant.signature ?? null,
911
+ ml_dsa_signature: grantOutcome.grant.ml_dsa_signature ?? null,
912
+ },
913
+ verification: {
914
+ scheme: schemeLabel(grantOutcome.grant.created_at_ms, grantOutcome.hasMlDsa),
915
+ ed25519_valid: grantOutcome.edValid,
916
+ ml_dsa_valid: grantOutcome.mlDsaValid,
917
+ canonical_payload: grantOutcome.canonical,
918
+ object_class: 'gap:capability_grant',
919
+ pubkey_url: '/verify/pubkey',
920
+ scheme_url: '/verify/scheme',
921
+ },
922
+ });
923
+ return;
924
+ }
925
+ if (wantsHtml(req)) {
926
+ res.status(404).type('html').send(renderNotFoundHtml(receipt_id));
927
+ return;
928
+ }
929
+ res.status(404).json({
930
+ verified: false,
931
+ error: 'Receipt not found',
932
+ receipt_id,
933
+ });
934
+ return;
935
+ }
936
+ const payload = {
937
+ receipt_id: receipt.receipt_id,
938
+ tenant_id: receipt.tenant_id,
939
+ decision: receipt.decision,
940
+ action_class: receipt.action_class,
941
+ risk_level: receipt.risk_level,
942
+ oid_hex: receipt.oid_hex,
943
+ recorded_at: receipt.recorded_at,
944
+ // CR-1: the signer binds gateway_manifest_sha256 when a manifest is present
945
+ // (the production case). It MUST be reconstructed here or every production
946
+ // receipt fails verification. `?? undefined` lets canonicalPayload drop it
947
+ // for legacy 7-field receipts so those still verify.
948
+ gateway_manifest_sha256: receipt.gateway_manifest_sha256 ?? undefined,
949
+ // T14 (#274) — Authority context. Include only when non-null so pre-T14
950
+ // receipts continue to verify against the 8-field (or fewer) canonical form.
951
+ // canonicalPayload() omits null/undefined, so a null stored value produces
952
+ // the same canonical bytes as absent.
953
+ ...(receipt.grant_oid ? { grant_oid: receipt.grant_oid } : {}),
954
+ ...(receipt.intent_oid ? { intent_oid: receipt.intent_oid } : {}),
955
+ ...(receipt.rule_id ? { rule_id: receipt.rule_id } : {}),
956
+ ...(receipt.subject_oid ? { subject_oid: receipt.subject_oid } : {}),
957
+ // #101 -- four governance-sensing fields. Each omitted (not null) when
958
+ // absent so pre-#101 receipts canonicalize identically to before.
959
+ ...(receipt.session_id != null ? { session_id: receipt.session_id } : {}),
960
+ ...(receipt.policy_versions != null ? { policy_versions: receipt.policy_versions } : {}),
961
+ ...(receipt.action_payload_hash != null ? { action_payload_hash: receipt.action_payload_hash } : {}),
962
+ ...(receipt.sequence != null ? { sequence: receipt.sequence } : {}),
963
+ // #124 -- correlation key fields. Omit-when-null so pre-#124 receipts
964
+ // (including all legacy and direct-inference receipts) canonicalize
965
+ // identically and verify unchanged.
966
+ ...(receipt.target_id != null ? { target_id: receipt.target_id } : {}),
967
+ ...(receipt.target_class != null ? { target_class: receipt.target_class } : {}),
968
+ ...(receipt.state_sha256 != null ? { state_sha256: receipt.state_sha256 } : {}),
969
+ };
970
+ // Hybrid verification — receipts produced by Sprint 11+ carry both
971
+ // Ed25519 (signature) AND ML-DSA-65 (ml_dsa_signature) signatures over
972
+ // the same canonical bytes. Legacy receipts (pre-Sprint-11) carry only
973
+ // Ed25519; for those we fall back to the classical-only check.
974
+ const hasMlDsa = !!receipt.ml_dsa_signature;
975
+ let edValid = false;
976
+ let mlDsaValid = false;
977
+ let signatureValid = false;
978
+ // #253: new receipts bind `degraded` into the signed canonical payload;
979
+ // legacy / pre-#253 receipts did not. Verify against the degraded-bound form
980
+ // first, then fall back to the legacy form. A NEW receipt whose stored
981
+ // `degraded` was flipped matches NEITHER form, so the tamper is detected;
982
+ // only a receipt genuinely signed without `degraded` passes via the fallback.
983
+ const verifyPayload = (p) => {
984
+ if (!receipt.signature)
985
+ return { ed: false, ml: false, ok: false };
986
+ if (hasMlDsa) {
987
+ const h = verifyReceiptSignatureHybrid(p, { ed25519: receipt.signature, ml_dsa: receipt.ml_dsa_signature });
988
+ return { ed: h.ed25519, ml: h.ml_dsa, ok: h.ok };
989
+ }
990
+ // F10: a post-K1 Ed25519-only receipt is a downgrade — hard-fail, no fallback.
991
+ if (isPostCutoverDowngrade(receipt.recorded_at, hasMlDsa)) {
992
+ return { ed: false, ml: false, ok: false };
993
+ }
994
+ const ed = verifyReceiptSignature(p, receipt.signature);
995
+ return { ed, ml: false, ok: ed };
996
+ };
997
+ let v = verifyPayload({ ...payload, degraded: !!receipt.degraded });
998
+ if (!v.ok) {
999
+ // Fallback: try without `degraded` for legacy receipts signed before the
1000
+ // `degraded` field was bound into the canonical payload. If the fallback
1001
+ // also fails, keep the FIRST attempt's per-algorithm diagnostics so that
1002
+ // callers can still see WHICH algorithm failed (e.g. Ed25519 ok but ML-DSA
1003
+ // tampered). Overwriting the diagnostics with the fallback's all-false
1004
+ // result would hide the per-algorithm detail that makes tamper detection
1005
+ // observable.
1006
+ const vFallback = verifyPayload(payload);
1007
+ if (vFallback.ok) {
1008
+ v = vFallback;
1009
+ }
1010
+ else {
1011
+ // Neither attempt produced ok=true. Prefer the diagnostics from
1012
+ // whichever attempt produced more information (any true field).
1013
+ const firstHasInfo = v.ed || v.ml;
1014
+ if (!firstHasInfo)
1015
+ v = vFallback;
1016
+ // else keep v (first attempt) — it has richer per-algorithm detail.
1017
+ }
1018
+ }
1019
+ edValid = v.ed;
1020
+ mlDsaValid = v.ml;
1021
+ signatureValid = v.ok;
1022
+ if (wantsHtml(req)) {
1023
+ res.type('html').send(renderReceiptHtml(receipt, payload, signatureValid));
1024
+ return;
1025
+ }
1026
+ res.json({
1027
+ verified: signatureValid,
1028
+ receipt_id: receipt.receipt_id,
1029
+ signer_key_id: receipt.signer_key_id || getSignerKeyId(),
1030
+ receipt: {
1031
+ ...payload,
1032
+ action_type: receipt.action_type,
1033
+ action_desc: receipt.action_desc,
1034
+ provider: receipt.provider,
1035
+ mode: receipt.mode,
1036
+ latency_ms: receipt.latency_ms,
1037
+ signature: receipt.signature,
1038
+ ml_dsa_signature: receipt.ml_dsa_signature ?? null,
1039
+ policy_refs: JSON.parse(receipt.policy_refs || '[]'),
1040
+ // T14 (#274) — Authority context (null when not present on the receipt)
1041
+ grant_oid: receipt.grant_oid ?? null,
1042
+ intent_oid: receipt.intent_oid ?? null,
1043
+ rule_id: receipt.rule_id ?? null,
1044
+ subject_oid: receipt.subject_oid ?? null,
1045
+ // #101 -- governance-sensing fields (null when not present)
1046
+ session_id: receipt.session_id ?? null,
1047
+ policy_versions: receipt.policy_versions != null
1048
+ ? (() => { try {
1049
+ return JSON.parse(receipt.policy_versions);
1050
+ }
1051
+ catch {
1052
+ return null;
1053
+ } })()
1054
+ : null,
1055
+ action_payload_hash: receipt.action_payload_hash ?? null,
1056
+ sequence: receipt.sequence ?? null,
1057
+ // #124 -- correlation key fields (null when not present)
1058
+ target_id: receipt.target_id ?? null,
1059
+ target_class: receipt.target_class ?? null,
1060
+ state_sha256: receipt.state_sha256 ?? null,
1061
+ },
1062
+ verification: {
1063
+ scheme: schemeLabel(receipt.recorded_at, hasMlDsa),
1064
+ ed25519_valid: edValid,
1065
+ ml_dsa_valid: mlDsaValid,
1066
+ canonical_payload: canonicalPayload(payload),
1067
+ pubkey_url: '/verify/pubkey',
1068
+ scheme_url: '/verify/scheme',
1069
+ },
1070
+ });
1071
+ });
1072
+ // ── HTML render helpers ─────────────────────────────────────────────────────
1073
+ // Public verification page. Anyone with a receipt_id can open this in a browser
1074
+ // and see: what action, what verdict, who decided, when, and whether the
1075
+ // Ed25519 signature on the SynOI-canonical payload verifies against our
1076
+ // published public key. Designed to be the SHAREABLE proof artifact — drop a
1077
+ // URL in a compliance ticket / sales deck / audit log.
1078
+ function wantsHtml(req) {
1079
+ const accept = (req.header('accept') ?? '').toLowerCase();
1080
+ // Browsers send "text/html,application/xhtml+xml,...". curl/code clients
1081
+ // either send no Accept or send application/json explicitly.
1082
+ if (accept.includes('text/html'))
1083
+ return true;
1084
+ if (accept.includes('application/json'))
1085
+ return false;
1086
+ // Programmatic callers without an Accept header get JSON (backward-compat
1087
+ // with everything that's been calling /verify/:id since launch).
1088
+ return false;
1089
+ }
1090
+ function escapeHtml(s) {
1091
+ if (s == null)
1092
+ return '';
1093
+ return String(s).replace(/[&<>"']/g, c => ({
1094
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
1095
+ })[c]);
1096
+ }
1097
+ function fmtDate(ms) {
1098
+ try {
1099
+ return new Date(ms).toISOString().replace('T', ' ').replace('.000Z', ' UTC');
1100
+ }
1101
+ catch {
1102
+ return String(ms);
1103
+ }
1104
+ }
1105
+ function decisionBadge(decision) {
1106
+ const color = decision === 'allow' ? '#3fb950'
1107
+ : decision === 'deny' ? '#f85149'
1108
+ : decision === 'shadow_block' ? '#d29922'
1109
+ : '#6e7681';
1110
+ return `<span style="background:${color};color:#fff;padding:4px 12px;border-radius:6px;font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:0.5px;">${escapeHtml(decision)}</span>`;
1111
+ }
1112
+ function riskBadge(level) {
1113
+ const color = level === 'high' ? '#f85149' : level === 'medium' ? '#d29922' : '#3fb950';
1114
+ return `<span style="background:${color}33;color:${color};padding:2px 8px;border-radius:4px;font-weight:600;font-size:11px;text-transform:uppercase;">${escapeHtml(level)}</span>`;
1115
+ }
1116
+ function sigBadge(valid) {
1117
+ return valid
1118
+ ? `<span style="background:#3fb95033;color:#3fb950;padding:4px 10px;border-radius:4px;font-weight:600;font-size:12px;">✓ Signature valid</span>`
1119
+ : `<span style="background:#f8514933;color:#f85149;padding:4px 10px;border-radius:4px;font-weight:600;font-size:12px;">✗ Signature INVALID</span>`;
1120
+ }
1121
+ function renderReceiptHtml(receipt, payload, valid) {
1122
+ return `<!doctype html>
1123
+ <html lang="en">
1124
+ <head>
1125
+ <meta charset="utf-8">
1126
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1127
+ <title>SynOI Decision Receipt — ${escapeHtml(receipt.receipt_id)}</title>
1128
+ <meta property="og:title" content="SynOI Decision Receipt — ${escapeHtml(receipt.decision)} ${escapeHtml(receipt.action_type)}">
1129
+ <meta property="og:description" content="Cryptographically-signed audit record of an AI action governed by SynOI.">
1130
+ <style>
1131
+ :root { color-scheme: light dark; }
1132
+ * { box-sizing: border-box; }
1133
+ body { margin:0; padding:32px 24px; font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
1134
+ background:#0d1117; color:#c9d1d9; min-height:100vh; }
1135
+ @media (prefers-color-scheme: light) {
1136
+ body { background:#f6f8fa; color:#1f2328; }
1137
+ .card, header { background:#fff; border-color:#d0d7de; }
1138
+ .muted, footer { color:#656d76 !important; }
1139
+ th { color:#656d76 !important; }
1140
+ code { background:#f0f1f3 !important; color:#1f2328 !important; }
1141
+ }
1142
+ .wrap { max-width:780px; margin:0 auto; }
1143
+ header { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:24px; margin-bottom:20px; }
1144
+ header h1 { margin:0 0 6px; font-size:16px; font-weight:600; color:#8b949e; text-transform:uppercase; letter-spacing:1px; }
1145
+ header .id { font-family:ui-monospace,SFMono-Regular,monospace; font-size:13px; color:#8b949e; word-break:break-all; }
1146
+ .verdict-row { display:flex; gap:12px; align-items:center; margin-top:18px; flex-wrap:wrap; }
1147
+ .card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:20px; margin-bottom:16px; }
1148
+ .card h2 { margin:0 0 12px; font-size:11px; font-weight:600; color:#8b949e; text-transform:uppercase; letter-spacing:0.8px; }
1149
+ table { width:100%; border-collapse:collapse; }
1150
+ th, td { text-align:left; padding:8px 0; border-bottom:1px solid #21262d; font-size:13px; vertical-align:top; }
1151
+ th { width:170px; font-weight:500; color:#8b949e; }
1152
+ td { color:#f0f6fc; word-break:break-word; }
1153
+ code { background:#21262d; padding:1px 6px; border-radius:3px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
1154
+ .action-desc { font-family:ui-monospace,SFMono-Regular,monospace; background:#21262d; padding:12px 14px; border-radius:6px;
1155
+ white-space:pre-wrap; word-break:break-word; font-size:13px; line-height:1.5; }
1156
+ .sig { font-family:ui-monospace,SFMono-Regular,monospace; font-size:11px; color:#6e7681; word-break:break-all; }
1157
+ .links { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; }
1158
+ .links a { color:#58a6ff; text-decoration:none; font-size:12px; }
1159
+ .links a:hover { text-decoration:underline; }
1160
+ footer { text-align:center; margin-top:24px; font-size:11px; color:#6e7681; }
1161
+ .muted { color:#6e7681; }
1162
+ </style>
1163
+ </head>
1164
+ <body>
1165
+ <div class="wrap">
1166
+
1167
+ <header>
1168
+ <h1>SynOI Decision Receipt</h1>
1169
+ <div class="id">${escapeHtml(receipt.receipt_id)}</div>
1170
+ <div class="verdict-row">
1171
+ ${decisionBadge(receipt.decision)}
1172
+ ${riskBadge(receipt.risk_level)}
1173
+ <span class="muted">Class ${escapeHtml(receipt.action_class)}</span>
1174
+ <span style="flex:1"></span>
1175
+ ${sigBadge(valid)}
1176
+ </div>
1177
+ </header>
1178
+
1179
+ <div class="card">
1180
+ <h2>Action</h2>
1181
+ <table>
1182
+ <tr><th>Type</th><td><code>${escapeHtml(receipt.action_type)}</code></td></tr>
1183
+ <tr><th>Description</th><td><div class="action-desc">${escapeHtml(receipt.action_desc)}</div></td></tr>
1184
+ <tr><th>Provider</th><td>${escapeHtml(receipt.provider)}</td></tr>
1185
+ <tr><th>Mode</th><td>${escapeHtml(receipt.mode)}</td></tr>
1186
+ </table>
1187
+ </div>
1188
+
1189
+ <div class="card">
1190
+ <h2>Provenance</h2>
1191
+ <table>
1192
+ <tr><th>Tenant</th><td><code>${escapeHtml(receipt.tenant_id)}</code></td></tr>
1193
+ <tr><th>Recorded at</th><td>${escapeHtml(fmtDate(receipt.recorded_at))}</td></tr>
1194
+ <tr><th>Latency</th><td>${escapeHtml(receipt.latency_ms)} ms</td></tr>
1195
+ <tr><th>OID</th><td><code>${escapeHtml(receipt.oid_hex)}</code></td></tr>
1196
+ ${receipt.parent_receipt_id ? `<tr><th>Parent receipt</th><td><a href="/verify/${escapeHtml(receipt.parent_receipt_id)}" style="color:#58a6ff;text-decoration:none;"><code>${escapeHtml(receipt.parent_receipt_id)}</code></a></td></tr>` : ''}
1197
+ </table>
1198
+ </div>
1199
+
1200
+ <div class="card">
1201
+ <h2>Signature</h2>
1202
+ <table>
1203
+ <tr><th>Status</th><td>${sigBadge(valid)}</td></tr>
1204
+ <tr><th>Algorithm</th><td>Ed25519</td></tr>
1205
+ <tr><th>Signer key</th><td><code>${escapeHtml(receipt.signer_key_id || getSignerKeyId())}</code></td></tr>
1206
+ <tr><th>Signature</th><td class="sig">${escapeHtml(receipt.signature || '(unsigned)')}</td></tr>
1207
+ <tr><th>Canonical payload</th><td><code style="display:block;padding:8px;word-break:break-all;">${escapeHtml(canonicalPayload(payload))}</code></td></tr>
1208
+ </table>
1209
+ <div class="links">
1210
+ <a href="/verify/${escapeHtml(receipt.receipt_id)}/raw">Raw canonical bytes →</a>
1211
+ <a href="/verify/pubkey">Public key →</a>
1212
+ <a href="/verify/scheme">Verification scheme →</a>
1213
+ <a href="/verify/${escapeHtml(receipt.receipt_id)}" onclick="event.preventDefault();fetch(this.href,{headers:{Accept:'application/json'}}).then(r=>r.json()).then(j=>{document.getElementById('json').textContent=JSON.stringify(j,null,2);document.getElementById('json-wrap').style.display='block';});return false;">Show JSON →</a>
1214
+ </div>
1215
+ <pre id="json-wrap" style="display:none;background:#0d1117;border:1px solid #30363d;padding:14px;border-radius:6px;margin-top:12px;overflow:auto;max-height:300px;font-size:11px;"><code id="json"></code></pre>
1216
+ </div>
1217
+
1218
+ <footer>
1219
+ This page is the public verification surface for SynOI Decision Receipts. Anyone with the receipt ID can verify the signature offline using the published Ed25519 public key.
1220
+ </footer>
1221
+
1222
+ </div>
1223
+ </body>
1224
+ </html>`;
1225
+ }
1226
+ function tryVerifyAgpReceipt(receiptId) {
1227
+ // Lazy require breaks the verify-router <-> agp/receipt-sign module cycle at
1228
+ // init time (receipt-sign imports the signer from this module). Both modules
1229
+ // are fully initialised by the time a request reaches this handler.
1230
+ const { getReceipt } = require('./gap/store');
1231
+ const r = getReceipt(receiptId);
1232
+ if (!r)
1233
+ return null;
1234
+ const hasMlDsa = !!r.ml_dsa_signature;
1235
+ let edValid = false;
1236
+ let mlDsaValid = false;
1237
+ let signatureValid = false;
1238
+ let projection;
1239
+ let canonicalString;
1240
+ if (r.signature_algorithm === 'Ed25519' && r.signature) {
1241
+ // GAP-spec signature (2026-06+): canonical form is the envelope minus the
1242
+ // exclusion set; Ed25519 is base64url. Use gapSigningPayload from receipt-sign
1243
+ // so sign and verify share exactly the same exclusion set.
1244
+ // Minimal local type instead of `typeof import('./gap/receipt-sign')`:
1245
+ // a whole-module type reference pulls receipt-sign.ts (and its KMS-hybrid
1246
+ // signing-oracle.ts import) into ANY program that type-checks this file,
1247
+ // including src/daemon.ts (the lite entrypoint), even though this
1248
+ // function is dead code from daemon.ts's perspective (it mounts
1249
+ // initSigningKeys only, never the verifyRouter default export this
1250
+ // function serves). The lazy runtime require() below is unchanged; only
1251
+ // the STATIC type dependency is narrowed to the one function shape
1252
+ // actually used, per the CI dep-graph assertion (test/daemon-dep-graph.
1253
+ // test.ts) that enforces daemon.ts has no reachable edge into KMS.
1254
+ const { gapSigningPayload, canonicalReceiptProjection } = require('./gap/receipt-sign');
1255
+ // VERIFY basis: signGapReceipt signs canonicalize(gapSigningPayload(r)) — the
1256
+ // GAP CDRO envelope form (nested `body`, top-level `type`/`created_by`/...).
1257
+ const canonical = (0, sraid_1.canonicalize)(gapSigningPayload(r));
1258
+ canonicalString = canonical;
1259
+ // DISPLAY basis: the FLAT projection the JSON assembly (line ~894) and the
1260
+ // HTML renderer consume (receipt.decision, receipt.decision_subject_oid, ...).
1261
+ // The envelope form carries those under `body`, so spreading it left them
1262
+ // undefined (demo-reissue / demo-revoke "decision ... got undefined").
1263
+ projection = canonicalReceiptProjection(r);
1264
+ const edHex = Buffer.from(r.signature, 'base64url').toString('hex');
1265
+ if (hasMlDsa) {
1266
+ const h = verifyCanonicalBytesHybrid(canonical, { ed25519: edHex, ml_dsa: r.ml_dsa_signature });
1267
+ edValid = h.ed25519;
1268
+ mlDsaValid = h.ml_dsa;
1269
+ signatureValid = h.ok;
1270
+ }
1271
+ else if (isPostCutoverDowngrade(r.created_at_ms, hasMlDsa)) {
1272
+ // F10: post-K1 Ed25519-only GAP receipt is a downgrade - hard-fail.
1273
+ edValid = false;
1274
+ signatureValid = false;
1275
+ }
1276
+ else {
1277
+ edValid = verifyCanonicalBytesEd(canonical, edHex);
1278
+ signatureValid = edValid;
1279
+ }
1280
+ }
1281
+ else {
1282
+ // Legacy path: projection form + hex signatures (pre-2026-06 receipts / grants).
1283
+ // Same narrowing as above (avoid a whole-module type reference into
1284
+ // receipt-sign.ts / signing-oracle.ts from a lite-daemon-reachable file).
1285
+ const { canonicalReceiptProjection } = require('./gap/receipt-sign');
1286
+ projection = canonicalReceiptProjection(r);
1287
+ // Legacy receipts are signed over canonicalPayload(flat projection); that IS
1288
+ // the canonical byte-basis, so display it verbatim.
1289
+ canonicalString = canonicalPayload(projection);
1290
+ if (r.signature) {
1291
+ if (hasMlDsa) {
1292
+ const h = verifyReceiptSignatureHybrid(projection, {
1293
+ ed25519: r.signature,
1294
+ ml_dsa: r.ml_dsa_signature,
1295
+ });
1296
+ edValid = h.ed25519;
1297
+ mlDsaValid = h.ml_dsa;
1298
+ signatureValid = h.ok;
1299
+ }
1300
+ else if (isPostCutoverDowngrade(r.created_at_ms, hasMlDsa)) {
1301
+ // F10: post-K1 Ed25519-only legacy-projection receipt is a downgrade.
1302
+ edValid = false;
1303
+ signatureValid = false;
1304
+ }
1305
+ else {
1306
+ edValid = verifyReceiptSignature(projection, r.signature);
1307
+ signatureValid = edValid;
1308
+ }
1309
+ }
1310
+ }
1311
+ return { receipt: r, projection, canonicalString, edValid, mlDsaValid, signatureValid, hasMlDsa };
1312
+ }
1313
+ // Resolve + verify a CapabilityGrant CDRO by OID. Per the GAP spec a CDRO
1314
+ // signature covers the canonical envelope, so the grant is verified over
1315
+ // canonicalize(cdroContentCore(grant)) -- the SAME bytes signGrantCdro signs
1316
+ // (agp/engine). Returns null when no grant matches the OID.
1317
+ function tryVerifyGrant(oid) {
1318
+ const { getGrant } = require('./gap/store');
1319
+ const g = getGrant(oid);
1320
+ if (!g)
1321
+ return null;
1322
+ const canonical = grantSigningCanonical(g);
1323
+ const hasMlDsa = !!g.ml_dsa_signature;
1324
+ let edValid = false;
1325
+ let mlDsaValid = false;
1326
+ let signatureValid = false;
1327
+ if (g.signature) {
1328
+ if (hasMlDsa) {
1329
+ const h = verifyCanonicalBytesHybrid(canonical, { ed25519: g.signature, ml_dsa: g.ml_dsa_signature });
1330
+ edValid = h.ed25519;
1331
+ mlDsaValid = h.ml_dsa;
1332
+ signatureValid = h.ok;
1333
+ }
1334
+ else if (isPostCutoverDowngrade(g.created_at_ms, hasMlDsa)) {
1335
+ // F10: post-K1 Ed25519-only CapabilityGrant CDRO is a downgrade — hard-fail.
1336
+ edValid = false;
1337
+ signatureValid = false;
1338
+ }
1339
+ else {
1340
+ edValid = verifyCanonicalBytesEd(canonical, g.signature);
1341
+ signatureValid = edValid;
1342
+ }
1343
+ }
1344
+ return { grant: g, canonical, edValid, mlDsaValid, signatureValid, hasMlDsa };
1345
+ }
1346
+ function renderAgpReceiptHtml(o) {
1347
+ const r = o.receipt;
1348
+ const decision = String(o.projection.decision ?? r.body.status);
1349
+ const cited = r.body.cited_oracle_inputs ?? [];
1350
+ const scheme = schemeLabel(r.created_at_ms, o.hasMlDsa);
1351
+ // NDA acceptance receipts carry a signer block + document version. Render a
1352
+ // "Signed document" card so the verify page shows WHO signed WHAT.
1353
+ const bodyx = r.body;
1354
+ const ndaSigner = bodyx['nda_signer'];
1355
+ const ndaVersion = bodyx['nda_version'];
1356
+ const signerCard = ndaSigner ? `<div class="card">
1357
+ <h2>Signed document</h2>
1358
+ <table>
1359
+ ${ndaSigner['name'] ? `<tr><th>Signed by</th><td>${escapeHtml(String(ndaSigner['name']))}</td></tr>` : ''}
1360
+ ${ndaSigner['email'] ? `<tr><th>Email</th><td>${escapeHtml(String(ndaSigner['email']))}</td></tr>` : ''}
1361
+ ${ndaSigner['company'] ? `<tr><th>Company</th><td>${escapeHtml(String(ndaSigner['company']))}</td></tr>` : ''}
1362
+ ${ndaSigner['title'] ? `<tr><th>Title</th><td>${escapeHtml(String(ndaSigner['title']))}</td></tr>` : ''}
1363
+ ${ndaVersion ? `<tr><th>Document version</th><td><code>${escapeHtml(String(ndaVersion))}</code></td></tr>` : ''}
1364
+ <tr><th>Document hash</th><td><code>${escapeHtml(r.body.subject_oid)}</code></td></tr>
1365
+ </table>
1366
+ </div>` : '';
1367
+ const citedRows = cited.map((c) => {
1368
+ const raw = JSON.stringify(c.raw_value ?? c.value ?? {}, null, 0);
1369
+ return `<tr>
1370
+ <th>${escapeHtml(String(c.subject_type ?? 'oracle'))}</th>
1371
+ <td>
1372
+ <div class="action-desc">${escapeHtml(raw)}</div>
1373
+ ${c.source_url ? `<div class="muted" style="margin-top:6px;font-size:12px;">source: <code>${escapeHtml(String(c.source_url))}</code></div>` : ''}
1374
+ ${c.fetched_at ? `<div class="muted" style="font-size:12px;">fetched: ${escapeHtml(String(c.fetched_at))}</div>` : ''}
1375
+ ${c.value_hash ? `<div class="sig" style="margin-top:4px;">${escapeHtml(String(c.value_hash))}</div>` : ''}
1376
+ </td>
1377
+ </tr>`;
1378
+ }).join('');
1379
+ return `<!doctype html>
1380
+ <html lang="en">
1381
+ <head>
1382
+ <meta charset="utf-8">
1383
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1384
+ <title>SynOI Decision Receipt — ${escapeHtml(r.oid)}</title>
1385
+ <meta property="og:title" content="SynOI Decision Receipt — ${escapeHtml(decision)}">
1386
+ <meta property="og:description" content="Cryptographically-signed audit record of an AI action governed by SynOI.">
1387
+ <style>
1388
+ :root { color-scheme: light dark; }
1389
+ * { box-sizing: border-box; }
1390
+ body { margin:0; padding:32px 24px; font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
1391
+ background:#0d1117; color:#c9d1d9; min-height:100vh; }
1392
+ @media (prefers-color-scheme: light) {
1393
+ body { background:#f6f8fa; color:#1f2328; }
1394
+ .card, header { background:#fff; border-color:#d0d7de; }
1395
+ .muted, footer { color:#656d76 !important; }
1396
+ th { color:#656d76 !important; }
1397
+ code { background:#f0f1f3 !important; color:#1f2328 !important; }
1398
+ }
1399
+ .wrap { max-width:780px; margin:0 auto; }
1400
+ header { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:24px; margin-bottom:20px; }
1401
+ header h1 { margin:0 0 6px; font-size:16px; font-weight:600; color:#8b949e; text-transform:uppercase; letter-spacing:1px; }
1402
+ header .id { font-family:ui-monospace,SFMono-Regular,monospace; font-size:13px; color:#8b949e; word-break:break-all; }
1403
+ .verdict-row { display:flex; gap:12px; align-items:center; margin-top:18px; flex-wrap:wrap; }
1404
+ .card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:20px; margin-bottom:16px; }
1405
+ .card h2 { margin:0 0 12px; font-size:11px; font-weight:600; color:#8b949e; text-transform:uppercase; letter-spacing:0.8px; }
1406
+ table { width:100%; border-collapse:collapse; }
1407
+ th, td { text-align:left; padding:8px 0; border-bottom:1px solid #21262d; font-size:13px; vertical-align:top; }
1408
+ th { width:170px; font-weight:500; color:#8b949e; }
1409
+ td { color:#f0f6fc; word-break:break-word; }
1410
+ code { background:#21262d; padding:1px 6px; border-radius:3px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
1411
+ .action-desc { font-family:ui-monospace,SFMono-Regular,monospace; background:#21262d; padding:12px 14px; border-radius:6px;
1412
+ white-space:pre-wrap; word-break:break-word; font-size:13px; line-height:1.5; }
1413
+ .sig { font-family:ui-monospace,SFMono-Regular,monospace; font-size:11px; color:#6e7681; word-break:break-all; }
1414
+ .links { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; }
1415
+ .links a { color:#58a6ff; text-decoration:none; font-size:12px; }
1416
+ .links a:hover { text-decoration:underline; }
1417
+ footer { text-align:center; margin-top:24px; font-size:11px; color:#6e7681; }
1418
+ .muted { color:#6e7681; }
1419
+ </style>
1420
+ </head>
1421
+ <body>
1422
+ <div class="wrap">
1423
+
1424
+ <header>
1425
+ <h1>SynOI Decision Receipt</h1>
1426
+ <div class="id">${escapeHtml(r.oid)}</div>
1427
+ <div class="verdict-row">
1428
+ ${decisionBadge(decision)}
1429
+ <span class="muted">${escapeHtml(r.body.subject_kind)}</span>
1430
+ <span style="flex:1"></span>
1431
+ ${sigBadge(o.signatureValid)}
1432
+ </div>
1433
+ </header>
1434
+
1435
+ <div class="card">
1436
+ <h2>Provenance</h2>
1437
+ <table>
1438
+ <tr><th>Tenant</th><td><code>${escapeHtml(r.tenant_id)}</code></td></tr>
1439
+ <tr><th>Decided at</th><td>${escapeHtml(fmtDate(r.created_at_ms))}</td></tr>
1440
+ <tr><th>Status</th><td><code>${escapeHtml(r.body.status)}</code></td></tr>
1441
+ <tr><th>Subject OID</th><td><code>${escapeHtml(r.body.subject_oid)}</code></td></tr>
1442
+ ${r.prev ? `<tr><th>Supersedes</th><td><a href="/verify/${escapeHtml(r.prev)}" style="color:#58a6ff;text-decoration:none;"><code>${escapeHtml(r.prev)}</code></a></td></tr>` : ''}
1443
+ </table>
1444
+ </div>
1445
+
1446
+ ${signerCard}
1447
+
1448
+ ${citedRows ? `<div class="card">
1449
+ <h2>Cited oracle inputs</h2>
1450
+ <table>${citedRows}</table>
1451
+ </div>` : ''}
1452
+
1453
+ <div class="card">
1454
+ <h2>Signature</h2>
1455
+ <table>
1456
+ <tr><th>Status</th><td>${sigBadge(o.signatureValid)}</td></tr>
1457
+ <tr><th>Scheme</th><td>${escapeHtml(scheme)}</td></tr>
1458
+ <tr><th>Ed25519</th><td>${o.edValid ? 'valid' : 'INVALID'}</td></tr>
1459
+ ${o.hasMlDsa ? `<tr><th>ML-DSA-65</th><td>${o.mlDsaValid ? 'valid' : 'INVALID'}</td></tr>` : ''}
1460
+ <tr><th>Signer key</th><td><code>${escapeHtml(r.signature_key_id || getSignerKeyId())}</code></td></tr>
1461
+ <tr><th>Signature</th><td class="sig">${escapeHtml(r.signature || '(unsigned)')}</td></tr>
1462
+ <tr><th>Canonical payload</th><td><code style="display:block;padding:8px;word-break:break-all;">${escapeHtml(o.canonicalString)}</code></td></tr>
1463
+ </table>
1464
+ <div class="links">
1465
+ <a href="/verify/pubkey">Public key →</a>
1466
+ <a href="/verify/scheme">Verification scheme →</a>
1467
+ <a href="/verify/${escapeHtml(r.oid)}" onclick="event.preventDefault();fetch(this.href,{headers:{Accept:'application/json'}}).then(r=>r.json()).then(j=>{document.getElementById('json').textContent=JSON.stringify(j,null,2);document.getElementById('json-wrap').style.display='block';});return false;">Show JSON →</a>
1468
+ </div>
1469
+ <pre id="json-wrap" style="display:none;background:#0d1117;border:1px solid #30363d;padding:14px;border-radius:6px;margin-top:12px;overflow:auto;max-height:300px;font-size:11px;"><code id="json"></code></pre>
1470
+ </div>
1471
+
1472
+ <footer>
1473
+ This page is the public verification surface for SynOI Decision Receipts. Anyone with the receipt ID can verify the signature offline using the published public keys.
1474
+ </footer>
1475
+
1476
+ </div>
1477
+ </body>
1478
+ </html>`;
1479
+ }
1480
+ function renderGrantHtml(o) {
1481
+ const g = o.grant;
1482
+ const scheme = schemeLabel(g.created_at_ms, o.hasMlDsa);
1483
+ const body = g.body;
1484
+ const grantee = body['grantee'];
1485
+ const granteeOid = grantee ? String(grantee['actor_oid'] ?? grantee['actor_id'] ?? '') : '';
1486
+ // Surface the predicate / scopes when present; always show the full body too.
1487
+ const predicate = body['predicate'] ?? body['capability_scopes'] ?? body['capability'] ?? null;
1488
+ const bodyJson = JSON.stringify(g.body, null, 2);
1489
+ return `<!doctype html>
1490
+ <html lang="en">
1491
+ <head>
1492
+ <meta charset="utf-8">
1493
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1494
+ <title>SynOI Capability Grant — ${escapeHtml(g.oid)}</title>
1495
+ <meta property="og:title" content="SynOI Capability Grant">
1496
+ <meta property="og:description" content="Cryptographically-signed authorization grant governed by SynOI.">
1497
+ <style>
1498
+ :root { color-scheme: light dark; }
1499
+ * { box-sizing: border-box; }
1500
+ body { margin:0; padding:32px 24px; font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
1501
+ background:#0d1117; color:#c9d1d9; min-height:100vh; }
1502
+ @media (prefers-color-scheme: light) {
1503
+ body { background:#f6f8fa; color:#1f2328; }
1504
+ .card, header { background:#fff; border-color:#d0d7de; }
1505
+ .muted, footer { color:#656d76 !important; }
1506
+ th { color:#656d76 !important; }
1507
+ code { background:#f0f1f3 !important; color:#1f2328 !important; }
1508
+ }
1509
+ .wrap { max-width:780px; margin:0 auto; }
1510
+ header { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:24px; margin-bottom:20px; }
1511
+ header h1 { margin:0 0 6px; font-size:16px; font-weight:600; color:#8b949e; text-transform:uppercase; letter-spacing:1px; }
1512
+ header .id { font-family:ui-monospace,SFMono-Regular,monospace; font-size:13px; color:#8b949e; word-break:break-all; }
1513
+ .verdict-row { display:flex; gap:12px; align-items:center; margin-top:18px; flex-wrap:wrap; }
1514
+ .card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:20px; margin-bottom:16px; }
1515
+ .card h2 { margin:0 0 12px; font-size:11px; font-weight:600; color:#8b949e; text-transform:uppercase; letter-spacing:0.8px; }
1516
+ table { width:100%; border-collapse:collapse; }
1517
+ th, td { text-align:left; padding:8px 0; border-bottom:1px solid #21262d; font-size:13px; vertical-align:top; }
1518
+ th { width:170px; font-weight:500; color:#8b949e; }
1519
+ td { color:#f0f6fc; word-break:break-word; }
1520
+ code { background:#21262d; padding:1px 6px; border-radius:3px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
1521
+ .action-desc { font-family:ui-monospace,SFMono-Regular,monospace; background:#21262d; padding:12px 14px; border-radius:6px;
1522
+ white-space:pre-wrap; word-break:break-word; font-size:13px; line-height:1.5; }
1523
+ .sig { font-family:ui-monospace,SFMono-Regular,monospace; font-size:11px; color:#6e7681; word-break:break-all; }
1524
+ .links { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; }
1525
+ .links a { color:#58a6ff; text-decoration:none; font-size:12px; }
1526
+ .links a:hover { text-decoration:underline; }
1527
+ footer { text-align:center; margin-top:24px; font-size:11px; color:#6e7681; }
1528
+ .muted { color:#6e7681; }
1529
+ </style>
1530
+ </head>
1531
+ <body>
1532
+ <div class="wrap">
1533
+
1534
+ <header>
1535
+ <h1>SynOI Capability Grant</h1>
1536
+ <div class="id">${escapeHtml(g.oid)}</div>
1537
+ <div class="verdict-row">
1538
+ <span class="muted">capability grant</span>
1539
+ <span style="flex:1"></span>
1540
+ ${sigBadge(o.signatureValid)}
1541
+ </div>
1542
+ </header>
1543
+
1544
+ <div class="card">
1545
+ <h2>Provenance</h2>
1546
+ <table>
1547
+ <tr><th>Tenant</th><td><code>${escapeHtml(g.tenant_id)}</code></td></tr>
1548
+ <tr><th>Issued at</th><td>${escapeHtml(fmtDate(g.created_at_ms))}</td></tr>
1549
+ <tr><th>Issued by</th><td><code>${escapeHtml(g.created_by)}</code></td></tr>
1550
+ ${granteeOid ? `<tr><th>Grantee</th><td><code>${escapeHtml(granteeOid)}</code></td></tr>` : ''}
1551
+ ${g.supersedes ? `<tr><th>Supersedes</th><td><a href="/verify/${escapeHtml(g.supersedes)}" style="color:#58a6ff;text-decoration:none;"><code>${escapeHtml(g.supersedes)}</code></a></td></tr>` : ''}
1552
+ </table>
1553
+ </div>
1554
+
1555
+ ${predicate !== null ? `<div class="card">
1556
+ <h2>Authorized predicate</h2>
1557
+ <div class="action-desc">${escapeHtml(JSON.stringify(predicate, null, 2))}</div>
1558
+ </div>` : ''}
1559
+
1560
+ <div class="card">
1561
+ <h2>Grant body</h2>
1562
+ <div class="action-desc">${escapeHtml(bodyJson)}</div>
1563
+ </div>
1564
+
1565
+ <div class="card">
1566
+ <h2>Signature</h2>
1567
+ <table>
1568
+ <tr><th>Status</th><td>${sigBadge(o.signatureValid)}</td></tr>
1569
+ <tr><th>Scheme</th><td>${escapeHtml(scheme)}</td></tr>
1570
+ <tr><th>Ed25519</th><td>${o.edValid ? 'valid' : 'INVALID'}</td></tr>
1571
+ ${o.hasMlDsa ? `<tr><th>ML-DSA-65</th><td>${o.mlDsaValid ? 'valid' : 'INVALID'}</td></tr>` : ''}
1572
+ <tr><th>Signer key</th><td><code>${escapeHtml(g.signature_key_id || getSignerKeyId())}</code></td></tr>
1573
+ <tr><th>Signature</th><td class="sig">${escapeHtml(g.signature || '(unsigned)')}</td></tr>
1574
+ <tr><th>Canonical payload</th><td><code style="display:block;padding:8px;word-break:break-all;">${escapeHtml(o.canonical)}</code></td></tr>
1575
+ </table>
1576
+ <div class="links">
1577
+ <a href="/verify/pubkey">Public key →</a>
1578
+ <a href="/verify/scheme">Verification scheme →</a>
1579
+ <a href="/verify/${escapeHtml(g.oid)}" onclick="event.preventDefault();fetch(this.href,{headers:{Accept:'application/json'}}).then(r=>r.json()).then(j=>{document.getElementById('json').textContent=JSON.stringify(j,null,2);document.getElementById('json-wrap').style.display='block';});return false;">Show JSON →</a>
1580
+ </div>
1581
+ <pre id="json-wrap" style="display:none;background:#0d1117;border:1px solid #30363d;padding:14px;border-radius:6px;margin-top:12px;overflow:auto;max-height:300px;font-size:11px;"><code id="json"></code></pre>
1582
+ </div>
1583
+
1584
+ <footer>
1585
+ This page is the public verification surface for a SynOI Capability Grant. Anyone with the grant OID can verify the signature offline using the published public keys.
1586
+ </footer>
1587
+
1588
+ </div>
1589
+ </body>
1590
+ </html>`;
1591
+ }
1592
+ function renderNotFoundHtml(receiptId) {
1593
+ return `<!doctype html>
1594
+ <html lang="en">
1595
+ <head>
1596
+ <meta charset="utf-8">
1597
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1598
+ <title>Receipt not found</title>
1599
+ <style>
1600
+ body { font:14px/1.5 ui-sans-serif,system-ui,sans-serif; background:#0d1117; color:#c9d1d9;
1601
+ display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
1602
+ .wrap { max-width:480px; padding:32px; text-align:center; }
1603
+ h1 { font-size:18px; color:#f85149; margin:0 0 12px; }
1604
+ code { background:#21262d; padding:2px 8px; border-radius:3px; font-family:ui-monospace,monospace; font-size:13px; }
1605
+ p { color:#8b949e; }
1606
+ </style>
1607
+ </head>
1608
+ <body><div class="wrap">
1609
+ <h1>Receipt not found</h1>
1610
+ <p>No record matches <code>${escapeHtml(receiptId)}</code>.</p>
1611
+ <p>Receipts older than the retention window may have been pruned. Receipt IDs are typed as <code>rcpt-{timestamp}-{random}</code>.</p>
1612
+ </div></body>
1613
+ </html>`;
1614
+ }
1615
+ // GET /verify/:receipt_id/raw — raw canonical payload for manual verification
1616
+ router.get('/:receipt_id/raw', (req, res) => {
1617
+ const receipt_id = normalizeReceiptId(req.params.receipt_id);
1618
+ const receipt = receipt_store_1.receiptStore.getReceipt(receipt_id);
1619
+ if (!receipt) {
1620
+ res.status(404).json({ error: 'Receipt not found', receipt_id });
1621
+ return;
1622
+ }
1623
+ // Reconstruct the SAME payload as GET /verify/:id, including the fields the
1624
+ // signer binds: gateway_manifest_sha256 and the T14 (#274) authority context
1625
+ // (grant_oid, intent_oid, rule_id, subject_oid). Omitting any of these
1626
+ // produces canonical bytes that do NOT match what was signed, defeating the
1627
+ // entire purpose of this endpoint (independent verification). canonicalPayload()
1628
+ // drops null/undefined, so absent fields produce the same bytes as before.
1629
+ const payload = {
1630
+ receipt_id: receipt.receipt_id,
1631
+ tenant_id: receipt.tenant_id,
1632
+ decision: receipt.decision,
1633
+ action_class: receipt.action_class,
1634
+ risk_level: receipt.risk_level,
1635
+ oid_hex: receipt.oid_hex,
1636
+ recorded_at: receipt.recorded_at,
1637
+ gateway_manifest_sha256: receipt.gateway_manifest_sha256 ?? undefined,
1638
+ ...(receipt.grant_oid ? { grant_oid: receipt.grant_oid } : {}),
1639
+ ...(receipt.intent_oid ? { intent_oid: receipt.intent_oid } : {}),
1640
+ ...(receipt.rule_id ? { rule_id: receipt.rule_id } : {}),
1641
+ ...(receipt.subject_oid ? { subject_oid: receipt.subject_oid } : {}),
1642
+ // #101 -- governance-sensing fields; omit-when-null so pre-#101 receipts
1643
+ // produce identical canonical bytes. These were missing from the raw
1644
+ // endpoint but are bound into the signed payload for new receipts.
1645
+ ...(receipt.session_id != null ? { session_id: receipt.session_id } : {}),
1646
+ ...(receipt.policy_versions != null ? { policy_versions: receipt.policy_versions } : {}),
1647
+ ...(receipt.action_payload_hash != null ? { action_payload_hash: receipt.action_payload_hash } : {}),
1648
+ ...(receipt.sequence != null ? { sequence: receipt.sequence } : {}),
1649
+ // #124 -- correlation key fields; omit-when-null so pre-#124 receipts
1650
+ // produce identical canonical bytes.
1651
+ ...(receipt.target_id != null ? { target_id: receipt.target_id } : {}),
1652
+ ...(receipt.target_class != null ? { target_class: receipt.target_class } : {}),
1653
+ ...(receipt.state_sha256 != null ? { state_sha256: receipt.state_sha256 } : {}),
1654
+ };
1655
+ // #253: new receipts bind `degraded` into the signed canonical payload;
1656
+ // legacy receipts did not. Return whichever form actually verifies so the
1657
+ // raw bytes match what was signed — for a new receipt that is the
1658
+ // degraded-bound form, for a legacy receipt the form without `degraded`.
1659
+ const verifies = (p) => {
1660
+ if (!receipt.signature)
1661
+ return false;
1662
+ if (receipt.ml_dsa_signature) {
1663
+ return verifyReceiptSignatureHybrid(p, {
1664
+ ed25519: receipt.signature,
1665
+ ml_dsa: receipt.ml_dsa_signature,
1666
+ }).ok;
1667
+ }
1668
+ // F10: keep this internal helper in lockstep with the route handlers — a
1669
+ // post-K1 Ed25519-only receipt is a downgrade and must NOT verify here either.
1670
+ if (isPostCutoverDowngrade(receipt.recorded_at, false))
1671
+ return false;
1672
+ return verifyReceiptSignature(p, receipt.signature);
1673
+ };
1674
+ const withDegraded = { ...payload, degraded: !!receipt.degraded };
1675
+ const signedPayload = verifies(withDegraded) ? withDegraded
1676
+ : verifies(payload) ? payload
1677
+ : withDegraded;
1678
+ res.setHeader('Content-Type', 'text/plain');
1679
+ res.send(canonicalPayload(signedPayload));
1680
+ });
1681
+ // GET /verify/:receipt_id/anchor — Sprint 3, ticket #195.
1682
+ //
1683
+ // Returns the receipt's Merkle inclusion proof + the batch's anchor data.
1684
+ // Verifiers walk the proof from the leaf_hash up to the merkle_root, then
1685
+ // (when ots_proof is present) verify the merkle_root against the
1686
+ // OpenTimestamps proof using standard `ots verify` tooling.
1687
+ //
1688
+ // Response shape:
1689
+ // 200 — { batch_id, merkle_root, leaf_hash, proof, batch }
1690
+ // 404 — receipt not found, or not yet anchored
1691
+ //
1692
+ // `proof` is an array of { sibling: hex, position: 'L'|'R' } steps. The
1693
+ // verifier algorithm is the RFC 6962 audit-path walk implemented in
1694
+ // src/merkle.ts:verifyAuditPath() — also exposed as a portable function
1695
+ // in @synoi/verify (see package release notes).
1696
+ router.get('/:receipt_id/anchor', (req, res) => {
1697
+ const receipt_id = normalizeReceiptId(req.params.receipt_id);
1698
+ const receipt = receipt_store_1.receiptStore.getReceipt(receipt_id);
1699
+ if (!receipt) {
1700
+ res.status(404).json({ error: 'Receipt not found', receipt_id });
1701
+ return;
1702
+ }
1703
+ if (!receipt.batch_id || !receipt.leaf_hash || !receipt.merkle_proof_json) {
1704
+ res.status(404).json({
1705
+ error: 'Receipt not yet anchored',
1706
+ receipt_id,
1707
+ anchor_status: receipt.anchor_status ?? null,
1708
+ hint: 'Anchor batches run every 10 minutes. Try again after that interval, or check /verify/scheme for details.',
1709
+ });
1710
+ return;
1711
+ }
1712
+ const batch = receipt_store_1.receiptStore.getAnchorBatch(receipt.batch_id);
1713
+ if (!batch) {
1714
+ res.status(404).json({ error: 'Anchor batch missing', receipt_id, batch_id: receipt.batch_id });
1715
+ return;
1716
+ }
1717
+ res.json({
1718
+ receipt_id,
1719
+ batch_id: receipt.batch_id,
1720
+ leaf_hash: receipt.leaf_hash,
1721
+ merkle_root: batch.merkle_root,
1722
+ proof: JSON.parse(receipt.merkle_proof_json),
1723
+ anchored_at: receipt.anchored_at,
1724
+ anchor_status: receipt.anchor_status,
1725
+ // Sprint 5 — SynOI counter-signature on the batch root. Verifiers
1726
+ // fetch synoi.public_key from a published location (e.g.,
1727
+ // https://api.synoi.systems/v1/synoi/pubkey) and verify the signature
1728
+ // against canonical_bundle.
1729
+ synoi: batch.synoi_signature ? {
1730
+ signature: batch.synoi_signature,
1731
+ signer_key_id: batch.synoi_signer_key_id,
1732
+ status: batch.synoi_status,
1733
+ reason: batch.synoi_reason,
1734
+ signed_at: batch.synoi_signed_at,
1735
+ canonical_bundle: batch.synoi_canonical_bundle,
1736
+ } : null,
1737
+ ots: {
1738
+ proof_b64: batch.ots_proof,
1739
+ submitted_at: batch.ots_submitted_at,
1740
+ upgraded_at: batch.ots_upgraded_at,
1741
+ },
1742
+ rekor: {
1743
+ log_id: batch.rekor_log_id,
1744
+ log_index: batch.rekor_log_index,
1745
+ at: batch.rekor_at,
1746
+ },
1747
+ leaf_count: batch.leaf_count,
1748
+ batch_created_at: batch.created_at,
1749
+ verification_hint: 'Verify: (1) start from leaf_hash, apply each proof step (hash with sibling on indicated side), arrive at merkle_root. ' +
1750
+ '(2) When synoi.signature is present, verify Ed25519(synoi.canonical_bundle) against SynOI\'s published public key. ' +
1751
+ '(3) For Bitcoin-anchored verification, run `ots verify <ots.bin>` against the decoded ots.proof_b64. ' +
1752
+ 'Implementation reference: src/merkle.ts:verifyAuditPath() and @synoi/verify.',
1753
+ });
1754
+ });
1755
+ exports.default = router;