use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Jwk {
Rsa {
e: String,
n: String,
},
Ec {
crv: String,
x: String,
y: String,
},
}
fn canonical_rsa_json(e: &str, n: &str) -> String {
format!(r#"{{"e":"{}","kty":"RSA","n":"{}"}}"#, e, n)
}
fn canonical_ec_json(crv: &str, x: &str, y: &str) -> String {
format!(r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, crv, x, y)
}
pub fn jwk_thumbprint(jwk: &Jwk) -> String {
let canonical = match jwk {
Jwk::Rsa { e, n } => canonical_rsa_json(e, n),
Jwk::Ec { crv, x, y } => canonical_ec_json(crv, x, y),
};
let hash = Sha256::digest(canonical.as_bytes());
URL_SAFE_NO_PAD.encode(hash.as_ref() as &[u8])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jwk_thumbprint_rsa_rfc7638_appendix_a1() {
let e = "AQAB";
let n = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw";
let jwk = Jwk::Rsa {
e: e.to_string(),
n: n.to_string(),
};
let thumbprint = jwk_thumbprint(&jwk);
assert_eq!(
thumbprint, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
"RFC 7638 §3.1 test vector mismatch — check field order in canonical JSON"
);
}
#[test]
fn jwk_thumbprint_canonical_ordering() {
let e = "AQAB";
let n = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw";
let jwk_a = Jwk::Rsa {
e: e.to_string(),
n: n.to_string(),
};
let jwk_b = Jwk::Rsa {
e: e.to_string(),
n: n.to_string(),
};
assert_eq!(
jwk_thumbprint(&jwk_a),
jwk_thumbprint(&jwk_b),
"Identical JWKs must produce identical thumbprints"
);
let canonical = canonical_rsa_json(e, n);
let e_pos = canonical.find("\"e\"").expect("e member missing");
let kty_pos = canonical.find("\"kty\"").expect("kty member missing");
let n_pos = canonical.find("\"n\"").expect("n member missing");
assert!(
e_pos < kty_pos && kty_pos < n_pos,
"Canonical RSA JSON must order members e < kty < n; got positions {e_pos}, {kty_pos}, {n_pos}"
);
}
#[test]
fn jwk_thumbprint_no_padding() {
let jwk_rsa = Jwk::Rsa {
e: "AQAB".to_string(),
n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw".to_string(),
};
let jwk_ec = Jwk::Ec {
crv: "P-256".to_string(),
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU".to_string(),
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0".to_string(),
};
let tp_rsa = jwk_thumbprint(&jwk_rsa);
let tp_ec = jwk_thumbprint(&jwk_ec);
assert!(
!tp_rsa.contains('='),
"RSA thumbprint must not contain padding: {tp_rsa}"
);
assert!(
!tp_ec.contains('='),
"EC thumbprint must not contain padding: {tp_ec}"
);
}
#[test]
fn jwk_thumbprint_ec_p256() {
let jwk = Jwk::Ec {
crv: "P-256".to_string(),
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU".to_string(),
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0".to_string(),
};
let thumbprint = jwk_thumbprint(&jwk);
assert_eq!(
thumbprint.len(),
43,
"EC thumbprint must be 43 characters (32 bytes SHA-256, base64url no pad); got: {thumbprint}"
);
assert!(
thumbprint
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"EC thumbprint must contain only URL-safe base64 chars: {thumbprint}"
);
assert!(
!thumbprint.contains('='),
"EC thumbprint must have no padding: {thumbprint}"
);
let canonical = canonical_ec_json("P-256", &jwk.x_coord(), &jwk.y_coord());
let crv_pos = canonical.find("\"crv\"").expect("crv member missing");
let kty_pos = canonical.find("\"kty\"").expect("kty member missing");
let x_pos = canonical.find("\"x\"").expect("x member missing");
let y_pos = canonical.find("\"y\"").expect("y member missing");
assert!(
crv_pos < kty_pos && kty_pos < x_pos && x_pos < y_pos,
"Canonical EC JSON must order crv < kty < x < y"
);
}
impl Jwk {
fn x_coord(&self) -> String {
match self {
Jwk::Ec { x, .. } => x.clone(),
_ => panic!("not an EC key"),
}
}
fn y_coord(&self) -> String {
match self {
Jwk::Ec { y, .. } => y.clone(),
_ => panic!("not an EC key"),
}
}
}
}