This repository has no description
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}