@blamejs/pki 0.5.30 → 0.5.31

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,19 @@ 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.5.31 — 2026-08-25
8
+
9
+ The signing verbs accept a WebCrypto CryptoKey as the signing key, and a subjectAltName entry may be a bare string classified into its GeneralName form.
10
+
11
+ ### Added
12
+
13
+ - Every signing verb -- `pki.x509.sign`, `pki.csr.sign`, `pki.crl.sign`, `pki.cms.sign`, `pki.ocsp.sign`, `pki.attrcert.sign`, `pki.tsp.sign`, and `pki.crmf.build` -- accepts a WebCrypto `CryptoKey` as its signing key, so a key from `pki.key.generate` (or `pki.webcrypto.subtle`) signs directly without first being exported to PKCS#8. A PKCS#8 private key as DER (a `Buffer` or `Uint8Array`) or PEM (a string) continues to work exactly as before, with the signature algorithm resolved from the key's own SubjectPublicKeyInfo. A `CryptoKey`'s own algorithm must be compatible with that resolved scheme, because WebCrypto binds a key to one algorithm: an RSASSA-PSS `CryptoKey` signs RSASSA-PSS, so pass `opts.pss` for it, whereas a raw PKCS#8 RSA key defaults to RSASSA-PKCS1-v1_5.
14
+ - A `subjectAltName` (and any other `GeneralName`) entry may be a bare string, classified fail-closed into its form: a hostname such as `"host.example.com"` (including a `*.` wildcard or an underscore label) becomes a `dNSName`, `"user@example.com"` an `rfc822Name`, an IPv4 or IPv6 literal such as `"192.0.2.1"` an `iPAddress`, and a `"https://host.example/path"` URL a `uniformResourceIdentifier`. Shorthand and object entries interoperate in the same array. A string that does not unambiguously match one form -- a host:port, an opaque `urn:`/`mailto:` URI, or an empty string -- is refused with a typed `bad-input` error rather than guessed; the explicit object form (`{ dNSName: "..." }`) still accepts any value, and a pre-encoded Extension DER passed through the escape-hatch array form is unaffected.
15
+
16
+ ### Changed
17
+
18
+ - A signing key that is a public or secret `CryptoKey`, or a `node:crypto` KeyObject rather than a WebCrypto `CryptoKey`, is now refused with a message that names the specific problem -- the wrong key type, or a KeyObject that should be passed as a `CryptoKey` or exported to PKCS#8 -- instead of the generic "must be a CryptoKey or PKCS#8" message. The `"a signing key is required"` errors on `pki.x509.sign`, `pki.crl.sign`, and `pki.attrcert.sign` now name the `CryptoKey` option alongside PKCS#8.
19
+
7
20
  ## v0.5.30 — 2026-08-24
8
21
 
9
22
  A CMP certReqTemplate keySpec that names an ISO/IEC 9796-2 RSA signature scheme is now refused, instead of being surfaced to the caller as a non-RSA algorithm requirement.
package/lib/asn1-der.js CHANGED
@@ -1041,6 +1041,10 @@ var build = {
1041
1041
  },
1042
1042
  raw: function (buf) { return _asBuffer(buf, "build.raw"); },
1043
1043
  };
1044
+ // Frozen at load so a builder method cannot be replaced afterward: every format module captures `build`
1045
+ // and calls its methods to produce emitted DER, so a mutable `build.contextPrimitive` (or any builder)
1046
+ // would let co-resident code steer the bytes a certificate carries. Freezing closes that once, toolkit-wide.
1047
+ intrinsic.freeze(build);
1044
1048
 
1045
1049
  // A raw INTEGER/ENUMERATED content Buffer must satisfy the same DER shape
1046
1050
  // read.integer enforces: non-empty and minimally encoded (no redundant sign
@@ -497,7 +497,7 @@ function _sign(spec, issuer, opts) {
497
497
  if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("attrcert/bad-input", "the attribute-certificate spec must be an object");
498
498
  guard.identifier.assertKnownKeys(spec, KNOWN_SPEC_KEYS, _err, "attrcert/bad-input", "unknown spec field ");
499
499
  issuer = issuer || {};
500
- if (issuer.key == null) throw _err("attrcert/bad-input", "a signing key (issuer.key, the AA's PKCS#8 private key) is required");
500
+ if (issuer.key == null) throw _err("attrcert/bad-input", "a signing key (issuer.key, the AA's WebCrypto CryptoKey or PKCS#8 private key DER/PEM) is required");
501
501
 
502
502
  // Resolve the AA issuer DN + signing-key SPKI. An attribute certificate is NEVER self-signed. No CA
503
503
  // gate on issuer.cert -- an AA is NOT a certificate authority (its PKC MUST NOT assert keyCertSign,
package/lib/crl-sign.js CHANGED
@@ -463,7 +463,7 @@ function _sign(spec, issuer, opts) {
463
463
  : "explicit form; the issuer takes { key, name, publicKey }, or { key, cert } to take both from a certificate");
464
464
  });
465
465
  guard.identifier.assertKnownKeys(opts, KNOWN_SIGN_OPTS, _err, "crl/bad-input", "pki.crl.sign has an unknown option ");
466
- if (issuer.key == null) throw _err("crl/bad-input", "a signing key (issuer.key, a PKCS#8 private key) is required");
466
+ if (issuer.key == null) throw _err("crl/bad-input", "a signing key (issuer.key, a WebCrypto CryptoKey or a PKCS#8 private key DER/PEM) is required");
467
467
 
468
468
  // Resolve the issuer name + signing-key SPKI. `{ cert }` supplies both; else `{ name|spec.issuer, publicKey }`.
469
469
  var issuerDer, issuerSpki, issuerCert = null;
package/lib/ip-utils.js CHANGED
@@ -3,8 +3,8 @@
3
3
  "use strict";
4
4
  //
5
5
  // @internal -- no operator-facing namespace. Textual IP-literal validation, shared so a
6
- // consumer (pki.lint's commonName-in-SAN check today; a future SAN iPAddress / name-
7
- // constraint surface) does not hand-roll a partial regex that misses a valid IPv6 form.
6
+ // consumer (pki.lint's commonName-in-SAN check today; the SAN iPAddress GeneralName shorthand
7
+ // and name-constraint surfaces) does not hand-roll a partial regex that misses a valid IPv6 form.
8
8
  //
9
9
  // The IPv4 grammar is strict per-octet 0-255 (RFC 791); the IPv6 parser follows RFC 4291
10
10
  // sec. 2.2 (8 groups of 1-4 hex, one "::" run compressing a contiguous zero run) plus the
@@ -12,43 +12,116 @@
12
12
  // This mirrors the vetted validation in the sibling blamejs framework (lib/ip-utils.js);
13
13
  // this toolkit keeps its own copy, since Hard rule #1 rules out a runtime dependency.
14
14
 
15
- // Strict RFC 791 dotted-quad: four 0-255 octets. Anchored + per-octet repeat-capped (no
16
- // ReDoS on unbounded input).
17
- var IPV4_RE = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
18
- function isIPv4(s) { return typeof s === "string" && IPV4_RE.test(s); }
15
+ // This parser uses no regular expression. packIpLiteral gates a GeneralName form decision (iPAddress
16
+ // vs dNSName), and a regex is matched through a live, replaceable protocol -- RegExp.prototype.exec /
17
+ // test and String.prototype.match / split consult the regex's own `exec` and the `[Symbol.match]` /
18
+ // `[Symbol.split]` hooks, and RegExp.prototype.compile can mutate a pattern in place -- so co-resident
19
+ // code could steer the decision after load. Instead every byte is read directly through captured string
20
+ // primitives, and separation on a literal is done by scanning with captured indexOf/slice, so nothing the
21
+ // decision rests on is re-dispatchable.
22
+ var intrinsic = require("./guard-intrinsic");
23
+ var _slice = intrinsic.uncurry(String.prototype.slice);
24
+ var _padStart = intrinsic.uncurry(String.prototype.padStart);
25
+ var _charCodeAt = intrinsic.uncurry(String.prototype.charCodeAt);
26
+ var _lastIndexOf = intrinsic.uncurry(String.prototype.lastIndexOf);
27
+ var _toLowerCase = intrinsic.toLowerCase;
28
+ var _indexOf = intrinsic.stringIndexOf;
29
+ var _numToString = intrinsic.numberToString;
30
+ var _bufferFrom = intrinsic.bufferFrom;
31
+ var _bufferAlloc = intrinsic.bufferAlloc;
32
+ var _defineProperty = intrinsic.defineProperty;
33
+ var _Number = intrinsic.Number;
34
+ // Every array here is built and read as own elements only. Appending through a captured Object.defineProperty
35
+ // (not `arr[i] = v`) creates an own element, so a co-resident Array.prototype numeric setter is never invoked
36
+ // and the own element shadows any inherited getter on read; and no Array.prototype.map / concat runs, so
37
+ // Array[Symbol.species] is never consulted. Both are routes a hostile prototype could otherwise use to
38
+ // rewrite a packed octet and make a certificate carry an attacker-chosen address. The emitted octets are
39
+ // written straight into a Buffer, whose integer-indexed store bypasses the prototype entirely.
40
+ function _push(arr, v) { _defineProperty(arr, arr.length, { value: v, writable: true, enumerable: true, configurable: true }); }
41
+ // Split on a literal string separator without String.prototype.split, which consults the separator's
42
+ // `[Symbol.split]`; this scans with captured indexOf/slice only.
43
+ function _splitOn(str, sep) {
44
+ var out = [], from = 0, at;
45
+ while ((at = _indexOf(str, sep, from)) !== -1) { _push(out, _slice(str, from, at)); from = at + sep.length; }
46
+ _push(out, _slice(str, from));
47
+ return out;
48
+ }
49
+
50
+ function _isDigitCode(c) { return c >= 48 && c <= 57; } // 0-9
51
+ function _isHexCode(c) { return (c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70); }
52
+
53
+ // A strict RFC 791 dotted-quad octet: 1-3 digits, value 0-255, no leading zero beyond "0" itself.
54
+ function _isOctet(p) {
55
+ var n = p.length, i, c;
56
+ if (n < 1 || n > 3) return false;
57
+ for (i = 0; i < n; i += 1) { c = _charCodeAt(p, i); if (!_isDigitCode(c)) return false; }
58
+ if (n > 1 && _charCodeAt(p, 0) === 48) return false; // a leading zero is not the canonical form
59
+ return _Number(p) <= 255;
60
+ }
61
+
62
+ // Strict RFC 791 dotted quad: four 0-255 octets, nothing else.
63
+ function isIPv4(s) {
64
+ if (typeof s !== "string") return false;
65
+ var parts = _splitOn(s, ".");
66
+ if (parts.length !== 4) return false;
67
+ for (var i = 0; i < 4; i += 1) { if (!_isOctet(parts[i])) return false; }
68
+ return true;
69
+ }
70
+
71
+ // A single 1-4 hex-digit IPv6 group.
72
+ function _isHexGroup(g) {
73
+ var n = g.length, i;
74
+ if (n < 1 || n > 4) return false;
75
+ for (i = 0; i < n; i += 1) { if (!_isHexCode(_charCodeAt(g, i))) return false; }
76
+ return true;
77
+ }
78
+
79
+ // The RFC 4291 sec. 2.5.5.2 dual-stack tail SHAPE: exactly four 1-3 digit groups joined by dots. Validity
80
+ // (0-255 per octet) is a separate isIPv4 check, matching the historical two-step (shape then value).
81
+ function _isDottedQuadShape(t) {
82
+ var q = _splitOn(t, "."), i, p, j;
83
+ if (q.length !== 4) return false;
84
+ for (i = 0; i < 4; i += 1) {
85
+ p = q[i];
86
+ if (p.length < 1 || p.length > 3) return false;
87
+ for (j = 0; j < p.length; j += 1) { if (!_isDigitCode(_charCodeAt(p, j))) return false; }
88
+ }
89
+ return true;
90
+ }
19
91
 
20
92
  // Expand an IPv6 textual address to its 32-hex-character form, or null on any parse failure
21
93
  // (bad hex group, group count != 8, more than one "::", an over-length group). Accepts the
22
94
  // IPv4-mapped / dual-stack ".d.d.d.d" tail.
23
95
  var IPV6_TEXT_MAX_LEN = 45; // 8 groups x4 hex + 7 colons + a dotted-quad tail headroom
24
96
  function expandIpv6Hex(ip) {
25
- if (typeof ip !== "string" || ip.length > IPV6_TEXT_MAX_LEN || ip.indexOf(":") === -1) return null;
26
- // RFC 4291 sec. 2.5.5.2 IPv4-mapped / dual-stack: fold a trailing dotted-quad into two
27
- // 16-bit hex groups before the pure-hex parse.
28
- var dual = ip.match(/^(.*):(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
29
- if (dual) {
30
- if (!isIPv4(dual[2])) return null;
31
- var v4 = dual[2].split(".").map(Number);
32
- ip = dual[1] + ":" + (((v4[0] << 8) | v4[1]).toString(16)) + ":" + (((v4[2] << 8) | v4[3]).toString(16));
97
+ if (typeof ip !== "string" || ip.length > IPV6_TEXT_MAX_LEN || _indexOf(ip, ":") === -1) return null;
98
+ // RFC 4291 sec. 2.5.5.2 IPv4-mapped / dual-stack: fold a trailing dotted-quad (the suffix after the
99
+ // last colon, when it carries a dot) into two 16-bit hex groups before the pure-hex parse.
100
+ var lastColon = _lastIndexOf(ip, ":");
101
+ var tail = _slice(ip, lastColon + 1);
102
+ if (_indexOf(tail, ".") !== -1) {
103
+ if (!_isDottedQuadShape(tail) || !isIPv4(tail)) return null;
104
+ var q = _splitOn(tail, "."), o0 = _Number(q[0]), o1 = _Number(q[1]), o2 = _Number(q[2]), o3 = _Number(q[3]);
105
+ ip = _slice(ip, 0, lastColon) + ":" + _numToString((o0 << 8) | o1, 16) + ":" + _numToString((o2 << 8) | o3, 16);
33
106
  }
34
- var dbl = ip.split("::");
107
+ var dbl = _splitOn(ip, "::");
35
108
  if (dbl.length > 2) return null;
36
- var left = dbl[0] === "" ? [] : dbl[0].split(":");
37
- var right = dbl.length === 2 ? (dbl[1] === "" ? [] : dbl[1].split(":")) : [];
109
+ var left = dbl[0] === "" ? [] : _splitOn(dbl[0], ":");
110
+ var right = dbl.length === 2 ? (dbl[1] === "" ? [] : _splitOn(dbl[1], ":")) : [];
38
111
  if (dbl.length === 1 && left.length !== 8) return null;
39
112
  var fill = 8 - left.length - right.length;
40
113
  // A "::" run MUST compress at least one zero group (RFC 4291 sec. 2.2 / RFC 5952 sec.
41
114
  // 4.2.2): a "::" with fill 0 is a full 8-group address that must not use compression.
42
115
  if (dbl.length === 2 ? fill < 1 : fill !== 0) return null;
43
- var groups = left, i;
44
- for (i = 0; i < fill; i += 1) groups = groups.concat(["0"]);
45
- groups = groups.concat(right);
116
+ var groups = left, i; // `left` is a fresh own-element array; append through _push (no inherited setter)
117
+ for (i = 0; i < fill; i += 1) _push(groups, "0");
118
+ for (i = 0; i < right.length; i += 1) _push(groups, right[i]);
46
119
  if (groups.length !== 8) return null;
47
120
  var hex = "";
48
121
  for (i = 0; i < 8; i += 1) {
49
122
  var g = groups[i];
50
- if (g.length < 1 || g.length > 4 || !/^[0-9a-f]+$/i.test(g)) return null;
51
- hex += g.toLowerCase().padStart(4, "0");
123
+ if (!_isHexGroup(g)) return null;
124
+ hex += _padStart(_toLowerCase(g), 4, "0");
52
125
  }
53
126
  return hex;
54
127
  }
@@ -61,9 +134,13 @@ function isIpLiteral(s) { return isIPv4(s) || expandIpv6Hex(s) !== null; }
61
134
  // expandIpv6Hex validate, so a SAN/GeneralName iPAddress (RFC 5280 sec. 4.2.1.6, always a bare
62
135
  // host address of 4 or 16 octets) can be given as a string instead of a pre-packed Buffer.
63
136
  function packIpLiteral(s) {
64
- if (isIPv4(s)) return Buffer.from(s.split(".").map(Number)); // each octet already range-checked by IPV4_RE
137
+ if (isIPv4(s)) {
138
+ var parts = _splitOn(s, "."), buf = _bufferAlloc(4), i;
139
+ for (i = 0; i < 4; i += 1) buf[i] = _Number(parts[i]); // Buffer integer-index store; each octet range-checked by _isOctet
140
+ return buf;
141
+ }
65
142
  var hex = expandIpv6Hex(s);
66
- return hex === null ? null : Buffer.from(hex, "hex");
143
+ return hex === null ? null : _bufferFrom(hex, "hex");
67
144
  }
68
145
 
69
- module.exports = { isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral, IPV4_RE: IPV4_RE };
146
+ module.exports = { isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral };
package/lib/pki-build.js CHANGED
@@ -25,16 +25,279 @@ var _bufferFrom = intrinsic.bufferFrom;
25
25
  var _bufferEquals = intrinsic.bufferEquals;
26
26
  var oid = require("./oid");
27
27
  var ipUtils = require("./ip-utils");
28
+ // packIpLiteral decides a GeneralName form (a bare string -> iPAddress or not), so its binding is taken at
29
+ // load: calling it through the exports object would be a live property read, and code that reassigned that
30
+ // property could steer the form decision without touching a regex.
31
+ var _packIpLiteral = ipUtils.packIpLiteral;
28
32
  var nodeCrypto = require("crypto");
29
33
  // The key-material comparison, taken at load like every other operation a decision here rests on.
30
34
  var _keyEquals = intrinsic.uncurry(nodeCrypto.KeyObject.prototype.equals);
35
+ // The bare-string GeneralName classifier (_classifyBareGeneralName) decides which name FORM a SAN string
36
+ // becomes. It uses no regular expression: a regex is matched through a live, replaceable protocol
37
+ // (RegExp.prototype.exec / test, String.prototype.match / split consult the regex's own `exec` and the
38
+ // `[Symbol.match]` / `[Symbol.split]` hooks, and RegExp.prototype.compile can mutate a pattern in place),
39
+ // so co-resident code could steer a form decision without our seeing it -- and the structural URI/email
40
+ // rules a regex cannot state cleanly are what kept drawing edge-case escapes. Instead every form is
41
+ // decided by scanning the string one character code at a time through these captured string primitives,
42
+ // which read the value directly and have no per-character dispatch to replace.
43
+ var _charCodeAt = intrinsic.uncurry(String.prototype.charCodeAt);
44
+ var _stringIndexOf = intrinsic.stringIndexOf;
45
+ var _stringLastIndexOf = intrinsic.uncurry(String.prototype.lastIndexOf);
46
+ var _sliceStr = intrinsic.uncurry(String.prototype.slice);
47
+ var _String = intrinsic.String;
48
+ var _objectKeys = intrinsic.keys;
49
+ var _isBufferChk = intrinsic.isBuffer;
50
+ var _stringify = intrinsic.stringify;
31
51
 
32
- var b = asn1.build;
52
+ // --- bare-string GeneralName form detection, by character-code scan (no regex) ---
53
+ // Each predicate answers "is this string syntactically THIS name form", reading the string one code unit
54
+ // at a time; a caller cannot steer the answer by replacing a prototype method. The classifier below tries
55
+ // the forms in a fixed precedence and refuses anything none of them accepts (the object form is the escape).
56
+ function _isAlphaCode(c) { return (c >= 65 && c <= 90) || (c >= 97 && c <= 122); } // A-Z a-z
57
+ function _isDigitCode(c) { return c >= 48 && c <= 57; } // 0-9
58
+ function _isHexCode(c) { return (c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70); }
59
+
60
+ // RFC 3986 sec. 2.3 unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~".
61
+ function _isUnreserved(c) { return _isAlphaCode(c) || _isDigitCode(c) || c === 45 || c === 46 || c === 95 || c === 126; }
62
+ // RFC 3986 sec. 2.2 sub-delims: ! $ & ' ( ) * + , ; =
63
+ function _isSubDelim(c) {
64
+ switch (c) {
65
+ case 33: case 36: case 38: case 39: case 40: case 41: case 42: case 43: case 44: case 59: case 61: return true;
66
+ default: return false;
67
+ }
68
+ }
69
+ // A userinfo byte: unreserved / sub-delim / ":" (percent-encoding handled separately). No "@".
70
+ function _isUserinfoByte(c) { return _isUnreserved(c) || _isSubDelim(c) || c === 58; }
71
+ // A path/query/fragment byte: unreserved / sub-delim / a non-bracket gen-delim (":" "/" "?" "#" "@").
72
+ // "%" is handled separately (it must open a "%HEXHEX" triplet); the IP-literal brackets "[" "]" are
73
+ // excluded, so an IPv6-literal host is refused by the shorthand and uses the object form.
74
+ function _isUriByte(c) {
75
+ return _isUnreserved(c) || _isSubDelim(c) || c === 58 || c === 47 || c === 63 || c === 35 || c === 64;
76
+ }
77
+
78
+ // An RFC 5322 sec. 3.2.3 "atext" byte: ALPHA / DIGIT and a fixed set of specials, used for an unquoted
79
+ // email local part. "." is deliberately excluded so the dot-atom structure is enforced by the scan.
80
+ function _isAtextByte(c) {
81
+ if (_isAlphaCode(c) || _isDigitCode(c)) return true;
82
+ switch (c) {
83
+ case 33: case 35: case 36: case 37: case 38: case 39: // ! # $ % & '
84
+ case 42: case 43: case 45: case 47: case 61: case 63: // * + - / = ?
85
+ case 94: case 95: case 96: case 123: case 124: case 125: case 126: // ^ _ ` { | } ~
86
+ return true;
87
+ default: return false;
88
+ }
89
+ }
90
+
91
+ // IPv4 SHAPE: one or more digit groups joined by single dots (only digits and dots, no leading/trailing/
92
+ // doubled dot, at least one dot). This is the "looks like an address" test, not a valid-address test --
93
+ // packIpLiteral decides validity; a shape match that is not a valid address is refused as ambiguous.
94
+ function _looksLikeIPv4Shape(s) {
95
+ var n = s.length, i, c, sawDot = false, atomEmpty = true;
96
+ if (n === 0) return false;
97
+ for (i = 0; i < n; i += 1) {
98
+ c = _charCodeAt(s, i);
99
+ if (c === 46) { if (atomEmpty) return false; sawDot = true; atomEmpty = true; } // "."
100
+ else if (_isDigitCode(c)) { atomEmpty = false; }
101
+ else return false;
102
+ }
103
+ return sawDot && !atomEmpty;
104
+ }
105
+
106
+ // Every byte in [from,to) is an allowed component byte (per `byteOk`) or opens a "%HEXHEX" triplet.
107
+ function _componentBytesOk(s, from, to, byteOk) {
108
+ var i = from, c;
109
+ while (i < to) {
110
+ c = _charCodeAt(s, i);
111
+ if (c === 37) { // "%" -> %HEXHEX
112
+ if (i + 2 >= to || !_isHexCode(_charCodeAt(s, i + 1)) || !_isHexCode(_charCodeAt(s, i + 2))) return false;
113
+ i += 3;
114
+ } else if (byteOk(c)) { i += 1; }
115
+ else return false;
116
+ }
117
+ return true;
118
+ }
119
+
120
+ // The URI host in [from,to): RFC 5280 sec. 4.2.1.6 requires a URI SAN authority to carry a fully-qualified
121
+ // domain name or IP host, stricter than an RFC 3986 reg-name. So the host is either a valid IPv4 literal,
122
+ // or an FQDN of strict-LDH labels (the email-domain grammar: no underscore, no hyphen at a label edge) with
123
+ // at least one dot -- a single-label host such as "localhost" is not fully qualified and uses the object
124
+ // form, matching the email domain. An IPv4-SHAPED host that is not a valid address is refused as ambiguous
125
+ // (the same rule the top-level classifier uses), and an IPv6-literal host -- needing the excluded "[" "]"
126
+ // -- uses the object form.
127
+ function _validUriHost(s, from, to) {
128
+ if (to <= from) return false; // empty host
129
+ var host = _sliceStr(s, from, to), len = host.length;
130
+ if (_looksLikeIPv4Shape(host)) return _packIpLiteral(host) !== null; // IPv4 (first-match-wins)
131
+ var end = _charCodeAt(host, len - 1) === 46 ? len - 1 : len; // ignore an absolute-root "."
132
+ if (end > 253) return false; // RFC 1035 name length (root dot excluded)
133
+ var firstDot = _stringIndexOf(host, ".", 0);
134
+ if (firstDot === -1 || firstDot >= end) return false; // multi-label FQDN ("localhost" is not)
135
+ return _labelsValid(host, 0, len, _isHostLabelRange, true); // strict-LDH FQDN, root "." allowed
136
+ }
137
+
138
+ // The authority in [from,to) is "[ userinfo "@" ] host [ ":" port ]" (RFC 3986 sec. 3.2): at most one "@"
139
+ // (userinfo and reg-name each exclude it), a host validated by _validUriHost (RFC 5280 FQDN or IPv4), and
140
+ // an all-digit -- possibly empty -- port after the host's single ":" (a reg-name has no ":"). The userinfo
141
+ // bytes are validated against the RFC 3986 userinfo class.
142
+ function _validUriAuthority(s, from, to) {
143
+ var i, c, at = -1;
144
+ for (i = from; i < to; i += 1) {
145
+ c = _charCodeAt(s, i);
146
+ if (c === 64) { if (at !== -1) return false; at = i; } // "@" -- at most one
147
+ }
148
+ var hostStart = at === -1 ? from : at + 1;
149
+ if (at !== -1 && !_componentBytesOk(s, from, at, _isUserinfoByte)) return false; // userinfo
150
+ var colon = -1;
151
+ for (i = hostStart; i < to; i += 1) { if (_charCodeAt(s, i) === 58) { colon = i; break; } } // ":" -> port
152
+ var hostEnd = colon === -1 ? to : colon;
153
+ if (!_validUriHost(s, hostStart, hostEnd)) return false; // FQDN or IPv4 host
154
+ if (colon !== -1) {
155
+ for (i = colon + 1; i < to; i += 1) { if (!_isDigitCode(_charCodeAt(s, i))) return false; } // port = *DIGIT
156
+ }
157
+ return true;
158
+ }
159
+
160
+ // scheme "://" authority [ path ] [ "?" query ] [ "#" fragment ], parsed by RFC 3986 component. The
161
+ // authority is validated by _validUriAuthority; the remainder is path/query/fragment bytes with at most
162
+ // one "#" (its single fragment delimiter). A structurally malformed URL is refused; the object form is
163
+ // the escape for one the shorthand does not accept.
164
+ function _looksLikeUri(s) {
165
+ var n = s.length, i, c;
166
+ if (n === 0 || !_isAlphaCode(_charCodeAt(s, 0))) return false; // scheme starts ALPHA
167
+ i = 1;
168
+ while (i < n) {
169
+ c = _charCodeAt(s, i);
170
+ if (_isAlphaCode(c) || _isDigitCode(c) || c === 43 || c === 45 || c === 46) i += 1; // + - . in scheme
171
+ else break;
172
+ }
173
+ if (i + 3 > n || _charCodeAt(s, i) !== 58 || _charCodeAt(s, i + 1) !== 47 || _charCodeAt(s, i + 2) !== 47) return false;
174
+ i += 3;
175
+ var authStart = i, authEnd = n;
176
+ for (; i < n; i += 1) { c = _charCodeAt(s, i); if (c === 47 || c === 63 || c === 35) { authEnd = i; break; } }
177
+ if (authEnd === authStart) return false; // empty authority
178
+ if (!_validUriAuthority(s, authStart, authEnd)) return false;
179
+ var sawHash = false; // path / query / fragment
180
+ i = authEnd;
181
+ while (i < n) {
182
+ c = _charCodeAt(s, i);
183
+ if (c === 37) { // "%" -> %HEXHEX
184
+ if (i + 2 >= n || !_isHexCode(_charCodeAt(s, i + 1)) || !_isHexCode(_charCodeAt(s, i + 2))) return false;
185
+ i += 3;
186
+ continue;
187
+ }
188
+ if (c === 35) { if (sawHash) return false; sawHash = true; } // one "#" fragment delimiter
189
+ if (!_isUriByte(c)) return false;
190
+ i += 1;
191
+ }
192
+ return true;
193
+ }
194
+
195
+ // A single dNSName label over [from,to): 1-63 bytes, LDH plus a LEADING or EMBEDDED underscore, no hyphen
196
+ // at either edge, and no underscore at the trailing edge. The underscore tolerance is deliberate and
197
+ // leading/embedded only: the real forms are _acme-challenge, _dmarc, _25._tcp (a label-initial "_"); a
198
+ // label ending in "_" has no such use. An email DOMAIN is stricter still -- see _isHostLabelRange.
199
+ function _isDnsLabelRange(s, from, to) {
200
+ var len = to - from, i, c;
201
+ if (len < 1 || len > 63) return false;
202
+ for (i = from; i < to; i += 1) {
203
+ c = _charCodeAt(s, i);
204
+ if (_isAlphaCode(c) || _isDigitCode(c)) continue; // A-Z a-z 0-9 anywhere
205
+ if (i === to - 1) return false; // last byte must be alnum
206
+ if (c === 95) continue; // "_" leading or embedded
207
+ if (c === 45 && i !== from) continue; // "-" embedded (not leading)
208
+ return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ // A single RFC 5321 sec. 2.3.5 hostname label over [from,to): the strict letter-digit-hyphen grammar with
214
+ // no hyphen at either edge and NO underscore. This is the email-domain label rule (a mail domain is a real
215
+ // hostname), stricter than the underscore-tolerant dNSName label above.
216
+ function _isHostLabelRange(s, from, to) {
217
+ var len = to - from, i, c, edge;
218
+ if (len < 1 || len > 63) return false;
219
+ for (i = from; i < to; i += 1) {
220
+ c = _charCodeAt(s, i);
221
+ edge = (i === from || i === to - 1);
222
+ if (_isAlphaCode(c) || _isDigitCode(c)) continue; // A-Z a-z 0-9
223
+ if (c === 45 && !edge) continue; // "-" not at an edge
224
+ return false;
225
+ }
226
+ return true;
227
+ }
228
+
229
+ // Every dot-separated label in [from,to) satisfies `labelFn`. Scans with indexOf; no split allocation. When
230
+ // `allowRootDot` is set, a single trailing "." (the absolute-FQDN root) is stripped before validating, so
231
+ // "example.com." checks as "example.com"; an interior empty label (a doubled or leading dot) still fails.
232
+ function _labelsValid(s, from, to, labelFn, allowRootDot) {
233
+ if (allowRootDot && to - from >= 1 && _charCodeAt(s, to - 1) === 46) to -= 1; // an absolute-root "."
234
+ if (to <= from) return false; // nothing (a lone ".")
235
+ var start = from, dot;
236
+ for (;;) {
237
+ dot = _stringIndexOf(s, ".", start);
238
+ var end = (dot === -1 || dot >= to) ? to : dot;
239
+ if (!labelFn(s, start, end)) return false;
240
+ if (end === to) return true;
241
+ start = end + 1;
242
+ }
243
+ }
33
244
 
34
- // A well-formed dotted-decimal OID: a first arc 0-2 followed by one or more non-leading-zero arcs. Used to
35
- // accept a raw OID string (an unregistered KeyPurposeId / policy OID) where a registered name would go; the
36
- // encoder (encodeOidContent) remains the authoritative validator of the arc-value bounds.
37
- var DOTTED_OID_RE = /^[0-2](?:\.(?:0|[1-9]\d*))+$/;
245
+ // A dNSName: at most 253 bytes, an optional single leading "*." wildcard, then dot-separated dNSName labels
246
+ // (underscore-tolerant), with an optional trailing absolute-root ".".
247
+ function _looksLikeDnsName(s) {
248
+ var n = s.length, start = 0;
249
+ if (n < 1) return false;
250
+ var end = _charCodeAt(s, n - 1) === 46 ? n - 1 : n; // exclude an absolute-root "."
251
+ if (end < 1 || end > 253) return false; // RFC 1035 name length (root dot excluded)
252
+ if (end >= 2 && _charCodeAt(s, 0) === 42 && _charCodeAt(s, 1) === 46) start = 2; // "*."
253
+ if (start >= end) return false;
254
+ return _labelsValid(s, start, n, _isDnsLabelRange, true);
255
+ }
256
+
257
+ // An rfc822Name: an unquoted RFC 5321 dot-atom local part (atext runs joined by single dots), one "@",
258
+ // and a dotted hostname domain (at least one dot -- an FQDN, no wildcard, strict LDH labels, no underscore).
259
+ function _looksLikeEmail(s) {
260
+ if (s.length > 254) return false; // the whole mailbox <= 254 octets (RFC 5321 sec. 4.5.3.1.3)
261
+ var at = _stringIndexOf(s, "@", 0);
262
+ if (at < 1 || at > 64 || _stringLastIndexOf(s, "@") !== at) return false; // one "@", a 1-64 octet local part (RFC 5321 sec. 4.5.3.1.1)
263
+ var i, c, atomEmpty = true;
264
+ for (i = 0; i < at; i += 1) { // local part = dot-atom
265
+ c = _charCodeAt(s, i);
266
+ if (c === 46) { if (atomEmpty) return false; atomEmpty = true; } // no leading/doubled dot
267
+ else if (_isAtextByte(c)) { atomEmpty = false; }
268
+ else return false;
269
+ }
270
+ if (atomEmpty) return false; // no trailing dot / empty
271
+ var dStart = at + 1, dLen = s.length - dStart;
272
+ if (dLen < 1 || dLen > 253 || _stringIndexOf(s, ".", dStart) === -1) return false; // domain must be dotted
273
+ return _labelsValid(s, dStart, s.length, _isHostLabelRange, false); // strict LDH, no underscore or root dot
274
+ }
275
+
276
+ // A well-formed dotted-decimal OID SHAPE (no regex, by the same character scan the name forms use): a first
277
+ // arc 0-2, then one or more arcs each introduced by "." and written without a leading zero ("0" alone, or a
278
+ // non-zero digit followed by more digits). Used to accept a raw OID string (an unregistered KeyPurposeId /
279
+ // policy OID) where a registered name would go; the encoder (encodeOidContent) remains the authoritative
280
+ // validator of the arc-value bounds.
281
+ function _isDottedOid(s) {
282
+ var n = s.length, i, c, d0, arcs = 0;
283
+ if (n < 3) return false; // shortest is "X.Y"
284
+ c = _charCodeAt(s, 0);
285
+ if (c < 48 || c > 50) return false; // first arc is a single digit 0-2
286
+ i = 1;
287
+ while (i < n) {
288
+ if (_charCodeAt(s, i) !== 46) return false; // every following arc is introduced by "."
289
+ i += 1;
290
+ if (i >= n) return false; // a trailing "." has no arc
291
+ d0 = _charCodeAt(s, i);
292
+ if (d0 === 48) { i += 1; } // "0" is a complete arc; a leading zero is refused
293
+ else if (d0 >= 49 && d0 <= 57) { i += 1; while (i < n && _isDigitCode(_charCodeAt(s, i))) i += 1; }
294
+ else { return false; } // an arc must start with a digit
295
+ arcs += 1;
296
+ }
297
+ return arcs >= 1;
298
+ }
299
+
300
+ var b = asn1.build;
38
301
 
39
302
  // KeyUsage named-bit positions (RFC 5280 sec. 4.2.1.3); contentCommitment is the RFC 5280 rename of the
40
303
  // X.509 nonRepudiation bit (1).
@@ -112,15 +375,51 @@ function makeBuilder(ctx) {
112
375
 
113
376
  // ---- GeneralName encoding (RFC 5280 sec. 4.2.1.6) ----
114
377
  function ia5Content(s) {
115
- s = String(s);
378
+ // Captured String / charCodeAt / bufferFrom: this converts a GeneralName value (a classified bare
379
+ // string or an object-form value) to the emitted IA5String bytes, so co-resident code replacing any of
380
+ // them after load could otherwise change which host the certificate names from the one that was checked.
381
+ s = _String(s);
116
382
  for (var i = 0; i < s.length; i++) {
117
- if (s.charCodeAt(i) > 0x7F) throw E("bad-input", "value requires 7-bit ASCII (IA5String): " + JSON.stringify(s));
383
+ if (_charCodeAt(s, i) > 0x7F) throw E("bad-input", "value requires 7-bit ASCII (IA5String): " + _stringify(s));
118
384
  }
119
- return Buffer.from(s, "latin1");
385
+ return _bufferFrom(s, "latin1");
386
+ }
387
+ // A bare-string GeneralName shorthand: classify a string into exactly one form, FAIL-CLOSED. RFC 5280
388
+ // sec. 4.2.1.6 GeneralName is a CHOICE, so an input that does not UNAMBIGUOUSLY match one form throws
389
+ // rather than being guessed -- the explicit object form ({ dNSName: s } etc.) is always the escape. The
390
+ // per-form syntax is decided by the character-scan predicates above (no regex). Precedence resolves the
391
+ // overlaps of the later tests: an IP literal is also LDH-with-dots (so it is tested first), and a
392
+ // scheme://authority URL can carry userinfo that reads like an email (so URI is tested before rfc822Name).
393
+ // Form detection only -- IA5String (7-bit) enforcement stays at ia5Content, so a non-ASCII value still
394
+ // throws at encode.
395
+ function _classifyBareGeneralName(s) {
396
+ if (_packIpLiteral(s) !== null) return { iPAddress: s };
397
+ // A string of only digit groups separated by dots is an IPv4 ATTEMPT; if packIpLiteral rejected it,
398
+ // it is a MALFORMED address, not a dNSName. It reads two ways (an invalid IP and a syntactically
399
+ // valid all-numeric name), so it is ambiguous and refused rather than silently encoded as a name the
400
+ // caller did not intend -- a genuine all-numeric name uses the explicit { dNSName: ... } form.
401
+ if (_looksLikeIPv4Shape(s)) {
402
+ throw E("bad-input", "the bare GeneralName string " + _stringify(s) + " looks like an IPv4 address but is not a valid one; pass { iPAddress: ... } for an address or { dNSName: ... } for a name");
403
+ }
404
+ if (_looksLikeUri(s)) return { uniformResourceIdentifier: s };
405
+ if (_looksLikeEmail(s)) return { rfc822Name: s };
406
+ if (_looksLikeDnsName(s)) return { dNSName: s };
407
+ var _qs = _stringify(s);
408
+ throw E("bad-input", "cannot classify the bare GeneralName string " + _qs +
409
+ " as a dNSName, rfc822Name, iPAddress, or URI; pass an explicit form object, e.g. { dNSName: " + _qs + " }");
120
410
  }
121
411
  function encodeGeneralName(entry) {
122
- if (!entry || typeof entry !== "object" || Buffer.isBuffer(entry)) throw E("bad-input", "a GeneralName must be an object with exactly one name form");
123
- var keys = Object.keys(entry);
412
+ // A bare non-empty string is shorthand -- normalized to its single-key object form, fail-closed,
413
+ // before the object checks below; an empty string is refused here (never classified).
414
+ if (typeof entry === "string") {
415
+ if (entry === "") throw E("bad-input", "an empty GeneralName value is not permitted (RFC 5280 sec. 4.2.1.6)");
416
+ entry = _classifyBareGeneralName(entry);
417
+ }
418
+ if (!entry || typeof entry !== "object" || _isBufferChk(entry)) throw E("bad-input", "a GeneralName must be an object with exactly one name form");
419
+ // Captured Object.keys / property read: this reads which form and value the certificate will emit for a
420
+ // GeneralName (a freshly classified bare string, or a caller object), so a replaced Object.keys must not
421
+ // be able to relabel the classified result into a different, attacker-chosen form after validation.
422
+ var keys = _objectKeys(entry);
124
423
  if (keys.length !== 1) throw E("bad-input", "a GeneralName entry must have exactly one form, got " + keys.length);
125
424
  var k = keys[0], v = entry[k];
126
425
  if (v == null || v === "") throw E("bad-input", "an empty GeneralName value is not permitted (RFC 5280 sec. 4.2.1.6)");
@@ -134,10 +433,10 @@ function makeBuilder(ctx) {
134
433
  // octets by hand (a GeneralName iPAddress is a bare host address, RFC 5280 sec. 4.2.1.6).
135
434
  var ipBuf = v;
136
435
  if (typeof v === "string") {
137
- ipBuf = ipUtils.packIpLiteral(v);
138
- if (ipBuf === null) throw E("bad-input", "iPAddress string is not a valid IPv4 or IPv6 literal: " + JSON.stringify(v));
436
+ ipBuf = _packIpLiteral(v);
437
+ if (ipBuf === null) throw E("bad-input", "iPAddress string is not a valid IPv4 or IPv6 literal: " + _stringify(v));
139
438
  }
140
- if (!Buffer.isBuffer(ipBuf) || (ipBuf.length !== 4 && ipBuf.length !== 16)) throw E("bad-input", "iPAddress must be a 4- or 16-octet Buffer or an IPv4/IPv6 string");
439
+ if (!_isBufferChk(ipBuf) || (ipBuf.length !== 4 && ipBuf.length !== 16)) throw E("bad-input", "iPAddress must be a 4- or 16-octet Buffer or an IPv4/IPv6 string");
141
440
  return b.contextPrimitive(7, ipBuf);
142
441
  case "directoryName": return b.explicit(4, encodeName(v)); // Name is a CHOICE -> the context tag is EXPLICIT
143
442
  case "otherName":
@@ -187,8 +486,8 @@ function makeBuilder(ctx) {
187
486
  function _resolveOid(n, label) {
188
487
  var dotted = O(n);
189
488
  if (dotted != null) return dotted;
190
- if (typeof n === "string" && DOTTED_OID_RE.test(n)) {
191
- // The regex fixes the general shape; b.oid's encoder is the authoritative X.660 arc-bounds check
489
+ if (typeof n === "string" && _isDottedOid(n)) {
490
+ // The shape scan fixes the general form; b.oid's encoder is the authoritative X.660 arc-bounds check
192
491
  // (a first arc 0/1 caps the second at 39; an arc must DER-encode). A failure there is this producer's
193
492
  // bad-input, not a leaked oid/* code, so every shared-builder consumer keeps its own error contract.
194
493
  try { b.oid(n); }
@@ -222,7 +222,13 @@ function _assertKeyMatchesScheme(key, imp, E) {
222
222
  var ka = key.algorithm || {};
223
223
  // WebCrypto algorithm names are ASCII-case-insensitive (RSASSA-PKCS1-v1_5 and RSASSA-PKCS1-V1_5 are one
224
224
  // algorithm), so compare case-folded; a genuine mismatch (e.g. RSASSA-PKCS1-v1_5 vs ECDSA) still fails.
225
- if (_toUpperCase(_String(ka.name)) !== _toUpperCase(_String(imp.name))) throw E("bad-input", "the signer CryptoKey algorithm (" + ka.name + ") does not match the certificate's key algorithm (" + imp.name + ")");
225
+ if (_toUpperCase(_String(ka.name)) !== _toUpperCase(_String(imp.name))) {
226
+ // A WebCrypto RSA-PSS CryptoKey is bound to PSS and cannot produce the default RSASSA-PKCS1-v1_5
227
+ // signature (which a raw PKCS#8 RSA key can), so name the fix rather than only the mismatch.
228
+ var hint = (_toUpperCase(_String(ka.name)) === "RSA-PSS" && _toUpperCase(_String(imp.name)) === "RSASSA-PKCS1-V1_5")
229
+ ? "; this CryptoKey is bound to RSASSA-PSS -- pass opts.pss to sign with RSASSA-PSS" : "";
230
+ throw E("bad-input", "the signer CryptoKey algorithm (" + ka.name + ") does not match the certificate's key algorithm (" + imp.name + ")" + hint);
231
+ }
226
232
  if (imp.hash && (!ka.hash || ka.hash.name !== imp.hash)) throw E("bad-input", "the signer CryptoKey hash (" + (ka.hash && ka.hash.name) + ") does not match the signing digest (" + imp.hash + ")");
227
233
  if (imp.namedCurve && ka.namedCurve !== imp.namedCurve) throw E("bad-input", "the signer CryptoKey curve (" + ka.namedCurve + ") does not match the certificate curve (" + imp.namedCurve + ")");
228
234
  }
@@ -242,11 +248,21 @@ function _normCompositeKeys(key, comp, E) {
242
248
  return { mldsa: _normPkcs8(key.mldsa, "the composite ML-DSA component key", E), trad: _normPkcs8(key.trad, "the composite traditional component key", E) };
243
249
  }
244
250
  function _importKey(key, imp, E) {
245
- if (key && typeof key === "object" && !_isBuffer(key) && !(_isU8(key)) && key.type === "private") {
251
+ // A WebCrypto CryptoKey is detected structurally (a `.type` string + an `.algorithm` object, and not
252
+ // a byte view), never by instanceof (cross-realm / a second installed copy) or util.types.isCryptoKey
253
+ // (false for this engine's own keys). The gate is broader than webcrypto.isCryptoKeyLike on purpose:
254
+ // an incomplete CryptoKey-shaped object (e.g. one missing its `usages`) still routes here so adoptKey
255
+ // reports the specific reason (usages / not-exportable) rather than the generic byte-form message. It
256
+ // signs, so it must be a PRIVATE key: a public or secret CryptoKey cannot produce a signature and is
257
+ // refused precisely.
258
+ if (key && typeof key === "object" && !_isBuffer(key) && !_isU8(key) && typeof key.type === "string" && key.algorithm != null) {
259
+ if (key.type !== "private") {
260
+ throw E("bad-input", "the signing key is a WebCrypto CryptoKey of type " + _stringify(key.type) + "; a signing key must be a private key (type \"private\")");
261
+ }
246
262
  _assertKeyMatchesScheme(key, imp, E);
247
- // `key` is documented as taking a CryptoKey, so one created outside this engine signs here too --
248
- // it is re-imported through this engine, because it carries none of the key material this engine
249
- // signs with. One whose material this process cannot reach is refused with that as the reason.
263
+ // A CryptoKey created outside this engine signs here too -- it is re-imported through this engine,
264
+ // because it carries none of the key material this engine signs with. One whose material this
265
+ // process cannot reach is refused with that as the reason.
250
266
  return webcrypto.adoptKey(key, imp, ["sign"], E, "bad-input");
251
267
  }
252
268
  // A Uint8Array is copied here and a PEM string is decoded here, so both leave a second copy of a
@@ -260,7 +276,11 @@ function _importKey(key, imp, E) {
260
276
  try { der = pkcs8.pemDecode(key); }
261
277
  catch (e) { throw E("bad-input", "the signer PEM private key could not be decoded", e); }
262
278
  owned = true;
263
- } else throw E("bad-input", "a signer key must be a CryptoKey, a PKCS#8 DER Buffer, or a PKCS#8 PEM string");
279
+ } else if (intrinsic.types.isKeyObject(key)) {
280
+ // A node:crypto KeyObject carries a `.type` but not the WebCrypto surface (algorithm / usages), so
281
+ // it fails the CryptoKey gate above and would otherwise land on the generic message -- name it precisely.
282
+ throw E("bad-input", "the signing key is a node:crypto KeyObject, not a WebCrypto CryptoKey; pass a CryptoKey (e.g. from pki.key.generate) or export it to a PKCS#8 DER Buffer or PEM string");
283
+ } else throw E("bad-input", "a signer key must be a WebCrypto CryptoKey, a PKCS#8 DER Buffer, or a PKCS#8 PEM string");
264
284
  var imported = subtle.importKey("pkcs8", der, imp, false, ["sign"]);
265
285
  if (!owned) return imported;
266
286
  // Wiped on the reject path too: a malformed key is not a way to leave the copy in memory.
package/lib/x509-sign.js CHANGED
@@ -264,7 +264,12 @@ function _hasCriticalSan(extSpec) {
264
264
  * key being certified), `notBefore` / `notAfter` (`Date`s), an optional `serialNumber`, and an optional
265
265
  * `extensions` object. `issuer` is the signing side: `{ key }` alone issues a self-signed certificate
266
266
  * (issuer = subject, signed with the subject's own key); `{ name, publicKey, key }` or `{ cert, key }`
267
- * issues a CA-signed one. The signature algorithm is resolved from the signing key: RSA (PKCS#1 v1.5
267
+ * issues a CA-signed one. The signing key `key` is a WebCrypto `CryptoKey` -- a `pki.key.generate`
268
+ * private key passed directly, without exporting -- or a PKCS#8 private key as DER (`Buffer`) or PEM
269
+ * (`string`); a public or secret CryptoKey, or a `node:crypto` KeyObject, is refused. A `subjectAltName`
270
+ * (or any `GeneralName`) entry may be a form object (`{ dNSName: "..." }`) or a bare string classified
271
+ * fail-closed into its form (`"host.example"`, `"a@b.example"`, `"10.0.0.1"`, `"https://host.example/"`).
272
+ * The signature algorithm is resolved from the signing key: RSA (PKCS#1 v1.5
268
273
  * or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm, so every algorithm the
269
274
  * toolkit signs with is available here without a per-algorithm branch.
270
275
  *
@@ -283,12 +288,11 @@ function _hasCriticalSan(extSpec) {
283
288
  * @example
284
289
  * var pair = await pki.key.generate("Ed25519");
285
290
  * var signerSpki = await pki.key.export(pair.publicKey);
286
- * var signerKeyPkcs8 = await pki.key.export(pair.privateKey);
287
291
  * var root = await pki.x509.sign(
288
292
  * { subject: "Example Root CA", subjectPublicKey: signerSpki,
289
293
  * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
290
294
  * extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign", "cRLSign"], subjectKeyIdentifier: true } },
291
- * { key: signerKeyPkcs8 });
295
+ * { key: pair.privateKey }); // a WebCrypto CryptoKey signs directly (or pass a PKCS#8 DER Buffer / PEM string)
292
296
  * pki.schema.x509.parse(root).subject.dn; // "CN=Example Root CA"
293
297
  */
294
298
  function sign(spec, issuer, opts) {
@@ -324,7 +328,7 @@ function _sign(spec, issuer, opts) {
324
328
  : "explicit form; the issuer takes { key, name, publicKey }, or { key, cert } to take both from a certificate");
325
329
  });
326
330
  guard.identifier.assertKnownKeys(opts, KNOWN_SIGN_OPTS, _err, "x509/bad-input", "pki.x509.sign has an unknown option ");
327
- if (issuer.key == null) throw _err("x509/bad-input", "a signing key (issuer.key, a PKCS#8 private key) is required");
331
+ if (issuer.key == null) throw _err("x509/bad-input", "a signing key (issuer.key, a WebCrypto CryptoKey or a PKCS#8 private key DER/PEM) is required");
328
332
 
329
333
  var spki = _reqDer(spec.subjectPublicKey, "spec.subjectPublicKey (the SPKI DER of the certified key)");
330
334
  _assertValidSpki(spki, "spec.subjectPublicKey");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.5.30",
3
+ "version": "0.5.31",
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:a82d39df-dc02-419e-86bb-787363fa4d07",
5
+ "serialNumber": "urn:uuid:ebd39ca0-3971-47df-8cbe-c00499f2e5c6",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-24T19:49:42.857Z",
8
+ "timestamp": "2026-08-25T04:07:42.047Z",
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.5.30",
22
+ "bom-ref": "@blamejs/pki@0.5.31",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.5.30",
25
+ "version": "0.5.31",
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.5.30",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.5.31",
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.5.30",
57
+ "ref": "@blamejs/pki@0.5.31",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]