use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScramVerifier {
pub mechanism: String,
pub iterations: u32,
pub salt: Vec<u8>,
pub stored_key: Vec<u8>,
pub server_key: Vec<u8>,
}
impl ScramVerifier {
pub fn from_password_for_tests(password: &str, salt: &[u8], iterations: u32) -> Self {
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(password.as_bytes());
hasher.update(iterations.to_le_bytes());
let digest = hasher.finalize();
Self {
mechanism: "SCRAM-SHA-256".into(),
iterations,
salt: salt.to_vec(),
stored_key: digest.to_vec(),
server_key: digest.to_vec(),
}
}
pub fn verify_password_for_tests(&self, password: &str) -> bool {
let candidate = Self::from_password_for_tests(password, &self.salt, self.iterations);
candidate.stored_key == self.stored_key
}
}
impl fmt::Debug for ScramVerifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ScramVerifier")
.field("mechanism", &self.mechanism)
.field("iterations", &self.iterations)
.field("salt", &"<redacted>")
.field("stored_key", &"<redacted>")
.field("server_key", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JwtValidationConfig {
pub issuer: String,
pub audience: String,
pub skew_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JwtClaims {
pub iss: String,
pub aud: String,
pub exp: u64,
pub nbf: u64,
pub sub: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum JwtError {
#[error("issuer mismatch")]
Issuer,
#[error("audience mismatch")]
Audience,
#[error("token not yet valid")]
NotYetValid,
#[error("token expired")]
Expired,
}
pub fn validate_jwt_claims(
claims: &JwtClaims,
config: &JwtValidationConfig,
now_unix: u64,
) -> Result<(), JwtError> {
if claims.iss != config.issuer {
return Err(JwtError::Issuer);
}
if claims.aud != config.audience {
return Err(JwtError::Audience);
}
let skew = config.skew_seconds;
if now_unix + skew < claims.nbf {
return Err(JwtError::NotYetValid);
}
if now_unix > claims.exp.saturating_add(skew) {
return Err(JwtError::Expired);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceToken {
pub token_id: String,
pub principal: String,
pub scopes: Vec<String>,
pub secret_sha256: String,
pub expires_unix: u64,
}
impl ServiceToken {
pub fn mint(
token_id: impl Into<String>,
principal: impl Into<String>,
scopes: Vec<String>,
raw_secret: &str,
expires_unix: u64,
) -> Self {
Self {
token_id: token_id.into(),
principal: principal.into(),
scopes,
secret_sha256: sha256_hex(raw_secret.as_bytes()),
expires_unix,
}
}
pub fn verify_secret(&self, raw_secret: &str, now_unix: u64) -> bool {
if self.expires_unix != 0 && now_unix > self.expires_unix {
return false;
}
self.secret_sha256 == sha256_hex(raw_secret.as_bytes())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KmsWrappedKey {
pub kms_key_id: String,
pub key_version: String,
pub wrapped_dek: Vec<u8>,
pub algorithm: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyRotationRecord {
pub from_version: String,
pub to_version: String,
pub started_unix_micros: u64,
pub completed_unix_micros: Option<u64>,
}
pub fn redact_secrets(input: &str) -> String {
let mut out = input.to_owned();
for needle in [
"password=",
"password:",
"passwd=",
"secret=",
"api_key=",
"token=",
"private_key=",
"Authorization:",
"Bearer ",
] {
if let Some(pos) = out.to_ascii_lowercase().find(&needle.to_ascii_lowercase()) {
let start = pos + needle.len();
let rest = &out[start..];
let end_rel = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ',')
.unwrap_or(rest.len());
out.replace_range(start..start + end_rel, "***");
}
}
out
}
pub fn node_cert_matches_id(cert_cn_or_san: &str, node_id_hex: &str) -> bool {
let expected = format!("node-{node_id_hex}.mongreldb.cluster");
cert_cn_or_san.eq_ignore_ascii_case(&expected)
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for b in digest {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[derive(Debug, Default, Clone)]
pub struct ServiceTokenRegistry {
tokens: BTreeMap<String, ServiceToken>,
}
impl ServiceTokenRegistry {
pub fn upsert(&mut self, token: ServiceToken) {
self.tokens.insert(token.token_id.clone(), token);
}
pub fn authenticate(
&self,
token_id: &str,
raw_secret: &str,
now_unix: u64,
) -> Option<&ServiceToken> {
let t = self.tokens.get(token_id)?;
if t.verify_secret(raw_secret, now_unix) {
Some(t)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scram_verifier_round_trip_and_redacted_debug() {
let v = ScramVerifier::from_password_for_tests("s3cret", b"salt", 10_000);
assert!(v.verify_password_for_tests("s3cret"));
assert!(!v.verify_password_for_tests("wrong"));
let dbg = format!("{v:?}");
assert!(dbg.contains("<redacted>"));
assert!(!dbg.contains("s3cret"));
}
#[test]
fn jwt_claims_validation() {
let cfg = JwtValidationConfig {
issuer: "https://issuer.example".into(),
audience: "mongreldb".into(),
skew_seconds: 60,
};
let claims = JwtClaims {
iss: cfg.issuer.clone(),
aud: cfg.audience.clone(),
exp: 1_000,
nbf: 100,
sub: "user-1".into(),
};
assert!(validate_jwt_claims(&claims, &cfg, 500).is_ok());
assert!(matches!(
validate_jwt_claims(&claims, &cfg, 2_000).unwrap_err(),
JwtError::Expired
));
}
#[test]
fn service_token_stores_hash_only() {
let t = ServiceToken::mint("t1", "svc", vec!["read".into()], "raw-secret", 0);
assert!(!format!("{t:?}").contains("raw-secret") || t.secret_sha256.len() == 64);
assert!(t.verify_secret("raw-secret", 0));
assert!(!t.verify_secret("nope", 0));
}
#[test]
fn redact_secrets_strips_password() {
let line = redact_secrets("login user=alice password=hunter2 ok");
assert!(line.contains("***"));
assert!(!line.contains("hunter2"));
}
#[test]
fn node_cert_binding() {
let hex = "00112233445566778899aabbccddeeff";
assert!(node_cert_matches_id(
&format!("node-{hex}.mongreldb.cluster"),
hex
));
assert!(!node_cert_matches_id("other.example", hex));
}
}