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:
Argenis 2026-02-14 22:32:10 -05:00 committed by GitHub
commit 6c445d5db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 11 deletions

12
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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]