Repository navigation
fix(jose): typed PASETO keys, JWE header integrity, JSON limits, key-import perf - #80
Merged
Merged
Conversation
Keys were raw Buffers, and a 32-byte buffer is the same shape whether it is a v4.local secret or a v4.public public key. An app that passed its public key to encrypt/decrypt therefore let anyone holding that public key mint v4.local tokens (PoC: encrypt(…, publicKey) + decrypt(…, publicKey) round-tripped). Following PASETO's algorithm-lucidity guidance, keys are now PasetoKey objects carrying version + purpose: - generateKey() → v4 local, generateKeyPair() → v4 secret + v4 public - keys.local / keys.secret / keys.public wrap existing raw material (or Ed25519 KeyObjects), validated up front - encrypt/decrypt only take local keys, sign only secret, verify only public; anything else — including a bare buffer — is the new INVALID_KEY_PURPOSE - key.export() returns the raw wire form; toJSON() never includes bytes BREAKING CHANGE: raw Buffer / KeyObject keys are refused. Wrap them with keys.local(…), keys.secret(…) or keys.public(…).
@exortek/paseto now requires purpose-typed keys. pasetoIssuer checks for a v4 secret and a v4 public PasetoKey at configuration time, so raw buffers or swapped keys fail at startup rather than on the first token.
tokenPair.create treats a bare-number expiresIn as seconds, but handed it straight to encrypt / sign, which read a bare number as milliseconds. access.expiresIn: 900 produced a token that expired 0.9 s later while accessExpiresAt reported 15 minutes; an opaque:false refresh token with '30d' expired after about 43 minutes. Both are now passed with an explicit seconds unit, so the token exp matches the reported expiry.
decrypt / verify treated a trailing '.' (an empty fourth segment) as no footer, so every token verified under a second spelling. A present but empty footer segment is now INVALID_TOKEN.
verify / decrypt validated claims only when the payload was an object.
A string, bytes or array payload skipped every check, so
verify(sign('user@x'), pk, { issuer, audience }) passed. When issuer,
subject or audience is required and the payload is not a claims object,
the token is now rejected with CLAIM_MISMATCH.
With refresh.opaque: false the refresh JWT was signed with typ JWT, so when secret.refresh equalled secret.access it verified as an access token anywhere the access key was trusted. - token-pair now refuses secret.refresh equal to secret.access when refresh tokens are signed - refresh JWTs carry typ: refresh+jwt (RFC 8725 §3.11), so verifiers with a typ allowlist can tell them apart BREAKING CHANGE: tokenPair with refresh.opaque: false needs distinct access and refresh keys.
With refresh.opaque: false and secret.refresh equal to secret.access, a refresh token decrypted / verified as an access token under the access key. token-pair now refuses an identical access and refresh key when refresh tokens are PASETOs.
A PEM public key or certificate passed for HS256 was imported as an asymmetric KeyObject and only failed deep inside node:crypto with an untyped TypeError (500), instead of the documented INVALID_KEY. HMAC algs now refuse any PEM input up front.
verifyJson decoded the payload and tried every signature with no size or count limit, unlike compact verify (maxTokenSize 8192). A JWS JSON with thousands of signatures cost thousands of verifications per call. It now enforces maxTokenSize (default 64 KiB for the serialized JSON) and maxSignatures (default 8) before decoding anything.
decryptJson tried a key unwrap for every recipient with no limit on how many there were or how large the JSON was, unlike compact decrypt (maxTokenSize 8192). It now enforces maxTokenSize (default 64 KiB for the serialized JSON) and maxRecipients (default 8) first.
…d only decryptJson merged each recipient's unprotected header over the protected one and returned the result as protectedHeader. An attacker could therefore change a header value without touching the authenticated part — and callers reading protectedHeader trusted it. The shared unprotected header was ignored, and crit could come from an unprotected header. - protected, shared-unprotected and per-recipient headers must be disjoint (RFC 7516 §7.2.1), otherwise INVALID_HEADER - crit is only accepted in the protected header - the result reports protectedHeader (authenticated only), unprotectedHeader and the joint header separately - encryptJson refuses a parameter set both in options.header and per recipient, so it never produces such a JWE BREAKING CHANGE: decryptJson's protectedHeader no longer includes unprotected members (read header or unprotectedHeader for kid etc.), and JWEs with overlapping headers are rejected.
zip was listed as an understood header but never implemented, so a token with zip: DEF decrypted to its still-deflated bytes, returned as if they were the plaintext. Any zip header is now INVALID_HEADER.
The remote resolver compared the token's alg with the key's published alg only on a cache hit. A key that arrived through the kid-miss refetch was returned without the check, so an alg mismatch passed whenever the kid was new. Both paths now share one check.
The jws / jwt key normalizer checked a JWK's kty (and EC crv) but ignored the members that say what the key is for: a JWK published for RS512, with use: enc, or with key_ops lacking verify was still accepted for another algorithm or operation (RFC 7517 §4.2-4.4). Those mismatches are now INVALID_KEY.
toPublic deleted the private members it knows for EC / OKP / RSA and returned everything else unchanged — so a JWK with an unrecognised kty (or none) came back with its secret fields intact, ready to publish. Only EC, OKP and RSA are accepted now.
HMAC sign / verify exported the secret KeyObject's bytes on every call before handing them to createHmac, which accepts the KeyObject itself. ECDSA converted Node's DER signatures to JOSE's raw R‖S (and back) by hand; node:crypto produces and consumes that form directly with dsaEncoding: 'ieee-p1363' (available on Node 22). Measured on jws verify: HS256 2.8 → 2.5 µs, ES256 34.5 → 34.0 µs. RFC 7515 vectors unchanged.
Every jws / jwt verify re-parsed a JWK object or PEM string into a KeyObject, and the jwks local resolver re-imported its key on every resolve. Measured on an ES256 jwt verify: KeyObject 34.4 µs, PEM 48.5 µs, JWK 47.1 µs — about 40 % of the call was the import. - shared createImportCache: JWK objects memoised by identity (WeakMap), strings in a bounded LRU; per alg + direction, successes only - jws / jwt use it for JWK inputs, jwt also for PEM / secret strings - jwks local key sets cache each entry's imported key After: PEM 34.3 µs, JWK 33.7 µs. Key inputs should be treated as immutable once used.
The vendored BLAKE2b carried each 64-bit lane as a BigInt, so every v4.local encrypt / decrypt (three BLAKE2b calls) paid for BigInt allocation in the inner loop. Lanes are now pairs of 32-bit words in preallocated Uint32Arrays. Keyed, 32-byte output: 100 B 35.0 → 2.8 µs, 1 KB 152.9 → 9.8 µs, 8 KB 1064 → 71 µs. Verified against RFC 7693 Appendix A, the BLAKE2 keyed KAT, node's blake2b512 across block boundaries, the PASETO v4 vectors, and the previous implementation on 500 random keyed inputs. Token size is already capped (maxTokenSize) before the MAC runs.
README and web docs for PasetoKey (generateKey / generateKeyPair return typed keys, keys.local / secret / public wrap raw material, INVALID_KEY_PURPOSE), the distinct refresh key for opaque:false, and the seconds-based token-pair expiries.
jws verifyJson / jwe decryptJson size and count limits, the separate protected / unprotected / joint JWE headers, zip rejection, JWK alg / use / key_ops checks, toPublic's kty allowlist and typed jwt refresh tokens.
11 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What / why
JOSE / PASETO hardening plus three measured performance fixes. Each security fix has a regression test that reproduced the problem.
PASETO (
@exortek/paseto)encrypt/decryptlet anyone holding it mint local tokens. Keys are nowPasetoKeyobjects (version + purpose); wrong purpose or a bare buffer →INVALID_KEY_PURPOSE.keys.local / secret / publicwrap existing material.tokenPairhanded seconds to an API that reads numbers as milliseconds —expiresIn: 900produced a token valid for 0.9 s.JWT / JWS / JWE / JWK / JWKS
typ: refresh+jwtand need their own key (they verified as access tokens under a shared key).INVALID_KEYinstead of a TypeError.verifyJson/decryptJsonbounded by size and signature / recipient count.protectedHeadernow really is the protected header — previously an unprotected header could override and masquerade as protected.zipis rejected instead of returning compressed bytes.alg/use/key_ops;toPublicrefuses unknownkty; jwks applies the alg check to refetched keys too.Performance
dsaEncoding: 'ieee-p1363'.pasetoIssuerin@exortek/oauth2now requires the typed keys.Which package(s)?
@exortek/paseto@exortek/jwt@exortek/jws@exortek/jwe@exortek/jwk@exortek/jwks@exortek/oauth2Type of change
Checklist
yarn verifypasses locally (Redis integration suites skipped locally this time — CI runs them on both drivers)web/content/…)Additional notes
Independent of #78 and #79 except for
@exortek/oauth2, which also bumps major there. Not released on merge.