1use std::collections::BTreeMap;
8use std::fmt;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
13use argon2::Argon2;
14use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
15use base64::Engine;
16use hmac::{Hmac, Mac};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use subtle::ConstantTimeEq;
20use zeroize::Zeroizing;
21
22type HmacSha256 = Hmac<Sha256>;
23
24pub const SCRAM_SHA_256_MIN_ITERATIONS: u32 = 4096;
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
27pub enum SecurityHardeningError {
28 #[error("SCRAM iteration count {0} is below the minimum")]
29 ScramIterations(u32),
30 #[error("invalid SCRAM message: {0}")]
31 ScramMessage(String),
32 #[error("SCRAM client proof is invalid")]
33 ScramProof,
34 #[error("SCRAM channel binding does not satisfy policy")]
35 ScramChannelBinding,
36 #[error("password hash failed")]
37 PasswordHash,
38 #[error("secure random generation failed")]
39 Random,
40}
41
42#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ScramVerifier {
45 pub mechanism: String,
46 pub iterations: u32,
47 pub salt: Vec<u8>,
48 pub stored_key: Vec<u8>,
49 pub server_key: Vec<u8>,
50}
51
52impl ScramVerifier {
53 pub fn from_password(
54 password: &str,
55 salt: &[u8],
56 iterations: u32,
57 ) -> Result<Self, SecurityHardeningError> {
58 if iterations < SCRAM_SHA_256_MIN_ITERATIONS {
59 return Err(SecurityHardeningError::ScramIterations(iterations));
60 }
61 let salted_password = pbkdf2_hmac_sha256(password.as_bytes(), salt, iterations);
62 let client_key = hmac_sha256(&salted_password, b"Client Key");
63 let stored_key = Sha256::digest(client_key);
64 let server_key = hmac_sha256(&salted_password, b"Server Key");
65 Ok(Self {
66 mechanism: "SCRAM-SHA-256".into(),
67 iterations,
68 salt: salt.to_vec(),
69 stored_key: stored_key.to_vec(),
70 server_key: server_key.to_vec(),
71 })
72 }
73
74 pub fn verify_password(&self, password: &str) -> bool {
75 let Ok(candidate) = Self::from_password(password, &self.salt, self.iterations) else {
76 return false;
77 };
78 bool::from(candidate.stored_key.ct_eq(&self.stored_key))
79 }
80}
81
82impl fmt::Debug for ScramVerifier {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.debug_struct("ScramVerifier")
85 .field("mechanism", &self.mechanism)
86 .field("iterations", &self.iterations)
87 .field("salt", &"<redacted>")
88 .field("stored_key", &"<redacted>")
89 .field("server_key", &"<redacted>")
90 .finish()
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ScramChannelBindingPolicy {
96 Disabled,
97 Required,
98}
99
100pub struct ScramServerSession {
102 verifier: ScramVerifier,
103 client_first_bare: String,
104 server_first: String,
105 combined_nonce: String,
106 channel_binding_policy: ScramChannelBindingPolicy,
107 expected_channel_binding: Vec<u8>,
108}
109
110pub struct ScramClientSession {
113 password: Zeroizing<String>,
114 client_first_bare: String,
115 client_nonce: String,
116 expected_server_signature: Option<[u8; 32]>,
117}
118
119impl ScramClientSession {
120 pub fn begin(
121 username: &str,
122 password: impl Into<String>,
123 client_nonce: &str,
124 ) -> Result<Self, SecurityHardeningError> {
125 validate_scram_nonce(client_nonce)?;
126 if username.is_empty() || username.bytes().any(|byte| matches!(byte, b',' | b'=')) {
127 return Err(SecurityHardeningError::ScramMessage(
128 "username must not be empty or contain ',' or '='".into(),
129 ));
130 }
131 Ok(Self {
132 password: Zeroizing::new(password.into()),
133 client_first_bare: format!("n={username},r={client_nonce}"),
134 client_nonce: client_nonce.into(),
135 expected_server_signature: None,
136 })
137 }
138
139 pub fn client_first_bare(&self) -> &str {
140 &self.client_first_bare
141 }
142
143 pub fn respond(
144 &mut self,
145 server_first: &str,
146 ) -> Result<(String, String), SecurityHardeningError> {
147 let combined_nonce = scram_attribute(server_first, "r")?;
148 if !combined_nonce.starts_with(&self.client_nonce)
149 || combined_nonce.len() == self.client_nonce.len()
150 {
151 return Err(SecurityHardeningError::ScramMessage(
152 "server nonce does not extend client nonce".into(),
153 ));
154 }
155 let salt = STANDARD
156 .decode(scram_attribute(server_first, "s")?)
157 .map_err(|_| SecurityHardeningError::ScramMessage("invalid SCRAM salt".into()))?;
158 let iterations = scram_attribute(server_first, "i")?
159 .parse::<u32>()
160 .map_err(|_| SecurityHardeningError::ScramMessage("invalid iteration count".into()))?;
161 if iterations < SCRAM_SHA_256_MIN_ITERATIONS {
162 return Err(SecurityHardeningError::ScramIterations(iterations));
163 }
164 let client_final = format!("c=biws,r={combined_nonce}");
165 let auth_message = format!("{},{server_first},{client_final}", self.client_first_bare);
166 let salted_password = pbkdf2_hmac_sha256(self.password.as_bytes(), &salt, iterations);
167 let client_key = hmac_sha256(&salted_password, b"Client Key");
168 let stored_key = Sha256::digest(client_key);
169 let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
170 let mut proof = [0_u8; 32];
171 for (output, (key, signature)) in proof
172 .iter_mut()
173 .zip(client_key.iter().zip(client_signature))
174 {
175 *output = key ^ signature;
176 }
177 let server_key = hmac_sha256(&salted_password, b"Server Key");
178 self.expected_server_signature = Some(hmac_sha256(&server_key, auth_message.as_bytes()));
179 Ok((client_final, STANDARD.encode(proof)))
180 }
181
182 pub fn verify_server_final(&self, server_final: &str) -> Result<(), SecurityHardeningError> {
183 let expected = self.expected_server_signature.ok_or_else(|| {
184 SecurityHardeningError::ScramMessage("client response missing".into())
185 })?;
186 let actual = STANDARD
187 .decode(scram_attribute(server_final, "v")?)
188 .map_err(|_| SecurityHardeningError::ScramProof)?;
189 if actual.len() != expected.len() || !bool::from(actual.as_slice().ct_eq(&expected)) {
190 return Err(SecurityHardeningError::ScramProof);
191 }
192 Ok(())
193 }
194}
195
196impl ScramServerSession {
197 pub fn begin(
198 verifier: ScramVerifier,
199 client_first_bare: impl Into<String>,
200 client_nonce: &str,
201 server_nonce: &str,
202 channel_binding_policy: ScramChannelBindingPolicy,
203 expected_channel_binding: Vec<u8>,
204 ) -> Result<Self, SecurityHardeningError> {
205 validate_scram_nonce(client_nonce)?;
206 validate_scram_nonce(server_nonce)?;
207 if channel_binding_policy == ScramChannelBindingPolicy::Required
208 && expected_channel_binding.is_empty()
209 {
210 return Err(SecurityHardeningError::ScramChannelBinding);
211 }
212 let client_first_bare = client_first_bare.into();
213 let nonce_attribute = scram_attribute(&client_first_bare, "r")?;
214 if nonce_attribute != client_nonce {
215 return Err(SecurityHardeningError::ScramMessage(
216 "client nonce does not match client-first message".into(),
217 ));
218 }
219 let combined_nonce = format!("{client_nonce}{server_nonce}");
220 let server_first = format!(
221 "r={combined_nonce},s={},i={}",
222 STANDARD.encode(&verifier.salt),
223 verifier.iterations
224 );
225 Ok(Self {
226 verifier,
227 client_first_bare,
228 server_first,
229 combined_nonce,
230 channel_binding_policy,
231 expected_channel_binding,
232 })
233 }
234
235 pub fn server_first_message(&self) -> &str {
236 &self.server_first
237 }
238
239 pub fn finish(
240 self,
241 client_final_without_proof: &str,
242 client_proof_base64: &str,
243 ) -> Result<String, SecurityHardeningError> {
244 if scram_attribute(client_final_without_proof, "r")? != self.combined_nonce {
245 return Err(SecurityHardeningError::ScramMessage(
246 "combined nonce mismatch".into(),
247 ));
248 }
249 let channel_binding = STANDARD
250 .decode(scram_attribute(client_final_without_proof, "c")?)
251 .map_err(|_| {
252 SecurityHardeningError::ScramMessage("invalid channel binding encoding".into())
253 })?;
254 match self.channel_binding_policy {
255 ScramChannelBindingPolicy::Disabled if channel_binding != b"n,," => {
256 return Err(SecurityHardeningError::ScramChannelBinding);
257 }
258 ScramChannelBindingPolicy::Required
259 if channel_binding != self.expected_channel_binding =>
260 {
261 return Err(SecurityHardeningError::ScramChannelBinding);
262 }
263 _ => {}
264 }
265
266 let proof = STANDARD
267 .decode(client_proof_base64)
268 .map_err(|_| SecurityHardeningError::ScramProof)?;
269 if proof.len() != 32 || self.verifier.stored_key.len() != 32 {
270 return Err(SecurityHardeningError::ScramProof);
271 }
272 let auth_message = format!(
273 "{},{},{}",
274 self.client_first_bare, self.server_first, client_final_without_proof
275 );
276 let client_signature = hmac_sha256(&self.verifier.stored_key, auth_message.as_bytes());
277 let mut recovered_client_key = [0_u8; 32];
278 for (output, (proof_byte, signature_byte)) in recovered_client_key
279 .iter_mut()
280 .zip(proof.iter().zip(client_signature))
281 {
282 *output = proof_byte ^ signature_byte;
283 }
284 let recovered_stored_key = Sha256::digest(recovered_client_key);
285 if !bool::from(
286 recovered_stored_key
287 .as_slice()
288 .ct_eq(&self.verifier.stored_key),
289 ) {
290 return Err(SecurityHardeningError::ScramProof);
291 }
292 let server_signature = hmac_sha256(&self.verifier.server_key, auth_message.as_bytes());
293 Ok(format!("v={}", STANDARD.encode(server_signature)))
294 }
295}
296
297fn validate_scram_nonce(nonce: &str) -> Result<(), SecurityHardeningError> {
298 if nonce.is_empty()
299 || nonce
300 .bytes()
301 .any(|byte| byte == b',' || !(0x21..=0x7e).contains(&byte))
302 {
303 return Err(SecurityHardeningError::ScramMessage(
304 "nonce must be printable ASCII without comma".into(),
305 ));
306 }
307 Ok(())
308}
309
310fn scram_attribute<'a>(message: &'a str, name: &str) -> Result<&'a str, SecurityHardeningError> {
311 message
312 .split(',')
313 .find_map(|attribute| attribute.strip_prefix(&format!("{name}=")))
314 .filter(|value| !value.is_empty())
315 .ok_or_else(|| SecurityHardeningError::ScramMessage(format!("missing {name} attribute")))
316}
317
318fn hmac_sha256(key: &[u8], input: &[u8]) -> [u8; 32] {
319 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
320 mac.update(input);
321 mac.finalize().into_bytes().into()
322}
323
324fn pbkdf2_hmac_sha256(password: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
325 let mut first_input = Vec::with_capacity(salt.len() + 4);
326 first_input.extend_from_slice(salt);
327 first_input.extend_from_slice(&1_u32.to_be_bytes());
328 let mut u = hmac_sha256(password, &first_input);
329 let mut output = u;
330 for _ in 1..iterations {
331 u = hmac_sha256(password, &u);
332 for (output_byte, u_byte) in output.iter_mut().zip(u) {
333 *output_byte ^= u_byte;
334 }
335 }
336 output
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340pub enum JwtAlgorithm {
341 Rs256,
342 Es256,
343}
344
345impl JwtAlgorithm {
346 fn parse(value: &str) -> Result<Self, JwtError> {
347 match value {
348 "RS256" => Ok(Self::Rs256),
349 "ES256" => Ok(Self::Es256),
350 "none" | "NONE" => Err(JwtError::Algorithm),
351 _ => Err(JwtError::Algorithm),
352 }
353 }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct JwtValidationConfig {
359 pub issuer: String,
360 pub audience: String,
361 pub skew_seconds: u64,
362 pub allowed_algorithms: Vec<JwtAlgorithm>,
363 pub max_token_age_seconds: u64,
364 pub required_scopes: Vec<String>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct JwtClaims {
369 pub iss: String,
370 pub aud: String,
371 pub exp: u64,
372 pub nbf: u64,
373 pub iat: u64,
374 pub sub: String,
375 #[serde(default)]
376 pub scope: String,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
380pub enum JwtError {
381 #[error("malformed JWT")]
382 Malformed,
383 #[error("JWT algorithm is not allowed")]
384 Algorithm,
385 #[error("JWT signing key is unavailable")]
386 Key,
387 #[error("JWT signature is invalid")]
388 Signature,
389 #[error("issuer mismatch")]
391 Issuer,
392 #[error("audience mismatch")]
394 Audience,
395 #[error("token not yet valid")]
397 NotYetValid,
398 #[error("token expired")]
400 Expired,
401 #[error("token issued-at time is invalid")]
402 IssuedAt,
403 #[error("required JWT scope is missing")]
404 Scope,
405 #[error("JWKS provider failed: {0}")]
406 JwksProvider(String),
407}
408
409pub fn validate_jwt_claims(
411 claims: &JwtClaims,
412 config: &JwtValidationConfig,
413 now_unix: u64,
414) -> Result<(), JwtError> {
415 if claims.iss != config.issuer {
416 return Err(JwtError::Issuer);
417 }
418 if claims.aud != config.audience {
419 return Err(JwtError::Audience);
420 }
421 let skew = config.skew_seconds;
422 if now_unix + skew < claims.nbf {
423 return Err(JwtError::NotYetValid);
424 }
425 if now_unix > claims.exp.saturating_add(skew) {
426 return Err(JwtError::Expired);
427 }
428 if claims.iat > now_unix.saturating_add(skew)
429 || (config.max_token_age_seconds != 0
430 && now_unix
431 > claims
432 .iat
433 .saturating_add(config.max_token_age_seconds)
434 .saturating_add(skew))
435 {
436 return Err(JwtError::IssuedAt);
437 }
438 let scopes = claims.scope.split_ascii_whitespace().collect::<Vec<_>>();
439 if config
440 .required_scopes
441 .iter()
442 .any(|required| !scopes.contains(&required.as_str()))
443 {
444 return Err(JwtError::Scope);
445 }
446 Ok(())
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct Jwk {
451 pub kid: String,
452 pub kty: String,
453 pub alg: String,
454 #[serde(default)]
455 pub key_use: Option<String>,
456 #[serde(default)]
457 pub n: Option<String>,
458 #[serde(default)]
459 pub e: Option<String>,
460 #[serde(default)]
461 pub crv: Option<String>,
462 #[serde(default)]
463 pub x: Option<String>,
464 #[serde(default)]
465 pub y: Option<String>,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
469pub struct JwksDocument {
470 pub keys: Vec<Jwk>,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct VerifiedJwt {
475 pub principal: String,
476 pub scopes: Vec<String>,
477 pub claims: JwtClaims,
478}
479
480#[derive(Debug, Deserialize)]
481struct JwtHeader {
482 alg: String,
483 kid: Option<String>,
484}
485
486pub fn verify_jwt(
488 token: &str,
489 config: &JwtValidationConfig,
490 jwks: &JwksDocument,
491 now_unix: u64,
492) -> Result<VerifiedJwt, JwtError> {
493 let mut parts = token.split('.');
494 let encoded_header = parts.next().ok_or(JwtError::Malformed)?;
495 let encoded_claims = parts.next().ok_or(JwtError::Malformed)?;
496 let encoded_signature = parts.next().ok_or(JwtError::Malformed)?;
497 if parts.next().is_some()
498 || encoded_header.is_empty()
499 || encoded_claims.is_empty()
500 || encoded_signature.is_empty()
501 {
502 return Err(JwtError::Malformed);
503 }
504 let header_bytes = URL_SAFE_NO_PAD
505 .decode(encoded_header)
506 .map_err(|_| JwtError::Malformed)?;
507 let header: JwtHeader =
508 serde_json::from_slice(&header_bytes).map_err(|_| JwtError::Malformed)?;
509 let algorithm = JwtAlgorithm::parse(&header.alg)?;
510 if !config.allowed_algorithms.contains(&algorithm) {
511 return Err(JwtError::Algorithm);
512 }
513 let kid = header.kid.as_deref().ok_or(JwtError::Key)?;
514 let jwk = jwks
515 .keys
516 .iter()
517 .find(|key| key.kid == kid)
518 .ok_or(JwtError::Key)?;
519 if jwk.alg != header.alg || jwk.key_use.as_deref().is_some_and(|usage| usage != "sig") {
520 return Err(JwtError::Algorithm);
521 }
522 let signature = URL_SAFE_NO_PAD
523 .decode(encoded_signature)
524 .map_err(|_| JwtError::Malformed)?;
525 let signing_input = format!("{encoded_header}.{encoded_claims}");
526 verify_jws_signature(algorithm, jwk, signing_input.as_bytes(), &signature)?;
527 let claims_bytes = URL_SAFE_NO_PAD
528 .decode(encoded_claims)
529 .map_err(|_| JwtError::Malformed)?;
530 let claims: JwtClaims =
531 serde_json::from_slice(&claims_bytes).map_err(|_| JwtError::Malformed)?;
532 validate_jwt_claims(&claims, config, now_unix)?;
533 Ok(VerifiedJwt {
534 principal: claims.sub.clone(),
535 scopes: claims
536 .scope
537 .split_ascii_whitespace()
538 .map(str::to_owned)
539 .collect(),
540 claims,
541 })
542}
543
544fn verify_jws_signature(
545 algorithm: JwtAlgorithm,
546 jwk: &Jwk,
547 signing_input: &[u8],
548 signature_bytes: &[u8],
549) -> Result<(), JwtError> {
550 match algorithm {
551 JwtAlgorithm::Rs256 => {
552 if jwk.kty != "RSA" {
553 return Err(JwtError::Algorithm);
554 }
555 let modulus = decode_jwk_component(jwk.n.as_deref())?;
556 let exponent = decode_jwk_component(jwk.e.as_deref())?;
557 ring::signature::RsaPublicKeyComponents {
558 n: &modulus,
559 e: &exponent,
560 }
561 .verify(
562 &ring::signature::RSA_PKCS1_2048_8192_SHA256,
563 signing_input,
564 signature_bytes,
565 )
566 .map_err(|_| JwtError::Signature)
567 }
568 JwtAlgorithm::Es256 => {
569 if jwk.kty != "EC" || jwk.crv.as_deref() != Some("P-256") {
570 return Err(JwtError::Algorithm);
571 }
572 let x = decode_jwk_component(jwk.x.as_deref())?;
573 let y = decode_jwk_component(jwk.y.as_deref())?;
574 if x.len() != 32 || y.len() != 32 {
575 return Err(JwtError::Key);
576 }
577 let mut public_key = Vec::with_capacity(65);
578 public_key.push(4);
579 public_key.extend_from_slice(&x);
580 public_key.extend_from_slice(&y);
581 ring::signature::UnparsedPublicKey::new(
582 &ring::signature::ECDSA_P256_SHA256_FIXED,
583 public_key,
584 )
585 .verify(signing_input, signature_bytes)
586 .map_err(|_| JwtError::Signature)
587 }
588 }
589}
590
591fn decode_jwk_component(value: Option<&str>) -> Result<Vec<u8>, JwtError> {
592 URL_SAFE_NO_PAD
593 .decode(value.ok_or(JwtError::Key)?)
594 .map_err(|_| JwtError::Key)
595}
596
597pub struct JwksFetch {
598 pub document: JwksDocument,
599 pub max_age_seconds: u64,
600}
601
602pub trait JwksProvider: Send + Sync {
605 fn fetch(&self, issuer: &str) -> Result<JwksFetch, JwtError>;
606}
607
608struct CachedJwks {
609 document: JwksDocument,
610 expires_unix: u64,
611}
612
613pub struct JwksCache<P> {
616 provider: P,
617 state: std::sync::Mutex<Option<CachedJwks>>,
618}
619
620impl<P: JwksProvider> JwksCache<P> {
621 pub fn new(provider: P) -> Self {
622 Self {
623 provider,
624 state: std::sync::Mutex::new(None),
625 }
626 }
627
628 pub fn verify(
629 &self,
630 token: &str,
631 config: &JwtValidationConfig,
632 now_unix: u64,
633 ) -> Result<VerifiedJwt, JwtError> {
634 let kid = jwt_kid(token)?;
635 let mut state = self
636 .state
637 .lock()
638 .map_err(|_| JwtError::JwksProvider("cache lock poisoned".into()))?;
639 let refresh = state.as_ref().is_none_or(|cached| {
640 now_unix >= cached.expires_unix
641 || !cached.document.keys.iter().any(|key| key.kid == kid)
642 });
643 if refresh {
644 let fetched = self.provider.fetch(&config.issuer)?;
645 if fetched.max_age_seconds == 0 {
646 return Err(JwtError::JwksProvider(
647 "JWKS response has zero cache lifetime".into(),
648 ));
649 }
650 *state = Some(CachedJwks {
651 document: fetched.document,
652 expires_unix: now_unix.saturating_add(fetched.max_age_seconds),
653 });
654 }
655 let cached = state.as_ref().ok_or(JwtError::Key)?;
656 verify_jwt(token, config, &cached.document, now_unix)
657 }
658}
659
660fn jwt_kid(token: &str) -> Result<String, JwtError> {
661 let encoded_header = token.split('.').next().ok_or(JwtError::Malformed)?;
662 let header: JwtHeader = serde_json::from_slice(
663 &URL_SAFE_NO_PAD
664 .decode(encoded_header)
665 .map_err(|_| JwtError::Malformed)?,
666 )
667 .map_err(|_| JwtError::Malformed)?;
668 header.kid.ok_or(JwtError::Key)
669}
670
671#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct ServiceToken {
674 pub token_id: String,
675 pub principal: String,
676 pub scopes: Vec<String>,
677 pub secret_hash_phc: String,
679 pub expires_unix: u64,
680}
681
682impl ServiceToken {
683 pub fn mint(
684 token_id: impl Into<String>,
685 principal: impl Into<String>,
686 scopes: Vec<String>,
687 raw_secret: &str,
688 expires_unix: u64,
689 ) -> Result<Self, SecurityHardeningError> {
690 let mut salt_bytes = [0_u8; 16];
691 getrandom::getrandom(&mut salt_bytes).map_err(|_| SecurityHardeningError::Random)?;
692 let salt = SaltString::encode_b64(&salt_bytes)
693 .map_err(|_| SecurityHardeningError::PasswordHash)?;
694 let secret_hash_phc = Argon2::default()
695 .hash_password(raw_secret.as_bytes(), &salt)
696 .map_err(|_| SecurityHardeningError::PasswordHash)?
697 .to_string();
698 Ok(Self {
699 token_id: token_id.into(),
700 principal: principal.into(),
701 scopes,
702 secret_hash_phc,
703 expires_unix,
704 })
705 }
706
707 pub fn verify_secret(&self, raw_secret: &str, now_unix: u64) -> bool {
708 if self.expires_unix != 0 && now_unix > self.expires_unix {
709 return false;
710 }
711 let Ok(hash) = PasswordHash::new(&self.secret_hash_phc) else {
712 return false;
713 };
714 Argon2::default()
715 .verify_password(raw_secret.as_bytes(), &hash)
716 .is_ok()
717 }
718
719 pub fn issue(
721 token_id: impl Into<String>,
722 principal: impl Into<String>,
723 scopes: Vec<String>,
724 expires_unix: u64,
725 ) -> Result<IssuedServiceToken, SecurityHardeningError> {
726 let mut secret_bytes = [0_u8; 32];
727 getrandom::getrandom(&mut secret_bytes).map_err(|_| SecurityHardeningError::Random)?;
728 let secret = Zeroizing::new(URL_SAFE_NO_PAD.encode(secret_bytes));
729 let metadata = Self::mint(token_id, principal, scopes, &secret, expires_unix)?;
730 Ok(IssuedServiceToken { metadata, secret })
731 }
732}
733
734impl fmt::Debug for ServiceToken {
735 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
736 formatter
737 .debug_struct("ServiceToken")
738 .field("token_id", &self.token_id)
739 .field("principal", &self.principal)
740 .field("scopes", &self.scopes)
741 .field("secret_hash_phc", &"<redacted>")
742 .field("expires_unix", &self.expires_unix)
743 .finish()
744 }
745}
746
747pub struct IssuedServiceToken {
749 pub metadata: ServiceToken,
750 secret: Zeroizing<String>,
751}
752
753impl IssuedServiceToken {
754 pub fn expose_secret_once(&self) -> &str {
755 self.secret.as_str()
756 }
757}
758
759impl fmt::Debug for IssuedServiceToken {
760 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
761 formatter
762 .debug_struct("IssuedServiceToken")
763 .field("metadata", &self.metadata)
764 .field("secret", &"<redacted>")
765 .finish()
766 }
767}
768
769#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct KmsWrappedKey {
772 pub kms_key_id: String,
774 pub key_version: String,
776 pub wrapped_dek: Vec<u8>,
778 pub algorithm: String,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
784pub struct KmsDatabaseKeyEnvelope {
785 pub provider_id: String,
786 pub wrapped_key: KmsWrappedKey,
787}
788
789#[derive(Debug, Clone, Copy, PartialEq, Eq)]
790pub enum KeyManagementHealth {
791 Ready,
792 Degraded,
793 Unavailable,
794 Unsupported,
795}
796
797#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
798pub enum KeyManagementError {
799 #[error("KMS is unsupported: {0}")]
800 Unsupported(String),
801 #[error("KMS is unavailable: {0}")]
802 Unavailable(String),
803 #[error("KMS request failed: {0}")]
804 Failed(String),
805}
806
807pub trait KeyManagementProvider: Send + Sync {
810 fn provider_id(&self) -> &str;
811 fn wrap_key(
812 &self,
813 key_id: &str,
814 plaintext_key: &[u8],
815 ) -> Result<KmsWrappedKey, KeyManagementError>;
816 fn unwrap_key(&self, wrapped: &KmsWrappedKey)
817 -> Result<Zeroizing<Vec<u8>>, KeyManagementError>;
818 fn rewrap_key(
819 &self,
820 wrapped: &KmsWrappedKey,
821 new_key_id: &str,
822 ) -> Result<KmsWrappedKey, KeyManagementError>;
823 fn provider_health(&self) -> KeyManagementHealth;
824}
825
826#[derive(Debug, Default)]
830pub struct UnsupportedKeyManagementProvider;
831
832impl KeyManagementProvider for UnsupportedKeyManagementProvider {
833 fn provider_id(&self) -> &str {
834 "unsupported"
835 }
836
837 fn wrap_key(
838 &self,
839 _key_id: &str,
840 _plaintext_key: &[u8],
841 ) -> Result<KmsWrappedKey, KeyManagementError> {
842 Err(KeyManagementError::Unsupported(
843 "no KeyManagementProvider is configured".into(),
844 ))
845 }
846
847 fn unwrap_key(
848 &self,
849 _wrapped: &KmsWrappedKey,
850 ) -> Result<Zeroizing<Vec<u8>>, KeyManagementError> {
851 Err(KeyManagementError::Unsupported(
852 "no KeyManagementProvider is configured".into(),
853 ))
854 }
855
856 fn rewrap_key(
857 &self,
858 _wrapped: &KmsWrappedKey,
859 _new_key_id: &str,
860 ) -> Result<KmsWrappedKey, KeyManagementError> {
861 Err(KeyManagementError::Unsupported(
862 "no KeyManagementProvider is configured".into(),
863 ))
864 }
865
866 fn provider_health(&self) -> KeyManagementHealth {
867 KeyManagementHealth::Unsupported
868 }
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "snake_case")]
873pub enum KeyRotationPhase {
874 Pending,
875 WrappingNewKey,
876 DualRead,
877 Reencrypting,
878 Validating,
879 Published,
880 RetiringOldKey,
881 Succeeded,
882 Failed,
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887pub struct KeyRotationRecord {
888 pub from_version: String,
889 pub to_version: String,
890 #[serde(default)]
891 pub from_key_id: String,
892 #[serde(default)]
893 pub to_key_id: String,
894 #[serde(default)]
895 pub staged_wrapped_key: Option<KmsWrappedKey>,
896 pub started_unix_micros: u64,
897 pub completed_unix_micros: Option<u64>,
898 pub phase: KeyRotationPhase,
899 pub attempt: u32,
900 pub last_error: Option<String>,
901}
902
903impl KeyRotationRecord {
904 pub fn new(
905 from_version: impl Into<String>,
906 to_version: impl Into<String>,
907 started_unix_micros: u64,
908 ) -> Self {
909 Self {
910 from_version: from_version.into(),
911 to_version: to_version.into(),
912 from_key_id: String::new(),
913 to_key_id: String::new(),
914 staged_wrapped_key: None,
915 started_unix_micros,
916 completed_unix_micros: None,
917 phase: KeyRotationPhase::Pending,
918 attempt: 0,
919 last_error: None,
920 }
921 }
922
923 pub fn advance(&mut self, now_unix_micros: u64) -> Result<(), KeyManagementError> {
924 self.phase = match self.phase {
925 KeyRotationPhase::Pending => KeyRotationPhase::WrappingNewKey,
926 KeyRotationPhase::WrappingNewKey => KeyRotationPhase::DualRead,
927 KeyRotationPhase::DualRead => KeyRotationPhase::Reencrypting,
928 KeyRotationPhase::Reencrypting => KeyRotationPhase::Validating,
929 KeyRotationPhase::Validating => KeyRotationPhase::Published,
930 KeyRotationPhase::Published => KeyRotationPhase::RetiringOldKey,
931 KeyRotationPhase::RetiringOldKey => {
932 self.completed_unix_micros = Some(now_unix_micros);
933 KeyRotationPhase::Succeeded
934 }
935 KeyRotationPhase::Succeeded => return Ok(()),
936 KeyRotationPhase::Failed => {
937 return Err(KeyManagementError::Failed(
938 "failed rotation must be explicitly retried".into(),
939 ));
940 }
941 };
942 self.attempt = self.attempt.saturating_add(1);
943 self.last_error = None;
944 Ok(())
945 }
946
947 pub fn fail(&mut self, error: impl Into<String>) {
948 self.phase = KeyRotationPhase::Failed;
949 self.last_error = Some(redact_secrets(&error.into()));
950 }
951
952 pub fn retry(&mut self) {
953 if self.phase == KeyRotationPhase::Failed {
954 self.phase = KeyRotationPhase::Pending;
955 self.completed_unix_micros = None;
956 self.last_error = None;
957 }
958 }
959}
960
961#[derive(Debug, Clone)]
963pub struct KeyRotationJournal {
964 path: PathBuf,
965}
966
967impl KeyRotationJournal {
968 pub fn new(root: impl AsRef<Path>) -> Self {
969 Self {
970 path: root.as_ref().join("_key_rotation.json"),
971 }
972 }
973
974 pub fn load(&self) -> Result<Option<KeyRotationRecord>, KeyManagementError> {
975 let Some(parent) = self.path.parent() else {
976 return Err(KeyManagementError::Failed(
977 "rotation journal has no parent directory".into(),
978 ));
979 };
980 let file_name = self
981 .path
982 .file_name()
983 .ok_or_else(|| KeyManagementError::Failed("invalid rotation journal path".into()))?;
984 let root = crate::durable_file::DurableRoot::open(parent)
985 .map_err(|error| KeyManagementError::Failed(error.to_string()))?;
986 let mut file = match root.open_regular(file_name) {
987 Ok(file) => file,
988 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
989 Err(error) => return Err(KeyManagementError::Failed(error.to_string())),
990 };
991 let mut bytes = Vec::new();
992 file.read_to_end(&mut bytes)
993 .map_err(|error| KeyManagementError::Failed(error.to_string()))?;
994 serde_json::from_slice(&bytes)
995 .map(Some)
996 .map_err(|error| KeyManagementError::Failed(error.to_string()))
997 }
998
999 pub fn persist(&self, record: &KeyRotationRecord) -> Result<(), KeyManagementError> {
1000 let bytes = serde_json::to_vec(record)
1001 .map_err(|error| KeyManagementError::Failed(error.to_string()))?;
1002 crate::durable_file::write_atomic(&self.path, &bytes)
1003 .map_err(|error| KeyManagementError::Failed(error.to_string()))
1004 }
1005}
1006
1007pub fn redact_secrets(input: &str) -> String {
1009 let mut out = input.to_owned();
1010 for key in [
1011 "password",
1012 "passwd",
1013 "secret",
1014 "api_key",
1015 "token",
1016 "private_key",
1017 "authorization",
1018 ] {
1019 let mut search_from = 0;
1020 loop {
1021 let lower = out.to_ascii_lowercase();
1022 let Some(relative) = lower[search_from..].find(key) else {
1023 break;
1024 };
1025 let key_start = search_from + relative;
1026 let mut value_start = key_start + key.len();
1027 while out
1028 .as_bytes()
1029 .get(value_start)
1030 .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'"' | b'\''))
1031 {
1032 value_start += 1;
1033 }
1034 if !matches!(out.as_bytes().get(value_start), Some(b'=') | Some(b':')) {
1035 search_from = value_start;
1036 continue;
1037 }
1038 value_start += 1;
1039 while out
1040 .as_bytes()
1041 .get(value_start)
1042 .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'"' | b'\''))
1043 {
1044 value_start += 1;
1045 }
1046 if key == "authorization"
1047 && out[value_start..]
1048 .to_ascii_lowercase()
1049 .starts_with("bearer ")
1050 {
1051 value_start += "bearer ".len();
1052 }
1053 let value_end = out[value_start..]
1054 .find(|character: char| {
1055 character.is_whitespace()
1056 || matches!(character, '"' | '\'' | ',' | ';' | '&' | '}')
1057 })
1058 .map_or(out.len(), |relative| value_start + relative);
1059 if value_end > value_start {
1060 out.replace_range(value_start..value_end, "***");
1061 search_from = value_start + 3;
1062 } else {
1063 search_from = value_start;
1064 }
1065 }
1066 }
1067 let mut search_from = 0;
1068 loop {
1069 let lower = out.to_ascii_lowercase();
1070 let Some(relative) = lower[search_from..].find("bearer ") else {
1071 break;
1072 };
1073 let value_start = search_from + relative + "bearer ".len();
1074 let value_end = out[value_start..]
1075 .find(|character: char| {
1076 character.is_whitespace() || matches!(character, '"' | '\'' | ',' | ';')
1077 })
1078 .map_or(out.len(), |relative| value_start + relative);
1079 if value_end > value_start {
1080 out.replace_range(value_start..value_end, "***");
1081 search_from = value_start + 3;
1082 } else {
1083 break;
1084 }
1085 }
1086 out
1087}
1088
1089pub fn node_cert_matches_id(cert_cn_or_san: &str, node_id_hex: &str) -> bool {
1092 let expected = format!("node-{node_id_hex}.mongreldb.cluster");
1093 cert_cn_or_san.eq_ignore_ascii_case(&expected)
1094}
1095
1096#[derive(Debug, Default, Clone)]
1098pub struct ServiceTokenRegistry {
1099 tokens: BTreeMap<String, ServiceToken>,
1100}
1101
1102impl ServiceTokenRegistry {
1103 pub fn upsert(&mut self, token: ServiceToken) {
1105 self.tokens.insert(token.token_id.clone(), token);
1106 }
1107
1108 pub fn authenticate(
1110 &self,
1111 token_id: &str,
1112 raw_secret: &str,
1113 now_unix: u64,
1114 ) -> Option<&ServiceToken> {
1115 let t = self.tokens.get(token_id)?;
1116 if t.verify_secret(raw_secret, now_unix) {
1117 Some(t)
1118 } else {
1119 None
1120 }
1121 }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127
1128 #[test]
1129 fn scram_sha_256_matches_rfc_7677_exchange() {
1130 let salt = STANDARD.decode("W22ZaJ0SNY7soEsUEjb6gQ==").unwrap();
1131 let v = ScramVerifier::from_password("pencil", &salt, 4096).unwrap();
1132 assert!(v.verify_password("pencil"));
1133 assert!(!v.verify_password("wrong"));
1134 let dbg = format!("{v:?}");
1135 assert!(dbg.contains("<redacted>"));
1136 assert!(!dbg.contains("pencil"));
1137
1138 let client_nonce = "rOprNGfwEbeRWgbNEkqO";
1139 let server_nonce = "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0";
1140 let session = ScramServerSession::begin(
1141 v,
1142 format!("n=user,r={client_nonce}"),
1143 client_nonce,
1144 server_nonce,
1145 ScramChannelBindingPolicy::Disabled,
1146 Vec::new(),
1147 )
1148 .unwrap();
1149 assert_eq!(
1150 session.server_first_message(),
1151 "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"
1152 );
1153 let server_final = session
1154 .finish(
1155 "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0",
1156 "dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=",
1157 )
1158 .unwrap();
1159 assert_eq!(
1160 server_final,
1161 "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="
1162 );
1163 }
1164
1165 #[test]
1166 fn scram_rejects_weak_iterations_bad_proof_and_missing_channel_binding() {
1167 assert!(matches!(
1168 ScramVerifier::from_password("password", b"salt", 1024),
1169 Err(SecurityHardeningError::ScramIterations(1024))
1170 ));
1171 let verifier = ScramVerifier::from_password("password", b"salt", 4096).unwrap();
1172 let session = ScramServerSession::begin(
1173 verifier,
1174 "n=user,r=client",
1175 "client",
1176 "server",
1177 ScramChannelBindingPolicy::Required,
1178 b"p=tls-exporter,,binding".to_vec(),
1179 )
1180 .unwrap();
1181 assert!(matches!(
1182 session.finish(
1183 "c=biws,r=clientserver",
1184 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
1185 ),
1186 Err(SecurityHardeningError::ScramChannelBinding)
1187 ));
1188 }
1189
1190 #[test]
1191 fn scram_client_and_server_complete_same_exchange() {
1192 let verifier = ScramVerifier::from_password("secret", b"0123456789abcdef", 4096).unwrap();
1193 let mut client = ScramClientSession::begin("alice", "secret", "clientnonce").unwrap();
1194 let server = ScramServerSession::begin(
1195 verifier,
1196 client.client_first_bare(),
1197 "clientnonce",
1198 "servernonce",
1199 ScramChannelBindingPolicy::Disabled,
1200 Vec::new(),
1201 )
1202 .unwrap();
1203 let (client_final, proof) = client.respond(server.server_first_message()).unwrap();
1204 let server_final = server.finish(&client_final, &proof).unwrap();
1205 client.verify_server_final(&server_final).unwrap();
1206 }
1207
1208 #[test]
1209 fn jwt_claims_validation() {
1210 let cfg = JwtValidationConfig {
1211 issuer: "https://issuer.example".into(),
1212 audience: "mongreldb".into(),
1213 skew_seconds: 60,
1214 allowed_algorithms: vec![JwtAlgorithm::Es256],
1215 max_token_age_seconds: 3_600,
1216 required_scopes: vec!["read".into()],
1217 };
1218 let claims = JwtClaims {
1219 iss: cfg.issuer.clone(),
1220 aud: cfg.audience.clone(),
1221 exp: 1_000,
1222 nbf: 100,
1223 iat: 100,
1224 sub: "user-1".into(),
1225 scope: "read write".into(),
1226 };
1227 assert!(validate_jwt_claims(&claims, &cfg, 500).is_ok());
1228 assert!(matches!(
1229 validate_jwt_claims(&claims, &cfg, 2_000).unwrap_err(),
1230 JwtError::Expired
1231 ));
1232 }
1233
1234 fn signed_es256_token(
1235 kid: &str,
1236 claims: &JwtClaims,
1237 ) -> (String, Jwk, ring::signature::EcdsaKeyPair) {
1238 use ring::signature::KeyPair;
1239
1240 let random = ring::rand::SystemRandom::new();
1241 let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
1242 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1243 &random,
1244 )
1245 .unwrap();
1246 let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
1247 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1248 pkcs8.as_ref(),
1249 &random,
1250 )
1251 .unwrap();
1252 let header = URL_SAFE_NO_PAD.encode(
1253 serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":kid,"typ":"JWT"})).unwrap(),
1254 );
1255 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap());
1256 let signing_input = format!("{header}.{payload}");
1257 let signature = key_pair.sign(&random, signing_input.as_bytes()).unwrap();
1258 let token = format!(
1259 "{signing_input}.{}",
1260 URL_SAFE_NO_PAD.encode(signature.as_ref())
1261 );
1262 let public_key = key_pair.public_key().as_ref();
1263 assert_eq!(public_key.len(), 65);
1264 let jwk = Jwk {
1265 kid: kid.into(),
1266 kty: "EC".into(),
1267 alg: "ES256".into(),
1268 key_use: Some("sig".into()),
1269 n: None,
1270 e: None,
1271 crv: Some("P-256".into()),
1272 x: Some(URL_SAFE_NO_PAD.encode(&public_key[1..33])),
1273 y: Some(URL_SAFE_NO_PAD.encode(&public_key[33..65])),
1274 };
1275 (token, jwk, key_pair)
1276 }
1277
1278 fn jwt_config() -> JwtValidationConfig {
1279 JwtValidationConfig {
1280 issuer: "https://issuer.example".into(),
1281 audience: "mongreldb".into(),
1282 skew_seconds: 30,
1283 allowed_algorithms: vec![JwtAlgorithm::Es256],
1284 max_token_age_seconds: 600,
1285 required_scopes: vec!["read".into()],
1286 }
1287 }
1288
1289 fn jwt_claims(now: u64) -> JwtClaims {
1290 JwtClaims {
1291 iss: "https://issuer.example".into(),
1292 aud: "mongreldb".into(),
1293 exp: now + 300,
1294 nbf: now - 1,
1295 iat: now - 1,
1296 sub: "alice".into(),
1297 scope: "read write".into(),
1298 }
1299 }
1300
1301 #[test]
1302 fn jwt_verifies_signature_and_rejects_tampering_and_algorithm_confusion() {
1303 let now = 10_000;
1304 let (token, jwk, _) = signed_es256_token("key-1", &jwt_claims(now));
1305 let keys = JwksDocument {
1306 keys: vec![jwk.clone()],
1307 };
1308 let verified = verify_jwt(&token, &jwt_config(), &keys, now).unwrap();
1309 assert_eq!(verified.principal, "alice");
1310 assert_eq!(verified.scopes, ["read", "write"]);
1311
1312 let mut parts = token.split('.').map(str::to_owned).collect::<Vec<_>>();
1313 let mut signature = URL_SAFE_NO_PAD.decode(&parts[2]).unwrap();
1314 signature[0] ^= 1;
1315 parts[2] = URL_SAFE_NO_PAD.encode(signature);
1316 assert!(matches!(
1317 verify_jwt(&parts.join("."), &jwt_config(), &keys, now),
1318 Err(JwtError::Signature)
1319 ));
1320
1321 let confused_header = URL_SAFE_NO_PAD
1322 .encode(serde_json::to_vec(&serde_json::json!({"alg":"RS256","kid":"key-1"})).unwrap());
1323 parts[0] = confused_header;
1324 assert!(matches!(
1325 verify_jwt(&parts.join("."), &jwt_config(), &keys, now),
1326 Err(JwtError::Algorithm)
1327 ));
1328 }
1329
1330 struct RotatingJwksProvider {
1331 responses: std::sync::Mutex<std::collections::VecDeque<JwksFetch>>,
1332 }
1333
1334 impl JwksProvider for RotatingJwksProvider {
1335 fn fetch(&self, _issuer: &str) -> Result<JwksFetch, JwtError> {
1336 self.responses
1337 .lock()
1338 .unwrap()
1339 .pop_front()
1340 .ok_or_else(|| JwtError::JwksProvider("injected outage".into()))
1341 }
1342 }
1343
1344 #[test]
1345 fn jwks_cache_refreshes_unknown_kid_and_fails_closed_on_outage() {
1346 let now = 20_000;
1347 let (token_one, key_one, _) = signed_es256_token("key-1", &jwt_claims(now));
1348 let (token_two, key_two, _) = signed_es256_token("key-2", &jwt_claims(now));
1349 let cache = JwksCache::new(RotatingJwksProvider {
1350 responses: std::sync::Mutex::new(std::collections::VecDeque::from([
1351 JwksFetch {
1352 document: JwksDocument {
1353 keys: vec![key_one],
1354 },
1355 max_age_seconds: 300,
1356 },
1357 JwksFetch {
1358 document: JwksDocument {
1359 keys: vec![key_two],
1360 },
1361 max_age_seconds: 300,
1362 },
1363 ])),
1364 });
1365 assert_eq!(
1366 cache
1367 .verify(&token_one, &jwt_config(), now)
1368 .unwrap()
1369 .principal,
1370 "alice"
1371 );
1372 assert_eq!(
1373 cache
1374 .verify(&token_two, &jwt_config(), now)
1375 .unwrap()
1376 .principal,
1377 "alice"
1378 );
1379 assert!(matches!(
1380 cache.verify(&token_one, &jwt_config(), now),
1381 Err(JwtError::JwksProvider(_))
1382 ));
1383 }
1384
1385 #[test]
1386 fn service_token_stores_hash_only() {
1387 let t = ServiceToken::mint("t1", "svc", vec!["read".into()], "raw-secret", 0).unwrap();
1388 assert!(!format!("{t:?}").contains("raw-secret"));
1389 assert!(t.secret_hash_phc.starts_with("$argon2id$"));
1390 assert!(t.verify_secret("raw-secret", 0));
1391 assert!(!t.verify_secret("nope", 0));
1392 let issued = ServiceToken::issue("t2", "svc", vec!["write".into()], 0).unwrap();
1393 assert!(issued
1394 .metadata
1395 .verify_secret(issued.expose_secret_once(), 0));
1396 assert!(!format!("{issued:?}").contains(issued.expose_secret_once()));
1397 }
1398
1399 #[test]
1400 fn redact_secrets_strips_every_occurrence_and_structured_values() {
1401 let line = redact_secrets(
1402 r#"password=hunter2 password="second" {"token":"third"} Authorization: Bearer fourth"#,
1403 );
1404 assert_eq!(line.matches("***").count(), 4);
1405 assert!(!line.contains("hunter2"));
1406 assert!(!line.contains("second"));
1407 assert!(!line.contains("third"));
1408 assert!(!line.contains("fourth"));
1409 }
1410
1411 #[test]
1412 fn node_cert_binding() {
1413 let hex = "00112233445566778899aabbccddeeff";
1414 assert!(node_cert_matches_id(
1415 &format!("node-{hex}.mongreldb.cluster"),
1416 hex
1417 ));
1418 assert!(!node_cert_matches_id("other.example", hex));
1419 }
1420
1421 #[test]
1422 fn kms_is_explicitly_unsupported_without_provider() {
1423 let provider = UnsupportedKeyManagementProvider;
1424 assert_eq!(provider.provider_health(), KeyManagementHealth::Unsupported);
1425 assert!(matches!(
1426 provider.wrap_key("key", b"plaintext"),
1427 Err(KeyManagementError::Unsupported(_))
1428 ));
1429 }
1430
1431 #[test]
1432 fn rotation_journal_resumes_from_every_phase() {
1433 let dir = tempfile::tempdir().unwrap();
1434 let journal = KeyRotationJournal::new(dir.path());
1435 let mut record = KeyRotationRecord::new("old", "new", 1);
1436 loop {
1437 journal.persist(&record).unwrap();
1438 let recovered = journal.load().unwrap().unwrap();
1439 assert_eq!(recovered, record);
1440 if record.phase == KeyRotationPhase::Succeeded {
1441 break;
1442 }
1443 record.advance(99).unwrap();
1444 }
1445 assert_eq!(record.completed_unix_micros, Some(99));
1446 }
1447}