burnledger 0.8.1 → 0.9.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 (124) hide show
  1. package/README.md +139 -2
  2. package/dist/cjs/anchor.d.ts +5 -3
  3. package/dist/cjs/anchor.d.ts.map +1 -1
  4. package/dist/cjs/anchor.js +10 -4
  5. package/dist/cjs/anchor.js.map +1 -1
  6. package/dist/cjs/client.d.ts +95 -0
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +133 -2
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/customer-keys.d.ts +73 -1
  11. package/dist/cjs/customer-keys.d.ts.map +1 -1
  12. package/dist/cjs/customer-keys.js +329 -3
  13. package/dist/cjs/customer-keys.js.map +1 -1
  14. package/dist/cjs/enclave-registration.d.ts +153 -0
  15. package/dist/cjs/enclave-registration.d.ts.map +1 -0
  16. package/dist/cjs/enclave-registration.js +275 -0
  17. package/dist/cjs/enclave-registration.js.map +1 -0
  18. package/dist/cjs/enclave-seal.d.ts +43 -0
  19. package/dist/cjs/enclave-seal.d.ts.map +1 -1
  20. package/dist/cjs/enclave-seal.js +62 -1
  21. package/dist/cjs/enclave-seal.js.map +1 -1
  22. package/dist/cjs/errors.d.ts +10 -0
  23. package/dist/cjs/errors.d.ts.map +1 -1
  24. package/dist/cjs/errors.js +11 -1
  25. package/dist/cjs/errors.js.map +1 -1
  26. package/dist/cjs/index.browser.d.ts +7 -3
  27. package/dist/cjs/index.browser.d.ts.map +1 -1
  28. package/dist/cjs/index.browser.js +6 -3
  29. package/dist/cjs/index.browser.js.map +1 -1
  30. package/dist/cjs/index.d.ts +22 -10
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/cjs/index.js +30 -6
  33. package/dist/cjs/index.js.map +1 -1
  34. package/dist/cjs/key-group.d.ts +80 -0
  35. package/dist/cjs/key-group.d.ts.map +1 -0
  36. package/dist/cjs/key-group.js +136 -0
  37. package/dist/cjs/key-group.js.map +1 -0
  38. package/dist/cjs/models.d.ts +60 -0
  39. package/dist/cjs/models.d.ts.map +1 -1
  40. package/dist/cjs/models.js +63 -0
  41. package/dist/cjs/models.js.map +1 -1
  42. package/dist/cjs/status-document.d.ts +25 -0
  43. package/dist/cjs/status-document.d.ts.map +1 -0
  44. package/dist/cjs/status-document.js +62 -0
  45. package/dist/cjs/status-document.js.map +1 -0
  46. package/dist/cjs/verify.d.ts +108 -3
  47. package/dist/cjs/verify.d.ts.map +1 -1
  48. package/dist/cjs/verify.js +389 -146
  49. package/dist/cjs/verify.js.map +1 -1
  50. package/dist/cjs/web-verifier.d.ts +23 -3
  51. package/dist/cjs/web-verifier.d.ts.map +1 -1
  52. package/dist/cjs/web-verifier.js +31 -5
  53. package/dist/cjs/web-verifier.js.map +1 -1
  54. package/dist/esm/anchor.d.ts +5 -3
  55. package/dist/esm/anchor.d.ts.map +1 -1
  56. package/dist/esm/anchor.js +10 -4
  57. package/dist/esm/anchor.js.map +1 -1
  58. package/dist/esm/cli.d.ts +13 -19
  59. package/dist/esm/cli.d.ts.map +1 -1
  60. package/dist/esm/cli.js +88 -81
  61. package/dist/esm/cli.js.map +1 -1
  62. package/dist/esm/client.d.ts +95 -0
  63. package/dist/esm/client.d.ts.map +1 -1
  64. package/dist/esm/client.js +133 -2
  65. package/dist/esm/client.js.map +1 -1
  66. package/dist/esm/customer-keys.d.ts +73 -1
  67. package/dist/esm/customer-keys.d.ts.map +1 -1
  68. package/dist/esm/customer-keys.js +323 -3
  69. package/dist/esm/customer-keys.js.map +1 -1
  70. package/dist/esm/enclave-registration.d.ts +153 -0
  71. package/dist/esm/enclave-registration.d.ts.map +1 -0
  72. package/dist/esm/enclave-registration.js +265 -0
  73. package/dist/esm/enclave-registration.js.map +1 -0
  74. package/dist/esm/enclave-seal.d.ts +43 -0
  75. package/dist/esm/enclave-seal.d.ts.map +1 -1
  76. package/dist/esm/enclave-seal.js +61 -1
  77. package/dist/esm/enclave-seal.js.map +1 -1
  78. package/dist/esm/errors.d.ts +10 -0
  79. package/dist/esm/errors.d.ts.map +1 -1
  80. package/dist/esm/errors.js +10 -0
  81. package/dist/esm/errors.js.map +1 -1
  82. package/dist/esm/index.browser.d.ts +7 -3
  83. package/dist/esm/index.browser.d.ts.map +1 -1
  84. package/dist/esm/index.browser.js +6 -3
  85. package/dist/esm/index.browser.js.map +1 -1
  86. package/dist/esm/index.d.ts +22 -10
  87. package/dist/esm/index.d.ts.map +1 -1
  88. package/dist/esm/index.js +18 -8
  89. package/dist/esm/index.js.map +1 -1
  90. package/dist/esm/key-group.d.ts +80 -0
  91. package/dist/esm/key-group.d.ts.map +1 -0
  92. package/dist/esm/key-group.js +130 -0
  93. package/dist/esm/key-group.js.map +1 -0
  94. package/dist/esm/models.d.ts +60 -0
  95. package/dist/esm/models.d.ts.map +1 -1
  96. package/dist/esm/models.js +59 -0
  97. package/dist/esm/models.js.map +1 -1
  98. package/dist/esm/status-document.d.ts +25 -0
  99. package/dist/esm/status-document.d.ts.map +1 -0
  100. package/dist/esm/status-document.js +59 -0
  101. package/dist/esm/status-document.js.map +1 -0
  102. package/dist/esm/verify.d.ts +108 -3
  103. package/dist/esm/verify.d.ts.map +1 -1
  104. package/dist/esm/verify.js +386 -146
  105. package/dist/esm/verify.js.map +1 -1
  106. package/dist/esm/web-verifier.d.ts +23 -3
  107. package/dist/esm/web-verifier.d.ts.map +1 -1
  108. package/dist/esm/web-verifier.js +27 -5
  109. package/dist/esm/web-verifier.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/anchor.ts +10 -4
  112. package/src/cli.ts +96 -79
  113. package/src/client.ts +198 -6
  114. package/src/customer-keys.ts +373 -3
  115. package/src/enclave-registration.ts +390 -0
  116. package/src/enclave-seal.ts +108 -1
  117. package/src/errors.ts +11 -0
  118. package/src/index.browser.ts +9 -3
  119. package/src/index.ts +35 -6
  120. package/src/key-group.ts +181 -0
  121. package/src/models.ts +131 -0
  122. package/src/status-document.ts +59 -0
  123. package/src/verify.ts +499 -151
  124. package/src/web-verifier.ts +36 -3
@@ -15,7 +15,7 @@
15
15
  * certificate signing payload. issuanceBytes reconstructs json.Marshal
16
16
  * output at issuance time (transparency_status=PENDING, no transparency).
17
17
  */
18
- import { CERTIFICATE_REVOKED, UNSUPPORTED_ALGORITHM, UNSUPPORTED_FORMAT_VERSION, VerificationError, } from "./errors.js";
18
+ import { AUTHORIZATION_POLICY_FAILED, CERTIFICATE_REVOKED, UNSUPPORTED_ALGORITHM, UNSUPPORTED_FORMAT_VERSION, VerificationError, } from "./errors.js";
19
19
  // ---------------------------------------------------------------------------
20
20
  // Pure byte helpers (no Buffer, no node:crypto)
21
21
  // ---------------------------------------------------------------------------
@@ -63,8 +63,9 @@ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
63
63
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
64
64
  const FORMAT_VERSION_V8 = "8.0";
65
65
  // v9 adds two per-system fields, authorization and customer_key_id: whether the
66
- // enclave checked this system against a customer-signed registration
67
- // certificate (ADR-025 §4), and under which customer key group if it did.
66
+ // enclave checked this system against a registration certificate (ADR-025 §4),
67
+ // and under which customer key group if it did. That the key group SIGNED the
68
+ // registration holds only from enclave protocol 12: see PRE_PROTOCOL_12_IMAGES.
68
69
  //
69
70
  // The attestation gains NO field at v9. Its tag still moves, because the tag
70
71
  // follows the record's version and the record's bytes changed — a v9
@@ -208,17 +209,22 @@ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
208
209
  // in the same enclave call that signs the certificate (ADR-016 §2).
209
210
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
210
211
  const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
212
+ /** Decode hex: whole bytes, digits in either case, nothing else. Anything else
213
+ * throws TypeError. It is exported, so its input is a boundary: reading a stray
214
+ * character as 0, as it once did, turned a typo into different bytes. */
211
215
  export function hexToBytes(hex) {
212
- const len = hex.length >>> 1;
213
- const out = new Uint8Array(len);
214
- for (let i = 0; i < len; i++) {
215
- const hi = hex.charCodeAt(i * 2);
216
- const lo = hex.charCodeAt(i * 2 + 1);
217
- out[i] = (unhex(hi) << 4) | unhex(lo);
216
+ if (typeof hex !== "string")
217
+ throw new TypeError(`hex is not a string: ${typeof hex}`);
218
+ if (hex.length % 2 !== 0)
219
+ throw new TypeError(`hex has an odd number of digits: ${hex.length}`);
220
+ const out = new Uint8Array(hex.length / 2);
221
+ for (let i = 0; i < out.length; i++) {
222
+ out[i] = (unhex(hex, i * 2) << 4) | unhex(hex, i * 2 + 1);
218
223
  }
219
224
  return out;
220
225
  }
221
- function unhex(c) {
226
+ function unhex(hex, at) {
227
+ const c = hex.charCodeAt(at);
222
228
  // 0-9
223
229
  if (c >= 48 && c <= 57)
224
230
  return c - 48;
@@ -228,7 +234,7 @@ function unhex(c) {
228
234
  // A-F
229
235
  if (c >= 65 && c <= 70)
230
236
  return c - 55;
231
- return 0;
237
+ throw new TypeError(`hex has a non-hex character ${JSON.stringify(hex[at])} at ${at}`);
232
238
  }
233
239
  export function bytesToHex(bytes) {
234
240
  let out = "";
@@ -268,6 +274,13 @@ function textToBytes(s) {
268
274
  return new TextEncoder().encode(s);
269
275
  }
270
276
  export async function publicKeyFromHex(crypto, hexKey, opts) {
277
+ // Refused here, as the VerificationError callers are told to catch, rather
278
+ // than left to hexToBytes's TypeError. The alphabet is the one
279
+ // core.KeyEntry.PublicKeyInfo takes through hex.DecodeString: either case,
280
+ // whole bytes, nothing else.
281
+ if (typeof hexKey !== "string" || !HEX_TEXT_RE.test(hexKey)) {
282
+ throw new VerificationError(`public key is not hex: ${JSON.stringify(hexKey)}`);
283
+ }
271
284
  const raw = hexToBytes(hexKey);
272
285
  if (raw.length !== 32) {
273
286
  throw new VerificationError(`public key must be 32 bytes, got ${raw.length}`);
@@ -375,7 +388,7 @@ export function certificateAnchor(certificate) {
375
388
  if (transparency == null)
376
389
  return null;
377
390
  const sth = transparency.signed_tree_head;
378
- if (sth === undefined || typeof sth.timestamp !== "string")
391
+ if (sth == null || typeof sth.timestamp !== "string")
379
392
  return null;
380
393
  try {
381
394
  return parseRfc3339(sth.timestamp);
@@ -405,15 +418,22 @@ async function verifiedCertificateAnchor(crypto, certificate, pki) {
405
418
  return null;
406
419
  const transparency = certificate.transparency;
407
420
  const sth = transparency.signed_tree_head;
421
+ // Only a refusal of the head's own fields means "no anchor". A bare catch
422
+ // here gave a defect in this library — or a crypto provider that threw — the
423
+ // same quiet answer as a malformed document.
424
+ let headPayload;
425
+ let headSig;
408
426
  try {
409
- const headPayload = buildTreeHeadPayload(sth);
410
- const headSig = decodeSignature(sth.signature);
411
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
427
+ headPayload = buildTreeHeadPayload(sth);
428
+ headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
429
+ }
430
+ catch (e) {
431
+ if (e instanceof VerificationError)
412
432
  return null;
433
+ throw e;
413
434
  }
414
- catch {
435
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
415
436
  return null;
416
- }
417
437
  return anchor;
418
438
  }
419
439
  /** Throws for a key that cannot be used at all; returns the verdict otherwise.
@@ -449,14 +469,155 @@ role = "issuer key") {
449
469
  }
450
470
  return verdict;
451
471
  }
472
+ function policySet(values, field, lower) {
473
+ if (values === undefined)
474
+ return undefined;
475
+ if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
476
+ throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
477
+ }
478
+ if (values.length === 0) {
479
+ // An empty requirement requires nothing, which is never what a caller who
480
+ // built a policy meant.
481
+ throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
482
+ }
483
+ return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
484
+ }
485
+ /** Check a policy before anything is verified, so a caller's mistake surfaces as
486
+ * one whatever the record turns out to be. */
487
+ function parsePolicy(policy) {
488
+ const keys = policy.customerKeyIds;
489
+ if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
490
+ throw new TypeError("requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
491
+ "a key id you did not name proves nothing to you, because the relay can enroll a key group of its own");
492
+ }
493
+ return {
494
+ systems: policySet(policy.systems, "systems", true),
495
+ customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false),
496
+ };
497
+ }
498
+ /**
499
+ * The enclave images that issued format 9.0 while running wire protocol 11 or
500
+ * earlier, by PCR0, as the published measurement history records them
501
+ * (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
502
+ *
503
+ * Their `certified` is not evidence of a customer signature. Before protocol 12
504
+ * the enclave enrolled a key group with no proof that anyone held its keys, and
505
+ * registered a system for whoever relayed the authorization — so the host could
506
+ * make a genuine 9.0 record say `certified`, under a key id it chose, with no
507
+ * customer signature anywhere (reproduced by an independent re-review,
508
+ * 2026-09-13).
509
+ *
510
+ * THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
511
+ * admitting or rolling back to an image of protocol 11 or earlier once any
512
+ * customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
513
+ * leaves to the owner. The protocol-12 cutover's KMS tighten drops every
514
+ * measurement here from the signing key's policy, after which none of them can
515
+ * sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
516
+ * because it held the signing key while it ran.
517
+ *
518
+ * The Python SDK carries the same list, in _verify.py.
519
+ */
520
+ export const PRE_PROTOCOL_12_IMAGES = new Map([
521
+ ["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
522
+ ["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
523
+ ["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
524
+ ["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
525
+ ["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
526
+ ["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
527
+ ["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
528
+ ]);
529
+ export function issuingImage(certificate) {
530
+ const issuer = certificate.issuer;
531
+ const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
532
+ if (pcr0 === "")
533
+ return { kind: "unnamed" };
534
+ const name = PRE_PROTOCOL_12_IMAGES.get(pcr0);
535
+ return name === undefined
536
+ ? { kind: "protocol-12-or-later", pcr0 }
537
+ : { kind: "before-protocol-12", name, pcr0 };
538
+ }
539
+ /**
540
+ * What `certified` is evidence of, given the image that signed the record: the
541
+ * words every verifier prints after "certified under <key id> —". The Go CLI
542
+ * (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
543
+ * identically, and testdata/registration_line_cases.json holds them to it.
544
+ */
545
+ export function registrationQualifier(image) {
546
+ switch (image.kind) {
547
+ case "unnamed":
548
+ return ("not evidence here: this record names no enclave image, so it may come from one " +
549
+ "that could mark a system certified without the customer's signature.");
550
+ case "before-protocol-12":
551
+ return (`not evidence here: this record comes from ${image.name}, an enclave image of ` +
552
+ "protocol 11 or earlier, which could mark a system certified without the customer's signature.");
553
+ case "protocol-12-or-later":
554
+ return ("the key group with that id signed this system's registration, if that key id is yours: " +
555
+ "this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
556
+ "someone else authorized this verification.");
557
+ }
558
+ }
559
+ /**
560
+ * Throw unless a record that has already verified meets `policy`.
561
+ *
562
+ * From 9.0 every field read here is under the certificate signature, the
563
+ * issuer's enclave_pcr0 included. Below 9.0
564
+ * nothing signs `authorization` — those records carry the field outside the
565
+ * signed bytes — so one that says `certified` says only what its last holder
566
+ * typed. It fails on the format, before the field is read.
567
+ */
568
+ function enforceAuthorization(certificate, formatVersion, policy) {
569
+ const systemsWanted = policy.systems;
570
+ if (!signatureCoversAuthorization(formatVersion)) {
571
+ throw new VerificationError(`authorization required, but this record is format ${formatVersion}, which predates ` +
572
+ `customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`, AUTHORIZATION_POLICY_FAILED);
573
+ }
574
+ const systems = certificate.systems ?? [];
575
+ let required = systems;
576
+ if (systemsWanted !== undefined) {
577
+ const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
578
+ const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
579
+ if (missing.length > 0) {
580
+ throw new VerificationError(`authorization required for system ${missing[0]}, which this record does not cover`, AUTHORIZATION_POLICY_FAILED);
581
+ }
582
+ required = [...systemsWanted].sort().map((id) => byId.get(id));
583
+ }
584
+ const labelOf = (s) => `system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
585
+ for (const s of required) {
586
+ if (s.authorization !== AUTHORIZATION_CERTIFIED) {
587
+ throw new VerificationError(`authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
588
+ "the enclave did not check it against a registration", AUTHORIZATION_POLICY_FAILED);
589
+ }
590
+ }
591
+ // Asked once, for the record, after every required system has said certified:
592
+ // which image said it decides whether `certified` means a customer signed.
593
+ const image = issuingImage(certificate);
594
+ if (image.kind === "unnamed") {
595
+ throw new VerificationError("authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
596
+ "absent), so nothing shows it came from one that registers a system only with the " +
597
+ "customer's signature", AUTHORIZATION_POLICY_FAILED);
598
+ }
599
+ if (image.kind === "before-protocol-12") {
600
+ throw new VerificationError(`authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
601
+ "an enclave image of wire protocol 11 or earlier, which could register a system without " +
602
+ "the customer's signature; its `certified` does not show that any key group signed anything", AUTHORIZATION_POLICY_FAILED);
603
+ }
604
+ for (const s of required) {
605
+ if (!policy.customerKeyIds.has(s.customer_key_id)) {
606
+ throw new VerificationError(`${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
607
+ "accept; if it is not yours, someone else authorized this verification", AUTHORIZATION_POLICY_FAILED);
608
+ }
609
+ }
610
+ }
452
611
  /** Verify all signatures on a deletion certificate offline. */
453
- export async function verifyCertificate(crypto, certificate, publicKeys) {
612
+ export async function verifyCertificate(crypto, certificate, publicKeys, options) {
454
613
  // Step 0: can this SDK read the format at all? Every check below rebuilds
455
614
  // signed bytes from the version, so an unreadable version makes all of them
456
615
  // meaningless - and "unknown issuer key" would be the wrong thing to tell
457
616
  // someone holding a record that is merely newer than this library.
617
+ requireObject(certificate, "certificate");
618
+ const policy = options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
458
619
  const formatVersion = checkFormatVersion(certificate.certificate_format_version);
459
- const issuer = certificate.issuer;
620
+ const issuer = objectAt(certificate, "issuer", "issuer");
460
621
  // Step 0b: can this SDK check the algorithm the record names? Asked before
461
622
  // any signature, because verifying an Ed25519 signature over a record that
462
623
  // says it was signed with something else answers a question nobody asked.
@@ -467,7 +628,7 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
467
628
  `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
468
629
  "The record may be genuine and simply signed with a scheme this library does not implement.", UNSUPPORTED_ALGORITHM);
469
630
  }
470
- const keyId = issuer.key_id;
631
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
471
632
  const pki = publicKeys.get(keyId);
472
633
  if (pki === undefined)
473
634
  throw new VerificationError(`unknown issuer key: ${keyId}`);
@@ -477,7 +638,7 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
477
638
  // 1. Certificate signature FIRST — nothing below may trust a field until the
478
639
  // bytes carrying it are covered by a verified signature.
479
640
  const certPayload = buildCertificatePayload(certificate);
480
- const certSig = decodeSignature(certificate.certificate_signature);
641
+ const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
481
642
  if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
482
643
  throw new VerificationError("certificate signature is invalid");
483
644
  }
@@ -491,7 +652,7 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
491
652
  attested_at: att.attested_at,
492
653
  systems: attestationSystems(certificate),
493
654
  }, certificate.certificate_format_version);
494
- const attSig = decodeSignature(att.attestation_signature);
655
+ const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
495
656
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
496
657
  throw new VerificationError("attestation signature is invalid");
497
658
  }
@@ -540,6 +701,9 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
540
701
  if (!anyAttested) {
541
702
  throw new VerificationError("incomplete verification: no system held any records at attestation time");
542
703
  }
704
+ if (policy !== undefined) {
705
+ enforceAuthorization(certificate, formatVersion, policy);
706
+ }
543
707
  // A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
544
708
  // and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
545
709
  // reporting VALID here would be the lying by omission ADR-017 exists to stop.
@@ -548,13 +712,14 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
548
712
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
549
713
  /** Verify the transparency proof embedded in a certificate. */
550
714
  export async function verifyTransparency(crypto, certificate, publicKeys) {
551
- const transparency = certificate.transparency;
552
- if (transparency == null) {
715
+ requireObject(certificate, "certificate");
716
+ if (certificate.transparency == null) {
553
717
  return "NOT_AVAILABLE";
554
718
  }
555
- const sth = transparency.signed_tree_head;
556
- const issuer = certificate.issuer;
557
- const keyId = issuer.key_id;
719
+ const transparency = objectAt(certificate, "transparency", "transparency");
720
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
721
+ const issuer = objectAt(certificate, "issuer", "issuer");
722
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
558
723
  const pki = publicKeys.get(keyId);
559
724
  if (pki === undefined)
560
725
  throw new VerificationError(`unknown issuer key: ${keyId}`);
@@ -562,7 +727,7 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
562
727
  requireUsableKey(pki, certificateAnchor(certificate), keyId);
563
728
  // 1. Tree head signature
564
729
  const headPayload = buildTreeHeadPayload(sth);
565
- const headSig = decodeSignature(sth.signature);
730
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
566
731
  if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
567
732
  throw new VerificationError("tree head signature is invalid");
568
733
  }
@@ -575,10 +740,10 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
575
740
  // revocation of the same certificate produced identical leaves and the tree
576
741
  // committed to neither the entry type nor when it happened.
577
742
  const issuanceData = issuanceBytes(certificate);
578
- const leafPayload = buildLogLeafPayload(transparency.entry_type, certificate.certificate_id, await crypto.sha256(issuanceData), transparency.appended_at);
743
+ const leafPayload = buildLogLeafPayload(transparency.entry_type, stringAt(certificate, "certificate_id", "certificate_id"), await crypto.sha256(issuanceData), transparency.appended_at);
579
744
  const leaf = await hashLeaf(crypto, leafPayload);
580
- const proofHashes = (transparency.inclusion_proof ?? []).map((h) => decodeBytes(h));
581
- const root = decodeBytes(sth.root_hash);
745
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map((h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32));
746
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
582
747
  // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
583
748
  // for an explicit null and for an absent key rather than failing, so refusing
584
749
  // either would reject documents the reference accepts — the same class of
@@ -660,47 +825,89 @@ function canonicalStringify(val) {
660
825
  }
661
826
  return JSON.stringify(val);
662
827
  }
663
- // ---------------------------------------------------------------------------
664
- // Byte encoding helpers
665
- // ---------------------------------------------------------------------------
666
- /** Convert a byte field to lowercase hex. Handles:
667
- * - hex string (from hand-crafted test data)
668
- * - base64 string (from Go's json.Marshal of []byte slices)
669
- * - number[] (from Go's json.Marshal of [N]byte fixed arrays)
670
- */
671
- function toHex(value) {
672
- if (Array.isArray(value)) {
673
- return bytesToHex(new Uint8Array(value));
828
+ function requireObject(value, path) {
829
+ if (value === undefined || value === null)
830
+ throw new VerificationError(`${path} is missing`);
831
+ if (typeof value !== "object" || Array.isArray(value)) {
832
+ throw new VerificationError(`${path} is not an object`);
674
833
  }
675
- const str = value;
676
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
677
- return str.toLowerCase();
678
- }
679
- return bytesToHex(base64ToBytes(str));
834
+ return value;
835
+ }
836
+ function objectAt(parent, key, path) {
837
+ return requireObject(parent[key], path);
838
+ }
839
+ function stringAt(parent, key, path) {
840
+ const value = parent[key];
841
+ if (value === undefined || value === null)
842
+ throw new VerificationError(`${path} is missing`);
843
+ if (typeof value !== "string")
844
+ throw new VerificationError(`${path} is not a string`);
845
+ return value;
846
+ }
847
+ function optionalStringAt(parent, key, path) {
848
+ const value = parent[key];
849
+ if (value === undefined || value === null)
850
+ return undefined;
851
+ if (typeof value !== "string")
852
+ throw new VerificationError(`${path} is not a string`);
853
+ return value;
680
854
  }
681
- /** Decode a signature field to raw bytes. Same format handling as toHex. */
682
- function decodeSignature(value) {
855
+ /**
856
+ * An array field. Absent and null read as empty: Go leaves a nil slice.
857
+ *
858
+ * Returned dense. JSON cannot express a hole, but a caller building the object
859
+ * in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
860
+ * `undefined` and threw a TypeError. Array.from reads a hole as undefined,
861
+ * which the element's own reader then refuses as missing.
862
+ */
863
+ function arrayAt(parent, key, path) {
864
+ const value = parent[key];
865
+ if (value === undefined || value === null)
866
+ return [];
867
+ if (!Array.isArray(value))
868
+ throw new VerificationError(`${path} is not an array`);
869
+ return Array.from(value);
870
+ }
871
+ const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
872
+ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
873
+ /**
874
+ * Decode a fixed-size byte field — a hash, a key, a signature.
875
+ *
876
+ * Go marshals `[N]byte` as an array of numbers; hand-written documents carry
877
+ * hex or standard base64. A well-formed value of the right size is never
878
+ * ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
879
+ * in padding hex cannot contain.
880
+ *
881
+ * Everything else is refused here, by name: a number, an array element that is
882
+ * not a byte, text that is neither encoding, the wrong length. Decoding it
883
+ * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
884
+ * signature covers and blamed the signature — or threw from inside atob.
885
+ */
886
+ function decodeFixed(value, path, length) {
887
+ if (value === undefined || value === null)
888
+ throw new VerificationError(`${path} is missing`);
889
+ let raw;
683
890
  if (Array.isArray(value)) {
684
- return new Uint8Array(value);
891
+ if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
892
+ throw new VerificationError(`${path} is not an array of bytes`);
893
+ }
894
+ raw = new Uint8Array(value);
685
895
  }
686
- const str = value;
687
- if (/^[0-9a-fA-F]+$/.test(str)) {
688
- const raw = hexToBytes(str);
689
- if (raw.length === 64)
690
- return raw;
896
+ else if (typeof value === "string") {
897
+ if (HEX_TEXT_RE.test(value))
898
+ raw = hexToBytes(value);
899
+ else if (BASE64_TEXT_RE.test(value))
900
+ raw = base64ToBytes(value);
901
+ else
902
+ throw new VerificationError(`${path} is neither hex nor base64`);
691
903
  }
692
- return base64ToBytes(str);
693
- }
694
- /** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
695
- function decodeBytes(value) {
696
- if (Array.isArray(value)) {
697
- return new Uint8Array(value);
904
+ else {
905
+ throw new VerificationError(`${path} is not a byte string`);
698
906
  }
699
- const str = value;
700
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
701
- return hexToBytes(str);
907
+ if (raw.length !== length) {
908
+ throw new VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
702
909
  }
703
- return base64ToBytes(str);
910
+ return raw;
704
911
  }
705
912
  /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
706
913
  * field for an explicit JSON null or an absent key. */
@@ -729,14 +936,17 @@ const RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?
729
936
  * Exported from this module so the timestamp_vectors.json suite can diff it
730
937
  * against Go directly. It is deliberately not re-exported from index.ts, so it
731
938
  * is not part of the published package surface.
939
+ *
940
+ * `field` names the value in a refusal. It defaults to the word these messages
941
+ * always used, for callers normalizing a time that is not a field of a document.
732
942
  */
733
- export function formatTimestamp(ts) {
943
+ export function formatTimestamp(ts, field = "timestamp") {
734
944
  if (ts === null || ts === undefined)
735
945
  return ZERO_INSTANT;
736
946
  if (typeof ts !== "string") {
737
- throw new VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
947
+ throw new VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
738
948
  }
739
- return formatEpochSeconds(parseRfc3339(ts));
949
+ return formatEpochSeconds(parseRfc3339(ts, field));
740
950
  }
741
951
  /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
742
952
  *
@@ -747,20 +957,20 @@ export function formatTimestamp(ts) {
747
957
  * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
748
958
  * digits are dropped, matching Format's truncation.
749
959
  */
750
- function parseRfc3339(ts) {
960
+ function parseRfc3339(ts, field = "timestamp") {
751
961
  const m = RFC3339_RE.exec(ts);
752
962
  if (m === null)
753
- throw new VerificationError(`timestamp is not RFC 3339: ${ts}`);
963
+ throw new VerificationError(`${field} is not RFC 3339: ${ts}`);
754
964
  const [year, month, day, hour, minute, second] = m
755
965
  .slice(1, 7)
756
966
  .map((v) => Number.parseInt(v, 10));
757
967
  if (month < 1 || month > 12)
758
- throw new VerificationError(`timestamp month out of range: ${ts}`);
968
+ throw new VerificationError(`${field} month out of range: ${ts}`);
759
969
  if (day < 1 || day > daysInMonth(year, month)) {
760
- throw new VerificationError(`timestamp day out of range: ${ts}`);
970
+ throw new VerificationError(`${field} day out of range: ${ts}`);
761
971
  }
762
972
  if (hour > 23 || minute > 59 || second > 59) {
763
- throw new VerificationError(`timestamp time of day out of range: ${ts}`);
973
+ throw new VerificationError(`${field} time of day out of range: ${ts}`);
764
974
  }
765
975
  let offset = 0;
766
976
  const sign = m[7];
@@ -768,9 +978,9 @@ function parseRfc3339(ts) {
768
978
  const offHour = Number.parseInt(m[8], 10);
769
979
  const offMin = Number.parseInt(m[9], 10);
770
980
  if (offHour > 24)
771
- throw new VerificationError(`timestamp zone offset hour out of range: ${ts}`);
981
+ throw new VerificationError(`${field} zone offset hour out of range: ${ts}`);
772
982
  if (offMin > 60)
773
- throw new VerificationError(`timestamp zone offset minute out of range: ${ts}`);
983
+ throw new VerificationError(`${field} zone offset minute out of range: ${ts}`);
774
984
  offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
775
985
  }
776
986
  return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
@@ -854,17 +1064,17 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
854
1064
  // payload and call every genuine v8 record a forgery.
855
1065
  const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
856
1066
  const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
857
- const systems = att.systems.map((s) => {
1067
+ const systems = att.systems.map((s, i) => {
858
1068
  const sys = {
859
1069
  canonical_version: s.canonical_version ?? null,
860
1070
  connector_type: s.connector_type,
861
1071
  hash_scope: s.hash_scope,
862
- merkle_root: s.merkle_root
863
- ? toHex(s.merkle_root)
864
- : null,
1072
+ merkle_root: s.merkle_root == null
1073
+ ? null
1074
+ : bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
865
1075
  // The system's own observation time, not the envelope's.
866
- observed_at: formatTimestamp(s.observed_at),
867
- query_hash: toHex(s.query_hash),
1076
+ observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
1077
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
868
1078
  record_count: s.record_count,
869
1079
  system_id: s.system_id,
870
1080
  system_name: s.system_name,
@@ -882,7 +1092,7 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
882
1092
  attested_at: attestedAt,
883
1093
  payload_type: attestationPayloadType(certFormatVersion),
884
1094
  proof_mode: att.proof_mode,
885
- subject_hash: toHex(subject.identifier_hash),
1095
+ subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
886
1096
  systems,
887
1097
  };
888
1098
  return canonicalJson(payload);
@@ -948,9 +1158,9 @@ function attestationPayloadType(version) {
948
1158
  return PAYLOAD_TYPE_ATTESTATION;
949
1159
  }
950
1160
  function buildCertificatePayload(cert) {
951
- const att = cert.attestation;
952
- const issuer = cert.issuer;
953
- const subject = cert.subject;
1161
+ const att = objectAt(cert, "attestation", "attestation");
1162
+ const issuer = objectAt(cert, "issuer", "issuer");
1163
+ const subject = objectAt(cert, "subject", "subject");
954
1164
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
955
1165
  // and a verification list joined only on the human-editable system_name,
956
1166
  // which made a partial deletion indistinguishable from a complete one.
@@ -958,19 +1168,25 @@ function buildCertificatePayload(cert) {
958
1168
  const coversMeasured = signatureCoversMeasuredTransport(version);
959
1169
  const coversRecoverable = signatureCoversRecoverableState(version);
960
1170
  const coversAuthorization = signatureCoversAuthorization(version);
961
- const systems = (cert.systems ?? []).map((s) => {
1171
+ const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
1172
+ const where = `systems[${i}]`;
1173
+ const s = requireObject(entry, where);
962
1174
  const sys = {
963
- attested_at: formatTimestamp(s.attested_at),
964
- attested_count: s.attested_count,
965
- canonical_version: s.canonical_version ?? null,
966
- connector_type: s.connector_type,
967
- hash_scope: s.hash_scope,
968
- merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
969
- query_hash: toHex(s.query_hash),
970
- system_id: s.system_id,
971
- system_name: s.system_name,
972
- verified_at: formatTimestamp(s.verified_at),
973
- verified_count: s.verified_count,
1175
+ attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
1176
+ attested_count: requireUint(s.attested_count, `${where}.attested_count`),
1177
+ canonical_version: optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
1178
+ connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
1179
+ hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
1180
+ // Absent is a system with no Merkle root; anything present must be one.
1181
+ // Go's *[32]byte is nil or 32 bytes, never "" or [].
1182
+ merkle_root: s.merkle_root == null
1183
+ ? null
1184
+ : bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
1185
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
1186
+ system_id: stringAt(s, "system_id", `${where}.system_id`),
1187
+ system_name: stringAt(s, "system_name", `${where}.system_name`),
1188
+ verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
1189
+ verified_count: requireUint(s.verified_count, `${where}.verified_count`),
974
1190
  };
975
1191
  if (coversMeasured) {
976
1192
  sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name, version);
@@ -990,9 +1206,9 @@ function buildCertificatePayload(cert) {
990
1206
  // The attestation block carries no system list of its own: the merged list
991
1207
  // above is a superset of it. verification_signature is gone entirely.
992
1208
  const attObj = {
993
- attestation_signature: toHex(att.attestation_signature),
994
- attested_at: formatTimestamp(att.attested_at),
995
- proof_mode: att.proof_mode,
1209
+ attestation_signature: bytesToHex(decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64)),
1210
+ attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
1211
+ proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
996
1212
  };
997
1213
  // Fields added after v3 are gated on the certificate's OWN version. A v3
998
1214
  // certificate must reconstruct to the same bytes forever; reading these
@@ -1004,9 +1220,9 @@ function buildCertificatePayload(cert) {
1004
1220
  const isV5Plus = formatAtLeast(version, FORMAT_VERSION_V5);
1005
1221
  const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1006
1222
  const issuerObj = {
1007
- key_id: issuer.key_id,
1008
- name: issuer.name,
1009
- public_key: toHex(issuer.public_key),
1223
+ key_id: stringAt(issuer, "key_id", "issuer.key_id"),
1224
+ name: stringAt(issuer, "name", "issuer.name"),
1225
+ public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
1010
1226
  };
1011
1227
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1012
1228
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
@@ -1014,43 +1230,48 @@ function buildCertificatePayload(cert) {
1014
1230
  if (signatureCoversAlgorithm(version)) {
1015
1231
  issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer", version);
1016
1232
  }
1017
- if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1018
- issuerObj.legal_entity = issuer.legal_entity;
1233
+ // A non-string here used to be dropped silently, so a junk value added to a
1234
+ // record that never carried one still verified. Python refused it; so does
1235
+ // Go, at parse. Now this does too.
1236
+ if (isV4Plus) {
1237
+ const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
1238
+ if (legalEntity)
1239
+ issuerObj.legal_entity = legalEntity;
1019
1240
  }
1020
1241
  // Signed from v5, and omitted when absent or empty: a build with no
1021
1242
  // measurement signs none, and "" is a value a reader could mistake for one.
1022
- if (signatureCoversEnclavePcr0(version) &&
1023
- typeof issuer.enclave_pcr0 === "string" &&
1024
- issuer.enclave_pcr0 !== "") {
1025
- issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
1243
+ if (signatureCoversEnclavePcr0(version)) {
1244
+ const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
1245
+ if (pcr0)
1246
+ issuerObj.enclave_pcr0 = pcr0;
1026
1247
  }
1027
1248
  // v5 drops identifier_type_hint: signed, but always the constant "custom",
1028
1249
  // so it was never evidence. v3 and v4 keep it or they stop verifying.
1029
1250
  const subjectObj = {
1030
- identifier_hash: toHex(subject.identifier_hash),
1251
+ identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1031
1252
  };
1032
1253
  if (!isV5Plus) {
1033
- subjectObj.identifier_type_hint = subject.identifier_type_hint;
1254
+ subjectObj.identifier_type_hint = stringAt(subject, "identifier_type_hint", "subject.identifier_type_hint");
1034
1255
  }
1035
1256
  // status and revocation are deliberately absent (ADR-016 §3): a signature
1036
1257
  // commits to bytes at an instant, revocation is discovered later, so it
1037
1258
  // travels as a separate short-lived signed status statement.
1038
1259
  const payload = {
1039
1260
  attestation: attObj,
1040
- attestation_id: cert.attestation_id,
1041
- certificate_format_version: cert.certificate_format_version,
1042
- certificate_id: cert.certificate_id,
1043
- issued_at: formatTimestamp(cert.issued_at),
1261
+ attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
1262
+ certificate_format_version: version,
1263
+ certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
1264
+ issued_at: formatTimestamp(cert.issued_at, "issued_at"),
1044
1265
  issuer: issuerObj,
1045
1266
  payload_type: certificatePayloadType(version),
1046
1267
  subject: subjectObj,
1047
1268
  systems,
1048
1269
  };
1049
1270
  if (isV4Plus && cert.scope != null) {
1050
- const scope = cert.scope;
1271
+ const scope = objectAt(cert, "scope", "scope");
1051
1272
  payload.scope = {
1052
- text_sha256: toHex(scope.text_sha256),
1053
- version: scope.version,
1273
+ text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
1274
+ version: stringAt(scope, "version", "scope.version"),
1054
1275
  };
1055
1276
  }
1056
1277
  return canonicalJson(payload);
@@ -1061,7 +1282,7 @@ function buildLogLeafPayload(entryType, certificateId, certificateHash, appended
1061
1282
  throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
1062
1283
  }
1063
1284
  const payload = {
1064
- appended_at: formatTimestamp(appendedAt),
1285
+ appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
1065
1286
  certificate_hash: bytesToHex(certificateHash),
1066
1287
  certificate_id: certificateId,
1067
1288
  entry_type: entryType,
@@ -1110,13 +1331,13 @@ function attestationSystems(cert) {
1110
1331
  * between two modules of this package, not published surface.
1111
1332
  */
1112
1333
  export function buildTreeHeadPayload(head) {
1113
- const logId = head.log_id;
1114
- const named = typeof logId === "string" && logId !== "";
1334
+ const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
1335
+ const named = logId !== undefined && logId !== "";
1115
1336
  const payload = {
1116
1337
  payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
1117
- root_hash: toHex(head.root_hash),
1118
- timestamp: formatTimestamp(head.timestamp),
1119
- tree_size: head.tree_size,
1338
+ root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
1339
+ timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
1340
+ tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
1120
1341
  };
1121
1342
  if (named)
1122
1343
  payload.log_id = logId;
@@ -1256,21 +1477,21 @@ export async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRo
1256
1477
  export function buildCertificateStatusPayload(stmt) {
1257
1478
  const payload = {
1258
1479
  payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
1259
- certificate_id: stmt.certificate_id,
1260
- statement_expires_at: formatTimestamp(stmt.statement_expires_at),
1261
- statement_issued_at: formatTimestamp(stmt.statement_issued_at),
1262
- status: stmt.status,
1263
- sth_root_hash: toHex(stmt.sth_root_hash),
1264
- sth_tree_size: stmt.sth_tree_size,
1480
+ certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
1481
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
1482
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
1483
+ status: stringAt(stmt, "status", "status.status"),
1484
+ sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
1485
+ sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
1265
1486
  };
1266
- if (stmt.replacement_certificate_id != null) {
1267
- payload.replacement_certificate_id = stmt.replacement_certificate_id;
1268
- }
1487
+ const replacement = optionalStringAt(stmt, "replacement_certificate_id", "status.replacement_certificate_id");
1488
+ if (replacement !== undefined)
1489
+ payload.replacement_certificate_id = replacement;
1269
1490
  if (stmt.revocation_log_index != null) {
1270
- payload.revocation_log_index = stmt.revocation_log_index;
1491
+ payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
1271
1492
  }
1272
1493
  if (stmt.revoked_at != null) {
1273
- payload.revoked_at = formatTimestamp(stmt.revoked_at);
1494
+ payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
1274
1495
  }
1275
1496
  return canonicalJson(payload);
1276
1497
  }
@@ -1305,7 +1526,7 @@ export function buildKeyListPayload(doc) {
1305
1526
  keys: entries,
1306
1527
  statement_expires_at: formatTimestamp(doc.statement_expires_at),
1307
1528
  statement_issued_at: formatTimestamp(doc.statement_issued_at),
1308
- sth_root_hash: toHex(doc.sth_root_hash),
1529
+ sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
1309
1530
  sth_tree_size: doc.sth_tree_size,
1310
1531
  };
1311
1532
  return canonicalJson(payload);
@@ -1331,18 +1552,38 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1331
1552
  *
1332
1553
  * The third row is the point: `verifyCertificate` answers VALID there, which
1333
1554
  * reads as "not revoked" and is not something it checked.
1555
+ *
1556
+ * `options` is passed to {@link verifyCertificate} unchanged.
1334
1557
  */
1335
- export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
1558
+ export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now, options) {
1336
1559
  // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1337
1560
  // two soft verdicts. Returning a soft verdict here skipped every check below
1338
1561
  // it: a revoked certificate signed by a compromised-later key answered
1339
1562
  // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1340
1563
  // verdict is held instead, and resolved against what the statement says.
1341
- const base = await verifyCertificate(crypto, certificate, publicKeys);
1564
+ const base = await verifyCertificate(crypto, certificate, publicKeys, options);
1342
1565
  if (status == null) {
1343
1566
  return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1344
1567
  }
1345
- const keyId = status.key_id;
1568
+ return resolveWithBase(base, await verifyStatusStatement(crypto, status, publicKeys, certificate.certificate_id, now));
1569
+ }
1570
+ /**
1571
+ * The statement half of {@link verifyCertificateWithStatus}: authenticate a
1572
+ * status statement about `certificateId` against the published keys, and read
1573
+ * it at `now`. The /verify page's QR lookup has a record id and no record, and
1574
+ * uses this so it shows what a signed, fresh statement says, never what the
1575
+ * status endpoint claims.
1576
+ *
1577
+ * | input | result |
1578
+ * |---|---|
1579
+ * | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
1580
+ * | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
1581
+ * | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
1582
+ * | unknown or unusable key, bad signature, another certificate | throws VerificationError |
1583
+ */
1584
+ export async function verifyStatusStatement(crypto, status, publicKeys, certificateId, now) {
1585
+ requireObject(status, "status");
1586
+ const keyId = optionalStringAt(status, "key_id", "status.key_id");
1346
1587
  const info = keyId ? publicKeys.get(keyId) : undefined;
1347
1588
  if (!info) {
1348
1589
  throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
@@ -1363,10 +1604,10 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
1363
1604
  const at = formatTimestamp(when.toISOString());
1364
1605
  const statusKeyVerdict = requireUsableKey(info, parseRfc3339(at), keyId, "status statement key");
1365
1606
  const payload = buildCertificateStatusPayload(status);
1366
- // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
1607
+ // decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
1367
1608
  // array depending on the producer, and every other signature on this path
1368
1609
  // goes through the same decoder.
1369
- const sig = decodeSignature(status.signature);
1610
+ const sig = decodeFixed(status.signature, "status.signature", 64);
1370
1611
  if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
1371
1612
  throw new VerificationError("status statement signature is invalid");
1372
1613
  }
@@ -1383,8 +1624,7 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
1383
1624
  // Every signature still verified; the binding was the only forged part.
1384
1625
  // core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
1385
1626
  // while both SDKs and the browser bundle said VALID.
1386
- const certId = certificate.certificate_id;
1387
- if (status.certificate_id !== certId) {
1627
+ if (status.certificate_id !== certificateId) {
1388
1628
  throw new VerificationError("status statement is about a different certificate");
1389
1629
  }
1390
1630
  // Compared in the normalized form the payload signs, so the freshness check
@@ -1394,7 +1634,7 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
1394
1634
  if (at < issued || at >= expires) {
1395
1635
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
1396
1636
  // statement, which must never decay into VALID.
1397
- return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1637
+ return VALID_REVOCATION_UNKNOWN;
1398
1638
  }
1399
1639
  if (status.status === "REVOKED") {
1400
1640
  // Coded, because this is the only status-path rejection that is a fact
@@ -1409,12 +1649,12 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
1409
1649
  // case — was reported as revocation CHECKED AND PASSED. Mirrors
1410
1650
  // core.ValidCertificateStatusValue.
1411
1651
  if (!KNOWN_STATUS_VALUES.has(status.status)) {
1412
- return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1652
+ return VALID_REVOCATION_UNKNOWN;
1413
1653
  }
1414
1654
  // A soft verdict on the status key is the result, not a footnote — but it is
1415
1655
  // reported only once the statement has authenticated and been read, so it can
1416
1656
  // neither speak for an unverified statement nor suppress a revocation.
1417
- return resolveWithBase(base, statusKeyVerdict);
1657
+ return statusKeyVerdict;
1418
1658
  }
1419
1659
  /**
1420
1660
  * Picks what to report when the certificate's own key returned a soft verdict