@blamejs/pki 0.9.0 → 0.9.1
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 +11 -0
- package/MIGRATING.md +46 -0
- package/README.md +1 -1
- package/lib/constants.js +3 -0
- package/lib/est.js +89 -17
- package/lib/mime.js +124 -17
- package/lib/smime.js +0 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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.9.1 — 2026-10-08
|
|
8
|
+
|
|
9
|
+
Refuse a MIME entity that two conforming readers would read differently, and hold one entity to a bounded number of parts, header lines and parameters.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `pki.smime.verify` and `pki.smime.decrypt` refuse an entity whose `Content-Type` declares one parameter twice, or whose header area declares one field twice. Both readings of such an entity are conforming and they differ, so the signature's own coverage is in question: `boundary` sets where the parts begin, `protocol` and `smime-type` decide whether the entity is accepted, and `micalg` decides which digest is reported back. Previously the verdict followed the order the two occurrences appeared in. `boundary="DECOY"; boundary=<real>` returned `valid: true` while the same two values swapped was refused, and a second `Content-Type: text/plain` placed after the real field returned `valid: true` while the same field placed before it was refused, which left a reader taking the last field seeing `text/plain`, finding no signature part, and reading the message as unsigned. Both orders of both shapes now raise `smime/bad-mime`. An entity naming each parameter and each field once is unaffected, and every occurrence of a field that may legally repeat, such as `Received`, is still readable from `headers`: only resolving a repeated name to a single value is refused.
|
|
14
|
+
- One MIME entity is bounded at 1024 body parts (`pki.constants.LIMITS.MIME_MAX_PARTS`), 1024 header lines (`MIME_MAX_HEADERS`) and 256 `Content-Type` parameters (`MIME_MAX_PARAMS`), and each count is charged as the entity is scanned rather than after it has been taken apart. A 1.4 MiB body declaring 200,000 empty parts previously split into all 200,000, and a 16 MiB header area took 1114 ms and 653 MiB of resident memory to read. A folded field spends one of the 1024 lines per physical line it occupies. `pki.est.splitMultipartMixed` and `pki.est.parseServerKeygenResponse` are held to the same part and header-line bounds, and raise `est/bad-multipart`. From `pki.smime.verify`, an entity over the part bound raises `smime/bad-multipart` and one over the header-line or parameter bound raises `smime/bad-mime`. The 16 MiB entity cap is unchanged and is still applied before any of the bytes are copied.
|
|
15
|
+
- The record `pki.smime.verify` returns, and its `content`, no longer share the buffer the caller passed in. The parsed entity, its header area and its body were views into the caller's own bytes, so a write into that buffer after the verdict returned changed what the caller read back as verified. The entity is copied before it is read, the copy is byte-for-byte the input, and the octets a signature covers are unchanged.
|
|
16
|
+
- The reference at [pkijs.com](https://pkijs.com) reads on a phone. At a 375px viewport the page scrolled 771px wide, and the nineteen navigation groups stood 659px tall even collapsed, so the first screen held navigation and no documentation. The content now starts under a compact header carrying the brand and a menu button, the navigation opens from that button, and tables and code blocks scroll inside their own box instead of widening the page. The menu is a label and a checkbox, so it works with JavaScript off, as the search form does. The layout at 721px and above is unchanged.
|
|
17
|
+
|
|
7
18
|
## v0.9.0 — 2026-10-08
|
|
8
19
|
|
|
9
20
|
Import any namespace by name from an ESM caller, and give every record the toolkit builds for an omitted argument no prototype.
|
package/MIGRATING.md
CHANGED
|
@@ -14,6 +14,52 @@ The toolkit has no `deprecate()`-marked surface awaiting removal.
|
|
|
14
14
|
|
|
15
15
|
Listed newest-first.
|
|
16
16
|
|
|
17
|
+
### v0.9.1 — `pki.smime.verify, pki.smime.decrypt, pki.est.splitMultipartMixed, and pki.est.parseServerKeygenResponse`
|
|
18
|
+
|
|
19
|
+
A MIME entity that names one Content-Type parameter twice, or one header field twice, is refused rather than resolved by position, and one entity is bounded at 1024 body parts, 1024 header lines, and 256 Content-Type parameters.
|
|
20
|
+
|
|
21
|
+
No API shape changed, so there is nothing to rewrite. What changes is which messages produce a
|
|
22
|
+
verdict.
|
|
23
|
+
|
|
24
|
+
Two shapes are refused outright, in either order of the two occurrences:
|
|
25
|
+
|
|
26
|
+
- A `Content-Type` naming one parameter twice, such as `boundary="a"; boundary="b"`, or two
|
|
27
|
+
`micalg`, `protocol`, or `smime-type` parameters. Raises `smime/bad-mime`.
|
|
28
|
+
- A header area naming one field twice, such as two `Content-Type` lines. Raises
|
|
29
|
+
`smime/bad-mime`.
|
|
30
|
+
|
|
31
|
+
RFC 2045 sec. 5.1 gives a parameter one occurrence and RFC 5322 sec. 3.6 gives a singleton field
|
|
32
|
+
one, so an entity naming either twice has two conforming readings that disagree about what the
|
|
33
|
+
entity is: the `boundary` decides which octets a detached signature covers. Before this release
|
|
34
|
+
the verdict followed the order the two occurrences appeared in, so the same two values swapped
|
|
35
|
+
flipped between `valid: true` and a refusal.
|
|
36
|
+
|
|
37
|
+
A field that may legally repeat, such as `Received`, is unaffected, and every occurrence stays
|
|
38
|
+
readable from `headers`. Only resolving a repeated name to a single value is refused.
|
|
39
|
+
|
|
40
|
+
Three bounds apply to one entity:
|
|
41
|
+
|
|
42
|
+
| bound | value | `pki.constants.LIMITS` row |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| body parts | 1024 | `MIME_MAX_PARTS` |
|
|
45
|
+
| header lines | 1024 | `MIME_MAX_HEADERS` |
|
|
46
|
+
| `Content-Type` parameters | 256 | `MIME_MAX_PARAMS` |
|
|
47
|
+
|
|
48
|
+
A folded field spends one of the 1024 header lines for each physical line it occupies, so a field
|
|
49
|
+
folded over three lines costs three. From `pki.smime`, an entity over the part bound raises
|
|
50
|
+
`smime/bad-multipart` and one over the header-line or parameter bound raises `smime/bad-mime`;
|
|
51
|
+
the `pki.est` readers raise `est/bad-multipart` for all three. The 16 MiB `MIME_MAX_BYTES` entity
|
|
52
|
+
cap is unchanged.
|
|
53
|
+
|
|
54
|
+
If a message you must keep reading carries one of the refused shapes, it is ambiguous to every
|
|
55
|
+
other S/MIME implementation as well, and the sender has to emit each parameter and each field
|
|
56
|
+
once. No option restores the previous behavior, because a precedence rule no other reader is
|
|
57
|
+
obliged to share cannot settle which octets a signature covers.
|
|
58
|
+
|
|
59
|
+
Separately, the entity record and the `content` a verdict is read with no longer share the buffer
|
|
60
|
+
passed in. If you relied on mutating your own input buffer to change what a returned record reads
|
|
61
|
+
back, that no longer works, and the record now holds the bytes the verdict was computed over.
|
|
62
|
+
|
|
17
63
|
### v0.6.12 — `pki.crl.verify, pki.pkcs12.verifyMac, and pki.ct.verifySctWithLogList`
|
|
18
64
|
|
|
19
65
|
Each returned a bare boolean and now returns a verdict object; read `res.valid` where you read the boolean.
|
package/README.md
CHANGED
|
@@ -285,7 +285,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
285
285
|
| `pki.pkcs12` | PKCS#12 (.p12/.pfx) issuance and reading (RFC 7292 / RFC 9579, the latter obsoleted by RFC 9879). `build(spec, opts)` assembles a store from 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, or nested `safeContents` bags. Keys and certs are validated before wrapping, a key paired with a certificate (the `{ key, cert }` form, or bags sharing a `localKeyId`) must be the private half of that certificate's public key, and `friendlyName` (BMPString) and `localKeyId` are single-value. Integrity is a classic Appendix B HMAC (the default, for maximum interoperability) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`) over SHA-256/384/512, with 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 — which is what OpenSSL and NSS consume, so a file it emits opens in both, cross-checked bidirectionally. The MAC covers 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 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, signed by any `pki.cms.sign` signer and carrying no MacData (§4); privacy stays independent, so `password` still PBES2-encrypts the bags. Public-key privacy wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData`, never GCM) encrypted to recipient public keys through the `pki.cms.encrypt` recipient model, via per-safe `recipients` or the `opts.recipientCerts` convenience, restricted to certificate recipients (RSA-OAEP, ECDH, X25519, X448, ML-KEM) since a password or KEK recipient could not be reopened by `open`. All four integrity-by-privacy combinations are permitted (§3.1). `open(pfx, password, opts)` reads a store back: it verifies the MAC first, so a wrong password is the MAC verdict rather than 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 and secrets as raw DER, all with `friendlyName` and `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), with the signer surfaced in `signers` but never trust-chained, which remains the caller's `pki.path.validate` step. A legacy-PBE store's Appendix C 3DES and RC2 bags are decrypted, RC2 through an in-tree RFC 2268 cipher, so an `openssl pkcs12 -legacy` or NSS store opens; the legacy RC4 schemes are refused. An `id-envelopedData` safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` when absent), every recipient-side fault and every post-integrity decrypt failure collapsing to the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`. It reads what OpenSSL and NSS produce. Returns DER or a PEM `PKCS12`, with a typed `Pkcs12Error` on failure. Parsing stays at `pki.schema.pkcs12.parse` — `build`, `verifyMac`, `open` |
|
|
286
286
|
| `pki.pkcs11` | PKCS #11 URIs (RFC 7512), the syntax an operator names a token and an object on it with instead of a file path. `parseUri(uri)` reads one into `{ path, vendorPath, query, vendorQuery }`, each a prototype-less record: the path carries `token`, `manufacturer`, `serial`, `model`, the three `library-*` and three `slot-*` attributes, `object`, `type` and `id`, and the query carries `pin-source`, `pin-value`, `module-name` and `module-path`, with a vendor attribute in either component kept under its own name. A vendor query attribute carries a list of its values, which §2.4 permits to repeat; a vendor path attribute carries one value. `id` is a `Buffer`, since the attribute names bytes rather than text, and `slot-id` and `library-version` are numbers. `formatUri(record)` writes the record back, percent-encoding each value to what its own component admits unencoded and encoding `id` whole with uppercase hexadecimal, so two URIs naming one identifier compare equal. Parsing follows the §2.3 / §2.4 ABNF rather than a general URI reader and is fail-closed: a character the component admits only percent-encoded, a truncated or non-hexadecimal escape, a value that is not valid UTF-8, an attribute repeated where the RFC admits it once, a `type` outside the five the RFC enumerates, a URI carrying both `pin-source` and `pin-value`, and a relative `module-path` are each refused with their own `pkcs11/*` code. Syntax only: nothing here loads a PKCS #11 module or talks to a token — `parseUri`, `formatUri` |
|
|
287
287
|
| `pki.cms` | CMS signing, verification, encryption, and compression (RFC 5652). `sign(content, signers, opts)` produces a SignedData (§5), attached or detached, with one or many signers over 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, or EdDSA key (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. The content may also be an async iterable of byte chunks, which signs a large detached payload without holding it in memory: the payload is hashed incrementally under every signer's digest algorithm in one pass, and `verify` accepts the same streamed form as its `content` option. A signer may also be key-only — `{ key, spki, keyIdentifier }` with no certificate — which RFC 5272 §3.2 requires when a Full PKI Request's signer is the key of a certification request that request carries: the signer identifier takes the subjectKeyIdentifier form carrying the identifier the request declares, the signature scheme resolves from the request's own public key, and no certificate is embedded. `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: with signed attributes 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), and otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate. `valid` and `trusted` are separate claims and neither implies the other: a SignedData carries its own certificates, so `valid` says the signature is sound under one of them and nothing about who signed, while `trusted` says every signer chained to a root named in `opts.trustAnchors`, validated through the same RFC 5280 path engine `pki.path.validate` uses. Without anchors there is nothing to chain to and `trusted` is `false`; anchors that cannot be read throw, rather than reading as untrusted. An unrecognized option is refused rather than ignored. `countersign(cms, signers, opts)` adds a countersignature (§11.4) — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, with 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 under `signers[i].unsignedAttrs`, surfaced unauthenticated. The CAdES signature timestamp has verbs of its own: `timestampImprint(der, opts)` returns `{ imprint, signerIndex }`, where `imprint` is the `{ hashAlgorithm, hashedMessage }` argument `pki.tsp.request` takes, the hash of a signer's signature value with neither tag nor length (ETSI EN 319 122-1 clause 5.3), `attachTimestamp(der, token, opts)` splices the returned RFC 3161 token in as the signature-time-stamp unsigned attribute and refuses one whose imprint covers other bytes, and `verify` reports a row per attached token under `signers[i].signatureTimeStamps`, each carrying the generation time, the authority, and what the authority's chain established. A row is refused when its time precedes the signature's own `signing-time` or falls after the signing certificate's `notAfter`. `signers[i].cadesBaseline` reports which CAdES baseline requirements the signature meets. The RFC 6211 `CMSAlgorithmProtection` attribute is emitted on request by `sign`, `countersign` and `authenticate` (`algorithmProtection: true`, or `"registry"` for the IANA arc identifier) and always compared where present: a copy of the digest and signature or MAC algorithm identifiers, placed where the signature or the MAC covers them, with a disagreement refused as `cms/algorithm-protection-mismatch` and the result reporting `algorithmProtection` per signer. It matters most on an `AuthenticatedData`, whose MAC input is the authenticated attributes alone, leaving the outer digest and MAC algorithm fields covered by nothing. `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData, with recipients auto-dispatched off the certificate key to key transport (RSAES-OAEP; v1.5 is 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 with the PWRI-KEK of RFC 3211, carried forward by RFC 3370), or the post-quantum ML-KEM (and composite ML-KEM, draft-lamps-pq-composite-kem) KEMRecipientInfo (RFC 9629/9936), wrapping one fresh content key for every recipient. The content may also be an async iterable of byte chunks, streamed through the AES-GCM or AES-CBC content cipher so a large plaintext is encrypted without being buffered whole; the ciphertext is assembled before the definite-length encryptedContent is emitted, byte-for-byte the buffered form. `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, and password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. With `opts.stream: true` the recovered `content` is an async iterable of plaintext chunks instead of a single Buffer, for a payload too large to hold whole; the authenticated modes (AuthEnvelopedData, AuthenticatedData, AES-GCM) verify integrity before the iterable yields, so a forged message throws before any plaintext is exposed, while a CBC EnvelopedData or EncryptedData yields the plaintext as the cipher produces it. Every key-establishment secret the toolkit allocates is wiped once used, on the failing path as well as the succeeding one: the KEM shared secret and its derived key-encryption key, the raw ECDH / X25519 / X448 agreement secret, a password-derived key-encryption key, and the content-encryption key itself, cleared once the message is complete since all recipients share it. Caller-supplied key material is never written to (best-effort; NIST SP 800-227 §4.2, RFC 9629 §7). `authenticate(content, recipients, opts)` produces an `id-ct-authData` (§9): cleartext content plus an HMAC-SHA-256/384/512 MAC, authenticated but not encrypted, with the fresh MAC key wrapped for every recipient through the same RecipientInfo model. The MAC covers the authenticated attributes (content-type and 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`. `compress(content, opts)` and `decompress(input, opts)` produce and consume a CompressedData (RFC 3274; 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`. Compression is a size transform with no integrity or confidentiality (RFC 8551 §2.4.5). `certsOnly(certs, opts?)` builds a certs-only certificate-management message (RFC 8551 §3.8): a degenerate SignedData with an empty signerInfos and no eContent that conveys certificates and CRLs without signing anything, `parseCertsOnly(input, opts?)` reads one back to its raw certificate and CRL DER (a CRL-only message is valid here, unlike the cert-required RFC 5272 form that `pki.est.parseCertsOnly` reads), and `isCertsOnly(input)` recognizes one structurally. Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress`, `certsOnly`, `parseCertsOnly`, `isCertsOnly` |
|
|
288
|
-
| `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while 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, as `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 rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: 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. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC 8550 §4.4.4) and the anchor's own trust metadata must permit that purpose, since a root can be distrusted for email while remaining a good TLS root. `sign` refuses a signer certificate a conforming reader would reject: a `keyUsage` extension without `digitalSignature` or `nonRepudiation` (RFC 8550 §4.4.2), or an `extendedKeyUsage` extension without `emailProtection` or `anyExtendedKeyUsage` (§4.4.4); a certificate carrying neither extension signs. Override either with `requiredEku` / `checkPurpose`. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), 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` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `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 and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `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` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and 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` and `openssl cms`. `buildCertsOnly(certs, opts?)` wraps a `pki.cms.certsOnly` certificate-management message in one `application/pkcs7-mime; smime-type=certs-only; name=smime.p7c` entity, read back by OpenSSL's `pkcs7 -print_certs`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress`, `buildCertsOnly` |
|
|
288
|
+
| `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while 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, as `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 rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: 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. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC 8550 §4.4.4) and the anchor's own trust metadata must permit that purpose, since a root can be distrusted for email while remaining a good TLS root. `sign` refuses a signer certificate a conforming reader would reject: a `keyUsage` extension without `digitalSignature` or `nonRepudiation` (RFC 8550 §4.4.2), or an `extendedKeyUsage` extension without `emailProtection` or `anyExtendedKeyUsage` (§4.4.4); a certificate carrying neither extension signs. Override either with `requiredEku` / `checkPurpose`. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), 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` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `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 and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `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` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and 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` and `openssl cms`. `buildCertsOnly(certs, opts?)` wraps a `pki.cms.certsOnly` certificate-management message in one `application/pkcs7-mime; smime-type=certs-only; name=smime.p7c` entity, read back by OpenSSL's `pkcs7 -print_certs`. An entity whose Content-Type names one parameter twice, or whose header area names one field twice, is refused rather than resolved by position: RFC 2045 §5.1 and RFC 5322 §3.6 each give one occurrence, so the two readings disagree about which octets the signature covers. One entity is bounded at 1024 body parts, 1024 header lines and 256 Content-Type parameters (`MIME_MAX_PARTS`, `MIME_MAX_HEADERS`, `MIME_MAX_PARAMS`), counted as it is scanned, and the record a verdict is read with holds a copy of the entity rather than a view of the caller's buffer. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress`, `buildCertsOnly` |
|
|
289
289
|
| `pki.tsp` | Time-Stamp Protocol (RFC 3161). `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, and ordering, plus the §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). The TSA certificate is held to §2.3 before signing (one critical extendedKeyUsage naming timeStamping alone) with the codes `verify` answers, `genTime` keeps its milliseconds as the §2.4.2 fraction of a second, and an accuracy naming no field is refused. `request` and `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq); `response` and `parseResponse` handle the TimeStampResp a TSA returns, either a granted status wrapping a token or a rejection with PKIStatus and failure info, with the §2.4.2 status-to-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` — judged under the `timeStamping` purpose, so an anchor's own per-purpose trust metadata reaches the decision rather than sitting inert. It returns `{ valid, trusted, genTime, serialNumber, tstInfo, ... }`, where `trusted` is the separate claim that the authority chained to an anchor you named: without one there is nothing to chain to and it is `false`, which is the distinction a timestamp re-read years later turns on — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
|
|
290
290
|
| `pki.ocsp` | Online Certificate Status Protocol (RFC 6960), both the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs, with the CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile (obsoleted by RFC 9919) or under SHA-2, plus an optional RFC 9654 nonce (32 to 128 octets, the requester floor that section sets) and an optional requestor signature, under which the signer certificate's subject is the requestorName unless one is stated (RFC 6960 §4.1.2). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER, from the issuing CA directly or a delegated responder, under any `pki.cms.sign` key including the post-quantum ML-DSA and SLH-DSA sets, with `good`, `revoked` (reason and time), or `unknown` per-certificate status, and per-response `singleExtensions` naming the §4.4.4 archive cutoff and the §4.4.2 CRL reference as fields, each read back by name by the parser. The signer refuses what its own lint would grade an error: a pre-encoded singleExtension that RFC 6960 places in requests or in responseExtensions, a CRL entry extension carried at the wrong criticality, and CRL references or entry extensions on a non-issued response, which itself places the §4.4.8 extended revoked definition in responseExtensions without being asked. `buildErrorResponse(status)` produces the unsigned §2.3 error (`tryLater`, `unauthorized`, and the rest). `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 and passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency against `thisUpdate` and `nextUpdate`, and the request-nonce echo. It returns `{ status: "good" / "revoked" / "unknown", ... }` and never silently accepts. `verifyRequest(request, opts)` is the responder-side inverse of `buildRequest`: it verifies a client's signed request (RFC 6960 §4.1.1) under the requestor certificate the request carries, trying each embedded certificate since the field is unordered, and reports `signed`, `signatureValid`, every certificate whose key verified the signature and whose subject is the `requestorName` the request states (`signerCerts`, so a key carried under an expired certificate beside its renewal is not hidden behind ordering, while a same-key certificate under another name is not the requestor) with the first one's decoded subject, the full embedded certificate bag (`certs`, including intermediates for path building), and the request's CertIDs; the name check is reported as `requestorNamed` (RFC 6960 §4.1.2) and `valid` requires it, a request no verifying certificate names falls back to listing the verifying ones with `valid: false`, and an unsigned request is reported with `signed: false` rather than refused. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify`, `verifyRequest` |
|
|
291
291
|
| `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, a TLS-presentation-language payload inside the §3.3 double DER wrap, into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature. `reconstructSignedData` rebuilds the exact `digitally-signed` preimage, and `verifySct` verifies an SCT signature against a log's public key, routing an ECDSA signature through the strict DER-conformance gate and verifying through the crypto engine, resolving true or false and throwing a typed error on a structural fault. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs, recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so an unverified document is never parsed, read, cached, or surfaced, then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version` and `timestamp`. Above the per-SCT layer, The RFC 6962 sec. 4 log client is shipped: `getSth` fetches a signed tree head and verifies it under the caller-pinned log key, returning nothing when it does not verify, since every later check is measured against that root; `sthSignedData` builds the 50-byte sec. 3.5 preimage and `verifySth` checks a head held offline. `getProofByHash` and `getSthConsistency` fetch the audit and consistency proofs and fold them against tree heads a log signed, taking the query sizes from those heads so a size and a head cannot disagree. `getEntries` and `getRoots` fetch what the specification leaves unsigned and say so, with a short entry answer reported as truncation rather than a fault. `addChain` and `addPreChain` submit a chain and verify the SCT the log returns against the chain it was issued for. `verifySctList` renders a certificate-level verdict over every SCT a certificate carries (§3.3), reporting how many distinct trusted logs verified an SCT (so a duplicated SCT cannot inflate the count) and from how many distinct trusted operators against a caller's CT policy (`minScts` and `minOperators`, both defaulting to the RFC floor of one), where a policy shortfall is a verdict and a per-SCT failure is a recorded row rather than a thrown call; `x509CertEntry` reconstructs the precertificate log entry from a final certificate by removing the SCT-list extension from its TBSCertificate as byte surgery, so its embedded SCTs verify end to end (§3.2). The toolkit bakes in no vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifySctList`, `x509CertEntry`, `verifyLogListSignature`, `fetchLogList` |
|
package/lib/constants.js
CHANGED
|
@@ -189,6 +189,9 @@ var LIMITS = Object.assign(Object.create(null), {
|
|
|
189
189
|
PBKDF2_MAX_SALT: 1024,
|
|
190
190
|
MIME_MAX_BYTES: BYTES.mib(16),
|
|
191
191
|
HEADER_LINE_MAX_OCTETS: 998,
|
|
192
|
+
MIME_MAX_PARTS: 1024,
|
|
193
|
+
MIME_MAX_HEADERS: 1024,
|
|
194
|
+
MIME_MAX_PARAMS: 256,
|
|
192
195
|
COMPRESS_MAX_BYTES: BYTES.mib(16),
|
|
193
196
|
TLS_CERT_MSG_MAX_BYTES: BYTES.mib(16) - 1,
|
|
194
197
|
TLS_CERT_MAX_ENTRIES: 100,
|
package/lib/est.js
CHANGED
|
@@ -281,8 +281,14 @@ function _matchDelimAt(text, ls, boundary) {
|
|
|
281
281
|
if (_charCodeAt(text, q) === 0x0d && q + 1 < n && _charCodeAt(text, q + 1) === 0x0a) return { end: q + 2, close: close };
|
|
282
282
|
return null;
|
|
283
283
|
}
|
|
284
|
+
/** @internal The delimiter count is bounded before the list is built, since one mark is appended per
|
|
285
|
+
* delimiter found anywhere in the body and the scan does not stop at the terminal one. The bound is
|
|
286
|
+
* the part cap plus that terminal delimiter, and it is the same `C.LIMITS` row the MIME reader
|
|
287
|
+
* counts parts against, so the two doors hold a multipart body to one number. */
|
|
284
288
|
function _findMultipartMarks(text, boundary) {
|
|
285
289
|
var marks = [], n = text.length, searchStart = 0, p;
|
|
290
|
+
var counted = guard.limits.counter(constants.LIMITS.MIME_MAX_PARTS, E, "est/bad-multipart",
|
|
291
|
+
"the multipart body part");
|
|
286
292
|
while (searchStart <= n) {
|
|
287
293
|
var found = null, foundAt = -1;
|
|
288
294
|
for (p = searchStart; p <= n; p++) {
|
|
@@ -293,7 +299,15 @@ function _findMultipartMarks(text, boundary) {
|
|
|
293
299
|
if (lsN !== -1) { var r1 = _matchDelimAt(text, lsN, boundary); if (r1) { found = r1; foundAt = p; break; } }
|
|
294
300
|
}
|
|
295
301
|
if (!found) break;
|
|
302
|
+
/** @internal The closing delimiter opens no part, so it is not charged and the cap counts the
|
|
303
|
+
* parts an operator is told it counts. */
|
|
304
|
+
if (!found.close) counted.tick();
|
|
296
305
|
guard.list.append(marks, { at: foundAt, bodyStart: found.end, close: found.close });
|
|
306
|
+
/** @internal RFC 2046 sec. 5.1.1 discards everything after the closing delimiter, and the caller
|
|
307
|
+
* reads no mark past the first one that closes, so the scan stops there. Running on charged the
|
|
308
|
+
* epilogue against the part budget: a body at the cap was refused for text it never reads, while
|
|
309
|
+
* the same body without that epilogue was accepted. */
|
|
310
|
+
if (found.close) break;
|
|
297
311
|
searchStart = found.end === foundAt ? found.end + 1 : found.end;
|
|
298
312
|
}
|
|
299
313
|
return marks;
|
|
@@ -314,18 +328,29 @@ function _stripOneTrailingEol(seg, end) {
|
|
|
314
328
|
}
|
|
315
329
|
return end;
|
|
316
330
|
}
|
|
317
|
-
|
|
318
|
-
|
|
331
|
+
/** @internal Every line break in the part's header area is charged as the scan passes it, before the
|
|
332
|
+
* unfolded text is split, so the refusal costs the cap rather than the input. Counting the split
|
|
333
|
+
* lines afterwards instead read 2,000,000 one-line headers in 425 ms and 172 MiB of resident memory
|
|
334
|
+
* before refusing, and never reached two on 1,500,000 continuation lines, which fold into one line. */
|
|
335
|
+
function _unfoldHeaders(raw, counted) {
|
|
336
|
+
var out = "", last = 0, i = 0, n = raw.length, afterLastBreak = 0;
|
|
319
337
|
while (i < n) {
|
|
320
338
|
var nlLen = 0;
|
|
321
339
|
if (_charCodeAt(raw, i) === 0x0d && i + 1 < n && _charCodeAt(raw, i + 1) === 0x0a) nlLen = 2;
|
|
322
340
|
else if (_charCodeAt(raw, i) === 0x0a) nlLen = 1;
|
|
323
|
-
if (nlLen
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
341
|
+
if (nlLen === 0) { i += 1; continue; }
|
|
342
|
+
/** @internal The scan steps PAST the whole break rather than one byte into it, so a CRLF is one
|
|
343
|
+
* line and not two: stepping one byte left the LF to be read as a second break on the next pass,
|
|
344
|
+
* which charged every CRLF twice and halved the cap a part was actually held to. */
|
|
345
|
+
counted.tick();
|
|
346
|
+
var after = i + nlLen;
|
|
347
|
+
afterLastBreak = after;
|
|
348
|
+
if (after < n && (_charCodeAt(raw, after) === 0x20 || _charCodeAt(raw, after) === 0x09)) { out += _strSlice(raw, last, i); last = after; i = after; continue; }
|
|
349
|
+
i = after;
|
|
328
350
|
}
|
|
351
|
+
/** @internal The run after the last break is a line as well, and a header area that ends at a break
|
|
352
|
+
* has no run after it, so the charge is the LINE count the cap is stated in and not one less. */
|
|
353
|
+
if (afterLastBreak < n) counted.tick();
|
|
329
354
|
return out + _strSlice(raw, last, n);
|
|
330
355
|
}
|
|
331
356
|
function _splitCrlf(s) {
|
|
@@ -359,7 +384,9 @@ function splitMultipartMixed(body, contentType) {
|
|
|
359
384
|
var rawHeaders = _strSlice(seg, 0, sep);
|
|
360
385
|
var partBody = _strSlice(seg, _stripTwoLeadingEol(seg, sep), _stripOneTrailingEol(seg, seg.length));
|
|
361
386
|
var headers = intrinsic.create(null);
|
|
362
|
-
var
|
|
387
|
+
var countedHeaders = guard.limits.counter(constants.LIMITS.MIME_MAX_HEADERS, E, "est/bad-multipart",
|
|
388
|
+
"the multipart part header line");
|
|
389
|
+
var lines = _splitCrlf(_unfoldHeaders(rawHeaders, countedHeaders));
|
|
363
390
|
for (var li = 0; li < lines.length; li++) {
|
|
364
391
|
var line = lines[li];
|
|
365
392
|
/** @internal A part's header name and value are cut, trimmed and folded through the captured
|
|
@@ -367,7 +394,20 @@ function splitMultipartMixed(body, contentType) {
|
|
|
367
394
|
* its body goes to: measured, a part stating `application/pkcs8` was read as
|
|
368
395
|
* `application/pkcs7-mime` with a `smime-type` of `certs-only`. */
|
|
369
396
|
var col = _estIndexOf(line, ":");
|
|
370
|
-
if (col
|
|
397
|
+
if (col <= 0) continue;
|
|
398
|
+
var fname = _toLowerCase(_estTrim(_strSlice(line, 0, col)));
|
|
399
|
+
/** @internal A repeat is refused rather than overwritten. Storing each value in turn left the
|
|
400
|
+
* LAST line deciding the field, and the field this decides is the part's own content type,
|
|
401
|
+
* which selects the reader its body goes to: measured, the same two Content-Type lines swapped
|
|
402
|
+
* reported `application/pkcs7-mime; smime-type=certs-only` one way and `application/pkcs8` the
|
|
403
|
+
* other, which is which part is the private key and which is the certificate. RFC 5322 sec. 3.6
|
|
404
|
+
* gives a singleton field one occurrence, and taking the first is as conforming as taking the
|
|
405
|
+
* last, so there is no value here to resolve to. */
|
|
406
|
+
if (intrinsic.hasOwn(headers, fname)) {
|
|
407
|
+
throw E("est/bad-multipart", "a multipart part declares the " + guard.text.showValue(fname) +
|
|
408
|
+
" header field more than once, so the value it resolves to is ambiguous (RFC 5322 sec. 3.6)");
|
|
409
|
+
}
|
|
410
|
+
headers[fname] = _estTrim(_strSlice(line, col + 1));
|
|
371
411
|
}
|
|
372
412
|
var partCt = headers["content-type"] || "";
|
|
373
413
|
if (_ciStartsWith(partCt, "MULTIPART/")) throw E("est/bad-multipart", "a nested multipart part is not permitted");
|
|
@@ -471,36 +511,58 @@ function _recipientMatches(recipientInfos, opts) {
|
|
|
471
511
|
* replacement decides which segments a parameter is then looked for in, and one of those parameters
|
|
472
512
|
* is the multipart boundary a response body is divided on: measured, a `boundary=wire` header
|
|
473
513
|
* yielded `forged`. */
|
|
514
|
+
/** @internal The first segment is the media type rather than a parameter, so it is not charged, and
|
|
515
|
+
* the charge comes BEFORE the segment is stored, so a refusal costs the cap and not the input: with
|
|
516
|
+
* no cap here a 3.8 MiB Content-Type carrying 1,000,000 parameters was accepted in 194 ms and 100 MiB
|
|
517
|
+
* of resident memory, and a part's media type splits the same value a second time. The bound is the
|
|
518
|
+
* `C.LIMITS` row the MIME reader counts parameters against, so both doors hold one media type to one
|
|
519
|
+
* number. */
|
|
520
|
+
function _pushCtSegment(segs, seg, counted) {
|
|
521
|
+
if (segs.length > 0) counted.tick();
|
|
522
|
+
intrinsic.append(segs, seg);
|
|
523
|
+
}
|
|
524
|
+
|
|
474
525
|
function _splitContentTypeParams(s) {
|
|
475
526
|
var segs = [], cur = "", inQuote = false;
|
|
527
|
+
var counted = guard.limits.counter(constants.LIMITS.MIME_MAX_PARAMS, E, "est/bad-multipart",
|
|
528
|
+
"the Content-Type parameter");
|
|
476
529
|
for (var i = 0; i < s.length; i++) {
|
|
477
530
|
var ch = _estCharAt(s, i);
|
|
478
531
|
if (ch === '"') { inQuote = !inQuote; cur += ch; }
|
|
479
|
-
else if (ch === ";" && !inQuote) {
|
|
532
|
+
else if (ch === ";" && !inQuote) { _pushCtSegment(segs, cur, counted); cur = ""; }
|
|
480
533
|
else cur += ch;
|
|
481
534
|
}
|
|
482
|
-
|
|
535
|
+
_pushCtSegment(segs, cur, counted);
|
|
483
536
|
return segs;
|
|
484
537
|
}
|
|
485
538
|
|
|
486
539
|
function _ctParam(contentType, name) {
|
|
487
540
|
var segs = _splitContentTypeParams(_stringOf(contentType || ""));
|
|
488
|
-
var value = null,
|
|
541
|
+
var value = null, plain = 0, starred = 0;
|
|
489
542
|
/** @internal Each parameter is read through the captured operations. The name decides which
|
|
490
543
|
* parameter this is, and the value it returns is the one a caller acts on: for the RFC 7030
|
|
491
544
|
* multipart boundary, it is where a response body is split. */
|
|
492
545
|
for (var i = 1; i < segs.length; i++) {
|
|
493
546
|
var seg = segs[i];
|
|
494
547
|
var eq = _estIndexOf(seg, "=");
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
548
|
+
/** @internal An occurrence counts whether or not it carries a value, which is why a bare
|
|
549
|
+
* attribute is counted: skipping it let `boundary; boundary="b"` through while
|
|
550
|
+
* `boundary="a"; boundary="b"` was refused. The plain and the starred RFC 2231 spellings are
|
|
551
|
+
* counted apart, because what has to be refused is two readings carrying DIFFERENT values.
|
|
552
|
+
* `boundary*=us-ascii''DECOY; boundary=REAL` is read here as REAL and by an RFC 2231 reader as
|
|
553
|
+
* DECOY, while continuation segments alone leave this reader no boundary at all, which the
|
|
554
|
+
* caller already refuses (RFC 2231 sec. 3, sec. 4, sec. 7). */
|
|
555
|
+
var attr = _toLowerCase(_estTrim(eq === -1 ? seg : _strSlice(seg, 0, eq)));
|
|
556
|
+
var star = _estIndexOf(attr, "*");
|
|
557
|
+
if ((star > 0 ? _strSlice(attr, 0, star) : attr) !== name) continue;
|
|
558
|
+
if (star > 0) { starred++; continue; }
|
|
559
|
+
plain++;
|
|
560
|
+
if (eq === -1 || plain > 1) continue;
|
|
499
561
|
var val = _estTrim(_strSlice(seg, eq + 1));
|
|
500
562
|
if (val.length >= 2 && _estCharAt(val, 0) === '"' && _estCharAt(val, val.length - 1) === '"') val = _strSlice(val, 1, -1);
|
|
501
563
|
value = val;
|
|
502
564
|
}
|
|
503
|
-
return { value: value, duplicated:
|
|
565
|
+
return { value: value, duplicated: plain > 1 || (plain > 0 && starred > 0) };
|
|
504
566
|
}
|
|
505
567
|
|
|
506
568
|
var _MT_FIRST = pkix.charTable("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");
|
|
@@ -1777,6 +1839,16 @@ function _ciHeader(headers, name) {
|
|
|
1777
1839
|
"the response carries more than one " + lname + " header field, so what the body is cannot be told " +
|
|
1778
1840
|
"from it (RFC 9110 sec. 5.1: field names are case-insensitive)");
|
|
1779
1841
|
}
|
|
1842
|
+
/** @internal One key can still hold SEVERAL values, which is how `node:http` reports a field that
|
|
1843
|
+
* arrived more than once, and these three fields each carry one value. Counting keys alone left a
|
|
1844
|
+
* two-element array to be read as one string: measured, the two orders of the same pair gave
|
|
1845
|
+
* `est/bad-content-type` one way and an accepted classification the other, because the joined text
|
|
1846
|
+
* parsed as a media type in one order and not in the other. */
|
|
1847
|
+
if (intrinsic.isArray(found) && found.length > 1) {
|
|
1848
|
+
throw E("est/bad-content-type",
|
|
1849
|
+
"the response carries " + found.length + " values for " + lname + ", so what the body is cannot be " +
|
|
1850
|
+
"told from it (RFC 9110 sec. 5.1)");
|
|
1851
|
+
}
|
|
1780
1852
|
return n === 0 ? null : found;
|
|
1781
1853
|
}
|
|
1782
1854
|
|
package/lib/mime.js
CHANGED
|
@@ -26,6 +26,9 @@ var _push = guard.list.append;
|
|
|
26
26
|
var CRLF = Buffer.from("\r\n");
|
|
27
27
|
|
|
28
28
|
function _buf(v, E, code, label) { return guard.bytes.view(v, E, code, label); }
|
|
29
|
+
/** @internal `guard.limits.counter` throws `E(code, ...)`, and every consumer of this module hands in
|
|
30
|
+
* its error CLASS. */
|
|
31
|
+
function _factory(E) { return function (c, m) { return new E(c, m); }; }
|
|
29
32
|
|
|
30
33
|
function _splitPoint(bytes) {
|
|
31
34
|
for (var i = 0; i < bytes.length; i++) {
|
|
@@ -57,27 +60,83 @@ function _stripLeadingHtab(s) {
|
|
|
57
60
|
return s.slice(i);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
/** @internal The end of the line starting at `from`, and where the next one starts. A line is ended by
|
|
64
|
+
* CRLF, by a lone CR or by LF, which is the separator set `_toLf` followed by a split produces. */
|
|
65
|
+
function _lineEnd(s, from) {
|
|
66
|
+
for (var i = from; i < s.length; i++) {
|
|
67
|
+
var c = s.charCodeAt(i);
|
|
68
|
+
if (c === 0x0a) return { end: i, next: i + 1 };
|
|
69
|
+
if (c === 0x0d) return { end: i, next: (s.charCodeAt(i + 1) === 0x0a) ? i + 2 : i + 1 };
|
|
70
|
+
}
|
|
71
|
+
return { end: s.length, next: s.length };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @internal One line is cut at a time and EVERY line is charged, folded continuations and empty lines
|
|
75
|
+
* included, so the refusal costs the cap rather than the input. Splitting the whole header area first
|
|
76
|
+
* and counting fields afterwards read a 16 MiB area of 2,097,150 one-line fields in 1114 ms and
|
|
77
|
+
* 653 MiB of resident memory before the count refused it, and charging only fields left a 16 MiB area
|
|
78
|
+
* of 1,864,130 continuation lines folding into one 13 MB value in 1648 ms without the count ever
|
|
79
|
+
* reaching two. */
|
|
80
|
+
function _unfoldHeaders(headerText, counted) {
|
|
62
81
|
var out = [];
|
|
63
|
-
for (var
|
|
64
|
-
var
|
|
82
|
+
for (var at = 0; at < headerText.length;) {
|
|
83
|
+
var cut = _lineEnd(headerText, at);
|
|
84
|
+
var ln = headerText.slice(at, cut.end);
|
|
85
|
+
at = cut.next;
|
|
86
|
+
counted.tick();
|
|
65
87
|
if (ln === "") continue;
|
|
66
|
-
if ((ln
|
|
67
|
-
|
|
88
|
+
if ((ln.charAt(0) === " " || ln.charAt(0) === "\t") && out.length) {
|
|
89
|
+
out[out.length - 1] += " " + _stripLeadingHtab(ln);
|
|
90
|
+
} else {
|
|
91
|
+
_push(out, ln);
|
|
92
|
+
}
|
|
68
93
|
}
|
|
69
94
|
return out;
|
|
70
95
|
}
|
|
71
96
|
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
/**
|
|
98
|
+
* @internal
|
|
99
|
+
* RFC 2045 sec. 5.1 gives a parameter one occurrence, so a header naming one twice has no value this
|
|
100
|
+
* function can resolve: reading the last occurrence and reading the first are both conforming, and they
|
|
101
|
+
* disagree. These values decide what a signature covers (`boundary` sets where the parts begin),
|
|
102
|
+
* whether the entity is accepted (`protocol`, `smime-type`) and which digest is reported (`micalg`), so
|
|
103
|
+
* a repeat is refused here, where the record is built, rather than per parameter at each reader. An
|
|
104
|
+
* occurrence counts whether or not it carries a value, which is the `hp; hp="clear"` form; a single
|
|
105
|
+
* bare attribute still reaches the reader that owns it.
|
|
106
|
+
*
|
|
107
|
+
* What is refused is two readings that produce DIFFERENT VALUES, which is why the two RFC 2231
|
|
108
|
+
* spellings are not treated alike. A plain occurrence beside a starred one of the same attribute,
|
|
109
|
+
* `boundary*=us-ascii''DECOY; boundary=REAL`, gives this reader REAL and an RFC 2231 reader DECOY, so
|
|
110
|
+
* it is refused. Continuation segments alone, `name*0=a; name*1=b`, give this reader NO value for that
|
|
111
|
+
* attribute, so a consumer sees it as absent and the one place absence matters already fails closed:
|
|
112
|
+
* `splitMultipart` refuses an entity with no boundary. Refusing those would reject a conforming
|
|
113
|
+
* message over a parameter that cannot change what the signature covers.
|
|
114
|
+
*/
|
|
115
|
+
function _parseStructured(headerValue, E, code, label) {
|
|
116
|
+
var counted = guard.limits.counter(C.LIMITS.MIME_MAX_PARAMS, _factory(E), code, label + " parameter");
|
|
117
|
+
var parts = _splitSemicolons(headerValue, counted);
|
|
74
118
|
var type = _stripComments(parts[0]).trim().toLowerCase();
|
|
75
119
|
var params = Object.create(null);
|
|
120
|
+
var seenExact = Object.create(null), seenPlain = Object.create(null), seenStarred = Object.create(null);
|
|
76
121
|
for (var i = 1; i < parts.length; i++) {
|
|
77
122
|
var p = _stripComments(parts[i]);
|
|
78
123
|
var eq = p.indexOf("=");
|
|
124
|
+
var name = (eq < 0 ? p : p.slice(0, eq)).trim().toLowerCase();
|
|
125
|
+
if (name === "") continue;
|
|
126
|
+
var base = _baseAttribute(name);
|
|
127
|
+
var starred = base !== name;
|
|
128
|
+
if (seenExact[name] === true) {
|
|
129
|
+
throw new E(code, label + " declares the " + JSON.stringify(name) +
|
|
130
|
+
" parameter more than once, so its value is ambiguous (RFC 2045 sec. 5.1)");
|
|
131
|
+
}
|
|
132
|
+
if (starred ? seenPlain[base] === true : seenStarred[base] === true) {
|
|
133
|
+
throw new E(code, label + " spells the " + JSON.stringify(base) +
|
|
134
|
+
" parameter both plainly and as an RFC 2231 extended or continued value, and the two spellings " +
|
|
135
|
+
"carry different values (RFC 2231 sec. 7)");
|
|
136
|
+
}
|
|
137
|
+
seenExact[name] = true;
|
|
138
|
+
if (starred) seenStarred[base] = true; else seenPlain[base] = true;
|
|
79
139
|
if (eq < 0) continue;
|
|
80
|
-
var name = p.slice(0, eq).trim().toLowerCase();
|
|
81
140
|
var val = p.slice(eq + 1).trim();
|
|
82
141
|
if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') val = val.slice(1, -1);
|
|
83
142
|
params[name] = val;
|
|
@@ -99,7 +158,28 @@ function _stripComments(s) {
|
|
|
99
158
|
return out;
|
|
100
159
|
}
|
|
101
160
|
|
|
102
|
-
|
|
161
|
+
/** @internal The attribute an RFC 2231 name belongs to. Sections 3 and 4 spell one parameter three
|
|
162
|
+
* ways, `name`, `name*` for an extended value and `name*0` / `name*1` for a continuation, and
|
|
163
|
+
* section 7 makes them the same logical parameter. This module assembles neither starred form, so a
|
|
164
|
+
* reader that does takes a different value from the same bytes: measured, `boundary*=us-ascii''DECOY;
|
|
165
|
+
* boundary=REAL` verified here while an RFC 2231 reader splits the body on DECOY. Comparing the
|
|
166
|
+
* attribute rather than the spelling is what makes the two the same occurrence. A leading asterisk
|
|
167
|
+
* is a name in its own right and keeps it. */
|
|
168
|
+
function _baseAttribute(name) {
|
|
169
|
+
var star = name.indexOf("*");
|
|
170
|
+
return star > 0 ? name.slice(0, star) : name;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @internal The first segment is the media type rather than a parameter, so it is not charged. The
|
|
174
|
+
* charge comes BEFORE the segment is stored, so the refusal costs the cap and not the input: charging
|
|
175
|
+
* the segments after the split instead read a 3.8 MiB Content-Type of 1,000,000 parameters in 192 ms
|
|
176
|
+
* and 73 MiB of resident memory before the same cap refused it. */
|
|
177
|
+
function _pushSegment(out, seg, counted) {
|
|
178
|
+
if (counted !== undefined && out.length > 0) counted.tick();
|
|
179
|
+
_push(out, seg);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _splitSemicolons(s, counted) {
|
|
103
183
|
var out = [], cur = "", inQ = false, depth = 0;
|
|
104
184
|
for (var i = 0; i < s.length; i++) {
|
|
105
185
|
var ch = s[i];
|
|
@@ -107,21 +187,29 @@ function _splitSemicolons(s) {
|
|
|
107
187
|
else if (ch === '"' && depth === 0) { inQ = !inQ; cur += ch; }
|
|
108
188
|
else if (ch === "(" && !inQ) { depth++; cur += ch; }
|
|
109
189
|
else if (ch === ")" && !inQ && depth > 0) { depth--; cur += ch; }
|
|
110
|
-
else if (ch === ";" && !inQ && depth === 0) {
|
|
190
|
+
else if (ch === ";" && !inQ && depth === 0) { _pushSegment(out, cur, counted); cur = ""; }
|
|
111
191
|
else cur += ch;
|
|
112
192
|
}
|
|
113
|
-
|
|
193
|
+
_pushSegment(out, cur, counted);
|
|
114
194
|
return out;
|
|
115
195
|
}
|
|
116
196
|
|
|
117
197
|
function parse(input, E, code) {
|
|
118
|
-
|
|
119
|
-
|
|
198
|
+
/** @internal A snapshot, not a view: the record below holds `subarray`s of these bytes and a caller
|
|
199
|
+
* reads them back after the parse returns, so a view would let the caller's own later write change
|
|
200
|
+
* what the record says the entity was. Measured on the shipped verb: `pki.smime.verify` returned
|
|
201
|
+
* `valid: true` with `content` sharing the caller's store, and a write into that store afterwards
|
|
202
|
+
* changed the bytes the caller read as verified. The copy is byte-identical, so the octets a
|
|
203
|
+
* signature covers are unchanged. */
|
|
204
|
+
var viewed = _buf(input, E, code, "the MIME entity");
|
|
205
|
+
guard.limits.byteCap(viewed, C.LIMITS.MIME_MAX_BYTES, _factory(E), code, "the MIME entity");
|
|
206
|
+
var bytes = guard.bytes.snapshot(viewed, E, code, "the MIME entity");
|
|
120
207
|
var sp = _splitPoint(bytes);
|
|
121
208
|
var headerBytes = sp ? bytes.subarray(0, sp.headerEnd) : bytes;
|
|
122
209
|
var bodyBytes = sp ? bytes.subarray(sp.bodyStart) : Buffer.alloc(0);
|
|
123
210
|
var headerText = guard.text.decode(headerBytes, C.LIMITS.MIME_MAX_BYTES, E, { charset: "latin1", tooLarge: code, badInput: code, label: "the MIME headers" });
|
|
124
|
-
var
|
|
211
|
+
var countedHeaders = guard.limits.counter(C.LIMITS.MIME_MAX_HEADERS, _factory(E), code, "the MIME header line");
|
|
212
|
+
var rawHeaders = _unfoldHeaders(headerText, countedHeaders);
|
|
125
213
|
var headers = [];
|
|
126
214
|
for (var i = 0; i < rawHeaders.length; i++) {
|
|
127
215
|
var colon = rawHeaders[i].indexOf(":");
|
|
@@ -130,12 +218,29 @@ function parse(input, E, code) {
|
|
|
130
218
|
var body = rawHeaders[i].slice(colon + 1);
|
|
131
219
|
_push(headers,{ name: nm, lname: nm.toLowerCase(), value: body.trim(), rawValue: body.charAt(0) === " " ? body.slice(1) : body });
|
|
132
220
|
}
|
|
133
|
-
|
|
221
|
+
/** @internal Resolving a field that occurs more than once means choosing an occurrence, and reading
|
|
222
|
+
* the first and reading the last are both conforming (RFC 5322 sec. 3.6 gives a singleton field one
|
|
223
|
+
* occurrence). The media type decides which reader a body goes to and the transfer encoding decides
|
|
224
|
+
* how its octets are recovered, so a repeat is refused rather than resolved by position. Every
|
|
225
|
+
* occurrence stays in `headers`, where a field that may legally repeat, such as `Received`, is
|
|
226
|
+
* read instead. */
|
|
227
|
+
function header(name) {
|
|
228
|
+
var l = name.toLowerCase(), found = null;
|
|
229
|
+
for (var j = 0; j < headers.length; j++) {
|
|
230
|
+
if (headers[j].lname !== l) continue;
|
|
231
|
+
if (found !== null) {
|
|
232
|
+
throw new E(code, "the entity declares the " + JSON.stringify(headers[j].name) +
|
|
233
|
+
" field more than once, so resolving it to a single value is ambiguous (RFC 5322 sec. 3.6)");
|
|
234
|
+
}
|
|
235
|
+
found = headers[j];
|
|
236
|
+
}
|
|
237
|
+
return found === null ? null : found.value;
|
|
238
|
+
}
|
|
134
239
|
var ctv = header("content-type");
|
|
135
240
|
var cte = (header("content-transfer-encoding") || "").trim().toLowerCase() || "7bit";
|
|
136
241
|
return {
|
|
137
242
|
headers: headers, header: header,
|
|
138
|
-
contentType: ctv != null ? _parseStructured(ctv) : { value: "text/plain", type: "text/plain", params: Object.create(null) },
|
|
243
|
+
contentType: ctv != null ? _parseStructured(ctv, E, code, "the Content-Type") : { value: "text/plain", type: "text/plain", params: Object.create(null) },
|
|
139
244
|
cte: cte,
|
|
140
245
|
headerBytes: headerBytes, bodyBytes: bodyBytes, body: bodyBytes, bytes: bytes,
|
|
141
246
|
};
|
|
@@ -158,7 +263,9 @@ function splitMultipart(bodyBytes, boundary, E, code) {
|
|
|
158
263
|
if (idx < 0) throw new E(code, "no boundary delimiter found in the multipart body");
|
|
159
264
|
var pos = _afterLine(body, idx + delim.length, E, code);
|
|
160
265
|
var parts = [];
|
|
266
|
+
var counted = guard.limits.counter(C.LIMITS.MIME_MAX_PARTS, _factory(E), code, "the multipart body part");
|
|
161
267
|
while (true) {
|
|
268
|
+
counted.tick();
|
|
162
269
|
var next = _findDelim(body, delim, pos);
|
|
163
270
|
if (next < 0) throw new E(code, "an unterminated multipart body part (no closing boundary)");
|
|
164
271
|
var partEnd = next;
|
package/lib/smime.js
CHANGED
|
@@ -269,7 +269,6 @@ function _declaresHp(content) {
|
|
|
269
269
|
|
|
270
270
|
function _noneSurface() { return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: null, confidential: [], legacy: null } }; }
|
|
271
271
|
|
|
272
|
-
function _contentTypeCount(headers) { var n = 0; headers.forEach(function (h) { if (h.lname === "content-type") n++; }); return n; }
|
|
273
272
|
|
|
274
273
|
var RFC5322_SINGLETON = new Set(["date", "from", "sender", "reply-to", "to", "cc", "bcc", "message-id", "in-reply-to", "references", "subject", "return-path"]);
|
|
275
274
|
|
|
@@ -334,7 +333,6 @@ function _legacyHpSurface(canon, outerEnt, mode, enabled) {
|
|
|
334
333
|
if (partC.contentType.type !== "message/rfc822") return _noneSurface();
|
|
335
334
|
partD = mime.parse(partC.body, SmimeError, "smime/bad-header-protection");
|
|
336
335
|
} catch (_e) { return _noneSurface(); }
|
|
337
|
-
if (_contentTypeCount(partC.headers) > 1 || _contentTypeCount(partD.headers) > 1) return _noneSurface();
|
|
338
336
|
if (mime.hasParam(partD.contentType.value, "hp")) return _noneSurface();
|
|
339
337
|
if (_isCryptoLayer(partD.contentType.type)) return _noneSurface();
|
|
340
338
|
var ex = _extractProtected(partD.headers);
|
|
@@ -553,9 +551,6 @@ function _hpSurface(content, outerEnt, expectedMode, authenticated, legacyEnable
|
|
|
553
551
|
var canon = mime.canonicalizeText(content);
|
|
554
552
|
if (!_declaresHp(canon)) return _legacyHpSurface(canon, outerEnt, expectedMode, legacyEnabled);
|
|
555
553
|
var inner = mime.parse(canon, SmimeError, "smime/bad-header-protection");
|
|
556
|
-
var ctCount = _contentTypeCount(inner.headers);
|
|
557
|
-
if (ctCount > 1) throw _err("smime/bad-header-protection", "a header-protected payload must carry exactly one Content-Type field (found " + ctCount + ")");
|
|
558
|
-
if (mime.paramNameCount(inner.contentType.value, "hp") > 1) throw _err("smime/bad-header-protection", "a header-protected payload declares the hp parameter more than once");
|
|
559
554
|
var raw = inner.contentType.params.hp;
|
|
560
555
|
if (raw === undefined) throw _err("smime/bad-header-protection", "a header-protected payload declares a bare hp parameter with no value (expected hp=\"clear\" or hp=\"cipher\")");
|
|
561
556
|
var hp = raw.toLowerCase();
|
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:c9865553-89a9-487f-aef1-fe53df25b0ea",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-10-
|
|
8
|
+
"timestamp": "2026-10-08T04:38:54.565Z",
|
|
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.9.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.9.1",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.1",
|
|
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.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.9.1",
|
|
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.9.
|
|
57
|
+
"ref": "@blamejs/pki@0.9.1",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|