Skip to content

fix(jose): typed PASETO keys, JWE header integrity, JSON limits, key-import perf - #80

Merged
ExorTek merged 21 commits into
masterfrom
fix/jose-audit-hardening
Oct 24, 2026
Merged

ExorTek merged 21 commits into
masterfrom
fix/jose-audit-hardening

Conversation

@ExorTek

@ExorTek ExorTek commented Oct 24, 2026

Copy link
Copy Markdown
Owner

What / why

JOSE / PASETO hardening plus three measured performance fixes. Each security fix has a regression test that reproduced the problem.

PASETO (@exortek/paseto)

  • Typed keys. A raw 32-byte buffer is the same shape for a v4.local secret and a v4.public public key, so passing the public key to encrypt / decrypt let anyone holding it mint local tokens. Keys are now PasetoKey objects (version + purpose); wrong purpose or a bare buffer → INVALID_KEY_PURPOSE. keys.local / secret / public wrap existing material.
  • tokenPair handed seconds to an API that reads numbers as milliseconds — expiresIn: 900 produced a token valid for 0.9 s.
  • Refresh key must differ from the access key; empty footer segment rejected; claim requirements reject non-object payloads.

JWT / JWS / JWE / JWK / JWKS

  • Signed refresh tokens are typ: refresh+jwt and need their own key (they verified as access tokens under a shared key).
  • HMAC + PEM → INVALID_KEY instead of a TypeError.
  • verifyJson / decryptJson bounded by size and signature / recipient count.
  • JWE JSON headers must be disjoint (RFC 7516 §7.2.1); protectedHeader now really is the protected header — previously an unprotected header could override and masquerade as protected. zip is rejected instead of returning compressed bytes.
  • JWKs honour declared alg / use / key_ops; toPublic refuses unknown kty; jwks applies the alg check to refetched keys too.

Performance

  • Keys imported once per JWK object / PEM string: ES256 jwt verify with a PEM 48.5 → 34.3 µs.
  • BLAKE2b on 32-bit lanes: 1 KB 153 → 9.8 µs, 8 KB 1064 → 71 µs (checked against RFC 7693, the BLAKE2 KAT, node's blake2b512 and the previous implementation).
  • HMAC on the KeyObject, ECDSA via dsaEncoding: 'ieee-p1363'.

pasetoIssuer in @exortek/oauth2 now requires the typed keys.

Which package(s)?

  • @exortek/paseto
  • @exortek/jwt
  • @exortek/jws
  • @exortek/jwe
  • @exortek/jwk
  • @exortek/jwks
  • @exortek/oauth2
  • Repo tooling (build, CI, docs site, lint/format) — web docs

Type of change

  • 🐛 Bug fix
  • 💥 Breaking change (existing behaviour changes — requires a major bump)
  • 📖 Documentation

Checklist

  • yarn verify passes locally (Redis integration suites skipped locally this time — CI runs them on both drivers)
  • Tests added / updated for the change
  • Docs updated (README + web/content/…)
  • Changesets added — paseto / jwe / jwt / oauth2 major (with migration notes), jws minor, jwks / jwk patch
  • Public API changes carry updated JSDoc

Additional notes

Independent of #78 and #79 except for @exortek/oauth2, which also bumps major there. Not released on merge.

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.
Copilot AI balanced review requested due to automatic review settings October 24, 2026 13:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@ExorTek
ExorTek merged commit 3b047e7 into master Oct 24, 2026
3 checks passed
@ExorTek
ExorTek deleted the fix/jose-audit-hardening branch October 24, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants