Skip to main content

mongreldb_core/
security_hardening.rs

1//! Security hardening helpers (spec section 14.3, Stage 5C).
2//!
3//! Cluster mTLS is already enforced in `mongreldb-cluster` transport. This
4//! module owns production credential primitives and explicit unsupported
5//! boundaries for security services that require an operator integration.
6
7use 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/// Durable SCRAM-SHA-256 verifier material.
43#[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
100/// One server-side SCRAM-SHA-256 exchange.
101pub 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
110/// Client half of one SCRAM-SHA-256 exchange. The password is zeroized when
111/// the exchange finishes or is dropped.
112pub 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/// OIDC/JWT validation policy.
357#[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    /// Issuer mismatch.
390    #[error("issuer mismatch")]
391    Issuer,
392    /// Audience mismatch.
393    #[error("audience mismatch")]
394    Audience,
395    /// Token not yet valid.
396    #[error("token not yet valid")]
397    NotYetValid,
398    /// Token expired.
399    #[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
409/// Validate claims against config at `now_unix`.
410pub 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
486/// Verify a compact JWS before trusting any claims.
487pub 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
602/// Fetch boundary. A production adapter performs OIDC discovery and HTTPS
603/// retrieval with normal certificate validation.
604pub 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
613/// Thread-safe JWKS cache. Expiry and unknown `kid` both trigger one refresh;
614/// provider failure never falls back to stale keys.
615pub 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/// Service token metadata. The raw bearer secret is never stored.
672#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct ServiceToken {
674    pub token_id: String,
675    pub principal: String,
676    pub scopes: Vec<String>,
677    /// Argon2id PHC string with a unique random salt.
678    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    /// Issue a random 256-bit bearer secret. The caller receives it once.
720    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
747/// Newly issued token. `secret` is zeroized on drop and omitted from Debug.
748pub 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/// KMS-wrapped data encryption key metadata (spec §14.3).
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct KmsWrappedKey {
772    /// KMS key id.
773    pub kms_key_id: String,
774    /// Key version for online rotation.
775    pub key_version: String,
776    /// Wrapped DEK ciphertext.
777    pub wrapped_dek: Vec<u8>,
778    /// Algorithm id.
779    pub algorithm: String,
780}
781
782/// Durable database root-key envelope. Plaintext key bytes are never stored.
783#[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
807/// Provider boundary for external key management. Implementations must never
808/// log plaintext keys.
809pub 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/// Explicit fail-closed provider used when no production KMS integration is
827/// configured. KMS-backed encryption must not silently fall back to local
828/// metadata-only behavior.
829#[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/// Persistent online key rotation state.
886#[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/// Crash-safe journal for one online key rotation.
962#[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
1007/// Redact secrets from a free-form log/audit line.
1008pub 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
1089/// Node-cert ↔ node-id join enforcement helper: the CN/SAN must equal
1090/// `node-<hex>.mongreldb.cluster` for the presented node id.
1091pub 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/// Registry of service tokens (in-memory; durable store is catalog/settings).
1097#[derive(Debug, Default, Clone)]
1098pub struct ServiceTokenRegistry {
1099    tokens: BTreeMap<String, ServiceToken>,
1100}
1101
1102impl ServiceTokenRegistry {
1103    /// Insert or replace.
1104    pub fn upsert(&mut self, token: ServiceToken) {
1105        self.tokens.insert(token.token_id.clone(), token);
1106    }
1107
1108    /// Authenticate by token id + raw secret.
1109    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}