Merge pull request #71 from fettpl/fix/bearer-token-hashing
fix: store bearer tokens as SHA-256 hashes instead of plaintext
This commit is contained in:
commit
6c445d5db7
3 changed files with 97 additions and 11 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -1535,6 +1535,17 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -2548,6 +2559,7 @@ dependencies = [
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ uuid = { version = "1.11", default-features = false, features = ["v4", "std"] }
|
||||||
# Authenticated encryption (AEAD) for secret store
|
# Authenticated encryption (AEAD) for secret store
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
|
|
||||||
|
# SHA-256 for bearer token hashing
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
# Async traits
|
# Async traits
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
// Already-paired tokens are persisted in config so restarts don't require
|
// Already-paired tokens are persisted in config so restarts don't require
|
||||||
// re-pairing.
|
// re-pairing.
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
@ -18,13 +19,17 @@ const MAX_PAIR_ATTEMPTS: u32 = 5;
|
||||||
const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes
|
const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes
|
||||||
|
|
||||||
/// Manages pairing state for the gateway.
|
/// Manages pairing state for the gateway.
|
||||||
|
///
|
||||||
|
/// Bearer tokens are stored as SHA-256 hashes to prevent plaintext exposure
|
||||||
|
/// in config files. When a new token is generated, the plaintext is returned
|
||||||
|
/// to the client once, and only the hash is retained.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PairingGuard {
|
pub struct PairingGuard {
|
||||||
/// Whether pairing is required at all.
|
/// Whether pairing is required at all.
|
||||||
require_pairing: bool,
|
require_pairing: bool,
|
||||||
/// One-time pairing code (generated on startup, consumed on first pair).
|
/// One-time pairing code (generated on startup, consumed on first pair).
|
||||||
pairing_code: Option<String>,
|
pairing_code: Option<String>,
|
||||||
/// Set of valid bearer tokens (persisted across restarts).
|
/// Set of SHA-256 hashed bearer tokens (persisted across restarts).
|
||||||
paired_tokens: Mutex<HashSet<String>>,
|
paired_tokens: Mutex<HashSet<String>>,
|
||||||
/// Brute-force protection: failed attempt counter + lockout time.
|
/// Brute-force protection: failed attempt counter + lockout time.
|
||||||
failed_attempts: Mutex<(u32, Option<Instant>)>,
|
failed_attempts: Mutex<(u32, Option<Instant>)>,
|
||||||
|
|
@ -35,8 +40,21 @@ impl PairingGuard {
|
||||||
///
|
///
|
||||||
/// If `require_pairing` is true and no tokens exist yet, a fresh
|
/// If `require_pairing` is true and no tokens exist yet, a fresh
|
||||||
/// pairing code is generated and returned via `pairing_code()`.
|
/// pairing code is generated and returned via `pairing_code()`.
|
||||||
|
///
|
||||||
|
/// Existing tokens are accepted in both forms:
|
||||||
|
/// - Plaintext (`zc_...`): hashed on load for backward compatibility
|
||||||
|
/// - Already hashed (64-char hex): stored as-is
|
||||||
pub fn new(require_pairing: bool, existing_tokens: &[String]) -> Self {
|
pub fn new(require_pairing: bool, existing_tokens: &[String]) -> Self {
|
||||||
let tokens: HashSet<String> = existing_tokens.iter().cloned().collect();
|
let tokens: HashSet<String> = existing_tokens
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
if is_token_hash(t) {
|
||||||
|
t.clone()
|
||||||
|
} else {
|
||||||
|
hash_token(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let code = if require_pairing && tokens.is_empty() {
|
let code = if require_pairing && tokens.is_empty() {
|
||||||
Some(generate_code())
|
Some(generate_code())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -94,7 +112,7 @@ impl PairingGuard {
|
||||||
.paired_tokens
|
.paired_tokens
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
tokens.insert(token.clone());
|
tokens.insert(hash_token(&token));
|
||||||
return Ok(Some(token));
|
return Ok(Some(token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,16 +132,17 @@ impl PairingGuard {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a bearer token is valid.
|
/// Check if a bearer token is valid (compares against stored hashes).
|
||||||
pub fn is_authenticated(&self, token: &str) -> bool {
|
pub fn is_authenticated(&self, token: &str) -> bool {
|
||||||
if !self.require_pairing {
|
if !self.require_pairing {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
let hashed = hash_token(token);
|
||||||
let tokens = self
|
let tokens = self
|
||||||
.paired_tokens
|
.paired_tokens
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
tokens.contains(token)
|
tokens.contains(&hashed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the gateway is already paired (has at least one token).
|
/// Returns true if the gateway is already paired (has at least one token).
|
||||||
|
|
@ -135,7 +154,7 @@ impl PairingGuard {
|
||||||
!tokens.is_empty()
|
!tokens.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all paired tokens (for persisting to config).
|
/// Get all paired token hashes (for persisting to config).
|
||||||
pub fn tokens(&self) -> Vec<String> {
|
pub fn tokens(&self) -> Vec<String> {
|
||||||
let tokens = self
|
let tokens = self
|
||||||
.paired_tokens
|
.paired_tokens
|
||||||
|
|
@ -174,6 +193,17 @@ fn generate_token() -> String {
|
||||||
format!("zc_{}", uuid::Uuid::new_v4().as_simple())
|
format!("zc_{}", uuid::Uuid::new_v4().as_simple())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SHA-256 hash a bearer token for storage. Returns lowercase hex.
|
||||||
|
fn hash_token(token: &str) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(token.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a stored value looks like a SHA-256 hash (64 hex chars)
|
||||||
|
/// rather than a plaintext token.
|
||||||
|
fn is_token_hash(value: &str) -> bool {
|
||||||
|
value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
/// Constant-time string comparison to prevent timing attacks.
|
/// Constant-time string comparison to prevent timing attacks.
|
||||||
///
|
///
|
||||||
/// Does not short-circuit on length mismatch — always iterates over the
|
/// Does not short-circuit on length mismatch — always iterates over the
|
||||||
|
|
@ -258,10 +288,19 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_authenticated_with_valid_token() {
|
fn is_authenticated_with_valid_token() {
|
||||||
|
// Pass plaintext token — PairingGuard hashes it on load
|
||||||
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
||||||
assert!(guard.is_authenticated("zc_valid"));
|
assert!(guard.is_authenticated("zc_valid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_authenticated_with_prehashed_token() {
|
||||||
|
// Pass an already-hashed token (64 hex chars)
|
||||||
|
let hashed = hash_token("zc_valid");
|
||||||
|
let guard = PairingGuard::new(true, &[hashed]);
|
||||||
|
assert!(guard.is_authenticated("zc_valid"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_authenticated_with_invalid_token() {
|
fn is_authenticated_with_invalid_token() {
|
||||||
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
||||||
|
|
@ -276,11 +315,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tokens_returns_all_paired() {
|
fn tokens_returns_hashes() {
|
||||||
let guard = PairingGuard::new(true, &["a".into(), "b".into()]);
|
let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
|
||||||
let mut tokens = guard.tokens();
|
let tokens = guard.tokens();
|
||||||
tokens.sort();
|
assert_eq!(tokens.len(), 2);
|
||||||
assert_eq!(tokens, vec!["a", "b"]);
|
// Tokens should be stored as 64-char hex hashes, not plaintext
|
||||||
|
for t in &tokens {
|
||||||
|
assert_eq!(t.len(), 64, "Token should be a SHA-256 hash");
|
||||||
|
assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert!(!t.starts_with("zc_"), "Token should not be plaintext");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -292,6 +336,33 @@ mod tests {
|
||||||
assert!(!guard.is_authenticated("wrong"));
|
assert!(!guard.is_authenticated("wrong"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Token hashing ────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_token_produces_64_hex_chars() {
|
||||||
|
let hash = hash_token("zc_test_token");
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_token_is_deterministic() {
|
||||||
|
assert_eq!(hash_token("zc_abc"), hash_token("zc_abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_token_differs_for_different_inputs() {
|
||||||
|
assert_ne!(hash_token("zc_a"), hash_token("zc_b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_token_hash_detects_hash_vs_plaintext() {
|
||||||
|
assert!(is_token_hash(&hash_token("zc_test")));
|
||||||
|
assert!(!is_token_hash("zc_test_token"));
|
||||||
|
assert!(!is_token_hash("too_short"));
|
||||||
|
assert!(!is_token_hash(""));
|
||||||
|
}
|
||||||
|
|
||||||
// ── is_public_bind ───────────────────────────────────────
|
// ── is_public_bind ───────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue