Skip to content

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

Description

@proggeramlug

Repro (15 lines)

```ts
import jwt from "jsonwebtoken";
import { readFileSync } from "fs";

const PEM = readFileSync("/tmp/skelpo-jwt.pem", "utf8");
const ALG = "ES256"; // module-level const

const tokA = jwt.sign({ sub: "a" }, PEM, { algorithm: "ES256", issuer: "z" });
const headA = JSON.parse(Buffer.from(tokA.split(".")[0]!, "base64url").toString("utf8"));
console.log("(A) literal → header.alg =", headA.alg);

const tokB = jwt.sign({ sub: "b" }, PEM, { algorithm: ALG, issuer: "z" });
const headB = JSON.parse(Buffer.from(tokB.split(".")[0]!, "base64url").toString("utf8"));
console.log("(B) const ref → header.alg =", headB.alg);

const opts = { algorithm: "ES256" as const, issuer: "z" };
const tokC = jwt.sign({ sub: "c" }, PEM, opts);
const headC = JSON.parse(Buffer.from(tokC.split(".")[0]!, "base64url").toString("utf8"));
console.log("(C) const opts → header.alg =", headC.alg);
```

Observed (perry 0.5.1008)

```
(A) literal → header.alg = ES256
(B) const ref → header.alg = HS256 ← uses PEM as HMAC secret
(C) const opts → header.alg = HS256 ← same
```

Expected (tsx / Node)

```
(A) literal → header.alg = ES256
(B) const ref → header.alg = ES256
(C) const opts → header.alg = ES256
```

Pointer

`crates/perry-codegen/src/lower_call/native.rs::lower_jsonwebtoken_sign` only routes when the option value is an inline string literal:

```rust
"algorithm" => {
if let Expr::String(algorithm) = val { // ← only matches inline literals
runtime = match algorithm.as_str() {
"ES256" => "js_jwt_sign_es256",
"RS256" => "js_jwt_sign_rs256",
_ => "js_jwt_sign",
};
} else {
let _ = lower_expr(ctx, val)?; // ← const ref + spread + computed all fall here
}
}
```

`Expr::Identifier` (const reference), spread fields (`...opts`), and any non-literal expression all silently fall through to `js_jwt_sign` (HS256). Mirror bug on `verify`'s `algorithms: [...]` branch.

How this surfaces in shop-admin

`server/crypto/jwt.ts`:

```ts
const ALG = "ES256";
const ISSUER = "skelpo-shop-admin";

export function issueAccessToken(claims: AccessTokenClaims): string {
return jwt.sign(claims, loadKey(), {
algorithm: ALG, // ← falls back to HS256
expiresIn: ACCESS_TOKEN_TTL_SEC,
issuer: ISSUER,
});
}
```

The server boots fine and tokens validate (sign/verify both fall through to HS256 the same way), but the ES256 P-256 PEM is used as an HMAC secret instead of an EC key. The application thinks it's using ES256 — TypeScript types say so — but the on-wire tokens are HS256.

Suggested fix

Resolve simple const references at compile time before the literal match. Most TS-bound `const X = "literal"` is statically known. A small symbol-table lookup in `lower_jsonwebtoken_sign` (and the verify variants) would catch the common case. The general `Expr` may need full constant-folding — out of scope for this issue.

Local checkout: perry 0.5.1008, clean main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions