use sha2::{Digest, Sha256, Sha384, Sha512};
use zeroize::Zeroizing;
use super::Jwk;
use crate::error::{Error, Result};
use crate::json::Object;
use crate::util::b64url_encode;
const URI_PREFIX: &str = "urn:ietf:params:oauth:jwk-thumbprint:";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ThumbprintHash {
#[default]
Sha256,
Sha384,
Sha512,
}
impl ThumbprintHash {
pub fn from_name(name: &str) -> Result<ThumbprintHash> {
match name {
"sha-256" | "SHA-256" | "sha256" => Ok(ThumbprintHash::Sha256),
"sha-384" | "SHA-384" | "sha384" => Ok(ThumbprintHash::Sha384),
"sha-512" | "SHA-512" | "sha512" => Ok(ThumbprintHash::Sha512),
_ => Err(Error::UnsupportedAlgorithm),
}
}
pub fn hash_name(self) -> &'static str {
match self {
ThumbprintHash::Sha256 => "sha-256",
ThumbprintHash::Sha384 => "sha-384",
ThumbprintHash::Sha512 => "sha-512",
}
}
}
impl Jwk {
pub fn thumbprint(&self, hash: ThumbprintHash) -> String {
let input = self.canonical_input();
match hash {
ThumbprintHash::Sha256 => digest_b64url::<Sha256>(input.as_bytes()),
ThumbprintHash::Sha384 => digest_b64url::<Sha384>(input.as_bytes()),
ThumbprintHash::Sha512 => digest_b64url::<Sha512>(input.as_bytes()),
}
}
pub fn thumbprint_uri(&self, hash: ThumbprintHash) -> String {
let name = hash.hash_name();
let thumbprint = self.thumbprint(hash);
let mut uri = String::with_capacity(URI_PREFIX.len() + name.len() + 1 + thumbprint.len());
uri.push_str(URI_PREFIX);
uri.push_str(name);
uri.push(':');
uri.push_str(&thumbprint);
uri
}
pub fn thumbprint_sha256(&self) -> String {
self.thumbprint(ThumbprintHash::Sha256)
}
pub fn thumbprint_uri_sha256(&self) -> String {
self.thumbprint_uri(ThumbprintHash::Sha256)
}
fn canonical_input(&self) -> Zeroizing<String> {
canonical_json(self.thumbprint_members())
}
}
fn canonical_json(mut members: Object) -> Zeroizing<String> {
members.sort_members();
Zeroizing::new(members.to_json())
}
fn digest_b64url<D: Digest>(input: &[u8]) -> String {
b64url_encode(&D::digest(input))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json;
const RFC7638_RSA: &str = r#"{"kty":"RSA",
"n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e":"AQAB","alg":"RS256","kid":"2011-04-29"}"#;
const RFC7638_RSA_INPUT: &str = r#"{"e":"AQAB","kty":"RSA","n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw"}"#;
const RFC8037_ED25519_PRIVATE: &str = r#"{"kty":"OKP","crv":"Ed25519",
"d":"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A",
"x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}"#;
const RFC8037_ED25519_INPUT: &str =
r#"{"crv":"Ed25519","kty":"OKP","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}"#;
fn jwk(json: &str) -> Jwk {
Jwk::from_json(json.as_bytes()).expect("test key parses")
}
#[test]
fn rfc7638_3_1_canonical_input() {
assert_eq!(
jwk(RFC7638_RSA).canonical_input().as_str(),
RFC7638_RSA_INPUT
);
}
#[test]
fn rfc8037_a3_canonical_input() {
assert_eq!(
jwk(RFC8037_ED25519_PRIVATE).canonical_input().as_str(),
RFC8037_ED25519_INPUT
);
}
#[test]
fn rfc7638_3_1_sha256_thumbprint() {
assert_eq!(
jwk(RFC7638_RSA).thumbprint_sha256(),
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
);
}
#[test]
fn rfc8037_a3_sha256_thumbprint() {
assert_eq!(
jwk(RFC8037_ED25519_PRIVATE).thumbprint_sha256(),
"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"
);
}
#[test]
fn rfc9278_6_example_uri() {
assert_eq!(
jwk(RFC7638_RSA).thumbprint_uri_sha256(),
"urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
);
}
#[test]
fn uri_is_the_prefix_hash_name_and_thumbprint() {
let key = jwk(RFC8037_ED25519_PRIVATE);
let uri = key.thumbprint_uri_sha256();
let thumbprint = uri
.strip_prefix("urn:ietf:params:oauth:jwk-thumbprint:sha-256:")
.expect("RFC 9278 §3 prefix and §4 hash name");
assert_eq!(thumbprint, key.thumbprint_sha256());
}
const RFC7517_EC_PUBLIC: &str = r#"{"kty":"EC","crv":"P-256",
"x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
"use":"enc","kid":"1"}"#;
const RFC7517_EC_PRIVATE: &str = r#"{"kty":"EC","crv":"P-256",
"x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
"d":"870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE",
"use":"enc","kid":"1"}"#;
const RFC7520_P521_PUBLIC: &str = r#"{"kty":"EC",
"kid":"bilbo.baggins@hobbiton.example","use":"sig","crv":"P-521",
"x":"AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt",
"y":"AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1"}"#;
const RFC7520_P521_PRIVATE: &str = r#"{"kty":"EC",
"kid":"bilbo.baggins@hobbiton.example","use":"sig","crv":"P-521",
"x":"AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt",
"y":"AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1",
"d":"AAhRON2r9cqXX1hg-RoI6R1tX5p2rUAYdmpHZoC1XNM56KtscrX6zbKipQrCW9CGZH3T4ubpnoTKLDYJ_fF3_rJt"}"#;
const RFC8037_ED25519_PUBLIC: &str = r#"{"kty":"OKP","crv":"Ed25519",
"x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}"#;
const RFC8037_X25519_PUBLIC: &str =
r#"{"kty":"OKP","crv":"X25519","x":"3p7bfXt9wbTTW2HC7OQ1Nz-DQ8hbeGdNrfx-FG-IK08"}"#;
const RFC7517_OCT_AES: &str = r#"{"kty":"oct","alg":"A128KW","k":"GawgguFyGrWKav7AX4VKUg"}"#;
const RFC7517_OCT_HMAC: &str = r#"{"kty":"oct",
"k":"AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow",
"kid":"HMAC key used in JWS spec Appendix A.1 example"}"#;
#[test]
fn every_key_type_has_its_rfc7638_thumbprint() {
let cases = [
(RFC7638_RSA, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"),
(
RFC7517_EC_PUBLIC,
"cn-I_WNMClehiVp51i_0VpOENW1upEerA8sEam5hn-s",
),
(
RFC7520_P521_PUBLIC,
"dHri3SADZkrush5HU_50AoRhcKFryN-PI6jPBtPL55M",
),
(
RFC8037_ED25519_PUBLIC,
"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k",
),
(
RFC8037_X25519_PUBLIC,
"giQqigT_IKcuzHl0FVJ3k5ts3_TWNAxvsC08UZsfcM8",
),
(
RFC7517_OCT_AES,
"k1JnWRfC-5zzmL72vXIuBgTLfVROXBakS4OmGcrMCoc",
),
(
RFC7517_OCT_HMAC,
"y_x3gCJnL6oKGBBIXScabduwxTVy2Wd2bzRVEUbdUzc",
),
];
for (json, expected) in cases {
let key = jwk(json);
let thumbprint = key.thumbprint_sha256();
assert_eq!(thumbprint, expected, "{json}");
assert_eq!(thumbprint.len(), 43);
assert_eq!(
key.thumbprint_uri_sha256(),
format!("{URI_PREFIX}sha-256:{expected}")
);
}
}
#[test]
fn canonical_input_holds_only_the_required_members_in_order() {
assert_eq!(
jwk(RFC7517_EC_PRIVATE).canonical_input().as_str(),
r#"{"crv":"P-256","kty":"EC","x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4","y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"}"#
);
assert_eq!(
jwk(RFC7517_OCT_AES).canonical_input().as_str(),
r#"{"k":"GawgguFyGrWKav7AX4VKUg","kty":"oct"}"#
);
}
#[test]
fn canonical_json_orders_members_by_code_point() {
let mut members = json::Object::new();
members.insert("n", "nnn");
members.insert("kty", "RSA");
members.insert("e", "AQAB");
assert_eq!(
canonical_json(members).as_str(),
r#"{"e":"AQAB","kty":"RSA","n":"nnn"}"#
);
let mut members = json::Object::new();
members.insert("\u{10000}", "1");
members.insert("\u{E000}", "2");
members.insert("a", "3");
members.insert("Z", "4");
assert_eq!(
canonical_json(members).as_str(),
"{\"Z\":\"4\",\"a\":\"3\",\"\u{E000}\":\"2\",\"\u{10000}\":\"1\"}"
);
}
#[test]
fn oct_canonical_input_is_a_zeroizing_buffer() {
let input: Zeroizing<String> = jwk(RFC7517_OCT_HMAC).canonical_input();
assert!(input.starts_with(r#"{"k":"AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ"#));
}
#[test]
fn private_key_has_the_thumbprint_of_its_public_key() {
let pairs = [
(RFC7517_EC_PRIVATE, RFC7517_EC_PUBLIC),
(RFC7520_P521_PRIVATE, RFC7520_P521_PUBLIC),
(RFC8037_ED25519_PRIVATE, RFC8037_ED25519_PUBLIC),
];
for (private, public) in pairs {
let private = jwk(private);
let public = jwk(public);
assert!(!private.is_public() && public.is_public());
assert_eq!(private.thumbprint_sha256(), public.thumbprint_sha256());
assert_eq!(
private.thumbprint_uri_sha256(),
public.thumbprint_uri_sha256()
);
let derived = private.to_public().expect("EC and OKP have a public part");
assert_eq!(derived.thumbprint_sha256(), public.thumbprint_sha256());
}
}
#[test]
fn optional_and_unknown_members_do_not_change_the_thumbprint() {
let n = jwk(RFC7638_RSA);
let n = n.thumbprint_members();
let n = n.get("n").and_then(json::Value::as_str).expect("RSA n");
let variants = [
format!(r#"{{"n":"{n}","e":"AQAB","kty":"RSA"}}"#),
format!(
r#"{{"e":"AQAB","use":"sig","key_ops":["verify"],"kid":"other",
"x5t#S256":"abc","ext":{{"nested":[1,2,"é"]}},"n":"{n}",
"kty":"RSA","alg":"PS256"}}"#
),
];
for json in &variants {
assert_eq!(
jwk(json).thumbprint_sha256(),
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
"{json}"
);
}
}
#[test]
fn a_different_key_has_a_different_thumbprint() {
let alice = jwk(
r#"{"kty":"OKP","crv":"X25519","x":"hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo"}"#,
);
let bob = jwk(RFC8037_X25519_PUBLIC);
assert_ne!(alice.thumbprint_sha256(), bob.thumbprint_sha256());
let as_x25519 = jwk(
r#"{"kty":"OKP","crv":"X25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}"#,
);
let as_ed25519 = jwk(RFC8037_ED25519_PUBLIC);
assert_ne!(
as_ed25519.thumbprint_sha256(),
as_x25519.thumbprint_sha256()
);
}
#[test]
fn hash_names_are_the_iana_hash_name_strings() {
assert_eq!(ThumbprintHash::Sha256.hash_name(), "sha-256");
assert_eq!(ThumbprintHash::Sha384.hash_name(), "sha-384");
assert_eq!(ThumbprintHash::Sha512.hash_name(), "sha-512");
assert_eq!(ThumbprintHash::default(), ThumbprintHash::Sha256);
}
#[test]
fn from_name_accepts_exactly_the_nine_spellings() {
let cases = [
("sha-256", ThumbprintHash::Sha256),
("SHA-256", ThumbprintHash::Sha256),
("sha256", ThumbprintHash::Sha256),
("sha-384", ThumbprintHash::Sha384),
("SHA-384", ThumbprintHash::Sha384),
("sha384", ThumbprintHash::Sha384),
("sha-512", ThumbprintHash::Sha512),
("SHA-512", ThumbprintHash::Sha512),
("sha512", ThumbprintHash::Sha512),
];
for (name, hash) in cases {
assert_eq!(ThumbprintHash::from_name(name), Ok(hash), "{name}");
}
for (_, hash) in cases {
assert_eq!(ThumbprintHash::from_name(hash.hash_name()), Ok(hash));
}
}
#[test]
fn from_name_rejects_every_other_string() {
for name in [
"",
"SHA-1",
"sha-1",
"sha1",
"sha-256-128",
"sha3-256",
"Sha-256",
"SHA256",
"sha-256 ",
" sha-256",
"sha_256",
"SHA-384 ",
"Sha512",
"sha-224",
"sha-512/256",
] {
assert_eq!(
ThumbprintHash::from_name(name),
Err(crate::Error::UnsupportedAlgorithm),
"{name:?}"
);
}
}
#[test]
fn sha256_methods_delegate_to_the_general_ones() {
for json in [RFC7638_RSA, RFC8037_ED25519_PRIVATE, RFC7517_OCT_AES] {
let key = jwk(json);
assert_eq!(
key.thumbprint_sha256(),
key.thumbprint(ThumbprintHash::Sha256)
);
assert_eq!(
key.thumbprint_uri_sha256(),
key.thumbprint_uri(ThumbprintHash::Sha256)
);
}
}
mod properties {
use proptest::prelude::*;
use super::*;
use crate::json::{Object, Value};
fn member_value() -> impl Strategy<Value = Value> {
prop_oneof![
any::<String>().prop_map(Value::from),
any::<bool>().prop_map(Value::from),
Just(Value::Null),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn extra_members_never_change_the_thumbprint(
kid in any::<String>(),
extra in proptest::collection::vec(("[a-z]{1,8}", member_value()), 0..6),
kid_first in any::<bool>(),
) {
for json in [RFC7517_EC_PRIVATE, RFC8037_ED25519_PRIVATE, RFC7517_OCT_HMAC] {
let base = jwk(json);
let mut object = Object::new();
if kid_first {
object.insert("kid", kid.as_str());
}
for (name, value) in base.thumbprint_members().iter() {
object.insert(name, value.clone());
}
if !kid_first {
object.insert("kid", kid.as_str());
}
for (name, value) in &extra {
object.insert(format!("ext_{name}"), value.clone());
}
let key = Jwk::from_object(&object).expect("still a valid key");
prop_assert_eq!(key.thumbprint_sha256(), base.thumbprint_sha256());
}
}
}
}
}