This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-atproto / src / jwt.rs
17 kB 475 lines
1use base64::Engine; 2use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3use knot_runtime::{SignatureScheme, Signer}; 4use knot_types::crypto::{KeyCodec, PublicKey as CryptoKey}; 5use knot_types::service_auth::{ 6 JwtHeader, ParsedJwt, PublicKey as VerifyKey, ServiceAuthClaims, ServiceAuthError, parse_jwt, 7}; 8use knot_types::{AccountDid, CowStr, Did, DidService, KnotId, Nsid, ServiceDid, UnixSeconds}; 9 10pub(crate) const CLOCK_SKEW_SECS: i64 = 60; 11pub(crate) const SERVICE_TOKEN_LIFETIME_SECS: i64 = 60; 12const MAX_TOKEN_LIFETIME_SECS: i64 = 300; 13const MAX_NONCE_BYTES: usize = 256; 14 15#[derive(Debug, Clone, PartialEq, Eq, Hash)] 16pub struct JwtNonce(String); 17 18#[derive(Clone, PartialEq, Eq)] 19pub struct ServiceJwt(String); 20 21impl std::fmt::Debug for ServiceJwt { 22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 f.debug_tuple("ServiceJwt").finish_non_exhaustive() 24 } 25} 26 27impl ServiceJwt { 28 pub fn new(value: impl Into<String>) -> Result<Self, JwtError> { 29 let value = value.into(); 30 let three_segments = 31 value.split('.').count() == 3 && value.split('.').all(|segment| !segment.is_empty()); 32 match three_segments { 33 true => Ok(Self(value)), 34 false => Err(JwtError::NotAJwt), 35 } 36 } 37 38 pub fn as_str(&self) -> &str { 39 &self.0 40 } 41} 42 43impl std::fmt::Display for ServiceJwt { 44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 f.write_str(&self.0) 46 } 47} 48 49impl JwtNonce { 50 pub fn new(value: impl Into<String>) -> Result<Self, JwtError> { 51 let value = value.into(); 52 let len = value.len(); 53 (len <= MAX_NONCE_BYTES) 54 .then_some(Self(value)) 55 .ok_or(JwtError::OversizedNonce { len }) 56 } 57 58 pub fn as_str(&self) -> &str { 59 &self.0 60 } 61} 62 63#[derive(Debug, thiserror::Error)] 64pub enum JwtError { 65 #[error("malformed token: {0}")] 66 Parse(#[from] ServiceAuthError), 67 #[error("token type {typ:?} isn't JWT")] 68 UnexpectedType { typ: String }, 69 #[error("token isn't three dot-separated JWT segments")] 70 NotAJwt, 71 #[error("issuer {value:?} isn't valid account DID")] 72 MalformedIssuer { value: String }, 73 #[error("token has no jti, refusing write without replay protection")] 74 MissingNonce, 75 #[error("{len}-byte jti exceeds {MAX_NONCE_BYTES}-byte nonce limit")] 76 OversizedNonce { len: usize }, 77 #[error("issuer key codec isn't a signing algorithm")] 78 UnsupportedKeyCodec, 79 #[error("issuer key isn't valid verifying key: {0}")] 80 MalformedKey(String), 81 #[error("signature doesn't verify against issuer key")] 82 InvalidSignature, 83 #[error("audience mismatch: token addressed {actual}, expected {expected}")] 84 AudienceMismatch { expected: String, actual: String }, 85 #[error("token expired at {exp}, now {now}")] 86 Expired { exp: UnixSeconds, now: UnixSeconds }, 87 #[error("token issued in future: iat {iat}, now {now}")] 88 IssuedInFuture { iat: UnixSeconds, now: UnixSeconds }, 89 #[error("token lifetime is too long: iat {iat}, exp {exp}, limit {max}s")] 90 LifetimeTooLong { 91 exp: UnixSeconds, 92 iat: UnixSeconds, 93 max: i64, 94 }, 95 #[error("token expires at {exp}, before its issue at {iat}")] 96 ExpiresBeforeIssued { exp: UnixSeconds, iat: UnixSeconds }, 97 #[error("method binding mismatch: token bound to {actual:?}, expected {expected}")] 98 MethodMismatch { 99 expected: String, 100 actual: Option<String>, 101 }, 102} 103 104pub(crate) fn mint( 105 signer: &dyn Signer, 106 issuer: &KnotId, 107 audience: &ServiceDid, 108 method: &Nsid, 109 nonce: JwtNonce, 110 now_unix: UnixSeconds, 111) -> ServiceJwt { 112 let alg = match signer.scheme() { 113 SignatureScheme::Secp256k1 => "ES256K", 114 SignatureScheme::P256 => "ES256", 115 }; 116 let header = JwtHeader { 117 alg: CowStr::new_static(alg), 118 typ: CowStr::new_static("JWT"), 119 }; 120 let claims = ServiceAuthClaims { 121 iss: Did::new_owned(issuer.as_str()).expect("knot DID parses as a DID"), 122 aud: DidService::new_owned(audience.as_str()).expect("service DID parses as a DID"), 123 exp: now_unix 124 .saturating_add_secs(SERVICE_TOKEN_LIFETIME_SECS) 125 .get(), 126 iat: now_unix.get(), 127 jti: Some(nonce.as_str().into()), 128 lxm: Some(method.clone()), 129 }; 130 let header_b64 = 131 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("jwt header serializes")); 132 let payload_b64 = 133 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("service-auth claims serialize")); 134 let signing_input = format!("{header_b64}.{payload_b64}"); 135 let signature = signer.sign(signing_input.as_bytes()); 136 ServiceJwt(format!( 137 "{signing_input}.{}", 138 URL_SAFE_NO_PAD.encode(signature.as_bytes()) 139 )) 140} 141 142pub fn parse(token: &ServiceJwt) -> Result<ParsedJwt, JwtError> { 143 let parsed = parse_jwt(token.as_str())?; 144 let typ = parsed.header().typ.as_str(); 145 if !typ.eq_ignore_ascii_case("JWT") { 146 return Err(JwtError::UnexpectedType { 147 typ: typ.to_string(), 148 }); 149 } 150 Ok(parsed) 151} 152 153pub fn issuer(parsed: &ParsedJwt) -> Result<AccountDid, JwtError> { 154 let iss = parsed.claims().iss.as_str(); 155 AccountDid::new(iss).map_err(|_| JwtError::MalformedIssuer { 156 value: iss.to_string(), 157 }) 158} 159 160fn verifying_key(key: &CryptoKey<'_>) -> Result<VerifyKey, JwtError> { 161 match key.codec { 162 KeyCodec::Secp256k1 => VerifyKey::from_k256_bytes(&key.bytes) 163 .map_err(|e| JwtError::MalformedKey(e.to_string())), 164 KeyCodec::P256 => VerifyKey::from_p256_bytes(&key.bytes) 165 .map_err(|e| JwtError::MalformedKey(e.to_string())), 166 KeyCodec::Ed25519 | KeyCodec::Unknown(_) => Err(JwtError::UnsupportedKeyCodec), 167 } 168} 169 170pub trait TokenAudience { 171 fn as_str(&self) -> &str; 172 fn canonicalizes(&self, claimed: &str) -> bool; 173} 174 175impl TokenAudience for KnotId { 176 fn as_str(&self) -> &str { 177 KnotId::as_str(self) 178 } 179 180 fn canonicalizes(&self, claimed: &str) -> bool { 181 KnotId::new(claimed).is_ok_and(|aud| aud.as_str() == KnotId::as_str(self)) 182 } 183} 184 185impl TokenAudience for ServiceDid { 186 fn as_str(&self) -> &str { 187 ServiceDid::as_str(self) 188 } 189 190 fn canonicalizes(&self, claimed: &str) -> bool { 191 ServiceDid::new(claimed).is_ok_and(|aud| aud.as_str() == ServiceDid::as_str(self)) 192 } 193} 194 195pub fn check_claims( 196 parsed: &ParsedJwt, 197 audience: &impl TokenAudience, 198 method: &Nsid, 199 now_unix: UnixSeconds, 200) -> Result<(), JwtError> { 201 let claims = parsed.claims(); 202 let exp = UnixSeconds::new(claims.exp); 203 let iat = UnixSeconds::new(claims.iat); 204 205 if !audience.canonicalizes(claims.aud.as_str()) { 206 return Err(JwtError::AudienceMismatch { 207 expected: audience.as_str().to_string(), 208 actual: claims.aud.as_str().to_string(), 209 }); 210 } 211 212 if exp.saturating_add_secs(CLOCK_SKEW_SECS) < now_unix { 213 return Err(JwtError::Expired { exp, now: now_unix }); 214 } 215 216 if iat.saturating_sub_secs(CLOCK_SKEW_SECS) > now_unix { 217 return Err(JwtError::IssuedInFuture { iat, now: now_unix }); 218 } 219 220 if exp < iat { 221 return Err(JwtError::ExpiresBeforeIssued { exp, iat }); 222 } 223 224 if exp.get().saturating_sub(iat.get()) > MAX_TOKEN_LIFETIME_SECS { 225 return Err(JwtError::LifetimeTooLong { 226 exp, 227 iat, 228 max: MAX_TOKEN_LIFETIME_SECS, 229 }); 230 } 231 232 let bound = claims.lxm.as_ref().map(|lxm| lxm.as_str()); 233 if bound != Some(method.as_str()) { 234 return Err(JwtError::MethodMismatch { 235 expected: method.as_str().to_string(), 236 actual: bound.map(str::to_string), 237 }); 238 } 239 240 Ok(()) 241} 242 243pub(crate) fn nonce(parsed: &ParsedJwt) -> Result<JwtNonce, JwtError> { 244 let jti: &str = parsed 245 .claims() 246 .jti 247 .as_ref() 248 .map(|jti| jti.as_ref()) 249 .ok_or(JwtError::MissingNonce)?; 250 JwtNonce::new(jti) 251} 252 253pub fn verify_signature(parsed: &ParsedJwt, issuer_key: &CryptoKey<'_>) -> Result<(), JwtError> { 254 let key = verifying_key(issuer_key)?; 255 knot_types::service_auth::verify_signature(parsed, &key).map_err(|error| match error { 256 ServiceAuthError::InvalidSignature => JwtError::InvalidSignature, 257 other => JwtError::Parse(other), 258 }) 259} 260 261#[cfg(test)] 262mod tests { 263 use super::*; 264 use crate::test_support::mint as mint_claims; 265 use crate::test_support::*; 266 use std::borrow::Cow; 267 268 fn claims(iss: &str, aud: &str, exp: UnixSeconds, lxm: &str) -> serde_json::Value { 269 serde_json::json!({ 270 "iss": iss, 271 "aud": aud, 272 "exp": exp.get(), 273 "iat": exp.saturating_sub_secs(60).get(), 274 "lxm": lxm, 275 }) 276 } 277 278 #[test] 279 fn a_well_formed_token_authenticates_its_issuer() { 280 let signing = signer(1); 281 let public = k256_public(&signing); 282 let token = mint_claims( 283 &signing, 284 &claims(SQUID, KNOT, UnixSeconds::new(1_000), METHOD), 285 ); 286 let parsed = parse(&token).unwrap(); 287 check_claims( 288 &parsed, 289 &knot_did(KNOT), 290 &member_method(), 291 UnixSeconds::new(900), 292 ) 293 .unwrap(); 294 verify_signature(&parsed, &public).unwrap(); 295 assert_eq!(issuer(&parsed).unwrap(), AccountDid::new(SQUID).unwrap()); 296 } 297 298 struct ClaimsCase { 299 name: &'static str, 300 claims: fn() -> serde_json::Value, 301 now: i64, 302 expect: fn(&Result<(), JwtError>) -> bool, 303 } 304 305 const CLAIMS_CASES: &[ClaimsCase] = &[ 306 ClaimsCase { 307 name: "expired past the skew window", 308 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 1_000, "iat": 940, "lxm": METHOD }), 309 now: 1_100, 310 expect: |r| { 311 matches!(r, Err(JwtError::Expired { exp, now }) 312 if *exp == UnixSeconds::new(1_000) && *now == UnixSeconds::new(1_100)) 313 }, 314 }, 315 ClaimsCase { 316 name: "within the skew window past exp", 317 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 1_000, "iat": 940, "lxm": METHOD }), 318 now: 1_030, 319 expect: |r| r.is_ok(), 320 }, 321 ClaimsCase { 322 name: "addressed to another knot", 323 claims: || serde_json::json!({ "iss": SQUID, "aud": "did:web:oyster.cafe", "exp": 1_000, "iat": 940, "lxm": METHOD }), 324 now: 900, 325 expect: |r| matches!(r, Err(JwtError::AudienceMismatch { .. })), 326 }, 327 ClaimsCase { 328 name: "bound to another method", 329 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 1_000, "iat": 940, "lxm": "sh.tangled.repo.delete" }), 330 now: 900, 331 expect: |r| matches!(r, Err(JwtError::MethodMismatch { .. })), 332 }, 333 ClaimsCase { 334 name: "has no method binding", 335 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 1_000, "iat": 940 }), 336 now: 900, 337 expect: |r| matches!(r, Err(JwtError::MethodMismatch { actual: None, .. })), 338 }, 339 ClaimsCase { 340 name: "issued in the future", 341 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 2_001, "iat": 2_000, "lxm": METHOD }), 342 now: 900, 343 expect: |r| { 344 matches!(r, Err(JwtError::IssuedInFuture { iat, now }) 345 if *iat == UnixSeconds::new(2_000) && *now == UnixSeconds::new(900)) 346 }, 347 }, 348 ClaimsCase { 349 name: "lifetime exceeds the limit", 350 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 1_400, "iat": 1_000, "lxm": METHOD }), 351 now: 1_000, 352 expect: |r| { 353 matches!(r, Err(JwtError::LifetimeTooLong { exp, iat, max }) 354 if *exp == UnixSeconds::new(1_400) && *iat == UnixSeconds::new(1_000) && *max == 300) 355 }, 356 }, 357 ClaimsCase { 358 name: "expires before it was issued", 359 claims: || serde_json::json!({ "iss": SQUID, "aud": KNOT, "exp": 1_040, "iat": 1_050, "lxm": METHOD }), 360 now: 1_000, 361 expect: |r| { 362 matches!(r, Err(JwtError::ExpiresBeforeIssued { exp, iat }) 363 if *exp == UnixSeconds::new(1_040) && *iat == UnixSeconds::new(1_050)) 364 }, 365 }, 366 ClaimsCase { 367 name: "audience in a different case still matches", 368 claims: || serde_json::json!({ "iss": SQUID, "aud": "did:web:NEL.PET", "exp": 1_000, "iat": 940, "lxm": METHOD }), 369 now: 900, 370 expect: |r| r.is_ok(), 371 }, 372 ]; 373 374 #[test] 375 fn check_claims_enforces_the_audience_window_and_method_binding() { 376 let signing = signer(1); 377 CLAIMS_CASES.iter().for_each(|case| { 378 let token = mint_claims(&signing, &(case.claims)()); 379 let parsed = parse(&token).unwrap(); 380 let result = check_claims( 381 &parsed, 382 &knot_did(KNOT), 383 &member_method(), 384 UnixSeconds::new(case.now), 385 ); 386 assert!( 387 (case.expect)(&result), 388 "case {:?} got {result:?}", 389 case.name 390 ); 391 }); 392 } 393 394 #[test] 395 fn nonce_extraction_enforces_presence_and_limit() { 396 let signing = signer(1); 397 let with = |jti: Option<String>| { 398 let mut body = claims(SQUID, KNOT, UnixSeconds::new(1_000), METHOD); 399 if let Some(jti) = jti { 400 body["jti"] = serde_json::json!(jti); 401 } 402 parse(&mint_claims(&signing, &body)).unwrap() 403 }; 404 405 assert!(matches!(nonce(&with(None)), Err(JwtError::MissingNonce))); 406 assert_eq!( 407 nonce(&with(Some("nonce-1".to_string()))).unwrap().as_str(), 408 "nonce-1" 409 ); 410 assert!(matches!( 411 nonce(&with(Some("n".repeat(MAX_NONCE_BYTES + 1)))), 412 Err(JwtError::OversizedNonce { len }) if len == MAX_NONCE_BYTES + 1 413 )); 414 assert!(nonce(&with(Some("n".repeat(MAX_NONCE_BYTES)))).is_ok()); 415 } 416 417 #[test] 418 fn a_minted_token_round_trips_through_the_verify_half_and_honors_its_lifetime() { 419 let key = runtime_signer(8); 420 let knot_issuer = knot_did(KNOT); 421 let audience = ServiceDid::new("did:web:pds.oyster.cafe").unwrap(); 422 let bound = Nsid::new_owned("com.atproto.repo.putRecord").unwrap(); 423 let token = super::mint( 424 &key, 425 &knot_issuer, 426 &audience, 427 &bound, 428 JwtNonce::new("nonce-minted").unwrap(), 429 UnixSeconds::new(1_000), 430 ); 431 let parsed = parse(&token).unwrap(); 432 433 assert_eq!(parsed.claims().iat, 1_000); 434 assert_eq!(parsed.claims().exp, 1_000 + SERVICE_TOKEN_LIFETIME_SECS); 435 check_claims(&parsed, &audience, &bound, UnixSeconds::new(1_005)).unwrap(); 436 assert_eq!(nonce(&parsed).unwrap().as_str(), "nonce-minted"); 437 assert_eq!(issuer(&parsed).unwrap(), AccountDid::new(KNOT).unwrap()); 438 439 let public = CryptoKey { 440 codec: KeyCodec::Secp256k1, 441 bytes: Cow::Owned(knot_runtime::Signer::public_key(&key).as_bytes().to_vec()), 442 }; 443 verify_signature(&parsed, &public).unwrap(); 444 445 let stranger = k256_public(&signer(4)); 446 assert!(matches!( 447 verify_signature(&parsed, &stranger).unwrap_err(), 448 JwtError::InvalidSignature 449 )); 450 } 451 452 #[test] 453 fn a_jwt_nonce_preserves_its_string_and_compares_by_value() { 454 let nonce = JwtNonce::new("nonce-value").unwrap(); 455 assert_eq!(nonce.as_str(), "nonce-value"); 456 assert_eq!(nonce, JwtNonce::new("nonce-value".to_string()).unwrap()); 457 assert_ne!(nonce, JwtNonce::new("other").unwrap()); 458 } 459 460 #[test] 461 fn an_ed25519_issuer_key_is_unsupported() { 462 let signing = signer(1); 463 let token = mint_claims( 464 &signing, 465 &claims(SQUID, KNOT, UnixSeconds::new(1_000), METHOD), 466 ); 467 let parsed = parse(&token).unwrap(); 468 let ed = CryptoKey { 469 codec: KeyCodec::Ed25519, 470 bytes: Cow::Owned(vec![0u8; 32]), 471 }; 472 let error = verify_signature(&parsed, &ed).unwrap_err(); 473 assert!(matches!(error, JwtError::UnsupportedKeyCodec)); 474 } 475}