Replace Argon2 with salted SHA-256 password hashes
This commit is contained in:
+107
-160
@@ -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(¶ms.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
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user