@blamejs/pki 0.8.30 → 0.8.31
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 +18 -0
- package/README.md +1 -1
- package/lib/constants.js +2 -0
- package/lib/inspect.js +419 -0
- package/lib/schema-pkix.js +39 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ 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.8.31 — 2026-09-29
|
|
8
|
+
|
|
9
|
+
A structural dump of any DER file, and it still reads the file when the strict decoder refuses it.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `pki.inspect.asn1(input, opts?)` takes a PEM string or a DER Buffer and returns the structural dump as a string: per value the byte offset, the nesting depth, the header length, the content length, whether the value is constructed, the tag name, and for a primitive its rendered content. The offset, depth, header length, content length and form are written in the same fixed columns `openssl asn1parse` uses and are compared against it line for line across every seed corpus the toolkit ships, so the two outputs can be diffed directly. An OBJECT IDENTIFIER carries its dotted form and the name the registry has for it, which is the part `openssl` shows only for the OIDs its own table knows. An INTEGER carries its exact magnitude, read from the content bytes, with the bytes beside it once the value passes what a machine integer holds.
|
|
14
|
+
- A UTCTime or a GeneralizedTime renders the bytes as written rather than a formatted date. A dump is what you reach for when a file is wrong, and a time normalized through a Date hides the malformation being looked for: a month of 13 rolls into January of the next year and reads as valid.
|
|
15
|
+
- The report says which route produced it. `pki.asn1.decode` reads the input first. If it refuses, the first two lines name the walk and the refusal with its code and message, so a file that is not DER is explained rather than rejected: a truncated file, bytes after the top-level value, an indefinite length with its end-of-contents octets, a segmented OCTET STRING, a non-minimal long-form length, a SEQUENCE encoded primitive. The last line always states the outcome: the offset where the structure ends, or the offset of the value the walk could not read through and why, or the bytes that follow the top-level value, or the cap that stopped the report and where the next value begins. A partial dump cannot be read as a whole structure.
|
|
16
|
+
- The walk returns text and never a node tree, so nothing downstream can consume a tolerantly read structure and no verify path is reached by it. It carries the same caps a parse carries, and the report is bounded on a hostile file rather than becoming the denial of service the parsers refuse: nesting past the depth cap and a report past the node cap each stop and say so on the last line, and a value past the byte cap renders the bytes the cap allows and states how many it left out. The node cap reaches the decoder as its item cap too, so a file of many small values never builds a tree larger than the report will print. `opts.maxBytes`, `opts.maxDepth`, `opts.maxNodes` and `opts.maxValueBytes` set the four per call, defaulting to `C.LIMITS.DER_MAX_BYTES`, `C.LIMITS.DER_MAX_DEPTH` and the new `C.LIMITS.DUMP_MAX_NODES` and `C.LIMITS.DUMP_MAX_VALUE_BYTES`. `maxBytes` tightens only: the shared parse door carries its own byte ceiling and applies it first, so a value above `C.LIMITS.DER_MAX_BYTES` is refused rather than accepted and then contradicted.
|
|
17
|
+
- This verb withholds no value, which is what a byte-level dump is for and the one place in `pki.inspect` where it is true: where the other reports name a key and print none of it, this one prints bytes, private key material included. The value cap bounds how much of a value is rendered rather than whether it is, so it controls report size and not disclosure. `pki.inspect.any` does not route to it, so a report reached by format detection still names a key without printing it, and `pki.inspect.pkcs8` remains the report for a key file.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Every verb that accepts a DER Buffer or a PEM block in the same argument now reads a Buffer that is valid DER as DER, even where the bytes could also pass for armored text. A short DER header can be made entirely of printable octets, so a value whose content carried a `-----BEGIN` line was classified as PEM and then refused: an APPLICATION 3 primitive is identifier `0x43` and any content length from 32 to 126 is a printable length octet, which is enough to satisfy a byte scan looking for armor. Bytes that do not decode as one complete DER value are armor, which is the resolution `pki convert` already took, so a PEM file read into a Buffer still decodes as PEM, including one carrying a byte-order mark or explanatory text before its armor.
|
|
22
|
+
- A PEM block whose preamble carries a vertical tab or a form feed is recognized as PEM when the file is read into a Buffer. The rule has always been that a boundary preceded by a byte no text file carries is not armor, since DER is binary, but the byte set behind that rule left out two of the six ASCII whitespace characters, so such a file was handed to the DER parser and refused there. All six now pass. Every other byte stays outside the set: a preamble holding a NUL or a high byte means the buffer is not text, and a PEM file that genuinely carries one is passed as a string.
|
|
23
|
+
- Where a Buffer satisfies BOTH readings it is now refused with `<prefix>/bad-input` rather than read as one of them. A primitive tag makes any content valid, so a value whose declared length covers exactly the armored text after it is at once one complete DER value and a well-formed PEM block, and either choice hands a caller the other reading's bytes without saying so. The message names the two unambiguous ways to state which was meant: pass PEM as a string, or pass the DER value on its own. This is a coercion behavior for a byte source only; PEM passed as a string is unaffected.
|
|
24
|
+
|
|
7
25
|
## v0.8.30 — 2026-09-29
|
|
8
26
|
|
|
9
27
|
The 0.8.30 tarball is the one to install for everything 0.8.29 added.
|
package/README.md
CHANGED
|
@@ -288,7 +288,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
288
288
|
| `pki.hpke` | Hybrid Public Key Encryption (RFC 9180), the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS` and `setupR` establish a sender or recipient context (KEM encapsulation plus the HKDF key schedule); the context's `seal` and `open` AEAD-encrypt with a sequence-counter nonce, and `export` derives further secrets; the module-level `seal` and `open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) in all four modes, proven against the RFC 9180 Appendix A vectors, and the post-quantum ML-KEM-512/768/1024 KEMs (IANA 0x0040 to 0x0042) in the base and psk modes, proven against the draft-ietf-hpke-pq Appendix A vectors; HKDF-SHA256/SHA384/SHA512 by AES-GCM / ChaCha20Poly1305 / export-only. DHKEM(P-384) has no vector under an HKDF key schedule and fails closed. Pure composition over `node:crypto`; the PQ/T hybrid KEMs are a registry data-row extension once the CFRG combiner draft stabilizes — `suites`, `setupS`, `setupR`, `seal`, `open` |
|
|
289
289
|
| `pki.kem` | Composite ML-KEM key establishment (draft-ietf-lamps-pq-composite-kem), a post-quantum ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448 so the established secret holds if either component is later broken. `encapsulate` turns a recipient's composite `SubjectPublicKeyInfo` into a 256-bit shared secret and a ciphertext; `decapsulate` recovers the same secret from the ciphertext and the composite PKCS#8 private key. Each component KEM runs independently and the two secrets are mixed through a SHA3-256 combiner that binds the traditional ciphertext, the traditional public key, and a per-algorithm label. The twelve algorithms pair ML-KEM-768 and ML-KEM-1024 with RSA-OAEP 2048/3072/4096, ECDH over P-256/P-384/P-521 and brainpoolP256r1/P384r1, X25519, and X448, each verified against the draft Appendix G known-answer vectors; a malformed key or ciphertext, an unsupported algorithm, or a component decapsulation failure throws a typed `KemError` — `encapsulate`, `decapsulate` |
|
|
290
290
|
| `pki.sigstore` | Offline verifier for a Sigstore bundle, the artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots and 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. It reuses the X.509 parser, the RFC 5280 path validator, and the Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `verified: true` says the artifact was signed and logged, not that a party you trust signed it — Fulcio issues to anyone who completes an OIDC flow, so who signed is decided only by `opts.identity`, and `identityChecked` reports which of its fields were compared. An identity policy naming no field, or a field name that is not one of the three, is refused rather than satisfied — either would accept every signer while reading as a policy in force. A sixth leg is opt-in: pinning the certificate-transparency logs in `opts.ctLogs` checks the receipt Fulcio embeds in the certificate it issues (RFC 6962 §3.2), verified over the certificate as it stood before the receipt was added and under the key of the log that issued it, so the verdict says the signing certificate was public when it was issued rather than handed out quietly. At least one receipt must verify against a pinned log, the verdict reports `sctChecked` and `validScts`, and an empty log set is refused rather than read as a policy that checks nothing. Both content arms are read: a DSSE-wrapped in-toto attestation, and a message signature over an artifact's own bytes, which `cosign sign-blob` produces. For that arm `opts.artifact` supplies the bytes, and they are held to the hash the Rekor `hashedrekord` entry records before the signature is checked over them; the `messageDigest` the bundle carries is covered by no signature, so it is compared against the computed digest and never used in its place. `contentType` names the arm and the verdict has one shape for both — `pae`, `parseBundle`, `verifyBundle` |
|
|
291
|
-
| `pki.inspect` | Human-readable inspection, the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders an OpenSSL-style report: version, serial, signature algorithm, issuer and subject distinguished names, validity, public-key details (curve or modulus size plus the raw point or modulus), every decoded extension with its critical flag, and the signature. `crl`, `csr`, and `cms` render the other formats the same way — a CRL like `openssl crl -text`, a CSR like `openssl req -text`, and a CMS message like `openssl cms -cmsout -print`, with a stable summary for a non-SignedData ContentInfo — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry with one set of field renderers, it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency, and the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version. A certificate policy's user notice renders as text, both 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. Every format `pki.schema.all()` detects has a report and its own named verb, so a PKCS#8 key, a PKCS#12 store, a CRMF request, a CMP message, an EST CSR-attributes response, an RFC 5914 trust anchor list, an OCSP request or response, an RFC 3161 timestamp response and an RFC 5755 attribute certificate each read the same way a certificate does. No report prints private key material: a PKCS#8 and a PKCS#12 name the algorithm and say a private key is present, never its bytes, and a PKCS#12's encrypted safes are named rather than decrypted since these verbs take no password — `certificate`, `crl`, `csr`, `cms`, `pkcs8`, `pkcs12`, `crmf`, `cmp`, `csrattrs`, `trustanchor`, `ocspRequest`, `ocspResponse`, `tsp`, `attrcert`, `any` |
|
|
291
|
+
| `pki.inspect` | Human-readable inspection, the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders an OpenSSL-style report: version, serial, signature algorithm, issuer and subject distinguished names, validity, public-key details (curve or modulus size plus the raw point or modulus), every decoded extension with its critical flag, and the signature. `crl`, `csr`, and `cms` render the other formats the same way — a CRL like `openssl crl -text`, a CSR like `openssl req -text`, and a CMS message like `openssl cms -cmsout -print`, with a stable summary for a non-SignedData ContentInfo — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry with one set of field renderers, it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency, and the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version. A certificate policy's user notice renders as text, both 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. Every format `pki.schema.all()` detects has a report and its own named verb, so a PKCS#8 key, a PKCS#12 store, a CRMF request, a CMP message, an EST CSR-attributes response, an RFC 5914 trust anchor list, an OCSP request or response, an RFC 3161 timestamp response and an RFC 5755 attribute certificate each read the same way a certificate does. No report prints private key material: a PKCS#8 and a PKCS#12 name the algorithm and say a private key is present, never its bytes, and a PKCS#12's encrypted safes are named rather than decrypted since these verbs take no password. `asn1(input)` is the structural dump below the reports, shaped after `openssl asn1parse`: one line per value carrying the byte offset, the nesting depth, the header and content lengths, the form, the tag name, and a primitive's rendered content, with an OBJECT IDENTIFIER named from the registry and a time rendered as the bytes on the wire. A conforming input is read by the same strict decoder the parsers use; when that decode refuses the bytes, the report names the refusal and a header walk renders the prefix it can read and the offset where it stopped, so a truncated, trailing-byte, indefinite-length or non-minimal-length file still reads. It withholds no value: where the other verbs here name a key and print none of it, this one prints bytes, private key material included, so a report that must not print a key is `any` or `pkcs8` — `certificate`, `crl`, `csr`, `cms`, `pkcs8`, `pkcs12`, `crmf`, `cmp`, `csrattrs`, `trustanchor`, `ocspRequest`, `ocspResponse`, `tsp`, `attrcert`, `asn1`, `any` |
|
|
292
292
|
| `pki.webauthn` | WebAuthn and passkey verification, both halves: offline trust evaluation of a W3C WebAuthn (Level 3) registration, and signature verification of the assertion every login returns. `parseAttestationObject(bytes)` decodes the CBOR attestation object, authenticatorData, and COSE credential key over the strict `pki.cbor` codec; `parseAuthenticatorData(bytes)` reads the bare form an assertion carries through the same parser; `parseClientData(bytes, opts)` decodes the `clientDataJSON` no signature check looks inside, through the shared JSON guard since these are attacker-chosen bytes, returning the challenge decoded so a caller compares bytes rather than spellings, and checking the ceremony type, challenge, and origin when the relying party supplies what it issued. `verifyAssertion(input)` verifies an assertion signature over `authenticatorData \|\| SHA-256(clientDataJSON)` — raw bytes, no COSE_Sign1, an ES256 signature in ASN.1 DER — and applies the §7.2 step 21 counter rule when a stored `previousSignCount` is given, so a counter that fails to advance is refused as a cloned authenticator. `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for packed, tpm, android-key, apple, fido-u2f, and none: the x5c leaf key, the apple nonce, the tpm `certInfo` Name and `extraData` over the `pubArea`, the android `KeyDescription`, and the fido-u2f `verificationData`. It binds the credential public key to each attestation, through the signed authenticatorData for packed and fido-u2f or a cert or `pubArea`-key equality check for android-key, apple, and tpm, and enforces each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256/384/512, 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. The verdict field is `attestationVerified`, and `signatureVerified` for an assertion, rather than a bare `verified`, because a sound statement is a different claim from an acceptable ceremony: an attestation naming another origin's RP ID with user presence clear is perfectly sound and must not be registered. Pass `expectedRpId`, `requireUserPresence`, `requireUserVerification`, or `allowedAlgorithms` and those are checked, with `bindingChecked` reporting which ran, so a check that passed can be told from one that never happened. Pass `opts.clientDataJSON` instead of the digest and the client data is read here too: the ceremony type is checked unconditionally, since a login response replayed into a registration is what that rule stops, and the challenge, origin, and top-level origin are checked against what the relying party issued, with `clientData.checked` reporting which comparisons ran. A registration verdict also carries the `credentialId`, `credentialPublicKey`, and initial `signCount` a later login needs. A credential key declaring COSE algorithm `-65535` (RSASSA-PKCS1-v1_5 with SHA-1) is refused unless `allowedAlgorithms` names it, since every signature that credential ever makes would use SHA-1. Anchoring the trust path has two routes: `opts.metadata` resolves the roots the authenticator's own model registered, and `opts.rootCertificates` pins roots directly, which is what anchors the formats FIDO MDS does not cover, Apple's authenticators and the Google hardware-attestation roots among them. `metadata` governs when both are given, and `anchoredTo` names every route that anchored the path, joined with `+` when more than one did: `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is `null` only when nothing anchored the path. `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. `metadataAnchors(entry, opts)` applies the same status gate for a caller anchoring the path themselves: a model the catalogue has disqualified registers no anchors. 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, there is no trust-on-first-use, and retrieving the BLOB is out of scope. Fail-closed with typed `webauthn/*` errors — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
|
|
293
293
|
| `pki.lint` | Certificate linting, the zlint or 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, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, the six extensions a conforming CA must mark non-critical (reported by clause rather than as unrecognized) and the two it should (issuerAltName, cRLDistributionPoints), a policy mapping to or from anyPolicy, a unique identifier a conforming CA must not generate, a critical subjectAltName beside a non-empty subject, a critical extKeyUsage naming anyExtendedKeyUsage, a userNotice noticeRef, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the CA/Browser Forum subscriber certificate profile of §7.1.2.7 (the extension presence and criticality table, the authorityInformationAccess accessMethod and accessLocation tables, basicConstraints cA and pathLenConstraint, certificatePolicies against anyPolicy and the exactly-one reserved identifier rule and the permitted-qualifier table, the forbidden extended key purposes, the two Key Usage tables that differ by key type, and the subjectAltName GeneralName table with its criticality rule in both directions), the CA/Browser Forum S/MIME BR subscriber profile of §7.1.2.3 (a) through (n) as `cabf-smime`, whose requirements follow the generation a certificate declares through its reserved policy identifier (the distribution-point and accessMethod URI scheme rules, the policy qualifier rules for an id-qt-cps URL and an id-qt-unotice explicitText and noticeRef, the key-usage table for the certificate's own key algorithm including a bit set past the nine named positions, the extended key purposes forbidden at every generation and the ones Strict alone forbids, the authorityKeyIdentifier field rules, a required mailbox in the subjectAltName as an rfc822Name or an id-on-SmtpUTF8Mailbox otherName, the criticality each extension must carry, the subjectDirectoryAttributes and Adobe extensions the Strict generation prohibits, the Legal Entity Identifier extensions keyed on the certificate type rather than the generation, and the Legacy generation retired on 15 July 2025), the CA/Browser Forum Code Signing BR subscriber profile of §7.1.2.3 as `cabf-cs`, whose rows follow the kind a certificate declares through one of the three reserved identifiers of §7.1.6.1 (the extension presence and criticality rules at the strengths this document states them, a HTTP URL required in the distribution points and in each accessMethod location, a critical keyUsage asserting digitalSignature with `keyCertSign` and `cRLSign` forbidden and every other bit discouraged, the extended key purpose each kind must carry with the critical extKeyUsage a timestamp certificate needs, the §7.1.6.4 exactly-one-identifier rule gated to 15 September 2026, the §6.3.2 ceilings of 39 months, 460 days and 135 months, and the §6.1.5.2 floors of a 3072-bit RSA modulus, NIST P-256, P-384 or P-521, or DSA parameters of 2048 bits with a 224- or 256-bit modulus length, and the §7.1.4.2 subject distinguished name rows: a required commonName, a prohibited domainComponent, a subject attribute made only of the metadata characters the clause names, a required organizationName and two-letter countryName, one of localityName and stateOrProvinceName, and on an EV certificate a businessCategory from the four strings the clause names, a jurisdictionCountryName and a serialNumber), the ETSI qualified-certificate profile of EN 319 412-5 as `etsi-qc` (a non-critical qcStatements extension, a QcType naming one and only one of the three purposes the clause defines, a QcPDS providing an https URL and, on an EU qualified certificate, a document in English and no more than one per language, plus the EN 319 412-1 semantics identifiers holding a subject serialNumber or organizationIdentifier to a three-character identity type reference, a two-character country code, a hyphen-minus and an identifier, with XG required of an LEI value and a name registration authority URI required of a locally defined reference), the Mozilla Root Store Policy as `mozilla-root`, which is named rather than detected because a root program's requirements apply by virtue of the store a certificate chains to (the §5.1 AlgorithmIdentifier encodings compared as DER, an RSA modulus of at least 2048 bits and a whole number of octets, a public exponent above 1, an EdDSA key only beside id-kp-emailProtection, the §5.1.3 SHA-1 prohibitions on their own dates, a serial greater than zero with a short magnitude reported as a warning, an extKeyUsage on every end entity without anyExtendedKeyUsage, and the §5.3 intermediate rules from 2019, which pass over a certificate issued in its own name), the Chrome Root Program Policy v1.8 as `chrome-root`, named on the same grounds (the §1.3.2 dedicated-hierarchy rules holding a subordinate CA certificate's extKeyUsage to id-kp-serverAuth with id-kp-clientAuth reported as a warning, scoped to the unexpired certificates the clause names by passing over a notAfter before 15 June 2026, the same rules over a subscriber certificate issued on or after 15 March 2027 where id-kp-serverAuth stands alone and an extKeyUsage naming id-kp-OCSPSigning and nothing else is a delegated OCSP responder rather than a subscriber, the §1.3.1.3 three-year subordinate CA validity ceiling, and the §1.3.1.2 root term limit, which reports a self-signed CA certificate whose validity runs past the removal date its own notBefore fixes and passes over a CA reissued in its own name whose authorityKeyIdentifier names a key other than its own subjectKeyIdentifier), the Microsoft Trusted Root Program requirements as `microsoft-root`, named on the same grounds (the §3.A.1 root commonName and the keyUsage that must be present, critical and set keyCertSign and cRLSign, the §3.A.3 term of eight to 25 years, the §3.A.5 revocation pointer and §3.A.8 separation of uses on an issuing CA, the §3.A.10 seventeen reserved policy identifiers on an end entity, the §3.A.14 exclusivity of id-kp-OCSPSigning, the §3.B signature and key table including its code signing column, and the §3.E.2 extended key usages the program enables on a root), the Apple Root Program Policy as `apple-root`, named on the same grounds (its Appendix A trust purposes read as extended key usage sets, the §1.5 root key floor of RSA 4096 or NIST P-384, the §1.7 subordinate CA rules gated to 1 August 2026 and 1 July 2027, the §2.3 rfc822Name an S/MIME subscriber needs from 1 February 2027, and the subscriber purpose scoping that binds when its permission expires on 1 July 2027), the two algorithm-policy profiles, named for the same reason a root program's are (which policy a certificate is held to is the caller's assertion): `nist-pqc-transition` carries NIST IR 8547's two transitions, an error for a classical subject key or signature algorithm in a certificate issued from 2036 and a warning for a subject key at or below the 112-bit security strength issued from 2031, with the strength read off SP 800-57 Part 1 Rev 5 Table 2 and a composite signature passed over as the dual-algorithm operation the guidance endorses; and `cnsa-2.0` carries the suite profile of `draft-jenkins-cnsa2-pkix-profile-05` (ML-DSA-87 or ML-KEM-1024 as the subject public key, ML-DSA-87 as the signature, and the extension, criticality and key-usage rules each of the three certificate kinds carries, where the key-usage set follows the kind the subject key declares), the same signature rule reaching `crl` and `ocsp` under that name because the document states it once for all three, the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or 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). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `crl` (the RFC 5280 §5 revocation-list profile: nextUpdate presence, update ordering, cRLNumber presence and its 20-octet ceiling, authorityKeyIdentifier presence, the criticality each profiled CRL and entry extension must carry, freshestCRL in a delta CRL, and unrecognized critical extensions), `ocsp` (the RFC 6960 response profile: an empty signature, each SingleResponse's update ordering and its relation to producedAt, an empty `certs` field, the extended revoked definition and the fixed shape of a revoked answer for a non-issued certificate, the list each extension belongs in, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, unrecognized critical extensions, the reasons unspecified and removeFromCRL, and two answers for one CertID; the RFC 5019 lightweight rows run on request), `csr` (the artifact a CA reads before it issues: an empty subject naming no identity, an empty subject whose subjectAltName is not critical, a request for an extension the issuer determines, a request asking for a CA certificate, a broken signing digest, and a challengePassword traveling with the request; `opts.profile: "cabf-tls"` adds the certificate profile's own commonName-in-SAN, dNSName syntax, SAN-required and key-strength rows, applied to what the request asks for, and they run only when named because a request carries no extKeyUsage. It does not check the request's signature, which is a cryptographic operation `pki.csr.verify` owns), `cms` (the RFC 5652 profile: a signing-time naming a year from 1950 to 2049 encoded as GeneralizedTime where §11.3 requires UTCTime, a signer digest the digestAlgorithms set does not list, a version 1 attribute certificate, and notices for a SignedData with no signers and for a detached signature), `tsp` (the RFC 3161 profile: a tsa hint naming a subject the token's own certificate does not carry, a serial wider than 160 bits, and a token stating no accuracy), `attrcert` (the RFC 5755 profile: the §4.3 criticality table, a critical extension the profile does not define, a Holder naming more than one option, and an issuerUniqueID), each short because the strict parser behind it already refuses most of what its document requires and those clauses arrive as `lint/unparseable` carrying the parser's own code, `rules`, `profiles` |
|
|
294
294
|
| `pki.C` / `pki.constants` | Version-stable constants: the functional scale helpers `C.TIME.*` and `C.BYTES.*`, the codec `LIMITS`, and `version` |
|
package/lib/constants.js
CHANGED
|
@@ -112,6 +112,8 @@ var LIMITS = Object.assign(Object.create(null), {
|
|
|
112
112
|
DER_MAX_BYTES: BYTES.mib(16),
|
|
113
113
|
DER_MAX_DEPTH: 64,
|
|
114
114
|
DER_MAX_ITEMS: 4000000,
|
|
115
|
+
DUMP_MAX_NODES: 65536,
|
|
116
|
+
DUMP_MAX_VALUE_BYTES: 256,
|
|
115
117
|
NAMED_SCAN_MAX_ELEMENTS: 16777216,
|
|
116
118
|
MAX_DECODE_DEPTH_CEILING: 256,
|
|
117
119
|
PEM_MAX_BYTES: BYTES.mib(16),
|
package/lib/inspect.js
CHANGED
|
@@ -1983,6 +1983,424 @@ function attrCertReport(input) {
|
|
|
1983
1983
|
return L.join("\n") + "\n";
|
|
1984
1984
|
}
|
|
1985
1985
|
|
|
1986
|
+
/** @internal The tag names, inverted out of `asn1.TAGS` so the dump cannot name a universal type
|
|
1987
|
+
* the codec does not have. Tag 0 is the end-of-contents marker X.690 sec. 8.1.5 reserves; the codec
|
|
1988
|
+
* consumes it rather than naming it, and the header walk renders it as a node of its own. */
|
|
1989
|
+
var _DUMP_TAG_NAMES = (function () {
|
|
1990
|
+
var out = Object.create(null);
|
|
1991
|
+
out[0] = "EOC";
|
|
1992
|
+
Object.keys(asn1.TAGS).forEach(function (key) {
|
|
1993
|
+
var name = "";
|
|
1994
|
+
for (var i = 0; i < key.length; i++) name += key[i] === "_" ? " " : key[i];
|
|
1995
|
+
out[asn1.TAGS[key]] = name;
|
|
1996
|
+
});
|
|
1997
|
+
return out;
|
|
1998
|
+
})();
|
|
1999
|
+
var _DUMP_CLASS_LABEL = Object.assign(Object.create(null), {
|
|
2000
|
+
universal: "UNIVERSAL", application: "APPLICATION", private: "PRIVATE",
|
|
2001
|
+
});
|
|
2002
|
+
var _DUMP_CLASS_OF = Object.assign(Object.create(null), {
|
|
2003
|
+
0x00: "universal", 0x40: "application", 0x80: "context", 0xc0: "private",
|
|
2004
|
+
});
|
|
2005
|
+
var _DUMP_TEXT_TAGS = Object.assign(Object.create(null),
|
|
2006
|
+
{ 12: 1, 18: 1, 19: 1, 20: 1, 22: 1, 23: 1, 24: 1, 26: 1, 27: 1, 28: 1, 30: 1 });
|
|
2007
|
+
var _DUMP_MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
2008
|
+
var _DUMP_NAME_COLUMN = 26;
|
|
2009
|
+
var _DUMP_OFFSET_COLUMN = 5;
|
|
2010
|
+
var _DUMP_LENGTH_COLUMN = 4;
|
|
2011
|
+
var _DUMP_MAX_TAG_OCTETS = 4;
|
|
2012
|
+
var _DUMP_MAX_LENGTH_OCTETS = 6;
|
|
2013
|
+
|
|
2014
|
+
function _padLeft(s, w) { return s.length >= w ? s : " ".repeat(w - s.length) + s; }
|
|
2015
|
+
|
|
2016
|
+
/** @internal `length` decides one name: X.690 sec. 8.1.5 gives the end-of-contents value a zero
|
|
2017
|
+
* length, so a tag-0 value carrying content is not one and is named for its class and number.
|
|
2018
|
+
* Pass -1 where the length is indefinite, which an end-of-contents value never is. */
|
|
2019
|
+
function _dumpTagName(tagClass, tagNumber, length) {
|
|
2020
|
+
if (tagClass === "context") return "[" + tagNumber + "]";
|
|
2021
|
+
if (tagClass === "universal" && tagNumber === 0 && length !== 0) return _DUMP_CLASS_LABEL.universal + " [0]";
|
|
2022
|
+
if (tagClass === "universal" && _DUMP_TAG_NAMES[tagNumber] !== undefined) return _DUMP_TAG_NAMES[tagNumber];
|
|
2023
|
+
return _DUMP_CLASS_LABEL[tagClass] + " [" + tagNumber + "]";
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function _dumpLine(offset, depth, hl, len, constructed, name, value) {
|
|
2027
|
+
var head = _padLeft(String(offset), _DUMP_OFFSET_COLUMN) + ":d=" + depth + " hl=" + hl +
|
|
2028
|
+
" l=" + _padLeft(String(len), _DUMP_LENGTH_COLUMN) + (constructed ? " cons:" : " prim:");
|
|
2029
|
+
var field = " ".repeat(depth) + name;
|
|
2030
|
+
if (value === null) return head + " " + field;
|
|
2031
|
+
/** @internal One space always separates the name from the value, so a field the column cannot
|
|
2032
|
+
* hold (a long type name at depth) gets that space rather than running into the colon. */
|
|
2033
|
+
var pad = _DUMP_NAME_COLUMN - field.length;
|
|
2034
|
+
return head + " " + field + (pad > 0 ? " ".repeat(pad) : " ") + ":" + value;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function _dumpPrefixed(prefix, omitted) {
|
|
2038
|
+
if (omitted === 0) return prefix;
|
|
2039
|
+
var note = "(" + omitted + " more byte" + (omitted === 1 ? "" : "s") + " not rendered)";
|
|
2040
|
+
return prefix.length === 0 ? note : prefix + " " + note;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function _dumpHex(buf, limit) {
|
|
2044
|
+
if (buf.length === 0) return "(empty)";
|
|
2045
|
+
var keep = buf.length <= limit ? buf.length : limit;
|
|
2046
|
+
return _dumpPrefixed(_hexColon(buf.subarray(0, keep), {}), buf.length - keep);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function _dumpText(buf, limit) {
|
|
2050
|
+
var keep = buf.length <= limit ? buf.length : limit;
|
|
2051
|
+
return _dumpPrefixed(buf.subarray(0, keep).toString("latin1"), buf.length - keep);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/** @internal The magnitude read from the content bytes as a two's-complement BigInt in one pass,
|
|
2055
|
+
* behind the same per-value byte cap `asn1.read.integer` applies, because a byte-at-a-time
|
|
2056
|
+
* accumulate over attacker-length content is quadratic. Past that cap the bytes are rendered and
|
|
2057
|
+
* no magnitude is computed. The hex beside a magnitude is the content as written, so a leading zero
|
|
2058
|
+
* octet survives; a magnitude rendered back to hex would drop it. */
|
|
2059
|
+
function _dumpInteger(content, limit) {
|
|
2060
|
+
if (content.length === 0) return "(empty)";
|
|
2061
|
+
if (content.length > constants.LIMITS.DER_MAX_INTEGER_BYTES + 1) return _dumpHex(content, limit);
|
|
2062
|
+
var mag = BigInt("0x" + content.toString("hex"));
|
|
2063
|
+
var v = (content[0] & 0x80) !== 0 ? mag - (1n << BigInt(content.length * 8)) : mag;
|
|
2064
|
+
var text = v.toString();
|
|
2065
|
+
if (v <= _DUMP_MAX_SAFE && v >= -_DUMP_MAX_SAFE) return text;
|
|
2066
|
+
return text + " (0x" + content.toString("hex") + ")";
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function _dumpBitString(content, limit) {
|
|
2070
|
+
if (content.length === 0) return "(empty)";
|
|
2071
|
+
return content[0] + " unused bits, " + _dumpHex(content.subarray(1), limit);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function _dumpOid(content, limit) {
|
|
2075
|
+
var dotted;
|
|
2076
|
+
try { dotted = asn1.decodeOidContent(content); }
|
|
2077
|
+
catch (_e) { return _dumpHex(content, limit); }
|
|
2078
|
+
var name = oid.name(dotted);
|
|
2079
|
+
return name === undefined ? dotted : dotted + " (" + name + ")";
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
/** @internal `maxValueBytes` bounds every rendering whose size grows with the content length. A hex
|
|
2083
|
+
* dump and a text rendering are truncated and the omitted count stated, because a prefix of either
|
|
2084
|
+
* is still true of the value. A magnitude and a dotted OID are read from the whole content and
|
|
2085
|
+
* cannot be rendered from a prefix at all, so past the cap the bytes are what the report can
|
|
2086
|
+
* honestly show. A decoded form of fixed size renders whatever the cap is, since it cannot grow the
|
|
2087
|
+
* report: a BOOLEAN's verdict, and a BIT STRING's unused-bit count above its capped body. */
|
|
2088
|
+
function _dumpValue(tagClass, tagNumber, content, limit) {
|
|
2089
|
+
if (tagClass !== "universal") return _dumpHex(content, limit);
|
|
2090
|
+
if (tagNumber === asn1.TAGS.BOOLEAN) {
|
|
2091
|
+
if (content.length === 1 && content[0] === 0xff) return "TRUE";
|
|
2092
|
+
if (content.length === 1 && content[0] === 0x00) return "FALSE";
|
|
2093
|
+
return _dumpHex(content, limit);
|
|
2094
|
+
}
|
|
2095
|
+
if (tagNumber === asn1.TAGS.INTEGER || tagNumber === asn1.TAGS.ENUMERATED) {
|
|
2096
|
+
return content.length <= limit ? _dumpInteger(content, limit) : _dumpHex(content, limit);
|
|
2097
|
+
}
|
|
2098
|
+
if (tagNumber === asn1.TAGS.BIT_STRING) return _dumpBitString(content, limit);
|
|
2099
|
+
if (tagNumber === asn1.TAGS.NULL) return content.length === 0 ? null : _dumpHex(content, limit);
|
|
2100
|
+
if (tagNumber === asn1.TAGS.OBJECT_IDENTIFIER) {
|
|
2101
|
+
return content.length <= limit ? _dumpOid(content, limit) : _dumpHex(content, limit);
|
|
2102
|
+
}
|
|
2103
|
+
if (_DUMP_TEXT_TAGS[tagNumber] === 1 && _printable(content)) return _dumpText(content, limit);
|
|
2104
|
+
return _dumpHex(content, limit);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function _dumpBytes(n) { return n + (n === 1 ? " byte" : " bytes"); }
|
|
2108
|
+
function _dumpNodes(n) { return n + (n === 1 ? " node" : " nodes"); }
|
|
2109
|
+
function _dumpRemaining(n) { return _dumpBytes(n) + (n === 1 ? " that remains." : " that remain."); }
|
|
2110
|
+
|
|
2111
|
+
/** @internal The node cap records the offset it stopped at, not only that it fired: a report that
|
|
2112
|
+
* covers part of a file has to name where that part ends or a reader cannot tell what it covers. */
|
|
2113
|
+
function _dumpCapped(st, offset) {
|
|
2114
|
+
st.capped = true;
|
|
2115
|
+
st.capOffset = offset;
|
|
2116
|
+
return -1;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** @internal Renders a tree the strict decoder produced. It applies the node cap and not the depth
|
|
2120
|
+
* cap, because the decode that built this tree was given the same depth cap and refuses a deeper
|
|
2121
|
+
* one, so no node reaching here is past it. */
|
|
2122
|
+
function _dumpNode(node, depth, st) {
|
|
2123
|
+
if (st.capped) return;
|
|
2124
|
+
if (st.nodes >= st.maxNodes) { _dumpCapped(st, node.header.start); return; }
|
|
2125
|
+
st.nodes += 1;
|
|
2126
|
+
var hl = node.header.end - node.header.start;
|
|
2127
|
+
var name = _dumpTagName(node.tagClass, node.tagNumber, node.length);
|
|
2128
|
+
/** @internal `content` and `children` are read without a fallback: a decode gives every primitive
|
|
2129
|
+
* a Buffer content, empty where the value is, and every constructed node a children array. A
|
|
2130
|
+
* fallback for either would be dead code standing in for a shape the decoder guarantees. */
|
|
2131
|
+
if (!node.constructed) {
|
|
2132
|
+
st.lines.push(_dumpLine(node.header.start, depth, hl, node.length, false, name,
|
|
2133
|
+
_dumpValue(node.tagClass, node.tagNumber, node.content, st.maxValueBytes)));
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
st.lines.push(_dumpLine(node.header.start, depth, hl, node.length, true, name, null));
|
|
2137
|
+
var kids = node.children;
|
|
2138
|
+
for (var i = 0; i < kids.length; i++) {
|
|
2139
|
+
_dumpNode(kids[i], depth + 1, st);
|
|
2140
|
+
if (st.capped) return;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/** @internal The header-by-header walk that runs when the strict decoder refuses the input. It reads
|
|
2145
|
+
* a tag and a length, renders the node, and on the first byte it cannot read records the offset and
|
|
2146
|
+
* the reason and unwinds. It returns the offset one past the value, or -1 once a stop or the node cap
|
|
2147
|
+
* has been recorded. It produces text and never a node, so nothing a lax read accepted can reach a
|
|
2148
|
+
* verify path, and it carries the caller's depth and node caps because it walks hostile nesting
|
|
2149
|
+
* exactly as a parse does. */
|
|
2150
|
+
function _dumpWalk(buf, start, limit, depth, st) {
|
|
2151
|
+
if (st.stop !== null || st.capped) return -1;
|
|
2152
|
+
if (depth > st.maxDepth) {
|
|
2153
|
+
st.stop = { offset: start, reason: "nesting exceeds the depth cap " + st.maxDepth + "." };
|
|
2154
|
+
return -1;
|
|
2155
|
+
}
|
|
2156
|
+
if (st.nodes >= st.maxNodes) return _dumpCapped(st, start);
|
|
2157
|
+
var p = start;
|
|
2158
|
+
if (p >= limit) {
|
|
2159
|
+
st.stop = { offset: start, reason: "the input ends where an identifier octet was expected." };
|
|
2160
|
+
return -1;
|
|
2161
|
+
}
|
|
2162
|
+
var first = buf[p]; p += 1;
|
|
2163
|
+
var constructed = (first & 0x20) !== 0;
|
|
2164
|
+
var tagClass = _DUMP_CLASS_OF[first & 0xc0];
|
|
2165
|
+
var tagNumber = first & 0x1f;
|
|
2166
|
+
if (tagNumber === 0x1f) {
|
|
2167
|
+
tagNumber = 0;
|
|
2168
|
+
var seen = 0;
|
|
2169
|
+
for (;;) {
|
|
2170
|
+
if (p >= limit) {
|
|
2171
|
+
st.stop = { offset: start, reason: "the high-tag-number form runs past the end of the input." };
|
|
2172
|
+
return -1;
|
|
2173
|
+
}
|
|
2174
|
+
var tb = buf[p]; p += 1;
|
|
2175
|
+
tagNumber = (tagNumber * 128) + (tb & 0x7f);
|
|
2176
|
+
seen += 1;
|
|
2177
|
+
if ((tb & 0x80) === 0) break;
|
|
2178
|
+
if (seen >= _DUMP_MAX_TAG_OCTETS) {
|
|
2179
|
+
st.stop = { offset: start, reason: "the tag number uses more than " + _DUMP_MAX_TAG_OCTETS + " octets." };
|
|
2180
|
+
return -1;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (p >= limit) {
|
|
2185
|
+
st.stop = { offset: start, reason: "the input ends where a length octet was expected." };
|
|
2186
|
+
return -1;
|
|
2187
|
+
}
|
|
2188
|
+
var lenByte = buf[p]; p += 1;
|
|
2189
|
+
var indefinite = false;
|
|
2190
|
+
var length = 0;
|
|
2191
|
+
if (lenByte === 0x80) {
|
|
2192
|
+
if (!constructed) {
|
|
2193
|
+
st.stop = { offset: start,
|
|
2194
|
+
reason: "an indefinite length on a primitive value has no end-of-contents octets to find." };
|
|
2195
|
+
return -1;
|
|
2196
|
+
}
|
|
2197
|
+
indefinite = true;
|
|
2198
|
+
} else if ((lenByte & 0x80) !== 0) {
|
|
2199
|
+
var n = lenByte & 0x7f;
|
|
2200
|
+
if (n > _DUMP_MAX_LENGTH_OCTETS) {
|
|
2201
|
+
st.stop = { offset: start, reason: "the length uses more than " + _DUMP_MAX_LENGTH_OCTETS + " octets." };
|
|
2202
|
+
return -1;
|
|
2203
|
+
}
|
|
2204
|
+
if (p + n > limit) {
|
|
2205
|
+
st.stop = { offset: start, reason: "the long-form length runs past the end of the input." };
|
|
2206
|
+
return -1;
|
|
2207
|
+
}
|
|
2208
|
+
for (var j = 0; j < n; j++) length = (length * 256) + buf[p + j];
|
|
2209
|
+
p += n;
|
|
2210
|
+
} else {
|
|
2211
|
+
length = lenByte;
|
|
2212
|
+
}
|
|
2213
|
+
var hl = p - start;
|
|
2214
|
+
var contentStart = p;
|
|
2215
|
+
var name = _dumpTagName(tagClass, tagNumber, indefinite ? -1 : length);
|
|
2216
|
+
|
|
2217
|
+
if (!constructed) {
|
|
2218
|
+
if (length > limit - contentStart) {
|
|
2219
|
+
st.stop = { offset: start,
|
|
2220
|
+
reason: "content length " + length + " overruns the " + _dumpRemaining(limit - contentStart) };
|
|
2221
|
+
return -1;
|
|
2222
|
+
}
|
|
2223
|
+
st.nodes += 1;
|
|
2224
|
+
st.lines.push(_dumpLine(start, depth, hl, length, false, name,
|
|
2225
|
+
_dumpValue(tagClass, tagNumber, buf.subarray(contentStart, contentStart + length), st.maxValueBytes)));
|
|
2226
|
+
return contentStart + length;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
st.nodes += 1;
|
|
2230
|
+
var cp = contentStart;
|
|
2231
|
+
var kid;
|
|
2232
|
+
if (indefinite) {
|
|
2233
|
+
st.lines.push(_dumpLine(start, depth, hl, "inf", true, name, null));
|
|
2234
|
+
for (;;) {
|
|
2235
|
+
if (cp + 2 > limit) {
|
|
2236
|
+
st.stop = { offset: start,
|
|
2237
|
+
reason: "the indefinite-length value has no end-of-contents octets before the end of the input." };
|
|
2238
|
+
return -1;
|
|
2239
|
+
}
|
|
2240
|
+
if (buf[cp] === 0x00 && buf[cp + 1] === 0x00) break;
|
|
2241
|
+
kid = _dumpWalk(buf, cp, limit, depth + 1, st);
|
|
2242
|
+
if (kid < 0) return -1;
|
|
2243
|
+
cp = kid;
|
|
2244
|
+
}
|
|
2245
|
+
/** @internal The end-of-contents octets are the one node the walk emits without recursing, so
|
|
2246
|
+
* both caps are applied to them here. Without the depth check an indefinite-length value with
|
|
2247
|
+
* no children renders a node one level past the cap and the report still says it completed. */
|
|
2248
|
+
if (depth + 1 > st.maxDepth) {
|
|
2249
|
+
st.stop = { offset: cp, reason: "nesting exceeds the depth cap " + st.maxDepth + "." };
|
|
2250
|
+
return -1;
|
|
2251
|
+
}
|
|
2252
|
+
if (st.nodes >= st.maxNodes) return _dumpCapped(st, cp);
|
|
2253
|
+
st.nodes += 1;
|
|
2254
|
+
st.lines.push(_dumpLine(cp, depth + 1, 2, 0, false, _DUMP_TAG_NAMES[0], null));
|
|
2255
|
+
return cp + 2;
|
|
2256
|
+
}
|
|
2257
|
+
st.lines.push(_dumpLine(start, depth, hl, length, true, name, null));
|
|
2258
|
+
var contentEnd = contentStart + length;
|
|
2259
|
+
var childLimit = contentEnd > limit ? limit : contentEnd;
|
|
2260
|
+
while (cp < childLimit) {
|
|
2261
|
+
kid = _dumpWalk(buf, cp, childLimit, depth + 1, st);
|
|
2262
|
+
if (kid < 0) return -1;
|
|
2263
|
+
cp = kid;
|
|
2264
|
+
}
|
|
2265
|
+
if (contentEnd > limit) {
|
|
2266
|
+
st.stop = { offset: start,
|
|
2267
|
+
reason: "content length " + length + " overruns the " + _dumpRemaining(limit - contentStart) };
|
|
2268
|
+
return -1;
|
|
2269
|
+
}
|
|
2270
|
+
return contentEnd;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
var KNOWN_DUMP_OPTS = Object.assign(Object.create(null),
|
|
2274
|
+
{ maxBytes: 1, maxDepth: 1, maxNodes: 1, maxValueBytes: 1 });
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* @primitive pki.inspect.asn1
|
|
2278
|
+
* @signature pki.inspect.asn1(input, opts?) -> string
|
|
2279
|
+
* @since 0.8.31
|
|
2280
|
+
* @status stable
|
|
2281
|
+
* @spec X.690, ISO/IEC 8825-1
|
|
2282
|
+
* @defends ASN.1-parser-DoS (CWE-400)
|
|
2283
|
+
* @related pki.asn1.decode, pki.inspect.any
|
|
2284
|
+
*
|
|
2285
|
+
* Render the TLV tree of `input` (a PEM string or DER Buffer) as a structural dump, one
|
|
2286
|
+
* line per value, in the shape `openssl asn1parse` prints: the byte offset, the nesting
|
|
2287
|
+
* depth, the header length, the content length, whether the value is constructed, the tag
|
|
2288
|
+
* name, and for a primitive its rendered content. An OBJECT IDENTIFIER carries its dotted
|
|
2289
|
+
* form and its registry name. A UTCTime or GeneralizedTime renders the bytes as written
|
|
2290
|
+
* rather than a formatted date, so a malformed time is visible instead of normalized away.
|
|
2291
|
+
*
|
|
2292
|
+
* Two routes produce a report and the first line names which one ran. `pki.asn1.decode`
|
|
2293
|
+
* reads the input first, so a conforming file is rendered from the same decoder the rest of
|
|
2294
|
+
* the toolkit parses with. When that decode refuses the bytes, the report names the refusal
|
|
2295
|
+
* and a header-by-header walk renders what it can read: a truncated file, trailing bytes, a
|
|
2296
|
+
* non-minimal length, an indefinite length, a segmented string. The last line of every report
|
|
2297
|
+
* states the outcome: the offset where the structure ends, or the value the walk could not read
|
|
2298
|
+
* through and why, or the bytes that follow the top-level value, or the cap that stopped the
|
|
2299
|
+
* report and where the next value begins. A partial dump cannot be mistaken for a whole
|
|
2300
|
+
* structure. The walk returns text and never a node tree, and it carries the depth and node
|
|
2301
|
+
* caps, so tolerating a broken encoding here relaxes nothing the parsers enforce.
|
|
2302
|
+
*
|
|
2303
|
+
* No value is withheld here. Where the other reports name a key and print none of it, this
|
|
2304
|
+
* one prints bytes, private key material included, which is what a byte-level dump is for.
|
|
2305
|
+
* The caps bound how much of a value is rendered, not whether it is. `pki.inspect.any` and
|
|
2306
|
+
* `pki.inspect.pkcs8` are the reports that name a key without printing it.
|
|
2307
|
+
*
|
|
2308
|
+
* @opts
|
|
2309
|
+
* maxBytes: number, // default and ceiling: C.LIMITS.DER_MAX_BYTES (16 MiB); refuses a
|
|
2310
|
+
* // longer input, and tightens only -- a larger value is refused
|
|
2311
|
+
* maxDepth: number, // default: C.LIMITS.DER_MAX_DEPTH (64); stops at this nesting depth
|
|
2312
|
+
* maxNodes: number, // default: C.LIMITS.DUMP_MAX_NODES; stops after this many nodes
|
|
2313
|
+
* maxValueBytes: number, // default: C.LIMITS.DUMP_MAX_VALUE_BYTES; renders at most this
|
|
2314
|
+
* // many bytes of one value and counts the rest
|
|
2315
|
+
*
|
|
2316
|
+
* @example
|
|
2317
|
+
* var b = pki.asn1.build;
|
|
2318
|
+
* var der = b.sequence([b.integer(1), b.oid("2.5.4.3"), b.nullValue()]);
|
|
2319
|
+
* console.log(pki.inspect.asn1(der));
|
|
2320
|
+
* // ASN.1 structure: 12 bytes, decoded as DER
|
|
2321
|
+
* // 0:d=0 hl=2 l= 10 cons: SEQUENCE
|
|
2322
|
+
* // 2:d=1 hl=2 l= 1 prim: INTEGER :1
|
|
2323
|
+
* // 5:d=1 hl=2 l= 3 prim: OBJECT IDENTIFIER :2.5.4.3 (commonName)
|
|
2324
|
+
* // 10:d=1 hl=2 l= 0 prim: NULL
|
|
2325
|
+
* // Complete: the structure ends at offset 12, the end of the input.
|
|
2326
|
+
* console.log(pki.inspect.asn1(der.subarray(0, 7)));
|
|
2327
|
+
* // ASN.1 structure: 7 bytes, header walk
|
|
2328
|
+
* // DER decode refused: asn1/truncated: content length overruns the buffer
|
|
2329
|
+
* // 0:d=0 hl=2 l= 10 cons: SEQUENCE
|
|
2330
|
+
* // 2:d=1 hl=2 l= 1 prim: INTEGER :1
|
|
2331
|
+
* // Incomplete: the walk could not read the value at offset 5: content length 3 overruns the 0 bytes that remain.
|
|
2332
|
+
*/
|
|
2333
|
+
function asn1Dump(input, opts) {
|
|
2334
|
+
if (opts === undefined || opts === null) opts = {};
|
|
2335
|
+
if (typeof opts !== "object" || Array.isArray(opts)) {
|
|
2336
|
+
throw _err("inspect/bad-input", "pki.inspect.asn1 options must be an object");
|
|
2337
|
+
}
|
|
2338
|
+
guard.identifier.assertKnownKeys(opts, KNOWN_DUMP_OPTS, _err, "inspect/bad-input",
|
|
2339
|
+
"pki.inspect.asn1 has an unknown option ");
|
|
2340
|
+
var o = guard.identifier.snapshotOptions(opts, KNOWN_DUMP_OPTS);
|
|
2341
|
+
/** @internal `maxBytes` tightens and never raises. The shared parse door carries its own byte
|
|
2342
|
+
* ceiling and applies it before this verb sees the bytes, so a larger value could not take effect;
|
|
2343
|
+
* bounding it here refuses that value instead of accepting it and then contradicting it. */
|
|
2344
|
+
var maxBytes = guard.limits.cap(o.maxBytes, "maxBytes", constants.LIMITS.DER_MAX_BYTES,
|
|
2345
|
+
{ max: constants.LIMITS.DER_MAX_BYTES });
|
|
2346
|
+
var maxDepth = guard.limits.depthCap(o.maxDepth, "maxDepth", constants.LIMITS.DER_MAX_DEPTH);
|
|
2347
|
+
var maxNodes = guard.limits.cap(o.maxNodes, "maxNodes", constants.LIMITS.DUMP_MAX_NODES);
|
|
2348
|
+
var maxValueBytes = guard.limits.cap(o.maxValueBytes, "maxValueBytes", constants.LIMITS.DUMP_MAX_VALUE_BYTES);
|
|
2349
|
+
|
|
2350
|
+
var der;
|
|
2351
|
+
try { der = pkix.coerceToDer(input, _INSPECT_ENTRY); }
|
|
2352
|
+
catch (e) { throw _err("inspect/bad-input", "input must be a DER Buffer or a PEM block", e); }
|
|
2353
|
+
if (der.length === 0) throw _err("inspect/bad-input", "input is empty");
|
|
2354
|
+
if (der.length > maxBytes) {
|
|
2355
|
+
throw _err("inspect/bad-input", "input " + der.length + " bytes exceeds the cap " + maxBytes);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
var head = ["ASN.1 structure: " + der.length + " bytes, decoded as DER"];
|
|
2359
|
+
var node = null;
|
|
2360
|
+
var refusal = null;
|
|
2361
|
+
/** @internal The decoder builds the whole node tree before the renderer counts a line, so the
|
|
2362
|
+
* report's node cap reaches it as an item cap or a file of small values allocates a tree the
|
|
2363
|
+
* report will never print. It is given one node more than the report prints, so a structure just
|
|
2364
|
+
* past the cap is still read strictly and the report says it stopped at the cap; further past it,
|
|
2365
|
+
* the decoder refuses, the report names that refusal, and the walk renders the bounded prefix. */
|
|
2366
|
+
try {
|
|
2367
|
+
node = asn1.decode(der, { maxBytes: maxBytes, maxDepth: maxDepth, maxItems: maxNodes + 1 });
|
|
2368
|
+
}
|
|
2369
|
+
catch (e) {
|
|
2370
|
+
refusal = e;
|
|
2371
|
+
head = ["ASN.1 structure: " + der.length + " bytes, header walk",
|
|
2372
|
+
"DER decode refused: " + (e.code || "error") + ": " + e.message];
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
var st = { lines: [], nodes: 0, maxNodes: maxNodes, maxDepth: maxDepth,
|
|
2376
|
+
maxValueBytes: maxValueBytes, capped: false, capOffset: 0, stop: null };
|
|
2377
|
+
var end;
|
|
2378
|
+
if (refusal === null) {
|
|
2379
|
+
_dumpNode(node, 0, st);
|
|
2380
|
+
end = node.header.start + node.bytes.length;
|
|
2381
|
+
} else {
|
|
2382
|
+
end = _dumpWalk(der, 0, der.length, 0, st);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
/** @internal Every stop names the value the walk could not read THROUGH, which is not the offset
|
|
2386
|
+
* reading had reached: an unterminated indefinite length is read to the end of the input and the
|
|
2387
|
+
* value at fault is still the one that opened it. */
|
|
2388
|
+
var tail;
|
|
2389
|
+
if (st.stop !== null) {
|
|
2390
|
+
tail = "Incomplete: the walk could not read the value at offset " + st.stop.offset + ": " +
|
|
2391
|
+
st.stop.reason;
|
|
2392
|
+
} else if (st.capped) {
|
|
2393
|
+
tail = "Incomplete: the report stopped after " + _dumpNodes(maxNodes) + ", the cap on one dump; " +
|
|
2394
|
+
"the next value begins at offset " + st.capOffset + ".";
|
|
2395
|
+
} else if (end < der.length) {
|
|
2396
|
+
tail = "Incomplete: " + _dumpBytes(der.length - end) + " at offset " + end +
|
|
2397
|
+
" follow the top-level value.";
|
|
2398
|
+
} else {
|
|
2399
|
+
tail = "Complete: the structure ends at offset " + end + ", the end of the input.";
|
|
2400
|
+
}
|
|
2401
|
+
return head.concat(st.lines, [tail]).join("\n");
|
|
2402
|
+
}
|
|
2403
|
+
|
|
1986
2404
|
var _INSPECT_BY_FORMAT = Object.assign(Object.create(null), {
|
|
1987
2405
|
x509: certificate, crl: crlReport, csr: csrReport, cms: cmsReport,
|
|
1988
2406
|
pkcs8: pkcs8Report, pkcs12: pkcs12Report, crmf: crmfReport, cmp: cmpReport,
|
|
@@ -2051,6 +2469,7 @@ module.exports = {
|
|
|
2051
2469
|
ocspResponse: ocspResponseReport,
|
|
2052
2470
|
tsp: tspReport,
|
|
2053
2471
|
attrcert: attrCertReport,
|
|
2472
|
+
asn1: asn1Dump,
|
|
2054
2473
|
any: any,
|
|
2055
2474
|
renderedExtensions: Object.keys(EXT_RENDERERS),
|
|
2056
2475
|
};
|
package/lib/schema-pkix.js
CHANGED
|
@@ -479,11 +479,44 @@ function coerceToDer(input, opts) {
|
|
|
479
479
|
* would otherwise be read end to end on the way to being refused for its size. */
|
|
480
480
|
guard.limits.byteCap(buf, _COERCE_MAX_BYTES, function (c, m) { return new opts.ErrorClass(c, m); },
|
|
481
481
|
opts.prefix + "/too-large", "parse input");
|
|
482
|
-
|
|
482
|
+
if (!_isPemArmor(buf)) return buf;
|
|
483
|
+
/** @internal A DER header short enough for the short form can be entirely text bytes, so the
|
|
484
|
+
* probe alone cannot separate armor from a DER value carrying an armor line in its content: an
|
|
485
|
+
* APPLICATION 3 primitive is identifier 0x43 and any content length from 32 to 126 is a
|
|
486
|
+
* printable length octet. Bytes that do not decode as one complete DER value are armor, which is
|
|
487
|
+
* the resolution `pki convert` takes and keeps every PEM file a caller reads into a Buffer on the
|
|
488
|
+
* PEM path. The decode runs only where the probe already said armor, so DER input reaches its
|
|
489
|
+
* parser without it. */
|
|
490
|
+
if (!_isCompleteDer(buf)) return pemDecode(buf, opts.pemLabel, opts.PemError);
|
|
491
|
+
/** @internal A primitive tag makes any content valid, so bytes can satisfy both readings at once
|
|
492
|
+
* and no rule chooses between them: a caller handed the wrong one would be handed the other
|
|
493
|
+
* reading's bytes, silently. The argument is what is ambiguous, not the file, so the message
|
|
494
|
+
* names the two unambiguous ways to say which was meant. */
|
|
495
|
+
if (_isPemBlock(buf, opts)) {
|
|
496
|
+
throw new opts.ErrorClass(opts.prefix + "/bad-input",
|
|
497
|
+
"parse input is both one complete DER value and a well-formed PEM block, so which was meant " +
|
|
498
|
+
"cannot be read from the bytes: pass PEM as a string, or pass the DER value on its own");
|
|
499
|
+
}
|
|
500
|
+
return buf;
|
|
483
501
|
}
|
|
484
502
|
throw new opts.ErrorClass(opts.prefix + "/bad-input", "parse expects a DER BufferSource (Buffer, typed array, DataView, or ArrayBuffer) or a PEM string");
|
|
485
503
|
}
|
|
486
504
|
|
|
505
|
+
/** @internal Whether a buffer also holds a well-formed PEM block, asked only to detect that both
|
|
506
|
+
* readings hold. The decoded bytes are discarded: the caller refuses rather than choosing one. */
|
|
507
|
+
function _isPemBlock(buf, opts) {
|
|
508
|
+
try { pemDecode(buf, opts.pemLabel, opts.PemError); return true; }
|
|
509
|
+
catch (_e) { return false; }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** @internal Whether a buffer is exactly one complete DER value. Trailing bytes disqualify it, so a
|
|
513
|
+
* PEM file whose leading text happened to decode cannot be taken for DER on that prefix alone. */
|
|
514
|
+
function _isCompleteDer(buf) {
|
|
515
|
+
// allow:bare-decode classifies bytes AT the door, before a parse context exists to carry caps; the buffer is byte-capped above and the decode holds the codec's own caps.
|
|
516
|
+
try { asn1.decode(buf); return true; }
|
|
517
|
+
catch (_e) { return false; }
|
|
518
|
+
}
|
|
519
|
+
|
|
487
520
|
/** @internal Whether a buffer is armored text rather than DER. Read from a fixed window at the
|
|
488
521
|
* front, the answer is wrong for a file whose explanatory text is longer than the window, and the
|
|
489
522
|
* caller then hands PEM to the DER decoder and reports the DER as malformed. So the whole buffer is
|
|
@@ -493,7 +526,11 @@ function _isPemArmor(buf) {
|
|
|
493
526
|
var n = buf.length;
|
|
494
527
|
for (var i = 0; i < n; i++) {
|
|
495
528
|
var c = buf[i];
|
|
496
|
-
|
|
529
|
+
/** @internal ASCII whitespace has six members and a text file carries all six, so the walk
|
|
530
|
+
* passes tab, line feed, vertical tab, form feed, carriage return and space, plus the printable
|
|
531
|
+
* range and the byte-order mark. Every other byte ends the walk: a boundary behind one means the
|
|
532
|
+
* buffer is not text, and DER is binary. A PEM file that does carry one is passed as a string. */
|
|
533
|
+
var textByte = (c >= 0x20 && c < 0x7f) || (c >= 0x09 && c <= 0x0d) ||
|
|
497
534
|
c === 0xef || c === 0xbb || c === 0xbf;
|
|
498
535
|
if (!textByte) return false;
|
|
499
536
|
if (c === 0x2d && _bufToString(_subarray(buf, i, i + 10), "latin1") === "-----BEGIN") return true;
|
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:ea5fc812-c57e-4508-9ef7-3dc7126381e6",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-29T06:11:34.254Z",
|
|
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.8.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.8.31",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.31",
|
|
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.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.8.31",
|
|
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.8.
|
|
57
|
+
"ref": "@blamejs/pki@0.8.31",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|