shadow-attest-core 2.0.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/anchors.js ADDED
@@ -0,0 +1,1321 @@
1
+ // packages/attest-core/anchors.js
2
+ // ─────────────────────────────────────────────────────────────────
3
+ // v3 M3 sprint 1 — external anchoring.
4
+ //
5
+ // RFC 3161 Time-Stamp Protocol (TSA) client + verifier. Given a Shadow
6
+ // evidence-bundle batch_root, requestTimestamp() posts a TimeStampReq to
7
+ // a TSA and returns an anchor object embeddable in bundle.external_anchors.
8
+ // verifyRfc3161Anchor() parses the returned TimeStampToken, checks that
9
+ // the messageImprint matches the expected batch_root, extracts genTime.
10
+ //
11
+ // Design constraints:
12
+ // - Zero external dependencies. All ASN.1 DER encoding + decoding is
13
+ // implemented inline against the specific structures RFC 3161 uses
14
+ // (TimeStampReq / TimeStampResp / TSTInfo). This keeps the attest-core
15
+ // zero-LLM-dep contract clean and makes bank-side security review
16
+ // simpler (no third-party crypto library to audit).
17
+ // - The verifier checks structural integrity (messageImprint match +
18
+ // genTime extraction). Sprint 1 does NOT verify the TSA's signature
19
+ // on the token — that requires full CMS SignedData parsing +
20
+ // certificate chain validation. Sprint 2 lands that. Until then, the
21
+ // trust level for a bundle with an anchor is reported as
22
+ // TIME_ANCHORED_STRUCTURAL, not TIME_ANCHORED. This is honest posture,
23
+ // not marketing.
24
+ // - No default TSA URL hard-coded to a specific vendor. The caller
25
+ // picks; documented free-tier options live in the module docstring.
26
+ // Freetsa.org (freetsa.org/tsr) is the widely-tested community
27
+ // default; DigiCert and Sectigo publish TSAs at published URLs. Any
28
+ // RFC 3161-compliant TSA works.
29
+ //
30
+ // References:
31
+ // - RFC 3161 (Time-Stamp Protocol / TSP)
32
+ // - RFC 5652 (Cryptographic Message Syntax)
33
+ // - RFC 5280 (X.509 certificate profile) — for sprint 2 signature check
34
+
35
+ import { createHash, createPublicKey, verify as cryptoVerify, X509Certificate } from "node:crypto";
36
+
37
+ // ── Trust-level enum ──────────────────────────────────────────
38
+
39
+ // The verifier reports one of these on every bundle it inspects. Never
40
+ // present SELF_SIGNED as more than it is; the language discipline in
41
+ // docs/AUTONOMOUS_SESSION_RULES.md rule 3 applies here transitively.
42
+ export const TRUST_LEVELS = Object.freeze({
43
+ SELF_SIGNED: "SELF_SIGNED",
44
+ // Sprint 1 exits: chain intact + at least one RFC 3161 anchor whose
45
+ // messageImprint matches the bundle's batch_root. Structural check
46
+ // only; TSA signature not yet verified.
47
+ TIME_ANCHORED_STRUCTURAL: "TIME_ANCHORED_STRUCTURAL",
48
+ // Sprint 2 target: chain intact + RFC 3161 anchor whose CMS SignedData
49
+ // signature verifies against a trusted CA chain. Not exposed until
50
+ // sprint 2 ships; declared here so callers can pattern-match on the
51
+ // full enum today.
52
+ TIME_ANCHORED: "TIME_ANCHORED",
53
+ // Sprint 3 exits: chain intact + Rekor entry body's payloadHash matches
54
+ // the bundle's batch_root. Log-operator received the entry; inclusion
55
+ // proof + SET signature not yet verified.
56
+ LOG_ANCHORED_STRUCTURAL: "LOG_ANCHORED_STRUCTURAL",
57
+ // Sprint 3 target: chain intact + Rekor inclusion proof against the
58
+ // published tree head + SET signature verified with a caller-supplied
59
+ // Rekor public key. Publicly witnessed — a bundle at this level cannot
60
+ // be silently rewritten by a compromised operator alone.
61
+ LOG_ANCHORED: "LOG_ANCHORED",
62
+ });
63
+
64
+ // Rekor trust rank: SELF < TIME_STRUCTURAL < LOG_STRUCTURAL < TIME_ANCHORED
65
+ // < LOG_ANCHORED. LOG_STRUCTURAL ranks above TIME_STRUCTURAL because a
66
+ // public log accepting an entry is a stronger claim than an unverified
67
+ // TSA response blob (which could be an operator-signed replay).
68
+ const TRUST_RANK = new Map([
69
+ [TRUST_LEVELS.SELF_SIGNED, 0],
70
+ [TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL, 1],
71
+ [TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL, 2],
72
+ [TRUST_LEVELS.TIME_ANCHORED, 3],
73
+ [TRUST_LEVELS.LOG_ANCHORED, 4],
74
+ ]);
75
+
76
+ export function trustLevelRank(level) {
77
+ return TRUST_RANK.get(level) ?? 0;
78
+ }
79
+
80
+ // ── ASN.1 DER helpers ─────────────────────────────────────────
81
+
82
+ // DER tag constants for the subset RFC 3161 requires.
83
+ const TAG_INTEGER = 0x02;
84
+ const TAG_BIT_STRING = 0x03;
85
+ const TAG_OCTET_STRING = 0x04;
86
+ const TAG_NULL = 0x05;
87
+ const TAG_OID = 0x06;
88
+ const TAG_GENERALIZED_TIME = 0x18;
89
+ const TAG_SEQUENCE = 0x30;
90
+ const TAG_SET = 0x31;
91
+
92
+ // OIDs used in RFC 3161 + related CMS structures.
93
+ const OID_SHA256 = "2.16.840.1.101.3.4.2.1";
94
+ const OID_SHA1 = "1.3.14.3.2.26";
95
+ const OID_SIGNED_DATA = "1.2.840.113549.1.7.2";
96
+ const OID_TSTINFO = "1.2.840.113549.1.9.16.1.4";
97
+
98
+ const HASH_ALG_BY_OID = new Map([
99
+ [OID_SHA256, { name: "sha256", digestLen: 32 }],
100
+ [OID_SHA1, { name: "sha1", digestLen: 20 }],
101
+ ]);
102
+ const OID_BY_HASH_ALG = new Map([
103
+ ["sha256", OID_SHA256],
104
+ ["sha1", OID_SHA1],
105
+ ]);
106
+
107
+ // CMS signed-attribute OIDs (RFC 5652 §11).
108
+ const OID_CONTENT_TYPE_ATTR = "1.2.840.113549.1.9.3";
109
+ const OID_MESSAGE_DIGEST_ATTR = "1.2.840.113549.1.9.4";
110
+ const OID_SIGNING_TIME_ATTR = "1.2.840.113549.1.9.5";
111
+
112
+ // Signature algorithm OIDs the TSA might use to sign its token.
113
+ // Node's crypto.verify() infers the algorithm class from the key object;
114
+ // we pass the digest name it expects (e.g. "sha256"). Ed25519 is a special
115
+ // case — its verify call uses `null` as the digest name.
116
+ const SIG_ALG_HANDLERS = new Map([
117
+ // RSA-SSA PKCS#1 v1.5
118
+ ["1.2.840.113549.1.1.11", { digest: "sha256", kind: "rsa" }], // sha256WithRSAEncryption
119
+ ["1.2.840.113549.1.1.12", { digest: "sha384", kind: "rsa" }], // sha384WithRSAEncryption
120
+ ["1.2.840.113549.1.1.13", { digest: "sha512", kind: "rsa" }], // sha512WithRSAEncryption
121
+ ["1.2.840.113549.1.1.5", { digest: "sha1", kind: "rsa" }], // sha1WithRSAEncryption (legacy)
122
+ ["1.2.840.113549.1.1.1", { digest: null, kind: "rsa" }], // rsaEncryption — digest per signedAttrs
123
+ // ECDSA
124
+ ["1.2.840.10045.4.3.2", { digest: "sha256", kind: "ecdsa" }], // ecdsa-with-SHA256
125
+ ["1.2.840.10045.4.3.3", { digest: "sha384", kind: "ecdsa" }], // ecdsa-with-SHA384
126
+ ["1.2.840.10045.4.3.4", { digest: "sha512", kind: "ecdsa" }], // ecdsa-with-SHA512
127
+ // EdDSA
128
+ ["1.3.101.112", { digest: null, kind: "ed25519" }],
129
+ ]);
130
+
131
+ /**
132
+ * Encode a non-negative integer as a DER-encoded length prefix.
133
+ * Short form (0-127) is one byte; long form is 0x80 | N followed by N
134
+ * big-endian bytes.
135
+ */
136
+ function derEncodeLength(n) {
137
+ if (n < 0) throw new Error("derEncodeLength: negative");
138
+ if (n < 128) return Buffer.from([n]);
139
+ const bytes = [];
140
+ let x = n;
141
+ while (x > 0) { bytes.unshift(x & 0xff); x >>>= 8; }
142
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
143
+ }
144
+
145
+ /**
146
+ * Encode a single DER TLV given tag + payload bytes.
147
+ */
148
+ function derEncodeTLV(tag, payload) {
149
+ return Buffer.concat([Buffer.from([tag]), derEncodeLength(payload.length), payload]);
150
+ }
151
+
152
+ /**
153
+ * Encode a positive integer as INTEGER (two's-complement, minimal, with
154
+ * a leading 0x00 pad if the high bit would otherwise be set).
155
+ */
156
+ function derEncodeInteger(n) {
157
+ if (n < 0) throw new Error("derEncodeInteger: negative not supported");
158
+ const bytes = [];
159
+ if (n === 0) bytes.push(0);
160
+ else {
161
+ let x = n;
162
+ while (x > 0) { bytes.unshift(x & 0xff); x = Math.floor(x / 256); }
163
+ if (bytes[0] & 0x80) bytes.unshift(0);
164
+ }
165
+ return derEncodeTLV(TAG_INTEGER, Buffer.from(bytes));
166
+ }
167
+
168
+ /**
169
+ * Encode a dotted OID string as OBJECT IDENTIFIER TLV.
170
+ */
171
+ function derEncodeOid(oid) {
172
+ const parts = oid.split(".").map(Number);
173
+ if (parts.length < 2) throw new Error(`derEncodeOid: malformed "${oid}"`);
174
+ const bytes = [40 * parts[0] + parts[1]];
175
+ for (let i = 2; i < parts.length; i++) {
176
+ let v = parts[i];
177
+ const chunk = [];
178
+ do { chunk.unshift(v & 0x7f); v >>>= 7; } while (v > 0);
179
+ for (let j = 0; j < chunk.length - 1; j++) chunk[j] |= 0x80;
180
+ bytes.push(...chunk);
181
+ }
182
+ return derEncodeTLV(TAG_OID, Buffer.from(bytes));
183
+ }
184
+
185
+ function derEncodeOctetString(bytes) {
186
+ return derEncodeTLV(TAG_OCTET_STRING, Buffer.from(bytes));
187
+ }
188
+
189
+ function derEncodeNull() {
190
+ return Buffer.from([TAG_NULL, 0x00]);
191
+ }
192
+
193
+ function derEncodeSequence(...children) {
194
+ return derEncodeTLV(TAG_SEQUENCE, Buffer.concat(children));
195
+ }
196
+
197
+ /**
198
+ * Encode an AlgorithmIdentifier (SEQUENCE { OID, NULL parameters }).
199
+ */
200
+ function derEncodeAlgorithmIdentifier(oid) {
201
+ return derEncodeSequence(derEncodeOid(oid), derEncodeNull());
202
+ }
203
+
204
+ // ── DER parser (walk-oriented) ────────────────────────────────
205
+
206
+ /**
207
+ * Read a length prefix at `pos`. Returns { length, bytesRead }.
208
+ */
209
+ function derReadLength(buf, pos) {
210
+ const b0 = buf[pos];
211
+ if (b0 < 128) return { length: b0, bytesRead: 1 };
212
+ const n = b0 & 0x7f;
213
+ if (n === 0) throw new Error("derReadLength: indefinite form not supported");
214
+ if (n > 4) throw new Error("derReadLength: length too big");
215
+ let len = 0;
216
+ for (let i = 0; i < n; i++) len = (len << 8) | buf[pos + 1 + i];
217
+ return { length: len, bytesRead: 1 + n };
218
+ }
219
+
220
+ /**
221
+ * Read a TLV at `pos`. Returns { tag, length, contentStart, next }.
222
+ */
223
+ function derReadTLV(buf, pos) {
224
+ const tag = buf[pos];
225
+ const { length, bytesRead } = derReadLength(buf, pos + 1);
226
+ const contentStart = pos + 1 + bytesRead;
227
+ return { tag, length, contentStart, next: contentStart + length };
228
+ }
229
+
230
+ /**
231
+ * Parse an INTEGER at `pos` as a Number (throws if it doesn't fit).
232
+ */
233
+ function derReadInteger(buf, pos) {
234
+ const tlv = derReadTLV(buf, pos);
235
+ if (tlv.tag !== TAG_INTEGER) throw new Error(`derReadInteger: expected INTEGER, got 0x${tlv.tag.toString(16)}`);
236
+ const content = buf.slice(tlv.contentStart, tlv.next);
237
+ if (content.length > 6) throw new Error("derReadInteger: value too big to fit in Number safely");
238
+ let n = 0;
239
+ for (const b of content) n = n * 256 + b;
240
+ return { value: n, next: tlv.next };
241
+ }
242
+
243
+ /**
244
+ * Parse an OID at `pos` as a dotted string.
245
+ */
246
+ function derReadOid(buf, pos) {
247
+ const tlv = derReadTLV(buf, pos);
248
+ if (tlv.tag !== TAG_OID) throw new Error(`derReadOid: expected OID, got 0x${tlv.tag.toString(16)}`);
249
+ const content = buf.slice(tlv.contentStart, tlv.next);
250
+ if (content.length === 0) throw new Error("derReadOid: empty");
251
+ const parts = [Math.floor(content[0] / 40), content[0] % 40];
252
+ let v = 0;
253
+ for (let i = 1; i < content.length; i++) {
254
+ v = (v << 7) | (content[i] & 0x7f);
255
+ if ((content[i] & 0x80) === 0) { parts.push(v); v = 0; }
256
+ }
257
+ return { value: parts.join("."), next: tlv.next };
258
+ }
259
+
260
+ function derReadOctetString(buf, pos) {
261
+ const tlv = derReadTLV(buf, pos);
262
+ if (tlv.tag !== TAG_OCTET_STRING) throw new Error(`derReadOctetString: expected OCTET STRING, got 0x${tlv.tag.toString(16)}`);
263
+ return { value: buf.slice(tlv.contentStart, tlv.next), next: tlv.next };
264
+ }
265
+
266
+ /**
267
+ * Parse a GeneralizedTime at `pos`. Returns ISO 8601 UTC string.
268
+ * Formats accepted: YYYYMMDDHHMMSS[.fff]Z (RFC 5280 §4.1.2.5.2 profile).
269
+ */
270
+ function derReadGeneralizedTime(buf, pos) {
271
+ const tlv = derReadTLV(buf, pos);
272
+ if (tlv.tag !== TAG_GENERALIZED_TIME) throw new Error(`derReadGeneralizedTime: expected GeneralizedTime, got 0x${tlv.tag.toString(16)}`);
273
+ const s = buf.slice(tlv.contentStart, tlv.next).toString("utf8");
274
+ const m = s.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?Z$/);
275
+ if (!m) throw new Error(`derReadGeneralizedTime: malformed "${s}"`);
276
+ const [, y, mo, d, h, mi, se, frac] = m;
277
+ return { value: `${y}-${mo}-${d}T${h}:${mi}:${se}${frac ?? ""}Z`, next: tlv.next };
278
+ }
279
+
280
+ // ── RFC 3161 message construction ─────────────────────────────
281
+
282
+ /**
283
+ * Build a DER-encoded TimeStampReq for a given hex-encoded digest.
284
+ * @param {object} params
285
+ * @param {string} params.digestHex — hex-encoded hash of the batch root
286
+ * @param {string} [params.hashAlgorithm] — "sha256" (default) or "sha1"
287
+ * @param {boolean} [params.certReq] — request the TSA include its cert; default true
288
+ * @returns {Buffer} DER-encoded TimeStampReq
289
+ */
290
+ export function buildTimestampRequest(params) {
291
+ const {
292
+ digestHex,
293
+ hashAlgorithm = "sha256",
294
+ certReq = true,
295
+ } = params ?? {};
296
+ if (!digestHex || typeof digestHex !== "string") {
297
+ throw new Error("buildTimestampRequest: digestHex required (hex string)");
298
+ }
299
+ const oid = OID_BY_HASH_ALG.get(hashAlgorithm);
300
+ if (!oid) throw new Error(`buildTimestampRequest: unsupported hashAlgorithm "${hashAlgorithm}"`);
301
+ const digest = Buffer.from(digestHex, "hex");
302
+ const expectedLen = HASH_ALG_BY_OID.get(oid).digestLen;
303
+ if (digest.length !== expectedLen) {
304
+ throw new Error(`buildTimestampRequest: digest is ${digest.length}B, expected ${expectedLen}B for ${hashAlgorithm}`);
305
+ }
306
+
307
+ const messageImprint = derEncodeSequence(
308
+ derEncodeAlgorithmIdentifier(oid),
309
+ derEncodeOctetString(digest),
310
+ );
311
+
312
+ // Nonce ≥ 0. We use a small deterministic-ish value; TSAs replay the
313
+ // nonce back so the caller can pair request↔response. If a caller
314
+ // wants unpredictability they can pass their own DER-crafted request.
315
+ const nonce = derEncodeInteger(Math.floor(Math.random() * 0xffff) + 1);
316
+ const certReqTLV = Buffer.from([0x01, 0x01, certReq ? 0xff : 0x00]);
317
+
318
+ return derEncodeSequence(
319
+ derEncodeInteger(1), // version
320
+ messageImprint,
321
+ nonce,
322
+ certReqTLV,
323
+ );
324
+ }
325
+
326
+ // ── RFC 3161 response parsing ─────────────────────────────────
327
+
328
+ /**
329
+ * Parse a DER-encoded TimeStampResp. Extracts the top-level status and,
330
+ * if the token is present, the embedded TSTInfo fields relevant to
331
+ * verification (messageImprint + genTime + serialNumber).
332
+ *
333
+ * @param {Buffer|Uint8Array} bytes — DER-encoded TimeStampResp
334
+ * @returns {{status: {statusCode: number, statusString?: string}, tstInfo?: {messageImprintAlgorithm: string, messageImprintHash: Buffer, genTimeIso: string, serialNumber: number, tokenBytes: Buffer}}}
335
+ */
336
+ export function parseTimestampResponse(bytes) {
337
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
338
+ const outer = derReadTLV(buf, 0);
339
+ if (outer.tag !== TAG_SEQUENCE) {
340
+ throw new Error(`parseTimestampResponse: expected SEQUENCE, got 0x${outer.tag.toString(16)}`);
341
+ }
342
+ let pos = outer.contentStart;
343
+
344
+ // PKIStatusInfo ::= SEQUENCE { status INTEGER, ... }
345
+ const statusInfo = derReadTLV(buf, pos);
346
+ if (statusInfo.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: PKIStatusInfo not SEQUENCE");
347
+ const statusInt = derReadInteger(buf, statusInfo.contentStart);
348
+ const status = { statusCode: statusInt.value };
349
+ pos = statusInfo.next;
350
+
351
+ if (pos >= outer.next) return { status };
352
+
353
+ // TimeStampToken is a ContentInfo (SEQUENCE) with contentType = signedData.
354
+ const contentInfo = derReadTLV(buf, pos);
355
+ if (contentInfo.tag !== TAG_SEQUENCE) {
356
+ throw new Error("parseTimestampResponse: TimeStampToken not SEQUENCE");
357
+ }
358
+
359
+ // Walk contentInfo → signedData → encapContentInfo → eContent → TSTInfo.
360
+ // Structure:
361
+ // ContentInfo ::= SEQUENCE {
362
+ // contentType OBJECT IDENTIFIER (signedData 1.2.840.113549.1.7.2),
363
+ // content [0] EXPLICIT ANY
364
+ // }
365
+ // SignedData ::= SEQUENCE {
366
+ // version INTEGER, digestAlgorithms SET, encapContentInfo SEQUENCE, ...
367
+ // }
368
+ // EncapsulatedContentInfo ::= SEQUENCE {
369
+ // eContentType OBJECT IDENTIFIER (id-ct-TSTInfo 1.2.840.113549.1.9.16.1.4),
370
+ // eContent [0] EXPLICIT OCTET STRING (TSTInfo DER-encoded)
371
+ // }
372
+ const contentTypeOid = derReadOid(buf, contentInfo.contentStart);
373
+ if (contentTypeOid.value !== OID_SIGNED_DATA) {
374
+ throw new Error(`parseTimestampResponse: expected signedData OID, got ${contentTypeOid.value}`);
375
+ }
376
+ // content [0] EXPLICIT — tag 0xa0, contains the SignedData SEQUENCE.
377
+ const contentExplicit = derReadTLV(buf, contentTypeOid.next);
378
+ if (contentExplicit.tag !== 0xa0) {
379
+ throw new Error(`parseTimestampResponse: expected [0] EXPLICIT tag, got 0x${contentExplicit.tag.toString(16)}`);
380
+ }
381
+ const signedData = derReadTLV(buf, contentExplicit.contentStart);
382
+ if (signedData.tag !== TAG_SEQUENCE) {
383
+ throw new Error("parseTimestampResponse: SignedData not SEQUENCE");
384
+ }
385
+
386
+ // Walk into SignedData: version, digestAlgorithms, encapContentInfo,
387
+ // [0] IMPLICIT certificates (optional), [1] IMPLICIT crls (optional),
388
+ // signerInfos SET.
389
+ let sdPos = signedData.contentStart;
390
+ // Skip version (INTEGER).
391
+ const sdVersion = derReadTLV(buf, sdPos);
392
+ sdPos = sdVersion.next;
393
+ // Skip digestAlgorithms (SET).
394
+ const sdDigestAlgs = derReadTLV(buf, sdPos);
395
+ sdPos = sdDigestAlgs.next;
396
+
397
+ // encapContentInfo — SEQUENCE { eContentType OID, eContent [0] EXPLICIT OCTET STRING }
398
+ const encap = derReadTLV(buf, sdPos);
399
+ if (encap.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: encapContentInfo not SEQUENCE");
400
+ sdPos = encap.next;
401
+ const eContentType = derReadOid(buf, encap.contentStart);
402
+ if (eContentType.value !== OID_TSTINFO) {
403
+ throw new Error(`parseTimestampResponse: expected TSTInfo OID, got ${eContentType.value}`);
404
+ }
405
+ const eContentExplicit = derReadTLV(buf, eContentType.next);
406
+ if (eContentExplicit.tag !== 0xa0) {
407
+ throw new Error("parseTimestampResponse: expected [0] EXPLICIT for eContent");
408
+ }
409
+ const tstInfoOctet = derReadTLV(buf, eContentExplicit.contentStart);
410
+ if (tstInfoOctet.tag !== TAG_OCTET_STRING) {
411
+ throw new Error("parseTimestampResponse: expected OCTET STRING wrapping TSTInfo");
412
+ }
413
+ const tstInfoBytes = buf.slice(tstInfoOctet.contentStart, tstInfoOctet.next);
414
+ const tokenBytes = buf.slice(contentInfo.contentStart - (contentInfo.contentStart - pos), contentInfo.next);
415
+
416
+ // Extract CMS pieces needed for signature verification. Certificates,
417
+ // CRLs, and signerInfos live after encapContentInfo in SignedData.
418
+ const certificatesDer = [];
419
+ let signerInfoBytes = null;
420
+ while (sdPos < signedData.next) {
421
+ const tlv = derReadTLV(buf, sdPos);
422
+ if (tlv.tag === 0xa0) {
423
+ // [0] IMPLICIT certificates — SET/SEQUENCE OF CertificateChoices.
424
+ // Sprint 4: extract ALL X.509 certificate SEQUENCEs so chain
425
+ // validation can walk leaf → intermediates → root. The first cert
426
+ // is treated as the signer's leaf (sprint 2 behavior preserved via
427
+ // certificatesDer[0]).
428
+ let cpos = tlv.contentStart;
429
+ while (cpos < tlv.next) {
430
+ const cert = derReadTLV(buf, cpos);
431
+ if (cert.tag === TAG_SEQUENCE) {
432
+ certificatesDer.push(buf.slice(cpos, cert.next));
433
+ }
434
+ cpos = cert.next;
435
+ }
436
+ } else if (tlv.tag === 0xa1) {
437
+ // [1] IMPLICIT crls — skip; sprint 2 does not consult CRLs.
438
+ } else if (tlv.tag === TAG_SET) {
439
+ // signerInfos SET OF SignerInfo. We take the first for sprint 2.
440
+ const firstSignerInfo = derReadTLV(buf, tlv.contentStart);
441
+ if (firstSignerInfo.tag === TAG_SEQUENCE) {
442
+ signerInfoBytes = buf.slice(tlv.contentStart, firstSignerInfo.next);
443
+ }
444
+ }
445
+ sdPos = tlv.next;
446
+ }
447
+
448
+ // Parse TSTInfo.
449
+ const tstInfoSeq = derReadTLV(tstInfoBytes, 0);
450
+ if (tstInfoSeq.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: TSTInfo not SEQUENCE");
451
+
452
+ let tpos = tstInfoSeq.contentStart;
453
+ const tVersion = derReadTLV(tstInfoBytes, tpos); tpos = tVersion.next;
454
+ const tPolicyOid = derReadOid(tstInfoBytes, tpos); tpos = tPolicyOid.next;
455
+
456
+ // messageImprint SEQUENCE { hashAlgorithm AlgorithmIdentifier, hashedMessage OCTET STRING }
457
+ const mi = derReadTLV(tstInfoBytes, tpos); tpos = mi.next;
458
+ if (mi.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: messageImprint not SEQUENCE");
459
+ const miAlgSeq = derReadTLV(tstInfoBytes, mi.contentStart);
460
+ const miAlgOid = derReadOid(tstInfoBytes, miAlgSeq.contentStart);
461
+ const miHashInfo = HASH_ALG_BY_OID.get(miAlgOid.value);
462
+ if (!miHashInfo) throw new Error(`parseTimestampResponse: unsupported messageImprint algorithm ${miAlgOid.value}`);
463
+ const miHashOctet = derReadOctetString(tstInfoBytes, miAlgSeq.next);
464
+
465
+ // serialNumber INTEGER
466
+ const serial = derReadInteger(tstInfoBytes, tpos); tpos = serial.next;
467
+
468
+ // genTime GeneralizedTime
469
+ const genTime = derReadGeneralizedTime(tstInfoBytes, tpos); tpos = genTime.next;
470
+
471
+ return {
472
+ status,
473
+ tstInfo: {
474
+ messageImprintAlgorithm: miHashInfo.name,
475
+ messageImprintHash: miHashOctet.value,
476
+ genTimeIso: genTime.value,
477
+ serialNumber: serial.value,
478
+ tokenBytes,
479
+ // Sprint 2 pieces — may be null on synthetic TSRs used in tests.
480
+ tstInfoBytes,
481
+ certificateDer: certificatesDer[0] ?? null,
482
+ certificatesDer,
483
+ signerInfoBytes,
484
+ },
485
+ };
486
+ }
487
+
488
+ // ── CMS SignedData signature verification (sprint 2) ──────────
489
+
490
+ /**
491
+ * Parse a SignerInfo TLV blob and return the fields needed to verify.
492
+ * @param {Buffer} buf — DER SignerInfo SEQUENCE bytes (outer SEQUENCE included)
493
+ * @returns {{digestAlgorithmOid: string, signedAttrsDerForSig: Buffer|null, messageDigestAttr: Buffer|null, signatureAlgorithmOid: string, signature: Buffer}}
494
+ */
495
+ function parseSignerInfo(buf) {
496
+ const outer = derReadTLV(buf, 0);
497
+ if (outer.tag !== TAG_SEQUENCE) throw new Error("parseSignerInfo: outer not SEQUENCE");
498
+ let p = outer.contentStart;
499
+
500
+ // version INTEGER
501
+ const version = derReadTLV(buf, p); p = version.next;
502
+ // sid — either issuerAndSerialNumber SEQUENCE OR [0] IMPLICIT subjectKeyIdentifier.
503
+ const sid = derReadTLV(buf, p); p = sid.next;
504
+ // digestAlgorithm AlgorithmIdentifier
505
+ const dAlg = derReadTLV(buf, p); p = dAlg.next;
506
+ const dAlgOid = derReadOid(buf, dAlg.contentStart);
507
+
508
+ // signedAttrs [0] IMPLICIT SET OF Attribute — OPTIONAL
509
+ let signedAttrsDerForSig = null;
510
+ let messageDigestAttr = null;
511
+ const tlv1 = derReadTLV(buf, p);
512
+ if (tlv1.tag === 0xa0) {
513
+ // Re-encode as SET (0x31) with the same length + content per RFC 5652 §5.4
514
+ // "The IMPLICIT [0] tag in the signedAttrs is not used for the DER encoding,
515
+ // rather an EXPLICIT SET OF tag is used."
516
+ const setDer = Buffer.concat([
517
+ Buffer.from([TAG_SET]),
518
+ derEncodeLength(tlv1.length),
519
+ buf.slice(tlv1.contentStart, tlv1.next),
520
+ ]);
521
+ signedAttrsDerForSig = setDer;
522
+
523
+ // Walk attributes to find messageDigest.
524
+ let apos = tlv1.contentStart;
525
+ while (apos < tlv1.next) {
526
+ const attr = derReadTLV(buf, apos);
527
+ if (attr.tag !== TAG_SEQUENCE) { apos = attr.next; continue; }
528
+ const attrOid = derReadOid(buf, attr.contentStart);
529
+ const attrValues = derReadTLV(buf, attrOid.next);
530
+ if (attrValues.tag !== TAG_SET) { apos = attr.next; continue; }
531
+ if (attrOid.value === OID_MESSAGE_DIGEST_ATTR) {
532
+ const digestOctet = derReadOctetString(buf, attrValues.contentStart);
533
+ messageDigestAttr = digestOctet.value;
534
+ }
535
+ apos = attr.next;
536
+ }
537
+ p = tlv1.next;
538
+ }
539
+
540
+ // signatureAlgorithm AlgorithmIdentifier
541
+ const sAlg = derReadTLV(buf, p); p = sAlg.next;
542
+ const sAlgOid = derReadOid(buf, sAlg.contentStart);
543
+
544
+ // signature OCTET STRING
545
+ const sig = derReadOctetString(buf, p);
546
+
547
+ return {
548
+ digestAlgorithmOid: dAlgOid.value,
549
+ signedAttrsDerForSig,
550
+ messageDigestAttr,
551
+ signatureAlgorithmOid: sAlgOid.value,
552
+ signature: sig.value,
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Verify the CMS SignedData signature over a parsed TSTInfo (eContent),
558
+ * using the certificate embedded in the CMS structure. Returns { ok, reason? }.
559
+ *
560
+ * Scope note (sprint 2): the certificate is used AS-IS from the CMS; this
561
+ * function does NOT walk a trust chain to a root CA. Real deployments
562
+ * should pair this with an operator-managed trust store; see docs/THREAT_MODEL.md
563
+ * §6.3 for the honest posture around what a valid CMS signature does and
564
+ * does not imply.
565
+ *
566
+ * Sprint 4: caTrustStorePem[] enables real chain validation. When provided,
567
+ * this function walks leaf → embedded intermediates → a caller-supplied root
568
+ * CA and reports caChainValidated:true only if a full chain terminates at a
569
+ * trust-store root. Without a trust store, caChainValidated is null (the
570
+ * sprint 2 posture — signature verified against the embedded cert whose
571
+ * ownership is unproven).
572
+ *
573
+ * @param {object} params
574
+ * @param {Buffer} params.eContentBytes — the DER-encoded TSTInfo bytes
575
+ * @param {Buffer} [params.certificateDer] — legacy: the signer's X.509 leaf DER (sprint 2)
576
+ * @param {Buffer[]} [params.certificatesDer] — sprint 4: all embedded certs; [0] is the leaf
577
+ * @param {Buffer} params.signerInfoBytes — the SignerInfo SEQUENCE bytes
578
+ * @param {string[]} [params.caTrustStorePem] — sprint 4: PEM-encoded root CA certs
579
+ * @returns {{ok: boolean, reason?: string, signerSubject?: string, caChainValidated?: boolean|null, chainFailReason?: string, chainAnchorSubject?: string}}
580
+ */
581
+ export function verifyCmsSignature(params) {
582
+ const {
583
+ eContentBytes,
584
+ certificateDer,
585
+ certificatesDer,
586
+ signerInfoBytes,
587
+ caTrustStorePem,
588
+ } = params ?? {};
589
+ if (!eContentBytes) return { ok: false, reason: "eContentBytes required" };
590
+ const allCerts = Array.isArray(certificatesDer) && certificatesDer.length > 0
591
+ ? certificatesDer
592
+ : (certificateDer ? [certificateDer] : []);
593
+ if (allCerts.length === 0) return { ok: false, reason: "certificateDer or certificatesDer required" };
594
+ if (!signerInfoBytes) return { ok: false, reason: "signerInfoBytes required" };
595
+
596
+ let cert;
597
+ try {
598
+ cert = new X509Certificate(allCerts[0]);
599
+ } catch (err) {
600
+ return { ok: false, reason: `certificate parse failed: ${err.message}` };
601
+ }
602
+ const publicKey = cert.publicKey;
603
+
604
+ let signerInfo;
605
+ try {
606
+ signerInfo = parseSignerInfo(signerInfoBytes);
607
+ } catch (err) {
608
+ return { ok: false, reason: `SignerInfo parse failed: ${err.message}` };
609
+ }
610
+
611
+ const sigAlg = SIG_ALG_HANDLERS.get(signerInfo.signatureAlgorithmOid);
612
+ if (!sigAlg) return { ok: false, reason: `unsupported signatureAlgorithm ${signerInfo.signatureAlgorithmOid}` };
613
+ const digestInfo = HASH_ALG_BY_OID.get(
614
+ // Map the signer's digestAlgorithm OID.
615
+ signerInfo.digestAlgorithmOid,
616
+ );
617
+ if (!digestInfo) return { ok: false, reason: `unsupported digestAlgorithm ${signerInfo.digestAlgorithmOid}` };
618
+
619
+ // If signedAttrs is present, verify that the messageDigest attribute
620
+ // equals the hash of eContent, then sign over the DER-encoded SET
621
+ // (with tag 0x31, not the IMPLICIT [0] 0xa0).
622
+ let dataToVerify;
623
+ if (signerInfo.signedAttrsDerForSig) {
624
+ if (!signerInfo.messageDigestAttr) {
625
+ return { ok: false, reason: "signedAttrs present but messageDigest attribute missing" };
626
+ }
627
+ const expectedDigest = createHash(digestInfo.name).update(eContentBytes).digest();
628
+ if (!expectedDigest.equals(signerInfo.messageDigestAttr)) {
629
+ return { ok: false, reason: "signedAttrs.messageDigest does not match hash(eContent)" };
630
+ }
631
+ dataToVerify = signerInfo.signedAttrsDerForSig;
632
+ } else {
633
+ // No signedAttrs — sign eContent directly. This is rare for TSAs
634
+ // but allowed by RFC 5652.
635
+ dataToVerify = Buffer.from(eContentBytes);
636
+ }
637
+
638
+ const digestName = sigAlg.digest;
639
+ let sigOk;
640
+ try {
641
+ sigOk = cryptoVerify(
642
+ // Node picks the algorithm from the key type + this digest name.
643
+ // Ed25519 requires the digest name to be null.
644
+ digestName,
645
+ dataToVerify,
646
+ publicKey,
647
+ signerInfo.signature,
648
+ );
649
+ } catch (err) {
650
+ return { ok: false, reason: `cryptoVerify threw: ${err.message}` };
651
+ }
652
+ if (!sigOk) return { ok: false, reason: "CMS signature verification failed" };
653
+
654
+ // Sprint 4: optional CA chain validation. If a trust store is not
655
+ // supplied, caChainValidated is reported as null so callers can tell
656
+ // "operator did not opt in" apart from "opted in and passed".
657
+ let caChainValidated = null;
658
+ let chainFailReason;
659
+ let chainAnchorSubject;
660
+ if (Array.isArray(caTrustStorePem) && caTrustStorePem.length > 0) {
661
+ const chainResult = validateCmsCertChain({
662
+ leafCert: cert,
663
+ intermediateDers: allCerts.slice(1),
664
+ trustStorePems: caTrustStorePem,
665
+ });
666
+ caChainValidated = chainResult.ok;
667
+ if (chainResult.ok) {
668
+ chainAnchorSubject = chainResult.anchorSubject;
669
+ } else {
670
+ chainFailReason = chainResult.reason;
671
+ }
672
+ }
673
+
674
+ return {
675
+ ok: true,
676
+ signerSubject: cert.subject,
677
+ caChainValidated,
678
+ ...(chainAnchorSubject ? { chainAnchorSubject } : {}),
679
+ ...(chainFailReason ? { chainFailReason } : {}),
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Walk an X.509 certificate chain from a leaf, through embedded
685
+ * intermediates, terminating at a caller-supplied trust-store root.
686
+ *
687
+ * Node's X509Certificate.checkIssued() confirms name-chaining (issuer of
688
+ * the child equals subject of the parent); X509Certificate.verify(pub)
689
+ * confirms the child's signature was produced with the parent's key.
690
+ * Together those match the sub-check that RFC 5280 §6 formalizes.
691
+ *
692
+ * Sprint 4 scope: name-chain + signature-chain + validity-period check.
693
+ * Out of scope for this sprint: CRL/OCSP revocation, name constraints,
694
+ * policy processing, keyUsage/extKeyUsage enforcement. Callers who need
695
+ * revocation should pair this with an OCSP responder query at the sealing
696
+ * side.
697
+ *
698
+ * @param {object} params
699
+ * @param {X509Certificate} params.leafCert
700
+ * @param {Buffer[]} params.intermediateDers
701
+ * @param {string[]} params.trustStorePems
702
+ * @returns {{ok: true, anchorSubject: string, chainLength: number}|{ok: false, reason: string}}
703
+ */
704
+ export function validateCmsCertChain(params) {
705
+ const { leafCert, intermediateDers = [], trustStorePems } = params ?? {};
706
+ if (!leafCert) return { ok: false, reason: "leafCert required" };
707
+ if (!Array.isArray(trustStorePems) || trustStorePems.length === 0) {
708
+ return { ok: false, reason: "trustStorePems must be a non-empty array" };
709
+ }
710
+
711
+ let intermediates;
712
+ try {
713
+ intermediates = intermediateDers.map((d) => new X509Certificate(d));
714
+ } catch (err) {
715
+ return { ok: false, reason: `intermediate parse failed: ${err.message}` };
716
+ }
717
+ let roots;
718
+ try {
719
+ roots = trustStorePems.map((pem) => new X509Certificate(pem));
720
+ } catch (err) {
721
+ return { ok: false, reason: `trust-store parse failed: ${err.message}` };
722
+ }
723
+
724
+ const now = Date.now();
725
+ const seenFingerprints = new Set();
726
+ let current = leafCert;
727
+ let chainLength = 1;
728
+
729
+ for (let step = 0; step < 16; step++) {
730
+ const fp = current.fingerprint256;
731
+ if (seenFingerprints.has(fp)) {
732
+ return { ok: false, reason: "certificate loop detected" };
733
+ }
734
+ seenFingerprints.add(fp);
735
+
736
+ // Validity window check on every cert in the chain.
737
+ const notBefore = Date.parse(current.validFrom);
738
+ const notAfter = Date.parse(current.validTo);
739
+ if (Number.isFinite(notBefore) && now < notBefore) {
740
+ return { ok: false, reason: `cert not yet valid: ${current.subject}` };
741
+ }
742
+ if (Number.isFinite(notAfter) && now > notAfter) {
743
+ return { ok: false, reason: `cert expired: ${current.subject}` };
744
+ }
745
+
746
+ // Termination: current cert issued by a root in the trust store.
747
+ for (const root of roots) {
748
+ // Self-signed root case: current fingerprint matches a trusted root.
749
+ if (root.fingerprint256 === current.fingerprint256) {
750
+ return { ok: true, anchorSubject: root.subject, chainLength };
751
+ }
752
+ if (current.checkIssued(root)) {
753
+ let signatureOk = false;
754
+ try { signatureOk = current.verify(root.publicKey); } catch { /* fall through */ }
755
+ if (signatureOk) {
756
+ return { ok: true, anchorSubject: root.subject, chainLength: chainLength + 1 };
757
+ }
758
+ }
759
+ }
760
+
761
+ // Walk to an embedded intermediate. Skip the current cert itself in
762
+ // case the leaf appears in the intermediates array too.
763
+ let parent = null;
764
+ for (const inter of intermediates) {
765
+ if (inter.fingerprint256 === current.fingerprint256) continue;
766
+ if (current.checkIssued(inter)) {
767
+ let signatureOk = false;
768
+ try { signatureOk = current.verify(inter.publicKey); } catch { /* fall through */ }
769
+ if (signatureOk) { parent = inter; break; }
770
+ }
771
+ }
772
+ if (!parent) {
773
+ return { ok: false, reason: `no trusted issuer for "${current.subject}"` };
774
+ }
775
+ current = parent;
776
+ chainLength++;
777
+ }
778
+ return { ok: false, reason: "chain exceeded 16 steps" };
779
+ }
780
+
781
+ // ── HTTP client (Node built-in fetch) ─────────────────────────
782
+
783
+ /**
784
+ * Post a TimeStampReq to a TSA and return a Shadow anchor object suitable
785
+ * for embedding in bundle.external_anchors[].
786
+ *
787
+ * @param {object} params
788
+ * @param {string} params.batchRootHex — the bundle.batch_root (hex sha256)
789
+ * @param {string} params.tsaUrl — TSA endpoint URL (must accept application/timestamp-query)
790
+ * @param {string} [params.hashAlgorithm] — "sha256" default
791
+ * @param {number} [params.timeoutMs] — default 15000
792
+ * @returns {Promise<{kind: "rfc3161-tsa", batch_root: string, anchor_ref: string, anchored_at_utc: string}>}
793
+ */
794
+ export async function requestTimestamp(params) {
795
+ const {
796
+ batchRootHex,
797
+ tsaUrl,
798
+ hashAlgorithm = "sha256",
799
+ timeoutMs = 15000,
800
+ } = params ?? {};
801
+ if (!batchRootHex) throw new Error("requestTimestamp: batchRootHex required");
802
+ if (!tsaUrl) throw new Error("requestTimestamp: tsaUrl required");
803
+
804
+ // The messageImprint is a hash of the bundle's own batch_root (which is
805
+ // already a sha256). This produces sha256(sha256(batch)), which is what
806
+ // the TSA signs. On verify we re-hash the batch_root the same way and
807
+ // compare against the token's messageImprint.
808
+ const digest = createHash(hashAlgorithm).update(Buffer.from(batchRootHex, "hex")).digest();
809
+ const req = buildTimestampRequest({ digestHex: digest.toString("hex"), hashAlgorithm });
810
+
811
+ const controller = new AbortController();
812
+ const to = setTimeout(() => controller.abort(), timeoutMs);
813
+ let res;
814
+ try {
815
+ res = await fetch(tsaUrl, {
816
+ method: "POST",
817
+ headers: { "Content-Type": "application/timestamp-query" },
818
+ body: req,
819
+ signal: controller.signal,
820
+ });
821
+ } finally {
822
+ clearTimeout(to);
823
+ }
824
+
825
+ if (!res.ok) {
826
+ throw new Error(`requestTimestamp: TSA returned HTTP ${res.status} from ${tsaUrl}`);
827
+ }
828
+ const respBytes = Buffer.from(await res.arrayBuffer());
829
+ const parsed = parseTimestampResponse(respBytes);
830
+ if (parsed.status.statusCode !== 0 && parsed.status.statusCode !== 1) {
831
+ // 0 = granted, 1 = grantedWithMods (both are success per RFC 3161).
832
+ throw new Error(`requestTimestamp: TSA statusCode ${parsed.status.statusCode}`);
833
+ }
834
+ if (!parsed.tstInfo) {
835
+ throw new Error("requestTimestamp: response missing timeStampToken");
836
+ }
837
+
838
+ return {
839
+ kind: "rfc3161-tsa",
840
+ batch_root: batchRootHex,
841
+ anchor_ref: respBytes.toString("base64url"),
842
+ anchored_at_utc: parsed.tstInfo.genTimeIso,
843
+ };
844
+ }
845
+
846
+ // ── Anchor verification ───────────────────────────────────────
847
+
848
+ /**
849
+ * Verify an RFC 3161 anchor against an expected bundle batch_root.
850
+ *
851
+ * Sprint 1 default (verifyCms: false): structural verification only —
852
+ * checks that the messageImprint in the parsed TSTInfo equals
853
+ * sha256(batch_root_bytes) (or sha1, per the anchor's algorithm), and
854
+ * extracts genTime. Reports trustLevel TIME_ANCHORED_STRUCTURAL.
855
+ *
856
+ * Sprint 2 opt-in (verifyCms: true): additionally verifies the CMS
857
+ * SignedData signature over the TSTInfo using the certificate embedded
858
+ * in the token. Reports trustLevel TIME_ANCHORED on success; falls back
859
+ * to TIME_ANCHORED_STRUCTURAL on CMS-verify failure with a diagnostic
860
+ * reason. Does NOT walk a certificate trust chain to a root CA — the
861
+ * caller's operator must pair this with a trust store; see
862
+ * docs/THREAT_MODEL.md §6.3.
863
+ *
864
+ * @param {object} params
865
+ * @param {object} params.anchor — one entry from bundle.external_anchors
866
+ * @param {string} params.expectedBatchRootHex — bundle.batch_root
867
+ * @param {boolean} [params.verifyCms] — attempt full CMS signature verify
868
+ * @returns {{ok: true, genTimeIso: string, trustLevel: string, cmsSignerSubject?: string, cmsFailReason?: string} | {ok: false, reason: string}}
869
+ */
870
+ export function verifyRfc3161Anchor(params) {
871
+ const { anchor, expectedBatchRootHex, verifyCms = false, caTrustStorePem } = params ?? {};
872
+ if (!anchor) return { ok: false, reason: "anchor required" };
873
+ if (anchor.kind !== "rfc3161-tsa") return { ok: false, reason: `wrong kind "${anchor.kind}"` };
874
+ if (!expectedBatchRootHex) return { ok: false, reason: "expectedBatchRootHex required" };
875
+ if (anchor.batch_root && anchor.batch_root !== expectedBatchRootHex) {
876
+ return { ok: false, reason: "anchor.batch_root does not match bundle.batch_root" };
877
+ }
878
+ if (!anchor.anchor_ref) return { ok: false, reason: "anchor.anchor_ref missing" };
879
+
880
+ let respBytes;
881
+ try {
882
+ respBytes = Buffer.from(anchor.anchor_ref, "base64url");
883
+ } catch (err) {
884
+ return { ok: false, reason: `anchor_ref base64url decode failed: ${err.message}` };
885
+ }
886
+
887
+ let parsed;
888
+ try {
889
+ parsed = parseTimestampResponse(respBytes);
890
+ } catch (err) {
891
+ return { ok: false, reason: `TimeStampResp parse failed: ${err.message}` };
892
+ }
893
+ if (parsed.status.statusCode !== 0 && parsed.status.statusCode !== 1) {
894
+ return { ok: false, reason: `TSA statusCode ${parsed.status.statusCode}` };
895
+ }
896
+ if (!parsed.tstInfo) return { ok: false, reason: "response missing timeStampToken" };
897
+
898
+ const expectedDigest = createHash(parsed.tstInfo.messageImprintAlgorithm)
899
+ .update(Buffer.from(expectedBatchRootHex, "hex"))
900
+ .digest();
901
+ if (!expectedDigest.equals(parsed.tstInfo.messageImprintHash)) {
902
+ return { ok: false, reason: "messageImprint does not match sha256(batch_root)" };
903
+ }
904
+
905
+ // Sprint 2: attempt CMS SignedData signature verification if requested
906
+ // AND the token contains the pieces needed (certificate + SignerInfo).
907
+ // Fall back to TIME_ANCHORED_STRUCTURAL on failure so the caller sees a
908
+ // clear diagnostic without the top-level verify going false.
909
+ if (verifyCms) {
910
+ if (!parsed.tstInfo.certificateDer || !parsed.tstInfo.signerInfoBytes) {
911
+ return {
912
+ ok: true,
913
+ genTimeIso: parsed.tstInfo.genTimeIso,
914
+ trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
915
+ cmsFailReason: "token missing embedded certificate or SignerInfo",
916
+ };
917
+ }
918
+ const cmsResult = verifyCmsSignature({
919
+ eContentBytes: parsed.tstInfo.tstInfoBytes,
920
+ certificatesDer: parsed.tstInfo.certificatesDer,
921
+ signerInfoBytes: parsed.tstInfo.signerInfoBytes,
922
+ caTrustStorePem,
923
+ });
924
+ if (!cmsResult.ok) {
925
+ return {
926
+ ok: true,
927
+ genTimeIso: parsed.tstInfo.genTimeIso,
928
+ trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
929
+ cmsFailReason: cmsResult.reason,
930
+ };
931
+ }
932
+ // Sprint 4: if a trust store was provided AND chain failed, honestly
933
+ // demote to STRUCTURAL with a chainFailReason. Sig verified against
934
+ // an untrusted cert is not stronger than "TSA-shaped bytes matched".
935
+ if (Array.isArray(caTrustStorePem) && caTrustStorePem.length > 0
936
+ && cmsResult.caChainValidated !== true) {
937
+ return {
938
+ ok: true,
939
+ genTimeIso: parsed.tstInfo.genTimeIso,
940
+ trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
941
+ cmsFailReason: cmsResult.chainFailReason ?? "CA chain validation failed",
942
+ };
943
+ }
944
+ return {
945
+ ok: true,
946
+ genTimeIso: parsed.tstInfo.genTimeIso,
947
+ trustLevel: TRUST_LEVELS.TIME_ANCHORED,
948
+ cmsSignerSubject: cmsResult.signerSubject,
949
+ caChainValidated: cmsResult.caChainValidated,
950
+ ...(cmsResult.chainAnchorSubject ? { chainAnchorSubject: cmsResult.chainAnchorSubject } : {}),
951
+ };
952
+ }
953
+
954
+ return {
955
+ ok: true,
956
+ genTimeIso: parsed.tstInfo.genTimeIso,
957
+ trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
958
+ };
959
+ }
960
+
961
+ // ─────────────────────────────────────────────────────────────────
962
+ // v3 M3 sprint 3 — Sigstore Rekor transparency-log adapter.
963
+ // ─────────────────────────────────────────────────────────────────
964
+ //
965
+ // Rekor is a public append-only transparency log for signed artifacts
966
+ // (sigstore.dev). Submitting a Shadow evidence bundle's batch_root + signature
967
+ // to Rekor yields an entry that is publicly witnessed: even a compromised
968
+ // Shadow operator cannot silently rewrite history without also compromising
969
+ // the Rekor log itself (which is monitored by third parties).
970
+ //
971
+ // Structural verification (LOG_ANCHORED_STRUCTURAL): parse the Rekor entry
972
+ // body and confirm its embedded payload hash matches the bundle's batch_root.
973
+ // This proves "the operator submitted a matching hash to a log endpoint";
974
+ // it does NOT prove inclusion in the actual tree or authenticity of the
975
+ // response.
976
+ //
977
+ // Full verification (LOG_ANCHORED): also verify (a) the RFC 9162 inclusion
978
+ // proof against the tree root hash returned in the response, and (b) the
979
+ // Signed Entry Timestamp (SET) — a Rekor-signed statement binding
980
+ // (body, logID, logIndex, integratedTime) — using a caller-supplied Rekor
981
+ // public key. Fresh Rekor pubkey: `curl https://rekor.sigstore.dev/api/v1/log/publicKey`.
982
+ //
983
+ // Design constraints (same discipline as sprint 1/2):
984
+ // - Zero external dependencies. All Merkle math + JSON canonicalization
985
+ // implemented inline.
986
+ // - No hard-coded Rekor pubkey. Rotation is a real operational risk;
987
+ // caller must supply the current pubkey PEM. verifyRekorAnchor with
988
+ // mode:"full" throws a clear error if pubkey missing.
989
+ // - The client function submitRekorEntry does network I/O; callers must
990
+ // opt in. Tests use captured fixtures rather than live network calls
991
+ // (parallel to the freetsa test policy).
992
+ //
993
+ // References:
994
+ // - RFC 9162 Certificate Transparency 2.0 (tree hash format + inclusion proof)
995
+ // - Sigstore Rekor API v1 (github.com/sigstore/rekor/blob/main/openapi.yaml)
996
+
997
+ /**
998
+ * Build the canonicalized hashedrekord v0.0.1 entry body for a bundle.
999
+ *
1000
+ * @param {object} params
1001
+ * @param {string} params.batchRootHex — bundle.batch_root (hex).
1002
+ * @param {string} params.signatureBase64 — bundle signature (base64).
1003
+ * @param {string} params.publicKeyPem — Ed25519 public key PEM used to sign.
1004
+ * @returns {{body: string, hashedrekord: object}} — body is base64 of canonical JSON.
1005
+ */
1006
+ export function buildRekorHashedrekordEntry({ batchRootHex, signatureBase64, publicKeyPem }) {
1007
+ if (!batchRootHex || !signatureBase64 || !publicKeyPem) {
1008
+ throw new Error("buildRekorHashedrekordEntry: batchRootHex + signatureBase64 + publicKeyPem all required");
1009
+ }
1010
+ const hashedrekord = {
1011
+ apiVersion: "0.0.1",
1012
+ kind: "hashedrekord",
1013
+ spec: {
1014
+ data: {
1015
+ hash: { algorithm: "sha256", value: batchRootHex.toLowerCase() },
1016
+ },
1017
+ signature: {
1018
+ content: signatureBase64,
1019
+ publicKey: { content: Buffer.from(publicKeyPem, "utf8").toString("base64") },
1020
+ },
1021
+ },
1022
+ };
1023
+ const canonical = canonicalizeJson(hashedrekord);
1024
+ return { body: Buffer.from(canonical, "utf8").toString("base64"), hashedrekord };
1025
+ }
1026
+
1027
+ /**
1028
+ * Submit a hashedrekord entry to a Rekor instance. Returns the anchor object
1029
+ * ready to embed in bundle.external_anchors.
1030
+ *
1031
+ * NETWORK — opt-in. Callers, not tests, invoke this.
1032
+ *
1033
+ * @param {object} params
1034
+ * @param {string} params.batchRootHex
1035
+ * @param {string} params.signatureBase64
1036
+ * @param {string} params.publicKeyPem — Ed25519 signer key (bundle signer).
1037
+ * @param {string} [params.rekorUrl] — default "https://rekor.sigstore.dev"
1038
+ * @param {number} [params.timeoutMs] — default 15000
1039
+ * @returns {Promise<object>} — anchor object with kind:"rekor"
1040
+ */
1041
+ export async function submitRekorEntry({
1042
+ batchRootHex,
1043
+ signatureBase64,
1044
+ publicKeyPem,
1045
+ rekorUrl = "https://rekor.sigstore.dev",
1046
+ timeoutMs = 15000,
1047
+ }) {
1048
+ const { hashedrekord } = buildRekorHashedrekordEntry({
1049
+ batchRootHex,
1050
+ signatureBase64,
1051
+ publicKeyPem,
1052
+ });
1053
+ const abort = new AbortController();
1054
+ const t = setTimeout(() => abort.abort(), timeoutMs);
1055
+ let resp;
1056
+ try {
1057
+ resp = await fetch(`${rekorUrl.replace(/\/$/, "")}/api/v1/log/entries`, {
1058
+ method: "POST",
1059
+ headers: { "content-type": "application/json", accept: "application/json" },
1060
+ body: JSON.stringify(hashedrekord),
1061
+ signal: abort.signal,
1062
+ });
1063
+ } finally {
1064
+ clearTimeout(t);
1065
+ }
1066
+ if (!resp.ok) {
1067
+ const bodyText = await resp.text().catch(() => "");
1068
+ throw new Error(`Rekor POST failed: HTTP ${resp.status} ${bodyText.slice(0, 200)}`);
1069
+ }
1070
+ const respJson = await resp.json();
1071
+ const uuid = Object.keys(respJson)[0];
1072
+ if (!uuid) throw new Error("Rekor response missing entry uuid");
1073
+ const entry = respJson[uuid];
1074
+ return {
1075
+ kind: "rekor",
1076
+ logUrl: rekorUrl,
1077
+ uuid,
1078
+ logIndex: entry.logIndex,
1079
+ logID: entry.logID,
1080
+ integratedTime: entry.integratedTime,
1081
+ body: entry.body,
1082
+ inclusionProof: entry.verification?.inclusionProof ?? null,
1083
+ signedEntryTimestamp: entry.verification?.signedEntryTimestamp ?? null,
1084
+ };
1085
+ }
1086
+
1087
+ /**
1088
+ * RFC 8785–ish canonical JSON stringify for the subset used here (objects
1089
+ * with string keys, strings, integers, arrays, booleans, null; no floats,
1090
+ * no Unicode surrogates). Keys sorted lexicographically at every level.
1091
+ */
1092
+ export function canonicalizeJson(v) {
1093
+ if (v === null) return "null";
1094
+ if (typeof v === "boolean") return v ? "true" : "false";
1095
+ if (typeof v === "number") {
1096
+ if (!Number.isFinite(v)) throw new Error("canonicalizeJson: non-finite number");
1097
+ if (!Number.isInteger(v)) throw new Error("canonicalizeJson: only integers supported");
1098
+ return String(v);
1099
+ }
1100
+ if (typeof v === "string") return JSON.stringify(v);
1101
+ if (Array.isArray(v)) return "[" + v.map(canonicalizeJson).join(",") + "]";
1102
+ if (typeof v === "object") {
1103
+ const keys = Object.keys(v).sort();
1104
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalizeJson(v[k]));
1105
+ return "{" + parts.join(",") + "}";
1106
+ }
1107
+ throw new Error(`canonicalizeJson: unsupported type ${typeof v}`);
1108
+ }
1109
+
1110
+ /**
1111
+ * Extract the payload SHA-256 hash from a base64-encoded Rekor hashedrekord
1112
+ * body. Returns null if the body is malformed or not a hashedrekord.
1113
+ *
1114
+ * @param {string} bodyBase64
1115
+ * @returns {{algorithm: string, hex: string}|null}
1116
+ */
1117
+ export function extractRekorPayloadHash(bodyBase64) {
1118
+ try {
1119
+ const raw = Buffer.from(bodyBase64, "base64").toString("utf8");
1120
+ const parsed = JSON.parse(raw);
1121
+ if (parsed?.kind !== "hashedrekord") return null;
1122
+ const h = parsed?.spec?.data?.hash;
1123
+ if (!h?.algorithm || !h?.value) return null;
1124
+ return { algorithm: String(h.algorithm), hex: String(h.value).toLowerCase() };
1125
+ } catch {
1126
+ return null;
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * RFC 9162 §2.1.3 inner-node hash: SHA-256(0x01 || left || right).
1132
+ */
1133
+ function hashChildren(left, right) {
1134
+ return createHash("sha256").update(Buffer.from([0x01])).update(left).update(right).digest();
1135
+ }
1136
+
1137
+ /**
1138
+ * RFC 9162 §2.1.3 leaf hash: SHA-256(0x00 || raw_body_bytes).
1139
+ * Rekor's leaf-hash input is the base64-decoded body bytes.
1140
+ */
1141
+ export function rekorLeafHash(bodyBase64) {
1142
+ const raw = Buffer.from(bodyBase64, "base64");
1143
+ return createHash("sha256").update(Buffer.from([0x00])).update(raw).digest();
1144
+ }
1145
+
1146
+ /**
1147
+ * Verify an RFC 9162 inclusion proof.
1148
+ *
1149
+ * Ported from the widely-audited pattern used by sigstore-js and
1150
+ * transparency-dev/merkle (Go). Returns true only when the reconstructed
1151
+ * root matches expectedRootHex AND the proof was fully consumed with the
1152
+ * subtree-size counter reaching zero.
1153
+ *
1154
+ * @param {object} params
1155
+ * @param {number} params.leafIndex — 0-based
1156
+ * @param {number} params.treeSize — must be > leafIndex
1157
+ * @param {string[]} params.hashesHex — sibling hashes bottom-up (hex)
1158
+ * @param {string} params.expectedRootHex — hex-encoded expected root
1159
+ * @param {Buffer} params.leafHash — 32-byte SHA-256 leaf hash
1160
+ * @returns {boolean}
1161
+ */
1162
+ export function verifyInclusionProof({ leafIndex, treeSize, hashesHex, expectedRootHex, leafHash }) {
1163
+ if (!Number.isInteger(leafIndex) || !Number.isInteger(treeSize)) return false;
1164
+ if (leafIndex < 0 || treeSize <= 0 || leafIndex >= treeSize) return false;
1165
+ if (leafHash.length !== 32) return false;
1166
+ // Single-leaf tree: no siblings expected, leaf IS root.
1167
+ if (treeSize === 1) {
1168
+ return hashesHex.length === 0 && leafHash.equals(Buffer.from(expectedRootHex, "hex"));
1169
+ }
1170
+ let fn = leafIndex;
1171
+ let sn = treeSize - 1;
1172
+ let r = leafHash;
1173
+ for (const hHex of hashesHex) {
1174
+ const h = Buffer.from(hHex, "hex");
1175
+ if (h.length !== 32) return false;
1176
+ if (sn === 0) return false;
1177
+ if ((fn & 1) === 1 || fn === sn) {
1178
+ r = hashChildren(h, r);
1179
+ while ((fn & 1) === 0 && fn !== 0) {
1180
+ fn = Math.floor(fn / 2);
1181
+ sn = Math.floor(sn / 2);
1182
+ }
1183
+ } else {
1184
+ r = hashChildren(r, h);
1185
+ }
1186
+ fn = Math.floor(fn / 2);
1187
+ sn = Math.floor(sn / 2);
1188
+ }
1189
+ return sn === 0 && r.equals(Buffer.from(expectedRootHex, "hex"));
1190
+ }
1191
+
1192
+ /**
1193
+ * Verify the Rekor Signed Entry Timestamp (SET). The SET is an ECDSA
1194
+ * signature by Rekor over the canonical JSON of {body, integratedTime,
1195
+ * logID, logIndex}.
1196
+ *
1197
+ * @param {object} params
1198
+ * @param {object} params.anchor — must contain body/integratedTime/logID/logIndex/signedEntryTimestamp
1199
+ * @param {string|Buffer|KeyObject} params.rekorPubKey — PEM/KeyObject
1200
+ * @returns {{ok: boolean, reason?: string}}
1201
+ */
1202
+ export function verifyRekorSet({ anchor, rekorPubKey }) {
1203
+ if (!anchor?.signedEntryTimestamp) {
1204
+ return { ok: false, reason: "anchor missing signedEntryTimestamp" };
1205
+ }
1206
+ const canonical = canonicalizeJson({
1207
+ body: anchor.body,
1208
+ integratedTime: anchor.integratedTime,
1209
+ logID: anchor.logID,
1210
+ logIndex: anchor.logIndex,
1211
+ });
1212
+ const sig = Buffer.from(anchor.signedEntryTimestamp, "base64");
1213
+ let keyObj;
1214
+ try {
1215
+ keyObj = rekorPubKey && typeof rekorPubKey === "object" && rekorPubKey.type
1216
+ ? rekorPubKey
1217
+ : createPublicKey(rekorPubKey);
1218
+ } catch (err) {
1219
+ return { ok: false, reason: `Rekor pubkey parse failed: ${err.message}` };
1220
+ }
1221
+ const ok = cryptoVerify("sha256", Buffer.from(canonical, "utf8"), keyObj, sig);
1222
+ return ok ? { ok: true } : { ok: false, reason: "SET signature verification failed" };
1223
+ }
1224
+
1225
+ /**
1226
+ * Verify a Rekor anchor object. Two modes:
1227
+ * - Structural (default): confirm entry body payload-hash matches
1228
+ * expectedBatchRootHex. Elevates to LOG_ANCHORED_STRUCTURAL.
1229
+ * - Full: also verify inclusion proof + SET signature with rekorPubKey.
1230
+ * Elevates to LOG_ANCHORED on success. On partial failure, falls back
1231
+ * to LOG_ANCHORED_STRUCTURAL with a diagnostic.
1232
+ *
1233
+ * @param {object} params
1234
+ * @param {object} params.anchor
1235
+ * @param {string} params.expectedBatchRootHex
1236
+ * @param {boolean} [params.verifyFull]
1237
+ * @param {string|Buffer|KeyObject} [params.rekorPubKey] — required if verifyFull
1238
+ * @returns {{ok: boolean, reason?: string, trustLevel?: string, ...}}
1239
+ */
1240
+ export function verifyRekorAnchor({ anchor, expectedBatchRootHex, verifyFull = false, rekorPubKey } = {}) {
1241
+ if (!anchor || anchor.kind !== "rekor") {
1242
+ return { ok: false, reason: "anchor.kind must be 'rekor'" };
1243
+ }
1244
+ if (!anchor.body) return { ok: false, reason: "anchor missing body" };
1245
+ const payload = extractRekorPayloadHash(anchor.body);
1246
+ if (!payload) return { ok: false, reason: "rekor body is not a parseable hashedrekord" };
1247
+ if (payload.algorithm !== "sha256") {
1248
+ return { ok: false, reason: `unsupported payload hash algorithm "${payload.algorithm}"` };
1249
+ }
1250
+ if (payload.hex !== expectedBatchRootHex.toLowerCase()) {
1251
+ return { ok: false, reason: "rekor payload hash does not match batch_root" };
1252
+ }
1253
+
1254
+ if (!verifyFull) {
1255
+ return {
1256
+ ok: true,
1257
+ trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
1258
+ logIndex: anchor.logIndex,
1259
+ integratedTime: anchor.integratedTime,
1260
+ };
1261
+ }
1262
+
1263
+ if (!rekorPubKey) {
1264
+ return {
1265
+ ok: true,
1266
+ trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
1267
+ logIndex: anchor.logIndex,
1268
+ integratedTime: anchor.integratedTime,
1269
+ fullFailReason: "rekorPubKey option required for full verification",
1270
+ };
1271
+ }
1272
+
1273
+ // Inclusion proof
1274
+ const ip = anchor.inclusionProof;
1275
+ if (!ip?.rootHash || !Array.isArray(ip?.hashes) ||
1276
+ !Number.isInteger(ip?.logIndex) || !Number.isInteger(ip?.treeSize)) {
1277
+ return {
1278
+ ok: true,
1279
+ trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
1280
+ logIndex: anchor.logIndex,
1281
+ integratedTime: anchor.integratedTime,
1282
+ fullFailReason: "anchor missing or malformed inclusionProof",
1283
+ };
1284
+ }
1285
+ const leaf = rekorLeafHash(anchor.body);
1286
+ const inclOk = verifyInclusionProof({
1287
+ leafIndex: ip.logIndex,
1288
+ treeSize: ip.treeSize,
1289
+ hashesHex: ip.hashes,
1290
+ expectedRootHex: ip.rootHash,
1291
+ leafHash: leaf,
1292
+ });
1293
+ if (!inclOk) {
1294
+ return {
1295
+ ok: true,
1296
+ trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
1297
+ logIndex: anchor.logIndex,
1298
+ integratedTime: anchor.integratedTime,
1299
+ fullFailReason: "inclusion proof failed to reproduce rootHash",
1300
+ };
1301
+ }
1302
+
1303
+ // SET
1304
+ const setResult = verifyRekorSet({ anchor, rekorPubKey });
1305
+ if (!setResult.ok) {
1306
+ return {
1307
+ ok: true,
1308
+ trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
1309
+ logIndex: anchor.logIndex,
1310
+ integratedTime: anchor.integratedTime,
1311
+ fullFailReason: setResult.reason,
1312
+ };
1313
+ }
1314
+
1315
+ return {
1316
+ ok: true,
1317
+ trustLevel: TRUST_LEVELS.LOG_ANCHORED,
1318
+ logIndex: anchor.logIndex,
1319
+ integratedTime: anchor.integratedTime,
1320
+ };
1321
+ }