burnledger 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +139 -2
  2. package/dist/cjs/anchor.d.ts +5 -3
  3. package/dist/cjs/anchor.d.ts.map +1 -1
  4. package/dist/cjs/anchor.js +10 -4
  5. package/dist/cjs/anchor.js.map +1 -1
  6. package/dist/cjs/client.d.ts +95 -0
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +133 -2
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/customer-keys.d.ts +73 -1
  11. package/dist/cjs/customer-keys.d.ts.map +1 -1
  12. package/dist/cjs/customer-keys.js +329 -3
  13. package/dist/cjs/customer-keys.js.map +1 -1
  14. package/dist/cjs/enclave-registration.d.ts +153 -0
  15. package/dist/cjs/enclave-registration.d.ts.map +1 -0
  16. package/dist/cjs/enclave-registration.js +275 -0
  17. package/dist/cjs/enclave-registration.js.map +1 -0
  18. package/dist/cjs/enclave-seal.d.ts +43 -0
  19. package/dist/cjs/enclave-seal.d.ts.map +1 -1
  20. package/dist/cjs/enclave-seal.js +62 -1
  21. package/dist/cjs/enclave-seal.js.map +1 -1
  22. package/dist/cjs/errors.d.ts +10 -0
  23. package/dist/cjs/errors.d.ts.map +1 -1
  24. package/dist/cjs/errors.js +11 -1
  25. package/dist/cjs/errors.js.map +1 -1
  26. package/dist/cjs/index.browser.d.ts +7 -3
  27. package/dist/cjs/index.browser.d.ts.map +1 -1
  28. package/dist/cjs/index.browser.js +6 -3
  29. package/dist/cjs/index.browser.js.map +1 -1
  30. package/dist/cjs/index.d.ts +22 -10
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/cjs/index.js +30 -6
  33. package/dist/cjs/index.js.map +1 -1
  34. package/dist/cjs/key-group.d.ts +80 -0
  35. package/dist/cjs/key-group.d.ts.map +1 -0
  36. package/dist/cjs/key-group.js +136 -0
  37. package/dist/cjs/key-group.js.map +1 -0
  38. package/dist/cjs/models.d.ts +60 -0
  39. package/dist/cjs/models.d.ts.map +1 -1
  40. package/dist/cjs/models.js +63 -0
  41. package/dist/cjs/models.js.map +1 -1
  42. package/dist/cjs/status-document.d.ts +25 -0
  43. package/dist/cjs/status-document.d.ts.map +1 -0
  44. package/dist/cjs/status-document.js +62 -0
  45. package/dist/cjs/status-document.js.map +1 -0
  46. package/dist/cjs/verify.d.ts +108 -3
  47. package/dist/cjs/verify.d.ts.map +1 -1
  48. package/dist/cjs/verify.js +389 -146
  49. package/dist/cjs/verify.js.map +1 -1
  50. package/dist/cjs/web-verifier.d.ts +23 -3
  51. package/dist/cjs/web-verifier.d.ts.map +1 -1
  52. package/dist/cjs/web-verifier.js +31 -5
  53. package/dist/cjs/web-verifier.js.map +1 -1
  54. package/dist/esm/anchor.d.ts +5 -3
  55. package/dist/esm/anchor.d.ts.map +1 -1
  56. package/dist/esm/anchor.js +10 -4
  57. package/dist/esm/anchor.js.map +1 -1
  58. package/dist/esm/cli.d.ts +13 -19
  59. package/dist/esm/cli.d.ts.map +1 -1
  60. package/dist/esm/cli.js +88 -81
  61. package/dist/esm/cli.js.map +1 -1
  62. package/dist/esm/client.d.ts +95 -0
  63. package/dist/esm/client.d.ts.map +1 -1
  64. package/dist/esm/client.js +133 -2
  65. package/dist/esm/client.js.map +1 -1
  66. package/dist/esm/customer-keys.d.ts +73 -1
  67. package/dist/esm/customer-keys.d.ts.map +1 -1
  68. package/dist/esm/customer-keys.js +323 -3
  69. package/dist/esm/customer-keys.js.map +1 -1
  70. package/dist/esm/enclave-registration.d.ts +153 -0
  71. package/dist/esm/enclave-registration.d.ts.map +1 -0
  72. package/dist/esm/enclave-registration.js +265 -0
  73. package/dist/esm/enclave-registration.js.map +1 -0
  74. package/dist/esm/enclave-seal.d.ts +43 -0
  75. package/dist/esm/enclave-seal.d.ts.map +1 -1
  76. package/dist/esm/enclave-seal.js +61 -1
  77. package/dist/esm/enclave-seal.js.map +1 -1
  78. package/dist/esm/errors.d.ts +10 -0
  79. package/dist/esm/errors.d.ts.map +1 -1
  80. package/dist/esm/errors.js +10 -0
  81. package/dist/esm/errors.js.map +1 -1
  82. package/dist/esm/index.browser.d.ts +7 -3
  83. package/dist/esm/index.browser.d.ts.map +1 -1
  84. package/dist/esm/index.browser.js +6 -3
  85. package/dist/esm/index.browser.js.map +1 -1
  86. package/dist/esm/index.d.ts +22 -10
  87. package/dist/esm/index.d.ts.map +1 -1
  88. package/dist/esm/index.js +18 -8
  89. package/dist/esm/index.js.map +1 -1
  90. package/dist/esm/key-group.d.ts +80 -0
  91. package/dist/esm/key-group.d.ts.map +1 -0
  92. package/dist/esm/key-group.js +130 -0
  93. package/dist/esm/key-group.js.map +1 -0
  94. package/dist/esm/models.d.ts +60 -0
  95. package/dist/esm/models.d.ts.map +1 -1
  96. package/dist/esm/models.js +59 -0
  97. package/dist/esm/models.js.map +1 -1
  98. package/dist/esm/status-document.d.ts +25 -0
  99. package/dist/esm/status-document.d.ts.map +1 -0
  100. package/dist/esm/status-document.js +59 -0
  101. package/dist/esm/status-document.js.map +1 -0
  102. package/dist/esm/verify.d.ts +108 -3
  103. package/dist/esm/verify.d.ts.map +1 -1
  104. package/dist/esm/verify.js +386 -146
  105. package/dist/esm/verify.js.map +1 -1
  106. package/dist/esm/web-verifier.d.ts +23 -3
  107. package/dist/esm/web-verifier.d.ts.map +1 -1
  108. package/dist/esm/web-verifier.js +27 -5
  109. package/dist/esm/web-verifier.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/anchor.ts +10 -4
  112. package/src/cli.ts +96 -79
  113. package/src/client.ts +198 -6
  114. package/src/customer-keys.ts +373 -3
  115. package/src/enclave-registration.ts +390 -0
  116. package/src/enclave-seal.ts +108 -1
  117. package/src/errors.ts +11 -0
  118. package/src/index.browser.ts +9 -3
  119. package/src/index.ts +35 -6
  120. package/src/key-group.ts +181 -0
  121. package/src/models.ts +131 -0
  122. package/src/status-document.ts +59 -0
  123. package/src/verify.ts +499 -151
  124. package/src/web-verifier.ts +36 -3
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}`);
@@ -469,8 +481,8 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
469
481
  export function certificateAnchor(certificate: Record<string, unknown>): number | null {
470
482
  const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
471
483
  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;
484
+ const sth = transparency.signed_tree_head as Record<string, unknown> | null | undefined;
485
+ if (sth == null || typeof sth.timestamp !== "string") return null;
474
486
  try {
475
487
  return parseRfc3339(sth.timestamp);
476
488
  } catch {
@@ -503,13 +515,19 @@ async function verifiedCertificateAnchor(
503
515
 
504
516
  const transparency = certificate.transparency as Record<string, unknown>;
505
517
  const sth = transparency.signed_tree_head as Record<string, unknown>;
518
+ // Only a refusal of the head's own fields means "no anchor". A bare catch
519
+ // here gave a defect in this library — or a crypto provider that threw — the
520
+ // same quiet answer as a malformed document.
521
+ let headPayload: Uint8Array;
522
+ let headSig: Uint8Array;
506
523
  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;
524
+ headPayload = buildTreeHeadPayload(sth);
525
+ headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
526
+ } catch (e) {
527
+ if (e instanceof VerificationError) return null;
528
+ throw e;
512
529
  }
530
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
513
531
  return anchor;
514
532
  }
515
533
 
@@ -559,21 +577,246 @@ function requireUsableKey(
559
577
 
560
578
  type Cert = Record<string, unknown>;
561
579
 
580
+ /**
581
+ * Require what ADR-025 lets a record prove: that a key group you name signed the
582
+ * registration the enclave checked each system against before measuring it.
583
+ *
584
+ * A pass means each required system is `certified` under one of
585
+ * `customerKeyIds`, in a record from an enclave image of wire protocol 12 or
586
+ * later — the first that registers a system only with that key group's
587
+ * signatures. A record from an earlier image (PRE_PROTOCOL_12_IMAGES), or one
588
+ * that names no image, fails: those images could say `certified` with no
589
+ * customer signature at all.
590
+ *
591
+ * Off unless asked for, because a record that says `authorization: none` is
592
+ * still a genuine record. Whoever relays requests to the enclave can always
593
+ * leave a registration out (ADR-025 §5, §8), and the enclave cannot refuse that
594
+ * for want of durable state. So "my systems must be certified" can be enforced
595
+ * in one place only: here, by the party relying on the record.
596
+ */
597
+ export interface AuthorizationPolicy {
598
+ /** The system_ids that must be certified. Omitted: every system the record covers. */
599
+ readonly systems?: readonly string[];
600
+ /**
601
+ * The key groups you accept. Required: `certified` under a key id you did not
602
+ * name proves nothing to you, because the relay can enroll a key group of its
603
+ * own and the record would name that one.
604
+ */
605
+ readonly customerKeyIds: readonly string[];
606
+ }
607
+
608
+ export interface VerifyOptions {
609
+ /**
610
+ * Refuse the record, with a VerificationError whose code is
611
+ * AUTHORIZATION_POLICY_FAILED, unless it meets this policy. Omitted: no
612
+ * requirement, and `authorization: none` verifies as it always has.
613
+ */
614
+ readonly requireAuthorization?: AuthorizationPolicy;
615
+ }
616
+
617
+ function policySet(values: readonly string[] | undefined, field: string, lower: boolean): Set<string> | undefined {
618
+ if (values === undefined) return undefined;
619
+ if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
620
+ throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
621
+ }
622
+ if (values.length === 0) {
623
+ // An empty requirement requires nothing, which is never what a caller who
624
+ // built a policy meant.
625
+ throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
626
+ }
627
+ return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
628
+ }
629
+
630
+ interface ParsedPolicy {
631
+ readonly systems: Set<string> | undefined;
632
+ readonly customerKeyIds: Set<string>;
633
+ }
634
+
635
+ /** Check a policy before anything is verified, so a caller's mistake surfaces as
636
+ * one whatever the record turns out to be. */
637
+ function parsePolicy(policy: AuthorizationPolicy): ParsedPolicy {
638
+ const keys = policy.customerKeyIds;
639
+ if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
640
+ throw new TypeError(
641
+ "requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
642
+ "a key id you did not name proves nothing to you, because the relay can enroll a key group of its own",
643
+ );
644
+ }
645
+ return {
646
+ systems: policySet(policy.systems, "systems", true),
647
+ customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false)!,
648
+ };
649
+ }
650
+
651
+ /**
652
+ * The enclave images that issued format 9.0 while running wire protocol 11 or
653
+ * earlier, by PCR0, as the published measurement history records them
654
+ * (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
655
+ *
656
+ * Their `certified` is not evidence of a customer signature. Before protocol 12
657
+ * the enclave enrolled a key group with no proof that anyone held its keys, and
658
+ * registered a system for whoever relayed the authorization — so the host could
659
+ * make a genuine 9.0 record say `certified`, under a key id it chose, with no
660
+ * customer signature anywhere (reproduced by an independent re-review,
661
+ * 2026-09-13).
662
+ *
663
+ * THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
664
+ * admitting or rolling back to an image of protocol 11 or earlier once any
665
+ * customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
666
+ * leaves to the owner. The protocol-12 cutover's KMS tighten drops every
667
+ * measurement here from the signing key's policy, after which none of them can
668
+ * sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
669
+ * because it held the signing key while it ran.
670
+ *
671
+ * The Python SDK carries the same list, in _verify.py.
672
+ */
673
+ export const PRE_PROTOCOL_12_IMAGES: ReadonlyMap<string, string> = new Map([
674
+ ["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
675
+ ["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
676
+ ["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
677
+ ["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
678
+ ["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
679
+ ["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
680
+ ["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
681
+ ]);
682
+
683
+ /**
684
+ * What a record's signed issuer.enclave_pcr0 makes of `certified`: whether the
685
+ * image that signed it could mark a system certified without the customer's
686
+ * signature. "protocol-12-or-later" is the inference the closed list licenses:
687
+ * a genuine 9.0 record under any other measurement was signed by a later image.
688
+ */
689
+ export type IssuingImage =
690
+ | { readonly kind: "unnamed" }
691
+ | { readonly kind: "before-protocol-12"; readonly name: string; readonly pcr0: string }
692
+ | { readonly kind: "protocol-12-or-later"; readonly pcr0: string };
693
+
694
+ export function issuingImage(certificate: Cert): IssuingImage {
695
+ const issuer = certificate.issuer as Cert | undefined;
696
+ const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
697
+ if (pcr0 === "") return { kind: "unnamed" };
698
+ const name = PRE_PROTOCOL_12_IMAGES.get(pcr0);
699
+ return name === undefined
700
+ ? { kind: "protocol-12-or-later", pcr0 }
701
+ : { kind: "before-protocol-12", name, pcr0 };
702
+ }
703
+
704
+ /**
705
+ * What `certified` is evidence of, given the image that signed the record: the
706
+ * words every verifier prints after "certified under <key id> —". The Go CLI
707
+ * (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
708
+ * identically, and testdata/registration_line_cases.json holds them to it.
709
+ */
710
+ export function registrationQualifier(image: IssuingImage): string {
711
+ switch (image.kind) {
712
+ case "unnamed":
713
+ return (
714
+ "not evidence here: this record names no enclave image, so it may come from one " +
715
+ "that could mark a system certified without the customer's signature."
716
+ );
717
+ case "before-protocol-12":
718
+ return (
719
+ `not evidence here: this record comes from ${image.name}, an enclave image of ` +
720
+ "protocol 11 or earlier, which could mark a system certified without the customer's signature."
721
+ );
722
+ case "protocol-12-or-later":
723
+ return (
724
+ "the key group with that id signed this system's registration, if that key id is yours: " +
725
+ "this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
726
+ "someone else authorized this verification."
727
+ );
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Throw unless a record that has already verified meets `policy`.
733
+ *
734
+ * From 9.0 every field read here is under the certificate signature, the
735
+ * issuer's enclave_pcr0 included. Below 9.0
736
+ * nothing signs `authorization` — those records carry the field outside the
737
+ * signed bytes — so one that says `certified` says only what its last holder
738
+ * typed. It fails on the format, before the field is read.
739
+ */
740
+ function enforceAuthorization(certificate: Cert, formatVersion: string, policy: ParsedPolicy): void {
741
+ const systemsWanted = policy.systems;
742
+ if (!signatureCoversAuthorization(formatVersion)) {
743
+ throw new VerificationError(
744
+ `authorization required, but this record is format ${formatVersion}, which predates ` +
745
+ `customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`,
746
+ AUTHORIZATION_POLICY_FAILED,
747
+ );
748
+ }
749
+ const systems = (certificate.systems as Record<string, unknown>[] | null | undefined) ?? [];
750
+ let required = systems;
751
+ if (systemsWanted !== undefined) {
752
+ const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
753
+ const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
754
+ if (missing.length > 0) {
755
+ throw new VerificationError(
756
+ `authorization required for system ${missing[0]}, which this record does not cover`,
757
+ AUTHORIZATION_POLICY_FAILED,
758
+ );
759
+ }
760
+ required = [...systemsWanted].sort().map((id) => byId.get(id)!);
761
+ }
762
+ const labelOf = (s: Record<string, unknown>): string =>
763
+ `system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
764
+ for (const s of required) {
765
+ if (s.authorization !== AUTHORIZATION_CERTIFIED) {
766
+ throw new VerificationError(
767
+ `authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
768
+ "the enclave did not check it against a registration",
769
+ AUTHORIZATION_POLICY_FAILED,
770
+ );
771
+ }
772
+ }
773
+ // Asked once, for the record, after every required system has said certified:
774
+ // which image said it decides whether `certified` means a customer signed.
775
+ const image = issuingImage(certificate);
776
+ if (image.kind === "unnamed") {
777
+ throw new VerificationError(
778
+ "authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
779
+ "absent), so nothing shows it came from one that registers a system only with the " +
780
+ "customer's signature",
781
+ AUTHORIZATION_POLICY_FAILED,
782
+ );
783
+ }
784
+ if (image.kind === "before-protocol-12") {
785
+ throw new VerificationError(
786
+ `authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
787
+ "an enclave image of wire protocol 11 or earlier, which could register a system without " +
788
+ "the customer's signature; its `certified` does not show that any key group signed anything",
789
+ AUTHORIZATION_POLICY_FAILED,
790
+ );
791
+ }
792
+ for (const s of required) {
793
+ if (!policy.customerKeyIds.has(s.customer_key_id as string)) {
794
+ throw new VerificationError(
795
+ `${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
796
+ "accept; if it is not yours, someone else authorized this verification",
797
+ AUTHORIZATION_POLICY_FAILED,
798
+ );
799
+ }
800
+ }
801
+ }
802
+
562
803
  /** Verify all signatures on a deletion certificate offline. */
563
804
  export async function verifyCertificate(
564
805
  crypto: CryptoOps,
565
806
  certificate: Cert,
566
807
  publicKeys: Map<string, PublicKeyInfo>,
808
+ options?: VerifyOptions,
567
809
  ): Promise<VerificationResult> {
568
810
  // Step 0: can this SDK read the format at all? Every check below rebuilds
569
811
  // signed bytes from the version, so an unreadable version makes all of them
570
812
  // meaningless - and "unknown issuer key" would be the wrong thing to tell
571
813
  // 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
- );
814
+ requireObject(certificate, "certificate");
815
+ const policy =
816
+ options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
817
+ const formatVersion = checkFormatVersion(certificate.certificate_format_version);
575
818
 
576
- const issuer = certificate.issuer as Record<string, unknown>;
819
+ const issuer = objectAt(certificate, "issuer", "issuer");
577
820
  // Step 0b: can this SDK check the algorithm the record names? Asked before
578
821
  // any signature, because verifying an Ed25519 signature over a record that
579
822
  // says it was signed with something else answers a question nobody asked.
@@ -587,7 +830,7 @@ export async function verifyCertificate(
587
830
  UNSUPPORTED_ALGORITHM,
588
831
  );
589
832
  }
590
- const keyId = issuer.key_id as string;
833
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
591
834
 
592
835
  const pki = publicKeys.get(keyId);
593
836
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
@@ -602,7 +845,7 @@ export async function verifyCertificate(
602
845
  // 1. Certificate signature FIRST — nothing below may trust a field until the
603
846
  // bytes carrying it are covered by a verified signature.
604
847
  const certPayload = buildCertificatePayload(certificate);
605
- const certSig = decodeSignature(certificate.certificate_signature as string);
848
+ const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
606
849
  if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
607
850
  throw new VerificationError("certificate signature is invalid");
608
851
  }
@@ -621,7 +864,7 @@ export async function verifyCertificate(
621
864
  },
622
865
  certificate.certificate_format_version as string,
623
866
  );
624
- const attSig = decodeSignature(att.attestation_signature as string);
867
+ const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
625
868
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
626
869
  throw new VerificationError("attestation signature is invalid");
627
870
  }
@@ -682,6 +925,10 @@ export async function verifyCertificate(
682
925
  );
683
926
  }
684
927
 
928
+ if (policy !== undefined) {
929
+ enforceAuthorization(certificate, formatVersion, policy);
930
+ }
931
+
685
932
  // A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
686
933
  // and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
687
934
  // reporting VALID here would be the lying by omission ADR-017 exists to stop.
@@ -697,16 +944,15 @@ export async function verifyTransparency(
697
944
  certificate: Cert,
698
945
  publicKeys: Map<string, PublicKeyInfo>,
699
946
  ): Promise<TransparencyResult> {
700
- const transparency = certificate.transparency as
701
- | Record<string, unknown>
702
- | undefined;
703
- if (transparency == null) {
947
+ requireObject(certificate, "certificate");
948
+ if (certificate.transparency == null) {
704
949
  return "NOT_AVAILABLE" as TransparencyResult;
705
950
  }
951
+ const transparency = objectAt(certificate, "transparency", "transparency");
706
952
 
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;
953
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
954
+ const issuer = objectAt(certificate, "issuer", "issuer");
955
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
710
956
 
711
957
  const pki = publicKeys.get(keyId);
712
958
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
@@ -715,7 +961,7 @@ export async function verifyTransparency(
715
961
 
716
962
  // 1. Tree head signature
717
963
  const headPayload = buildTreeHeadPayload(sth);
718
- const headSig = decodeSignature(sth.signature as string);
964
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
719
965
  if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
720
966
  throw new VerificationError("tree head signature is invalid");
721
967
  }
@@ -731,16 +977,16 @@ export async function verifyTransparency(
731
977
  const issuanceData = issuanceBytes(certificate);
732
978
  const leafPayload = buildLogLeafPayload(
733
979
  transparency.entry_type,
734
- certificate.certificate_id as string,
980
+ stringAt(certificate, "certificate_id", "certificate_id"),
735
981
  await crypto.sha256(issuanceData),
736
982
  transparency.appended_at,
737
983
  );
738
984
  const leaf = await hashLeaf(crypto, leafPayload);
739
985
 
740
- const proofHashes = (
741
- (transparency.inclusion_proof as unknown[] | undefined) ?? []
742
- ).map((h) => decodeBytes(h));
743
- const root = decodeBytes(sth.root_hash);
986
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map(
987
+ (h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32),
988
+ );
989
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
744
990
  // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
745
991
  // for an explicit null and for an absent key rather than failing, so refusing
746
992
  // either would reject documents the reference accepts — the same class of
@@ -838,48 +1084,96 @@ function canonicalStringify(val: unknown): string {
838
1084
  }
839
1085
 
840
1086
  // ---------------------------------------------------------------------------
841
- // Byte encoding helpers
1087
+ // Reading the document
842
1088
  // ---------------------------------------------------------------------------
1089
+ //
1090
+ // A certificate or status statement handed to this verifier is untrusted JSON.
1091
+ // A key that is missing, a value of the wrong JSON type or bytes that will not
1092
+ // decode is a fact about the DOCUMENT, so every field is read through one of
1093
+ // these and refused with a VerificationError naming it. Casting and reading
1094
+ // instead threw TypeError from `undefined.x` and a DOMException from atob,
1095
+ // which a caller cannot tell apart from a bug in this library (BL-4-001-B).
1096
+ //
1097
+ // Absent and null are the same thing, as they are to Go's encoding/json.
843
1098
 
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();
1099
+ type Doc = Record<string, unknown>;
1100
+
1101
+ function requireObject(value: unknown, path: string): Doc {
1102
+ if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1103
+ if (typeof value !== "object" || Array.isArray(value)) {
1104
+ throw new VerificationError(`${path} is not an object`);
856
1105
  }
857
- return bytesToHex(base64ToBytes(str));
1106
+ return value as Doc;
858
1107
  }
859
1108
 
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);
1109
+ function objectAt(parent: Doc, key: string, path: string): Doc {
1110
+ return requireObject(parent[key], path);
1111
+ }
1112
+
1113
+ function stringAt(parent: Doc, key: string, path: string): string {
1114
+ const value = parent[key];
1115
+ if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1116
+ if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
1117
+ return value;
871
1118
  }
872
1119
 
873
- /** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
874
- function decodeBytes(value: unknown): Uint8Array {
1120
+ function optionalStringAt(parent: Doc, key: string, path: string): string | undefined {
1121
+ const value = parent[key];
1122
+ if (value === undefined || value === null) return undefined;
1123
+ if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
1124
+ return value;
1125
+ }
1126
+
1127
+ /**
1128
+ * An array field. Absent and null read as empty: Go leaves a nil slice.
1129
+ *
1130
+ * Returned dense. JSON cannot express a hole, but a caller building the object
1131
+ * in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
1132
+ * `undefined` and threw a TypeError. Array.from reads a hole as undefined,
1133
+ * which the element's own reader then refuses as missing.
1134
+ */
1135
+ function arrayAt(parent: Doc, key: string, path: string): unknown[] {
1136
+ const value = parent[key];
1137
+ if (value === undefined || value === null) return [];
1138
+ if (!Array.isArray(value)) throw new VerificationError(`${path} is not an array`);
1139
+ return Array.from(value);
1140
+ }
1141
+
1142
+ const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
1143
+ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1144
+
1145
+ /**
1146
+ * Decode a fixed-size byte field — a hash, a key, a signature.
1147
+ *
1148
+ * Go marshals `[N]byte` as an array of numbers; hand-written documents carry
1149
+ * hex or standard base64. A well-formed value of the right size is never
1150
+ * ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
1151
+ * in padding hex cannot contain.
1152
+ *
1153
+ * Everything else is refused here, by name: a number, an array element that is
1154
+ * not a byte, text that is neither encoding, the wrong length. Decoding it
1155
+ * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
1156
+ * signature covers and blamed the signature — or threw from inside atob.
1157
+ */
1158
+ function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
1159
+ if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1160
+ let raw: Uint8Array;
875
1161
  if (Array.isArray(value)) {
876
- return new Uint8Array(value as number[]);
1162
+ if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
1163
+ throw new VerificationError(`${path} is not an array of bytes`);
1164
+ }
1165
+ raw = new Uint8Array(value as number[]);
1166
+ } else if (typeof value === "string") {
1167
+ if (HEX_TEXT_RE.test(value)) raw = hexToBytes(value);
1168
+ else if (BASE64_TEXT_RE.test(value)) raw = base64ToBytes(value);
1169
+ else throw new VerificationError(`${path} is neither hex nor base64`);
1170
+ } else {
1171
+ throw new VerificationError(`${path} is not a byte string`);
877
1172
  }
878
- const str = value as string;
879
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
880
- return hexToBytes(str);
1173
+ if (raw.length !== length) {
1174
+ throw new VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
881
1175
  }
882
- return base64ToBytes(str);
1176
+ return raw;
883
1177
  }
884
1178
 
885
1179
  /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
@@ -912,13 +1206,16 @@ const RFC3339_RE =
912
1206
  * Exported from this module so the timestamp_vectors.json suite can diff it
913
1207
  * against Go directly. It is deliberately not re-exported from index.ts, so it
914
1208
  * is not part of the published package surface.
1209
+ *
1210
+ * `field` names the value in a refusal. It defaults to the word these messages
1211
+ * always used, for callers normalizing a time that is not a field of a document.
915
1212
  */
916
- export function formatTimestamp(ts: unknown): string {
1213
+ export function formatTimestamp(ts: unknown, field = "timestamp"): string {
917
1214
  if (ts === null || ts === undefined) return ZERO_INSTANT;
918
1215
  if (typeof ts !== "string") {
919
- throw new VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
1216
+ throw new VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
920
1217
  }
921
- return formatEpochSeconds(parseRfc3339(ts));
1218
+ return formatEpochSeconds(parseRfc3339(ts, field));
922
1219
  }
923
1220
 
924
1221
  /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
@@ -930,19 +1227,19 @@ export function formatTimestamp(ts: unknown): string {
930
1227
  * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
931
1228
  * digits are dropped, matching Format's truncation.
932
1229
  */
933
- function parseRfc3339(ts: string): number {
1230
+ function parseRfc3339(ts: string, field = "timestamp"): number {
934
1231
  const m = RFC3339_RE.exec(ts);
935
- if (m === null) throw new VerificationError(`timestamp is not RFC 3339: ${ts}`);
1232
+ if (m === null) throw new VerificationError(`${field} is not RFC 3339: ${ts}`);
936
1233
  const [year, month, day, hour, minute, second] = m
937
1234
  .slice(1, 7)
938
1235
  .map((v) => Number.parseInt(v!, 10)) as [number, number, number, number, number, number];
939
1236
 
940
- if (month < 1 || month > 12) throw new VerificationError(`timestamp month out of range: ${ts}`);
1237
+ if (month < 1 || month > 12) throw new VerificationError(`${field} month out of range: ${ts}`);
941
1238
  if (day < 1 || day > daysInMonth(year, month)) {
942
- throw new VerificationError(`timestamp day out of range: ${ts}`);
1239
+ throw new VerificationError(`${field} day out of range: ${ts}`);
943
1240
  }
944
1241
  if (hour > 23 || minute > 59 || second > 59) {
945
- throw new VerificationError(`timestamp time of day out of range: ${ts}`);
1242
+ throw new VerificationError(`${field} time of day out of range: ${ts}`);
946
1243
  }
947
1244
 
948
1245
  let offset = 0;
@@ -950,8 +1247,8 @@ function parseRfc3339(ts: string): number {
950
1247
  if (sign !== undefined) {
951
1248
  const offHour = Number.parseInt(m[8]!, 10);
952
1249
  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}`);
1250
+ if (offHour > 24) throw new VerificationError(`${field} zone offset hour out of range: ${ts}`);
1251
+ if (offMin > 60) throw new VerificationError(`${field} zone offset minute out of range: ${ts}`);
955
1252
  offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
956
1253
  }
957
1254
 
@@ -1051,17 +1348,18 @@ function buildAttestationPayload(
1051
1348
  // payload and call every genuine v8 record a forgery.
1052
1349
  const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
1053
1350
  const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
1054
- const systems = (att.systems as Record<string, unknown>[]).map((s) => {
1351
+ const systems = (att.systems as Record<string, unknown>[]).map((s, i) => {
1055
1352
  const sys: Record<string, unknown> = {
1056
1353
  canonical_version: (s.canonical_version as string | null) ?? null,
1057
1354
  connector_type: s.connector_type as string,
1058
1355
  hash_scope: s.hash_scope as string,
1059
- merkle_root: s.merkle_root
1060
- ? toHex(s.merkle_root as string)
1061
- : null,
1356
+ merkle_root:
1357
+ s.merkle_root == null
1358
+ ? null
1359
+ : bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
1062
1360
  // 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),
1361
+ observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
1362
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
1065
1363
  record_count: s.record_count as number,
1066
1364
  system_id: s.system_id as string,
1067
1365
  system_name: s.system_name as string,
@@ -1095,7 +1393,7 @@ function buildAttestationPayload(
1095
1393
  attested_at: attestedAt,
1096
1394
  payload_type: attestationPayloadType(certFormatVersion),
1097
1395
  proof_mode: att.proof_mode as string,
1098
- subject_hash: toHex(subject.identifier_hash as string),
1396
+ subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1099
1397
  systems,
1100
1398
  };
1101
1399
  return canonicalJson(payload);
@@ -1161,9 +1459,9 @@ function attestationPayloadType(version: string): string {
1161
1459
  }
1162
1460
 
1163
1461
  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>;
1462
+ const att = objectAt(cert, "attestation", "attestation");
1463
+ const issuer = objectAt(cert, "issuer", "issuer");
1464
+ const subject = objectAt(cert, "subject", "subject");
1167
1465
 
1168
1466
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
1169
1467
  // and a verification list joined only on the human-editable system_name,
@@ -1172,19 +1470,27 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1172
1470
  const coversMeasured = signatureCoversMeasuredTransport(version);
1173
1471
  const coversRecoverable = signatureCoversRecoverableState(version);
1174
1472
  const coversAuthorization = signatureCoversAuthorization(version);
1175
- const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
1473
+ const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
1474
+ const where = `systems[${i}]`;
1475
+ const s = requireObject(entry, where);
1176
1476
  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,
1477
+ attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
1478
+ attested_count: requireUint(s.attested_count, `${where}.attested_count`),
1479
+ canonical_version:
1480
+ optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
1481
+ connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
1482
+ hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
1483
+ // Absent is a system with no Merkle root; anything present must be one.
1484
+ // Go's *[32]byte is nil or 32 bytes, never "" or [].
1485
+ merkle_root:
1486
+ s.merkle_root == null
1487
+ ? null
1488
+ : bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
1489
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
1490
+ system_id: stringAt(s, "system_id", `${where}.system_id`),
1491
+ system_name: stringAt(s, "system_name", `${where}.system_name`),
1492
+ verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
1493
+ verified_count: requireUint(s.verified_count, `${where}.verified_count`),
1188
1494
  };
1189
1495
  if (coversMeasured) {
1190
1496
  sys.read_only_enforcement = requireMeasured(
@@ -1219,9 +1525,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1219
1525
  // The attestation block carries no system list of its own: the merged list
1220
1526
  // above is a superset of it. verification_signature is gone entirely.
1221
1527
  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,
1528
+ attestation_signature: bytesToHex(
1529
+ decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64),
1530
+ ),
1531
+ attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
1532
+ proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
1225
1533
  };
1226
1534
 
1227
1535
  // Fields added after v3 are gated on the certificate's OWN version. A v3
@@ -1235,9 +1543,9 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1235
1543
  const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1236
1544
 
1237
1545
  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),
1546
+ key_id: stringAt(issuer, "key_id", "issuer.key_id"),
1547
+ name: stringAt(issuer, "name", "issuer.name"),
1548
+ public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
1241
1549
  };
1242
1550
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1243
1551
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
@@ -1250,26 +1558,31 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1250
1558
  version,
1251
1559
  );
1252
1560
  }
1253
- if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1254
- issuerObj.legal_entity = issuer.legal_entity;
1561
+ // A non-string here used to be dropped silently, so a junk value added to a
1562
+ // record that never carried one still verified. Python refused it; so does
1563
+ // Go, at parse. Now this does too.
1564
+ if (isV4Plus) {
1565
+ const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
1566
+ if (legalEntity) issuerObj.legal_entity = legalEntity;
1255
1567
  }
1256
1568
  // Signed from v5, and omitted when absent or empty: a build with no
1257
1569
  // 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;
1570
+ if (signatureCoversEnclavePcr0(version)) {
1571
+ const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
1572
+ if (pcr0) issuerObj.enclave_pcr0 = pcr0;
1264
1573
  }
1265
1574
 
1266
1575
  // v5 drops identifier_type_hint: signed, but always the constant "custom",
1267
1576
  // so it was never evidence. v3 and v4 keep it or they stop verifying.
1268
1577
  const subjectObj: Record<string, unknown> = {
1269
- identifier_hash: toHex(subject.identifier_hash as string),
1578
+ identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1270
1579
  };
1271
1580
  if (!isV5Plus) {
1272
- subjectObj.identifier_type_hint = subject.identifier_type_hint as string;
1581
+ subjectObj.identifier_type_hint = stringAt(
1582
+ subject,
1583
+ "identifier_type_hint",
1584
+ "subject.identifier_type_hint",
1585
+ );
1273
1586
  }
1274
1587
 
1275
1588
  // status and revocation are deliberately absent (ADR-016 §3): a signature
@@ -1277,20 +1590,20 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1277
1590
  // travels as a separate short-lived signed status statement.
1278
1591
  const payload: Record<string, unknown> = {
1279
1592
  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),
1593
+ attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
1594
+ certificate_format_version: version,
1595
+ certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
1596
+ issued_at: formatTimestamp(cert.issued_at, "issued_at"),
1284
1597
  issuer: issuerObj,
1285
1598
  payload_type: certificatePayloadType(version),
1286
1599
  subject: subjectObj,
1287
1600
  systems,
1288
1601
  };
1289
1602
  if (isV4Plus && cert.scope != null) {
1290
- const scope = cert.scope as Record<string, unknown>;
1603
+ const scope = objectAt(cert, "scope", "scope");
1291
1604
  payload.scope = {
1292
- text_sha256: toHex(scope.text_sha256 as string),
1293
- version: scope.version as string,
1605
+ text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
1606
+ version: stringAt(scope, "version", "scope.version"),
1294
1607
  };
1295
1608
  }
1296
1609
  return canonicalJson(payload);
@@ -1307,7 +1620,7 @@ function buildLogLeafPayload(
1307
1620
  throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
1308
1621
  }
1309
1622
  const payload = {
1310
- appended_at: formatTimestamp(appendedAt),
1623
+ appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
1311
1624
  certificate_hash: bytesToHex(certificateHash),
1312
1625
  certificate_id: certificateId,
1313
1626
  entry_type: entryType,
@@ -1358,13 +1671,13 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
1358
1671
  * between two modules of this package, not published surface.
1359
1672
  */
1360
1673
  export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
1361
- const logId = head.log_id;
1362
- const named = typeof logId === "string" && logId !== "";
1674
+ const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
1675
+ const named = logId !== undefined && logId !== "";
1363
1676
  const payload: Record<string, unknown> = {
1364
1677
  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,
1678
+ root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
1679
+ timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
1680
+ tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
1368
1681
  };
1369
1682
  if (named) payload.log_id = logId;
1370
1683
  return canonicalJson(payload);
@@ -1535,21 +1848,24 @@ export function buildCertificateStatusPayload(
1535
1848
  ): Uint8Array {
1536
1849
  const payload: Record<string, unknown> = {
1537
1850
  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,
1851
+ certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
1852
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
1853
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
1854
+ status: stringAt(stmt, "status", "status.status"),
1855
+ sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
1856
+ sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
1544
1857
  };
1545
- if (stmt.replacement_certificate_id != null) {
1546
- payload.replacement_certificate_id = stmt.replacement_certificate_id as string;
1547
- }
1858
+ const replacement = optionalStringAt(
1859
+ stmt,
1860
+ "replacement_certificate_id",
1861
+ "status.replacement_certificate_id",
1862
+ );
1863
+ if (replacement !== undefined) payload.replacement_certificate_id = replacement;
1548
1864
  if (stmt.revocation_log_index != null) {
1549
- payload.revocation_log_index = stmt.revocation_log_index as number;
1865
+ payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
1550
1866
  }
1551
1867
  if (stmt.revoked_at != null) {
1552
- payload.revoked_at = formatTimestamp(stmt.revoked_at);
1868
+ payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
1553
1869
  }
1554
1870
  return canonicalJson(payload);
1555
1871
  }
@@ -1582,7 +1898,7 @@ export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
1582
1898
  keys: entries,
1583
1899
  statement_expires_at: formatTimestamp(doc.statement_expires_at),
1584
1900
  statement_issued_at: formatTimestamp(doc.statement_issued_at),
1585
- sth_root_hash: toHex(doc.sth_root_hash as string),
1901
+ sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
1586
1902
  sth_tree_size: doc.sth_tree_size as number,
1587
1903
  };
1588
1904
  return canonicalJson(payload);
@@ -1610,6 +1926,8 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1610
1926
  *
1611
1927
  * The third row is the point: `verifyCertificate` answers VALID there, which
1612
1928
  * reads as "not revoked" and is not something it checked.
1929
+ *
1930
+ * `options` is passed to {@link verifyCertificate} unchanged.
1613
1931
  */
1614
1932
  export async function verifyCertificateWithStatus(
1615
1933
  crypto: CryptoOps,
@@ -1617,18 +1935,49 @@ export async function verifyCertificateWithStatus(
1617
1935
  publicKeys: Map<string, PublicKeyInfo>,
1618
1936
  status?: Record<string, unknown> | null,
1619
1937
  now?: Date,
1938
+ options?: VerifyOptions,
1620
1939
  ): Promise<string> {
1621
1940
  // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1622
1941
  // two soft verdicts. Returning a soft verdict here skipped every check below
1623
1942
  // it: a revoked certificate signed by a compromised-later key answered
1624
1943
  // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1625
1944
  // verdict is held instead, and resolved against what the statement says.
1626
- const base = await verifyCertificate(crypto, certificate, publicKeys);
1945
+ const base = await verifyCertificate(crypto, certificate, publicKeys, options);
1627
1946
  if (status == null) {
1628
1947
  return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1629
1948
  }
1630
1949
 
1631
- const keyId = status.key_id as string | undefined;
1950
+ return resolveWithBase(
1951
+ base,
1952
+ await verifyStatusStatement(
1953
+ crypto, status, publicKeys, (certificate as Record<string, unknown>).certificate_id, now,
1954
+ ),
1955
+ );
1956
+ }
1957
+
1958
+ /**
1959
+ * The statement half of {@link verifyCertificateWithStatus}: authenticate a
1960
+ * status statement about `certificateId` against the published keys, and read
1961
+ * it at `now`. The /verify page's QR lookup has a record id and no record, and
1962
+ * uses this so it shows what a signed, fresh statement says, never what the
1963
+ * status endpoint claims.
1964
+ *
1965
+ * | input | result |
1966
+ * |---|---|
1967
+ * | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
1968
+ * | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
1969
+ * | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
1970
+ * | unknown or unusable key, bad signature, another certificate | throws VerificationError |
1971
+ */
1972
+ export async function verifyStatusStatement(
1973
+ crypto: CryptoOps,
1974
+ status: Record<string, unknown>,
1975
+ publicKeys: Map<string, PublicKeyInfo>,
1976
+ certificateId: unknown,
1977
+ now?: Date,
1978
+ ): Promise<string> {
1979
+ requireObject(status, "status");
1980
+ const keyId = optionalStringAt(status, "key_id", "status.key_id");
1632
1981
  const info = keyId ? publicKeys.get(keyId) : undefined;
1633
1982
  if (!info) {
1634
1983
  throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
@@ -1656,10 +2005,10 @@ export async function verifyCertificateWithStatus(
1656
2005
  );
1657
2006
 
1658
2007
  const payload = buildCertificateStatusPayload(status);
1659
- // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
2008
+ // decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
1660
2009
  // array depending on the producer, and every other signature on this path
1661
2010
  // goes through the same decoder.
1662
- const sig = decodeSignature(status.signature);
2011
+ const sig = decodeFixed(status.signature, "status.signature", 64);
1663
2012
  if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
1664
2013
  throw new VerificationError("status statement signature is invalid");
1665
2014
  }
@@ -1677,8 +2026,7 @@ export async function verifyCertificateWithStatus(
1677
2026
  // Every signature still verified; the binding was the only forged part.
1678
2027
  // core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
1679
2028
  // 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) {
2029
+ if (status.certificate_id !== certificateId) {
1682
2030
  throw new VerificationError("status statement is about a different certificate");
1683
2031
  }
1684
2032
 
@@ -1689,7 +2037,7 @@ export async function verifyCertificateWithStatus(
1689
2037
  if (at < issued || at >= expires) {
1690
2038
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
1691
2039
  // statement, which must never decay into VALID.
1692
- return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
2040
+ return VALID_REVOCATION_UNKNOWN;
1693
2041
  }
1694
2042
 
1695
2043
  if (status.status === "REVOKED") {
@@ -1705,12 +2053,12 @@ export async function verifyCertificateWithStatus(
1705
2053
  // case — was reported as revocation CHECKED AND PASSED. Mirrors
1706
2054
  // core.ValidCertificateStatusValue.
1707
2055
  if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
1708
- return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
2056
+ return VALID_REVOCATION_UNKNOWN;
1709
2057
  }
1710
2058
  // A soft verdict on the status key is the result, not a footnote — but it is
1711
2059
  // reported only once the statement has authenticated and been read, so it can
1712
2060
  // neither speak for an unverified statement nor suppress a revocation.
1713
- return resolveWithBase(base, statusKeyVerdict);
2061
+ return statusKeyVerdict;
1714
2062
  }
1715
2063
 
1716
2064
  /**