@blamejs/pki 0.2.23 → 0.2.24

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 CHANGED
@@ -4,6 +4,15 @@ 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.2.24 — 2026-07-16
8
+
9
+ SCT-list encoding and log signing join pki.ct: encodeSctList builds an RFC 6962 SignedCertificateTimestampList byte-for-byte, and signSct performs a Certificate Transparency log's signing step, completing the parse/verify/encode/sign symmetry.
10
+
11
+ ### Added
12
+
13
+ - pki.ct.encodeSctList(scts) builds an RFC 6962 SCT-list extension value from an array of SCTs -- the exact inverse of pki.ct.parseSctList (byte-identical round-trip). A decoded v1 SCT is rebuilt from its fields in the RFC 6962 sec. 3.2 order; an opaque non-v1 entry re-emits its rawSct verbatim (forward compatibility). The list must be non-empty and stays within the parser's SCT_MAX_COUNT / SCT_MAX_BYTES caps. Fail-closed with a typed CtError.
14
+ - pki.ct.signSct(entry, logKey, opts) performs a Certificate Transparency log's signing step (RFC 6962 sec. 3.2): it rebuilds the digitally-signed preimage via the same reconstructSignedData builder the verifier hashes, signs it with the log's private key (ECDSA NIST P-256 or RSA >= 2048, SHA-256 per sec. 2.1.4), and returns a fully-formed v1 SCT that pki.ct.verifySct accepts. The LogID is derived as SHA-256 of the log's SubjectPublicKeyInfo (sec. 3.4); a supplied opts.logId must match. Composes with encodeSctList to assemble a signed SCT-list extension.
15
+
7
16
  ## v0.2.23 — 2026-07-16
8
17
 
9
18
  CMS content encryption arrives as pki.cms.encrypt and pki.cms.decrypt: EnvelopedData, AuthEnvelopedData, and EncryptedData with every RFC 5652 recipient type -- RSA-OAEP, ephemeral-static ECDH, X25519/X448, symmetric key-wrap, password, and post-quantum ML-KEM (RFC 9629/9936) -- and a single, oracle-free decryption verdict.
package/README.md CHANGED
@@ -224,7 +224,7 @@ is callable today; nothing below is a stub.
224
224
  | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, RSA / RSASSA-PSS / ECDSA / EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA (pairing ML-DSA with a traditional RSA / ECDSA / EdDSA — accepted only when **both** components verify — draft-ietf-lamps-cms-composite-sigs)); it builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: when signed attributes are present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. **Content encryption** (RFC 5652/5083/5084/9629): `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData — recipients auto-dispatch off the certificate key to key-transport (RSAES-OAEP; v1.5 never emitted), key-agreement (ephemeral-static ECDH over P-256/384/521 with the X9.63 KDF, and X25519/X448 with HKDF), symmetric key-wrap, password (PBKDF2 + RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936) — one fresh content key wrapped for every recipient. `decrypt(input, keyMaterial, opts)` recovers the content through the matching arm and returns it with an `authenticated` flag; every secret-dependent failure collapses to one uniform `cms/decrypt-failed` verdict (Bleichenbacher / EFAIL / password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. Fail-closed with typed `cms/*` errors — `sign`, `verify`, `encrypt`, `decrypt` |
225
225
  | `pki.tsp` | RFC 3161 Time-Stamp Protocol — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and — with a trust anchor supplied — full certification-path validation of the TSA certificate at the token's `genTime`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
226
226
  | `pki.ocsp` | RFC 6960 Online Certificate Status Protocol — the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs (CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile, or SHA-2; optional RFC 9654 nonce, optional requestor signature). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER — the issuing CA directly or a delegated responder, any `pki.cms.sign` key including the post-quantum ML-DSA / SLH-DSA sets, with `good` / `revoked` (reason + time) / `unknown` per-certificate status, and `buildErrorResponse(status)` the unsigned §2.3 error (`tryLater` / `unauthorized` / …). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA or a CA-issued delegate bearing id-kp-OCSPSigning **and** id-pkix-ocsp-nocheck, passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency (`thisUpdate`/`nextUpdate`), and the request-nonce echo — returning `{ status: "good" / "revoked" / "unknown", … }`, never a silent accept. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
227
- | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `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; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine — resolving true or false, and throwing a typed error on a structural fault. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct` |
227
+ | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `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; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine — resolving true or false, and throwing a typed error on a structural fault. The producing side: `encodeSctList` builds the extension value byte-for-byte (the exact inverse of `parseSctList`) and `signSct` performs a log's signing step (rebuilding the same signed-data preimage the verifier hashes and signing it with the log's ECDSA-P-256 / RSA key). Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct` |
228
228
  | `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` |
229
229
  | `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
230
230
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @internal
6
+ * lib/byte-writer.js -- a growable big-endian byte sink: the ENCODING-layer twin of
7
+ * lib/byte-reader.js, the shared producer under the toolkit's non-ASN.1 length-prefixed
8
+ * wire formats (the TLS-vector SCT list pki.ct emits; any future TLS-presentation
9
+ * producer). Every fixed-width integer is range-checked against its field width and
10
+ * every length-prefixed vector against its declared bound BEFORE a byte is committed,
11
+ * so an out-of-range value or an over-long body faults through the caller's typed
12
+ * ErrorClass rather than silently truncating mod 2^(8*width). It carries the CALLER's
13
+ * ErrorClass exactly as ByteReader / the guard family do, so every wire format keeps
14
+ * its own `domain/reason` fault code.
15
+ *
16
+ * This is an engine primitive, not a format: a new fixed-width or length-prefixed wire
17
+ * field is a method here (with its own range check), never a hand-rolled Buffer write
18
+ * in a format module.
19
+ */
20
+
21
+ // A growable big-endian sink, faulting through the caller's ErrorClass `E`
22
+ // (constructed `new E(code, message)`). `defaultCode` is the fault code used when a
23
+ // write is not given its own.
24
+ function ByteWriter(E, defaultCode) {
25
+ this.parts = [];
26
+ this.len = 0;
27
+ this.E = E;
28
+ this.defaultCode = defaultCode || "byte-writer/bad-value";
29
+ }
30
+ ByteWriter.prototype._push = function (buf) { this.parts.push(buf); this.len += buf.length; };
31
+ // A non-negative integer that fills exactly `width` big-endian octets; the width MUST be 1..4 (a wider
32
+ // or malformed width would break the max computation via 32-bit shift wrap-around and mis-size the
33
+ // field), and a value outside [0, 2^(8*width)-1] (or a non-integer) faults rather than silently wrapping.
34
+ ByteWriter.prototype._uint = function (v, width, code) {
35
+ if (!Number.isInteger(width) || width < 1 || width > 4) throw new this.E(code || this.defaultCode, "an integer width must be 1..4, got " + width);
36
+ var max = width === 4 ? 0xffffffff : (1 << (8 * width)) - 1;
37
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0 || v > max) {
38
+ throw new this.E(code || this.defaultCode, "a uint" + (8 * width) + " must be an integer in 0.." + max + ", got " + v);
39
+ }
40
+ var b = Buffer.alloc(width); b.writeUIntBE(v, 0, width); this._push(b);
41
+ };
42
+ ByteWriter.prototype.u8 = function (v, code) { this._uint(v, 1, code); return this; };
43
+ ByteWriter.prototype.u16 = function (v, code) { this._uint(v, 2, code); return this; };
44
+ ByteWriter.prototype.u24 = function (v, code) { this._uint(v, 3, code); return this; };
45
+ ByteWriter.prototype.u32 = function (v, code) { this._uint(v, 4, code); return this; };
46
+ // A uint64 from a BigInt or a safe-integer Number; refused if out of [0, 2^64-1].
47
+ ByteWriter.prototype.u64 = function (v, code) {
48
+ var big;
49
+ if (typeof v === "bigint") big = v;
50
+ else if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) big = BigInt(v);
51
+ else throw new this.E(code || this.defaultCode, "a uint64 must be a non-negative integer or BigInt, got " + v);
52
+ if (big < 0n || big > 0xffffffffffffffffn) throw new this.E(code || this.defaultCode, "a uint64 must be in 0..2^64-1");
53
+ var b = Buffer.alloc(8); b.writeBigUInt64BE(big); this._push(b);
54
+ return this;
55
+ };
56
+ // Append a raw opaque slice verbatim (a fixed-width field).
57
+ ByteWriter.prototype.bytes = function (buf, code) {
58
+ if (!Buffer.isBuffer(buf)) throw new this.E(code || this.defaultCode, "bytes() requires a Buffer");
59
+ this._push(buf); return this;
60
+ };
61
+ // opaque<min..max> -- a `lenWidth`-byte big-endian length prefix (1..4) then `body`.
62
+ // The body length is bounded [min, max] BEFORE the prefix is written, so an over-long
63
+ // body cannot emit a prefix that disagrees with what follows.
64
+ ByteWriter.prototype.vector = function (lenWidth, min, max, body, code) {
65
+ if (!Buffer.isBuffer(body)) throw new this.E(code || this.defaultCode, "vector() body must be a Buffer");
66
+ if (body.length < min) throw new this.E(code || this.defaultCode, "vector body " + body.length + " below minimum " + min);
67
+ if (max != null && body.length > max) throw new this.E(code || this.defaultCode, "vector body " + body.length + " above maximum " + max);
68
+ this._uint(body.length, lenWidth, code);
69
+ this._push(body);
70
+ return this;
71
+ };
72
+ ByteWriter.prototype.length = function () { return this.len; };
73
+ ByteWriter.prototype.build = function () { return Buffer.concat(this.parts, this.len); };
74
+
75
+ module.exports = ByteWriter;
package/lib/ct.js CHANGED
@@ -38,11 +38,13 @@
38
38
  * bounded TLS-struct decode, fail-closed.
39
39
  */
40
40
 
41
+ var nodeCrypto = require("crypto");
41
42
  var asn1 = require("./asn1-der.js");
42
43
  var constants = require("./constants.js");
43
44
  var frameworkError = require("./framework-error.js");
44
45
  var guard = require("./guard-all.js");
45
46
  var ByteReader = require("./byte-reader.js");
47
+ var ByteWriter = require("./byte-writer.js");
46
48
  var oid = require("./oid.js");
47
49
  var webcrypto = require("./webcrypto.js");
48
50
  var validator = require("./validator-all.js");
@@ -136,15 +138,15 @@ function _parseSct(r, sctLen) {
136
138
 
137
139
  /**
138
140
  * @primitive pki.ct.parseSctList
139
- * @signature pki.ct.parseSctList(extValue) -> { scts, unknownScts }
141
+ * @signature pki.ct.parseSctList(extValue) -> { scts, unknownScts, all }
140
142
  * @since 0.1.20
141
143
  * @status experimental
142
144
  * @spec RFC 6962, RFC 5246, RFC 8446
143
- * @related pki.ct.reconstructSignedData, pki.schema.x509.parse
145
+ * @related pki.ct.reconstructSignedData, pki.ct.encodeSctList, pki.schema.x509.parse
144
146
  *
145
147
  * Parse the value of an RFC 6962 SCT-list extension (the raw `extnValue`
146
148
  * content an `x509.parse` / OCSP extension already surfaces) into
147
- * `{ scts, unknownScts }`. Each entry of `scts` is a fully decoded v1 SCT:
149
+ * `{ scts, unknownScts, all }`. Each entry of `scts` is a fully decoded v1 SCT:
148
150
  * `version` (0), `logId` (32-byte Buffer) + `logIdHex`, `timestamp` (BigInt,
149
151
  * exact) + `timestampMs` (Number or `null` above 2^53) + `timestampDate`,
150
152
  * `extensions` (raw Buffer), `hashAlg` / `sigAlg` (1-byte code points) + a named
@@ -152,7 +154,10 @@ function _parseSct(r, sctLen) {
152
154
  * SerializedSCT body). A SerializedSCT whose version this parser does not define
153
155
  * is preserved OPAQUE in `unknownScts` as `{ version, rawSct }` rather than
154
156
  * failing the list -- RFC 6962 sec. 3.3 frames each SerializedSCT with its own length
155
- * so unknown versions are skippable (forward compatibility).
157
+ * so unknown versions are skippable (forward compatibility). `all` lists every
158
+ * SerializedSCT (known and unknown) in the exact wire order, so
159
+ * `encodeSctList(all)` reproduces the list byte-identically even when the two
160
+ * kinds are interleaved.
156
161
  *
157
162
  * The extension value is a DER `OCTET STRING` wrapping the TLS-encoded list
158
163
  * (RFC 6962 sec. 3.3 double wrap); everything below that peel is TLS presentation
@@ -186,7 +191,7 @@ function parseSctList(extValue) {
186
191
  if (listLen < 1) {
187
192
  throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 sec. 3.3)");
188
193
  }
189
- var scts = [], unknownScts = [];
194
+ var scts = [], unknownScts = [], all = [];
190
195
  // The shared item counter bounds the TOTAL element count (known + preserved-
191
196
  // unknown) before it can drive unbounded per-element work (RFC 6962 sec. 3.3).
192
197
  var sctCount = guard.limits.counter(C.LIMITS.SCT_MAX_COUNT, _ctErr, "ct/too-many-scts", "SCT");
@@ -203,10 +208,12 @@ function parseSctList(extValue) {
203
208
  }
204
209
  sctCount.tick();
205
210
  var one = _parseSct(outer.subReader(sctLen, "ct/list-trailing-bytes"), sctLen);
206
- if (one.unknown) unknownScts.push({ version: one.version, rawSct: one.rawSct });
207
- else scts.push(one);
211
+ if (one.unknown) { var u = { version: one.version, rawSct: one.rawSct }; unknownScts.push(u); all.push(u); }
212
+ else { scts.push(one); all.push(one); }
208
213
  }
209
- return { scts: scts, unknownScts: unknownScts };
214
+ // `all` preserves the exact wire order across known + unknown entries, so encodeSctList(all)
215
+ // reproduces the list byte-identically even when the two kinds were interleaved.
216
+ return { scts: scts, unknownScts: unknownScts, all: all };
210
217
  }
211
218
 
212
219
  function _u24Bytes(n) {
@@ -409,10 +416,188 @@ async function verifySct(entry, sct, logPublicKey) {
409
416
  }
410
417
  }
411
418
 
419
+ // The TLS-vector WRITER bound to the ct/* fault domain -- the encode twin of TlsReader,
420
+ // over the shared ByteWriter engine primitive (see lib/byte-writer.js).
421
+ function TlsWriter() { return new ByteWriter(CtError, "ct/bad-input"); }
422
+
423
+ // Encode one SerializedSCT body. A v1 SCT is rebuilt from its fields in the exact _parseSct
424
+ // order (so it round-trips byte-identically); an opaque non-v1 entry re-emits its rawSct
425
+ // verbatim (RFC 6962 sec. 3.3 forward-compat symmetry -- encode preserves what parse preserved).
426
+ function _encodeSctBody(sct) {
427
+ if (!sct || typeof sct !== "object") throw new CtError("ct/bad-input", "each SCT must be an object");
428
+ if (sct.version !== 0) {
429
+ // The SerializedSCT version is a single byte (RFC 6962 sec. 3.2), so a declared version must be a
430
+ // 0..255 integer -- reject a non-byte value rather than masking it (a masked 263 would spoof v7).
431
+ if (typeof sct.version !== "number" || !Number.isInteger(sct.version) || sct.version < 0 || sct.version > 255) {
432
+ throw new CtError("ct/bad-input", "an SCT version must be a byte in 0..255 (RFC 6962 sec. 3.2)");
433
+ }
434
+ // An opaque entry re-emits its rawSct verbatim, but the SerializedSCT's on-wire version is
435
+ // rawSct[0] -- refuse a rawSct whose leading byte disagrees with the declared version, so encode
436
+ // never emits a list whose framing our own parser would reject or re-classify (RFC 6962 sec. 3.3).
437
+ var raw = _toBuffer(sct.rawSct, "sct.rawSct");
438
+ if (raw.length < 1 || raw[0] !== sct.version) {
439
+ throw new CtError("ct/bad-input", "an opaque SCT's rawSct[0] must equal its declared version (RFC 6962 sec. 3.3)");
440
+ }
441
+ return raw;
442
+ }
443
+ var w = new TlsWriter();
444
+ w.u8(0, "ct/bad-input"); // Version -- v1(0)
445
+ var logId = _toBuffer(sct.logId, "sct.logId");
446
+ if (logId.length !== LOGID_BYTES) throw new CtError("ct/bad-input", "an SCT logId must be exactly " + LOGID_BYTES + " bytes (RFC 6962 sec. 3.2)");
447
+ w.bytes(logId);
448
+ w.u64(guard.range.uint64(sct.timestamp, _ctErr, "ct/bad-input", "sct.timestamp"), "ct/bad-input"); // uint64 timestamp
449
+ w.vector(2, 0, 0xffff, _toBuffer(sct.extensions, "sct.extensions"), "ct/bad-input"); // CtExtensions<0..2^16-1>
450
+ w.u8(sct.hashAlg, "ct/bad-input"); // digitally-signed hash
451
+ w.u8(sct.sigAlg, "ct/bad-input"); // digitally-signed signature
452
+ w.vector(2, 0, 0xffff, _toBuffer(sct.signature, "sct.signature"), "ct/bad-input"); // signature<0..2^16-1>
453
+ return w.build();
454
+ }
455
+
456
+ /**
457
+ * @primitive pki.ct.encodeSctList
458
+ * @signature pki.ct.encodeSctList(scts) -> Buffer
459
+ * @since 0.2.24
460
+ * @status experimental
461
+ * @spec RFC 6962, RFC 5246
462
+ * @related pki.ct.parseSctList, pki.ct.signSct
463
+ *
464
+ * Build the value of an RFC 6962 SCT-list extension from an array of SCTs -- the exact
465
+ * inverse of `parseSctList`, such that `parseSctList(encodeSctList(list.all))` round-trips to
466
+ * identical bytes. Each element is either a decoded v1 SCT (the shape `parseSctList().scts[]`
467
+ * or `signSct` returns: `version` 0, 32-byte `logId`, `timestamp` BigInt, raw `extensions`,
468
+ * `hashAlg` / `sigAlg` code points, raw `signature`) -- rebuilt from its fields in the RFC
469
+ * 6962 sec. 3.2 field order -- or an opaque non-v1 entry (`{ version, rawSct }`) whose
470
+ * `rawSct` is re-emitted verbatim (forward compatibility, sec. 3.3). Pass `parseSctList().all`
471
+ * (not `.scts`) to preserve the exact wire order and every unknown-version entry.
472
+ *
473
+ * Returns the DER `OCTET STRING`-wrapped TLS `SignedCertificateTimestampList` (the same
474
+ * `extnValue` content `parseSctList` consumes). The list must be non-empty and stays within
475
+ * the parser's `SCT_MAX_COUNT` element cap and the RFC 6962 sec. 3.3 65535-byte list-body cap so
476
+ * encode cannot emit what parse would reject. Throws a typed `CtError` (`ct/empty-list`,
477
+ * `ct/bad-input`, `ct/too-large`, `ct/too-many-scts`) on malformed input.
478
+ *
479
+ * @example
480
+ * var list = pki.ct.parseSctList(sctExtValue);
481
+ * var reEncoded = pki.ct.encodeSctList(list.all); // byte-identical to sctExtValue
482
+ */
483
+ function encodeSctList(scts) {
484
+ if (!Array.isArray(scts)) throw new CtError("ct/bad-input", "encodeSctList expects an array of SCTs");
485
+ if (scts.length < 1) throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 sec. 3.3)");
486
+ var sctCount = guard.limits.counter(C.LIMITS.SCT_MAX_COUNT, _ctErr, "ct/too-many-scts", "SCT");
487
+ var elements = [], total = 0;
488
+ for (var i = 0; i < scts.length; i++) {
489
+ sctCount.tick();
490
+ var ew = new TlsWriter();
491
+ ew.vector(2, 1, 0xffff, _encodeSctBody(scts[i]), "ct/bad-input"); // SerializedSCT<1..2^16-1>
492
+ var el = ew.build();
493
+ total += el.length;
494
+ // Enforce the sct_list<1..2^16-1> body cap INCREMENTALLY, so an over-long list fails at the first
495
+ // element that crosses the bound instead of accumulating the whole (potentially large) set first.
496
+ if (total > 0xffff) throw new CtError("ct/too-large", "the SCT list body exceeds the 65535-byte maximum (RFC 6962 sec. 3.3)");
497
+ elements.push(el);
498
+ }
499
+ var lw = new TlsWriter();
500
+ lw.vector(2, 1, 0xffff, Buffer.concat(elements, total), "ct/too-large"); // sct_list<1..2^16-1>
501
+ return asn1.build.octetString(lw.build()); // RFC 6962 sec. 3.3 DER OCTET STRING wrap
502
+ }
503
+
504
+ // A CT log private key -> its PKCS#8 DER (for WebCrypto sign) + the SPKI DER (for the LogID +
505
+ // the sec. 2.1.4 algorithm profile). Accepts a PKCS#8 DER Buffer, a PEM string, a node
506
+ // KeyObject, or a { key, format, type } input.
507
+ function _logKeyMaterial(logKey) {
508
+ // Every key operation runs inside the guard: a duck-typed impostor (an object carrying an
509
+ // asymmetricKeyType but not a real KeyObject) fails at export/createPublicKey and surfaces the
510
+ // typed ct/bad-input, never a raw TypeError. The private-key check keeps its own CtError.
511
+ try {
512
+ var keyObj;
513
+ if (logKey && typeof logKey === "object" && logKey.asymmetricKeyType) keyObj = logKey;
514
+ else if (Buffer.isBuffer(logKey)) keyObj = nodeCrypto.createPrivateKey({ key: logKey, format: "der", type: "pkcs8" });
515
+ else keyObj = nodeCrypto.createPrivateKey(logKey);
516
+ if (keyObj.type !== "private") throw new CtError("ct/bad-input", "signSct requires the CT log PRIVATE key");
517
+ return { pkcs8: keyObj.export({ type: "pkcs8", format: "der" }), spki: nodeCrypto.createPublicKey(keyObj).export({ type: "spki", format: "der" }) };
518
+ } catch (e) {
519
+ if (e instanceof CtError) throw e;
520
+ throw new CtError("ct/bad-input", "the CT log private key could not be loaded", e);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * @primitive pki.ct.signSct
526
+ * @signature pki.ct.signSct(entry, logKey, opts?) -> Promise<sct>
527
+ * @since 0.2.24
528
+ * @status experimental
529
+ * @spec RFC 6962
530
+ * @related pki.ct.verifySct, pki.ct.reconstructSignedData, pki.ct.encodeSctList
531
+ *
532
+ * Perform a Certificate Transparency log's signing step (RFC 6962 sec. 3.2): rebuild the exact
533
+ * `digitally-signed` preimage over `entry` (via `reconstructSignedData`, the SAME builder the
534
+ * verifier hashes), sign it with the log's private key, and return a fully-formed v1 SCT that
535
+ * `verifySct` accepts against the log's public key. `entry` is the log entry the SCT covers
536
+ * (`{ entryType: 0, leafCert }` or `{ entryType: 1, tbsCertificate, issuerKeyHash }`, as for
537
+ * `reconstructSignedData`); `logKey` is the log's private key (PKCS#8 DER `Buffer`, PEM string,
538
+ * or a node `KeyObject`).
539
+ *
540
+ * The log-key profile is RFC 6962 sec. 2.1.4: ECDSA NIST P-256 (`sigAlg` 3) or RSA >= 2048
541
+ * (`sigAlg` 1), SHA-256 only -- an unsupported key fails closed `ct/unsupported-algorithm`. The
542
+ * `logId` is derived as SHA-256 of the log SPKI (sec. 3.4); a supplied `opts.logId` must match.
543
+ * The returned SCT is the parseSctList/verifySct shape and composes with `encodeSctList`.
544
+ *
545
+ * @opts timestamp ms since the epoch (finite non-negative integer/BigInt). Default `Date.now()`.
546
+ * @opts extensions raw `CtExtensions` bytes (opaque<0..2^16-1>). Default empty.
547
+ * @opts logId assert the derived LogID equals this 32-byte value (fail closed on mismatch).
548
+ * @example
549
+ * var sct = await pki.ct.signSct({ entryType: 0, leafCert: der }, signerKeyPkcs8);
550
+ * var ext = pki.ct.encodeSctList([sct]);
551
+ */
552
+ async function signSct(entry, logKey, opts) {
553
+ opts = opts || {};
554
+ var mat = _logKeyMaterial(logKey);
555
+ var alg = _spkiAlg(mat.spki);
556
+ var hashAlg = 4, sigAlg, imp, sign, ecdsaDer = false, coordLen;
557
+ if (alg.algOid === oid.byName("ecPublicKey")) {
558
+ var ec = CT_EC_CURVE[alg.curveOid];
559
+ if (!ec) throw new CtError("ct/unsupported-algorithm", "unsupported SCT log EC curve (RFC 6962 sec. 2.1.4 mandates NIST P-256)");
560
+ sigAlg = 3; imp = { name: "ECDSA", namedCurve: ec.curve }; sign = { name: "ECDSA", hash: "SHA-256" }; ecdsaDer = true; coordLen = ec.coordLen;
561
+ } else if (alg.algOid === oid.byName("rsaEncryption")) {
562
+ if (!(alg.rsaBits >= 2048)) throw new CtError("ct/unsupported-algorithm", "the SCT log RSA key is below the RFC 6962 sec. 2.1.4 minimum of 2048 bits");
563
+ sigAlg = 1; imp = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; sign = { name: "RSASSA-PKCS1-v1_5" };
564
+ } else {
565
+ throw new CtError("ct/unsupported-algorithm", "unsupported SCT log key algorithm (RFC 6962 sec. 2.1.4 supports ecdsa P-256 / rsa)");
566
+ }
567
+ var timestamp;
568
+ if (opts.timestamp == null) timestamp = BigInt(Date.now());
569
+ else if (typeof opts.timestamp === "bigint") timestamp = opts.timestamp;
570
+ else if (typeof opts.timestamp === "number" && Number.isSafeInteger(opts.timestamp) && opts.timestamp >= 0) timestamp = BigInt(opts.timestamp);
571
+ else throw new CtError("ct/bad-input", "timestamp must be a finite non-negative integer or BigInt (RFC 6962 sec. 3.2)");
572
+ var extensions = opts.extensions == null ? Buffer.alloc(0) : _toBuffer(opts.extensions, "opts.extensions");
573
+ var logId = Buffer.from(await subtle.digest("SHA-256", mat.spki));
574
+ if (opts.logId != null && !_toBuffer(opts.logId, "opts.logId").equals(logId)) {
575
+ throw new CtError("ct/bad-input", "opts.logId does not match SHA-256 of the log key (RFC 6962 sec. 3.4)");
576
+ }
577
+ // Reuse the ONE preimage builder the verifier uses -- sign and verify cannot diverge.
578
+ var preimage = reconstructSignedData(entry, { version: 0, timestamp: timestamp, extensions: extensions });
579
+ var priv = await subtle.importKey("pkcs8", mat.pkcs8, imp, false, ["sign"]);
580
+ var sigRaw = Buffer.from(await subtle.sign(sign, priv, preimage));
581
+ var signature = ecdsaDer ? validator.sig.rawToEcdsaDer(sigRaw, coordLen) : sigRaw;
582
+ return {
583
+ version: 0,
584
+ logId: logId, logIdHex: logId.toString("hex"),
585
+ timestamp: timestamp,
586
+ extensions: extensions,
587
+ hashAlg: hashAlg, sigAlg: sigAlg,
588
+ // The `|| null` fallbacks mirror parseSctList's shape; unreachable here since signSct only ever
589
+ // emits the in-map sha256(4) + ecdsa(3)/rsa(1) code points (coverage residual).
590
+ signatureAlgorithm: { hash: hashAlg, hashName: HASH_ALGORITHMS[hashAlg] || null, signature: sigAlg, signatureName: SIGNATURE_ALGORITHMS[sigAlg] || null },
591
+ signature: signature,
592
+ };
593
+ }
594
+
412
595
  module.exports = {
413
596
  parseSctList: parseSctList,
414
597
  reconstructSignedData: reconstructSignedData,
415
598
  verifySct: verifySct,
599
+ encodeSctList: encodeSctList,
600
+ signSct: signSct,
416
601
  HASH_ALGORITHMS: HASH_ALGORITHMS,
417
602
  SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
418
603
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:218fb396-9c81-482d-be9e-f08c7add5683",
5
+ "serialNumber": "urn:uuid:778bbc49-b521-4695-b1a1-915cda8b8012",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T08:33:14.109Z",
8
+ "timestamp": "2026-07-16T09:47:36.237Z",
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.2.23",
22
+ "bom-ref": "@blamejs/pki@0.2.24",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.23",
25
+ "version": "0.2.24",
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.2.23",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.24",
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.2.23",
57
+ "ref": "@blamejs/pki@0.2.24",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]