@blamejs/pki 0.4.5 → 0.4.6

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,22 @@ 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.4.6 — 2026-08-08
8
+
9
+ A C509 certificate now has one encoding where the specification defines one -- nine alternative spellings that rebuilt a byte-identical X.509 certificate, so that a single signature covered all of them, are refused, and the encoder emits the spelling it accepts.
10
+
11
+ ### Fixed
12
+
13
+ - An attribute value now carries the one spelling the specification assigns it. A text value of even length drawn only from the characters 0-9 and a-f is a byte string; a value in EUI-64 form is a tagged MAC address, 48-bit when it matches the FF-FE marker pattern and 64-bit otherwise; anything else is text. Each alternative spelling rebuilt the identical certificate, so the one signature over it covered them all. An empty value spelled as an empty byte string is refused for the same reason -- it renders as the empty text, which already has a spelling.
14
+ - A name holding a single common name is the bare value, not an array of one pair; an extensions field holding only a key usage is the single integer, not an array of two; and an alternative name holding exactly one DNS name is the bare text, not an array. Each of these compact forms is the encoding the specification defines for that case, and the long form of the same value is now refused rather than accepted alongside it. The long form remains the encoding for every case that is not the single one -- a name with two attributes, a key usage beside another extension, an alternative name with two entries.
15
+ - A certificate whose issuer is identical to its subject encodes that issuer as the CBOR simple value null, which the specification requires and which this toolkit previously wrote out in full. Both directions changed: the encoder emits the null, and a certificate that spells the issuer out instead is refused. The comparison is made on the certificate's own bytes rather than on a normalized name, because the reconstruction rebuilds a null issuer from the subject -- two names that merely compare equal would rebuild different bytes and break the signature over them.
16
+ - Algorithm parameters must be a complete element. An empty byte string is none, and it rebuilt the same algorithm identifier as the form that omits parameters entirely, giving one algorithm two encodings.
17
+ - The encoder walks the same rules it enforces on the way in, so a certificate it emits is one it reads back. Previously it wrote an even-length-hex attribute value as text -- a spelling its own parser now refuses -- and wrote a self-signed certificate's issuer out in full.
18
+
19
+ ### Known limitations
20
+
21
+ - One redundancy remains because the specification permits it: a registered algorithm may be encoded either as its registry integer or as its object identifier, and both are accepted, so a certificate using one is byte-different from the same certificate using the other. Identify a certificate by the X.509 bytes it reconstructs rather than by its C509 bytes.
22
+
7
23
  ## v0.4.5 — 2026-08-08
8
24
 
9
25
  A private key created outside this toolkit's own WebCrypto now signs and exports across the toolkit -- a key from the platform's WebCrypto, or from a separately-installed copy of this toolkit, previously reached the crypto library as a key it could not read and failed with a type error instead of a reason.
package/lib/jose.js CHANGED
@@ -437,9 +437,10 @@ async function sign(opts) {
437
437
  var payloadB64 = payload.length === 0 ? "" : b64uEncode(payload);
438
438
  var signingInput = Buffer.from(protectedB64 + "." + payloadB64, "ascii");
439
439
  var jwk = header.jwk || opts.jwk || {};
440
- // opts.key is documented as a CryptoKey, so a CryptoKey from any WebCrypto implementation signs
441
- // here; one from another implementation carries none of the material this engine signs with and
442
- // is re-imported through it, rather than reaching subtle.sign as a key it cannot read.
440
+ // opts.key is documented as a CryptoKey, so one created outside this engine signs here too: it
441
+ // carries none of the material this engine signs with, and is re-imported through it rather than
442
+ // reaching subtle.sign as a key it cannot read. A key whose material this process cannot reach at
443
+ // all -- non-extractable, or held behind another implementation's interface -- is refused there.
443
444
  var signKey = await wcEngine.adoptKey(opts.key, null, ["sign"], E, "jose/bad-input");
444
445
  var sigBuf = Buffer.from(await webcrypto.subtle.sign(_cryptoAlg(checked.algRow, jwk, signKey), signKey, signingInput));
445
446
  // Pin the produced signature length the same way verify does. A key whose curve /
@@ -225,6 +225,11 @@ function _algorithm(node, byInt, code, label) {
225
225
  // The [~oid, params] form carries the DER parameters as a CBOR byte string; a non-byte-string here
226
226
  // is malformed and cannot be reconstructed (b.raw would append garbage) -- fail closed.
227
227
  if (node.children[1].majorType !== 2) throw _err(code, label + " algorithm parameters must be a CBOR byte string");
228
+ // The parameters carry ONE complete DER element spliced into the reconstruction. An empty byte
229
+ // string is no element at all: it rebuilds the same AlgorithmIdentifier as the bare ~oid form,
230
+ // which is how the CDDL already spells "no parameters" -- so accepting it would give one
231
+ // algorithm two encodings, and one X.509 signature would cover both.
232
+ if (node.children[1].content.length === 0) throw _err(code, label + " algorithm parameters must not be an empty byte string; omit them with the bare ~oid form (draft sec. 3.1.3)");
228
233
  return { name: a.name, oid: a.oid, parameters: node.children[1].content };
229
234
  }
230
235
  throw _err(code, label + " is not a C509 AlgorithmIdentifier (int / ~oid / [~oid, params])");
@@ -248,6 +253,40 @@ function _specialText(node) {
248
253
  throw _err("c509/bad-name", "an attribute value is not a C509 SpecialText (text / bytes / tag-48)");
249
254
  }
250
255
 
256
+ // draft sec. 3.1.4 fixes WHICH of the three SpecialText spellings a value takes -- the choice belongs to
257
+ // the specification, not the sender. A text string of an even length >= 2 drawn only from '0'-'9'/'a'-'f'
258
+ // is encoded as a byte string; a text string of the form "HH-HH-HH-HH-HH-HH-HH-HH" is encoded as a tag-48
259
+ // MAC address, 48-bit when it matches "HH-HH-HH-FF-FE-HH-HH-HH" and 64-bit otherwise; anything else is a
260
+ // text string. Accepting a second spelling of one value would give the reconstructed certificate more than
261
+ // one C509 encoding, so a single X.509 signature would cover them all and two distinct byte strings would
262
+ // name one certificate. NOT enforced for a natively signed certificate: sec. 3.1.4 says bytes and tag 48
263
+ // there "do not correspond to any predefined text string encoding and may also be used for other attribute
264
+ // types", so no canonical text spelling exists to hold one to.
265
+ var _HEX_OPTIMIZED = /^(?:[0-9a-f]{2})+$/;
266
+ var _EUI64_TEXT = /^(?:[0-9A-F]{2}-){7}[0-9A-F]{2}$/;
267
+ function _assertCanonicalSpecialText(node, isNative, E) {
268
+ if (isNative) return;
269
+ if (node.majorType === 3) {
270
+ var t = cbor.read.textString(node);
271
+ if (t.length >= 2 && _HEX_OPTIMIZED.test(t)) {
272
+ throw _err(E, "a text attribute value of even-length hex characters must be encoded as a CBOR byte string (draft sec. 3.1.4)");
273
+ }
274
+ if (_EUI64_TEXT.test(t)) {
275
+ throw _err(E, "a text attribute value in EUI-64 form must be encoded as a CBOR tag-48 MAC address (draft sec. 3.1.4)");
276
+ }
277
+ return;
278
+ }
279
+ if (node.majorType === 2) {
280
+ // An empty byte string renders as the empty text, whose canonical spelling is a text string.
281
+ if (node.content.length === 0) throw _err(E, "an empty attribute value must be encoded as a CBOR text string (draft sec. 3.1.4)");
282
+ return;
283
+ }
284
+ var eui = node.children[0].content;
285
+ if (eui.length === 8 && eui[3] === 0xff && eui[4] === 0xfe) {
286
+ throw _err(E, "an EUI-64 of the form HH-HH-HH-FF-FE-HH-HH-HH must be encoded as a 48-bit MAC address (draft sec. 3.1.4)");
287
+ }
288
+ }
289
+
251
290
  // A single Name (sec. 3.1.4/sec. 3.1.6): the CBOR simple null (issuer only) | a bare SpecialText single
252
291
  // commonName | an array of RDNAttributes. Surfaces { dn, rdns, eui64? } shape-compatible with x509.
253
292
  // An rdn's (attribute type, sign) pair DECLARES an X.509 string type, so its text must be valid for that type --
@@ -269,6 +308,7 @@ function _name509(node, isSubject, isNative) {
269
308
  // A bare SpecialText (not an array) is a single commonName attribute (attributeType == +1).
270
309
  if (node.majorType === 3 || node.majorType === 2 || node.majorType === 6) {
271
310
  var sv = _specialText(node);
311
+ _assertCanonicalSpecialText(node, isNative, "c509/bad-name");
272
312
  // The bare form is a single commonName -- hold its value to the SAME rules the array form applies, so a
273
313
  // natively-signed certificate (which never reconstructs) cannot carry a value the reconstruction would refuse.
274
314
  // A tag-48 MAC renders to a fixed 17-character EUI-64 string that satisfies every value rule by
@@ -307,6 +347,7 @@ function _name509(node, isSubject, isNative) {
307
347
  var tname = ATTR_BY_INT[Math.abs(ti)];
308
348
  if (tname === undefined) throw _err("c509/bad-name", "attribute type integer " + ti + " has no C509 registry row");
309
349
  var v = _specialText(kids[i + 1]);
350
+ _assertCanonicalSpecialText(kids[i + 1], isNative, "c509/bad-name");
310
351
  var vv = v.text !== undefined ? v.text : (v.hex !== undefined ? v.hex : _macToEui64String(v.eui64));
311
352
  // The RENDERED text is what carries into the rdn -- deliberately NOT the raw eui64, because the EUI shortcut
312
353
  // in _reconAttrValue is unconditional and would take precedence over the attribute's declared string type,
@@ -317,6 +358,13 @@ function _name509(node, isSubject, isNative) {
317
358
  rdns.push(rdn);
318
359
  parts.push(_shortName(tname) + "=" + guard.name.escapeDnValue(vv));
319
360
  }
361
+ // sec. 3.1.4: "If Name contains a single 'common name' attribute with attributeType = +1, it is for
362
+ // compactness encoded as just the SpecialText containing the single attribute value." The array
363
+ // spelling of that one case is therefore a second encoding of a name that already has a canonical
364
+ // one. Only +1 has a bare form -- a negative (printableString) commonName keeps the array.
365
+ if (!isNative && rdns.length === 1 && rdns[0].type === "commonName" && !rdns[0].printable) {
366
+ throw _err("c509/bad-name", "a Name holding a single +1 commonName must be encoded as a bare SpecialText, not an array (draft sec. 3.1.4)");
367
+ }
320
368
  return { rdns: rdns, dn: parts.join(",") };
321
369
  }
322
370
  // The rendered dn is an RFC 4514 string, so every VALUE is escaped through the shared guard the rest of the
@@ -1109,6 +1157,14 @@ function _extValueToDer(name, node, isNative) {
1109
1157
  case "subjectAltName":
1110
1158
  case "issuerAltName": // SubjectAltName = GeneralNames / text (exactly one dNSName -> bare text)
1111
1159
  if (node.majorType === 3) return b.sequence([b.contextPrimitive(2, _ia5Bytes(node, 2))]);
1160
+ // sec. 3.3: "If subjectAltName contains exactly one dNSName, the array and the int are omitted and
1161
+ // extensionValue is the dNSName encoded as a CBOR text string." The array spelling of that one case
1162
+ // is a second encoding of the same extension value.
1163
+ if (!isNative && node.majorType === 4 && node.children && node.children.length === 2 &&
1164
+ node.children[0].majorType === 0 && Number(cbor.read.int(node.children[0])) === 2 &&
1165
+ node.children[1].majorType === 3) {
1166
+ throw _err("c509/bad-extensions", "a " + name + " holding exactly one dNSName must be encoded as a bare CBOR text string, not an array (draft sec. 3.3)");
1167
+ }
1112
1168
  return b.sequence(_generalNamesToDer(node, isNative));
1113
1169
  case "nameConstraints": { // [ permittedSubtrees / null, excludedSubtrees / null ]
1114
1170
  if (node.majorType !== 4 || !node.children || node.children.length !== 2) throw _err("c509/bad-extensions", "a nameConstraints value must be a 2-element CBOR array [ permitted, excluded ] (sec. 3.3)");
@@ -1747,6 +1803,14 @@ function _extensions(node, isNative) {
1747
1803
  // Each extension is an (extensionID, extensionValue) pair; an odd-length array is a dangling
1748
1804
  // extension identifier with no value -- reject rather than silently drop the trailing element.
1749
1805
  if (kids.length % 2 !== 0) throw _err("c509/bad-extensions", "a C509 extensions array must be id/value pairs (dangling extension identifier)");
1806
+ // sec. 3.1.10: "If the CBOR array contains exactly two ints and the absolute value of the first int is
1807
+ // 2 (corresponding to keyUsage), the CBOR array is omitted and the 'extensions' field is encoded as a
1808
+ // single CBOR int." The array spelling of that one case is a second encoding of the same extensions.
1809
+ if (!isNative && kids.length === 2 &&
1810
+ (kids[0].majorType === 0 || kids[0].majorType === 1) && (kids[1].majorType === 0 || kids[1].majorType === 1) &&
1811
+ Math.abs(Number(cbor.read.int(kids[0]))) === 2) {
1812
+ throw _err("c509/bad-extensions", "an extensions field holding only keyUsage must be encoded as a single CBOR int, not an array (draft sec. 3.1.10)");
1813
+ }
1750
1814
  for (var i = 0; i + 1 < kids.length; i += 2) {
1751
1815
  var idNode = kids[i], valNode = kids[i + 1];
1752
1816
  var name, extOid, critical, valContent;
@@ -1921,13 +1985,21 @@ function _reconAlgId(alg) {
1921
1985
  // The full type-3 -> DER Certificate reconstruction, byte-for-byte.
1922
1986
  function _reconstructDer(r, sigNode) {
1923
1987
  var sigAlgSeq = _reconAlgId(r.signatureAlgorithm);
1988
+ var subjectName = _reconName(r.subject);
1989
+ var spelledIssuer = r.issuer && r.issuer.rdns ? _reconName(r.issuer) : null;
1990
+ // sec. 3.1.4: an issuer identical to the subject MUST be the CBOR simple value null. Spelling it
1991
+ // out instead rebuilds the same DER, so one certificate would have two C509 encodings and the one
1992
+ // X.509 signature over that DER would cover both.
1993
+ if (spelledIssuer !== null && spelledIssuer.equals(subjectName)) {
1994
+ throw _err("c509/bad-name", "an issuer identical to the subject must be encoded as the CBOR simple value null (draft sec. 3.1.4)");
1995
+ }
1924
1996
  var tbsFields = [
1925
1997
  b.explicit(0, b.integer(2n)), // version v3 (type-3 is X.509 v3)
1926
1998
  b.integer(r.serialNumber),
1927
1999
  sigAlgSeq,
1928
- _reconName(r.issuer && r.issuer.rdns ? r.issuer : r.subject), // null issuer -> issuer == subject
2000
+ spelledIssuer === null ? subjectName : spelledIssuer, // null issuer -> issuer == subject
1929
2001
  b.sequence([_reconTime(r.validity.notBefore), _reconTime(r.validity.notAfter)]),
1930
- _reconName(r.subject),
2002
+ subjectName,
1931
2003
  _reconSpki(r.subjectPublicKeyAlgorithm, r.subjectPublicKey, r.rsaPublicKey),
1932
2004
  ];
1933
2005
  // RFC 5280 sec. 4.1: the [3] extensions field is OPTIONAL and, when present, SHALL contain at least one
@@ -2088,6 +2160,10 @@ function _minBytes(n) {
2088
2160
  return Buffer.from(hex, "hex");
2089
2161
  }
2090
2162
  // A C509 AlgorithmIdentifier -> int (registry) | ~oid (bare bytes) | [~oid, params]. `key` selects the row.
2163
+ // The bare-~oid arm is unreachable from either call site as the type-3 encoder stands, and is kept as
2164
+ // the honest fallback rather than an assumption: the signature slot refuses every non-ECDSA algorithm
2165
+ // before this runs, and every curve the subjectPublicKey slot accepts (prime256v1 / secp384r1 /
2166
+ // secp521r1) carries an ecPublicKey registry row. Adding a curve without its row would reach it.
2091
2167
  function _encAlgorithm(alg, toInt, key) {
2092
2168
  var i = toInt[key];
2093
2169
  if (i !== undefined && !(alg.parameters && alg.parameters.length)) return cbor.build.int(BigInt(i));
@@ -2095,10 +2171,16 @@ function _encAlgorithm(alg, toInt, key) {
2095
2171
  if (alg.parameters && alg.parameters.length) return cbor.build.array([oidBytes, cbor.build.byteString(alg.parameters)]);
2096
2172
  return oidBytes;
2097
2173
  }
2098
- // A SpecialText attribute value -> CBOR (text | tag-48 EUI). v1 encodes the text + eui64 forms.
2174
+ // A SpecialText attribute value -> CBOR, walking the sec. 3.1.4 cascade: an EUI-64 is a tag-48 MAC
2175
+ // address, a text string of even length >= 2 drawn only from '0'-'9'/'a'-'f' is a byte string, and
2176
+ // anything else is a text string. The decoder holds an incoming certificate to exactly this cascade,
2177
+ // so the encoder walks it too -- a spelling chosen here that the decoder refuses would make this
2178
+ // encoder emit certificates its own parser rejects.
2099
2179
  function _encSpecialText(rdn) {
2100
2180
  if (rdn.eui64) return cbor.build.tag(48, cbor.build.byteString(rdn.eui64));
2101
- return cbor.build.textString(String(rdn.value));
2181
+ var v = String(rdn.value);
2182
+ if (v.length >= 2 && _HEX_OPTIMIZED.test(v)) return cbor.build.byteString(Buffer.from(v, "hex"));
2183
+ return cbor.build.textString(v);
2102
2184
  }
2103
2185
  // A Name -> CBOR: null (issuer only) | a bare SpecialText single utf8 commonName | an array of RDN pairs.
2104
2186
  function _encName(name, isSubject) {
@@ -2375,7 +2457,13 @@ _derToType3 = function (input, opts) {
2375
2457
  certificateType: 3,
2376
2458
  serialNumber: c.serialNumber, // no serialNumberHex -> the encoder uses the minimal ~biguint magnitude
2377
2459
  signatureAlgorithm: { name: c.signatureAlgorithm.name, oid: c.signatureAlgorithm.oid },
2378
- issuer: _c509NameFromDer(c.issuer.bytes),
2460
+ // sec. 3.1.4: "If the 'issuer' field is identical to the 'subject' field, e.g., in case of
2461
+ // self-signed certificates, then the 'issuer' field MUST be encoded as the CBOR simple value
2462
+ // null." Compared on the RAW DER bytes, not on a canonicalized name: the reconstruction rebuilds
2463
+ // a null issuer FROM the subject, so two names that merely compare equal (a PrintableString
2464
+ // against a UTF8String of the same characters) would rebuild different bytes and break the
2465
+ // signature that covers them. Byte-identical is exactly the condition under which that is safe.
2466
+ issuer: c.issuer.bytes.equals(c.subject.bytes) ? null : _c509NameFromDer(c.issuer.bytes),
2379
2467
  validity: { notBefore: c.validity.notBefore, notAfter: c.validity.notAfter.getTime() === _NO_EXPIRY ? null : c.validity.notAfter },
2380
2468
  subject: _c509NameFromDer(c.subject.bytes),
2381
2469
  subjectPublicKeyAlgorithm: { name: "ecPublicKey", oid: c.subjectPublicKeyInfo.algorithm.oid, curve: curve },
@@ -170,9 +170,9 @@ function _normCompositeKeys(key, comp, E) {
170
170
  function _importKey(key, imp, E) {
171
171
  if (key && typeof key === "object" && !Buffer.isBuffer(key) && !(key instanceof Uint8Array) && key.type === "private") {
172
172
  _assertKeyMatchesScheme(key, imp, E);
173
- // `key` is documented as taking a CryptoKey, so a CryptoKey from any WebCrypto implementation
174
- // signs here -- one from another implementation is re-imported through this engine, because it
175
- // carries none of the key material this engine signs with.
173
+ // `key` is documented as taking a CryptoKey, so one created outside this engine signs here too --
174
+ // it is re-imported through this engine, because it carries none of the key material this engine
175
+ // signs with. One whose material this process cannot reach is refused with that as the reason.
176
176
  return webcrypto.adoptKey(key, imp, ["sign"], E, "bad-input");
177
177
  }
178
178
  var der;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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:1b37cc21-0807-4acb-b565-ce23bee1bd57",
5
+ "serialNumber": "urn:uuid:1feef474-ddbd-4d2d-ae82-845a967ba69b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-08T19:12:34.134Z",
8
+ "timestamp": "2026-08-08T20:12:36.384Z",
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.4.5",
22
+ "bom-ref": "@blamejs/pki@0.4.6",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.5",
25
+ "version": "0.4.6",
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.4.5",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.4.6",
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.4.5",
57
+ "ref": "@blamejs/pki@0.4.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]