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
package/src/verify.ts CHANGED
@@ -88,6 +88,17 @@ const FORMAT_VERSION_V8 = "8.0";
88
88
  const PAYLOAD_TYPE_ATTESTATION_V9 = "burnledger.attestation.v9";
89
89
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V9 = "burnledger.verification_record.v9";
90
90
  const FORMAT_VERSION_V9 = "9.0";
91
+ // v10 signs one top-level field, run_id: the run-level record (ADR-031) the
92
+ // record was issued under, when it was issued under one. The attestation bytes
93
+ // are v9's; its tag still moves, for the reason every version's does.
94
+ //
95
+ // READ-ONLY IN THIS BUILD, mirroring core, for the runbook-invariant-5 reason
96
+ // spelled out for v8 above. Until issuance moves, a run binds its records by
97
+ // the run record's records_root alone (run-record.ts), which is the binding
98
+ // the verifier checks in every case.
99
+ const PAYLOAD_TYPE_ATTESTATION_V10 = "burnledger.attestation.v10";
100
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V10 = "burnledger.verification_record.v10";
101
+ const FORMAT_VERSION_V10 = "10.0";
91
102
  const FORMAT_VERSION_V3 = "3.0";
92
103
 
93
104
  /**
@@ -115,6 +126,7 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
115
126
  FORMAT_VERSION_V7,
116
127
  FORMAT_VERSION_V8,
117
128
  FORMAT_VERSION_V9,
129
+ FORMAT_VERSION_V10,
118
130
  ]);
119
131
 
120
132
  /**
@@ -193,6 +205,20 @@ function signatureCoversAuthorization(version: string): boolean {
193
205
  return formatAtLeast(version, FORMAT_VERSION_V9);
194
206
  }
195
207
 
208
+ /**
209
+ * Does this format sign the top-level run_id (ADR-031)?
210
+ *
211
+ * v10 onward, and optional even there: a record issued outside a run has none.
212
+ * A record OLDER than v10 that carries one is refused outright rather than
213
+ * signed without it — the field would ride in the JSON beside a signature that
214
+ * says nothing about it. Exported for the audit pack verifier, which asks the
215
+ * same question of a record that carries none: below v10 that says nothing
216
+ * about its run, from v10 it says the record was issued under no run.
217
+ */
218
+ export function signatureCoversRunId(version: string): boolean {
219
+ return formatAtLeast(version, FORMAT_VERSION_V10);
220
+ }
221
+
196
222
  /** The two values a v9 record's per-system authorization may take. */
197
223
  const AUTHORIZATION_CERTIFIED = "certified";
198
224
  const AUTHORIZATION_NONE = "none";
@@ -447,6 +473,18 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
447
473
  if (pki.revoked) return "UNKNOWN_KEY";
448
474
  if (pki.keyStatus === "compromised") {
449
475
  if (anchor === null || pki.compromisedFrom === undefined) return KEY_COMPROMISED;
476
+ // The validity window is tested before the compromise anchor (R03-1). A
477
+ // signature made outside the stated interval was never authorized, whatever
478
+ // the key's later disposition, so an anchor outside the window is
479
+ // KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
480
+ // window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
481
+ // the window alone already refuses.
482
+ if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
483
+ return KEY_OUTSIDE_VALIDITY;
484
+ }
485
+ if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
486
+ return KEY_OUTSIDE_VALIDITY;
487
+ }
450
488
  return anchor < parseRfc3339(pki.compromisedFrom)
451
489
  ? VALID_KEY_COMPROMISED_LATER
452
490
  : KEY_COMPROMISED;
@@ -491,19 +529,28 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
491
529
  }
492
530
 
493
531
  /**
494
- * The anchor, but only once the tree head carrying it has been checked against
495
- * the key being evaluated.
532
+ * The anchor, but only once this record has been PROVEN to be in the tree head
533
+ * carrying it — its inclusion proof verified against that head, not merely the
534
+ * head's own signature checked.
496
535
  *
497
- * The bypass this closes: for a key declared compromised, evaluateKey answers
498
- * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
499
- * anchor predates the compromise — and the second is a verdict requireUsableKey
500
- * passes through as the RESULT of verifyCertificate. Since the timestamp is
501
- * covered by no signature, a holder could backdate it and have a certificate
502
- * signed with a compromised key reported as verified-with-a-caveat. Deleting
503
- * `transparency` failed closed; keeping a doctored one did not.
536
+ * The bypass this closes (R12-1/R14-1): for a key declared compromised,
537
+ * evaluateKey answers KEY_COMPROMISED with no anchor and
538
+ * VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
539
+ * second is a verdict requireUsableKey passes through as the RESULT of
540
+ * verifyCertificate. A signed tree head is a public artifact: any genuine older
541
+ * head the issuer ever published can be stapled onto a record it never
542
+ * contained, and its signature still verifies. Checking only the head signature
543
+ * let a holder of a certificate signed after the compromise attach a
544
+ * pre-compromise head and have the forgery reported as verified-with-a-caveat.
545
+ * The head's timestamp is an honest anchor for THIS record only once THIS record
546
+ * is shown to be committed to under it.
504
547
  *
505
- * A head that does not verify yields no anchor, which is the same conservative
506
- * answer as a certificate carrying no transparency block at all.
548
+ * checkCertificateInclusion is exactly that proof; it deliberately does NOT test
549
+ * the key's usability at head time — that is the verdict this anchor exists to
550
+ * compute, so gating the anchor on it would drop the anchor for an out-of-window
551
+ * key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
552
+ * of a held inclusion proof yields no anchor, the same conservative answer as a
553
+ * certificate with no transparency block at all.
507
554
  */
508
555
  async function verifiedCertificateAnchor(
509
556
  crypto: CryptoOps,
@@ -513,21 +560,15 @@ async function verifiedCertificateAnchor(
513
560
  const anchor = certificateAnchor(certificate);
514
561
  if (anchor === null) return null;
515
562
 
516
- const transparency = certificate.transparency as Record<string, unknown>;
517
- const sth = transparency.signed_tree_head as Record<string, unknown>;
518
- // Only a refusal of the head's own fields means "no anchor". A bare catch
519
- // here gave a defect in this library — or a crypto provider that threw — the
520
- // same quiet answer as a malformed document.
521
- let headPayload: Uint8Array;
522
- let headSig: Uint8Array;
563
+ // Only a refusal of the proof means "no anchor". A defect in this library, or
564
+ // a crypto provider that threw, must not get the same quiet answer as a
565
+ // document whose inclusion proof does not verify.
523
566
  try {
524
- headPayload = buildTreeHeadPayload(sth);
525
- headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
567
+ await checkCertificateInclusion(crypto, certificate, pki);
526
568
  } catch (e) {
527
569
  if (e instanceof VerificationError) return null;
528
570
  throw e;
529
571
  }
530
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
531
572
  return anchor;
532
573
  }
533
574
 
@@ -949,15 +990,49 @@ export async function verifyTransparency(
949
990
  return "NOT_AVAILABLE" as TransparencyResult;
950
991
  }
951
992
  const transparency = objectAt(certificate, "transparency", "transparency");
952
-
953
- const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
993
+ // Read before the key lookup, as it always was: a proof with no head is
994
+ // reported as that, not as a key the reader does not hold.
995
+ objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
954
996
  const issuer = objectAt(certificate, "issuer", "issuer");
955
997
  const keyId = stringAt(issuer, "key_id", "issuer.key_id");
956
998
 
957
999
  const pki = publicKeys.get(keyId);
958
1000
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
959
- // The tree head carries its own timestamp, so this path always has an anchor.
960
- requireUsableKey(pki, certificateAnchor(certificate), keyId);
1001
+ await verifyTransparencyHead(crypto, transparency, pki, keyId);
1002
+
1003
+ // The leaf is NOT the certificate. It is the canonical log_leaf.v3 payload
1004
+ // over entry_type, certificate_id, certificate_hash and appended_at, where
1005
+ // certificate_hash is SHA-256 of the issuance-time certificate JSON
1006
+ // (ADR-016 §4). v2 hashed the certificate directly, so an issuance and a
1007
+ // revocation of the same certificate produced identical leaves and the tree
1008
+ // committed to neither the entry type nor when it happened.
1009
+ const issuanceData = issuanceBytes(certificate);
1010
+ const leafPayload = buildLogLeafPayload(
1011
+ transparency.entry_type,
1012
+ stringAt(certificate, "certificate_id", "certificate_id"),
1013
+ await crypto.sha256(issuanceData),
1014
+ transparency.appended_at,
1015
+ );
1016
+ await verifyTransparencyInclusion(crypto, transparency, await hashLeaf(crypto, leafPayload));
1017
+
1018
+ return "INCLUDED" as TransparencyResult;
1019
+ }
1020
+
1021
+ /**
1022
+ * Verify the head signature and the Merkle inclusion of a certificate's proof —
1023
+ * everything about whether this record is committed to under its head, but NOT
1024
+ * whether the key was usable when it signed. That last question is the caller's;
1025
+ * certificateAnchor needs membership, not authority, and folding authority in
1026
+ * here made the anchor for an out-of-window key collapse to "no anchor" and the
1027
+ * verdict soften. Throws VerificationError on any inclusion failure.
1028
+ */
1029
+ async function checkCertificateInclusion(
1030
+ crypto: CryptoOps,
1031
+ certificate: Cert,
1032
+ pki: PublicKeyInfo,
1033
+ ): Promise<void> {
1034
+ const transparency = objectAt(certificate, "transparency", "transparency");
1035
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
961
1036
 
962
1037
  // 1. Tree head signature
963
1038
  const headPayload = buildTreeHeadPayload(sth);
@@ -1028,8 +1103,115 @@ export async function verifyTransparency(
1028
1103
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
1029
1104
  throw new VerificationError("merkle inclusion proof is invalid");
1030
1105
  }
1106
+ }
1031
1107
 
1032
- return "INCLUDED" as TransparencyResult;
1108
+ /** The first half of a transparency check, shared with the run record's
1109
+ * (run-record.ts): the key's authority at the head's own timestamp, then the
1110
+ * head's signature. The tree head carries its own timestamp, so this path
1111
+ * always has an anchor; a timestamp that does not read leaves none, which is
1112
+ * the same conservative answer as no proof at all. Throws the VerificationError
1113
+ * the caller reports. Exported as an internal seam, not from index.ts. */
1114
+ export async function verifyTransparencyHead(
1115
+ crypto: CryptoOps,
1116
+ transparency: Doc,
1117
+ pki: PublicKeyInfo,
1118
+ keyId: string,
1119
+ ): Promise<void> {
1120
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
1121
+ let anchor: number | null = null;
1122
+ if (typeof sth.timestamp === "string") {
1123
+ try {
1124
+ anchor = parseRfc3339(sth.timestamp);
1125
+ } catch {
1126
+ anchor = null;
1127
+ }
1128
+ }
1129
+ requireUsableKey(pki, anchor, keyId);
1130
+
1131
+ const headPayload = buildTreeHeadPayload(sth);
1132
+ const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
1133
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
1134
+ throw new VerificationError("tree head signature is invalid");
1135
+ }
1136
+ }
1137
+
1138
+ /** The second half: an already-hashed leaf against the signed head, after the
1139
+ * unsigned duplicates beside the proof are held to the signed copies. Throws
1140
+ * the VerificationError the caller reports. Exported as an internal seam. */
1141
+ export async function verifyTransparencyInclusion(
1142
+ crypto: CryptoOps,
1143
+ transparency: Doc,
1144
+ leaf: Uint8Array,
1145
+ ): Promise<void> {
1146
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
1147
+ const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map(
1148
+ (h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32),
1149
+ );
1150
+ const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
1151
+ // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
1152
+ // for an explicit null and for an absent key rather than failing, so refusing
1153
+ // either would reject documents the reference accepts — the same class of
1154
+ // divergence as #452. An offline verifier is only useful while it agrees.
1155
+ const index = requireUint(transparency.entry_index ?? 0, "entry_index");
1156
+ const treeSize = requireUint(sth.tree_size ?? 0, "signed_tree_head.tree_size");
1157
+
1158
+ // transparency.tree_size duplicates the signed one but carries no signature:
1159
+ // BuildTreeHeadPayload covers only the copy inside signed_tree_head. Go used
1160
+ // to verify against the unsigned copy while this SDK used the signed one, so
1161
+ // the same document got two verdicts (#456). Both now require the two to agree
1162
+ // and then verify against the signed copy — the server always writes them
1163
+ // equal, so this rejects only edited documents.
1164
+ const unsignedSize = requireUint(
1165
+ transparency.tree_size ?? 0,
1166
+ "transparency.tree_size",
1167
+ );
1168
+ if (unsignedSize !== treeSize) {
1169
+ throw new VerificationError(
1170
+ `transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
1171
+ `(${treeSize}); it is not covered by any signature`,
1172
+ );
1173
+ }
1174
+
1175
+ // Same rule for the log id (Q31). transparency.log_id sits beside log_url and
1176
+ // is the field a reader looks at to answer "which log is this?", and nothing
1177
+ // signs it — so a holder could relabel a genuine proof as belonging to a
1178
+ // different log while every signature still checked out. A head predating log
1179
+ // ids has neither side set and passes.
1180
+ const signedLogId = (sth.log_id as string | undefined) ?? "";
1181
+ const unsignedLogId = (transparency.log_id as string | undefined) ?? "";
1182
+ if (unsignedLogId !== signedLogId) {
1183
+ throw new VerificationError(
1184
+ `transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
1185
+ `tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`,
1186
+ );
1187
+ }
1188
+
1189
+ if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
1190
+ throw new VerificationError("merkle inclusion proof is invalid");
1191
+ }
1192
+ }
1193
+
1194
+ /** The refusals of verifyTransparencyHead and verifyTransparencyInclusion under
1195
+ * core.TransparencyResult's names, for the verifiers that report a pack or a
1196
+ * run record beside Go's verdicts rather than by throwing. The key verdicts
1197
+ * collapse to KEY_UNUSABLE as they do in Go, whose transparency result does not
1198
+ * say which key problem. Anything unmatched is a field this SDK could not read:
1199
+ * UNREADABLE_DOCUMENT, never a tampering claim. */
1200
+ const TRANSPARENCY_VERDICTS: ReadonlyArray<[prefix: string, verdict: string]> = [
1201
+ ["issuer key is compromised:", "KEY_UNUSABLE"],
1202
+ ["issuer key was not valid when it signed:", "KEY_UNUSABLE"],
1203
+ ["issuer key has an unusable status", "KEY_UNUSABLE"],
1204
+ ["tree head signature is invalid", "INVALID_TREE_HEAD_SIGNATURE"],
1205
+ ["transparency.tree_size (", "INVALID_INCLUSION_PROOF"],
1206
+ ["transparency.log_id (", "INVALID_INCLUSION_PROOF"],
1207
+ ["merkle inclusion proof is invalid", "INVALID_INCLUSION_PROOF"],
1208
+ ];
1209
+
1210
+ export function transparencyVerdictName(e: VerificationError): string {
1211
+ for (const [prefix, verdict] of TRANSPARENCY_VERDICTS) {
1212
+ if (e.message.startsWith(prefix)) return verdict;
1213
+ }
1214
+ return "UNREADABLE_DOCUMENT";
1033
1215
  }
1034
1216
 
1035
1217
  // ---------------------------------------------------------------------------
@@ -1139,6 +1321,43 @@ function arrayAt(parent: Doc, key: string, path: string): unknown[] {
1139
1321
  return Array.from(value);
1140
1322
  }
1141
1323
 
1324
+ /** A UUID in the forms Go's uuid.Parse accepts: 8-4-4-4-12 hex, the same in
1325
+ * braces or behind "urn:uuid:", or 32 bare hex digits. */
1326
+ 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})\}?$/;
1327
+ const UUID_BARE_RE = /^[0-9a-fA-F]{32}$/;
1328
+
1329
+ /** The 16 bytes of a UUID, in any form Go's uuid.Parse reads. Throws TypeError
1330
+ * for anything else: a UUID reaches a signed payload only as its canonical
1331
+ * string, and an unreadable one must not be signed as whatever text arrived. */
1332
+ export function parseUuid(text: string): Uint8Array {
1333
+ if (typeof text !== "string") throw new TypeError(`uuid is not a string: ${typeof text}`);
1334
+ const m = UUID_RE.exec(text);
1335
+ const hex = m !== null ? m.slice(1).join("") : UUID_BARE_RE.test(text) ? text : null;
1336
+ if (hex === null) throw new TypeError(`not a UUID: ${JSON.stringify(text)}`);
1337
+ return hexToBytes(hex);
1338
+ }
1339
+
1340
+ /** The canonical lowercase 8-4-4-4-12 form, as Go's uuid.UUID.String prints:
1341
+ * the only form a UUID takes inside a signed payload. */
1342
+ export function formatUuid(bytes: Uint8Array): string {
1343
+ if (bytes.length !== 16) throw new TypeError(`uuid is ${bytes.length} bytes, expected 16`);
1344
+ const h = bytesToHex(bytes);
1345
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1346
+ }
1347
+
1348
+ /** Read a UUID field as Go's uuid.UUID unmarshals it, refusing with a
1349
+ * VerificationError what encoding/json would refuse to parse. */
1350
+ function uuidAt(parent: Doc, key: string, path: string): string {
1351
+ const v = parent[key];
1352
+ if (typeof v !== "string") throw new VerificationError(`${path} is not a string`);
1353
+ try {
1354
+ return formatUuid(parseUuid(v));
1355
+ } catch (e) {
1356
+ if (e instanceof TypeError) throw new VerificationError(`${path} is not a UUID: ${JSON.stringify(v)}`);
1357
+ throw e;
1358
+ }
1359
+ }
1360
+
1142
1361
  const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
1143
1362
  const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1144
1363
 
@@ -1155,7 +1374,9 @@ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/
1155
1374
  * leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
1156
1375
  * signature covers and blamed the signature — or threw from inside atob.
1157
1376
  */
1158
- function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
1377
+ /** Exported for run-record.ts, whose digests and roots arrive in the same
1378
+ * shapes; not published from index.ts. */
1379
+ export function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
1159
1380
  if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
1160
1381
  let raw: Uint8Array;
1161
1382
  if (Array.isArray(value)) {
@@ -1430,6 +1651,7 @@ function requireMeasured(
1430
1651
  /** Domain separator for the record itself, by certificate format version. */
1431
1652
  function certificatePayloadType(version: string): string {
1432
1653
  checkFormatVersion(version);
1654
+ if (version === FORMAT_VERSION_V10) return PAYLOAD_TYPE_VERIFICATION_RECORD_V10;
1433
1655
  if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_VERIFICATION_RECORD_V9;
1434
1656
  if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_VERIFICATION_RECORD_V8;
1435
1657
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
@@ -1450,6 +1672,7 @@ function attestationPayloadType(version: string): string {
1450
1672
  // Equality here, deliberately: each format has its OWN separator, so this is
1451
1673
  // a lookup rather than a "this version onward" question. v3 and v4 share one
1452
1674
  // because their attestation bytes are identical.
1675
+ if (version === FORMAT_VERSION_V10) return PAYLOAD_TYPE_ATTESTATION_V10;
1453
1676
  if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_ATTESTATION_V9;
1454
1677
  if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_ATTESTATION_V8;
1455
1678
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
@@ -1606,17 +1829,33 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1606
1829
  version: stringAt(scope, "version", "scope.version"),
1607
1830
  };
1608
1831
  }
1832
+ // Signed from v10 when present. On an older format the field is refused
1833
+ // rather than dropped from the bytes: core.BuildCertificatePayload returns an
1834
+ // error there and the verdict is INVALID_CERTIFICATE_SIGNATURE, so the same
1835
+ // words open this message for the audit pack verifier to read it back as
1836
+ // that verdict. A JSON null is an absent run, as Go's *uuid.UUID reads it.
1837
+ if (cert.run_id != null) {
1838
+ if (!signatureCoversRunId(version)) {
1839
+ throw new VerificationError(
1840
+ `certificate signature is invalid: the record carries run_id, which format ${version} does not ` +
1841
+ `sign (run_id is signed from ${FORMAT_VERSION_V10}), so no signature covers the document as presented`,
1842
+ );
1843
+ }
1844
+ payload.run_id = uuidAt(cert, "run_id", "run_id");
1845
+ }
1609
1846
  return canonicalJson(payload);
1610
1847
  }
1611
1848
 
1612
- /** Port of Go's BuildLogLeafPayload (ADR-016 §4). */
1613
- function buildLogLeafPayload(
1849
+ /** Port of Go's BuildLogLeafPayload (ADR-016 §4). A RUN_RECORD leaf names its
1850
+ * run id where the other two name a certificate id, under the same key
1851
+ * (ADR-031). Exported for run-record.ts; not published from index.ts. */
1852
+ export function buildLogLeafPayload(
1614
1853
  entryType: unknown,
1615
1854
  certificateId: string,
1616
1855
  certificateHash: Uint8Array,
1617
1856
  appendedAt: unknown,
1618
1857
  ): Uint8Array {
1619
- if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION") {
1858
+ if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION" && entryType !== "RUN_RECORD") {
1620
1859
  throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
1621
1860
  }
1622
1861
  const payload = {
@@ -1687,10 +1926,21 @@ export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array
1687
1926
  // Merkle tree (RFC 6962) — ports of core/merkle.go
1688
1927
  // ---------------------------------------------------------------------------
1689
1928
 
1690
- async function hashLeaf(crypto: CryptoOps, data: Uint8Array): Promise<Uint8Array> {
1929
+ /** Exported for run-record.ts, whose roots are trees over these leaves. */
1930
+ export async function hashLeaf(crypto: CryptoOps, data: Uint8Array): Promise<Uint8Array> {
1691
1931
  return crypto.sha256(concatBytes(new Uint8Array([0x00]), data));
1692
1932
  }
1693
1933
 
1934
+ /** The RFC 6962 root over already-hashed leaves, in the order given. Port of
1935
+ * Go's treeHash, which like it takes at least one leaf: the empty tree has no
1936
+ * root here, and a caller with nothing to commit to says so itself. */
1937
+ export async function merkleRoot(crypto: CryptoOps, leaves: Uint8Array[]): Promise<Uint8Array> {
1938
+ if (leaves.length === 0) throw new RangeError("merkleRoot: no leaves");
1939
+ if (leaves.length === 1) return leaves[0]!;
1940
+ const k = splitPoint(leaves.length);
1941
+ return hashNode(crypto, await merkleRoot(crypto, leaves.slice(0, k)), await merkleRoot(crypto, leaves.slice(k)));
1942
+ }
1943
+
1694
1944
  async function hashNode(crypto: CryptoOps, left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
1695
1945
  const prefix = new Uint8Array([0x01]);
1696
1946
  return crypto.sha256(concatBytes(prefix, concatBytes(left, right)));
@@ -1763,7 +2013,12 @@ async function chainInclusion(
1763
2013
  }
1764
2014
 
1765
2015
  function isPow2(n: number): boolean {
1766
- return n > 0 && (n & (n - 1)) === 0;
2016
+ // Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
2017
+ // signed integers, so for a tree size >= 2^31 the bit test gives the wrong
2018
+ // answer. This holds for every safe integer.
2019
+ if (n < 1) return false;
2020
+ while (n % 2 === 0) n /= 2;
2021
+ return n === 1;
1767
2022
  }
1768
2023
 
1769
2024
  /** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
@@ -1800,9 +2055,14 @@ export async function verifyConsistency(
1800
2055
  let fn = oldSize - 1;
1801
2056
  let sn = newSize - 1;
1802
2057
 
1803
- while ((fn & 1) === 1) {
1804
- fn >>= 1;
1805
- sn >>= 1;
2058
+ // Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
2059
+ // integers, so for tree sizes >= 2^31 fn and sn are truncated and this
2060
+ // consistency check silently returned the wrong answer (CONFIRMED where Go
2061
+ // says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
2062
+ // integer, which requireUint already bounds the inputs to.
2063
+ while (fn % 2 === 1) {
2064
+ fn = Math.floor(fn / 2);
2065
+ sn = Math.floor(sn / 2);
1806
2066
  }
1807
2067
 
1808
2068
  // Drive the walk from the tree, not from the proof's length. Looping on
@@ -1817,19 +2077,19 @@ export async function verifyConsistency(
1817
2077
  const c = proof[pIdx]!;
1818
2078
  pIdx++;
1819
2079
 
1820
- if ((fn & 1) === 1 || fn === sn) {
2080
+ if (fn % 2 === 1 || fn === sn) {
1821
2081
  fr = await hashNode(crypto, c, fr);
1822
2082
  sr = await hashNode(crypto, c, sr);
1823
- while (fn !== 0 && (fn & 1) === 0) {
1824
- fn >>= 1;
1825
- sn >>= 1;
2083
+ while (fn !== 0 && fn % 2 === 0) {
2084
+ fn = Math.floor(fn / 2);
2085
+ sn = Math.floor(sn / 2);
1826
2086
  }
1827
2087
  } else {
1828
2088
  sr = await hashNode(crypto, sr, c);
1829
2089
  }
1830
2090
 
1831
- fn >>= 1;
1832
- sn >>= 1;
2091
+ fn = Math.floor(fn / 2);
2092
+ sn = Math.floor(sn / 2);
1833
2093
  }
1834
2094
 
1835
2095
  return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
package/src/webhooks.ts CHANGED
@@ -16,14 +16,32 @@
16
16
 
17
17
  import { createHmac, timingSafeEqual } from "node:crypto";
18
18
 
19
+ /** Strictly hex-decode `value`, or undefined if it is not even-length pure hex.
20
+ * Buffer.from(_, "hex") silently truncates at the first non-hex character, which
21
+ * would decode only the first signature of a rotation header — so the input is
22
+ * validated as pure hex first. */
23
+ function decodeHexStrict(value: string): Buffer | undefined {
24
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) {
25
+ return undefined;
26
+ }
27
+ return Buffer.from(value, "hex");
28
+ }
29
+
19
30
  /**
20
31
  * Verify that a webhook payload was signed by the expected secret.
21
32
  *
33
+ * During a secret rotation the server sends both signatures in one header as
34
+ * `<old>,<new>` for a 24 h window, so whichever secret a receiver holds matches
35
+ * one of them without dropping deliveries. The header is split on the comma,
36
+ * each part is trimmed and strictly hex-decoded, and it verifies if any part
37
+ * matches under `secret`. A single signature (no comma) is the ordinary case and
38
+ * one iteration of the same loop.
39
+ *
22
40
  * @param secret The webhook secret (raw UTF-8, as returned by the API)
23
41
  * @param timestamp The RFC3339 timestamp from the X-BurnLedger-Timestamp header
24
42
  * @param body The raw request body bytes
25
- * @param signature The hex-encoded HMAC-SHA256 signature from the X-BurnLedger-Signature header
26
- * @returns true if the signature is valid
43
+ * @param signature The hex-encoded HMAC-SHA256 signature(s) from the X-BurnLedger-Signature header
44
+ * @returns true if any signature in the header is valid
27
45
  */
28
46
  export function verifyWebhookSignature(
29
47
  secret: string,
@@ -44,16 +62,15 @@ export function verifyWebhookSignature(
44
62
  .update(bodyBytes)
45
63
  .digest();
46
64
 
47
- let received: Buffer;
48
- try {
49
- received = Buffer.from(signature, "hex");
50
- } catch {
51
- return false;
65
+ // Every candidate is compared in constant time; the loop does not return early
66
+ // on a match so the work does not reveal which position matched.
67
+ let matched = false;
68
+ for (const part of signature.split(",")) {
69
+ const received = decodeHexStrict(part.trim());
70
+ if (received === undefined || received.length !== expected.length) continue;
71
+ if (timingSafeEqual(expected, received)) matched = true;
52
72
  }
53
-
54
- if (received.length !== expected.length) return false;
55
-
56
- return timingSafeEqual(expected, received);
73
+ return matched;
57
74
  }
58
75
 
59
76
  /**