Replace Argon2 with salted SHA-256 password hashes

This commit is contained in:
2026-10-28 13:55:29 +00:00
parent ad168aae63
commit a33549e81a
7 changed files with 157 additions and 250 deletions
+107 -160
View File
@@ -1,14 +1,14 @@
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
use argon2::{
Algorithm, Argon2, Params, Version,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
use std::{
collections::{BTreeMap, BTreeSet},
net::IpAddr,
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use http::{HeaderMap, header::PROXY_AUTHORIZATION};
use ipnet::IpNet;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use thiserror::Error;
use tokio::sync::Semaphore;
use zeroize::Zeroizing;
use crate::{
@@ -19,22 +19,44 @@ use crate::{
const MAX_AUTHORIZATION_HEADER_LEN: usize = 4096;
const MAX_USERNAME_LEN: usize = 256;
const MAX_PASSWORD_LEN: usize = 1024;
const MAX_CONCURRENT_VERIFICATIONS: usize = 4;
const MIN_ARGON2_MEMORY_KIB: u32 = 4096;
const MAX_ARGON2_MEMORY_KIB: u32 = 65_536;
const MAX_ARGON2_ITERATIONS: u32 = 10;
const MAX_ARGON2_PARALLELISM: u32 = 4;
const SALT_LEN: usize = 16;
const DIGEST_LEN: usize = 32;
const HASH_PREFIX: &str = "$hermes-sha256$v=1$";
pub(crate) struct Acl {
default_action: Action,
users: std::collections::BTreeMap<String, User>,
users: BTreeMap<String, User>,
rules: Vec<Rule>,
has_user_rules: bool,
verification_limit: Arc<Semaphore>,
}
struct User {
password_hash: String,
password_hash: SaltedHash,
}
struct SaltedHash {
salt: [u8; SALT_LEN],
digest: [u8; DIGEST_LEN],
}
impl SaltedHash {
fn parse(encoded: &str) -> Option<Self> {
let encoded = encoded.strip_prefix(HASH_PREFIX)?;
let (salt, digest) = encoded.split_once('$')?;
if digest.contains('$') {
return None;
}
Some(Self {
salt: BASE64_STANDARD.decode(salt).ok()?.try_into().ok()?,
digest: BASE64_STANDARD.decode(digest).ok()?.try_into().ok()?,
})
}
fn verify(&self, password: &[u8]) -> bool {
let actual = compute_digest(&self.salt, password);
bool::from(actual.as_slice().ct_eq(self.digest.as_slice()))
}
}
struct Rule {
@@ -108,15 +130,11 @@ impl AclOutcome {
impl Acl {
pub fn from_config(config: AclConfig) -> Result<Self, AclError> {
let mut users = std::collections::BTreeMap::new();
let mut users = BTreeMap::new();
for (username, user) in config.users {
validate_password_hash(&username, &user.password_hash)?;
users.insert(
username,
User {
password_hash: user.password_hash,
},
);
let password_hash = SaltedHash::parse(&user.password_hash)
.ok_or_else(|| AclError::InvalidPasswordHash(username.clone()))?;
users.insert(username, User { password_hash });
}
let mut names = BTreeSet::new();
@@ -135,31 +153,23 @@ impl Acl {
users,
rules,
has_user_rules,
verification_limit: Arc::new(Semaphore::new(MAX_CONCURRENT_VERIFICATIONS)),
})
}
pub async fn authorize(
&self,
peer: IpAddr,
headers: &HeaderMap,
) -> Result<AclOutcome, AclError> {
pub fn authorize(&self, peer: IpAddr, headers: &HeaderMap) -> AclOutcome {
let credentials = match parse_basic_credentials(headers) {
Credentials::Missing => None,
Credentials::Invalid => return Ok(AclOutcome::challenge("invalid_credentials")),
Credentials::Invalid => return AclOutcome::challenge("invalid_credentials"),
Credentials::Present(credentials) => Some(credentials),
};
let authenticated_user = match credentials {
Some(credentials) => {
let Some(user) = self.users.get(&credentials.username) else {
return Ok(AclOutcome::challenge("invalid_credentials"));
return AclOutcome::challenge("invalid_credentials");
};
if !self
.verify_password(user.password_hash.clone(), credentials.password)
.await?
{
return Ok(AclOutcome::challenge("invalid_credentials"));
if !user.password_hash.verify(credentials.password.as_slice()) {
return AclOutcome::challenge("invalid_credentials");
}
Some(credentials.username)
}
@@ -168,7 +178,7 @@ impl Acl {
for rule in &self.rules {
if rule_matches(rule, peer, authenticated_user.as_deref()) {
return Ok(AclOutcome::rule(rule));
return AclOutcome::rule(rule);
}
}
@@ -176,41 +186,14 @@ impl Acl {
&& self.default_action == Action::Deny
&& self.has_user_rules
{
return Ok(AclOutcome::challenge("authentication_required"));
return AclOutcome::challenge("authentication_required");
}
Ok(AclOutcome::default(self.default_action))
}
async fn verify_password(
&self,
password_hash: String,
password: Zeroizing<Vec<u8>>,
) -> Result<bool, AclError> {
let permit = self
.verification_limit
.clone()
.acquire_owned()
.await
.map_err(|_| AclError::AuthenticationTask)?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
let parsed =
PasswordHash::new(&password_hash).map_err(|_| AclError::AuthenticationTask)?;
Ok(Argon2::default()
.verify_password(password.as_slice(), &parsed)
.is_ok())
})
.await
.map_err(|_| AclError::AuthenticationTask)?
AclOutcome::default(self.default_action)
}
}
fn compile_rule(
rule: RuleConfig,
users: &std::collections::BTreeMap<String, User>,
) -> Result<Rule, AclError> {
fn compile_rule(rule: RuleConfig, users: &BTreeMap<String, User>) -> Result<Rule, AclError> {
if rule.name.trim().is_empty() {
return Err(AclError::EmptyRuleName);
}
@@ -244,29 +227,6 @@ fn compile_rule(
})
}
fn validate_password_hash(username: &str, encoded: &str) -> Result<(), AclError> {
let hash = PasswordHash::new(encoded)
.map_err(|_| AclError::InvalidPasswordHash(username.to_owned()))?;
if hash.algorithm.as_str() != Algorithm::Argon2id.as_str()
|| hash.version != Some(u32::from(Version::V0x13 as u8))
{
return Err(AclError::InvalidPasswordHash(username.to_owned()));
}
let params =
Params::try_from(&hash).map_err(|_| AclError::InvalidPasswordHash(username.to_owned()))?;
if !(MIN_ARGON2_MEMORY_KIB..=MAX_ARGON2_MEMORY_KIB).contains(&params.m_cost())
|| params.t_cost() == 0
|| params.t_cost() > MAX_ARGON2_ITERATIONS
|| params.p_cost() == 0
|| params.p_cost() > MAX_ARGON2_PARALLELISM
{
return Err(AclError::UnsafePasswordParameters(username.to_owned()));
}
Ok(())
}
fn rule_matches(rule: &Rule, peer: IpAddr, authenticated_user: Option<&str>) -> bool {
let user_matches = rule.users.is_empty()
|| authenticated_user.is_some_and(|username| rule.users.contains(username));
@@ -339,7 +299,21 @@ pub fn hash_password(password: &[u8]) -> Result<String, BoxError> {
return Err("password must not exceed 1024 bytes".into());
}
Ok(Argon2::default().hash_password(password)?.to_string())
let mut salt = [0_u8; SALT_LEN];
getrandom::fill(&mut salt)?;
let digest = compute_digest(&salt, password);
Ok(format!(
"{HASH_PREFIX}{}${}",
BASE64_STANDARD.encode(salt),
BASE64_STANDARD.encode(digest)
))
}
fn compute_digest(salt: &[u8; SALT_LEN], password: &[u8]) -> [u8; DIGEST_LEN] {
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(password);
hasher.finalize().into()
}
#[derive(Debug, Error)]
@@ -354,18 +328,12 @@ pub enum AclError {
UnknownUser { rule: String, username: String },
#[error("ACL rule {rule:?} has invalid network {network:?}")]
InvalidNetwork { rule: String, network: String },
#[error("ACL user {0:?} has an invalid or unsupported Argon2id password hash")]
#[error("ACL user {0:?} has an invalid or unsupported salted SHA-256 password hash")]
InvalidPasswordHash(String),
#[error("ACL user {0:?} has unsafe Argon2id parameters")]
UnsafePasswordParameters(String),
#[error("password verification task failed")]
AuthenticationTask,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use http::HeaderValue;
use super::*;
@@ -400,22 +368,19 @@ mod tests {
)])
}
#[tokio::test]
async fn authenticates_basic_user() {
#[test]
fn authenticates_basic_user() {
let acl = user_acl(Action::Deny);
let outcome = acl
.authorize(
"127.0.0.1".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
)
.await
.unwrap();
let outcome = acl.authorize(
"127.0.0.1".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
);
assert_eq!(outcome.decision, Decision::Allow);
assert_eq!(outcome.reason, "user");
}
#[tokio::test]
async fn challenges_missing_malformed_unknown_and_wrong_credentials() {
#[test]
fn challenges_missing_malformed_unknown_and_wrong_credentials() {
let acl = user_acl(Action::Deny);
let malformed = HeaderMap::from_iter([(
PROXY_AUTHORIZATION,
@@ -427,16 +392,13 @@ mod tests {
headers("alice", "wrong"),
malformed,
] {
let outcome = acl
.authorize("127.0.0.1".parse().unwrap(), &headers)
.await
.unwrap();
let outcome = acl.authorize("127.0.0.1".parse().unwrap(), &headers);
assert_eq!(outcome.decision, Decision::Challenge);
}
}
#[tokio::test]
async fn matches_ip_network_without_credentials() {
#[test]
fn matches_ip_network_without_credentials() {
let acl = Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::new(),
@@ -449,21 +411,15 @@ mod tests {
})
.unwrap();
let allowed = acl
.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new())
.await
.unwrap();
let allowed = acl.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new());
assert_eq!(allowed.decision, Decision::Allow);
let denied = acl
.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new())
.await
.unwrap();
let denied = acl.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new());
assert_eq!(denied.decision, Decision::Deny);
}
#[tokio::test]
async fn applies_first_match_and_combines_selectors() {
#[test]
fn applies_first_match_and_combines_selectors() {
let password_hash = hash_password(PASSWORD).unwrap();
let acl = Acl::from_config(AclConfig {
default_action: Action::Allow,
@@ -480,23 +436,17 @@ mod tests {
})
.unwrap();
let denied = acl
.authorize(
"192.0.2.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
)
.await
.unwrap();
let denied = acl.authorize(
"192.0.2.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
);
assert_eq!(denied.decision, Decision::Deny);
assert_eq!(denied.rule.as_deref(), Some("deny-alice-off-network"));
let allowed = acl
.authorize(
"198.51.100.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
)
.await
.unwrap();
let allowed = acl.authorize(
"198.51.100.10".parse().unwrap(),
&headers("alice", "correct horse battery staple"),
);
assert_eq!(allowed.decision, Decision::Allow);
}
@@ -505,16 +455,12 @@ mod tests {
let first = hash_password(PASSWORD).unwrap();
let second = hash_password(PASSWORD).unwrap();
assert_ne!(first, second);
assert!(first.starts_with("$argon2id$v=19$"));
assert!(first.starts_with(HASH_PREFIX));
for encoded in [first, second] {
let parsed = PasswordHash::new(&encoded).unwrap();
assert!(Argon2::default().verify_password(PASSWORD, &parsed).is_ok());
assert!(
Argon2::default()
.verify_password(b"wrong", &parsed)
.is_err()
);
let parsed = SaltedHash::parse(&encoded).unwrap();
assert!(parsed.verify(PASSWORD));
assert!(!parsed.verify(b"wrong"));
}
}
@@ -532,19 +478,20 @@ mod tests {
});
assert!(matches!(empty, Err(AclError::EmptyRule(_))));
let invalid_hash = Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::from([(
"alice".to_owned(),
UserConfig {
password_hash: "plaintext".to_owned(),
},
)]),
rules: vec![],
});
assert!(matches!(
invalid_hash,
Err(AclError::InvalidPasswordHash(_))
));
for password_hash in [
"plaintext".to_owned(),
"$argon2id$v=19$unsupported".to_owned(),
"$hermes-sha256$v=1$c2hvcnQ=$c2hvcnQ=".to_owned(),
] {
let invalid_hash = Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::from([("alice".to_owned(), UserConfig { password_hash })]),
rules: vec![],
});
assert!(matches!(
invalid_hash,
Err(AclError::InvalidPasswordHash(_))
));
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
/// Generate a salted Argon2id password hash for the YAML configuration.
/// Generate a salted SHA-256 password hash for the YAML configuration.
HashPassword,
}
+4 -15
View File
@@ -161,7 +161,7 @@ async fn handle_http(request: Request<Incoming>, peer: IpAddr, state: ProxyState
async move {
let _active = state.metrics.request_started();
let started = Instant::now();
let result = match authorize_request(&request, peer, &state).await {
let result = match authorize_request(&request, peer, &state) {
Ok(Some(response)) => Ok(response),
Ok(None) => forward_http(request, &state).await,
Err(error) => Err(error),
@@ -172,23 +172,12 @@ async fn handle_http(request: Request<Incoming>, peer: IpAddr, state: ProxyState
.await
}
async fn authorize_request(
fn authorize_request(
request: &Request<Incoming>,
peer: IpAddr,
state: &ProxyState,
) -> Result<Option<ProxyResponse>, ProxyError> {
let outcome = state
.acl
.authorize(peer, request.headers())
.await
.map_err(|error| {
ProxyError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"acl_internal",
"acl",
error.to_string(),
)
})?;
let outcome = state.acl.authorize(peer, request.headers());
state
.metrics
@@ -348,7 +337,7 @@ async fn handle_connect(
async move {
let _active = state.metrics.request_started();
let started = Instant::now();
let result = match authorize_request(&request, peer, &state).await {
let result = match authorize_request(&request, peer, &state) {
Ok(Some(response)) => Ok(response),
Ok(None) => prepare_connect(request, &state).await,
Err(error) => Err(error),