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}
129
130impl std::fmt::Debug for JwkVerifier {
131    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        formatter.write_str("JwkVerifier")
133    }
134}
135
136/// The key material, in whichever form the enabled features can verify with.
137enum Key {
138    #[cfg(feature = "ed25519")]
139    Ed25519(Box<ed25519_dalek::VerifyingKey>),
140    #[cfg(feature = "p256")]
141    P256(Box<p256::ecdsa::VerifyingKey>),
142    #[cfg(feature = "p384")]
143    P384(Box<p384::ecdsa::VerifyingKey>),
144}
145
146impl ArtifactVerifier for JwkVerifier {
147    #[cfg_attr(
148        not(any(feature = "ed25519", feature = "p256", feature = "p384")),
149        allow(unused_variables)
150    )]
151    fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
152        match &self.inner {
153            #[cfg(feature = "ed25519")]
154            Key::Ed25519(key) => {
155                use ed25519_dalek::Verifier;
156
157                ed25519_dalek::Signature::from_slice(signature)
158                    .is_ok_and(|signature| key.verify(data, &signature).is_ok())
159            }
160            #[cfg(feature = "p256")]
161            Key::P256(key) => {
162                use p256::ecdsa::signature::Verifier;
163
164                p256::ecdsa::Signature::from_slice(signature)
165                    .is_ok_and(|signature| key.verify(data, &signature).is_ok())
166            }
167            #[cfg(feature = "p384")]
168            Key::P384(key) => {
169                use p384::ecdsa::signature::Verifier;
170
171                p384::ecdsa::Signature::from_slice(signature)
172                    .is_ok_and(|signature| key.verify(data, &signature).is_ok())
173            }
174            // With no curve feature enabled the enum has no variants and nothing reaches here.
175            #[allow(unreachable_patterns)]
176            _ => false,
177        }
178    }
179}
180
181/// Builds a verifier from a published JWK.
182///
183/// A JWK carrying a private component is refused outright: a verification key is public, and one
184/// that arrived with `d` is either a mistake or an attempt to have this build hold a secret it was
185/// never given.
186pub fn public_key_from_jwk(jwk: &Value) -> Result<JwkVerifier, JwkError> {
187    if jwk.get("d").is_some() {
188        return Err(JwkError::NotPublic);
189    }
190
191    // The algorithms are agreed first, so a JWK whose `alg` contradicts its material never reaches
192    // the key readers below.
193    let expected = expected_algorithms_for_jwk(jwk)?;
194    let x = coordinate(jwk, "x")?;
195
196    match expected.jose {
197        "EdDSA" => ed25519_from_x(x),
198        "ES256" => p256_from_coordinates(x, coordinate(jwk, "y")?),
199        "ES384" => p384_from_coordinates(x, coordinate(jwk, "y")?),
200        other => Err(JwkError::Unsupported(other.to_owned())),
201    }
202}
203
204// Each reader exists in two forms — one when its curve is compiled in, one that says so when it is
205// not — rather than one function branching on `cfg`. The build then carries only the code it can
206// actually run.
207
208#[cfg(feature = "ed25519")]
209fn ed25519_from_x(x: Vec<u8>) -> Result<JwkVerifier, JwkError> {
210    let bytes: [u8; 32] = x
211        .try_into()
212        .map_err(|_| JwkError::WrongLength("an Ed25519 `x` is not 32 bytes".to_owned()))?;
213    let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
214        .map_err(|error| JwkError::WrongLength(error.to_string()))?;
215
216    Ok(JwkVerifier {
217        inner: Key::Ed25519(Box::new(key)),
218    })
219}
220
221#[cfg(not(feature = "ed25519"))]
222fn ed25519_from_x(_x: Vec<u8>) -> Result<JwkVerifier, JwkError> {
223    Err(JwkError::Unsupported(
224        "Ed25519: enable the `ed25519` feature".to_owned(),
225    ))
226}
227
228#[cfg(feature = "p256")]
229fn p256_from_coordinates(x: Vec<u8>, y: Vec<u8>) -> Result<JwkVerifier, JwkError> {
230    let point = sec1_point(&x, &y, 32, "P-256")?;
231    let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&point)
232        .map_err(|error| JwkError::WrongLength(error.to_string()))?;
233
234    Ok(JwkVerifier {
235        inner: Key::P256(Box::new(key)),
236    })
237}
238
239#[cfg(not(feature = "p256"))]
240fn p256_from_coordinates(_x: Vec<u8>, _y: Vec<u8>) -> Result<JwkVerifier, JwkError> {
241    Err(JwkError::Unsupported(
242        "P-256: enable the `p256` feature".to_owned(),
243    ))
244}
245
246#[cfg(feature = "p384")]
247fn p384_from_coordinates(x: Vec<u8>, y: Vec<u8>) -> Result<JwkVerifier, JwkError> {
248    let point = sec1_point(&x, &y, 48, "P-384")?;
249    let key = p384::ecdsa::VerifyingKey::from_sec1_bytes(&point)
250        .map_err(|error| JwkError::WrongLength(error.to_string()))?;
251
252    Ok(JwkVerifier {
253        inner: Key::P384(Box::new(key)),
254    })
255}
256
257#[cfg(not(feature = "p384"))]
258fn p384_from_coordinates(_x: Vec<u8>, _y: Vec<u8>) -> Result<JwkVerifier, JwkError> {
259    Err(JwkError::Unsupported(
260        "P-384: enable the `p384` feature".to_owned(),
261    ))
262}
263
264/// `0x04 || x || y`, the uncompressed point the curve libraries read.
265#[cfg_attr(
266    not(any(feature = "p256", feature = "p384")),
267    allow(dead_code, unused_variables)
268)]
269fn sec1_point(x: &[u8], y: &[u8], width: usize, curve: &str) -> Result<Vec<u8>, JwkError> {
270    if x.len() != width || y.len() != width {
271        return Err(JwkError::WrongLength(format!(
272            "a {curve} coordinate is not {width} bytes"
273        )));
274    }
275
276    let mut point = Vec::with_capacity(1 + width * 2);
277    point.push(0x04);
278    point.extend_from_slice(x);
279    point.extend_from_slice(y);
280
281    Ok(point)
282}
283
284fn member<'a>(jwk: &'a Value, name: &str) -> Option<&'a str> {
285    jwk.get(name)?.as_str()
286}
287
288fn coordinate(jwk: &Value, name: &'static str) -> Result<Vec<u8>, JwkError> {
289    use base64::Engine;
290
291    let encoded = member(jwk, name).ok_or(JwkError::Missing(name))?;
292    base64::engine::general_purpose::URL_SAFE_NO_PAD
293        .decode(encoded)
294        .map_err(|_| JwkError::NotBase64Url(name))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use serde_json::json;
301
302    #[test]
303    fn algorithms_come_from_key_material_not_from_what_the_jwk_claims() {
304        let ed25519 = json!({"kty": "OKP", "crv": "Ed25519", "x": "AA"});
305        assert_eq!(
306            expected_algorithms_for_jwk(&ed25519).unwrap(),
307            ExpectedAlgorithms {
308                jose: "EdDSA",
309                cose: Some(SigningAlgorithm::EdDSA),
310            }
311        );
312
313        let p256 = json!({"kty": "EC", "crv": "P-256", "x": "AA", "y": "AA"});
314        assert_eq!(expected_algorithms_for_jwk(&p256).unwrap().jose, "ES256");
315
316        let p384 = json!({"kty": "EC", "crv": "P-384", "x": "AA", "y": "AA"});
317        assert_eq!(expected_algorithms_for_jwk(&p384).unwrap().jose, "ES384");
318
319        // An `alg` the material cannot produce is an error, not a preference: honouring it would
320        // let an artifact pick how it is verified.
321        let lying = json!({"kty": "OKP", "crv": "Ed25519", "alg": "ES256", "x": "AA"});
322        assert_eq!(
323            expected_algorithms_for_jwk(&lying).unwrap_err(),
324            JwkError::AlgorithmDisagreement {
325                declared: "ES256".to_owned(),
326                actual: "EdDSA",
327            }
328        );
329
330        // RSA belongs to the OAuth side, not to PIC artifacts.
331        let rsa = json!({"kty": "RSA", "n": "AA", "e": "AQAB"});
332        assert!(matches!(
333            expected_algorithms_for_jwk(&rsa).unwrap_err(),
334            JwkError::Unsupported(_)
335        ));
336    }
337
338    #[test]
339    fn a_jwk_carrying_private_material_is_refused() {
340        let private = json!({"kty": "OKP", "crv": "Ed25519", "x": "AA", "d": "secret"});
341        assert_eq!(
342            public_key_from_jwk(&private).unwrap_err(),
343            JwkError::NotPublic
344        );
345    }
346
347    #[test]
348    fn a_malformed_key_is_refused_rather_than_truncated() {
349        let short = json!({"kty": "OKP", "crv": "Ed25519", "x": "AAAA"});
350        assert!(matches!(
351            public_key_from_jwk(&short).unwrap_err(),
352            JwkError::WrongLength(_)
353        ));
354
355        let not_base64 = json!({"kty": "OKP", "crv": "Ed25519", "x": "not base64!"});
356        assert_eq!(
357            public_key_from_jwk(&not_base64).unwrap_err(),
358            JwkError::NotBase64Url("x")
359        );
360
361        let no_y = json!({"kty": "EC", "crv": "P-256", "x": "AA"});
362        assert_eq!(
363            public_key_from_jwk(&no_y).unwrap_err(),
364            JwkError::Missing("y")
365        );
366    }
367
368    #[cfg(feature = "ed25519")]
369    #[test]
370    fn an_ed25519_jwk_verifies_what_its_key_signed() {
371        use base64::Engine;
372        use ed25519_dalek::Signer;
373
374        let signing = ed25519_dalek::SigningKey::from_bytes(&[0x42; 32]);
375        let jwk = json!({
376            "kty": "OKP",
377            "crv": "Ed25519",
378            "alg": "EdDSA",
379            "x": base64::engine::general_purpose::URL_SAFE_NO_PAD
380                .encode(signing.verifying_key().as_bytes()),
381        });
382
383        let verifier = public_key_from_jwk(&jwk).expect("the JWK reads");
384        let signature = signing.sign(b"artifact bytes");
385        assert!(verifier.verify(b"artifact bytes", &signature.to_bytes()));
386        assert!(!verifier.verify(b"other bytes", &signature.to_bytes()));
387    }
388
389    #[cfg(feature = "p256")]
390    #[test]
391    fn a_p256_jwk_verifies_what_its_key_signed() {
392        use base64::Engine;
393        use p256::ecdsa::signature::Signer;
394
395        let signing = p256::ecdsa::SigningKey::from_slice(&[0x11; 32]).expect("a signing key");
396        let point = signing.verifying_key().to_encoded_point(false);
397        let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
398        let jwk = json!({
399            "kty": "EC",
400            "crv": "P-256",
401            "alg": "ES256",
402            "x": encode(point.x().expect("x")),
403            "y": encode(point.y().expect("y")),
404        });
405
406        let verifier = public_key_from_jwk(&jwk).expect("the JWK reads");
407        let signature: p256::ecdsa::Signature = signing.sign(b"artifact bytes");
408        assert!(verifier.verify(b"artifact bytes", &signature.to_bytes()));
409        assert!(!verifier.verify(b"other bytes", &signature.to_bytes()));
410    }
411}