burnledger 0.2.2 → 0.3.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/cjs/index.browser.d.ts +2 -1
  4. package/dist/cjs/index.browser.d.ts.map +1 -1
  5. package/dist/cjs/index.browser.js +7 -1
  6. package/dist/cjs/index.browser.js.map +1 -1
  7. package/dist/cjs/index.d.ts +9 -1
  8. package/dist/cjs/index.d.ts.map +1 -1
  9. package/dist/cjs/index.js +17 -1
  10. package/dist/cjs/index.js.map +1 -1
  11. package/dist/cjs/models.d.ts +28 -1
  12. package/dist/cjs/models.d.ts.map +1 -1
  13. package/dist/cjs/models.js +8 -0
  14. package/dist/cjs/models.js.map +1 -1
  15. package/dist/cjs/verify.d.ts +55 -1
  16. package/dist/cjs/verify.d.ts.map +1 -1
  17. package/dist/cjs/verify.js +414 -104
  18. package/dist/cjs/verify.js.map +1 -1
  19. package/dist/cjs/web-verifier.d.ts +29 -0
  20. package/dist/cjs/web-verifier.d.ts.map +1 -0
  21. package/dist/cjs/web-verifier.js +70 -0
  22. package/dist/cjs/web-verifier.js.map +1 -0
  23. package/dist/esm/cli.d.ts.map +1 -1
  24. package/dist/esm/cli.js +12 -5
  25. package/dist/esm/cli.js.map +1 -1
  26. package/dist/esm/index.browser.d.ts +2 -1
  27. package/dist/esm/index.browser.d.ts.map +1 -1
  28. package/dist/esm/index.browser.js +3 -0
  29. package/dist/esm/index.browser.js.map +1 -1
  30. package/dist/esm/index.d.ts +9 -1
  31. package/dist/esm/index.d.ts.map +1 -1
  32. package/dist/esm/index.js +13 -1
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/models.d.ts +28 -1
  35. package/dist/esm/models.d.ts.map +1 -1
  36. package/dist/esm/models.js +8 -0
  37. package/dist/esm/models.js.map +1 -1
  38. package/dist/esm/verify.d.ts +55 -1
  39. package/dist/esm/verify.d.ts.map +1 -1
  40. package/dist/esm/verify.js +411 -105
  41. package/dist/esm/verify.js.map +1 -1
  42. package/dist/esm/web-verifier.d.ts +29 -0
  43. package/dist/esm/web-verifier.d.ts.map +1 -0
  44. package/dist/esm/web-verifier.js +63 -0
  45. package/dist/esm/web-verifier.js.map +1 -0
  46. package/package.json +12 -11
  47. package/src/cli.ts +207 -0
  48. package/src/client.ts +555 -0
  49. package/src/crypto-browser.ts +49 -0
  50. package/src/crypto-node.ts +40 -0
  51. package/src/crypto.ts +10 -0
  52. package/src/errors.ts +154 -0
  53. package/src/http.ts +209 -0
  54. package/src/index.browser.ts +110 -0
  55. package/src/index.ts +134 -0
  56. package/src/keys.ts +18 -0
  57. package/src/models.ts +558 -0
  58. package/src/pagination.ts +64 -0
  59. package/src/verify.ts +956 -0
  60. package/src/web-verifier.ts +89 -0
  61. package/src/webhooks.ts +76 -0
package/src/verify.ts ADDED
@@ -0,0 +1,956 @@
1
+ /** Offline certificate and transparency verification.
2
+ *
3
+ * Ports the Go signing payload builders (core/payload.go) and Merkle tree
4
+ * verification (core/merkle.go) to TypeScript. All crypto is delegated to
5
+ * a CryptoOps provider (Node or browser).
6
+ *
7
+ * Critical encoding details:
8
+ * - Go's json.Marshal encodes []byte as base64, [N]byte as number arrays.
9
+ * - The payload builders normalize both formats to lowercase hex strings.
10
+ * - Timestamps in payloads use second-precision UTC: "YYYY-MM-DDTHH:MM:SSZ".
11
+ * - RFC 8785 canonical JSON: sorted keys recursively, no whitespace.
12
+ * - Transparency leaf = SHA-256(0x00 || logLeafPayload), where the payload
13
+ * commits to entry_type, certificate_id, SHA-256(issuanceBytes(cert)) and
14
+ * appended_at (ADR-016 §4). NOT the
15
+ * certificate signing payload. issuanceBytes reconstructs json.Marshal
16
+ * output at issuance time (transparency_status=PENDING, no transparency).
17
+ */
18
+
19
+ import type { CryptoOps } from "./crypto.js";
20
+ import { VerificationError } from "./errors.js";
21
+ import type { TransparencyResult, VerificationResult } from "./models.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Pure byte helpers (no Buffer, no node:crypto)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const HEX_CHARS = "0123456789abcdef";
28
+
29
+ // Signing-format v2 domain-separation tags (CR-H01). Must match the
30
+ // source-of-truth const block in Go's core/payload.go byte-for-byte.
31
+ const PAYLOAD_TYPE_ATTESTATION = "burnledger.attestation.v3";
32
+ const PAYLOAD_TYPE_CERTIFICATE = "burnledger.certificate.v3";
33
+ const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
34
+ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
35
+ // There is deliberately no verification payload type: v3 produces those facts
36
+ // in the same enclave call that signs the certificate (ADR-016 §2).
37
+ const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
38
+
39
+ export function hexToBytes(hex: string): Uint8Array {
40
+ const len = hex.length >>> 1;
41
+ const out = new Uint8Array(len);
42
+ for (let i = 0; i < len; i++) {
43
+ const hi = hex.charCodeAt(i * 2);
44
+ const lo = hex.charCodeAt(i * 2 + 1);
45
+ out[i] = (unhex(hi) << 4) | unhex(lo);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function unhex(c: number): number {
51
+ // 0-9
52
+ if (c >= 48 && c <= 57) return c - 48;
53
+ // a-f
54
+ if (c >= 97 && c <= 102) return c - 87;
55
+ // A-F
56
+ if (c >= 65 && c <= 70) return c - 55;
57
+ return 0;
58
+ }
59
+
60
+ export function bytesToHex(bytes: Uint8Array): string {
61
+ let out = "";
62
+ for (let i = 0; i < bytes.length; i++) {
63
+ const b = bytes[i]!;
64
+ out += HEX_CHARS[b >>> 4];
65
+ out += HEX_CHARS[b & 0x0f];
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
71
+ const out = new Uint8Array(a.length + b.length);
72
+ out.set(a, 0);
73
+ out.set(b, a.length);
74
+ return out;
75
+ }
76
+
77
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
78
+ if (a.length !== b.length) return false;
79
+ for (let i = 0; i < a.length; i++) {
80
+ if (a[i] !== b[i]) return false;
81
+ }
82
+ return true;
83
+ }
84
+
85
+ function base64ToBytes(b64: string): Uint8Array {
86
+ // Use atob for universal browser+node compatibility.
87
+ // Node 18+ has atob globally; browsers always have it.
88
+ const bin = atob(b64);
89
+ const out = new Uint8Array(bin.length);
90
+ for (let i = 0; i < bin.length; i++) {
91
+ out[i] = bin.charCodeAt(i);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function textToBytes(s: string): Uint8Array {
97
+ return new TextEncoder().encode(s);
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Public key info
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export interface PublicKeyInfo {
105
+ readonly keyBytes: Uint8Array;
106
+ readonly keyId: string;
107
+ readonly revoked: boolean;
108
+ }
109
+
110
+ export async function publicKeyFromHex(
111
+ crypto: CryptoOps,
112
+ hexKey: string,
113
+ opts?: { revoked?: boolean },
114
+ ): Promise<PublicKeyInfo> {
115
+ const raw = hexToBytes(hexKey);
116
+ if (raw.length !== 32) {
117
+ throw new VerificationError(`public key must be 32 bytes, got ${raw.length}`);
118
+ }
119
+ const hash = await crypto.sha256(raw);
120
+ const keyId = "dp_k_" + bytesToHex(hash);
121
+ return { keyBytes: raw, keyId, revoked: opts?.revoked ?? false };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Public API
126
+ // ---------------------------------------------------------------------------
127
+
128
+ type Cert = Record<string, unknown>;
129
+
130
+ /** Verify all signatures on a deletion certificate offline. */
131
+ export async function verifyCertificate(
132
+ crypto: CryptoOps,
133
+ certificate: Cert,
134
+ publicKeys: Map<string, PublicKeyInfo>,
135
+ ): Promise<VerificationResult> {
136
+ const issuer = certificate.issuer as Record<string, unknown>;
137
+ const keyId = issuer.key_id as string;
138
+
139
+ const pki = publicKeys.get(keyId);
140
+ if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
141
+ if (pki.revoked) throw new VerificationError(`issuer key is revoked: ${keyId}`);
142
+
143
+ // 1. Certificate signature FIRST — nothing below may trust a field until the
144
+ // bytes carrying it are covered by a verified signature.
145
+ const certPayload = buildCertificatePayload(certificate);
146
+ const certSig = decodeSignature(certificate.certificate_signature as string);
147
+ if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
148
+ throw new VerificationError("certificate signature is invalid");
149
+ }
150
+
151
+ // 2. Attestation signature, rebuilt from the merged list. There is no
152
+ // verification signature: those facts are produced in the same enclave call
153
+ // that signs the certificate, so certificate_signature already covers them
154
+ // (ADR-016 §2).
155
+ const att = certificate.attestation as Record<string, unknown>;
156
+ const attPayload = buildAttestationPayload(
157
+ certificate.subject as Record<string, unknown>,
158
+ {
159
+ proof_mode: att.proof_mode,
160
+ attested_at: att.attested_at,
161
+ systems: attestationSystems(certificate),
162
+ },
163
+ );
164
+ const attSig = decodeSignature(att.attestation_signature as string);
165
+ if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
166
+ throw new VerificationError("attestation signature is invalid");
167
+ }
168
+
169
+ // 3. Structural rules on the system set.
170
+ //
171
+ // v2 carried two lists joined on the human-editable system_name, so "attested
172
+ // but not verified" was a representable state that had to be caught by a
173
+ // check — and for a long time was not (#451). v3 carries one list keyed by
174
+ // system_id, which makes that state unexpressible. What remains is
175
+ // structural, and mirrors core/verify.go step 4.
176
+ const systems = (certificate.systems as Record<string, unknown>[]) ?? [];
177
+ if (systems.length === 0) {
178
+ throw new VerificationError("certificate attests no systems, so it asserts nothing");
179
+ }
180
+
181
+ const seen = new Set<string>();
182
+ let anyAttested = false;
183
+ for (const s of systems) {
184
+ const systemId = s.system_id as string | undefined;
185
+ if (!systemId || systemId === NIL_UUID) {
186
+ throw new VerificationError("malformed systems: a system carries no system_id");
187
+ }
188
+ if (seen.has(systemId)) {
189
+ // Duplicate ids would let one verification stand in for several systems,
190
+ // which is the v2 hole in a new costume.
191
+ throw new VerificationError(`malformed systems: duplicate system_id ${systemId}`);
192
+ }
193
+ seen.add(systemId);
194
+
195
+ const attestedAt = parseRfc3339(s.attested_at as string);
196
+ const verifiedAt = parseRfc3339(s.verified_at as string);
197
+ if (verifiedAt < attestedAt) {
198
+ throw new VerificationError(
199
+ `malformed systems: system ${systemId} was verified before it was attested`,
200
+ );
201
+ }
202
+
203
+ if ((s.attested_count as number) > 0) anyAttested = true;
204
+ }
205
+
206
+ // 4. Nothing may remain anywhere.
207
+ for (const s of systems) {
208
+ const remaining = s.verified_count as number;
209
+ if (remaining !== 0) {
210
+ throw new VerificationError(
211
+ `data still present: system "${s.system_name as string}" reports ${remaining} record(s)`,
212
+ );
213
+ }
214
+ }
215
+
216
+ // 5. At least one system must have held records at attest time — across the
217
+ // set, not per system. Every system reporting zero means the certificate
218
+ // documents the deletion of nothing.
219
+ if (!anyAttested) {
220
+ throw new VerificationError(
221
+ "incomplete verification: no system held any records at attestation time",
222
+ );
223
+ }
224
+
225
+ return "VALID" as VerificationResult;
226
+ }
227
+
228
+ const NIL_UUID = "00000000-0000-0000-0000-000000000000";
229
+
230
+
231
+ /** Verify the transparency proof embedded in a certificate. */
232
+ export async function verifyTransparency(
233
+ crypto: CryptoOps,
234
+ certificate: Cert,
235
+ publicKeys: Map<string, PublicKeyInfo>,
236
+ ): Promise<TransparencyResult> {
237
+ const transparency = certificate.transparency as
238
+ | Record<string, unknown>
239
+ | undefined;
240
+ if (transparency == null) {
241
+ return "NOT_AVAILABLE" as TransparencyResult;
242
+ }
243
+
244
+ const sth = transparency.signed_tree_head as Record<string, unknown>;
245
+ const issuer = certificate.issuer as Record<string, unknown>;
246
+ const keyId = issuer.key_id as string;
247
+
248
+ const pki = publicKeys.get(keyId);
249
+ if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
250
+ if (pki.revoked) throw new VerificationError(`issuer key is revoked: ${keyId}`);
251
+
252
+ // 1. Tree head signature
253
+ const headPayload = buildTreeHeadPayload(sth);
254
+ const headSig = decodeSignature(sth.signature as string);
255
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
256
+ throw new VerificationError("tree head signature is invalid");
257
+ }
258
+
259
+ // 2. Merkle inclusion proof.
260
+ //
261
+ // The leaf is NOT the certificate. It is the canonical log_leaf.v3 payload
262
+ // over entry_type, certificate_id, certificate_hash and appended_at, where
263
+ // certificate_hash is SHA-256 of the issuance-time certificate JSON
264
+ // (ADR-016 §4). v2 hashed the certificate directly, so an issuance and a
265
+ // revocation of the same certificate produced identical leaves and the tree
266
+ // committed to neither the entry type nor when it happened.
267
+ const issuanceData = issuanceBytes(certificate);
268
+ const leafPayload = buildLogLeafPayload(
269
+ transparency.entry_type,
270
+ certificate.certificate_id as string,
271
+ await crypto.sha256(issuanceData),
272
+ transparency.appended_at,
273
+ );
274
+ const leaf = await hashLeaf(crypto, leafPayload);
275
+
276
+ const proofHashes = (
277
+ (transparency.inclusion_proof as unknown[] | undefined) ?? []
278
+ ).map((h) => decodeBytes(h));
279
+ const root = decodeBytes(sth.root_hash);
280
+ // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
281
+ // for an explicit null and for an absent key rather than failing, so refusing
282
+ // either would reject documents the reference accepts — the same class of
283
+ // divergence as #452. An offline verifier is only useful while it agrees.
284
+ const index = requireUint(transparency.entry_index ?? 0, "entry_index");
285
+ const treeSize = requireUint(sth.tree_size ?? 0, "signed_tree_head.tree_size");
286
+
287
+ // transparency.tree_size duplicates the signed one but carries no signature:
288
+ // BuildTreeHeadPayload covers only the copy inside signed_tree_head. Go used
289
+ // to verify against the unsigned copy while this SDK used the signed one, so
290
+ // the same document got two verdicts (#456). Both now require the two to agree
291
+ // and then verify against the signed copy — the server always writes them
292
+ // equal, so this rejects only edited documents.
293
+ const unsignedSize = requireUint(
294
+ transparency.tree_size ?? 0,
295
+ "transparency.tree_size",
296
+ );
297
+ if (unsignedSize !== treeSize) {
298
+ throw new VerificationError(
299
+ `transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
300
+ `(${treeSize}); it is not covered by any signature`,
301
+ );
302
+ }
303
+
304
+ if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
305
+ throw new VerificationError("merkle inclusion proof is invalid");
306
+ }
307
+
308
+ return "INCLUDED" as TransparencyResult;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Issuance bytes — matches Go's core.IssuanceBytes(cert)
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /** Reconstruct the certificate JSON as it existed at issuance time.
316
+ *
317
+ * At issuance: transparency_status = "PENDING", transparency = nil (omitted).
318
+ * The leaf preimage is the RFC 8785 canonical JSON of that form — exactly
319
+ * Go's `CanonicalJSON(json.Marshal(cert))` in core.IssuanceBytes. It MUST be
320
+ * canonicalized (sorted keys, recursively), not serialized in insertion order:
321
+ * Go's struct field order is not alphabetical, so insertion-order stringify
322
+ * produces a different leaf hash and every genuine certificate fails inclusion
323
+ * verification (#C-01).
324
+ */
325
+ export function issuanceBytes(certificate: Cert): Uint8Array {
326
+ const clone = JSON.parse(JSON.stringify(certificate)) as Record<string, unknown>;
327
+ clone.transparency_status = "PENDING";
328
+ delete clone.transparency;
329
+ return canonicalJson(clone);
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // RFC 8785 Canonical JSON
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function canonicalJson(obj: unknown): Uint8Array {
337
+ return textToBytes(canonicalStringify(obj));
338
+ }
339
+
340
+ function canonicalStringify(val: unknown): string {
341
+ if (val === null || val === undefined) return "null";
342
+ if (typeof val === "boolean") return val ? "true" : "false";
343
+ if (typeof val === "number") return JSON.stringify(val);
344
+ if (typeof val === "string") return JSON.stringify(val);
345
+ if (Array.isArray(val)) {
346
+ return "[" + val.map(canonicalStringify).join(",") + "]";
347
+ }
348
+ if (typeof val === "object") {
349
+ const obj = val as Record<string, unknown>;
350
+ const keys = Object.keys(obj).sort();
351
+ const pairs = keys.map(
352
+ (k) => JSON.stringify(k) + ":" + canonicalStringify(obj[k]),
353
+ );
354
+ return "{" + pairs.join(",") + "}";
355
+ }
356
+ return JSON.stringify(val);
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Byte encoding helpers
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /** Convert a byte field to lowercase hex. Handles:
364
+ * - hex string (from hand-crafted test data)
365
+ * - base64 string (from Go's json.Marshal of []byte slices)
366
+ * - number[] (from Go's json.Marshal of [N]byte fixed arrays)
367
+ */
368
+ function toHex(value: unknown): string {
369
+ if (Array.isArray(value)) {
370
+ return bytesToHex(new Uint8Array(value as number[]));
371
+ }
372
+ const str = value as string;
373
+ if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
374
+ return str.toLowerCase();
375
+ }
376
+ return bytesToHex(base64ToBytes(str));
377
+ }
378
+
379
+ /** Decode a signature field to raw bytes. Same format handling as toHex. */
380
+ function decodeSignature(value: unknown): Uint8Array {
381
+ if (Array.isArray(value)) {
382
+ return new Uint8Array(value as number[]);
383
+ }
384
+ const str = value as string;
385
+ if (/^[0-9a-fA-F]+$/.test(str)) {
386
+ const raw = hexToBytes(str);
387
+ if (raw.length === 64) return raw;
388
+ }
389
+ return base64ToBytes(str);
390
+ }
391
+
392
+ /** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
393
+ function decodeBytes(value: unknown): Uint8Array {
394
+ if (Array.isArray(value)) {
395
+ return new Uint8Array(value as number[]);
396
+ }
397
+ const str = value as string;
398
+ if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
399
+ return hexToBytes(str);
400
+ }
401
+ return base64ToBytes(str);
402
+ }
403
+
404
+ /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
405
+ * field for an explicit JSON null or an absent key. */
406
+ const ZERO_INSTANT = "0001-01-01T00:00:00Z";
407
+
408
+ /** The grammar encoding/json accepts for time.Time: strict RFC 3339, uppercase
409
+ * "T" and "Z" only, fixed field widths, at least one fractional digit if a "."
410
+ * is present. Anything else makes Go fail to parse the certificate at all. */
411
+ const RFC3339_RE =
412
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/;
413
+
414
+ /** Normalize a certificate timestamp to the UTC form Go signs.
415
+ *
416
+ * Go builds every payload timestamp as `t.UTC().Format("2006-01-02T15:04:05Z")`
417
+ * (core/payload.go) on a value encoding/json already parsed as strict RFC 3339.
418
+ * This reproduces both halves — the same acceptance rules and the same
419
+ * conversion — because the two are one contract, not two.
420
+ *
421
+ * It must convert, never truncate. `12:00:00+05:00` and `12:00:00Z` are six
422
+ * hours apart; treating them as the same string made this verifier accept a
423
+ * certificate whose attestation window had been re-labelled to a different
424
+ * instant, and reject genuine certificates issued by a server on local time
425
+ * (#452). Input Go would refuse to parse is refused here rather than
426
+ * string-surgered into something that verifies.
427
+ *
428
+ * `null`/`undefined` — an explicit JSON null, or a key Go's struct has and the
429
+ * document does not — is Go's zero time, not an error.
430
+ *
431
+ * Exported from this module so the timestamp_vectors.json suite can diff it
432
+ * against Go directly. It is deliberately not re-exported from index.ts, so it
433
+ * is not part of the published package surface.
434
+ */
435
+ export function formatTimestamp(ts: unknown): string {
436
+ if (ts === null || ts === undefined) return ZERO_INSTANT;
437
+ if (typeof ts !== "string") {
438
+ throw new VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
439
+ }
440
+ return formatEpochSeconds(parseRfc3339(ts));
441
+ }
442
+
443
+ /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
444
+ *
445
+ * Rejects exactly what Go's encoding/json rejects, including the ranges its
446
+ * parser enforces once the shape matches. Those ranges were measured against
447
+ * Go 1.26, not inferred from RFC 3339, and they are not the obvious ones: the
448
+ * time of day is 23:59:59 at most, but a zone offset runs to +24:60 — both
449
+ * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
450
+ * digits are dropped, matching Format's truncation.
451
+ */
452
+ function parseRfc3339(ts: string): number {
453
+ const m = RFC3339_RE.exec(ts);
454
+ if (m === null) throw new VerificationError(`timestamp is not RFC 3339: ${ts}`);
455
+ const [year, month, day, hour, minute, second] = m
456
+ .slice(1, 7)
457
+ .map((v) => Number.parseInt(v!, 10)) as [number, number, number, number, number, number];
458
+
459
+ if (month < 1 || month > 12) throw new VerificationError(`timestamp month out of range: ${ts}`);
460
+ if (day < 1 || day > daysInMonth(year, month)) {
461
+ throw new VerificationError(`timestamp day out of range: ${ts}`);
462
+ }
463
+ if (hour > 23 || minute > 59 || second > 59) {
464
+ throw new VerificationError(`timestamp time of day out of range: ${ts}`);
465
+ }
466
+
467
+ let offset = 0;
468
+ const sign = m[7];
469
+ if (sign !== undefined) {
470
+ const offHour = Number.parseInt(m[8]!, 10);
471
+ const offMin = Number.parseInt(m[9]!, 10);
472
+ if (offHour > 24) throw new VerificationError(`timestamp zone offset hour out of range: ${ts}`);
473
+ if (offMin > 60) throw new VerificationError(`timestamp zone offset minute out of range: ${ts}`);
474
+ offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
475
+ }
476
+
477
+ return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
478
+ }
479
+
480
+ /** Render seconds since the Unix epoch as `YYYY-MM-DDTHH:MM:SSZ`.
481
+ *
482
+ * Matches Go's Format for the reachable extremes: a year 0000 timestamp with a
483
+ * `+24:00` offset lands in year -1, which Go prints as `-0001`, and 9999-12-31
484
+ * with `-24:00` lands in year 10000, which Go prints unpadded.
485
+ */
486
+ function formatEpochSeconds(total: number): string {
487
+ const days = Math.floor(total / 86400);
488
+ const secs = total - days * 86400;
489
+ const [year, month, day] = civilFromDays(days);
490
+ const hour = Math.floor(secs / 3600);
491
+ const minute = Math.floor((secs - hour * 3600) / 60);
492
+ const second = secs - hour * 3600 - minute * 60;
493
+ const printedYear = year < 0 ? "-" + pad(-year, 4) : pad(year, 4);
494
+ return `${printedYear}-${pad(month, 2)}-${pad(day, 2)}T${pad(hour, 2)}:${pad(minute, 2)}:${pad(second, 2)}Z`;
495
+ }
496
+
497
+ function pad(value: number, width: number): string {
498
+ return String(value).padStart(width, "0");
499
+ }
500
+
501
+ /** Length of a proleptic Gregorian month. Year 0 is a leap year; 1900 is not. */
502
+ function daysInMonth(year: number, month: number): number {
503
+ if (month === 2) {
504
+ const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
505
+ return isLeap ? 29 : 28;
506
+ }
507
+ return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
508
+ }
509
+
510
+ /** Days from 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm).
511
+ *
512
+ * Deliberately not `Date`: Go accepts year 0000, and a zone offset can push the
513
+ * UTC instant to year -1 or 10000, where `Date`'s parsing and formatting stop
514
+ * agreeing with Go. A verifier that disagrees with the reference on any input is
515
+ * a verifier a relying party cannot use to second-guess the reference.
516
+ */
517
+ function daysFromCivil(year: number, month: number, day: number): number {
518
+ const y = month <= 2 ? year - 1 : year;
519
+ const era = Math.floor(y / 400);
520
+ const yearOfEra = y - era * 400; // [0, 399]
521
+ const shiftedMonth = month > 2 ? month - 3 : month + 9; // [0, 11]
522
+ const dayOfYear = Math.floor((153 * shiftedMonth + 2) / 5) + day - 1; // [0, 365]
523
+ const dayOfEra =
524
+ yearOfEra * 365 +
525
+ Math.floor(yearOfEra / 4) -
526
+ Math.floor(yearOfEra / 100) +
527
+ dayOfYear;
528
+ return era * 146097 + dayOfEra - 719468;
529
+ }
530
+
531
+ /** Inverse of daysFromCivil. */
532
+ function civilFromDays(days: number): [number, number, number] {
533
+ const z = days + 719468;
534
+ const era = Math.floor(z / 146097);
535
+ const dayOfEra = z - era * 146097; // [0, 146096]
536
+ const yearOfEra = Math.floor(
537
+ (dayOfEra -
538
+ Math.floor(dayOfEra / 1460) +
539
+ Math.floor(dayOfEra / 36524) -
540
+ Math.floor(dayOfEra / 146096)) /
541
+ 365,
542
+ ); // [0, 399]
543
+ const dayOfYear =
544
+ dayOfEra -
545
+ (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100)); // [0, 365]
546
+ const shiftedMonth = Math.floor((5 * dayOfYear + 2) / 153); // [0, 11]
547
+ const day = dayOfYear - Math.floor((153 * shiftedMonth + 2) / 5) + 1;
548
+ const month = shiftedMonth < 10 ? shiftedMonth + 3 : shiftedMonth - 9;
549
+ const year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0);
550
+ return [year, month, day];
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Payload builders — exact ports of core/payload.go
555
+ // ---------------------------------------------------------------------------
556
+
557
+ function buildAttestationPayload(
558
+ subject: Record<string, unknown>,
559
+ att: Record<string, unknown>,
560
+ ): Uint8Array {
561
+ const attestedAt = formatTimestamp(att.attested_at);
562
+ const systems = (att.systems as Record<string, unknown>[]).map((s) => ({
563
+ canonical_version: (s.canonical_version as string | null) ?? null,
564
+ connector_type: s.connector_type as string,
565
+ hash_scope: s.hash_scope as string,
566
+ merkle_root: s.merkle_root
567
+ ? toHex(s.merkle_root as string)
568
+ : null,
569
+ // The system's own observation time, not the envelope's.
570
+ observed_at: formatTimestamp(s.observed_at),
571
+ query_hash: toHex(s.query_hash as string),
572
+ record_count: s.record_count as number,
573
+ system_id: s.system_id as string,
574
+ system_name: s.system_name as string,
575
+ }));
576
+
577
+ const payload = {
578
+ attested_at: attestedAt,
579
+ payload_type: PAYLOAD_TYPE_ATTESTATION,
580
+ proof_mode: att.proof_mode as string,
581
+ subject_hash: toHex(subject.identifier_hash as string),
582
+ systems,
583
+ };
584
+ return canonicalJson(payload);
585
+ }
586
+
587
+ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
588
+ const att = cert.attestation as Record<string, unknown>;
589
+ const issuer = cert.issuer as Record<string, unknown>;
590
+ const subject = cert.subject as Record<string, unknown>;
591
+
592
+ // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
593
+ // and a verification list joined only on the human-editable system_name,
594
+ // which made a partial deletion indistinguishable from a complete one.
595
+ const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => ({
596
+ attested_at: formatTimestamp(s.attested_at),
597
+ attested_count: s.attested_count as number,
598
+ canonical_version: (s.canonical_version as string | null) ?? null,
599
+ connector_type: s.connector_type as string,
600
+ hash_scope: s.hash_scope as string,
601
+ merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
602
+ query_hash: toHex(s.query_hash as string),
603
+ system_id: s.system_id as string,
604
+ system_name: s.system_name as string,
605
+ verified_at: formatTimestamp(s.verified_at),
606
+ verified_count: s.verified_count as number,
607
+ }));
608
+
609
+ // The attestation block carries no system list of its own: the merged list
610
+ // above is a superset of it. verification_signature is gone entirely.
611
+ const attObj = {
612
+ attestation_signature: toHex(att.attestation_signature as string),
613
+ attested_at: formatTimestamp(att.attested_at),
614
+ proof_mode: att.proof_mode as string,
615
+ };
616
+
617
+ const issuerObj = {
618
+ key_id: issuer.key_id as string,
619
+ name: issuer.name as string,
620
+ public_key: toHex(issuer.public_key as string),
621
+ };
622
+
623
+ const subjectObj = {
624
+ identifier_hash: toHex(subject.identifier_hash as string),
625
+ identifier_type_hint: subject.identifier_type_hint as string,
626
+ };
627
+
628
+ // status and revocation are deliberately absent (ADR-016 §3): a signature
629
+ // commits to bytes at an instant, revocation is discovered later, so it
630
+ // travels as a separate short-lived signed status statement.
631
+ const payload = {
632
+ attestation: attObj,
633
+ attestation_id: cert.attestation_id as string,
634
+ certificate_format_version: cert.certificate_format_version as string,
635
+ certificate_id: cert.certificate_id as string,
636
+ issued_at: formatTimestamp(cert.issued_at),
637
+ issuer: issuerObj,
638
+ payload_type: PAYLOAD_TYPE_CERTIFICATE,
639
+ subject: subjectObj,
640
+ systems,
641
+ };
642
+ return canonicalJson(payload);
643
+ }
644
+
645
+ /** Port of Go's BuildLogLeafPayload (ADR-016 §4). */
646
+ function buildLogLeafPayload(
647
+ entryType: unknown,
648
+ certificateId: string,
649
+ certificateHash: Uint8Array,
650
+ appendedAt: unknown,
651
+ ): Uint8Array {
652
+ if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION") {
653
+ throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
654
+ }
655
+ const payload = {
656
+ appended_at: formatTimestamp(appendedAt),
657
+ certificate_hash: bytesToHex(certificateHash),
658
+ certificate_id: certificateId,
659
+ entry_type: entryType,
660
+ payload_type: PAYLOAD_TYPE_LOG_LEAF,
661
+ };
662
+ return canonicalJson(payload);
663
+ }
664
+
665
+ /**
666
+ * Project the merged system list back onto the attest-time shape, so the
667
+ * attestation signature — produced days before the certificate existed — can be
668
+ * checked. Mirrors DeletionCertificate.AttestationSystems() in Go.
669
+ */
670
+ function attestationSystems(cert: Record<string, unknown>): Record<string, unknown>[] {
671
+ return ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => ({
672
+ system_id: s.system_id,
673
+ system_name: s.system_name,
674
+ connector_type: s.connector_type,
675
+ hash_scope: s.hash_scope,
676
+ query_hash: s.query_hash,
677
+ record_count: s.attested_count,
678
+ observed_at: s.attested_at,
679
+ merkle_root: s.merkle_root,
680
+ canonical_version: s.canonical_version,
681
+ }));
682
+ }
683
+
684
+ function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
685
+ const payload = {
686
+ payload_type: PAYLOAD_TYPE_TREE_HEAD,
687
+ root_hash: toHex(head.root_hash as string),
688
+ timestamp: formatTimestamp(head.timestamp),
689
+ tree_size: head.tree_size as number,
690
+ };
691
+ return canonicalJson(payload);
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // Merkle tree (RFC 6962) — ports of core/merkle.go
696
+ // ---------------------------------------------------------------------------
697
+
698
+ async function hashLeaf(crypto: CryptoOps, data: Uint8Array): Promise<Uint8Array> {
699
+ return crypto.sha256(concatBytes(new Uint8Array([0x00]), data));
700
+ }
701
+
702
+ async function hashNode(crypto: CryptoOps, left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
703
+ const prefix = new Uint8Array([0x01]);
704
+ return crypto.sha256(concatBytes(prefix, concatBytes(left, right)));
705
+ }
706
+
707
+ /** Read a tree index or size the way Go's uint64 unmarshalling does.
708
+ *
709
+ * Go's struct fields are uint64, so a negative or fractional JSON number makes
710
+ * the whole certificate fail to parse, and the `index >= size` guard in
711
+ * verifyInclusion never sees one. This SDK gets a JavaScript number, where that
712
+ * guard passes a negative index straight through and any value past 2^53 has
713
+ * already lost the precision the Merkle arithmetic depends on (#456). Both are
714
+ * rejected here, at the document boundary, rather than trusted downstream.
715
+ */
716
+ function requireUint(value: unknown, field: string): number {
717
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
718
+ throw new VerificationError(
719
+ `${field} is not an exact non-negative integer: ${JSON.stringify(value)}`,
720
+ );
721
+ }
722
+ if (value < 0) throw new VerificationError(`${field} is negative: ${value}`);
723
+ return value;
724
+ }
725
+
726
+ /** Largest power of 2 strictly less than n. Port of Go's splitPoint.
727
+ *
728
+ * The loop terminates because requireUint has already bounded n to a safe
729
+ * integer; Go, which has no such bound, computes the same value with
730
+ * `1 << (bits.Len64(n-1) - 1)` because its loop form overflowed (#456).
731
+ */
732
+ function splitPoint(n: number): number {
733
+ let k = 1;
734
+ while (k * 2 < n) {
735
+ k *= 2;
736
+ }
737
+ return k;
738
+ }
739
+
740
+ async function verifyInclusion(
741
+ crypto: CryptoOps,
742
+ leaf: Uint8Array,
743
+ index: number,
744
+ size: number,
745
+ proof: Uint8Array[],
746
+ root: Uint8Array,
747
+ ): Promise<boolean> {
748
+ if (size === 0 || index >= size) return false;
749
+ if (size === 1) return proof.length === 0 && bytesEqual(leaf, root);
750
+ const [computed, consumed] = await chainInclusion(crypto, leaf, index, size, proof);
751
+ return consumed === proof.length && bytesEqual(computed, root);
752
+ }
753
+
754
+ async function chainInclusion(
755
+ crypto: CryptoOps,
756
+ leaf: Uint8Array,
757
+ index: number,
758
+ n: number,
759
+ proof: Uint8Array[],
760
+ ): Promise<[Uint8Array, number]> {
761
+ if (n === 1) return [leaf, 0];
762
+ const k = splitPoint(n);
763
+ if (index < k) {
764
+ const [inner, used] = await chainInclusion(crypto, leaf, index, k, proof);
765
+ if (used >= proof.length) return [inner, used + 1];
766
+ return [await hashNode(crypto, inner, proof[used]!), used + 1];
767
+ }
768
+ const [inner, used] = await chainInclusion(crypto, leaf, index - k, n - k, proof);
769
+ if (used >= proof.length) return [inner, used + 1];
770
+ return [await hashNode(crypto, proof[used]!, inner), used + 1];
771
+ }
772
+
773
+ function isPow2(n: number): boolean {
774
+ return n > 0 && (n & (n - 1)) === 0;
775
+ }
776
+
777
+ /** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
778
+ *
779
+ * Port of Go's VerifyConsistency / Python's _verify_consistency.
780
+ */
781
+ export async function verifyConsistency(
782
+ crypto: CryptoOps,
783
+ oldSize: number,
784
+ newSize: number,
785
+ oldRoot: Uint8Array,
786
+ newRoot: Uint8Array,
787
+ proof: Uint8Array[],
788
+ ): Promise<boolean> {
789
+ if (oldSize === 0 || newSize === 0 || oldSize > newSize) return false;
790
+ if (oldSize === newSize) {
791
+ return proof.length === 0 && bytesEqual(oldRoot, newRoot);
792
+ }
793
+
794
+ let pIdx = 0;
795
+ let fr: Uint8Array;
796
+ let sr: Uint8Array;
797
+
798
+ if (isPow2(oldSize)) {
799
+ fr = oldRoot;
800
+ sr = oldRoot;
801
+ } else {
802
+ if (pIdx >= proof.length) return false;
803
+ fr = proof[pIdx]!;
804
+ sr = proof[pIdx]!;
805
+ pIdx++;
806
+ }
807
+
808
+ let fn = oldSize - 1;
809
+ let sn = newSize - 1;
810
+
811
+ while ((fn & 1) === 1) {
812
+ fn >>= 1;
813
+ sn >>= 1;
814
+ }
815
+
816
+ // Drive the walk from the tree, not from the proof's length. Looping on
817
+ // `pIdx < proof.length` let the prover choose how many steps ran, so a log
818
+ // could understate its own size and present the genuine proof for the size it
819
+ // really had — STH{size: 6} carrying the root of 7 leaves verified against the
820
+ // real 7 -> 8 proof. The only consumer is a witness detecting exactly that
821
+ // equivocation. Length is part of the claim: too few elements runs out here,
822
+ // too many is caught by the pIdx check below.
823
+ while (sn !== 0) {
824
+ if (pIdx >= proof.length) return false;
825
+ const c = proof[pIdx]!;
826
+ pIdx++;
827
+
828
+ if ((fn & 1) === 1 || fn === sn) {
829
+ fr = await hashNode(crypto, c, fr);
830
+ sr = await hashNode(crypto, c, sr);
831
+ while (fn !== 0 && (fn & 1) === 0) {
832
+ fn >>= 1;
833
+ sn >>= 1;
834
+ }
835
+ } else {
836
+ sr = await hashNode(crypto, sr, c);
837
+ }
838
+
839
+ fn >>= 1;
840
+ sn >>= 1;
841
+ }
842
+
843
+ return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
844
+ }
845
+
846
+
847
+ /**
848
+ * Port of Go's BuildCertificateStatusPayload.
849
+ *
850
+ * Optional fields are omitted, never emitted as null: the issuer omits them, so
851
+ * a verifier that emitted nulls would rebuild different bytes and reject a
852
+ * genuine statement.
853
+ */
854
+ export function buildCertificateStatusPayload(
855
+ stmt: Record<string, unknown>,
856
+ ): Uint8Array {
857
+ const payload: Record<string, unknown> = {
858
+ payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
859
+ certificate_id: stmt.certificate_id as string,
860
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at),
861
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at),
862
+ status: stmt.status as string,
863
+ sth_root_hash: toHex(stmt.sth_root_hash as string),
864
+ sth_tree_size: stmt.sth_tree_size as number,
865
+ };
866
+ if (stmt.replacement_certificate_id != null) {
867
+ payload.replacement_certificate_id = stmt.replacement_certificate_id as string;
868
+ }
869
+ if (stmt.revocation_log_index != null) {
870
+ payload.revocation_log_index = stmt.revocation_log_index as number;
871
+ }
872
+ if (stmt.revoked_at != null) {
873
+ payload.revoked_at = formatTimestamp(stmt.revoked_at);
874
+ }
875
+ return canonicalJson(payload);
876
+ }
877
+
878
+ /** The verdict when a certificate verified but nothing said whether it was revoked. */
879
+ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
880
+
881
+ /**
882
+ * Verify a certificate and, separately, what a statement says about its
883
+ * revocation.
884
+ *
885
+ * A signature commits to bytes at an instant; revocation is discovered later,
886
+ * so no edit to the signed certificate can express it. The statement is a
887
+ * separate short-lived document — fetch it from
888
+ * `GET /v1/certificates/{id}/status`, or read `status_statement` from the
889
+ * certificate response, where it is stapled for exactly this purpose.
890
+ *
891
+ * | input | result |
892
+ * |---|---|
893
+ * | fresh statement, REVOKED | throws VerificationError |
894
+ * | fresh statement, ACTIVE | `"VALID"` |
895
+ * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"` |
896
+ *
897
+ * The last row is the point: `verifyCertificate` answers VALID there, which
898
+ * reads as "not revoked" and is not something it checked.
899
+ */
900
+ export async function verifyCertificateWithStatus(
901
+ crypto: CryptoOps,
902
+ certificate: Cert,
903
+ publicKeys: Map<string, PublicKeyInfo>,
904
+ status?: Record<string, unknown> | null,
905
+ now?: Date,
906
+ ): Promise<string> {
907
+ const base = await verifyCertificate(crypto, certificate, publicKeys);
908
+ if (base !== "VALID") {
909
+ return base as string;
910
+ }
911
+ if (status == null) {
912
+ return VALID_REVOCATION_UNKNOWN;
913
+ }
914
+
915
+ const keyId = status.key_id as string | undefined;
916
+ const info = keyId ? publicKeys.get(keyId) : undefined;
917
+ if (!info) {
918
+ throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
919
+ }
920
+ if (info.revoked) {
921
+ throw new VerificationError("status statement signed by a revoked key");
922
+ }
923
+
924
+ const payload = buildCertificateStatusPayload(status);
925
+ // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
926
+ // array depending on the producer, and every other signature on this path
927
+ // goes through the same decoder.
928
+ const sig = decodeSignature(status.signature);
929
+ if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
930
+ throw new VerificationError("status statement signature is invalid");
931
+ }
932
+
933
+ // A validly signed statement about a different certificate must not be
934
+ // allowed to speak for this one.
935
+ const certId = (certificate as Record<string, unknown>).id ??
936
+ (certificate as Record<string, unknown>).certificate_id;
937
+ if (status.certificate_id !== certId) {
938
+ throw new VerificationError("status statement is about a different certificate");
939
+ }
940
+
941
+ // Compared in the normalized form the payload signs, so the freshness check
942
+ // cannot disagree with what was signed about the same two instants.
943
+ const at = formatTimestamp((now ?? new Date()).toISOString());
944
+ const issued = formatTimestamp(status.statement_issued_at);
945
+ const expires = formatTimestamp(status.statement_expires_at);
946
+ if (at < issued || at >= expires) {
947
+ // Stale is not a weaker answer, it is no answer — including for a REVOKED
948
+ // statement, which must never decay into VALID.
949
+ return VALID_REVOCATION_UNKNOWN;
950
+ }
951
+
952
+ if (status.status === "REVOKED") {
953
+ throw new VerificationError("certificate has been revoked");
954
+ }
955
+ return "VALID";
956
+ }