@blamejs/pki 0.1.27 → 0.1.29
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/CHANGELOG.md +22 -1
- package/README.md +1 -0
- package/index.js +7 -0
- package/lib/asn1-der.js +12 -2
- package/lib/cbor-det.js +14 -2
- package/lib/constants.js +11 -0
- package/lib/ct.js +13 -6
- package/lib/framework-error.js +17 -4
- package/lib/merkle.js +345 -0
- package/lib/webcrypto.js +23 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,28 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## v0.1.
|
|
7
|
+
## v0.1.29 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
A detached-backed BufferSource now fails closed with a typed error at every byte-input boundary.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- pki.webcrypto digest / sign / verify no longer silently process a detached-backed Buffer as EMPTY input (a fail-open where a transferred backing ArrayBuffer left the view zero-length); a detached BufferSource is now rejected with a typed webcrypto/data error, as is getRandomValues.
|
|
14
|
+
- pki.asn1.decode, pki.cbor.decode, and pki.ct.parseSctList reject a detached-backed Buffer or view with a typed error (asn1/not-buffer, cbor/not-buffer, ct/bad-input) instead of a raw TypeError or a misleading truncated-input verdict. The underlying byte-view failure is threaded as the error cause.
|
|
15
|
+
|
|
16
|
+
## v0.1.28 — 2026-07-10
|
|
17
|
+
|
|
18
|
+
Merkle transparency proof verification joins the toolkit as pki.merkle.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- pki.merkle.leafHash / nodeHash / emptyRootHash -- the RFC 6962 / RFC 9162 tree hashes: a leaf is SHA-256(0x00 || entry), an interior node is SHA-256(0x01 || left || right), the empty tree is SHA-256(""). The domain-separation prefixes are applied unconditionally.
|
|
23
|
+
- pki.merkle.verifyInclusion({ leafIndex, treeSize, leafHash, proof, rootHash }) -- verify an RFC 6962 / RFC 9162 audit proof by folding the leaf up the audit path and constant-time-comparing the reconstructed root to a trusted root. Returns true only when the proof binds the leaf to the root.
|
|
24
|
+
- pki.merkle.verifyConsistency({ oldSize, newSize, oldRoot, newRoot, proof }) -- verify an append-only consistency proof by reconstructing BOTH the old and the new root and constant-time-comparing each; the append-only guarantee lives in the old-root leg.
|
|
25
|
+
- The error taxonomy gains MerkleError (merkle/*). A node-count ceiling (C.LIMITS.MERKLE_MAX_PROOF_NODES) rejects a pathologically long proof before any hashing; the precise per-proof guard is the geometry check in each verifier.
|
|
26
|
+
- Fuzz target merkle-verify (both fold algorithms and the hash producers over adversarial coordinates, hashes, and proofs) joins the per-PR and nightly fuzz matrices with a seed corpus.
|
|
27
|
+
|
|
28
|
+
## v0.1.27 — 2026-07-10
|
|
8
29
|
|
|
9
30
|
A strict deterministic-CBOR codec joins the toolkit as pki.cbor.
|
|
10
31
|
|
package/README.md
CHANGED
|
@@ -222,6 +222,7 @@ is callable today; nothing below is a stub.
|
|
|
222
222
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
223
223
|
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes; `crlChecker` supplies CRL-based revocation. Pure and re-entrant, fail-closed — `validate`, `crlChecker` |
|
|
224
224
|
| `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
|
|
225
|
+
| `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
|
225
226
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
226
227
|
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError`, each carrying a stable `code` in `domain/reason` form |
|
|
227
228
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
package/index.js
CHANGED
|
@@ -36,6 +36,7 @@ var webcrypto = require("./lib/webcrypto");
|
|
|
36
36
|
var schema = require("./lib/schema-all");
|
|
37
37
|
var path = require("./lib/path-validate");
|
|
38
38
|
var ct = require("./lib/ct");
|
|
39
|
+
var merkle = require("./lib/merkle");
|
|
39
40
|
var est = require("./lib/est");
|
|
40
41
|
var jose = require("./lib/jose");
|
|
41
42
|
var acme = require("./lib/acme");
|
|
@@ -65,6 +66,12 @@ module.exports = {
|
|
|
65
66
|
// SCT-list extension a certificate / OCSP response carries; the signature is
|
|
66
67
|
// surfaced raw for external verification (pki.ct.reconstructSignedData).
|
|
67
68
|
ct: ct,
|
|
69
|
+
// `merkle` is the RFC 6962 / RFC 9162 Merkle-tree proof-verification core --
|
|
70
|
+
// pki.merkle.leafHash / nodeHash / emptyRootHash build the domain-separated
|
|
71
|
+
// (0x00 leaf / 0x01 node) SHA-256 tree hashes; pki.merkle.verifyInclusion and
|
|
72
|
+
// verifyConsistency fold an audit / consistency proof and constant-time-
|
|
73
|
+
// compare to a checkpoint root. Pure sync hashing, fail-closed, transport-free.
|
|
74
|
+
merkle: merkle,
|
|
68
75
|
// `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the
|
|
69
76
|
// transport-agnostic client codecs (base64 transfer, multipart splitter),
|
|
70
77
|
// certs-only + serverkeygen validators over CMS, the enroll-attribute builders,
|
package/lib/asn1-der.js
CHANGED
|
@@ -105,8 +105,18 @@ function _className(bits) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function _asBuffer(input, who) {
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// A Buffer is a Uint8Array, so it routes through the same guarded re-view --
|
|
109
|
+
// NOT a `Buffer.isBuffer` fast-path that returns it as-is. A detached backing
|
|
110
|
+
// ArrayBuffer (a transferred / structuredClone'd view) reads as zero-length;
|
|
111
|
+
// re-viewing through Buffer.from surfaces it as a typed error here rather than
|
|
112
|
+
// a misleading downstream truncated-DER verdict on an empty buffer.
|
|
113
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
114
|
+
try {
|
|
115
|
+
return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new Asn1Error("asn1/not-buffer", who + ": input is not a usable byte view (detached backing buffer?)", e);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
110
120
|
throw new Asn1Error("asn1/not-buffer", who + ": expected a Buffer / Uint8Array");
|
|
111
121
|
}
|
|
112
122
|
|
package/lib/cbor-det.js
CHANGED
|
@@ -57,8 +57,20 @@ var _utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
|
57
57
|
var _MAX_EPOCH_SECONDS = 8640000000000n;
|
|
58
58
|
|
|
59
59
|
function _asBuffer(input, who) {
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// A Buffer is itself a Uint8Array, so it goes through the same guarded re-view
|
|
61
|
+
// -- NOT a `Buffer.isBuffer` fast-path that would return it as-is. A detached
|
|
62
|
+
// backing ArrayBuffer (a transferred / structuredClone'd view) has had its
|
|
63
|
+
// bytes removed and reads as zero-length; the fast-path would hand the byte
|
|
64
|
+
// walk an empty buffer. Always re-view through Buffer.from so a detached input
|
|
65
|
+
// fails closed here as a typed error rather than a raw TypeError deeper in the
|
|
66
|
+
// walk (or a misleading truncated-input verdict).
|
|
67
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
68
|
+
try {
|
|
69
|
+
return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new CborError("cbor/not-buffer", who + ": input is not a usable byte view (detached backing buffer?)", e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
throw new CborError("cbor/not-buffer", who + ": expected a Buffer / Uint8Array");
|
|
63
75
|
}
|
|
64
76
|
|
package/lib/constants.js
CHANGED
|
@@ -176,6 +176,17 @@ var LIMITS = {
|
|
|
176
176
|
// 2-5 SCTs; 256 is far above policy.
|
|
177
177
|
SCT_MAX_BYTES: BYTES.kib(64) + 1,
|
|
178
178
|
SCT_MAX_COUNT: 256,
|
|
179
|
+
// Merkle-proof node-count ceiling (RFC 6962 / RFC 9162). A DoS backstop that
|
|
180
|
+
// rejects a pathologically long proof array BEFORE the O(log n) fold does any
|
|
181
|
+
// digest work. 65 is the exact structural maximum: a tree size is a uint64, so
|
|
182
|
+
// an audit (inclusion) path is at most ceil(log2(2^64)) = 64 nodes, but a
|
|
183
|
+
// consistency proof is at most ceil(log2(newSize)) + 1 = 65 nodes (the extra
|
|
184
|
+
// node is the SUBPROOF terminal for a non-power-of-two old size near 2^64). Real
|
|
185
|
+
// CT logs are 2^30-2^40 (30-40-node proofs), so 65 is generous headroom while
|
|
186
|
+
// still bounding hostile work. The PRECISE per-proof guard is the geometry
|
|
187
|
+
// check in each verify (the node count must equal what the coordinates require);
|
|
188
|
+
// this is the coarse cap. A node count, not a byte size.
|
|
189
|
+
MERKLE_MAX_PROOF_NODES: 65,
|
|
179
190
|
// Certification-path length ceiling: bounds the per-cert asymmetric verify
|
|
180
191
|
// work on an untrusted certificate bundle (a real chain is well under this;
|
|
181
192
|
// the operator may override via opts.maxPathCerts).
|
package/lib/ct.js
CHANGED
|
@@ -111,8 +111,18 @@ function _peelInner(extValue) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
function _toBuffer(v, field) {
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// A Buffer is a Uint8Array, so it routes through the same guarded re-view --
|
|
115
|
+
// NOT a `Buffer.isBuffer` fast-path that returns it as-is. A detached backing
|
|
116
|
+
// ArrayBuffer (a transferred / structuredClone'd view) reads as zero-length;
|
|
117
|
+
// re-viewing through Buffer.from surfaces it as a typed error here rather than
|
|
118
|
+
// a misleading downstream bad-DER verdict on an empty buffer.
|
|
119
|
+
if (Buffer.isBuffer(v) || v instanceof Uint8Array) {
|
|
120
|
+
try {
|
|
121
|
+
return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
throw new CtError("ct/bad-input", field + " is not a usable byte view (detached backing buffer?)", e);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
116
126
|
throw new CtError("ct/bad-input", field + " must be a Buffer or Uint8Array");
|
|
117
127
|
}
|
|
118
128
|
|
|
@@ -199,10 +209,7 @@ function _parseSct(r, sctLen) {
|
|
|
199
209
|
* }
|
|
200
210
|
*/
|
|
201
211
|
function parseSctList(extValue) {
|
|
202
|
-
|
|
203
|
-
throw new CtError("ct/bad-input", "parseSctList expects the SCT-list extension value as a Buffer or Uint8Array");
|
|
204
|
-
}
|
|
205
|
-
var blob = _peelInner(Buffer.isBuffer(extValue) ? extValue : Buffer.from(extValue));
|
|
212
|
+
var blob = _peelInner(_toBuffer(extValue, "the SCT-list extension value"));
|
|
206
213
|
if (blob.length > C.LIMITS.SCT_MAX_BYTES) {
|
|
207
214
|
throw new CtError("ct/too-large", "SCT list " + blob.length + " bytes exceeds the cap " + C.LIMITS.SCT_MAX_BYTES);
|
|
208
215
|
}
|
package/lib/framework-error.js
CHANGED
|
@@ -127,8 +127,9 @@ var ConstantsError = defineClass("ConstantsError");
|
|
|
127
127
|
// Asn1Error -- malformed / non-canonical DER: truncated TLV, indefinite
|
|
128
128
|
// length, non-minimal length or integer, leftover trailing bytes, depth
|
|
129
129
|
// or size cap exceeded. DER is a canonical encoding, so anything the
|
|
130
|
-
// decoder rejects is permanently invalid.
|
|
131
|
-
|
|
130
|
+
// decoder rejects is permanently invalid. withCause threads a raw byte-view
|
|
131
|
+
// failure (a detached backing ArrayBuffer) as the cause rather than discarding it.
|
|
132
|
+
var Asn1Error = defineClass("Asn1Error", { withCause: true });
|
|
132
133
|
|
|
133
134
|
// CborError -- malformed / non-deterministic CBOR: a reserved or indefinite
|
|
134
135
|
// additional-info value, a stray break, a non-minimal ("preferred") argument,
|
|
@@ -136,8 +137,9 @@ var Asn1Error = defineClass("Asn1Error");
|
|
|
136
137
|
// key, a non-minimal / oversized bignum, bad UTF-8, a wrong-typed tag body,
|
|
137
138
|
// leftover trailing bytes, or a depth / size cap exceeded. RFC 8949 core-
|
|
138
139
|
// deterministic encoding (sec. 4.2.1) is canonical, so anything the decoder
|
|
139
|
-
// rejects is permanently invalid.
|
|
140
|
-
|
|
140
|
+
// rejects is permanently invalid. withCause threads a raw byte-view failure
|
|
141
|
+
// (a detached backing ArrayBuffer) as the cause rather than discarding it.
|
|
142
|
+
var CborError = defineClass("CborError", { withCause: true });
|
|
141
143
|
|
|
142
144
|
// OidError -- a malformed object-identifier: fewer than two arcs, a first
|
|
143
145
|
// arc outside 0..2, a second arc >= 40 under arcs 0/1, or a non-minimal
|
|
@@ -228,6 +230,16 @@ var SmimeError = defineClass("SmimeError", { withCause: true });
|
|
|
228
230
|
// inner `asn1/*` decode error) as `.cause`.
|
|
229
231
|
var CtError = defineClass("CtError", { withCause: true });
|
|
230
232
|
|
|
233
|
+
// MerkleError -- a malformed input to the RFC 6962 / RFC 9162 Merkle-tree
|
|
234
|
+
// proof-verification core: a tree coordinate that is not a non-negative integer
|
|
235
|
+
// (or a Number above 2^53 where an exact BigInt is required), a leafIndex
|
|
236
|
+
// outside its tree, an inverted consistency window, a hash chunk that is not 32
|
|
237
|
+
// bytes, or a proof whose node count does not match the tree geometry. The final
|
|
238
|
+
// root comparison is a constant-time boolean (root matched / did not), NOT an
|
|
239
|
+
// error -- only a structurally malformed input throws. Carries an underlying
|
|
240
|
+
// leaf fault as `.cause`.
|
|
241
|
+
var MerkleError = defineClass("MerkleError", { withCause: true });
|
|
242
|
+
|
|
231
243
|
// CsrattrsError -- a byte sequence that is not a well-formed RFC 8951 sec. 3.5
|
|
232
244
|
// CsrAttrs (SEQUENCE OF AttrOrOID), or one violating an RFC 9908 semantic rule
|
|
233
245
|
// (a repeated id-ExtensionReq, an extension-request values SET that is not
|
|
@@ -281,6 +293,7 @@ module.exports = {
|
|
|
281
293
|
CmpError: CmpError,
|
|
282
294
|
PathError: PathError,
|
|
283
295
|
CtError: CtError,
|
|
296
|
+
MerkleError: MerkleError,
|
|
284
297
|
SmimeError: SmimeError,
|
|
285
298
|
CsrattrsError: CsrattrsError,
|
|
286
299
|
EstError: EstError,
|
package/lib/merkle.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.merkle
|
|
6
|
+
* @nav Transparency
|
|
7
|
+
* @title Merkle
|
|
8
|
+
* @order 220
|
|
9
|
+
* @slug merkle
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* RFC 6962 (Certificate Transparency) / RFC 9162 (CT 2.0) Merkle-tree hash
|
|
13
|
+
* and proof-verification core -- the load-bearing primitive a static-CT
|
|
14
|
+
* client, a Merkle-Tree-Certificates relying party, and a sigstore / Rekor
|
|
15
|
+
* inclusion check all compose. It is ALL strict verification over SHA-256:
|
|
16
|
+
* zero new crypto.
|
|
17
|
+
*
|
|
18
|
+
* `leafHash` / `nodeHash` / `emptyRootHash` build the tree hashes with the
|
|
19
|
+
* two domain-separation prefixes fixed by the spec -- a leaf is
|
|
20
|
+
* `SHA-256(0x00 || entry)`, an interior node is `SHA-256(0x01 || left ||
|
|
21
|
+
* right)`, the empty tree is `SHA-256("")`. Those `0x00` / `0x01` prefixes
|
|
22
|
+
* are the second-preimage defense: without them a leaf whose bytes equal a
|
|
23
|
+
* valid interior node's preimage could be smuggled in as present.
|
|
24
|
+
*
|
|
25
|
+
* `verifyInclusion` folds an audit path back to a root and constant-time-
|
|
26
|
+
* compares it to a trusted checkpoint root; `verifyConsistency` reconstructs
|
|
27
|
+
* BOTH the old and the new root from a consistency proof (the append-only
|
|
28
|
+
* guarantee lives in the old-root leg). Both are fail-closed: a malformed
|
|
29
|
+
* coordinate, an out-of-range index, an inverted window, a wrong hash length,
|
|
30
|
+
* or a proof whose node count does not match the tree geometry throws a typed
|
|
31
|
+
* `merkle/*` error; the ONLY boolean-`false` result is the final root
|
|
32
|
+
* comparison ("root matched" vs "did not"). A `false` from `verifyInclusion`
|
|
33
|
+
* means "not proven present against this root", never "validly absent" -- an
|
|
34
|
+
* inclusion proof cannot express absence. Tree coordinates are uint64, carried
|
|
35
|
+
* as `BigInt` so a large index is never `Number`-narrowed. This is NOT a DER
|
|
36
|
+
* format: like `pki.ct` it is a companion module reached explicitly, never
|
|
37
|
+
* routed by the detect-and-parse orchestrator.
|
|
38
|
+
*
|
|
39
|
+
* @card
|
|
40
|
+
* `leafHash` / `nodeHash` / `emptyRootHash` + `verifyInclusion` /
|
|
41
|
+
* `verifyConsistency` over sync SHA-256, fail-closed, transport-free.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
var nodeCrypto = require("node:crypto");
|
|
45
|
+
var constants = require("./constants");
|
|
46
|
+
var frameworkError = require("./framework-error");
|
|
47
|
+
|
|
48
|
+
var MerkleError = frameworkError.MerkleError;
|
|
49
|
+
|
|
50
|
+
var SHA256_BYTES = 32;
|
|
51
|
+
var UINT64_MAX = 18446744073709551615n; // 2n ** 64n - 1n
|
|
52
|
+
var LEAF_PREFIX = Buffer.from([0x00]);
|
|
53
|
+
var NODE_PREFIX = Buffer.from([0x01]);
|
|
54
|
+
var EMPTY = Buffer.alloc(0);
|
|
55
|
+
|
|
56
|
+
function _sha256(buf) {
|
|
57
|
+
return nodeCrypto.createHash("sha256").update(buf).digest();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _toBuffer(v, field) {
|
|
61
|
+
if (!Buffer.isBuffer(v) && !(v instanceof Uint8Array)) {
|
|
62
|
+
throw new MerkleError("merkle/bad-input", field + " must be a Buffer or Uint8Array");
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
// A zero-copy view over the same bytes; this also surfaces a detached
|
|
66
|
+
// backing ArrayBuffer (a transferred / structuredClone'd view) as a typed
|
|
67
|
+
// error here rather than a raw TypeError from a later concat / digest.
|
|
68
|
+
return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new MerkleError("merkle/bad-input", field + " is not a usable byte view", e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _node32(v, field) {
|
|
75
|
+
var buf = _toBuffer(v, field);
|
|
76
|
+
if (buf.length !== SHA256_BYTES) {
|
|
77
|
+
throw new MerkleError("merkle/bad-hash-length", field + " must be exactly 32 bytes, got " + buf.length);
|
|
78
|
+
}
|
|
79
|
+
return buf;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// number | bigint -> a validated BigInt in [0, 2^64). A Number >= 2^53 (where
|
|
83
|
+
// precision is lost), a negative / non-integer, or a non-number-non-bigint
|
|
84
|
+
// throws -- the number-narrows-unbounded-integer discipline for uint64 tree
|
|
85
|
+
// coordinates.
|
|
86
|
+
function _coerceCoord(v, field) {
|
|
87
|
+
var b;
|
|
88
|
+
if (typeof v === "bigint") {
|
|
89
|
+
b = v;
|
|
90
|
+
} else if (typeof v === "number") {
|
|
91
|
+
if (!Number.isSafeInteger(v) || v < 0) {
|
|
92
|
+
throw new MerkleError("merkle/bad-input", field + " must be a non-negative safe integer or a BigInt (a Number >= 2^53 loses precision)");
|
|
93
|
+
}
|
|
94
|
+
b = BigInt(v);
|
|
95
|
+
} else {
|
|
96
|
+
throw new MerkleError("merkle/bad-input", field + " must be a number or a BigInt");
|
|
97
|
+
}
|
|
98
|
+
if (b < 0n || b > UINT64_MAX) {
|
|
99
|
+
throw new MerkleError("merkle/bad-input", field + " must be in [0, 2^64)");
|
|
100
|
+
}
|
|
101
|
+
return b;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _proofNodes(proof) {
|
|
105
|
+
if (!Array.isArray(proof)) throw new MerkleError("merkle/bad-proof", "proof must be an array");
|
|
106
|
+
if (proof.length > constants.LIMITS.MERKLE_MAX_PROOF_NODES) {
|
|
107
|
+
throw new MerkleError("merkle/proof-too-large", "proof has " + proof.length + " nodes, exceeds " + constants.LIMITS.MERKLE_MAX_PROOF_NODES);
|
|
108
|
+
}
|
|
109
|
+
var out = [];
|
|
110
|
+
for (var i = 0; i < proof.length; i++) out.push(_node32(proof[i], "proof[" + i + "]"));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Constant-time equality of two already-validated equal-length 32-byte buffers.
|
|
115
|
+
function _ctEq(a, b) {
|
|
116
|
+
return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @primitive pki.merkle.leafHash
|
|
121
|
+
* @signature pki.merkle.leafHash(entry) -> Buffer
|
|
122
|
+
* @since 0.1.28
|
|
123
|
+
* @status experimental
|
|
124
|
+
* @spec RFC 6962, RFC 9162
|
|
125
|
+
* @related pki.merkle.nodeHash, pki.merkle.verifyInclusion
|
|
126
|
+
*
|
|
127
|
+
* The Merkle leaf hash `MTH({d}) = SHA-256(0x00 || entry)`. The `0x00` prefix
|
|
128
|
+
* is the leaf-domain second-preimage separation and is applied unconditionally.
|
|
129
|
+
* Throws `merkle/bad-input` if `entry` is not a Buffer / Uint8Array.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* pki.merkle.leafHash(Buffer.from("leaf data")); // -> <Buffer 32-byte leaf hash>
|
|
133
|
+
*/
|
|
134
|
+
function leafHash(entry) {
|
|
135
|
+
var e = _toBuffer(entry, "entry");
|
|
136
|
+
return _sha256(Buffer.concat([LEAF_PREFIX, e]));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @primitive pki.merkle.nodeHash
|
|
141
|
+
* @signature pki.merkle.nodeHash(left, right) -> Buffer
|
|
142
|
+
* @since 0.1.28
|
|
143
|
+
* @status experimental
|
|
144
|
+
* @spec RFC 6962, RFC 9162
|
|
145
|
+
* @related pki.merkle.leafHash
|
|
146
|
+
*
|
|
147
|
+
* The Merkle interior-node hash `SHA-256(0x01 || left || right)`. Both operands
|
|
148
|
+
* must be 32-byte hashes; the `0x01` prefix is applied unconditionally. Throws
|
|
149
|
+
* `merkle/bad-input` on a non-buffer operand, `merkle/bad-hash-length` on an
|
|
150
|
+
* operand that is not exactly 32 bytes.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* var l = pki.merkle.leafHash(Buffer.from([0]));
|
|
154
|
+
* var r = pki.merkle.leafHash(Buffer.from([1]));
|
|
155
|
+
* pki.merkle.nodeHash(l, r); // -> <Buffer 32-byte node hash>
|
|
156
|
+
*/
|
|
157
|
+
function nodeHash(left, right) {
|
|
158
|
+
var l = _node32(left, "left");
|
|
159
|
+
var r = _node32(right, "right");
|
|
160
|
+
return _sha256(Buffer.concat([NODE_PREFIX, l, r]));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @primitive pki.merkle.emptyRootHash
|
|
165
|
+
* @signature pki.merkle.emptyRootHash() -> Buffer
|
|
166
|
+
* @since 0.1.28
|
|
167
|
+
* @status experimental
|
|
168
|
+
* @spec RFC 6962, RFC 9162
|
|
169
|
+
* @related pki.merkle.verifyConsistency
|
|
170
|
+
*
|
|
171
|
+
* The Merkle tree head of the empty tree, `MTH({}) = SHA-256("")`
|
|
172
|
+
* (`e3b0c442...b855`). A fresh Buffer each call.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* pki.merkle.emptyRootHash(); // -> <Buffer e3 b0 c4 42 ...>
|
|
176
|
+
*/
|
|
177
|
+
function emptyRootHash() {
|
|
178
|
+
return _sha256(EMPTY);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @primitive pki.merkle.verifyInclusion
|
|
183
|
+
* @signature pki.merkle.verifyInclusion(opts) -> boolean
|
|
184
|
+
* @since 0.1.28
|
|
185
|
+
* @status experimental
|
|
186
|
+
* @spec RFC 6962, RFC 9162
|
|
187
|
+
* @related pki.merkle.leafHash, pki.merkle.verifyConsistency
|
|
188
|
+
*
|
|
189
|
+
* Verify an RFC 6962 / RFC 9162 audit (inclusion) proof: fold `leafHash` up the
|
|
190
|
+
* audit path and constant-time-compare the reconstructed root to `rootHash`.
|
|
191
|
+
* Returns `true` iff the proof binds the leaf to the root; a well-formed proof
|
|
192
|
+
* that does not match returns `false` ("not proven present against this root",
|
|
193
|
+
* NEVER "validly absent"). A malformed input -- a coordinate that is not a
|
|
194
|
+
* non-negative integer (or a Number >= 2^53), `treeSize` 0, `leafIndex >=
|
|
195
|
+
* treeSize`, a non-32-byte hash, or a proof whose node count does not match the
|
|
196
|
+
* tree geometry -- throws a typed `merkle/*` error.
|
|
197
|
+
*
|
|
198
|
+
* @opts
|
|
199
|
+
* leafIndex: number | bigint, // 0-based leaf position (uint64; pass BigInt above 2^53)
|
|
200
|
+
* treeSize: number | bigint, // total leaf count of the tree the root commits to
|
|
201
|
+
* leafHash: Buffer, // 32-byte leaf hash (e.g. from pki.merkle.leafHash)
|
|
202
|
+
* proof: Buffer[], // the audit path, each node a 32-byte hash
|
|
203
|
+
* rootHash: Buffer, // 32-byte trusted checkpoint root
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* var lh = pki.merkle.leafHash(Buffer.from([0]));
|
|
207
|
+
* pki.merkle.verifyInclusion({ leafIndex: 0, treeSize: 1, leafHash: lh, proof: [], rootHash: lh }); // -> true
|
|
208
|
+
*/
|
|
209
|
+
function verifyInclusion(opts) {
|
|
210
|
+
opts = opts || {};
|
|
211
|
+
var leafIndex = _coerceCoord(opts.leafIndex, "leafIndex");
|
|
212
|
+
var treeSize = _coerceCoord(opts.treeSize, "treeSize");
|
|
213
|
+
if (treeSize === 0n) throw new MerkleError("merkle/empty-tree", "an empty tree has no leaves to include");
|
|
214
|
+
if (leafIndex >= treeSize) throw new MerkleError("merkle/index-out-of-range", "leafIndex " + leafIndex + " is not less than treeSize " + treeSize);
|
|
215
|
+
var lh = _node32(opts.leafHash, "leafHash");
|
|
216
|
+
var rootHash = _node32(opts.rootHash, "rootHash");
|
|
217
|
+
var proof = _proofNodes(opts.proof);
|
|
218
|
+
|
|
219
|
+
var fn = leafIndex;
|
|
220
|
+
var sn = treeSize - 1n;
|
|
221
|
+
var r = lh;
|
|
222
|
+
for (var i = 0; i < proof.length; i++) {
|
|
223
|
+
var p = proof[i];
|
|
224
|
+
if (sn === 0n) throw new MerkleError("merkle/bad-proof-length", "proof is longer than the tree geometry allows");
|
|
225
|
+
if ((fn & 1n) === 1n || fn === sn) {
|
|
226
|
+
r = nodeHash(p, r);
|
|
227
|
+
if ((fn & 1n) === 0n) {
|
|
228
|
+
do { fn >>= 1n; sn >>= 1n; } while ((fn & 1n) === 0n && fn !== 0n);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
r = nodeHash(r, p);
|
|
232
|
+
}
|
|
233
|
+
fn >>= 1n;
|
|
234
|
+
sn >>= 1n;
|
|
235
|
+
}
|
|
236
|
+
if (sn !== 0n) throw new MerkleError("merkle/bad-proof-length", "proof is shorter than the tree geometry requires");
|
|
237
|
+
return _ctEq(r, rootHash);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @primitive pki.merkle.verifyConsistency
|
|
242
|
+
* @signature pki.merkle.verifyConsistency(opts) -> boolean
|
|
243
|
+
* @since 0.1.28
|
|
244
|
+
* @status experimental
|
|
245
|
+
* @spec RFC 6962, RFC 9162
|
|
246
|
+
* @related pki.merkle.verifyInclusion, pki.merkle.emptyRootHash
|
|
247
|
+
*
|
|
248
|
+
* Verify an RFC 6962 / RFC 9162 consistency proof between an older tree of
|
|
249
|
+
* `oldSize` leaves (root `oldRoot`) and a newer tree of `newSize` leaves (root
|
|
250
|
+
* `newRoot`). Reconstructs BOTH roots from the proof and constant-time-compares
|
|
251
|
+
* each; returns `true` iff both match (the append-only guarantee lives in the
|
|
252
|
+
* old-root leg -- a proof that yields a valid `newRoot` but the wrong `oldRoot`
|
|
253
|
+
* is a rewritten history and returns `false`). The empty tree is a prefix of
|
|
254
|
+
* every tree, so `oldSize` 0 checks `oldRoot == emptyRootHash()` -- and, when
|
|
255
|
+
* `newSize` is also 0, `newRoot == emptyRootHash()` too (both trees empty); for
|
|
256
|
+
* `newSize > 0` an empty-old proof places no binding on `newRoot` (an empty prior
|
|
257
|
+
* tree carries no append-only guarantee -- authenticate `newRoot` separately,
|
|
258
|
+
* e.g. via its checkpoint signature). Equal non-zero sizes require an empty proof
|
|
259
|
+
* and `oldRoot == newRoot`. A malformed input --
|
|
260
|
+
* `oldSize > newSize`, a non-empty proof where
|
|
261
|
+
* the geometry requires none (or empty where it requires one), a non-32-byte
|
|
262
|
+
* hash, or a wrong node count -- throws a typed `merkle/*` error.
|
|
263
|
+
*
|
|
264
|
+
* @opts
|
|
265
|
+
* oldSize: number | bigint, // leaf count of the older tree (uint64)
|
|
266
|
+
* newSize: number | bigint, // leaf count of the newer tree (>= oldSize)
|
|
267
|
+
* oldRoot: Buffer, // 32-byte root of the older tree
|
|
268
|
+
* newRoot: Buffer, // 32-byte root of the newer tree
|
|
269
|
+
* proof: Buffer[], // the consistency proof, each node a 32-byte hash
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* var r = pki.merkle.leafHash(Buffer.from([0]));
|
|
273
|
+
* pki.merkle.verifyConsistency({ oldSize: 1, newSize: 1, oldRoot: r, newRoot: r, proof: [] }); // -> true
|
|
274
|
+
*/
|
|
275
|
+
function verifyConsistency(opts) {
|
|
276
|
+
opts = opts || {};
|
|
277
|
+
var oldSize = _coerceCoord(opts.oldSize, "oldSize");
|
|
278
|
+
var newSize = _coerceCoord(opts.newSize, "newSize");
|
|
279
|
+
var oldRoot = _node32(opts.oldRoot, "oldRoot");
|
|
280
|
+
var newRoot = _node32(opts.newRoot, "newRoot");
|
|
281
|
+
var proof = _proofNodes(opts.proof);
|
|
282
|
+
|
|
283
|
+
if (oldSize > newSize) throw new MerkleError("merkle/old-size-exceeds-new", "oldSize " + oldSize + " exceeds newSize " + newSize);
|
|
284
|
+
if (oldSize === 0n) {
|
|
285
|
+
if (proof.length !== 0) throw new MerkleError("merkle/bad-proof-length", "an empty older tree admits only the empty consistency proof");
|
|
286
|
+
var er = emptyRootHash();
|
|
287
|
+
var oldEmpty = _ctEq(oldRoot, er);
|
|
288
|
+
// The empty tree is a prefix of every tree, so its own root must be the
|
|
289
|
+
// empty root. When the NEW tree is also empty, its root must be the empty
|
|
290
|
+
// root too (a bogus newRoot must not pass); when newSize > 0 an empty-old
|
|
291
|
+
// proof places no binding on newRoot.
|
|
292
|
+
if (newSize === 0n) {
|
|
293
|
+
var newEmpty = _ctEq(newRoot, er);
|
|
294
|
+
return oldEmpty && newEmpty;
|
|
295
|
+
}
|
|
296
|
+
return oldEmpty;
|
|
297
|
+
}
|
|
298
|
+
if (oldSize === newSize) {
|
|
299
|
+
if (proof.length !== 0) throw new MerkleError("merkle/sizes-equal-nonempty-proof", "equal tree sizes require an empty consistency proof");
|
|
300
|
+
return _ctEq(oldRoot, newRoot);
|
|
301
|
+
}
|
|
302
|
+
if (proof.length === 0) throw new MerkleError("merkle/empty-consistency-proof", "a non-trivial consistency proof must not be empty");
|
|
303
|
+
|
|
304
|
+
var path = proof;
|
|
305
|
+
if ((oldSize & (oldSize - 1n)) === 0n) {
|
|
306
|
+
// oldSize is an exact power of two: the old root is the first proof node
|
|
307
|
+
// (it is not otherwise carried in the proof).
|
|
308
|
+
path = [oldRoot].concat(proof);
|
|
309
|
+
}
|
|
310
|
+
var fn = oldSize - 1n;
|
|
311
|
+
var sn = newSize - 1n;
|
|
312
|
+
while ((fn & 1n) === 1n) { fn >>= 1n; sn >>= 1n; }
|
|
313
|
+
var fr = path[0];
|
|
314
|
+
var sr = path[0];
|
|
315
|
+
for (var i = 1; i < path.length; i++) {
|
|
316
|
+
var c = path[i];
|
|
317
|
+
if (sn === 0n) throw new MerkleError("merkle/bad-proof-length", "consistency proof is longer than the geometry allows");
|
|
318
|
+
if ((fn & 1n) === 1n || fn === sn) {
|
|
319
|
+
fr = nodeHash(c, fr);
|
|
320
|
+
sr = nodeHash(c, sr);
|
|
321
|
+
if ((fn & 1n) === 0n) {
|
|
322
|
+
do { fn >>= 1n; sn >>= 1n; } while ((fn & 1n) === 0n && fn !== 0n);
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
sr = nodeHash(sr, c);
|
|
326
|
+
}
|
|
327
|
+
fn >>= 1n;
|
|
328
|
+
sn >>= 1n;
|
|
329
|
+
}
|
|
330
|
+
if (sn !== 0n) throw new MerkleError("merkle/bad-proof-length", "consistency proof is shorter than the geometry requires");
|
|
331
|
+
// Evaluate BOTH constant-time compares unconditionally before combining, so a
|
|
332
|
+
// `&&` never short-circuits the second `timingSafeEqual` (which would leak,
|
|
333
|
+
// via timing, whether the old-root leg matched).
|
|
334
|
+
var okOld = _ctEq(fr, oldRoot);
|
|
335
|
+
var okNew = _ctEq(sr, newRoot);
|
|
336
|
+
return okOld && okNew;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
leafHash: leafHash,
|
|
341
|
+
nodeHash: nodeHash,
|
|
342
|
+
emptyRootHash: emptyRootHash,
|
|
343
|
+
verifyInclusion: verifyInclusion,
|
|
344
|
+
verifyConsistency: verifyConsistency,
|
|
345
|
+
};
|
package/lib/webcrypto.js
CHANGED
|
@@ -47,9 +47,20 @@ var MAX_RANDOM_BYTES = 65536;
|
|
|
47
47
|
// ---- value helpers ---------------------------------------------------
|
|
48
48
|
|
|
49
49
|
function _toBuf(data, who) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
var isAb = data instanceof ArrayBuffer;
|
|
51
|
+
// A Buffer is itself an ArrayBuffer view, so it is handled by the view arm --
|
|
52
|
+
// NOT a `Buffer.isBuffer` fast-path that would return it as-is. A detached
|
|
53
|
+
// backing ArrayBuffer (a transferred / structuredClone'd Buffer, view, or
|
|
54
|
+
// ArrayBuffer) has had its bytes removed and reads as zero-length; the
|
|
55
|
+
// fast-path would silently hash / sign EMPTY (a fail-OPEN). Always re-view
|
|
56
|
+
// through Buffer.from so a detached input fails closed here as a typed error.
|
|
57
|
+
if (isAb || ArrayBuffer.isView(data)) {
|
|
58
|
+
try {
|
|
59
|
+
return isAb ? Buffer.from(data) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new WebCryptoError("webcrypto/data", (who || "input") + ": input is not a usable byte source (detached backing buffer?)", e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
53
64
|
throw new WebCryptoError("webcrypto/data", (who || "input") + ": expected BufferSource (ArrayBuffer / TypedArray / Buffer)");
|
|
54
65
|
}
|
|
55
66
|
|
|
@@ -718,7 +729,15 @@ Crypto.prototype.getRandomValues = function getRandomValues(typedArray) {
|
|
|
718
729
|
if (typedArray.byteLength > MAX_RANDOM_BYTES) {
|
|
719
730
|
throw new WebCryptoError("webcrypto/data", "getRandomValues: byteLength exceeds " + MAX_RANDOM_BYTES);
|
|
720
731
|
}
|
|
721
|
-
|
|
732
|
+
var view;
|
|
733
|
+
try {
|
|
734
|
+
// A detached backing ArrayBuffer surfaces as a typed error rather than a
|
|
735
|
+
// raw TypeError from Buffer.from over the transferred/cloned view.
|
|
736
|
+
view = Buffer.from(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
|
|
737
|
+
} catch (e) {
|
|
738
|
+
throw new WebCryptoError("webcrypto/data", "getRandomValues: byte view is not usable (detached backing buffer?)", e);
|
|
739
|
+
}
|
|
740
|
+
nodeCrypto.randomFillSync(view);
|
|
722
741
|
return typedArray;
|
|
723
742
|
};
|
|
724
743
|
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:26032baa-c793-470f-863c-569dc97c186e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-11T05:58:59.165Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.1.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.1.29",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.1.
|
|
25
|
+
"version": "0.1.29",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.1.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.1.29",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.1.
|
|
57
|
+
"ref": "@blamejs/pki@0.1.29",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|