//! Asymmetric JWT signing keys: `EdDSA` (Ed25519) and ES256 (P-256).
//!
//! This module is gated behind the `asym-jwt` feature (enabled transitively
//! by `oidc`). It adds the two asymmetric JOSE algorithms an identity
//! provider needs to mint and verify ID tokens against a published JWKS:
//!
//! * **`EdDSA`** — Ed25519 signatures over the `Ed25519` curve (RFC 8037 §3.1).
//! * **ES256** — ECDSA over the NIST P-256 curve with SHA-256 (RFC 7518 §3.4).
//!
//! # Design
//!
//! * **Curve arithmetic is delegated, not hand-rolled.** The crate's
//! "implement from the RFC using only `std`" philosophy applies to hashes,
//! HMAC, and encodings — primitives that are auditable in a few hundred
//! lines. Constant-time Edwards / Weierstrass curve arithmetic is **not**
//! in that class; getting it wrong is catastrophic and the bar here is
//! "tested against vectors and reviewed". The well-audited, `no_std`-
//! friendly `RustCrypto` crates ([`ed25519_dalek`], [`p256`]) are the
//! idiomatic Rust choice and are pulled in only when this feature is on.
//! * **The crate owns randomness.** Key generation seeds from the crate's
//! own [`fill_random`](crate::crypto::fill_random) CSPRNG and feeds raw
//! bytes into `from_bytes` / `from_slice`, so no `rand_core` integration
//! feature is required from the dependencies.
//! * **`alg=none` stays unrepresentable.** These variants live on
//! [`AsymmetricAlgorithm`], which — like [`JwtSigningAlgorithm`] — has no
//! `None` arm. The signing path cannot produce an unsigned token.
//!
//! # Security
//!
//! * Private key material lives in [`Zeroizing`] buffers and is cleared on
//! drop. It never appears in `Debug` output (the [`AsymmetricSigningKey`]
//! `Debug`
//! impl prints only the algorithm and `kid`) nor in any error message.
//! * The public-key halves and the `kid` are **not** secret and are exposed
//! for JWKS publication.
//!
//! [`JwtSigningAlgorithm`]: super::JwtSigningAlgorithm
use core::fmt;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{Sha256, fill_random};
use crate::encoding::base64url_encode;
use super::header::JwtAlgorithm;
// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------
/// An asymmetric JWS signing algorithm.
///
/// Distinct from [`JwtSigningAlgorithm`](super::JwtSigningAlgorithm) (the
/// HMAC signing enum) so that the two key models — shared secret versus
/// keypair — stay in separate types and cannot be confused at a call site.
/// Like that enum, this one has **no `None` variant**: an unsigned token is
/// unrepresentable on the signing path.
#[doc(alias = "asymmetric_algorithm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AsymmetricAlgorithm {
/// `EdDSA` using Ed25519 (RFC 8037 §3.1). JWK `kty` `OKP`, `crv` `Ed25519`.
EdDsa,
/// ECDSA using P-256 and SHA-256 (RFC 7518 §3.4). JWK `kty` `EC`,
/// `crv` `P-256`.
Es256,
/// RSASSA-PKCS1-v1_5 using SHA-256 (RFC 7518 §3.3). JWK `kty` `RSA`.
///
/// **Verify-only.** This algorithm exists to validate ID tokens minted
/// by external OIDC providers (Google, Microsoft, Okta), which sign with
/// RSA. The crate never generates an RSA keypair or signs with one — its
/// own identity provider uses [`EdDsa`](Self::EdDsa) /
/// [`Es256`](Self::Es256) — so
/// [`AsymmetricSigningKey::generate`] and
/// [`AsymmetricSigningKey::from_private_bytes`] reject it.
Rs256,
/// RSASSA-PKCS1-v1_5 using SHA-512 (RFC 7518 §3.3). JWK `kty` `RSA`.
/// Verify-only, as for [`Rs256`](Self::Rs256).
Rs512,
}
impl AsymmetricAlgorithm {
/// Returns the JOSE `alg` header value (`"EdDSA"`, `"ES256"`, `"RS256"`,
/// or `"RS512"`).
#[must_use]
#[inline]
pub fn as_str(self) -> &'static str {
match self {
Self::EdDsa => "EdDSA",
Self::Es256 => "ES256",
Self::Rs256 => "RS256",
Self::Rs512 => "RS512",
}
}
/// Returns the JWK key type (`kty`): `"OKP"` for `EdDSA`, `"EC"` for
/// ES256, `"RSA"` for RS256 / RS512.
#[must_use]
#[inline]
pub fn key_type(self) -> &'static str {
match self {
Self::EdDsa => "OKP",
Self::Es256 => "EC",
Self::Rs256 | Self::Rs512 => "RSA",
}
}
/// Returns the JWK curve (`crv`): `"Ed25519"` or `"P-256"`.
///
/// RSA keys have no curve parameter (RFC 7518 §6.3), so RS256 / RS512
/// return `None`.
#[must_use]
#[inline]
pub fn curve(self) -> Option<&'static str> {
match self {
Self::EdDsa => Some("Ed25519"),
Self::Es256 => Some("P-256"),
Self::Rs256 | Self::Rs512 => None,
}
}
/// Returns `true` if this is an RSA algorithm (`RS256` / `RS512`).
#[must_use]
#[inline]
pub fn is_rsa(self) -> bool {
matches!(self, Self::Rs256 | Self::Rs512)
}
/// Returns the parsing-side [`JwtAlgorithm`] this algorithm corresponds
/// to, for callers relating the signing and parsing enums.
#[must_use]
#[inline]
pub fn to_jwt_algorithm(self) -> JwtAlgorithm {
match self {
Self::EdDsa => JwtAlgorithm::EdDSA,
Self::Es256 => JwtAlgorithm::ES256,
Self::Rs256 => JwtAlgorithm::RS256,
Self::Rs512 => JwtAlgorithm::RS512,
}
}
}
impl fmt::Display for AsymmetricAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// The category of asymmetric-key failure.
#[derive(Debug, Clone, PartialEq, Eq)]
enum AsymmetricKeyErrorKind {
/// The platform CSPRNG was unavailable during key generation.
Random,
/// The supplied key material was the wrong length or otherwise invalid
/// for the algorithm (e.g. a P-256 scalar outside `[1, n)`).
InvalidKeyMaterial,
/// The algorithm is verify-only and cannot be used to construct a signing
/// key (RS256 / RS512 — the crate verifies external RSA tokens but never
/// mints them).
SigningUnsupported,
}
/// Error returned when constructing or generating an asymmetric key fails.
///
/// Error messages never include key material.
#[doc(alias = "asymmetric_key_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsymmetricKeyError {
kind: AsymmetricKeyErrorKind,
}
impl AsymmetricKeyError {
/// Returns `true` if key generation failed because the CSPRNG was
/// unavailable.
#[must_use]
#[inline]
pub fn is_random(&self) -> bool {
self.kind == AsymmetricKeyErrorKind::Random
}
/// Returns `true` if the supplied key material was invalid.
#[must_use]
#[inline]
pub fn is_invalid_key_material(&self) -> bool {
self.kind == AsymmetricKeyErrorKind::InvalidKeyMaterial
}
/// Returns `true` if the algorithm is verify-only and cannot back a
/// signing key (RS256 / RS512).
#[must_use]
#[inline]
pub fn is_signing_unsupported(&self) -> bool {
self.kind == AsymmetricKeyErrorKind::SigningUnsupported
}
}
impl fmt::Display for AsymmetricKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
AsymmetricKeyErrorKind::Random => {
write!(f, "asymmetric key: platform CSPRNG unavailable")
}
AsymmetricKeyErrorKind::InvalidKeyMaterial => {
write!(f, "asymmetric key: invalid key material")
}
AsymmetricKeyErrorKind::SigningUnsupported => {
write!(f, "asymmetric key: algorithm is verify-only (no signing)")
}
}
}
}
impl std::error::Error for AsymmetricKeyError {}
// ---------------------------------------------------------------------------
// Public verifying key
// ---------------------------------------------------------------------------
/// The public half of an asymmetric signing key, plus its `kid`.
///
/// This is the verification key a relying party (or a JWKS document) holds:
/// no secret material. For Ed25519 it wraps the 32-byte compressed point;
/// for P-256 it wraps the uncompressed affine `(x, y)` coordinates; for RSA
/// it wraps the big-endian modulus and public exponent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsymmetricVerifyingKey {
alg: AsymmetricAlgorithm,
kid: String,
/// `EdDSA`: the 32-byte public key. ES256: the 32-byte `x` coordinate.
/// RS256 / RS512: the big-endian RSA modulus `n`.
x: Vec<u8>,
/// ES256: the 32-byte `y` coordinate. RS256 / RS512: the big-endian RSA
/// public exponent `e`. Empty for `EdDSA`.
y: Vec<u8>,
}
impl AsymmetricVerifyingKey {
/// Returns the algorithm this key verifies.
#[must_use]
#[inline]
pub fn algorithm(&self) -> AsymmetricAlgorithm {
self.alg
}
/// Returns the key identifier (`kid`).
#[must_use]
#[inline]
pub fn kid(&self) -> &str {
&self.kid
}
/// Returns the JWK `x` parameter bytes: the Ed25519 public key, the P-256
/// `x` coordinate, or — for RSA — the big-endian modulus `n`.
#[must_use]
#[inline]
pub fn x_bytes(&self) -> &[u8] {
&self.x
}
/// Returns the second public parameter: the P-256 `y` coordinate (ES256)
/// or the big-endian RSA public exponent `e` (RS256 / RS512). `None` for
/// `EdDSA`, which has no second parameter.
#[must_use]
#[inline]
pub fn y_bytes(&self) -> Option<&[u8]> {
match self.alg {
AsymmetricAlgorithm::EdDsa => None,
AsymmetricAlgorithm::Es256
| AsymmetricAlgorithm::Rs256
| AsymmetricAlgorithm::Rs512 => Some(&self.y),
}
}
/// Reconstructs a verifying key from its JWK parameters.
///
/// * `EdDSA` — `x` is the 32-byte Ed25519 public key; `y` must be `None`.
/// * ES256 — `x` and `y` are the 32-byte P-256 affine coordinates.
///
/// The point is validated (Ed25519 decompression / P-256 on-curve
/// check) so a malformed JWK is rejected at construction rather than at
/// the first verification.
///
/// # Errors
///
/// Returns [`AsymmetricKeyError`] if the coordinates are the wrong
/// length or do not decode to a valid public key.
pub fn from_coordinates(
alg: AsymmetricAlgorithm,
kid: impl Into<String>,
x: &[u8],
y: Option<&[u8]>,
) -> Result<Self, AsymmetricKeyError> {
match alg {
AsymmetricAlgorithm::EdDsa => {
if y.is_some() {
return Err(invalid_key());
}
let bytes: [u8; 32] = x.try_into().map_err(|_| invalid_key())?;
// Decompression validates the point lies on the curve.
ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|_| invalid_key())?;
Ok(Self {
alg,
kid: kid.into(),
x: bytes.to_vec(),
y: Vec::new(),
})
}
AsymmetricAlgorithm::Es256 => {
let y = y.ok_or_else(invalid_key)?;
let xb: [u8; 32] = x.try_into().map_err(|_| invalid_key())?;
let yb: [u8; 32] = y.try_into().map_err(|_| invalid_key())?;
// Validates the point is on the P-256 curve.
p256_verifying_key(&xb, &yb).ok_or_else(invalid_key)?;
Ok(Self {
alg,
kid: kid.into(),
x: xb.to_vec(),
y: yb.to_vec(),
})
}
// RSA keys carry a modulus/exponent pair, not curve coordinates.
// Build them via `from_rsa_components` instead.
AsymmetricAlgorithm::Rs256 | AsymmetricAlgorithm::Rs512 => Err(invalid_key()),
}
}
/// Reconstructs an RSA verifying key from its JWK `n` (modulus) and `e`
/// (public exponent) parameters, both big-endian (RFC 7518 §6.3.1).
///
/// `alg` must be [`Rs256`](AsymmetricAlgorithm::Rs256) or
/// [`Rs512`](AsymmetricAlgorithm::Rs512). The modulus/exponent are
/// validated by constructing the public key, so a malformed JWK is
/// rejected here rather than at the first verification.
///
/// # Errors
///
/// Returns [`AsymmetricKeyError`] if `alg` is not an RSA algorithm or the
/// components do not form a valid RSA public key.
pub fn from_rsa_components(
alg: AsymmetricAlgorithm,
kid: impl Into<String>,
n: &[u8],
e: &[u8],
) -> Result<Self, AsymmetricKeyError> {
if !alg.is_rsa() {
return Err(invalid_key());
}
// Validate the components form a usable RSA public key up front.
rsa_public_key(n, e).ok_or_else(invalid_key)?;
Ok(Self {
alg,
kid: kid.into(),
x: n.to_vec(),
y: e.to_vec(),
})
}
/// Replaces this key's `kid`, consuming and returning `self`.
///
/// Used by JWK parsing to set the thumbprint-default `kid` after the key
/// is built, avoiding a redundant second construction.
#[must_use]
pub(crate) fn with_kid(mut self, kid: String) -> Self {
self.kid = kid;
self
}
/// Verifies a detached signature over `signing_input`.
///
/// `signing_input` is the ASCII `base64url(header).base64url(payload)`
/// (RFC 7515 §5.2 step 8); `signature` is the raw signature bytes
/// (64 bytes for both Ed25519 and the ES256 `r || s` form).
///
/// Returns `true` if and only if the signature is valid. Never panics on
/// malformed input — an unparseable signature is simply invalid.
#[must_use]
pub fn verify(&self, signing_input: &[u8], signature: &[u8]) -> bool {
match self.alg {
AsymmetricAlgorithm::EdDsa => verify_ed25519(&self.x, signing_input, signature),
AsymmetricAlgorithm::Es256 => verify_es256(&self.x, &self.y, signing_input, signature),
AsymmetricAlgorithm::Rs256 => verify_rs256(&self.x, &self.y, signing_input, signature),
AsymmetricAlgorithm::Rs512 => verify_rs512(&self.x, &self.y, signing_input, signature),
}
}
/// Computes the RFC 7638 JWK thumbprint of this public key and returns
/// it base64url-encoded — the canonical, stable `kid` value.
///
/// The thumbprint is the SHA-256 digest of the JWK's required members in
/// lexicographic order with no whitespace (RFC 7638 §3): for `OKP`
/// `{"crv","kty","x"}`, for `EC` `{"crv","kty","x","y"}`, for `RSA`
/// `{"e","kty","n"}`.
#[must_use]
pub fn thumbprint(&self) -> String {
let canonical = match self.alg {
AsymmetricAlgorithm::EdDsa => format!(
r#"{{"crv":"Ed25519","kty":"OKP","x":"{}"}}"#,
base64url_encode(&self.x),
),
AsymmetricAlgorithm::Es256 => format!(
r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
base64url_encode(&self.x),
base64url_encode(&self.y),
),
// RFC 7638 §3.2: RSA required members are `e`, `kty`, `n`.
AsymmetricAlgorithm::Rs256 | AsymmetricAlgorithm::Rs512 => format!(
r#"{{"e":"{}","kty":"RSA","n":"{}"}}"#,
base64url_encode(&self.y),
base64url_encode(&self.x),
),
};
base64url_encode(&Sha256::digest(canonical.as_bytes()))
}
}
// ---------------------------------------------------------------------------
// Signing keypair
// ---------------------------------------------------------------------------
/// An asymmetric signing keypair: the private key (in [`Zeroizing`]) plus the
/// derived public key and `kid`.
///
/// Generate one with [`generate`](Self::generate), or reconstruct a persisted
/// one with [`from_private_bytes`](Self::from_private_bytes). Sign with
/// [`sign`](Self::sign). The public half for JWKS publication is
/// [`verifying_key`](Self::verifying_key).
///
/// # Security
///
/// The private scalar / seed is held in a [`Zeroizing`] buffer cleared on
/// drop. The [`fmt::Debug`] impl deliberately omits it.
pub struct AsymmetricSigningKey {
alg: AsymmetricAlgorithm,
kid: String,
/// Private key material: the 32-byte Ed25519 seed or P-256 scalar.
private: Zeroizing<Vec<u8>>,
/// Cached public half (not secret).
public: AsymmetricVerifyingKey,
}
impl fmt::Debug for AsymmetricSigningKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// SECURITY: never print the private key material.
f.debug_struct("AsymmetricSigningKey")
.field("alg", &self.alg)
.field("kid", &self.kid)
.finish_non_exhaustive()
}
}
impl AsymmetricSigningKey {
/// Generates a fresh keypair for `alg`, seeded from the platform CSPRNG.
///
/// The `kid` is set to the RFC 7638 JWK thumbprint of the public key, a
/// stable identifier derived solely from the public coordinates.
///
/// # Errors
///
/// Returns [`AsymmetricKeyError`] if the platform CSPRNG is unavailable.
pub fn generate(alg: AsymmetricAlgorithm) -> Result<Self, AsymmetricKeyError> {
match alg {
AsymmetricAlgorithm::EdDsa => {
let mut seed = Zeroizing::new(vec![0u8; 32]);
fill_random(&mut seed).map_err(|_| AsymmetricKeyError {
kind: AsymmetricKeyErrorKind::Random,
})?;
// Any 32-byte string is a valid Ed25519 seed.
Self::from_private_bytes(alg, &seed)
}
AsymmetricAlgorithm::Es256 => {
// SECURITY: a P-256 private scalar must be in `[1, n)`.
// `SigningKey::from_slice` rejects out-of-range scalars, so
// we sample-and-retry. With a 256-bit order the rejection
// probability is negligible; the loop terminates promptly.
let mut scalar = Zeroizing::new(vec![0u8; 32]);
for _ in 0..16 {
fill_random(&mut scalar).map_err(|_| AsymmetricKeyError {
kind: AsymmetricKeyErrorKind::Random,
})?;
if let Ok(key) = Self::from_private_bytes(alg, &scalar) {
return Ok(key);
}
}
Err(AsymmetricKeyError {
kind: AsymmetricKeyErrorKind::Random,
})
}
// RS256 / RS512 are verify-only: the crate validates external RSA
// tokens but never mints them (its IdP uses EdDSA / ES256).
AsymmetricAlgorithm::Rs256 | AsymmetricAlgorithm::Rs512 => Err(signing_unsupported()),
}
}
/// Reconstructs a keypair from raw private key bytes.
///
/// * `EdDSA` — a 32-byte Ed25519 seed (any 32 bytes are valid).
/// * ES256 — a 32-byte big-endian P-256 scalar in `[1, n)`.
///
/// The `kid` is the RFC 7638 JWK thumbprint of the derived public key.
///
/// # Errors
///
/// Returns [`AsymmetricKeyError`] if the bytes are the wrong length or
/// (for ES256) not a valid scalar.
pub fn from_private_bytes(
alg: AsymmetricAlgorithm,
private: &[u8],
) -> Result<Self, AsymmetricKeyError> {
match alg {
AsymmetricAlgorithm::EdDsa => {
let seed: [u8; 32] = private.try_into().map_err(|_| invalid_key())?;
let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
let pk = sk.verifying_key().to_bytes();
let public = AsymmetricVerifyingKey {
alg,
kid: String::new(),
x: pk.to_vec(),
y: Vec::new(),
};
Ok(Self::finish(alg, Zeroizing::new(seed.to_vec()), public))
}
AsymmetricAlgorithm::Es256 => {
let sk = p256::ecdsa::SigningKey::from_slice(private).map_err(|_| invalid_key())?;
let (x, y) = p256_public_coordinates(&sk).ok_or_else(invalid_key)?;
let public = AsymmetricVerifyingKey {
alg,
kid: String::new(),
x: x.to_vec(),
y: y.to_vec(),
};
Ok(Self::finish(alg, Zeroizing::new(private.to_vec()), public))
}
// RS256 / RS512 are verify-only (see `generate`).
AsymmetricAlgorithm::Rs256 | AsymmetricAlgorithm::Rs512 => Err(signing_unsupported()),
}
}
/// Completes construction: computes the thumbprint `kid` once and stamps
/// it onto both halves.
fn finish(
alg: AsymmetricAlgorithm,
private: Zeroizing<Vec<u8>>,
mut public: AsymmetricVerifyingKey,
) -> Self {
let kid = public.thumbprint();
public.kid.clone_from(&kid);
Self {
alg,
kid,
private,
public,
}
}
/// Overrides the auto-derived (thumbprint) `kid` with a caller-chosen one.
///
/// Useful when a deployment numbers its keys (`"2026-06"`) rather than
/// using the thumbprint. The public half's `kid` is updated in lockstep.
#[must_use]
pub fn with_kid(mut self, kid: impl Into<String>) -> Self {
let kid = kid.into();
self.kid.clone_from(&kid);
self.public.kid = kid;
self
}
/// Returns the algorithm.
#[must_use]
#[inline]
pub fn algorithm(&self) -> AsymmetricAlgorithm {
self.alg
}
/// Returns the key identifier (`kid`).
#[must_use]
#[inline]
pub fn kid(&self) -> &str {
&self.kid
}
/// Returns the public half for verification or JWKS publication.
#[must_use]
#[inline]
pub fn verifying_key(&self) -> &AsymmetricVerifyingKey {
&self.public
}
/// Returns a clone of the public half (owned, for JWKS assembly).
#[must_use]
#[inline]
pub fn to_verifying_key(&self) -> AsymmetricVerifyingKey {
self.public.clone()
}
/// Exports the raw private key bytes (Ed25519 seed or P-256 scalar) in a
/// [`Zeroizing`] buffer, for the caller to persist (encrypted) and later
/// reload via [`from_private_bytes`](Self::from_private_bytes).
///
/// # Security
///
/// This is secret material. Store it encrypted (e.g. via
/// [`SecretBox`](crate::crypto::SecretBox)); never log it.
#[must_use]
pub fn private_bytes(&self) -> Zeroizing<Vec<u8>> {
Zeroizing::new(self.private.to_vec())
}
/// Signs `signing_input`, returning the raw signature bytes (64 bytes for
/// both algorithms).
///
/// `signing_input` is the ASCII `base64url(header).base64url(payload)`.
///
/// # Panics
///
/// Does not panic in practice: the stored private material was validated
/// at construction (a 32-byte Ed25519 seed or an in-range P-256 scalar),
/// so the internal re-derivation cannot fail.
// NOTE: the typed signing key is re-derived from `self.private` per call
// rather than cached. Caching it would mean holding the secret scalar in a
// second, non-`Zeroizing` representation alongside the carefully-zeroized
// `private` buffer; the derivation is a 32-byte copy (Ed25519) or an
// in-range scalar check (P-256), not a point multiplication, so the cost
// is negligible and signing keys are already cached one level up.
#[must_use = "this returns the signature bytes"]
pub fn sign(&self, signing_input: &[u8]) -> Vec<u8> {
match self.alg {
AsymmetricAlgorithm::EdDsa => {
use ed25519_dalek::Signer as _;
let seed: [u8; 32] = self
.private
.as_slice()
.try_into()
.expect("Ed25519 seed is 32 bytes by construction");
let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
sk.sign(signing_input).to_bytes().to_vec()
}
AsymmetricAlgorithm::Es256 => {
use p256::ecdsa::signature::Signer as _;
let sk = p256::ecdsa::SigningKey::from_slice(&self.private)
.expect("P-256 scalar valid by construction");
let sig: p256::ecdsa::Signature = sk.sign(signing_input);
// Emit canonical low-S signatures (ECDSA's `sign` leaves S
// unnormalized, so ~half are high-S). Low-S is non-malleable
// and matches the high-S rejection in `verify_es256`.
let sig = sig.normalize_s().unwrap_or(sig);
sig.to_bytes().to_vec()
}
// Unreachable: `generate` / `from_private_bytes` reject RSA, so an
// RSA `AsymmetricSigningKey` can never be constructed.
AsymmetricAlgorithm::Rs256 | AsymmetricAlgorithm::Rs512 => {
unreachable!("RSA signing keys cannot be constructed (verify-only)")
}
}
}
}
// ---------------------------------------------------------------------------
// Curve helpers (the only place the dependency types are touched)
// ---------------------------------------------------------------------------
#[inline]
fn invalid_key() -> AsymmetricKeyError {
AsymmetricKeyError {
kind: AsymmetricKeyErrorKind::InvalidKeyMaterial,
}
}
#[inline]
fn signing_unsupported() -> AsymmetricKeyError {
AsymmetricKeyError {
kind: AsymmetricKeyErrorKind::SigningUnsupported,
}
}
/// Minimum accepted RSA modulus size, in bits. RS256/RS512 ID tokens from
/// real OIDC providers use ≥ 2048-bit keys; rejecting anything weaker is
/// defense-in-depth against a misconfigured or hostile JWKS advertising a
/// forgeable small-modulus key (the `rsa` crate itself enforces no such floor).
const MIN_RSA_MODULUS_BITS: usize = 2048;
/// Builds an RSA public key from big-endian modulus / exponent bytes,
/// returning `None` if they do not form a valid key or the modulus is smaller
/// than [`MIN_RSA_MODULUS_BITS`]. The `rsa` crate validates the exponent range.
fn rsa_public_key(n: &[u8], e: &[u8]) -> Option<rsa::RsaPublicKey> {
use rsa::BigUint;
use rsa::traits::PublicKeyParts as _;
let key = rsa::RsaPublicKey::new(BigUint::from_bytes_be(n), BigUint::from_bytes_be(e)).ok()?;
if key.n().bits() < MIN_RSA_MODULUS_BITS {
return None;
}
Some(key)
}
/// RS256 (RSASSA-PKCS1-v1_5 + SHA-256) verify path. `false` on any malformed
/// input (never panics).
fn verify_rs256(n: &[u8], e: &[u8], msg: &[u8], signature: &[u8]) -> bool {
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::signature::Verifier as _;
let Some(pk) = rsa_public_key(n, e) else {
return false;
};
let Ok(sig) = Signature::try_from(signature) else {
return false;
};
VerifyingKey::<sha2::Sha256>::new(pk)
.verify(msg, &sig)
.is_ok()
}
/// RS512 (RSASSA-PKCS1-v1_5 + SHA-512) verify path. `false` on any malformed
/// input (never panics).
fn verify_rs512(n: &[u8], e: &[u8], msg: &[u8], signature: &[u8]) -> bool {
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::signature::Verifier as _;
let Some(pk) = rsa_public_key(n, e) else {
return false;
};
let Ok(sig) = Signature::try_from(signature) else {
return false;
};
VerifyingKey::<sha2::Sha512>::new(pk)
.verify(msg, &sig)
.is_ok()
}
/// Decode the SHA-256-curve-free Ed25519 verify path. `false` on any
/// malformed input (never panics).
fn verify_ed25519(public: &[u8], msg: &[u8], signature: &[u8]) -> bool {
// `verify_strict` is an inherent method on `VerifyingKey`, so the
// `Verifier` trait import is intentionally not needed here.
let Ok(pk_bytes) = <[u8; 32]>::try_from(public) else {
return false;
};
let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes) else {
return false;
};
let Ok(sig_bytes) = <[u8; 64]>::try_from(signature) else {
return false;
};
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
// `verify_strict` (not `verify`): rejects non-canonical signature scalars
// and small-order / mixed-order public keys, giving a strongly-binding
// verification. The permissive `verify` allows malleable encodings, so a
// captured token could be re-serialized into a second valid string —
// breaking any consumer that dedups/replay-tracks on the token bytes.
vk.verify_strict(msg, &sig).is_ok()
}
/// P-256 verify path. `false` on any malformed input (never panics).
fn verify_es256(x: &[u8], y: &[u8], msg: &[u8], signature: &[u8]) -> bool {
use p256::ecdsa::signature::Verifier as _;
let (Ok(xb), Ok(yb)) = (<[u8; 32]>::try_from(x), <[u8; 32]>::try_from(y)) else {
return false;
};
let Some(vk) = p256_verifying_key(&xb, &yb) else {
return false;
};
let Ok(sig) = p256::ecdsa::Signature::from_slice(signature) else {
return false;
};
// Reject high-S (malleable) signatures: ECDSA accepts both `(r, s)` and
// `(r, n−s)`, so without this an observer could mint a second valid token
// string for the same claims (breaking token-bytes dedup/replay tracking).
// `normalize_s` returns `Some` only when the input WAS high-S; our own
// signer (RustCrypto) always emits low-S, so legitimate tokens are
// unaffected. NOTE: this also rejects high-S tokens from non-normalizing
// third-party ES256 signers — acceptable for an IdP, but a consumer
// verifying foreign tokens should be aware.
if sig.normalize_s().is_some() {
return false;
}
vk.verify(msg, &sig).is_ok()
}
/// Builds a P-256 verifying key from affine coordinates, returning `None` if
/// the point is not on the curve.
fn p256_verifying_key(x: &[u8; 32], y: &[u8; 32]) -> Option<p256::ecdsa::VerifyingKey> {
let point = p256::EncodedPoint::from_affine_coordinates(x.into(), y.into(), false);
p256::ecdsa::VerifyingKey::from_encoded_point(&point).ok()
}
/// Extracts the uncompressed affine `(x, y)` coordinates of a P-256
/// signing key's public point.
fn p256_public_coordinates(sk: &p256::ecdsa::SigningKey) -> Option<([u8; 32], [u8; 32])> {
let vk = p256::ecdsa::VerifyingKey::from(sk);
let point = vk.to_encoded_point(false);
let x: [u8; 32] = (*point.x()?).into();
let y: [u8; 32] = (*point.y()?).into();
Some((x, y))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::encoding::base64url_decode;
// --- Algorithm metadata ---
#[test]
fn algorithm_metadata() {
assert_eq!(AsymmetricAlgorithm::EdDsa.as_str(), "EdDSA");
assert_eq!(AsymmetricAlgorithm::EdDsa.key_type(), "OKP");
assert_eq!(AsymmetricAlgorithm::EdDsa.curve(), Some("Ed25519"));
assert!(!AsymmetricAlgorithm::EdDsa.is_rsa());
assert_eq!(AsymmetricAlgorithm::Es256.as_str(), "ES256");
assert_eq!(AsymmetricAlgorithm::Es256.key_type(), "EC");
assert_eq!(AsymmetricAlgorithm::Es256.curve(), Some("P-256"));
assert_eq!(
AsymmetricAlgorithm::EdDsa.to_jwt_algorithm(),
JwtAlgorithm::EdDSA
);
assert_eq!(
AsymmetricAlgorithm::Es256.to_jwt_algorithm(),
JwtAlgorithm::ES256
);
assert_eq!(AsymmetricAlgorithm::Es256.to_string(), "ES256");
// RSA metadata.
assert_eq!(AsymmetricAlgorithm::Rs256.as_str(), "RS256");
assert_eq!(AsymmetricAlgorithm::Rs512.as_str(), "RS512");
assert_eq!(AsymmetricAlgorithm::Rs256.key_type(), "RSA");
assert_eq!(AsymmetricAlgorithm::Rs256.curve(), None);
assert!(AsymmetricAlgorithm::Rs256.is_rsa());
assert!(AsymmetricAlgorithm::Rs512.is_rsa());
assert_eq!(
AsymmetricAlgorithm::Rs256.to_jwt_algorithm(),
JwtAlgorithm::RS256
);
assert_eq!(
AsymmetricAlgorithm::Rs512.to_jwt_algorithm(),
JwtAlgorithm::RS512
);
}
// --- Round-trip sign/verify ---
#[test]
fn ed25519_sign_verify_round_trip() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
let msg = b"header.payload";
let sig = key.sign(msg);
assert_eq!(sig.len(), 64);
assert!(key.verifying_key().verify(msg, &sig));
assert!(!key.verifying_key().verify(b"header.tampered", &sig));
}
#[test]
fn es256_sign_verify_round_trip() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
let msg = b"header.payload";
let sig = key.sign(msg);
assert_eq!(sig.len(), 64);
assert!(key.verifying_key().verify(msg, &sig));
assert!(!key.verifying_key().verify(b"other", &sig));
}
#[test]
fn wrong_key_does_not_verify() {
for alg in [AsymmetricAlgorithm::EdDsa, AsymmetricAlgorithm::Es256] {
let a = AsymmetricSigningKey::generate(alg).unwrap();
let b = AsymmetricSigningKey::generate(alg).unwrap();
let sig = a.sign(b"msg");
assert!(!b.verifying_key().verify(b"msg", &sig));
}
}
#[test]
fn malformed_signature_is_invalid_not_panic() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
assert!(!key.verifying_key().verify(b"msg", b"too-short"));
assert!(!key.verifying_key().verify(b"msg", &[0u8; 64]));
}
// --- Persistence round-trip ---
#[test]
fn private_bytes_round_trip() {
for alg in [AsymmetricAlgorithm::EdDsa, AsymmetricAlgorithm::Es256] {
let key = AsymmetricSigningKey::generate(alg).unwrap();
let raw = key.private_bytes();
assert_eq!(raw.len(), 32);
let restored = AsymmetricSigningKey::from_private_bytes(alg, &raw).unwrap();
assert_eq!(restored.kid(), key.kid());
let sig = restored.sign(b"msg");
assert!(key.verifying_key().verify(b"msg", &sig));
}
}
#[test]
fn coordinate_round_trip() {
for alg in [AsymmetricAlgorithm::EdDsa, AsymmetricAlgorithm::Es256] {
let key = AsymmetricSigningKey::generate(alg).unwrap();
let vk = key.verifying_key();
let rebuilt =
AsymmetricVerifyingKey::from_coordinates(alg, vk.kid(), vk.x_bytes(), vk.y_bytes())
.unwrap();
let sig = key.sign(b"msg");
assert!(rebuilt.verify(b"msg", &sig));
}
}
// --- Error paths ---
#[test]
fn from_private_bytes_wrong_length() {
let err = AsymmetricSigningKey::from_private_bytes(AsymmetricAlgorithm::EdDsa, b"short")
.unwrap_err();
assert!(err.is_invalid_key_material());
}
#[test]
fn es256_rejects_zero_scalar() {
// The all-zero scalar is not in [1, n).
let err = AsymmetricSigningKey::from_private_bytes(AsymmetricAlgorithm::Es256, &[0u8; 32])
.unwrap_err();
assert!(err.is_invalid_key_material());
}
#[test]
fn eddsa_coordinates_reject_y() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
let err = AsymmetricVerifyingKey::from_coordinates(
AsymmetricAlgorithm::EdDsa,
"k",
key.verifying_key().x_bytes(),
Some(&[0u8; 32]),
)
.unwrap_err();
assert!(err.is_invalid_key_material());
}
#[test]
fn es256_off_curve_point_rejected() {
let err = AsymmetricVerifyingKey::from_coordinates(
AsymmetricAlgorithm::Es256,
"k",
&[1u8; 32],
Some(&[1u8; 32]),
)
.unwrap_err();
assert!(err.is_invalid_key_material());
}
// --- Thumbprint / kid ---
#[test]
fn kid_is_thumbprint_by_default() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
assert_eq!(key.kid(), key.verifying_key().thumbprint());
assert!(!key.kid().is_empty());
}
#[test]
fn with_kid_overrides_both_halves() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256)
.unwrap()
.with_kid("2026-q2");
assert_eq!(key.kid(), "2026-q2");
assert_eq!(key.verifying_key().kid(), "2026-q2");
}
#[test]
fn debug_does_not_leak_private_key() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
let raw = key.private_bytes();
let dbg = format!("{key:?}");
// The private seed bytes must not appear in any rendering.
let hex = crate::encoding::hex_encode(&raw);
assert!(!dbg.contains(&hex));
assert!(dbg.contains("AsymmetricSigningKey"));
}
// --- RFC 8037 Appendix A.3 known-answer (Ed25519) ---
#[test]
fn rfc8037_a3_ed25519_signature() {
// RFC 8037 Appendix A.1 private key `d` and A.3 signing input.
let d = base64url_decode("nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A").unwrap();
let key = AsymmetricSigningKey::from_private_bytes(AsymmetricAlgorithm::EdDsa, &d).unwrap();
// A.1 public key `x`.
assert_eq!(
base64url_encode(key.verifying_key().x_bytes()),
"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
);
// A.3: signing input is the ASCII of "eyJhbGciOiJFZERTQSJ9.Example..."
let signing_input = b"eyJhbGciOiJFZERTQSJ9.\
RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc";
let sig = key.sign(signing_input);
// RFC 8037 A.4 expected signature (base64url).
assert_eq!(
base64url_encode(&sig),
"hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg",
);
assert!(key.verifying_key().verify(signing_input, &sig));
}
// --- ES256 determinism note: ECDSA P-256 here is randomised, so we
// verify round-trip rather than a fixed signature vector. ---
#[test]
fn es256_signatures_verify_across_fresh_signings() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
let s1 = key.sign(b"msg");
let s2 = key.sign(b"msg");
// Both verify even though ECDSA is (typically) randomised.
assert!(key.verifying_key().verify(b"msg", &s1));
assert!(key.verifying_key().verify(b"msg", &s2));
}
// --- RSA verification (RS256 / RS512) ---
//
// Known-answer vectors: a 2048-bit RSA key with its public modulus `n`
// (`e` = 65537 = "AQAB"), and two ID-token-shaped JWTs it signed
// (PKCS1-v1_5 over SHA-256 / SHA-512). Generated once with the Python
// `cryptography` library; this crate only verifies. Mirrors how a Google
// ID token arrives at a relying party.
const RSA_N: &str = "l12KvkYdWWq2IwpT4kSOh-eC0kIGQzD4AgRAQ2WZY6-RC0m5X3yolmLIwCzH4CJhq1vm7mhG76RgvXoC2VlP7B2nHlz8-wPhk33Re4ia-Z4J6E_aIFn56Y5t01tv2N52rcijgS1Drvkqo2VvO9HWjBdpKi7cpW-lwG0fPYWQ0ibv1IrALZV66Qkp6QMT_wPtgbEYoeocMkSb7URUQFuFqL4BnucW7s8rQ1bFsw7oZ-_uQx_3JN0d3FQgJC2PUe-k2A7U8srQjKI26y3rKkNOe8n7LMUVFEj1rEbSn_OxEiLfUrjIMIJHikWd1QWH8uIpULs8ExeezpOgaeNQOUvdOw";
const RSA_E: &str = "AQAB";
const RS256_TOKEN: &str = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzYS10ZXN0LTEifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoia29uc29sZS1jbGllbnQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTcwMDAwMDAwMCwiZW1haWwiOiJ1c2VyQGVudHJvcHlzb2Z0d29ya3MuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJUZXN0IFVzZXIifQ.GSt8PH2tF-Yd-O9qLZGXgMgXX8NISLN3svmYC245mACNYnHCPm5ottQMsVgXl5ux3BbMrWG1LeX4hLES9Ip7djmDJBuSsmT6XMKGLuOre5GokgFCLCXFsAwz_F3GSSSOKcRRb13-nuBdPnG919SYbS0E0mvD0eSzbmWHdUci5br8QsZQWwJryf9u0RGx3ZdMer2BXT0Qj4bJI1D4s1CK4T7z7jxk1PHeXvZ7w0GOdutJ5VG2jfgPjqfq07RxyBvSRJ_j549t30pmVMite6vnqbvv9673wX_-pN3VLwqR1QXRgaeyZYN3LoUCS1bmBw5kaw6tVCBGPADUz6VRvyUgiA";
const RS512_TOKEN: &str = "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6InJzYS10ZXN0LTEifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoia29uc29sZS1jbGllbnQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTcwMDAwMDAwMCwiZW1haWwiOiJ1c2VyQGVudHJvcHlzb2Z0d29ya3MuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJUZXN0IFVzZXIifQ.TyqxMJz0L-fopNBTKta7fTzv7EF8d8L-FVpd4aRhmqKgBWW4p9I3tbcd15icbMT20eyIr5QhQN1UQs2Id5LPRgGje-60Wo90skEYYnBYrllH7qjdGEItKtwAHZ8Q6mDdHGuwtIZyyoM0izfhGenv4EJXm7c6MI35xmbszTG858xYvPBuHpiX9MdQg1vRDorki5VMTpNyBKdYUBq0Se0Uajlik2XcAJM0dINKvgAbJTmx6sevHkYIlLEy1PbBVND9AJoexY9OZIJKmZAiddj3x7LL5gU9NHLJIs7kTYa32neUgeCGFqHvhpeFTR6Fp_UVMulA9FW7RNTTK_lws3yhgQ";
fn rsa_test_key(alg: AsymmetricAlgorithm) -> AsymmetricVerifyingKey {
let n = base64url_decode(RSA_N).unwrap();
let e = base64url_decode(RSA_E).unwrap();
AsymmetricVerifyingKey::from_rsa_components(alg, "rsa-test-1", &n, &e).unwrap()
}
/// Splits a compact JWT into `(signing_input, raw_signature)`.
fn split_for_verify(token: &str) -> (Vec<u8>, Vec<u8>) {
let dot = token.rfind('.').unwrap();
let signing_input = token.as_bytes()[..dot].to_vec();
let sig = base64url_decode(&token[dot + 1..]).unwrap();
(signing_input, sig)
}
#[test]
fn rs256_known_answer_verifies() {
let key = rsa_test_key(AsymmetricAlgorithm::Rs256);
assert_eq!(key.algorithm(), AsymmetricAlgorithm::Rs256);
let (input, sig) = split_for_verify(RS256_TOKEN);
assert!(key.verify(&input, &sig));
}
#[test]
fn rs512_known_answer_verifies() {
let key = rsa_test_key(AsymmetricAlgorithm::Rs512);
let (input, sig) = split_for_verify(RS512_TOKEN);
assert!(key.verify(&input, &sig));
}
#[test]
fn rsa_rejects_tampered_payload_and_wrong_alg() {
let key = rsa_test_key(AsymmetricAlgorithm::Rs256);
let (mut input, sig) = split_for_verify(RS256_TOKEN);
// Flip a payload byte: signature must no longer verify.
*input.last_mut().unwrap() ^= 0x01;
assert!(!key.verify(&input, &sig));
// The RS256 token must not verify under an RS512 key (wrong hash).
let key512 = rsa_test_key(AsymmetricAlgorithm::Rs512);
let (input, sig) = split_for_verify(RS256_TOKEN);
assert!(!key512.verify(&input, &sig));
}
#[test]
fn rsa_malformed_signature_is_invalid_not_panic() {
let key = rsa_test_key(AsymmetricAlgorithm::Rs256);
assert!(!key.verify(b"msg", b"too-short"));
assert!(!key.verify(b"msg", &[0u8; 256]));
}
#[test]
fn from_rsa_components_rejects_non_rsa_alg() {
let err = AsymmetricVerifyingKey::from_rsa_components(
AsymmetricAlgorithm::EdDsa,
"k",
&[0u8; 32],
b"\x01\x00\x01",
)
.unwrap_err();
assert!(err.is_invalid_key_material());
}
#[test]
fn from_rsa_components_rejects_weak_modulus() {
// A 512-bit (64-byte) modulus is below the 2048-bit floor: even if the
// `rsa` crate would construct it, a relying party must not accept a
// forgeable small-modulus key from a JWKS.
let weak_n = [0xFFu8; 64]; // 512 bits, odd
let err = AsymmetricVerifyingKey::from_rsa_components(
AsymmetricAlgorithm::Rs256,
"weak",
&weak_n,
b"\x01\x00\x01",
)
.unwrap_err();
assert!(err.is_invalid_key_material());
}
#[test]
fn from_coordinates_rejects_rsa_alg() {
let err = AsymmetricVerifyingKey::from_coordinates(
AsymmetricAlgorithm::Rs256,
"k",
&[1u8; 256],
None,
)
.unwrap_err();
assert!(err.is_invalid_key_material());
}
#[test]
fn rsa_signing_is_unsupported() {
for alg in [AsymmetricAlgorithm::Rs256, AsymmetricAlgorithm::Rs512] {
let gen_err = AsymmetricSigningKey::generate(alg).unwrap_err();
assert!(gen_err.is_signing_unsupported());
let load_err = AsymmetricSigningKey::from_private_bytes(alg, &[0u8; 32]).unwrap_err();
assert!(load_err.is_signing_unsupported());
}
}
#[test]
fn rsa_verifying_key_exposes_n_and_e() {
let vk = rsa_test_key(AsymmetricAlgorithm::Rs256);
assert_eq!(base64url_encode(vk.x_bytes()), RSA_N);
assert_eq!(base64url_encode(vk.y_bytes().unwrap()), RSA_E);
// RSA thumbprint uses RFC 7638 members {e, kty, n} and is stable.
assert!(!vk.thumbprint().is_empty());
}
}