JOSE

Unreviewed Node only Crypto 1.53–4.2× faster

Ed25519 JWK generation and RFC 7638 thumbprints. JWE roadmap.

Targets
Node only

Install

pnpm add @amigo-labs/jose

Benchmarks

Trend (12 pts)

Benchmark

jwkThumbprint - Ed25519

1.53× vs slowest
  • @amigo-labs/jose 460.48K hz · 1.53×
  • jose (panva, pure JS) 300.21K hz

Benchmark

jwkThumbprint - RSA-2048

1.60× vs slowest
  • @amigo-labs/jose 390.36K hz · 1.60×
  • jose (panva, pure JS) 244.15K hz

Benchmark

generateEd25519KeyPair

4.22× vs slowest
  • @amigo-labs/jose 45.38K hz · 4.22×
  • jose (panva, pure JS) — generateKeyPair Ed25519 + exportJWK 10.75K hz
Performance trend for JOSE
12 commits · last 2026-05-22

README

@amigo-labs/jose

npm version npm downloads license

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-in crypto.generateKeyPair('rsa') (OpenSSL via the libuv thread-pool) outpaces every pure-Rust rsa crate 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 JoseKey class (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

PlatformArchitecture
Linuxx64 (glibc), x64 (musl), arm64
macOSx64, arm64
Windowsx64

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/jwt is 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 family
    • aws-lc-rs + rsa + aes-gcm + p256/p384/p521 as 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

BACKLOG check

No entry. Fresh candidate.

FFI-overhead prediction

FactorAssessment
Per-call algorithmic workJWE 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 distributionToken: 500 B–4 KB. JWK JSON: 200 B–2 KB. Buffer lane or string, both OK at these sizes
Output size distributionCleartext 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 realismLow — auth calls are per-request, not batchable. Stateful class substitutes for batching
FFI-share estimate vs. Rust workPer 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:

    1. Scope creep: panva/jose covers 30+ algorithms. We do 4. JS users with exotic algos (RSA1_5 — legacy, PBES2 — legacy, …) have to stay with panva/jose. Document clearly
    2. 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)
    3. JWKS remote caching: createRemoteJWKSet from 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
    4. Bundle discipline: be frugal with algo selection. Default features aes-gcm, rsa, p256 cover 90% of real-world use-cases

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/josejose (panva, pure JS)Speedup
jwkThumbprint Ed25519507,407 hz271,279 hz1.87×
jwkThumbprint RSA-2048470,445 hz182,718 hz2.57× ✅✅
generateEd25519KeyPair52,939 hz7,312 hz7.24× ✅✅✅
generateRsaKeyPair (2048)6.92 hz18.08 hz0.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: generateRsaKeyPair is 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 generateRsaKeyPair in 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:

  • generateRsaKeyPair completely removed from the public API (not deprecated, was never public)
  • rsa crate + transitive deps (pkcs1, pkcs8 for RSA, num-bigint-dig, num-traits, num-iter, zeroize) removed from Cargo.toml
  • README documents the decision + shows node:crypto.generateKeyPair as the alternative
  • RsaGenTask task struct removed; the RSA thumbprint path is fully preserved (no dependency on the RSA crate)

New measurement after rescope:

Function@amigo-labs/josepanva/joseSpeedup
jwkThumbprint Ed25519398,751 hz246,636 hz1.62× 🟡
jwkThumbprint RSA-2048368,756 hz168,409 hz2.19× 🟢
generateEd25519KeyPair46,406 hz6,660 hz6.97× 🟢🟢

Portfolio impact:

  • No more Red point in the crate
  • Median function (jwkThumbprint on 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