@blamejs/pki 0.4.11 → 0.4.13
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 +39 -0
- package/README.md +3 -3
- package/lib/attrcert-sign.js +3 -1
- package/lib/cms-decrypt.js +66 -6
- package/lib/cms-encrypt.js +23 -9
- package/lib/crl-sign.js +2 -1
- package/lib/csr-sign.js +3 -1
- package/lib/guard-all.js +2 -0
- package/lib/guard-secret.js +82 -0
- package/lib/inspect.js +38 -4
- package/lib/lint.js +114 -1
- package/lib/oid.js +35 -0
- package/lib/pbes2.js +10 -3
- package/lib/schema-c509.js +5 -1
- package/lib/schema-cms.js +3 -3
- package/lib/schema-pkix.js +105 -0
- package/lib/webcrypto.js +92 -12
- package/lib/x509-sign.js +3 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ 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.13 — 2026-08-10
|
|
8
|
+
|
|
9
|
+
A KEM shared secret and the key it derives are now wiped as soon as they stop being needed -- on the failing path as well as the succeeding one, which is the path an attacker chooses.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- A KEM shared secret and the key-encryption key derived from it are wiped as soon as they stop being needed, satisfying NIST SP 800-227 RS5 / sec. 4.2 and RFC 9629 sec. 7. The wipe runs in a finally, so a decryption that FAILS clears the same buffers a successful one does -- a wipe on the success path alone would preserve the secret in exactly the case an attacker can force. Only buffers this library allocated are cleared; a caller's key material, certificate, and the returned plaintext are never written to, and the plaintext remains usable after the wipe.
|
|
14
|
+
- This is best-effort and is documented as such rather than overstated. The runtime copies a shared secret into places no code can reach -- the decapsulation result on its way out, and again when it is imported as key material -- and may relocate a buffer's backing store. Wiping the copies the library holds shortens the window in which a secret is readable; it does not mean a secret never persists in memory.
|
|
15
|
+
- pki.oid.kemParams resolves an ML-KEM parameter set to its FIPS 203 Table 3 sizes, by dotted OID or by registered name.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The ML-KEM ciphertext-length check FIPS 203 sec. 7.3 requires of a decapsulating party is now performed by the crypto engine, so a caller reaching decapsulateBits directly is covered rather than only the CMS path that happens to call it today. It reports webcrypto/bad-kem-ciphertext, naming the parameter set and both lengths, where the failure was previously indistinguishable from any other decapsulation fault; a ciphertext whose length is valid for a DIFFERENT parameter set is refused as such rather than treated as merely short. The check is on length ONLY: a correct-length ciphertext that has been tampered with still resolves to a pseudo-random shared secret, because turning that into an error would give an attacker a decryption oracle. No engine detail reaches a CMS caller: a structurally valid message whose decryption fails for any secret-dependent reason still reports the single uniform cms/decrypt-failed verdict. A message whose ML-KEM ciphertext length does not match the parameter set the message itself declares is a separate case and always was -- including a length that would be valid for a different set -- because the strict parser rejects it up front and names it: the mismatch is a structural fault, decidable from the message alone, with nothing about it depending on a key.
|
|
20
|
+
- The ML-KEM parameter sizes resolve from one registry instead of three separate tables in three modules. The encapsulation-key lengths were already duplicated verbatim in two of them, and each new consumer meant another copy that could drift; a parameter set is a property of the algorithm identifier, so it now lives beside the registry that resolves one. Behaviour is unchanged.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- The roadmap attributed two rules to NIST SP 800-227 that it does not state: implicit rejection and re-encapsulation are FIPS 203's, reached through SP 800-227's requirement to comply with the KEM's own standard, and SP 800-227 sec. 4.3 explicitly permits a shared secret to be used directly, truncated, or split into segments -- the unconditional key-derivation requirement comes from RFC 9629 sec. 5. The entry now states what each document requires.
|
|
25
|
+
|
|
26
|
+
## v0.4.12 — 2026-08-09
|
|
27
|
+
|
|
28
|
+
A CMS message can no longer declare one content cipher and be opened with another: the declared algorithm's mode is now bound to the container that carries it, so an EnvelopedData naming an authenticated cipher is refused rather than opened, unauthenticated, under a result that reported it as authenticated.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- pki.lint reports the RFC 5280 sec. 4.2.1.4 rules for a certificate policy's user notice, at the strength the specification states each one: encoding a notice as VisibleString or BMPString is an error, since conforming CAs must not; a notice past 200 characters, an empty one, and one containing control characters are warnings; a UTF8String notice that is not in Unicode normalization form C is a notice. The length is measured in characters, so a conforming notice whose accented or emoji characters occupy more storage than 200 units is not reported, and a value whose contents do not decode under its own declared string type is not measured at all -- though the encoding rule, which the ASN.1 tag alone answers, still reports it. The rules live in the linter and not the decoder deliberately: the same section directs certificate users to handle an over-long notice gracefully, so a verifier that refused one would reject certificates that exist and are otherwise valid.
|
|
33
|
+
- The two ends of the SIZE (1..200) bound are separate findings rather than one, because the section treats them differently: it directs certificate users to handle a notice ABOVE 200 characters gracefully and says nothing of the sort about an empty one, so suppressing the first must not silently suppress the second. Both cover a notice reference's organization as well as the explicit text, since the bound belongs to the DisplayText type rather than to one of the two fields that use it.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- pki.inspect renders a certificate policy's user notice as text. A user notice is a constructed value, so it previously fell to the hexadecimal fallback and an operator could not read the notice the qualifier exists to display; its explicit text and its notice reference now render, the reference carrying its organization together with the notice numbers that identify which notice is meant.
|
|
38
|
+
- pki.inspect renders an authority-information-access location given as a directory name. It previously printed a bare form tag, hiding the responder or issuer identity the entry exists to convey, while the same name form already printed as a distinguished name elsewhere in the report.
|
|
39
|
+
- The producing entry points state their error contract completely. pki.x509.sign, pki.csr.sign, pki.crl.sign and pki.attrcert.sign accept raw DER for a name, a pre-encoded extension, or a public key; a structural fault in those bytes raises the format's own error, while a malformed leaf inside them raises the codec's, which the parsing entry points already documented and these did not.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- A CMS content cipher is now bound to the container that declares it. An EnvelopedData must name a CBC cipher and an AuthEnvelopedData an AEAD one, checked before the content-encryption key is used; a mismatch is refused as an unsupported algorithm naming both the cipher and the container. Previously only the cipher's key length was resolved, and because AES-CBC and AES-GCM share key lengths, an EnvelopedData whose algorithm identifier had been changed to the same-size AES-GCM identifier decrypted successfully as unauthenticated CBC while reporting the AEAD algorithm in its result -- so a caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honoured. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, rather than by a stated rule.
|
|
44
|
+
- The password-recipient inner cipher is resolved through the same identifier-keyed table. It previously required a CBC mode by matching the algorithm identifier's display name, which pki.oid.register can rebind, so a caller that had registered a name over a built-in one could change which ciphers that check admitted.
|
|
45
|
+
|
|
7
46
|
## v0.4.11 — 2026-08-09
|
|
8
47
|
|
|
9
48
|
A WebAuthn attestation can now be bound to the roots the authenticator's own model actually registered, by reading a FIDO Metadata Service BLOB that is verified and chained to a root you supply before any of its contents are parsed.
|
package/README.md
CHANGED
|
@@ -232,7 +232,7 @@ is callable today; nothing below is a stub.
|
|
|
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
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
|
-
| `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` |
|
|
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. A KEM shared secret and its derived key-encryption key are wiped once used, on the failing path as well as the succeeding one (best-effort; NIST SP 800-227, RFC 9629 sec. 7). **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` |
|
|
237
237
|
| `pki.tsp` | RFC 3161 Time-Stamp Protocol — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and — with a trust anchor supplied — full certification-path validation of the TSA certificate at the token's `genTime`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
|
|
238
238
|
| `pki.ocsp` | RFC 6960 Online Certificate Status Protocol — the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs (CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile, or SHA-2; optional RFC 9654 nonce, optional requestor signature). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER — the issuing CA directly or a delegated responder, any `pki.cms.sign` key including the post-quantum ML-DSA / SLH-DSA sets, with `good` / `revoked` (reason + time) / `unknown` per-certificate status, and `buildErrorResponse(status)` the unsigned §2.3 error (`tryLater` / `unauthorized` / …). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA or a CA-issued delegate bearing id-kp-OCSPSigning **and** id-pkix-ocsp-nocheck, passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency (`thisUpdate`/`nextUpdate`), and the request-nonce echo — returning `{ status: "good" / "revoked" / "unknown", … }`, never a silent accept. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
|
|
@@ -243,9 +243,9 @@ is callable today; nothing below is a stub.
|
|
|
243
243
|
| `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
|
|
244
244
|
| `pki.hpke` | Hybrid Public Key Encryption (RFC 9180) — the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS`/`setupR` establish a sender/recipient context (KEM encapsulation + HKDF key schedule); the context's `seal`/`open` AEAD-encrypt with a sequence-counter nonce and `export` derives further secrets; `seal`/`open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) × HKDF-SHA256/SHA512 × AES-GCM/ChaCha20Poly1305/export-only × all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM / X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
|
|
245
245
|
| `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key; the ephemeral Fulcio certificate chain, validated as of the Rekor log time; the RFC 9162 inclusion proof folded to a Rekor-signed tree root; the log entry bound to this exact signature; and the in-toto SLSA subject digest the caller confirms against the published artifact. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
|
|
246
|
-
| `pki.inspect` | Human-readable inspection — the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. `crl` / `csr` / `cms` render the non-certificate formats the same way — a CRL like `openssl crl -text` (issuer, Last/Next Update, CRL extensions, each revoked entry with its serial, revocation date, and named reason), a CSR like `openssl req -text` (subject, key, requested extensions and attributes), and a CMS message like `openssl cms -cmsout -print` (a SignedData's content type, digest algorithms, embedded certificates, and each SignerInfo with its signer identifier, algorithms, attributes, and signature; a non-SignedData ContentInfo gets a stable summary) — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry, reusing one set of field renderers, so it names extension/algorithm OIDs an OpenSSL build shows only as raw bytes and never drifts. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
|
|
246
|
+
| `pki.inspect` | Human-readable inspection — the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. `crl` / `csr` / `cms` render the non-certificate formats the same way — a CRL like `openssl crl -text` (issuer, Last/Next Update, CRL extensions, each revoked entry with its serial, revocation date, and named reason), a CSR like `openssl req -text` (subject, key, requested extensions and attributes), and a CMS message like `openssl cms -cmsout -print` (a SignedData's content type, digest algorithms, embedded certificates, and each SignerInfo with its signer identifier, algorithms, attributes, and signature; a non-SignedData ContentInfo gets a stable summary) — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry, reusing one set of field renderers, so it names extension/algorithm OIDs an OpenSSL build shows only as raw bytes and never drifts. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a certificate policy's user notice renders as text (its explicit text, and a notice reference with the notice numbers that identify it) rather than hex, and a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
|
|
247
247
|
| `pki.webauthn` | WebAuthn / passkey attestation verification — offline trust evaluation of a W3C WebAuthn (Level 3) attestation. `parseAttestationObject(bytes)` decodes the CBOR attestation object + authenticatorData + COSE credential key over the strict `pki.cbor` codec; `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for **packed / tpm / android-key / apple / fido-u2f / none** — the x5c leaf key, the apple nonce, the tpm `certInfo` Name/`extraData` over the `pubArea`, the android `KeyDescription`, the fido-u2f `verificationData` — binding the credential public key to each attestation (via the signed authenticatorData for packed/fido-u2f, or a cert/`pubArea`-key equality check for android-key/apple/tpm) and enforcing each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers **ESP256/384/512, Ed25519, and Ed448** — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally-encoded DER ECDSA signature. A verifier, not a ceremony client; fail-closed with typed `webauthn/*` errors. `verifyMetadataBlob(blob, opts)` reads a **FIDO Metadata Service (MDS v3)** BLOB — the signed catalogue of registered authenticator models — verifying its JWS and chaining its signer to an operator-supplied FIDO root **before** the payload is parsed, with sequence-number rollback and `nextUpdate` freshness checks; passing the result as `opts.metadata` to `verify` resolves the authenticator's registered attestation roots from its identifier and requires the trust path to fully validate to one of them, refusing an unlisted or revoked model. Both of the catalogue's key spaces are covered — an aaguid, and the attestation-certificate key identifiers a U2F authenticator is listed under instead. No FIDO root is bundled and there is no trust-on-first-use; retrieving the BLOB is out of scope — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
|
|
248
|
-
| `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality — basicConstraints/nameConstraints/policyConstraints/inhibitAnyPolicy must be critical and keyUsage should be, nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI/AKI presence including the end-entity subjectKeyIdentifier, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
|
|
248
|
+
| `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality — basicConstraints/nameConstraints/policyConstraints/inhibitAnyPolicy must be critical and keyUsage should be, nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI/AKI presence including the end-entity subjectKeyIdentifier, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, and the sec. 4.2.1.4 certificate-policy user-notice rules — a VisibleString/BMPString explicitText, a notice past 200 characters, an empty notice, control characters, and a non-NFC UTF8String notice, each at the strength the clause states). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
|
|
249
249
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
250
250
|
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError` / `WebauthnError` / `LintError`, each carrying a stable `code` in `domain/reason` form |
|
|
251
251
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
|
package/lib/attrcert-sign.js
CHANGED
|
@@ -428,7 +428,9 @@ function _buildExtensions(extSpec, aaSpki) {
|
|
|
428
428
|
* attribute certificate is never self-signed. The signature algorithm is resolved from the AA key (RSA
|
|
429
429
|
* PKCS#1 v1.5 or PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm), and the signature is verified
|
|
430
430
|
* under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE
|
|
431
|
-
* CERTIFICATE` with `opts.pem`. Malformed input throws a typed `AttrCertError
|
|
431
|
+
* CERTIFICATE` with `opts.pem`. Malformed input throws a typed `AttrCertError`; where the spec carries
|
|
432
|
+
* raw DER -- a holder or issuer `Name` Buffer, a pre-encoded `Extension` -- a malformed leaf inside
|
|
433
|
+
* those bytes throws `Asn1Error`. The AA certificate's own
|
|
432
434
|
* profile (RFC 5755 sec. 4.5) and validity are a verification-layer concern -- validate the AA
|
|
433
435
|
* certificate with `pki.path.validate` before trusting the attribute certificate. Parsing is
|
|
434
436
|
* `pki.schema.attrcert.parse`.
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -36,6 +36,7 @@ function _err(code, message, cause) { return new CmsError(code, message, cause);
|
|
|
36
36
|
function _fail() { return new CmsError("cms/decrypt-failed", "the CMS content could not be decrypted (uniform by design -- padding / integrity / key-unwrap failures are indistinguishable to defeat oracles)"); }
|
|
37
37
|
|
|
38
38
|
var CONTENT_KEYBITS = pbes2.CONTENT_KEYBITS; // content-encryption OID -> key bits (the shared PBES2 table)
|
|
39
|
+
var CONTENT_MODE = pbes2.CONTENT_MODE; // the same rows' cipher mode ("cbc" | "gcm")
|
|
39
40
|
|
|
40
41
|
// ---- entry -----------------------------------------------------------------
|
|
41
42
|
async function decrypt(input, keyMaterial, opts) {
|
|
@@ -63,6 +64,13 @@ async function decryptEnvelopedData(parsed, keyMaterial, opts, contentTypeName)
|
|
|
63
64
|
var recips = parsed.recipientInfos || [];
|
|
64
65
|
var candidates = _selectCandidates(recips, keyMaterial, opts); // stage 1 (typed, distinct)
|
|
65
66
|
var eci = parsed.encryptedContentInfo;
|
|
67
|
+
// The content cipher's MODE is a structural property of the message -- readable from the algorithm
|
|
68
|
+
// identifier with no key material at all -- so it is decided HERE, before any recipient is tried.
|
|
69
|
+
// Deciding it per candidate would make a structural verdict depend on key acquisition succeeding
|
|
70
|
+
// first: several password or KEK recipients can match the same key material, so the distinct
|
|
71
|
+
// cms/unsupported-algorithm would be swallowed by the ambiguous-candidate loop below and the caller
|
|
72
|
+
// told their key was wrong, after repeating a PBKDF2 derivation for every candidate to learn it.
|
|
73
|
+
_assertContentCipherMode(eci, ct);
|
|
66
74
|
// Password- and KEK-based recipients carry no rid, so several may match the same key material; try
|
|
67
75
|
// each until one yields a CEK that opens the content. A single candidate propagates its exact
|
|
68
76
|
// verdict; multiple ambiguous candidates collapse to the uniform verdict so nothing is leaked.
|
|
@@ -274,7 +282,9 @@ async function _pwriCek(ri, km, opts) {
|
|
|
274
282
|
var inner = asn1.decode(kea.parameters); // inner AES-CBC AlgorithmIdentifier
|
|
275
283
|
var innerOid = asn1.read.oid(inner.children[0]);
|
|
276
284
|
var innerBits = CONTENT_KEYBITS[innerOid];
|
|
277
|
-
|
|
285
|
+
// The mode comes from the OID-keyed table, not from the OID's display NAME: pki.oid.register() can
|
|
286
|
+
// override a built-in name, and a name-matched mode check would then admit a non-CBC inner cipher.
|
|
287
|
+
if (!innerBits || CONTENT_MODE[innerOid] !== "cbc") throw _err("cms/unsupported-algorithm", "unsupported pwri inner cipher");
|
|
278
288
|
var iv = asn1.read.octetString(inner.children[1]);
|
|
279
289
|
var kek = nodeCrypto.pbkdf2Sync(pbes2.passwordBytes(km.password, _err, "cms"), pb.salt, pb.iterations, innerBits / 8, pb.prfNode);
|
|
280
290
|
return _pwriUnwrap(kek, ri.encryptedKey, iv, innerBits);
|
|
@@ -298,10 +308,45 @@ async function _kemriCek(ri, km) {
|
|
|
298
308
|
var wrapAlg = k.wrap;
|
|
299
309
|
if (WRAP_KEK_LENGTHS[wrapAlg.oid] !== kekBytes) throw _fail(); // M29 re-check on the consumer path
|
|
300
310
|
var priv = await subtle.importKey("pkcs8", _normKeyDer(km.key), { name: wcName }, false, ["decapsulateBits"]);
|
|
301
|
-
var ss =
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
311
|
+
var ss = null, kek = null, ssAb = null, kekAb = null;
|
|
312
|
+
try {
|
|
313
|
+
// The engine hands back an ArrayBuffer it allocated, and the Buffer below is a copy of it. Both
|
|
314
|
+
// hold the secret, so both are wiped: clearing only the copy this function made would leave the
|
|
315
|
+
// engine's return value readable, which is the same omission one level up.
|
|
316
|
+
ssAb = await subtle.decapsulateBits({ name: wcName }, priv, kemct);
|
|
317
|
+
ss = Buffer.from(ssAb);
|
|
318
|
+
var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
|
|
319
|
+
kekAb = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapAlg.name, kekBytes, k.ukm || null) }, ssKey, kekBytes * 8);
|
|
320
|
+
kek = Buffer.from(kekAb);
|
|
321
|
+
return await _aesKwUnwrap(kek, k.encryptedKey);
|
|
322
|
+
} finally {
|
|
323
|
+
// NIST SP 800-227 RS5 / sec. 4.2 and RFC 9629 sec. 7: the shared secret and the KEK it derives
|
|
324
|
+
// are destroyed as soon as they stop being needed. In a `finally`, so an unwrap that FAILS --
|
|
325
|
+
// the common case under a wrong key or a tampered encryptedKey -- leaves nothing behind either;
|
|
326
|
+
// a wipe on the success path alone would keep the secret exactly when an attacker made it fail.
|
|
327
|
+
// Only buffers allocated here are wiped: km.key, the message bytes and the returned CEK are the
|
|
328
|
+
// caller's or the caller's to consume.
|
|
329
|
+
// A Uint8Array view aliases the ArrayBuffer's own bytes, so wiping the view wipes the buffer
|
|
330
|
+
// the engine returned -- no further copy is made in order to destroy one.
|
|
331
|
+
guard.secret.zeroizeAll([ss, kek, ssAb ? new Uint8Array(ssAb) : null, kekAb ? new Uint8Array(kekAb) : null],
|
|
332
|
+
CmsError, "cms/bad-input", "the KEM shared secret");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The declared cipher MODE must match the container's authentication model: an AEAD cipher belongs in
|
|
337
|
+
// AuthEnvelopedData and a plain CBC cipher in EnvelopedData (RFC 5083 sec. 2.1 / RFC 5084 sec. 3). Key
|
|
338
|
+
// length alone does not separate them, so without this an EnvelopedData naming AES-GCM was opened as
|
|
339
|
+
// unauthenticated CBC while the result still reported the AEAD algorithm -- telling the caller the
|
|
340
|
+
// content was authenticated when nothing had authenticated it. The mirror case dereferenced AEAD
|
|
341
|
+
// parameters the parser leaves null for a non-AEAD algorithm, faulting instead of failing closed.
|
|
342
|
+
// An unresolvable OID is left to _openContent, which names it as the unsupported algorithm it is.
|
|
343
|
+
function _assertContentCipherMode(eci, ct) {
|
|
344
|
+
var oidStr = eci.contentEncryptionAlgorithm.oid;
|
|
345
|
+
if (!CONTENT_MODE[oidStr]) return;
|
|
346
|
+
var wantMode = ct === "authEnvelopedData" ? "gcm" : "cbc";
|
|
347
|
+
if (CONTENT_MODE[oidStr] !== wantMode) {
|
|
348
|
+
throw _err("cms/unsupported-algorithm", "contentEncryptionAlgorithm " + oidStr + " is not a " + wantMode.toUpperCase() + " cipher, which " + ct + " requires (RFC 5083 sec. 2.1 / RFC 5084 sec. 3)");
|
|
349
|
+
}
|
|
305
350
|
}
|
|
306
351
|
|
|
307
352
|
// ---- stage 3: open the content (uniform failure) ---------------------------
|
|
@@ -384,7 +429,22 @@ async function _aesKwUnwrap(kek, wrapped) {
|
|
|
384
429
|
// length, so an AuthenticatedData MAC key that is not 16/24/32 octets (e.g. a 64-octet HMAC-SHA-512
|
|
385
430
|
// key from another implementation) is recovered instead of rejected before the MAC is even checked.
|
|
386
431
|
var raw = await subtle.unwrapKey("raw", wrapped, kekKey, { name: "AES-KW" }, { name: "HMAC", hash: "SHA-256" }, true, ["sign"]);
|
|
387
|
-
|
|
432
|
+
// exportKey allocates an ArrayBuffer holding the unwrapped key; the Buffer below is a copy of it,
|
|
433
|
+
// and the original would otherwise stay readable with nothing referencing it. That is a
|
|
434
|
+
// controllable allocation, not one of the runtime-internal copies the best-effort caveat covers.
|
|
435
|
+
// Only the intermediate is wiped -- the returned Buffer IS the key the caller must use to open
|
|
436
|
+
// the content, so wiping it here would destroy the result rather than protect it.
|
|
437
|
+
var rawAb = await subtle.exportKey("raw", raw);
|
|
438
|
+
try {
|
|
439
|
+
// An explicit COPY, not Buffer.from(arrayBuffer) -- that form returns a VIEW sharing the
|
|
440
|
+
// ArrayBuffer's memory, so wiping the intermediate below would zero the key being returned.
|
|
441
|
+
var view = new Uint8Array(rawAb);
|
|
442
|
+
var out = Buffer.alloc(view.length);
|
|
443
|
+
out.set(view);
|
|
444
|
+
return out;
|
|
445
|
+
} finally {
|
|
446
|
+
guard.secret.zeroize(new Uint8Array(rawAb), CmsError, "cms/bad-input", "the unwrapped content key");
|
|
447
|
+
}
|
|
388
448
|
}
|
|
389
449
|
function _eccSharedInfo(wrapName, ukm, kekBytes) {
|
|
390
450
|
var kids = [b.sequence([b.oid(O(wrapName))])];
|
package/lib/cms-encrypt.js
CHANGED
|
@@ -275,15 +275,29 @@ async function _buildKemri(cek, cert, opts) {
|
|
|
275
275
|
var pub = await subtle.importKey("spki", cert.subjectPublicKeyInfo.bytes, { name: wcName }, false, ["encapsulateBits"]);
|
|
276
276
|
var kem = await subtle.encapsulateBits({ name: wcName }, pub);
|
|
277
277
|
var ss = Buffer.from(kem.sharedKey), kemct = Buffer.from(kem.ciphertext);
|
|
278
|
-
var
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
278
|
+
var kek = null, kekAb = null;
|
|
279
|
+
try {
|
|
280
|
+
var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
|
|
281
|
+
kekAb = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapName, kekBytes, ukm) }, ssKey, kekBytes * 8);
|
|
282
|
+
kek = Buffer.from(kekAb);
|
|
283
|
+
var encryptedKey = await _aesKwWrap(kek, cek);
|
|
284
|
+
var rid = _rid(cert, opts.keyIdentifier);
|
|
285
|
+
var kemriKids = [b.integer(0n), rid.node, _algId(oid.name(keyOid), "absent"), b.octetString(kemct), _algId("hkdfWithSha256", "absent"), b.integer(BigInt(kekBytes))];
|
|
286
|
+
if (ukm) kemriKids.push(b.explicit(0, b.octetString(ukm)));
|
|
287
|
+
kemriKids.push(_algId(wrapName, "absent"), b.octetString(encryptedKey));
|
|
288
|
+
var kemri = b.sequence(kemriKids);
|
|
289
|
+
return { tag: 4, node: b.sequence([b.oid(O("kem")), kemri]) };
|
|
290
|
+
} finally {
|
|
291
|
+
// RFC 9629 sec. 7 asks the SENDER to discard the shared secret and KEK once the recipient entry
|
|
292
|
+
// is built -- and to use a fresh secret per recipient, so this runs per call rather than once at
|
|
293
|
+
// the end of a multi-recipient message. In a `finally`, so a wrap or encoding failure does not
|
|
294
|
+
// leave them behind. The CEK is the caller's and is wiped by no one here; kemct is public.
|
|
295
|
+
// kem.sharedKey is the ArrayBuffer the engine returned and ss is this function's copy of it;
|
|
296
|
+
// both hold the secret, so both are cleared. A Uint8Array view aliases the buffer's bytes, so
|
|
297
|
+
// wiping the view wipes the buffer itself. kem.ciphertext is public and stays.
|
|
298
|
+
guard.secret.zeroizeAll([ss, kek, kem.sharedKey ? new Uint8Array(kem.sharedKey) : null, kekAb ? new Uint8Array(kekAb) : null],
|
|
299
|
+
CmsError, "cms/bad-input", "the KEM shared secret");
|
|
300
|
+
}
|
|
287
301
|
}
|
|
288
302
|
|
|
289
303
|
// AES-KW wrap of the CEK under a raw KEK.
|
package/lib/crl-sign.js
CHANGED
|
@@ -478,7 +478,8 @@ function _sign(spec, issuer, opts) {
|
|
|
478
478
|
* empty revocation list omits `revokedCertificates` rather than emitting an empty SEQUENCE (sec. 5.1.2.6);
|
|
479
479
|
* `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime (sec. 5.3.1/5.3.2);
|
|
480
480
|
* per-extension criticality is fixed by the RFC; and the produced signature is verified under the issuer
|
|
481
|
-
* key before return. A violation throws a typed `CrlError
|
|
481
|
+
* key before return. A violation throws a typed `CrlError`; where the spec carries raw DER -- an issuer
|
|
482
|
+
* `Name` Buffer or a pre-encoded `Extension` -- a malformed leaf inside those bytes throws `Asn1Error`.
|
|
482
483
|
*
|
|
483
484
|
* @opts
|
|
484
485
|
* - `pem` (boolean) -- return a PEM `X509 CRL` string instead of DER.
|
package/lib/csr-sign.js
CHANGED
|
@@ -82,7 +82,9 @@ function _challengePassword(pw) {
|
|
|
82
82
|
* to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the
|
|
83
83
|
* request is returned. The signature algorithm is resolved from the subject key (RSA PKCS#1 v1.5 or PSS,
|
|
84
84
|
* ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm). Returns DER, or a PEM `CERTIFICATE REQUEST` with
|
|
85
|
-
* `opts.pem`. Malformed input throws a typed `CsrError
|
|
85
|
+
* `opts.pem`. Malformed input throws a typed `CsrError`; where the spec carries raw DER -- a `Name`
|
|
86
|
+
* Buffer, a pre-encoded requested `Extension` or `Attribute` -- a malformed leaf inside those bytes
|
|
87
|
+
* throws `Asn1Error` instead. Certificate-request parsing is `pki.schema.csr.parse`.
|
|
86
88
|
*
|
|
87
89
|
* @opts
|
|
88
90
|
* - `pem` (boolean) -- return a PEM `CERTIFICATE REQUEST` string instead of DER.
|
package/lib/guard-all.js
CHANGED
|
@@ -59,6 +59,7 @@ var json = require("./guard-json");
|
|
|
59
59
|
var identifier = require("./guard-identifier");
|
|
60
60
|
var header = require("./guard-header");
|
|
61
61
|
var compress = require("./guard-compress");
|
|
62
|
+
var secret = require("./guard-secret");
|
|
62
63
|
|
|
63
64
|
module.exports = {
|
|
64
65
|
bytes: bytes,
|
|
@@ -73,4 +74,5 @@ module.exports = {
|
|
|
73
74
|
identifier: identifier,
|
|
74
75
|
header: header,
|
|
75
76
|
compress: compress,
|
|
77
|
+
secret: secret,
|
|
76
78
|
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- no operator-facing namespace. The documented surface is the KEM
|
|
6
|
+
// key-establishment paths that compose this guard (pki.cms.encrypt / decrypt).
|
|
7
|
+
//
|
|
8
|
+
// guard-secret -- wipe a secret buffer the TOOLKIT ALLOCATED at the moment it
|
|
9
|
+
// stops being needed. NIST SP 800-227 RS5 / sec. 4.2 requires that a KEM shared
|
|
10
|
+
// secret and every intermediate value be destroyed as soon as they are no longer
|
|
11
|
+
// needed; RFC 9629 sec. 7 says the same of the KEK a KEMRecipientInfo derives.
|
|
12
|
+
//
|
|
13
|
+
// The defended class is secret lifetime, not secret disclosure: a shared secret
|
|
14
|
+
// or KEK left readable in the heap widens the window in which a later memory
|
|
15
|
+
// disclosure -- a core dump, a swapped page, a same-process read primitive --
|
|
16
|
+
// yields key material for traffic that was already decrypted.
|
|
17
|
+
//
|
|
18
|
+
// SCOPE, stated honestly because the docstring is the only place a reader learns
|
|
19
|
+
// it: this is BEST EFFORT. The runtime copies buffers into places no JS can
|
|
20
|
+
// reach (node's decapsulate return is copied on the way out; importKey("raw")
|
|
21
|
+
// copies into a KeyObject), and V8 may relocate a backing store, leaving the
|
|
22
|
+
// original bytes behind. Wiping the copies the toolkit holds shortens the
|
|
23
|
+
// window. It does not support a claim that a secret never persists in memory,
|
|
24
|
+
// and no operator-facing text may imply that it does.
|
|
25
|
+
//
|
|
26
|
+
// OWNERSHIP IS THE CONTRACT. Only a buffer the toolkit allocated may be wiped --
|
|
27
|
+
// never a caller's opts.key / opts.cert / opts.kek / opts.password, and never the
|
|
28
|
+
// input DER. Silently destroying a caller's own memory is a worse defect than
|
|
29
|
+
// leaving a secret readable, and it is the failure mode a zeroization patch
|
|
30
|
+
// reaches for first, so the call sites pass only their own intermediates.
|
|
31
|
+
|
|
32
|
+
var bytes = require("./guard-bytes");
|
|
33
|
+
|
|
34
|
+
// zeroize(value, ErrorClass, code, label) -> the same object, cleared.
|
|
35
|
+
// value : a Buffer / TypedArray the TOOLKIT allocated, or null / undefined
|
|
36
|
+
// (absent is a no-op so a `finally` needs no branch around it).
|
|
37
|
+
// ErrorClass : the caller's typed error CONSTRUCTOR, declared with
|
|
38
|
+
// `{ withCause: true }`. The guard family carries two currencies --
|
|
39
|
+
// most guards take a (code, message) factory and call it without
|
|
40
|
+
// `new`, while guard-bytes / guard-header take the class and
|
|
41
|
+
// construct it. This module's ONLY throw is the delegated re-view
|
|
42
|
+
// below, so it must pass what guard-bytes expects: the class, and
|
|
43
|
+
// one that accepts a cause, because guard-bytes threads the raw
|
|
44
|
+
// detach fault through as one. A plain class fails to construct at
|
|
45
|
+
// the single moment the caller needs a real error.
|
|
46
|
+
// code : the frozen domain/reason code a detached buffer rejects under.
|
|
47
|
+
// label : field phrase for the message.
|
|
48
|
+
//
|
|
49
|
+
// A detached ArrayBuffer cannot be written, and reaching one here means a caller
|
|
50
|
+
// handed over memory that was transferred away -- a real fault, not something to
|
|
51
|
+
// swallow, so it routes through the shared re-view guard and throws typed.
|
|
52
|
+
//
|
|
53
|
+
// The `.fill(0)` shape lives ONLY in this module: a wipe re-inlined anywhere in
|
|
54
|
+
// lib/ -- including a module not yet written -- is flagged, so the safe
|
|
55
|
+
// implementation is also the tripwire that stops the next consumer from rolling
|
|
56
|
+
// its own partial one.
|
|
57
|
+
// @enforced-by guard-shape-reinlined
|
|
58
|
+
// @guard-shape \.fill\s*\(\s*0\s*[,)]
|
|
59
|
+
function zeroize(value, ErrorClass, code, label) {
|
|
60
|
+
if (value === null || value === undefined) return value;
|
|
61
|
+
// Re-view through the shared bytes guard: it is the single place that decides
|
|
62
|
+
// what counts as a writable BufferSource and rejects a detached one typed.
|
|
63
|
+
var view = bytes.view(value, ErrorClass, code, label);
|
|
64
|
+
view.fill(0);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// zeroizeAll(list, ErrorClass, code, label) -- wipe every present member, tolerating
|
|
69
|
+
// holes so a `finally` can name intermediates that may not have been reached.
|
|
70
|
+
//
|
|
71
|
+
// @enforced-by behavioral -- this is a loop over zeroize, which carries the family's only
|
|
72
|
+
// rename-proof shape (the `.fill(0)` above). It introduces no shape of its own, so a lexical
|
|
73
|
+
// detector here would anchor on a renameable symbol and go silently green (drift rule sec. 3).
|
|
74
|
+
// The behavioural guards are guard-secret.test.js (holes tolerated, every member cleared) and the
|
|
75
|
+
// CMS vectors that assert the shared secret and KEK are wiped on BOTH the success and failure paths.
|
|
76
|
+
function zeroizeAll(list, ErrorClass, code, label) {
|
|
77
|
+
if (!list) return list;
|
|
78
|
+
for (var i = 0; i < list.length; i++) zeroize(list[i], ErrorClass, code, label);
|
|
79
|
+
return list;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { zeroize: zeroize, zeroizeAll: zeroizeAll };
|
package/lib/inspect.js
CHANGED
|
@@ -46,6 +46,9 @@ function _err(code, message, cause) { return new InspectError(code, message, cau
|
|
|
46
46
|
// caught by the renderer and fall back to a hex dump (inspection is best-effort).
|
|
47
47
|
var NS = pkix.makeNS("inspect", InspectError, oid);
|
|
48
48
|
var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
|
|
49
|
+
// Dispatch on the stable OID, not the display name: pki.oid.register() can override a built-in
|
|
50
|
+
// name, which would silently skip the userNotice rendering and hex-dump the notice instead.
|
|
51
|
+
var OID_UNOTICE = oid.byName("unotice");
|
|
49
52
|
|
|
50
53
|
// ---- formatting helpers ------------------------------------------------------
|
|
51
54
|
|
|
@@ -196,14 +199,17 @@ function _ipString(buf) {
|
|
|
196
199
|
// their structural separators are left as-is, matching OpenSSL (a GeneralName has no
|
|
197
200
|
// RFC 4514-equivalent escaping profile, and escaping a legitimate comma in a URI
|
|
198
201
|
// would misrepresent it).
|
|
202
|
+
// The DN of a decoded directoryName GeneralName value. Shared so every renderer that meets this
|
|
203
|
+
// form -- the SAN/AKI GeneralName path and the AIA/SIA accessLocation path, which carry different
|
|
204
|
+
// decoded shapes -- prints the same DN rather than one of them falling back to a bare tag number.
|
|
205
|
+
function _gnDn(value) {
|
|
206
|
+
return (value && Array.isArray(value.rdns)) ? _dnString(value) : ((value && value.dn) || "");
|
|
207
|
+
}
|
|
199
208
|
function _gn(g) {
|
|
200
209
|
if (!g || typeof g !== "object") return "";
|
|
201
210
|
var t = g.tagNumber;
|
|
202
211
|
if (t === 7 && Buffer.isBuffer(g.value)) return "IP Address:" + _ipString(g.value);
|
|
203
|
-
if (t === 4)
|
|
204
|
-
var dn = (g.value && Array.isArray(g.value.rdns)) ? _dnString(g.value) : ((g.value && g.value.dn) || "");
|
|
205
|
-
return "DirName:" + dn;
|
|
206
|
-
}
|
|
212
|
+
if (t === 4) return "DirName:" + _gnDn(g.value);
|
|
207
213
|
if (t === 0) return "othername:" + (Buffer.isBuffer(g.bytes) ? _hexColon(g.bytes, {}) : "<unsupported>");
|
|
208
214
|
var kind = GN_KIND[t] || ("tag" + t);
|
|
209
215
|
var v = (typeof g.value === "string") ? _clean(g.value)
|
|
@@ -368,6 +374,30 @@ var EXT_RENDERERS = {
|
|
|
368
374
|
var label = null;
|
|
369
375
|
try { label = oid.name(qid); }
|
|
370
376
|
catch (_e) { /* unregistered qualifier */ }
|
|
377
|
+
// A userNotice is a constructed SEQUENCE, so the printable-content test below can never
|
|
378
|
+
// read it and it would hex-dump -- leaving the operator unable to read the very text the
|
|
379
|
+
// qualifier exists to display. Render its DisplayText members through the shared pkix
|
|
380
|
+
// reader (the same one pki.lint measures), so both agree on what the notice says.
|
|
381
|
+
if (qid === OID_UNOTICE) {
|
|
382
|
+
// Render only when EVERY member decoded under its declared string type. A null text means
|
|
383
|
+
// the value did not, and showing the members that happened to decode would present a
|
|
384
|
+
// partial notice as a complete one -- so the whole qualifier falls through to the hex
|
|
385
|
+
// dump, where the operator sees the bytes the certificate actually holds.
|
|
386
|
+
// A null noticeNumbers means the reference did not fully decode, and is refused for the
|
|
387
|
+
// same reason as a null text: a partially decoded notice must not be shown as a whole one.
|
|
388
|
+
var texts = pkix.userNoticeTexts(q);
|
|
389
|
+
if (texts.length && texts.every(function (t) {
|
|
390
|
+
return t.text !== null && (t.field !== "organization" || t.noticeNumbers !== null);
|
|
391
|
+
})) {
|
|
392
|
+
texts.forEach(function (t) {
|
|
393
|
+
// A NoticeReference is identified by organization AND number, so the numbers ride with
|
|
394
|
+
// the organization -- printing the text alone would drop the key that names the notice.
|
|
395
|
+
var nums = (t.noticeNumbers && t.noticeNumbers.length) ? " #" + t.noticeNumbers.join(", ") : "";
|
|
396
|
+
lines.push(inner + " " + (label || qid) + " " + t.field + ": " + _clean(t.text) + nums);
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
371
401
|
var val = (q && !q.constructed && Buffer.isBuffer(q.content) && _printable(q.content))
|
|
372
402
|
? _clean(q.content.toString("latin1"))
|
|
373
403
|
: _hexColon(q && Buffer.isBuffer(q.bytes) ? q.bytes : Buffer.alloc(0), {});
|
|
@@ -397,6 +427,10 @@ var EXT_RENDERERS = {
|
|
|
397
427
|
else if (loc.tag === 2) lv = "DNS:" + loc.value;
|
|
398
428
|
else if (loc.tag === 1) lv = "email:" + loc.value;
|
|
399
429
|
else if (loc.tag === 7) lv = "IP:" + _ipString(loc.value);
|
|
430
|
+
// A directoryName accessLocation carries a decoded Name, so print the DN. Without this it fell
|
|
431
|
+
// to the bracketed-tag fallback and rendered a bare "[4]", hiding the responder/issuer identity
|
|
432
|
+
// the entry exists to convey -- while the same form already printed as DirName elsewhere.
|
|
433
|
+
else if (loc.tag === 4) lv = "DirName:" + _gnDn(loc.value);
|
|
400
434
|
else lv = typeof loc.value === "string" ? loc.value : "[" + loc.tag + "]";
|
|
401
435
|
return inner + (LABEL[m] || m || ad.accessMethod) + " - " + lv;
|
|
402
436
|
}).join("\n");
|
package/lib/lint.js
CHANGED
|
@@ -274,6 +274,58 @@ function _ecCurveName(spki) {
|
|
|
274
274
|
} catch (_e) { return null; } // explicit / invalid EC parameters are not an approved named curve
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// RFC 5280 marks several extensions MUST (error) or SHOULD (warn) be critical. The shape is
|
|
278
|
+
// uniform: applies when the extension is present, fires when its raw `critical` flag is not
|
|
279
|
+
// true. The rule reads `ctx.raw(name).critical` WITHOUT decoding the value -- criticality is
|
|
280
|
+
// a structural property of the extension, independent of its contents.
|
|
281
|
+
// ---- RFC 5280 4.2.1.4 userNotice DisplayText ----
|
|
282
|
+
// certificatePolicies surfaces each policy's qualifiers as RAW bytes (an external verifier may hash
|
|
283
|
+
// them), so the DisplayText values are read out of those bytes here. The UserNotice walk itself lives
|
|
284
|
+
// in schema-pkix (pki.inspect renders the same values), so the two consumers cannot disagree about
|
|
285
|
+
// which members are DisplayText or how a BMPString decodes.
|
|
286
|
+
var OID_UNOTICE = oid.byName("unotice");
|
|
287
|
+
var _T_BMP = asn1.TAGS.BMP_STRING, _T_VISIBLE = asn1.TAGS.VISIBLE_STRING, _T_UTF8 = asn1.TAGS.UTF8_STRING;
|
|
288
|
+
// The C0 and C1 control ranges, tested by code point rather than a regex: the characters this rule is
|
|
289
|
+
// ABOUT cannot appear in the source, and a regex holding them would be a control byte here.
|
|
290
|
+
function _hasControlChar(str) {
|
|
291
|
+
for (var i = 0; i < str.length; i++) {
|
|
292
|
+
var c = str.charCodeAt(i);
|
|
293
|
+
if (c <= 0x1f || (c >= 0x7f && c <= 0x9f)) return true;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
// Every DisplayText in the certificate's certificatePolicies. Both positions are collected --
|
|
298
|
+
// explicitText AND NoticeReference.organization -- because the SIZE bound is on the DisplayText type,
|
|
299
|
+
// so reporting only explicitText would leave its sibling unmeasured.
|
|
300
|
+
// The two decodes below are fail-safe belts on the never-throw data path, not reachable paths: the
|
|
301
|
+
// shared decoder ran pkix.assertPolicyQualifiers before surfacing qualifiersBytes, so the bytes are
|
|
302
|
+
// already known to decode as a SEQUENCE whose every element is a two-member PolicyQualifierInfo led
|
|
303
|
+
// by a readable OID. An undecodable extension never reaches here at all -- it is the separate
|
|
304
|
+
// extension-undecodable finding.
|
|
305
|
+
function _policyDisplayTexts(ctx) {
|
|
306
|
+
var d = ctx.decode("certificatePolicies");
|
|
307
|
+
if (!d || !Array.isArray(d.value)) return [];
|
|
308
|
+
var out = [];
|
|
309
|
+
d.value.forEach(function (pi) {
|
|
310
|
+
if (!pi.qualifiersBytes || !pi.qualifiersBytes.length) return;
|
|
311
|
+
var quals;
|
|
312
|
+
// allow:swallow-unverified re-decoding bytes that already decoded under assertPolicyQualifiers cannot throw
|
|
313
|
+
try { quals = asn1.decode(pi.qualifiersBytes).children; } catch (_e) { return; }
|
|
314
|
+
(quals || []).forEach(function (pq) {
|
|
315
|
+
var qid;
|
|
316
|
+
// allow:swallow-unverified assertPolicyQualifiers already read this OID, so re-reading it cannot throw
|
|
317
|
+
try { qid = asn1.read.oid(pq.children[0]); } catch (_e2) { return; }
|
|
318
|
+
if (qid !== OID_UNOTICE) return;
|
|
319
|
+
// Entries whose contents did not decode arrive with a null `text` and keep their tag: the rules
|
|
320
|
+
// below that read the text skip them, while the encoding rule -- which the ASN.1 tag alone
|
|
321
|
+
// answers -- still sees them.
|
|
322
|
+
out = out.concat(pkix.userNoticeTexts(pq.children[1]));
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
function _hasPolicyDisplayText(cert, ctx) { return _policyDisplayTexts(ctx).length > 0; }
|
|
328
|
+
|
|
277
329
|
// RFC 5280 marks several extensions MUST (error) or SHOULD (warn) be critical. The shape is
|
|
278
330
|
// uniform: applies when the extension is present, fires when its raw `critical` flag is not
|
|
279
331
|
// true. The rule reads `ctx.raw(name).critical` WITHOUT decoding the value -- criticality is
|
|
@@ -413,6 +465,65 @@ var RFC5280_RULES = [
|
|
|
413
465
|
appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !(bc && bc.value && bc.value.cA === true); },
|
|
414
466
|
check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? null : true; },
|
|
415
467
|
},
|
|
468
|
+
// 4.2.1.4 governs the userNotice qualifier's DisplayText with one MUST NOT and three SHOULD-level
|
|
469
|
+
// rules. None of them can live in the decoder: the section closes by directing certificate users to
|
|
470
|
+
// "gracefully handle explicitText with more than 200 characters", so a verifier that rejected these
|
|
471
|
+
// would refuse certificates that are in the wild and otherwise valid. Reporting them is exactly what
|
|
472
|
+
// a linter is for, so each rule carries the severity its normative word does.
|
|
473
|
+
{
|
|
474
|
+
// DisplayText is SIZE (1..200) -- both ends -- but the two ends get SEPARATE ids rather than one
|
|
475
|
+
// length rule, because the section treats them differently: it tells certificate users to handle a
|
|
476
|
+
// notice ABOVE 200 gracefully, and says nothing of the sort about an empty one. An operator acting
|
|
477
|
+
// on that advice suppresses the over-long finding; folding both into a single id would silently
|
|
478
|
+
// suppress the empty case along with it, which has no such carve-out.
|
|
479
|
+
id: "lint/rfc5280/explicit-text-too-long", severity: "warn", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
|
|
480
|
+
message: "a userNotice DisplayText should not exceed 200 characters",
|
|
481
|
+
appliesTo: _hasPolicyDisplayText,
|
|
482
|
+
check: function (cert, ctx) {
|
|
483
|
+
var over = _policyDisplayTexts(ctx).filter(function (d) { return d.text !== null && d.chars > pkix.DISPLAY_TEXT_MAX; });
|
|
484
|
+
return over.length ? { context: { count: over.length, longest: Math.max.apply(null, over.map(function (d) { return d.chars; })) } } : null;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: "lint/rfc5280/explicit-text-empty", severity: "warn", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
|
|
489
|
+
message: "a userNotice DisplayText must not be empty (SIZE (1..200))",
|
|
490
|
+
appliesTo: _hasPolicyDisplayText,
|
|
491
|
+
check: function (cert, ctx) {
|
|
492
|
+
var empty = _policyDisplayTexts(ctx).filter(function (d) { return d.text !== null && d.chars < 1; });
|
|
493
|
+
return empty.length ? { context: { count: empty.length } } : null;
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: "lint/rfc5280/explicit-text-bad-encoding", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
|
|
498
|
+
message: "conforming CAs must not encode explicitText as VisibleString or BMPString",
|
|
499
|
+
appliesTo: _hasPolicyDisplayText,
|
|
500
|
+
check: function (cert, ctx) {
|
|
501
|
+
var bad = _policyDisplayTexts(ctx).filter(function (d) { return d.field === "explicitText" && (d.tagNumber === _T_VISIBLE || d.tagNumber === _T_BMP); });
|
|
502
|
+
return bad.length ? { context: { count: bad.length, encoding: bad[0].tagNumber === _T_BMP ? "BMPString" : "VisibleString" } } : null;
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: "lint/rfc5280/explicit-text-control-chars", severity: "warn", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
|
|
507
|
+
message: "an explicitText should not include control characters (U+0000 to U+001F, U+007F to U+009F)",
|
|
508
|
+
appliesTo: _hasPolicyDisplayText,
|
|
509
|
+
check: function (cert, ctx) {
|
|
510
|
+
var bad = _policyDisplayTexts(ctx).filter(function (d) { return d.field === "explicitText" && d.text !== null && _hasControlChar(d.text); });
|
|
511
|
+
return bad.length ? { context: { count: bad.length } } : null;
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
id: "lint/rfc5280/explicit-text-not-nfc", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
|
|
516
|
+
message: "a UTF8String explicitText should be normalized to Unicode normalization form C (NFC)",
|
|
517
|
+
appliesTo: _hasPolicyDisplayText,
|
|
518
|
+
check: function (cert, ctx) {
|
|
519
|
+
// Only the utf8String arm carries the NFC recommendation; the other arms cannot express the
|
|
520
|
+
// combining sequences the rule is about.
|
|
521
|
+
var bad = _policyDisplayTexts(ctx).filter(function (d) {
|
|
522
|
+
return d.field === "explicitText" && d.text !== null && d.tagNumber === _T_UTF8 && d.text.normalize("NFC") !== d.text;
|
|
523
|
+
});
|
|
524
|
+
return bad.length ? { context: { count: bad.length } } : null;
|
|
525
|
+
},
|
|
526
|
+
},
|
|
416
527
|
];
|
|
417
528
|
|
|
418
529
|
function _isTls(cert, ctx) { return ctx.isTlsServerCert; }
|
|
@@ -517,8 +628,10 @@ var CABF_TLS_RULES = [
|
|
|
517
628
|
|
|
518
629
|
// RFC 9935 -- ML-KEM public keys in X.509 certificates. The OID is the sole authority for the
|
|
519
630
|
// parameter set; the SPKI BIT STRING is the raw ek, exactly 384k+32 octets for that OID.
|
|
631
|
+
// Encapsulation-key lengths come from the shared ML-KEM parameter registry (FIPS 203 Table 3),
|
|
632
|
+
// keyed by NAME because an SPKI surfaces its algorithm by name on this path.
|
|
520
633
|
var ML_KEM_EK_LEN = {};
|
|
521
|
-
["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n
|
|
634
|
+
["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) { ML_KEM_EK_LEN[n] = oid.kemParams(n).ek; });
|
|
522
635
|
function _isMlKem(cert) {
|
|
523
636
|
var spki = cert.subjectPublicKeyInfo;
|
|
524
637
|
return !!(spki && spki.algorithm && ML_KEM_EK_LEN[spki.algorithm.name] !== undefined);
|
package/lib/oid.js
CHANGED
|
@@ -693,11 +693,46 @@ function paramsMustBeAbsent(dotted) {
|
|
|
693
693
|
return _PARAMS_ABSENT.has(dotted);
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
// ---- ML-KEM parameter sets (FIPS 203 Table 3) --------------------------------
|
|
697
|
+
//
|
|
698
|
+
// ONE row per parameter set, resolvable by dotted OID or by registered name, because the
|
|
699
|
+
// consumers hold different currencies: the CMS codec and the decrypt path key by OID, the
|
|
700
|
+
// linter keys by the SPKI algorithm's name. Before this table the same FIPS 203 constants
|
|
701
|
+
// lived in three modules -- schema-cms's ciphertext lengths, webcrypto's {ek, dk}, and
|
|
702
|
+
// lint's encapsulation-key lengths -- so a fourth consumer (a composite KEM, an HPKE row,
|
|
703
|
+
// HQC by analogy) meant a fourth copy, and the ek values were already duplicated verbatim
|
|
704
|
+
// in two of them. A parameter set is a property OF the algorithm identifier, so it lives
|
|
705
|
+
// beside the registry that resolves one rather than in whichever module needed it first.
|
|
706
|
+
//
|
|
707
|
+
// ek : encapsulation-key octets dk : decapsulation-key octets
|
|
708
|
+
// ct : ciphertext octets ss : shared-secret octets
|
|
709
|
+
// A null-prototype table: a plain object would answer kemParams("toString") with a function
|
|
710
|
+
// inherited from Object.prototype, so the documented fail-closed contract would hold for every
|
|
711
|
+
// input EXCEPT the handful every object already carries -- exactly the ones an untrusted
|
|
712
|
+
// identifier might be.
|
|
713
|
+
var KEM_PARAMS = Object.create(null);
|
|
714
|
+
[["id-ml-kem-512", 800, 1632, 768, 32],
|
|
715
|
+
["id-ml-kem-768", 1184, 2400, 1088, 32],
|
|
716
|
+
["id-ml-kem-1024", 1568, 3168, 1568, 32]].forEach(function (r) {
|
|
717
|
+
// FROZEN, because these rows are reachable from the public surface AND one of them now governs a
|
|
718
|
+
// security check: the engine reads .ct to enforce the FIPS 203 sec. 7.3 ciphertext length. A shared
|
|
719
|
+
// mutable row would let any code in the process rewrite that bound once, for every later call --
|
|
720
|
+
// a parameter set is a fact about the algorithm, not a setting an application may retune.
|
|
721
|
+
var row = Object.freeze({ ek: r[1], dk: r[2], ct: r[3], ss: r[4] });
|
|
722
|
+
KEM_PARAMS[byName(r[0])] = row; // by dotted OID
|
|
723
|
+
KEM_PARAMS[r[0]] = row; // and by registered name -- the same frozen row
|
|
724
|
+
});
|
|
725
|
+
Object.freeze(KEM_PARAMS);
|
|
726
|
+
// kemParams(oidOrName) -> the row, or undefined for anything that is not an ML-KEM
|
|
727
|
+
// parameter set. Undefined is the caller's signal to fail closed; it never guesses a size.
|
|
728
|
+
function kemParams(oidOrName) { return KEM_PARAMS[oidOrName]; }
|
|
729
|
+
|
|
696
730
|
module.exports = {
|
|
697
731
|
name: name,
|
|
698
732
|
byName: byName,
|
|
699
733
|
has: has,
|
|
700
734
|
paramsMustBeAbsent: paramsMustBeAbsent,
|
|
735
|
+
kemParams: kemParams,
|
|
701
736
|
register: register,
|
|
702
737
|
registerFamily: registerFamily,
|
|
703
738
|
all: all,
|
package/lib/pbes2.js
CHANGED
|
@@ -26,8 +26,15 @@ var PRF_NODE_BY_NAME = { hmacWithSHA1: "sha1", hmacWithSHA256: "sha256", hmacWit
|
|
|
26
26
|
var PRF_NODE_BY_OID = {}; Object.keys(PRF_NODE_BY_NAME).forEach(function (n) { PRF_NODE_BY_OID[O(n)] = PRF_NODE_BY_NAME[n]; });
|
|
27
27
|
|
|
28
28
|
// content-encryption OID -> AES key bits (CBC + GCM). The PBES2 encryptionScheme + CMS content cipher table.
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// CONTENT_MODE rides the SAME rows so a cipher can never be present for its key length but absent for its
|
|
30
|
+
// mode: key length alone does not distinguish an AEAD cipher from a plain one, and a consumer that resolves
|
|
31
|
+
// only the length would open content in the wrong mode whenever the two happen to share a key size.
|
|
32
|
+
var CONTENT_KEYBITS = {}, CONTENT_MODE = {};
|
|
33
|
+
[["aes128-CBC", 128, "cbc"], ["aes192-CBC", 192, "cbc"], ["aes256-CBC", 256, "cbc"],
|
|
34
|
+
["aes128-GCM", 128, "gcm"], ["aes192-GCM", 192, "gcm"], ["aes256-GCM", 256, "gcm"]].forEach(function (r) {
|
|
35
|
+
CONTENT_KEYBITS[O(r[0])] = r[1];
|
|
36
|
+
CONTENT_MODE[O(r[0])] = r[2];
|
|
37
|
+
});
|
|
31
38
|
|
|
32
39
|
// A password is an octet string (RFC 8018 sec. 2): a string is UTF-8-encoded deterministically (correct for
|
|
33
40
|
// non-ASCII, and byte-identical to OpenSSL), a Buffer/Uint8Array used verbatim.
|
|
@@ -211,6 +218,6 @@ module.exports = {
|
|
|
211
218
|
prfNodeByName: prfNodeByName, prfNodeByOid: prfNodeByOid,
|
|
212
219
|
pbkdf2ParamsSeq: pbkdf2ParamsSeq, pbes2AlgId: pbes2AlgId, parsePbkdf2Params: parsePbkdf2Params,
|
|
213
220
|
requireChildren: requireChildren, seqChildren: seqChildren,
|
|
214
|
-
cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, pbes2Encrypt: pbes2Encrypt, pbes2Decrypt: pbes2Decrypt, CONTENT_KEYBITS: CONTENT_KEYBITS,
|
|
221
|
+
cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, pbes2Encrypt: pbes2Encrypt, pbes2Decrypt: pbes2Decrypt, CONTENT_KEYBITS: CONTENT_KEYBITS, CONTENT_MODE: CONTENT_MODE,
|
|
215
222
|
pbmac1AlgId: pbmac1AlgId, pbmac1: pbmac1,
|
|
216
223
|
};
|
package/lib/schema-c509.js
CHANGED
|
@@ -1031,7 +1031,11 @@ function _qualifierToDer(qidNode, qtextNode) {
|
|
|
1031
1031
|
if (qtextNode.majorType !== 3) throw _err("c509/bad-extensions", "a policyQualifier value must be a CBOR text string");
|
|
1032
1032
|
var text = cbor.read.textString(qtextNode);
|
|
1033
1033
|
if (qi === 1) return b.sequence([b.oid(oid.byName("cps")), _ia5Universal(text, "a CPSuri")]); // CPSuri ::= IA5String
|
|
1034
|
-
// id-qt-unotice: UserNotice ::= SEQUENCE { explicitText utf8String } -- noticeRef omitted
|
|
1034
|
+
// id-qt-unotice: UserNotice ::= SEQUENCE { explicitText utf8String } -- noticeRef omitted. Only the SIZE
|
|
1035
|
+
// (1..200) FLOOR is enforced: an empty explicitText is a degenerate value no encoder can have produced,
|
|
1036
|
+
// while RFC 5280 sec. 4.2.1.4 directs certificate users to gracefully handle a notice ABOVE 200
|
|
1037
|
+
// characters, and draft-20 sec. 3.3's compact predicate is SIZE-silent -- so an over-long notice
|
|
1038
|
+
// transcodes rather than being refused.
|
|
1035
1039
|
if (text.length === 0) throw _err("c509/bad-extensions", "a UserNotice explicitText must be non-empty (DisplayText SIZE 1..200)");
|
|
1036
1040
|
return b.sequence([b.oid(oid.byName("unotice")), b.sequence([b.utf8(text)])]);
|
|
1037
1041
|
}
|
package/lib/schema-cms.js
CHANGED
|
@@ -106,10 +106,10 @@ WRAP_KEK_LENGTHS[oid.byName("aes256-wrap")] = 32;
|
|
|
106
106
|
// ML-KEM OID -> the exact ciphertext (kemct) length in octets (FIPS 203). A
|
|
107
107
|
// recognized ML-KEM kem carries a fixed-size ciphertext; any other length can
|
|
108
108
|
// never decapsulate. (The params-absent rule rides the shared oid registry.)
|
|
109
|
+
// Ciphertext lengths come from the shared ML-KEM parameter registry (FIPS 203 Table 3) rather
|
|
110
|
+
// than a local copy, so this codec and the crypto engine cannot disagree about a parameter set.
|
|
109
111
|
var KEM_CT_LENGTHS = {};
|
|
110
|
-
|
|
111
|
-
KEM_CT_LENGTHS[oid.byName("id-ml-kem-768")] = 1088;
|
|
112
|
-
KEM_CT_LENGTHS[oid.byName("id-ml-kem-1024")] = 1568;
|
|
112
|
+
["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) { KEM_CT_LENGTHS[oid.byName(n)] = oid.kemParams(n).ct; });
|
|
113
113
|
|
|
114
114
|
// Recognized AEAD content-encryption OIDs -> the AES-GCM/CCM parameter shape + the
|
|
115
115
|
// legal ICVlen set (RFC 5084). An unrecognized content-encryption OID surfaces its
|
package/lib/schema-pkix.js
CHANGED
|
@@ -703,6 +703,108 @@ function assertPolicyQualifiers(qNode, fail) {
|
|
|
703
703
|
});
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
+
// DisplayText ::= CHOICE { ia5String, visibleString, bmpString, utf8String } each SIZE (1..200)
|
|
707
|
+
// -- RFC 5280 sec. 4.2.1.4. The bound is deliberately NOT enforced here, and that is a normative
|
|
708
|
+
// requirement rather than a gap: the same section closes with "While the explicitText has a maximum
|
|
709
|
+
// size of 200 characters, some non-conforming CAs exceed this limit. Therefore, certificate users
|
|
710
|
+
// SHOULD gracefully handle explicitText with more than 200 characters." A decoder IS the certificate
|
|
711
|
+
// user that note addresses, so refusing an over-long notice would violate a SHOULD and reject
|
|
712
|
+
// certificates that are in the wild and otherwise valid. The bound belongs to the two layers that
|
|
713
|
+
// can act on it: an issuer, which must not MINT one, and pki.lint, which reports it as an advisory.
|
|
714
|
+
//
|
|
715
|
+
// Measured in CHARACTERS, not octets: a conforming 200-character UTF8String notice can occupy 800
|
|
716
|
+
// octets, so `.length` (UTF-16 units) or a byte count would misjudge a valid value. Exported so
|
|
717
|
+
// every layer that reports the bound counts it identically instead of keeping its own copy.
|
|
718
|
+
var DISPLAY_TEXT_MAX = 200;
|
|
719
|
+
function displayTextChars(str) { return Array.from(str).length; }
|
|
720
|
+
|
|
721
|
+
// @internal
|
|
722
|
+
// userNoticeTexts(qualifier) -- the DisplayText values of a decoded UserNotice qualifier, as
|
|
723
|
+
// [{ field, tagNumber, text, chars }] in encounter order. `field` is "explicitText" or
|
|
724
|
+
// "organization" (the NoticeReference member), so a caller that must treat the two alike (the
|
|
725
|
+
// SIZE bound applies to both) and one that must not (only explicitText carries the encoding and
|
|
726
|
+
// normalization rules) can each select correctly.
|
|
727
|
+
//
|
|
728
|
+
// UserNotice ::= SEQUENCE { noticeRef NoticeReference OPTIONAL, explicitText DisplayText OPTIONAL }
|
|
729
|
+
// NoticeReference ::= SEQUENCE { organization DisplayText, noticeNumbers SEQUENCE OF INTEGER }
|
|
730
|
+
// Both members are OPTIONAL and distinguished by tag, so a leading SEQUENCE is the noticeRef and any
|
|
731
|
+
// DisplayText member is the explicitText. Returns [] for a qualifier that is not a UserNotice --
|
|
732
|
+
// this reads an already-decoded node for display and reporting, and is never a validity verdict.
|
|
733
|
+
var _DISPLAY_TEXT_TAGS = null;
|
|
734
|
+
function _isDisplayTextNode(n) {
|
|
735
|
+
if (!_DISPLAY_TEXT_TAGS) {
|
|
736
|
+
_DISPLAY_TEXT_TAGS = {};
|
|
737
|
+
_DISPLAY_TEXT_TAGS[_T.IA5_STRING] = 1; _DISPLAY_TEXT_TAGS[_T.VISIBLE_STRING] = 1;
|
|
738
|
+
_DISPLAY_TEXT_TAGS[_T.BMP_STRING] = 1; _DISPLAY_TEXT_TAGS[_T.UTF8_STRING] = 1;
|
|
739
|
+
}
|
|
740
|
+
return !!n && n.tagClass === "universal" && _DISPLAY_TEXT_TAGS[n.tagNumber] === 1;
|
|
741
|
+
}
|
|
742
|
+
// Decoded through the STRICT reader, which validates each arm against its own declared string type
|
|
743
|
+
// (invalid UTF-8, a high bit in an IA5String, an odd-length or lone-surrogate BMPString all throw).
|
|
744
|
+
// Reading the content bytes directly would repair the value instead: `toString("utf8")` substitutes
|
|
745
|
+
// U+FFFD for invalid sequences and a hand-rolled UCS-2 loop drops a trailing odd octet, so a caller
|
|
746
|
+
// would render or measure text the certificate does not contain. Returns null when the value does
|
|
747
|
+
// not decode, so the caller can take its own fallback rather than trust a repair.
|
|
748
|
+
// An undecodable value yields an entry whose `text` and `chars` are null but whose `field` and
|
|
749
|
+
// `tagNumber` are still present. The two are separable facts: which ASN.1 string type was used is
|
|
750
|
+
// established by the tag alone, so a rule about the ENCODING stays answerable even when the contents
|
|
751
|
+
// do not decode, while every rule that reads the TEXT must skip the entry rather than analyze a
|
|
752
|
+
// repair. Collapsing both into "not analyzable" would let a prohibited encoding escape its finding
|
|
753
|
+
// by also being malformed inside.
|
|
754
|
+
function _dtEntry(field, node) {
|
|
755
|
+
var text;
|
|
756
|
+
// allow:swallow-unverified an undecodable DisplayText keeps its tag and drops its text; the callers branch on text === null
|
|
757
|
+
try { text = asn1.read.string(node); } catch (_e) { text = null; }
|
|
758
|
+
return { field: field, tagNumber: node.tagNumber, text: text, chars: text === null ? null : displayTextChars(text) };
|
|
759
|
+
}
|
|
760
|
+
// A NoticeReference names its notice by ORGANIZATION **and** NUMBER -- the organization alone does not
|
|
761
|
+
// identify which notice is meant, so a consumer that surfaced only the text would lose the lookup key.
|
|
762
|
+
// Returns null when the numbers are absent, wrongly shaped, or contain a member that is not a readable
|
|
763
|
+
// INTEGER: an incompletely decoded reference must NOT be presentable, because dropping the members that
|
|
764
|
+
// failed would render a partial reference indistinguishable from a whole one -- the same defect as
|
|
765
|
+
// omitting the numbers entirely, in a form that is harder to notice. Numbers are decimal strings.
|
|
766
|
+
function _noticeNumbers(node) {
|
|
767
|
+
if (!node || node.tagClass !== "universal" || node.tagNumber !== _T.SEQUENCE || !node.children) return null;
|
|
768
|
+
var nums = [], ok = true;
|
|
769
|
+
node.children.forEach(function (n) {
|
|
770
|
+
// allow:swallow-unverified a non-INTEGER member makes the whole reference undecodable; the caller falls back rather than rendering part of it
|
|
771
|
+
try { nums.push(String(asn1.read.integer(n))); } catch (_e) { ok = false; }
|
|
772
|
+
});
|
|
773
|
+
return ok ? nums : null;
|
|
774
|
+
}
|
|
775
|
+
// Every DisplayText member is returned, including one whose contents did not decode -- that entry
|
|
776
|
+
// carries its tag with a null `text`. A caller that RENDERS the notice must check that every entry
|
|
777
|
+
// decoded before showing any of it (a partial notice is indistinguishable from a complete one); a
|
|
778
|
+
// caller that only classifies the encoding can read the tags regardless.
|
|
779
|
+
// The LAYOUT is validated before any member is read: UserNotice fixes the order (noticeRef first) and
|
|
780
|
+
// the cardinality (each member at most once, nothing else present). Collecting every recognized member
|
|
781
|
+
// and ignoring the rest would accept a duplicated explicitText, a reversed order, or an extra member,
|
|
782
|
+
// and the caller -- which can only see whether the entries it got look complete -- would then present
|
|
783
|
+
// a structurally invalid notice as a whole one. A malformed layout yields NO entries, so a renderer
|
|
784
|
+
// falls back to hex and a reporter measures nothing.
|
|
785
|
+
function userNoticeTexts(qualifier) {
|
|
786
|
+
if (!qualifier || qualifier.tagClass !== "universal" || qualifier.tagNumber !== _T.SEQUENCE || !qualifier.children) return [];
|
|
787
|
+
var kids = qualifier.children;
|
|
788
|
+
if (kids.length > 2) return [];
|
|
789
|
+
var i = 0, out = [];
|
|
790
|
+
// noticeRef [absent | first]: a SEQUENCE of { organization DisplayText, noticeNumbers }.
|
|
791
|
+
if (i < kids.length && kids[i].tagClass === "universal" && kids[i].tagNumber === _T.SEQUENCE) {
|
|
792
|
+
var nr = kids[i];
|
|
793
|
+
if (!nr.children || nr.children.length !== 2 || !_isDisplayTextNode(nr.children[0])) return [];
|
|
794
|
+
var org = _dtEntry("organization", nr.children[0]);
|
|
795
|
+
org.noticeNumbers = _noticeNumbers(nr.children[1]);
|
|
796
|
+
out.push(org);
|
|
797
|
+
i++;
|
|
798
|
+
}
|
|
799
|
+
// explicitText [absent | last]: a DisplayText. Anything else remaining is not a UserNotice member.
|
|
800
|
+
if (i < kids.length) {
|
|
801
|
+
if (!_isDisplayTextNode(kids[i])) return [];
|
|
802
|
+
out.push(_dtEntry("explicitText", kids[i]));
|
|
803
|
+
i++;
|
|
804
|
+
}
|
|
805
|
+
return i === kids.length ? out : [];
|
|
806
|
+
}
|
|
807
|
+
|
|
706
808
|
// Shared imperative decode helpers for the certExtensionDecoders + attrValueDecoders factories. Both
|
|
707
809
|
// take the caller's ns, so the helpers close over ns.E / ns.oid; extracted once so the two factories
|
|
708
810
|
// share one copy (a per-OID value-decoder body is the identical idiom to a per-OID extension body).
|
|
@@ -1468,6 +1570,9 @@ module.exports = {
|
|
|
1468
1570
|
signedEnvelopeTbs: signedEnvelopeTbs,
|
|
1469
1571
|
rootSequenceChildren: rootSequenceChildren,
|
|
1470
1572
|
assertPolicyQualifiers: assertPolicyQualifiers,
|
|
1573
|
+
DISPLAY_TEXT_MAX: DISPLAY_TEXT_MAX,
|
|
1574
|
+
displayTextChars: displayTextChars,
|
|
1575
|
+
userNoticeTexts: userNoticeTexts,
|
|
1471
1576
|
signedEnvelope: signedEnvelope,
|
|
1472
1577
|
attrValueToString: attrValueToString,
|
|
1473
1578
|
attributeTypeAndValue: attributeTypeAndValue,
|
package/lib/webcrypto.js
CHANGED
|
@@ -608,8 +608,16 @@ async function _deriveBitsRaw(alg, key, length) {
|
|
|
608
608
|
}
|
|
609
609
|
if (name === "HKDF") {
|
|
610
610
|
_requireDeriveLength(length, "HKDF");
|
|
611
|
-
|
|
612
|
-
|
|
611
|
+
// _secretBytes EXPORTS the key material into a fresh Buffer this module owns -- for a KEM flow
|
|
612
|
+
// that Buffer is the shared secret itself. It is a controllable allocation, not one of the
|
|
613
|
+
// runtime-internal copies the best-effort caveat covers, so it is wiped once HKDF has consumed it.
|
|
614
|
+
var ikm = _secretBytes(key);
|
|
615
|
+
try {
|
|
616
|
+
var derived = nodeCrypto.hkdfSync(_hashNode(alg.hash, "HKDF"), ikm, _toBuf(alg.salt, "HKDF salt"), _toBuf(alg.info || Buffer.alloc(0), "HKDF info"), length / 8);
|
|
617
|
+
return derived instanceof ArrayBuffer ? derived : _toArrayBuffer(Buffer.from(derived));
|
|
618
|
+
} finally {
|
|
619
|
+
guard.secret.zeroize(ikm, WebCryptoError, "webcrypto/operation", "the HKDF input key material");
|
|
620
|
+
}
|
|
613
621
|
}
|
|
614
622
|
if (name === "PBKDF2") {
|
|
615
623
|
_requireDeriveLength(length, "PBKDF2");
|
|
@@ -701,7 +709,18 @@ SubtleCrypto.prototype.encapsulateBits = async function encapsulateBits(algorith
|
|
|
701
709
|
var r;
|
|
702
710
|
try { r = nodeCrypto.encapsulate(encapsulationKey._handle); }
|
|
703
711
|
catch (e) { throw new WebCryptoError("webcrypto/operation", "encapsulateBits: ML-KEM encapsulation failed", e); }
|
|
704
|
-
|
|
712
|
+
try {
|
|
713
|
+
// _toArrayBuffer copies via ArrayBuffer.slice, so the shared key is passed straight through --
|
|
714
|
+
// an intermediate Buffer.from would be another copy of the secret that nothing wipes. The
|
|
715
|
+
// ciphertext is public and needs no such care, but it is copied the same way for symmetry.
|
|
716
|
+
return { sharedKey: _toArrayBuffer(r.sharedKey), ciphertext: _toArrayBuffer(r.ciphertext) };
|
|
717
|
+
} finally {
|
|
718
|
+
// Encapsulation produces a shared secret exactly as decapsulation does, so it owes the same
|
|
719
|
+
// duty: the provider's buffer is wiped once the caller's copy exists (NIST SP 800-227 RS5 /
|
|
720
|
+
// sec. 4.2, RFC 9629 sec. 7). Wiping only the decapsulation side would make the guarantee a
|
|
721
|
+
// half-truth -- the sender holds the same secret the recipient does.
|
|
722
|
+
guard.secret.zeroize(r.sharedKey, WebCryptoError, "webcrypto/operation", "the KEM shared secret");
|
|
723
|
+
}
|
|
705
724
|
};
|
|
706
725
|
|
|
707
726
|
SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorithm, decapsulationKey, ciphertext) {
|
|
@@ -711,10 +730,39 @@ SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorith
|
|
|
711
730
|
_requireAlgMatch(alg, decapsulationKey, "decapsulateBits");
|
|
712
731
|
if (decapsulationKey.type !== "private") throw new WebCryptoError("webcrypto/invalid-access", "decapsulateBits requires a private (decapsulation) key, got " + JSON.stringify(decapsulationKey.type));
|
|
713
732
|
var ct = _toBuf(ciphertext, "decapsulateBits ciphertext");
|
|
733
|
+
// FIPS 203 sec. 7.3 makes the ciphertext-length check the ONE per-execution input check a
|
|
734
|
+
// decapsulating party owes, so it belongs here, at the engine boundary, rather than only in the
|
|
735
|
+
// format module that happens to call this today: a direct caller -- or a future composite-KEM or
|
|
736
|
+
// HPKE consumer -- inherits nothing from a check that lives in cms-decrypt. A distinct code names
|
|
737
|
+
// the real reason, which "the operation failed" cannot.
|
|
738
|
+
//
|
|
739
|
+
// Length ONLY. A correct-length ciphertext that has been tampered with must still resolve to a
|
|
740
|
+
// pseudo-random shared secret (the Fujisaki-Okamoto implicit rejection of FIPS 203 sec. 6.3);
|
|
741
|
+
// turning that into a throw would hand an attacker a decryption oracle, and it is the property
|
|
742
|
+
// the CMS uniform verdict is built on.
|
|
743
|
+
// The registry is keyed by the registered OID name ("id-ml-kem-768"), which is the node
|
|
744
|
+
// algorithm name this module already maps ("ml-kem-768") under its id- prefix.
|
|
745
|
+
var kemRow = oid.kemParams("id-" + ML_KEM_NODE[alg.name]);
|
|
746
|
+
if (kemRow && ct.length !== kemRow.ct) {
|
|
747
|
+
throw new WebCryptoError("webcrypto/bad-kem-ciphertext",
|
|
748
|
+
"decapsulateBits: " + alg.name + " expects a " + kemRow.ct + "-octet ciphertext, got " + ct.length + " (FIPS 203 sec. 7.3)");
|
|
749
|
+
}
|
|
714
750
|
var ss;
|
|
715
751
|
try { ss = nodeCrypto.decapsulate(decapsulationKey._handle, ct); }
|
|
716
752
|
catch (e) { throw new WebCryptoError("webcrypto/operation", "decapsulateBits: ML-KEM decapsulation failed (malformed or wrong-length ciphertext)", e); }
|
|
717
|
-
|
|
753
|
+
try {
|
|
754
|
+
// ss is already a Buffer, and _toArrayBuffer copies via ArrayBuffer.slice -- so it is passed
|
|
755
|
+
// straight through. An intermediate Buffer.from(ss) would be a THIRD copy of the secret that
|
|
756
|
+
// nothing wipes, which would give back most of what the wipe below is for.
|
|
757
|
+
return _toArrayBuffer(ss);
|
|
758
|
+
} finally {
|
|
759
|
+
// The shared secret is returned as a COPY, so the buffer the provider handed back would stay
|
|
760
|
+
// readable until collection -- and a caller wiping only what it receives would leave the
|
|
761
|
+
// original behind, which is the whole secret. Wiping here means the engine owns the lifetime of
|
|
762
|
+
// the buffer it allocated, and every caller (CMS today, a composite KEM or HPKE later) inherits
|
|
763
|
+
// it rather than each having to remember (NIST SP 800-227 RS5 / sec. 4.2).
|
|
764
|
+
guard.secret.zeroize(ss, WebCryptoError, "webcrypto/operation", "the KEM shared secret");
|
|
765
|
+
}
|
|
718
766
|
};
|
|
719
767
|
|
|
720
768
|
SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey, wrapAlgorithm) {
|
|
@@ -731,10 +779,16 @@ SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey
|
|
|
731
779
|
if (bytes.length < 16 || bytes.length % 8 !== 0) {
|
|
732
780
|
throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW requires the serialized key be a multiple of 8 bytes (>= 16); got " + bytes.length + " -- format " + JSON.stringify(format) + " is not AES-KW-wrappable");
|
|
733
781
|
}
|
|
782
|
+
// The mirror of unwrapKey below: in a KEM flow this export is the SENDER's copy of the same
|
|
783
|
+
// key-encryption key, so leaving it unwiped would keep a full copy of the KEK alive for the
|
|
784
|
+
// process lifetime and make the wipes the CMS layer performs pointless in the encrypt direction.
|
|
785
|
+
var wkBytes = null;
|
|
734
786
|
try {
|
|
735
|
-
|
|
787
|
+
wkBytes = _secretBytes(wrappingKey);
|
|
788
|
+
var c = nodeCrypto.createCipheriv("aes" + wrappingKey.algorithm.length + "-wrap", wkBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
|
|
736
789
|
return _toArrayBuffer(Buffer.concat([c.update(bytes), c.final()]));
|
|
737
790
|
} catch (e) { throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW key wrap failed", e); }
|
|
791
|
+
finally { guard.secret.zeroize(wkBytes, WebCryptoError, "webcrypto/operation", "the AES-KW wrapping key"); }
|
|
738
792
|
}
|
|
739
793
|
// Delegate to a content-encryption algorithm (RSA-OAEP / AES-GCM).
|
|
740
794
|
var wrapKeyClone = _cloneWithUsage(wrappingKey, "encrypt");
|
|
@@ -760,10 +814,19 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
|
|
|
760
814
|
if (wrapped.length < 24 || wrapped.length % 8 !== 0) {
|
|
761
815
|
throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW wrapped key must be a multiple of 8 bytes (>= 24); got " + wrapped.length);
|
|
762
816
|
}
|
|
817
|
+
// The exported wrapping key is a Buffer this module owns; in a KEM flow it is the KEK derived
|
|
818
|
+
// from the shared secret, so it is wiped once the unwrap has consumed it -- on the failing path
|
|
819
|
+
// too, which is the one an attacker induces by tampering with the wrapped key.
|
|
820
|
+
// Declared outside so the finally can reach it, but EXPORTED INSIDE the try: a key whose handle
|
|
821
|
+
// cannot be exported must still surface the typed verdict this branch promises, not a raw node
|
|
822
|
+
// TypeError -- and a non-PkiError throw would also break the fuzz-harness contract.
|
|
823
|
+
var kwBytes = null;
|
|
763
824
|
try {
|
|
764
|
-
|
|
825
|
+
kwBytes = _secretBytes(unwrappingKey);
|
|
826
|
+
var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", kwBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
|
|
765
827
|
bytes = Buffer.concat([d.update(wrapped), d.final()]);
|
|
766
828
|
} catch (e) { throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW key unwrap failed (integrity or length)", e); }
|
|
829
|
+
finally { guard.secret.zeroize(kwBytes, WebCryptoError, "webcrypto/operation", "the AES-KW unwrapping key"); }
|
|
767
830
|
} else {
|
|
768
831
|
var unwrapKeyClone = _cloneWithUsage(unwrappingKey, "decrypt");
|
|
769
832
|
bytes = Buffer.from(await this.decrypt(unwrapAlgorithm, unwrapKeyClone, wrappedKey));
|
|
@@ -783,7 +846,14 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
|
|
|
783
846
|
} else {
|
|
784
847
|
keyData = bytes;
|
|
785
848
|
}
|
|
786
|
-
|
|
849
|
+
try {
|
|
850
|
+
return await this.importKey(format, keyData, unwrappedKeyAlgorithm, extractable, keyUsages);
|
|
851
|
+
} finally {
|
|
852
|
+
// `bytes` is the UNWRAPPED key in plaintext -- a module-owned buffer, and the last plaintext
|
|
853
|
+
// copy this layer controls once importKey has taken its own. Clearing the caller-visible copy
|
|
854
|
+
// downstream while leaving this one live would make that wipe ceremonial.
|
|
855
|
+
guard.secret.zeroize(bytes, WebCryptoError, "webcrypto/operation", "the unwrapped key material");
|
|
856
|
+
}
|
|
787
857
|
};
|
|
788
858
|
|
|
789
859
|
function _cloneWithUsage(key, usage) {
|
|
@@ -819,10 +889,13 @@ function _nodeKey(fn, who) {
|
|
|
819
889
|
// RFC 9935 sec. 6 ML-KEM-*-PrivateKey CHOICE: the inner sizes, keyed by the OID -- the OID is
|
|
820
890
|
// the SOLE authority for the parameter set (never a length heuristic). ek = 384k+32, dk = the
|
|
821
891
|
// FIPS 203 decapsulation key length.
|
|
892
|
+
// {ek, dk} come from the shared ML-KEM parameter registry (FIPS 203 Table 3) -- the same rows the
|
|
893
|
+
// CMS codec and the linter read, so a parameter set cannot mean one size here and another there.
|
|
822
894
|
var ML_KEM_INNER = {};
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
ML_KEM_INNER[oid.byName(
|
|
895
|
+
["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) {
|
|
896
|
+
var row = oid.kemParams(n);
|
|
897
|
+
ML_KEM_INNER[oid.byName(n)] = { ek: row.ek, dk: row.dk };
|
|
898
|
+
});
|
|
826
899
|
|
|
827
900
|
function _isOctet(node, size) {
|
|
828
901
|
return node && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING &&
|
|
@@ -1028,9 +1101,16 @@ SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
|
|
|
1028
1101
|
if (!key.extractable) throw new WebCryptoError("webcrypto/invalid-access", "key is not extractable");
|
|
1029
1102
|
if (format === "jwk") return key._handle.export({ format: "jwk" });
|
|
1030
1103
|
if (key.type === "secret") {
|
|
1104
|
+
// export() allocates a fresh Buffer holding the secret key, and _toArrayBuffer copies it via
|
|
1105
|
+
// ArrayBuffer.slice -- so the export Buffer is a controllable allocation nothing references once
|
|
1106
|
+
// the copy exists. Wiped in a `finally` so the unsupported-format throw below clears it too.
|
|
1031
1107
|
var raw = key._handle.export();
|
|
1032
|
-
|
|
1033
|
-
|
|
1108
|
+
try {
|
|
1109
|
+
if (format === "raw") return _toArrayBuffer(raw);
|
|
1110
|
+
throw new WebCryptoError("webcrypto/not-supported", "exportKey: secret keys support 'raw' / 'jwk' only");
|
|
1111
|
+
} finally {
|
|
1112
|
+
guard.secret.zeroize(raw, WebCryptoError, "webcrypto/operation", "the exported secret key");
|
|
1113
|
+
}
|
|
1034
1114
|
}
|
|
1035
1115
|
if (format === "spki") return _toArrayBuffer(key._handle.export({ format: "der", type: "spki" }));
|
|
1036
1116
|
if (format === "pkcs8") return _toArrayBuffer(key._handle.export({ format: "der", type: "pkcs8" }));
|
package/lib/x509-sign.js
CHANGED
|
@@ -245,6 +245,9 @@ function _hasCriticalSan(extSpec) {
|
|
|
245
245
|
* (positive, <= 20 octets), the validity UTCTime/GeneralizedTime cutover, the DER DEFAULT omissions
|
|
246
246
|
* (v1 tag, `critical=FALSE`, `cA=FALSE`), and the CA cross-field rules (keyCertSign and
|
|
247
247
|
* pathLenConstraint require cA=TRUE) are all enforced; a violation throws a typed `CertificateError`.
|
|
248
|
+
* Where the spec carries raw DER -- a `Name` Buffer, a pre-encoded `Extension`, an issuer `publicKey`
|
|
249
|
+
* SPKI -- a structural fault throws `CertificateError`, while a malformed leaf inside those bytes
|
|
250
|
+
* throws `Asn1Error`, the same two-error contract the parsers present.
|
|
248
251
|
*
|
|
249
252
|
* @opts
|
|
250
253
|
* - `pem` (boolean) -- return a PEM `CERTIFICATE` string instead of DER.
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:47691d4d-f958-4718-adb0-5a945e2dd23e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-10T06:22:53.264Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.4.13",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.4.
|
|
25
|
+
"version": "0.4.13",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.4.13",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.4.13",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|