Skip to main content

radixdb_executor/
credentials.rs

1//! Shared password-verifier primitives for catalog and server authentication.
2
3use argon2::{
4    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
5    Argon2, Params,
6};
7use radixdb_core::{Error, Result};
8
9pub const MAX_PASSWORD_BYTES: usize = 1024;
10const MIN_MEMORY_COST_KIB: u32 = Params::DEFAULT_M_COST;
11const MAX_MEMORY_COST_KIB: u32 = 256 * 1024;
12const MIN_TIME_COST: u32 = Params::DEFAULT_T_COST;
13const MAX_TIME_COST: u32 = 10;
14const MAX_PARALLELISM: u32 = 16;
15
16/// Derive a salted Argon2id PHC verifier without retaining the plaintext.
17pub fn hash_password_verifier(password: &str) -> Result<String> {
18    validate_password_length(password)?;
19    let salt_seed = uuid::Uuid::now_v7();
20    let salt = SaltString::encode_b64(salt_seed.as_bytes())
21        .map_err(|_| Error::internal("failed to encode credential salt"))?;
22    Argon2::default()
23        .hash_password(password.as_bytes(), &salt)
24        .map(|hash| hash.to_string())
25        .map_err(|_| Error::internal("failed to derive credential verifier"))
26}
27
28/// Validate that a configured verifier is a syntactically valid Argon2id PHC string.
29pub fn validate_password_verifier(encoded: &str) -> Result<()> {
30    let parsed = PasswordHash::new(encoded)
31        .map_err(|_| Error::invalid_argument("password verifier must be a valid PHC string"))?;
32    if parsed.algorithm.as_str() != "argon2id" {
33        return Err(Error::invalid_argument(
34            "password verifier must use the argon2id algorithm",
35        ));
36    }
37    if parsed.version != Some(19) || parsed.salt.is_none() || parsed.hash.is_none() {
38        return Err(Error::invalid_argument(
39            "password verifier must contain Argon2 version 19, salt and hash",
40        ));
41    }
42    let params = Params::try_from(&parsed)
43        .map_err(|_| Error::invalid_argument("password verifier parameters are invalid"))?;
44    let output_len = params.output_len().unwrap_or(Params::DEFAULT_OUTPUT_LEN);
45    if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(&params.m_cost())
46        || !(MIN_TIME_COST..=MAX_TIME_COST).contains(&params.t_cost())
47        || !(Params::MIN_P_COST..=MAX_PARALLELISM).contains(&params.p_cost())
48        || !(Params::DEFAULT_OUTPUT_LEN..=64).contains(&output_len)
49    {
50        return Err(Error::invalid_argument(
51            "password verifier parameters are outside the supported security bounds",
52        ));
53    }
54    Ok(())
55}
56
57/// Verify a password without disclosing why a verifier did not match.
58pub fn verify_password_verifier(encoded: &str, password: &str) -> bool {
59    if password.is_empty() || password.len() > MAX_PASSWORD_BYTES {
60        return false;
61    }
62    if validate_password_verifier(encoded).is_err() {
63        return false;
64    }
65    let parsed = PasswordHash::new(encoded).expect("validated password verifier must parse");
66    Argon2::default()
67        .verify_password(password.as_bytes(), &parsed)
68        .is_ok()
69}
70
71fn validate_password_length(password: &str) -> Result<()> {
72    if password.is_empty() || password.len() > MAX_PASSWORD_BYTES {
73        return Err(Error::invalid_argument(
74            "password length must be in 1..=1024 bytes",
75        ));
76    }
77    Ok(())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn generated_verifier_is_argon2id_and_matches_only_its_password() {
86        let encoded = hash_password_verifier("correct horse battery staple").unwrap();
87        assert!(encoded.starts_with("$argon2id$"));
88        assert_eq!(validate_password_verifier(&encoded), Ok(()));
89        assert!(verify_password_verifier(
90            &encoded,
91            "correct horse battery staple"
92        ));
93        assert!(!verify_password_verifier(&encoded, "wrong"));
94    }
95
96    #[test]
97    fn malformed_and_non_argon2id_verifiers_are_rejected() {
98        assert!(validate_password_verifier("not-a-phc-string").is_err());
99        assert!(validate_password_verifier("$argon2i$v=19$m=4096,t=3,p=1$c2FsdA$YWJj").is_err());
100        assert!(validate_password_verifier("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA").is_err());
101        assert!(validate_password_verifier(
102            "$argon2id$v=19$m=4294967295,t=2,p=1$c2FsdA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo"
103        )
104        .is_err());
105        assert!(!verify_password_verifier("not-a-phc-string", "secret"));
106    }
107
108    #[test]
109    fn password_length_is_bounded() {
110        assert!(hash_password_verifier("").is_err());
111        assert!(hash_password_verifier(&"x".repeat(MAX_PASSWORD_BYTES + 1)).is_err());
112    }
113}