burnledger 0.8.1 → 0.9.1

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 (142) hide show
  1. package/README.md +144 -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 +175 -54
  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 +165 -0
  15. package/dist/cjs/enclave-registration.d.ts.map +1 -0
  16. package/dist/cjs/enclave-registration.js +287 -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 +75 -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/node-runtime.d.ts +40 -0
  43. package/dist/cjs/node-runtime.d.ts.map +1 -0
  44. package/dist/cjs/node-runtime.js +42 -0
  45. package/dist/cjs/node-runtime.js.map +1 -0
  46. package/dist/cjs/status-document.d.ts +25 -0
  47. package/dist/cjs/status-document.d.ts.map +1 -0
  48. package/dist/cjs/status-document.js +62 -0
  49. package/dist/cjs/status-document.js.map +1 -0
  50. package/dist/cjs/verify.d.ts +108 -3
  51. package/dist/cjs/verify.d.ts.map +1 -1
  52. package/dist/cjs/verify.js +451 -171
  53. package/dist/cjs/verify.js.map +1 -1
  54. package/dist/cjs/web-verifier.d.ts +23 -3
  55. package/dist/cjs/web-verifier.d.ts.map +1 -1
  56. package/dist/cjs/web-verifier.js +31 -5
  57. package/dist/cjs/web-verifier.js.map +1 -1
  58. package/dist/cjs/webhooks.d.ts +9 -2
  59. package/dist/cjs/webhooks.d.ts.map +1 -1
  60. package/dist/cjs/webhooks.js +29 -11
  61. package/dist/cjs/webhooks.js.map +1 -1
  62. package/dist/esm/anchor.d.ts +5 -3
  63. package/dist/esm/anchor.d.ts.map +1 -1
  64. package/dist/esm/anchor.js +10 -4
  65. package/dist/esm/anchor.js.map +1 -1
  66. package/dist/esm/cli.d.ts +30 -17
  67. package/dist/esm/cli.d.ts.map +1 -1
  68. package/dist/esm/cli.js +142 -83
  69. package/dist/esm/cli.js.map +1 -1
  70. package/dist/esm/client.d.ts +95 -0
  71. package/dist/esm/client.d.ts.map +1 -1
  72. package/dist/esm/client.js +175 -21
  73. package/dist/esm/client.js.map +1 -1
  74. package/dist/esm/customer-keys.d.ts +73 -1
  75. package/dist/esm/customer-keys.d.ts.map +1 -1
  76. package/dist/esm/customer-keys.js +323 -3
  77. package/dist/esm/customer-keys.js.map +1 -1
  78. package/dist/esm/enclave-registration.d.ts +165 -0
  79. package/dist/esm/enclave-registration.d.ts.map +1 -0
  80. package/dist/esm/enclave-registration.js +277 -0
  81. package/dist/esm/enclave-registration.js.map +1 -0
  82. package/dist/esm/enclave-seal.d.ts +43 -0
  83. package/dist/esm/enclave-seal.d.ts.map +1 -1
  84. package/dist/esm/enclave-seal.js +61 -1
  85. package/dist/esm/enclave-seal.js.map +1 -1
  86. package/dist/esm/errors.d.ts +10 -0
  87. package/dist/esm/errors.d.ts.map +1 -1
  88. package/dist/esm/errors.js +10 -0
  89. package/dist/esm/errors.js.map +1 -1
  90. package/dist/esm/index.browser.d.ts +7 -3
  91. package/dist/esm/index.browser.d.ts.map +1 -1
  92. package/dist/esm/index.browser.js +6 -3
  93. package/dist/esm/index.browser.js.map +1 -1
  94. package/dist/esm/index.d.ts +22 -10
  95. package/dist/esm/index.d.ts.map +1 -1
  96. package/dist/esm/index.js +30 -8
  97. package/dist/esm/index.js.map +1 -1
  98. package/dist/esm/key-group.d.ts +80 -0
  99. package/dist/esm/key-group.d.ts.map +1 -0
  100. package/dist/esm/key-group.js +130 -0
  101. package/dist/esm/key-group.js.map +1 -0
  102. package/dist/esm/models.d.ts +60 -0
  103. package/dist/esm/models.d.ts.map +1 -1
  104. package/dist/esm/models.js +59 -0
  105. package/dist/esm/models.js.map +1 -1
  106. package/dist/esm/node-runtime.d.ts +40 -0
  107. package/dist/esm/node-runtime.d.ts.map +1 -0
  108. package/dist/esm/node-runtime.js +38 -0
  109. package/dist/esm/node-runtime.js.map +1 -0
  110. package/dist/esm/status-document.d.ts +25 -0
  111. package/dist/esm/status-document.d.ts.map +1 -0
  112. package/dist/esm/status-document.js +59 -0
  113. package/dist/esm/status-document.js.map +1 -0
  114. package/dist/esm/verify.d.ts +108 -3
  115. package/dist/esm/verify.d.ts.map +1 -1
  116. package/dist/esm/verify.js +448 -171
  117. package/dist/esm/verify.js.map +1 -1
  118. package/dist/esm/web-verifier.d.ts +23 -3
  119. package/dist/esm/web-verifier.d.ts.map +1 -1
  120. package/dist/esm/web-verifier.js +27 -5
  121. package/dist/esm/web-verifier.js.map +1 -1
  122. package/dist/esm/webhooks.d.ts +9 -2
  123. package/dist/esm/webhooks.d.ts.map +1 -1
  124. package/dist/esm/webhooks.js +29 -11
  125. package/dist/esm/webhooks.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/anchor.ts +10 -4
  128. package/src/cli.ts +145 -78
  129. package/src/client.ts +241 -25
  130. package/src/customer-keys.ts +373 -3
  131. package/src/enclave-registration.ts +402 -0
  132. package/src/enclave-seal.ts +108 -1
  133. package/src/errors.ts +11 -0
  134. package/src/index.browser.ts +9 -3
  135. package/src/index.ts +48 -6
  136. package/src/key-group.ts +181 -0
  137. package/src/models.ts +131 -0
  138. package/src/node-runtime.ts +57 -0
  139. package/src/status-document.ts +59 -0
  140. package/src/verify.ts +565 -177
  141. package/src/web-verifier.ts +36 -3
  142. package/src/webhooks.ts +28 -11
package/src/verify.ts CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  import type { CryptoOps } from "./crypto.js";
20
20
  import {
21
+ AUTHORIZATION_POLICY_FAILED,
21
22
  CERTIFICATE_REVOKED,
22
23
  UNSUPPORTED_ALGORITHM,
23
24
  UNSUPPORTED_FORMAT_VERSION,
@@ -74,8 +75,9 @@ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
74
75
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
75
76
  const FORMAT_VERSION_V8 = "8.0";
76
77
  // v9 adds two per-system fields, authorization and customer_key_id: whether the
77
- // enclave checked this system against a customer-signed registration
78
- // certificate (ADR-025 §4), and under which customer key group if it did.
78
+ // enclave checked this system against a registration certificate (ADR-025 §4),
79
+ // and under which customer key group if it did. That the key group SIGNED the
80
+ // registration holds only from enclave protocol 12: see PRE_PROTOCOL_12_IMAGES.
79
81
  //
80
82
  // The attestation gains NO field at v9. Its tag still moves, because the tag
81
83
  // follows the record's version and the record's bytes changed — a v9
@@ -246,25 +248,28 @@ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
246
248
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
247
249
  const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
248
250
 
251
+ /** Decode hex: whole bytes, digits in either case, nothing else. Anything else
252
+ * throws TypeError. It is exported, so its input is a boundary: reading a stray
253
+ * character as 0, as it once did, turned a typo into different bytes. */
249
254
  export function hexToBytes(hex: string): Uint8Array {
250
- const len = hex.length >>> 1;
251
- const out = new Uint8Array(len);
252
- for (let i = 0; i < len; i++) {
253
- const hi = hex.charCodeAt(i * 2);
254
- const lo = hex.charCodeAt(i * 2 + 1);
255
- out[i] = (unhex(hi) << 4) | unhex(lo);
255
+ if (typeof hex !== "string") throw new TypeError(`hex is not a string: ${typeof hex}`);
256
+ if (hex.length % 2 !== 0) throw new TypeError(`hex has an odd number of digits: ${hex.length}`);
257
+ const out = new Uint8Array(hex.length / 2);
258
+ for (let i = 0; i < out.length; i++) {
259
+ out[i] = (unhex(hex, i * 2) << 4) | unhex(hex, i * 2 + 1);
256
260
  }
257
261
  return out;
258
262
  }
259
263
 
260
- function unhex(c: number): number {
264
+ function unhex(hex: string, at: number): number {
265
+ const c = hex.charCodeAt(at);
261
266
  // 0-9
262
267
  if (c >= 48 && c <= 57) return c - 48;
263
268
  // a-f
264
269
  if (c >= 97 && c <= 102) return c - 87;
265
270
  // A-F
266
271
  if (c >= 65 && c <= 70) return c - 55;
267
- return 0;
272
+ throw new TypeError(`hex has a non-hex character ${JSON.stringify(hex[at])} at ${at}`);
268
273
  }
269
274
 
270
275
  export function bytesToHex(bytes: Uint8Array): string {
@@ -362,6 +367,13 @@ export async function publicKeyFromHex(
362
367
  hexKey: string,
363
368
  opts?: PublicKeyOptions,
364
369
  ): Promise<PublicKeyInfo> {
370
+ // Refused here, as the VerificationError callers are told to catch, rather
371
+ // than left to hexToBytes's TypeError. The alphabet is the one
372
+ // core.KeyEntry.PublicKeyInfo takes through hex.DecodeString: either case,
373
+ // whole bytes, nothing else.
374
+ if (typeof hexKey !== "string" || !HEX_TEXT_RE.test(hexKey)) {
375
+ throw new VerificationError(`public key is not hex: ${JSON.stringify(hexKey)}`);
376
+ }
365
377
  const raw = hexToBytes(hexKey);
366
378
  if (raw.length !== 32) {
367
379
  throw new VerificationError(`public key must be 32 bytes, got ${raw.length}`);
@@ -435,6 +447,18 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
435
447
  if (pki.revoked) return "UNKNOWN_KEY";
436
448
  if (pki.keyStatus === "compromised") {
437
449
  if (anchor === null || pki.compromisedFrom === undefined) return KEY_COMPROMISED;
450
+ // The validity window is tested before the compromise anchor (R03-1). A
451
+ // signature made outside the stated interval was never authorized, whatever
452
+ // the key's later disposition, so an anchor outside the window is
453
+ // KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
454
+ // window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
455
+ // the window alone already refuses.
456
+ if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
457
+ return KEY_OUTSIDE_VALIDITY;
458
+ }
459
+ if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
460
+ return KEY_OUTSIDE_VALIDITY;
461
+ }
438
462
  return anchor < parseRfc3339(pki.compromisedFrom)
439
463
  ? VALID_KEY_COMPROMISED_LATER
440
464
  : KEY_COMPROMISED;
@@ -469,8 +493,8 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
469
493
  export function certificateAnchor(certificate: Record<string, unknown>): number | null {
470
494
  const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
471
495
  if (transparency == null) return null;
472
- const sth = transparency.signed_tree_head as Record<string, unknown> | undefined;
473
- if (sth === undefined || typeof sth.timestamp !== "string") return null;
496
+ const sth = transparency.signed_tree_head as Record<string, unknown> | null | undefined;
497
+ if (sth == null || typeof sth.timestamp !== "string") return null;
474
498
  try {
475
499
  return parseRfc3339(sth.timestamp);
476
500
  } catch {
@@ -479,19 +503,28 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
479
503
  }
480
504
 
481
505
  /**
482
- * The anchor, but only once the tree head carrying it has been checked against
483
- * the key being evaluated.
506
+ * The anchor, but only once this record has been PROVEN to be in the tree head
507
+ * carrying it — its inclusion proof verified against that head, not merely the
508
+ * head's own signature checked.
484
509
  *
485
- * The bypass this closes: for a key declared compromised, evaluateKey answers
486
- * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
487
- * anchor predates the compromise — and the second is a verdict requireUsableKey
488
- * passes through as the RESULT of verifyCertificate. Since the timestamp is
489
- * covered by no signature, a holder could backdate it and have a certificate
490
- * signed with a compromised key reported as verified-with-a-caveat. Deleting
491
- * `transparency` failed closed; keeping a doctored one did not.
510
+ * The bypass this closes (R12-1/R14-1): for a key declared compromised,
511
+ * evaluateKey answers KEY_COMPROMISED with no anchor and
512
+ * VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
513
+ * second is a verdict requireUsableKey passes through as the RESULT of
514
+ * verifyCertificate. A signed tree head is a public artifact: any genuine older
515
+ * head the issuer ever published can be stapled onto a record it never
516
+ * contained, and its signature still verifies. Checking only the head signature
517
+ * let a holder of a certificate signed after the compromise attach a
518
+ * pre-compromise head and have the forgery reported as verified-with-a-caveat.
519
+ * The head's timestamp is an honest anchor for THIS record only once THIS record
520
+ * is shown to be committed to under it.
492
521
  *
493
- * A head that does not verify yields no anchor, which is the same conservative
494
- * answer as a certificate carrying no transparency block at all.
522
+ * checkCertificateInclusion is exactly that proof; it deliberately does NOT test
523
+ * the key's usability at head time — that is the verdict this anchor exists to
524
+ * compute, so gating the anchor on it would drop the anchor for an out-of-window
525
+ * key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
526
+ * of a held inclusion proof yields no anchor, the same conservative answer as a
527
+ * certificate with no transparency block at all.
495
528
  */
496
529
  async function verifiedCertificateAnchor(
497
530
  crypto: CryptoOps,
@@ -501,14 +534,14 @@ async function verifiedCertificateAnchor(
501
534
  const anchor = certificateAnchor(certificate);
502
535
  if (anchor === null) return null;
503
536
 
504
- const transparency = certificate.transparency as Record<string, unknown>;
505
- const sth = transparency.signed_tree_head as Record<string, unknown>;
537
+ // Only a refusal of the proof means "no anchor". A defect in this library, or
538
+ // a crypto provider that threw, must not get the same quiet answer as a
539
+ // document whose inclusion proof does not verify.
506
540
  try {
507
- const headPayload = buildTreeHeadPayload(sth);
508
- const headSig = decodeSignature(sth.signature);
509
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
510
- } catch {
511
- return null;
541
+ await checkCertificateInclusion(crypto, certificate, pki);
542
+ } catch (e) {
543
+ if (e instanceof VerificationError) return null;
544
+ throw e;
512
545
  }
513
546
  return anchor;
514
547
  }
@@ -559,21 +592,246 @@ function requireUsableKey(
559
592
 
560
593
  type Cert = Record<string, unknown>;
561
594
 
595
+ /**
596
+ * Require what ADR-025 lets a record prove: that a key group you name signed the
597
+ * registration the enclave checked each system against before measuring it.
598
+ *
599
+ * A pass means each required system is `certified` under one of
600
+ * `customerKeyIds`, in a record from an enclave image of wire protocol 12 or
601
+ * later — the first that registers a system only with that key group's
602
+ * signatures. A record from an earlier image (PRE_PROTOCOL_12_IMAGES), or one
603
+ * that names no image, fails: those images could say `certified` with no
604
+ * customer signature at all.
605
+ *
606
+ * Off unless asked for, because a record that says `authorization: none` is
607
+ * still a genuine record. Whoever relays requests to the enclave can always
608
+ * leave a registration out (ADR-025 §5, §8), and the enclave cannot refuse that
609
+ * for want of durable state. So "my systems must be certified" can be enforced
610
+ * in one place only: here, by the party relying on the record.
611
+ */
612
+ export interface AuthorizationPolicy {
613
+ /** The system_ids that must be certified. Omitted: every system the record covers. */
614
+ readonly systems?: readonly string[];
615
+ /**
616
+ * The key groups you accept. Required: `certified` under a key id you did not
617
+ * name proves nothing to you, because the relay can enroll a key group of its
618
+ * own and the record would name that one.
619
+ */
620
+ readonly customerKeyIds: readonly string[];
621
+ }
622
+
623
+ export interface VerifyOptions {
624
+ /**
625
+ * Refuse the record, with a VerificationError whose code is
626
+ * AUTHORIZATION_POLICY_FAILED, unless it meets this policy. Omitted: no
627
+ * requirement, and `authorization: none` verifies as it always has.
628
+ */
629
+ readonly requireAuthorization?: AuthorizationPolicy;
630
+ }
631
+
632
+ function policySet(values: readonly string[] | undefined, field: string, lower: boolean): Set<string> | undefined {
633
+ if (values === undefined) return undefined;
634
+ if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
635
+ throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
636
+ }
637
+ if (values.length === 0) {
638
+ // An empty requirement requires nothing, which is never what a caller who
639
+ // built a policy meant.
640
+ throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
641
+ }
642
+ return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
643
+ }
644
+
645
+ interface ParsedPolicy {
646
+ readonly systems: Set<string> | undefined;
647
+ readonly customerKeyIds: Set<string>;
648
+ }
649
+
650
+ /** Check a policy before anything is verified, so a caller's mistake surfaces as
651
+ * one whatever the record turns out to be. */
652
+ function parsePolicy(policy: AuthorizationPolicy): ParsedPolicy {
653
+ const keys = policy.customerKeyIds;
654
+ if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
655
+ throw new TypeError(
656
+ "requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
657
+ "a key id you did not name proves nothing to you, because the relay can enroll a key group of its own",
658
+ );
659
+ }
660
+ return {
661
+ systems: policySet(policy.systems, "systems", true),
662
+ customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false)!,
663
+ };
664
+ }
665
+
666
+ /**
667
+ * The enclave images that issued format 9.0 while running wire protocol 11 or
668
+ * earlier, by PCR0, as the published measurement history records them
669
+ * (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
670
+ *
671
+ * Their `certified` is not evidence of a customer signature. Before protocol 12
672
+ * the enclave enrolled a key group with no proof that anyone held its keys, and
673
+ * registered a system for whoever relayed the authorization — so the host could
674
+ * make a genuine 9.0 record say `certified`, under a key id it chose, with no
675
+ * customer signature anywhere (reproduced by an independent re-review,
676
+ * 2026-09-13).
677
+ *
678
+ * THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
679
+ * admitting or rolling back to an image of protocol 11 or earlier once any
680
+ * customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
681
+ * leaves to the owner. The protocol-12 cutover's KMS tighten drops every
682
+ * measurement here from the signing key's policy, after which none of them can
683
+ * sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
684
+ * because it held the signing key while it ran.
685
+ *
686
+ * The Python SDK carries the same list, in _verify.py.
687
+ */
688
+ export const PRE_PROTOCOL_12_IMAGES: ReadonlyMap<string, string> = new Map([
689
+ ["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
690
+ ["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
691
+ ["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
692
+ ["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
693
+ ["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
694
+ ["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
695
+ ["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
696
+ ]);
697
+
698
+ /**
699
+ * What a record's signed issuer.enclave_pcr0 makes of `certified`: whether the
700
+ * image that signed it could mark a system certified without the customer's
701
+ * signature. "protocol-12-or-later" is the inference the closed list licenses:
702
+ * a genuine 9.0 record under any other measurement was signed by a later image.
703
+ */
704
+ export type IssuingImage =
705
+ | { readonly kind: "unnamed" }
706
+ | { readonly kind: "before-protocol-12"; readonly name: string; readonly pcr0: string }
707
+ | { readonly kind: "protocol-12-or-later"; readonly pcr0: string };
708
+
709
+ export function issuingImage(certificate: Cert): IssuingImage {
710
+ const issuer = certificate.issuer as Cert | undefined;
711
+ const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
712
+ if (pcr0 === "") return { kind: "unnamed" };
713
+ const name = PRE_PROTOCOL_12_IMAGES.get(pcr0);
714
+ return name === undefined
715
+ ? { kind: "protocol-12-or-later", pcr0 }
716
+ : { kind: "before-protocol-12", name, pcr0 };
717
+ }
718
+
719
+ /**
720
+ * What `certified` is evidence of, given the image that signed the record: the
721
+ * words every verifier prints after "certified under <key id> —". The Go CLI
722
+ * (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
723
+ * identically, and testdata/registration_line_cases.json holds them to it.
724
+ */
725
+ export function registrationQualifier(image: IssuingImage): string {
726
+ switch (image.kind) {
727
+ case "unnamed":
728
+ return (
729
+ "not evidence here: this record names no enclave image, so it may come from one " +
730
+ "that could mark a system certified without the customer's signature."
731
+ );
732
+ case "before-protocol-12":
733
+ return (
734
+ `not evidence here: this record comes from ${image.name}, an enclave image of ` +
735
+ "protocol 11 or earlier, which could mark a system certified without the customer's signature."
736
+ );
737
+ case "protocol-12-or-later":
738
+ return (
739
+ "the key group with that id signed this system's registration, if that key id is yours: " +
740
+ "this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
741
+ "someone else authorized this verification."
742
+ );
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Throw unless a record that has already verified meets `policy`.
748
+ *
749
+ * From 9.0 every field read here is under the certificate signature, the
750
+ * issuer's enclave_pcr0 included. Below 9.0
751
+ * nothing signs `authorization` — those records carry the field outside the
752
+ * signed bytes — so one that says `certified` says only what its last holder
753
+ * typed. It fails on the format, before the field is read.
754
+ */
755
+ function enforceAuthorization(certificate: Cert, formatVersion: string, policy: ParsedPolicy): void {
756
+ const systemsWanted = policy.systems;
757
+ if (!signatureCoversAuthorization(formatVersion)) {
758
+ throw new VerificationError(
759
+ `authorization required, but this record is format ${formatVersion}, which predates ` +
760
+ `customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`,
761
+ AUTHORIZATION_POLICY_FAILED,
762
+ );
763
+ }
764
+ const systems = (certificate.systems as Record<string, unknown>[] | null | undefined) ?? [];
765
+ let required = systems;
766
+ if (systemsWanted !== undefined) {
767
+ const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
768
+ const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
769
+ if (missing.length > 0) {
770
+ throw new VerificationError(
771
+ `authorization required for system ${missing[0]}, which this record does not cover`,
772
+ AUTHORIZATION_POLICY_FAILED,
773
+ );
774
+ }
775
+ required = [...systemsWanted].sort().map((id) => byId.get(id)!);
776
+ }
777
+ const labelOf = (s: Record<string, unknown>): string =>
778
+ `system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
779
+ for (const s of required) {
780
+ if (s.authorization !== AUTHORIZATION_CERTIFIED) {
781
+ throw new VerificationError(
782
+ `authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
783
+ "the enclave did not check it against a registration",
784
+ AUTHORIZATION_POLICY_FAILED,
785
+ );
786
+ }
787
+ }
788
+ // Asked once, for the record, after every required system has said certified:
789
+ // which image said it decides whether `certified` means a customer signed.
790
+ const image = issuingImage(certificate);
791
+ if (image.kind === "unnamed") {
792
+ throw new VerificationError(
793
+ "authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
794
+ "absent), so nothing shows it came from one that registers a system only with the " +
795
+ "customer's signature",
796
+ AUTHORIZATION_POLICY_FAILED,
797
+ );
798
+ }
799
+ if (image.kind === "before-protocol-12") {
800
+ throw new VerificationError(
801
+ `authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
802
+ "an enclave image of wire protocol 11 or earlier, which could register a system without " +
803
+ "the customer's signature; its `certified` does not show that any key group signed anything",
804
+ AUTHORIZATION_POLICY_FAILED,
805
+ );
806
+ }
807
+ for (const s of required) {
808
+ if (!policy.customerKeyIds.has(s.customer_key_id as string)) {
809
+ throw new VerificationError(
810
+ `${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
811
+ "accept; if it is not yours, someone else authorized this verification",
812
+ AUTHORIZATION_POLICY_FAILED,
813
+ );
814
+ }
815
+ }
816
+ }
817
+
562
818
  /** Verify all signatures on a deletion certificate offline. */
563
819
  export async function verifyCertificate(
564
820
  crypto: CryptoOps,
565
821
  certificate: Cert,
566
822
  publicKeys: Map<string, PublicKeyInfo>,
823
+ options?: VerifyOptions,
567
824
  ): Promise<VerificationResult> {
568
825
  // Step 0: can this SDK read the format at all? Every check below rebuilds
569
826
  // signed bytes from the version, so an unreadable version makes all of them
570
827
  // meaningless - and "unknown issuer key" would be the wrong thing to tell
571
828
  // someone holding a record that is merely newer than this library.
572
- const formatVersion = checkFormatVersion(
573
- (certificate as Record<string, unknown>).certificate_format_version,
574
- );
829
+ requireObject(certificate, "certificate");
830
+ const policy =
831
+ options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
832
+ const formatVersion = checkFormatVersion(certificate.certificate_format_version);
575
833
 
576
- const issuer = certificate.issuer as Record<string, unknown>;
834
+ const issuer = objectAt(certificate, "issuer", "issuer");
577
835
  // Step 0b: can this SDK check the algorithm the record names? Asked before
578
836
  // any signature, because verifying an Ed25519 signature over a record that
579
837
  // says it was signed with something else answers a question nobody asked.
@@ -587,7 +845,7 @@ export async function verifyCertificate(
587
845
  UNSUPPORTED_ALGORITHM,
588
846
  );
589
847
  }
590
- const keyId = issuer.key_id as string;
848
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
591
849
 
592
850
  const pki = publicKeys.get(keyId);
593
851
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
@@ -602,7 +860,7 @@ export async function verifyCertificate(
602
860
  // 1. Certificate signature FIRST — nothing below may trust a field until the
603
861
  // bytes carrying it are covered by a verified signature.
604
862
  const certPayload = buildCertificatePayload(certificate);
605
- const certSig = decodeSignature(certificate.certificate_signature as string);
863
+ const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
606
864
  if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
607
865
  throw new VerificationError("certificate signature is invalid");
608
866
  }
@@ -621,7 +879,7 @@ export async function verifyCertificate(
621
879
  },
622
880
  certificate.certificate_format_version as string,
623
881
  );
624
- const attSig = decodeSignature(att.attestation_signature as string);
882
+ const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
625
883
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
626
884
  throw new VerificationError("attestation signature is invalid");
627
885
  }
@@ -682,6 +940,10 @@ export async function verifyCertificate(
682
940
  );
683
941
  }
684
942
 
943
+ if (policy !== undefined) {
944
+ enforceAuthorization(certificate, formatVersion, policy);
945
+ }
946
+
685
947
  // A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
686
948
  // and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
687
949
  // reporting VALID here would be the lying by omission ADR-017 exists to stop.
@@ -697,25 +959,41 @@ export async function verifyTransparency(
697
959
  certificate: Cert,
698
960
  publicKeys: Map<string, PublicKeyInfo>,
699
961
  ): Promise<TransparencyResult> {
700
- const transparency = certificate.transparency as
701
- | Record<string, unknown>
702
- | undefined;
703
- if (transparency == null) {
962
+ requireObject(certificate, "certificate");
963
+ if (certificate.transparency == null) {
704
964
  return "NOT_AVAILABLE" as TransparencyResult;
705
965
  }
706
-
707
- const sth = transparency.signed_tree_head as Record<string, unknown>;
708
- const issuer = certificate.issuer as Record<string, unknown>;
709
- const keyId = issuer.key_id as string;
966
+ const issuer = objectAt(certificate, "issuer", "issuer");
967
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
710
968
 
711
969
  const pki = publicKeys.get(keyId);
712
970
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
713
971
  // The tree head carries its own timestamp, so this path always has an anchor.
714
972
  requireUsableKey(pki, certificateAnchor(certificate), keyId);
715
973
 
974
+ await checkCertificateInclusion(crypto, certificate, pki);
975
+ return "INCLUDED" as TransparencyResult;
976
+ }
977
+
978
+ /**
979
+ * Verify the head signature and the Merkle inclusion of a certificate's proof —
980
+ * everything about whether this record is committed to under its head, but NOT
981
+ * whether the key was usable when it signed. That last question is the caller's;
982
+ * certificateAnchor needs membership, not authority, and folding authority in
983
+ * here made the anchor for an out-of-window key collapse to "no anchor" and the
984
+ * verdict soften. Throws VerificationError on any inclusion failure.
985
+ */
986
+ async function checkCertificateInclusion(
987
+ crypto: CryptoOps,
988
+ certificate: Cert,
989
+ pki: PublicKeyInfo,
990
+ ): Promise<void> {
991
+ const transparency = objectAt(certificate, "transparency", "transparency");
992
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
993
+
716
994
  // 1. Tree head signature
717
995
  const headPayload = buildTreeHeadPayload(sth);
718
- const headSig = decodeSignature(sth.signature as string);
996
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
719
997
  if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
720
998
  throw new VerificationError("tree head signature is invalid");
721
999
  }
@@ -731,16 +1009,16 @@ export async function verifyTransparency(
731
1009
  const issuanceData = issuanceBytes(certificate);
732
1010
  const leafPayload = buildLogLeafPayload(
733
1011
  transparency.entry_type,
734
- certificate.certificate_id as string,
1012
+ stringAt(certificate, "certificate_id", "certificate_id"),
735
1013
  await crypto.sha256(issuanceData),
736
1014
  transparency.appended_at,
737
1015
  );
738
1016
  const leaf = await hashLeaf(crypto, leafPayload);
739
1017
 
740
- const proofHashes = (
741
- (transparency.inclusion_proof as unknown[] | undefined) ?? []
742
- ).map((h) => decodeBytes(h));
743
- const root = decodeBytes(sth.root_hash);
1018
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map(
1019
+ (h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32),
1020
+ );
1021
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
744
1022
  // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
745
1023
  // for an explicit null and for an absent key rather than failing, so refusing
746
1024
  // either would reject documents the reference accepts — the same class of
@@ -782,8 +1060,6 @@ export async function verifyTransparency(
782
1060
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
783
1061
  throw new VerificationError("merkle inclusion proof is invalid");
784
1062
  }
785
-
786
- return "INCLUDED" as TransparencyResult;
787
1063
  }
788
1064
 
789
1065
  // ---------------------------------------------------------------------------
@@ -838,48 +1114,96 @@ function canonicalStringify(val: unknown): string {
838
1114
  }
839
1115
 
840
1116
  // ---------------------------------------------------------------------------
841
- // Byte encoding helpers
1117
+ // Reading the document
842
1118
  // ---------------------------------------------------------------------------
1119
+ //
1120
+ // A certificate or status statement handed to this verifier is untrusted JSON.
1121
+ // A key that is missing, a value of the wrong JSON type or bytes that will not
1122
+ // decode is a fact about the DOCUMENT, so every field is read through one of
1123
+ // these and refused with a VerificationError naming it. Casting and reading
1124
+ // instead threw TypeError from `undefined.x` and a DOMException from atob,
1125
+ // which a caller cannot tell apart from a bug in this library (BL-4-001-B).
1126
+ //
1127
+ // Absent and null are the same thing, as they are to Go's encoding/json.
843
1128
 
844
- /** Convert a byte field to lowercase hex. Handles:
845
- * - hex string (from hand-crafted test data)
846
- * - base64 string (from Go's json.Marshal of []byte slices)
847
- * - number[] (from Go's json.Marshal of [N]byte fixed arrays)
848
- */
849
- function toHex(value: unknown): string {
850
- if (Array.isArray(value)) {
851
- return bytesToHex(new Uint8Array(value as number[]));
852
- }
853
- const str = value as string;
854
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
855
- return str.toLowerCase();
1129
+ type Doc = Record<string, unknown>;
1130
+
1131
+ function requireObject(value: unknown, path: string): Doc {
1132
+ if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1133
+ if (typeof value !== "object" || Array.isArray(value)) {
1134
+ throw new VerificationError(`${path} is not an object`);
856
1135
  }
857
- return bytesToHex(base64ToBytes(str));
1136
+ return value as Doc;
858
1137
  }
859
1138
 
860
- /** Decode a signature field to raw bytes. Same format handling as toHex. */
861
- function decodeSignature(value: unknown): Uint8Array {
862
- if (Array.isArray(value)) {
863
- return new Uint8Array(value as number[]);
864
- }
865
- const str = value as string;
866
- if (/^[0-9a-fA-F]+$/.test(str)) {
867
- const raw = hexToBytes(str);
868
- if (raw.length === 64) return raw;
869
- }
870
- return base64ToBytes(str);
1139
+ function objectAt(parent: Doc, key: string, path: string): Doc {
1140
+ return requireObject(parent[key], path);
871
1141
  }
872
1142
 
873
- /** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
874
- function decodeBytes(value: unknown): Uint8Array {
1143
+ function stringAt(parent: Doc, key: string, path: string): string {
1144
+ const value = parent[key];
1145
+ if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1146
+ if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
1147
+ return value;
1148
+ }
1149
+
1150
+ function optionalStringAt(parent: Doc, key: string, path: string): string | undefined {
1151
+ const value = parent[key];
1152
+ if (value === undefined || value === null) return undefined;
1153
+ if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
1154
+ return value;
1155
+ }
1156
+
1157
+ /**
1158
+ * An array field. Absent and null read as empty: Go leaves a nil slice.
1159
+ *
1160
+ * Returned dense. JSON cannot express a hole, but a caller building the object
1161
+ * in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
1162
+ * `undefined` and threw a TypeError. Array.from reads a hole as undefined,
1163
+ * which the element's own reader then refuses as missing.
1164
+ */
1165
+ function arrayAt(parent: Doc, key: string, path: string): unknown[] {
1166
+ const value = parent[key];
1167
+ if (value === undefined || value === null) return [];
1168
+ if (!Array.isArray(value)) throw new VerificationError(`${path} is not an array`);
1169
+ return Array.from(value);
1170
+ }
1171
+
1172
+ const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
1173
+ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1174
+
1175
+ /**
1176
+ * Decode a fixed-size byte field — a hash, a key, a signature.
1177
+ *
1178
+ * Go marshals `[N]byte` as an array of numbers; hand-written documents carry
1179
+ * hex or standard base64. A well-formed value of the right size is never
1180
+ * ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
1181
+ * in padding hex cannot contain.
1182
+ *
1183
+ * Everything else is refused here, by name: a number, an array element that is
1184
+ * not a byte, text that is neither encoding, the wrong length. Decoding it
1185
+ * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
1186
+ * signature covers and blamed the signature — or threw from inside atob.
1187
+ */
1188
+ function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
1189
+ if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1190
+ let raw: Uint8Array;
875
1191
  if (Array.isArray(value)) {
876
- return new Uint8Array(value as number[]);
1192
+ if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
1193
+ throw new VerificationError(`${path} is not an array of bytes`);
1194
+ }
1195
+ raw = new Uint8Array(value as number[]);
1196
+ } else if (typeof value === "string") {
1197
+ if (HEX_TEXT_RE.test(value)) raw = hexToBytes(value);
1198
+ else if (BASE64_TEXT_RE.test(value)) raw = base64ToBytes(value);
1199
+ else throw new VerificationError(`${path} is neither hex nor base64`);
1200
+ } else {
1201
+ throw new VerificationError(`${path} is not a byte string`);
877
1202
  }
878
- const str = value as string;
879
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
880
- return hexToBytes(str);
1203
+ if (raw.length !== length) {
1204
+ throw new VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
881
1205
  }
882
- return base64ToBytes(str);
1206
+ return raw;
883
1207
  }
884
1208
 
885
1209
  /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
@@ -912,13 +1236,16 @@ const RFC3339_RE =
912
1236
  * Exported from this module so the timestamp_vectors.json suite can diff it
913
1237
  * against Go directly. It is deliberately not re-exported from index.ts, so it
914
1238
  * is not part of the published package surface.
1239
+ *
1240
+ * `field` names the value in a refusal. It defaults to the word these messages
1241
+ * always used, for callers normalizing a time that is not a field of a document.
915
1242
  */
916
- export function formatTimestamp(ts: unknown): string {
1243
+ export function formatTimestamp(ts: unknown, field = "timestamp"): string {
917
1244
  if (ts === null || ts === undefined) return ZERO_INSTANT;
918
1245
  if (typeof ts !== "string") {
919
- throw new VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
1246
+ throw new VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
920
1247
  }
921
- return formatEpochSeconds(parseRfc3339(ts));
1248
+ return formatEpochSeconds(parseRfc3339(ts, field));
922
1249
  }
923
1250
 
924
1251
  /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
@@ -930,19 +1257,19 @@ export function formatTimestamp(ts: unknown): string {
930
1257
  * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
931
1258
  * digits are dropped, matching Format's truncation.
932
1259
  */
933
- function parseRfc3339(ts: string): number {
1260
+ function parseRfc3339(ts: string, field = "timestamp"): number {
934
1261
  const m = RFC3339_RE.exec(ts);
935
- if (m === null) throw new VerificationError(`timestamp is not RFC 3339: ${ts}`);
1262
+ if (m === null) throw new VerificationError(`${field} is not RFC 3339: ${ts}`);
936
1263
  const [year, month, day, hour, minute, second] = m
937
1264
  .slice(1, 7)
938
1265
  .map((v) => Number.parseInt(v!, 10)) as [number, number, number, number, number, number];
939
1266
 
940
- if (month < 1 || month > 12) throw new VerificationError(`timestamp month out of range: ${ts}`);
1267
+ if (month < 1 || month > 12) throw new VerificationError(`${field} month out of range: ${ts}`);
941
1268
  if (day < 1 || day > daysInMonth(year, month)) {
942
- throw new VerificationError(`timestamp day out of range: ${ts}`);
1269
+ throw new VerificationError(`${field} day out of range: ${ts}`);
943
1270
  }
944
1271
  if (hour > 23 || minute > 59 || second > 59) {
945
- throw new VerificationError(`timestamp time of day out of range: ${ts}`);
1272
+ throw new VerificationError(`${field} time of day out of range: ${ts}`);
946
1273
  }
947
1274
 
948
1275
  let offset = 0;
@@ -950,8 +1277,8 @@ function parseRfc3339(ts: string): number {
950
1277
  if (sign !== undefined) {
951
1278
  const offHour = Number.parseInt(m[8]!, 10);
952
1279
  const offMin = Number.parseInt(m[9]!, 10);
953
- if (offHour > 24) throw new VerificationError(`timestamp zone offset hour out of range: ${ts}`);
954
- if (offMin > 60) throw new VerificationError(`timestamp zone offset minute out of range: ${ts}`);
1280
+ if (offHour > 24) throw new VerificationError(`${field} zone offset hour out of range: ${ts}`);
1281
+ if (offMin > 60) throw new VerificationError(`${field} zone offset minute out of range: ${ts}`);
955
1282
  offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
956
1283
  }
957
1284
 
@@ -1051,17 +1378,18 @@ function buildAttestationPayload(
1051
1378
  // payload and call every genuine v8 record a forgery.
1052
1379
  const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
1053
1380
  const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
1054
- const systems = (att.systems as Record<string, unknown>[]).map((s) => {
1381
+ const systems = (att.systems as Record<string, unknown>[]).map((s, i) => {
1055
1382
  const sys: Record<string, unknown> = {
1056
1383
  canonical_version: (s.canonical_version as string | null) ?? null,
1057
1384
  connector_type: s.connector_type as string,
1058
1385
  hash_scope: s.hash_scope as string,
1059
- merkle_root: s.merkle_root
1060
- ? toHex(s.merkle_root as string)
1061
- : null,
1386
+ merkle_root:
1387
+ s.merkle_root == null
1388
+ ? null
1389
+ : bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
1062
1390
  // The system's own observation time, not the envelope's.
1063
- observed_at: formatTimestamp(s.observed_at),
1064
- query_hash: toHex(s.query_hash as string),
1391
+ observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
1392
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
1065
1393
  record_count: s.record_count as number,
1066
1394
  system_id: s.system_id as string,
1067
1395
  system_name: s.system_name as string,
@@ -1095,7 +1423,7 @@ function buildAttestationPayload(
1095
1423
  attested_at: attestedAt,
1096
1424
  payload_type: attestationPayloadType(certFormatVersion),
1097
1425
  proof_mode: att.proof_mode as string,
1098
- subject_hash: toHex(subject.identifier_hash as string),
1426
+ subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1099
1427
  systems,
1100
1428
  };
1101
1429
  return canonicalJson(payload);
@@ -1161,9 +1489,9 @@ function attestationPayloadType(version: string): string {
1161
1489
  }
1162
1490
 
1163
1491
  function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1164
- const att = cert.attestation as Record<string, unknown>;
1165
- const issuer = cert.issuer as Record<string, unknown>;
1166
- const subject = cert.subject as Record<string, unknown>;
1492
+ const att = objectAt(cert, "attestation", "attestation");
1493
+ const issuer = objectAt(cert, "issuer", "issuer");
1494
+ const subject = objectAt(cert, "subject", "subject");
1167
1495
 
1168
1496
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
1169
1497
  // and a verification list joined only on the human-editable system_name,
@@ -1172,19 +1500,27 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1172
1500
  const coversMeasured = signatureCoversMeasuredTransport(version);
1173
1501
  const coversRecoverable = signatureCoversRecoverableState(version);
1174
1502
  const coversAuthorization = signatureCoversAuthorization(version);
1175
- const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
1503
+ const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
1504
+ const where = `systems[${i}]`;
1505
+ const s = requireObject(entry, where);
1176
1506
  const sys: Record<string, unknown> = {
1177
- attested_at: formatTimestamp(s.attested_at),
1178
- attested_count: s.attested_count as number,
1179
- canonical_version: (s.canonical_version as string | null) ?? null,
1180
- connector_type: s.connector_type as string,
1181
- hash_scope: s.hash_scope as string,
1182
- merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
1183
- query_hash: toHex(s.query_hash as string),
1184
- system_id: s.system_id as string,
1185
- system_name: s.system_name as string,
1186
- verified_at: formatTimestamp(s.verified_at),
1187
- verified_count: s.verified_count as number,
1507
+ attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
1508
+ attested_count: requireUint(s.attested_count, `${where}.attested_count`),
1509
+ canonical_version:
1510
+ optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
1511
+ connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
1512
+ hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
1513
+ // Absent is a system with no Merkle root; anything present must be one.
1514
+ // Go's *[32]byte is nil or 32 bytes, never "" or [].
1515
+ merkle_root:
1516
+ s.merkle_root == null
1517
+ ? null
1518
+ : bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
1519
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
1520
+ system_id: stringAt(s, "system_id", `${where}.system_id`),
1521
+ system_name: stringAt(s, "system_name", `${where}.system_name`),
1522
+ verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
1523
+ verified_count: requireUint(s.verified_count, `${where}.verified_count`),
1188
1524
  };
1189
1525
  if (coversMeasured) {
1190
1526
  sys.read_only_enforcement = requireMeasured(
@@ -1219,9 +1555,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1219
1555
  // The attestation block carries no system list of its own: the merged list
1220
1556
  // above is a superset of it. verification_signature is gone entirely.
1221
1557
  const attObj = {
1222
- attestation_signature: toHex(att.attestation_signature as string),
1223
- attested_at: formatTimestamp(att.attested_at),
1224
- proof_mode: att.proof_mode as string,
1558
+ attestation_signature: bytesToHex(
1559
+ decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64),
1560
+ ),
1561
+ attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
1562
+ proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
1225
1563
  };
1226
1564
 
1227
1565
  // Fields added after v3 are gated on the certificate's OWN version. A v3
@@ -1235,9 +1573,9 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1235
1573
  const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1236
1574
 
1237
1575
  const issuerObj: Record<string, unknown> = {
1238
- key_id: issuer.key_id as string,
1239
- name: issuer.name as string,
1240
- public_key: toHex(issuer.public_key as string),
1576
+ key_id: stringAt(issuer, "key_id", "issuer.key_id"),
1577
+ name: stringAt(issuer, "name", "issuer.name"),
1578
+ public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
1241
1579
  };
1242
1580
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1243
1581
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
@@ -1250,26 +1588,31 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1250
1588
  version,
1251
1589
  );
1252
1590
  }
1253
- if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1254
- issuerObj.legal_entity = issuer.legal_entity;
1591
+ // A non-string here used to be dropped silently, so a junk value added to a
1592
+ // record that never carried one still verified. Python refused it; so does
1593
+ // Go, at parse. Now this does too.
1594
+ if (isV4Plus) {
1595
+ const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
1596
+ if (legalEntity) issuerObj.legal_entity = legalEntity;
1255
1597
  }
1256
1598
  // Signed from v5, and omitted when absent or empty: a build with no
1257
1599
  // measurement signs none, and "" is a value a reader could mistake for one.
1258
- if (
1259
- signatureCoversEnclavePcr0(version) &&
1260
- typeof issuer.enclave_pcr0 === "string" &&
1261
- issuer.enclave_pcr0 !== ""
1262
- ) {
1263
- issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
1600
+ if (signatureCoversEnclavePcr0(version)) {
1601
+ const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
1602
+ if (pcr0) issuerObj.enclave_pcr0 = pcr0;
1264
1603
  }
1265
1604
 
1266
1605
  // v5 drops identifier_type_hint: signed, but always the constant "custom",
1267
1606
  // so it was never evidence. v3 and v4 keep it or they stop verifying.
1268
1607
  const subjectObj: Record<string, unknown> = {
1269
- identifier_hash: toHex(subject.identifier_hash as string),
1608
+ identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1270
1609
  };
1271
1610
  if (!isV5Plus) {
1272
- subjectObj.identifier_type_hint = subject.identifier_type_hint as string;
1611
+ subjectObj.identifier_type_hint = stringAt(
1612
+ subject,
1613
+ "identifier_type_hint",
1614
+ "subject.identifier_type_hint",
1615
+ );
1273
1616
  }
1274
1617
 
1275
1618
  // status and revocation are deliberately absent (ADR-016 §3): a signature
@@ -1277,20 +1620,20 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1277
1620
  // travels as a separate short-lived signed status statement.
1278
1621
  const payload: Record<string, unknown> = {
1279
1622
  attestation: attObj,
1280
- attestation_id: cert.attestation_id as string,
1281
- certificate_format_version: cert.certificate_format_version as string,
1282
- certificate_id: cert.certificate_id as string,
1283
- issued_at: formatTimestamp(cert.issued_at),
1623
+ attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
1624
+ certificate_format_version: version,
1625
+ certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
1626
+ issued_at: formatTimestamp(cert.issued_at, "issued_at"),
1284
1627
  issuer: issuerObj,
1285
1628
  payload_type: certificatePayloadType(version),
1286
1629
  subject: subjectObj,
1287
1630
  systems,
1288
1631
  };
1289
1632
  if (isV4Plus && cert.scope != null) {
1290
- const scope = cert.scope as Record<string, unknown>;
1633
+ const scope = objectAt(cert, "scope", "scope");
1291
1634
  payload.scope = {
1292
- text_sha256: toHex(scope.text_sha256 as string),
1293
- version: scope.version as string,
1635
+ text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
1636
+ version: stringAt(scope, "version", "scope.version"),
1294
1637
  };
1295
1638
  }
1296
1639
  return canonicalJson(payload);
@@ -1307,7 +1650,7 @@ function buildLogLeafPayload(
1307
1650
  throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
1308
1651
  }
1309
1652
  const payload = {
1310
- appended_at: formatTimestamp(appendedAt),
1653
+ appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
1311
1654
  certificate_hash: bytesToHex(certificateHash),
1312
1655
  certificate_id: certificateId,
1313
1656
  entry_type: entryType,
@@ -1358,13 +1701,13 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
1358
1701
  * between two modules of this package, not published surface.
1359
1702
  */
1360
1703
  export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
1361
- const logId = head.log_id;
1362
- const named = typeof logId === "string" && logId !== "";
1704
+ const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
1705
+ const named = logId !== undefined && logId !== "";
1363
1706
  const payload: Record<string, unknown> = {
1364
1707
  payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
1365
- root_hash: toHex(head.root_hash as string),
1366
- timestamp: formatTimestamp(head.timestamp),
1367
- tree_size: head.tree_size as number,
1708
+ root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
1709
+ timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
1710
+ tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
1368
1711
  };
1369
1712
  if (named) payload.log_id = logId;
1370
1713
  return canonicalJson(payload);
@@ -1450,7 +1793,12 @@ async function chainInclusion(
1450
1793
  }
1451
1794
 
1452
1795
  function isPow2(n: number): boolean {
1453
- return n > 0 && (n & (n - 1)) === 0;
1796
+ // Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
1797
+ // signed integers, so for a tree size >= 2^31 the bit test gives the wrong
1798
+ // answer. This holds for every safe integer.
1799
+ if (n < 1) return false;
1800
+ while (n % 2 === 0) n /= 2;
1801
+ return n === 1;
1454
1802
  }
1455
1803
 
1456
1804
  /** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
@@ -1487,9 +1835,14 @@ export async function verifyConsistency(
1487
1835
  let fn = oldSize - 1;
1488
1836
  let sn = newSize - 1;
1489
1837
 
1490
- while ((fn & 1) === 1) {
1491
- fn >>= 1;
1492
- sn >>= 1;
1838
+ // Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
1839
+ // integers, so for tree sizes >= 2^31 fn and sn are truncated and this
1840
+ // consistency check silently returned the wrong answer (CONFIRMED where Go
1841
+ // says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
1842
+ // integer, which requireUint already bounds the inputs to.
1843
+ while (fn % 2 === 1) {
1844
+ fn = Math.floor(fn / 2);
1845
+ sn = Math.floor(sn / 2);
1493
1846
  }
1494
1847
 
1495
1848
  // Drive the walk from the tree, not from the proof's length. Looping on
@@ -1504,19 +1857,19 @@ export async function verifyConsistency(
1504
1857
  const c = proof[pIdx]!;
1505
1858
  pIdx++;
1506
1859
 
1507
- if ((fn & 1) === 1 || fn === sn) {
1860
+ if (fn % 2 === 1 || fn === sn) {
1508
1861
  fr = await hashNode(crypto, c, fr);
1509
1862
  sr = await hashNode(crypto, c, sr);
1510
- while (fn !== 0 && (fn & 1) === 0) {
1511
- fn >>= 1;
1512
- sn >>= 1;
1863
+ while (fn !== 0 && fn % 2 === 0) {
1864
+ fn = Math.floor(fn / 2);
1865
+ sn = Math.floor(sn / 2);
1513
1866
  }
1514
1867
  } else {
1515
1868
  sr = await hashNode(crypto, sr, c);
1516
1869
  }
1517
1870
 
1518
- fn >>= 1;
1519
- sn >>= 1;
1871
+ fn = Math.floor(fn / 2);
1872
+ sn = Math.floor(sn / 2);
1520
1873
  }
1521
1874
 
1522
1875
  return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
@@ -1535,21 +1888,24 @@ export function buildCertificateStatusPayload(
1535
1888
  ): Uint8Array {
1536
1889
  const payload: Record<string, unknown> = {
1537
1890
  payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
1538
- certificate_id: stmt.certificate_id as string,
1539
- statement_expires_at: formatTimestamp(stmt.statement_expires_at),
1540
- statement_issued_at: formatTimestamp(stmt.statement_issued_at),
1541
- status: stmt.status as string,
1542
- sth_root_hash: toHex(stmt.sth_root_hash as string),
1543
- sth_tree_size: stmt.sth_tree_size as number,
1891
+ certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
1892
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
1893
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
1894
+ status: stringAt(stmt, "status", "status.status"),
1895
+ sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
1896
+ sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
1544
1897
  };
1545
- if (stmt.replacement_certificate_id != null) {
1546
- payload.replacement_certificate_id = stmt.replacement_certificate_id as string;
1547
- }
1898
+ const replacement = optionalStringAt(
1899
+ stmt,
1900
+ "replacement_certificate_id",
1901
+ "status.replacement_certificate_id",
1902
+ );
1903
+ if (replacement !== undefined) payload.replacement_certificate_id = replacement;
1548
1904
  if (stmt.revocation_log_index != null) {
1549
- payload.revocation_log_index = stmt.revocation_log_index as number;
1905
+ payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
1550
1906
  }
1551
1907
  if (stmt.revoked_at != null) {
1552
- payload.revoked_at = formatTimestamp(stmt.revoked_at);
1908
+ payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
1553
1909
  }
1554
1910
  return canonicalJson(payload);
1555
1911
  }
@@ -1582,7 +1938,7 @@ export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
1582
1938
  keys: entries,
1583
1939
  statement_expires_at: formatTimestamp(doc.statement_expires_at),
1584
1940
  statement_issued_at: formatTimestamp(doc.statement_issued_at),
1585
- sth_root_hash: toHex(doc.sth_root_hash as string),
1941
+ sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
1586
1942
  sth_tree_size: doc.sth_tree_size as number,
1587
1943
  };
1588
1944
  return canonicalJson(payload);
@@ -1610,6 +1966,8 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1610
1966
  *
1611
1967
  * The third row is the point: `verifyCertificate` answers VALID there, which
1612
1968
  * reads as "not revoked" and is not something it checked.
1969
+ *
1970
+ * `options` is passed to {@link verifyCertificate} unchanged.
1613
1971
  */
1614
1972
  export async function verifyCertificateWithStatus(
1615
1973
  crypto: CryptoOps,
@@ -1617,18 +1975,49 @@ export async function verifyCertificateWithStatus(
1617
1975
  publicKeys: Map<string, PublicKeyInfo>,
1618
1976
  status?: Record<string, unknown> | null,
1619
1977
  now?: Date,
1978
+ options?: VerifyOptions,
1620
1979
  ): Promise<string> {
1621
1980
  // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1622
1981
  // two soft verdicts. Returning a soft verdict here skipped every check below
1623
1982
  // it: a revoked certificate signed by a compromised-later key answered
1624
1983
  // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1625
1984
  // verdict is held instead, and resolved against what the statement says.
1626
- const base = await verifyCertificate(crypto, certificate, publicKeys);
1985
+ const base = await verifyCertificate(crypto, certificate, publicKeys, options);
1627
1986
  if (status == null) {
1628
1987
  return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1629
1988
  }
1630
1989
 
1631
- const keyId = status.key_id as string | undefined;
1990
+ return resolveWithBase(
1991
+ base,
1992
+ await verifyStatusStatement(
1993
+ crypto, status, publicKeys, (certificate as Record<string, unknown>).certificate_id, now,
1994
+ ),
1995
+ );
1996
+ }
1997
+
1998
+ /**
1999
+ * The statement half of {@link verifyCertificateWithStatus}: authenticate a
2000
+ * status statement about `certificateId` against the published keys, and read
2001
+ * it at `now`. The /verify page's QR lookup has a record id and no record, and
2002
+ * uses this so it shows what a signed, fresh statement says, never what the
2003
+ * status endpoint claims.
2004
+ *
2005
+ * | input | result |
2006
+ * |---|---|
2007
+ * | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
2008
+ * | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
2009
+ * | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
2010
+ * | unknown or unusable key, bad signature, another certificate | throws VerificationError |
2011
+ */
2012
+ export async function verifyStatusStatement(
2013
+ crypto: CryptoOps,
2014
+ status: Record<string, unknown>,
2015
+ publicKeys: Map<string, PublicKeyInfo>,
2016
+ certificateId: unknown,
2017
+ now?: Date,
2018
+ ): Promise<string> {
2019
+ requireObject(status, "status");
2020
+ const keyId = optionalStringAt(status, "key_id", "status.key_id");
1632
2021
  const info = keyId ? publicKeys.get(keyId) : undefined;
1633
2022
  if (!info) {
1634
2023
  throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
@@ -1656,10 +2045,10 @@ export async function verifyCertificateWithStatus(
1656
2045
  );
1657
2046
 
1658
2047
  const payload = buildCertificateStatusPayload(status);
1659
- // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
2048
+ // decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
1660
2049
  // array depending on the producer, and every other signature on this path
1661
2050
  // goes through the same decoder.
1662
- const sig = decodeSignature(status.signature);
2051
+ const sig = decodeFixed(status.signature, "status.signature", 64);
1663
2052
  if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
1664
2053
  throw new VerificationError("status statement signature is invalid");
1665
2054
  }
@@ -1677,8 +2066,7 @@ export async function verifyCertificateWithStatus(
1677
2066
  // Every signature still verified; the binding was the only forged part.
1678
2067
  // core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
1679
2068
  // while both SDKs and the browser bundle said VALID.
1680
- const certId = (certificate as Record<string, unknown>).certificate_id;
1681
- if (status.certificate_id !== certId) {
2069
+ if (status.certificate_id !== certificateId) {
1682
2070
  throw new VerificationError("status statement is about a different certificate");
1683
2071
  }
1684
2072
 
@@ -1689,7 +2077,7 @@ export async function verifyCertificateWithStatus(
1689
2077
  if (at < issued || at >= expires) {
1690
2078
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
1691
2079
  // statement, which must never decay into VALID.
1692
- return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
2080
+ return VALID_REVOCATION_UNKNOWN;
1693
2081
  }
1694
2082
 
1695
2083
  if (status.status === "REVOKED") {
@@ -1705,12 +2093,12 @@ export async function verifyCertificateWithStatus(
1705
2093
  // case — was reported as revocation CHECKED AND PASSED. Mirrors
1706
2094
  // core.ValidCertificateStatusValue.
1707
2095
  if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
1708
- return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
2096
+ return VALID_REVOCATION_UNKNOWN;
1709
2097
  }
1710
2098
  // A soft verdict on the status key is the result, not a footnote — but it is
1711
2099
  // reported only once the statement has authenticated and been read, so it can
1712
2100
  // neither speak for an unverified statement nor suppress a revocation.
1713
- return resolveWithBase(base, statusKeyVerdict);
2101
+ return statusKeyVerdict;
1714
2102
  }
1715
2103
 
1716
2104
  /**