@blamejs/pki 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,37 @@ 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.5.2 — 2026-08-13
7
+ ## v0.5.3 — 2026-08-15
8
+
9
+ pki.webauthn checks the ceremony at registration, withholds a revoked model's anchors, and refuses a name comparison it cannot perform.
10
+
11
+ ### Added
12
+
13
+ - pki.webauthn.verify accepts opts.clientDataJSON as an alternative to the clientDataHash argument -- exactly one of the two, neither inferred from the other's absence. Given the JSON it reads it: the ceremony TYPE is checked unconditionally, because which ceremony a response belongs to is fixed by the specification rather than chosen by a caller, and a login response replayed into a registration is what that check stops. The challenge, origin and top-level origin are checked against what you issued, and the verdict carries clientData, whose checked field says which comparisons ran. From the digest form clientData is null: nothing read it, and reporting anything else would claim a check that never happened. An expectation supplied without the JSON is refused rather than left silently uncompared.
14
+ - parseClientData, verify and verifyAssertion take expectedTopOrigin -- an origin, a list of them, or null to require an unframed ceremony, which a list cannot express. In a cross-origin ceremony origin is the framed document's and topOrigin names the page that framed it, so the framing policy is about the second: a value a relying party could read and nothing could compare. It is compared whole and case-sensitively, as origin already was, and reported in checked alongside it. Whether a ceremony was framed is stated by both crossOrigin and topOrigin and is only usable when they agree, so a response cannot answer the policy with the field it left out.
15
+ - A verified FIDO metadata result reports rollbackChecked and the previousNo it was compared against, as it already reported what the freshness rule found. The rollback rule runs only when a caller supplies the sequence number it holds, so a result that recorded nothing could not be told from one where the check was skipped -- and being able to show the catalogue never went backwards is the whole point of the rule.
16
+ - pki.jose.sigAlgs() lists the JWS signature algorithms this toolkit verifies, one row per alg with the JWK key type, curve, hash and PSS salt length it requires. Each call returns fresh rows; the registry that drives verification is never handed out, so nothing a caller does to the result can widen what a signature check accepts. MAC algorithms are absent by construction -- HS256 is not a signature algorithm, and listing it beside RS256 is how the key-confusion class starts.
17
+
18
+ ### Changed
19
+
20
+ - Every byte argument in pki.webauthn accepts the same forms: a Buffer, any typed-array view, a DataView, or an ArrayBuffer. They differed per argument before, so verify took an attestation object as an ArrayBuffer while refusing a clientDataHash in the form crypto.subtle.digest returns -- the natural output of the API these verbs exist to serve. The same held for a metadata BLOB, which is retrieved over the network and so most naturally arrives as the ArrayBuffer a fetched body gives you, and for the certificates supplied as trust anchors, where identical DER is the identical certificate whichever container it was read into. Anything that is not bytes is refused by name rather than described by whichever parser reaches it first.
21
+ - opts.requireCtsProfileMatch is refused when the attestation cannot satisfy it. It is a demand about an android-safetynet device-integrity signal and was checked only inside that format's arm, so a relying party that demanded a CTS-matching device got a pass from a packed or none attestation that was never asked the question -- the same shape the TPM key policy beside it already guarded against. A mistyped requireCtsProfileMatch or verifySafetyNetJws is now a configuration fault whatever the format, rather than a truthy string that demands nothing.
22
+ - pki.webauthn.metadataAnchors takes a second, optional argument carrying the metadata result, the instant to judge at, and the attestation certificate presented.
23
+ - The documented signatures of pki.webauthn.verify, verifyAssertion and parseAuthenticatorData list every field their verdicts carry, and verify documents its options.
24
+
25
+ ### Fixed
26
+
27
+ - The canonical RFC 5280 sec. 7.1 distinguished-name comparison refuses input it cannot compare, instead of answering that it matched. It takes the RDN sequence on both sides; handed a parsed Name object it compared two absent lengths, ran its loop zero times, and returned true for two unrelated names. One caller passed that shape -- the FIDO metadata anchor test, whose name half was therefore inert, leaving the public-key comparison to carry a decision RFC 5280 sec. 6.1.1 defines as name AND key. It passes the RDN sequence now, and every other call site in the toolkit already did.
28
+ - A metadata entry whose status reports disqualify the model registers no attestation anchors. The route an operator follows to anchor an attestation themselves -- metadataFor, then metadataAnchors, then pki.path.validate -- never consulted those reports, so a REVOKED authenticator's registered roots were handed back and the path validated against them. metadataAnchors now refuses with webauthn/metadata-status. An entry may only be judged against the catalogue it was read out of: a process holding two would otherwise be able to pair an entry from one with the other, and the second's status policy and freshness would decide about the first's reports -- a by-date reading handing back anchors the entry's own catalogue records as revoked. It reads the status reports exactly as the attestation path does when given the same three things -- the verified catalogue and its status policy, the instant to judge at, and the attestation certificate presented -- and applies the strictest reading of whichever you leave out: any disqualifying report denies, judged now, with no report treated as concerning some other certificate. Supplying all three, which is what the attestation path does, is what makes the two verdicts identical; supplying none can only refuse more.
29
+ - An attestation certificate's public key must be the same KIND of key as the credential public key, not only the same bytes. An X25519 key-agreement key and an Ed25519 signing key are both 32 raw bytes, so on the Edwards curves the material alone cannot separate them -- and for the apple format, which carries no attestation signature, this comparison is the entire binding. The certificate's declared key algorithm is now part of it for every key type; before, only the EC branch asked, as a curve check.
30
+ - An RSA credential key's public exponent must be minimally encoded, as its modulus already had to be. Padded, 00 01 reads as a two-byte exponent and skips the value check that refuses 1 -- the identity exponent, under which every signature verifies. A padded short modulus could clear the modulus floor the same way.
31
+ - An Edwards certificate key is validated on-curve and full-order before a metadata BLOB signature is verified under it. The identity point and the other low-order points are imported by the platform without complaint and verify a trivial signature over ANY message, so a leaf carrying one authenticates whatever payload it is shown -- and chaining to the pinned FIDO root does not help, because such a certificate is malformed rather than unissued. Every other Edwards key in the toolkit already passed this gate; the metadata reader takes the same one now, through the shared entry rather than a second copy of it.
32
+ - An id-RSASSA-PSS key's parameters restrict it even where they look empty, in both directions -- signing and verification read the restriction through one reader. RFC 4055 sec. 3.1 makes the PRESENCE of the parameters the line: absent, they restrict nothing; present, the certificate user must use the hash they identify. hashAlgorithm is [0] ... DEFAULT sha1Identifier, so a parameters SEQUENCE that omits it names SHA-1 rather than declining to name anything -- and reading that omission as no restriction let a key its own certificate confines to SHA-1 sign and verify under SHA-256 or SHA-512. Parameters that are present but unreadable are likewise a restriction that cannot be honored, which is not the same as no restriction, so they are refused rather than fallen through.
33
+ - The RSA credential-key floor is measured in bits, not bytes. A byte count is not a bit count: a minimally encoded 256-byte modulus whose leading byte is 01 is 2041 bits, seven short of the 2048-bit floor, and cleared a test written in bytes.
34
+ - A metadata BLOB signed with RSASSA-PSS, EdDSA or ML-DSA verifies. The reader carried its own six-row JWS algorithm table beside the toolkit's registry, and only the registry had been extended, so PS256 was accepted as an ACME signature and refused as a metadata signature. The table is derived from pki.jose's registry now and is total over it, so an algorithm the toolkit verifies cannot be one this reader rejects: an X.509 SubjectPublicKeyInfo carries an Edwards key (RFC 8410) and an ML-DSA key (RFC 9881) as readily as an EC one. EdDSA names a scheme without fixing a curve, so the certificate decides whether it is Ed25519 or Ed448. An x5c leaf carrying an id-RSASSA-PSS key verifies a PS256/384/512 BLOB, and the restriction that certificate places on the key is enforced in both directions: the key may not verify an RSASSA-PKCS1-v1_5 signature, and where the certificate pins a single hash, a signature under any other is refused.
35
+ - The android-safetynet service chain reaches its anchors through the same walk as every other certificate chain in the namespace, rather than a second copy of it. An x5c ending in a cross-signed form of the pinned root -- the ordinary shape during a CA rotation -- now chains, where the local copy left it in the path to fail against a root that never issued it.
36
+
37
+ ## v0.5.2 — 2026-08-12
8
38
 
9
39
  pki.cms.verify gains a trust seam: name the roots you accept, and the verdict says whether the signer chained to one.
10
40
 
package/README.md CHANGED
@@ -248,7 +248,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
248
248
  | `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) by HKDF-SHA256/SHA512 by AES-GCM / ChaCha20Poly1305 / export-only, across all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM and X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
249
249
  | `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 — `pae`, `parseBundle`, `verifyBundle` |
250
250
  | `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 — `certificate`, `crl`, `csr`, `cms`, `any` |
251
- | `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, 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. The challenge and origin remain the relying party's to compare, through `parseClientData`. 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. 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` |
251
+ | `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` |
252
252
  | `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 plus 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, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, 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`, `rules`, `profiles` |
253
253
  | `pki.C` / `pki.constants` | Version-stable constants: the functional scale helpers `C.TIME.*` and `C.BYTES.*`, the codec `LIMITS`, and `version` |
254
254
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError`, `Asn1Error`, `OidError`, `PemError`, `CertificateError`, `CrlError`, `CsrError`, `Pkcs8Error`, `CmsError`, `OcspError`, `TspError`, `AttrCertError`, `CrmfError`, `Pkcs12Error`, `CmpError`, `PathError`, `CtError`, `JoseError`, `AcmeError`, `WebauthnError`, and `LintError`, each carrying a stable `code` in `domain/reason` form |
package/lib/constants.js CHANGED
@@ -308,10 +308,13 @@ var LIMITS = {
308
308
  // would otherwise let unauthenticated input buy heap ahead of every authentication step.
309
309
  MDS_BLOB_HEADER_MAX_BYTES: BYTES.kib(256),
310
310
  // The JWS signature, likewise read before anything is authenticated. Every algorithm this reader
311
- // supports has a tightly bounded signature: 64/96/132 bytes for ECDSA P-256/384/521, and the
312
- // modulus size for RSA. 2 KiB covers an RSA-16384 key with room to spare, and refuses a segment
313
- // whose only purpose is to make the verifier allocate.
314
- MDS_BLOB_SIG_MAX_BYTES: BYTES.kib(2),
311
+ // supports has a tightly bounded signature: 64/96/132 bytes for ECDSA P-256/384/521, 64 and 114
312
+ // for Ed25519 and Ed448, the modulus size for RSA, and 2420 / 3309 / 4627 for ML-DSA-44/65/87
313
+ // (FIPS 204). The bound has to clear the LARGEST of those -- an accepted algorithm whose ordinary
314
+ // signature the ceiling refuses is a row that can never verify -- so 8 KiB, which covers ML-DSA-87
315
+ // and an RSA-16384 key with room to spare while still refusing a segment whose only purpose is to
316
+ // make the verifier allocate.
317
+ MDS_BLOB_SIG_MAX_BYTES: BYTES.kib(8),
315
318
  MDS_MAX_ENTRIES: 4096,
316
319
  // A model's certification history: bounded like the other repeated per-entry structures, because
317
320
  // the status gate walks the whole array on every verification.
@@ -42,8 +42,12 @@ function view(input, ErrorClass, code, label) {
42
42
 
43
43
  // source(input, ErrorClass, code, label) -> Buffer | throws ErrorClass
44
44
  // Accepts the full W3C BufferSource (Buffer / TypedArray view / raw ArrayBuffer)
45
- // -- the WebCrypto input contract. A raw ArrayBuffer is copied via Buffer.from,
46
- // which also throws on a detached backing store.
45
+ // -- the WebCrypto input contract. Re-VIEWS in every case, sharing the caller's
46
+ // memory rather than taking a copy: `Buffer.from(arrayBuffer)` wraps the backing
47
+ // store, it does not duplicate it. That is what makes this safe to call before a
48
+ // size ceiling has been applied -- nothing is materialized -- and it is also why a
49
+ // caller that must hold the bytes across an await wants `snapshotSource`, which
50
+ // copies, rather than this. Throws on a detached backing store.
47
51
  // @enforced-by guard-shape-reinlined (the re-view shape is declared on view above)
48
52
  function source(input, ErrorClass, code, label) {
49
53
  var isAb = input instanceof ArrayBuffer;
package/lib/guard-name.js CHANGED
@@ -76,8 +76,22 @@ function _canonAttrValue(v, E, code, label) {
76
76
  // composes; dnEqual composes it over the RDN sequence. Comparing an RDN by raw DER
77
77
  // would treat two RFC 5280-equal names as different (or let a truncation name
78
78
  // compare equal).
79
+ // Both comparands must be the RDN SEQUENCE, not the parsed Name object that carries one. This is
80
+ // not a style check: a parsed Name has no `length`, so `a.length !== b.length` compares undefined
81
+ // with undefined, the loop over `i < undefined` never runs, and the comparison returns TRUE for two
82
+ // unrelated names. An identity comparison that answers "equal" because it was handed the wrong
83
+ // shape is the worst possible failure of this guard -- it is the one place a DN identity is
84
+ // decided, and every caller reads a true from it as proof. So a comparison that CANNOT be
85
+ // performed refuses, and the caller learns at the call site rather than trusting a fabricated
86
+ // match. (A caller holding a parsed Name passes its `.rdns`.)
87
+ function _assertSequence(a, b, E, code, label, what) {
88
+ if (!Array.isArray(a) || !Array.isArray(b)) {
89
+ throw E(code, "cannot compare " + label + ": " + what + " comparison requires the RDN sequence on both sides (pass name.rdns, not the parsed Name)");
90
+ }
91
+ }
79
92
  // @enforced-by guard-shape-reinlined (shares the canonicalization shape declared on dnEqual)
80
93
  function rdnEqual(a, b, E, code, label) {
94
+ _assertSequence(a, b, E, code, label, "an RDN");
81
95
  if (a.length !== b.length) return false;
82
96
  var used = [];
83
97
  for (var i = 0; i < a.length; i++) {
@@ -101,6 +115,7 @@ function rdnEqual(a, b, E, code, label) {
101
115
  // @enforced-by guard-shape-reinlined
102
116
  // @guard-shape replace\(/\\s\+/g,
103
117
  function dnEqual(rdnsA, rdnsB, E, code, label) {
118
+ _assertSequence(rdnsA, rdnsB, E, code, label, "a distinguished-name");
104
119
  if (rdnsA.length !== rdnsB.length) return false;
105
120
  for (var i = 0; i < rdnsA.length; i++) {
106
121
  if (!rdnEqual(rdnsA[i], rdnsB[i], E, code, label)) return false;
package/lib/jose.js CHANGED
@@ -566,8 +566,47 @@ async function thumbprint(jwk) {
566
566
  return b64uEncode(digest);
567
567
  }
568
568
 
569
+ /**
570
+ * @primitive pki.jose.sigAlgs
571
+ * @signature pki.jose.sigAlgs() -> Array<{alg,kty,crv,hash,saltLength}>
572
+ * @since 0.5.3
573
+ * @status stable
574
+ * @spec RFC 7518 sec. 3, RFC 8037, RFC 9964
575
+ * @related pki.jose.verify
576
+ *
577
+ * The JWS signature algorithms this toolkit verifies, one row per `alg`, each
578
+ * naming the JWK key type it requires (`kty`, plus the exact `crv` where the
579
+ * curve is fixed), the hash, and the RSASSA-PSS salt length where the algorithm
580
+ * is PSS. Rows describe key material in RFC 7517 / 7518 vocabulary only, so a
581
+ * caller can decide whether a key it holds can sign or verify a given `alg`
582
+ * without a table of its own.
583
+ *
584
+ * MAC algorithms are deliberately absent: `HS*` is not a signature algorithm and
585
+ * listing it beside `RS256` is how the HMAC key-confusion class starts. `none`
586
+ * does not exist here at all.
587
+ *
588
+ * Each call returns a fresh array of fresh rows -- the registry that drives
589
+ * verification is never handed out, so nothing a caller does to the result can
590
+ * widen what a signature check accepts.
591
+ *
592
+ * @example
593
+ * var pss = pki.jose.sigAlgs().filter(function (r) { return r.saltLength; });
594
+ * pss.map(function (r) { return r.alg; }); // -> ["PS256", "PS384", "PS512"]
595
+ */
596
+ function sigAlgs() {
597
+ return Object.keys(SIG_ALGS).map(function (alg) {
598
+ var row = SIG_ALGS[alg];
599
+ var out = { alg: alg, kty: row.kty };
600
+ if (row.crv) out.crv = row.crv;
601
+ if (row.hash) out.hash = row.hash;
602
+ if (row.saltLength) out.saltLength = row.saltLength;
603
+ return out;
604
+ });
605
+ }
606
+
569
607
  module.exports = {
570
608
  base64url: { encode: b64uEncode, decode: b64uDecode },
609
+ sigAlgs: sigAlgs,
571
610
  parseJson: parseJson,
572
611
  verify: verify,
573
612
  sign: sign,
@@ -67,23 +67,61 @@ function _pssAlgId(digestName) {
67
67
  var params = b.sequence([b.explicit(0, hashAlg), b.explicit(1, mgf), b.explicit(2, b.integer(BigInt(PSS_SALT[HASH[digestName]])))]);
68
68
  return b.sequence([b.oid(O("rsassaPss")), params]);
69
69
  }
70
- // An id-RSASSA-PSS SPKI MAY pin its permitted hash in the params (RFC 4055 sec. 1.2 / 3.1). Read
71
- // it so signing honors the restriction; absent params or an unrecognized hash returns null.
70
+ // The hash an id-RSASSA-PSS SPKI restricts its key to, or null when it restricts none.
71
+ //
72
+ // RFC 4055 sec. 3.1 draws the line at whether the parameters are THERE: "if present, the parameters
73
+ // field MUST contain RSASSA-PSS-params", and "if RSASSA-PSS-params is present, the certificate user
74
+ // MUST perform those operations using the one-way hash function ... identified in the ...
75
+ // parameters". Absent parameters therefore restrict nothing, and null says so.
76
+ //
77
+ // PRESENT parameters are a restriction even where they look empty. `hashAlgorithm` is
78
+ // `[0] HashAlgorithm DEFAULT sha1Identifier`, so a params SEQUENCE that omits it names SHA-1 --
79
+ // it does not decline to name anything. Reading the omission as "no restriction" is the fail-open
80
+ // that matters here: it turns a key its own certificate confines to SHA-1 into one that will verify
81
+ // a SHA-512 signature. And parameters that are present but unreadable are a restriction this code
82
+ // cannot honor, which is not the same as no restriction either, so they are refused.
72
83
  function _pssHashFromSpki(cert, E) {
73
84
  var params = cert.subjectPublicKeyInfo.algorithm.parameters;
74
85
  if (params == null) return null;
75
- var node = asn1.decode(params);
76
- if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children) return null;
86
+ var node;
87
+ try { node = asn1.decode(params); }
88
+ catch (e) { throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters are not decodable, so the restriction they carry cannot be honored", e); }
89
+ if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children) {
90
+ throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters are not an RSASSA-PSS-params SEQUENCE (RFC 4055 sec. 3.1)");
91
+ }
77
92
  var hashField = node.children.filter(function (c) { return c.tagClass === "context" && c.tagNumber === 0; })[0];
78
- if (!hashField || !hashField.children || !hashField.children[0] || !hashField.children[0].children) return null;
93
+ // DEFAULT sha1Identifier -- an omitted hashAlgorithm names SHA-1, which this toolkit does not
94
+ // sign or verify with, so it is reported as the pin it is and refused by the caller's own table.
95
+ if (!hashField) return "sha1";
96
+ if (!hashField.children || !hashField.children[0] || !hashField.children[0].children) {
97
+ throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters carry a malformed hashAlgorithm");
98
+ }
79
99
  var oidNode = hashField.children[0].children[0];
80
- if (!oidNode || oidNode.tagClass !== "universal" || oidNode.tagNumber !== asn1.TAGS.OBJECT_IDENTIFIER) return null;
100
+ if (!oidNode || oidNode.tagClass !== "universal" || oidNode.tagNumber !== asn1.TAGS.OBJECT_IDENTIFIER) {
101
+ throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters hashAlgorithm is not an OBJECT IDENTIFIER");
102
+ }
81
103
  var pinnedOid = asn1.read.oid(oidNode);
82
104
  var name = HASH_NAME_BY_OID[pinnedOid];
83
105
  if (!name) throw E("unsupported-algorithm", "the id-RSASSA-PSS signer key pins an unsupported hash algorithm (" + pinnedOid + ")");
84
106
  return name;
85
107
  }
86
108
 
109
+ // @internal -- the WebCrypto hash name an id-RSASSA-PSS SPKI pins, or null when it pins none.
110
+ // Verifiers need the same restriction the signer above honors: a key whose certificate says
111
+ // SHA-256 must not be handed a SHA-512 signature to check, and reading the pin in two places is
112
+ // how the two directions come to disagree. Throws through the caller's E on a hash this toolkit
113
+ // does not implement, so an unreadable restriction is never treated as no restriction.
114
+ function pssSpkiPinnedHash(cert, E) {
115
+ var d = _pssHashFromSpki(cert, E);
116
+ if (!d) return null;
117
+ // A pin this toolkit has no WebCrypto hash for -- SHA-1, which RSASSA-PSS-params names by DEFAULT
118
+ // -- is still a pin. Returning undefined here would hand the caller a falsy value it reads as
119
+ // "unrestricted", which is the same fail-open the DEFAULT reading above exists to close, one
120
+ // layer up.
121
+ if (!HASH[d]) throw E("unsupported-algorithm", "the id-RSASSA-PSS key is restricted to " + d + ", which this toolkit does not verify with");
122
+ return HASH[d];
123
+ }
124
+
87
125
  // resolveSignScheme(cert, so, noSignedAttrs, E) -> the signature scheme from the signer cert's
88
126
  // public-key algorithm + per-signer opts (so.digestAlgorithm / so.pss / so.combinedRsaSig -- the
89
127
  // last folds the digest into a combined RSA signature OID for a caller with no digestAlgorithm
@@ -205,6 +243,7 @@ function signOverTbs(scheme, key, signedBytes, E) {
205
243
  // no drift between the two.
206
244
  module.exports = {
207
245
  resolveSignScheme: resolveSignScheme,
246
+ pssSpkiPinnedHash: pssSpkiPinnedHash,
208
247
  signOverTbs: signOverTbs,
209
248
  MLDSA_SUITABLE_DIGEST: MLDSA_SUITABLE_DIGEST,
210
249
  SLHDSA_BY_OID: SLHDSA_BY_OID,
@@ -57,6 +57,17 @@ var OKP_CRV = { 6: { oid: "Ed25519", len: 32 }, 7: { oid: "Ed448", len: 57 } };
57
57
  // -8 (EdDSA) is Ed25519 ONLY, and the RFC 9864 fully-specified ids (-9 ESP256, -51 ESP384,
58
58
  // -52 ESP512, -19 Ed25519, -53 Ed448) each pin key type + curve. A verifier accepts the
59
59
  // fully-specified ids even though WebAuthn recommends against them for credential creation.
60
+ // The RSA credential-key bounds. 2048 bits is the floor every current FIDO authenticator and
61
+ // NIST SP 800-57 agree on; nothing in the field emits less, so the floor refuses forgeable keys
62
+ // without refusing real ones. The exponent bound is a work bound, not a security one.
63
+ var RSA_MIN_MODULUS_BITS = 2048;
64
+ var RSA_MAX_EXPONENT_BYTES = 8;
65
+ // The modulus BIT length. A byte count is not one: minimally encoded, a 256-byte modulus whose
66
+ // leading byte is 0x01 is 2041 bits, and would clear a floor expressed in bytes while sitting below
67
+ // the floor that floor exists to state. The leading byte is non-zero by the minimal-encoding check
68
+ // above, so its position fixes the total.
69
+ function _modulusBits(n) { return (n.length - 1) * 8 + (32 - Math.clz32(n[0])); }
70
+
60
71
  var ALG_PROFILE = {
61
72
  "-7": { kty: 2, crv: 1 }, "-35": { kty: 2, crv: 2 }, "-36": { kty: 2, crv: 3 },
62
73
  "-9": { kty: 2, crv: 1 }, "-51": { kty: 2, crv: 2 }, "-52": { kty: 2, crv: 3 },
@@ -111,6 +122,34 @@ function credentialKey(node, E, code, unsupportedCode) {
111
122
  } else if (kty === 3n) {
112
123
  key.n = ib(-1); key.e = ib(-2);
113
124
  if (!key.n || !key.n.length || !key.e || !key.e.length) throw bad("an RSA COSE_Key must carry n (-1) and e (-2)");
125
+ // The MATERIAL, not merely its presence -- the same standard the other two key types are held
126
+ // to, where EC2 pins x/y to the curve's field size and has the point validated on the curve,
127
+ // and OKP pins x to an exact length. Checking only presence let a 1-byte modulus and an
128
+ // exponent of 1 through as conformant credential public keys, and both reach the WebCrypto
129
+ // import, so they reach real signature verification. e = 1 makes RSA the identity function:
130
+ // the "signature" is the message, and it verifies under any modulus.
131
+ // BOTH values first, before either is judged. RFC 8230 sec. 4 encodes n and e as unsigned
132
+ // big-endian integers with no leading zero, and every check below reads a byte LENGTH as though
133
+ // it were a magnitude: the modulus floor, the exponent bound, and the exponent's value. A
134
+ // padded encoding decouples the two, so `00 01` would be read as a two-byte exponent and skip
135
+ // the value check that refuses 1 -- the degenerate key the whole check exists to catch. (An EC2
136
+ // coordinate is the opposite case, fixed-width and zero-padded by definition, which is why this
137
+ // rule is stated for the RSA parameters and not for x/y.)
138
+ if (key.n[0] === 0) throw bad("an RSA COSE_Key modulus (-1) must be minimally encoded, with no leading zero byte (RFC 8230 sec. 4)");
139
+ if (key.e[0] === 0) throw bad("an RSA COSE_Key exponent (-2) must be minimally encoded, with no leading zero byte (RFC 8230 sec. 4)");
140
+ var modulusBits = _modulusBits(key.n);
141
+ if (modulusBits < RSA_MIN_MODULUS_BITS) {
142
+ throw bad("an RSA COSE_Key modulus (-1) is " + modulusBits + " bits, below the " +
143
+ RSA_MIN_MODULUS_BITS + "-bit minimum");
144
+ }
145
+ // e must be odd and greater than 1: RSA needs gcd(e, phi(n)) = 1, so an even exponent is not a
146
+ // valid RSA public exponent at all, and 1 is the degenerate case above. Bounded on the way in
147
+ // so a caller cannot hand over a megabyte of exponent for the modular exponentiation to chew.
148
+ if (key.e.length > RSA_MAX_EXPONENT_BYTES) throw bad("an RSA COSE_Key exponent (-2) is longer than " + RSA_MAX_EXPONENT_BYTES + " bytes");
149
+ if ((key.e[key.e.length - 1] & 1) === 0) throw bad("an RSA COSE_Key exponent (-2) must be odd");
150
+ // Minimal encoding above makes a one-byte e the ONLY way to express a value this small, so the
151
+ // comparison is on the value and not on where it happens to sit.
152
+ if (key.e.length === 1 && key.e[0] <= 1) throw bad("an RSA COSE_Key exponent (-2) must be greater than 1 -- e = 1 makes RSA the identity function");
114
153
  } else {
115
154
  throw bad("unsupported COSE_Key kty " + Number(kty));
116
155
  }