fix(jsruntime): jose JWT (HS256) end-to-end through V8 fallback#1004
Merged
Conversation
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`.
e63c1b2 to
afd5236
Compare
This was referenced May 18, 2026
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).
4 tasks
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/`.
This was referenced May 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Six-layered fix so
await new SignJWT(...).sign(createSecretKey(...))throughjoseproduces a valid token andjwtVerifysucceeds 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
Uint8Arraywas marshalling into V8 as a plainv8::Array, so jose'sinstanceof Uint8Arraychecks failed before any await happened. Six layers needed fixing:bridge.rs:native_object_to_v8now materializes realv8::Uint8Array(and matchingInt8Array/Int16Array/...) by consultinglookup_typed_array_kindandis_registered_bufferbefore the generic-object arm. Libraries that branch onvalue instanceof Uint8Array(jose, jsonwebtoken, every JWS impl) now type-check correctly.node:crypto.createHmacwas a no-op — V8 stub returned''from.digest(). Now backed by a newop_perry_hmac(alg, key, data)deno op usinghmac+sha2(same versions asperry-stdlib'scryptofeature). Supports SHA256/384/512.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.globalThis.btoa/atobwere absent (V8 standalone, unlike Node v16+, doesn't expose them). Added pure-JS WHATWG-spec impls. Crucially,atobnow treats'='as the b64 padding index (was returning -1 from__b64chars.indexOf('=')and OR'ing 0xff into the last byte — every base64url-decodedUint8Arrayhad a spurious trailing byte, which madecrypto.timingSafeEqualsee length 33 vs 32 and reject every otherwise-correct JWT).node:crypto.createSecretKeywas V8-stub-only — native compile returnedundefined. Newjs_crypto_create_secret_keyinperry-stdlibreturns a Uint8Array-markedBufferHeader; wired through codegen (PropertyGet shape viaexpr.rs) and HIR lowering (named-import shape vialower/expr_call.rs). HS* in jose accepts Uint8Array directly.util.types.isKeyObject/isCryptoKeyand a realKeyObjectclass in the V8 stubs — jose'sis_key_object.jscallsutil.types.isKeyObject(obj), andgetSignVerifyKeydoesinstanceof 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. Printsstring+3(typeof token, parts after.split)./tmp/perry-jose-2/test.ts(withawait jwtVerify(token, key)thenconsole.log('sub=' + payload.sub)) — printstoken type: string,sub=alice.node -e "crypto.createHmac('sha256','secret').update(...)".Files touched
crates/perry-jsruntime/Cargo.toml—hmac = "0.12",sha2 = "0.10".crates/perry-jsruntime/src/ops.rs— newop_perry_hmac.crates/perry-jsruntime/src/bridge.rs— typed-array / buffer marshalling.crates/perry-jsruntime/src/modules.rs— realcreateHmac/createSecretKey/KeyObject/webcrypto/sign/verifyin thenode:cryptoV8 stub;util.types.isKeyObject/isCryptoKeyin thenode:utilstub.crates/perry-jsruntime/src/node_polyfills.js—btoa/atobglobals (with=padding fix);Buffer.toString('base64url');Buffer.from(_, 'base64url').crates/perry-stdlib/src/crypto.rs—js_crypto_create_secret_key.crates/perry-codegen/src/expr.rs—crypto.createSecretKey(...)dispatch.crates/perry-codegen/src/runtime_decls.rs— extern decl.crates/perry-hir/src/lower/expr_call.rs— named-import lowering forcreateSecretKey(...).crates/perry-api-manifest/src/entries.rs— strict-API manifest entry.test-files/test_jose_sign.ts— new HS256 smoke.