burnledger 0.8.0 → 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 +146 -0
  11. package/dist/cjs/customer-keys.d.ts.map +1 -0
  12. package/dist/cjs/customer-keys.js +465 -0
  13. package/dist/cjs/customer-keys.js.map +1 -0
  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 +23 -9
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/cjs/index.js +40 -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 +112 -3
  47. package/dist/cjs/verify.d.ts.map +1 -1
  48. package/dist/cjs/verify.js +393 -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 +109 -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 +146 -0
  67. package/dist/esm/customer-keys.d.ts.map +1 -0
  68. package/dist/esm/customer-keys.js +450 -0
  69. package/dist/esm/customer-keys.js.map +1 -0
  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 +23 -9
  87. package/dist/esm/index.d.ts.map +1 -1
  88. package/dist/esm/index.js +18 -7
  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 +112 -3
  103. package/dist/esm/verify.d.ts.map +1 -1
  104. package/dist/esm/verify.js +390 -147
  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 +122 -79
  113. package/src/client.ts +198 -6
  114. package/src/customer-keys.ts +550 -0
  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 +48 -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 +503 -152
  124. package/src/web-verifier.ts +36 -3
@@ -17,22 +17,26 @@
17
17
  * output at issuance time (transparency_status=PENDING, no transparency).
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.VALID_REVOCATION_UNKNOWN = exports.VALID_KEY_WINDOW_UNKNOWN = exports.VALID_KEY_COMPROMISED_LATER = exports.KEY_COMPROMISED = exports.KEY_OUTSIDE_VALIDITY = exports.KNOWN_FORMAT_VERSIONS = void 0;
20
+ exports.VALID_REVOCATION_UNKNOWN = exports.PRE_PROTOCOL_12_IMAGES = exports.VALID_KEY_WINDOW_UNKNOWN = exports.VALID_KEY_COMPROMISED_LATER = exports.KEY_COMPROMISED = exports.KEY_OUTSIDE_VALIDITY = exports.KNOWN_FORMAT_VERSIONS = void 0;
21
21
  exports.hexToBytes = hexToBytes;
22
22
  exports.bytesToHex = bytesToHex;
23
23
  exports.publicKeyFromHex = publicKeyFromHex;
24
24
  exports.keyIsUsable = keyIsUsable;
25
25
  exports.evaluateKey = evaluateKey;
26
26
  exports.certificateAnchor = certificateAnchor;
27
+ exports.issuingImage = issuingImage;
28
+ exports.registrationQualifier = registrationQualifier;
27
29
  exports.verifyCertificate = verifyCertificate;
28
30
  exports.verifyTransparency = verifyTransparency;
29
31
  exports.issuanceBytes = issuanceBytes;
32
+ exports.canonicalJson = canonicalJson;
30
33
  exports.formatTimestamp = formatTimestamp;
31
34
  exports.buildTreeHeadPayload = buildTreeHeadPayload;
32
35
  exports.verifyConsistency = verifyConsistency;
33
36
  exports.buildCertificateStatusPayload = buildCertificateStatusPayload;
34
37
  exports.buildKeyListPayload = buildKeyListPayload;
35
38
  exports.verifyCertificateWithStatus = verifyCertificateWithStatus;
39
+ exports.verifyStatusStatement = verifyStatusStatement;
36
40
  const errors_js_1 = require("./errors.js");
37
41
  // ---------------------------------------------------------------------------
38
42
  // Pure byte helpers (no Buffer, no node:crypto)
@@ -81,8 +85,9 @@ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
81
85
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
82
86
  const FORMAT_VERSION_V8 = "8.0";
83
87
  // v9 adds two per-system fields, authorization and customer_key_id: whether the
84
- // enclave checked this system against a customer-signed registration
85
- // certificate (ADR-025 §4), and under which customer key group if it did.
88
+ // enclave checked this system against a registration certificate (ADR-025 §4),
89
+ // and under which customer key group if it did. That the key group SIGNED the
90
+ // registration holds only from enclave protocol 12: see PRE_PROTOCOL_12_IMAGES.
86
91
  //
87
92
  // The attestation gains NO field at v9. Its tag still moves, because the tag
88
93
  // follows the record's version and the record's bytes changed — a v9
@@ -226,17 +231,22 @@ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
226
231
  // in the same enclave call that signs the certificate (ADR-016 §2).
227
232
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
228
233
  const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
234
+ /** Decode hex: whole bytes, digits in either case, nothing else. Anything else
235
+ * throws TypeError. It is exported, so its input is a boundary: reading a stray
236
+ * character as 0, as it once did, turned a typo into different bytes. */
229
237
  function hexToBytes(hex) {
230
- const len = hex.length >>> 1;
231
- const out = new Uint8Array(len);
232
- for (let i = 0; i < len; i++) {
233
- const hi = hex.charCodeAt(i * 2);
234
- const lo = hex.charCodeAt(i * 2 + 1);
235
- out[i] = (unhex(hi) << 4) | unhex(lo);
238
+ if (typeof hex !== "string")
239
+ throw new TypeError(`hex is not a string: ${typeof hex}`);
240
+ if (hex.length % 2 !== 0)
241
+ throw new TypeError(`hex has an odd number of digits: ${hex.length}`);
242
+ const out = new Uint8Array(hex.length / 2);
243
+ for (let i = 0; i < out.length; i++) {
244
+ out[i] = (unhex(hex, i * 2) << 4) | unhex(hex, i * 2 + 1);
236
245
  }
237
246
  return out;
238
247
  }
239
- function unhex(c) {
248
+ function unhex(hex, at) {
249
+ const c = hex.charCodeAt(at);
240
250
  // 0-9
241
251
  if (c >= 48 && c <= 57)
242
252
  return c - 48;
@@ -246,7 +256,7 @@ function unhex(c) {
246
256
  // A-F
247
257
  if (c >= 65 && c <= 70)
248
258
  return c - 55;
249
- return 0;
259
+ throw new TypeError(`hex has a non-hex character ${JSON.stringify(hex[at])} at ${at}`);
250
260
  }
251
261
  function bytesToHex(bytes) {
252
262
  let out = "";
@@ -286,6 +296,13 @@ function textToBytes(s) {
286
296
  return new TextEncoder().encode(s);
287
297
  }
288
298
  async function publicKeyFromHex(crypto, hexKey, opts) {
299
+ // Refused here, as the VerificationError callers are told to catch, rather
300
+ // than left to hexToBytes's TypeError. The alphabet is the one
301
+ // core.KeyEntry.PublicKeyInfo takes through hex.DecodeString: either case,
302
+ // whole bytes, nothing else.
303
+ if (typeof hexKey !== "string" || !HEX_TEXT_RE.test(hexKey)) {
304
+ throw new errors_js_1.VerificationError(`public key is not hex: ${JSON.stringify(hexKey)}`);
305
+ }
289
306
  const raw = hexToBytes(hexKey);
290
307
  if (raw.length !== 32) {
291
308
  throw new errors_js_1.VerificationError(`public key must be 32 bytes, got ${raw.length}`);
@@ -393,7 +410,7 @@ function certificateAnchor(certificate) {
393
410
  if (transparency == null)
394
411
  return null;
395
412
  const sth = transparency.signed_tree_head;
396
- if (sth === undefined || typeof sth.timestamp !== "string")
413
+ if (sth == null || typeof sth.timestamp !== "string")
397
414
  return null;
398
415
  try {
399
416
  return parseRfc3339(sth.timestamp);
@@ -423,15 +440,22 @@ async function verifiedCertificateAnchor(crypto, certificate, pki) {
423
440
  return null;
424
441
  const transparency = certificate.transparency;
425
442
  const sth = transparency.signed_tree_head;
443
+ // Only a refusal of the head's own fields means "no anchor". A bare catch
444
+ // here gave a defect in this library — or a crypto provider that threw — the
445
+ // same quiet answer as a malformed document.
446
+ let headPayload;
447
+ let headSig;
426
448
  try {
427
- const headPayload = buildTreeHeadPayload(sth);
428
- const headSig = decodeSignature(sth.signature);
429
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
449
+ headPayload = buildTreeHeadPayload(sth);
450
+ headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
451
+ }
452
+ catch (e) {
453
+ if (e instanceof errors_js_1.VerificationError)
430
454
  return null;
455
+ throw e;
431
456
  }
432
- catch {
457
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
433
458
  return null;
434
- }
435
459
  return anchor;
436
460
  }
437
461
  /** Throws for a key that cannot be used at all; returns the verdict otherwise.
@@ -467,14 +491,155 @@ role = "issuer key") {
467
491
  }
468
492
  return verdict;
469
493
  }
494
+ function policySet(values, field, lower) {
495
+ if (values === undefined)
496
+ return undefined;
497
+ if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
498
+ throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
499
+ }
500
+ if (values.length === 0) {
501
+ // An empty requirement requires nothing, which is never what a caller who
502
+ // built a policy meant.
503
+ throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
504
+ }
505
+ return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
506
+ }
507
+ /** Check a policy before anything is verified, so a caller's mistake surfaces as
508
+ * one whatever the record turns out to be. */
509
+ function parsePolicy(policy) {
510
+ const keys = policy.customerKeyIds;
511
+ if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
512
+ throw new TypeError("requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
513
+ "a key id you did not name proves nothing to you, because the relay can enroll a key group of its own");
514
+ }
515
+ return {
516
+ systems: policySet(policy.systems, "systems", true),
517
+ customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false),
518
+ };
519
+ }
520
+ /**
521
+ * The enclave images that issued format 9.0 while running wire protocol 11 or
522
+ * earlier, by PCR0, as the published measurement history records them
523
+ * (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
524
+ *
525
+ * Their `certified` is not evidence of a customer signature. Before protocol 12
526
+ * the enclave enrolled a key group with no proof that anyone held its keys, and
527
+ * registered a system for whoever relayed the authorization — so the host could
528
+ * make a genuine 9.0 record say `certified`, under a key id it chose, with no
529
+ * customer signature anywhere (reproduced by an independent re-review,
530
+ * 2026-09-13).
531
+ *
532
+ * THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
533
+ * admitting or rolling back to an image of protocol 11 or earlier once any
534
+ * customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
535
+ * leaves to the owner. The protocol-12 cutover's KMS tighten drops every
536
+ * measurement here from the signing key's policy, after which none of them can
537
+ * sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
538
+ * because it held the signing key while it ran.
539
+ *
540
+ * The Python SDK carries the same list, in _verify.py.
541
+ */
542
+ exports.PRE_PROTOCOL_12_IMAGES = new Map([
543
+ ["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
544
+ ["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
545
+ ["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
546
+ ["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
547
+ ["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
548
+ ["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
549
+ ["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
550
+ ]);
551
+ function issuingImage(certificate) {
552
+ const issuer = certificate.issuer;
553
+ const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
554
+ if (pcr0 === "")
555
+ return { kind: "unnamed" };
556
+ const name = exports.PRE_PROTOCOL_12_IMAGES.get(pcr0);
557
+ return name === undefined
558
+ ? { kind: "protocol-12-or-later", pcr0 }
559
+ : { kind: "before-protocol-12", name, pcr0 };
560
+ }
561
+ /**
562
+ * What `certified` is evidence of, given the image that signed the record: the
563
+ * words every verifier prints after "certified under <key id> —". The Go CLI
564
+ * (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
565
+ * identically, and testdata/registration_line_cases.json holds them to it.
566
+ */
567
+ function registrationQualifier(image) {
568
+ switch (image.kind) {
569
+ case "unnamed":
570
+ return ("not evidence here: this record names no enclave image, so it may come from one " +
571
+ "that could mark a system certified without the customer's signature.");
572
+ case "before-protocol-12":
573
+ return (`not evidence here: this record comes from ${image.name}, an enclave image of ` +
574
+ "protocol 11 or earlier, which could mark a system certified without the customer's signature.");
575
+ case "protocol-12-or-later":
576
+ return ("the key group with that id signed this system's registration, if that key id is yours: " +
577
+ "this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
578
+ "someone else authorized this verification.");
579
+ }
580
+ }
581
+ /**
582
+ * Throw unless a record that has already verified meets `policy`.
583
+ *
584
+ * From 9.0 every field read here is under the certificate signature, the
585
+ * issuer's enclave_pcr0 included. Below 9.0
586
+ * nothing signs `authorization` — those records carry the field outside the
587
+ * signed bytes — so one that says `certified` says only what its last holder
588
+ * typed. It fails on the format, before the field is read.
589
+ */
590
+ function enforceAuthorization(certificate, formatVersion, policy) {
591
+ const systemsWanted = policy.systems;
592
+ if (!signatureCoversAuthorization(formatVersion)) {
593
+ throw new errors_js_1.VerificationError(`authorization required, but this record is format ${formatVersion}, which predates ` +
594
+ `customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`, errors_js_1.AUTHORIZATION_POLICY_FAILED);
595
+ }
596
+ const systems = certificate.systems ?? [];
597
+ let required = systems;
598
+ if (systemsWanted !== undefined) {
599
+ const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
600
+ const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
601
+ if (missing.length > 0) {
602
+ throw new errors_js_1.VerificationError(`authorization required for system ${missing[0]}, which this record does not cover`, errors_js_1.AUTHORIZATION_POLICY_FAILED);
603
+ }
604
+ required = [...systemsWanted].sort().map((id) => byId.get(id));
605
+ }
606
+ const labelOf = (s) => `system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
607
+ for (const s of required) {
608
+ if (s.authorization !== AUTHORIZATION_CERTIFIED) {
609
+ throw new errors_js_1.VerificationError(`authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
610
+ "the enclave did not check it against a registration", errors_js_1.AUTHORIZATION_POLICY_FAILED);
611
+ }
612
+ }
613
+ // Asked once, for the record, after every required system has said certified:
614
+ // which image said it decides whether `certified` means a customer signed.
615
+ const image = issuingImage(certificate);
616
+ if (image.kind === "unnamed") {
617
+ throw new errors_js_1.VerificationError("authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
618
+ "absent), so nothing shows it came from one that registers a system only with the " +
619
+ "customer's signature", errors_js_1.AUTHORIZATION_POLICY_FAILED);
620
+ }
621
+ if (image.kind === "before-protocol-12") {
622
+ throw new errors_js_1.VerificationError(`authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
623
+ "an enclave image of wire protocol 11 or earlier, which could register a system without " +
624
+ "the customer's signature; its `certified` does not show that any key group signed anything", errors_js_1.AUTHORIZATION_POLICY_FAILED);
625
+ }
626
+ for (const s of required) {
627
+ if (!policy.customerKeyIds.has(s.customer_key_id)) {
628
+ throw new errors_js_1.VerificationError(`${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
629
+ "accept; if it is not yours, someone else authorized this verification", errors_js_1.AUTHORIZATION_POLICY_FAILED);
630
+ }
631
+ }
632
+ }
470
633
  /** Verify all signatures on a deletion certificate offline. */
471
- async function verifyCertificate(crypto, certificate, publicKeys) {
634
+ async function verifyCertificate(crypto, certificate, publicKeys, options) {
472
635
  // Step 0: can this SDK read the format at all? Every check below rebuilds
473
636
  // signed bytes from the version, so an unreadable version makes all of them
474
637
  // meaningless - and "unknown issuer key" would be the wrong thing to tell
475
638
  // someone holding a record that is merely newer than this library.
639
+ requireObject(certificate, "certificate");
640
+ const policy = options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
476
641
  const formatVersion = checkFormatVersion(certificate.certificate_format_version);
477
- const issuer = certificate.issuer;
642
+ const issuer = objectAt(certificate, "issuer", "issuer");
478
643
  // Step 0b: can this SDK check the algorithm the record names? Asked before
479
644
  // any signature, because verifying an Ed25519 signature over a record that
480
645
  // says it was signed with something else answers a question nobody asked.
@@ -485,7 +650,7 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
485
650
  `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
486
651
  "The record may be genuine and simply signed with a scheme this library does not implement.", errors_js_1.UNSUPPORTED_ALGORITHM);
487
652
  }
488
- const keyId = issuer.key_id;
653
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
489
654
  const pki = publicKeys.get(keyId);
490
655
  if (pki === undefined)
491
656
  throw new errors_js_1.VerificationError(`unknown issuer key: ${keyId}`);
@@ -495,7 +660,7 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
495
660
  // 1. Certificate signature FIRST — nothing below may trust a field until the
496
661
  // bytes carrying it are covered by a verified signature.
497
662
  const certPayload = buildCertificatePayload(certificate);
498
- const certSig = decodeSignature(certificate.certificate_signature);
663
+ const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
499
664
  if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
500
665
  throw new errors_js_1.VerificationError("certificate signature is invalid");
501
666
  }
@@ -509,7 +674,7 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
509
674
  attested_at: att.attested_at,
510
675
  systems: attestationSystems(certificate),
511
676
  }, certificate.certificate_format_version);
512
- const attSig = decodeSignature(att.attestation_signature);
677
+ const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
513
678
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
514
679
  throw new errors_js_1.VerificationError("attestation signature is invalid");
515
680
  }
@@ -558,6 +723,9 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
558
723
  if (!anyAttested) {
559
724
  throw new errors_js_1.VerificationError("incomplete verification: no system held any records at attestation time");
560
725
  }
726
+ if (policy !== undefined) {
727
+ enforceAuthorization(certificate, formatVersion, policy);
728
+ }
561
729
  // A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
562
730
  // and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
563
731
  // reporting VALID here would be the lying by omission ADR-017 exists to stop.
@@ -566,13 +734,14 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
566
734
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
567
735
  /** Verify the transparency proof embedded in a certificate. */
568
736
  async function verifyTransparency(crypto, certificate, publicKeys) {
569
- const transparency = certificate.transparency;
570
- if (transparency == null) {
737
+ requireObject(certificate, "certificate");
738
+ if (certificate.transparency == null) {
571
739
  return "NOT_AVAILABLE";
572
740
  }
573
- const sth = transparency.signed_tree_head;
574
- const issuer = certificate.issuer;
575
- const keyId = issuer.key_id;
741
+ const transparency = objectAt(certificate, "transparency", "transparency");
742
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
743
+ const issuer = objectAt(certificate, "issuer", "issuer");
744
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
576
745
  const pki = publicKeys.get(keyId);
577
746
  if (pki === undefined)
578
747
  throw new errors_js_1.VerificationError(`unknown issuer key: ${keyId}`);
@@ -580,7 +749,7 @@ async function verifyTransparency(crypto, certificate, publicKeys) {
580
749
  requireUsableKey(pki, certificateAnchor(certificate), keyId);
581
750
  // 1. Tree head signature
582
751
  const headPayload = buildTreeHeadPayload(sth);
583
- const headSig = decodeSignature(sth.signature);
752
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
584
753
  if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
585
754
  throw new errors_js_1.VerificationError("tree head signature is invalid");
586
755
  }
@@ -593,10 +762,10 @@ async function verifyTransparency(crypto, certificate, publicKeys) {
593
762
  // revocation of the same certificate produced identical leaves and the tree
594
763
  // committed to neither the entry type nor when it happened.
595
764
  const issuanceData = issuanceBytes(certificate);
596
- const leafPayload = buildLogLeafPayload(transparency.entry_type, certificate.certificate_id, await crypto.sha256(issuanceData), transparency.appended_at);
765
+ const leafPayload = buildLogLeafPayload(transparency.entry_type, stringAt(certificate, "certificate_id", "certificate_id"), await crypto.sha256(issuanceData), transparency.appended_at);
597
766
  const leaf = await hashLeaf(crypto, leafPayload);
598
- const proofHashes = (transparency.inclusion_proof ?? []).map((h) => decodeBytes(h));
599
- const root = decodeBytes(sth.root_hash);
767
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map((h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32));
768
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
600
769
  // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
601
770
  // for an explicit null and for an absent key rather than failing, so refusing
602
771
  // either would reject documents the reference accepts — the same class of
@@ -652,6 +821,9 @@ function issuanceBytes(certificate) {
652
821
  // ---------------------------------------------------------------------------
653
822
  // RFC 8785 Canonical JSON
654
823
  // ---------------------------------------------------------------------------
824
+ /** Exported for tests/verify.test.ts, which keeps a second implementation of
825
+ * this and must be able to prove the two agree. Not re-exported from index.ts,
826
+ * so it stays off the published API. */
655
827
  function canonicalJson(obj) {
656
828
  return textToBytes(canonicalStringify(obj));
657
829
  }
@@ -675,47 +847,89 @@ function canonicalStringify(val) {
675
847
  }
676
848
  return JSON.stringify(val);
677
849
  }
678
- // ---------------------------------------------------------------------------
679
- // Byte encoding helpers
680
- // ---------------------------------------------------------------------------
681
- /** Convert a byte field to lowercase hex. Handles:
682
- * - hex string (from hand-crafted test data)
683
- * - base64 string (from Go's json.Marshal of []byte slices)
684
- * - number[] (from Go's json.Marshal of [N]byte fixed arrays)
685
- */
686
- function toHex(value) {
687
- if (Array.isArray(value)) {
688
- return bytesToHex(new Uint8Array(value));
850
+ function requireObject(value, path) {
851
+ if (value === undefined || value === null)
852
+ throw new errors_js_1.VerificationError(`${path} is missing`);
853
+ if (typeof value !== "object" || Array.isArray(value)) {
854
+ throw new errors_js_1.VerificationError(`${path} is not an object`);
689
855
  }
690
- const str = value;
691
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
692
- return str.toLowerCase();
693
- }
694
- return bytesToHex(base64ToBytes(str));
856
+ return value;
857
+ }
858
+ function objectAt(parent, key, path) {
859
+ return requireObject(parent[key], path);
860
+ }
861
+ function stringAt(parent, key, path) {
862
+ const value = parent[key];
863
+ if (value === undefined || value === null)
864
+ throw new errors_js_1.VerificationError(`${path} is missing`);
865
+ if (typeof value !== "string")
866
+ throw new errors_js_1.VerificationError(`${path} is not a string`);
867
+ return value;
868
+ }
869
+ function optionalStringAt(parent, key, path) {
870
+ const value = parent[key];
871
+ if (value === undefined || value === null)
872
+ return undefined;
873
+ if (typeof value !== "string")
874
+ throw new errors_js_1.VerificationError(`${path} is not a string`);
875
+ return value;
695
876
  }
696
- /** Decode a signature field to raw bytes. Same format handling as toHex. */
697
- function decodeSignature(value) {
877
+ /**
878
+ * An array field. Absent and null read as empty: Go leaves a nil slice.
879
+ *
880
+ * Returned dense. JSON cannot express a hole, but a caller building the object
881
+ * in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
882
+ * `undefined` and threw a TypeError. Array.from reads a hole as undefined,
883
+ * which the element's own reader then refuses as missing.
884
+ */
885
+ function arrayAt(parent, key, path) {
886
+ const value = parent[key];
887
+ if (value === undefined || value === null)
888
+ return [];
889
+ if (!Array.isArray(value))
890
+ throw new errors_js_1.VerificationError(`${path} is not an array`);
891
+ return Array.from(value);
892
+ }
893
+ const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
894
+ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
895
+ /**
896
+ * Decode a fixed-size byte field — a hash, a key, a signature.
897
+ *
898
+ * Go marshals `[N]byte` as an array of numbers; hand-written documents carry
899
+ * hex or standard base64. A well-formed value of the right size is never
900
+ * ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
901
+ * in padding hex cannot contain.
902
+ *
903
+ * Everything else is refused here, by name: a number, an array element that is
904
+ * not a byte, text that is neither encoding, the wrong length. Decoding it
905
+ * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
906
+ * signature covers and blamed the signature — or threw from inside atob.
907
+ */
908
+ function decodeFixed(value, path, length) {
909
+ if (value === undefined || value === null)
910
+ throw new errors_js_1.VerificationError(`${path} is missing`);
911
+ let raw;
698
912
  if (Array.isArray(value)) {
699
- return new Uint8Array(value);
913
+ if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
914
+ throw new errors_js_1.VerificationError(`${path} is not an array of bytes`);
915
+ }
916
+ raw = new Uint8Array(value);
700
917
  }
701
- const str = value;
702
- if (/^[0-9a-fA-F]+$/.test(str)) {
703
- const raw = hexToBytes(str);
704
- if (raw.length === 64)
705
- return raw;
918
+ else if (typeof value === "string") {
919
+ if (HEX_TEXT_RE.test(value))
920
+ raw = hexToBytes(value);
921
+ else if (BASE64_TEXT_RE.test(value))
922
+ raw = base64ToBytes(value);
923
+ else
924
+ throw new errors_js_1.VerificationError(`${path} is neither hex nor base64`);
706
925
  }
707
- return base64ToBytes(str);
708
- }
709
- /** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
710
- function decodeBytes(value) {
711
- if (Array.isArray(value)) {
712
- return new Uint8Array(value);
926
+ else {
927
+ throw new errors_js_1.VerificationError(`${path} is not a byte string`);
713
928
  }
714
- const str = value;
715
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
716
- return hexToBytes(str);
929
+ if (raw.length !== length) {
930
+ throw new errors_js_1.VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
717
931
  }
718
- return base64ToBytes(str);
932
+ return raw;
719
933
  }
720
934
  /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
721
935
  * field for an explicit JSON null or an absent key. */
@@ -744,14 +958,17 @@ const RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?
744
958
  * Exported from this module so the timestamp_vectors.json suite can diff it
745
959
  * against Go directly. It is deliberately not re-exported from index.ts, so it
746
960
  * is not part of the published package surface.
961
+ *
962
+ * `field` names the value in a refusal. It defaults to the word these messages
963
+ * always used, for callers normalizing a time that is not a field of a document.
747
964
  */
748
- function formatTimestamp(ts) {
965
+ function formatTimestamp(ts, field = "timestamp") {
749
966
  if (ts === null || ts === undefined)
750
967
  return ZERO_INSTANT;
751
968
  if (typeof ts !== "string") {
752
- throw new errors_js_1.VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
969
+ throw new errors_js_1.VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
753
970
  }
754
- return formatEpochSeconds(parseRfc3339(ts));
971
+ return formatEpochSeconds(parseRfc3339(ts, field));
755
972
  }
756
973
  /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
757
974
  *
@@ -762,20 +979,20 @@ function formatTimestamp(ts) {
762
979
  * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
763
980
  * digits are dropped, matching Format's truncation.
764
981
  */
765
- function parseRfc3339(ts) {
982
+ function parseRfc3339(ts, field = "timestamp") {
766
983
  const m = RFC3339_RE.exec(ts);
767
984
  if (m === null)
768
- throw new errors_js_1.VerificationError(`timestamp is not RFC 3339: ${ts}`);
985
+ throw new errors_js_1.VerificationError(`${field} is not RFC 3339: ${ts}`);
769
986
  const [year, month, day, hour, minute, second] = m
770
987
  .slice(1, 7)
771
988
  .map((v) => Number.parseInt(v, 10));
772
989
  if (month < 1 || month > 12)
773
- throw new errors_js_1.VerificationError(`timestamp month out of range: ${ts}`);
990
+ throw new errors_js_1.VerificationError(`${field} month out of range: ${ts}`);
774
991
  if (day < 1 || day > daysInMonth(year, month)) {
775
- throw new errors_js_1.VerificationError(`timestamp day out of range: ${ts}`);
992
+ throw new errors_js_1.VerificationError(`${field} day out of range: ${ts}`);
776
993
  }
777
994
  if (hour > 23 || minute > 59 || second > 59) {
778
- throw new errors_js_1.VerificationError(`timestamp time of day out of range: ${ts}`);
995
+ throw new errors_js_1.VerificationError(`${field} time of day out of range: ${ts}`);
779
996
  }
780
997
  let offset = 0;
781
998
  const sign = m[7];
@@ -783,9 +1000,9 @@ function parseRfc3339(ts) {
783
1000
  const offHour = Number.parseInt(m[8], 10);
784
1001
  const offMin = Number.parseInt(m[9], 10);
785
1002
  if (offHour > 24)
786
- throw new errors_js_1.VerificationError(`timestamp zone offset hour out of range: ${ts}`);
1003
+ throw new errors_js_1.VerificationError(`${field} zone offset hour out of range: ${ts}`);
787
1004
  if (offMin > 60)
788
- throw new errors_js_1.VerificationError(`timestamp zone offset minute out of range: ${ts}`);
1005
+ throw new errors_js_1.VerificationError(`${field} zone offset minute out of range: ${ts}`);
789
1006
  offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
790
1007
  }
791
1008
  return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
@@ -869,17 +1086,17 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
869
1086
  // payload and call every genuine v8 record a forgery.
870
1087
  const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
871
1088
  const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
872
- const systems = att.systems.map((s) => {
1089
+ const systems = att.systems.map((s, i) => {
873
1090
  const sys = {
874
1091
  canonical_version: s.canonical_version ?? null,
875
1092
  connector_type: s.connector_type,
876
1093
  hash_scope: s.hash_scope,
877
- merkle_root: s.merkle_root
878
- ? toHex(s.merkle_root)
879
- : null,
1094
+ merkle_root: s.merkle_root == null
1095
+ ? null
1096
+ : bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
880
1097
  // The system's own observation time, not the envelope's.
881
- observed_at: formatTimestamp(s.observed_at),
882
- query_hash: toHex(s.query_hash),
1098
+ observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
1099
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
883
1100
  record_count: s.record_count,
884
1101
  system_id: s.system_id,
885
1102
  system_name: s.system_name,
@@ -897,7 +1114,7 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
897
1114
  attested_at: attestedAt,
898
1115
  payload_type: attestationPayloadType(certFormatVersion),
899
1116
  proof_mode: att.proof_mode,
900
- subject_hash: toHex(subject.identifier_hash),
1117
+ subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
901
1118
  systems,
902
1119
  };
903
1120
  return canonicalJson(payload);
@@ -963,9 +1180,9 @@ function attestationPayloadType(version) {
963
1180
  return PAYLOAD_TYPE_ATTESTATION;
964
1181
  }
965
1182
  function buildCertificatePayload(cert) {
966
- const att = cert.attestation;
967
- const issuer = cert.issuer;
968
- const subject = cert.subject;
1183
+ const att = objectAt(cert, "attestation", "attestation");
1184
+ const issuer = objectAt(cert, "issuer", "issuer");
1185
+ const subject = objectAt(cert, "subject", "subject");
969
1186
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
970
1187
  // and a verification list joined only on the human-editable system_name,
971
1188
  // which made a partial deletion indistinguishable from a complete one.
@@ -973,19 +1190,25 @@ function buildCertificatePayload(cert) {
973
1190
  const coversMeasured = signatureCoversMeasuredTransport(version);
974
1191
  const coversRecoverable = signatureCoversRecoverableState(version);
975
1192
  const coversAuthorization = signatureCoversAuthorization(version);
976
- const systems = (cert.systems ?? []).map((s) => {
1193
+ const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
1194
+ const where = `systems[${i}]`;
1195
+ const s = requireObject(entry, where);
977
1196
  const sys = {
978
- attested_at: formatTimestamp(s.attested_at),
979
- attested_count: s.attested_count,
980
- canonical_version: s.canonical_version ?? null,
981
- connector_type: s.connector_type,
982
- hash_scope: s.hash_scope,
983
- merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
984
- query_hash: toHex(s.query_hash),
985
- system_id: s.system_id,
986
- system_name: s.system_name,
987
- verified_at: formatTimestamp(s.verified_at),
988
- verified_count: s.verified_count,
1197
+ attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
1198
+ attested_count: requireUint(s.attested_count, `${where}.attested_count`),
1199
+ canonical_version: optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
1200
+ connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
1201
+ hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
1202
+ // Absent is a system with no Merkle root; anything present must be one.
1203
+ // Go's *[32]byte is nil or 32 bytes, never "" or [].
1204
+ merkle_root: s.merkle_root == null
1205
+ ? null
1206
+ : bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
1207
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
1208
+ system_id: stringAt(s, "system_id", `${where}.system_id`),
1209
+ system_name: stringAt(s, "system_name", `${where}.system_name`),
1210
+ verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
1211
+ verified_count: requireUint(s.verified_count, `${where}.verified_count`),
989
1212
  };
990
1213
  if (coversMeasured) {
991
1214
  sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name, version);
@@ -1005,9 +1228,9 @@ function buildCertificatePayload(cert) {
1005
1228
  // The attestation block carries no system list of its own: the merged list
1006
1229
  // above is a superset of it. verification_signature is gone entirely.
1007
1230
  const attObj = {
1008
- attestation_signature: toHex(att.attestation_signature),
1009
- attested_at: formatTimestamp(att.attested_at),
1010
- proof_mode: att.proof_mode,
1231
+ attestation_signature: bytesToHex(decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64)),
1232
+ attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
1233
+ proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
1011
1234
  };
1012
1235
  // Fields added after v3 are gated on the certificate's OWN version. A v3
1013
1236
  // certificate must reconstruct to the same bytes forever; reading these
@@ -1019,9 +1242,9 @@ function buildCertificatePayload(cert) {
1019
1242
  const isV5Plus = formatAtLeast(version, FORMAT_VERSION_V5);
1020
1243
  const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1021
1244
  const issuerObj = {
1022
- key_id: issuer.key_id,
1023
- name: issuer.name,
1024
- public_key: toHex(issuer.public_key),
1245
+ key_id: stringAt(issuer, "key_id", "issuer.key_id"),
1246
+ name: stringAt(issuer, "name", "issuer.name"),
1247
+ public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
1025
1248
  };
1026
1249
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1027
1250
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
@@ -1029,43 +1252,48 @@ function buildCertificatePayload(cert) {
1029
1252
  if (signatureCoversAlgorithm(version)) {
1030
1253
  issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer", version);
1031
1254
  }
1032
- if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1033
- issuerObj.legal_entity = issuer.legal_entity;
1255
+ // A non-string here used to be dropped silently, so a junk value added to a
1256
+ // record that never carried one still verified. Python refused it; so does
1257
+ // Go, at parse. Now this does too.
1258
+ if (isV4Plus) {
1259
+ const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
1260
+ if (legalEntity)
1261
+ issuerObj.legal_entity = legalEntity;
1034
1262
  }
1035
1263
  // Signed from v5, and omitted when absent or empty: a build with no
1036
1264
  // measurement signs none, and "" is a value a reader could mistake for one.
1037
- if (signatureCoversEnclavePcr0(version) &&
1038
- typeof issuer.enclave_pcr0 === "string" &&
1039
- issuer.enclave_pcr0 !== "") {
1040
- issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
1265
+ if (signatureCoversEnclavePcr0(version)) {
1266
+ const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
1267
+ if (pcr0)
1268
+ issuerObj.enclave_pcr0 = pcr0;
1041
1269
  }
1042
1270
  // v5 drops identifier_type_hint: signed, but always the constant "custom",
1043
1271
  // so it was never evidence. v3 and v4 keep it or they stop verifying.
1044
1272
  const subjectObj = {
1045
- identifier_hash: toHex(subject.identifier_hash),
1273
+ identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1046
1274
  };
1047
1275
  if (!isV5Plus) {
1048
- subjectObj.identifier_type_hint = subject.identifier_type_hint;
1276
+ subjectObj.identifier_type_hint = stringAt(subject, "identifier_type_hint", "subject.identifier_type_hint");
1049
1277
  }
1050
1278
  // status and revocation are deliberately absent (ADR-016 §3): a signature
1051
1279
  // commits to bytes at an instant, revocation is discovered later, so it
1052
1280
  // travels as a separate short-lived signed status statement.
1053
1281
  const payload = {
1054
1282
  attestation: attObj,
1055
- attestation_id: cert.attestation_id,
1056
- certificate_format_version: cert.certificate_format_version,
1057
- certificate_id: cert.certificate_id,
1058
- issued_at: formatTimestamp(cert.issued_at),
1283
+ attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
1284
+ certificate_format_version: version,
1285
+ certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
1286
+ issued_at: formatTimestamp(cert.issued_at, "issued_at"),
1059
1287
  issuer: issuerObj,
1060
1288
  payload_type: certificatePayloadType(version),
1061
1289
  subject: subjectObj,
1062
1290
  systems,
1063
1291
  };
1064
1292
  if (isV4Plus && cert.scope != null) {
1065
- const scope = cert.scope;
1293
+ const scope = objectAt(cert, "scope", "scope");
1066
1294
  payload.scope = {
1067
- text_sha256: toHex(scope.text_sha256),
1068
- version: scope.version,
1295
+ text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
1296
+ version: stringAt(scope, "version", "scope.version"),
1069
1297
  };
1070
1298
  }
1071
1299
  return canonicalJson(payload);
@@ -1076,7 +1304,7 @@ function buildLogLeafPayload(entryType, certificateId, certificateHash, appended
1076
1304
  throw new errors_js_1.VerificationError(`invalid log entry_type: ${String(entryType)}`);
1077
1305
  }
1078
1306
  const payload = {
1079
- appended_at: formatTimestamp(appendedAt),
1307
+ appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
1080
1308
  certificate_hash: bytesToHex(certificateHash),
1081
1309
  certificate_id: certificateId,
1082
1310
  entry_type: entryType,
@@ -1125,13 +1353,13 @@ function attestationSystems(cert) {
1125
1353
  * between two modules of this package, not published surface.
1126
1354
  */
1127
1355
  function buildTreeHeadPayload(head) {
1128
- const logId = head.log_id;
1129
- const named = typeof logId === "string" && logId !== "";
1356
+ const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
1357
+ const named = logId !== undefined && logId !== "";
1130
1358
  const payload = {
1131
1359
  payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
1132
- root_hash: toHex(head.root_hash),
1133
- timestamp: formatTimestamp(head.timestamp),
1134
- tree_size: head.tree_size,
1360
+ root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
1361
+ timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
1362
+ tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
1135
1363
  };
1136
1364
  if (named)
1137
1365
  payload.log_id = logId;
@@ -1271,21 +1499,21 @@ async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRoot, pro
1271
1499
  function buildCertificateStatusPayload(stmt) {
1272
1500
  const payload = {
1273
1501
  payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
1274
- certificate_id: stmt.certificate_id,
1275
- statement_expires_at: formatTimestamp(stmt.statement_expires_at),
1276
- statement_issued_at: formatTimestamp(stmt.statement_issued_at),
1277
- status: stmt.status,
1278
- sth_root_hash: toHex(stmt.sth_root_hash),
1279
- sth_tree_size: stmt.sth_tree_size,
1502
+ certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
1503
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
1504
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
1505
+ status: stringAt(stmt, "status", "status.status"),
1506
+ sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
1507
+ sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
1280
1508
  };
1281
- if (stmt.replacement_certificate_id != null) {
1282
- payload.replacement_certificate_id = stmt.replacement_certificate_id;
1283
- }
1509
+ const replacement = optionalStringAt(stmt, "replacement_certificate_id", "status.replacement_certificate_id");
1510
+ if (replacement !== undefined)
1511
+ payload.replacement_certificate_id = replacement;
1284
1512
  if (stmt.revocation_log_index != null) {
1285
- payload.revocation_log_index = stmt.revocation_log_index;
1513
+ payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
1286
1514
  }
1287
1515
  if (stmt.revoked_at != null) {
1288
- payload.revoked_at = formatTimestamp(stmt.revoked_at);
1516
+ payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
1289
1517
  }
1290
1518
  return canonicalJson(payload);
1291
1519
  }
@@ -1320,7 +1548,7 @@ function buildKeyListPayload(doc) {
1320
1548
  keys: entries,
1321
1549
  statement_expires_at: formatTimestamp(doc.statement_expires_at),
1322
1550
  statement_issued_at: formatTimestamp(doc.statement_issued_at),
1323
- sth_root_hash: toHex(doc.sth_root_hash),
1551
+ sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
1324
1552
  sth_tree_size: doc.sth_tree_size,
1325
1553
  };
1326
1554
  return canonicalJson(payload);
@@ -1346,18 +1574,38 @@ exports.VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1346
1574
  *
1347
1575
  * The third row is the point: `verifyCertificate` answers VALID there, which
1348
1576
  * reads as "not revoked" and is not something it checked.
1577
+ *
1578
+ * `options` is passed to {@link verifyCertificate} unchanged.
1349
1579
  */
1350
- async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
1580
+ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now, options) {
1351
1581
  // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1352
1582
  // two soft verdicts. Returning a soft verdict here skipped every check below
1353
1583
  // it: a revoked certificate signed by a compromised-later key answered
1354
1584
  // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1355
1585
  // verdict is held instead, and resolved against what the statement says.
1356
- const base = await verifyCertificate(crypto, certificate, publicKeys);
1586
+ const base = await verifyCertificate(crypto, certificate, publicKeys, options);
1357
1587
  if (status == null) {
1358
1588
  return resolveWithBase(base, exports.VALID_REVOCATION_UNKNOWN);
1359
1589
  }
1360
- const keyId = status.key_id;
1590
+ return resolveWithBase(base, await verifyStatusStatement(crypto, status, publicKeys, certificate.certificate_id, now));
1591
+ }
1592
+ /**
1593
+ * The statement half of {@link verifyCertificateWithStatus}: authenticate a
1594
+ * status statement about `certificateId` against the published keys, and read
1595
+ * it at `now`. The /verify page's QR lookup has a record id and no record, and
1596
+ * uses this so it shows what a signed, fresh statement says, never what the
1597
+ * status endpoint claims.
1598
+ *
1599
+ * | input | result |
1600
+ * |---|---|
1601
+ * | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
1602
+ * | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
1603
+ * | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
1604
+ * | unknown or unusable key, bad signature, another certificate | throws VerificationError |
1605
+ */
1606
+ async function verifyStatusStatement(crypto, status, publicKeys, certificateId, now) {
1607
+ requireObject(status, "status");
1608
+ const keyId = optionalStringAt(status, "key_id", "status.key_id");
1361
1609
  const info = keyId ? publicKeys.get(keyId) : undefined;
1362
1610
  if (!info) {
1363
1611
  throw new errors_js_1.VerificationError(`status statement signed by unknown key: ${keyId}`);
@@ -1378,10 +1626,10 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1378
1626
  const at = formatTimestamp(when.toISOString());
1379
1627
  const statusKeyVerdict = requireUsableKey(info, parseRfc3339(at), keyId, "status statement key");
1380
1628
  const payload = buildCertificateStatusPayload(status);
1381
- // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
1629
+ // decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
1382
1630
  // array depending on the producer, and every other signature on this path
1383
1631
  // goes through the same decoder.
1384
- const sig = decodeSignature(status.signature);
1632
+ const sig = decodeFixed(status.signature, "status.signature", 64);
1385
1633
  if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
1386
1634
  throw new errors_js_1.VerificationError("status statement signature is invalid");
1387
1635
  }
@@ -1398,8 +1646,7 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1398
1646
  // Every signature still verified; the binding was the only forged part.
1399
1647
  // core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
1400
1648
  // while both SDKs and the browser bundle said VALID.
1401
- const certId = certificate.certificate_id;
1402
- if (status.certificate_id !== certId) {
1649
+ if (status.certificate_id !== certificateId) {
1403
1650
  throw new errors_js_1.VerificationError("status statement is about a different certificate");
1404
1651
  }
1405
1652
  // Compared in the normalized form the payload signs, so the freshness check
@@ -1409,7 +1656,7 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1409
1656
  if (at < issued || at >= expires) {
1410
1657
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
1411
1658
  // statement, which must never decay into VALID.
1412
- return resolveWithBase(base, exports.VALID_REVOCATION_UNKNOWN);
1659
+ return exports.VALID_REVOCATION_UNKNOWN;
1413
1660
  }
1414
1661
  if (status.status === "REVOKED") {
1415
1662
  // Coded, because this is the only status-path rejection that is a fact
@@ -1424,12 +1671,12 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1424
1671
  // case — was reported as revocation CHECKED AND PASSED. Mirrors
1425
1672
  // core.ValidCertificateStatusValue.
1426
1673
  if (!KNOWN_STATUS_VALUES.has(status.status)) {
1427
- return resolveWithBase(base, exports.VALID_REVOCATION_UNKNOWN);
1674
+ return exports.VALID_REVOCATION_UNKNOWN;
1428
1675
  }
1429
1676
  // A soft verdict on the status key is the result, not a footnote — but it is
1430
1677
  // reported only once the statement has authenticated and been read, so it can
1431
1678
  // neither speak for an unverified statement nor suppress a revocation.
1432
- return resolveWithBase(base, statusKeyVerdict);
1679
+ return statusKeyVerdict;
1433
1680
  }
1434
1681
  /**
1435
1682
  * Picks what to report when the certificate's own key returned a soft verdict