Skip to main content

pic_continuity/
jwk.rs

1/*
2 * Copyright Nitro Agility S.r.l.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Verification keys from published JWKs (RFC 7517).
18//!
19//! Every party that checks a PIC artifact starts from a key someone published: a relying party
20//! reading a realm's `jwks_uri`, a workload verifying the checkpoint it was handed, a settlement
21//! authority reading `cnf.jwk` out of a Proof of Relationship. Without this they each write the
22//! same JWK reader, and each one is a place to get the algorithm agreement wrong.
23//!
24//! # Algorithm agreement
25//!
26//! A key can produce exactly one signature algorithm, and [`expected_algorithms_for_jwk`] says
27//! which. That is what stops an artifact from *choosing* how it is verified: a candidate claiming
28//! `alg` that its key cannot produce, or a JWK whose declared `alg` disagrees with its own key
29//! material, is rejected before a signature is checked rather than after.
30//!
31//! # What is not here
32//!
33//! RSA. It is the shape identity providers publish, and an OAuth access token is not a PIC
34//! artifact — a deployment that exchanges one reads it with its own code, and this crate stays
35//! about the artifacts the profile defines.
36//!
37//! The curve implementations follow the crate's feature flags, so a build that enables none still
38//! compiles and every reader here answers "unsupported".
39
40use serde_json::Value;
41
42use crate::cose::SigningAlgorithm;
43use crate::trust::ArtifactVerifier;
44
45/// Why a JWK could not become a verification key.
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum JwkError {
48    /// The JWK carries a private component and is not a published verification key.
49    #[error("the JWK carries a private key component")]
50    NotPublic,
51    /// A member the key type requires is missing.
52    #[error("the JWK has no `{0}`")]
53    Missing(&'static str),
54    /// A member is present but not in the encoding a JWK uses.
55    #[error("`{0}` is not unpadded base64url")]
56    NotBase64Url(&'static str),
57    /// A coordinate or key is the wrong length for its curve.
58    #[error("{0}")]
59    WrongLength(String),
60    /// The key type or curve is one this build does not verify with.
61    #[error("unsupported key: {0}")]
62    Unsupported(String),
63    /// The `alg` the JWK declares is not the one its key material can produce.
64    #[error("JWK `alg` `{declared}` does not match key material algorithm `{actual}`")]
65    AlgorithmDisagreement {
66        /// What the JWK said.
67        declared: String,
68        /// What the key can actually produce.
69        actual: &'static str,
70    },
71}
72
73/// The algorithms one key can produce.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct ExpectedAlgorithms {
76    /// The JOSE `alg` a JWS signed by this key names.
77    pub jose: &'static str,
78    /// The COSE algorithm, when the key can sign PIC COSE artifacts.
79    pub cose: Option<SigningAlgorithm>,
80}
81
82/// The algorithms this JWK's key material can produce, whatever it claims.
83///
84/// Reading the key type rather than trusting `alg` is the point: `alg` is a claim, key material is
85/// a fact. When the JWK declares an `alg` that disagrees with its own material, that is an error
86/// rather than a preference to honour.
87pub fn expected_algorithms_for_jwk(jwk: &Value) -> Result<ExpectedAlgorithms, JwkError> {
88    let key_type = member(jwk, "kty").ok_or(JwkError::Missing("kty"))?;
89    let curve = member(jwk, "crv").unwrap_or_default();
90
91    let expected = match (key_type, curve) {
92        ("OKP", "Ed25519") => ExpectedAlgorithms {
93            jose: "EdDSA",
94            cose: Some(SigningAlgorithm::EdDSA),
95        },
96        ("EC", "P-256") => ExpectedAlgorithms {
97            jose: "ES256",
98            cose: Some(SigningAlgorithm::ES256),
99        },
100        ("EC", "P-384") => ExpectedAlgorithms {
101            jose: "ES384",
102            cose: Some(SigningAlgorithm::ES384),
103        },
104        (other, curve) => {
105            return Err(JwkError::Unsupported(format!(
106                "`kty` `{other}` with `crv` `{curve}`"
107            )));
108        }
109    };
110
111    if let Some(declared) = member(jwk, "alg")
112        && declared != expected.jose
113    {
114        return Err(JwkError::AlgorithmDisagreement {
115            declared: declared.to_owned(),
116            actual: expected.jose,
117        });
118    }
119
120    Ok(expected)
121}
122
123/// A verifier over one published key.
124///
125/// Deliberately opaque: it prints what it is, never the key material it holds.
126pub struct JwkVerifier {
127    inner: Key,
128    expected: ExpectedAlgorithms,
129}
130
131impl std::fmt::Debug for JwkVerifier {
132    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        formatter.write_str("JwkVerifier")
134    }
135}
136
137/// The key material, in whichever form the enabled features can verify with.
138enum Key {
139    #[cfg(feature = "ed25519")]
140    Ed25519(Box<ed25519_dalek::VerifyingKey>),
141    #[cfg(feature = "p256")]
142    P256(Box<p256::ecdsa::VerifyingKey>),
143    #[cfg(feature = "p384")]
144    P384(Box<p384::ecdsa::VerifyingKey>),
145}
146
147impl ArtifactVerifier for JwkVerifier {
148    #[cfg_attr(
149        not(any(feature = "ed25519", feature = "p256", feature = "p384")),
150        allow(unused_variables)
151    )]
152    fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
153        match &self.inner {
154            #[cfg(feature = "ed25519")]
155            Key::Ed25519(key) => {
156                use ed25519_dalek::Verifier;
157
158                ed25519_dalek::Signature::from_slice(signature)
159                    .is_ok_and(|signature| key.verify(data, &signature).is_ok())
160            }
161            #[cfg(feature = "p256")]
162            Key::P256(key) => {
163                use p256::ecdsa::signature::Verifier;
164
165                p256::ecdsa::Signature::from_slice(signature)
166                    .is_ok_and(|signature| key.verify(data, &signature).is_ok())
167            }
168            #[cfg(feature = "p384")]
169            Key::P384(key) => {
170                use p384::ecdsa::signature::Verifier;
171
172                p384::ecdsa::Signature::from_slice(signature)
173                    .is_ok_and(|signature| key.verify(data, &signature).is_ok())
174            }
175            // With no curve feature enabled the enum has no variants and nothing reaches here.
176            #[allow(unreachable_patterns)]
177            _ => false,
178        }
179    }
180
181    fn expected_jws_algorithm(&self) -> Option<&'static str> {
182        Some(self.expected.jose)
183    }
184
185    fn expected_cose_algorithm(&self) -> Option<SigningAlgorithm> {
186        self.expected.cose
187    }
188}
189
190/// Builds a verifier from a published JWK.
191///
192/// A JWK carrying a private component is refused outright: a verification key is public, and one
193/// that arrived with `d` is either a mistake or an attempt to have this build hold a secret it was
194/// never given.
195pub fn public_key_from_jwk(jwk: &Value) -> Result<JwkVerifier, JwkError> {
196    if jwk.get("d").is_some() {
197        return Err(JwkError::NotPublic);
198    }
199
200    // The algorithms are agreed first, so a JWK whose `alg` contradicts its material never reaches
201    // the key readers below.
202    let expected = expected_algorithms_for_jwk(jwk)?;
203    let x = coordinate(jwk, "x")?;
204
205    match expected.jose {
206        "EdDSA" => ed25519_from_x(x, expected),
207        "ES256" => p256_from_coordinates(x, coordinate(jwk, "y")?, expected),
208        "ES384" => p384_from_coordinates(x, coordinate(jwk, "y")?, expected),
209        other => Err(JwkError::Unsupported(other.to_owned())),
210    }
211}
212
213// Each reader exists in two forms — one when its curve is compiled in, one that says so when it is
214// not — rather than one function branching on `cfg`. The build then carries only the code it can
215// actually run.
216
217#[cfg(feature = "ed25519")]
218fn ed25519_from_x(x: Vec<u8>, expected: ExpectedAlgorithms) -> Result<JwkVerifier, JwkError> {
219    let bytes: [u8; 32] = x
220        .try_into()
221        .map_err(|_| JwkError::WrongLength("an Ed25519 `x` is not 32 bytes".to_owned()))?;
222    let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
223        .map_err(|error| JwkError::WrongLength(error.to_string()))?;
224
225    Ok(JwkVerifier {
226        inner: Key::Ed25519(Box::new(key)),
227        expected,
228    })
229}
230
231#[cfg(not(feature = "ed25519"))]
232fn ed25519_from_x(_x: Vec<u8>, _expected: ExpectedAlgorithms) -> Result<JwkVerifier, JwkError> {
233    Err(JwkError::Unsupported(
234        "Ed25519: enable the `ed25519` feature".to_owned(),
235    ))
236}
237
238#[cfg(feature = "p256")]
239fn p256_from_coordinates(
240    x: Vec<u8>,
241    y: Vec<u8>,
242    expected: ExpectedAlgorithms,
243) -> Result<JwkVerifier, JwkError> {
244    let point = sec1_point(&x, &y, 32, "P-256")?;
245    let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&point)
246        .map_err(|error| JwkError::WrongLength(error.to_string()))?;
247
248    Ok(JwkVerifier {
249        inner: Key::P256(Box::new(key)),
250        expected,
251    })
252}
253
254#[cfg(not(feature = "p256"))]
255fn p256_from_coordinates(
256    _x: Vec<u8>,
257    _y: Vec<u8>,
258    _expected: ExpectedAlgorithms,
259) -> Result<JwkVerifier, JwkError> {
260    Err(JwkError::Unsupported(
261        "P-256: enable the `p256` feature".to_owned(),
262    ))
263}
264
265#[cfg(feature = "p384")]
266fn p384_from_coordinates(
267    x: Vec<u8>,
268    y: Vec<u8>,
269    expected: ExpectedAlgorithms,
270) -> Result<JwkVerifier, JwkError> {
271    let point = sec1_point(&x, &y, 48, "P-384")?;
272    let key = p384::ecdsa::VerifyingKey::from_sec1_bytes(&point)
273        .map_err(|error| JwkError::WrongLength(error.to_string()))?;
274
275    Ok(JwkVerifier {
276        inner: Key::P384(Box::new(key)),
277        expected,
278    })
279}
280
281#[cfg(not(feature = "p384"))]
282fn p384_from_coordinates(
283    _x: Vec<u8>,
284    _y: Vec<u8>,
285    _expected: ExpectedAlgorithms,
286) -> Result<JwkVerifier, JwkError> {
287    Err(JwkError::Unsupported(
288        "P-384: enable the `p384` feature".to_owned(),
289    ))
290}
291
292/// `0x04 || x || y`, the uncompressed point the curve libraries read.
293#[cfg_attr(
294    not(any(feature = "p256", feature = "p384")),
295    allow(dead_code, unused_variables)
296)]
297fn sec1_point(x: &[u8], y: &[u8], width: usize, curve: &str) -> Result<Vec<u8>, JwkError> {
298    if x.len() != width || y.len() != width {
299        return Err(JwkError::WrongLength(format!(
300            "a {curve} coordinate is not {width} bytes"
301        )));
302    }
303
304    let mut point = Vec::with_capacity(1 + width * 2);
305    point.push(0x04);
306    point.extend_from_slice(x);
307    point.extend_from_slice(y);
308
309    Ok(point)
310}
311
312fn member<'a>(jwk: &'a Value, name: &str) -> Option<&'a str> {
313    jwk.get(name)?.as_str()
314}
315
316fn coordinate(jwk: &Value, name: &'static str) -> Result<Vec<u8>, JwkError> {
317    use base64::Engine;
318
319    let encoded = member(jwk, name).ok_or(JwkError::Missing(name))?;
320    base64::engine::general_purpose::URL_SAFE_NO_PAD
321        .decode(encoded)
322        .map_err(|_| JwkError::NotBase64Url(name))
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde_json::json;
329
330    #[test]
331    fn algorithms_come_from_key_material_not_from_what_the_jwk_claims() {
332        let ed25519 = json!({"kty": "OKP", "crv": "Ed25519", "x": "AA"});
333        assert_eq!(
334            expected_algorithms_for_jwk(&ed25519).unwrap(),
335            ExpectedAlgorithms {
336                jose: "EdDSA",
337                cose: Some(SigningAlgorithm::EdDSA),
338            }
339        );
340
341        let p256 = json!({"kty": "EC", "crv": "P-256", "x": "AA", "y": "AA"});
342        assert_eq!(expected_algorithms_for_jwk(&p256).unwrap().jose, "ES256");
343
344        let p384 = json!({"kty": "EC", "crv": "P-384", "x": "AA", "y": "AA"});
345        assert_eq!(expected_algorithms_for_jwk(&p384).unwrap().jose, "ES384");
346
347        // An `alg` the material cannot produce is an error, not a preference: honouring it would
348        // let an artifact pick how it is verified.
349        let lying = json!({"kty": "OKP", "crv": "Ed25519", "alg": "ES256", "x": "AA"});
350        assert_eq!(
351            expected_algorithms_for_jwk(&lying).unwrap_err(),
352            JwkError::AlgorithmDisagreement {
353                declared: "ES256".to_owned(),
354                actual: "EdDSA",
355            }
356        );
357
358        // RSA belongs to the OAuth side, not to PIC artifacts.
359        let rsa = json!({"kty": "RSA", "n": "AA", "e": "AQAB"});
360        assert!(matches!(
361            expected_algorithms_for_jwk(&rsa).unwrap_err(),
362            JwkError::Unsupported(_)
363        ));
364    }
365
366    #[test]
367    fn a_jwk_carrying_private_material_is_refused() {
368        let private = json!({"kty": "OKP", "crv": "Ed25519", "x": "AA", "d": "secret"});
369        assert_eq!(
370            public_key_from_jwk(&private).unwrap_err(),
371            JwkError::NotPublic
372        );
373    }
374
375    #[test]
376    fn a_malformed_key_is_refused_rather_than_truncated() {
377        let short = json!({"kty": "OKP", "crv": "Ed25519", "x": "AAAA"});
378        assert!(matches!(
379            public_key_from_jwk(&short).unwrap_err(),
380            JwkError::WrongLength(_)
381        ));
382
383        let not_base64 = json!({"kty": "OKP", "crv": "Ed25519", "x": "not base64!"});
384        assert_eq!(
385            public_key_from_jwk(&not_base64).unwrap_err(),
386            JwkError::NotBase64Url("x")
387        );
388
389        let no_y = json!({"kty": "EC", "crv": "P-256", "x": "AA"});
390        assert_eq!(
391            public_key_from_jwk(&no_y).unwrap_err(),
392            JwkError::Missing("y")
393        );
394    }
395
396    #[cfg(feature = "ed25519")]
397    #[test]
398    fn an_ed25519_jwk_verifies_what_its_key_signed() {
399        use base64::Engine;
400        use ed25519_dalek::Signer;
401
402        let signing = ed25519_dalek::SigningKey::from_bytes(&[0x42; 32]);
403        let jwk = json!({
404            "kty": "OKP",
405            "crv": "Ed25519",
406            "alg": "EdDSA",
407            "x": base64::engine::general_purpose::URL_SAFE_NO_PAD
408                .encode(signing.verifying_key().as_bytes()),
409        });
410
411        let verifier = public_key_from_jwk(&jwk).expect("the JWK reads");
412        let signature = signing.sign(b"artifact bytes");
413        assert!(verifier.verify(b"artifact bytes", &signature.to_bytes()));
414        assert!(!verifier.verify(b"other bytes", &signature.to_bytes()));
415    }
416
417    #[cfg(feature = "p256")]
418    #[test]
419    fn a_p256_jwk_verifies_what_its_key_signed() {
420        use base64::Engine;
421        use p256::ecdsa::signature::Signer;
422
423        let signing = p256::ecdsa::SigningKey::from_slice(&[0x11; 32]).expect("a signing key");
424        let point = signing.verifying_key().to_encoded_point(false);
425        let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
426        let jwk = json!({
427            "kty": "EC",
428            "crv": "P-256",
429            "alg": "ES256",
430            "x": encode(point.x().expect("x")),
431            "y": encode(point.y().expect("y")),
432        });
433
434        let verifier = public_key_from_jwk(&jwk).expect("the JWK reads");
435        let signature: p256::ecdsa::Signature = signing.sign(b"artifact bytes");
436        assert!(verifier.verify(b"artifact bytes", &signature.to_bytes()));
437        assert!(!verifier.verify(b"other bytes", &signature.to_bytes()));
438    }
439}