@blamejs/pki 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/lib/jose.js +49 -6
- package/lib/sigstore.js +58 -6
- package/lib/webcrypto.js +35 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ 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.1 — 2026-08-12
|
|
8
|
+
|
|
9
|
+
Four verify and export paths stop answering a question other than the one they were asked: the key you supply governs, and a private key exports as one.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- pki.sigstore.verifyBundle reports identityChecked alongside verified: a boolean per identity field showing which were actually compared. verified: true says the artifact was signed and logged, not that a party you trust signed it -- Fulcio issues a certificate to anyone who completes an OIDC flow, so who signed is decided only by opts.identity, and the two claims were previously indistinguishable in the verdict. An opts.identity naming none of san, issuer or sourceRepositoryURI is now refused rather than satisfied: every comparison inside it was falsy, so it accepted every signer while reading as a policy in force. An unrecognized field name is refused for the same reason -- cosign spells this certificateIdentity, and swallowed it pinned nothing under a name the operator believed constrained the signer.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- pki.webcrypto.subtle.exportKey("raw", privateKey) is refused with webcrypto/not-supported rather than answered with the public key. The W3C definition of raw covers public and secret keys; there is no raw private-key serialization for EC or OKP, and Node's own WebCrypto refuses it too. The consequence ran through wrapKey, which forwards the caller's format straight to exportKey: a private key wrapped as raw escrowed the PUBLIC key, and unwrapping it returned a handle announcing usages ["sign"] that cannot sign, with the private key gone and no error at any step. Use pkcs8 or jwk to serialize a private key; the public half still exports as raw.
|
|
18
|
+
- A post-quantum private key exported to a JWK re-imports as a private key. ML-DSA, ML-KEM and SLH-DSA JWKs are kty: "AKP" and carry the private half in priv, while the import tested only for the d an EC or OKP key uses -- so every PQC private JWK read as public. The re-imported key was type public yet still announced usages ["sign"], and extractable was forced true even where the caller asked for false: a key that could not sign, said it could, and ignored the extractability it was given. Round-tripping now preserves the half that signs.
|
|
19
|
+
- pki.jose.verify treats opts.key as the key the message must be signed under. Where the profile also permits an embedded header jwk -- acme-outer does -- the embedded key was preferred and the two were never compared, so the sender chose which key verified its own message and a caller supplying the account key it expected got no benefit from doing so. The two must now be the same key, compared as RFC 7638 thumbprints so member order cannot make equal keys differ, and a disagreement is refused with jose/key-mismatch. The verdict carries keySource, naming which key answered, because a signature checked against a key the caller named is a different claim from one checked against the key the message brought with it.
|
|
20
|
+
|
|
7
21
|
## v0.5.0 — 2026-08-12
|
|
8
22
|
|
|
9
23
|
CMC -- Certificate Management over CMS -- ships end to end: build a Full PKI Request, carry it to a CA over EST, and read the response into one terminal outcome.
|
package/README.md
CHANGED
|
@@ -222,7 +222,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
222
222
|
| `pki.schema.csrattrs` | EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC and RSA key-type conventions, the certification-request-info template). Unknown types are surfaced raw; structure and the RFC 9908 semantic MUSTs are fail-closed — `parse` |
|
|
223
223
|
| `pki.est` | Enrollment over Secure Transport (RFC 7030 / 8951 / 9908 / 7616). The client verbs `cacerts`, `simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, and `fullcmc` drive the RFC 7030 flow over `pki.transport` (inject your own via `opts.transport`, or take the fail-closed default): https only, an explicit trust anchor required, same-origin redirects followed while a downgrade or loop is refused, a 202 Retry-After surfaced but never slept, HTTP Basic or Digest (RFC 7616, SHA-256 / SHA-512-256; MD5 and no-qop refused by default) answered only after the server is authenticated, and the issued certificate chosen by public-key match. `serverkeygen` requests a server-generated key, cleartext or an opaque CMS EnvelopedData, with encryption bound to the CSR's key-identifier attribute over a confidentiality-bearing cipher. `csrattrs` fetches the CA's RFC 9908 attributes policy. `fullcmc` (§4.3) carries a CMC Full PKI Request and reduces the CA's answer through `pki.cmc.verify` to one terminal outcome, refusing a response that fails to echo the transaction and nonce the request carried, or that covers a key the request never asked for; a certs-only reply to a bound request is refused rather than read as an issuance. Under the verbs sit the transport-agnostic codecs they compose: the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only and serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `transferDecode`/`transferEncode`, `parseCertsOnly`, `splitMultipartMixed`, `parseServerKeygenResponse`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
224
224
|
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive. `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body, tls }`, where `tls` carries the negotiated `protocol`, `cipher`, and raw `peerCertificate`. This is the toolkit's only socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. The EST, ACME, and CMP clients reuse it verbatim. If you inject your own transport, return `tls` too: `pki.est.serverkeygen` asserts the negotiated cipher can protect the delivered private key, and a transport that reports no cipher is trusted rather than refused, so omitting the field silently skips that check — `https` |
|
|
225
|
-
| `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `parseJson` is a bounded reader that refuses duplicate members at any depth. `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
225
|
+
| `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `parseJson` is a bounded reader that refuses duplicate members at any depth. `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published. `opts.key` names the key a message must be signed under and governs: where the profile also permits an embedded header `jwk`, the two must be the same key — compared as RFC 7638 thumbprints, so member order cannot make equal keys differ — and a disagreement is refused rather than resolved in the message's favour. `keySource` reports which key answered, since a signature checked against a key you named is a different claim from one checked against the key the message carried — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
226
226
|
| `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) complete the lifecycle. Every URL is https only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry, reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. Over the message layer it composes resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
227
227
|
| `pki.schema.smime` | S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551). `parseSigningCertificate` and `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` and serial); `parseSmimeCapabilities` decodes the ordered capability list; `decodeAttribute` dispatches a CMS attribute by OID, enforcing the single-value rule and deferring on unknown types. A companion decoder for CMS signed attributes rather than an auto-routed format — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
228
228
|
| `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defence. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, because a server can echo both correctly while answering about a different message. Several status controls are permitted and the worst governs, so a failure cannot hide behind an earlier success; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
|
|
@@ -246,7 +246,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
246
246
|
| `pki.trust` | Mozilla and CCADB trust-store ingestion. `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export, both into one constraint-carrying anchor shape: the per-purpose trust bits, where only `CKT_NSS_TRUSTED_DELEGATOR` grants, and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer and serial rather than adjacency and are cross-checked against the parsed DER, so metadata cannot attach to the wrong root. `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
247
247
|
| `pki.shbs` | Stateful hash-based signature verification: HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and in CMS by RFC 9708, profiled by NIST SP 800-208 for CNSA 2.0 firmware signing. `verify` checks an HSS signature, where every level must pass, and `verifyLms` a single-tree LMS, over the raw public-key and signature blobs the parsers already surface. Pure public-input SHA-256 and SHAKE256 hashing, a data-driven typecode registry, and bounds-before-slice reads; a malformed blob throws a typed `ShbsError` while a well-formed but wrong signature returns `false`. Verification only by design, since stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
|
|
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
|
-
| `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 — `pae`, `parseBundle`, `verifyBundle` |
|
|
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
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` |
|
|
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` |
|
package/lib/jose.js
CHANGED
|
@@ -327,7 +327,7 @@ function assertPublicJwk(jwk) {
|
|
|
327
327
|
|
|
328
328
|
/**
|
|
329
329
|
* @primitive pki.jose.verify
|
|
330
|
-
* @signature pki.jose.verify(jws, opts) -> Promise<{ header, payload }>
|
|
330
|
+
* @signature pki.jose.verify(jws, opts) -> Promise<{ header, payload, keySource }>
|
|
331
331
|
* @since 0.1.25
|
|
332
332
|
* @status stable
|
|
333
333
|
* @spec RFC 7515, RFC 7518, RFC 8555
|
|
@@ -337,10 +337,20 @@ function assertPublicJwk(jwk) {
|
|
|
337
337
|
* `"acme-outer"`). Structural rules fail closed BEFORE any crypto: the
|
|
338
338
|
* `signatures`/`header` members and a detached payload are rejected, the
|
|
339
339
|
* protected header is validated against the profile (alg registry, nonce, url,
|
|
340
|
-
* exactly-one-of jwk/kid, crit), the signature byte length is pinned per alg
|
|
341
|
-
*
|
|
342
|
-
* `opts.key`
|
|
343
|
-
* `
|
|
340
|
+
* exactly-one-of jwk/kid, crit), and the signature byte length is pinned per alg.
|
|
341
|
+
*
|
|
342
|
+
* `opts.key` names the key the message must be signed under, and it governs: where
|
|
343
|
+
* the profile also permits an embedded header `jwk`, the two must be the SAME key
|
|
344
|
+
* or the message is refused with `jose/key-mismatch`. They are compared as RFC 7638
|
|
345
|
+
* thumbprints, so member order and members outside the key itself cannot make equal
|
|
346
|
+
* keys look different. Without `opts.key` the embedded `jwk` is used where the
|
|
347
|
+
* profile permits one -- which verifies that the message is internally consistent,
|
|
348
|
+
* not that any particular signer produced it. `keySource` reports which of the two
|
|
349
|
+
* answered, so a signature checked against a caller-named key is distinguishable
|
|
350
|
+
* from one checked against the key the message brought with it.
|
|
351
|
+
*
|
|
352
|
+
* Returns `{ header, payload, keySource }` (payload a raw `Buffer`); a failed
|
|
353
|
+
* signature throws `jose/verify-failed`.
|
|
344
354
|
*
|
|
345
355
|
* @opts
|
|
346
356
|
* profile: string // "acme-outer" | "eab-inner" | "keychange-inner"
|
|
@@ -365,8 +375,38 @@ async function verify(jws, opts) {
|
|
|
365
375
|
var header = parseJson(b64uDecode(jws.protected));
|
|
366
376
|
var profileName = opts.profile || "acme-outer";
|
|
367
377
|
var checked = _checkHeader(header, profileName);
|
|
378
|
+
// A caller supplying opts.key is NAMING the key this message must be signed under. A profile
|
|
379
|
+
// that also permits an embedded jwk lets the message carry one, and preferring that would let
|
|
380
|
+
// the sender choose which key verifies it -- exactly the question opts.key was asked to settle.
|
|
381
|
+
// So when both are present they must be the SAME key, compared as RFC 7638 thumbprints: that
|
|
382
|
+
// canonicalizes member order and ignores members outside the key itself, so two spellings of
|
|
383
|
+
// one key agree and two different keys cannot.
|
|
384
|
+
// A SUPPLIED key that cannot be used is refused, never quietly treated as absent. Falling back
|
|
385
|
+
// to the embedded jwk there would drop the caller's intent to pin a signer at the moment it
|
|
386
|
+
// matters most -- a `key` that came back null from a lookup would verify against whatever the
|
|
387
|
+
// message carried, and the verdict would report the embedded key as though none was named.
|
|
388
|
+
// `undefined` alone means "not supplied", so spreading an options object stays safe.
|
|
389
|
+
if (opts.key !== undefined && (typeof opts.key !== "object" || opts.key === null || Array.isArray(opts.key))) {
|
|
390
|
+
throw E("jose/bad-key", "opts.key was supplied but is not a JWK object, so the key this message must be signed under cannot be established");
|
|
391
|
+
}
|
|
368
392
|
var jwk = header.jwk || opts.key;
|
|
369
393
|
if (!jwk) throw E("jose/bad-key", "a verification key is required (opts.key) when the profile does not embed a jwk");
|
|
394
|
+
var keySource = "embedded-jwk";
|
|
395
|
+
if (opts.key) {
|
|
396
|
+
keySource = "opts.key";
|
|
397
|
+
if (header.jwk) {
|
|
398
|
+
var embeddedTp, suppliedTp;
|
|
399
|
+
try { embeddedTp = await thumbprint(header.jwk); suppliedTp = await thumbprint(opts.key); }
|
|
400
|
+
catch (e) { throw E("jose/bad-key", "the embedded jwk and opts.key could not be compared as RFC 7638 thumbprints", e); }
|
|
401
|
+
if (embeddedTp !== suppliedTp) {
|
|
402
|
+
throw E("jose/key-mismatch",
|
|
403
|
+
"the JWS embeds a jwk that is not the key supplied as opts.key, so the message names a " +
|
|
404
|
+
"different signer than the caller expects (embedded thumbprint " + embeddedTp +
|
|
405
|
+
", supplied " + suppliedTp + ")");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
jwk = opts.key;
|
|
409
|
+
}
|
|
370
410
|
_assertKeyType(checked.algRow, jwk);
|
|
371
411
|
var sig = b64uDecode(jws.signature);
|
|
372
412
|
var want = _expectedSigBytes(checked.algRow, jwk);
|
|
@@ -378,7 +418,10 @@ async function verify(jws, opts) {
|
|
|
378
418
|
catch (e) { throw E("jose/bad-key", "the JWK could not be imported for verification", e); }
|
|
379
419
|
var ok = await webcrypto.subtle.verify(_cryptoAlg(checked.algRow, jwk), key, sig, signingInput);
|
|
380
420
|
if (!ok) throw E("jose/verify-failed", "the JWS signature did not verify");
|
|
381
|
-
|
|
421
|
+
// WHICH key answered. A caller auditing the decision needs "checked against the key I named"
|
|
422
|
+
// to be distinguishable from "checked against the one the message brought with it"; the two
|
|
423
|
+
// are different claims and only the first says anything about who the signer is.
|
|
424
|
+
return { header: header, payload: b64uDecode(jws.payload), keySource: keySource };
|
|
382
425
|
}
|
|
383
426
|
|
|
384
427
|
// ---- flattened-JWS sign (RFC 7515 sec. 5.1) ------------------------------
|
package/lib/sigstore.js
CHANGED
|
@@ -516,14 +516,54 @@ function _fulcioExtValue(ext, leafArc) {
|
|
|
516
516
|
return asn1.read.string(asn1.decode(ext.value));
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
// The fields an identity policy may constrain. Named once so the check, the refusal of an
|
|
520
|
+
// unknown key, and the report of what ran cannot drift apart.
|
|
521
|
+
var IDENTITY_FIELDS = ["san", "issuer", "sourceRepositoryURI"];
|
|
522
|
+
// The guard tests membership with hasOwnProperty, so the permitted set is a lookup object --
|
|
523
|
+
// an array would treat "0"/"1" as the known keys and reject every real field name.
|
|
524
|
+
var IDENTITY_KEYS = { san: 1, issuer: 1, sourceRepositoryURI: 1 };
|
|
525
|
+
|
|
526
|
+
// Returns WHICH fields were actually compared. A bundle verifies its own signature and log
|
|
527
|
+
// inclusion whoever signed it -- Fulcio issues to anyone who completes an OIDC flow -- so
|
|
528
|
+
// `verified: true` without an identity policy says the artifact was signed and logged, not that
|
|
529
|
+
// a trusted party signed it. The caller cannot tell those apart from a bare boolean.
|
|
519
530
|
function _checkIdentity(id, policy) {
|
|
520
|
-
|
|
531
|
+
var ran = { san: false, issuer: false, sourceRepositoryURI: false };
|
|
532
|
+
if (policy === undefined || policy === null) return ran;
|
|
533
|
+
if (typeof policy !== "object" || Array.isArray(policy)) {
|
|
534
|
+
throw _err("sigstore/bad-input", "opts.identity must be an object naming the identity fields to pin (" + IDENTITY_FIELDS.join(", ") + ")");
|
|
535
|
+
}
|
|
536
|
+
// An unknown key is refused, not ignored: cosign spells this `certificateIdentity`, and a
|
|
537
|
+
// swallowed spelling checks nothing under a name the operator believes pins the signer.
|
|
538
|
+
// The guard rejects through a (code, message) FACTORY. Handing it the error CLASS raises
|
|
539
|
+
// "class constructor cannot be invoked without new" -- a raw, untyped throw escaping a public
|
|
540
|
+
// verb, on the branch a valid-input test never takes.
|
|
541
|
+
guard.identifier.assertKnownKeys(policy, IDENTITY_KEYS, _err, "sigstore/bad-input", "opts.identity has an unknown key ");
|
|
542
|
+
// A policy that constrains NOTHING is a configuration mistake, and the most dangerous input on
|
|
543
|
+
// this surface: every guard below is falsy, so it accepts every signer while reading as though
|
|
544
|
+
// an identity policy is in force. Refused at the boundary rather than answered.
|
|
545
|
+
var asked = IDENTITY_FIELDS.filter(function (f) { return policy[f] !== undefined; });
|
|
546
|
+
if (!asked.length) {
|
|
547
|
+
throw _err("sigstore/bad-input", "opts.identity constrains nothing -- name at least one of " + IDENTITY_FIELDS.join(", ") + ", or omit it to state that the signer is not being checked");
|
|
548
|
+
}
|
|
549
|
+
// A named field must carry a value that can actually be compared. The comparisons below are
|
|
550
|
+
// truthiness-guarded, so an empty string or a null would be skipped while the field had been
|
|
551
|
+
// named -- reporting a signer check that never ran, which is the exact confusion this report
|
|
552
|
+
// exists to remove. Deciding "asked" and deciding "compared" must be the SAME test, so a value
|
|
553
|
+
// that cannot be compared is refused here rather than quietly becoming "not asked".
|
|
554
|
+
asked.forEach(function (f) {
|
|
555
|
+
if (typeof policy[f] !== "string" || policy[f] === "") {
|
|
556
|
+
throw _err("sigstore/bad-input", "opts.identity." + f + " must be a non-empty string -- a value that cannot be compared would leave the signer unchecked under a policy that names it");
|
|
557
|
+
}
|
|
558
|
+
ran[f] = true;
|
|
559
|
+
});
|
|
521
560
|
var sanValue = id.san && id.san.value;
|
|
522
561
|
if (policy.san && sanValue !== policy.san) throw _err("sigstore/identity-mismatch", "the certificate SAN " + JSON.stringify(sanValue) + " does not match the expected identity");
|
|
523
562
|
// The OIDC issuer is carried by the current Issuer V2 (.1.8) or, on older certs,
|
|
524
563
|
// only by the deprecated raw-string issuer (.1.1); match against either.
|
|
525
564
|
if (policy.issuer && policy.issuer !== id.extensions.issuer && policy.issuer !== id.extensions.issuerLegacy) throw _err("sigstore/identity-mismatch", "the certificate OIDC issuer does not match the expected issuer");
|
|
526
565
|
if (policy.sourceRepositoryURI && id.extensions.sourceRepositoryURI !== policy.sourceRepositoryURI) throw _err("sigstore/identity-mismatch", "the certificate source-repository URI does not match");
|
|
566
|
+
return ran;
|
|
527
567
|
}
|
|
528
568
|
|
|
529
569
|
// ---- in-toto Statement leg ---------------------------------------------------
|
|
@@ -561,14 +601,23 @@ function _statement(payload, payloadType, expectedPredicate) {
|
|
|
561
601
|
* the log entry bound to this exact signature; and the in-toto SLSA statement.
|
|
562
602
|
* Any leg failing throws a typed `sigstore/*` error. On success returns
|
|
563
603
|
* `{ verified: true, payload, statement, subjects, predicateType, predicate,
|
|
564
|
-
* identity, integratedTime }` -- `payload` is the RAW verified
|
|
565
|
-
* (never a re-serialization), and the caller confirms a
|
|
566
|
-
* matches the published artifact.
|
|
604
|
+
* identity, identityChecked, integratedTime }` -- `payload` is the RAW verified
|
|
605
|
+
* envelope bytes (never a re-serialization), and the caller confirms a
|
|
606
|
+
* `subjects[].digest` matches the published artifact.
|
|
607
|
+
*
|
|
608
|
+
* `verified: true` says the artifact was signed and logged -- not that a party you
|
|
609
|
+
* trust signed it. Fulcio issues a certificate to anyone who completes an OIDC
|
|
610
|
+
* flow, so WHO signed is decided only by `opts.identity`, and `identityChecked`
|
|
611
|
+
* reports which of its fields were compared (`{ san, issuer, sourceRepositoryURI }`,
|
|
612
|
+
* each a boolean). An `identity` naming none of them is refused rather than
|
|
613
|
+
* satisfied, since it would accept every signer while reading as a policy; so is an
|
|
614
|
+
* unrecognized field name, which would otherwise pin nothing under a spelling the
|
|
615
|
+
* operator believes constrains the signer.
|
|
567
616
|
*
|
|
568
617
|
* @opts
|
|
569
618
|
* fulcioRoots: Array, // the Fulcio CA anchors: a DER Buffer or { der, validFor } each
|
|
570
619
|
* rekorKeys: Array, // [{ keyId, spki, validFor? }] the Rekor log public keys
|
|
571
|
-
* identity: object, // optional policy: { san, issuer, sourceRepositoryURI }
|
|
620
|
+
* identity: object, // optional policy: { san, issuer, sourceRepositoryURI }; at least one required when present
|
|
572
621
|
* predicateType: string, // optional: require this in-toto predicateType (e.g. the SLSA URI)
|
|
573
622
|
* time: Date, // optional check-date override (default: the Rekor integratedTime)
|
|
574
623
|
*
|
|
@@ -616,7 +665,7 @@ async function verifyBundle(bundle, opts) {
|
|
|
616
665
|
var checkTime = (opts.time instanceof Date) ? opts.time.getTime() : C.TIME.seconds(integratedTime);
|
|
617
666
|
await _verifyChain(leaf, _chainDers(vm), fulcioRoots, checkTime);
|
|
618
667
|
var identity = _identity(leaf);
|
|
619
|
-
_checkIdentity(identity, opts.identity);
|
|
668
|
+
var identityChecked = _checkIdentity(identity, opts.identity);
|
|
620
669
|
|
|
621
670
|
// Leg 4 -- the in-toto SLSA statement + subject binding (with an optional
|
|
622
671
|
// caller-pinned predicateType).
|
|
@@ -630,6 +679,9 @@ async function verifyBundle(bundle, opts) {
|
|
|
630
679
|
predicateType: st.predicateType,
|
|
631
680
|
predicate: st.predicate,
|
|
632
681
|
identity: identity,
|
|
682
|
+
// Which identity fields were actually compared. `verified` says the artifact was signed and
|
|
683
|
+
// logged; only these say a signer the caller named was the one who signed it.
|
|
684
|
+
identityChecked: identityChecked,
|
|
633
685
|
integratedTime: integratedTime,
|
|
634
686
|
};
|
|
635
687
|
}
|
package/lib/webcrypto.js
CHANGED
|
@@ -1097,7 +1097,17 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
|
|
|
1097
1097
|
var a2 = (name === "HMAC") ? { name: name, hash: _hashObj(alg.hash, "importKey jwk HMAC"), length: kbuf.length * 8 } : { name: name, length: kbuf.length * 8 };
|
|
1098
1098
|
return new CryptoKey("secret", extractable, a2, usages, s2);
|
|
1099
1099
|
}
|
|
1100
|
-
|
|
1100
|
+
// The private half is named by the KEY TYPE, not by one spelling. EC and OKP carry it in `d`;
|
|
1101
|
+
// an AKP JWK -- how ML-DSA, ML-KEM and SLH-DSA are represented -- carries it in `priv`.
|
|
1102
|
+
// Testing `d` alone reads every PQC private JWK as public, so a re-import yields a public key
|
|
1103
|
+
// that still announces `usages: ["sign"]` and forces `extractable` true whatever the caller
|
|
1104
|
+
// asked, silently dropping the half that signs.
|
|
1105
|
+
// `priv` is read ONLY for an AKP key, the type that defines it. RFC 7517 sec. 4 requires an
|
|
1106
|
+
// unrecognized member to be ignored, so a member of that name on an EC or OKP JWK is an
|
|
1107
|
+
// extension this implementation has no meaning for -- reading it as private material there
|
|
1108
|
+
// would turn a valid public-key import into a failure.
|
|
1109
|
+
var isPrivate = Object.prototype.hasOwnProperty.call(jwk, "d") ||
|
|
1110
|
+
(jwk.kty === "AKP" && Object.prototype.hasOwnProperty.call(jwk, "priv"));
|
|
1101
1111
|
var ko = _nodeKey(function () { return isPrivate ? nodeCrypto.createPrivateKey({ key: jwk, format: "jwk" }) : nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }, "importKey jwk");
|
|
1102
1112
|
return new CryptoKey(isPrivate ? "private" : "public", isPrivate ? extractable : true, _algFromImport(name, alg, ko), usages, ko);
|
|
1103
1113
|
}
|
|
@@ -1187,6 +1197,17 @@ function _curveFromKey(ko) {
|
|
|
1187
1197
|
* (either), or `raw` (symmetric, or an uncompressed EC / OKP public
|
|
1188
1198
|
* point). Throws unless the key was created `extractable`.
|
|
1189
1199
|
*
|
|
1200
|
+
* `raw` is defined for public and secret keys only -- asking for it on a private
|
|
1201
|
+
* key throws `webcrypto/not-supported` rather than answering with the public half.
|
|
1202
|
+
* This matters through `wrapKey`, which forwards the caller's format here: wrapping
|
|
1203
|
+
* a private key as `raw` would otherwise escrow the public key, and unwrapping it
|
|
1204
|
+
* returns a handle announcing `usages: ["sign"]` that cannot sign, with the private
|
|
1205
|
+
* key gone. Use `pkcs8` or `jwk` to serialize a private key.
|
|
1206
|
+
*
|
|
1207
|
+
* A private `jwk` round-trips as a private key for every algorithm, ML-DSA, ML-KEM
|
|
1208
|
+
* and SLH-DSA included: those are `kty: "AKP"` and carry the private half in `priv`
|
|
1209
|
+
* rather than the `d` an EC or OKP key uses.
|
|
1210
|
+
*
|
|
1190
1211
|
* @example
|
|
1191
1212
|
* var keyPair = await pki.webcrypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
1192
1213
|
* var spki = await pki.webcrypto.subtle.exportKey("spki", keyPair.publicKey);
|
|
@@ -1206,7 +1227,19 @@ SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
|
|
|
1206
1227
|
}
|
|
1207
1228
|
if (format === "spki") return _toArrayBuffer(key._handle.export({ format: "der", type: "spki" }));
|
|
1208
1229
|
if (format === "pkcs8") return _toArrayBuffer(key._handle.export({ format: "der", type: "pkcs8" }));
|
|
1209
|
-
if (format === "raw")
|
|
1230
|
+
if (format === "raw") {
|
|
1231
|
+
// "raw" is defined for PUBLIC and secret keys; there is no raw private-key serialization for
|
|
1232
|
+
// EC or OKP. Answering a private-key request with the public half hands back the opposite of
|
|
1233
|
+
// what was asked for, with nothing to notice it by -- and `wrapKey` forwards the caller's
|
|
1234
|
+
// format straight here, so a private key wrapped as "raw" escrows the PUBLIC key. Unwrapping
|
|
1235
|
+
// that yields a handle announcing it can sign, which cannot, and the private key is gone.
|
|
1236
|
+
if (key.type !== "public") {
|
|
1237
|
+
throw new WebCryptoError("webcrypto/not-supported",
|
|
1238
|
+
"exportKey: 'raw' is defined for public and secret keys only -- a " + key.type +
|
|
1239
|
+
" key has no raw serialization; use 'pkcs8' or 'jwk'");
|
|
1240
|
+
}
|
|
1241
|
+
return _toArrayBuffer(_rawPublic(key));
|
|
1242
|
+
}
|
|
1210
1243
|
throw new WebCryptoError("webcrypto/not-supported", "exportKey: unsupported format " + JSON.stringify(format));
|
|
1211
1244
|
};
|
|
1212
1245
|
|
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:350d591c-d7d9-431c-8902-3859e2fdad78",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-12T22:09:02.062Z",
|
|
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.5.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.5.1",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.5.
|
|
25
|
+
"version": "0.5.1",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.5.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.5.1",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.5.
|
|
57
|
+
"ref": "@blamejs/pki@0.5.1",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|