1use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ScramVerifier {
19 pub mechanism: String,
21 pub iterations: u32,
23 pub salt: Vec<u8>,
25 pub stored_key: Vec<u8>,
27 pub server_key: Vec<u8>,
29}
30
31impl ScramVerifier {
32 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct JwtValidationConfig {
72 pub issuer: String,
74 pub audience: String,
76 pub skew_seconds: u64,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct JwtClaims {
83 pub iss: String,
85 pub aud: String,
87 pub exp: u64,
89 pub nbf: u64,
91 pub sub: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97pub enum JwtError {
98 #[error("issuer mismatch")]
100 Issuer,
101 #[error("audience mismatch")]
103 Audience,
104 #[error("token not yet valid")]
106 NotYetValid,
107 #[error("token expired")]
109 Expired,
110}
111
112pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct ServiceToken {
137 pub token_id: String,
139 pub principal: String,
141 pub scopes: Vec<String>,
143 pub secret_sha256: String,
145 pub expires_unix: u64,
147}
148
149impl ServiceToken {
150 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct KmsWrappedKey {
179 pub kms_key_id: String,
181 pub key_version: String,
183 pub wrapped_dek: Vec<u8>,
185 pub algorithm: String,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct KeyRotationRecord {
192 pub from_version: String,
194 pub to_version: String,
196 pub started_unix_micros: u64,
198 pub completed_unix_micros: Option<u64>,
200}
201
202pub 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
228pub 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#[derive(Debug, Default, Clone)]
250pub struct ServiceTokenRegistry {
251 tokens: BTreeMap<String, ServiceToken>,
252}
253
254impl ServiceTokenRegistry {
255 pub fn upsert(&mut self, token: ServiceToken) {
257 self.tokens.insert(token.token_id.clone(), token);
258 }
259
260 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}