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
|
|
@ -43,6 +43,9 @@ uuid = { version = "1.11", default-features = false, features = ["v4", "std"] }
|
||||||
# Authenticated encryption (AEAD) for secret store
|
# Authenticated encryption (AEAD) for secret store
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
|
|
||||||
|
# SHA-256 for bearer token hashing
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
# Async traits
|
# Async traits
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
// Already-paired tokens are persisted in config so restarts don't require
|
// Already-paired tokens are persisted in config so restarts don't require
|
||||||
// re-pairing.
|
// re-pairing.
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
@ -18,13 +19,17 @@ const MAX_PAIR_ATTEMPTS: u32 = 5;
|
||||||
const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes
|
const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes
|
||||||
|
|
||||||
/// Manages pairing state for the gateway.
|
/// 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)]
|
#[derive(Debug)]
|
||||||
pub struct PairingGuard {
|
pub struct PairingGuard {
|
||||||
/// Whether pairing is required at all.
|
/// Whether pairing is required at all.
|
||||||
require_pairing: bool,
|
require_pairing: bool,
|
||||||
/// One-time pairing code (generated on startup, consumed on first pair).
|
/// One-time pairing code (generated on startup, consumed on first pair).
|
||||||
pairing_code: Option<String>,
|
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>>,
|
paired_tokens: Mutex<HashSet<String>>,
|
||||||
/// Brute-force protection: failed attempt counter + lockout time.
|
/// Brute-force protection: failed attempt counter + lockout time.
|
||||||
failed_attempts: Mutex<(u32, Option<Instant>)>,
|
failed_attempts: Mutex<(u32, Option<Instant>)>,
|
||||||
|
|
@ -35,8 +40,21 @@ impl PairingGuard {
|
||||||
///
|
///
|
||||||
/// If `require_pairing` is true and no tokens exist yet, a fresh
|
/// If `require_pairing` is true and no tokens exist yet, a fresh
|
||||||
/// pairing code is generated and returned via `pairing_code()`.
|
/// 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 {
|
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() {
|
let code = if require_pairing && tokens.is_empty() {
|
||||||
Some(generate_code())
|
Some(generate_code())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -94,7 +112,7 @@ impl PairingGuard {
|
||||||
.paired_tokens
|
.paired_tokens
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
tokens.insert(token.clone());
|
tokens.insert(hash_token(&token));
|
||||||
return Ok(Some(token));
|
return Ok(Some(token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,16 +132,17 @@ impl PairingGuard {
|
||||||
Ok(None)
|
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 {
|
pub fn is_authenticated(&self, token: &str) -> bool {
|
||||||
if !self.require_pairing {
|
if !self.require_pairing {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
let hashed = hash_token(token);
|
||||||
let tokens = self
|
let tokens = self
|
||||||
.paired_tokens
|
.paired_tokens
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.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).
|
/// Returns true if the gateway is already paired (has at least one token).
|
||||||
|
|
@ -135,7 +154,7 @@ impl PairingGuard {
|
||||||
!tokens.is_empty()
|
!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> {
|
pub fn tokens(&self) -> Vec<String> {
|
||||||
let tokens = self
|
let tokens = self
|
||||||
.paired_tokens
|
.paired_tokens
|
||||||
|
|
@ -174,6 +193,23 @@ fn generate_token() -> String {
|
||||||
format!("zc_{}", uuid::Uuid::new_v4().as_simple())
|
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.
|
/// Constant-time string comparison to prevent timing attacks on pairing code.
|
||||||
pub fn constant_time_eq(a: &str, b: &str) -> bool {
|
pub fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
|
|
@ -246,10 +282,19 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_authenticated_with_valid_token() {
|
fn is_authenticated_with_valid_token() {
|
||||||
|
// Pass plaintext token — PairingGuard hashes it on load
|
||||||
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
||||||
assert!(guard.is_authenticated("zc_valid"));
|
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]
|
#[test]
|
||||||
fn is_authenticated_with_invalid_token() {
|
fn is_authenticated_with_invalid_token() {
|
||||||
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
let guard = PairingGuard::new(true, &["zc_valid".into()]);
|
||||||
|
|
@ -264,11 +309,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tokens_returns_all_paired() {
|
fn tokens_returns_hashes() {
|
||||||
let guard = PairingGuard::new(true, &["a".into(), "b".into()]);
|
let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
|
||||||
let mut tokens = guard.tokens();
|
let tokens = guard.tokens();
|
||||||
tokens.sort();
|
assert_eq!(tokens.len(), 2);
|
||||||
assert_eq!(tokens, vec!["a", "b"]);
|
// 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]
|
#[test]
|
||||||
|
|
@ -280,6 +330,33 @@ mod tests {
|
||||||
assert!(!guard.is_authenticated("wrong"));
|
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 ───────────────────────────────────────
|
// ── is_public_bind ───────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ fn hex_encode(data: &[u8]) -> String {
|
||||||
|
|
||||||
/// Hex-decode a hex string to bytes.
|
/// Hex-decode a hex string to bytes.
|
||||||
fn hex_decode(hex: &str) -> Result<Vec<u8>> {
|
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");
|
anyhow::bail!("Hex string has odd length");
|
||||||
}
|
}
|
||||||
(0..hex.len())
|
(0..hex.len())
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,7 @@ impl BrowserTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
impl Tool for BrowserTool {
|
impl Tool for BrowserTool {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"browser"
|
"browser"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue