Skip to content

fix(jwt): #1074 — algorithm const ref + sign/verify dispatch#1089

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-agent-af235feeeea596ecf
May 19, 2026
Merged

fix(jwt): #1074 — algorithm const ref + sign/verify dispatch#1089
proggeramlug merged 1 commit into
mainfrom
worktree-agent-af235feeeea596ecf

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Closes #1074.

jwt.sign(payload, key, { algorithm: X, ... }) silently fell back to HS256 (using the user's PEM as an HMAC secret) when algorithm was anything other than an inline string literal — const-bound identifier, ternary, spread, or a const-bound options object. Mirror bug on jwt.verify's algorithm / algorithms: [...]. Real cryptographic downgrade: tokens were HMAC-signed with the PEM bytes while header.alg claimed HS256, so any verifier accepting either family quietly accepted the downgrade.

Approach (mirrors #1076 / PR #1080 for crypto.createHmac)

  • Const resolution only: no
  • Runtime dispatch fallback: yes (js_jwt_sign_dyn, js_jwt_verify_dyn)
  • Both: the inline-literal fast path is unchanged; everything else routes through runtime dispatch

The fast path ({ algorithm: \"ES256\" } inline literal) still picks the typed js_jwt_sign_es256 / _rs256 symbol at compile time. Non-literal shapes lower the alg value as *const StringHeader and call new js_jwt_sign_dyn(alg, payload, secret, exp, kid), which dispatches to the same sign_common paths at runtime.

For case C (whole options is a non-extractable expression), there's a second variant js_jwt_sign_dyn_opts(payload, secret, opts_jsvalue) that extracts algorithm / expiresIn / keyid from the NaN-boxed object at runtime via js_object_get_field_by_name before deferring to _dyn. Same shape for verify, including algorithms: [...] plural via js_array_get.

Verify side (algorithms: [...])

Yes — lower_jsonwebtoken_verify was updated to cover all the shapes:

  • algorithm: \"ES256\" (literal) — fast path
  • algorithm: ALG (const ref) — runtime dispatch via js_jwt_verify_dyn
  • algorithms: [\"ES256\"] (array of literal) — fast path
  • algorithms: [ALG] (array of non-literal) — runtime dispatch
  • whole options non-extractable — js_jwt_verify_dyn_opts reads algorithm first, then falls back to algorithms[0]

Deferred

  • Multi-algorithm fallback in algorithms: [\"ES256\", \"RS256\"] — only the first entry is honored (matches pre-fix behavior; underlying Rust jsonwebtoken crate's verify is single-algorithm anyway).
  • Non-array algorithms (e.g. a const-bound array reference at the field-level) inside an otherwise-inline options object — falls through to the previous HS256 fallback. Rare in practice; case C handles the more common spread shape.

Test plan

  • test-files/test_jwt_sign_dynamic_alg.ts — covers cases A (inline literal), B (const-bound ident), C (const-bound options object), D (verify with const-bound algorithm). Output matches node --experimental-strip-types byte-for-byte: (A) ES256 / (B) ES256 / (C) ES256 / (D) a.
  • Existing test_issue_915_jwt_sign.ts and test_issue_927_jwt_verify_returns_object.ts still pass.
  • cargo test --release -p perry-codegen --test manifest_consistency — 4 passed.
  • cargo build --release -p perry-runtime -p perry-stdlib -p perry — clean.

No version bump / changelog change — maintainer handles at merge time.

…literal-match

`jwt.sign(payload, key, { algorithm: X, ... })` silently fell back to HS256
(using the user's PEM as an HMAC secret) when `algorithm` was anything other
than an inline string literal: const-bound identifier, ternary, spread, or
when the entire options object was a const ref. Same mirror bug on
`jwt.verify`'s `algorithm` / `algorithms: [...]` option, where ES/RS tokens
silently failed verification.

This is a real cryptographic downgrade: the JWT was HMAC-signed with the
PEM's raw bytes, the on-wire `header.alg` claimed `"HS256"`, and any
verifier that accepts either HMAC or EC/RSA quietly accepted the
downgraded token.

Fix (approach mirrors #1076's runtime-dispatch fallback for
`crypto.createHmac`):

  Stdlib (perry-stdlib/src/jsonwebtoken.rs):
  - js_jwt_sign_dyn(alg, payload, secret, exp, kid): reads the algorithm
    name from a runtime `*const StringHeader` and dispatches to the same
    `sign_common` paths the typed `js_jwt_sign_es256` / `_rs256` helpers
    use. Unknown algs fall back to HS256 with a PERRY_DEBUG log.
  - js_jwt_verify_dyn(alg, token, secret): mirror for verify.
  - js_jwt_sign_dyn_opts(payload, secret, opts_jsvalue): case where the
    entire options object is a non-extractable expression (`const opts =
    {...}; jwt.sign(p, k, opts)`). Extracts `algorithm` / `expiresIn` /
    `keyid` from the NaN-boxed object at runtime via
    `js_object_get_field_by_name`, then defers to `js_jwt_sign_dyn`.
  - js_jwt_verify_dyn_opts: mirror, including `algorithms: [...]` plural
    via `js_array_get`.

  Codegen (crates/perry-codegen/src/lower_call/native.rs):
  - lower_jsonwebtoken_sign: when `algorithm` is non-literal, lower its
    value as a `*const StringHeader` via `get_raw_string_ptr` and route to
    `js_jwt_sign_dyn`. When the whole options expression is not an
    inline-object (or AnonShape), route to `js_jwt_sign_dyn_opts` with
    the options JSValue as the third arg.
  - lower_jsonwebtoken_verify: mirror, covering both singular `algorithm`
    and `algorithms: [literal]` / `algorithms: [non-literal]` / non-array
    `algorithms` shapes.

The inline-literal fast path is unchanged — common case still routes
directly to the typed helpers at compile time.

Validated byte-for-byte against `node --experimental-strip-types` in
test-files/test_jwt_sign_dynamic_alg.ts (cases A inline-literal,
B const-bound ident, C const-bound opts object, D verify with const-bound
algorithm). Existing #915 and #927 jwt regressions still pass.
@proggeramlug proggeramlug merged commit 88a0f86 into main May 19, 2026
9 checks passed
@proggeramlug proggeramlug deleted the worktree-agent-af235feeeea596ecf branch May 19, 2026 10:10
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.

jwt.sign algorithm option only matches inline string literals — const refs fall back to HS256

1 participant