Replace Argon2 with salted SHA-256 password hashes
This commit is contained in:
Generated
+27
-66
@@ -67,19 +67,6 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -103,27 +90,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
@@ -191,18 +163,18 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
@@ -221,15 +193,6 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
@@ -237,8 +200,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -394,10 +357,10 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
name = "hermes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
"bytes",
|
||||
"clap",
|
||||
"getrandom 0.4.3",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -410,6 +373,8 @@ dependencies = [
|
||||
"rpassword",
|
||||
"serde",
|
||||
"serde_norway",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -820,33 +785,12 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"phc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"ctutils",
|
||||
"getrandom 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -1083,6 +1027,17 @@ dependencies = [
|
||||
"unsafe-libyaml-norway",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1136,6 +1091,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
|
||||
+3
-1
@@ -7,10 +7,10 @@ description = "A minimal HTTP forward proxy with OpenTelemetry observability"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
argon2 = { version = "0.6", features = ["zeroize"] }
|
||||
base64 = "0.23"
|
||||
bytes = "1.11"
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
getrandom = "0.4"
|
||||
http = "1.4"
|
||||
http-body-util = "0.1.5"
|
||||
hyper = { version = "1.12", features = ["client", "http1", "server"] }
|
||||
@@ -23,6 +23,8 @@ ipnet = "2.12"
|
||||
rpassword = "7.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_norway = "0.9"
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -9,7 +9,7 @@ Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streamin
|
||||
- Streaming request and response bodies
|
||||
- Upstream connection pooling and configurable timeouts
|
||||
- Ordered allow/deny ACLs using Basic authentication and/or client IP networks
|
||||
- Salted Argon2id password hashes; plaintext passwords are never stored in YAML
|
||||
- Fast salted SHA-256 password hashes; plaintext passwords are never stored in YAML
|
||||
- Removal of standard and `Connection`-nominated hop-by-hop headers
|
||||
- W3C Trace Context extraction and injection for plain HTTP traffic
|
||||
- Structured, trace-correlated logs on stdout and through OTLP
|
||||
@@ -47,7 +47,7 @@ acl:
|
||||
|
||||
users:
|
||||
alice:
|
||||
password_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
password_hash: "$hermes-sha256$v=1$..."
|
||||
|
||||
rules:
|
||||
- name: alice
|
||||
@@ -65,7 +65,7 @@ acl:
|
||||
| `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector |
|
||||
| `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers |
|
||||
| `acl.default_action` | `deny` | Action when no rule matches |
|
||||
| `acl.users` | empty | Usernames and Argon2id PHC password hashes |
|
||||
| `acl.users` | empty | Usernames and salted SHA-256 password hashes |
|
||||
| `acl.rules` | empty | Ordered ACL rules |
|
||||
|
||||
Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes.
|
||||
@@ -98,11 +98,19 @@ curl --proxy http://127.0.0.1:8080 \
|
||||
cargo run -- hash-password
|
||||
```
|
||||
|
||||
The helper prompts without echoing, confirms the password, and prints a PHC string such as `$argon2id$v=19$...`. Copy the complete string into the user's `password_hash` field. The PHC string includes a cryptographically random salt, the Argon2id parameters, and the hash; no separate salt field is needed. Running the helper twice for the same password produces different salted hashes.
|
||||
The helper prompts without echoing, confirms the password, and prints a versioned string in this format:
|
||||
|
||||
Password verification uses bounded blocking work so Argon2 does not stall Tokio's async runtime. Decoded password buffers are zeroized after use.
|
||||
```text
|
||||
$hermes-sha256$v=1$<base64-salt>$<base64-digest>
|
||||
```
|
||||
|
||||
**Basic authentication is not encrypted in transit.** Argon2 protects the stored password hash, but a client sending Basic credentials over cleartext HTTP exposes them to the network. Use Basic authentication only on localhost or a trusted private network unless downstream TLS is added. Do not expose Hermes publicly without appropriate network controls.
|
||||
Copy the complete string into the user's `password_hash` field. Hermes computes `SHA-256(salt || password)` using a cryptographically random 16-byte salt. No separate salt field is needed, and running the helper twice for the same password produces different salted hashes. Argon2id hashes from earlier configurations are not supported; regenerate them with the helper.
|
||||
|
||||
Password verification is synchronous and inexpensive, and digest comparisons are constant-time. Decoded password buffers are zeroized after use.
|
||||
|
||||
**Fast password hashes can be brute-forced offline.** Anyone who obtains the YAML can efficiently guess passwords and compare them with the stored salted SHA-256 digest. Use strong, unique passwords and protect the configuration file; this format deliberately trades password-cracking resistance for cheap request verification.
|
||||
|
||||
**Basic authentication is not encrypted in transit.** A client sending Basic credentials over cleartext HTTP exposes them to the network. Use Basic authentication only on localhost or a trusted private network unless downstream TLS is added. Do not expose Hermes publicly without appropriate network controls.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ acl:
|
||||
# default_action: deny
|
||||
# users:
|
||||
# alice:
|
||||
# password_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
# password_hash: "$hermes-sha256$v=1$..."
|
||||
# rules:
|
||||
# - name: alice
|
||||
# action: allow
|
||||
|
||||
+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