@blamejs/pki 0.1.19 → 0.1.21
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 +27 -0
- package/README.md +2 -1
- package/index.js +5 -0
- package/lib/constants.js +11 -0
- package/lib/ct.js +315 -0
- package/lib/framework-error.js +10 -0
- package/lib/oid.js +68 -4
- package/lib/path-validate.js +50 -12
- package/lib/schema-pkix.js +38 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ 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.21 — 2026-07-09
|
|
8
|
+
|
|
9
|
+
SLH-DSA signatures verify in certification-path validation, and the post-quantum / EdDSA parameters-absent rule is enforced across every format.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- SLH-DSA signature verification in pki.path.validate — all twelve FIPS 205 parameter sets (id-slh-dsa-sha2-{128,192,256}{s,f} and the SHAKE sets). A certificate or CRL signed with SLH-DSA now verifies by importing the issuer's SLH-DSA public key and checking the one-shot signature over the raw signed region; the same rows serve both the certificate signature check and the CRL revocation checker. ML-DSA and the classical RSA / ECDSA / EdDSA set were already wired.
|
|
14
|
+
- pki.oid.paramsMustBeAbsent(oid) — a predicate that reports whether an AlgorithmIdentifier bearing the given OID must encode its parameters field as absent (the ML-DSA and SLH-DSA families and the RFC 8410 Edwards / Montgomery curves). It is the single source the shared AlgorithmIdentifier decoder consults.
|
|
15
|
+
- The OID registry now names the twelve pre-hash HashSLH-DSA identifiers (id-hash-slh-dsa-*, RFC 9909 §3), so a certificate or CRL that carries a HashSLH-DSA algorithm resolves to a name and is covered by the parameters-absent rule.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent — ML-DSA, SLH-DSA, Ed25519, Ed448, X25519, and X448 (RFC 9909 §3, RFC 9814 §4, RFC 9881 §2, RFC 8410 §3) — failing closed with a <format>/bad-algorithm-parameters code. Previously a stray explicit NULL or arbitrary bytes in that field were surfaced raw. The rule is enforced once in the shared decoder, so every format that names an algorithm inherits it; a conforming identifier, which omits the field, is unaffected.
|
|
20
|
+
- Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID — EdDSA, ML-DSA, and SLH-DSA — rejecting a mismatch with a path/algorithm-mismatch reason (RFC 9814 §4). Because the underlying WebCrypto import binds a public key of a different type to the requested algorithm name and verifies with the real key, a certificate or CRL signed by one key type but labelling its signatureAlgorithm as another one-shot type could otherwise validate; the check closes that algorithm-confusion path for both the certificate signature and the CRL revocation checker.
|
|
21
|
+
|
|
22
|
+
## v0.1.20 — 2026-07-09
|
|
23
|
+
|
|
24
|
+
An RFC 6962 Certificate Transparency SCT-list parser joins the toolkit.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- pki.ct.parseSctList(extValue) — RFC 6962 SCT-list parsing. It decodes the SignedCertificateTimestampList extension value (the raw extnValue content an x509 or OCSP extension surfaces) into { scts, unknownScts }. Each scts entry is a fully decoded v1 SCT: version (0), logId (32-byte Buffer) plus logIdHex, timestamp (exact BigInt) plus timestampMs (a Number, or null above 2^53) plus timestampDate, extensions (raw Buffer), the hashAlg / sigAlg code points plus a named signatureAlgorithm, the raw signature, and rawSct (the full SerializedSCT body). A SerializedSCT whose version is not v1 is preserved opaque in unknownScts as { version, rawSct } rather than failing the list (RFC 6962 §3.3 gives each SerializedSCT its own length so unknown versions are skippable). The extension value is the §3.3 double DER OCTET STRING wrap over a TLS-encoded list, decoded with a bounded reader that validates the list and per-SCT framing and every internal length, and asserts a per-list byte and count cap before it iterates. The signature is never verified and the log id never recomputed. Malformed input fails closed with a typed ct/* (or leaf asn1/*) code.
|
|
29
|
+
- pki.ct.reconstructSignedData(entry, sct) — rebuilds the exact digitally-signed preimage a verifier hashes to check an SCT's signature (RFC 6962 §3.2), for a decoded v1 SCT. entry selects the log-entry form: { entryType: 0, leafCert } for an SCT delivered over TLS or OCSP (signed over the leaf certificate), or { entryType: 1, tbsCertificate, issuerKeyHash } for an SCT embedded in a certificate (signed over the issuer key hash and the precertificate TBS). The preimage reuses the parsed SCT's raw extensions byte-for-byte; a verifier hashes the returned bytes and checks the signature with the log's public key.
|
|
30
|
+
- The certificate-extension value registry gains the SCT-list decoder and the precertificate-poison decoder (the poison value is content-validated as ASN.1 NULL, not merely tag-checked).
|
|
31
|
+
- The OID registry gains the Certificate Transparency arc — the SCT-list, precertificate-poison, precertificate-signing-certificate, and OCSP SCT-list identifiers — so those extension OIDs resolve to names.
|
|
32
|
+
- The error taxonomy gains CtError, carrying a stable ct/* code.
|
|
33
|
+
|
|
7
34
|
## v0.1.19 — 2026-07-09
|
|
8
35
|
|
|
9
36
|
An RFC 9810 Certificate Management Protocol message parser joins the pki.schema family.
|
package/README.md
CHANGED
|
@@ -204,8 +204,9 @@ is callable today; nothing below is a stub.
|
|
|
204
204
|
| `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
205
205
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
206
206
|
| `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` |
|
|
207
|
+
| `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` |
|
|
207
208
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
208
|
-
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError`, each carrying a stable `code` in `domain/reason` form |
|
|
209
|
+
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError`, each carrying a stable `code` in `domain/reason` form |
|
|
209
210
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
|
210
211
|
|
|
211
212
|
### CLI
|
package/index.js
CHANGED
|
@@ -30,6 +30,7 @@ var oid = require("./lib/oid");
|
|
|
30
30
|
var webcrypto = require("./lib/webcrypto");
|
|
31
31
|
var schema = require("./lib/schema-all");
|
|
32
32
|
var path = require("./lib/path-validate");
|
|
33
|
+
var ct = require("./lib/ct");
|
|
33
34
|
|
|
34
35
|
module.exports = {
|
|
35
36
|
version: constants.version,
|
|
@@ -46,6 +47,10 @@ module.exports = {
|
|
|
46
47
|
// `path` is RFC 5280 §6 certification-path validation — pki.path.validate
|
|
47
48
|
// runs the §6.1 state machine over an already-parsed path + a trust anchor.
|
|
48
49
|
path: path,
|
|
50
|
+
// `ct` is RFC 6962 Certificate Transparency — pki.ct.parseSctList decodes the
|
|
51
|
+
// SCT-list extension a certificate / OCSP response carries; the signature is
|
|
52
|
+
// surfaced raw for external verification (pki.ct.reconstructSignedData).
|
|
53
|
+
ct: ct,
|
|
49
54
|
// A ready W3C Crypto instance (globalThis.crypto shape) with the classes for
|
|
50
55
|
// constructing more attached under the same namespace (pki.webcrypto.CryptoKey,
|
|
51
56
|
// .SubtleCrypto, .Crypto, .WebCryptoError). PQC-first, classical-capable, zero-dep.
|
package/lib/constants.js
CHANGED
|
@@ -117,6 +117,17 @@ var LIMITS = {
|
|
|
117
117
|
PEM_MAX_BYTES: BYTES.mib(16),
|
|
118
118
|
DER_MAX_INTEGER_BYTES: BYTES.kib(16),
|
|
119
119
|
OID_MAX_SUBIDENTIFIER_BYTES: 32,
|
|
120
|
+
// Certificate Transparency SCT-list bounds (RFC 6962 §3.3). The outer sct_list
|
|
121
|
+
// vector carries a 2-byte length prefix, so a well-formed list body is at most
|
|
122
|
+
// 2^16-1 = 65535 bytes and the full TLS blob (prefix + body) is at most 65537.
|
|
123
|
+
// The byte cap sits at that structural maximum, so the largest conforming list
|
|
124
|
+
// is accepted while an oversized inner OCTET STRING (up to the DER document cap)
|
|
125
|
+
// is refused BEFORE the list is walked; the per-list count cap is likewise
|
|
126
|
+
// asserted before iteration so a hostile length prefix cannot drive unbounded
|
|
127
|
+
// work (the CVE-2022-0778 class: crafted bytes inside a certificate extension
|
|
128
|
+
// pinning a validator). A real chain carries 2-5 SCTs; 256 is far above policy.
|
|
129
|
+
SCT_MAX_BYTES: BYTES.kib(64) + 1,
|
|
130
|
+
SCT_MAX_COUNT: 256,
|
|
120
131
|
// Certification-path length ceiling: bounds the per-cert asymmetric verify
|
|
121
132
|
// work on an untrusted certificate bundle (a real chain is well under this;
|
|
122
133
|
// the operator may override via opts.maxPathCerts).
|
package/lib/ct.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.ct
|
|
6
|
+
* @nav Transparency
|
|
7
|
+
* @title CT
|
|
8
|
+
* @order 210
|
|
9
|
+
* @slug ct
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* Certificate Transparency SCT-list handling per RFC 6962. `parseSctList`
|
|
13
|
+
* decodes the `SignedCertificateTimestampList` an X.509 certificate (or an
|
|
14
|
+
* OCSP response) carries in the SCT extension into its individual signed
|
|
15
|
+
* certificate timestamps.
|
|
16
|
+
*
|
|
17
|
+
* The SCT payload is encoded in the TLS presentation language (RFC 8446 §3 /
|
|
18
|
+
* RFC 5246 §4 conventions) — positional, tag-less, fixed-width big-endian
|
|
19
|
+
* integers and length-prefixed opaque vectors — NOT ASN.1/DER. So this module
|
|
20
|
+
* owns a bounded big-endian TLS-struct reader rather than composing the DER
|
|
21
|
+
* schema engine; the only ASN.1 surface is the §3.3 double wrap (the
|
|
22
|
+
* extension value is a DER OCTET STRING whose content is another DER OCTET
|
|
23
|
+
* STRING whose content is the TLS list — the certificate/OCSP layer peels the
|
|
24
|
+
* outer, this module peels the inner).
|
|
25
|
+
*
|
|
26
|
+
* Structure is decoded, crypto is surfaced RAW: each SCT surfaces its `logId`
|
|
27
|
+
* (32 raw bytes — SHA-256 of the log's SPKI, never recomputed), the exact
|
|
28
|
+
* `timestamp` as a BigInt, the raw `extensions`, the named-but-not-interpreted
|
|
29
|
+
* `hashAlg`/`sigAlg` code points, and the raw `signature`. The parser NEVER
|
|
30
|
+
* verifies a signature, recomputes a LogID, or trusts a log — a verifier
|
|
31
|
+
* composes `webcrypto` over `reconstructSignedData(...)`, the exact
|
|
32
|
+
* `digitally-signed` preimage. DER-only carrier, fail-closed.
|
|
33
|
+
*
|
|
34
|
+
* @card
|
|
35
|
+
* Parse RFC 6962 Certificate Transparency SCT lists from a certificate or OCSP
|
|
36
|
+
* extension — per-SCT logId / timestamp (BigInt) / algorithm / raw signature,
|
|
37
|
+
* the signed-preimage reconstruction surfaced for external verification,
|
|
38
|
+
* bounded TLS-struct decode, fail-closed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var asn1 = require("./asn1-der.js");
|
|
42
|
+
var constants = require("./constants.js");
|
|
43
|
+
var frameworkError = require("./framework-error.js");
|
|
44
|
+
|
|
45
|
+
var CtError = frameworkError.CtError;
|
|
46
|
+
var C = constants;
|
|
47
|
+
|
|
48
|
+
// RFC 5246 §7.4.1.4.1 code points — 1-byte, NOT OIDs. Surfaced named; an
|
|
49
|
+
// unknown code surfaces as its numeric byte with a null name (never rejected —
|
|
50
|
+
// off-profile-pair rejection is a verifier-tier log-conformance concern).
|
|
51
|
+
var HASH_ALGORITHMS = {
|
|
52
|
+
0: "none", 1: "md5", 2: "sha1", 3: "sha224", 4: "sha256", 5: "sha384", 6: "sha512",
|
|
53
|
+
};
|
|
54
|
+
var SIGNATURE_ALGORITHMS = { 0: "anonymous", 1: "rsa", 2: "dsa", 3: "ecdsa" };
|
|
55
|
+
|
|
56
|
+
// A minimum viable v1 SCT body: version(1) + LogID(32) + timestamp(8) +
|
|
57
|
+
// empty-extensions(2) + digitally-signed{ hash(1) + sig(1) + empty-sig(2) } = 47.
|
|
58
|
+
var SCT_MIN_BODY = 47;
|
|
59
|
+
var LOGID_BYTES = 32;
|
|
60
|
+
var MAX_SAFE = 9007199254740991n; // 2^53 - 1; above this a Number loses precision
|
|
61
|
+
|
|
62
|
+
// ---- TlsReader — the one net-new primitive (an ENCODING layer, not a schema
|
|
63
|
+
// combinator): a bounded big-endian TLS-vector cursor with a hard [pos, end).
|
|
64
|
+
// A lying inner length can overrun only the current sub-reader's `end`, never
|
|
65
|
+
// the parent buffer, so bounds-before-slice is structural.
|
|
66
|
+
function TlsReader(buf, start, end) { this.buf = buf; this.pos = start; this.end = end; }
|
|
67
|
+
TlsReader.prototype._need = function (n, code) {
|
|
68
|
+
if (this.pos + n > this.end) {
|
|
69
|
+
throw new CtError(code || "ct/truncated", "need " + n + " byte(s), only " + (this.end - this.pos) + " remain");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
TlsReader.prototype.u8 = function (code) { this._need(1, code); return this.buf[this.pos++]; };
|
|
73
|
+
TlsReader.prototype.u16 = function (code) { this._need(2, code); var v = this.buf.readUInt16BE(this.pos); this.pos += 2; return v; };
|
|
74
|
+
TlsReader.prototype.u24 = function (code) {
|
|
75
|
+
this._need(3, code);
|
|
76
|
+
var v = (this.buf[this.pos] << 16) | (this.buf[this.pos + 1] << 8) | this.buf[this.pos + 2];
|
|
77
|
+
this.pos += 3; return v;
|
|
78
|
+
};
|
|
79
|
+
TlsReader.prototype.u64 = function (code) { this._need(8, code); var v = this.buf.readBigUInt64BE(this.pos); this.pos += 8; return v; };
|
|
80
|
+
TlsReader.prototype.fixed = function (n, code) {
|
|
81
|
+
this._need(n, code);
|
|
82
|
+
var s = this.buf.subarray(this.pos, this.pos + n); this.pos += n; return s;
|
|
83
|
+
};
|
|
84
|
+
// opaque<min..max> — a length-prefixed vector (lenWidth-byte big-endian prefix).
|
|
85
|
+
// The length prefix itself is a plain read (a truncation there is ct/truncated);
|
|
86
|
+
// only a LYING length whose body overruns the bound carries the field's own code.
|
|
87
|
+
TlsReader.prototype.vector = function (lenWidth, min, max, code) {
|
|
88
|
+
var len = lenWidth === 3 ? this.u24() : this.u16();
|
|
89
|
+
if (len < min) throw new CtError(code, "vector length " + len + " below minimum " + min);
|
|
90
|
+
if (max != null && len > max) throw new CtError(code, "vector length " + len + " above maximum " + max);
|
|
91
|
+
this._need(len, code);
|
|
92
|
+
var s = this.buf.subarray(this.pos, this.pos + len); this.pos += len; return s;
|
|
93
|
+
};
|
|
94
|
+
TlsReader.prototype.subReader = function (len, code) {
|
|
95
|
+
this._need(len, code);
|
|
96
|
+
var r = new TlsReader(this.buf, this.pos, this.pos + len); this.pos += len; return r;
|
|
97
|
+
};
|
|
98
|
+
TlsReader.prototype.remaining = function () { return this.end - this.pos; };
|
|
99
|
+
TlsReader.prototype.atEnd = function () { return this.pos === this.end; };
|
|
100
|
+
|
|
101
|
+
// Peel the RFC 6962 §3.3 inner DER OCTET STRING (the certificate/OCSP layer
|
|
102
|
+
// already peeled the outer extnValue OCTET STRING). Rides the strict codec, so
|
|
103
|
+
// an indefinite length / constructed OCTET STRING / trailing bytes / single
|
|
104
|
+
// wrap all fail closed here; the asn1/* fault attaches as `.cause`.
|
|
105
|
+
function _peelInner(extValue) {
|
|
106
|
+
var node;
|
|
107
|
+
try { node = asn1.decode(extValue); }
|
|
108
|
+
catch (e) { throw new CtError("ct/bad-der", "the SCT-list extension value is not valid DER (RFC 6962 §3.3)", e); }
|
|
109
|
+
try { return asn1.read.octetString(node); }
|
|
110
|
+
catch (e) { throw new CtError("ct/bad-der", "the SCT-list extension value must be a DER OCTET STRING wrapping the TLS list (RFC 6962 §3.3)", e); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _toBuffer(v, field) {
|
|
114
|
+
if (Buffer.isBuffer(v)) return v;
|
|
115
|
+
if (v instanceof Uint8Array) return Buffer.from(v);
|
|
116
|
+
throw new CtError("ct/bad-input", field + " must be a Buffer or Uint8Array");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Parse one SerializedSCT body inside a sub-reader bounded to the element (so a
|
|
120
|
+
// lying extensions/signature length overruns the element, never the list). A
|
|
121
|
+
// version this parser does not define is preserved OPAQUE, not rejected: RFC 6962
|
|
122
|
+
// §3.3 gives every SerializedSCT its own length prefix precisely so a client "can
|
|
123
|
+
// still parse old SCTs while skipping over new SCTs whose versions they don't
|
|
124
|
+
// understand" — so an unknown version yields { unknown, version, rawSct } and the
|
|
125
|
+
// v1-specific field decode (and the 47-byte floor) is skipped.
|
|
126
|
+
function _parseSct(r, sctLen) {
|
|
127
|
+
var bodyStart = r.pos;
|
|
128
|
+
var version = r.u8(); // sctLen >= 1 is guaranteed by the SerializedSCT<1..> check
|
|
129
|
+
if (version !== 0) {
|
|
130
|
+
return { unknown: true, version: version, rawSct: r.buf.subarray(bodyStart, r.end) };
|
|
131
|
+
}
|
|
132
|
+
if (sctLen < SCT_MIN_BODY) {
|
|
133
|
+
throw new CtError("ct/sct-too-short", "a v1 SCT body is at least " + SCT_MIN_BODY + " bytes, got " + sctLen + " (RFC 6962 §3.2)");
|
|
134
|
+
}
|
|
135
|
+
var logId = r.fixed(LOGID_BYTES);
|
|
136
|
+
var timestamp = r.u64();
|
|
137
|
+
var extensions = r.vector(2, 0, null, "ct/ext-overrun");
|
|
138
|
+
var hashAlg = r.u8();
|
|
139
|
+
var sigAlg = r.u8();
|
|
140
|
+
var signature = r.vector(2, 0, null, "ct/sig-overrun");
|
|
141
|
+
if (!r.atEnd()) {
|
|
142
|
+
throw new CtError("ct/sct-trailing-bytes", (r.end - r.pos) + " byte(s) left in a SerializedSCT after the signature (RFC 6962 §3.3)");
|
|
143
|
+
}
|
|
144
|
+
var timestampMs = timestamp <= MAX_SAFE ? Number(timestamp) : null;
|
|
145
|
+
return {
|
|
146
|
+
version: 0,
|
|
147
|
+
logId: logId, logIdHex: logId.toString("hex"),
|
|
148
|
+
timestamp: timestamp,
|
|
149
|
+
timestampMs: timestampMs,
|
|
150
|
+
timestampDate: new Date(timestampMs != null ? timestampMs : Number(timestamp)),
|
|
151
|
+
extensions: extensions,
|
|
152
|
+
hashAlg: hashAlg, sigAlg: sigAlg,
|
|
153
|
+
signatureAlgorithm: {
|
|
154
|
+
hash: hashAlg, hashName: HASH_ALGORITHMS[hashAlg] || null,
|
|
155
|
+
signature: sigAlg, signatureName: SIGNATURE_ALGORITHMS[sigAlg] || null,
|
|
156
|
+
},
|
|
157
|
+
signature: signature,
|
|
158
|
+
rawSct: r.buf.subarray(bodyStart, r.end),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @primitive pki.ct.parseSctList
|
|
164
|
+
* @signature pki.ct.parseSctList(extValue) -> { scts, unknownScts }
|
|
165
|
+
* @since 0.1.20
|
|
166
|
+
* @status experimental
|
|
167
|
+
* @spec RFC 6962, RFC 5246, RFC 8446
|
|
168
|
+
* @related pki.ct.reconstructSignedData, pki.schema.x509.parse
|
|
169
|
+
*
|
|
170
|
+
* Parse the value of an RFC 6962 SCT-list extension (the raw `extnValue`
|
|
171
|
+
* content an `x509.parse` / OCSP extension already surfaces) into
|
|
172
|
+
* `{ scts, unknownScts }`. Each entry of `scts` is a fully decoded v1 SCT:
|
|
173
|
+
* `version` (0), `logId` (32-byte Buffer) + `logIdHex`, `timestamp` (BigInt,
|
|
174
|
+
* exact) + `timestampMs` (Number or `null` above 2^53) + `timestampDate`,
|
|
175
|
+
* `extensions` (raw Buffer), `hashAlg` / `sigAlg` (1-byte code points) + a named
|
|
176
|
+
* `signatureAlgorithm`, the raw `signature` Buffer, and `rawSct` (the full
|
|
177
|
+
* SerializedSCT body). A SerializedSCT whose version this parser does not define
|
|
178
|
+
* is preserved OPAQUE in `unknownScts` as `{ version, rawSct }` rather than
|
|
179
|
+
* failing the list — RFC 6962 §3.3 frames each SerializedSCT with its own length
|
|
180
|
+
* so unknown versions are skippable (forward compatibility).
|
|
181
|
+
*
|
|
182
|
+
* The extension value is a DER `OCTET STRING` wrapping the TLS-encoded list
|
|
183
|
+
* (RFC 6962 §3.3 double wrap); everything below that peel is TLS presentation
|
|
184
|
+
* language, decoded with a bounded cursor. Structure is decoded, crypto is
|
|
185
|
+
* surfaced RAW — the signature is never verified and the LogID never recomputed.
|
|
186
|
+
*
|
|
187
|
+
* Throws `CtError` with a stable `ct/*` code on any malformed input (a bad inner
|
|
188
|
+
* DER wrap is `ct/bad-der` with the `asn1/*` fault as `.cause`), never a raw
|
|
189
|
+
* `TypeError`.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* var cert = pki.schema.x509.parse(pem);
|
|
193
|
+
* var sctOid = pki.oid.byName("signedCertificateTimestampList");
|
|
194
|
+
* var ext = (cert.extensions || []).find(function (e) { return e.oid === sctOid; });
|
|
195
|
+
* if (ext) {
|
|
196
|
+
* var list = pki.ct.parseSctList(ext.value);
|
|
197
|
+
* list.scts[0].logIdHex; // the log's key id
|
|
198
|
+
* list.scts[0].timestamp; // exact BigInt ms since epoch
|
|
199
|
+
* }
|
|
200
|
+
*/
|
|
201
|
+
function parseSctList(extValue) {
|
|
202
|
+
if (!Buffer.isBuffer(extValue) && !(extValue instanceof Uint8Array)) {
|
|
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));
|
|
206
|
+
if (blob.length > C.LIMITS.SCT_MAX_BYTES) {
|
|
207
|
+
throw new CtError("ct/too-large", "SCT list " + blob.length + " bytes exceeds the cap " + C.LIMITS.SCT_MAX_BYTES);
|
|
208
|
+
}
|
|
209
|
+
var outer = new TlsReader(blob, 0, blob.length);
|
|
210
|
+
var listLen = outer.u16("ct/bad-list");
|
|
211
|
+
if (listLen + 2 !== blob.length) {
|
|
212
|
+
throw new CtError("ct/bad-list", "the SCT list declared length " + listLen + " does not match the " + (blob.length - 2) + " byte(s) present (RFC 6962 §3.3)");
|
|
213
|
+
}
|
|
214
|
+
if (listLen < 1) {
|
|
215
|
+
throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 §3.3)");
|
|
216
|
+
}
|
|
217
|
+
var scts = [], unknownScts = [];
|
|
218
|
+
while (!outer.atEnd()) {
|
|
219
|
+
if (outer.remaining() < 2) {
|
|
220
|
+
throw new CtError("ct/list-trailing-bytes", "a dangling partial element after the last complete SCT (RFC 6962 §3.3)");
|
|
221
|
+
}
|
|
222
|
+
var sctLen = outer.u16("ct/list-trailing-bytes");
|
|
223
|
+
if (sctLen < 1) {
|
|
224
|
+
throw new CtError("ct/sct-empty", "a SerializedSCT must be non-empty (RFC 6962 §3.3)");
|
|
225
|
+
}
|
|
226
|
+
if (outer.remaining() < sctLen) {
|
|
227
|
+
throw new CtError("ct/list-trailing-bytes", "a SerializedSCT length " + sctLen + " overruns the list (RFC 6962 §3.3)");
|
|
228
|
+
}
|
|
229
|
+
var one = _parseSct(outer.subReader(sctLen, "ct/list-trailing-bytes"), sctLen);
|
|
230
|
+
if (one.unknown) unknownScts.push({ version: one.version, rawSct: one.rawSct });
|
|
231
|
+
else scts.push(one);
|
|
232
|
+
// The cap bounds the TOTAL element count (known + preserved-unknown) before it
|
|
233
|
+
// can drive unbounded per-element work.
|
|
234
|
+
if (scts.length + unknownScts.length > C.LIMITS.SCT_MAX_COUNT) {
|
|
235
|
+
throw new CtError("ct/too-many-scts", "SCT count exceeds the cap " + C.LIMITS.SCT_MAX_COUNT);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { scts: scts, unknownScts: unknownScts };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _u24Bytes(n) {
|
|
242
|
+
if (n < 1 || n > 0xffffff) {
|
|
243
|
+
throw new CtError("ct/bad-tbs-length", "a certificate / TBSCertificate length must be in 1..2^24-1, got " + n + " (RFC 6962 §3.1)");
|
|
244
|
+
}
|
|
245
|
+
return Buffer.from([(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @primitive pki.ct.reconstructSignedData
|
|
250
|
+
* @signature pki.ct.reconstructSignedData(entry, sct) -> Buffer
|
|
251
|
+
* @since 0.1.20
|
|
252
|
+
* @status experimental
|
|
253
|
+
* @spec RFC 6962
|
|
254
|
+
* @related pki.ct.parseSctList
|
|
255
|
+
*
|
|
256
|
+
* Rebuild the exact `digitally-signed` preimage bytes an external verifier
|
|
257
|
+
* hashes to check an SCT's signature (RFC 6962 §3.2), for a parsed `sct`.
|
|
258
|
+
* `entry` selects the log-entry arm:
|
|
259
|
+
* - `{ entryType: 0, leafCert: <DER Buffer> }` — an SCT delivered over TLS /
|
|
260
|
+
* OCSP, signed over `x509_entry(0)` with the leaf certificate.
|
|
261
|
+
* - `{ entryType: 1, tbsCertificate: <DER Buffer>, issuerKeyHash: <32B> }` —
|
|
262
|
+
* an SCT EMBEDDED in a certificate, signed over `precert_entry(1)` with the
|
|
263
|
+
* issuer key hash + the precertificate TBS (the TBS with only the SCT
|
|
264
|
+
* extension removed). `issuerKeyHash` is SHA-256 of the issuer's SPKI DER.
|
|
265
|
+
*
|
|
266
|
+
* The preimage reuses the parsed SCT's raw `extensions` byte-for-byte and
|
|
267
|
+
* re-emits the fixed-width scalars canonically. This never verifies anything —
|
|
268
|
+
* a verifier hashes the returned bytes and checks the signature with the log's
|
|
269
|
+
* public key (compose `webcrypto`). Throws `CtError` (`ct/bad-entry-type`,
|
|
270
|
+
* `ct/bad-issuer-key-hash`, `ct/bad-tbs-length`) on a malformed entry.
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* var sct = pki.ct.parseSctList(sctExtValue).scts[0];
|
|
274
|
+
* var preimage = pki.ct.reconstructSignedData({ entryType: 0, leafCert: der }, sct);
|
|
275
|
+
* // hash `preimage` + verify against the log's public key at the verify layer
|
|
276
|
+
*/
|
|
277
|
+
function reconstructSignedData(entry, sct) {
|
|
278
|
+
entry = entry || {};
|
|
279
|
+
var entryType = entry.entryType;
|
|
280
|
+
if (entryType !== 0 && entryType !== 1) {
|
|
281
|
+
throw new CtError("ct/bad-entry-type", "entryType must be x509_entry(0) or precert_entry(1), got " + entryType + " (RFC 6962 §3.1)");
|
|
282
|
+
}
|
|
283
|
+
// A fully decoded v1 SCT (from parseSctList().scts[]) — not an opaque
|
|
284
|
+
// unknownScts entry, whose body layout is undefined and cannot be signed over.
|
|
285
|
+
if (!sct || typeof sct.timestamp !== "bigint" || sct.version !== 0) {
|
|
286
|
+
throw new CtError("ct/bad-input", "reconstructSignedData expects a decoded v1 SCT from parseSctList().scts[]");
|
|
287
|
+
}
|
|
288
|
+
var parts = [];
|
|
289
|
+
parts.push(Buffer.from([sct.version & 0xff])); // Version — v1(0)
|
|
290
|
+
parts.push(Buffer.from([0])); // SignatureType — certificate_timestamp(0)
|
|
291
|
+
var ts = Buffer.alloc(8); ts.writeBigUInt64BE(BigInt(sct.timestamp)); parts.push(ts); // uint64 timestamp
|
|
292
|
+
parts.push(Buffer.from([(entryType >> 8) & 0xff, entryType & 0xff])); // LogEntryType (2 bytes BE)
|
|
293
|
+
if (entryType === 0) {
|
|
294
|
+
var cert = _toBuffer(entry.leafCert, "leafCert");
|
|
295
|
+
parts.push(_u24Bytes(cert.length)); parts.push(cert); // ASN.1Cert<1..2^24-1>
|
|
296
|
+
} else {
|
|
297
|
+
var ikh = _toBuffer(entry.issuerKeyHash, "issuerKeyHash");
|
|
298
|
+
if (ikh.length !== 32) {
|
|
299
|
+
throw new CtError("ct/bad-issuer-key-hash", "issuer_key_hash must be exactly 32 bytes (SHA-256 of the issuer SPKI), got " + ikh.length + " (RFC 6962 §3.2)");
|
|
300
|
+
}
|
|
301
|
+
var tbs = _toBuffer(entry.tbsCertificate, "tbsCertificate");
|
|
302
|
+
parts.push(ikh);
|
|
303
|
+
parts.push(_u24Bytes(tbs.length)); parts.push(tbs); // PreCert.tbs_certificate<1..2^24-1>
|
|
304
|
+
}
|
|
305
|
+
var ext = _toBuffer(sct.extensions, "sct.extensions"); // reuse the parsed raw bytes, never re-encode
|
|
306
|
+
parts.push(Buffer.from([(ext.length >> 8) & 0xff, ext.length & 0xff])); parts.push(ext); // CtExtensions
|
|
307
|
+
return Buffer.concat(parts);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
parseSctList: parseSctList,
|
|
312
|
+
reconstructSignedData: reconstructSignedData,
|
|
313
|
+
HASH_ALGORITHMS: HASH_ALGORITHMS,
|
|
314
|
+
SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
|
|
315
|
+
};
|
package/lib/framework-error.js
CHANGED
|
@@ -179,6 +179,15 @@ var CmpError = defineClass("CmpError", { withCause: true });
|
|
|
179
179
|
// `.cause`.
|
|
180
180
|
var PathError = defineClass("PathError", { withCause: true });
|
|
181
181
|
|
|
182
|
+
// CtError — a byte sequence that is not a well-formed RFC 6962 Certificate
|
|
183
|
+
// Transparency SCT list: a malformed inner DER OCTET-STRING wrap, or a TLS
|
|
184
|
+
// presentation-language framing violation (a lying vector length, a field read
|
|
185
|
+
// past its bound, a truncated element). An SCT whose version this parser does
|
|
186
|
+
// not define is NOT an error — RFC 6962 §3.3 makes unknown versions skippable,
|
|
187
|
+
// so they are preserved opaque. Carries the underlying leaf fault (e.g. the
|
|
188
|
+
// inner `asn1/*` decode error) as `.cause`.
|
|
189
|
+
var CtError = defineClass("CtError", { withCause: true });
|
|
190
|
+
|
|
182
191
|
module.exports = {
|
|
183
192
|
PkiError: PkiError,
|
|
184
193
|
defineClass: defineClass,
|
|
@@ -199,4 +208,5 @@ module.exports = {
|
|
|
199
208
|
Pkcs12Error: Pkcs12Error,
|
|
200
209
|
CmpError: CmpError,
|
|
201
210
|
PathError: PathError,
|
|
211
|
+
CtError: CtError,
|
|
202
212
|
};
|
package/lib/oid.js
CHANGED
|
@@ -116,6 +116,15 @@ var FAMILIES = {
|
|
|
116
116
|
pkixKp: { base: [1, 3, 6, 1, 5, 5, 7, 3], of: {
|
|
117
117
|
serverAuth: 1, clientAuth: 2, codeSigning: 3, emailProtection: 4, timeStamping: 8, ocspSigning: 9 } },
|
|
118
118
|
|
|
119
|
+
// Google Certificate Transparency (RFC 6962) on the 1.3.6.1.4.1.11129.2.4 arc:
|
|
120
|
+
// the SCT-list X.509 extension (§3.3), the precertificate poison (§3.1), the
|
|
121
|
+
// precert-signing EKU (§3.1, naming only), and the OCSP-delivered SCT list
|
|
122
|
+
// (§3.3). The SCT payload itself is TLS presentation language, not DER — it is
|
|
123
|
+
// parsed by lib/ct.js (pki.ct), never routed through the DER schema engine.
|
|
124
|
+
ct: { base: [1, 3, 6, 1, 4, 1, 11129, 2, 4], of: {
|
|
125
|
+
signedCertificateTimestampList: 2, precertificatePoison: 3,
|
|
126
|
+
precertificateSigningCert: 4, ocspSignedCertificateTimestampList: 5 } },
|
|
127
|
+
|
|
119
128
|
// PKCS#1 RSA public-key + RSASSA signature algorithms.
|
|
120
129
|
rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
|
|
121
130
|
rsaEncryption: 1, rsaesOaep: 7, mgf1: 8, rsassaPss: 10,
|
|
@@ -208,9 +217,10 @@ var FAMILIES = {
|
|
|
208
217
|
sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake256: 12 } },
|
|
209
218
|
|
|
210
219
|
// NIST signature algorithms — FIPS 204 ML-DSA + FIPS 205 SLH-DSA share the
|
|
211
|
-
// signature arc 2.16.840.1.101.3.4.3.
|
|
212
|
-
//
|
|
213
|
-
// sets .
|
|
220
|
+
// signature arc 2.16.840.1.101.3.4.3. RFC 9909 §3 assigns the 12 Pure SLH-DSA
|
|
221
|
+
// sets .20-.31 (SHA-2 .20-.25, SHAKE .26-.31) and the 12 pre-hash HashSLH-DSA
|
|
222
|
+
// sets .35-.46, each pairing a parameter set with its message-digest hash. The
|
|
223
|
+
// parameters MUST be absent for every one of them (enforced via paramsMustBeAbsent).
|
|
214
224
|
nistSig: { base: [2, 16, 840, 1, 101, 3, 4, 3], of: {
|
|
215
225
|
"id-ml-dsa-44": 17, "id-ml-dsa-65": 18, "id-ml-dsa-87": 19,
|
|
216
226
|
"id-slh-dsa-sha2-128s": 20, "id-slh-dsa-sha2-128f": 21,
|
|
@@ -218,7 +228,13 @@ var FAMILIES = {
|
|
|
218
228
|
"id-slh-dsa-sha2-256s": 24, "id-slh-dsa-sha2-256f": 25,
|
|
219
229
|
"id-slh-dsa-shake-128s": 26, "id-slh-dsa-shake-128f": 27,
|
|
220
230
|
"id-slh-dsa-shake-192s": 28, "id-slh-dsa-shake-192f": 29,
|
|
221
|
-
"id-slh-dsa-shake-256s": 30, "id-slh-dsa-shake-256f": 31
|
|
231
|
+
"id-slh-dsa-shake-256s": 30, "id-slh-dsa-shake-256f": 31,
|
|
232
|
+
"id-hash-slh-dsa-sha2-128s-with-sha256": 35, "id-hash-slh-dsa-sha2-128f-with-sha256": 36,
|
|
233
|
+
"id-hash-slh-dsa-sha2-192s-with-sha512": 37, "id-hash-slh-dsa-sha2-192f-with-sha512": 38,
|
|
234
|
+
"id-hash-slh-dsa-sha2-256s-with-sha512": 39, "id-hash-slh-dsa-sha2-256f-with-sha512": 40,
|
|
235
|
+
"id-hash-slh-dsa-shake-128s-with-shake128": 41, "id-hash-slh-dsa-shake-128f-with-shake128": 42,
|
|
236
|
+
"id-hash-slh-dsa-shake-192s-with-shake256": 43, "id-hash-slh-dsa-shake-192f-with-shake256": 44,
|
|
237
|
+
"id-hash-slh-dsa-shake-256s-with-shake256": 45, "id-hash-slh-dsa-shake-256f-with-shake256": 46 } },
|
|
222
238
|
|
|
223
239
|
// NIST KEM — FIPS 203 ML-KEM (arc 2.16.840.1.101.3.4.4).
|
|
224
240
|
nistKem: { base: [2, 16, 840, 1, 101, 3, 4, 4], of: {
|
|
@@ -393,10 +409,58 @@ function fromDER(input) {
|
|
|
393
409
|
return asn1.read.oid(node);
|
|
394
410
|
}
|
|
395
411
|
|
|
412
|
+
// Algorithm identifiers whose `parameters` field MUST be absent — no explicit
|
|
413
|
+
// NULL, no bytes. The FIPS 204 ML-DSA and FIPS 205 SLH-DSA signature families
|
|
414
|
+
// and the RFC 8410 Edwards / Montgomery curves each carry this MUST: RFC 9909 §3
|
|
415
|
+
// (SLH-DSA in X.509), RFC 9814 §4 (SLH-DSA in CMS), RFC 9881 §2 (ML-DSA in X.509),
|
|
416
|
+
// RFC 8410 §3 (Ed25519 / Ed448 / X25519 / X448). Built by NAME through the same
|
|
417
|
+
// registry every caller uses, so a typo fails closed at load rather than silently
|
|
418
|
+
// dropping a member. One set drives the single shared AlgorithmIdentifier reject
|
|
419
|
+
// (schema-pkix), so every format consumer inherits the rule with no per-format code.
|
|
420
|
+
var _PARAMS_ABSENT = new Set();
|
|
421
|
+
[
|
|
422
|
+
"id-ml-dsa-44", "id-ml-dsa-65", "id-ml-dsa-87",
|
|
423
|
+
"id-slh-dsa-sha2-128s", "id-slh-dsa-sha2-128f", "id-slh-dsa-sha2-192s",
|
|
424
|
+
"id-slh-dsa-sha2-192f", "id-slh-dsa-sha2-256s", "id-slh-dsa-sha2-256f",
|
|
425
|
+
"id-slh-dsa-shake-128s", "id-slh-dsa-shake-128f", "id-slh-dsa-shake-192s",
|
|
426
|
+
"id-slh-dsa-shake-192f", "id-slh-dsa-shake-256s", "id-slh-dsa-shake-256f",
|
|
427
|
+
// Pre-hash HashSLH-DSA sets (RFC 9909 §3) — parameters MUST be absent too.
|
|
428
|
+
"id-hash-slh-dsa-sha2-128s-with-sha256", "id-hash-slh-dsa-sha2-128f-with-sha256",
|
|
429
|
+
"id-hash-slh-dsa-sha2-192s-with-sha512", "id-hash-slh-dsa-sha2-192f-with-sha512",
|
|
430
|
+
"id-hash-slh-dsa-sha2-256s-with-sha512", "id-hash-slh-dsa-sha2-256f-with-sha512",
|
|
431
|
+
"id-hash-slh-dsa-shake-128s-with-shake128", "id-hash-slh-dsa-shake-128f-with-shake128",
|
|
432
|
+
"id-hash-slh-dsa-shake-192s-with-shake256", "id-hash-slh-dsa-shake-192f-with-shake256",
|
|
433
|
+
"id-hash-slh-dsa-shake-256s-with-shake256", "id-hash-slh-dsa-shake-256f-with-shake256",
|
|
434
|
+
"Ed25519", "Ed448", "X25519", "X448",
|
|
435
|
+
].forEach(function (nm) { _PARAMS_ABSENT.add(byName(nm)); });
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @primitive pki.oid.paramsMustBeAbsent
|
|
439
|
+
* @signature pki.oid.paramsMustBeAbsent(dotted) -> boolean
|
|
440
|
+
* @since 0.1.21
|
|
441
|
+
* @status experimental
|
|
442
|
+
* @spec RFC 9909, RFC 9814, RFC 9881, RFC 8410
|
|
443
|
+
* @related pki.oid.name, pki.oid.byName
|
|
444
|
+
*
|
|
445
|
+
* True when an AlgorithmIdentifier bearing this OID MUST encode its `parameters`
|
|
446
|
+
* field as ABSENT (not an explicit NULL, not any bytes): the FIPS 204 ML-DSA and
|
|
447
|
+
* FIPS 205 SLH-DSA signature families and the RFC 8410 Edwards / Montgomery
|
|
448
|
+
* curves. The shared AlgorithmIdentifier decoder consults this and fails closed
|
|
449
|
+
* on a present-parameters violation, so every format inherits the rule.
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* pki.oid.paramsMustBeAbsent(pki.oid.byName("id-slh-dsa-sha2-128s")); // -> true
|
|
453
|
+
* pki.oid.paramsMustBeAbsent(pki.oid.byName("rsaEncryption")); // -> false
|
|
454
|
+
*/
|
|
455
|
+
function paramsMustBeAbsent(dotted) {
|
|
456
|
+
return _PARAMS_ABSENT.has(dotted);
|
|
457
|
+
}
|
|
458
|
+
|
|
396
459
|
module.exports = {
|
|
397
460
|
name: name,
|
|
398
461
|
byName: byName,
|
|
399
462
|
has: has,
|
|
463
|
+
paramsMustBeAbsent: paramsMustBeAbsent,
|
|
400
464
|
register: register,
|
|
401
465
|
registerFamily: registerFamily,
|
|
402
466
|
all: all,
|
package/lib/path-validate.js
CHANGED
|
@@ -86,9 +86,16 @@ var SIG_ALGS = {};
|
|
|
86
86
|
// NULL must be present — RSASSA-PKCS1-v1_5, RFC 4055 §5) or "absent" (parameters
|
|
87
87
|
// must be omitted — ECDSA/EdDSA/ML-DSA, RFC 5758/8410). A cert deviating from
|
|
88
88
|
// its algorithm's required shape is malformed and rejected before verify.
|
|
89
|
-
|
|
89
|
+
// `sameKeyOid` marks the one-shot families whose PUBLIC-KEY algorithm OID is the
|
|
90
|
+
// SAME as the signature algorithm OID — EdDSA, ML-DSA, SLH-DSA. For these, Node's
|
|
91
|
+
// WebCrypto imports an SPKI of ANOTHER type under the requested name and verifies
|
|
92
|
+
// with the real key (it does NOT reject a mismatched SPKI the way it does for
|
|
93
|
+
// RSA/ECDSA), so the issuer-key <-> signature-algorithm consistency (RFC 9814 §4)
|
|
94
|
+
// must be checked structurally: the SPKI OID must equal the signature OID.
|
|
95
|
+
function _sig(name, verify, imp, params, ecdsa, sameKeyOid) {
|
|
90
96
|
var entry = { verify: verify, imp: imp, params: params };
|
|
91
97
|
if (ecdsa) entry.ecdsa = true;
|
|
98
|
+
if (sameKeyOid) entry.sameKeyOid = true;
|
|
92
99
|
SIG_ALGS[oid.byName(name)] = entry;
|
|
93
100
|
}
|
|
94
101
|
// RSASSA-PKCS1-v1_5 — parameters MUST be NULL.
|
|
@@ -99,13 +106,22 @@ _sig("sha512WithRSAEncryption", { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" },
|
|
|
99
106
|
_sig("ecdsaWithSHA256", { name: "ECDSA", hash: "SHA-256" }, { name: "ECDSA" }, "absent", true);
|
|
100
107
|
_sig("ecdsaWithSHA384", { name: "ECDSA", hash: "SHA-384" }, { name: "ECDSA" }, "absent", true);
|
|
101
108
|
_sig("ecdsaWithSHA512", { name: "ECDSA", hash: "SHA-512" }, { name: "ECDSA" }, "absent", true);
|
|
102
|
-
// EdDSA (one-shot, no hash parameter) — params absent.
|
|
103
|
-
_sig("Ed25519", { name: "Ed25519" }, { name: "Ed25519" }, "absent");
|
|
104
|
-
_sig("Ed448", { name: "Ed448" }, { name: "Ed448" }, "absent");
|
|
105
|
-
// ML-DSA (FIPS 204) — params absent.
|
|
106
|
-
_sig("id-ml-dsa-44", { name: "ML-DSA-44" }, { name: "ML-DSA-44" }, "absent");
|
|
107
|
-
_sig("id-ml-dsa-65", { name: "ML-DSA-65" }, { name: "ML-DSA-65" }, "absent");
|
|
108
|
-
_sig("id-ml-dsa-87", { name: "ML-DSA-87" }, { name: "ML-DSA-87" }, "absent");
|
|
109
|
+
// EdDSA (one-shot, no hash parameter) — params absent; key OID == sig OID.
|
|
110
|
+
_sig("Ed25519", { name: "Ed25519" }, { name: "Ed25519" }, "absent", false, true);
|
|
111
|
+
_sig("Ed448", { name: "Ed448" }, { name: "Ed448" }, "absent", false, true);
|
|
112
|
+
// ML-DSA (FIPS 204) — params absent; key OID == sig OID.
|
|
113
|
+
_sig("id-ml-dsa-44", { name: "ML-DSA-44" }, { name: "ML-DSA-44" }, "absent", false, true);
|
|
114
|
+
_sig("id-ml-dsa-65", { name: "ML-DSA-65" }, { name: "ML-DSA-65" }, "absent", false, true);
|
|
115
|
+
_sig("id-ml-dsa-87", { name: "ML-DSA-87" }, { name: "ML-DSA-87" }, "absent", false, true);
|
|
116
|
+
// SLH-DSA (FIPS 205) — params absent; key OID == sig OID. The twelve pure sets;
|
|
117
|
+
// the RFC 9909 §3 OID name maps to the WebCrypto set name by id-slh-dsa-<set> ->
|
|
118
|
+
// SLH-DSA-<SET> (the webcrypto SLH_DSA_NODE keys). One-shot verify like ML-DSA.
|
|
119
|
+
["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
|
|
120
|
+
"shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
|
|
121
|
+
].forEach(function (set) {
|
|
122
|
+
var wc = "SLH-DSA-" + set.toUpperCase();
|
|
123
|
+
_sig("id-slh-dsa-" + set, { name: wc }, { name: wc }, "absent", false, true);
|
|
124
|
+
});
|
|
109
125
|
|
|
110
126
|
// RSASSA-PSS resolves its hash + salt from the AlgorithmIdentifier parameters.
|
|
111
127
|
var OID_RSA_PSS = oid.byName("rsassaPss");
|
|
@@ -238,6 +254,24 @@ function resolveDescriptor(sigAlg) {
|
|
|
238
254
|
return d;
|
|
239
255
|
}
|
|
240
256
|
|
|
257
|
+
// RFC 9814 §4 issuer-key <-> signature-algorithm consistency (algorithm-confusion
|
|
258
|
+
// defense). For the one-shot families whose public key shares the signature OID
|
|
259
|
+
// (EdDSA, ML-DSA, SLH-DSA), Node's WebCrypto imports an SPKI of a DIFFERENT type
|
|
260
|
+
// under the requested name and verifies with the real key — so an Ed25519-signed
|
|
261
|
+
// certificate labelled SLH-DSA would otherwise validate. Enforce structurally:
|
|
262
|
+
// the issuer SPKI's algorithm OID MUST equal the signature algorithm OID. (For
|
|
263
|
+
// RSA/ECDSA — different key vs signature OIDs — WebCrypto's import already rejects
|
|
264
|
+
// a mismatched key type, so `sameKeyOid` is not set and this is a no-op.)
|
|
265
|
+
function assertKeyMatchesSigAlg(spkiBytes, sigOid, d) {
|
|
266
|
+
if (!d || !d.sameKeyOid) return;
|
|
267
|
+
var keyOid;
|
|
268
|
+
try { keyOid = asn1.read.oid(asn1.decode(spkiBytes).children[0].children[0]); }
|
|
269
|
+
catch (e) { throw E("path/algorithm-mismatch", "cannot read the issuer public-key algorithm identifier", e); }
|
|
270
|
+
if (keyOid !== sigOid) {
|
|
271
|
+
throw E("path/algorithm-mismatch", "issuer public-key algorithm " + keyOid + " does not match the signature algorithm " + sigOid + " (RFC 9814 §4 - algorithm confusion)");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
241
275
|
// DER Ecdsa-Sig-Value SEQUENCE { r, s } -> fixed-width r||s (P1363), rejecting
|
|
242
276
|
// r or s outside [1, n-1] (CVE-2022-21449 "Psychic Signatures").
|
|
243
277
|
function ecdsaDerToP1363(der, curve) {
|
|
@@ -270,8 +304,10 @@ function ecdsaDerToP1363(der, curve) {
|
|
|
270
304
|
// Verify cert.signatureValue over cert.tbsBytes with the working public key.
|
|
271
305
|
function builtinVerify(state, cert) {
|
|
272
306
|
var d;
|
|
273
|
-
try {
|
|
274
|
-
|
|
307
|
+
try {
|
|
308
|
+
d = resolveDescriptor(cert.signatureAlgorithm);
|
|
309
|
+
assertKeyMatchesSigAlg(state.workingPublicKey, cert.signatureAlgorithm.oid, d);
|
|
310
|
+
} catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/unsupported-algorithm"), error: e }); }
|
|
275
311
|
// The signature is an octet-aligned BIT STRING (no unused bits) for every
|
|
276
312
|
// supported algorithm; a non-zero unused-bit count is malformed.
|
|
277
313
|
if (cert.signatureValue.unusedBits !== 0) return Promise.resolve({ ok: false, code: "path/bad-signature" });
|
|
@@ -1387,8 +1423,10 @@ function crlEntryReason(entry) {
|
|
|
1387
1423
|
|
|
1388
1424
|
function verifyCrlSignature(theCrl, issuer) {
|
|
1389
1425
|
var d;
|
|
1390
|
-
try {
|
|
1391
|
-
|
|
1426
|
+
try {
|
|
1427
|
+
d = resolveDescriptor(theCrl.signatureAlgorithm);
|
|
1428
|
+
assertKeyMatchesSigAlg(issuer.workingPublicKey, theCrl.signatureAlgorithm.oid, d);
|
|
1429
|
+
} catch (_e) { return Promise.resolve(false); }
|
|
1392
1430
|
if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
|
|
1393
1431
|
var key;
|
|
1394
1432
|
return subtle.importKey("spki", issuer.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
|
package/lib/schema-pkix.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
var asn1 = require("./asn1-der");
|
|
17
17
|
var constants = require("./constants");
|
|
18
18
|
var schema = require("./schema-engine");
|
|
19
|
+
// ct is a leaf format module (requires only the codec / oid / constants / error
|
|
20
|
+
// core, never pkix) so the SCT-list extension VALUE decoder registers here as a
|
|
21
|
+
// registry row, not a switch. The one-directional edge keeps the layering acyclic.
|
|
22
|
+
var ct = require("./ct");
|
|
19
23
|
|
|
20
24
|
var PEM_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
|
|
21
25
|
|
|
@@ -152,6 +156,15 @@ function algorithmIdentifier(ns, opts) {
|
|
|
152
156
|
arity: { min: 1 }, code: ns.prefix + "/bad-algorithm-identifier", what: "AlgorithmIdentifier",
|
|
153
157
|
build: function (m, ctx) {
|
|
154
158
|
var dotted = m.fields.algorithm.value;
|
|
159
|
+
// RFC 9909 §3 / RFC 9814 §4 / RFC 9881 §2 / RFC 8410 §3: for the PQC
|
|
160
|
+
// signature families (ML-DSA, SLH-DSA) and the Edwards/Montgomery curves,
|
|
161
|
+
// the `parameters` field MUST be absent — no explicit NULL, no bytes.
|
|
162
|
+
// Enforced ONCE here so every consumer of the shared AlgorithmIdentifier
|
|
163
|
+
// inherits the rule; the guard-parity bug class is structurally impossible.
|
|
164
|
+
if (m.fields.parameters.present && ctx.oid.paramsMustBeAbsent(dotted)) {
|
|
165
|
+
throw ctx.E(ctx.prefix + "/bad-algorithm-parameters",
|
|
166
|
+
"the " + (ctx.oid.name(dotted) || dotted) + " AlgorithmIdentifier parameters field MUST be absent (RFC 9909 §3 / RFC 9814 §4 / RFC 9881 §2 / RFC 8410 §3)");
|
|
167
|
+
}
|
|
155
168
|
return { oid: dotted, name: ctx.oid.name(dotted) || null, parameters: m.fields.parameters.present ? m.fields.parameters.node.bytes : null };
|
|
156
169
|
},
|
|
157
170
|
});
|
|
@@ -603,6 +616,29 @@ function certExtensionDecoders(ns) {
|
|
|
603
616
|
catch (e) { throw ns.E(C, "SubjectKeyIdentifier must be an OCTET STRING (RFC 5280 §4.2.1.2)", e); }
|
|
604
617
|
}
|
|
605
618
|
|
|
619
|
+
// signedCertificateTimestampList (RFC 6962 §3.3) — the extension value is a DER
|
|
620
|
+
// OCTET STRING wrapping the TLS-encoded SCT list. Delegated to the CT module,
|
|
621
|
+
// which owns the inner peel + the bounded TLS decode; structure is decoded, the
|
|
622
|
+
// signature stays raw and unverified. The registry contract is that a decoder
|
|
623
|
+
// throws a `<prefix>/bad-*` code, so the CT module's own `ct/*` fault is wrapped
|
|
624
|
+
// and carried as `.cause` rather than leaking a foreign namespace here.
|
|
625
|
+
function sctList(buf) {
|
|
626
|
+
var C = ns.prefix + "/bad-extension-value";
|
|
627
|
+
try { return ct.parseSctList(buf); }
|
|
628
|
+
catch (e) { throw ns.E(C, "malformed signedCertificateTimestampList extension value (RFC 6962 §3.3)", e); }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// precertificatePoison (RFC 6962 §3.1) — a critical extension whose value is
|
|
632
|
+
// exactly ASN.1 NULL (05 00), the marker distinguishing a precertificate from
|
|
633
|
+
// a final certificate. Content-validated, not merely tag-checked.
|
|
634
|
+
function precertPoison(buf) {
|
|
635
|
+
var C = ns.prefix + "/bad-extension-value";
|
|
636
|
+
var n = decodeTop(buf, C, "PrecertificatePoison");
|
|
637
|
+
try { asn1.read.nullValue(n); }
|
|
638
|
+
catch (e) { throw ns.E(C, "the precertificate poison extension value must be ASN.1 NULL (RFC 6962 §3.1)", e); }
|
|
639
|
+
return { poison: true };
|
|
640
|
+
}
|
|
641
|
+
|
|
606
642
|
function O(nm) { return ns.oid.byName(nm); }
|
|
607
643
|
var byOid = {};
|
|
608
644
|
byOid[O("basicConstraints")] = basicConstraints;
|
|
@@ -617,6 +653,8 @@ function certExtensionDecoders(ns) {
|
|
|
617
653
|
byOid[O("extKeyUsage")] = extKeyUsage;
|
|
618
654
|
byOid[O("authorityKeyIdentifier")] = authorityKeyIdentifier;
|
|
619
655
|
byOid[O("subjectKeyIdentifier")] = subjectKeyIdentifier;
|
|
656
|
+
byOid[O("signedCertificateTimestampList")] = sctList;
|
|
657
|
+
byOid[O("precertificatePoison")] = precertPoison;
|
|
620
658
|
return { byOid: byOid };
|
|
621
659
|
}
|
|
622
660
|
|
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:bcaea9d8-cd0d-4a9f-8ac3-72b3efecc8be",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-09T15:12:46.820Z",
|
|
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.21",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.1.
|
|
25
|
+
"version": "0.1.21",
|
|
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.21",
|
|
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.21",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|