dpop_verifier/
jwk.rs

1use crate::DpopError;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
3use base64::Engine;
4#[cfg(feature = "eddsa")]
5use ed25519_dalek::VerifyingKey as Ed25519VerifyingKey;
6use p256::ecdsa::VerifyingKey;
7use p256::{EncodedPoint, FieldBytes};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10
11// Constants for key sizes
12const P256_COORDINATE_LENGTH: usize = 32;
13#[cfg(feature = "eddsa")]
14const ED25519_KEY_LENGTH: usize = 32;
15
16/// Build a P-256 verifying key from JWK x/y (base64url, no padding).
17pub fn verifying_key_from_p256_xy(x_b64: &str, y_b64: &str) -> Result<VerifyingKey, DpopError> {
18    let x_coordinate = B64
19        .decode(x_b64)
20        .map_err(|_| DpopError::BadJwk("bad jwk.x"))?;
21    let y_coordinate = B64
22        .decode(y_b64)
23        .map_err(|_| DpopError::BadJwk("bad jwk.y"))?;
24
25    if x_coordinate.len() != P256_COORDINATE_LENGTH || y_coordinate.len() != P256_COORDINATE_LENGTH
26    {
27        return Err(DpopError::BadJwk("jwk x/y must be 32 bytes"));
28    }
29
30    let encoded_point = EncodedPoint::from_affine_coordinates(
31        FieldBytes::from_slice(&x_coordinate),
32        FieldBytes::from_slice(&y_coordinate),
33        /* compress = */ false,
34    );
35
36    VerifyingKey::from_encoded_point(&encoded_point)
37        .map_err(|_| DpopError::BadJwk("invalid EC point"))
38}
39
40pub fn thumbprint_ec_p256(x_b64: &str, y_b64: &str) -> Result<String, DpopError> {
41    let mut canonical_jwk_map = BTreeMap::new();
42    canonical_jwk_map.insert("crv", "P-256");
43    canonical_jwk_map.insert("kty", "EC");
44    canonical_jwk_map.insert("x", x_b64);
45    canonical_jwk_map.insert("y", y_b64);
46    let canonical_json =
47        serde_json::to_string(&canonical_jwk_map).map_err(|_| DpopError::BadJwk("canonicalize"))?;
48    Ok(B64.encode(Sha256::digest(canonical_json.as_bytes())))
49}
50
51#[cfg(feature = "eddsa")]
52pub fn verifying_key_from_okp_ed25519(
53    x_b64: &str,
54) -> Result<Ed25519VerifyingKey, crate::DpopError> {
55    let key_bytes_vec = B64
56        .decode(x_b64)
57        .map_err(|_| crate::DpopError::BadJwk("bad jwk.x"))?;
58
59    if key_bytes_vec.len() != ED25519_KEY_LENGTH {
60        return Err(crate::DpopError::BadJwk("jwk x must be 32 bytes"));
61    }
62
63    let key_bytes: [u8; ED25519_KEY_LENGTH] = key_bytes_vec
64        .try_into()
65        .map_err(|_| crate::DpopError::BadJwk("invalid key length"))?;
66
67    Ed25519VerifyingKey::from_bytes(&key_bytes)
68        .map_err(|_| crate::DpopError::BadJwk("invalid Ed25519 key"))
69}
70
71#[cfg(feature = "eddsa")]
72pub fn thumbprint_okp_ed25519(x_b64: &str) -> Result<String, crate::DpopError> {
73    // RFC 7638 canonical JWK members (sorted): kty, crv, x
74    let mut canonical_jwk_map = BTreeMap::new();
75    canonical_jwk_map.insert("crv", "Ed25519");
76    canonical_jwk_map.insert("kty", "OKP");
77    canonical_jwk_map.insert("x", x_b64);
78    let canonical_json = serde_json::to_string(&canonical_jwk_map)
79        .map_err(|_| crate::DpopError::BadJwk("canonicalize"))?;
80    Ok(B64.encode(Sha256::digest(canonical_json.as_bytes())))
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use p256::ecdsa::SigningKey;
87    use rand_core::OsRng;
88
89    // --- verifying_key_from_p256_xy -------------------------------------------
90
91    #[test]
92    fn vk_from_xy_roundtrip_valid() {
93        // Generate a real keypair, extract x/y, and round-trip through the helper.
94        let signing_key = SigningKey::random(&mut OsRng);
95        let verifying_key = VerifyingKey::from(&signing_key);
96        let encoded_point = verifying_key.to_encoded_point(false);
97        let x_b64 = B64.encode(encoded_point.x().expect("x"));
98        let y_b64 = B64.encode(encoded_point.y().expect("y"));
99
100        let reconstructed = verifying_key_from_p256_xy(&x_b64, &y_b64).expect("verifying key");
101        assert_eq!(reconstructed.to_encoded_point(false), encoded_point);
102    }
103
104    #[test]
105    fn vk_rejects_bad_base64_inputs() {
106        // Invalid URL-safe base64 (has padding/invalid chars)
107        let good32 = B64.encode([0u8; 32]);
108        assert!(
109            verifying_key_from_p256_xy("AA==", &good32).is_err(),
110            "padded x should fail"
111        );
112        assert!(
113            verifying_key_from_p256_xy(&good32, "A*").is_err(),
114            "invalid char in y should fail"
115        );
116    }
117
118    #[test]
119    fn vk_rejects_wrong_lengths() {
120        let x31 = B64.encode([0u8; 31]);
121        let x32 = B64.encode([0u8; 32]);
122        let y33 = B64.encode([0u8; 33]);
123        assert!(
124            verifying_key_from_p256_xy(&x31, &x32).is_err(),
125            "31-byte x must fail"
126        );
127        assert!(
128            verifying_key_from_p256_xy(&x32, &y33).is_err(),
129            "33-byte y must fail"
130        );
131    }
132
133    #[test]
134    fn vk_rejects_invalid_point() {
135        // (0,0) is not a valid P-256 point
136        let zeros32 = B64.encode([0u8; 32]);
137        let err = verifying_key_from_p256_xy(&zeros32, &zeros32).unwrap_err();
138        matches!(err, DpopError::BadJwk(_));
139    }
140
141    // --- thumbprint_ec_p256 ----------------------------------------------------
142
143    #[test]
144    fn thumbprint_has_length_43_and_no_padding() {
145        // Any 32-byte x/y produce SHA-256 -> 32 bytes -> base64url length 43, no '=' padding
146        let x = B64.encode([0u8; 32]);
147        let y = B64.encode([1u8; 32]);
148        let t = thumbprint_ec_p256(&x, &y).expect("thumbprint");
149        assert_eq!(t.len(), 43);
150        assert!(!t.contains('='));
151    }
152
153    #[test]
154    fn thumbprint_is_deterministic() {
155        let x = B64.encode([42u8; 32]);
156        let y = B64.encode([99u8; 32]);
157        let t1 = thumbprint_ec_p256(&x, &y).unwrap();
158        let t2 = thumbprint_ec_p256(&x, &y).unwrap();
159        assert_eq!(t1, t2);
160    }
161
162    #[test]
163    fn thumbprint_changes_when_xy_changes() {
164        let x = B64.encode([7u8; 32]);
165        let mut x2_bytes = [7u8; 32];
166        x2_bytes[0] ^= 0x01; // flip a bit
167        let x2 = B64.encode(x2_bytes);
168        let y = B64.encode([9u8; 32]);
169
170        let t1 = thumbprint_ec_p256(&x, &y).unwrap();
171        let t2 = thumbprint_ec_p256(&x2, &y).unwrap();
172        assert_ne!(t1, t2);
173    }
174
175    #[test]
176    fn thumbprint_canonicalization_order_is_fixed() {
177        // Verify canonical JWK JSON (crv,kty,x,y in sorted key order) produces a fixed digest.
178        // Since the function uses BTreeMap, insertion order shouldn't matter to the result.
179        let x_coordinate = B64.encode([0xAB; 32]);
180        let y_coordinate = B64.encode([0xCD; 32]);
181
182        // Compute via function
183        let thumbprint_from_func = thumbprint_ec_p256(&x_coordinate, &y_coordinate).unwrap();
184
185        // Recompute manually to assert intent: JSON of sorted keys -> sha256 -> base64url (no pad)
186        let mut canonical_jwk_map = BTreeMap::new();
187        canonical_jwk_map.insert("crv", "P-256");
188        canonical_jwk_map.insert("kty", "EC");
189        canonical_jwk_map.insert("x", x_coordinate.as_str());
190        canonical_jwk_map.insert("y", y_coordinate.as_str());
191        let canonical_json = serde_json::to_string(&canonical_jwk_map).unwrap();
192        let thumbprint_manual = B64.encode(Sha256::digest(canonical_json.as_bytes()));
193        assert_eq!(thumbprint_from_func, thumbprint_manual);
194    }
195}