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:
parent
365692853c
commit
b3bfbaff4a
4 changed files with 93 additions and 12 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ impl BrowserTool {
|
|||
}
|
||||
|
||||
#[async_trait]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
impl Tool for BrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
"browser"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue