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
@@ -17,13 +17,15 @@
17
17
  * output at issuance time (transparency_status=PENDING, no transparency).
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.VALID_REVOCATION_UNKNOWN = exports.VALID_KEY_WINDOW_UNKNOWN = exports.VALID_KEY_COMPROMISED_LATER = exports.KEY_COMPROMISED = exports.KEY_OUTSIDE_VALIDITY = exports.KNOWN_FORMAT_VERSIONS = void 0;
20
+ exports.VALID_REVOCATION_UNKNOWN = exports.PRE_PROTOCOL_12_IMAGES = exports.VALID_KEY_WINDOW_UNKNOWN = exports.VALID_KEY_COMPROMISED_LATER = exports.KEY_COMPROMISED = exports.KEY_OUTSIDE_VALIDITY = exports.KNOWN_FORMAT_VERSIONS = void 0;
21
21
  exports.hexToBytes = hexToBytes;
22
22
  exports.bytesToHex = bytesToHex;
23
23
  exports.publicKeyFromHex = publicKeyFromHex;
24
24
  exports.keyIsUsable = keyIsUsable;
25
25
  exports.evaluateKey = evaluateKey;
26
26
  exports.certificateAnchor = certificateAnchor;
27
+ exports.issuingImage = issuingImage;
28
+ exports.registrationQualifier = registrationQualifier;
27
29
  exports.verifyCertificate = verifyCertificate;
28
30
  exports.verifyTransparency = verifyTransparency;
29
31
  exports.issuanceBytes = issuanceBytes;
@@ -34,6 +36,7 @@ exports.verifyConsistency = verifyConsistency;
34
36
  exports.buildCertificateStatusPayload = buildCertificateStatusPayload;
35
37
  exports.buildKeyListPayload = buildKeyListPayload;
36
38
  exports.verifyCertificateWithStatus = verifyCertificateWithStatus;
39
+ exports.verifyStatusStatement = verifyStatusStatement;
37
40
  const errors_js_1 = require("./errors.js");
38
41
  // ---------------------------------------------------------------------------
39
42
  // Pure byte helpers (no Buffer, no node:crypto)
@@ -82,8 +85,9 @@ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
82
85
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
83
86
  const FORMAT_VERSION_V8 = "8.0";
84
87
  // v9 adds two per-system fields, authorization and customer_key_id: whether the
85
- // enclave checked this system against a customer-signed registration
86
- // certificate (ADR-025 §4), and under which customer key group if it did.
88
+ // enclave checked this system against a registration certificate (ADR-025 §4),
89
+ // and under which customer key group if it did. That the key group SIGNED the
90
+ // registration holds only from enclave protocol 12: see PRE_PROTOCOL_12_IMAGES.
87
91
  //
88
92
  // The attestation gains NO field at v9. Its tag still moves, because the tag
89
93
  // follows the record's version and the record's bytes changed — a v9
@@ -227,17 +231,22 @@ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
227
231
  // in the same enclave call that signs the certificate (ADR-016 §2).
228
232
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
229
233
  const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
234
+ /** Decode hex: whole bytes, digits in either case, nothing else. Anything else
235
+ * throws TypeError. It is exported, so its input is a boundary: reading a stray
236
+ * character as 0, as it once did, turned a typo into different bytes. */
230
237
  function hexToBytes(hex) {
231
- const len = hex.length >>> 1;
232
- const out = new Uint8Array(len);
233
- for (let i = 0; i < len; i++) {
234
- const hi = hex.charCodeAt(i * 2);
235
- const lo = hex.charCodeAt(i * 2 + 1);
236
- out[i] = (unhex(hi) << 4) | unhex(lo);
238
+ if (typeof hex !== "string")
239
+ throw new TypeError(`hex is not a string: ${typeof hex}`);
240
+ if (hex.length % 2 !== 0)
241
+ throw new TypeError(`hex has an odd number of digits: ${hex.length}`);
242
+ const out = new Uint8Array(hex.length / 2);
243
+ for (let i = 0; i < out.length; i++) {
244
+ out[i] = (unhex(hex, i * 2) << 4) | unhex(hex, i * 2 + 1);
237
245
  }
238
246
  return out;
239
247
  }
240
- function unhex(c) {
248
+ function unhex(hex, at) {
249
+ const c = hex.charCodeAt(at);
241
250
  // 0-9
242
251
  if (c >= 48 && c <= 57)
243
252
  return c - 48;
@@ -247,7 +256,7 @@ function unhex(c) {
247
256
  // A-F
248
257
  if (c >= 65 && c <= 70)
249
258
  return c - 55;
250
- return 0;
259
+ throw new TypeError(`hex has a non-hex character ${JSON.stringify(hex[at])} at ${at}`);
251
260
  }
252
261
  function bytesToHex(bytes) {
253
262
  let out = "";
@@ -287,6 +296,13 @@ function textToBytes(s) {
287
296
  return new TextEncoder().encode(s);
288
297
  }
289
298
  async function publicKeyFromHex(crypto, hexKey, opts) {
299
+ // Refused here, as the VerificationError callers are told to catch, rather
300
+ // than left to hexToBytes's TypeError. The alphabet is the one
301
+ // core.KeyEntry.PublicKeyInfo takes through hex.DecodeString: either case,
302
+ // whole bytes, nothing else.
303
+ if (typeof hexKey !== "string" || !HEX_TEXT_RE.test(hexKey)) {
304
+ throw new errors_js_1.VerificationError(`public key is not hex: ${JSON.stringify(hexKey)}`);
305
+ }
290
306
  const raw = hexToBytes(hexKey);
291
307
  if (raw.length !== 32) {
292
308
  throw new errors_js_1.VerificationError(`public key must be 32 bytes, got ${raw.length}`);
@@ -357,6 +373,18 @@ function evaluateKey(pki, anchor) {
357
373
  if (pki.keyStatus === "compromised") {
358
374
  if (anchor === null || pki.compromisedFrom === undefined)
359
375
  return exports.KEY_COMPROMISED;
376
+ // The validity window is tested before the compromise anchor (R03-1). A
377
+ // signature made outside the stated interval was never authorized, whatever
378
+ // the key's later disposition, so an anchor outside the window is
379
+ // KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
380
+ // window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
381
+ // the window alone already refuses.
382
+ if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
383
+ return exports.KEY_OUTSIDE_VALIDITY;
384
+ }
385
+ if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
386
+ return exports.KEY_OUTSIDE_VALIDITY;
387
+ }
360
388
  return anchor < parseRfc3339(pki.compromisedFrom)
361
389
  ? exports.VALID_KEY_COMPROMISED_LATER
362
390
  : exports.KEY_COMPROMISED;
@@ -394,7 +422,7 @@ function certificateAnchor(certificate) {
394
422
  if (transparency == null)
395
423
  return null;
396
424
  const sth = transparency.signed_tree_head;
397
- if (sth === undefined || typeof sth.timestamp !== "string")
425
+ if (sth == null || typeof sth.timestamp !== "string")
398
426
  return null;
399
427
  try {
400
428
  return parseRfc3339(sth.timestamp);
@@ -404,34 +432,43 @@ function certificateAnchor(certificate) {
404
432
  }
405
433
  }
406
434
  /**
407
- * The anchor, but only once the tree head carrying it has been checked against
408
- * the key being evaluated.
435
+ * The anchor, but only once this record has been PROVEN to be in the tree head
436
+ * carrying it — its inclusion proof verified against that head, not merely the
437
+ * head's own signature checked.
409
438
  *
410
- * The bypass this closes: for a key declared compromised, evaluateKey answers
411
- * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
412
- * anchor predates the compromise — and the second is a verdict requireUsableKey
413
- * passes through as the RESULT of verifyCertificate. Since the timestamp is
414
- * covered by no signature, a holder could backdate it and have a certificate
415
- * signed with a compromised key reported as verified-with-a-caveat. Deleting
416
- * `transparency` failed closed; keeping a doctored one did not.
439
+ * The bypass this closes (R12-1/R14-1): for a key declared compromised,
440
+ * evaluateKey answers KEY_COMPROMISED with no anchor and
441
+ * VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
442
+ * second is a verdict requireUsableKey passes through as the RESULT of
443
+ * verifyCertificate. A signed tree head is a public artifact: any genuine older
444
+ * head the issuer ever published can be stapled onto a record it never
445
+ * contained, and its signature still verifies. Checking only the head signature
446
+ * let a holder of a certificate signed after the compromise attach a
447
+ * pre-compromise head and have the forgery reported as verified-with-a-caveat.
448
+ * The head's timestamp is an honest anchor for THIS record only once THIS record
449
+ * is shown to be committed to under it.
417
450
  *
418
- * A head that does not verify yields no anchor, which is the same conservative
419
- * answer as a certificate carrying no transparency block at all.
451
+ * checkCertificateInclusion is exactly that proof; it deliberately does NOT test
452
+ * the key's usability at head time — that is the verdict this anchor exists to
453
+ * compute, so gating the anchor on it would drop the anchor for an out-of-window
454
+ * key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
455
+ * of a held inclusion proof yields no anchor, the same conservative answer as a
456
+ * certificate with no transparency block at all.
420
457
  */
421
458
  async function verifiedCertificateAnchor(crypto, certificate, pki) {
422
459
  const anchor = certificateAnchor(certificate);
423
460
  if (anchor === null)
424
461
  return null;
425
- const transparency = certificate.transparency;
426
- const sth = transparency.signed_tree_head;
462
+ // Only a refusal of the proof means "no anchor". A defect in this library, or
463
+ // a crypto provider that threw, must not get the same quiet answer as a
464
+ // document whose inclusion proof does not verify.
427
465
  try {
428
- const headPayload = buildTreeHeadPayload(sth);
429
- const headSig = decodeSignature(sth.signature);
430
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
431
- return null;
466
+ await checkCertificateInclusion(crypto, certificate, pki);
432
467
  }
433
- catch {
434
- return null;
468
+ catch (e) {
469
+ if (e instanceof errors_js_1.VerificationError)
470
+ return null;
471
+ throw e;
435
472
  }
436
473
  return anchor;
437
474
  }
@@ -468,14 +505,155 @@ role = "issuer key") {
468
505
  }
469
506
  return verdict;
470
507
  }
508
+ function policySet(values, field, lower) {
509
+ if (values === undefined)
510
+ return undefined;
511
+ if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
512
+ throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
513
+ }
514
+ if (values.length === 0) {
515
+ // An empty requirement requires nothing, which is never what a caller who
516
+ // built a policy meant.
517
+ throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
518
+ }
519
+ return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
520
+ }
521
+ /** Check a policy before anything is verified, so a caller's mistake surfaces as
522
+ * one whatever the record turns out to be. */
523
+ function parsePolicy(policy) {
524
+ const keys = policy.customerKeyIds;
525
+ if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
526
+ throw new TypeError("requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
527
+ "a key id you did not name proves nothing to you, because the relay can enroll a key group of its own");
528
+ }
529
+ return {
530
+ systems: policySet(policy.systems, "systems", true),
531
+ customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false),
532
+ };
533
+ }
534
+ /**
535
+ * The enclave images that issued format 9.0 while running wire protocol 11 or
536
+ * earlier, by PCR0, as the published measurement history records them
537
+ * (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
538
+ *
539
+ * Their `certified` is not evidence of a customer signature. Before protocol 12
540
+ * the enclave enrolled a key group with no proof that anyone held its keys, and
541
+ * registered a system for whoever relayed the authorization — so the host could
542
+ * make a genuine 9.0 record say `certified`, under a key id it chose, with no
543
+ * customer signature anywhere (reproduced by an independent re-review,
544
+ * 2026-09-13).
545
+ *
546
+ * THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
547
+ * admitting or rolling back to an image of protocol 11 or earlier once any
548
+ * customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
549
+ * leaves to the owner. The protocol-12 cutover's KMS tighten drops every
550
+ * measurement here from the signing key's policy, after which none of them can
551
+ * sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
552
+ * because it held the signing key while it ran.
553
+ *
554
+ * The Python SDK carries the same list, in _verify.py.
555
+ */
556
+ exports.PRE_PROTOCOL_12_IMAGES = new Map([
557
+ ["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
558
+ ["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
559
+ ["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
560
+ ["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
561
+ ["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
562
+ ["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
563
+ ["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
564
+ ]);
565
+ function issuingImage(certificate) {
566
+ const issuer = certificate.issuer;
567
+ const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
568
+ if (pcr0 === "")
569
+ return { kind: "unnamed" };
570
+ const name = exports.PRE_PROTOCOL_12_IMAGES.get(pcr0);
571
+ return name === undefined
572
+ ? { kind: "protocol-12-or-later", pcr0 }
573
+ : { kind: "before-protocol-12", name, pcr0 };
574
+ }
575
+ /**
576
+ * What `certified` is evidence of, given the image that signed the record: the
577
+ * words every verifier prints after "certified under <key id> —". The Go CLI
578
+ * (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
579
+ * identically, and testdata/registration_line_cases.json holds them to it.
580
+ */
581
+ function registrationQualifier(image) {
582
+ switch (image.kind) {
583
+ case "unnamed":
584
+ return ("not evidence here: this record names no enclave image, so it may come from one " +
585
+ "that could mark a system certified without the customer's signature.");
586
+ case "before-protocol-12":
587
+ return (`not evidence here: this record comes from ${image.name}, an enclave image of ` +
588
+ "protocol 11 or earlier, which could mark a system certified without the customer's signature.");
589
+ case "protocol-12-or-later":
590
+ return ("the key group with that id signed this system's registration, if that key id is yours: " +
591
+ "this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
592
+ "someone else authorized this verification.");
593
+ }
594
+ }
595
+ /**
596
+ * Throw unless a record that has already verified meets `policy`.
597
+ *
598
+ * From 9.0 every field read here is under the certificate signature, the
599
+ * issuer's enclave_pcr0 included. Below 9.0
600
+ * nothing signs `authorization` — those records carry the field outside the
601
+ * signed bytes — so one that says `certified` says only what its last holder
602
+ * typed. It fails on the format, before the field is read.
603
+ */
604
+ function enforceAuthorization(certificate, formatVersion, policy) {
605
+ const systemsWanted = policy.systems;
606
+ if (!signatureCoversAuthorization(formatVersion)) {
607
+ throw new errors_js_1.VerificationError(`authorization required, but this record is format ${formatVersion}, which predates ` +
608
+ `customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`, errors_js_1.AUTHORIZATION_POLICY_FAILED);
609
+ }
610
+ const systems = certificate.systems ?? [];
611
+ let required = systems;
612
+ if (systemsWanted !== undefined) {
613
+ const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
614
+ const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
615
+ if (missing.length > 0) {
616
+ throw new errors_js_1.VerificationError(`authorization required for system ${missing[0]}, which this record does not cover`, errors_js_1.AUTHORIZATION_POLICY_FAILED);
617
+ }
618
+ required = [...systemsWanted].sort().map((id) => byId.get(id));
619
+ }
620
+ const labelOf = (s) => `system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
621
+ for (const s of required) {
622
+ if (s.authorization !== AUTHORIZATION_CERTIFIED) {
623
+ throw new errors_js_1.VerificationError(`authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
624
+ "the enclave did not check it against a registration", errors_js_1.AUTHORIZATION_POLICY_FAILED);
625
+ }
626
+ }
627
+ // Asked once, for the record, after every required system has said certified:
628
+ // which image said it decides whether `certified` means a customer signed.
629
+ const image = issuingImage(certificate);
630
+ if (image.kind === "unnamed") {
631
+ throw new errors_js_1.VerificationError("authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
632
+ "absent), so nothing shows it came from one that registers a system only with the " +
633
+ "customer's signature", errors_js_1.AUTHORIZATION_POLICY_FAILED);
634
+ }
635
+ if (image.kind === "before-protocol-12") {
636
+ throw new errors_js_1.VerificationError(`authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
637
+ "an enclave image of wire protocol 11 or earlier, which could register a system without " +
638
+ "the customer's signature; its `certified` does not show that any key group signed anything", errors_js_1.AUTHORIZATION_POLICY_FAILED);
639
+ }
640
+ for (const s of required) {
641
+ if (!policy.customerKeyIds.has(s.customer_key_id)) {
642
+ throw new errors_js_1.VerificationError(`${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
643
+ "accept; if it is not yours, someone else authorized this verification", errors_js_1.AUTHORIZATION_POLICY_FAILED);
644
+ }
645
+ }
646
+ }
471
647
  /** Verify all signatures on a deletion certificate offline. */
472
- async function verifyCertificate(crypto, certificate, publicKeys) {
648
+ async function verifyCertificate(crypto, certificate, publicKeys, options) {
473
649
  // Step 0: can this SDK read the format at all? Every check below rebuilds
474
650
  // signed bytes from the version, so an unreadable version makes all of them
475
651
  // meaningless - and "unknown issuer key" would be the wrong thing to tell
476
652
  // someone holding a record that is merely newer than this library.
653
+ requireObject(certificate, "certificate");
654
+ const policy = options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
477
655
  const formatVersion = checkFormatVersion(certificate.certificate_format_version);
478
- const issuer = certificate.issuer;
656
+ const issuer = objectAt(certificate, "issuer", "issuer");
479
657
  // Step 0b: can this SDK check the algorithm the record names? Asked before
480
658
  // any signature, because verifying an Ed25519 signature over a record that
481
659
  // says it was signed with something else answers a question nobody asked.
@@ -486,7 +664,7 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
486
664
  `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
487
665
  "The record may be genuine and simply signed with a scheme this library does not implement.", errors_js_1.UNSUPPORTED_ALGORITHM);
488
666
  }
489
- const keyId = issuer.key_id;
667
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
490
668
  const pki = publicKeys.get(keyId);
491
669
  if (pki === undefined)
492
670
  throw new errors_js_1.VerificationError(`unknown issuer key: ${keyId}`);
@@ -496,7 +674,7 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
496
674
  // 1. Certificate signature FIRST — nothing below may trust a field until the
497
675
  // bytes carrying it are covered by a verified signature.
498
676
  const certPayload = buildCertificatePayload(certificate);
499
- const certSig = decodeSignature(certificate.certificate_signature);
677
+ const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
500
678
  if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
501
679
  throw new errors_js_1.VerificationError("certificate signature is invalid");
502
680
  }
@@ -510,7 +688,7 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
510
688
  attested_at: att.attested_at,
511
689
  systems: attestationSystems(certificate),
512
690
  }, certificate.certificate_format_version);
513
- const attSig = decodeSignature(att.attestation_signature);
691
+ const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
514
692
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
515
693
  throw new errors_js_1.VerificationError("attestation signature is invalid");
516
694
  }
@@ -559,6 +737,9 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
559
737
  if (!anyAttested) {
560
738
  throw new errors_js_1.VerificationError("incomplete verification: no system held any records at attestation time");
561
739
  }
740
+ if (policy !== undefined) {
741
+ enforceAuthorization(certificate, formatVersion, policy);
742
+ }
562
743
  // A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
563
744
  // and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
564
745
  // reporting VALID here would be the lying by omission ADR-017 exists to stop.
@@ -567,21 +748,34 @@ async function verifyCertificate(crypto, certificate, publicKeys) {
567
748
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
568
749
  /** Verify the transparency proof embedded in a certificate. */
569
750
  async function verifyTransparency(crypto, certificate, publicKeys) {
570
- const transparency = certificate.transparency;
571
- if (transparency == null) {
751
+ requireObject(certificate, "certificate");
752
+ if (certificate.transparency == null) {
572
753
  return "NOT_AVAILABLE";
573
754
  }
574
- const sth = transparency.signed_tree_head;
575
- const issuer = certificate.issuer;
576
- const keyId = issuer.key_id;
755
+ const issuer = objectAt(certificate, "issuer", "issuer");
756
+ const keyId = stringAt(issuer, "key_id", "issuer.key_id");
577
757
  const pki = publicKeys.get(keyId);
578
758
  if (pki === undefined)
579
759
  throw new errors_js_1.VerificationError(`unknown issuer key: ${keyId}`);
580
760
  // The tree head carries its own timestamp, so this path always has an anchor.
581
761
  requireUsableKey(pki, certificateAnchor(certificate), keyId);
762
+ await checkCertificateInclusion(crypto, certificate, pki);
763
+ return "INCLUDED";
764
+ }
765
+ /**
766
+ * Verify the head signature and the Merkle inclusion of a certificate's proof —
767
+ * everything about whether this record is committed to under its head, but NOT
768
+ * whether the key was usable when it signed. That last question is the caller's;
769
+ * certificateAnchor needs membership, not authority, and folding authority in
770
+ * here made the anchor for an out-of-window key collapse to "no anchor" and the
771
+ * verdict soften. Throws VerificationError on any inclusion failure.
772
+ */
773
+ async function checkCertificateInclusion(crypto, certificate, pki) {
774
+ const transparency = objectAt(certificate, "transparency", "transparency");
775
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
582
776
  // 1. Tree head signature
583
777
  const headPayload = buildTreeHeadPayload(sth);
584
- const headSig = decodeSignature(sth.signature);
778
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
585
779
  if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
586
780
  throw new errors_js_1.VerificationError("tree head signature is invalid");
587
781
  }
@@ -594,10 +788,10 @@ async function verifyTransparency(crypto, certificate, publicKeys) {
594
788
  // revocation of the same certificate produced identical leaves and the tree
595
789
  // committed to neither the entry type nor when it happened.
596
790
  const issuanceData = issuanceBytes(certificate);
597
- const leafPayload = buildLogLeafPayload(transparency.entry_type, certificate.certificate_id, await crypto.sha256(issuanceData), transparency.appended_at);
791
+ const leafPayload = buildLogLeafPayload(transparency.entry_type, stringAt(certificate, "certificate_id", "certificate_id"), await crypto.sha256(issuanceData), transparency.appended_at);
598
792
  const leaf = await hashLeaf(crypto, leafPayload);
599
- const proofHashes = (transparency.inclusion_proof ?? []).map((h) => decodeBytes(h));
600
- const root = decodeBytes(sth.root_hash);
793
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map((h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32));
794
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
601
795
  // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
602
796
  // for an explicit null and for an absent key rather than failing, so refusing
603
797
  // either would reject documents the reference accepts — the same class of
@@ -629,7 +823,6 @@ async function verifyTransparency(crypto, certificate, publicKeys) {
629
823
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
630
824
  throw new errors_js_1.VerificationError("merkle inclusion proof is invalid");
631
825
  }
632
- return "INCLUDED";
633
826
  }
634
827
  // ---------------------------------------------------------------------------
635
828
  // Issuance bytes — matches Go's core.IssuanceBytes(cert)
@@ -679,47 +872,89 @@ function canonicalStringify(val) {
679
872
  }
680
873
  return JSON.stringify(val);
681
874
  }
682
- // ---------------------------------------------------------------------------
683
- // Byte encoding helpers
684
- // ---------------------------------------------------------------------------
685
- /** Convert a byte field to lowercase hex. Handles:
686
- * - hex string (from hand-crafted test data)
687
- * - base64 string (from Go's json.Marshal of []byte slices)
688
- * - number[] (from Go's json.Marshal of [N]byte fixed arrays)
689
- */
690
- function toHex(value) {
691
- if (Array.isArray(value)) {
692
- return bytesToHex(new Uint8Array(value));
875
+ function requireObject(value, path) {
876
+ if (value === undefined || value === null)
877
+ throw new errors_js_1.VerificationError(`${path} is missing`);
878
+ if (typeof value !== "object" || Array.isArray(value)) {
879
+ throw new errors_js_1.VerificationError(`${path} is not an object`);
693
880
  }
694
- const str = value;
695
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
696
- return str.toLowerCase();
697
- }
698
- return bytesToHex(base64ToBytes(str));
881
+ return value;
882
+ }
883
+ function objectAt(parent, key, path) {
884
+ return requireObject(parent[key], path);
699
885
  }
700
- /** Decode a signature field to raw bytes. Same format handling as toHex. */
701
- function decodeSignature(value) {
886
+ function stringAt(parent, key, path) {
887
+ const value = parent[key];
888
+ if (value === undefined || value === null)
889
+ throw new errors_js_1.VerificationError(`${path} is missing`);
890
+ if (typeof value !== "string")
891
+ throw new errors_js_1.VerificationError(`${path} is not a string`);
892
+ return value;
893
+ }
894
+ function optionalStringAt(parent, key, path) {
895
+ const value = parent[key];
896
+ if (value === undefined || value === null)
897
+ return undefined;
898
+ if (typeof value !== "string")
899
+ throw new errors_js_1.VerificationError(`${path} is not a string`);
900
+ return value;
901
+ }
902
+ /**
903
+ * An array field. Absent and null read as empty: Go leaves a nil slice.
904
+ *
905
+ * Returned dense. JSON cannot express a hole, but a caller building the object
906
+ * in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
907
+ * `undefined` and threw a TypeError. Array.from reads a hole as undefined,
908
+ * which the element's own reader then refuses as missing.
909
+ */
910
+ function arrayAt(parent, key, path) {
911
+ const value = parent[key];
912
+ if (value === undefined || value === null)
913
+ return [];
914
+ if (!Array.isArray(value))
915
+ throw new errors_js_1.VerificationError(`${path} is not an array`);
916
+ return Array.from(value);
917
+ }
918
+ const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
919
+ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
920
+ /**
921
+ * Decode a fixed-size byte field — a hash, a key, a signature.
922
+ *
923
+ * Go marshals `[N]byte` as an array of numbers; hand-written documents carry
924
+ * hex or standard base64. A well-formed value of the right size is never
925
+ * ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
926
+ * in padding hex cannot contain.
927
+ *
928
+ * Everything else is refused here, by name: a number, an array element that is
929
+ * not a byte, text that is neither encoding, the wrong length. Decoding it
930
+ * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
931
+ * signature covers and blamed the signature — or threw from inside atob.
932
+ */
933
+ function decodeFixed(value, path, length) {
934
+ if (value === undefined || value === null)
935
+ throw new errors_js_1.VerificationError(`${path} is missing`);
936
+ let raw;
702
937
  if (Array.isArray(value)) {
703
- return new Uint8Array(value);
938
+ if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
939
+ throw new errors_js_1.VerificationError(`${path} is not an array of bytes`);
940
+ }
941
+ raw = new Uint8Array(value);
704
942
  }
705
- const str = value;
706
- if (/^[0-9a-fA-F]+$/.test(str)) {
707
- const raw = hexToBytes(str);
708
- if (raw.length === 64)
709
- return raw;
943
+ else if (typeof value === "string") {
944
+ if (HEX_TEXT_RE.test(value))
945
+ raw = hexToBytes(value);
946
+ else if (BASE64_TEXT_RE.test(value))
947
+ raw = base64ToBytes(value);
948
+ else
949
+ throw new errors_js_1.VerificationError(`${path} is neither hex nor base64`);
710
950
  }
711
- return base64ToBytes(str);
712
- }
713
- /** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
714
- function decodeBytes(value) {
715
- if (Array.isArray(value)) {
716
- return new Uint8Array(value);
951
+ else {
952
+ throw new errors_js_1.VerificationError(`${path} is not a byte string`);
717
953
  }
718
- const str = value;
719
- if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
720
- return hexToBytes(str);
954
+ if (raw.length !== length) {
955
+ throw new errors_js_1.VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
721
956
  }
722
- return base64ToBytes(str);
957
+ return raw;
723
958
  }
724
959
  /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
725
960
  * field for an explicit JSON null or an absent key. */
@@ -748,14 +983,17 @@ const RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?
748
983
  * Exported from this module so the timestamp_vectors.json suite can diff it
749
984
  * against Go directly. It is deliberately not re-exported from index.ts, so it
750
985
  * is not part of the published package surface.
986
+ *
987
+ * `field` names the value in a refusal. It defaults to the word these messages
988
+ * always used, for callers normalizing a time that is not a field of a document.
751
989
  */
752
- function formatTimestamp(ts) {
990
+ function formatTimestamp(ts, field = "timestamp") {
753
991
  if (ts === null || ts === undefined)
754
992
  return ZERO_INSTANT;
755
993
  if (typeof ts !== "string") {
756
- throw new errors_js_1.VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
994
+ throw new errors_js_1.VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
757
995
  }
758
- return formatEpochSeconds(parseRfc3339(ts));
996
+ return formatEpochSeconds(parseRfc3339(ts, field));
759
997
  }
760
998
  /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
761
999
  *
@@ -766,20 +1004,20 @@ function formatTimestamp(ts) {
766
1004
  * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
767
1005
  * digits are dropped, matching Format's truncation.
768
1006
  */
769
- function parseRfc3339(ts) {
1007
+ function parseRfc3339(ts, field = "timestamp") {
770
1008
  const m = RFC3339_RE.exec(ts);
771
1009
  if (m === null)
772
- throw new errors_js_1.VerificationError(`timestamp is not RFC 3339: ${ts}`);
1010
+ throw new errors_js_1.VerificationError(`${field} is not RFC 3339: ${ts}`);
773
1011
  const [year, month, day, hour, minute, second] = m
774
1012
  .slice(1, 7)
775
1013
  .map((v) => Number.parseInt(v, 10));
776
1014
  if (month < 1 || month > 12)
777
- throw new errors_js_1.VerificationError(`timestamp month out of range: ${ts}`);
1015
+ throw new errors_js_1.VerificationError(`${field} month out of range: ${ts}`);
778
1016
  if (day < 1 || day > daysInMonth(year, month)) {
779
- throw new errors_js_1.VerificationError(`timestamp day out of range: ${ts}`);
1017
+ throw new errors_js_1.VerificationError(`${field} day out of range: ${ts}`);
780
1018
  }
781
1019
  if (hour > 23 || minute > 59 || second > 59) {
782
- throw new errors_js_1.VerificationError(`timestamp time of day out of range: ${ts}`);
1020
+ throw new errors_js_1.VerificationError(`${field} time of day out of range: ${ts}`);
783
1021
  }
784
1022
  let offset = 0;
785
1023
  const sign = m[7];
@@ -787,9 +1025,9 @@ function parseRfc3339(ts) {
787
1025
  const offHour = Number.parseInt(m[8], 10);
788
1026
  const offMin = Number.parseInt(m[9], 10);
789
1027
  if (offHour > 24)
790
- throw new errors_js_1.VerificationError(`timestamp zone offset hour out of range: ${ts}`);
1028
+ throw new errors_js_1.VerificationError(`${field} zone offset hour out of range: ${ts}`);
791
1029
  if (offMin > 60)
792
- throw new errors_js_1.VerificationError(`timestamp zone offset minute out of range: ${ts}`);
1030
+ throw new errors_js_1.VerificationError(`${field} zone offset minute out of range: ${ts}`);
793
1031
  offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
794
1032
  }
795
1033
  return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
@@ -873,17 +1111,17 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
873
1111
  // payload and call every genuine v8 record a forgery.
874
1112
  const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
875
1113
  const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
876
- const systems = att.systems.map((s) => {
1114
+ const systems = att.systems.map((s, i) => {
877
1115
  const sys = {
878
1116
  canonical_version: s.canonical_version ?? null,
879
1117
  connector_type: s.connector_type,
880
1118
  hash_scope: s.hash_scope,
881
- merkle_root: s.merkle_root
882
- ? toHex(s.merkle_root)
883
- : null,
1119
+ merkle_root: s.merkle_root == null
1120
+ ? null
1121
+ : bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
884
1122
  // The system's own observation time, not the envelope's.
885
- observed_at: formatTimestamp(s.observed_at),
886
- query_hash: toHex(s.query_hash),
1123
+ observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
1124
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
887
1125
  record_count: s.record_count,
888
1126
  system_id: s.system_id,
889
1127
  system_name: s.system_name,
@@ -901,7 +1139,7 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
901
1139
  attested_at: attestedAt,
902
1140
  payload_type: attestationPayloadType(certFormatVersion),
903
1141
  proof_mode: att.proof_mode,
904
- subject_hash: toHex(subject.identifier_hash),
1142
+ subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
905
1143
  systems,
906
1144
  };
907
1145
  return canonicalJson(payload);
@@ -967,9 +1205,9 @@ function attestationPayloadType(version) {
967
1205
  return PAYLOAD_TYPE_ATTESTATION;
968
1206
  }
969
1207
  function buildCertificatePayload(cert) {
970
- const att = cert.attestation;
971
- const issuer = cert.issuer;
972
- const subject = cert.subject;
1208
+ const att = objectAt(cert, "attestation", "attestation");
1209
+ const issuer = objectAt(cert, "issuer", "issuer");
1210
+ const subject = objectAt(cert, "subject", "subject");
973
1211
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
974
1212
  // and a verification list joined only on the human-editable system_name,
975
1213
  // which made a partial deletion indistinguishable from a complete one.
@@ -977,19 +1215,25 @@ function buildCertificatePayload(cert) {
977
1215
  const coversMeasured = signatureCoversMeasuredTransport(version);
978
1216
  const coversRecoverable = signatureCoversRecoverableState(version);
979
1217
  const coversAuthorization = signatureCoversAuthorization(version);
980
- const systems = (cert.systems ?? []).map((s) => {
1218
+ const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
1219
+ const where = `systems[${i}]`;
1220
+ const s = requireObject(entry, where);
981
1221
  const sys = {
982
- attested_at: formatTimestamp(s.attested_at),
983
- attested_count: s.attested_count,
984
- canonical_version: s.canonical_version ?? null,
985
- connector_type: s.connector_type,
986
- hash_scope: s.hash_scope,
987
- merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
988
- query_hash: toHex(s.query_hash),
989
- system_id: s.system_id,
990
- system_name: s.system_name,
991
- verified_at: formatTimestamp(s.verified_at),
992
- verified_count: s.verified_count,
1222
+ attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
1223
+ attested_count: requireUint(s.attested_count, `${where}.attested_count`),
1224
+ canonical_version: optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
1225
+ connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
1226
+ hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
1227
+ // Absent is a system with no Merkle root; anything present must be one.
1228
+ // Go's *[32]byte is nil or 32 bytes, never "" or [].
1229
+ merkle_root: s.merkle_root == null
1230
+ ? null
1231
+ : bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
1232
+ query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
1233
+ system_id: stringAt(s, "system_id", `${where}.system_id`),
1234
+ system_name: stringAt(s, "system_name", `${where}.system_name`),
1235
+ verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
1236
+ verified_count: requireUint(s.verified_count, `${where}.verified_count`),
993
1237
  };
994
1238
  if (coversMeasured) {
995
1239
  sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name, version);
@@ -1009,9 +1253,9 @@ function buildCertificatePayload(cert) {
1009
1253
  // The attestation block carries no system list of its own: the merged list
1010
1254
  // above is a superset of it. verification_signature is gone entirely.
1011
1255
  const attObj = {
1012
- attestation_signature: toHex(att.attestation_signature),
1013
- attested_at: formatTimestamp(att.attested_at),
1014
- proof_mode: att.proof_mode,
1256
+ attestation_signature: bytesToHex(decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64)),
1257
+ attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
1258
+ proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
1015
1259
  };
1016
1260
  // Fields added after v3 are gated on the certificate's OWN version. A v3
1017
1261
  // certificate must reconstruct to the same bytes forever; reading these
@@ -1023,9 +1267,9 @@ function buildCertificatePayload(cert) {
1023
1267
  const isV5Plus = formatAtLeast(version, FORMAT_VERSION_V5);
1024
1268
  const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1025
1269
  const issuerObj = {
1026
- key_id: issuer.key_id,
1027
- name: issuer.name,
1028
- public_key: toHex(issuer.public_key),
1270
+ key_id: stringAt(issuer, "key_id", "issuer.key_id"),
1271
+ name: stringAt(issuer, "name", "issuer.name"),
1272
+ public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
1029
1273
  };
1030
1274
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1031
1275
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
@@ -1033,43 +1277,48 @@ function buildCertificatePayload(cert) {
1033
1277
  if (signatureCoversAlgorithm(version)) {
1034
1278
  issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer", version);
1035
1279
  }
1036
- if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1037
- issuerObj.legal_entity = issuer.legal_entity;
1280
+ // A non-string here used to be dropped silently, so a junk value added to a
1281
+ // record that never carried one still verified. Python refused it; so does
1282
+ // Go, at parse. Now this does too.
1283
+ if (isV4Plus) {
1284
+ const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
1285
+ if (legalEntity)
1286
+ issuerObj.legal_entity = legalEntity;
1038
1287
  }
1039
1288
  // Signed from v5, and omitted when absent or empty: a build with no
1040
1289
  // measurement signs none, and "" is a value a reader could mistake for one.
1041
- if (signatureCoversEnclavePcr0(version) &&
1042
- typeof issuer.enclave_pcr0 === "string" &&
1043
- issuer.enclave_pcr0 !== "") {
1044
- issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
1290
+ if (signatureCoversEnclavePcr0(version)) {
1291
+ const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
1292
+ if (pcr0)
1293
+ issuerObj.enclave_pcr0 = pcr0;
1045
1294
  }
1046
1295
  // v5 drops identifier_type_hint: signed, but always the constant "custom",
1047
1296
  // so it was never evidence. v3 and v4 keep it or they stop verifying.
1048
1297
  const subjectObj = {
1049
- identifier_hash: toHex(subject.identifier_hash),
1298
+ identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
1050
1299
  };
1051
1300
  if (!isV5Plus) {
1052
- subjectObj.identifier_type_hint = subject.identifier_type_hint;
1301
+ subjectObj.identifier_type_hint = stringAt(subject, "identifier_type_hint", "subject.identifier_type_hint");
1053
1302
  }
1054
1303
  // status and revocation are deliberately absent (ADR-016 §3): a signature
1055
1304
  // commits to bytes at an instant, revocation is discovered later, so it
1056
1305
  // travels as a separate short-lived signed status statement.
1057
1306
  const payload = {
1058
1307
  attestation: attObj,
1059
- attestation_id: cert.attestation_id,
1060
- certificate_format_version: cert.certificate_format_version,
1061
- certificate_id: cert.certificate_id,
1062
- issued_at: formatTimestamp(cert.issued_at),
1308
+ attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
1309
+ certificate_format_version: version,
1310
+ certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
1311
+ issued_at: formatTimestamp(cert.issued_at, "issued_at"),
1063
1312
  issuer: issuerObj,
1064
1313
  payload_type: certificatePayloadType(version),
1065
1314
  subject: subjectObj,
1066
1315
  systems,
1067
1316
  };
1068
1317
  if (isV4Plus && cert.scope != null) {
1069
- const scope = cert.scope;
1318
+ const scope = objectAt(cert, "scope", "scope");
1070
1319
  payload.scope = {
1071
- text_sha256: toHex(scope.text_sha256),
1072
- version: scope.version,
1320
+ text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
1321
+ version: stringAt(scope, "version", "scope.version"),
1073
1322
  };
1074
1323
  }
1075
1324
  return canonicalJson(payload);
@@ -1080,7 +1329,7 @@ function buildLogLeafPayload(entryType, certificateId, certificateHash, appended
1080
1329
  throw new errors_js_1.VerificationError(`invalid log entry_type: ${String(entryType)}`);
1081
1330
  }
1082
1331
  const payload = {
1083
- appended_at: formatTimestamp(appendedAt),
1332
+ appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
1084
1333
  certificate_hash: bytesToHex(certificateHash),
1085
1334
  certificate_id: certificateId,
1086
1335
  entry_type: entryType,
@@ -1129,13 +1378,13 @@ function attestationSystems(cert) {
1129
1378
  * between two modules of this package, not published surface.
1130
1379
  */
1131
1380
  function buildTreeHeadPayload(head) {
1132
- const logId = head.log_id;
1133
- const named = typeof logId === "string" && logId !== "";
1381
+ const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
1382
+ const named = logId !== undefined && logId !== "";
1134
1383
  const payload = {
1135
1384
  payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
1136
- root_hash: toHex(head.root_hash),
1137
- timestamp: formatTimestamp(head.timestamp),
1138
- tree_size: head.tree_size,
1385
+ root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
1386
+ timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
1387
+ tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
1139
1388
  };
1140
1389
  if (named)
1141
1390
  payload.log_id = logId;
@@ -1205,7 +1454,14 @@ async function chainInclusion(crypto, leaf, index, n, proof) {
1205
1454
  return [await hashNode(crypto, proof[used], inner), used + 1];
1206
1455
  }
1207
1456
  function isPow2(n) {
1208
- return n > 0 && (n & (n - 1)) === 0;
1457
+ // Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
1458
+ // signed integers, so for a tree size >= 2^31 the bit test gives the wrong
1459
+ // answer. This holds for every safe integer.
1460
+ if (n < 1)
1461
+ return false;
1462
+ while (n % 2 === 0)
1463
+ n /= 2;
1464
+ return n === 1;
1209
1465
  }
1210
1466
  /** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
1211
1467
  *
@@ -1233,9 +1489,14 @@ async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRoot, pro
1233
1489
  }
1234
1490
  let fn = oldSize - 1;
1235
1491
  let sn = newSize - 1;
1236
- while ((fn & 1) === 1) {
1237
- fn >>= 1;
1238
- sn >>= 1;
1492
+ // Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
1493
+ // integers, so for tree sizes >= 2^31 fn and sn are truncated and this
1494
+ // consistency check silently returned the wrong answer (CONFIRMED where Go
1495
+ // says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
1496
+ // integer, which requireUint already bounds the inputs to.
1497
+ while (fn % 2 === 1) {
1498
+ fn = Math.floor(fn / 2);
1499
+ sn = Math.floor(sn / 2);
1239
1500
  }
1240
1501
  // Drive the walk from the tree, not from the proof's length. Looping on
1241
1502
  // `pIdx < proof.length` let the prover choose how many steps ran, so a log
@@ -1249,19 +1510,19 @@ async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRoot, pro
1249
1510
  return false;
1250
1511
  const c = proof[pIdx];
1251
1512
  pIdx++;
1252
- if ((fn & 1) === 1 || fn === sn) {
1513
+ if (fn % 2 === 1 || fn === sn) {
1253
1514
  fr = await hashNode(crypto, c, fr);
1254
1515
  sr = await hashNode(crypto, c, sr);
1255
- while (fn !== 0 && (fn & 1) === 0) {
1256
- fn >>= 1;
1257
- sn >>= 1;
1516
+ while (fn !== 0 && fn % 2 === 0) {
1517
+ fn = Math.floor(fn / 2);
1518
+ sn = Math.floor(sn / 2);
1258
1519
  }
1259
1520
  }
1260
1521
  else {
1261
1522
  sr = await hashNode(crypto, sr, c);
1262
1523
  }
1263
- fn >>= 1;
1264
- sn >>= 1;
1524
+ fn = Math.floor(fn / 2);
1525
+ sn = Math.floor(sn / 2);
1265
1526
  }
1266
1527
  return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
1267
1528
  }
@@ -1275,21 +1536,21 @@ async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRoot, pro
1275
1536
  function buildCertificateStatusPayload(stmt) {
1276
1537
  const payload = {
1277
1538
  payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
1278
- certificate_id: stmt.certificate_id,
1279
- statement_expires_at: formatTimestamp(stmt.statement_expires_at),
1280
- statement_issued_at: formatTimestamp(stmt.statement_issued_at),
1281
- status: stmt.status,
1282
- sth_root_hash: toHex(stmt.sth_root_hash),
1283
- sth_tree_size: stmt.sth_tree_size,
1539
+ certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
1540
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
1541
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
1542
+ status: stringAt(stmt, "status", "status.status"),
1543
+ sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
1544
+ sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
1284
1545
  };
1285
- if (stmt.replacement_certificate_id != null) {
1286
- payload.replacement_certificate_id = stmt.replacement_certificate_id;
1287
- }
1546
+ const replacement = optionalStringAt(stmt, "replacement_certificate_id", "status.replacement_certificate_id");
1547
+ if (replacement !== undefined)
1548
+ payload.replacement_certificate_id = replacement;
1288
1549
  if (stmt.revocation_log_index != null) {
1289
- payload.revocation_log_index = stmt.revocation_log_index;
1550
+ payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
1290
1551
  }
1291
1552
  if (stmt.revoked_at != null) {
1292
- payload.revoked_at = formatTimestamp(stmt.revoked_at);
1553
+ payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
1293
1554
  }
1294
1555
  return canonicalJson(payload);
1295
1556
  }
@@ -1324,7 +1585,7 @@ function buildKeyListPayload(doc) {
1324
1585
  keys: entries,
1325
1586
  statement_expires_at: formatTimestamp(doc.statement_expires_at),
1326
1587
  statement_issued_at: formatTimestamp(doc.statement_issued_at),
1327
- sth_root_hash: toHex(doc.sth_root_hash),
1588
+ sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
1328
1589
  sth_tree_size: doc.sth_tree_size,
1329
1590
  };
1330
1591
  return canonicalJson(payload);
@@ -1350,18 +1611,38 @@ exports.VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1350
1611
  *
1351
1612
  * The third row is the point: `verifyCertificate` answers VALID there, which
1352
1613
  * reads as "not revoked" and is not something it checked.
1614
+ *
1615
+ * `options` is passed to {@link verifyCertificate} unchanged.
1353
1616
  */
1354
- async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
1617
+ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now, options) {
1355
1618
  // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1356
1619
  // two soft verdicts. Returning a soft verdict here skipped every check below
1357
1620
  // it: a revoked certificate signed by a compromised-later key answered
1358
1621
  // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1359
1622
  // verdict is held instead, and resolved against what the statement says.
1360
- const base = await verifyCertificate(crypto, certificate, publicKeys);
1623
+ const base = await verifyCertificate(crypto, certificate, publicKeys, options);
1361
1624
  if (status == null) {
1362
1625
  return resolveWithBase(base, exports.VALID_REVOCATION_UNKNOWN);
1363
1626
  }
1364
- const keyId = status.key_id;
1627
+ return resolveWithBase(base, await verifyStatusStatement(crypto, status, publicKeys, certificate.certificate_id, now));
1628
+ }
1629
+ /**
1630
+ * The statement half of {@link verifyCertificateWithStatus}: authenticate a
1631
+ * status statement about `certificateId` against the published keys, and read
1632
+ * it at `now`. The /verify page's QR lookup has a record id and no record, and
1633
+ * uses this so it shows what a signed, fresh statement says, never what the
1634
+ * status endpoint claims.
1635
+ *
1636
+ * | input | result |
1637
+ * |---|---|
1638
+ * | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
1639
+ * | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
1640
+ * | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
1641
+ * | unknown or unusable key, bad signature, another certificate | throws VerificationError |
1642
+ */
1643
+ async function verifyStatusStatement(crypto, status, publicKeys, certificateId, now) {
1644
+ requireObject(status, "status");
1645
+ const keyId = optionalStringAt(status, "key_id", "status.key_id");
1365
1646
  const info = keyId ? publicKeys.get(keyId) : undefined;
1366
1647
  if (!info) {
1367
1648
  throw new errors_js_1.VerificationError(`status statement signed by unknown key: ${keyId}`);
@@ -1382,10 +1663,10 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1382
1663
  const at = formatTimestamp(when.toISOString());
1383
1664
  const statusKeyVerdict = requireUsableKey(info, parseRfc3339(at), keyId, "status statement key");
1384
1665
  const payload = buildCertificateStatusPayload(status);
1385
- // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
1666
+ // decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
1386
1667
  // array depending on the producer, and every other signature on this path
1387
1668
  // goes through the same decoder.
1388
- const sig = decodeSignature(status.signature);
1669
+ const sig = decodeFixed(status.signature, "status.signature", 64);
1389
1670
  if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
1390
1671
  throw new errors_js_1.VerificationError("status statement signature is invalid");
1391
1672
  }
@@ -1402,8 +1683,7 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1402
1683
  // Every signature still verified; the binding was the only forged part.
1403
1684
  // core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
1404
1685
  // while both SDKs and the browser bundle said VALID.
1405
- const certId = certificate.certificate_id;
1406
- if (status.certificate_id !== certId) {
1686
+ if (status.certificate_id !== certificateId) {
1407
1687
  throw new errors_js_1.VerificationError("status statement is about a different certificate");
1408
1688
  }
1409
1689
  // Compared in the normalized form the payload signs, so the freshness check
@@ -1413,7 +1693,7 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1413
1693
  if (at < issued || at >= expires) {
1414
1694
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
1415
1695
  // statement, which must never decay into VALID.
1416
- return resolveWithBase(base, exports.VALID_REVOCATION_UNKNOWN);
1696
+ return exports.VALID_REVOCATION_UNKNOWN;
1417
1697
  }
1418
1698
  if (status.status === "REVOKED") {
1419
1699
  // Coded, because this is the only status-path rejection that is a fact
@@ -1428,12 +1708,12 @@ async function verifyCertificateWithStatus(crypto, certificate, publicKeys, stat
1428
1708
  // case — was reported as revocation CHECKED AND PASSED. Mirrors
1429
1709
  // core.ValidCertificateStatusValue.
1430
1710
  if (!KNOWN_STATUS_VALUES.has(status.status)) {
1431
- return resolveWithBase(base, exports.VALID_REVOCATION_UNKNOWN);
1711
+ return exports.VALID_REVOCATION_UNKNOWN;
1432
1712
  }
1433
1713
  // A soft verdict on the status key is the result, not a footnote — but it is
1434
1714
  // reported only once the statement has authenticated and been read, so it can
1435
1715
  // neither speak for an unverified statement nor suppress a revocation.
1436
- return resolveWithBase(base, statusKeyVerdict);
1716
+ return statusKeyVerdict;
1437
1717
  }
1438
1718
  /**
1439
1719
  * Picks what to report when the certificate's own key returned a soft verdict