fix: store bearer tokens as SHA-256 hashes instead of plaintext

Hash paired bearer tokens with SHA-256 before storing in config and
in-memory. When authenticating, hash the incoming token and compare
against stored hashes. Backward compatible: existing plaintext tokens
(zc_ prefix) are detected and hashed on load; already-hashed tokens
(64-char hex) are stored as-is.

Closes #58

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fettpl 2026-02-14 23:58:09 +01:00
parent 365692853c
commit b3bfbaff4a
4 changed files with 93 additions and 12 deletions

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,23 @@ 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 {
let digest = Sha256::digest(token.as_bytes());
let mut hex = String::with_capacity(64);
for b in digest {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
/// 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 on pairing code.
pub fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
@ -246,10 +282,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()]);
@ -264,11 +309,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]
@ -280,6 +330,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]

View file

@ -241,7 +241,7 @@ fn hex_encode(data: &[u8]) -> String {
/// Hex-decode a hex string to bytes.
fn hex_decode(hex: &str) -> Result<Vec<u8>> {
if hex.len() % 2 != 0 {
if !hex.len().is_multiple_of(2) {
anyhow::bail!("Hex string has odd length");
}
(0..hex.len())

View file

@ -366,6 +366,7 @@ impl BrowserTool {
}
#[async_trait]
#[allow(clippy::too_many_lines)]
impl Tool for BrowserTool {
fn name(&self) -> &str {
"browser"