Skip to content

fix(jsruntime): jose JWT (HS256) end-to-end through V8 fallback#1004

Merged
proggeramlug merged 1 commit into
mainfrom
fix-jose-jwt-v8-marshalling
May 18, 2026
Merged

fix(jsruntime): jose JWT (HS256) end-to-end through V8 fallback#1004
proggeramlug merged 1 commit into
mainfrom
fix-jose-jwt-v8-marshalling

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

Six-layered fix so await new SignJWT(...).sign(createSecretKey(...)) through jose produces a valid token and jwtVerify succeeds end-to-end through Perry's V8 fallback.

The original task description framed this as "string return from V8 Promise await". The actual root cause turned out to be deeper: a perry-side Uint8Array was marshalling into V8 as a plain v8::Array, so jose's instanceof Uint8Array checks failed before any await happened. Six layers needed fixing:

  1. Native to V8 Uint8Array/TypedArray/BufferHeader marshallingbridge.rs:native_object_to_v8 now materializes real v8::Uint8Array (and matching Int8Array/Int16Array/...) by consulting lookup_typed_array_kind and is_registered_buffer before the generic-object arm. Libraries that branch on value instanceof Uint8Array (jose, jsonwebtoken, every JWS impl) now type-check correctly.
  2. node:crypto.createHmac was a no-op — V8 stub returned '' from .digest(). Now backed by a new op_perry_hmac(alg, key, data) deno op using hmac + sha2 (same versions as perry-stdlib's crypto feature). Supports SHA256/384/512.
  3. Buffer.toString('base64url') / Buffer.from(_, 'base64url') were unimplemented — fell through to utf8 and produced binary garbage in JWS tokens. Now encode/decode the URL-safe alphabet correctly and handle padding.
  4. globalThis.btoa / atob were absent (V8 standalone, unlike Node v16+, doesn't expose them). Added pure-JS WHATWG-spec impls. Crucially, atob now treats '=' as the b64 padding index (was returning -1 from __b64chars.indexOf('=') and OR'ing 0xff into the last byte — every base64url-decoded Uint8Array had a spurious trailing byte, which made crypto.timingSafeEqual see length 33 vs 32 and reject every otherwise-correct JWT).
  5. node:crypto.createSecretKey was V8-stub-only — native compile returned undefined. New js_crypto_create_secret_key in perry-stdlib returns a Uint8Array-marked BufferHeader; wired through codegen (PropertyGet shape via expr.rs) and HIR lowering (named-import shape via lower/expr_call.rs). HS* in jose accepts Uint8Array directly.
  6. util.types.isKeyObject / isCryptoKey and a real KeyObject class in the V8 stubs — jose's is_key_object.js calls util.types.isKeyObject(obj), and getSignVerifyKey does instanceof KeyObject. Both are now wired.

See CHANGELOG.md (v0.5.1002 block) for the full per-bug writeup.

Test plan

  • test-files/test_jose_sign.ts (new) — import { SignJWT, createSecretKey }; HS256 sign smoke. Prints string + 3 (typeof token, parts after .split).
  • Original task repro at /tmp/perry-jose-2/test.ts (with await jwtVerify(token, key) then console.log('sub=' + payload.sub)) — prints token type: string, sub=alice.
  • HMAC byte-for-byte match against node -e "crypto.createHmac('sha256','secret').update(...)".
  • CI parity / smoke gate.

Files touched

  • crates/perry-jsruntime/Cargo.tomlhmac = "0.12", sha2 = "0.10".
  • crates/perry-jsruntime/src/ops.rs — new op_perry_hmac.
  • crates/perry-jsruntime/src/bridge.rs — typed-array / buffer marshalling.
  • crates/perry-jsruntime/src/modules.rs — real createHmac/createSecretKey/KeyObject/webcrypto/sign/verify in the node:crypto V8 stub; util.types.isKeyObject / isCryptoKey in the node:util stub.
  • crates/perry-jsruntime/src/node_polyfills.jsbtoa/atob globals (with = padding fix); Buffer.toString('base64url'); Buffer.from(_, 'base64url').
  • crates/perry-stdlib/src/crypto.rsjs_crypto_create_secret_key.
  • crates/perry-codegen/src/expr.rscrypto.createSecretKey(...) dispatch.
  • crates/perry-codegen/src/runtime_decls.rs — extern decl.
  • crates/perry-hir/src/lower/expr_call.rs — named-import lowering for createSecretKey(...).
  • crates/perry-api-manifest/src/entries.rs — strict-API manifest entry.
  • test-files/test_jose_sign.ts — new HS256 smoke.

Six-layered fix so `await new SignJWT(...).sign(createSecretKey(...))`
through `jose` produces a valid token and `jwtVerify` succeeds:

1. Native to V8 Uint8Array/TypedArray/BufferHeader marshalling now
   materializes a real `v8::Uint8Array` instead of a plain `v8::Array`,
   so jose's `instanceof Uint8Array` checks pass (bridge.rs).
2. `node:crypto.createHmac` in the V8 stub gets a real HMAC-SHA256/384/512
   via the new `op_perry_hmac` deno op (hmac + sha2 crates).
3. `Buffer.toString('base64url')` / `Buffer.from(_, 'base64url')` now
   encode/decode the URL-safe alphabet with padding handling.
4. `globalThis.btoa` / `atob` added (V8 standalone lacks them); `atob`
   now treats '=' as the b64 padding index so it stops corrupting
   the last byte of every base64url-decoded Uint8Array.
5. `node:crypto.createSecretKey` is now natively dispatched to a
   Uint8Array-marked BufferHeader (jose accepts Uint8Array for HS*).
6. `util.types.isKeyObject` / `isCryptoKey` shipped and the V8 stub's
   `KeyObject` became a real class with `_material` bytes.

Test: `test-files/test_jose_sign.ts`. Original repro now prints
`sub=alice`.
@proggeramlug proggeramlug force-pushed the fix-jose-jwt-v8-marshalling branch from e63c1b2 to afd5236 Compare May 18, 2026 08:17
@proggeramlug proggeramlug merged commit ecf11c7 into main May 18, 2026
@proggeramlug proggeramlug deleted the fix-jose-jwt-v8-marshalling branch May 18, 2026 08:17
proggeramlug added a commit that referenced this pull request May 18, 2026
…1009)

PR #973 lowered bare built-in idents (`Promise`, `Array`, `Date`, ...)
as `PropertyGet { GlobalGet(0), name }` so they route through the
globalThis singleton closure path. Two codegen call sites that
specialize `.then()` dispatch were still pattern-matching the legacy
`Expr::GlobalGet(_)` shape only:

- `type_analysis::is_promise_expr` for `Promise.resolve/reject/all/race/
  allSettled/any(...)` and `Array.fromAsync(...)`.
- `lower_call.rs`'s fused `Promise.resolve(x).then(cb)` fast path that
  routes to `js_promise_resolved_then`.

When `is_promise_expr` returned false, `.then(cb)` fell through to the
generic native method dispatch which doesn't enqueue the callback —
microtask-02..07 and edge-promises went silent in compile-smoke's
Native no-fallback gate, and on Linux V8 surfaced the same shape as
`TypeError: then is not a function`. The Native gate had been red on
every PR since #973 (admin-bypassed on #997 / #1000 / #1003 / #1004).

Extract `type_analysis::is_global_builtin_named(expr, name)` that
matches both shapes (legacy `GlobalGet(_)` and the post-#973
`PropertyGet { GlobalGet(0), name }`) and route both call sites
through it.

Validation: `scripts/run_native_no_fallback_tests.sh` — 35 passed, 0
failed (was 28/7 pre-fix).
proggeramlug added a commit that referenced this pull request May 18, 2026
… of passing them through as primitives (#1010)

PR #1004 followup. The jose end-to-end sweep fixture
(/tmp/perry-compat-sweep/jose/entry.ts) — sign then immediately verify
with the same secret — still failed after #1004 with `JWSInvalid:
Compact JWS must be a string or Uint8Array`. The diagnostic probe
showed `typeof jwt = "object"` and `String(jwt) = "[object Promise]"`,
meaning `await new SignJWT(...).sign(secret)` returned the unresolved
V8 Promise handle to user code.

Root cause: `js_async_step_chain(value, step)` (the hot path the
async-to-generator transform emits in place of
`Promise.resolve(value).then(step, step)`) short-circuits on
`is_definitely_primitive(value)`, which checks `tag != POINTER_TAG`.
JS_HANDLE_TAG (0x7FFB) — the bridge's tag for a V8 handle including a
V8 Promise — fails that check the same way STRING_TAG does, so the
unresolved Promise handle is enqueued directly as the next step's
resolution value. `js_promise_resolved` (the unfused equivalent)
already calls `adapt_foreign_promise_value` for exactly this reason;
the fused step-chain path was missing the same call.

Fix: route `value` through `adapt_foreign_promise_value` at the entry
of both `js_async_step_chain` and `js_async_step_done`. The adapter
calls back into perry-jsruntime's `js_await_any_promise`, which wraps
a V8 Promise in a native pending Promise (POINTER_TAG) the rest of
the dispatch already handles via the `js_value_is_promise` arm.

Test: `test-files/test_jose_signverify_roundtrip.ts` (new) — pins the
SignJWT → jwtVerify roundtrip; prints `sub=alice`. Verified against
the original sweep fixture at `/tmp/perry-compat-sweep/jose/`.
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.

1 participant