@blamejs/pki 0.4.4 → 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,33 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.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
+
23
+ ## v0.4.5 — 2026-08-08
24
+
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.
26
+
27
+ ### Fixed
28
+
29
+ - Certificate, CRL, CSR, CMS, attribute-certificate, OCSP, CMP and CRMF signing, together with pki.key.export and pki.jose.sign, accept a private key created by the platform's WebCrypto or by a separately-installed copy of this toolkit. Previously only a key this toolkit's own engine created would work; any other reached the crypto library as a key with no material behind it and failed with a type error naming an internal property, giving a caller who had followed the documented contract nothing to act on. Signing, private-key export, public-key export and secret-key signing are all covered, across EC, Edwards and RSA keys.
30
+ - A key that cannot be reached is refused with the reason and the ways forward -- import it through this toolkit's WebCrypto, or pass it as DER -- rather than being reported as an argument of the wrong type. That covers a key created non-extractable, which no implementation can export, and one belonging to an implementation that keeps its material behind its own interface. The non-extractable refusal holds on every path, including for a key whose handle this process could otherwise read directly, so the flag means the same thing wherever the key came from. A non-extractable key this toolkit created is unaffected and still signs, since it is used in place rather than exported.
31
+ - A key's permitted usages travel with it. Re-importing is the one moment that restriction could be widened -- the new key is created with the usages the operation needs, not the ones the key was created with -- so every needed usage must be present on the original first. A key marked verify-only is refused rather than signing, which is what this toolkit's own engine already did for its own keys; the two now agree.
32
+ - pki.webcrypto.subtle refuses a key created by a different WebCrypto implementation with a typed fault that names where it came from, and distinguishes it from an argument that is no key at all. The specification leaves cross-implementation use undefined; every operation that reads key material -- sign, verify, encrypt, decrypt, key derivation and encapsulation, wrapping, and export -- previously let a bare type error escape from inside the crypto library instead. The counterpart public key of a key-agreement operation, which travels in the algorithm rather than as the key argument, is checked on the same footing.
33
+
7
34
  ## v0.4.4 — 2026-08-08
8
35
 
9
36
  pki.schema.c509 encodes and decodes the RFC 3779 resource-delegation extensions -- a C509 certificate carrying IP address blocks or AS identifiers now parses at all, where before it was refused outright, and its addresses ride the compact form the specification defines.
package/README.md CHANGED
@@ -201,7 +201,7 @@ is callable today; nothing below is a stub.
201
201
  | `pki.asn1` | Strict, bounded DER codec — `decode` (zero-copy node tree), `encode`, `build.*` canonical-DER value builders, `read.*` typed readers, `TAGS`, OID-content encode/decode |
202
202
  | `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers incl. the keyed map lookup `read.mapGet` (text or COSE-label integer key, the map's major type asserted in the accessor), fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
203
203
  | `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
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 |
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). Cross-implementation use is left undefined by the specification, so a `CryptoKey` created by a different WebCrypto implementation is refused here with a typed fault naming where it came from -- the `pki.*` verbs adopt such a key instead, from the platform's WebCrypto or from a separately-installed copy of this toolkit. Zero-dependency, OpenSSL-interoperable |
205
205
  | `pki.tls` | RFC 8879 TLS certificate compression — `decompressCertificate` / `compressCertificate` decode and build a `CompressedCertificate` (zlib / brotli / zstd, each offered only where the runtime decompresses it safely) with the two-sided bound RFC 8879 §5 requires (capped at the message's own declared length, then compared to it exactly), and `parseCertificateMessage` decodes the RFC 8446 §4.4.2 Certificate message to per-entry certificate DER |
206
206
  | `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 |
207
207
  | `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` |
@@ -230,7 +230,7 @@ is callable today; nothing below is a stub.
230
230
  | `pki.crmf` | RFC 4211 certificate-request-message issuance — `build(spec, key, opts)` assembles a `CertReqMessages`: a `spec` of `certReqId` (default 0; the RFC 9483 `-1` sentinel allowed), a `certTemplate` of the requested certificate fields (`subject`, `publicKey` — the SPKI DER of the key being certified — `validity`, requested `extensions`, an optional `version` 2), optional `controls` and `regInfo` (regToken / authenticator / utf8Pairs / oldCertID / protocolEncrKey, or pre-encoded `AttributeTypeAndValue` DER), and an optional `pop` selector. `key` (or `{ key }`) is the requester's private key — the message carries a `POPOSigningKey` proof of possession signed with the private half of `certTemplate.publicKey` (verified before the message is returned), exactly as a PKCS#10 CSR proves possession; a complete template signs the `CertRequest`, an incomplete one signs a `POPOSigningKeyInput`. The signature algorithm is resolved from the requested public key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. `key` is optional for a `raVerified` proof. Pass an array of specs for a batch; the CA-assigned template fields are never emitted. Returns DER, or a PEM block with `opts.pem`; malformed input throws a typed `CrmfError`. Parsing stays at `pki.schema.crmf.parse` — `build` |
231
231
  | `pki.cmp` | RFC 9810 Certificate Management Protocol message building — `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` / `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata (`transactionID`, `senderNonce` / `recipNonce`, `messageTime` as a GeneralizedTime, `senderKID` / `recipKID`, `freeText`, `generalInfo`); `message.body` is a single-key object naming the arm. Request-side: `ir` / `cr` / `kur` (a `CertReqMessages` spec delegated to `pki.crmf.build`), `p10cr` (a PKCS#10 `CertificationRequest`), `certConf`, `pollReq`, `genm`, `rr`. CA/responder-side: `ip` / `cp` / `kup` / `ccp` (a `CertRepMessage` — `caPubs` and `response` entries carrying a `PKIStatusInfo` and, under a granting status, a `certifiedKeyPair`), `rp` (revocation response), `genp`, `error`, `pollRep`, `krp` (key-recovery response), `pkiconf`. Protection is exactly one of `opts.{ key, cert }` — a signature over the message under the sender key, the algorithm resolved from the signer certificate so RSA (PKCS#1 v1.5 / PSS), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch — or `opts.mac` — a PBMAC1 shared-secret HMAC (RFC 9481 / 9579, PBKDF2-derived). The protection covers the exact DER of the virtual `ProtectedPart` (the header and body) and is self-verified before the message is returned; the `protectionAlg` is derived, never caller-set, so the message the parser accepts is coherent by construction. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. `transfer(url, message, opts)` carries a built message to a CMP endpoint over the shared `pki.transport` (RFC 9811 HTTP transfer) — one POST of the DER PKIMessage, the response classified fail-closed (200-only success, a non-200 2xx or an un-followed 3xx refused, a 4xx/5xx carrying a CMP error PKIMessage forwarded as the integrity-protected verdict) with protection surfaced not verified; `wellKnownUrl(base, opts)` builds the RFC 9811 §3.4 `/.well-known/cmp` request-URIs. `verify(message, opts)` checks the protection on an incoming `PKIMessage` — a signature (through the same certification-path engine `pki.crl.verify` / `pki.ocsp.verify` use, with the EdDSA low-order-point and algorithm-confusion gates) or a PBMAC1 MAC (recomputed from `opts.sharedSecret` and the message's own PBKDF2 parameters, constant-time compared) over the exact `ProtectedPart` reconstructed from the parser's raw slices; fail-closed on an unprotected message, a legacy / KEM MAC algorithm, an omitted keyLength, or a SHA-1 PRF, returning a `{ valid, trusted, protectionType, signer, ... }` verdict. With `opts.trustAnchors` the signature signer certificate is fully path-validated (RFC 5280 §6.1 plus the RFC 9483 §3.2 `keyUsage.digitalSignature` gate) at a trusted current time (or an explicit `opts.time` for historical verification — never the message's self-asserted `messageTime`) before it is reported trusted; without one the verdict is crypto-only and the signer certificate is surfaced to anchor. `session(opts)` returns a stateful enrollment session whose `enroll(request)` drives a full RFC 9810 `ir` / `cr` / `kur` / `p10cr` transaction over the shared transport — composing `build` / `transfer` / `verify` — with every response protection-verified, signer-trusted (chained to a supplied anchor, or the shared secret matched), and bound to the exchange (a stable `transactionID`, a fresh-`senderNonce` / echoed-`recipNonce` chain) before its body is read, a bounded `pollReq` / `pollRep` loop for a `waiting` status, and a `certConf` / `pkiConf` (or implicit) confirmation with an explicit `hashAlg` for a sig algorithm that does not convey its hash, returning a terminal `{ outcome, certificate, chain, status, trusted, confirmed, implicitConfirm, transactionID, polls, transcript }`; the signature flavor requires `opts.trustAnchors` to authenticate the CA, and a verified rejection / error or an exhausted poll budget is a terminal verdict while a tampered / untrusted / desynchronized response is a typed throw. Parsing stays at `pki.schema.cmp.parse` — `build`, `transfer`, `wellKnownUrl`, `verify`, `session` |
232
232
  | `pki.crl` | RFC 5280 §5 certificate revocation list issuance — `sign(spec, issuer, opts)` builds and signs a `CertificateList`: a `spec` of `thisUpdate` / `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` + `revocationDate` with an optional `reason` or `invalidityDate`), and an optional `extensions` object (authority key identifier, issuing distribution point, delta-CRL indicator, freshest CRL, authority information access) or an array of pre-encoded Extension DER; an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), the outer `signatureAlgorithm` matches `tbsCertList.signature`, an empty revocation list omits the field rather than emitting an empty SEQUENCE, `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime, per-extension criticality is fixed by the RFC, and the produced signature is verified under the issuer key before return. `verify(crl, issuer)` checks a CRL signature through the one path-validation signature engine (algorithm-confusion and EdDSA low-order gates included), and `isRevoked(crl, serialNumber)` looks a serial up in the revocation list. Returns DER, or a PEM `X509 CRL` with `opts.pem`; malformed input throws a typed `CrlError`. Parsing stays at `pki.schema.crl.parse` — `sign` / `verify` / `isRevoked` |
233
- | `pki.key` | RFC 5958 / RFC 8018 key-material lifecycle — `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 + AES-CBC-Pad): `opts` selects the `cipher` (`aes-256-cbc` default, `aes-192-cbc`, `aes-128-cbc`), the `prf` (`hmacWithSHA256` default, SHA-384/512, SHA-1), the `iterations` (default 600000), and the `salt`; the plaintext is validated as PKCS#8 before encryption, a default `prf` and `keyLength` are omitted so the parameters are byte-exact with OpenSSL, and the output is re-parsed before return. `decrypt(encrypted, password, opts)` recovers the inner `PrivateKeyInfo` (re-validated through `pki.schema.pkcs8.parse`) — only PBES2/PBKDF2/AES-CBC is accepted (PBES1, PBMAC1, scrypt refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), a malformed parameter set or wrong-length IV is a distinct typed error, and — because a MAC-less PBES2-CBC decrypt must not be a padding oracle (RFC 8018 §8) — a wrong password and a valid-pad-but-not-a-key both surface the one uniform `key/decrypt-failed`. `export(key, opts)` / `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo, delegating the encoding to WebCrypto so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters (an ambiguous RSA/EC import requires `opts.algorithm`). `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards/Montgomery curves, and the FIPS post-quantum ML-DSA / ML-KEM, and `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM; fail-closed with typed `KeyError`. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt` / `decrypt` / `export` / `import` / `generate` / `publicFromPrivate` |
233
+ | `pki.key` | RFC 5958 / RFC 8018 key-material lifecycle — `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 + AES-CBC-Pad): `opts` selects the `cipher` (`aes-256-cbc` default, `aes-192-cbc`, `aes-128-cbc`), the `prf` (`hmacWithSHA256` default, SHA-384/512, SHA-1), the `iterations` (default 600000), and the `salt`; the plaintext is validated as PKCS#8 before encryption, a default `prf` and `keyLength` are omitted so the parameters are byte-exact with OpenSSL, and the output is re-parsed before return. `decrypt(encrypted, password, opts)` recovers the inner `PrivateKeyInfo` (re-validated through `pki.schema.pkcs8.parse`) — only PBES2/PBKDF2/AES-CBC is accepted (PBES1, PBMAC1, scrypt refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), a malformed parameter set or wrong-length IV is a distinct typed error, and — because a MAC-less PBES2-CBC decrypt must not be a padding oracle (RFC 8018 §8) — a wrong password and a valid-pad-but-not-a-key both surface the one uniform `key/decrypt-failed`. `export(key, opts)` / `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo -- the key may come from the platform's WebCrypto or from a separately-installed copy of this toolkit, and is exported through whichever holds its material (a non-extractable key, or one whose implementation keeps its material out of reach, is refused with that as the reason) -- delegating the encoding to WebCrypto so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters (an ambiguous RSA/EC import requires `opts.algorithm`). `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards/Montgomery curves, and the FIPS post-quantum ML-DSA / ML-KEM, and `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM; fail-closed with typed `KeyError`. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt` / `decrypt` / `export` / `import` / `generate` / `publicFromPrivate` |
234
234
  | `pki.pkcs12` | RFC 7292 / RFC 9579 PKCS#12 (.p12/.pfx) issuance — `build(spec, opts)` assembles a password-integrity store. `spec` is the OpenSSL-style `{ key, cert, ca?, friendlyName?, localKeyId? }` or the full `{ safeContents: [...] }`, where each element is a plaintext or PBES2-encrypted `SafeContents` of key / shroudedKey / cert / crl / secret / nested `safeContents` bags. Keys and certs are validated before wrapping; `friendlyName` (BMPString) and `localKeyId` attributes are single-value. The store is protected by a classic Appendix B HMAC (default, max interop) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`), over SHA-256/384/512, with the shrouded keys and cert safes encrypted under RFC 8018 PBES2 (AES-128/192/256-CBC). Every password is encoded the PKCS#12 way — BMPString+NULL for the classic MAC, UTF-8 for the PBES2 bags and PBMAC1 (what OpenSSL and NSS consume) — so a file it emits opens in OpenSSL and NSS, cross-checked bidirectionally. The MAC is computed over the exact AuthenticatedSafe byte range, a DEFAULT-1 `MacData.iterations` is rejected up front, and the store is re-parsed before return. `verifyMac(pfx, password, opts)` recomputes a store's classic or PBMAC1 MAC over `macedBytes` and constant-time-compares it, throwing on a MAC-less or public-key-integrity store. **Public-key integrity** (`opts.integrity.mode: "public-key"`) wraps the AuthenticatedSafe in a CMS SignedData instead of a MAC — a signature from any `pki.cms.sign` signer (RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite), no MacData (RFC 7292 §4); privacy stays independent, so the `password` still PBES2-encrypts the bags. **Public-key privacy** — per-safe `recipients` (or the `opts.recipientCerts` convenience) wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData` — never GCM) encrypting it to recipient public keys through the shipped `pki.cms.encrypt` recipient model, restricted to certificate recipients (RSA-OAEP / ECDH / X25519 / X448 / ML-KEM — a password or KEK recipient, which `open` could not reopen, is rejected); all four integrity × privacy combinations are permitted (RFC 7292 §3.1). **Legacy-PBE read** — `open` decrypts the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` / NSS store uses (RC2 via an in-tree RFC 2268 cipher), so an older store opens; the legacy RC4 schemes are refused. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not a decrypt error), then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` — keys as re-validated PKCS#8 DER, certs/CRLs/secrets as raw DER, all with `friendlyName`/`localKeyId`, nested safes recursively. A MAC-less store is refused unless `opts.allowUnauthenticated`; a **public-key-integrity store is verified through its CMS SignedData signature first** (`pkcs12/signature-invalid` on failure, the signer surfaced in `signers` but never trust-chained — the caller's `pki.path.validate` step); a legacy-PBE (App. C) store's 3DES / RC2 bags are decrypted (RC4 refused); an `id-envelopedData` (public-key privacy) safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` if absent, every recipient-side fault the uniform `pkcs12/decrypt-failed`); a post-integrity decrypt failure is the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`; it reads what OpenSSL and NSS produce. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` / `open` |
235
235
  | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, RSA / RSASSA-PSS / ECDSA / EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA (pairing ML-DSA with a traditional RSA / ECDSA / EdDSA — accepted only when **both** components verify — draft-ietf-lamps-cms-composite-sigs)); it builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: when signed attributes are present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. **Countersignatures** (RFC 5652 §11.4): `countersign(cms, signers, opts)` adds a countersignature — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, the primary bytes preserved so it still verifies — attached as the id-countersignature unsigned attribute; `verify` returns each countersignature's verdict under `signers[i].countersignatures` and every unsigned attribute (an RFC 3161 timestamp token attachable via `sign`'s `unsignedAttributes`) under `signers[i].unsignedAttrs`, surfaced unauthenticated. **Content encryption** (RFC 5652/5083/5084/9629): `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData — recipients auto-dispatch off the certificate key to key-transport (RSAES-OAEP; v1.5 never emitted), key-agreement (ephemeral-static ECDH over P-256/384/521 with the X9.63 KDF, and X25519/X448 with HKDF), symmetric key-wrap, password (PBKDF2 + RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936) — one fresh content key wrapped for every recipient. `decrypt(input, keyMaterial, opts)` recovers the content through the matching arm and returns it with an `authenticated` flag; every secret-dependent failure collapses to one uniform `cms/decrypt-failed` verdict (Bleichenbacher / EFAIL / password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. **AuthenticatedData** (RFC 5652 §9): `authenticate(content, recipients, opts)` produces an `id-ct-authData` — cleartext content plus an HMAC-SHA-256/384/512 MAC (authenticated but not encrypted), the fresh MAC key wrapped for every recipient through the same RecipientInfo model as `encrypt`; the MAC covers the authenticated attributes (content-type + message-digest) re-tagged to the EXPLICIT SET OF (§9.2), or the content octets directly. `decrypt` recovers the MAC key, recomputes the MAC and independently the message-digest (§9.3), and releases the content only after both pass, with every secret-dependent failure collapsing to the uniform `cms/decrypt-failed`. **Compression** (RFC 3274): `compress(content, opts)` / `decompress(input, opts)` produce and consume a CompressedData (ZLIB, version 0, id-alg-zlibCompress); decompress bounds the uncompressed output at 16 MiB and stops before it is materialized, so a decompression bomb fails closed as `cms/decompress-too-large` — a size transform with no integrity/confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress` |
236
236
  | `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one — `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat); the `smime-type` is derived from the CMS body, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. **Header protection** (RFC 9788): `sign` / `encrypt` gain `opts.protectHeaders` — the caller's `opts.headers` are inlined on the Cryptographic Payload root (its Content-Type gains `hp="clear"` signed / `hp="cipher"` encrypted) so the CMS signature/encryption covers them, defeating a transport that rewrites or reads Subject/From/… `verify` / `decrypt` surface the AUTHENTICATED inner set as `protectedHeaders` + `headerProtection { present, mode, fromMismatch, confidential, legacy }` (a tampered outer header cannot alter it; `fromMismatch` flags an outer From that disagrees). Encryption applies a Header Confidentiality Policy — the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments/Keywords, so the real values live only in the ciphertext; `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard (a CR/LF/NUL value or a non-ftext name is rejected), and a malformed/contradictory `hp` wrap fails closed (`smime/bad-header-protection`), never a silent downgrade; the CMS crypto is unchanged. Inbound **legacy** RFC 8551 header protection is recognized opt-in: `verify` / `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload (the RFC 9788 §4.10.1 four-condition identification) and surface the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }` (`headers` an ordered `[{ name, value }]` array that retains legally-repeated fields like `Received`) — never in `protectedHeaders` and never setting `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present`/`protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` (cross-checking `legacy.fromMismatch`) consumes it. Off by default; a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
package/lib/jose.js CHANGED
@@ -36,7 +36,8 @@
36
36
  */
37
37
 
38
38
  var constants = require("./constants");
39
- var webcrypto = require("./webcrypto").webcrypto;
39
+ var wcEngine = require("./webcrypto");
40
+ var webcrypto = wcEngine.webcrypto;
40
41
  var frameworkError = require("./framework-error");
41
42
  var guard = require("./guard-all");
42
43
  var edwardsPoint = require("./edwards-point");
@@ -391,6 +392,11 @@ async function verify(jws, opts) {
391
392
  * `opts.key` a private `CryptoKey`. The signing input is built from the encoded
392
393
  * header and payload and signed with the alg the header names.
393
394
  *
395
+ * The key need not have been created by this toolkit's own WebCrypto: one from the platform's, or from a
396
+ * separately-installed copy of this toolkit, is re-imported through this engine, since it carries none of
397
+ * the material this engine signs with. A key created non-extractable cannot be re-imported, and is refused
398
+ * with that as the reason, as is one whose implementation keeps its material out of reach entirely.
399
+ *
394
400
  * @opts
395
401
  * protected: object // the protected header (alg, nonce, url, jwk|kid)
396
402
  * payload: Buffer // the raw payload octets ("" for POST-as-GET)
@@ -412,7 +418,7 @@ async function sign(opts) {
412
418
  // A non-CryptoKey (a raw Buffer, a string, a JWK object) would reach subtle.sign
413
419
  // and throw a bare TypeError; require a CryptoKey (it carries an `algorithm`) so
414
420
  // every caller -- and every acme builder that composes sign -- fails closed.
415
- if (!opts.key || typeof opts.key !== "object" || typeof opts.key.algorithm !== "object") throw E("jose/bad-input", "a private or secret CryptoKey (opts.key) is required");
421
+ if (!wcEngine.isCryptoKeyLike(opts.key)) throw E("jose/bad-input", "a private or secret CryptoKey (opts.key) is required");
416
422
  var checked = _checkHeader(header, opts.profile || "acme-outer");
417
423
  // An RSA / HMAC key binds its hash at import; the signature-length pin below cannot
418
424
  // see a hash mismatch (the modulus / MAC size is unchanged), so a SHA-512 key under
@@ -431,7 +437,12 @@ async function sign(opts) {
431
437
  var payloadB64 = payload.length === 0 ? "" : b64uEncode(payload);
432
438
  var signingInput = Buffer.from(protectedB64 + "." + payloadB64, "ascii");
433
439
  var jwk = header.jwk || opts.jwk || {};
434
- var sigBuf = Buffer.from(await webcrypto.subtle.sign(_cryptoAlg(checked.algRow, jwk, opts.key), opts.key, signingInput));
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.
444
+ var signKey = await wcEngine.adoptKey(opts.key, null, ["sign"], E, "jose/bad-input");
445
+ var sigBuf = Buffer.from(await webcrypto.subtle.sign(_cryptoAlg(checked.algRow, jwk, signKey), signKey, signingInput));
435
446
  // Pin the produced signature length the same way verify does. A key whose curve /
436
447
  // hash / modulus does not match the header alg (a P-384 key under ES256, an
437
448
  // HS512 key under HS256) still signs, but emits the wrong length -- caught here
package/lib/key.js CHANGED
@@ -40,7 +40,6 @@ var guard = require("./guard-all");
40
40
 
41
41
  var b = asn1.build;
42
42
  var subtle = webcrypto.webcrypto.subtle;
43
- var CryptoKey = webcrypto.CryptoKey;
44
43
  var KeyError = frameworkError.KeyError;
45
44
  var PemError = frameworkError.PemError;
46
45
  function O(n) { return oid.byName(n); }
@@ -65,14 +64,14 @@ var INFER_ALG = {};
65
64
  "shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
66
65
  ].forEach(function (s) { INFER_ALG[O("id-slh-dsa-" + s)] = { name: ("SLH-DSA-" + s).toUpperCase() }; });
67
66
 
68
- function _isCryptoKey(x) { return x instanceof CryptoKey; }
67
+ function _isCryptoKey(x) { return webcrypto.isCryptoKeyLike(x); }
69
68
  function _algName(a) { return typeof a === "string" ? a : (a && a.name); }
70
69
 
71
70
  // A private-key input (CryptoKey | DER Buffer | 'PRIVATE KEY' PEM) -> the raw PKCS#8 PrivateKeyInfo DER.
72
71
  async function _toPrivateKeyDer(input) {
73
72
  if (_isCryptoKey(input)) {
74
73
  if (input.type !== "private") throw _err("key/bad-input", "a private CryptoKey is required (got a " + input.type + " key)");
75
- return Buffer.from(await subtle.exportKey("pkcs8", input));
74
+ return webcrypto.exportAnyKey(input, _err, "key/bad-input");
76
75
  }
77
76
  return pkix.coerceToDer(input, { pemLabel: "PRIVATE KEY", PemError: PemError, ErrorClass: KeyError, prefix: "key" });
78
77
  }
@@ -197,6 +196,11 @@ function _decryptPbes2(encAlg, ciphertext, password, opts) {
197
196
  * NULL, EC a namedCurve OID, and Ed25519 / Ed448 / X25519 / X448 omit parameters (RFC 8410 sec. 3); the
198
197
  * wrapper never re-encodes the AlgorithmIdentifier.
199
198
  *
199
+ * The key need not have been created by this toolkit's own WebCrypto: one from the platform's, or from a
200
+ * separately-installed copy of this toolkit, is exported through whichever of them holds its material,
201
+ * since neither can read the other's. A key created non-extractable is refused with that as the reason,
202
+ * as is one whose implementation keeps its material out of this process's reach entirely.
203
+ *
200
204
  * @opts
201
205
  * - `format` (string) -- `der` (default) or `pem`.
202
206
  * - `label` (string) -- the PEM label (defaults `PRIVATE KEY` / `PUBLIC KEY` by key type).
@@ -207,11 +211,11 @@ function _decryptPbes2(encAlg, ciphertext, password, opts) {
207
211
  async function export_(key, opts) {
208
212
  opts = opts || {};
209
213
  if (!_isCryptoKey(key)) throw _err("key/bad-input", "export expects a WebCrypto CryptoKey");
210
- var format, defaultLabel;
211
- if (key.type === "private") { format = "pkcs8"; defaultLabel = "PRIVATE KEY"; }
212
- else if (key.type === "public") { format = "spki"; defaultLabel = "PUBLIC KEY"; }
214
+ var defaultLabel;
215
+ if (key.type === "private") defaultLabel = "PRIVATE KEY";
216
+ else if (key.type === "public") defaultLabel = "PUBLIC KEY";
213
217
  else throw _err("key/bad-input", "export supports asymmetric (private / public) CryptoKeys only");
214
- var der = Buffer.from(await subtle.exportKey(format, key));
218
+ var der = await webcrypto.exportAnyKey(key, _err, "key/bad-input");
215
219
  var fmt = opts.format || "der";
216
220
  if (fmt === "der") return der;
217
221
  if (fmt === "pem") return pkix.pemEncode(der, opts.label || defaultLabel, PemError);
@@ -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,7 +170,10 @@ 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
- return Promise.resolve(key);
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
+ return webcrypto.adoptKey(key, imp, ["sign"], E, "bad-input");
174
177
  }
175
178
  var der;
176
179
  if (Buffer.isBuffer(key)) der = key;
package/lib/webcrypto.js CHANGED
@@ -209,7 +209,21 @@ function CryptoKey(type, extractable, algorithm, usages, handle) {
209
209
  Object.defineProperty(this, "_handle", { value: handle, enumerable: false });
210
210
  }
211
211
 
212
+ // This engine reads key material through its own CryptoKey handle, which a key minted by another
213
+ // WebCrypto implementation does not carry -- W3C leaves cross-implementation use undefined, and
214
+ // reaching for the missing handle raises a bare type error from inside the crypto library rather
215
+ // than a verdict. Refuse it here, naming which of the two it is: a foreign CryptoKey (import it
216
+ // through this engine, or use the pki.* verbs, which adopt one) or something that is no key at all.
217
+ function _requireOwnKey(key, who) {
218
+ if (key instanceof CryptoKey) return;
219
+ if (isCryptoKeyLike(key)) {
220
+ throw new WebCryptoError("webcrypto/invalid-access", who + ": the key was created by a different WebCrypto implementation; re-import it through this one");
221
+ }
222
+ throw new WebCryptoError("webcrypto/invalid-access", who + ": a CryptoKey is required");
223
+ }
224
+
212
225
  function _requireUsage(key, usage) {
226
+ _requireOwnKey(key, usage);
213
227
  if (key.usages.indexOf(usage) === -1) {
214
228
  throw new WebCryptoError("webcrypto/invalid-access", "key is not permitted for '" + usage + "' (usages: " + key.usages.join(",") + ")");
215
229
  }
@@ -580,6 +594,7 @@ async function _deriveBitsRaw(alg, key, length) {
580
594
  var name = alg.name;
581
595
  if (name === "ECDH" || name === "X25519" || name === "X448") {
582
596
  _requireAlgMatch(alg, alg.public, name + " public key");
597
+ _requireOwnKey(alg.public, "deriveBits public key");
583
598
  var secret = nodeCrypto.diffieHellman({ privateKey: key._handle, publicKey: alg.public._handle });
584
599
  if (length == null) return _toArrayBuffer(secret);
585
600
  _requireDeriveLength(length, name);
@@ -1009,6 +1024,7 @@ function _curveFromKey(ko) {
1009
1024
  * var spki = await pki.webcrypto.subtle.exportKey("spki", keyPair.publicKey);
1010
1025
  */
1011
1026
  SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
1027
+ _requireOwnKey(key, "exportKey");
1012
1028
  if (!key.extractable) throw new WebCryptoError("webcrypto/invalid-access", "key is not extractable");
1013
1029
  if (format === "jwk") return key._handle.export({ format: "jwk" });
1014
1030
  if (key.type === "secret") {
@@ -1083,11 +1099,98 @@ function decompressEcPoint(sec1Compressed, nodeCurve, E, code) {
1083
1099
  }
1084
1100
  }
1085
1101
 
1102
+ // A CryptoKey from a DIFFERENT WebCrypto implementation -- node:crypto's global `webcrypto`, a
1103
+ // browser's, or a userland polyfill's -- is indistinguishable from one of this engine's by `type`,
1104
+ // `algorithm` and `usages`, yet carries none of the key material this engine holds. Handing one to
1105
+ // this engine raises a bare error from inside the crypto library, so a caller who followed the
1106
+ // documented "pass a CryptoKey" contract is told the argument is the wrong type rather than which
1107
+ // implementation it came from. Neither engine can export the other's keys, so a foreign key is
1108
+ // exported through its OWN implementation and re-imported here. Both take the CALLER's typed error
1109
+ // factory + code so every boundary keeps its own domain/reason.
1110
+ var _crypto = new Crypto();
1111
+ // Null-prototype: is caller-supplied, and an inherited member ("constructor", "toString")
1112
+ // would otherwise resolve to a truthy non-format and slip past the check below.
1113
+ var _EXPORT_FORMAT = Object.assign(Object.create(null), { "private": "pkcs8", "public": "spki", "secret": "raw" });
1114
+
1115
+ // @internal
1116
+ // True for any object shaped like a WebCrypto CryptoKey, whichever implementation minted it.
1117
+ function isCryptoKeyLike(x) {
1118
+ return !!x && typeof x === "object" && typeof x.type === "string" &&
1119
+ typeof x.extractable === "boolean" && !!x.algorithm && typeof x.algorithm === "object" &&
1120
+ typeof x.algorithm.name === "string" && Array.isArray(x.usages);
1121
+ }
1122
+
1123
+ // @internal
1124
+ // Export any CryptoKey's material through whichever implementation owns it, in the format its
1125
+ // `type` implies. A key this engine minted goes through this engine; a foreign one through
1126
+ // node:crypto's. A foreign key that is not extractable cannot be reached at all -- that is a
1127
+ // permanent verdict, and it is reported as itself rather than as an export failure.
1128
+ function exportAnyKey(key, E, code) {
1129
+ var format = _EXPORT_FORMAT[key && key.type];
1130
+ if (!format) throw E(code, "a CryptoKey with a private, public, or secret type is required");
1131
+ if (key instanceof CryptoKey) {
1132
+ return Promise.resolve()
1133
+ .then(function () { return _crypto.subtle.exportKey(format, key); })
1134
+ .then(function (b) { return Buffer.from(b); });
1135
+ }
1136
+ if (!key.extractable) {
1137
+ throw E(code, "the CryptoKey comes from a different WebCrypto implementation and is not extractable, so its key material cannot be reached; import it through pki.webcrypto.subtle, or pass the key as DER");
1138
+ }
1139
+ // A separately-installed copy of this engine is the same code under a different class identity, so
1140
+ // its keys fail the `instanceof` above while carrying the very handle this engine reads -- and no
1141
+ // WebCrypto implementation but that copy would accept them. Read the handle directly. The
1142
+ // extractable check above stays AHEAD of this: the handle can always be read, so honouring the
1143
+ // key's own extractable promise is this branch's responsibility, not the crypto library's.
1144
+ var handle = key._handle;
1145
+ if (handle instanceof nodeCrypto.KeyObject && handle.type === key.type) {
1146
+ return Promise.resolve().then(function () {
1147
+ return format === "raw" ? handle.export() : handle.export({ format: "der", type: format });
1148
+ }).then(function (b) { return Buffer.from(b); }, function (e) {
1149
+ throw E(code, "the CryptoKey comes from a different copy of this WebCrypto engine and its key material could not be read; import it through pki.webcrypto.subtle, or pass the key as DER", e);
1150
+ });
1151
+ }
1152
+ // Otherwise the only remaining implementation whose keys are reachable from this process is the
1153
+ // platform's own. A third party's -- a browser's, a userland polyfill's -- keeps its material
1154
+ // behind its own SubtleCrypto, which nothing here holds a reference to, so it is refused below.
1155
+ return Promise.resolve()
1156
+ .then(function () { return nodeCrypto.webcrypto.subtle.exportKey(format, key); })
1157
+ .then(function (b) { return Buffer.from(b); }, function (e) {
1158
+ throw E(code, "the CryptoKey comes from a different WebCrypto implementation and could not be exported for re-import; import it through pki.webcrypto.subtle, or pass the key as DER", e);
1159
+ });
1160
+ }
1161
+
1162
+ // @internal
1163
+ // A CryptoKey this engine can use. One it minted passes through untouched -- so a non-extractable
1164
+ // key of its own keeps working -- and a foreign one is re-imported. `importParams` may be null, in
1165
+ // which case the key's own `algorithm` is used, which is what the key actually is.
1166
+ function adoptKey(key, importParams, usages, E, code) {
1167
+ if (key instanceof CryptoKey) return Promise.resolve(key);
1168
+ // A key's usages are a capability restriction it carries, and re-importing it is the one moment
1169
+ // that restriction could be widened -- the new key is created with the usages the CALLER wants,
1170
+ // not the ones the original was created with. Require every one of them up front, so an adopted
1171
+ // key can do no more than it could where it came from, and a verify-only key is refused here
1172
+ // exactly as this engine's own verify-only key is refused by _requireUsage.
1173
+ if (!Array.isArray(key.usages)) throw E(code, "a CryptoKey carrying its permitted usages is required");
1174
+ for (var i = 0; i < usages.length; i++) {
1175
+ if (key.usages.indexOf(usages[i]) === -1) {
1176
+ throw E(code, "the CryptoKey is not permitted for '" + usages[i] + "' (usages: " + key.usages.join(",") + ")");
1177
+ }
1178
+ }
1179
+ return Promise.resolve()
1180
+ .then(function () { return exportAnyKey(key, E, code); })
1181
+ .then(function (der) {
1182
+ return _crypto.subtle.importKey(_EXPORT_FORMAT[key.type], der, importParams || key.algorithm, false, usages);
1183
+ });
1184
+ }
1185
+
1086
1186
  module.exports = {
1087
- webcrypto: new Crypto(),
1187
+ webcrypto: _crypto,
1088
1188
  Crypto: Crypto,
1089
1189
  SubtleCrypto: SubtleCrypto,
1090
1190
  CryptoKey: CryptoKey,
1091
1191
  WebCryptoError: WebCryptoError,
1092
1192
  decompressEcPoint: decompressEcPoint,
1193
+ isCryptoKeyLike: isCryptoKeyLike,
1194
+ exportAnyKey: exportAnyKey,
1195
+ adoptKey: adoptKey,
1093
1196
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.4",
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:1be17160-9a0e-4c1f-b171-d5234287a696",
5
+ "serialNumber": "urn:uuid:1feef474-ddbd-4d2d-ae82-845a967ba69b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-08T17:10:39.565Z",
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.4",
22
+ "bom-ref": "@blamejs/pki@0.4.6",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.4",
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.4",
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.4",
57
+ "ref": "@blamejs/pki@0.4.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]