navree
Documentation

Signatures and keys

JSON Web Signatures with HS256, EdDSA, ES256 and RS256, JSON Web Keys and key sets, PKCS#8 keys such as Apple's .p8 files, and key generation, in navree::crypto::jose.

On this page

navree::crypto::jose signs and verifies JSON Web Signatures (JWS, RFC 7515) and reads and publishes JSON Web Keys (JWK, RFC 7517). Reach for it when a token crosses the boundary of your app:

  • verify tokens that another service signs, such as an identity provider's ID tokens or a push service's OIDC token, against the keys it publishes;
  • sign tokens that another service verifies, such as Apple's client secret, a service account's assertion or your own ID tokens;
  • publish your public keys as a JSON Web Key Set, so others can verify what you sign.

Navree's own packages are built on it: social sign-in verifies ID tokens and signs Apple's client secret with it, and the OAuth server signs its EdDSA ID tokens and serves /oauth/jwks with it. The access tokens of authentication are a separate thing: HS256 tokens that only your app reads.

Every signature runs on ring, which rustls already brings into your app, so the module adds no cryptography crate to your dependency tree.

Setting up

Featurecrypto The module is part of navree-crypto, behind the crypto feature of the navree facade (in the default full feature). It is a set of types, not a Navree module: there is nothing to register, and nothing runs until you call it.

Rust
AlgorithmKeySignsVerifies
HS256, HS384, HS512A shared secret of at least 32, 48 or 64 bytesYesYes
EdDSAEd25519YesYes
ES256, ES384P-256, P-384YesYes
RS256, RS384, RS512RSA, 2048 to 8192 bitsYes, with a key you loadYes
PS256, PS384, PS512RSA, 2048 to 8192 bitsYes, with a key you loadYes

A token whose header names any other algorithm, none included, doesn't even parse.

Signing a token

Load a SigningKey, then sign. This function makes the short-lived assertion a partner API asks for, signed with the P-256 key the partner registered for you:

src/modules/partners/assertion.rs

sign_jwt serializes the claims as JSON and signs them with the header {"alg":"ES256","kid":"<key_id>","typ":"JWT"}: the algorithm comes from the key, the key id from with_kid. The result is the compact form, header.payload.signature, each part base64url without padding.

When the other side wants a different header, build it and use sign_json:

Rust
Method
sign_jwt(&claims)Claims as JSON, header {alg, kid, typ: "JWT"}
sign_json(&header, &claims)Claims as JSON, your header
sign_jws(&header, &[u8])Any payload bytes, your header
sign(&[u8])The raw signature of a message, for protocols that aren't JWS
header(){alg, kid} of this key, to build on

The header's alg must be the key's: signing with a mismatched header fails with JoseError::NoKey. ECDSA signatures are randomized, so signing the same claims twice gives two different tokens; HMAC, Ed25519 and RSASSA-PKCS1-v1_5 signatures are deterministic.

Keys

Loading private keys

SigningKey::from_pem reads the key formats that providers hand out and tools write:

PEM labelFormatKeys
PRIVATE KEYPKCS#8Ed25519 (v1 or v2), P-256, P-384, RSA
RSA PRIVATE KEYPKCS#1RSA
EC PRIVATE KEYSEC 1P-256, P-384

The algorithm follows from the key: EdDSA for Ed25519, ES256 for P-256, ES384 for P-384 and RS256 for RSA. An Apple .p8 file is a PKCS#8 P-256 key and loads as it is.

Keys usually live in the environment. A PEM key pasted into one variable with literal \n sequences works, and so does the base64 body without its -----BEGIN and -----END lines:

.env

Encrypted PEM keys are refused: decrypt them before you deploy. Errors are JoseError::InvalidKey with a reason, and never quote the key.

The other constructors:

Constructor
SigningKey::from_pkcs8_der(&[u8])A PKCS#8 document as DER
SigningKey::from_jwk(&jwk)A private JWK: oct (k), OKP Ed25519 (d, x), EC (d, x, y) or RSA (n, e, d, p, q, dp, dq, qi). Its kid and alg carry over
SigningKey::hmac(alg, secret)An HMAC secret for HS256, HS384 or HS512
SigningKey::ed25519_from_seed(&[u8; 32])The Ed25519 key of a 32-byte seed

An HMAC secret shorter than the hash output (32 bytes for HS256) is refused, as RFC 7518 requires. A JWK whose private and public halves don't belong together is refused too.

Generating keys

SigningKey::generate_ed25519() and SigningKey::generate_es256() make new random keys. Export one as PKCS#8 to store it:

Rust
Text

Ed25519 keys export in the 48-byte PKCS#8 v1 form of RFC 8410, which other tools read. to_pkcs8_der() gives the DER bytes. Both return None for HMAC keys, which have no PKCS#8 form. RSA keys can't be generated here: create them with another tool and load them.

Key ids and algorithms

A key id (kid) tells the verifier which key of a set signed a token. Set one with with_kid, or use the key's RFC 7638 thumbprint, a stable id computed from the public key alone:

Rust

An RSA key signs RS256 unless you pick another RSA algorithm, and an HMAC secret long enough for a larger hash can sign with it:

Rust

Ed25519 and ECDSA keys have one algorithm each, so with_algorithm refuses any other.

Verifying a token

Parse the token, verify it with the key you trust, then read its claims:

src/modules/partners/callback.rs

Each step refuses something different:

StepFails withWhen
Jws::parseMalformedNot three base64url parts, a header that isn't a JSON object with alg, or a crit header (no extension is understood, so none may be critical)
Jws::parseUnsupportedAlgorithmalg is none or not in the table above
verifyNoKeyThe key doesn't verify the header's algorithm
verifyBadSignatureThe signature is wrong
claims::<T>()PayloadThe payload isn't JSON of type T

Until verify succeeds, the header and payload are whatever the sender wrote. Read the kid to find the key, and nothing else.

A key verifies only the algorithms of its type: an Ed25519 key verifies EdDSA, a P-256 key ES256, an RSA key the RS and PS algorithms, and an HMAC key its own hash. A token can't switch a public key into an HMAC secret by naming HS256, which is the classic way to forge tokens against a verifier that trusts the header. To accept one algorithm only, pin it:

Rust

A JWK with an alg member is pinned to it already.

Signatures say who signed a token, not whether it is still valid. Checking exp, nbf, aud and iss is yours to do, as the example does with exp.

Public keys

Constructor
VerifyingKey::from_pem(&str)PUBLIC KEY (SubjectPublicKeyInfo: RSA, P-256, P-384, Ed25519), RSA PUBLIC KEY (PKCS#1) or the key of a CERTIFICATE
VerifyingKey::from_spki_der(&[u8])A SubjectPublicKeyInfo document as DER
VerifyingKey::from_certificate_der(&[u8])The public key of an X.509 certificate as DER. Only the key is read: the certificate's own signature, validity and extensions aren't checked, so trust it for where it came from, such as an HTTPS download from the signer's host
VerifyingKey::from_jwk(&jwk)A JWK's public members; its kid carries over and its alg pins the algorithm
VerifyingKey::rsa(n, e)An RSA modulus and exponent, big-endian
VerifyingKey::ec(alg, x, y)P-256 (ES256) or P-384 (ES384) coordinates
VerifyingKey::ed25519(&[u8])A 32-byte Ed25519 public key
VerifyingKey::hmac(alg, secret)An HMAC secret
signing_key.verifying_key()The public half of a SigningKey (or the same secret, for HMAC)

verify(alg, message, signature) checks a raw signature outside a JWS. verify_der does the same with ECDSA signatures in their DER form, the form WebAuthn authenticators send; JWS uses the fixed-width R || S form. verify_legacy_sha1(message, signature) checks an RSA PKCS#1 v1.5 signature with SHA-1, for senders that still sign that way (AWS SNS signature version 1, which incoming webhooks verify); SHA-1 is no JWS algorithm, so no token can ask for it, and only an RSA key that isn't pinned to an algorithm accepts it. to_jwk() turns a public key into a JWK.

Verifying against a key set

An identity provider publishes its public keys as a JSON Web Key Set ({"keys": [...]}) and rotates them every few weeks. Fetch the set, cache it, and verify with verify_with, which picks the key by the token's kid:

src/modules/sso/id_tokens.rs

verify_with returns the key that verified the token. It tries the keys of the set that:

  • have the key type of the token's algorithm (RSA for RS256, EC for ES256, OKP for EdDSA);
  • have no use, or use: "sig", and no key_ops, or key_ops with verify;
  • have no alg, or the token's alg;
  • have the token's kid, when the token names one. Without a kid, every key of the type is tried.

No candidate is JoseError::NoKey; candidates that don't verify give the last error, usually BadSignature. HMAC tokens are refused with UnsupportedAlgorithm: a key set is public, so a shared secret never belongs in one.

When a provider rotates its keys, new tokens name a kid your cached set lacks, so verify_with fails with NoKey. Fetch the set again when that happens, but not more often than every 30 seconds or so, or a stream of forged tokens with made-up key ids turns into a stream of requests to the provider. JwkSet::has_kid tells you whether the cached set knows the token's key.

Reading a set skips entries that aren't keys (RFC 7517 §5), so one odd entry doesn't make the rest unusable. Members Navree doesn't read, such as x5c, x5t or a provider's issuer, are kept in Jwk::other.

Publishing a key set

To let others verify what you sign, serve your public keys. Keep the previous key in the set for a while after a rotation, so tokens signed before it still verify:

src/modules/tokens/jwks.rs
GET /.well-known/jwks.json

public_jwk() is the public half only, with the key's kid, its alg and use: "sig"; HMAC keys have none, so filter_map skips them. A Jwk read from elsewhere may hold private members (d, k, RSA's p and q): Jwk::to_public() and JwkSet::to_public() drop them, and Jwk::is_private() tells you whether there are any. The Debug output of Jwk, SigningKey and VerifyingKey never shows private material.

Limits

  • Compact serialization only: there is no JSON serialization of JWS, and no JSON Web Encryption (JWE).
  • ES512 (P-521) and EdDSA with Ed448 aren't supported.
  • RSA keys can't be generated, only loaded. SigningKey::to_pkcs8_der returns None for an RSA key read from a JWK.
  • Claims are not validated for you: check exp, nbf, aud and iss yourself.