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 adds SCRAM-style password verifiers, OIDC/JWT claim validation
5//! seams, service tokens, KMS wrap metadata, online rotation records, and
6//! secret redaction used by logs/audit.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14/// SCRAM-style salted password verifier (does not implement the full wire
15/// exchange โ€” that lives in the protocol adapter; this stores the durable
16/// verifier material).
17#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ScramVerifier {
19    /// Mechanism name (e.g. `SCRAM-SHA-256`).
20    pub mechanism: String,
21    /// Iteration count.
22    pub iterations: u32,
23    /// Salt (base64 or hex; stored opaque).
24    pub salt: Vec<u8>,
25    /// Stored key.
26    pub stored_key: Vec<u8>,
27    /// Server key.
28    pub server_key: Vec<u8>,
29}
30
31impl ScramVerifier {
32    /// Build a deterministic test verifier from a password (NOT for production
33    /// key stretching โ€” production uses a proper PBKDF). Useful for unit tests
34    /// of the storage shape and redaction.
35    pub fn from_password_for_tests(password: &str, salt: &[u8], iterations: u32) -> Self {
36        let mut hasher = Sha256::new();
37        hasher.update(salt);
38        hasher.update(password.as_bytes());
39        hasher.update(iterations.to_le_bytes());
40        let digest = hasher.finalize();
41        Self {
42            mechanism: "SCRAM-SHA-256".into(),
43            iterations,
44            salt: salt.to_vec(),
45            stored_key: digest.to_vec(),
46            server_key: digest.to_vec(),
47        }
48    }
49
50    /// Verify a password against this stored verifier (test KDF).
51    pub fn verify_password_for_tests(&self, password: &str) -> bool {
52        let candidate = Self::from_password_for_tests(password, &self.salt, self.iterations);
53        candidate.stored_key == self.stored_key
54    }
55}
56
57impl fmt::Debug for ScramVerifier {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.debug_struct("ScramVerifier")
60            .field("mechanism", &self.mechanism)
61            .field("iterations", &self.iterations)
62            .field("salt", &"<redacted>")
63            .field("stored_key", &"<redacted>")
64            .field("server_key", &"<redacted>")
65            .finish()
66    }
67}
68
69/// OIDC/JWT validation config (claim checks only; signature via injected key).
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct JwtValidationConfig {
72    /// Expected issuer.
73    pub issuer: String,
74    /// Expected audience.
75    pub audience: String,
76    /// Clock skew allowance seconds.
77    pub skew_seconds: u64,
78}
79
80/// Minimal JWT-like claims map for validation tests.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct JwtClaims {
83    /// Issuer.
84    pub iss: String,
85    /// Audience.
86    pub aud: String,
87    /// Expiration unix seconds.
88    pub exp: u64,
89    /// Not-before unix seconds.
90    pub nbf: u64,
91    /// Subject.
92    pub sub: String,
93}
94
95/// Why JWT validation failed.
96#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97pub enum JwtError {
98    /// Issuer mismatch.
99    #[error("issuer mismatch")]
100    Issuer,
101    /// Audience mismatch.
102    #[error("audience mismatch")]
103    Audience,
104    /// Token not yet valid.
105    #[error("token not yet valid")]
106    NotYetValid,
107    /// Token expired.
108    #[error("token expired")]
109    Expired,
110}
111
112/// Validate claims against config at `now_unix`.
113pub fn validate_jwt_claims(
114    claims: &JwtClaims,
115    config: &JwtValidationConfig,
116    now_unix: u64,
117) -> Result<(), JwtError> {
118    if claims.iss != config.issuer {
119        return Err(JwtError::Issuer);
120    }
121    if claims.aud != config.audience {
122        return Err(JwtError::Audience);
123    }
124    let skew = config.skew_seconds;
125    if now_unix + skew < claims.nbf {
126        return Err(JwtError::NotYetValid);
127    }
128    if now_unix > claims.exp.saturating_add(skew) {
129        return Err(JwtError::Expired);
130    }
131    Ok(())
132}
133
134/// Service token (bearer) bound to a principal name and scope set.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct ServiceToken {
137    /// Token id.
138    pub token_id: String,
139    /// Principal name.
140    pub principal: String,
141    /// Scopes.
142    pub scopes: Vec<String>,
143    /// SHA-256 of the raw secret (never store the raw secret).
144    pub secret_sha256: String,
145    /// Expiry unix seconds (`0` = non-expiring).
146    pub expires_unix: u64,
147}
148
149impl ServiceToken {
150    /// Mint metadata from a raw secret (stores only the hash).
151    pub fn mint(
152        token_id: impl Into<String>,
153        principal: impl Into<String>,
154        scopes: Vec<String>,
155        raw_secret: &str,
156        expires_unix: u64,
157    ) -> Self {
158        Self {
159            token_id: token_id.into(),
160            principal: principal.into(),
161            scopes,
162            secret_sha256: sha256_hex(raw_secret.as_bytes()),
163            expires_unix,
164        }
165    }
166
167    /// Verify a presented raw secret.
168    pub fn verify_secret(&self, raw_secret: &str, now_unix: u64) -> bool {
169        if self.expires_unix != 0 && now_unix > self.expires_unix {
170            return false;
171        }
172        self.secret_sha256 == sha256_hex(raw_secret.as_bytes())
173    }
174}
175
176/// KMS-wrapped data encryption key metadata (spec ยง14.3).
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct KmsWrappedKey {
179    /// KMS key id.
180    pub kms_key_id: String,
181    /// Key version for online rotation.
182    pub key_version: String,
183    /// Wrapped DEK ciphertext.
184    pub wrapped_dek: Vec<u8>,
185    /// Algorithm id.
186    pub algorithm: String,
187}
188
189/// Online key rotation record.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct KeyRotationRecord {
192    /// Previous key version.
193    pub from_version: String,
194    /// New key version.
195    pub to_version: String,
196    /// Rotation started unix micros.
197    pub started_unix_micros: u64,
198    /// Rotation completed unix micros (`None` while in progress).
199    pub completed_unix_micros: Option<u64>,
200}
201
202/// Redact secrets from a free-form log/audit line.
203pub fn redact_secrets(input: &str) -> String {
204    let mut out = input.to_owned();
205    for needle in [
206        "password=",
207        "password:",
208        "passwd=",
209        "secret=",
210        "api_key=",
211        "token=",
212        "private_key=",
213        "Authorization:",
214        "Bearer ",
215    ] {
216        if let Some(pos) = out.to_ascii_lowercase().find(&needle.to_ascii_lowercase()) {
217            let start = pos + needle.len();
218            let rest = &out[start..];
219            let end_rel = rest
220                .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ',')
221                .unwrap_or(rest.len());
222            out.replace_range(start..start + end_rel, "***");
223        }
224    }
225    out
226}
227
228/// Node-cert โ†” node-id join enforcement helper: the CN/SAN must equal
229/// `node-<hex>.mongreldb.cluster` for the presented node id.
230pub fn node_cert_matches_id(cert_cn_or_san: &str, node_id_hex: &str) -> bool {
231    let expected = format!("node-{node_id_hex}.mongreldb.cluster");
232    cert_cn_or_san.eq_ignore_ascii_case(&expected)
233}
234
235fn sha256_hex(bytes: &[u8]) -> String {
236    let mut hasher = Sha256::new();
237    hasher.update(bytes);
238    let digest = hasher.finalize();
239    const HEX: &[u8; 16] = b"0123456789abcdef";
240    let mut out = String::with_capacity(64);
241    for b in digest {
242        out.push(HEX[(b >> 4) as usize] as char);
243        out.push(HEX[(b & 0x0f) as usize] as char);
244    }
245    out
246}
247
248/// Registry of service tokens (in-memory; durable store is catalog/settings).
249#[derive(Debug, Default, Clone)]
250pub struct ServiceTokenRegistry {
251    tokens: BTreeMap<String, ServiceToken>,
252}
253
254impl ServiceTokenRegistry {
255    /// Insert or replace.
256    pub fn upsert(&mut self, token: ServiceToken) {
257        self.tokens.insert(token.token_id.clone(), token);
258    }
259
260    /// Authenticate by token id + raw secret.
261    pub fn authenticate(
262        &self,
263        token_id: &str,
264        raw_secret: &str,
265        now_unix: u64,
266    ) -> Option<&ServiceToken> {
267        let t = self.tokens.get(token_id)?;
268        if t.verify_secret(raw_secret, now_unix) {
269            Some(t)
270        } else {
271            None
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn scram_verifier_round_trip_and_redacted_debug() {
282        let v = ScramVerifier::from_password_for_tests("s3cret", b"salt", 10_000);
283        assert!(v.verify_password_for_tests("s3cret"));
284        assert!(!v.verify_password_for_tests("wrong"));
285        let dbg = format!("{v:?}");
286        assert!(dbg.contains("<redacted>"));
287        assert!(!dbg.contains("s3cret"));
288    }
289
290    #[test]
291    fn jwt_claims_validation() {
292        let cfg = JwtValidationConfig {
293            issuer: "https://issuer.example".into(),
294            audience: "mongreldb".into(),
295            skew_seconds: 60,
296        };
297        let claims = JwtClaims {
298            iss: cfg.issuer.clone(),
299            aud: cfg.audience.clone(),
300            exp: 1_000,
301            nbf: 100,
302            sub: "user-1".into(),
303        };
304        assert!(validate_jwt_claims(&claims, &cfg, 500).is_ok());
305        assert!(matches!(
306            validate_jwt_claims(&claims, &cfg, 2_000).unwrap_err(),
307            JwtError::Expired
308        ));
309    }
310
311    #[test]
312    fn service_token_stores_hash_only() {
313        let t = ServiceToken::mint("t1", "svc", vec!["read".into()], "raw-secret", 0);
314        assert!(!format!("{t:?}").contains("raw-secret") || t.secret_sha256.len() == 64);
315        assert!(t.verify_secret("raw-secret", 0));
316        assert!(!t.verify_secret("nope", 0));
317    }
318
319    #[test]
320    fn redact_secrets_strips_password() {
321        let line = redact_secrets("login user=alice password=hunter2 ok");
322        assert!(line.contains("***"));
323        assert!(!line.contains("hunter2"));
324    }
325
326    #[test]
327    fn node_cert_binding() {
328        let hex = "00112233445566778899aabbccddeeff";
329        assert!(node_cert_matches_id(
330            &format!("node-{hex}.mongreldb.cluster"),
331            hex
332        ));
333        assert!(!node_cert_matches_id("other.example", hex));
334    }
335}