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",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
|
@ -2548,6 +2559,7 @@ dependencies = [
|
|||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shellexpand",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ uuid = { version = "1.11", default-features = false, features = ["v4", "std"] }
|
|||
# Authenticated encryption (AEAD) for secret store
|
||||
chacha20poly1305 = "0.10"
|
||||
|
||||
# SHA-256 for bearer token hashing
|
||||
sha2 = "0.10"
|
||||
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
// Already-paired tokens are persisted in config so restarts don't require
|
||||
// re-pairing.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
|
@ -18,13 +19,17 @@ const MAX_PAIR_ATTEMPTS: u32 = 5;
|
|||
const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
/// 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)]
|
||||
pub struct PairingGuard {
|
||||
/// Whether pairing is required at all.
|
||||
require_pairing: bool,
|
||||
/// One-time pairing code (generated on startup, consumed on first pair).
|
||||
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>>,
|
||||
/// Brute-force protection: failed attempt counter + lockout time.
|
||||
failed_attempts: Mutex<(u32, Option<Instant>)>,
|
||||
|
|
@ -35,8 +40,21 @@ impl PairingGuard {
|
|||
///
|
||||
/// If `require_pairing` is true and no tokens exist yet, a fresh
|
||||
/// 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 {
|
||||
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() {
|
||||
Some(generate_code())
|
||||
} else {
|
||||
|
|
@ -94,7 +112,7 @@ impl PairingGuard {
|
|||
.paired_tokens
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
tokens.insert(token.clone());
|
||||
tokens.insert(hash_token(&token));
|
||||
return Ok(Some(token));
|
||||
}
|
||||
}
|
||||
|
|
@ -114,16 +132,17 @@ impl PairingGuard {
|
|||
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 {
|
||||
if !self.require_pairing {
|
||||
return true;
|
||||
}
|
||||
let hashed = hash_token(token);
|
||||
let tokens = self
|
||||
.paired_tokens
|
||||
.lock()
|
||||
.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).
|
||||
|
|
@ -135,7 +154,7 @@ impl PairingGuard {
|
|||
!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> {
|
||||
let tokens = self
|
||||
.paired_tokens
|
||||
|
|
@ -174,6 +193,17 @@ fn generate_token() -> String {
|
|||
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.
|
||||
///
|
||||
/// Does not short-circuit on length mismatch — always iterates over the
|
||||
|
|
@ -258,10 +288,19 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn is_authenticated_with_valid_token() {
|
||||
// Pass plaintext token — PairingGuard hashes it on load
|
||||
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
||||
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]
|
||||
fn is_authenticated_with_invalid_token() {
|
||||
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
||||
|
|
@ -276,11 +315,16 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_returns_all_paired() {
|
||||
let guard = PairingGuard::new(true, &["a".into(), "b".into()]);
|
||||
let mut tokens = guard.tokens();
|
||||
tokens.sort();
|
||||
assert_eq!(tokens, vec!["a", "b"]);
|
||||
fn tokens_returns_hashes() {
|
||||
let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
|
||||
let tokens = guard.tokens();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
// 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]
|
||||
|
|
@ -292,6 +336,33 @@ mod tests {
|
|||
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 ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue