1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use ed25519_dalek::SigningKey;
7use ed25519_dalek::VerifyingKey;
8use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
9use jsonwebtoken::{
10 Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode,
11};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::error::AuthError;
16use crate::util::{ensure_restrictive_permissions, now_unix, set_restrictive_permissions};
17
18#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct AccessClaims {
20 pub iss: String,
21 pub sub: String,
22 pub aud: String,
23 pub exp: usize,
24 pub iat: usize,
25 pub jti: String,
26 pub scope: String,
27 pub azp: String,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct JwksDocument {
32 pub keys: Vec<JwkKey>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct JwkKey {
43 pub kty: String,
44 #[serde(rename = "use")]
45 pub use_: String,
46 pub alg: String,
47 pub kid: String,
48 pub crv: String,
49 pub x: String,
50}
51
52#[derive(Clone)]
54struct Verifier {
55 kid: String,
56 decoding_key: DecodingKey,
57 jwk: JwkKey,
58}
59
60#[derive(Clone)]
70pub struct SigningKeys {
71 pub key_id: String,
72 encoding_key: EncodingKey,
73 verifiers: Vec<Verifier>,
74 jwks: JwksDocument,
75}
76
77impl fmt::Debug for SigningKeys {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.debug_struct("SigningKeys")
80 .field("key_id", &self.key_id)
81 .field("verifier_count", &self.verifiers.len())
82 .finish_non_exhaustive()
83 }
84}
85
86impl SigningKeys {
87 pub fn load_or_create(path: &Path) -> Result<Self, AuthError> {
98 if let Some(parent) = path.parent() {
99 std::fs::create_dir_all(parent).map_err(|error| {
100 AuthError::Storage(format!(
101 "create signing key directory `{}`: {error}",
102 parent.display()
103 ))
104 })?;
105 }
106
107 let active = load_or_generate_key(path)?;
108 let previous = load_optional_previous_key(&previous_key_path(path))?;
109 Self::from_keys(&active, previous.as_ref())
110 }
111
112 pub fn rotate(path: &Path) -> Result<Self, AuthError> {
123 let outgoing = load_or_generate_key(path)?;
124 let prev_path = previous_key_path(path);
125 std::fs::rename(path, &prev_path).map_err(|error| {
128 AuthError::Storage(format!(
129 "rotate signing key `{}` -> `{}`: {error}",
130 path.display(),
131 prev_path.display()
132 ))
133 })?;
134 set_restrictive_permissions(&prev_path)?;
135 let active = generate_signing_key(path)?;
136 drop(outgoing);
141 let previous = load_optional_previous_key(&prev_path)?;
142 Self::from_keys(&active, previous.as_ref())
143 }
144
145 pub fn issue_access_token(&self, claims: &AccessClaims) -> Result<String, AuthError> {
146 let mut header = Header::new(Algorithm::EdDSA);
147 header.kid = Some(self.key_id.clone());
148 encode(&header, &claims, &self.encoding_key)
149 .map_err(|error| AuthError::Storage(format!("encode access token: {error}")))
150 }
151
152 #[deprecated(note = "Use `validate_access_token_with_issuer` for RFC 7519 §4.1.1 compliance")]
161 pub fn validate_access_token(
162 &self,
163 token: &str,
164 expected_audience: &str,
165 ) -> Result<AccessClaims, AuthError> {
166 let decoding_key = self.decoding_key_for_token(token)?;
167 let mut validation = Validation::new(Algorithm::EdDSA);
168 validation.set_audience(&[expected_audience]);
169 decode::<AccessClaims>(token, decoding_key, &validation)
170 .map(|data| data.claims)
171 .map_err(|_| AuthError::InvalidAccessToken)
172 }
173
174 pub fn validate_access_token_with_issuer(
183 &self,
184 token: &str,
185 expected_audience: &str,
186 expected_issuer: &str,
187 ) -> Result<AccessClaims, AuthError> {
188 let decoding_key = self.decoding_key_for_token(token)?;
189 let mut validation = Validation::new(Algorithm::EdDSA);
190 validation.set_audience(&[expected_audience]);
191 validation.set_issuer(&[expected_issuer]);
192 decode::<AccessClaims>(token, decoding_key, &validation)
193 .map(|data| data.claims)
194 .map_err(|_| AuthError::InvalidAccessToken)
195 }
196
197 pub const fn jwks(&self) -> &JwksDocument {
198 &self.jwks
199 }
200
201 fn decoding_key_for_token(&self, token: &str) -> Result<&DecodingKey, AuthError> {
206 let header = decode_header(token).map_err(|_| AuthError::InvalidAccessToken)?;
207 match header.kid {
208 Some(kid) => self
209 .verifiers
210 .iter()
211 .find(|verifier| verifier.kid == kid)
212 .map(|verifier| &verifier.decoding_key)
213 .ok_or(AuthError::InvalidAccessToken),
214 None => self
215 .verifiers
216 .first()
217 .map(|verifier| &verifier.decoding_key)
218 .ok_or(AuthError::InvalidAccessToken),
219 }
220 }
221
222 fn from_keys(active: &SigningKey, previous: Option<&SigningKey>) -> Result<Self, AuthError> {
223 let active_der = active
224 .to_pkcs8_der()
225 .map_err(|error| AuthError::Storage(format!("encode signing key DER: {error}")))?;
226 let active_verifier = build_verifier(&active.verifying_key())?;
227 let key_id = active_verifier.kid.clone();
228
229 let mut verifiers = vec![active_verifier];
230 if let Some(previous) = previous {
231 let previous_verifier = build_verifier(&previous.verifying_key())?;
232 if previous_verifier.kid != key_id {
236 verifiers.push(previous_verifier);
237 }
238 }
239
240 let jwks = JwksDocument {
241 keys: verifiers
242 .iter()
243 .map(|verifier| verifier.jwk.clone())
244 .collect(),
245 };
246
247 Ok(Self {
248 key_id,
249 encoding_key: EncodingKey::from_ed_der(active_der.as_bytes()),
250 verifiers,
251 jwks,
252 })
253 }
254}
255
256fn build_verifier(public_key: &VerifyingKey) -> Result<Verifier, AuthError> {
258 let public_der = public_key
259 .to_public_key_der()
260 .map_err(|error| AuthError::Storage(format!("encode public key DER: {error}")))?;
261 let digest = Sha256::digest(public_der.as_bytes());
262 let kid = URL_SAFE_NO_PAD.encode(&digest[..12]);
263
264 let jwk = JwkKey {
265 kty: "OKP".to_string(),
266 use_: "sig".to_string(),
267 alg: "EdDSA".to_string(),
268 kid: kid.clone(),
269 crv: "Ed25519".to_string(),
270 x: URL_SAFE_NO_PAD.encode(public_key.as_bytes()),
271 };
272
273 Ok(Verifier {
274 kid,
275 decoding_key: DecodingKey::from_ed_der(public_key.as_bytes()),
278 jwk,
279 })
280}
281
282fn previous_key_path(path: &Path) -> PathBuf {
283 let mut name = path.file_name().unwrap_or_default().to_os_string();
284 name.push(".prev");
285 path.with_file_name(name)
286}
287
288fn load_or_generate_key(path: &Path) -> Result<SigningKey, AuthError> {
292 if !path.exists() {
293 return generate_signing_key(path);
294 }
295
296 ensure_restrictive_permissions(path)?;
297 let key_bytes = std::fs::read(path).map_err(|error| {
298 AuthError::Storage(format!("read signing key `{}`: {error}", path.display()))
299 })?;
300 match SigningKey::from_pkcs8_der(&key_bytes) {
301 Ok(key) => {
302 ensure_restrictive_permissions(path)?;
303 Ok(key)
304 }
305 Err(_) => {
306 quarantine_key(path)?;
307 generate_signing_key(path)
308 }
309 }
310}
311
312fn load_optional_previous_key(path: &Path) -> Result<Option<SigningKey>, AuthError> {
316 if !path.exists() {
317 return Ok(None);
318 }
319 ensure_restrictive_permissions(path)?;
320 let key_bytes = std::fs::read(path).map_err(|error| {
321 AuthError::Storage(format!(
322 "read previous signing key `{}`: {error}",
323 path.display()
324 ))
325 })?;
326 match SigningKey::from_pkcs8_der(&key_bytes) {
327 Ok(key) => Ok(Some(key)),
328 Err(_) => {
329 quarantine_key(path)?;
330 Ok(None)
331 }
332 }
333}
334
335fn quarantine_key(path: &Path) -> Result<(), AuthError> {
338 let retired = path.with_extension(format!("retired-{}", now_unix()));
339 std::fs::rename(path, &retired).map_err(|error| {
340 AuthError::Storage(format!(
341 "retire legacy signing key `{}`: {error}",
342 path.display()
343 ))
344 })?;
345 set_restrictive_permissions(&retired)?;
346 Ok(())
347}
348
349fn generate_signing_key(path: &Path) -> Result<SigningKey, AuthError> {
350 let mut bytes = [0_u8; 32];
351 getrandom::fill(&mut bytes)
352 .map_err(|error| AuthError::Storage(format!("generate Ed25519 key material: {error}")))?;
353 let key = SigningKey::from_bytes(&bytes);
354 bytes.fill(0);
355 let der = key
356 .to_pkcs8_der()
357 .map_err(|error| AuthError::Storage(format!("encode signing key DER: {error}")))?;
358 std::fs::write(path, der.as_bytes()).map_err(|error| {
359 AuthError::Storage(format!("write signing key `{}`: {error}", path.display()))
360 })?;
361 set_restrictive_permissions(path)?;
362 Ok(key)
363}
364
365#[cfg(test)]
366mod tests {
367 use jsonwebtoken::{Algorithm, decode_header};
368
369 use super::{AccessClaims, SigningKeys, previous_key_path};
370
371 #[test]
372 fn generated_key_is_reused_on_second_load() {
373 let dir = tempfile::tempdir().unwrap();
374 let path = dir.path().join("auth-jwt.key");
375 let first = SigningKeys::load_or_create(&path).unwrap();
376 let second = SigningKeys::load_or_create(&path).unwrap();
377 assert_eq!(first.key_id, second.key_id);
378 }
379
380 #[cfg(unix)]
381 #[test]
382 fn signing_key_refuses_world_readable_permissions() {
383 use std::os::unix::fs::PermissionsExt;
384
385 let dir = tempfile::tempdir().unwrap();
386 let path = dir.path().join("auth-jwt.key");
387 std::fs::write(&path, "bad").unwrap();
388 std::fs::set_permissions(&path, PermissionsExt::from_mode(0o644)).unwrap();
389 let err = SigningKeys::load_or_create(&path).unwrap_err();
390 assert!(err.to_string().contains("permissions"));
391 }
392
393 #[test]
394 #[allow(deprecated)]
395 fn minted_access_token_round_trips_and_contains_kid() {
396 let signer = test_signer();
397 let claims = sample_claims();
398 let token = signer.issue_access_token(&claims).unwrap();
399 let claims = signer
400 .validate_access_token(&token, "https://lab.example.com")
401 .unwrap();
402 assert_eq!(claims.aud, "https://lab.example.com");
403 assert!(!claims.jti.is_empty());
404 assert!(decode_header(&token).unwrap().kid.is_some());
405 }
406
407 #[test]
408 fn minted_token_uses_eddsa_algorithm() {
409 let signer = test_signer();
410 let token = signer.issue_access_token(&sample_claims()).unwrap();
411 assert_eq!(decode_header(&token).unwrap().alg, Algorithm::EdDSA);
412 }
413
414 #[test]
415 fn jwks_advertises_okp_ed25519_key() {
416 let signer = test_signer();
417 let jwk = &signer.jwks().keys[0];
418 assert_eq!(jwk.kty, "OKP");
419 assert_eq!(jwk.crv, "Ed25519");
420 assert_eq!(jwk.alg, "EdDSA");
421 assert_eq!(jwk.kid, signer.key_id);
422 assert!(!jwk.x.is_empty());
423 }
424
425 #[test]
426 #[allow(deprecated)]
427 fn wrong_audience_is_rejected() {
428 let signer = test_signer();
429 let claims = sample_claims();
430 let token = signer.issue_access_token(&claims).unwrap();
431 let result = signer.validate_access_token(&token, "https://other.example.com");
432 assert!(
433 result.is_err(),
434 "token with wrong audience must be rejected"
435 );
436 }
437
438 #[test]
439 fn validate_with_issuer_accepts_matching_issuer() {
440 let signer = test_signer();
441 let claims = sample_claims();
442 let token = signer.issue_access_token(&claims).unwrap();
443 let decoded = signer
444 .validate_access_token_with_issuer(
445 &token,
446 "https://lab.example.com",
447 "https://lab.example.com",
448 )
449 .expect("token with matching issuer must validate");
450 assert_eq!(decoded.iss, "https://lab.example.com");
451 }
452
453 #[test]
454 fn validate_with_issuer_rejects_wrong_issuer_via_validation_struct() {
455 let signer = test_signer();
459 let claims = sample_claims();
460 let token = signer.issue_access_token(&claims).unwrap();
461 let result = signer.validate_access_token_with_issuer(
462 &token,
463 "https://lab.example.com",
464 "https://attacker.example.com",
465 );
466 assert!(
467 result.is_err(),
468 "token signed by us but with wrong expected issuer must be rejected"
469 );
470 }
471
472 #[test]
473 fn token_from_a_foreign_key_is_rejected() {
474 let signer = test_signer();
475 let stranger = test_signer();
476 let token = stranger.issue_access_token(&sample_claims()).unwrap();
477 let result = signer.validate_access_token_with_issuer(
478 &token,
479 "https://lab.example.com",
480 "https://lab.example.com",
481 );
482 assert!(
483 result.is_err(),
484 "token signed by a different key (unknown kid) must be rejected"
485 );
486 }
487
488 #[cfg(unix)]
489 #[test]
490 fn legacy_rsa_key_is_quarantined_and_replaced() {
491 use std::os::unix::fs::PermissionsExt;
492
493 let dir = tempfile::tempdir().unwrap();
494 let path = dir.path().join("auth-jwt.key");
495 std::fs::write(
498 &path,
499 "-----BEGIN PRIVATE KEY-----\nMIIlegacyrsakeymaterial==\n-----END PRIVATE KEY-----\n",
500 )
501 .unwrap();
502 std::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)).unwrap();
503
504 let signer = SigningKeys::load_or_create(&path).unwrap();
505
506 let retired: Vec<_> = std::fs::read_dir(dir.path())
509 .unwrap()
510 .filter_map(Result::ok)
511 .filter(|entry| entry.file_name().to_string_lossy().contains(".retired-"))
512 .collect();
513 assert_eq!(retired.len(), 1, "legacy key should be quarantined once");
514 let token = signer.issue_access_token(&sample_claims()).unwrap();
515 assert_eq!(decode_header(&token).unwrap().alg, Algorithm::EdDSA);
516 }
517
518 #[test]
519 fn rotate_keeps_previous_key_valid_during_overlap() {
520 let dir = tempfile::tempdir().unwrap();
521 let path = dir.path().join("auth-jwt.key");
522 let before = SigningKeys::load_or_create(&path).unwrap();
523 let old_token = before.issue_access_token(&sample_claims()).unwrap();
524
525 let after = SigningKeys::rotate(&path).unwrap();
526
527 assert_ne!(after.key_id, before.key_id);
529 assert_eq!(after.jwks().keys.len(), 2);
530 assert!(previous_key_path(&path).exists());
531
532 after
535 .validate_access_token_with_issuer(
536 &old_token,
537 "https://lab.example.com",
538 "https://lab.example.com",
539 )
540 .expect("pre-rotation token must remain valid during overlap");
541
542 let new_token = after.issue_access_token(&sample_claims()).unwrap();
544 after
545 .validate_access_token_with_issuer(
546 &new_token,
547 "https://lab.example.com",
548 "https://lab.example.com",
549 )
550 .expect("post-rotation token must validate");
551 }
552
553 #[test]
554 fn reload_after_rotate_still_carries_both_keys() {
555 let dir = tempfile::tempdir().unwrap();
556 let path = dir.path().join("auth-jwt.key");
557 let _before = SigningKeys::load_or_create(&path).unwrap();
558 let rotated = SigningKeys::rotate(&path).unwrap();
559 let reloaded = SigningKeys::load_or_create(&path).unwrap();
560 assert_eq!(reloaded.key_id, rotated.key_id);
561 assert_eq!(
562 reloaded.jwks().keys.len(),
563 2,
564 "a restart after rotation must reload the overlap key"
565 );
566 }
567
568 fn test_signer() -> SigningKeys {
569 let dir = tempfile::tempdir().unwrap();
570 let path = dir.path().join("auth-jwt.key");
571 SigningKeys::load_or_create(&path).unwrap()
572 }
573
574 fn sample_claims() -> AccessClaims {
575 AccessClaims {
576 iss: "https://lab.example.com".to_string(),
577 sub: "google-user".to_string(),
578 aud: "https://lab.example.com".to_string(),
579 exp: 4_102_444_800,
580 iat: 1_700_000_000,
581 jti: "test-jti".to_string(),
582 scope: "lab".to_string(),
583 azp: "client".to_string(),
584 }
585 }
586}