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.
- package/README.md +9 -4
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +49 -59
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-registration.d.ts +14 -2
- package/dist/cjs/enclave-registration.d.ts.map +1 -1
- package/dist/cjs/enclave-registration.js +14 -2
- package/dist/cjs/enclave-registration.js.map +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/node-runtime.d.ts +40 -0
- package/dist/cjs/node-runtime.d.ts.map +1 -0
- package/dist/cjs/node-runtime.js +42 -0
- package/dist/cjs/node-runtime.js.map +1 -0
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +72 -35
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/webhooks.d.ts +9 -2
- package/dist/cjs/webhooks.d.ts.map +1 -1
- package/dist/cjs/webhooks.js +29 -11
- package/dist/cjs/webhooks.js.map +1 -1
- package/dist/esm/cli.d.ts +19 -0
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +58 -6
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +49 -26
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-registration.d.ts +14 -2
- package/dist/esm/enclave-registration.d.ts.map +1 -1
- package/dist/esm/enclave-registration.js +14 -2
- package/dist/esm/enclave-registration.js.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/node-runtime.d.ts +40 -0
- package/dist/esm/node-runtime.d.ts.map +1 -0
- package/dist/esm/node-runtime.js +38 -0
- package/dist/esm/node-runtime.js.map +1 -0
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +72 -35
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/webhooks.d.ts +9 -2
- package/dist/esm/webhooks.d.ts.map +1 -1
- package/dist/esm/webhooks.js +29 -11
- package/dist/esm/webhooks.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +55 -5
- package/src/client.ts +50 -26
- package/src/enclave-registration.ts +14 -2
- package/src/index.ts +13 -0
- package/src/node-runtime.ts +57 -0
- package/src/verify.ts +76 -36
- 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
|
|
495
|
-
*
|
|
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,
|
|
498
|
-
* KEY_COMPROMISED with no anchor and
|
|
499
|
-
* anchor predates the compromise — and the
|
|
500
|
-
* passes through as the RESULT of
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
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
|
-
*
|
|
506
|
-
*
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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 (
|
|
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 &&
|
|
1824
|
-
fn
|
|
1825
|
-
sn
|
|
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
|
|
1832
|
-
sn
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
/**
|