@blamejs/pki 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.3.4 — 2026-07-17
8
+
9
+ pki.schema.c509.encode produces C509 CBOR certificates -- a DER X.509 certificate compresses to a compact, byte-exact-invertible type-3 C509, and a deterministic-CBOR encoder joins pki.cbor.
10
+
11
+ ### Added
12
+
13
+ - pki.schema.c509.encode(input) encodes a C509 certificate to deterministic-CBOR bytes -- a DER X.509 v3 certificate to a compact type-3 C509 (byte-exact-invertible, so the original signature verifies), or a pki.schema.c509.parse result re-emitted to its native array. Canonical deterministic CBOR with the registry integer shorthands and the C509 compressions; a certificate outside the invertible covered set throws a typed C509Error. Certificate parsing remains pki.schema.c509.parse.
14
+ - pki.cbor.build is a deterministic-CBOR encoder (RFC 8949 section 4.2) -- the byte-exact inverse of pki.cbor.decode: shortest-form heads, definite lengths, sorted and unique map keys, over unsigned and negative integers, byte and text strings, arrays, maps, tags, and the tagged bignum / epoch-time / object-identifier leaves. Encoded output always re-decodes through the strict decoder.
15
+
7
16
  ## v0.3.3 — 2026-07-17
8
17
 
9
18
  pki.crmf.build issues RFC 4211 certificate request messages -- a CertReqMessages with a signature proof of possession, over every algorithm the toolkit supports.
package/README.md CHANGED
@@ -204,7 +204,7 @@ is callable today; nothing below is a stub.
204
204
  | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation and certificate/PKCS#8 import — the RFC 9935 seed / expandedKey / both private-key CHOICE is validated fail-closed, so an OpenSSL-legacy bare-seed or an internally inconsistent key is rejected with a typed error (KEM encapsulation lands with CMS KEM-decrypt). Zero-dependency, OpenSSL-interoperable |
205
205
  | `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
206
206
  | `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields, with named + partly-decoded extensions — including the RFC 3739 / ETSI EN 319 412-5 qualified-certificate `qcStatements` (EU-qualified declaration, reliance limit, QSCD flag, certificate type, retention, PDS URLs, country of qualification; unknown statements preserved opaque) and the Microsoft Active Directory Certificate Services enrollment extensions (certificate template, CA version, previous-CA-certificate hash, application policies), fail-closed — `parse`, `pemDecode`, `pemEncode` |
207
- | `pki.schema.c509` | Parse C509 CBOR-encoded certificates (draft-ietf-cose-cbor-encoded-cert) — the compact CBOR profile of X.509, decoded fail-closed under deterministic CBOR; an explicit `parse` call (CBOR, not DER, so not auto-routed) |
207
+ | `pki.schema.c509` | Parse **and encode** C509 CBOR-encoded certificates (draft-ietf-cose-cbor-encoded-cert) — the compact CBOR profile of X.509, decoded fail-closed under deterministic CBOR; an explicit `parse` call (CBOR, not DER, so not auto-routed). `encode(input)` is the byte-exact inverse: a DER X.509 v3 certificate forward-transforms to a compact type-3 C509 whose reconstruction reproduces the original DER byte for byte (so the original signature still verifies), or a `parse` result re-emits its native array — canonical deterministic CBOR with the registry integer shorthands and the C509 compressions; a certificate outside the invertible set throws a typed `C509Error` |
208
208
  | `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode`, `pemEncode` |
209
209
  | `pki.schema.csr` | Parse DER / PEM PKCS#10 certification requests per RFC 2986 — subject DN, public key, requested attributes, signature, fail-closed — `parse`, `pemDecode`, `pemEncode` |
210
210
  | `pki.schema.pkcs8` | Parse DER / PEM PKCS#8 private keys per RFC 5208 / 5958 — algorithm, raw key bytes, attributes, optional public key, fail-closed; encrypted keys recognized (not decrypted) — `parse`, `parseEncrypted`, `pemDecode`, `pemEncode` |
package/lib/cbor-det.js CHANGED
@@ -624,8 +624,111 @@ function readOid(node) {
624
624
  return asn1.decodeOidContent(inner.content);
625
625
  }
626
626
 
627
+ // ---- deterministic-CBOR encoder (RFC 8949 sec. 4.2) -- the byte-exact inverse of decode ----
628
+ // Every emitted head is the shortest form (the exact mirror of decode's cbor/non-minimal-argument reject),
629
+ // every length is definite, and map keys are sorted bytewise + unique -- so decode(build.x(...)) round-trips
630
+ // or the builder produced a shape its own strict decoder rejects. Nested items are pre-encoded CBOR item
631
+ // Buffers (the asn1.build pattern); the CBOR-encoded PKI surfaces (C509, COSE) compose these leaves.
632
+ function _cbItem(x, who) {
633
+ if (!Buffer.isBuffer(x)) throw new CborError("cbor/not-buffer", who + ": expected a pre-encoded CBOR item Buffer");
634
+ return x;
635
+ }
636
+ // Validate an assembled container decodes: well-formed nested items (no malformed head, indefinite length,
637
+ // stray break, or trailing bytes), AND within the decoder's depth cap -- a container adds ONE nesting
638
+ // level, so a child AT the cap would overflow the output. Decoding the whole output in one pass catches
639
+ // both, so the encoder never produces bytes its own strict decoder rejects.
640
+ function _assertDecodes(out, who) {
641
+ try { decode(out); } catch (e) { throw new CborError("cbor/bad-item", who + ": produced bytes the strict decoder rejects (" + (e && e.code) + ")", e); }
642
+ return out;
643
+ }
644
+ // Mirror the decoder's total-size cap (constants.LIMITS.CBOR_MAX_BYTES) so a string leaf never emits an
645
+ // item the strict reader would reject as too large -- the encode/decode round trip stays closed under the
646
+ // default limits.
647
+ function _capString(out, who) {
648
+ if (out.length > constants.LIMITS.CBOR_MAX_BYTES) throw new CborError("cbor/too-large", who + ": encoded " + out.length + " bytes exceeds cap " + constants.LIMITS.CBOR_MAX_BYTES);
649
+ return out;
650
+ }
651
+ // Reject a JS string carrying an unpaired UTF-16 surrogate: Buffer.from(..., "utf8") would silently
652
+ // substitute U+FFFD, so the emitted text string would not be the input (and read.text enforces well-formed
653
+ // UTF-8). Fail closed to match the decoder rather than corrupt the text.
654
+ function _assertWellFormedText(str, who) {
655
+ for (var i = 0; i < str.length; i++) {
656
+ var c = str.charCodeAt(i);
657
+ if (c >= 0xd800 && c <= 0xdbff) { var n = str.charCodeAt(i + 1); if (!(n >= 0xdc00 && n <= 0xdfff)) throw new CborError("cbor/bad-utf8", who + ": the string has an unpaired high surrogate"); i++; }
658
+ else if (c >= 0xdc00 && c <= 0xdfff) throw new CborError("cbor/bad-utf8", who + ": the string has an unpaired low surrogate");
659
+ }
660
+ }
661
+ // The initial byte + minimal-width argument for major type `mt` and unsigned `argument`.
662
+ function _head(mt, argument, who) {
663
+ var a = typeof argument === "bigint" ? argument : BigInt(argument);
664
+ if (a < 0n) throw new CborError("cbor/bad-argument", who + ": a CBOR head argument must be non-negative");
665
+ if (a > 0xffffffffffffffffn) throw new CborError("cbor/bad-argument", who + ": a CBOR head argument exceeds 64 bits");
666
+ var mtb = mt << 5, b;
667
+ if (a <= 23n) return Buffer.from([mtb | Number(a)]);
668
+ if (a < 256n) return Buffer.from([mtb | 24, Number(a)]);
669
+ if (a < 65536n) { b = Buffer.alloc(3); b[0] = mtb | 25; b.writeUInt16BE(Number(a), 1); return b; }
670
+ if (a < 4294967296n) { b = Buffer.alloc(5); b[0] = mtb | 26; b.writeUInt32BE(Number(a), 1); return b; }
671
+ b = Buffer.alloc(9); b[0] = mtb | 27; b.writeBigUInt64BE(a, 1); return b;
672
+ }
673
+ var build = {
674
+ uint: function (n) { var a = typeof n === "bigint" ? n : BigInt(n); if (a < 0n) throw new CborError("cbor/bad-argument", "build.uint: value must be non-negative"); return _head(0, a, "build.uint"); },
675
+ nint: function (n) { var a = typeof n === "bigint" ? n : BigInt(n); if (a >= 0n) throw new CborError("cbor/bad-argument", "build.nint: value must be negative"); return _head(1, -1n - a, "build.nint"); },
676
+ int: function (n) { var a = typeof n === "bigint" ? n : BigInt(n); return a >= 0n ? _head(0, a, "build.int") : _head(1, -1n - a, "build.int"); },
677
+ byteString: function (buf) { var b = _asBuffer(buf, "build.byteString"); return _capString(Buffer.concat([_head(2, b.length, "build.byteString"), b]), "build.byteString"); },
678
+ textString: function (s) { var str = String(s); _assertWellFormedText(str, "build.textString"); var b = Buffer.from(str, "utf8"); return _capString(Buffer.concat([_head(3, b.length, "build.textString"), b]), "build.textString"); },
679
+ array: function (items) {
680
+ if (!Array.isArray(items)) throw new CborError("cbor/bad-argument", "build.array: expected an array of pre-encoded items");
681
+ var parts = items.map(function (x, i) { return _cbItem(x, "build.array[" + i + "]"); });
682
+ return _assertDecodes(Buffer.concat([_head(4, items.length, "build.array")].concat(parts)), "build.array");
683
+ },
684
+ map: function (pairs) {
685
+ if (!Array.isArray(pairs)) throw new CborError("cbor/bad-argument", "build.map: expected an array of [key, value] pairs");
686
+ var enc = pairs.map(function (p, i) { if (!Array.isArray(p) || p.length !== 2) throw new CborError("cbor/bad-argument", "build.map[" + i + "]: each entry is a [key, value] pair"); return [_cbItem(p[0], "build.map key"), _cbItem(p[1], "build.map value")]; });
687
+ // RFC 8949 sec. 4.2.1 Core Deterministic: keys sorted in the BYTEWISE lexicographic order of their
688
+ // encodings -- NOT the length-first order of sec. 4.2.3 (the older RFC 7049 "Canonical CBOR" variant).
689
+ // This MUST match the decoder, which enforces bytewise order (_profile "deterministic" -> Buffer.compare
690
+ // at :70); a length-first sort here would emit a map the strict decoder then rejects as unsorted.
691
+ enc.sort(function (a, b) { return Buffer.compare(a[0], b[0]); });
692
+ for (var i = 1; i < enc.length; i++) { if (Buffer.compare(enc[i - 1][0], enc[i][0]) === 0) throw new CborError("cbor/duplicate-map-key", "build.map: duplicate map key"); }
693
+ var flat = [_head(5, pairs.length, "build.map")];
694
+ enc.forEach(function (p) { flat.push(p[0], p[1]); });
695
+ return _assertDecodes(Buffer.concat(flat), "build.map");
696
+ },
697
+ tag: function (tagNum, inner) { return _assertDecodes(Buffer.concat([_head(6, tagNum, "build.tag"), _cbItem(inner, "build.tag content")]), "build.tag"); },
698
+ boolean: function (v) { return Buffer.from([v ? 0xf5 : 0xf4]); },
699
+ nullValue: function () { return Buffer.from([0xf6]); },
700
+ raw: function (buf) { return _assertDecodes(_asBuffer(buf, "build.raw"), "build.raw"); },
701
+ // Tagged leaves -- the inverse of read.biguint / read.time / read.oid.
702
+ biguint: function (n) {
703
+ var a = typeof n === "bigint" ? n : BigInt(n);
704
+ if (a < 0n) throw new CborError("cbor/bad-argument", "build.biguint: value must be non-negative");
705
+ var hex = a.toString(16); if (hex.length % 2) hex = "0" + hex;
706
+ var mag = a === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex");
707
+ if (mag.length <= 8) throw new CborError("cbor/bad-argument", "build.biguint: a value that fits 8 bytes must use build.uint (RFC 8949 preferred serialization)");
708
+ // Mirror read.biguint's cap so the tagged bignum builder never emits a value the strict reader rejects.
709
+ if (mag.length > constants.LIMITS.CBOR_MAX_BIGUINT_BYTES) throw new CborError("cbor/biguint-too-large", "build.biguint: " + mag.length + " bytes exceeds cap " + constants.LIMITS.CBOR_MAX_BIGUINT_BYTES);
710
+ return build.tag(2, build.byteString(mag));
711
+ },
712
+ time: function (v) {
713
+ var ms;
714
+ if (v instanceof Date) {
715
+ ms = v.getTime();
716
+ if (Number.isNaN(ms)) throw new CborError("cbor/bad-time", "build.time: an Invalid Date has no epoch value");
717
+ // read.time reads only an integer (second-granularity) tag-1, so a sub-second Date cannot round-trip:
718
+ // reject it rather than silently truncate the milliseconds.
719
+ if (ms % 1000 !== 0) throw new CborError("cbor/bad-time", "build.time: a Date with sub-second precision cannot be a second-granularity CBOR epoch; round to whole seconds");
720
+ }
721
+ var secs = v instanceof Date ? BigInt(Math.floor(ms / 1000)) : (typeof v === "bigint" ? v : BigInt(v));
722
+ // Mirror read.time's bound so build.time never emits a tag-1 the strict reader would reject as out of range.
723
+ if (secs < -_MAX_EPOCH_SECONDS || secs > _MAX_EPOCH_SECONDS) throw new CborError("cbor/bad-time", "build.time: epoch seconds " + secs + " are outside the representable Date range");
724
+ return build.tag(1, build.int(secs));
725
+ },
726
+ oid: function (dotted) { return build.tag(111, build.byteString(asn1.encodeOidContent(dotted))); },
727
+ };
728
+
627
729
  module.exports = {
628
730
  decode: decode,
731
+ build: build,
629
732
  read: {
630
733
  uint: readUint,
631
734
  nint: readNint,
package/lib/schema-all.js CHANGED
@@ -298,7 +298,7 @@ module.exports = {
298
298
  x509: { parse: x509.parse, pemDecode: x509.pemDecode, pemEncode: x509.pemEncode },
299
299
  // C509 is CBOR, not DER: an explicit-call surface only, NOT added to FORMATS / the detect-and-route
300
300
  // parse() below (which detect DER shapes). No pemDecode/pemEncode -- C509 is binary CBOR.
301
- c509: { parse: c509.parse },
301
+ c509: { parse: c509.parse, encode: c509.encode },
302
302
  crl: { parse: crl.parse, pemDecode: crl.pemDecode, pemEncode: crl.pemEncode },
303
303
  csr: { parse: csr.parse, pemDecode: csr.pemDecode, pemEncode: csr.pemEncode },
304
304
  pkcs8: { parse: pkcs8.parse, parseEncrypted: pkcs8.parseEncrypted, pemDecode: pkcs8.pemDecode, pemEncode: pkcs8.pemEncode },
@@ -17,6 +17,8 @@
17
17
  var cbor = require("./cbor-det");
18
18
  var asn1 = require("./asn1-der");
19
19
  var oid = require("./oid");
20
+ var x509 = require("./schema-x509");
21
+ var pkix = require("./schema-pkix");
20
22
  var constants = require("./constants");
21
23
  var frameworkError = require("./framework-error");
22
24
  var validator = require("./validator-all");
@@ -452,14 +454,16 @@ function parse(input) {
452
454
  signatureValue: signatureValue,
453
455
  };
454
456
 
455
- // Native (type-2) signed region (sec. 3.1.12): the RAW bytes of the CBOR-sequence elements 0..9 (NOT
456
- // the outer array head, NOT the signature) -- a zero-copy subarray a native verifier hashes.
457
- if (type === 2) {
458
- var b0 = f[0].bytes, b9 = f[9].bytes;
459
- var start = b0.byteOffset - input.byteOffset;
460
- var end = b9.byteOffset - input.byteOffset + b9.length;
461
- result.signedData = input.subarray(start, end);
462
- }
457
+ // The RAW bytes of CBOR-array elements 0..9 (NOT the outer array head, NOT the signature) -- a zero-copy
458
+ // subarray. For a native (type-2) certificate this is the signed region a verifier hashes (sec. 3.1.12);
459
+ // surfaced as `_fieldBytes` for BOTH types so encode() re-emits a parsed certificate byte-for-byte (a
460
+ // re-derivation from the decoded values could differ on a canonical-equivalent form, breaking a type-2
461
+ // signature or a type-3 DER reconstruction). `signedData` keeps its type-2-only signed-region meaning.
462
+ // From the DECODED root's own bytes (root.bytes == array head + all 11 elements), not offset arithmetic
463
+ // on `input` (which breaks when input is a Uint8Array the codec normalized to a different buffer): the
464
+ // fields region is root.bytes minus the 1-byte array(11) head and minus the trailing signatureValue.
465
+ result._fieldBytes = root.bytes.subarray(1, root.bytes.length - f[10].bytes.length);
466
+ if (type === 2) result.signedData = result._fieldBytes;
463
467
 
464
468
  // Type-3 is an invertible re-encoding of a DER X.509 certificate: reconstruct the original DER
465
469
  // byte-for-byte so the original signature verifies and x509.parse recovers the certificate.
@@ -476,4 +480,325 @@ function matches(node) {
476
480
  (Number(node.children[0].argument) === 2 || Number(node.children[0].argument) === 3);
477
481
  }
478
482
 
479
- module.exports = { parse: parse, matches: matches };
483
+ // ---- the encode (the producing side; draft-20 sec. 3) -----------------------
484
+ // The byte-exact inverse of parse/reconstruct: emit the 11-element deterministic-CBOR C509 array via the
485
+ // cbor.build.* emitter. Two inputs, dispatched structurally: a DER X.509 certificate (Buffer/PEM) -> the
486
+ // FLAGSHIP type-3 forward transform (parse(encode(der)).reconstructedDer == der, so the original signature
487
+ // verifies); a c509.parse result -> re-emit its native array. Signing-free (mirrors ct.encodeSctList).
488
+
489
+ // The registry INVERSE tables -- the canonical int per name (the lossy forward map is resolved to ONE
490
+ // choice: EXT_BY_INT maps both 2 and 7 to keyUsage, so keyUsage encodes to the canonical draft int 2).
491
+ var SIG_ALG_TO_INT = { ecdsaWithSHA256: 0, ecdsaWithSHA384: 1, ecdsaWithSHA512: 2 };
492
+ var PK_ALG_TO_INT = { rsaEncryption: 0, "ecPublicKey|prime256v1": 1, "ecPublicKey|secp384r1": 2, "ecPublicKey|secp521r1": 3 };
493
+ var ATTR_TO_INT = { commonName: 1, surname: 2, serialNumber: 3, countryName: 4, localityName: 6, stateOrProvinceName: 7, organizationName: 8, organizationalUnitName: 9, title: 10 };
494
+ var EXT_TO_INT = { subjectKeyIdentifier: 1, keyUsage: 2, subjectAltName: 3, basicConstraints: 4, authorityKeyIdentifier: 10 };
495
+
496
+ // A non-negative BigInt -> its minimal big-endian ~biguint bytes (the leading 0x00 sign octet omitted).
497
+ function _minBytes(n) {
498
+ if (n < 0n) throw _err("c509/bad-serial", "a ~biguint value must be non-negative");
499
+ if (n === 0n) return Buffer.alloc(0);
500
+ var hex = n.toString(16); if (hex.length % 2) hex = "0" + hex;
501
+ return Buffer.from(hex, "hex");
502
+ }
503
+ // A C509 AlgorithmIdentifier -> int (registry) | ~oid (bare bytes) | [~oid, params]. `key` selects the row.
504
+ function _encAlgorithm(alg, toInt, key) {
505
+ var i = toInt[key];
506
+ if (i !== undefined && !(alg.parameters && alg.parameters.length)) return cbor.build.int(BigInt(i));
507
+ var oidBytes = cbor.build.byteString(asn1.encodeOidContent(alg.oid)); // ~oid: bare BER OID content
508
+ if (alg.parameters && alg.parameters.length) return cbor.build.array([oidBytes, cbor.build.byteString(alg.parameters)]);
509
+ return oidBytes;
510
+ }
511
+ // A SpecialText attribute value -> CBOR (text | tag-48 EUI). v1 encodes the text + eui64 forms.
512
+ function _encSpecialText(rdn) {
513
+ if (rdn.eui64) return cbor.build.tag(48, cbor.build.byteString(rdn.eui64));
514
+ return cbor.build.textString(String(rdn.value));
515
+ }
516
+ // A Name -> CBOR: null (issuer only) | a bare SpecialText single utf8 commonName | an array of RDN pairs.
517
+ function _encName(name, isSubject) {
518
+ if (name === null || name === undefined) {
519
+ if (!isSubject) return cbor.build.nullValue(); // issuer == subject (self-signed)
520
+ throw _err("c509/bad-name", "the subject Name is required");
521
+ }
522
+ var rdns = name.rdns || [];
523
+ if (rdns.length === 1 && rdns[0].type === "commonName" && !rdns[0].printable) return _encSpecialText(rdns[0]);
524
+ var items = [];
525
+ rdns.forEach(function (rdn) {
526
+ var ai = ATTR_TO_INT[rdn.type];
527
+ if (ai === undefined) throw _err("c509/bad-name", "attribute type " + rdn.type + " has no C509 registry int");
528
+ items.push(cbor.build.int(BigInt(rdn.printable ? -ai : ai))); // sign selects printableString
529
+ items.push(_encSpecialText(rdn));
530
+ });
531
+ return cbor.build.array(items);
532
+ }
533
+ // subjectPublicKey -> CBOR: EC point byte string, or an RSA ~biguint modulus ([modulus, exponent] when e != 65537).
534
+ function _encSpk(r) {
535
+ if (r.rsaPublicKey) {
536
+ var mod = cbor.build.byteString(_minBytes(r.rsaPublicKey.modulus));
537
+ if (r.rsaPublicKey.exponent === 65537n) return mod;
538
+ return cbor.build.array([mod, cbor.build.byteString(_minBytes(r.rsaPublicKey.exponent))]);
539
+ }
540
+ if (!Buffer.isBuffer(r.subjectPublicKey)) throw _err("c509/bad-spki", "the subjectPublicKey bytes are missing");
541
+ return cbor.build.byteString(r.subjectPublicKey);
542
+ }
543
+ // extensions -> CBOR: the keyUsage int-shortcut (a lone keyUsage), else an array of [extID, extValue] pairs.
544
+ function _encExtensions(exts) {
545
+ if (exts.length === 1 && exts[0].name === "keyUsage" && typeof exts[0].keyUsageBits === "number") {
546
+ return cbor.build.int(BigInt(exts[0].critical ? -exts[0].keyUsageBits : exts[0].keyUsageBits));
547
+ }
548
+ var items = [];
549
+ exts.forEach(function (ext) {
550
+ var ei = EXT_TO_INT[ext.name];
551
+ if (ei !== undefined) {
552
+ items.push(cbor.build.int(BigInt(ext.critical ? -ei : ei)));
553
+ // a registered-int extension carries its extnValue DER bytes as a bare byte string.
554
+ if (!Buffer.isBuffer(ext.value)) throw _err("c509/non-invertible", "extension " + ext.name + " has no byte-string value to encode");
555
+ items.push(cbor.build.byteString(ext.value));
556
+ } else {
557
+ items.push(cbor.build.byteString(asn1.encodeOidContent(ext.oid))); // ~oid extension id
558
+ if (!Buffer.isBuffer(ext.value)) throw _err("c509/non-invertible", "extension " + (ext.oid || ext.name) + " has no byte-string value to encode");
559
+ var bs = cbor.build.byteString(ext.value);
560
+ items.push(ext.critical ? cbor.build.array([bs]) : bs); // critical ~oid value wraps in a 1-element array
561
+ }
562
+ });
563
+ return cbor.build.array(items);
564
+ }
565
+ // forward-declared below; the DER X.509 -> type-3 C509 structured result.
566
+ var _derToType3;
567
+ // A hand-built (no _fieldBytes) result must carry the structured fields the encode below reads; a missing or
568
+ // wrong-typed field fails closed with a typed verdict rather than a raw property-access crash.
569
+ function _requireResultShape(r) {
570
+ if (r.certificateType == null) throw _err("c509/bad-input", "a C509 result must carry certificateType");
571
+ if (r.serialNumber == null && r.serialNumberHex == null) throw _err("c509/bad-input", "a C509 result must carry serialNumber or serialNumberHex");
572
+ if (!r.signatureAlgorithm || typeof r.signatureAlgorithm.name !== "string") throw _err("c509/bad-input", "a C509 result must carry signatureAlgorithm.name");
573
+ if (!r.subjectPublicKeyAlgorithm || typeof r.subjectPublicKeyAlgorithm.name !== "string") throw _err("c509/bad-input", "a C509 result must carry subjectPublicKeyAlgorithm.name");
574
+ if (!r.validity || !(r.validity.notBefore instanceof Date) || (r.validity.notAfter !== null && !(r.validity.notAfter instanceof Date))) throw _err("c509/bad-input", "a C509 result must carry validity.notBefore (Date) and notAfter (Date or null)");
575
+ if (!Array.isArray(r.extensions)) throw _err("c509/bad-input", "a C509 result must carry an extensions array");
576
+ if (!Buffer.isBuffer(r.signatureValue)) throw _err("c509/bad-input", "a C509 result must carry a Buffer signatureValue");
577
+ }
578
+ // A validity Date -> its C509 ~time (a non-negative CBOR epoch uint). A pre-epoch date cannot be
579
+ // represented (the parser accepts only an unwrapped major-type-0 integer) and fails closed here.
580
+ function _validityUint(date, label) {
581
+ var secs = Math.floor(date.getTime() / 1000);
582
+ if (!isFinite(secs) || secs < 0) throw _err("c509/bad-validity", label + " is before the Unix epoch or not a valid date; C509 ~time is a non-negative CBOR epoch");
583
+ return cbor.build.uint(BigInt(secs));
584
+ }
585
+ // A structured C509 result -> the 11-element deterministic-CBOR array.
586
+ function _encodeC509Array(r) {
587
+ // Re-emit a PARSED certificate's raw fields (elements 0..9) VERBATIM -- re-deriving from the decoded
588
+ // values could differ on a canonical-equivalent form (a byte-string attribute value, a registry alias)
589
+ // and break a type-2 native signature (which covers these bytes) or a type-3 DER reconstruction (which
590
+ // depends on the field values). Both types preserve the exact bytes; only a hand-built result (no
591
+ // _fieldBytes) re-derives from the structured values below.
592
+ if (Buffer.isBuffer(r._fieldBytes)) {
593
+ if (!Buffer.isBuffer(r.signatureValue)) throw _err("c509/bad-input", "a re-emitted certificate must carry a Buffer signatureValue");
594
+ var out = Buffer.concat([Buffer.from([0x8b]), r._fieldBytes, cbor.build.byteString(r.signatureValue)]); // array(11) head + fields 0..9 + signatureValue
595
+ parse(out); // fail closed: a caller-mutated _fieldBytes must still re-parse as a valid C509, else parse throws a typed c509/* verdict
596
+ return out;
597
+ }
598
+ _requireResultShape(r);
599
+ var pkKey = r.subjectPublicKeyAlgorithm.curve ? r.subjectPublicKeyAlgorithm.name + "|" + r.subjectPublicKeyAlgorithm.curve : r.subjectPublicKeyAlgorithm.name;
600
+ var arr = cbor.build.array([
601
+ cbor.build.int(BigInt(r.certificateType)),
602
+ cbor.build.byteString(r.serialNumberHex != null ? Buffer.from(r.serialNumberHex, "hex") : _minBytes(r.serialNumber)),
603
+ _encAlgorithm(r.signatureAlgorithm, SIG_ALG_TO_INT, r.signatureAlgorithm.name),
604
+ _encName(r.issuer, false),
605
+ _validityUint(r.validity.notBefore, "validityNotBefore"),
606
+ r.validity.notAfter === null ? cbor.build.nullValue() : _validityUint(r.validity.notAfter, "validityNotAfter"),
607
+ _encName(r.subject, true),
608
+ _encAlgorithm(r.subjectPublicKeyAlgorithm, PK_ALG_TO_INT, pkKey),
609
+ _encSpk(r),
610
+ _encExtensions(r.extensions),
611
+ cbor.build.byteString(r.signatureValue),
612
+ ]);
613
+ parse(arr); // fail closed: a hand-built result must re-parse as a valid C509 (as the verbatim path checks), else parse throws a typed c509/* verdict
614
+ return arr;
615
+ }
616
+
617
+ /**
618
+ * @primitive pki.schema.c509.encode
619
+ * @signature pki.schema.c509.encode(input[, opts]) -> Buffer
620
+ * @since 0.3.4
621
+ * @status experimental
622
+ * @spec draft-ietf-cose-cbor-encoded-cert, RFC 8949, RFC 9090
623
+ * @related pki.schema.c509.parse
624
+ *
625
+ * Encode a C509 certificate to its deterministic-CBOR bytes -- the producing-side inverse of
626
+ * `pki.schema.c509.parse`. `input` is either a DER X.509 v3 certificate (a Buffer or PEM string), which is
627
+ * forward-transformed to a **type-3** C509 (a compact CBOR re-encoding whose signature is copied from the
628
+ * source and re-expressed as a fixed-width r||s, so `parse(encode(der)).reconstructedDer` reproduces the
629
+ * original DER byte for byte and the original signature still verifies), or a `pki.schema.c509.parse`
630
+ * result object, which is re-emitted to its native deterministic-CBOR array. The emission is canonical
631
+ * deterministic CBOR (RFC 8949 sec. 4.2) -- shortest-form heads, definite lengths, sorted map keys, and the
632
+ * registry integer shorthand for every registered algorithm / attribute / extension. It is signing-free (a
633
+ * byte transform, like `pki.ct.encodeSctList`); a shape outside the covered set throws a typed `C509Error`.
634
+ *
635
+ * The fixed-width ECDSA r||s is sized by the ISSUER's signing curve, which a leaf certificate does not
636
+ * carry. It is resolved authoritatively (never a magnitude guess, and matching issuer/subject Names are not
637
+ * taken as proof of self-signing): from `opts.issuerCurve`, or from the RFC 5480 standard digest<->curve
638
+ * pairing the signature algorithm implies. A certificate signed with a non-standard digest/curve pairing
639
+ * (its r/s wider than the digest's standard curve) fails closed -- supply the issuer curve via
640
+ * `opts.issuerCurve`.
641
+ *
642
+ * @opts
643
+ * - `issuerCurve` (string) -- the ISSUER's ECDSA signing curve "P-256" / "P-384" / "P-521" (or the OID
644
+ * names prime256v1 / secp384r1 / secp521r1); authoritative, overrides the resolution above. Consulted
645
+ * only for the DER -> type-3 path; ignored when re-emitting a parse result.
646
+ *
647
+ * @example
648
+ * var cbor = pki.schema.c509.encode(signerCertDer); // a DER cert -> a compact type-3 C509
649
+ * pki.schema.c509.parse(cbor).certificateType; // 3
650
+ */
651
+ // A commonName string that is a MAC / EUI address ("HH-HH-HH-FF-FE-HH-HH-HH") -> the C509 tag-48 byte
652
+ // value (draft-20 sec. 3.2.3): an FF-FE-in-the-middle EUI-64 collapses to its 6-byte EUI-48, else the
653
+ // 8-byte EUI-64 verbatim. A non-MAC commonName returns null (encoded as text). The exact inverse of
654
+ // _macToEui64String, so the reconstruction rebuilds the identical DER commonName string.
655
+ function _euiFromCn(value) {
656
+ if (!/^[0-9A-F]{2}(-[0-9A-F]{2}){7}$/.test(value)) return null;
657
+ var bytes = Buffer.from(value.replace(/-/g, ""), "hex");
658
+ if (bytes[3] === 0xff && bytes[4] === 0xfe) return Buffer.concat([bytes.subarray(0, 3), bytes.subarray(5)]);
659
+ return bytes;
660
+ }
661
+ // A DER Name -> the C509 structured rdns, decoding each attribute's string type (PrintableString ->
662
+ // printable, UTF8String -> utf8; the C509 int sign carries this). Single-attribute RDNs only (v1).
663
+ function _c509NameFromDer(nameBytes) {
664
+ var node = asn1.decode(nameBytes);
665
+ var rdns = [];
666
+ (node.children || []).forEach(function (rdnSet) {
667
+ if (!rdnSet.children || rdnSet.children.length !== 1) throw _err("c509/non-invertible", "a C509 Name requires single-attribute RDNs");
668
+ var attr = rdnSet.children[0];
669
+ var attrName = oid.name(asn1.read.oid(attr.children[0]));
670
+ if (attrName == null || ATTR_TO_INT[attrName] === undefined) throw _err("c509/non-invertible", "attribute type " + attrName + " has no C509 registry integer");
671
+ var valNode = attr.children[1];
672
+ var value = asn1.read.string(valNode);
673
+ var eui = attrName === "commonName" ? _euiFromCn(value) : null;
674
+ if (eui) rdns.push({ type: attrName, value: value, eui64: eui }); // tag-48 MAC commonName
675
+ else rdns.push({ type: attrName, value: value, printable: valNode.tagNumber === asn1.TAGS.PRINTABLE_STRING });
676
+ });
677
+ return { rdns: rdns };
678
+ }
679
+ // A keyUsage extnValue (a DER KeyUsage BIT STRING) -> the C509 integer whose bit i is the named bit i.
680
+ function _keyUsageBitsFromDer(extnValue) {
681
+ var bs;
682
+ try {
683
+ bs = asn1.read.bitString(asn1.decode(extnValue));
684
+ } catch (_e) {
685
+ return null; // a malformed keyUsage BIT STRING cannot take the int shortcut -> encode it as a raw extension
686
+ }
687
+ var total = bs.bytes.length * 8 - bs.unusedBits, value = 0;
688
+ for (var bit = 0; bit < total && bit < 31; bit++) { if (bs.bytes[bit >> 3] & (0x80 >> (bit & 7))) value |= (1 << bit); }
689
+ return value > 0 && value <= 0x1ff ? value : null;
690
+ }
691
+ var _NO_EXPIRY = Date.UTC(9999, 11, 31, 23, 59, 59);
692
+ // The C509 (OID/node) curve names <-> the WebCrypto namedCurve the P1363 converter expects, and the
693
+ // RFC 5480 standard digest<->curve pairing an ECDSA signature algorithm implies.
694
+ var NODE_TO_WEBCRYPTO = { "prime256v1": "P-256", "secp384r1": "P-384", "secp521r1": "P-521" };
695
+ var WEBCRYPTO_FIELD_BYTES = { "P-256": 32, "P-384": 48, "P-521": 66 };
696
+ var SIG_ALG_TO_CURVE = { "ecdsaWithSHA256": "P-256", "ecdsaWithSHA384": "P-384", "ecdsaWithSHA512": "P-521" };
697
+ // A DER INTEGER magnitude byte length: the content past any leading sign/pad octet.
698
+ function _magBytes(intNode) {
699
+ var c = intNode.content, i = 0;
700
+ while (i < c.length - 1 && c[i] === 0x00) i++;
701
+ return c.length - i;
702
+ }
703
+ // The max r/s magnitude width of a DER ECDSA signature (validating its two-INTEGER shape).
704
+ function _sigMagWidth(derSig) {
705
+ var n;
706
+ try { n = asn1.decode(derSig); } catch (e) { throw _err("c509/bad-signature", "the ECDSA issuer signature is not valid DER", e); }
707
+ if (n.tagNumber !== asn1.TAGS.SEQUENCE || n.tagClass !== "universal" || !n.children || n.children.length !== 2) throw _err("c509/bad-signature", "the ECDSA issuer signature must be a SEQUENCE of two INTEGERs");
708
+ for (var i = 0; i < 2; i++) {
709
+ var ch = n.children[i];
710
+ if (ch.tagNumber !== asn1.TAGS.INTEGER || ch.tagClass !== "universal" || ch.constructed) throw _err("c509/bad-signature", "the ECDSA issuer signature r and s must be universal INTEGERs");
711
+ }
712
+ return Math.max(_magBytes(n.children[0]), _magBytes(n.children[1]));
713
+ }
714
+ var CURVE_ORDER = ["P-256", "P-384", "P-521"]; // ascending field size
715
+ // The smallest supported curve whose field holds a magnitude, or null if none does.
716
+ function _minCurveForMag(mag) {
717
+ for (var i = 0; i < CURVE_ORDER.length; i++) { if (mag <= WEBCRYPTO_FIELD_BYTES[CURVE_ORDER[i]]) return CURVE_ORDER[i]; }
718
+ return null;
719
+ }
720
+ // The ISSUER signature's fixed-width r||s is sized by the ISSUER's signing curve. The DER ECDSA signature
721
+ // does NOT carry the curve and the r/s magnitudes are only a lower bound (a P-384 signature with small r/s
722
+ // is byte-indistinguishable from a P-256 one), so the curve is resolved from an AUTHORITATIVE source, never
723
+ // a guess: (1) an explicit opts.issuerCurve, or (2) the RFC 5480 standard digest<->curve pairing the
724
+ // signature algorithm implies -- and ONLY when that pairing is the smallest curve the magnitude admits.
725
+ // Matching issuer/subject Names are NOT treated as proof of self-signing (a self-issued certificate may be
726
+ // cross-signed by a different key on a different curve). A signature whose r/s exceed the digest's standard
727
+ // curve (a larger key) OR whose magnitude also fits a SMALLER curve (a smaller key signing with a larger
728
+ // digest) leaves the curve undetermined: it fails closed and directs the caller to opts.issuerCurve.
729
+ function _resolveIssuerSigCurve(c, opts) {
730
+ var mag = _sigMagWidth(c.signatureValue.bytes), curve;
731
+ if (opts && opts.issuerCurve != null) {
732
+ curve = String(opts.issuerCurve);
733
+ if (NODE_TO_WEBCRYPTO[curve]) curve = NODE_TO_WEBCRYPTO[curve];
734
+ if (!WEBCRYPTO_FIELD_BYTES[curve]) throw _err("c509/bad-input", "opts.issuerCurve must be P-256 / P-384 / P-521 (or prime256v1 / secp384r1 / secp521r1); got " + opts.issuerCurve);
735
+ } else {
736
+ curve = SIG_ALG_TO_CURVE[c.signatureAlgorithm.name];
737
+ if (!curve) throw _err("c509/non-invertible", "cannot resolve the issuer signing curve for signature algorithm " + c.signatureAlgorithm.name);
738
+ var minCurve = _minCurveForMag(mag);
739
+ if (!minCurve) throw _err("c509/non-invertible", "the ECDSA signature r/s width " + mag + " exceeds every supported curve field");
740
+ if (WEBCRYPTO_FIELD_BYTES[curve] < mag) throw _err("c509/non-invertible", "the issuer signed with a non-standard digest/curve pairing (r/s width " + mag + " exceeds the " + curve + " field implied by " + c.signatureAlgorithm.name + "); pass opts.issuerCurve (P-256 / P-384 / P-521)");
741
+ if (WEBCRYPTO_FIELD_BYTES[curve] > WEBCRYPTO_FIELD_BYTES[minCurve]) throw _err("c509/non-invertible", "signature algorithm " + c.signatureAlgorithm.name + " does not uniquely determine the issuer curve (r/s width " + mag + " also fits " + minCurve + "); pass opts.issuerCurve (P-256 / P-384 / P-521)");
742
+ }
743
+ if (mag > WEBCRYPTO_FIELD_BYTES[curve]) throw _err("c509/non-invertible", "the ECDSA signature r/s width " + mag + " does not fit the resolved " + curve + " field");
744
+ return curve;
745
+ }
746
+ // Compress an uncompressed SEC1 EC point (0x04||X||Y) to the C509 marker form (draft-20 sec. 3.2.2): the
747
+ // sign-of-Y marker 0xFE (Y even) / 0xFD (Y odd) followed by X. The inverse of webcrypto.decompressEcPoint,
748
+ // so the type-3 reconstruction recovers the exact original point. A non-0x04 point is kept verbatim.
749
+ function _compressEcPoint(point, coordLen) {
750
+ if (!point.length || point[0] !== 0x04) return point;
751
+ if (point.length !== 1 + 2 * coordLen) throw _err("c509/non-invertible", "uncompressed EC point length " + point.length + " does not match the curve field size");
752
+ var x = point.subarray(1, 1 + coordLen), y = point.subarray(1 + coordLen);
753
+ return Buffer.concat([Buffer.from([(y[y.length - 1] & 1) ? 0xfd : 0xfe]), x]);
754
+ }
755
+ // A DER X.509 v3 certificate -> the type-3 C509 structured result (the inverse of _reconstructDer). Only
756
+ // the reconstruction's covered set is invertible; encode() self-verifies the byte-exact round trip.
757
+ _derToType3 = function (input, opts) {
758
+ var c;
759
+ try { c = x509.parse(input); } catch (e) { throw _err("c509/bad-input", "the input is not a valid X.509 certificate", e); }
760
+ if (!/^ecdsa/i.test(c.signatureAlgorithm.name || "")) throw _err("c509/non-invertible", "type-3 C509 encoding covers only ECDSA-signed certificates; got " + (c.signatureAlgorithm.name || "an unregistered algorithm"));
761
+ if (c.subjectPublicKeyInfo.algorithm.name !== "ecPublicKey") throw _err("c509/non-invertible", "type-3 C509 encoding covers only EC (ecPublicKey) certificates in v1; got " + (c.subjectPublicKeyInfo.algorithm.name || "an unregistered algorithm"));
762
+ var curveOid = asn1.read.oid(asn1.decode(c.subjectPublicKeyInfo.algorithm.parameters));
763
+ var curve = oid.name(curveOid);
764
+ var coordLen = EC_FIELD_BYTES[curve];
765
+ if (!coordLen) throw _err("c509/non-invertible", "unsupported EC subject curve " + (curve || curveOid));
766
+ var sigCurve = _resolveIssuerSigCurve(c, opts); // the ISSUER signing curve, resolved authoritatively (opts / digest pairing)
767
+ var spkiNode = asn1.decode(c.subjectPublicKeyInfo.bytes);
768
+ return {
769
+ certificateType: 3,
770
+ serialNumber: c.serialNumber, // no serialNumberHex -> the encoder uses the minimal ~biguint magnitude
771
+ signatureAlgorithm: { name: c.signatureAlgorithm.name, oid: c.signatureAlgorithm.oid },
772
+ issuer: _c509NameFromDer(c.issuer.bytes),
773
+ validity: { notBefore: c.validity.notBefore, notAfter: c.validity.notAfter.getTime() === _NO_EXPIRY ? null : c.validity.notAfter },
774
+ subject: _c509NameFromDer(c.subject.bytes),
775
+ subjectPublicKeyAlgorithm: { name: "ecPublicKey", oid: c.subjectPublicKeyInfo.algorithm.oid, curve: curve },
776
+ subjectPublicKey: _compressEcPoint(asn1.read.bitString(spkiNode.children[1]).bytes, coordLen), // 0x04||X||Y -> C509 compressed marker
777
+ rsaPublicKey: null,
778
+ extensions: (c.extensions || []).map(function (e) {
779
+ var ext = { name: e.name, oid: e.oid, critical: !!e.critical, value: e.value };
780
+ if (e.name === "keyUsage") { var bits = _keyUsageBitsFromDer(e.value); if (bits != null) ext.keyUsageBits = bits; } // enable the int-shortcut
781
+ return ext;
782
+ }),
783
+ signatureValue: validator.sig.ecdsaDerToP1363(c.signatureValue.bytes, sigCurve, C509Error, "c509/bad-signature"),
784
+ };
785
+ };
786
+
787
+ function encode(input, opts) {
788
+ if (input && typeof input === "object" && !Buffer.isBuffer(input) && input.certificateType != null) {
789
+ return _encodeC509Array(input); // a parse result -> re-emit its native array
790
+ }
791
+ if (!Buffer.isBuffer(input) && typeof input !== "string") throw _err("c509/bad-input", "encode input must be a DER/PEM X.509 certificate or a c509.parse result");
792
+ // Normalize input to DER through the SAME shared coercion x509.parse uses (a PEM string, a PEM-armored
793
+ // Buffer, or raw DER all collapse to the certificate DER), so the self-verify compares against exactly the
794
+ // bytes _derToType3 processed -- never a Buffer of PEM text.
795
+ var origDer = pkix.coerceToDer(input, { pemLabel: "CERTIFICATE", PemError: frameworkError.PemError, ErrorClass: C509Error, prefix: "c509" });
796
+ var encoded = _encodeC509Array(_derToType3(origDer, opts));
797
+ // The type-3 transform MUST invert back to the original DER byte-for-byte (so the original signature
798
+ // verifies) -- self-verify it, failing closed on any edge the reconstruction cannot reproduce.
799
+ var recon = parse(encoded).reconstructedDer;
800
+ if (!recon || Buffer.compare(recon, origDer) !== 0) throw _err("c509/non-invertible", "the type-3 C509 does not reconstruct the source certificate byte-for-byte");
801
+ return encoded;
802
+ }
803
+
804
+ module.exports = { parse: parse, matches: matches, encode: encode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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:e238289a-30dc-45fc-b08a-4cc7a4f0a503",
5
+ "serialNumber": "urn:uuid:4cfd38cc-6772-42e6-bed2-46e04c2a950e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T09:21:09.414Z",
8
+ "timestamp": "2026-07-17T17:26:06.040Z",
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.3.3",
22
+ "bom-ref": "@blamejs/pki@0.3.4",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.3",
25
+ "version": "0.3.4",
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.3.3",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.4",
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.3.3",
57
+ "ref": "@blamejs/pki@0.3.4",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]