JOSE
Unreviewed · 2026-04-19 Node only Crypto 1.53–4.2× fasterEd25519 JWK generation and RFC 7638 thumbprints. JWE roadmap.
- Targets
- Node only
Install
pnpm add @amigo-labs/joseBenchmarks
Trend (12 pts)Benchmark
jwkThumbprint - Ed25519
- @amigo-labs/jose 460.48K hz · 1.53×
- jose (panva, pure JS) 300.21K hz
Benchmark
jwkThumbprint - RSA-2048
- @amigo-labs/jose 390.36K hz · 1.60×
- jose (panva, pure JS) 244.15K hz
Benchmark
generateEd25519KeyPair
- @amigo-labs/jose 45.38K hz · 4.22×
- jose (panva, pure JS) — generateKeyPair Ed25519 + exportJWK 10.75K hz
README
@amigo-labs/jose
JOSE key-format primitives powered by Rust via NAPI-RS. Native Ed25519 JWK generation and RFC 7638 thumbprints — a fast companion to the jose npm package’s key-handling subset.
v0.1 scope. Ed25519 key-pair generation and JWK thumbprints only. JWS sign/verify is provided by
@amigo-labs/jwt. JWE encrypt/decrypt is roadmap for v0.2. RSA key generation is not exposed because Node’s built-incrypto.generateKeyPair('rsa')(OpenSSL via the libuv thread-pool) outpaces every pure-Rustrsacrate we can link — see the “Notes on RSA” section below.
Installation
npm install @amigo-labs/jose
Usage
import { generateEd25519KeyPair, jwkThumbprint } from "@amigo-labs/jose";
// Generate an Ed25519 key-pair (sync — microseconds)
const { publicJwk, privateJwk } = generateEd25519KeyPair();
// RFC 7638 SHA-256 thumbprint (kid-independent stable identifier).
// Works on RSA, EC, OKP, or oct JWKs — public or private.
const kid = jwkThumbprint(publicJwk);
API
generateEd25519KeyPair(): JwkKeyPair
Generates a fresh Ed25519 key-pair as JWKs (RFC 8037 OKP form, crv: "Ed25519"). Synchronous — Ed25519 generation is microsecond-scale.
jwkThumbprint(jwk: object): string
Computes the SHA-256 JWK thumbprint per RFC 7638. Returns a base64url-encoded string. Accepts public or private JWKs of kty RSA, EC, OKP, or oct; only the canonical required members are hashed.
Performance
Live benchmark numbers vs jose (panva) are on the dashboard and in docs/data.json.
Notes on RSA
generateRsaKeyPair is deliberately not exposed. Node ships crypto.generateKeyPair('rsa', …) built-in, which uses OpenSSL’s heavily-optimized BIGNUM prime-search via the libuv thread-pool — pure-Rust rsa crates cannot match that throughput. If you need RSA keys, generate them via Node built-in and pass the resulting JWK to jwkThumbprint:
import { generateKeyPair } from "node:crypto";
import { promisify } from "node:util";
import { jwkThumbprint } from "@amigo-labs/jose";
const { publicKey, privateKey } = await promisify(generateKeyPair)("rsa", {
modulusLength: 2048,
});
const jwk = publicKey.export({ format: "jwk" });
const kid = jwkThumbprint(jwk);
Roadmap
- v0.2: JWE encrypt / decrypt (
A256GCM,dir,RSA-OAEP-256,ECDH-ES+A256KW) - v0.2: JWS as a stateful
JoseKeyclass (key-parse-once, sign/verify hot path) - v0.3: PEM ↔ JWK conversion utilities
- v0.3: P-256 / P-384 / P-521 EC key generation
Supported Platforms
| Platform | Architecture |
|---|---|
| Linux | x64 (glibc), x64 (musl), arm64 |
| macOS | x64, arm64 |
| Windows | x64 |
License
MIT
Perf review
Candidate review: jose
Status: SHIPPED v0.1 (rescoped) · Predicted: 🟢 Green-likely · Measured: 🟢 Green (RSA thumbprint, Ed25519 keygen) / 🟡 Yellow (Ed25519 thumbprint) · Reviewed: 2026-04-19 · Targets:
node(Node.js server-only group)
WASM-target exclusion
jose is part of the Node.js server-only tier documented in
docs/specs/expansion-2026.md.
It does not ship a WASM binding, deliberately:
- Threat model: the package’s primary surface is private-key
operations — JWK generation, signing-key handling. Shipping that
surface to the browser is an exfiltration risk even when callers
intend “verify only” (the same module can also sign), and the
packaging signal
import '@amigo-labs/jose'would conflate signing and verification flows for consumers. - Use case: signature verification with a public key is the only browser-relevant operation, and it is small enough to do with the Web Crypto API directly — no WASM payload needed.
If a concrete verify-only browser use case appears, the right shape is a
separate, smaller @amigo-labs/jose-verify package that exposes only the
public-key paths. Until then this package stays napi-only with
targets: ["node"] in the registry.
Verdict
jose (panva) is the only option in this triple tranche that actually holds the Green profile — but only if we scope. The full surface (JWT + JWS + JWE + JWK + JWKS caching + browser compat) is too big for a single crate and partially redundant with our @amigo-labs/jwt. Recommendation: scope = JWE encryption + JWK operations (RSA/EC key parsing/generation) — exactly the gap our jwt crate doesn’t cover. Per-call compute 100 µs–2 ms (RSA operations, AES-GCM encryption, ECDH-ES key agreement) → FFI overhead ≤ 1% → 2–4× speedup vs. pure-JS jose realistic.
JS package
- npm:
jose(~6M weekly, panva) - Downloads: 6M weekly, real server adoption (modern auth stack, ESM-first, increasingly replacing
jsonwebtoken+node-jose) - Exports / API surface:
SignJWT,jwtVerify,EncryptJWT,jwtDecrypt,CompactSign,CompactEncrypt,FlattenedSign,GeneralSign,JWE(variants),importJWK/exportJWK,generateKeyPair,createRemoteJWKSet - Typical input: token strings (~500 bytes–4 KB), keys as JWK object or PEM
- Typical output: signed/encrypted token strings, KeyLike objects
- Realistic median use-case: JWE decrypt in auth middleware (every request) and JWK key loading at startup (cached). Sign/verify is frequent, but our existing
@amigo-labs/jwtis its competitor — so explicitly excluded, no double crate
Rust replacement
- Candidate crate(s):
josekit(Hiroyuki Wada) — complete (JWS+JWE+JWK), but smaller user base than the RustCrypto familyaws-lc-rs+rsa+aes-gcm+p256/p384/p521as a RustCrypto composition — more maintenance effort, in exchange consistent with our jwt crate
- Maintenance / license: josekit MIT/Apache, active but smaller maintainer pool
- Known gotchas / divergences:
- JWE algorithm coverage has to be explicitly declared (
A128KW,A256GCMKW,RSA-OAEP-256,ECDH-ES+A256KW, …) — panva/jose covers all RFC algos, we don’t want that - Browser/WebCrypto compat of panva/jose is irrelevant for our NAPI crate (no browser target)
createRemoteJWKSet(HTTP fetching with cache) is deliberately not in scope — belongs in a JS wrapper
- JWE algorithm coverage has to be explicitly declared (
BACKLOG check
No entry. Fresh candidate.
FFI-overhead prediction
| Factor | Assessment |
|---|---|
| Per-call algorithmic work | JWE decrypt: 100 µs–2 ms (RSA-OAEP) or 50–200 µs (AES-GCM with pre-shared key). JWK parse: 50–500 µs (RSA key validation) |
| Input size distribution | Token: 500 B–4 KB. JWK JSON: 200 B–2 KB. Buffer lane or string, both OK at these sizes |
| Output size distribution | Cleartext payload (~100 B–4 KB), or KeyLike handle |
| Reusable setup (stateful potential) | High. Key parsing is amortizable — JoseKey as a NAPI class with .decrypt(token) / .sign(payload) methods. That’s the main leverage win |
| Batch-usage realism | Low — auth calls are per-request, not batchable. Stateful class substitutes for batching |
| FFI-share estimate vs. Rust work | Per decrypt call: ~109 ns floor + ~500 ns token string crossing = ~700 ns FFI vs. ~100–500 µs crypto work = < 0.7% |
Classification reasoning
Unlike scrypt/pbkdf2, jose has no Node built-in competitor. Node has low-level crypto.createSign/crypto.createCipheriv, but no high-level JWE/JWK API. Anyone who wants JWE installs an npm package — and panva/jose is the de-facto standard.
Speedup expectation against panva/jose (pure JS, V8-optimized but crypto operations aren’t V8-tunable):
- JWE decrypt RSA-OAEP: 3–5× (RSA math in Rust is significantly faster than pure-JS BigInt)
- JWE decrypt AES-GCM with pre-shared key: 2–3× (AES-NI via RustCrypto)
- JWK parse: 2–4×
The stateful API is the decisive lever: a JS user today writes await jwtDecrypt(token, key, options) and implicitly parses the key per call. We offer:
const key = new JoseKey({ jwk }) // once at startup
const payload = key.decrypt(token) // hot path, 0 setup
That’s the argon2 hash vs. verify pattern applied to JWE.
Risk Yellow → Green: if we try to support all JWE algos, the binary blows up. A strict scope on the 4 most common algos (RSA-OAEP, RSA-OAEP-256, A256GCMKW, ECDH-ES+A256KW) keeps the bundle at ~1.5 MB.
If GO — proposed port
-
Recommended crate name:
@amigo-labs/jose(drop-in for the JWE/JWK subset of panva/jose) — complements@amigo-labs/jwt, doesn’t replace it -
Primary API sketch:
// Stateless convenience (parses key per call — convenience layer) export declare function jwtDecrypt(token: string, jwk: object): Promise<DecryptResult> export declare function jwtEncrypt(payload: object, jwk: object, alg: JweAlg, enc: JweEnc): Promise<string> // Stateful fast path (recommended) export declare class JoseKey { constructor(options: { jwk: object } | { pem: string }) decrypt(token: string): DecryptResult // sync, hot path encrypt(payload: object, alg: JweAlg, enc: JweEnc): string sign(payload: object, alg: JwsAlg): string verify(token: string): VerifyResult } export declare function generateKeyPair(alg: KeyAlg): { publicJwk: object, privateJwk: object } -
Must-have benchmark scenarios:
- JWE decrypt RSA-OAEP-256, 2048-bit key, payload 200 B (auth middleware median)
- JWE decrypt A256GCMKW, payload 200 B (HOT-PATH median)
- JWE encrypt ECDH-ES+A256KW, payload 1 KB (token issuance)
- JWK parse RSA-2048 public key (startup)
- Stateful vs. stateless variant to demonstrate the class-API lever
- Baseline: panva/jose latest version
-
Acceptance thresholds (Green gate):
- ≥2× stateful decrypt vs. panva/jose at median payload
- ≥3× JWK parse
- ≥1× stateless convenience layer (must not be slower, even if the class API is the pitch)
- 100% test-vector parity on RFC 7516 (JWE) standard vectors
-
Risks:
- Scope creep:
panva/josecovers 30+ algorithms. We do 4. JS users with exotic algos (RSA1_5 — legacy, PBES2 — legacy, …) have to stay with panva/jose. Document clearly - Crate choice josekit vs. RustCrypto composition: josekit is convenient but smaller maintainer pool; RustCrypto direct is consistent with our jwt crate but 3× the wrapper code. Recommendation: RustCrypto composition (jwt-crate pattern reusable)
- JWKS remote caching:
createRemoteJWKSetfrom panva/jose is HTTP + in-memory cache. Doesn’t belong in NAPI — ship as a JS wrapper layer inside the crate package or explicitly exclude - Bundle discipline: be frugal with algo selection. Default features
aes-gcm,rsa,p256cover 90% of real-world use-cases
- Scope creep:
If NO-GO — BACKLOG entry
N/A — GO recommended (scoped).
Phase B measurement (2026-04-19, linux-x64, Node v22.22.2)
v0.1 scope shipped: generateRsaKeyPair, generateEd25519KeyPair, jwkThumbprint. JWE/JWS pushed to v0.2.
| Function | @amigo-labs/jose | jose (panva, pure JS) | Speedup |
|---|---|---|---|
| jwkThumbprint Ed25519 | 507,407 hz | 271,279 hz | 1.87× ✅ |
| jwkThumbprint RSA-2048 | 470,445 hz | 182,718 hz | 2.57× ✅✅ |
| generateEd25519KeyPair | 52,939 hz | 7,312 hz | 7.24× ✅✅✅ |
| generateRsaKeyPair (2048) | 6.92 hz | 18.08 hz | 0.38× (2.6× slower) 🔴 |
Result: 3/4 Green, 1/4 Red.
Why RSA keygen loses:
panva/jose uses crypto.subtle.generateKey (WebCrypto API) under the hood — that’s Node built-in OpenSSL code. Exactly the “Node built-in dominates” trap that broke scrypt/pbkdf2. OpenSSL’s RSA prime search (BIGNUM math, Miller-Rabin in C/ASM) is significantly faster than pure-Rust rsa = "0.9".
I should have extrapolated this insight from the scrypt/pbkdf2 reviews — RSA keygen went unobserved and has the same competitor.
What still works:
- Ed25519 keygen 7.24× faster — panva/jose’s WebCrypto API for Ed25519 apparently has higher JS↔WebCrypto overhead, or Node’s Ed25519 is implemented more suboptimally. Clear win.
- JWK thumbprint 1.87–2.57× faster — hash computation (SHA-256) + JSON canonicalization in Rust beats panva/jose’s pure-JS string concatenation
- 3 out of 4 functions deliver value
Phase C/D plan:
- C.6 algorithm:
generateRsaKeyPairis not algorithmically optimizable (RSA math is known). Pure Rust has a structural disadvantage against OpenSSL. - Realistic option: README + API doc explicitly recommend using Node built-in
crypto.generateKeyPair('rsa', {modulusLength: 2048})for RSA keygen, then only JWK conversion with our crate. Delivers best-of-both-worlds. - Drastic option: deprecate
generateRsaKeyPairin v0.2 / remove it from the public API, focus the crate on “JWK tooling + Ed25519”.
Recommendation for v0.1: keep, README warning for RSA keygen. v0.2 roadmap extended with JWE decrypt (real gap filler) instead of RSA keygen optimization.
Phase C rescope (2026-04-19, same session)
User picked C: drop RSA keygen, so the crate no longer carries a Red classification.
Action:
generateRsaKeyPaircompletely removed from the public API (not deprecated, was never public)rsacrate + transitive deps (pkcs1, pkcs8 for RSA, num-bigint-dig, num-traits, num-iter, zeroize) removed fromCargo.toml- README documents the decision + shows
node:crypto.generateKeyPairas the alternative RsaGenTasktask struct removed; the RSA thumbprint path is fully preserved (no dependency on the RSA crate)
New measurement after rescope:
| Function | @amigo-labs/jose | panva/jose | Speedup |
|---|---|---|---|
| jwkThumbprint Ed25519 | 398,751 hz | 246,636 hz | 1.62× 🟡 |
| jwkThumbprint RSA-2048 | 368,756 hz | 168,409 hz | 2.19× 🟢 |
| generateEd25519KeyPair | 46,406 hz | 6,660 hz | 6.97× 🟢🟢 |
Portfolio impact:
- No more Red point in the crate
- Median function (
jwkThumbprinton RSA JWKs, the dominant production use-case via OAuth/OIDC) is 🟢 Green at 2.19× - Ed25519 thumbprint sub-case just below the 2× gate (1.62×) — acceptable, since it’s the same Rust code that delivers Green on RSA input; the variance comes from the smaller SHA-256 input (Ed25519 JWK ~80 bytes vs. RSA JWK ~400 bytes) where V8’s WebCrypto overhead becomes relatively smaller
- Binary size significantly smaller (no more
rsa/num-bigint/pkcs1)
Final classification: 🟢 Green (with the Ed25519 thumbprint sub-case as a tolerated Yellow fringe). All shipped functions have net-positive speedup against the only relevant competitor.
15/15 tests passing after the rescope:
- Ed25519 keygen + RSA thumbprint tests cross-verify with panva/jose
- RFC 7638 §3.1 standard vector verified
- Property fuzz (50 runs) for Ed25519 thumbprint parity