burnledger 0.9.0 → 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 (55) hide show
  1. package/README.md +9 -4
  2. package/dist/cjs/client.d.ts.map +1 -1
  3. package/dist/cjs/client.js +49 -59
  4. package/dist/cjs/client.js.map +1 -1
  5. package/dist/cjs/enclave-registration.d.ts +14 -2
  6. package/dist/cjs/enclave-registration.d.ts.map +1 -1
  7. package/dist/cjs/enclave-registration.js +14 -2
  8. package/dist/cjs/enclave-registration.js.map +1 -1
  9. package/dist/cjs/index.d.ts.map +1 -1
  10. package/dist/cjs/index.js +45 -0
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/node-runtime.d.ts +40 -0
  13. package/dist/cjs/node-runtime.d.ts.map +1 -0
  14. package/dist/cjs/node-runtime.js +42 -0
  15. package/dist/cjs/node-runtime.js.map +1 -0
  16. package/dist/cjs/verify.d.ts.map +1 -1
  17. package/dist/cjs/verify.js +72 -35
  18. package/dist/cjs/verify.js.map +1 -1
  19. package/dist/cjs/webhooks.d.ts +9 -2
  20. package/dist/cjs/webhooks.d.ts.map +1 -1
  21. package/dist/cjs/webhooks.js +29 -11
  22. package/dist/cjs/webhooks.js.map +1 -1
  23. package/dist/esm/cli.d.ts +19 -0
  24. package/dist/esm/cli.d.ts.map +1 -1
  25. package/dist/esm/cli.js +58 -6
  26. package/dist/esm/cli.js.map +1 -1
  27. package/dist/esm/client.d.ts.map +1 -1
  28. package/dist/esm/client.js +49 -26
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/enclave-registration.d.ts +14 -2
  31. package/dist/esm/enclave-registration.d.ts.map +1 -1
  32. package/dist/esm/enclave-registration.js +14 -2
  33. package/dist/esm/enclave-registration.js.map +1 -1
  34. package/dist/esm/index.d.ts.map +1 -1
  35. package/dist/esm/index.js +12 -0
  36. package/dist/esm/index.js.map +1 -1
  37. package/dist/esm/node-runtime.d.ts +40 -0
  38. package/dist/esm/node-runtime.d.ts.map +1 -0
  39. package/dist/esm/node-runtime.js +38 -0
  40. package/dist/esm/node-runtime.js.map +1 -0
  41. package/dist/esm/verify.d.ts.map +1 -1
  42. package/dist/esm/verify.js +72 -35
  43. package/dist/esm/verify.js.map +1 -1
  44. package/dist/esm/webhooks.d.ts +9 -2
  45. package/dist/esm/webhooks.d.ts.map +1 -1
  46. package/dist/esm/webhooks.js +29 -11
  47. package/dist/esm/webhooks.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/cli.ts +55 -5
  50. package/src/client.ts +50 -26
  51. package/src/enclave-registration.ts +14 -2
  52. package/src/index.ts +13 -0
  53. package/src/node-runtime.ts +57 -0
  54. package/src/verify.ts +76 -36
  55. package/src/webhooks.ts +28 -11
package/src/verify.ts CHANGED
@@ -447,6 +447,18 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
447
447
  if (pki.revoked) return "UNKNOWN_KEY";
448
448
  if (pki.keyStatus === "compromised") {
449
449
  if (anchor === null || pki.compromisedFrom === undefined) return KEY_COMPROMISED;
450
+ // The validity window is tested before the compromise anchor (R03-1). A
451
+ // signature made outside the stated interval was never authorized, whatever
452
+ // the key's later disposition, so an anchor outside the window is
453
+ // KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
454
+ // window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
455
+ // the window alone already refuses.
456
+ if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
457
+ return KEY_OUTSIDE_VALIDITY;
458
+ }
459
+ if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
460
+ return KEY_OUTSIDE_VALIDITY;
461
+ }
450
462
  return anchor < parseRfc3339(pki.compromisedFrom)
451
463
  ? VALID_KEY_COMPROMISED_LATER
452
464
  : KEY_COMPROMISED;
@@ -491,19 +503,28 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
491
503
  }
492
504
 
493
505
  /**
494
- * The anchor, but only once the tree head carrying it has been checked against
495
- * the key being evaluated.
506
+ * The anchor, but only once this record has been PROVEN to be in the tree head
507
+ * carrying it — its inclusion proof verified against that head, not merely the
508
+ * head's own signature checked.
496
509
  *
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.
510
+ * The bypass this closes (R12-1/R14-1): for a key declared compromised,
511
+ * evaluateKey answers KEY_COMPROMISED with no anchor and
512
+ * VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
513
+ * second is a verdict requireUsableKey passes through as the RESULT of
514
+ * verifyCertificate. A signed tree head is a public artifact: any genuine older
515
+ * head the issuer ever published can be stapled onto a record it never
516
+ * contained, and its signature still verifies. Checking only the head signature
517
+ * let a holder of a certificate signed after the compromise attach a
518
+ * pre-compromise head and have the forgery reported as verified-with-a-caveat.
519
+ * The head's timestamp is an honest anchor for THIS record only once THIS record
520
+ * is shown to be committed to under it.
504
521
  *
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.
522
+ * checkCertificateInclusion is exactly that proof; it deliberately does NOT test
523
+ * the key's usability at head time — that is the verdict this anchor exists to
524
+ * compute, so gating the anchor on it would drop the anchor for an out-of-window
525
+ * key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
526
+ * of a held inclusion proof yields no anchor, the same conservative answer as a
527
+ * certificate with no transparency block at all.
507
528
  */
508
529
  async function verifiedCertificateAnchor(
509
530
  crypto: CryptoOps,
@@ -513,21 +534,15 @@ async function verifiedCertificateAnchor(
513
534
  const anchor = certificateAnchor(certificate);
514
535
  if (anchor === null) return null;
515
536
 
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;
537
+ // Only a refusal of the proof means "no anchor". A defect in this library, or
538
+ // a crypto provider that threw, must not get the same quiet answer as a
539
+ // document whose inclusion proof does not verify.
523
540
  try {
524
- headPayload = buildTreeHeadPayload(sth);
525
- headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
541
+ await checkCertificateInclusion(crypto, certificate, pki);
526
542
  } catch (e) {
527
543
  if (e instanceof VerificationError) return null;
528
544
  throw e;
529
545
  }
530
- if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
531
546
  return anchor;
532
547
  }
533
548
 
@@ -948,9 +963,6 @@ export async function verifyTransparency(
948
963
  if (certificate.transparency == null) {
949
964
  return "NOT_AVAILABLE" as TransparencyResult;
950
965
  }
951
- const transparency = objectAt(certificate, "transparency", "transparency");
952
-
953
- const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
954
966
  const issuer = objectAt(certificate, "issuer", "issuer");
955
967
  const keyId = stringAt(issuer, "key_id", "issuer.key_id");
956
968
 
@@ -959,6 +971,26 @@ export async function verifyTransparency(
959
971
  // The tree head carries its own timestamp, so this path always has an anchor.
960
972
  requireUsableKey(pki, certificateAnchor(certificate), keyId);
961
973
 
974
+ await checkCertificateInclusion(crypto, certificate, pki);
975
+ return "INCLUDED" as TransparencyResult;
976
+ }
977
+
978
+ /**
979
+ * Verify the head signature and the Merkle inclusion of a certificate's proof —
980
+ * everything about whether this record is committed to under its head, but NOT
981
+ * whether the key was usable when it signed. That last question is the caller's;
982
+ * certificateAnchor needs membership, not authority, and folding authority in
983
+ * here made the anchor for an out-of-window key collapse to "no anchor" and the
984
+ * verdict soften. Throws VerificationError on any inclusion failure.
985
+ */
986
+ async function checkCertificateInclusion(
987
+ crypto: CryptoOps,
988
+ certificate: Cert,
989
+ pki: PublicKeyInfo,
990
+ ): Promise<void> {
991
+ const transparency = objectAt(certificate, "transparency", "transparency");
992
+ const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
993
+
962
994
  // 1. Tree head signature
963
995
  const headPayload = buildTreeHeadPayload(sth);
964
996
  const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
@@ -1028,8 +1060,6 @@ export async function verifyTransparency(
1028
1060
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
1029
1061
  throw new VerificationError("merkle inclusion proof is invalid");
1030
1062
  }
1031
-
1032
- return "INCLUDED" as TransparencyResult;
1033
1063
  }
1034
1064
 
1035
1065
  // ---------------------------------------------------------------------------
@@ -1763,7 +1793,12 @@ async function chainInclusion(
1763
1793
  }
1764
1794
 
1765
1795
  function isPow2(n: number): boolean {
1766
- return n > 0 && (n & (n - 1)) === 0;
1796
+ // Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
1797
+ // signed integers, so for a tree size >= 2^31 the bit test gives the wrong
1798
+ // answer. This holds for every safe integer.
1799
+ if (n < 1) return false;
1800
+ while (n % 2 === 0) n /= 2;
1801
+ return n === 1;
1767
1802
  }
1768
1803
 
1769
1804
  /** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
@@ -1800,9 +1835,14 @@ export async function verifyConsistency(
1800
1835
  let fn = oldSize - 1;
1801
1836
  let sn = newSize - 1;
1802
1837
 
1803
- while ((fn & 1) === 1) {
1804
- fn >>= 1;
1805
- sn >>= 1;
1838
+ // Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
1839
+ // integers, so for tree sizes >= 2^31 fn and sn are truncated and this
1840
+ // consistency check silently returned the wrong answer (CONFIRMED where Go
1841
+ // says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
1842
+ // integer, which requireUint already bounds the inputs to.
1843
+ while (fn % 2 === 1) {
1844
+ fn = Math.floor(fn / 2);
1845
+ sn = Math.floor(sn / 2);
1806
1846
  }
1807
1847
 
1808
1848
  // Drive the walk from the tree, not from the proof's length. Looping on
@@ -1817,19 +1857,19 @@ export async function verifyConsistency(
1817
1857
  const c = proof[pIdx]!;
1818
1858
  pIdx++;
1819
1859
 
1820
- if ((fn & 1) === 1 || fn === sn) {
1860
+ if (fn % 2 === 1 || fn === sn) {
1821
1861
  fr = await hashNode(crypto, c, fr);
1822
1862
  sr = await hashNode(crypto, c, sr);
1823
- while (fn !== 0 && (fn & 1) === 0) {
1824
- fn >>= 1;
1825
- sn >>= 1;
1863
+ while (fn !== 0 && fn % 2 === 0) {
1864
+ fn = Math.floor(fn / 2);
1865
+ sn = Math.floor(sn / 2);
1826
1866
  }
1827
1867
  } else {
1828
1868
  sr = await hashNode(crypto, sr, c);
1829
1869
  }
1830
1870
 
1831
- fn >>= 1;
1832
- sn >>= 1;
1871
+ fn = Math.floor(fn / 2);
1872
+ sn = Math.floor(sn / 2);
1833
1873
  }
1834
1874
 
1835
1875
  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
  /**