From 7c5a20d452c4af30afac21d641bd7425d7096d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:46:19 +0000 Subject: [PATCH 1/2] refactor(stdlib): remove jsonwebtoken native binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #10683. The hand-written native jsonwebtoken binding (crates/perry-ext-jsonwebtoken, plus a second duplicate implementation in crates/perry-stdlib/src/jsonwebtoken.rs exporting the same js_jwt_* symbols per #10678) has a live security defect: verify() returns null instead of throwing on every forgery case (tampered payload, wrong secret, alg:none, garbage token, tampered signature, expired token), so `try { jwt.verify(...) } catch { reject() }` never rejects a forgery. sign(..., { expiresIn: "1h" }) also silently drops the expiry (string coerces to NaN, and the runtime only writes `exp` when > 0.0). Removes both copies plus the dedicated codegen lowering path (lower_call/native/jsonwebtoken.rs's lower_jsonwebtoken_sign/_verify and its native_runtime_branch.rs dispatch), the decode-only NativeModSig row in native_table/utils_crypto.rs, the js_jwt_* FFI declarations in runtime_decls/stdlib_ffi/third_party.rs, the well_known_bindings.toml entry, the NATIVE_MODULES/manifest rows, the bundled-jsonwebtoken stdlib feature (re-wiring dep:rsa/dep:spki directly onto perry-stdlib's `crypto` feature, since webcrypto/key_object.rs and keys.rs need them independently of jsonwebtoken), and the Android stub exports. The real `jsonwebtoken` crates.io dependency stays — it is unrelated Rust tooling used by perry's own Apple code-signing (commands/run/resign.rs, commands/setup/common_apple.rs). Regenerated docs/api/perry.d.ts, docs/src/api/reference.md (--print-api-manifest) and docs/src/native-libraries/governance.md (binding_governance.py --table). Updated workspace-architecture.json (workspace_members 83->82, externalize 33->32) and scripts/string_payload_access_baseline.txt (perry-stdlib inline-offset sites 40->39, from the deleted stdlib file). --- Cargo.lock | 12 - Cargo.toml | 2 - crates/perry-api-manifest/src/entries.rs | 1 - .../perry-api-manifest/src/entries/part_1.rs | 65 -- .../src/lower_call/native/jsonwebtoken.rs | 290 ------ .../src/lower_call/native/mod.rs | 4 - .../native/native_runtime_branch.rs | 7 - .../lower_call/native_table/utils_crypto.rs | 18 - .../runtime_decls/stdlib_ffi/third_party.rs | 21 - crates/perry-ext-jsonwebtoken/Cargo.toml | 22 - crates/perry-ext-jsonwebtoken/src/lib.rs | 399 -------- crates/perry-stdlib/Cargo.toml | 4 +- crates/perry-stdlib/src/jsonwebtoken.rs | 904 ------------------ crates/perry-stdlib/src/lib.rs | 5 - crates/perry-ui-android/src/stdlib_stubs.rs | 20 - crates/perry/src/commands/stdlib_features.rs | 1 - crates/perry/well_known_bindings.toml | 12 - docs/api/perry.d.ts | 11 +- docs/src/api/reference.md | 11 +- docs/src/native-libraries/governance.md | 1 - scripts/string_payload_access_baseline.txt | 2 +- scripts/unrooted_local_shape_baseline.json | 1 - workspace-architecture.json | 9 +- 23 files changed, 6 insertions(+), 1816 deletions(-) delete mode 100644 crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs delete mode 100644 crates/perry-ext-jsonwebtoken/Cargo.toml delete mode 100644 crates/perry-ext-jsonwebtoken/src/lib.rs delete mode 100644 crates/perry-stdlib/src/jsonwebtoken.rs diff --git a/Cargo.lock b/Cargo.lock index 0894a6f6d7..51a3548395 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6026,17 +6026,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "perry-ext-jsonwebtoken" -version = "0.5.1596" -dependencies = [ - "base64 0.22.1", - "jsonwebtoken", - "perry-ffi", - "serde", - "serde_json", -] - [[package]] name = "perry-ext-lru-cache" version = "0.5.1596" @@ -6420,7 +6409,6 @@ dependencies = [ "hyper", "hyper-util", "image", - "jsonwebtoken", "lazy_static", "lettre", "libc", diff --git a/Cargo.toml b/Cargo.toml index 32c668115e..aed7788097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ members = [ "crates/perry-ext-uuid", "crates/perry-ext-bcrypt", "crates/perry-ext-argon2", - "crates/perry-ext-jsonwebtoken", "crates/perry-ext-validator", "crates/perry-validation", "crates/perry-perex", @@ -482,7 +481,6 @@ perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } perry-ext-bcrypt = { path = "crates/perry-ext-bcrypt" } perry-ext-argon2 = { path = "crates/perry-ext-argon2" } -perry-ext-jsonwebtoken = { path = "crates/perry-ext-jsonwebtoken" } perry-ext-validator = { path = "crates/perry-ext-validator" } perry-validation = { path = "crates/perry-validation" } perry-perex = { path = "crates/perry-perex" } diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 0e1415b629..de7f6a8bec 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -47,7 +47,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "crypto", // (Node builtin) hashing, HMAC, cipher, sign/verify, WebCrypto "dotenv", // .env file loader "dotenv/config", // dotenv's auto-load-on-import subpath - "jsonwebtoken", // JWT sign/verify "nanoid", // compact URL-safe ID generation "validator", // string validators/sanitizers "ethers", // Ethereum library (utils/wallet/ABI) diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index aa51556739..5af3539565 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1166,71 +1166,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ }], TypeSpec::Number, ), - method_sig( - "jsonwebtoken", - "sign", - false, - None, - &[ - ParamSpec::Named { - name: "payload", - ty: TypeSpec::Any, - optional: false, - }, - ParamSpec::Named { - name: "secret", - ty: TypeSpec::String, - optional: false, - }, - ParamSpec::Named { - name: "options", - ty: TypeSpec::Any, - optional: true, - }, - // #915: FFI's 4th arg is `kid_ptr: *const StringHeader` — the - // dispatch table padding zeroes it when the user doesn't pass - // it. Surfacing the slot in the manifest keeps the - // #512 arity-drift assertion happy without forcing every - // caller to write a 4th positional arg. - ParamSpec::Named { - name: "kid", - ty: TypeSpec::String, - optional: true, - }, - ], - TypeSpec::String, - ), - method_sig( - "jsonwebtoken", - "verify", - false, - None, - &[ - ParamSpec::Named { - name: "token", - ty: TypeSpec::String, - optional: false, - }, - ParamSpec::Named { - name: "secret", - ty: TypeSpec::String, - optional: false, - }, - ], - TypeSpec::Any, - ), - method_sig( - "jsonwebtoken", - "decode", - false, - None, - &[ParamSpec::Named { - name: "token", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Any, - ), method_sig( "nodemailer", "createTransport", diff --git a/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs b/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs deleted file mode 100644 index dbdec4be7c..0000000000 --- a/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! `lower_jsonwebtoken_sign` / `lower_jsonwebtoken_verify` and the -//! payload-pointer helper. Split out of `lower_call/native.rs` -//! (~272 LOC) so the parent module stays under the 2000-line cap. -//! -//! Both entry points are option-aware (algorithm: HS256 / ES256 / -//! RS256) and route to typed runtime helpers when possible, falling -//! back to the `_dyn` / `_dyn_opts` paths when the algorithm / -//! options object isn't an inline literal (#1074). - -use anyhow::{bail, Result}; -use perry_hir::Expr; - -use crate::expr::{lower_expr, FnCtx}; -use crate::nanbox::double_literal; -use crate::type_analysis::is_string_expr; -use crate::types::{DOUBLE, I32, I64}; - -use super::*; - -fn lower_jsonwebtoken_payload_ptr(ctx: &mut FnCtx<'_>, payload: &Expr) -> Result { - if is_string_expr(ctx, payload) { - return get_raw_string_ptr(ctx, payload); - } - - let boxed_payload = lower_expr(ctx, payload)?; - Ok(ctx.block().call( - I64, - "js_json_stringify", - &[(DOUBLE, &boxed_payload), (I32, "0")], - )) -} - -pub(super) fn lower_jsonwebtoken_sign(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - if args.len() < 2 { - bail!( - "jsonwebtoken.sign(payload, secret, options?) expects at least 2 args, got {}", - args.len() - ); - } - - let payload_ptr = lower_jsonwebtoken_payload_ptr(ctx, &args[0])?; - let secret_ptr = get_raw_string_ptr(ctx, &args[1])?; - let mut runtime = "js_jwt_sign"; - // #1074: when the user writes `{ algorithm: ALG }` (i.e. `algorithm` - // is a non-literal expression), the inline-literal fast path can't - // pick a typed runtime helper. We track that as a fallback to - // `js_jwt_sign_dyn`, which takes the alg string as a runtime argument - // and dispatches there. Pre-#1074 this fell through to the HS256 - // path silently — a real cryptographic downgrade. - let mut alg_ptr_dyn: Option = None; - let mut expires_in = double_literal(0.0); - let mut kid_ptr = "0".to_string(); - - if let Some(options) = args.get(2) { - if let Some(props) = extract_options_fields(ctx, options) { - for (key, val) in &props { - match key.as_str() { - "algorithm" => { - if let Expr::String(algorithm) = val { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_sign_es256", - "RS256" => "js_jwt_sign_rs256", - _ => "js_jwt_sign", - }; - } else { - // Non-literal alg (#1074): lower to a string - // pointer and let `js_jwt_sign_dyn` pick the - // right backend at runtime. - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, val)?); - runtime = "js_jwt_sign_dyn"; - } - } - "expiresIn" => { - expires_in = lower_expr(ctx, val)?; - } - "keyid" | "kid" => { - kid_ptr = get_raw_string_ptr(ctx, val)?; - } - _ => { - let _ = lower_expr(ctx, val)?; - } - } - } - } else { - // #1074 case C: the options expression is not an inline - // object literal (e.g. `const opts = { algorithm: "ES256" }; - // jwt.sign(p, k, opts)`). Lower options as a NaN-boxed - // JSValue and route to `js_jwt_sign_dyn_opts`, which - // extracts algorithm/expiresIn/keyid at runtime. - let opts_val = lower_expr(ctx, options)?; - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - ctx.pending_declares.push(( - "js_jwt_sign_dyn_opts".to_string(), - I64, - vec![I64, I64, DOUBLE], - )); - let raw = ctx.block().call( - I64, - "js_jwt_sign_dyn_opts", - &[(I64, &payload_ptr), (I64, &secret_ptr), (DOUBLE, &opts_val)], - ); - return Ok(ctx.block().bitcast_i64_to_double(&raw)); - } - } - - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - - // Build the call. The five-arg dyn path takes the alg string first; - // the four-arg typed-helper path doesn't (the algorithm is implied - // by the symbol name). - let raw = if let Some(alg_ptr) = alg_ptr_dyn { - ctx.pending_declares.push(( - "js_jwt_sign_dyn".to_string(), - I64, - vec![I64, I64, I64, DOUBLE, I64], - )); - ctx.block().call( - I64, - "js_jwt_sign_dyn", - &[ - (I64, &alg_ptr), - (I64, &payload_ptr), - (I64, &secret_ptr), - (DOUBLE, &expires_in), - (I64, &kid_ptr), - ], - ) - } else { - ctx.pending_declares - .push((runtime.to_string(), I64, vec![I64, I64, DOUBLE, I64])); - ctx.block().call( - I64, - runtime, - &[ - (I64, &payload_ptr), - (I64, &secret_ptr), - (DOUBLE, &expires_in), - (I64, &kid_ptr), - ], - ) - }; - Ok(ctx.block().bitcast_i64_to_double(&raw)) -} - -/// Dispatch `jsonwebtoken.verify(token, secret_or_pem, options?)` to -/// the right runtime (HS256 / ES256 / RS256) based on the -/// `algorithms: ['…']` (or singular `algorithm: '…'`) option. -/// Mirrors `lower_jsonwebtoken_sign`. -/// -/// perry#927 follow-up: the generic NativeModSig table picked -/// `js_jwt_verify` (HS256-only) for every algorithm, so ES256 / RS256 -/// tokens silently failed verification (returning `null` to user -/// code, breaking the shop-admin auth middleware after a successful -/// signup). Verify needs the same option-aware routing that `sign` -/// already has. -/// -/// Return shape matches the old `NR_OBJ_FROM_JSON_STR`: the runtime -/// hands back a JSON-text `*mut StringHeader` (or null), which we -/// pipe through `js_json_parse_or_null` so user code sees a real -/// object on success and `null` on failure (no throw). -pub(super) fn lower_jsonwebtoken_verify(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - if args.len() < 2 { - bail!( - "jsonwebtoken.verify(token, secret, options?) expects at least 2 args, got {}", - args.len() - ); - } - - let token_ptr = get_raw_string_ptr(ctx, &args[0])?; - let secret_ptr = get_raw_string_ptr(ctx, &args[1])?; - let mut runtime = "js_jwt_verify"; - // #1074: when `algorithm` (or the first entry of `algorithms`) is a - // non-literal expression, lower it as a string and route through - // `js_jwt_verify_dyn` instead of silently picking HS256. - let mut alg_ptr_dyn: Option = None; - - if let Some(options) = args.get(2) { - if let Some(props) = extract_options_fields(ctx, options) { - for (key, val) in &props { - match key.as_str() { - // `algorithm: 'ES256'` (singular) — accepted for - // symmetry with `sign`'s option name. - "algorithm" => { - if let Expr::String(algorithm) = val { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_verify_es256", - "RS256" => "js_jwt_verify_rs256", - _ => "js_jwt_verify", - }; - } else { - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, val)?); - runtime = "js_jwt_verify_dyn"; - } - } - // `algorithms: ['ES256']` (plural array) — the - // canonical Node `jsonwebtoken.verify` shape. - // First entry decides routing; the underlying Rust - // jsonwebtoken crate's verify is single-algorithm, - // so multi-algorithm fallback isn't honored. - "algorithms" => { - if let Expr::Array(elems) = val { - match elems.first() { - Some(Expr::String(algorithm)) => { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_verify_es256", - "RS256" => "js_jwt_verify_rs256", - _ => "js_jwt_verify", - }; - } - // #1074: first element is a non-literal - // (e.g. `algorithms: [ALG]` where ALG is - // a const-bound name). Lower it as a - // string and route through the dyn path. - Some(other) => { - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, other)?); - runtime = "js_jwt_verify_dyn"; - } - None => {} - } - } else { - // `algorithms` is a non-array expression - // (e.g. a const-bound array reference). We - // could try harder, but the runtime opts - // path below already handles this when the - // whole options object is non-extractable. - // Lower the side effect and let the - // following HS256 fallback fire — same as - // pre-#1074 (rare in practice). - let _ = lower_expr(ctx, val)?; - } - } - _ => { - let _ = lower_expr(ctx, val)?; - } - } - } - } else { - // #1074 case C: options is not an inline object literal — - // defer extraction to `js_jwt_verify_dyn_opts`, which reads - // `algorithm` / `algorithms[0]` at runtime. - let opts_val = lower_expr(ctx, options)?; - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - ctx.pending_declares.push(( - "js_jwt_verify_dyn_opts".to_string(), - I64, - vec![I64, I64, DOUBLE], - )); - ctx.pending_declares - .push(("js_json_parse_or_null".to_string(), I64, vec![I64])); - let blk = ctx.block(); - let raw = blk.call( - I64, - "js_jwt_verify_dyn_opts", - &[(I64, &token_ptr), (I64, &secret_ptr), (DOUBLE, &opts_val)], - ); - let parsed_bits = blk.call(I64, "js_json_parse_or_null", &[(I64, &raw)]); - return Ok(blk.bitcast_i64_to_double(&parsed_bits)); - } - } - - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - - let raw = if let Some(alg_ptr) = alg_ptr_dyn { - ctx.pending_declares - .push(("js_jwt_verify_dyn".to_string(), I64, vec![I64, I64, I64])); - ctx.block().call( - I64, - "js_jwt_verify_dyn", - &[(I64, &alg_ptr), (I64, &token_ptr), (I64, &secret_ptr)], - ) - } else { - ctx.pending_declares - .push((runtime.to_string(), I64, vec![I64, I64])); - ctx.block() - .call(I64, runtime, &[(I64, &token_ptr), (I64, &secret_ptr)]) - }; - ctx.pending_declares - .push(("js_json_parse_or_null".to_string(), I64, vec![I64])); - let blk = ctx.block(); - let parsed_bits = blk.call(I64, "js_json_parse_or_null", &[(I64, &raw)]); - Ok(blk.bitcast_i64_to_double(&parsed_bits)) -} diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index 8c255a1bd9..6f5e681b11 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -17,8 +17,6 @@ //! Split into siblings: //! - `box_style.rs` — `apply_box_style` + `emit_dim_setter` (perry/tui //! `Box(...)` inline-style destructure helpers). -//! - `jsonwebtoken.rs` — `lower_jsonwebtoken_sign` / `_verify` (#1074 -//! algorithm-aware routing). //! The giant `lower_native_method_call` dispatcher itself stays here. use anyhow::{bail, Result}; @@ -49,11 +47,9 @@ pub(super) use super::{ }; mod box_style; -mod jsonwebtoken; mod perf_hooks; use box_style::apply_box_style; -use jsonwebtoken::{lower_jsonwebtoken_sign, lower_jsonwebtoken_verify}; fn util_types_arg_is_async_function_static(ctx: &FnCtx<'_>, expr: &Expr) -> Option { match expr { diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index c1e5a9a059..5fa5d09881 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -312,13 +312,6 @@ } } - if module == "jsonwebtoken" && method == "sign" && object.is_none() { - return lower_jsonwebtoken_sign(ctx, args); - } - if module == "jsonwebtoken" && method == "verify" && object.is_none() { - return lower_jsonwebtoken_verify(ctx, args); - } - // node:perf_hooks → native/perf_hooks.rs (performance.* + PerformanceObserver). if let Some(v) = perf_hooks::lower_perf_hooks_method(ctx, module, method, object, args)? { return Ok(v); diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index 880a733ee1..f94c677f70 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -75,24 +75,6 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ args: &[NA_STR], ret: NR_F64, }, - // ========== jsonwebtoken ========== - // `sign` and `verify` are intentionally handled in - // lower_call/native.rs — both need option-dependent runtime - // selection (HS256 / ES256 / RS256) that the generic table can't - // express. `decode` stays here because it has no algorithm options. - NativeModSig { - module: "jsonwebtoken", - has_receiver: false, - method: "decode", - class_filter: None, - runtime: "js_jwt_decode", - // js_jwt_decode(token_ptr) -> *mut StringHeader (JSON of payload). - // NR_OBJ_FROM_JSON_STR pipes the returned JSON through - // js_json_parse_or_null so user code sees an object (mirrors - // `verify`'s post-#927 contract). Issue #927. - args: &[NA_STR], - ret: NR_OBJ_FROM_JSON_STR, - }, // ========== nodemailer ========== NativeModSig { module: "nodemailer", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 471f8c5726..ff029c6f90 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -63,27 +63,6 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_perry_native_f32", DOUBLE, &[DOUBLE]); module.declare_function("js_perry_native_f64", DOUBLE, &[DOUBLE]); - // ========== jsonwebtoken / JWT ========== - module.declare_function("js_jwt_decode", I64, &[I64]); - module.declare_function("js_jwt_sign", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_sign_es256", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_sign_rs256", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_verify", I64, &[I64, I64]); - module.declare_function("js_jwt_verify_es256", I64, &[I64, I64]); - module.declare_function("js_jwt_verify_rs256", I64, &[I64, I64]); - // #1074: runtime-algorithm dispatchers. The codegen `lower_jsonwebtoken_*` - // fast paths still hard-route literal `algorithm: "ES256"` to the typed - // helpers above; non-literal shapes (const-bound ident, spread, ternary) - // are routed here with the alg name lowered as a string at runtime. - module.declare_function("js_jwt_sign_dyn", I64, &[I64, I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_verify_dyn", I64, &[I64, I64, I64]); - // #1074 case C: options is a whole non-extractable expression - // (`const opts = { algorithm: "ES256" }; jwt.sign(p, k, opts)`). We - // pass `opts` as a NaN-boxed JSValue and the runtime helper extracts - // `algorithm` / `expiresIn` / `keyid` via `js_object_get_field_by_name`. - module.declare_function("js_jwt_sign_dyn_opts", I64, &[I64, I64, DOUBLE]); - module.declare_function("js_jwt_verify_dyn_opts", I64, &[I64, I64, DOUBLE]); - // ========== axios / node-fetch ========== module.declare_function("js_axios_create", DOUBLE, &[I64]); module.declare_function("js_axios_delete", I64, &[I64]); diff --git a/crates/perry-ext-jsonwebtoken/Cargo.toml b/crates/perry-ext-jsonwebtoken/Cargo.toml deleted file mode 100644 index 3450cc9270..0000000000 --- a/crates/perry-ext-jsonwebtoken/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "perry-ext-jsonwebtoken" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `jsonwebtoken` package — uses only `perry-ffi`. Sync, string-only port (Phase 5 step 7)." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -jsonwebtoken.workspace = true -serde = { workspace = true } -serde_json = { workspace = true } -base64.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-jsonwebtoken/src/lib.rs b/crates/perry-ext-jsonwebtoken/src/lib.rs deleted file mode 100644 index 42faadda03..0000000000 --- a/crates/perry-ext-jsonwebtoken/src/lib.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! Native bindings for the npm `jsonwebtoken` package. -//! -//! Sync wrapper — no async/await, no Promise. Uses only the -//! perry-ffi v0.5 string surface. Functionally identical to -//! `crates/perry-stdlib/src/jsonwebtoken.rs`. Seventh wrapper port -//! under #466 Phase 5. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use perry_ffi::{alloc_string, nanbox_string_bits, read_string, JsString, StringHeader}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Generic claims structure that can hold any JSON. Mirrors the -/// shape `perry-stdlib::jsonwebtoken` uses so encoded / decoded -/// tokens are byte-compatible. -#[derive(Debug, Serialize, Deserialize)] -struct Claims { - #[serde(flatten)] - data: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -/// Shared signing logic — parse payload, apply expiry, encode with -/// the given algorithm/key. `kid_ptr` is optional (null = no `kid` -/// header field). Returns a NaN-boxed string i64, or 0 on error. -unsafe fn sign_common( - payload_ptr: *const StringHeader, - expires_in_secs: f64, - algorithm: Algorithm, - key: &EncodingKey, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(payload_json) = read_str(payload_ptr) else { - return 0; - }; - - let mut claims: Claims = serde_json::from_str(&payload_json).unwrap_or_else(|_| Claims { - data: HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: None, - iss: None, - aud: None, - }); - - if expires_in_secs > 0.0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - claims.exp = Some(now + expires_in_secs as u64); - if claims.iat.is_none() { - claims.iat = Some(now); - } - } - - let mut header = Header::new(algorithm); - if !kid_ptr.is_null() { - if let Some(kid) = read_str(kid_ptr) { - if !kid.is_empty() { - header.kid = Some(kid); - } - } - } - - match encode(&header, &claims, key) { - Ok(token) => { - let s = alloc_string(&token); - nanbox_string_bits(s.as_raw()) as i64 - } - Err(_) => 0, - } -} - -/// `jwt.sign(payload, secret)` — HS256. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(secret) = read_str(secret_ptr) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::HS256, - &EncodingKey::from_secret(secret.as_bytes()), - kid_ptr, - ) -} - -/// `jwt.sign(payload, ecPrivateKeyPem, { algorithm: 'ES256' })` — -/// PKCS#8 PEM-encoded EC P-256 private key. Used by APNs. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_es256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(pem) = read_str(pem_ptr) else { - return 0; - }; - let Ok(key) = EncodingKey::from_ec_pem(pem.as_bytes()) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::ES256, - &key, - kid_ptr, - ) -} - -/// `jwt.sign(payload, rsaPrivateKeyPem, { algorithm: 'RS256' })` — -/// PKCS#8 PEM-encoded RSA private key. Used by FCM. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_rs256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(pem) = read_str(pem_ptr) else { - return 0; - }; - let Ok(key) = EncodingKey::from_rsa_pem(pem.as_bytes()) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::RS256, - &key, - kid_ptr, - ) -} - -/// `jwt.verify(token, secret)` — HS256. Returns the claims as a -/// JSON string. -/// -/// # Safety -/// -/// `token_ptr` and `secret_ptr` must be null or Perry-runtime -/// `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - let Some(token) = read_str(token_ptr) else { - return std::ptr::null_mut(); - }; - let Some(secret) = read_str(secret_ptr) else { - return std::ptr::null_mut(); - }; - - let key = DecodingKey::from_secret(secret.as_bytes()); - let mut validation = Validation::new(Algorithm::HS256); - // Match Node's `jsonwebtoken`: validate the `exp` claim whenever it is - // present (so expired tokens are rejected), but do not *require* exp — a - // token that legitimately omits expiry still verifies. `required_spec_claims` - // stays empty for the latter; `validate_exp = true` enforces the former. - // - // This previously read `validate_exp = false`, which accepted expired - // tokens indefinitely (GHSA-5324-c68v-8w62 / CVE-2026-53777) — the same - // bug already fixed in crates/perry-stdlib/src/jsonwebtoken.rs. - validation.required_spec_claims = std::collections::HashSet::new(); - validation.validate_exp = true; - - match decode::(&token, &key, &validation) { - Ok(token_data) => { - let json = serde_json::to_string(&token_data.claims).unwrap_or_else(|_| "{}".into()); - alloc_string(&json).as_raw() - } - Err(_) => std::ptr::null_mut(), - } -} - -/// `jwt.decode(token)` — split-and-base64-decode the payload, no -/// signature verification. -/// -/// # Safety -/// -/// `token_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_decode(token_ptr: *const StringHeader) -> *mut StringHeader { - let Some(token) = read_str(token_ptr) else { - return std::ptr::null_mut(); - }; - - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return std::ptr::null_mut(); - } - - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let Ok(payload_bytes) = engine.decode(parts[1]) else { - return std::ptr::null_mut(); - }; - let Ok(payload_json) = String::from_utf8(payload_bytes) else { - return std::ptr::null_mut(); - }; - if serde_json::from_str::(&payload_json).is_err() { - return std::ptr::null_mut(); - } - alloc_string(&payload_json).as_raw() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn s(handle: i64) -> String { - const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - let raw = (handle as u64 & POINTER_MASK) as *mut StringHeader; - read_string(unsafe { JsString::from_raw(raw) }) - .map(String::from) - .unwrap_or_default() - } - - fn ps(p: *mut StringHeader) -> Option { - if p.is_null() { - return None; - } - read_string(unsafe { JsString::from_raw(p) }).map(String::from) - } - - #[test] - fn sign_then_verify_round_trip() { - let payload = alloc_string(r#"{"sub":"1234","name":"Alice"}"#); - let secret = alloc_string("supersecret"); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - secret.as_raw() as *const _, - 3600.0, - std::ptr::null(), - ) - }; - assert_ne!(token_bits, 0, "sign returned zero"); - let token = s(token_bits); - assert!( - token.starts_with("eyJ"), - "JWT should start with eyJ: {}", - token - ); - - let token_handle = alloc_string(&token); - let claims_ptr = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string("supersecret").as_raw() as *const _, - ) - }; - let claims = ps(claims_ptr).expect("verify returned non-null"); - assert!(claims.contains("\"name\":\"Alice\""), "got: {}", claims); - assert!(claims.contains("\"sub\":\"1234\""), "got: {}", claims); - } - - #[test] - fn verify_with_wrong_secret_returns_null() { - let payload = alloc_string(r#"{"sub":"x"}"#); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - alloc_string("right").as_raw() as *const _, - 0.0, - std::ptr::null(), - ) - }; - let token = s(token_bits); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string("wrong").as_raw() as *const _, - ) - }; - assert!(result.is_null(), "wrong secret should fail verify"); - } - - #[test] - fn decode_skips_signature_check() { - // Decode unverified — even with a wrong secret, decode - // returns the payload. Used by clients that just need to - // peek at the claims (e.g. `exp`) before deciding whether - // to refresh. - let payload = alloc_string(r#"{"role":"admin"}"#); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - alloc_string("k").as_raw() as *const _, - 0.0, - std::ptr::null(), - ) - }; - let token = s(token_bits); - let result_ptr = unsafe { js_jwt_decode(alloc_string(&token).as_raw() as *const _) }; - let claims = ps(result_ptr).expect("decode non-null"); - assert!(claims.contains("\"role\":\"admin\""), "got: {}", claims); - } - - #[test] - fn verify_rejects_expired_token() { - // Regression for #5066 / GHSA-5324-c68v-8w62: expired token must be rejected. - let secret = "supersecret"; - let expired_claims = Claims { - data: std::collections::HashMap::new(), - exp: Some(1), - iat: None, - nbf: None, - sub: Some("1234".into()), - iss: None, - aud: None, - }; - let token = encode( - &Header::new(Algorithm::HS256), - &expired_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .expect("encode expired token"); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string(secret).as_raw() as *const _, - ) - }; - assert!( - result.is_null(), - "expired token must be rejected, got claims: {:?}", - ps(result) - ); - } - - #[test] - fn verify_accepts_token_without_exp() { - // Node parity: token omitting exp must still verify (required_spec_claims empty). - let secret = "supersecret"; - let claims = Claims { - data: std::collections::HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: Some("1234".into()), - iss: None, - aud: None, - }; - let token = encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .expect("encode no-exp token"); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string(secret).as_raw() as *const _, - ) - }; - assert!(!result.is_null(), "token without exp must still verify"); - } -} diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 2881d64681..3dd3510220 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -229,10 +229,9 @@ bundled-mongodb = ["dep:mongodb", "dep:bson", "dep:futures-util", "async-runtime # bindings so the well-known flip (#466 Phase 4 step 2) can route them # to perry-ext-bcrypt / perry-ext-argon2 without taking the rest of # the crypto surface offline. -crypto = ["dep:sha2", "dep:sha1", "dep:sha3", "dep:shake", "dep:sha3_010", "dep:sha3-utils", "dep:rsa-sha1", "dep:md-5", "dep:hex", "dep:hmac", "dep:aes", "dep:aes_09", "dep:cbc", "dep:ecb", "dep:ctr", "dep:scrypt", "dep:pbkdf2", "dep:base64", "dep:x25519-dalek", "dep:x448", "dep:ed25519-dalek", "dep:ed448-goldilocks", "dep:aes-gcm", "dep:chacha20poly1305", "dep:ghash", "dep:aes-kw", "dep:hkdf", "dep:p256", "dep:p384", "dep:p521", "dep:x509-cert", "dep:ml-kem", "async-runtime", "ids", "bundled-bcrypt", "bundled-argon2", "bundled-jsonwebtoken", "bundled-ethers"] +crypto = ["dep:sha2", "dep:sha1", "dep:sha3", "dep:shake", "dep:sha3_010", "dep:sha3-utils", "dep:rsa-sha1", "dep:md-5", "dep:hex", "dep:hmac", "dep:aes", "dep:aes_09", "dep:cbc", "dep:ecb", "dep:ctr", "dep:scrypt", "dep:pbkdf2", "dep:base64", "dep:x25519-dalek", "dep:x448", "dep:ed25519-dalek", "dep:ed448-goldilocks", "dep:aes-gcm", "dep:chacha20poly1305", "dep:ghash", "dep:aes-kw", "dep:hkdf", "dep:p256", "dep:p384", "dep:p521", "dep:rsa", "dep:spki", "dep:x509-cert", "dep:ml-kem", "async-runtime", "ids", "bundled-bcrypt", "bundled-argon2", "bundled-ethers"] bundled-bcrypt = ["dep:bcrypt", "async-runtime"] bundled-argon2 = ["dep:argon2", "async-runtime"] -bundled-jsonwebtoken = ["dep:jsonwebtoken", "dep:p256", "dep:rsa", "dep:spki"] # ethers blockchain utilities — pure Rust, no extra deps. Default-on # through `crypto` umbrella; the well-known flip strips this and # routes to perry-ext-ethers when `import 'ethers'` is detected. @@ -388,7 +387,6 @@ md-5 = { version = "0.11", optional = true } hex = { workspace = true, optional = true } hmac = { version = "0.13", optional = true } bcrypt = { version = "0.19", optional = true } -jsonwebtoken = { workspace = true, optional = true } p256 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } p384 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } p521 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } diff --git a/crates/perry-stdlib/src/jsonwebtoken.rs b/crates/perry-stdlib/src/jsonwebtoken.rs deleted file mode 100644 index 0bfb6dd0bd..0000000000 --- a/crates/perry-stdlib/src/jsonwebtoken.rs +++ /dev/null @@ -1,904 +0,0 @@ -//! JSON Web Token module (jsonwebtoken compatible) -//! -//! Native implementation of the 'jsonwebtoken' npm package. -//! Provides JWT sign, verify, and decode functionality. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use perry_runtime::{ - js_object_get_field_by_name, js_string_from_bytes, ObjectHeader, StringHeader, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use crate::common::string_from_header; - -/// Generic claims structure that can hold any JSON -#[derive(Debug, Serialize, Deserialize)] -struct Claims { - #[serde(flatten)] - data: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, -} - -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -/// Shared signing logic — parse payload, apply expiry, encode with given algorithm/key. -/// `kid_ptr` is optional (null = no `kid` header field). Returns a NaN-boxed string i64, -/// or 0 on error. -unsafe fn sign_common( - payload_ptr: *const StringHeader, - expires_in_secs: f64, - algorithm: Algorithm, - key: &EncodingKey, - kid_ptr: *const StringHeader, -) -> i64 { - let payload_json = match string_from_header(payload_ptr) { - Some(p) => p, - None => return 0, - }; - - let mut claims: Claims = match serde_json::from_str(&payload_json) { - Ok(c) => c, - Err(_) => Claims { - data: HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: None, - iss: None, - aud: None, - }, - }; - - if expires_in_secs > 0.0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - claims.exp = Some(now + expires_in_secs as u64); - if claims.iat.is_none() { - claims.iat = Some(now); - } - } - - let mut header = Header::new(algorithm); - if !kid_ptr.is_null() { - if let Some(kid) = string_from_header(kid_ptr) { - if !kid.is_empty() { - header.kid = Some(kid); - } - } - } - - match encode(&header, &claims, key) { - Ok(token) => { - let ptr = js_string_from_bytes(token.as_ptr(), token.len() as u32); - (STRING_TAG | (ptr as u64 & POINTER_MASK)) as i64 - } - Err(_) => 0, - } -} - -/// Sign a payload to create a JWT (HS256) -/// jwt.sign(payload, secret) -> string -/// jwt.sign(payload, secret, options) -> string -/// -/// `kid_ptr` may be null when no `keyid` is provided in options. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let secret = match string_from_header(secret_ptr) { - Some(s) => s, - None => return 0, - }; - let key = EncodingKey::from_secret(secret.as_bytes()); - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::HS256, - &key, - kid_ptr, - ) -} - -/// Sign a payload to create a JWT (ES256) -/// `pem_ptr` must contain a PKCS#8 PEM-encoded EC private key (P-256 curve). -/// jwt.sign(payload, ecPrivateKeyPem, { algorithm: 'ES256', keyid: '...' }) -> string -/// -/// Used by APNs (Apple Push Notification service) provider tokens — APNs requires -/// `kid` in the JWT header to identify which `.p8` key was used to sign. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_es256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => return 0, - }; - // jsonwebtoken's `EncodingKey::from_ec_pem` only accepts PKCS#8 - // (`-----BEGIN PRIVATE KEY-----`). openssl's default - // `ecparam -genkey -name prime256v1` emits SEC1 - // (`-----BEGIN EC PRIVATE KEY-----`), which is the form most users - // start with. Convert SEC1 → PKCS#8 transparently so both PEM - // forms work. Same ergonomic story as the verify side's - // `ec_pem_to_public_pem` helper. - let pkcs8_pem = if pem.contains("EC PRIVATE KEY") { - use p256::pkcs8::EncodePrivateKey; - match p256::SecretKey::from_sec1_pem(&pem) - .ok() - .and_then(|k| k.to_pkcs8_pem(Default::default()).ok()) - { - Some(p) => p.to_string(), - None => { - eprintln!("[jwt-sign-es256] could not convert SEC1 EC PEM to PKCS#8"); - return 0; - } - } - } else { - pem - }; - let key = match EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - eprintln!("[jwt-sign-es256] invalid EC PEM key: {}", e); - return 0; - } - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::ES256, - &key, - kid_ptr, - ) -} - -/// Sign a payload to create a JWT (RS256) -/// `pem_ptr` must contain a PKCS#8 PEM-encoded RSA private key. -/// jwt.sign(payload, rsaPrivateKeyPem, { algorithm: 'RS256', keyid: '...' }) -> string -/// -/// Used by FCM (Firebase Cloud Messaging) OAuth assertions. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_rs256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => return 0, - }; - let key = match EncodingKey::from_rsa_pem(pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - eprintln!("[jwt-sign-rs256] invalid RSA PEM key: {}", e); - return 0; - } - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::RS256, - &key, - kid_ptr, - ) -} - -/// Dynamic-algorithm `jwt.sign` dispatcher (#1074). -/// -/// The codegen fast path in `lower_jsonwebtoken_sign` routes inline-literal -/// `{ algorithm: "ES256" }` to `js_jwt_sign_es256` / `…_rs256` at compile -/// time. When `algorithm` is anything else — a const-bound identifier -/// (`const ALG = "ES256"; jwt.sign(p, k, { algorithm: ALG })`), a property -/// spread, a ternary, etc. — the fast path falls through and previously -/// silently signed with HS256 keyed by the user's PEM (cryptographic -/// downgrade: the token is HMAC-signed with the PEM bytes, on-wire -/// `header.alg` reads `"HS256"`, so any verifier that accepts either -/// HMAC OR EC/RSA quietly accepted the downgrade). -/// -/// This entry point reads the algorithm name from `alg_ptr` at runtime and -/// dispatches to the same `sign_common` paths the typed helpers use. The -/// inline-literal fast path remains for the common case; everything else -/// goes through here. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_dyn( - alg_ptr: *const StringHeader, - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let alg_name = string_from_header(alg_ptr).unwrap_or_else(|| "HS256".to_string()); - match alg_name.as_str() { - "ES256" => js_jwt_sign_es256(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - "RS256" => js_jwt_sign_rs256(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - "HS256" | "" => js_jwt_sign(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - other => { - // Unknown alg — treat as HS256 fallback (matches the legacy - // non-literal behavior) but log under PERRY_DEBUG so callers - // can diagnose. The header.alg will still say HS256, so the - // user's verifier rejects it properly — this is a safer - // failure mode than the pre-#1074 silent downgrade. - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!( - "[jwt-sign-dyn] unknown algorithm `{}`; falling back to HS256", - other - ); - } - js_jwt_sign(payload_ptr, secret_ptr, expires_in_secs, kid_ptr) - } - } -} - -/// Coerce a NaN-boxed JSValue (`f64`) into a raw `*const ObjectHeader` -/// pointer. Mirrors the upper-bits sniff used by the native HTTP bindings. -/// Returns null when the value isn't pointer-shaped. -unsafe fn jsvalue_to_object_ptr(obj_f64: f64) -> *const ObjectHeader { - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const ObjectHeader - } else { - std::ptr::null() - } -} - -/// Read a named property off a NaN-boxed object value, returning its -/// string-typed result as a `*const StringHeader` (or null when missing/ -/// not-a-string). The field name is materialized as a transient -/// `*const StringHeader` because that's `js_object_get_field_by_name`'s -/// signature. -unsafe fn opts_get_string_field(obj_f64: f64, field: &str) -> *const StringHeader { - let obj_ptr = jsvalue_to_object_ptr(obj_f64); - if obj_ptr.is_null() { - return std::ptr::null(); - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return std::ptr::null(); - } - if val.is_string() { - return val.as_string_ptr(); - } - std::ptr::null() -} - -/// Read a named property as f64. Returns 0.0 when missing/non-numeric -/// (matches `lower_jsonwebtoken_sign`'s `expires_in = double_literal(0.0)` -/// default). -unsafe fn opts_get_number_field(obj_f64: f64, field: &str) -> f64 { - let obj_ptr = jsvalue_to_object_ptr(obj_f64); - if obj_ptr.is_null() { - return 0.0; - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return 0.0; - } - if val.is_number() { - return val.as_number(); - } - 0.0 -} - -/// `jwt.sign(payload, secret, options)` where `options` is a non-extractable -/// expression (e.g. `const opts = { algorithm: "ES256", ... }; jwt.sign(p, k, opts)`) -/// — #1074 case C. The codegen lowers `opts` as a NaN-boxed JSValue and we -/// extract `algorithm` / `expiresIn` / `keyid` at runtime, then defer to -/// `js_jwt_sign_dyn`. Reads each option via `js_object_get_field_by_name` -/// (which works for ordinary `Expr::Object` literals → `__AnonShape_*` class -/// instances). -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_dyn_opts( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - options_value: f64, -) -> i64 { - let alg_ptr = opts_get_string_field(options_value, "algorithm"); - // `keyid` is the spec-correct field name; `kid` is accepted as an alias - // (matches the inline-literal codegen path which special-cases both). - let kid_ptr = { - let p = opts_get_string_field(options_value, "keyid"); - if p.is_null() { - opts_get_string_field(options_value, "kid") - } else { - p - } - }; - let expires_in = opts_get_number_field(options_value, "expiresIn"); - js_jwt_sign_dyn(alg_ptr, payload_ptr, secret_ptr, expires_in, kid_ptr) -} - -/// Shared verify path — runs the decode + returns claims as JSON, or -/// a null pointer on any failure. `debug` mirrors the gating in -/// `js_jwt_verify` (perry#924) so all three verify entry points emit -/// the same `[jwt-verify]` log lines under `PERRY_DEBUG=1`. -unsafe fn verify_decode( - token: &str, - key: &DecodingKey, - algorithm: Algorithm, - debug: bool, -) -> *mut StringHeader { - let mut validation = Validation::new(algorithm); - // Match Node's `jsonwebtoken`: validate the `exp` claim whenever it is - // present (so expired tokens are rejected), but do not *require* exp — a - // token that legitimately omits expiry still verifies. `required_spec_claims` - // stays empty for the latter; `validate_exp = true` enforces the former. - // - // This previously read `validate_exp = false`, which disabled expiry - // enforcement for every JWT verification path in the stdlib — expired - // tokens were accepted indefinitely (GHSA-5324-c68v-8w62 / CVE-2026-53777). - validation.required_spec_claims = std::collections::HashSet::new(); - validation.validate_exp = true; - - match decode::(token, key, &validation) { - Ok(token_data) => { - let json = - serde_json::to_string(&token_data.claims).unwrap_or_else(|_| "{}".to_string()); - if debug { - eprintln!( - "[jwt-verify] success, claims={}", - &json[..json.len().min(80)] - ); - } - js_string_from_bytes(json.as_ptr(), json.len() as u32) - } - Err(e) => { - if debug { - eprintln!("[jwt-verify] error: {}", e); - } - std::ptr::null_mut() - } - } -} - -/// Verify and decode an HS256 JWT -/// jwt.verify(token, secret) -> object (payload) -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - // perry#924: all `[jwt-verify]` eprintln!s are gated behind - // `PERRY_DEBUG=1`. Authenticated production services call - // `jwt.verify` per request, so the previous unconditional logging - // (token length + secret length + claims/error) flooded stderr and - // also leaked the secret length, narrowing the cracking surface - // when paired with a known JWT structure. The application layer - // already logs 401s at a useful granularity. - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let secret = match string_from_header(secret_ptr) { - Some(s) => s, - None => { - if debug { - eprintln!("[jwt-verify] secret_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let key = DecodingKey::from_secret(secret.as_bytes()); - verify_decode(&token, &key, Algorithm::HS256, debug) -} - -/// Coerce an EC PEM (public *or* private, SEC1 or PKCS#8) into a -/// PKCS#8 PUBLIC KEY PEM that `DecodingKey::from_ec_pem` accepts. -/// Mirrors Node's `jsonwebtoken` ergonomics: the user can pass the -/// same PEM to `sign` and `verify` without having to extract the -/// public key separately. perry#927 follow-up — without this, ES256 -/// `verify` rejected the very PEM the matching `sign` accepted, -/// breaking the shop-admin auth path even after the JSON-parse -/// return-shape fix. -fn ec_pem_to_public_pem(pem: &str) -> Option { - use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; - - if pem.contains("PUBLIC KEY") { - return Some(pem.to_string()); - } - - // Try PKCS#8 private (`-----BEGIN PRIVATE KEY-----`) first, - // then SEC1 (`-----BEGIN EC PRIVATE KEY-----`). - let secret = p256::SecretKey::from_pkcs8_pem(pem) - .or_else(|_| p256::SecretKey::from_sec1_pem(pem)) - .ok()?; - secret - .public_key() - .to_public_key_pem(Default::default()) - .ok() -} - -/// Verify and decode an ES256 JWT. -/// `pem_ptr` may contain either a PUBLIC key PEM (SPKI) or the -/// matching PRIVATE key PEM (PKCS#8 or SEC1) — the latter is -/// auto-converted via `ec_pem_to_public_pem` so callers can reuse -/// their signing key. -/// jwt.verify(token, pem, { algorithms: ['ES256'] }) -> object -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_es256( - token_ptr: *const StringHeader, - pem_ptr: *const StringHeader, -) -> *mut StringHeader { - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify-es256] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-es256] pem_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let public_pem = match ec_pem_to_public_pem(&pem) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-es256] could not derive EC public key from PEM"); - } - return std::ptr::null_mut(); - } - }; - - let key = match DecodingKey::from_ec_pem(public_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - if debug { - eprintln!("[jwt-verify-es256] invalid EC PEM key: {}", e); - } - return std::ptr::null_mut(); - } - }; - - verify_decode(&token, &key, Algorithm::ES256, debug) -} - -/// Coerce an RSA PEM (public *or* private, PKCS#1 or PKCS#8) into a -/// PEM that `DecodingKey::from_rsa_pem` accepts. Matches Node's -/// `jsonwebtoken` behavior of accepting either side of the keypair -/// on verify. -fn rsa_pem_to_public_pem(pem: &str) -> Option { - use rsa::pkcs1::EncodeRsaPublicKey; - use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey}; - - if pem.contains("PUBLIC KEY") { - // Either PKCS#1 `RSA PUBLIC KEY` or PKCS#8 `PUBLIC KEY` — - // both consumed directly by `DecodingKey::from_rsa_pem`. - return Some(pem.to_string()); - } - - // Try PKCS#8 (`-----BEGIN PRIVATE KEY-----`) then PKCS#1 - // (`-----BEGIN RSA PRIVATE KEY-----`). - let priv_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem) - .or_else(|_| { - use rsa::pkcs1::DecodeRsaPrivateKey; - rsa::RsaPrivateKey::from_pkcs1_pem(pem) - }) - .ok()?; - let pub_key = priv_key.to_public_key(); - pub_key - .to_public_key_pem(Default::default()) - .ok() - .or_else(|| pub_key.to_pkcs1_pem(Default::default()).ok()) -} - -/// Verify and decode an RS256 JWT. -/// `pem_ptr` may contain either a PUBLIC key PEM (PKCS#1 or PKCS#8) -/// or the matching PRIVATE key PEM (auto-converted via -/// `rsa_pem_to_public_pem`). -/// jwt.verify(token, pem, { algorithms: ['RS256'] }) -> object -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_rs256( - token_ptr: *const StringHeader, - pem_ptr: *const StringHeader, -) -> *mut StringHeader { - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify-rs256] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-rs256] pem_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let public_pem = match rsa_pem_to_public_pem(&pem) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-rs256] could not derive RSA public key from PEM"); - } - return std::ptr::null_mut(); - } - }; - - let key = match DecodingKey::from_rsa_pem(public_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - if debug { - eprintln!("[jwt-verify-rs256] invalid RSA PEM key: {}", e); - } - return std::ptr::null_mut(); - } - }; - - verify_decode(&token, &key, Algorithm::RS256, debug) -} - -/// Dynamic-algorithm `jwt.verify` dispatcher (#1074). -/// -/// Mirrors `js_jwt_sign_dyn`. The codegen fast path resolves -/// `algorithms: ["ES256"]` to `js_jwt_verify_es256` at compile time; -/// const-ref or computed shapes fell through to `js_jwt_verify` (HS256) -/// and silently rejected ES/RS tokens. This entry point reads the -/// algorithm name from `alg_ptr` at runtime and dispatches. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_dyn( - alg_ptr: *const StringHeader, - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - let alg_name = string_from_header(alg_ptr).unwrap_or_else(|| "HS256".to_string()); - match alg_name.as_str() { - "ES256" => js_jwt_verify_es256(token_ptr, secret_ptr), - "RS256" => js_jwt_verify_rs256(token_ptr, secret_ptr), - "HS256" | "" => js_jwt_verify(token_ptr, secret_ptr), - other => { - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!( - "[jwt-verify-dyn] unknown algorithm `{}`; falling back to HS256", - other - ); - } - js_jwt_verify(token_ptr, secret_ptr) - } - } -} - -/// `jwt.verify(token, secret, options)` where `options` is a non-extractable -/// expression (case C, #1074). Extract `algorithm` (singular) or the first -/// entry of `algorithms` (plural array) at runtime and defer to -/// `js_jwt_verify_dyn`. The plural-array first-entry rule mirrors the -/// compile-time fast path in `lower_jsonwebtoken_verify` — the underlying -/// `jsonwebtoken` crate verifies against one algorithm at a time, so we -/// pick the first. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_dyn_opts( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - options_value: f64, -) -> *mut StringHeader { - // Try singular `algorithm: "..."` first. - let mut alg_ptr = opts_get_string_field(options_value, "algorithm"); - // Then plural `algorithms: ["..."]`. Read the field, then index [0] - // through `js_array_get_f64` to mirror the compile-time fast path. - if alg_ptr.is_null() { - let obj_ptr = jsvalue_to_object_ptr(options_value); - if !obj_ptr.is_null() { - let key = js_string_from_bytes("algorithms".as_ptr(), "algorithms".len() as u32); - let arr_val = js_object_get_field_by_name(obj_ptr, key); - // Array is pointer-tagged in NaN-boxing; extract pointer if - // present. We reuse the existing array_get_f64 entry point - // because it's the most-tested path for array.[i] reads. - if !arr_val.is_undefined() && !arr_val.is_null() { - // The array NaN-box is POINTER_TAG-shaped just like an - // object — strip the upper bits to recover the raw - // ArrayHeader*. `js_array_get_f64` does its own tag - // strip too, but we already have an authoritative - // pointer here so just pass it through. - let arr_bits = arr_val.bits(); - let arr_ptr = - (arr_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ArrayHeader; - if !arr_ptr.is_null() { - let first_jsval = perry_runtime::js_array_get(arr_ptr, 0); - if first_jsval.is_string() { - alg_ptr = first_jsval.as_string_ptr(); - } - } - } - } - } - js_jwt_verify_dyn(alg_ptr, token_ptr, secret_ptr) -} - -/// Decode a JWT without verification (just parse the payload) -/// jwt.decode(token) -> object (payload) -#[no_mangle] -pub unsafe extern "C" fn js_jwt_decode(token_ptr: *const StringHeader) -> *mut StringHeader { - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => return std::ptr::null_mut(), - }; - - // Split the token into parts - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return std::ptr::null_mut(); - } - - // Decode the payload (second part) - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - - match engine.decode(parts[1]) { - Ok(payload_bytes) => { - match String::from_utf8(payload_bytes) { - Ok(payload_json) => { - // Validate it's valid JSON and return it - if serde_json::from_str::(&payload_json).is_ok() { - js_string_from_bytes(payload_json.as_ptr(), payload_json.len() as u32) - } else { - std::ptr::null_mut() - } - } - Err(_) => std::ptr::null_mut(), - } - } - Err(_) => std::ptr::null_mut(), - } -} - -#[cfg(all(test, unix))] -mod tests { - //! perry#924 regression tests — `jwt.verify` MUST be silent on the - //! happy path. We exercise the real `js_jwt_verify` FFI in a - //! subprocess (spawning the current test binary with a sentinel - //! env var) because cargo-test's harness installs a Rust-level - //! stderr capture that intercepts `eprintln!` before fd 2, making - //! in-process `dup2`-style capture vacuously pass. Subprocess - //! stderr is unaffected and gives us a real byte stream to count - //! lines against. - //! - //! Before the fix: - //! • valid token: 3 stderr lines (`token_len=…` + `success, claims=…`) - //! • invalid token: 2 stderr lines (`token_len=…` + `error: …`) - //! After the fix (no `PERRY_DEBUG`): - //! • valid token: 0 stderr lines - //! • invalid token: 0 stderr lines - //! With `PERRY_DEBUG=1`: original verbose output is restored. - use super::*; - use perry_runtime::js_string_from_bytes; - use std::process::{Command, Stdio}; - - /// Sentinel env var: when set, the targeted helper test runs the - /// FFI in this process (which is a subprocess of the real test) - /// and exits so the subprocess produces a clean, uncaptured - /// stderr stream for the parent test to inspect. Spawning is done - /// via `--exact …::__perry_924_helper --nocapture --quiet` so - /// only the helper test runs and harness stderr capture is off. - const HELPER_ENV: &str = "PERRY_924_HELPER"; - - /// Hidden helper test — invoked by the real tests via subprocess. - /// When `PERRY_924_HELPER` is set, exec the requested FFI scenario - /// and exit. Otherwise no-op (so a normal `cargo test` run just - /// records this as a trivially-passing test). - #[test] - fn __perry_924_helper() { - let Ok(mode) = std::env::var(HELPER_ENV) else { - return; - }; - unsafe { run_helper(&mode) }; - std::process::exit(0); - } - - unsafe fn run_helper(mode: &str) { - unsafe fn mk(s: &str) -> *mut StringHeader { - js_string_from_bytes(s.as_ptr(), s.len() as u32) - } - - match mode { - "valid" => { - // Mint a real HS256 token, then verify it. Success - // path → must not eprintln (unless PERRY_DEBUG set - // by parent). - let payload = mk(r#"{"sub":"1234","name":"Alice"}"#); - let secret = mk("supersecret"); - let token_bits = js_jwt_sign( - payload as *const _, - secret as *const _, - 0.0, - std::ptr::null(), - ); - assert_ne!(token_bits, 0); - let raw = (token_bits as u64 & POINTER_MASK) as *mut StringHeader; - let len = (*raw).byte_len as usize; - let data_ptr = (raw as *const u8).add(std::mem::size_of::()); - let token_bytes = std::slice::from_raw_parts(data_ptr, len); - let token_str = std::str::from_utf8(token_bytes).unwrap().to_string(); - - let token = mk(&token_str); - let secret2 = mk("supersecret"); - let result = js_jwt_verify(token as *const _, secret2 as *const _); - assert!(!result.is_null(), "verify must succeed on a valid token"); - } - "invalid" => { - // Garbage input → verify must fail silently (no log - // unless PERRY_DEBUG set). - let token = mk("not-a-jwt"); - let secret = mk("supersecret"); - let result = js_jwt_verify(token as *const _, secret as *const _); - assert!(result.is_null(), "verify must fail on garbage"); - } - other => panic!("unknown helper mode: {}", other), - } - } - - fn spawn_helper(mode: &str, debug: bool) -> std::process::Output { - let exe = std::env::current_exe().expect("current_exe"); - let mut cmd = Command::new(exe); - cmd.arg("--exact") - .arg("jsonwebtoken::tests::__perry_924_helper") - .arg("--nocapture") - .arg("--quiet") - .env(HELPER_ENV, mode) - .env_remove("PERRY_DEBUG") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if debug { - cmd.env("PERRY_DEBUG", "1"); - } - cmd.output().expect("spawn helper") - } - - #[test] - fn verify_valid_token_is_silent() { - let out = spawn_helper("valid", false); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.is_empty(), - "jwt.verify on a valid token must not log to stderr (perry#924); got: {:?}", - stderr - ); - } - - #[test] - fn verify_invalid_token_is_silent() { - let out = spawn_helper("invalid", false); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - // Application code (e.g. authMiddleware) already logs the - // 401 — stdlib must not duplicate. One line maximum if we - // ever decide a single error-class summary is worth it. - let lines = stderr.lines().count(); - assert!( - lines == 0, - "jwt.verify on invalid input must be silent (perry#924), got {} lines: {:?}", - lines, - stderr - ); - assert!( - !stderr.contains("[jwt-verify]"), - "no `[jwt-verify]` line may appear without PERRY_DEBUG; got: {:?}", - stderr - ); - } - - #[test] - fn verify_logs_under_perry_debug() { - let out = spawn_helper("valid", true); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("[jwt-verify] success"), - "PERRY_DEBUG=1 must restore verbose logging; got: {:?}", - stderr - ); - } - - // --- GHSA-5324-c68v-8w62 / CVE-2026-53777: exp must be enforced --- - - fn now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - } - - fn hs256_token(claims: serde_json::Value, secret: &[u8]) -> String { - encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(secret), - ) - .unwrap() - } - - #[test] - fn expired_token_is_rejected() { - let secret = b"supersecret"; - let token = hs256_token( - serde_json::json!({ "sub": "user123", "exp": now_secs() - 3600 }), - secret, - ); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(r.is_null(), "expired token must be rejected by jwt.verify"); - } - } - - #[test] - fn unexpired_token_is_accepted() { - let secret = b"supersecret"; - let token = hs256_token( - serde_json::json!({ "sub": "user123", "exp": now_secs() + 3600 }), - secret, - ); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(!r.is_null(), "valid, unexpired token must be accepted"); - } - } - - #[test] - fn token_without_exp_is_still_accepted() { - // Node's jsonwebtoken does not *require* exp; a token that omits it - // verifies. We must not regress that while enforcing exp-if-present. - let secret = b"supersecret"; - let token = hs256_token(serde_json::json!({ "sub": "user123" }), secret); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(!r.is_null(), "token without exp claim must still verify"); - } - } -} diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 1c9346d525..1a2970af77 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -340,14 +340,9 @@ pub mod argon2; #[cfg(feature = "bundled-argon2")] pub use argon2::*; -// jsonwebtoken split out into `bundled-jsonwebtoken` (v0.5.538) // for the same reason as bcrypt/argon2 — well-known flip // independence. The `crypto` umbrella still pulls it in for // backwards compat. -#[cfg(feature = "bundled-jsonwebtoken")] -pub mod jsonwebtoken; -#[cfg(feature = "bundled-jsonwebtoken")] -pub use jsonwebtoken::*; #[cfg(feature = "crypto")] pub mod crypto_e2e; diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 6e061442a9..4351918c1f 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -917,26 +917,6 @@ pub extern "C" fn js_ioredis_setex() -> i64 { } // js_json_* — real implementations in json.rs #[no_mangle] -pub extern "C" fn js_jwt_decode() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign_es256() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign_rs256() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_verify() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_lodash_camel_case() -> i64 { 0 } diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index a7f4b0561b..e45b6d39b7 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -101,7 +101,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // bcrypt also typically use sha256/jwt/etc., which keeps the // umbrella worthwhile. "bcrypt" => &["bundled-bcrypt"], - "jsonwebtoken" => &["bundled-jsonwebtoken"], "crypto" => &["crypto"], // ethers ships utility functions (formatUnits, parseUnits, // getAddress, keccak256, …). The keccak256 implementation is diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 59e13d4598..70b7e3b59c 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -131,18 +131,6 @@ repo = "https://github.com/ranisalt/node-argon2" ref = "786de7152f95881b0683aea1d2ca60ed0d6d9e2f" ported-at = "0.45.1" date = "2026-07-30" -[bindings.jsonwebtoken] -crate = "perry-ext-jsonwebtoken" -lib = "perry_ext_jsonwebtoken" -tracking = "#466" - -[bindings.jsonwebtoken.upstream] -version = "9.0.3" -sha256 = "d9af2628a7a4dda25acf1e19c7ecc2468e1e9e8d4619fe2cae829e89d96f6b82" -repo = "https://github.com/auth0/node-jsonwebtoken" -ref = "ed59e76ea37a80f54b833668c02a5271984dcba3" -ported-at = "9.0.3" -date = "2026-07-30" [bindings.validator] crate = "perry-ext-validator" lib = "perry_ext_validator" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 87dc9693bb..81c488c26b 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2093 entries across 136 modules +// Coverage: 2090 entries across 135 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2096,15 +2096,6 @@ declare module "iovalkey" { export function createClient(...args: any[]): any; } -declare module "jsonwebtoken" { - /** stdlib */ - export function decode(token: string): any; - /** stdlib */ - export function sign(payload: any, secret: string, options?: any, kid?: string): string; - /** stdlib */ - export function verify(token: string, secret: string): any; -} - declare module "lodash" { /** stdlib */ export function camelCase(p0: string): string; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index afcbe5c54a..affdd14dcb 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3035 entries across 138 modules. +Total: 3032 entries across 137 modules. ## Modules @@ -64,7 +64,6 @@ Total: 3035 entries across 138 modules. - [`inspector/promises`](#inspectorpromises) - [`ioredis`](#ioredis) - [`iovalkey`](#iovalkey) -- [`jsonwebtoken`](#jsonwebtoken) - [`lodash`](#lodash) - [`lru-cache`](#lru-cache) - [`module`](#module) @@ -2012,14 +2011,6 @@ Total: 3035 entries across 138 modules. - `createClient` — module -## `jsonwebtoken` - -### Methods - -- `decode` — module -- `sign` — module -- `verify` — module - ## `lodash` ### Methods diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 1591f27649..9813d11331 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -102,7 +102,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-jsonwebtoken` | `jsonwebtoken` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-lru-cache` | `lru-cache` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-moment` | `moment` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-mongodb` | `mongodb` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index 52cc4699a8..bc1ba4ef70 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -13,7 +13,7 @@ inline-offset | perry-ext-pg | 2 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 inline-offset | perry-runtime | 350 -inline-offset | perry-stdlib | 40 +inline-offset | perry-stdlib | 39 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 reader-helper | perry-runtime | 13 diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index 40f260534c..c1786de33f 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -19,7 +19,6 @@ "crates/perry-ext-http/src/server/response.rs": 1, "crates/perry-ext-http/src/server/types.rs": 1, "crates/perry-ext-ioredis/src/lib.rs": 1, - "crates/perry-ext-jsonwebtoken/src/lib.rs": 1, "crates/perry-ext-mongodb/src/lib.rs": 2, "crates/perry-ext-mysql2/src/lib.rs": 9, "crates/perry-ext-net/src/classes.rs": 2, diff --git a/workspace-architecture.json b/workspace-architecture.json index 623d2d2711..8f9365ffc4 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 83, + "workspace_members": 82, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 33, + "externalize": 32, "keep": 45, "merge": 1, "remove": 1, @@ -245,11 +245,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-jsonwebtoken": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-lru-cache": { "category": "binding", "decision": "externalize", From f62ebcf7c758df22a3add6016c3339fd44f07e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:47:41 +0000 Subject: [PATCH 2/2] changelog: add fragment for #10687 (jsonwebtoken native binding removal) --- ...10687-jsonwebtoken-native-binding-removal.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/10687-jsonwebtoken-native-binding-removal.md diff --git a/changelog.d/10687-jsonwebtoken-native-binding-removal.md b/changelog.d/10687-jsonwebtoken-native-binding-removal.md new file mode 100644 index 0000000000..d0c6817091 --- /dev/null +++ b/changelog.d/10687-jsonwebtoken-native-binding-removal.md @@ -0,0 +1,17 @@ +Removed the native `jsonwebtoken` binding (#10683): `verify()` returned +`null` instead of throwing on every forgery case (tampered payload, wrong +secret, `alg:none`, garbage token, tampered signature, expired token), and +`sign(..., { expiresIn: "1h" })` silently dropped the expiry. `import jwt +from "jsonwebtoken"` (no `perry.compilePackages` entry) now compiles the +real npm package from source, matching Node exactly including all six +thrown error names/messages. + +Deleted both duplicate hand-written implementations (`crates/perry-ext-jsonwebtoken` +and `crates/perry-stdlib/src/jsonwebtoken.rs`, which independently exported +the same `js_jwt_*` symbols — #10678) plus the dedicated codegen lowering +path in `crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs` that +bypassed the well-known-binding registry entirely. Re-wired `dep:rsa`/ +`dep:spki` directly onto perry-stdlib's `crypto` feature, since WebCrypto's +`key_object.rs`/`keys.rs` need them unconditionally and were only reachable +through the now-deleted `bundled-jsonwebtoken` feature by historical +accident.