@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.
- package/LICENSE +195 -0
- package/README.md +185 -0
- package/bin/synoi-gateway-lite.js +22 -0
- package/dist/cdro-mirror.js +280 -0
- package/dist/daemon.js +194 -0
- package/dist/gap/cited-oracle-inputs.js +283 -0
- package/dist/gap/lite-dashboard.html +502 -0
- package/dist/gap/lite-keystore.js +351 -0
- package/dist/gap/lite-mode.js +31 -0
- package/dist/gap/lite-signing-key.js +362 -0
- package/dist/gap/local-ingest-router.js +1642 -0
- package/dist/gap/operator-enrollment.js +466 -0
- package/dist/gap/store.js +1063 -0
- package/dist/gap/types.js +15 -0
- package/dist/key-provider.js +275 -0
- package/dist/keys.js +250 -0
- package/dist/native-mldsa.js +82 -0
- package/dist/receipt-store.js +801 -0
- package/dist/shadow-mode.js +85 -0
- package/dist/state-drift/quantize.js +107 -0
- package/dist/tenant-store.js +366 -0
- package/dist/verify-router.js +1755 -0
- package/npm-shrinkwrap.json +1400 -0
- package/package.json +46 -0
|
@@ -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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
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;
|