@blamejs/pki 0.1.1 → 0.1.2

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,34 @@ 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.2 — 2026-07-04
8
+
9
+ Fail-closed hardening across the DER codec, WebCrypto engine, and X.509 parser.
10
+
11
+ ### Changed
12
+
13
+ - pki.oid gains registerFamily(base, members): register a whole OID arc family in one call by its shared base arc and each member's trailing leaf. The built-in registry is now declared this way, so a new object identifier is a data entry under its family rather than a re-spelled full path.
14
+ - Every primitive now declares the normative reference it is derived from (@spec) and, where it guards a known attack, the class it defends (@defends). The generated reference documentation links each citation to its source — RFC section anchors, NIST FIPS, ITU-T, W3C, CVE and CWE — so the surface is traceable to the standards it implements.
15
+
16
+ ### Fixed
17
+
18
+ - pki.asn1.read.time rejects semantically invalid UTCTime/GeneralizedTime values (Feb 30, month 13, hour 25, second 60, day 00) instead of silently normalizing them, and preserves a four-digit GeneralizedTime year below 100 instead of remapping it a century, so a malformed or edge-case certificate validity window no longer parses to a shifted instant that disagrees with a strict verifier.
19
+ - The DER encoder is now symmetric with the decoder — no builder can emit DER the decoder would reject: build.utcTime rejects a year outside RFC 5280's 1950-2049 window rather than wrapping it a century, build.generalizedTime zero-pads the year to four digits, build.set orders its components as DER requires, build.integer/enumerated reject an empty or non-minimal content buffer, build.oid caps each sub-identifier, and build.ia5 rejects non-ASCII bytes.
20
+ - String decoding validates each restricted type: IA5String and VisibleString reject bytes outside their permitted range, PrintableString rejects characters outside its restricted set, and UTF8String rejects malformed UTF-8 instead of substituting the Unicode replacement character — closing a parser-differential on certificate name fields.
21
+ - BIT STRING decoding enforces DER's requirement that unused trailing bits be zero and rejects an empty BIT STRING that declares unused bits; UniversalString and BMPString decoding reject out-of-range and lone-surrogate code points with a typed Asn1Error instead of a bare RangeError.
22
+ - HMAC verify resolves false for a wrong-length signature instead of throwing, per the Web Cryptography API. AES-CTR encrypt/decrypt reject a counter length other than 128 rather than silently ignoring the parameter.
23
+ - pki.x509.parse raises a typed CertificateError (not a generic TypeError) for a truncated tbsCertificate, rejects a certificate carrying duplicate extensions (RFC 5280 §4.2), rejects a tbsCertificate with a repeated or out-of-order trailing field — a second extensions [3] wrapper (which would otherwise hide the first extension block and split duplicate extension OIDs across two wrappers past the per-extension check), or an out-of-order or unknown context field (RFC 5280 §4.1), rejects an empty issuer distinguished name (RFC 5280 §4.1.2.4) while still permitting an empty subject for the subjectAltName case, rejects an empty or non-SEQUENCE extensions field (RFC 5280 §4.1.2.9) with a typed error rather than a raw TypeError, validates the certificate version against the RFC 5280 set, and fails closed on a malformed string in a distinguished name (an invalid-UTF8 or out-of-range name value) instead of hex-escaping the invalid bytes away, so the decoder's strict string validation is enforced on the name path; a genuinely non-string attribute value (a primitive ANY-typed value, or a constructed non-string type such as a SEQUENCE) still renders as its RFC 4514 hex-encoded DER so the name stays representable.
24
+ - pki.oid.fromArcs rejects a negative or unsafe-integer arc instead of emitting a malformed OID string; the OID sub-identifier ceiling admits a 128-bit UUID-based arc; and the INTEGER ceiling admits a key at the magnitude cap with its DER sign octet.
25
+ - pki.version, pki.C.version, and the CLI now report the installed package version — the value is single-sourced from the package manifest and can no longer drift from the published release.
26
+
27
+ ### Security
28
+
29
+ - The DER decoder now builds every INTEGER and OID sub-identifier in a single linear pass and refuses any that exceed a per-value byte ceiling (C.LIMITS.DER_MAX_INTEGER_BYTES / OID sub-identifier limit), before reading them. Previously these values were accumulated a byte at a time, which is quadratic in their length: a certificate carrying an oversized serial number or OID arc — well within the overall size cap — could pin a CPU for minutes. This closes a remotely-triggerable decode denial-of-service reachable through pki.x509.parse and pki.asn1.read.*.
30
+ - The DER decoder rejects a primitive-encoded SEQUENCE or SET (X.690 §8.9.1/§8.11.1 require these to be constructed) rather than producing a leaf node. Previously such input decoded to a leaf that pki.x509.parse dereferenced as a structured node, crashing with an uncaught TypeError on attacker-controlled bytes; it now fails closed with a typed error.
31
+ - The DER decoder also rejects the mirror violation — a constructed encoding of a universal primitive-only type (INTEGER, OBJECT IDENTIFIER, BOOLEAN, the restricted strings, UTCTime/GeneralizedTime, BIT/OCTET STRING), which is valid BER but not valid DER (X.690 §10.2). Previously a constructed string tag decoded to a childless node that a certificate distinguished name would hex-render, letting an invalid BER/DER name value parse despite the restricted-string content checks; it now fails closed at decode.
32
+ - pki.webcrypto.subtle.unwrapKey now enforces the 'unwrapKey' key usage on every unwrap path, including the RSA-OAEP and AES-GCM delegate paths that previously skipped it — an unwrapping key without the 'unwrapKey' usage is now rejected. deriveKey now enforces the distinct 'deriveKey' usage rather than inheriting 'deriveBits'. Both close cases where an operator-set key-usage restriction could be bypassed.
33
+ - pki.x509.parse now rejects a certificate whose outer signatureAlgorithm does not match the signature algorithm inside the signed tbsCertificate (RFC 5280 §4.1.1.2). Surfacing the two AlgorithmIdentifiers without enforcing their equality let a certificate claim one algorithm in the signed body and another in the outer wrapper — a signature-algorithm-substitution vector; the two fields must now be identical.
34
+
7
35
  ## v0.1.1 — 2026-07-04
8
36
 
9
37
  First published release of the 0.1.x foundation.
package/lib/asn1-der.js CHANGED
@@ -65,6 +65,20 @@ var CLASS_CONTEXT = 0x80;
65
65
  var CLASS_PRIVATE = 0xc0;
66
66
  var CONSTRUCTED_BIT = 0x20;
67
67
 
68
+ // X.690 §8.x / §10.2 (DER) — every universal type the codec handles other than
69
+ // SEQUENCE and SET is a primitive-only type: it MUST be encoded primitive, and
70
+ // a constructed encoding (BOOLEAN, INTEGER, OID, the restricted strings,
71
+ // UTC/GeneralizedTime, and — DER-strict — BIT STRING / OCTET STRING) is not
72
+ // valid DER. Derived from TAGS so a newly-registered primitive type is covered
73
+ // automatically. This is the mirror of the primitive-SEQUENCE/SET rejection.
74
+ var PRIMITIVE_ONLY_UNIVERSAL_TAGS = (function () {
75
+ var s = Object.create(null);
76
+ Object.keys(TAGS).forEach(function (k) {
77
+ if (k !== "SEQUENCE" && k !== "SET") s[TAGS[k]] = true;
78
+ });
79
+ return s;
80
+ })();
81
+
68
82
  function _className(bits) {
69
83
  switch (bits) {
70
84
  case CLASS_UNIVERSAL: return "universal";
@@ -88,6 +102,8 @@ function _asBuffer(input, who) {
88
102
  * @signature pki.asn1.decode(bytes, opts?) -> node
89
103
  * @since 0.1.0
90
104
  * @status stable
105
+ * @spec X.690, ISO/IEC 8825-1
106
+ * @defends ASN.1-parser-DoS (CWE-400)
91
107
  * @related pki.asn1.encode
92
108
  *
93
109
  * Parse DER into a node tree. Each node is
@@ -156,6 +172,22 @@ function _decodeTLV(buf, start, limit, depth, maxDepth) {
156
172
  }
157
173
  }
158
174
 
175
+ // X.690 8.9.1 / 8.11.1 — a universal SEQUENCE or SET is always constructed;
176
+ // a primitive-tagged 0x10 / 0x11 is not valid DER (and would decode to a
177
+ // leaf that constructed-structure consumers dereference as a parent).
178
+ if (tagClassBits === CLASS_UNIVERSAL && (tagNumber === TAGS.SEQUENCE || tagNumber === TAGS.SET) && !constructed) {
179
+ throw new Asn1Error("asn1/bad-tlv", "SEQUENCE/SET must be constructed");
180
+ }
181
+
182
+ // X.690 §8.x / §10.2 — the mirror rule: a universal primitive-only type
183
+ // (INTEGER, OID, BOOLEAN, the restricted strings, times, BIT/OCTET STRING)
184
+ // encoded constructed is not valid DER. Without this a constructed string
185
+ // tag decodes to a childless node that later paths (an X.509 DN attribute
186
+ // value) would hex-render or dereference instead of failing closed.
187
+ if (tagClassBits === CLASS_UNIVERSAL && constructed && PRIMITIVE_ONLY_UNIVERSAL_TAGS[tagNumber]) {
188
+ throw new Asn1Error("asn1/constructed-primitive-type", "a universal primitive-only type must be encoded primitive in DER");
189
+ }
190
+
159
191
  if (p >= limit) throw new Asn1Error("asn1/truncated", "expected a length octet");
160
192
  var lenByte = buf[p]; p += 1;
161
193
  var length;
@@ -239,15 +271,23 @@ function readInteger(node) {
239
271
  _expectPrimitive(node, "readInteger");
240
272
  var c = node.content;
241
273
  if (c.length === 0) throw new Asn1Error("asn1/bad-integer", "INTEGER must have at least 1 content octet");
274
+ // Defense-in-depth: refuse an over-cap magnitude before touching BigInt.
275
+ // A one-shot hex parse is linear, but the length still bounds worst-case
276
+ // work — reject a hostile length prefix up front. The cap bounds the
277
+ // MAGNITUDE; a positive INTEGER whose top bit is set carries one leading
278
+ // 0x00 DER sign octet, so allow cap + 1 content bytes (an RSA-131072
279
+ // modulus is 16384 magnitude bytes plus that sign pad).
280
+ if (c.length > constants.LIMITS.DER_MAX_INTEGER_BYTES + 1) {
281
+ throw new Asn1Error("asn1/integer-too-large",
282
+ "INTEGER content " + c.length + " bytes exceeds cap " + (constants.LIMITS.DER_MAX_INTEGER_BYTES + 1));
283
+ }
242
284
  if (c.length > 1) {
243
285
  if (c[0] === 0x00 && (c[1] & 0x80) === 0) throw new Asn1Error("asn1/non-minimal-integer", "non-minimal positive INTEGER");
244
286
  if (c[0] === 0xff && (c[1] & 0x80) !== 0) throw new Asn1Error("asn1/non-minimal-integer", "non-minimal negative INTEGER");
245
287
  }
246
288
  var neg = (c[0] & 0x80) !== 0;
247
- var v = 0n;
248
- for (var i = 0; i < c.length; i++) v = (v << 8n) | BigInt(c[i]);
249
- if (neg) v -= (1n << BigInt(8 * c.length));
250
- return v;
289
+ var mag = c.length ? BigInt("0x" + Buffer.from(c).toString("hex")) : 0n;
290
+ return neg ? mag - (1n << BigInt(c.length * 8)) : mag;
251
291
  }
252
292
 
253
293
  function readBitString(node) {
@@ -258,6 +298,12 @@ function readBitString(node) {
258
298
  var unusedBits = c[0];
259
299
  if (unusedBits > 7) throw new Asn1Error("asn1/bad-bit-string", "unused-bit count " + unusedBits + " > 7");
260
300
  if (unusedBits > 0 && c.length === 1) throw new Asn1Error("asn1/bad-bit-string", "unused bits declared over an empty body");
301
+ // X.690 11.2.1 — the declared unused low bits of the final octet must be
302
+ // zero in DER.
303
+ if (unusedBits > 0 && c.length > 1) {
304
+ var mask = (1 << unusedBits) - 1;
305
+ if ((c[c.length - 1] & mask) !== 0) throw new Asn1Error("asn1/bad-bit-string", "DER requires unused bits to be zero");
306
+ }
261
307
  return { unusedBits: unusedBits, bytes: c.subarray(1) };
262
308
  }
263
309
 
@@ -279,6 +325,7 @@ function readNull(node) {
279
325
  * @signature pki.asn1.read.oid(node) -> "1.2.840.113549.1.1.11"
280
326
  * @since 0.1.0
281
327
  * @status stable
328
+ * @spec X.690 §8.19
282
329
  * @related pki.oid.name
283
330
  *
284
331
  * Decode an OBJECT IDENTIFIER node to its dotted-decimal string, enforcing
@@ -296,16 +343,27 @@ function readOid(node) {
296
343
  function decodeOidContent(buf) {
297
344
  if (buf.length === 0) throw new OidError("oid/empty", "OBJECT IDENTIFIER content is empty");
298
345
  var arcs = [];
299
- var value = 0n;
300
- var started = false;
346
+ var arcStart = 0;
301
347
  for (var i = 0; i < buf.length; i++) {
302
348
  var b = buf[i];
303
- if (!started && b === 0x80) throw new OidError("oid/non-minimal", "non-minimal sub-identifier (leading 0x80)");
304
- value = (value << 7n) | BigInt(b & 0x7f);
305
- started = true;
306
- if ((b & 0x80) === 0) { arcs.push(value); value = 0n; started = false; }
349
+ if (i === arcStart && b === 0x80) throw new OidError("oid/non-minimal", "non-minimal sub-identifier (leading 0x80)");
350
+ // Cap the continuation-byte run so a single arc can't drive unbounded
351
+ // BigInt growth before it terminates.
352
+ if (i - arcStart >= constants.LIMITS.OID_MAX_SUBIDENTIFIER_BYTES) {
353
+ throw new OidError("oid/subidentifier-too-large",
354
+ "OID sub-identifier exceeds " + constants.LIMITS.OID_MAX_SUBIDENTIFIER_BYTES + " octets");
355
+ }
356
+ if ((b & 0x80) === 0) {
357
+ // Terminal octet: fold the whole [arcStart..i] slice base-128 in one
358
+ // bounded pass (the run is capped above), no per-bit accumulation
359
+ // across the full OID content.
360
+ var value = 0n;
361
+ for (var k = arcStart; k <= i; k++) value = value * 128n + BigInt(buf[k] & 0x7f);
362
+ arcs.push(value);
363
+ arcStart = i + 1;
364
+ }
307
365
  }
308
- if (started) throw new OidError("oid/truncated", "OBJECT IDENTIFIER ends mid sub-identifier");
366
+ if (arcStart !== buf.length) throw new OidError("oid/truncated", "OBJECT IDENTIFIER ends mid sub-identifier");
309
367
  var first = arcs[0];
310
368
  var a1, a2;
311
369
  if (first < 40n) { a1 = 0n; a2 = first; }
@@ -320,15 +378,49 @@ function _decodeText(buf, encoding) {
320
378
  return buf.toString(encoding);
321
379
  }
322
380
 
381
+ // IA5String is 7-bit ASCII (T.50 / IA5) — every content octet is 0x00..0x7F.
382
+ function _decodeIa5(buf) {
383
+ for (var i = 0; i < buf.length; i++) {
384
+ if (buf[i] > 0x7F) throw new Asn1Error("asn1/bad-ia5-string", "IA5String requires 7-bit ASCII");
385
+ }
386
+ return buf.toString("latin1");
387
+ }
388
+
389
+ // VisibleString (ISO 646 IRV / X.690) is the printable ASCII range 0x20..0x7E
390
+ // — no control characters and no bytes with the high bit set.
391
+ function _decodeVisible(buf) {
392
+ for (var i = 0; i < buf.length; i++) {
393
+ if (buf[i] < 0x20 || buf[i] > 0x7E) throw new Asn1Error("asn1/bad-visible-string", "VisibleString must be 0x20..0x7E");
394
+ }
395
+ return buf.toString("latin1");
396
+ }
397
+
398
+ // PrintableString is restricted to A-Z a-z 0-9 space and ' ( ) + , - . / : = ?
399
+ function _decodePrintable(buf) {
400
+ var s = buf.toString("latin1");
401
+ if (!PRINTABLE_RE.test(s)) throw new Asn1Error("asn1/bad-printable-string", "PrintableString has characters outside the restricted set");
402
+ return s;
403
+ }
404
+
405
+ // UTF8String must be well-formed UTF-8; a lenient decoder substitutes U+FFFD
406
+ // for invalid sequences, silently mangling hostile input into valid text.
407
+ function _decodeUtf8Strict(buf) {
408
+ try {
409
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
410
+ } catch (_e) {
411
+ throw new Asn1Error("asn1/bad-utf8-string", "invalid UTF-8 in UTF8String");
412
+ }
413
+ }
414
+
323
415
  function readString(node) {
324
416
  if (node.tagClass !== "universal") throw new Asn1Error("asn1/expected-string", "readString: not a universal string type");
325
417
  _expectPrimitive(node, "readString");
326
418
  switch (node.tagNumber) {
327
- case TAGS.UTF8_STRING: return _decodeText(node.content, "utf8");
328
- case TAGS.PRINTABLE_STRING: return _decodeText(node.content, "latin1");
329
- case TAGS.IA5_STRING: return _decodeText(node.content, "latin1");
419
+ case TAGS.UTF8_STRING: return _decodeUtf8Strict(node.content);
420
+ case TAGS.PRINTABLE_STRING: return _decodePrintable(node.content);
421
+ case TAGS.IA5_STRING: return _decodeIa5(node.content);
330
422
  case TAGS.TELETEX_STRING: return _decodeText(node.content, "latin1");
331
- case TAGS.VISIBLE_STRING: return _decodeText(node.content, "latin1");
423
+ case TAGS.VISIBLE_STRING: return _decodeVisible(node.content);
332
424
  case TAGS.BMP_STRING: return _decodeUtf16be(node.content);
333
425
  case TAGS.UNIVERSAL_STRING: return _decodeUtf32be(node.content);
334
426
  default:
@@ -338,6 +430,12 @@ function readString(node) {
338
430
 
339
431
  function _decodeUtf16be(buf) {
340
432
  if (buf.length % 2 !== 0) throw new Asn1Error("asn1/bad-bmp-string", "BMPString length must be even");
433
+ // BMPString encodes BMP scalar values only; a UTF-16 surrogate code unit
434
+ // here is a lone surrogate, not valid text.
435
+ for (var i = 0; i < buf.length; i += 2) {
436
+ var u = (buf[i] << 8) | buf[i + 1];
437
+ if (u >= 0xD800 && u <= 0xDFFF) throw new Asn1Error("asn1/bad-bmp-string", "code point out of range");
438
+ }
341
439
  var swapped = Buffer.from(buf);
342
440
  swapped.swap16();
343
441
  return swapped.toString("utf16le");
@@ -348,6 +446,11 @@ function _decodeUtf32be(buf) {
348
446
  var out = "";
349
447
  for (var i = 0; i < buf.length; i += 4) {
350
448
  var cp = (buf[i] * 0x1000000) + (buf[i + 1] << 16) + (buf[i + 2] << 8) + buf[i + 3];
449
+ // Reject non-scalar values (> U+10FFFF or a surrogate) before
450
+ // String.fromCodePoint would throw a bare RangeError / emit a lone surrogate.
451
+ if (cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
452
+ throw new Asn1Error("asn1/bad-universal-string", "code point out of range");
453
+ }
351
454
  out += String.fromCodePoint(cp);
352
455
  }
353
456
  return out;
@@ -379,9 +482,23 @@ function readTime(node) {
379
482
  var hour = parseInt(m[4], 10);
380
483
  var min = parseInt(m[5], 10);
381
484
  var sec = parseInt(m[6], 10);
382
- var t = Date.UTC(year, month - 1, day, hour, min, sec);
383
- if (isNaN(t)) throw new Asn1Error("asn1/bad-time", "unparseable time " + JSON.stringify(s));
384
- return new Date(t);
485
+ // Build from the LITERAL year via setUTCFullYear: the Date.UTC() / new Date()
486
+ // constructors remap a year in 0..99 to 1900..1999, which would corrupt a
487
+ // GeneralizedTime year below 100 (0099 -> 1999). setUTCFullYear(year, ...)
488
+ // takes the year literally and uses that year's own calendar, so years
489
+ // below 100 and proleptic leap years are handled correctly.
490
+ var d = new Date(0);
491
+ d.setUTCFullYear(year, month - 1, day);
492
+ d.setUTCHours(hour, min, sec, 0);
493
+ if (isNaN(d.getTime())) throw new Asn1Error("asn1/bad-time", "unparseable time " + JSON.stringify(s));
494
+ // Date/setUTC* silently roll over out-of-range fields (Feb 30 -> Mar 2,
495
+ // month 13 -> next Jan, hour 25 -> +1 day). Reject unless every component
496
+ // round-trips exactly.
497
+ if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day ||
498
+ d.getUTCHours() !== hour || d.getUTCMinutes() !== min || d.getUTCSeconds() !== sec) {
499
+ throw new Asn1Error("asn1/bad-time", "time component out of range " + JSON.stringify(s));
500
+ }
501
+ return d;
385
502
  }
386
503
 
387
504
  // ---- Encoder / builders ---------------------------------------------
@@ -413,6 +530,7 @@ function encodeIdentifier(classBits, constructed, tagNumber) {
413
530
  * @signature pki.asn1.encode(classBits, constructed, tagNumber, content) -> Buffer
414
531
  * @since 0.1.0
415
532
  * @status stable
533
+ * @spec X.690, ISO/IEC 8825-1
416
534
  * @related pki.asn1.decode
417
535
  *
418
536
  * Low-level TLV encoder — prepend the identifier + DER length to a content
@@ -471,6 +589,12 @@ function encodeOidContent(dotted) {
471
589
  var body = [];
472
590
  var v = subids[s];
473
591
  do { body.unshift(Number(v & 0x7fn)); v >>= 7n; } while (v > 0n);
592
+ // Keep the encoder symmetric with the decoder's per-arc cap so build.oid
593
+ // can't emit a sub-identifier read.oid would reject as hostile.
594
+ if (body.length > constants.LIMITS.OID_MAX_SUBIDENTIFIER_BYTES) {
595
+ throw new OidError("oid/subidentifier-too-large",
596
+ "OID sub-identifier exceeds " + constants.LIMITS.OID_MAX_SUBIDENTIFIER_BYTES + " octets");
597
+ }
474
598
  for (var k = 0; k < body.length - 1; k++) body[k] |= 0x80;
475
599
  for (var j = 0; j < body.length; j++) out.push(body[j]);
476
600
  }
@@ -482,13 +606,22 @@ var PRINTABLE_RE = /^[A-Za-z0-9 '()+,\-./:=?]*$/;
482
606
  function _fmtTwo(n) { return (n < 10 ? "0" : "") + n; }
483
607
 
484
608
  function _generalizedTimeString(date) {
485
- return "" + date.getUTCFullYear() +
609
+ // GeneralizedTime is YYYYMMDDHHMMSSZ — a year below 1000 must still emit
610
+ // four digits, or read.time rejects the short (12-14 char) content.
611
+ var y = date.getUTCFullYear();
612
+ if (y < 0 || y > 9999) throw new Asn1Error("asn1/bad-generalizedtime", "GeneralizedTime year " + y + " outside 0000..9999");
613
+ var yyyy = ("000" + y).slice(-4);
614
+ return yyyy +
486
615
  _fmtTwo(date.getUTCMonth() + 1) + _fmtTwo(date.getUTCDate()) +
487
616
  _fmtTwo(date.getUTCHours()) + _fmtTwo(date.getUTCMinutes()) + _fmtTwo(date.getUTCSeconds()) + "Z";
488
617
  }
489
618
 
490
619
  function _utcTimeString(date) {
491
- var yy = date.getUTCFullYear() % 100;
620
+ // RFC 5280 §4.1.2.5.1 restricts UTCTime to 1950..2049; a year outside that
621
+ // wraps to the wrong century (2050 -> "50" -> decodes as 1950).
622
+ var y = date.getUTCFullYear();
623
+ if (y < 1950 || y > 2049) throw new Asn1Error("asn1/bad-utctime", "UTCTime year " + y + " outside 1950..2049; use GeneralizedTime");
624
+ var yy = y % 100;
492
625
  return _fmtTwo(yy) +
493
626
  _fmtTwo(date.getUTCMonth() + 1) + _fmtTwo(date.getUTCDate()) +
494
627
  _fmtTwo(date.getUTCHours()) + _fmtTwo(date.getUTCMinutes()) + _fmtTwo(date.getUTCSeconds()) + "Z";
@@ -499,6 +632,7 @@ function _utcTimeString(date) {
499
632
  * @signature pki.asn1.build.sequence([ ...tlvBuffers ]) -> Buffer
500
633
  * @since 0.1.0
501
634
  * @status stable
635
+ * @spec X.690, ISO/IEC 8825-1
502
636
  * @related pki.asn1.encode
503
637
  *
504
638
  * Canonical-DER value builders. Each returns the full TLV Buffer for one
@@ -513,18 +647,36 @@ function _utcTimeString(date) {
513
647
  */
514
648
  var build = {
515
649
  boolean: function (v) { return _universal(TAGS.BOOLEAN, false, Buffer.from([v ? 0xff : 0x00])); },
516
- integer: function (v) { return _universal(TAGS.INTEGER, false, Buffer.isBuffer(v) ? v : intToDer(v)); },
517
- enumerated: function (v) { return _universal(TAGS.ENUMERATED, false, Buffer.isBuffer(v) ? v : intToDer(v)); },
650
+ integer: function (v) { return _universal(TAGS.INTEGER, false, _intContent(v, "build.integer")); },
651
+ enumerated: function (v) { return _universal(TAGS.ENUMERATED, false, _intContent(v, "build.enumerated")); },
518
652
  nullValue: function () { return _universal(TAGS.NULL, false, Buffer.alloc(0)); },
519
653
  oid: function (dotted) { return _universal(TAGS.OBJECT_IDENTIFIER, false, encodeOidContent(dotted)); },
520
654
  octetString: function (buf) { return _universal(TAGS.OCTET_STRING, false, _asBuffer(buf, "build.octetString")); },
521
655
  bitString: function (buf, unusedBits) {
522
656
  var u = unusedBits || 0;
523
657
  if (u < 0 || u > 7) throw new Asn1Error("asn1/bad-bit-string", "unusedBits must be 0..7");
524
- return _universal(TAGS.BIT_STRING, false, Buffer.concat([Buffer.from([u]), _asBuffer(buf, "build.bitString")]));
658
+ var body = _asBuffer(buf, "build.bitString");
659
+ // X.690 8.6.2.3: an empty BIT STRING has no content octets, so its
660
+ // unused-bit count must be zero — there are no bits to leave unused.
661
+ if (u > 0 && body.length === 0) throw new Asn1Error("asn1/bad-bit-string", "empty BIT STRING must declare zero unused bits");
662
+ // Stay canonical: refuse a tail whose declared unused low bits are set.
663
+ if (u > 0 && body.length > 0) {
664
+ var mask = (1 << u) - 1;
665
+ if ((body[body.length - 1] & mask) !== 0) throw new Asn1Error("asn1/bad-bit-string", "unused bits must be zero");
666
+ }
667
+ return _universal(TAGS.BIT_STRING, false, Buffer.concat([Buffer.from([u]), body]));
525
668
  },
526
669
  utf8: function (s) { return _universal(TAGS.UTF8_STRING, false, Buffer.from(String(s), "utf8")); },
527
- ia5: function (s) { return _universal(TAGS.IA5_STRING, false, Buffer.from(String(s), "latin1")); },
670
+ ia5: function (s) {
671
+ // Validate the INPUT code points, not the latin1 output bytes: latin1
672
+ // truncates a code point > 0xFF to its low byte (U+0141 -> 0x41), which
673
+ // would silently pass a non-IA5 character through as a different one.
674
+ s = String(s);
675
+ for (var i = 0; i < s.length; i++) {
676
+ if (s.charCodeAt(i) > 0x7F) throw new Asn1Error("asn1/bad-ia5-string", "IA5String requires 7-bit ASCII");
677
+ }
678
+ return _universal(TAGS.IA5_STRING, false, Buffer.from(s, "latin1"));
679
+ },
528
680
  printable: function (s) {
529
681
  s = String(s);
530
682
  if (!PRINTABLE_RE.test(s)) throw new Asn1Error("asn1/bad-printable-string", "value has characters outside the PrintableString set");
@@ -533,7 +685,12 @@ var build = {
533
685
  utcTime: function (date) { return _universal(TAGS.UTC_TIME, false, Buffer.from(_utcTimeString(date), "latin1")); },
534
686
  generalizedTime: function (date) { return _universal(TAGS.GENERALIZED_TIME, false, Buffer.from(_generalizedTimeString(date), "latin1")); },
535
687
  sequence: function (children) { return _universal(TAGS.SEQUENCE, true, Buffer.concat(_asBufferArray(children, "build.sequence"))); },
536
- set: function (children) { return _universal(TAGS.SET, true, Buffer.concat(_asBufferArray(children, "build.set"))); },
688
+ set: function (children) {
689
+ // DER (X.690 11.6) requires SET component encodings in ascending order.
690
+ var arr = _asBufferArray(children, "build.set").slice();
691
+ arr.sort(Buffer.compare);
692
+ return _universal(TAGS.SET, true, Buffer.concat(arr));
693
+ },
537
694
  setOf: function (children) {
538
695
  var arr = _asBufferArray(children, "build.setOf").slice();
539
696
  arr.sort(Buffer.compare);
@@ -546,6 +703,26 @@ var build = {
546
703
  raw: function (buf) { return _asBuffer(buf, "build.raw"); },
547
704
  };
548
705
 
706
+ // A raw INTEGER/ENUMERATED content Buffer must satisfy the same DER shape
707
+ // read.integer enforces: non-empty and minimally encoded (no redundant sign
708
+ // octet). A BigInt/number goes through intToDer, which is minimal by build.
709
+ function _intContent(v, who) {
710
+ if (Buffer.isBuffer(v)) {
711
+ if (v.length === 0) throw new Asn1Error("asn1/bad-integer", who + ": INTEGER content is empty");
712
+ if (v.length > 1) {
713
+ if (v[0] === 0x00 && (v[1] & 0x80) === 0) throw new Asn1Error("asn1/non-minimal-integer", who + ": non-minimal positive INTEGER");
714
+ if (v[0] === 0xff && (v[1] & 0x80) !== 0) throw new Asn1Error("asn1/non-minimal-integer", who + ": non-minimal negative INTEGER");
715
+ }
716
+ }
717
+ var content = Buffer.isBuffer(v) ? v : intToDer(v);
718
+ // Symmetric with read.integer's per-value ceiling (magnitude + DER sign
719
+ // octet), so a builder can't emit an INTEGER the decoder would refuse.
720
+ if (content.length > constants.LIMITS.DER_MAX_INTEGER_BYTES + 1) {
721
+ throw new Asn1Error("asn1/integer-too-large", who + ": INTEGER content " + content.length + " bytes exceeds cap " + (constants.LIMITS.DER_MAX_INTEGER_BYTES + 1));
722
+ }
723
+ return content;
724
+ }
725
+
549
726
  function _asBufferArray(arr, who) {
550
727
  if (!Array.isArray(arr)) throw new Asn1Error("asn1/bad-children", who + ": expected an array of TLV buffers");
551
728
  return arr.map(function (b) { return _asBuffer(b, who); });
package/lib/constants.js CHANGED
@@ -52,6 +52,7 @@ var DAYS_PER_WEEK = 7;
52
52
  * @signature C.TIME.days(n) -> milliseconds
53
53
  * @since 0.1.0
54
54
  * @status stable
55
+ * @spec internal (design: functional time-scale helpers)
55
56
  *
56
57
  * Duration helpers. Each returns an integer count of milliseconds so the
57
58
  * value drops straight into `setTimeout`, a validity window, or an OCSP
@@ -78,6 +79,7 @@ var BYTES_PER_KIB = 1024;
78
79
  * @signature C.BYTES.mib(n) -> bytes
79
80
  * @since 0.1.0
80
81
  * @status stable
82
+ * @spec IEC 80000-13
81
83
  *
82
84
  * Binary-magnitude size helpers. Each returns an integer byte count for
83
85
  * codec limits (max DER input, max PEM block) so a size bound reads as
@@ -99,13 +101,29 @@ var BYTES = {
99
101
  // unbounded length prefix or a pathologically-nested SEQUENCE is a
100
102
  // classic decoder-DoS, so the bound is a fail-closed default rather
101
103
  // than a tunable an operator must remember to set.
104
+ //
105
+ // DER_MAX_INTEGER_BYTES and OID_MAX_SUBIDENTIFIER_BYTES are per-value
106
+ // ceilings the whole-document cap can't provide: a single INTEGER or OID
107
+ // sub-identifier under the 16 MiB limit can still carry hundreds of KiB,
108
+ // so a value reader that scales super-linearly in its content length is a
109
+ // decoder-DoS the document cap does not stop. 16 KiB covers any real key
110
+ // material (an RSA-131072 modulus), and 32 bytes covers any real OID arc:
111
+ // the largest standard sub-identifier is a 128-bit UUID-based arc (X.667,
112
+ // e.g. 2.25.<uuid>), which is 19 base-128 bytes — so the cap must exceed
113
+ // that, not sit at 16. Anything larger than 32 bytes is refused as hostile.
102
114
  var LIMITS = {
103
115
  DER_MAX_BYTES: BYTES.mib(16),
104
116
  DER_MAX_DEPTH: 64,
105
117
  PEM_MAX_BYTES: BYTES.mib(16),
118
+ DER_MAX_INTEGER_BYTES: BYTES.kib(16),
119
+ OID_MAX_SUBIDENTIFIER_BYTES: 32,
106
120
  };
107
121
 
108
- var VERSION = "0.1.0";
122
+ // Single-sourced from the package manifest so the reported version can
123
+ // never drift from the published package (package.json is always present
124
+ // in the installed tarball). A hand-maintained literal here silently
125
+ // shipped 0.1.0 on a 0.1.1 release.
126
+ var VERSION = require("../package.json").version;
109
127
 
110
128
  module.exports = {
111
129
  TIME: TIME,
@@ -32,6 +32,7 @@
32
32
  * @signature new PkiError(message, code)
33
33
  * @since 0.1.0
34
34
  * @status stable
35
+ * @spec internal (design: error taxonomy base class)
35
36
  *
36
37
  * Base class every toolkit error extends. Provides the unified
37
38
  * `instanceof` check plus the `{ name, code, isPkiError }` shape.
@@ -57,6 +58,7 @@ class PkiError extends Error {
57
58
  * @signature pki.errors.defineClass(name, opts?) -> constructor
58
59
  * @since 0.1.0
59
60
  * @status stable
61
+ * @spec internal (design: error-class factory)
60
62
  *
61
63
  * Factory that produces a `PkiError` subclass with the standard shape —
62
64
  * eliminating the per-domain boilerplate. The returned constructor takes
package/lib/oid.js CHANGED
@@ -17,14 +17,20 @@
17
17
  * codebase — is what lets a new algorithm be a data entry instead of a
18
18
  * code change.
19
19
  *
20
- * The seed set covers the RFC 5280 attribute types and extensions, the
20
+ * The seed set is declared by FAMILY: an OID belongs to a class with a
21
+ * shared base arc (the "starting variable" — `2.5.4` for the RFC 5280
22
+ * attribute types, `2.5.29` for the extensions, `2.16.840.1.101.3.4` for
23
+ * the NIST algorithms), and each member names only its trailing arc. The
24
+ * full OID is derived from base + leaf at load, so the arc hierarchy that
25
+ * IS the OID namespace is modelled directly instead of re-spelled per
26
+ * entry. It covers the RFC 5280 attribute types and extensions, the
21
27
  * classical signature / public-key / digest algorithms, and the
22
28
  * NIST-assigned post-quantum arcs (ML-DSA, ML-KEM, SLH-DSA). Operators
23
- * extend it with `register`.
29
+ * extend it with `register` (one OID) or `registerFamily` (a whole arc).
24
30
  *
25
31
  * @card
26
- * Two-way OID ↔ name registry with arc conversion, seeded with the
27
- * RFC 5280 and NIST post-quantum object identifiers.
32
+ * Two-way OID ↔ name registry with arc conversion, seeded by family from
33
+ * the RFC 5280 and NIST post-quantum object identifiers.
28
34
  */
29
35
 
30
36
  var asn1 = require("./asn1-der");
@@ -32,77 +38,64 @@ var frameworkError = require("./framework-error");
32
38
 
33
39
  var OidError = frameworkError.OidError;
34
40
 
35
- // SEED[dotted, canonicalName]. Kept as a flat table so it reads as
36
- // data and diffs cleanly when an arc is added.
37
- var SEED = [
38
- // --- RFC 5280 attribute types (2.5.4.*) ---
39
- ["2.5.4.3", "commonName"],
40
- ["2.5.4.4", "surname"],
41
- ["2.5.4.5", "serialNumber"],
42
- ["2.5.4.6", "countryName"],
43
- ["2.5.4.7", "localityName"],
44
- ["2.5.4.8", "stateOrProvinceName"],
45
- ["2.5.4.9", "streetAddress"],
46
- ["2.5.4.10", "organizationName"],
47
- ["2.5.4.11", "organizationalUnitName"],
48
- ["2.5.4.12", "title"],
49
- ["2.5.4.42", "givenName"],
50
- ["0.9.2342.19200300.100.1.25", "domainComponent"],
51
- ["1.2.840.113549.1.9.1", "emailAddress"],
52
-
53
- // --- Public-key + signature algorithms ---
54
- ["1.2.840.113549.1.1.1", "rsaEncryption"],
55
- ["1.2.840.113549.1.1.10", "rsassaPss"],
56
- ["1.2.840.113549.1.1.11", "sha256WithRSAEncryption"],
57
- ["1.2.840.113549.1.1.12", "sha384WithRSAEncryption"],
58
- ["1.2.840.113549.1.1.13", "sha512WithRSAEncryption"],
59
- ["1.2.840.10045.2.1", "ecPublicKey"],
60
- ["1.2.840.10045.4.3.2", "ecdsaWithSHA256"],
61
- ["1.2.840.10045.4.3.3", "ecdsaWithSHA384"],
62
- ["1.2.840.10045.4.3.4", "ecdsaWithSHA512"],
63
- ["1.2.840.10045.3.1.7", "prime256v1"],
64
- ["1.3.132.0.34", "secp384r1"],
65
- ["1.3.132.0.35", "secp521r1"],
66
- ["1.3.101.110", "X25519"],
67
- ["1.3.101.111", "X448"],
68
- ["1.3.101.112", "Ed25519"],
69
- ["1.3.101.113", "Ed448"],
70
-
71
- // --- Digests ---
72
- ["2.16.840.1.101.3.4.2.1", "sha256"],
73
- ["2.16.840.1.101.3.4.2.2", "sha384"],
74
- ["2.16.840.1.101.3.4.2.3", "sha512"],
75
- ["2.16.840.1.101.3.4.2.8", "sha3-256"],
76
- ["2.16.840.1.101.3.4.2.10", "sha3-512"],
77
- ["2.16.840.1.101.3.4.2.12", "shake256"],
78
-
79
- // --- RFC 5280 certificate extensions (2.5.29.*) ---
80
- ["2.5.29.14", "subjectKeyIdentifier"],
81
- ["2.5.29.15", "keyUsage"],
82
- ["2.5.29.17", "subjectAltName"],
83
- ["2.5.29.18", "issuerAltName"],
84
- ["2.5.29.19", "basicConstraints"],
85
- ["2.5.29.30", "nameConstraints"],
86
- ["2.5.29.31", "cRLDistributionPoints"],
87
- ["2.5.29.32", "certificatePolicies"],
88
- ["2.5.29.35", "authorityKeyIdentifier"],
89
- ["2.5.29.37", "extKeyUsage"],
90
- ["1.3.6.1.5.5.7.1.1", "authorityInfoAccess"],
91
-
92
- // --- NIST post-quantum algorithms ---
93
- // FIPS 204 ML-DSA (signature-algorithm arc 2.16.840.1.101.3.4.3.*).
94
- ["2.16.840.1.101.3.4.3.17", "id-ml-dsa-44"],
95
- ["2.16.840.1.101.3.4.3.18", "id-ml-dsa-65"],
96
- ["2.16.840.1.101.3.4.3.19", "id-ml-dsa-87"],
97
- // FIPS 203 ML-KEM (KEM arc 2.16.840.1.101.3.4.4.*).
98
- ["2.16.840.1.101.3.4.4.1", "id-ml-kem-512"],
99
- ["2.16.840.1.101.3.4.4.2", "id-ml-kem-768"],
100
- ["2.16.840.1.101.3.4.4.3", "id-ml-kem-1024"],
101
- // FIPS 205 SLH-DSA (a representative slice of the SHAKE-256 family).
102
- ["2.16.840.1.101.3.4.3.20", "id-slh-dsa-sha2-128s"],
103
- ["2.16.840.1.101.3.4.3.24", "id-slh-dsa-shake-128s"],
104
- ["2.16.840.1.101.3.4.3.27", "id-slh-dsa-shake-256s"],
105
- ];
41
+ // FAMILIESOIDs grouped by their shared base arc (the "similar starting
42
+ // variable" that defines a class). A member is `name: leaf`, where leaf is a
43
+ // trailing arc (number) or a short arc array for a multi-level leaf; the full
44
+ // arc is derived at load via base.concat(leaf). Declaring by family means no
45
+ // dotted-decimal OID literal appears in this source at all, and adding a
46
+ // member is one `name: leaf` line under its class — no base to re-type.
47
+ var FAMILIES = {
48
+ // RFC 5280 attribute types.
49
+ attributeType: { base: [2, 5, 4], of: {
50
+ commonName: 3, surname: 4, serialNumber: 5, countryName: 6,
51
+ localityName: 7, stateOrProvinceName: 8, streetAddress: 9,
52
+ organizationName: 10, organizationalUnitName: 11, title: 12, givenName: 42 } },
53
+
54
+ // RFC 5280 certificate extensions.
55
+ certExtension: { base: [2, 5, 29], of: {
56
+ subjectKeyIdentifier: 14, keyUsage: 15, subjectAltName: 17, issuerAltName: 18,
57
+ basicConstraints: 19, nameConstraints: 30, cRLDistributionPoints: 31,
58
+ certificatePolicies: 32, authorityKeyIdentifier: 35, extKeyUsage: 37 } },
59
+
60
+ // PKIX private extensions (authorityInfoAccess et al).
61
+ pkixAccess: { base: [1, 3, 6, 1, 5, 5, 7, 1], of: { authorityInfoAccess: 1 } },
62
+
63
+ // PKCS#1 RSA public-key + RSASSA signature algorithms.
64
+ rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
65
+ rsaEncryption: 1, rsassaPss: 10, sha256WithRSAEncryption: 11,
66
+ sha384WithRSAEncryption: 12, sha512WithRSAEncryption: 13 } },
67
+
68
+ // PKCS#9 attribute types.
69
+ pkcs9: { base: [1, 2, 840, 113549, 1, 9], of: { emailAddress: 1 } },
70
+
71
+ // ANSI X9.62 EC public key, named curve, and ECDSA signatures.
72
+ ansiX962: { base: [1, 2, 840, 10045], of: {
73
+ ecPublicKey: [2, 1], prime256v1: [3, 1, 7],
74
+ ecdsaWithSHA256: [4, 3, 2], ecdsaWithSHA384: [4, 3, 3], ecdsaWithSHA512: [4, 3, 4] } },
75
+
76
+ // SECG named curves.
77
+ secg: { base: [1, 3, 132, 0], of: { secp384r1: 34, secp521r1: 35 } },
78
+
79
+ // Edwards / Montgomery curves (RFC 8410).
80
+ edwards: { base: [1, 3, 101], of: { X25519: 110, X448: 111, Ed25519: 112, Ed448: 113 } },
81
+
82
+ // NIST hash functions (SHA-2, SHA-3, SHAKE).
83
+ nistHash: { base: [2, 16, 840, 1, 101, 3, 4, 2], of: {
84
+ sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake256: 12 } },
85
+
86
+ // NIST signature algorithms — FIPS 204 ML-DSA + FIPS 205 SLH-DSA share the
87
+ // signature arc 2.16.840.1.101.3.4.3.
88
+ nistSig: { base: [2, 16, 840, 1, 101, 3, 4, 3], of: {
89
+ "id-ml-dsa-44": 17, "id-ml-dsa-65": 18, "id-ml-dsa-87": 19,
90
+ "id-slh-dsa-sha2-128s": 20, "id-slh-dsa-shake-128s": 24, "id-slh-dsa-shake-256s": 27 } },
91
+
92
+ // NIST KEM — FIPS 203 ML-KEM (arc 2.16.840.1.101.3.4.4).
93
+ nistKem: { base: [2, 16, 840, 1, 101, 3, 4, 4], of: {
94
+ "id-ml-kem-512": 1, "id-ml-kem-768": 2, "id-ml-kem-1024": 3 } },
95
+
96
+ // Misc datatypes (RFC 4519 domainComponent).
97
+ datatype: { base: [0, 9, 2342, 19200300, 100, 1], of: { domainComponent: 25 } },
98
+ };
106
99
 
107
100
  var _byOid = new Map();
108
101
  var _byName = new Map();
@@ -113,7 +106,20 @@ function _index(dotted, name) {
113
106
  if (!_byName.has(name)) _byName.set(name, dotted);
114
107
  }
115
108
 
116
- for (var _i = 0; _i < SEED.length; _i++) _index(SEED[_i][0], SEED[_i][1]);
109
+ // An arc is a non-negative integer. Large arcs (a 128-bit UUID-based arc
110
+ // exceeds 2^53) must be BigInt to survive without precision loss, so both
111
+ // a non-negative safe-integer Number and a non-negative BigInt are valid.
112
+ function _isArc(a) {
113
+ if (typeof a === "bigint") return a >= 0n;
114
+ return typeof a === "number" && Number.isSafeInteger(a) && a >= 0;
115
+ }
116
+
117
+ // Seed the registry family by family — the built-in set is registered through
118
+ // the exact same primitive an operator uses (registerFamily), so there is one
119
+ // path from a declared family to indexed OIDs and no dotted literals anywhere.
120
+ Object.keys(FAMILIES).forEach(function (fam) {
121
+ registerFamily(FAMILIES[fam].base, FAMILIES[fam].of);
122
+ });
117
123
 
118
124
  function _assertDotted(dotted, who) {
119
125
  if (typeof dotted !== "string" || !/^\d+(\.\d+)+$/.test(dotted)) {
@@ -126,6 +132,7 @@ function _assertDotted(dotted, who) {
126
132
  * @signature pki.oid.name(dotted) -> string | undefined
127
133
  * @since 0.1.0
128
134
  * @status stable
135
+ * @spec X.660, RFC 5280
129
136
  * @related pki.oid.byName, pki.oid.register
130
137
  *
131
138
  * Resolve a dotted OID to its registered name. Returns `undefined` for an
@@ -155,7 +162,8 @@ function has(dotted) {
155
162
  * @signature pki.oid.register(dotted, name) -> void
156
163
  * @since 0.1.0
157
164
  * @status stable
158
- * @related pki.oid.name
165
+ * @spec X.660, RFC 5280
166
+ * @related pki.oid.registerFamily, pki.oid.name
159
167
  *
160
168
  * Add (or override) an OID → name mapping so an operator's private or
161
169
  * newly-standardized arc resolves through the same registry as the seed
@@ -171,6 +179,51 @@ function register(dotted, n) {
171
179
  _index(dotted, n);
172
180
  }
173
181
 
182
+ /**
183
+ * @primitive pki.oid.registerFamily
184
+ * @signature pki.oid.registerFamily(base, members) -> void
185
+ * @since 0.1.2
186
+ * @status stable
187
+ * @spec X.660
188
+ * @related pki.oid.register, pki.oid.name
189
+ *
190
+ * Register a whole OID family in one call. `base` is the shared arc prefix
191
+ * (the starting variable a class of OIDs has in common) and `members` maps
192
+ * each name to its trailing arc — a number, or a short arc array for a
193
+ * multi-level leaf. Each full OID is derived as `base` followed by the leaf,
194
+ * so a family is declared as its hierarchy rather than as re-spelled full
195
+ * paths. This is the primitive the built-in seed set itself is built from.
196
+ *
197
+ * @opts
198
+ * base: number[], // the shared arc prefix, e.g. [1,3,6,1,4,1,99999]
199
+ * members: object, // name -> number | number[] trailing arc
200
+ *
201
+ * @example
202
+ * pki.oid.registerFamily([1, 3, 6, 1, 4, 1, 99999], {
203
+ * widgetPolicy: 1,
204
+ * gadgetPolicy: [2, 4],
205
+ * });
206
+ * pki.oid.name("1.3.6.1.4.1.99999.2.4"); // -> "gadgetPolicy"
207
+ */
208
+ function registerFamily(base, members) {
209
+ if (!Array.isArray(base) || base.length < 1 || !base.every(_isArc)) {
210
+ throw new OidError("oid/bad-input", "registerFamily: base must be a non-empty array of non-negative integer arcs");
211
+ }
212
+ if (!members || typeof members !== "object") {
213
+ throw new OidError("oid/bad-input", "registerFamily: members must be an object of name -> leaf arc(s)");
214
+ }
215
+ Object.keys(members).forEach(function (nm) {
216
+ if (typeof nm !== "string" || nm.length === 0) {
217
+ throw new OidError("oid/bad-input", "registerFamily: member names must be non-empty strings");
218
+ }
219
+ var arcs = base.concat(members[nm]);
220
+ if (!arcs.every(_isArc)) {
221
+ throw new OidError("oid/bad-arc", "registerFamily: member " + JSON.stringify(nm) + " has a non-arc leaf");
222
+ }
223
+ _index(arcs.join("."), nm);
224
+ });
225
+ }
226
+
174
227
  function all() {
175
228
  var out = {};
176
229
  _byOid.forEach(function (v, k) { out[k] = v; });
@@ -191,8 +244,11 @@ function toArcs(dotted) {
191
244
  function fromArcs(arcs) {
192
245
  if (!Array.isArray(arcs) || arcs.length < 2) throw new OidError("oid/bad-input", "fromArcs: expected an array of >= 2 arcs");
193
246
  return arcs.map(function (a) {
194
- if (typeof a === "bigint") return a.toString();
195
- if (typeof a === "number" && Number.isInteger(a) && a >= 0) return String(a);
247
+ if (typeof a === "bigint") {
248
+ if (a < 0n) throw new OidError("oid/bad-arc", "fromArcs: arc " + String(a) + " is not a non-negative integer");
249
+ return a.toString();
250
+ }
251
+ if (typeof a === "number" && Number.isSafeInteger(a) && a >= 0) return String(a);
196
252
  throw new OidError("oid/bad-arc", "fromArcs: arc " + String(a) + " is not a non-negative integer");
197
253
  }).join(".");
198
254
  }
@@ -207,13 +263,14 @@ function fromDER(input) {
207
263
  }
208
264
 
209
265
  module.exports = {
210
- name: name,
211
- byName: byName,
212
- has: has,
213
- register: register,
214
- all: all,
215
- toArcs: toArcs,
216
- fromArcs: fromArcs,
217
- toDER: toDER,
218
- fromDER: fromDER,
266
+ name: name,
267
+ byName: byName,
268
+ has: has,
269
+ register: register,
270
+ registerFamily: registerFamily,
271
+ all: all,
272
+ toArcs: toArcs,
273
+ fromArcs: fromArcs,
274
+ toDER: toDER,
275
+ fromDER: fromDER,
219
276
  };
package/lib/webcrypto.js CHANGED
@@ -111,6 +111,7 @@ var SLH_DSA_NODE = {};
111
111
  * @signature new pki.webcrypto.CryptoKey(type, extractable, algorithm, usages, handle)
112
112
  * @since 0.1.0
113
113
  * @status stable
114
+ * @spec W3C WebCrypto §cryptokey
114
115
  *
115
116
  * Opaque handle to key material, matching the W3C `CryptoKey` shape:
116
117
  * `{ type, extractable, algorithm, usages }`. The underlying
@@ -266,7 +267,12 @@ SubtleCrypto.prototype.verify = async function verify(algorithm, key, signature,
266
267
  if (name === "HMAC") {
267
268
  var hm = nodeCrypto.createHmac(_hashNode(key.algorithm.hash, "verify"), key._handle);
268
269
  hm.update(buf);
269
- return nodeCrypto.timingSafeEqual(hm.digest(), sig);
270
+ // Verify must RESOLVE false for any invalid signature, including one of
271
+ // the wrong length. timingSafeEqual throws RangeError on a length
272
+ // mismatch, so gate on length first; the length is already public, so
273
+ // this leaks nothing a constant-time compare would protect.
274
+ var mac = hm.digest();
275
+ return mac.length === sig.length && nodeCrypto.timingSafeEqual(mac, sig);
270
276
  }
271
277
  throw new WebCryptoError("webcrypto/not-supported", "verify: unsupported algorithm " + JSON.stringify(name));
272
278
  };
@@ -295,6 +301,7 @@ SubtleCrypto.prototype.encrypt = async function encrypt(algorithm, key, data) {
295
301
  return _toArrayBuffer(Buffer.concat([c2.update(buf), c2.final()]));
296
302
  }
297
303
  if (name === "AES-CTR") {
304
+ _requireCtrLength128(alg);
298
305
  var c3 = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-ctr", _secretBytes(key), _toBuf(alg.counter, "AES-CTR counter"));
299
306
  return _toArrayBuffer(Buffer.concat([c3.update(buf), c3.final()]));
300
307
  }
@@ -328,6 +335,7 @@ SubtleCrypto.prototype.decrypt = async function decrypt(algorithm, key, data) {
328
335
  return _toArrayBuffer(Buffer.concat([d2.update(buf), d2.final()]));
329
336
  }
330
337
  if (name === "AES-CTR") {
338
+ _requireCtrLength128(alg);
331
339
  var d3 = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-ctr", _secretBytes(key), _toBuf(alg.counter, "AES-CTR counter"));
332
340
  return _toArrayBuffer(Buffer.concat([d3.update(buf), d3.final()]));
333
341
  }
@@ -336,9 +344,21 @@ SubtleCrypto.prototype.decrypt = async function decrypt(algorithm, key, data) {
336
344
 
337
345
  function _secretBytes(key) { return key._handle.export(); }
338
346
 
339
- SubtleCrypto.prototype.deriveBits = async function deriveBits(algorithm, key, length) {
340
- var alg = _normalizeAlg(algorithm, "deriveBits");
341
- _requireUsage(key, "deriveBits");
347
+ // AES-CTR: node always treats the full 128-bit block as the counter and
348
+ // never reads the spec's `length` (counter-width) parameter. A length < 128
349
+ // would silently diverge from the W3C definition, so fail closed — accept
350
+ // only the one value node can honor.
351
+ function _requireCtrLength128(alg) {
352
+ if (alg.length !== 128) {
353
+ throw new WebCryptoError("webcrypto/not-supported", "AES-CTR length must be 128");
354
+ }
355
+ }
356
+
357
+ // Raw key-agreement / KDF derivation with NO usage check — the usage a
358
+ // caller must hold differs by entry point (deriveBits requires "deriveBits",
359
+ // deriveKey requires "deriveKey"), so each public method checks its own
360
+ // usage and then routes the actual derivation through here.
361
+ function _deriveBitsRaw(alg, key, length) {
342
362
  var name = alg.name;
343
363
  if (name === "ECDH" || name === "X25519" || name === "X448") {
344
364
  var secret = nodeCrypto.diffieHellman({ privateKey: key._handle, publicKey: alg.public._handle });
@@ -353,12 +373,24 @@ SubtleCrypto.prototype.deriveBits = async function deriveBits(algorithm, key, le
353
373
  return _toArrayBuffer(out);
354
374
  }
355
375
  throw new WebCryptoError("webcrypto/not-supported", "deriveBits: unsupported algorithm " + JSON.stringify(name));
376
+ }
377
+
378
+ SubtleCrypto.prototype.deriveBits = async function deriveBits(algorithm, key, length) {
379
+ var alg = _normalizeAlg(algorithm, "deriveBits");
380
+ _requireUsage(key, "deriveBits");
381
+ return _deriveBitsRaw(alg, key, length);
356
382
  };
357
383
 
358
384
  SubtleCrypto.prototype.deriveKey = async function deriveKey(algorithm, baseKey, derivedKeyType, extractable, keyUsages) {
385
+ // deriveKey requires the "deriveKey" usage — NOT "deriveBits". Delegating
386
+ // to this.deriveBits would false-reject a key created with ["deriveKey"]
387
+ // and fail-open on a ["deriveBits"]-only key, so check the correct usage
388
+ // here and route the raw derivation past deriveBits' own usage gate.
389
+ _requireUsage(baseKey, "deriveKey");
390
+ var alg = _normalizeAlg(algorithm, "deriveKey");
359
391
  var dk = _normalizeAlg(derivedKeyType, "deriveKey");
360
392
  var bits = dk.length || (dk.name.indexOf("AES") === 0 ? dk.length : 256);
361
- var raw = await this.deriveBits(algorithm, baseKey, bits);
393
+ var raw = _deriveBitsRaw(alg, baseKey, bits);
362
394
  return this.importKey("raw", raw, dk, extractable, keyUsages);
363
395
  };
364
396
 
@@ -378,9 +410,14 @@ SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey
378
410
 
379
411
  SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey, unwrappingKey, unwrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages) {
380
412
  var alg = _normalizeAlg(unwrapAlgorithm, "unwrapKey");
413
+ // Enforce the "unwrapKey" usage before EITHER path. The delegated
414
+ // (RSA-OAEP / AES-GCM) branch clones the key with "decrypt" and hands off
415
+ // to this.decrypt, so without this top-level check the else branch would
416
+ // never verify the caller was actually permitted to unwrap (mirrors
417
+ // wrapKey, which checks before both of its paths).
418
+ _requireUsage(unwrappingKey, "unwrapKey");
381
419
  var bytes;
382
420
  if (alg.name === "AES-KW") {
383
- _requireUsage(unwrappingKey, "unwrapKey");
384
421
  var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", _secretBytes(unwrappingKey), Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
385
422
  bytes = Buffer.concat([d.update(_toBuf(wrappedKey, "unwrapKey")), d.final()]);
386
423
  } else {
@@ -470,6 +507,7 @@ function _curveFromKey(ko) {
470
507
  * @signature await pki.webcrypto.subtle.exportKey(format, key)
471
508
  * @since 0.1.0
472
509
  * @status stable
510
+ * @spec W3C WebCrypto §subtlecrypto, FIPS 186-5, FIPS 203, FIPS 204, FIPS 205, RFC 8017
473
511
  * @related pki.webcrypto.CryptoKey
474
512
  *
475
513
  * Export a `CryptoKey` to `spki` (public), `pkcs8` (private), `jwk`
@@ -507,6 +545,7 @@ function _rawPublic(key) {
507
545
  * @signature pki.webcrypto.getRandomValues(typedArray) / pki.webcrypto.subtle
508
546
  * @since 0.1.0
509
547
  * @status stable
548
+ * @spec W3C WebCrypto
510
549
  * @related pki.webcrypto.subtle
511
550
  *
512
551
  * A ready `Crypto` instance (the shape of `globalThis.crypto`) exposing
package/lib/x509.js CHANGED
@@ -64,6 +64,7 @@ var PEM_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
64
64
  * @signature pki.x509.pemDecode(text, label?) -> Buffer
65
65
  * @since 0.1.0
66
66
  * @status stable
67
+ * @spec RFC 7468, RFC 5280
67
68
  * @related pki.x509.pemEncode
68
69
  *
69
70
  * Extract the DER bytes from a PEM block. With `label` given (e.g.
@@ -91,6 +92,7 @@ function pemDecode(text, label) {
91
92
  * @signature pki.x509.pemEncode(der, label) -> string
92
93
  * @since 0.1.0
93
94
  * @status stable
95
+ * @spec RFC 7468, RFC 5280
94
96
  * @related pki.x509.pemDecode
95
97
  *
96
98
  * Wrap DER bytes in a PEM envelope with 64-column base64 lines.
@@ -121,10 +123,25 @@ function _algId(node) {
121
123
 
122
124
  function _attrValueToString(node) {
123
125
  try { return asn1.read.string(node); }
124
- catch (_e) {
125
- // Not a recognized string type (e.g. an ANY-typed value) — surface
126
- // the raw bytes as hex so the DN is still representable.
127
- return "#" + (node.content ? node.content.toString("hex") : node.bytes.toString("hex"));
126
+ catch (e) {
127
+ // A malformed KNOWN string type (invalid UTF-8, a non-IA5 byte, a
128
+ // PrintableString character outside its set, ...) is a malformed
129
+ // certificate and must fail closed — do NOT hex-encode it away, or the
130
+ // decoder's strict string validation is silently bypassed on the DN path.
131
+ // Those surface as asn1/bad-* content errors; raise a CertificateError so
132
+ // parse fails closed on the documented x509/* contract.
133
+ //
134
+ // A value that simply is not a decodable primitive string is NOT malformed
135
+ // and must stay representable: an ANY-typed non-string tag
136
+ // (asn1/expected-string) or a constructed universal type such as a SEQUENCE
137
+ // (asn1/expected-primitive). Per RFC 4514 §2.4 it is rendered as "#" plus
138
+ // the hex of its FULL DER encoding (tag + length + content — node.bytes),
139
+ // so a constructed value round-trips intact rather than being dropped or
140
+ // rejecting the whole certificate.
141
+ if (!e || (e.code !== "asn1/expected-string" && e.code !== "asn1/expected-primitive")) {
142
+ throw new CertificateError("x509/bad-atv", "malformed string in attribute value: " + ((e && e.message) || String(e)));
143
+ }
144
+ return "#" + node.bytes.toString("hex");
128
145
  }
129
146
  }
130
147
 
@@ -171,13 +188,30 @@ function _parseValidityTime(node) {
171
188
  }
172
189
 
173
190
  function _parseExtensions(seqNode) {
191
+ // RFC 5280 §4.1.2.9 — Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension.
192
+ // The wrapped value must be a SEQUENCE (not a primitive, which would read
193
+ // seqNode.children as null and throw a raw TypeError) and must carry at
194
+ // least one extension.
195
+ if (seqNode.tagClass !== "universal" || seqNode.tagNumber !== TAGS.SEQUENCE || !seqNode.children) {
196
+ throw new CertificateError("x509/bad-extensions", "extensions [3] must wrap a SEQUENCE");
197
+ }
198
+ if (seqNode.children.length === 0) {
199
+ throw new CertificateError("x509/bad-extensions", "extensions SEQUENCE must contain at least one extension");
200
+ }
174
201
  var out = [];
202
+ var seen = new Set();
175
203
  for (var i = 0; i < seqNode.children.length; i++) {
176
204
  var ext = seqNode.children[i];
177
205
  if (!ext.children || ext.children.length < 2) {
178
206
  throw new CertificateError("x509/bad-extension", "Extension must be a SEQUENCE");
179
207
  }
180
208
  var extnID = asn1.read.oid(ext.children[0]);
209
+ // RFC 5280 §4.2 — a certificate MUST NOT include more than one
210
+ // instance of a particular extension.
211
+ if (seen.has(extnID)) {
212
+ throw new CertificateError("x509/duplicate-extension", "certificate repeats extension " + extnID);
213
+ }
214
+ seen.add(extnID);
181
215
  var critical = false;
182
216
  var valueNode;
183
217
  if (ext.children.length === 3) {
@@ -203,6 +237,8 @@ function _parseExtensions(seqNode) {
203
237
  * @signature pki.x509.parse(input) -> certificate
204
238
  * @since 0.1.0
205
239
  * @status stable
240
+ * @spec RFC 5280, X.509
241
+ * @defends malformed-certificate-parse (CWE-20)
206
242
  * @related pki.asn1.decode, pki.oid.name
207
243
  *
208
244
  * Parse a DER `Buffer` or a PEM string/Buffer into a structured
@@ -245,17 +281,37 @@ function parse(input) {
245
281
  var tbs = root.children[0];
246
282
  var outerSigAlg = root.children[1];
247
283
  var sigValueNode = root.children[2];
248
- if (!tbs.children || tbs.children.length < 6) {
249
- throw new CertificateError("x509/bad-tbs", "tbsCertificate is too short");
284
+ if (!tbs.children || !tbs.children.length) {
285
+ throw new CertificateError("x509/bad-tbs", "tbsCertificate is empty");
250
286
  }
251
287
 
252
288
  var idx = 0;
253
- var version = 1;
289
+ var version;
254
290
  var first = tbs.children[0];
255
291
  if (first.tagClass === "context" && first.tagNumber === 0) {
256
292
  if (!first.children || !first.children.length) throw new CertificateError("x509/bad-version", "version [0] must wrap an INTEGER");
257
- version = Number(asn1.read.integer(first.children[0])) + 1;
293
+ // RFC 5280 §4.1.2.1 — version is INTEGER { v1(0), v2(1), v3(2) }; read
294
+ // it as a BigInt so an out-of-range value can't be coerced to a float,
295
+ // and reject an explicitly-encoded v1 (DER forbids encoding the DEFAULT).
296
+ var versionValue = asn1.read.integer(first.children[0]);
297
+ if (versionValue === 0n) {
298
+ throw new CertificateError("x509/bad-version", "DER forbids explicitly encoding the default version v1");
299
+ } else if (versionValue === 1n) {
300
+ version = 2;
301
+ } else if (versionValue === 2n) {
302
+ version = 3;
303
+ } else {
304
+ throw new CertificateError("x509/bad-version", "unsupported certificate version " + versionValue.toString());
305
+ }
258
306
  idx = 1;
307
+ } else {
308
+ version = 1;
309
+ }
310
+
311
+ // Six positional fields follow any [0] version; a shorter tbs would read
312
+ // `undefined` and throw a raw TypeError on the first property access.
313
+ if (tbs.children.length < idx + 6) {
314
+ throw new CertificateError("x509/bad-tbs", "tbsCertificate is too short");
259
315
  }
260
316
 
261
317
  var serialNode = tbs.children[idx++];
@@ -266,6 +322,21 @@ function parse(input) {
266
322
  var subject = _parseName(tbs.children[idx++]);
267
323
  var spkiNode = tbs.children[idx++];
268
324
 
325
+ // RFC 5280 §4.1.1.2 — the outer signatureAlgorithm MUST contain the same
326
+ // AlgorithmIdentifier (OID and parameters) as tbsCertificate.signature. A
327
+ // mismatch is a signature-algorithm-substitution vector, so compare the full
328
+ // DER of both fields rather than surfacing two disagreeing algorithms.
329
+ if (!outerSigAlg.bytes.equals(innerSigAlg.bytes)) {
330
+ throw new CertificateError("x509/bad-signature-algorithm", "signatureAlgorithm must match tbsCertificate.signature (RFC 5280 §4.1.1.2)");
331
+ }
332
+
333
+ // RFC 5280 §4.1.2.4 — the issuer field MUST contain a non-empty distinguished
334
+ // name. (An empty subject is permitted when a subjectAltName carries the
335
+ // identity — §4.1.2.6 — so only the issuer is required non-empty here.)
336
+ if (!issuer.rdns.length) {
337
+ throw new CertificateError("x509/bad-issuer", "issuer must be a non-empty distinguished name");
338
+ }
339
+
269
340
  if (!validityNode.children || validityNode.children.length !== 2) {
270
341
  throw new CertificateError("x509/bad-validity", "Validity must be a SEQUENCE of {notBefore, notAfter}");
271
342
  }
@@ -285,16 +356,36 @@ function parse(input) {
285
356
  };
286
357
 
287
358
  // Remaining tbs children: optional issuerUniqueID [1], subjectUniqueID
288
- // [2], and extensions [3] EXPLICIT. Only extensions are surfaced.
359
+ // [2], and extensions [3] EXPLICIT. Only extensions are surfaced. RFC 5280
360
+ // §4.1 fixes these as the trailing fields, each at most once and in strictly
361
+ // increasing tag order; a repeated or out-of-order tag (or an unknown /
362
+ // non-context field) is malformed. Without the monotonic guard a second [3]
363
+ // silently overwrites the first, hiding its extensions and splitting
364
+ // duplicate extension OIDs across two wrappers past the per-sequence check.
289
365
  var extensions = [];
366
+ var hasExtensions = false;
367
+ var lastTrailingTag = 0;
290
368
  for (; idx < tbs.children.length; idx++) {
291
369
  var t = tbs.children[idx];
292
- if (t.tagClass === "context" && t.tagNumber === 3) {
370
+ if (t.tagClass !== "context" || t.tagNumber < 1 || t.tagNumber > 3) {
371
+ throw new CertificateError("x509/bad-tbs", "unexpected field after subjectPublicKeyInfo; tbsCertificate allows only issuerUniqueID [1], subjectUniqueID [2], extensions [3]");
372
+ }
373
+ if (t.tagNumber <= lastTrailingTag) {
374
+ throw new CertificateError("x509/bad-tbs", "tbsCertificate trailing field [" + t.tagNumber + "] is repeated or out of order");
375
+ }
376
+ lastTrailingTag = t.tagNumber;
377
+ if (t.tagNumber === 3) {
378
+ hasExtensions = true;
293
379
  if (!t.children || !t.children.length) throw new CertificateError("x509/bad-extensions", "extensions [3] must wrap a SEQUENCE");
294
380
  extensions = _parseExtensions(t.children[0]);
295
381
  }
296
382
  }
297
383
 
384
+ // RFC 5280 §4.1.2.9 — the extensions field appears only in a v3 cert.
385
+ if (hasExtensions && version !== 3) {
386
+ throw new CertificateError("x509/bad-version", "extensions are only permitted in a v3 certificate");
387
+ }
388
+
298
389
  var sigBits = asn1.read.bitString(sigValueNode);
299
390
 
300
391
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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:3eef8bae-7aaa-4ecf-a97b-3774a378db59",
5
+ "serialNumber": "urn:uuid:61f60234-7f86-4857-9542-58817209d84d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-04T12:48:34.391Z",
8
+ "timestamp": "2026-07-04T19:11:06.976Z",
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.1",
22
+ "bom-ref": "@blamejs/pki@0.1.2",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.1",
25
+ "version": "0.1.2",
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.1",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.2",
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.1",
57
+ "ref": "@blamejs/pki@0.1.2",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]