From b3bfbaff4a5221986bb6b3fbb78f456f6dd5d29e Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:58:09 +0100 Subject: [PATCH] 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 --- Cargo.toml | 3 ++ src/security/pairing.rs | 99 ++++++++++++++++++++++++++++++++++++----- src/security/secrets.rs | 2 +- src/tools/browser.rs | 1 + 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index eebcbc9..fbdb65c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/security/pairing.rs b/src/security/pairing.rs index 5f55603..e8d946c 100644 --- a/src/security/pairing.rs +++ b/src/security/pairing.rs @@ -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, - /// Set of valid bearer tokens (persisted across restarts). + /// Set of SHA-256 hashed bearer tokens (persisted across restarts). paired_tokens: Mutex>, /// Brute-force protection: failed attempt counter + lockout time. failed_attempts: Mutex<(u32, Option)>, @@ -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 = existing_tokens.iter().cloned().collect(); + let tokens: HashSet = 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 { 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] diff --git a/src/security/secrets.rs b/src/security/secrets.rs index bafad38..3940843 100644 --- a/src/security/secrets.rs +++ b/src/security/secrets.rs @@ -241,7 +241,7 @@ fn hex_encode(data: &[u8]) -> String { /// Hex-decode a hex string to bytes. fn hex_decode(hex: &str) -> Result> { - if hex.len() % 2 != 0 { + if !hex.len().is_multiple_of(2) { anyhow::bail!("Hex string has odd length"); } (0..hex.len()) diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 5ee9505..25be13c 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -366,6 +366,7 @@ impl BrowserTool { } #[async_trait] +#[allow(clippy::too_many_lines)] impl Tool for BrowserTool { fn name(&self) -> &str { "browser"