10 releases (6 breaking)
| new 0.7.0 | Aug 5, 2026 |
|---|---|
| 0.6.0 | Aug 4, 2026 |
| 0.5.1 | Jul 7, 2026 |
| 0.5.0 | Jun 24, 2026 |
| 0.1.0 | Apr 2, 2026 |
#823 in Cryptography
263 downloads per month
Used in 2 crates
445KB
8K
SLoC
jose-rs
Pure-Rust JOSE (JSON Object Signing and Encryption) library covering JWS, JWE, JWK, and JWT standards. Built on kryptering for cryptographic operations, supporting both in-memory software keys and PKCS#11 HSM-backed keys.
Standards
| Standard | RFC | Coverage |
|---|---|---|
| JWS | 7515 | Compact + Flattened JSON + General JSON serialization |
| JWE | 7516 | Compact serialization |
| JWK | 7517 | RSA / EC / oct / OKP / AKP key types (see Supported algorithms for curve coverage), JWK Sets, Thumbprints (RFC 7638), key generation |
| JWT | 7519 | Claims, validation (exp/nbf/iss/aud/sub), nested JWT |
| JWA | 7518 | Implemented signature, encryption, and key management algorithms |
Supported algorithms
JWS signatures: HS256/384/512, RS256/384/512, PS256/384/512, ES256/384/512, EdDSA
JWS post-quantum signatures (opt-in): ML-DSA-44 / ML-DSA-65 /
ML-DSA-87 (FIPS 204), plus the six composite algorithms from
draft-ietf-jose-pq-composite-sigs-03: ML-DSA-44-ES256,
ML-DSA-65-ES256, ML-DSA-87-ES384, ML-DSA-44-Ed25519,
ML-DSA-65-Ed25519, and ML-DSA-87-Ed448.
JWE key management: dir, A128KW/A192KW/A256KW, RSA-OAEP-256 (RSA-OAEP only with --features deprecated)
JWE content encryption: A128GCM/A192GCM/A256GCM, A128CBC-HS256/A192CBC-HS384/A256CBC-HS512
Feature flags
| Feature | Default | Description |
|---|---|---|
pkcs11 |
Yes | PKCS#11 HSM key support via kryptering |
post-quantum |
No | ML-DSA and composite ML-DSA algorithms |
deprecated |
No | Legacy algorithms (RSA-OAEP with SHA-1, none) |
Usage
JWT (sign and verify)
use jose_rs::{JoseHeader, jwt, JwsAlgorithm};
use jose_rs::jwt::{Claims, Validation};
// Create a signer (HMAC-SHA256)
let key = kryptering::SoftwareKey::from_symmetric_bytes(
kryptering::KeyAlgorithm::Hmac,
b"my-secret-key-32-bytes-long!!!!!",
).unwrap();
let signer = kryptering::SoftwareSigner::new(
JwsAlgorithm::HS256.to_crypto().unwrap(),
key.clone(),
).unwrap();
// Encode
let header = JoseHeader::jwt("HS256");
let mut claims = Claims::default();
claims.iss = Some("my-service".into());
claims.sub = Some("user-42".into());
let token = jwt::encode(&signer, &header, &claims).unwrap();
// Decode and validate
let verifier = kryptering::SoftwareVerifier::new(
JwsAlgorithm::HS256.to_crypto().unwrap(),
key,
).unwrap();
let validation = Validation::new().with_issuer("my-service");
let decoded = jwt::decode(&verifier, &token, &validation).unwrap();
assert_eq!(decoded.sub.as_deref(), Some("user-42"));
JWE (encrypt and decrypt)
use jose_rs::{JweAlgorithm, JweEncryption};
let cek = [0x42u8; 32]; // 256-bit key for A256GCM
let plaintext = b"sensitive data";
let token = jose_rs::jwe::encrypt(&cek, plaintext, JweAlgorithm::Dir, JweEncryption::A256GCM).unwrap();
let decrypted = jose_rs::jwe::decrypt(&cek, &token).unwrap();
assert_eq!(decrypted, plaintext);
JWK (key management)
use jose_rs::jwk;
// Generate keys
let rsa_jwk = jwk::generate_rsa(2048).unwrap();
let ec_jwk = jwk::generate_ec("P-256").unwrap();
let ed_jwk = jwk::generate_ed25519().unwrap();
// Convert to/from kryptering keys
let software_key = jwk::jwk_to_software_key(&ec_jwk).unwrap();
let roundtripped = jwk::software_key_to_jwk(&software_key).unwrap();
// Thumbprint (RFC 7638)
let thumbprint = jwk::thumbprint::thumbprint_sha256(&ec_jwk).unwrap();
HSM-backed signing
use jose_rs::{JoseHeader, jwt};
use jose_rs::jwt::Claims;
// The same JWT API works with HSM keys -- just pass an HSM-backed signer
let provider = kryptering::pkcs11::Pkcs11Provider::new(
std::path::Path::new("/usr/lib/softhsm/libsofthsm2.so")
).unwrap();
let session = provider.open_session("1234").unwrap();
let signer = kryptering::pkcs11::Pkcs11Signer::new(
&session, "my-rsa-key",
kryptering::SignatureAlgorithm::RsaPkcs1v15(kryptering::HashAlgorithm::Sha256),
).unwrap();
let header = JoseHeader::jwt("RS256");
let claims = Claims::default();
let token = jwt::encode(&signer, &header, &claims).unwrap();
Examples
See the examples directory for complete, runnable examples covering all major JOSE operations:
- generate_keys -- generate RSA, EC P-256, Ed25519, HMAC, and AES keys as JWK files
- jwt_hmac -- JWT sign/verify with HMAC-SHA256
- jwt_rsa -- JWT sign with RS256, verify with public key
- jwt_ecdsa -- JWT sign with ES256, verify with public key
- jwt_eddsa -- JWT sign with EdDSA (Ed25519), verify with public key
- jwt_ml_dsa -- JWT sign/verify with pure ML-DSA
- jwt_composite -- JWT sign/verify with ML-DSA-65-Ed25519
- jwe_aes_kw -- JWE encrypt/decrypt with AES Key Wrap + AES-GCM
- jwe_rsa_oaep -- JWE encrypt with RSA-OAEP, decrypt with private key
- jws_json -- JWS flattened and general JSON serialization (multi-signature)
- jwk_thumbprint -- RFC 7638 JWK Thumbprint for all key types
- nested_jwt -- sign a JWT then encrypt it inside a JWE
Run cargo run --example generate_keys first to create the key files, then run any other example.
Security notes
-
rsacrate advisory (RUSTSEC-2023-0071, a.k.a. "Marvin attack"). The upstreamrsacrate has a network-observable timing side channel in its padding and private-key decryption paths. This affects JWERSA-OAEP-256and, with thedeprecatedfeature,RSA-OAEP. RustSec lists no patched release as of 2026-08-02. The mitigations described in.cargo/audit.tomlreduce oracle exposure but do not fix the upstream issue. Rate-limit decryption failures and prefer non-RSA key management algorithms. -
Yanked
spin 0.9.8. This is a cargo-audit warning, not a RustSec vulnerability. It is pulled in throughnum-bigint-dig 0.8.6 -> lazy_static 1.5.0 -> spin 0.9.8. The currentlazy_staticconstraint has no compatible non-yanked release. The warning remains visible in local and CI audit output while the upstream dependency chain is updated. -
Trust-sensitive header fields.
jku,x5u, andjwkheaders are parsed by the library but never fetched or trusted — callers must never resolvejku/x5uURLs without an explicit allow-list (SSRF / key-substitution risk) and must never treat an inlinejwkheader as a verification key without first verifying it against a trusted key store. -
jwt::decode_unverifiedis for inspection only. It returns the header and claims without any cryptographic check. Production code paths must calljwt::decodewith a real verifier andValidation. -
Algorithm binding.
jws/jwtverify functions reject tokens whosealgheader does not match the verifier's algorithm and always rejectalg: "none"— even with thedeprecatedfeature enabled. Acritheader is rejected if it names any parameter outside the union of the library's understood set (b64) and the caller-suppliedunderstood_critallow-list (RFC 7515 §4.1.11); an emptycritarray is itself rejected. RFC 7797 unencoded payloads (b64: false) are supported viasign_with_options/verify_with_options, andb64, when present, must be listed incrit(RFC 7797 §6). -
RSA minimum key size. Keys below 2048 bits are rejected at parse and at generation time (RFC 7518 §3.3 / §4.2).
-
AES-GCM nonce collision bound. Content encryption with A128GCM / A192GCM / A256GCM uses a 96-bit random nonce per RFC 7518 §5.3. Under a single CEK the collision probability reaches 2⁻³² after ~2³² messages (NIST SP 800-38D §8.3). For high-throughput senders, rotate CEKs well before that bound — the
dirmode with a fresh CEK per message avoids the issue entirely. -
Token size cap. The JWS and JWE decoders reject any input larger than
jose_rs::MAX_TOKEN_BYTES(1 MiB) before allocating any base64url buffer, to bound DoS from oversized attacker-supplied tokens. -
Debug output redaction.
Jwk'sDebugimplementation redacts private components (d,p,q,dp,dq,qi,k) — logging a privateJwkwill not spill the private material. -
CEK zeroization. Content Encryption Keys generated or recovered inside JWE
encrypt/decrypt— including theMAC_KEY || ENC_KEYsplit used for AES-CBC-HS — are wrapped inzeroize::Zeroizingand wiped from the heap when they go out of scope. This does not extend to key material held inside the underlying cipher crates (which manage their own state), and the decrypted plaintext returned to the caller is not zeroized — if your plaintext is itself a secret, wrap it on the caller side. -
Signing-side algorithm binding. Symmetric to the verify path, the
jws/jwtsign functions reject a header whosealgdoes not match the signer's algorithm, refusealg: "none", and refuse acritnaming an extension outside the understood allow-list. This prevents emitting malformed tokens whose header advertises a different algorithm than the one actually used. -
JWT Best Current Practices (RFC 8725).
Validationsupports pinning thetypheader (with_typ), capping token age viaiat(with_max_age, which requiresiat), rejecting future-datediatby default, and restricting the accepted signing algorithms independently of the verifier (with_allowed_algorithms). These close the JWT-context confusion and replay-window gaps that fall outside JWS-layer verification. -
JWK authorization (RFC 7517 §4.2/§4.3). Call
Jwk::check_opbefore using a key. If the JWK'suseorkey_opsfield is set, the call enforces it — a verify-only key cannot be used to sign, anenc-marked key cannot be used for signatures, and so on.Jwkalso providesto_public_jwk()for safely exporting a key (it strips every private component —d,p,q,dp,dq,qi,k). -
JWK
alg/ktyconsistency. Importing a JWK whosealgcontradicts itskty(e.g.alg: "RS256"on akty: "oct"key, oralg: "ES256"on aP-384curve) is rejected at conversion time with a clear error, rather than failing opaquely downstream. -
HMAC key minimum length (RFC 7518 §3.2). When an
octJWK declaresalg: "HS256","HS384", or"HS512", thekmaterial must be at least as long as the hash output (32, 48, or 64 bytes respectively). Shorter keys are rejected at JWK import. -
Private JWK fields zeroize on drop.
Jwk'sDropimpl callszeroize::Zeroizeond,p,q,dp,dq,qi, andkwhen present, so private material is wiped from the heap before the allocation is returned to the allocator. -
jwt::decode_unverifiedis#[deprecated]. Every call site now emits a compiler warning pointing users tojwt::decode. The function remains available for legitimate pre-verification inspection (e.g. reading a token'skidto select the right verifier). -
JWK-first API (safer one-shot for every operation). Prefer the
*_with_jwkfunctions over the manual "build a signer/verifier yourself" path:- Sign/verify:
jws::compact::sign_with_jwk,jws::compact::verify_with_jwk,jwt::encode_with_jwk,jwt::decode_with_jwk,jwt::decode_with_jwkset. - Encrypt/decrypt:
jwe::encrypt_with_jwk,jwe::decrypt_with_jwk.
Signing and verification derive the algorithm from the JWK or token header as appropriate. JWE encryption and decryption require
jwk.algto be pinned explicitly:jwe::encrypt_with_jwkreads the key-management algorithm from the JWK, andjwe::decrypt_with_jwkrejects tokens whose headeralgdoes not match that pinned value. This prevents algorithm-substitution when one key is reused across configurations.Each API enforces
Jwk::check_opfor the intended operation (Sign/Verify/Encrypt/Decrypt/WrapKey/UnwrapKey) and constructs the underlying signer/verifier or key-material form internally.decode_with_jwksetis the canonical OIDC flow: a token pinning akidselects exactly that JWK, and akidmatching nothing in an addressable set is a hard error rather than a fall-through. Tokens carrying nokid— and tokens pinning one against a set whose JWKs are themselves allkid-less, which cannot be addressed by name — try each key in the set in turn. Because that fallback can accept a token under any key in the set, pair it withValidation::require_kid()when the set mixes keys of differing trust. For new symmetric JWKs, preferjwk::generate_symmetric_for_alg(...)orjwk::generate_direct_symmetric(...)soalganduseare pinned at creation time. - Sign/verify:
Post-quantum and composite signatures (experimental)
ML-DSA support is available behind the opt-in post-quantum feature:
[dependencies]
jose-rs = { version = "0.5", features = ["post-quantum"] }
Enabling this pulls in the ml-dsa and pkcs8-pq crates plus kryptering's
post-quantum backend. It enables pure ML-DSA-44, ML-DSA-65, and
ML-DSA-87, as well as all six composite identifiers listed above.
JWK wire format (kty = "AKP")
Per draft-ietf-cose-dilithium, ML-DSA keys use the new "AKP"
("Algorithm Key Pair") key type with two base64url members:
pub— the raw FIPS 204 encoded public key (1312 / 1952 / 2592 bytes for the three security levels).priv— the 32-byte FIPS 204 seed (not an expanded private key). The expanded signing key is derived on demand viaML-DSA.KeyGen_internal(seed).
Jwk.priv is zeroized on Drop alongside the existing RSA/EC/oct
private-component wipe. Jwk::to_public_jwk() strips priv and keeps
pub, which is the correct shape for a JWK Set endpoint.
Composite algorithms use the same AKP members, with raw concatenated
encodings defined by draft-ietf-jose-pq-composite-sigs-03:
pub = ML-DSA public || traditional publicpriv = 32-byte ML-DSA seed || traditional private
The selected alg fixes the component algorithms, prehash, domain-separation
label, and every aggregate length. Import rejects wrong lengths, malformed
traditional public keys, and private/public mismatches. Generate composite
keys atomically so neither component is reused independently:
use kryptering::CompositeMlDsaVariant;
let key = jose_rs::jwk::generate_composite_mldsa(
CompositeMlDsaVariant::MlDsa65Ed25519,
).unwrap();
let header = jose_rs::JoseHeader::new("ML-DSA-65-Ed25519");
let token = jose_rs::jws::compact::sign_with_jwk(&key, b"payload", &header).unwrap();
let payload = jose_rs::jws::compact::verify_with_jwk(&key.to_public_jwk(), &token).unwrap();
assert_eq!(payload, b"payload");
Status and caveats
-
Draft-spec, not yet RFC. The authoritative spec is
draft-ietf-cose-dilithium-11(active, submitted to IESG, expected to publish as an RFC in 2026). The wire format may shift before publication — do not use this feature for long-lived signed artifacts that you cannot re-issue later. -
Composite draft status. Composite names and encodings follow
draft-ietf-jose-pq-composite-sigs-03. They may change before publication. The ML-DSA-87-Ed448 implementation currently relies on the pinned prereleaseed448-goldilocks 0.14.0-pre.15through kryptering. -
ml-dsacrate history. The RustCryptoml-dsacrate shipped three moderate-severity advisories during its 0.1.0 release-candidate series. jose now tracks the stableml-dsa 0.1.0(andpkcs8 0.11.0) releases via caret ranges, matching kryptering. All three advisories were fixed in release candidates earlier than the stable release, socargo auditis clean againstml-dsa 0.1.0:Advisory Summary Patched from GHSA-hcp2-x6j4-29j7 Decomposetiming side-channel during signing>= 0.1.0-rc.3GHSA-5x2r-hc65-25f9 Repeated hint indices (signature malleability) >= 0.1.0-rc.4GHSA-h37v-hp6w-2pp8 UseHintoff-by-two>= 0.1.0-rc.5Because the dependency now uses a caret range (
0.1.0), any futureml-dsaadvisory will be reported bycargo audit; remediation may require aCargo.lockupdate — consultcargo auditbefore bumping, and track RustCrypto/signatures for furtherml-dsareleases. -
Default signing mode is randomized (hedged). ML-DSA sign uses real randomness per FIPS 204 Algorithm 2, so repeated signatures over the same message and key are expected to differ while still verifying. The optional deterministic signing variant (equivalent to a zero
rnd) is not currently exposed. -
ML-DSA-87 test note. The expanded signing key (~5 KiB) exceeds the default 2 MiB debug-build test-thread stack; the ML-DSA-87 round-trip test spawns itself on a larger-stack thread. Release builds have smaller stack frames and are unaffected.
Development tooling
Dependency audit
The repository is wired for cargo-audit.
Local run:
cargo install --locked cargo-audit
cargo audit
CI runs the same command on every push to main, every pull request
that touches Cargo.toml / Cargo.lock, and on a weekly schedule
(.github/workflows/audit.yml). With the current lockfile, the command
completes successfully while still reporting the yanked spin 0.9.8 warning.
.cargo/audit.toml carries one documented advisory ignore:
RUSTSEC-2023-0071, the rsa crate Marvin timing advisory. Any RustSec
advisory not in that list fails local and CI audits. CI additionally parses
the JSON report and permits only the exact spin 0.9.8 yanked warning; any
other warning fails the job. The audit config and security notes are the
source of truth for accepted findings and their mitigations.
Interop test vectors (RFC 7520)
tests/rfc7520.rs verifies examples from
RFC 7520 — these are the
canonical worked vectors every JOSE implementation is expected to
reproduce, which gives the library a cross-implementation interop
baseline:
- JWS §4.1–4.4. RS256, PS384, ES512 verify; HS256 as a full byte-for-byte deterministic roundtrip (sign then byte-compare against the RFC's published compact serialization).
- JWE §5.6, §5.8. Direct encryption with A128GCM and A128KW + A128GCM — decrypt the RFC's published JWE and check the recovered plaintext.
- JWE §5.2. RSA-OAEP + A256GCM decrypt using the RFC's RSA-4096 key.
Runs only under
--features deprecatedbecause RSA-OAEP uses SHA-1.
All vectors are embedded verbatim from rfcs/rfc7520.txt with their
original indent-continuations intact; runtime whitespace-stripping
eliminates transcription risk in 1000+-char RSA key material.
Fuzzing
fuzz/ is a cargo-fuzz
project with eight targets covering the attacker-controlled parse and
verify paths (base64url, JWK JSON, thumbprint, JWS decode/verify, JWE
decrypt, JWT header/claims, and post-quantum JWS verify via
jws_verify_mldsa). See fuzz/README.md for the per-target breakdown.
rustup toolchain install nightly
cargo install --locked cargo-fuzz
cargo +nightly fuzz run jwk_from_json # run indefinitely
cargo +nightly fuzz run jws_verify -- -max_total_time=300
CI (.github/workflows/fuzz.yml) builds every target on every PR to
catch compilation regressions; the actual fuzzing runs are expected to
live in a longer-running job or OSS-Fuzz once the corpus stabilizes.
License
BSD-2-Clause
Dependencies
~16MB
~305K SLoC