burnledger 0.9.0 → 0.10.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 (88) hide show
  1. package/README.md +9 -4
  2. package/dist/cjs/audit-pack.d.ts +201 -0
  3. package/dist/cjs/audit-pack.d.ts.map +1 -0
  4. package/dist/cjs/audit-pack.js +867 -0
  5. package/dist/cjs/audit-pack.js.map +1 -0
  6. package/dist/cjs/client.d.ts +41 -1
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +107 -59
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/enclave-registration.d.ts +14 -2
  11. package/dist/cjs/enclave-registration.d.ts.map +1 -1
  12. package/dist/cjs/enclave-registration.js +14 -2
  13. package/dist/cjs/enclave-registration.js.map +1 -1
  14. package/dist/cjs/index.d.ts +46 -2
  15. package/dist/cjs/index.d.ts.map +1 -1
  16. package/dist/cjs/index.js +114 -2
  17. package/dist/cjs/index.js.map +1 -1
  18. package/dist/cjs/models.d.ts +28 -2
  19. package/dist/cjs/models.d.ts.map +1 -1
  20. package/dist/cjs/models.js +19 -1
  21. package/dist/cjs/models.js.map +1 -1
  22. package/dist/cjs/node-runtime.d.ts +40 -0
  23. package/dist/cjs/node-runtime.d.ts.map +1 -0
  24. package/dist/cjs/node-runtime.js +42 -0
  25. package/dist/cjs/node-runtime.js.map +1 -0
  26. package/dist/cjs/run-record.d.ts +130 -0
  27. package/dist/cjs/run-record.d.ts.map +1 -0
  28. package/dist/cjs/run-record.js +272 -0
  29. package/dist/cjs/run-record.js.map +1 -0
  30. package/dist/cjs/verify.d.ts +58 -0
  31. package/dist/cjs/verify.d.ts.map +1 -1
  32. package/dist/cjs/verify.js +277 -38
  33. package/dist/cjs/verify.js.map +1 -1
  34. package/dist/cjs/webhooks.d.ts +9 -2
  35. package/dist/cjs/webhooks.d.ts.map +1 -1
  36. package/dist/cjs/webhooks.js +29 -11
  37. package/dist/cjs/webhooks.js.map +1 -1
  38. package/dist/esm/audit-pack.d.ts +201 -0
  39. package/dist/esm/audit-pack.d.ts.map +1 -0
  40. package/dist/esm/audit-pack.js +858 -0
  41. package/dist/esm/audit-pack.js.map +1 -0
  42. package/dist/esm/cli.d.ts +52 -0
  43. package/dist/esm/cli.d.ts.map +1 -1
  44. package/dist/esm/cli.js +283 -11
  45. package/dist/esm/cli.js.map +1 -1
  46. package/dist/esm/client.d.ts +41 -1
  47. package/dist/esm/client.d.ts.map +1 -1
  48. package/dist/esm/client.js +108 -27
  49. package/dist/esm/client.js.map +1 -1
  50. package/dist/esm/enclave-registration.d.ts +14 -2
  51. package/dist/esm/enclave-registration.d.ts.map +1 -1
  52. package/dist/esm/enclave-registration.js +14 -2
  53. package/dist/esm/enclave-registration.js.map +1 -1
  54. package/dist/esm/index.d.ts +46 -2
  55. package/dist/esm/index.d.ts.map +1 -1
  56. package/dist/esm/index.js +64 -1
  57. package/dist/esm/index.js.map +1 -1
  58. package/dist/esm/models.d.ts +28 -2
  59. package/dist/esm/models.d.ts.map +1 -1
  60. package/dist/esm/models.js +18 -1
  61. package/dist/esm/models.js.map +1 -1
  62. package/dist/esm/node-runtime.d.ts +40 -0
  63. package/dist/esm/node-runtime.d.ts.map +1 -0
  64. package/dist/esm/node-runtime.js +38 -0
  65. package/dist/esm/node-runtime.js.map +1 -0
  66. package/dist/esm/run-record.d.ts +130 -0
  67. package/dist/esm/run-record.d.ts.map +1 -0
  68. package/dist/esm/run-record.js +262 -0
  69. package/dist/esm/run-record.js.map +1 -0
  70. package/dist/esm/verify.d.ts +58 -0
  71. package/dist/esm/verify.d.ts.map +1 -1
  72. package/dist/esm/verify.js +270 -41
  73. package/dist/esm/verify.js.map +1 -1
  74. package/dist/esm/webhooks.d.ts +9 -2
  75. package/dist/esm/webhooks.d.ts.map +1 -1
  76. package/dist/esm/webhooks.js +29 -11
  77. package/dist/esm/webhooks.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/audit-pack.ts +1067 -0
  80. package/src/cli.ts +289 -10
  81. package/src/client.ts +132 -27
  82. package/src/enclave-registration.ts +14 -2
  83. package/src/index.ts +130 -1
  84. package/src/models.ts +47 -3
  85. package/src/node-runtime.ts +57 -0
  86. package/src/run-record.ts +371 -0
  87. package/src/verify.ts +301 -41
  88. package/src/webhooks.ts +28 -11
@@ -76,6 +76,17 @@ const FORMAT_VERSION_V8 = "8.0";
76
76
  const PAYLOAD_TYPE_ATTESTATION_V9 = "burnledger.attestation.v9";
77
77
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V9 = "burnledger.verification_record.v9";
78
78
  const FORMAT_VERSION_V9 = "9.0";
79
+ // v10 signs one top-level field, run_id: the run-level record (ADR-031) the
80
+ // record was issued under, when it was issued under one. The attestation bytes
81
+ // are v9's; its tag still moves, for the reason every version's does.
82
+ //
83
+ // READ-ONLY IN THIS BUILD, mirroring core, for the runbook-invariant-5 reason
84
+ // spelled out for v8 above. Until issuance moves, a run binds its records by
85
+ // the run record's records_root alone (run-record.ts), which is the binding
86
+ // the verifier checks in every case.
87
+ const PAYLOAD_TYPE_ATTESTATION_V10 = "burnledger.attestation.v10";
88
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V10 = "burnledger.verification_record.v10";
89
+ const FORMAT_VERSION_V10 = "10.0";
79
90
  const FORMAT_VERSION_V3 = "3.0";
80
91
  /**
81
92
  * The only signature scheme this SDK can check. A v7 record names its own
@@ -101,6 +112,7 @@ export const KNOWN_FORMAT_VERSIONS = Object.freeze([
101
112
  FORMAT_VERSION_V7,
102
113
  FORMAT_VERSION_V8,
103
114
  FORMAT_VERSION_V9,
115
+ FORMAT_VERSION_V10,
104
116
  ]);
105
117
  /**
106
118
  * Returns the version, or throws if this SDK cannot read it.
@@ -168,6 +180,19 @@ function signatureCoversRecoverableState(version) {
168
180
  function signatureCoversAuthorization(version) {
169
181
  return formatAtLeast(version, FORMAT_VERSION_V9);
170
182
  }
183
+ /**
184
+ * Does this format sign the top-level run_id (ADR-031)?
185
+ *
186
+ * v10 onward, and optional even there: a record issued outside a run has none.
187
+ * A record OLDER than v10 that carries one is refused outright rather than
188
+ * signed without it — the field would ride in the JSON beside a signature that
189
+ * says nothing about it. Exported for the audit pack verifier, which asks the
190
+ * same question of a record that carries none: below v10 that says nothing
191
+ * about its run, from v10 it says the record was issued under no run.
192
+ */
193
+ export function signatureCoversRunId(version) {
194
+ return formatAtLeast(version, FORMAT_VERSION_V10);
195
+ }
171
196
  /** The two values a v9 record's per-system authorization may take. */
172
197
  const AUTHORIZATION_CERTIFIED = "certified";
173
198
  const AUTHORIZATION_NONE = "none";
@@ -351,6 +376,18 @@ export function evaluateKey(pki, anchor) {
351
376
  if (pki.keyStatus === "compromised") {
352
377
  if (anchor === null || pki.compromisedFrom === undefined)
353
378
  return KEY_COMPROMISED;
379
+ // The validity window is tested before the compromise anchor (R03-1). A
380
+ // signature made outside the stated interval was never authorized, whatever
381
+ // the key's later disposition, so an anchor outside the window is
382
+ // KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
383
+ // window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
384
+ // the window alone already refuses.
385
+ if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
386
+ return KEY_OUTSIDE_VALIDITY;
387
+ }
388
+ if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
389
+ return KEY_OUTSIDE_VALIDITY;
390
+ }
354
391
  return anchor < parseRfc3339(pki.compromisedFrom)
355
392
  ? VALID_KEY_COMPROMISED_LATER
356
393
  : KEY_COMPROMISED;
@@ -398,42 +435,44 @@ export function certificateAnchor(certificate) {
398
435
  }
399
436
  }
400
437
  /**
401
- * The anchor, but only once the tree head carrying it has been checked against
402
- * the key being evaluated.
438
+ * The anchor, but only once this record has been PROVEN to be in the tree head
439
+ * carrying it — its inclusion proof verified against that head, not merely the
440
+ * head's own signature checked.
403
441
  *
404
- * The bypass this closes: for a key declared compromised, evaluateKey answers
405
- * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
406
- * anchor predates the compromise — and the second is a verdict requireUsableKey
407
- * passes through as the RESULT of verifyCertificate. Since the timestamp is
408
- * covered by no signature, a holder could backdate it and have a certificate
409
- * signed with a compromised key reported as verified-with-a-caveat. Deleting
410
- * `transparency` failed closed; keeping a doctored one did not.
442
+ * The bypass this closes (R12-1/R14-1): for a key declared compromised,
443
+ * evaluateKey answers KEY_COMPROMISED with no anchor and
444
+ * VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
445
+ * second is a verdict requireUsableKey passes through as the RESULT of
446
+ * verifyCertificate. A signed tree head is a public artifact: any genuine older
447
+ * head the issuer ever published can be stapled onto a record it never
448
+ * contained, and its signature still verifies. Checking only the head signature
449
+ * let a holder of a certificate signed after the compromise attach a
450
+ * pre-compromise head and have the forgery reported as verified-with-a-caveat.
451
+ * The head's timestamp is an honest anchor for THIS record only once THIS record
452
+ * is shown to be committed to under it.
411
453
  *
412
- * A head that does not verify yields no anchor, which is the same conservative
413
- * answer as a certificate carrying no transparency block at all.
454
+ * checkCertificateInclusion is exactly that proof; it deliberately does NOT test
455
+ * the key's usability at head time — that is the verdict this anchor exists to
456
+ * compute, so gating the anchor on it would drop the anchor for an out-of-window
457
+ * key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
458
+ * of a held inclusion proof yields no anchor, the same conservative answer as a
459
+ * certificate with no transparency block at all.
414
460
  */
415
461
  async function verifiedCertificateAnchor(crypto, certificate, pki) {
416
462
  const anchor = certificateAnchor(certificate);
417
463
  if (anchor === null)
418
464
  return null;
419
- const transparency = certificate.transparency;
420
- const sth = transparency.signed_tree_head;
421
- // Only a refusal of the head's own fields means "no anchor". A bare catch
422
- // here gave a defect in this library — or a crypto provider that threw — the
423
- // same quiet answer as a malformed document.
424
- let headPayload;
425
- let headSig;
465
+ // Only a refusal of the proof means "no anchor". A defect in this library, or
466
+ // a crypto provider that threw, must not get the same quiet answer as a
467
+ // document whose inclusion proof does not verify.
426
468
  try {
427
- headPayload = buildTreeHeadPayload(sth);
428
- headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
469
+ await checkCertificateInclusion(crypto, certificate, pki);
429
470
  }
430
471
  catch (e) {
431
472
  if (e instanceof VerificationError)
432
473
  return null;
433
474
  throw e;
434
475
  }
435
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
436
- return null;
437
476
  return anchor;
438
477
  }
439
478
  /** Throws for a key that cannot be used at all; returns the verdict otherwise.
@@ -717,14 +756,37 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
717
756
  return "NOT_AVAILABLE";
718
757
  }
719
758
  const transparency = objectAt(certificate, "transparency", "transparency");
720
- const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
759
+ // Read before the key lookup, as it always was: a proof with no head is
760
+ // reported as that, not as a key the reader does not hold.
761
+ objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
721
762
  const issuer = objectAt(certificate, "issuer", "issuer");
722
763
  const keyId = stringAt(issuer, "key_id", "issuer.key_id");
723
764
  const pki = publicKeys.get(keyId);
724
765
  if (pki === undefined)
725
766
  throw new VerificationError(`unknown issuer key: ${keyId}`);
726
- // The tree head carries its own timestamp, so this path always has an anchor.
727
- requireUsableKey(pki, certificateAnchor(certificate), keyId);
767
+ await verifyTransparencyHead(crypto, transparency, pki, keyId);
768
+ // The leaf is NOT the certificate. It is the canonical log_leaf.v3 payload
769
+ // over entry_type, certificate_id, certificate_hash and appended_at, where
770
+ // certificate_hash is SHA-256 of the issuance-time certificate JSON
771
+ // (ADR-016 §4). v2 hashed the certificate directly, so an issuance and a
772
+ // revocation of the same certificate produced identical leaves and the tree
773
+ // committed to neither the entry type nor when it happened.
774
+ const issuanceData = issuanceBytes(certificate);
775
+ const leafPayload = buildLogLeafPayload(transparency.entry_type, stringAt(certificate, "certificate_id", "certificate_id"), await crypto.sha256(issuanceData), transparency.appended_at);
776
+ await verifyTransparencyInclusion(crypto, transparency, await hashLeaf(crypto, leafPayload));
777
+ return "INCLUDED";
778
+ }
779
+ /**
780
+ * Verify the head signature and the Merkle inclusion of a certificate's proof —
781
+ * everything about whether this record is committed to under its head, but NOT
782
+ * whether the key was usable when it signed. That last question is the caller's;
783
+ * certificateAnchor needs membership, not authority, and folding authority in
784
+ * here made the anchor for an out-of-window key collapse to "no anchor" and the
785
+ * verdict soften. Throws VerificationError on any inclusion failure.
786
+ */
787
+ async function checkCertificateInclusion(crypto, certificate, pki) {
788
+ const transparency = objectAt(certificate, "transparency", "transparency");
789
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
728
790
  // 1. Tree head signature
729
791
  const headPayload = buildTreeHeadPayload(sth);
730
792
  const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
@@ -775,7 +837,91 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
775
837
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
776
838
  throw new VerificationError("merkle inclusion proof is invalid");
777
839
  }
778
- return "INCLUDED";
840
+ }
841
+ /** The first half of a transparency check, shared with the run record's
842
+ * (run-record.ts): the key's authority at the head's own timestamp, then the
843
+ * head's signature. The tree head carries its own timestamp, so this path
844
+ * always has an anchor; a timestamp that does not read leaves none, which is
845
+ * the same conservative answer as no proof at all. Throws the VerificationError
846
+ * the caller reports. Exported as an internal seam, not from index.ts. */
847
+ export async function verifyTransparencyHead(crypto, transparency, pki, keyId) {
848
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
849
+ let anchor = null;
850
+ if (typeof sth.timestamp === "string") {
851
+ try {
852
+ anchor = parseRfc3339(sth.timestamp);
853
+ }
854
+ catch {
855
+ anchor = null;
856
+ }
857
+ }
858
+ requireUsableKey(pki, anchor, keyId);
859
+ const headPayload = buildTreeHeadPayload(sth);
860
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
861
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
862
+ throw new VerificationError("tree head signature is invalid");
863
+ }
864
+ }
865
+ /** The second half: an already-hashed leaf against the signed head, after the
866
+ * unsigned duplicates beside the proof are held to the signed copies. Throws
867
+ * the VerificationError the caller reports. Exported as an internal seam. */
868
+ export async function verifyTransparencyInclusion(crypto, transparency, leaf) {
869
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
870
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map((h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32));
871
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
872
+ // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
873
+ // for an explicit null and for an absent key rather than failing, so refusing
874
+ // either would reject documents the reference accepts — the same class of
875
+ // divergence as #452. An offline verifier is only useful while it agrees.
876
+ const index = requireUint(transparency.entry_index ?? 0, "entry_index");
877
+ const treeSize = requireUint(sth.tree_size ?? 0, "signed_tree_head.tree_size");
878
+ // transparency.tree_size duplicates the signed one but carries no signature:
879
+ // BuildTreeHeadPayload covers only the copy inside signed_tree_head. Go used
880
+ // to verify against the unsigned copy while this SDK used the signed one, so
881
+ // the same document got two verdicts (#456). Both now require the two to agree
882
+ // and then verify against the signed copy — the server always writes them
883
+ // equal, so this rejects only edited documents.
884
+ const unsignedSize = requireUint(transparency.tree_size ?? 0, "transparency.tree_size");
885
+ if (unsignedSize !== treeSize) {
886
+ throw new VerificationError(`transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
887
+ `(${treeSize}); it is not covered by any signature`);
888
+ }
889
+ // Same rule for the log id (Q31). transparency.log_id sits beside log_url and
890
+ // is the field a reader looks at to answer "which log is this?", and nothing
891
+ // signs it — so a holder could relabel a genuine proof as belonging to a
892
+ // different log while every signature still checked out. A head predating log
893
+ // ids has neither side set and passes.
894
+ const signedLogId = sth.log_id ?? "";
895
+ const unsignedLogId = transparency.log_id ?? "";
896
+ if (unsignedLogId !== signedLogId) {
897
+ throw new VerificationError(`transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
898
+ `tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`);
899
+ }
900
+ if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
901
+ throw new VerificationError("merkle inclusion proof is invalid");
902
+ }
903
+ }
904
+ /** The refusals of verifyTransparencyHead and verifyTransparencyInclusion under
905
+ * core.TransparencyResult's names, for the verifiers that report a pack or a
906
+ * run record beside Go's verdicts rather than by throwing. The key verdicts
907
+ * collapse to KEY_UNUSABLE as they do in Go, whose transparency result does not
908
+ * say which key problem. Anything unmatched is a field this SDK could not read:
909
+ * UNREADABLE_DOCUMENT, never a tampering claim. */
910
+ const TRANSPARENCY_VERDICTS = [
911
+ ["issuer key is compromised:", "KEY_UNUSABLE"],
912
+ ["issuer key was not valid when it signed:", "KEY_UNUSABLE"],
913
+ ["issuer key has an unusable status", "KEY_UNUSABLE"],
914
+ ["tree head signature is invalid", "INVALID_TREE_HEAD_SIGNATURE"],
915
+ ["transparency.tree_size (", "INVALID_INCLUSION_PROOF"],
916
+ ["transparency.log_id (", "INVALID_INCLUSION_PROOF"],
917
+ ["merkle inclusion proof is invalid", "INVALID_INCLUSION_PROOF"],
918
+ ];
919
+ export function transparencyVerdictName(e) {
920
+ for (const [prefix, verdict] of TRANSPARENCY_VERDICTS) {
921
+ if (e.message.startsWith(prefix))
922
+ return verdict;
923
+ }
924
+ return "UNREADABLE_DOCUMENT";
779
925
  }
780
926
  // ---------------------------------------------------------------------------
781
927
  // Issuance bytes — matches Go's core.IssuanceBytes(cert)
@@ -868,6 +1014,45 @@ function arrayAt(parent, key, path) {
868
1014
  throw new VerificationError(`${path} is not an array`);
869
1015
  return Array.from(value);
870
1016
  }
1017
+ /** A UUID in the forms Go's uuid.Parse accepts: 8-4-4-4-12 hex, the same in
1018
+ * braces or behind "urn:uuid:", or 32 bare hex digits. */
1019
+ const UUID_RE = /^(?:urn:uuid:)?\{?([0-9a-fA-F]{8})-([0-9a-fA-F]{4})-([0-9a-fA-F]{4})-([0-9a-fA-F]{4})-([0-9a-fA-F]{12})\}?$/;
1020
+ const UUID_BARE_RE = /^[0-9a-fA-F]{32}$/;
1021
+ /** The 16 bytes of a UUID, in any form Go's uuid.Parse reads. Throws TypeError
1022
+ * for anything else: a UUID reaches a signed payload only as its canonical
1023
+ * string, and an unreadable one must not be signed as whatever text arrived. */
1024
+ export function parseUuid(text) {
1025
+ if (typeof text !== "string")
1026
+ throw new TypeError(`uuid is not a string: ${typeof text}`);
1027
+ const m = UUID_RE.exec(text);
1028
+ const hex = m !== null ? m.slice(1).join("") : UUID_BARE_RE.test(text) ? text : null;
1029
+ if (hex === null)
1030
+ throw new TypeError(`not a UUID: ${JSON.stringify(text)}`);
1031
+ return hexToBytes(hex);
1032
+ }
1033
+ /** The canonical lowercase 8-4-4-4-12 form, as Go's uuid.UUID.String prints:
1034
+ * the only form a UUID takes inside a signed payload. */
1035
+ export function formatUuid(bytes) {
1036
+ if (bytes.length !== 16)
1037
+ throw new TypeError(`uuid is ${bytes.length} bytes, expected 16`);
1038
+ const h = bytesToHex(bytes);
1039
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1040
+ }
1041
+ /** Read a UUID field as Go's uuid.UUID unmarshals it, refusing with a
1042
+ * VerificationError what encoding/json would refuse to parse. */
1043
+ function uuidAt(parent, key, path) {
1044
+ const v = parent[key];
1045
+ if (typeof v !== "string")
1046
+ throw new VerificationError(`${path} is not a string`);
1047
+ try {
1048
+ return formatUuid(parseUuid(v));
1049
+ }
1050
+ catch (e) {
1051
+ if (e instanceof TypeError)
1052
+ throw new VerificationError(`${path} is not a UUID: ${JSON.stringify(v)}`);
1053
+ throw e;
1054
+ }
1055
+ }
871
1056
  const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
872
1057
  const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
873
1058
  /**
@@ -883,7 +1068,9 @@ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/
883
1068
  * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
884
1069
  * signature covers and blamed the signature — or threw from inside atob.
885
1070
  */
886
- function decodeFixed(value, path, length) {
1071
+ /** Exported for run-record.ts, whose digests and roots arrive in the same
1072
+ * shapes; not published from index.ts. */
1073
+ export function decodeFixed(value, path, length) {
887
1074
  if (value === undefined || value === null)
888
1075
  throw new VerificationError(`${path} is missing`);
889
1076
  let raw;
@@ -1120,6 +1307,8 @@ function requireMeasured(value, field, systemName, version) {
1120
1307
  /** Domain separator for the record itself, by certificate format version. */
1121
1308
  function certificatePayloadType(version) {
1122
1309
  checkFormatVersion(version);
1310
+ if (version === FORMAT_VERSION_V10)
1311
+ return PAYLOAD_TYPE_VERIFICATION_RECORD_V10;
1123
1312
  if (version === FORMAT_VERSION_V9)
1124
1313
  return PAYLOAD_TYPE_VERIFICATION_RECORD_V9;
1125
1314
  if (version === FORMAT_VERSION_V8)
@@ -1145,6 +1334,8 @@ function attestationPayloadType(version) {
1145
1334
  // Equality here, deliberately: each format has its OWN separator, so this is
1146
1335
  // a lookup rather than a "this version onward" question. v3 and v4 share one
1147
1336
  // because their attestation bytes are identical.
1337
+ if (version === FORMAT_VERSION_V10)
1338
+ return PAYLOAD_TYPE_ATTESTATION_V10;
1148
1339
  if (version === FORMAT_VERSION_V9)
1149
1340
  return PAYLOAD_TYPE_ATTESTATION_V9;
1150
1341
  if (version === FORMAT_VERSION_V8)
@@ -1274,11 +1465,25 @@ function buildCertificatePayload(cert) {
1274
1465
  version: stringAt(scope, "version", "scope.version"),
1275
1466
  };
1276
1467
  }
1468
+ // Signed from v10 when present. On an older format the field is refused
1469
+ // rather than dropped from the bytes: core.BuildCertificatePayload returns an
1470
+ // error there and the verdict is INVALID_CERTIFICATE_SIGNATURE, so the same
1471
+ // words open this message for the audit pack verifier to read it back as
1472
+ // that verdict. A JSON null is an absent run, as Go's *uuid.UUID reads it.
1473
+ if (cert.run_id != null) {
1474
+ if (!signatureCoversRunId(version)) {
1475
+ throw new VerificationError(`certificate signature is invalid: the record carries run_id, which format ${version} does not ` +
1476
+ `sign (run_id is signed from ${FORMAT_VERSION_V10}), so no signature covers the document as presented`);
1477
+ }
1478
+ payload.run_id = uuidAt(cert, "run_id", "run_id");
1479
+ }
1277
1480
  return canonicalJson(payload);
1278
1481
  }
1279
- /** Port of Go's BuildLogLeafPayload (ADR-016 §4). */
1280
- function buildLogLeafPayload(entryType, certificateId, certificateHash, appendedAt) {
1281
- if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION") {
1482
+ /** Port of Go's BuildLogLeafPayload (ADR-016 §4). A RUN_RECORD leaf names its
1483
+ * run id where the other two name a certificate id, under the same key
1484
+ * (ADR-031). Exported for run-record.ts; not published from index.ts. */
1485
+ export function buildLogLeafPayload(entryType, certificateId, certificateHash, appendedAt) {
1486
+ if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION" && entryType !== "RUN_RECORD") {
1282
1487
  throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
1283
1488
  }
1284
1489
  const payload = {
@@ -1346,9 +1551,21 @@ export function buildTreeHeadPayload(head) {
1346
1551
  // ---------------------------------------------------------------------------
1347
1552
  // Merkle tree (RFC 6962) — ports of core/merkle.go
1348
1553
  // ---------------------------------------------------------------------------
1349
- async function hashLeaf(crypto, data) {
1554
+ /** Exported for run-record.ts, whose roots are trees over these leaves. */
1555
+ export async function hashLeaf(crypto, data) {
1350
1556
  return crypto.sha256(concatBytes(new Uint8Array([0x00]), data));
1351
1557
  }
1558
+ /** The RFC 6962 root over already-hashed leaves, in the order given. Port of
1559
+ * Go's treeHash, which like it takes at least one leaf: the empty tree has no
1560
+ * root here, and a caller with nothing to commit to says so itself. */
1561
+ export async function merkleRoot(crypto, leaves) {
1562
+ if (leaves.length === 0)
1563
+ throw new RangeError("merkleRoot: no leaves");
1564
+ if (leaves.length === 1)
1565
+ return leaves[0];
1566
+ const k = splitPoint(leaves.length);
1567
+ return hashNode(crypto, await merkleRoot(crypto, leaves.slice(0, k)), await merkleRoot(crypto, leaves.slice(k)));
1568
+ }
1352
1569
  async function hashNode(crypto, left, right) {
1353
1570
  const prefix = new Uint8Array([0x01]);
1354
1571
  return crypto.sha256(concatBytes(prefix, concatBytes(left, right)));
@@ -1407,7 +1624,14 @@ async function chainInclusion(crypto, leaf, index, n, proof) {
1407
1624
  return [await hashNode(crypto, proof[used], inner), used + 1];
1408
1625
  }
1409
1626
  function isPow2(n) {
1410
- return n > 0 && (n & (n - 1)) === 0;
1627
+ // Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
1628
+ // signed integers, so for a tree size >= 2^31 the bit test gives the wrong
1629
+ // answer. This holds for every safe integer.
1630
+ if (n < 1)
1631
+ return false;
1632
+ while (n % 2 === 0)
1633
+ n /= 2;
1634
+ return n === 1;
1411
1635
  }
1412
1636
  /** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
1413
1637
  *
@@ -1435,9 +1659,14 @@ export async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRo
1435
1659
  }
1436
1660
  let fn = oldSize - 1;
1437
1661
  let sn = newSize - 1;
1438
- while ((fn & 1) === 1) {
1439
- fn >>= 1;
1440
- sn >>= 1;
1662
+ // Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
1663
+ // integers, so for tree sizes >= 2^31 fn and sn are truncated and this
1664
+ // consistency check silently returned the wrong answer (CONFIRMED where Go
1665
+ // says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
1666
+ // integer, which requireUint already bounds the inputs to.
1667
+ while (fn % 2 === 1) {
1668
+ fn = Math.floor(fn / 2);
1669
+ sn = Math.floor(sn / 2);
1441
1670
  }
1442
1671
  // Drive the walk from the tree, not from the proof's length. Looping on
1443
1672
  // `pIdx < proof.length` let the prover choose how many steps ran, so a log
@@ -1451,19 +1680,19 @@ export async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRo
1451
1680
  return false;
1452
1681
  const c = proof[pIdx];
1453
1682
  pIdx++;
1454
- if ((fn & 1) === 1 || fn === sn) {
1683
+ if (fn % 2 === 1 || fn === sn) {
1455
1684
  fr = await hashNode(crypto, c, fr);
1456
1685
  sr = await hashNode(crypto, c, sr);
1457
- while (fn !== 0 && (fn & 1) === 0) {
1458
- fn >>= 1;
1459
- sn >>= 1;
1686
+ while (fn !== 0 && fn % 2 === 0) {
1687
+ fn = Math.floor(fn / 2);
1688
+ sn = Math.floor(sn / 2);
1460
1689
  }
1461
1690
  }
1462
1691
  else {
1463
1692
  sr = await hashNode(crypto, sr, c);
1464
1693
  }
1465
- fn >>= 1;
1466
- sn >>= 1;
1694
+ fn = Math.floor(fn / 2);
1695
+ sn = Math.floor(sn / 2);
1467
1696
  }
1468
1697
  return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
1469
1698
  }