| 1 | //! Verifying an OpenID Connect ID token's signature with the issuer's |
| 2 | //! published keys (its JWKS, from `jwks_uri`): RSA (RS256/384/512 and |
| 3 | //! PS256/384/512) and P-256 (ES256). Anything else, `none` and the HMAC |
| 4 | //! algorithms above all, is refused: g1t never holds a key that would make |
| 5 | //! a shared secret mean anything here. |
| 6 | //! |
| 7 | //! The key is the one the token's header names by `kid`, or the only |
| 8 | //! signing key of the right type when it names none. A key marked for |
| 9 | //! encryption, or for another algorithm, is never used. |
| 10 | |
| 11 | use base64::Engine; |
| 12 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 13 | use rsa::pss::Pss; |
| 14 | use rsa::{BigUint, Pkcs1v15Sign, RsaPublicKey}; |
| 15 | use serde_json::Value; |
| 16 | use sha2::{Digest, Sha256, Sha384, Sha512}; |
| 17 | |
| 18 | const UNREADABLE: &str = "The identity provider's ID token could not be read."; |
| 19 | const BAD_SIGNATURE: &str = "The ID token's signature did not verify with the identity provider's published keys."; |
| 20 | |
| 21 | fn decode(part: &str) -> Result<Vec<u8>, String> { |
| 22 | URL_SAFE_NO_PAD.decode(part.trim_end_matches('=')).map_err(|_| UNREADABLE.to_owned()) |
| 23 | } |
| 24 | |
| 25 | /// The algorithms g1t accepts, and what each needs. |
| 26 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 27 | enum Alg { |
| 28 | Rs(Bits), |
| 29 | Ps(Bits), |
| 30 | Es256, |
| 31 | } |
| 32 | |
| 33 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 34 | enum Bits { |
| 35 | B256, |
| 36 | B384, |
| 37 | B512, |
| 38 | } |
| 39 | |
| 40 | impl Bits { |
| 41 | fn digest(self, data: &[u8]) -> Vec<u8> { |
| 42 | match self { |
| 43 | Bits::B256 => Sha256::digest(data).to_vec(), |
| 44 | Bits::B384 => Sha384::digest(data).to_vec(), |
| 45 | Bits::B512 => Sha512::digest(data).to_vec(), |
| 46 | } |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | impl Alg { |
| 51 | fn parse(name: &str) -> Result<Alg, String> { |
| 52 | Ok(match name { |
| 53 | "RS256" => Alg::Rs(Bits::B256), |
| 54 | "RS384" => Alg::Rs(Bits::B384), |
| 55 | "RS512" => Alg::Rs(Bits::B512), |
| 56 | "PS256" => Alg::Ps(Bits::B256), |
| 57 | "PS384" => Alg::Ps(Bits::B384), |
| 58 | "PS512" => Alg::Ps(Bits::B512), |
| 59 | "ES256" => Alg::Es256, |
| 60 | other => { |
| 61 | return Err(format!( |
| 62 | "The identity provider signs ID tokens with {other:?}, which g1t does not accept. Use RS256 (or PS256 or ES256)." |
| 63 | )); |
| 64 | } |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | fn kty(self) -> &'static str { |
| 69 | match self { |
| 70 | Alg::Rs(_) | Alg::Ps(_) => "RSA", |
| 71 | Alg::Es256 => "EC", |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// The key `kid` names, or the only one that fits. |
| 77 | fn key_for<'a>(jwks: &'a Value, kid: Option<&str>, alg: Alg, name: &str) -> Result<&'a Value, String> { |
| 78 | let usable: Vec<&Value> = jwks["keys"] |
| 79 | .as_array() |
| 80 | .into_iter() |
| 81 | .flatten() |
| 82 | .filter(|key| key["kty"].as_str() == Some(alg.kty())) |
| 83 | .filter(|key| key["use"].as_str().is_none_or(|u| u == "sig")) |
| 84 | .filter(|key| key["alg"].as_str().is_none_or(|a| a == name)) |
| 85 | .collect(); |
| 86 | let found = match kid { |
| 87 | Some(kid) => match usable.iter().find(|key| key["kid"].as_str() == Some(kid)) { |
| 88 | Some(key) => Some(*key), |
| 89 | // Keys published without ids: the only one, if there is one. |
| 90 | None if usable.len() == 1 && usable[0]["kid"].is_null() => Some(usable[0]), |
| 91 | None => None, |
| 92 | }, |
| 93 | None if usable.len() == 1 => usable.into_iter().next(), |
| 94 | None => None, |
| 95 | }; |
| 96 | found.ok_or_else(|| "The identity provider's published keys have none for this ID token. If it just rotated its keys, try again.".to_owned()) |
| 97 | } |
| 98 | |
| 99 | /// Verifies `token`'s signature with a key of `jwks` and returns its claims. |
| 100 | /// Only the signature: what the claims must say is checked after. |
| 101 | pub fn verify(token: &str, jwks: &Value) -> Result<Value, String> { |
| 102 | let mut parts = token.trim().split('.'); |
| 103 | let (Some(header), Some(payload), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { |
| 104 | return Err(UNREADABLE.to_owned()); |
| 105 | }; |
| 106 | let head: Value = serde_json::from_slice(&decode(header)?).map_err(|_| UNREADABLE.to_owned())?; |
| 107 | let name = head["alg"].as_str().unwrap_or("none"); |
| 108 | let alg = Alg::parse(name)?; |
| 109 | if head.get("crit").is_some() || head.get("jku").is_some() || head.get("x5u").is_some() || head.get("jwk").is_some() { |
| 110 | // Keys named in the token itself are never trusted. |
| 111 | return Err("The ID token names its own key, which g1t does not accept.".to_owned()); |
| 112 | } |
| 113 | let key = key_for(jwks, head["kid"].as_str(), alg, name)?; |
| 114 | let signed = format!("{header}.{payload}"); |
| 115 | let signature = decode(signature)?; |
| 116 | let verified = match alg { |
| 117 | Alg::Rs(bits) | Alg::Ps(bits) => { |
| 118 | let (Some(n), Some(e)) = (key["n"].as_str(), key["e"].as_str()) else { |
| 119 | return Err(BAD_SIGNATURE.to_owned()); |
| 120 | }; |
| 121 | let modulus = decode(n)?; |
| 122 | // Under 2048 bits is not a key to trust. |
| 123 | if modulus.iter().skip_while(|b| **b == 0).count() < 256 { |
| 124 | return Err("The identity provider signs with an RSA key shorter than 2048 bits, which g1t does not accept.".to_owned()); |
| 125 | } |
| 126 | let public = RsaPublicKey::new(BigUint::from_bytes_be(&modulus), BigUint::from_bytes_be(&decode(e)?)).map_err(|_| BAD_SIGNATURE.to_owned())?; |
| 127 | let hashed = bits.digest(signed.as_bytes()); |
| 128 | match (alg, bits) { |
| 129 | (Alg::Rs(_), Bits::B256) => public.verify(Pkcs1v15Sign::new::<Sha256>(), &hashed, &signature), |
| 130 | (Alg::Rs(_), Bits::B384) => public.verify(Pkcs1v15Sign::new::<Sha384>(), &hashed, &signature), |
| 131 | (Alg::Rs(_), Bits::B512) => public.verify(Pkcs1v15Sign::new::<Sha512>(), &hashed, &signature), |
| 132 | (_, Bits::B256) => public.verify(Pss::new::<Sha256>(), &hashed, &signature), |
| 133 | (_, Bits::B384) => public.verify(Pss::new::<Sha384>(), &hashed, &signature), |
| 134 | (_, Bits::B512) => public.verify(Pss::new::<Sha512>(), &hashed, &signature), |
| 135 | } |
| 136 | .is_ok() |
| 137 | } |
| 138 | Alg::Es256 => { |
| 139 | use p256::ecdsa::signature::Verifier; |
| 140 | use p256::ecdsa::{Signature, VerifyingKey}; |
| 141 | if key["crv"].as_str() != Some("P-256") { |
| 142 | return Err(BAD_SIGNATURE.to_owned()); |
| 143 | } |
| 144 | let (Some(x), Some(y)) = (key["x"].as_str(), key["y"].as_str()) else { |
| 145 | return Err(BAD_SIGNATURE.to_owned()); |
| 146 | }; |
| 147 | let mut point = vec![0x04]; |
| 148 | point.extend(decode(x)?); |
| 149 | point.extend(decode(y)?); |
| 150 | match (VerifyingKey::from_sec1_bytes(&point), Signature::from_slice(&signature)) { |
| 151 | (Ok(public), Ok(signature)) => public.verify(signed.as_bytes(), &signature).is_ok(), |
| 152 | _ => false, |
| 153 | } |
| 154 | } |
| 155 | }; |
| 156 | if !verified { |
| 157 | return Err(BAD_SIGNATURE.to_owned()); |
| 158 | } |
| 159 | serde_json::from_slice(&decode(payload)?).map_err(|_| UNREADABLE.to_owned()) |
| 160 | } |
| 161 | |
| 162 | #[cfg(test)] |
| 163 | mod tests { |
| 164 | use super::*; |
| 165 | use serde_json::json; |
| 166 | |
| 167 | fn fixtures() -> Value { |
| 168 | serde_json::from_str(include_str!("jwt_fixtures.json")).unwrap() |
| 169 | } |
| 170 | |
| 171 | fn token(name: &str) -> String { |
| 172 | fixtures()["tokens"][name].as_str().unwrap().to_owned() |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn tokens_signed_by_the_issuer_verify() { |
| 177 | let jwks = fixtures()["jwks"].clone(); |
| 178 | for name in ["rs256", "ps256", "es256", "rs256_other_key"] { |
| 179 | let claims = verify(&token(name), &jwks).unwrap_or_else(|e| panic!("{name}: {e}")); |
| 180 | assert_eq!(claims["sub"], "u1", "{name}"); |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn a_changed_token_does_not() { |
| 186 | let jwks = fixtures()["jwks"].clone(); |
| 187 | for name in ["rs256", "ps256", "es256"] { |
| 188 | let token = token(name); |
| 189 | let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect(); |
| 190 | let mut claims: Value = serde_json::from_slice(&decode(&parts[1]).unwrap()).unwrap(); |
| 191 | claims["sub"] = json!("eve"); |
| 192 | parts[1] = URL_SAFE_NO_PAD.encode(claims.to_string()); |
| 193 | assert_eq!(verify(&parts.join("."), &jwks).unwrap_err(), BAD_SIGNATURE, "{name}"); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | #[test] |
| 198 | fn a_key_the_issuer_did_not_publish_is_not_found() { |
| 199 | let without = fixtures()["jwks_without_rsa2"].clone(); |
| 200 | assert!(verify(&token("rs256_other_key"), &without).unwrap_err().contains("none for this ID token")); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn unsigned_and_shared_secret_tokens_are_refused() { |
| 205 | let jwks = fixtures()["jwks"].clone(); |
| 206 | let unsigned = format!("{}.{}.", URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#), URL_SAFE_NO_PAD.encode(r#"{"sub":"eve"}"#)); |
| 207 | assert!(verify(&unsigned, &jwks).unwrap_err().contains("\"none\"")); |
| 208 | let hmac = format!("{}.{}.c2ln", URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","kid":"rsa1"}"#), URL_SAFE_NO_PAD.encode(r#"{"sub":"eve"}"#)); |
| 209 | assert!(verify(&hmac, &jwks).unwrap_err().contains("HS256")); |
| 210 | let own_key = format!("{}.{}.c2ln", URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","jku":"https://evil.test/keys"}"#), URL_SAFE_NO_PAD.encode(r#"{"sub":"eve"}"#)); |
| 211 | assert!(verify(&own_key, &jwks).unwrap_err().contains("names its own key")); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn an_es256_token_is_not_checked_with_an_rsa_key() { |
| 216 | // The EC token's header renamed to an RSA key: the types differ. |
| 217 | let token = token("es256"); |
| 218 | let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect(); |
| 219 | parts[0] = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256","kid":"rsa1"}"#); |
| 220 | assert!(verify(&parts.join("."), &fixtures()["jwks"]).is_err()); |
| 221 | } |
| 222 | } |