Skip to main content

zeroclaw_config/
pairing.rs

1// Gateway pairing mode — first-connect authentication.
2//
3// On startup the gateway generates a one-time pairing code printed to the
4// terminal. The first client must present this code via `X-Pairing-Code`
5// header on a `POST /pair` request. The server responds with a bearer token
6// that must be sent on all subsequent requests via `Authorization: Bearer <token>`.
7//
8// Already-paired tokens are persisted in config so restarts don't require
9// re-pairing.
10
11use parking_lot::Mutex;
12use sha2::{Digest, Sha256};
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15use std::time::Instant;
16
17/// Maximum failed pairing attempts before lockout.
18const MAX_PAIR_ATTEMPTS: u32 = 5;
19/// Lockout duration after too many failed pairing attempts.
20const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes
21/// Maximum number of tracked client entries to bound memory usage.
22const MAX_TRACKED_CLIENTS: usize = 10_000;
23/// Retention period for failed-attempt entries with no activity.
24const FAILED_ATTEMPT_RETENTION_SECS: u64 = 900; // 15 min
25/// Minimum interval between full sweeps of the failed-attempt map.
26const FAILED_ATTEMPT_SWEEP_INTERVAL_SECS: u64 = 300; // 5 min
27
28/// Per-client failed attempt state with optional absolute lockout deadline.
29#[derive(Debug, Clone, Copy)]
30struct FailedAttemptState {
31    count: u32,
32    lockout_until: Option<Instant>,
33    last_attempt: Instant,
34}
35
36/// Why a `generate_pairing_code_if_vacant` call failed.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum GeneratePairingCodeError {
39    /// A pairing code is already pending; redeem or wait before issuing a new one.
40    Pending,
41    /// Pairing is disabled on this gateway.
42    PairingDisabled,
43}
44
45/// Manages pairing state for the gateway.
46///
47/// Bearer tokens are stored as SHA-256 hashes to prevent plaintext exposure
48/// in config files. When a new token is generated, the plaintext is returned
49/// to the client once, and only the hash is retained.
50// TODO: I've just made this work with parking_lot but it should use either flume or tokio's async mutexes
51#[derive(Debug, Clone)]
52pub struct PairingGuard {
53    /// Whether pairing is required at all.
54    require_pairing: bool,
55    /// One-time pairing code (generated on startup, consumed on first pair).
56    pairing_code: Arc<Mutex<Option<String>>>,
57    /// Set of SHA-256 hashed bearer tokens (persisted across restarts).
58    paired_tokens: Arc<Mutex<HashSet<String>>>,
59    /// Brute-force protection: per-client failed attempt state + last sweep timestamp.
60    failed_attempts: Arc<Mutex<(HashMap<String, FailedAttemptState>, Instant)>>,
61}
62
63impl PairingGuard {
64    /// Create a new pairing guard.
65    ///
66    /// If `require_pairing` is true and no tokens exist yet, a fresh
67    /// pairing code is generated and printed to the terminal. Once
68    /// paired, no code is generated on restart — operators can use
69    /// `generate_new_pairing_code()` or the CLI to create one on demand.
70    ///
71    /// Existing tokens are accepted in both forms:
72    /// - Plaintext (`zc_...`): hashed on load for backward compatibility
73    /// - Already hashed (64-char hex): stored as-is
74    pub fn new(require_pairing: bool, existing_tokens: &[String]) -> Self {
75        let tokens: HashSet<String> = existing_tokens
76            .iter()
77            .map(|t| {
78                if is_token_hash(t) {
79                    t.clone()
80                } else {
81                    hash_token(t)
82                }
83            })
84            .collect();
85        let code = if require_pairing && tokens.is_empty() {
86            Some(generate_code())
87        } else {
88            None
89        };
90        Self {
91            require_pairing,
92            pairing_code: Arc::new(Mutex::new(code)),
93            paired_tokens: Arc::new(Mutex::new(tokens)),
94            failed_attempts: Arc::new(Mutex::new((HashMap::new(), Instant::now()))),
95        }
96    }
97
98    /// The one-time pairing code (generated only on first startup when no tokens exist).
99    pub fn pairing_code(&self) -> Option<String> {
100        self.pairing_code.lock().clone()
101    }
102
103    /// Whether pairing is required at all.
104    pub fn require_pairing(&self) -> bool {
105        self.require_pairing
106    }
107
108    fn try_pair_blocking(&self, code: &str, client_id: &str) -> Result<Option<String>, u64> {
109        let client_id = normalize_client_key(client_id);
110        let now = Instant::now();
111
112        // Periodic sweep + lockout check
113        {
114            let mut guard = self.failed_attempts.lock();
115            let (ref mut map, ref mut last_sweep) = *guard;
116
117            // Sweep stale entries on interval
118            if now.duration_since(*last_sweep).as_secs() >= FAILED_ATTEMPT_SWEEP_INTERVAL_SECS {
119                prune_failed_attempts(map, now);
120                *last_sweep = now;
121            }
122
123            // Check brute force lockout for this specific client
124            if let Some(state) = map.get(&client_id)
125                && let Some(until) = state.lockout_until
126            {
127                if now < until {
128                    let remaining = (until - now).as_secs();
129                    return Err(remaining.max(1));
130                }
131                // Lockout expired — reset inline
132                map.remove(&client_id);
133            }
134        }
135
136        {
137            let mut pairing_code = self.pairing_code.lock();
138            if let Some(ref expected) = *pairing_code
139                && constant_time_eq(code.trim(), expected.trim())
140            {
141                // Reset failed attempts for this client on success
142                {
143                    let mut guard = self.failed_attempts.lock();
144                    guard.0.remove(&client_id);
145                }
146                let token = generate_token();
147                let mut tokens = self.paired_tokens.lock();
148                tokens.insert(hash_token(&token));
149
150                // Consume the pairing code so it cannot be reused
151                *pairing_code = None;
152
153                return Ok(Some(token));
154            }
155        }
156
157        // Increment failed attempts for this client
158        {
159            let mut guard = self.failed_attempts.lock();
160            let (ref mut map, _) = *guard;
161
162            // Enforce capacity bound: prune stale first, then LRU-evict if still full
163            if map.len() >= MAX_TRACKED_CLIENTS {
164                prune_failed_attempts(map, now);
165            }
166            if map.len() >= MAX_TRACKED_CLIENTS {
167                // Evict the least-recently-active entry
168                if let Some(lru_key) = map
169                    .iter()
170                    .min_by_key(|(_, s)| s.last_attempt)
171                    .map(|(k, _)| k.clone())
172                {
173                    map.remove(&lru_key);
174                }
175            }
176
177            let entry = map.entry(client_id).or_insert(FailedAttemptState {
178                count: 0,
179                lockout_until: None,
180                last_attempt: now,
181            });
182
183            entry.last_attempt = now;
184            entry.count += 1;
185
186            if entry.count >= MAX_PAIR_ATTEMPTS {
187                entry.lockout_until = Some(now + std::time::Duration::from_secs(PAIR_LOCKOUT_SECS));
188            }
189        }
190
191        Ok(None)
192    }
193
194    /// Attempt to pair with the given code. Returns a bearer token on success.
195    /// Returns `Err(lockout_seconds)` if locked out due to brute force.
196    /// `client_id` identifies the client for per-client lockout accounting.
197    pub async fn try_pair(&self, code: &str, client_id: &str) -> Result<Option<String>, u64> {
198        let this = self.clone();
199        let code = code.to_string();
200        let client_id = client_id.to_string();
201        // TODO: make this function the main one without spawning a task
202        let handle = tokio::task::spawn_blocking(move || this.try_pair_blocking(&code, &client_id));
203
204        handle
205            .await
206            .expect("failed to spawn blocking task this should not happen")
207    }
208
209    /// Check if a bearer token is valid (compares against stored hashes).
210    pub fn is_authenticated(&self, token: &str) -> bool {
211        if !self.require_pairing {
212            return true;
213        }
214        let hashed = hash_token(token);
215        let tokens = self.paired_tokens.lock();
216        tokens.contains(&hashed)
217    }
218
219    /// Returns true if the gateway is already paired (has at least one token).
220    pub fn is_paired(&self) -> bool {
221        let tokens = self.paired_tokens.lock();
222        !tokens.is_empty()
223    }
224
225    /// Get all paired token hashes (for persisting to config).
226    pub fn tokens(&self) -> Vec<String> {
227        let tokens = self.paired_tokens.lock();
228        tokens.iter().cloned().collect()
229    }
230
231    /// Revoke a paired token by plaintext. Returns true if removed.
232    ///
233    /// Test/convenience wrapper that hashes the plaintext, then defers to
234    /// [`revoke_token_hash`](Self::revoke_token_hash). Production revoke paths
235    /// already hold the hash (the device registry stores it) and should call
236    /// `revoke_token_hash` directly rather than re-hashing the plaintext.
237    ///
238    /// In-memory only; the caller must persist `tokens()` to config or a
239    /// restart will resurrect the token from disk.
240    pub fn revoke_token(&self, token: &str) -> bool {
241        let hashed = hash_token(token);
242        let mut tokens = self.paired_tokens.lock();
243        tokens.remove(&hashed)
244    }
245
246    /// Revoke a paired token by its SHA-256 hash. Returns true if removed.
247    pub fn revoke_token_hash(&self, token_hash: &str) -> bool {
248        let mut tokens = self.paired_tokens.lock();
249        tokens.remove(token_hash)
250    }
251
252    /// Revoke every paired token at once. Returns the number of tokens
253    /// invalidated. This is the "rotate after compromise — nuke everything"
254    /// path: when an operator does not know which token leaked, the only safe
255    /// action is to invalidate all of them and force every client to re-pair.
256    /// The caller must persist `tokens()` to config so a daemon restart does
257    /// not resurrect the revoked set.
258    pub fn revoke_all_tokens(&self) -> usize {
259        let mut tokens = self.paired_tokens.lock();
260        let count = tokens.len();
261        tokens.clear();
262        count
263    }
264
265    /// Generate a new pairing code that pairs an additional client.
266    ///
267    /// Does not revoke existing tokens. To rotate a compromised token,
268    /// pair with `revoke_token`/`revoke_token_hash` + a config persist pass.
269    pub fn generate_new_pairing_code(&self) -> Option<String> {
270        if !self.require_pairing {
271            return None;
272        }
273        let new_code = generate_code();
274        *self.pairing_code.lock() = Some(new_code.clone());
275        Some(new_code)
276    }
277
278    /// Generate a new pairing code only when no code is already pending.
279    ///
280    /// Returns `Ok(code)` on success, `Err(GeneratePairingCodeError::Pending)`
281    /// when the slot is already occupied, and
282    /// `Err(GeneratePairingCodeError::PairingDisabled)` when pairing is off.
283    /// The check + write is atomic — concurrent callers cannot both observe
284    /// the slot vacant and then both write into it.
285    pub fn generate_pairing_code_if_vacant(&self) -> Result<String, GeneratePairingCodeError> {
286        if !self.require_pairing {
287            return Err(GeneratePairingCodeError::PairingDisabled);
288        }
289        let mut slot = self.pairing_code.lock();
290        if slot.is_some() {
291            return Err(GeneratePairingCodeError::Pending);
292        }
293        let new_code = generate_code();
294        *slot = Some(new_code.clone());
295        Ok(new_code)
296    }
297
298    /// Get the token hash for a given plaintext token (for device registry lookup).
299    pub fn token_hash(token: &str) -> String {
300        use sha2::{Digest, Sha256};
301        hex::encode(Sha256::digest(token.as_bytes()))
302    }
303
304    /// Check if a token is paired and return its hash.
305    pub fn authenticate_and_hash(&self, token: &str) -> Option<String> {
306        if self.is_authenticated(token) {
307            Some(Self::token_hash(token))
308        } else {
309            None
310        }
311    }
312}
313
314/// Normalize a client identifier: trim whitespace, map empty to `"unknown"`.
315fn normalize_client_key(key: &str) -> String {
316    let trimmed = key.trim();
317    if trimmed.is_empty() {
318        "unknown".to_string()
319    } else {
320        trimmed.to_string()
321    }
322}
323
324/// Remove failed-attempt entries whose `last_attempt` is older than the retention window.
325fn prune_failed_attempts(map: &mut HashMap<String, FailedAttemptState>, now: Instant) {
326    map.retain(|_, state| {
327        now.duration_since(state.last_attempt).as_secs() < FAILED_ATTEMPT_RETENTION_SECS
328    });
329}
330
331/// Generate a 6-digit numeric pairing code using cryptographically secure randomness.
332fn generate_code() -> String {
333    // UUID v4 uses getrandom (backed by /dev/urandom on Linux, BCryptGenRandom
334    // on Windows) — a CSPRNG. We extract 4 bytes from it for a uniform random
335    // number in [0, 1_000_000).
336    //
337    // Rejection sampling eliminates modulo bias: values above the largest
338    // multiple of 1_000_000 that fits in u32 are discarded and re-drawn.
339    // The rejection probability is ~0.02%, so this loop almost always exits
340    // on the first iteration.
341    const UPPER_BOUND: u32 = 1_000_000;
342    const REJECT_THRESHOLD: u32 = (u32::MAX / UPPER_BOUND) * UPPER_BOUND;
343
344    loop {
345        let uuid = uuid::Uuid::new_v4();
346        let bytes = uuid.as_bytes();
347        let raw = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
348
349        if raw < REJECT_THRESHOLD {
350            return format!("{:06}", raw % UPPER_BOUND);
351        }
352    }
353}
354
355/// Generate a cryptographically-adequate bearer token with 256-bit entropy.
356///
357/// Uses `rand::rng()` which is backed by the OS CSPRNG
358/// (/dev/urandom on Linux, BCryptGenRandom on Windows, SecRandomCopyBytes
359/// on macOS). The 32 random bytes (256 bits) are hex-encoded for a
360/// 64-character token, providing 256 bits of entropy.
361fn generate_token() -> String {
362    let bytes: [u8; 32] = rand::random();
363    format!("zc_{}", hex::encode(bytes))
364}
365
366/// SHA-256 hash a bearer token for storage. Returns lowercase hex.
367fn hash_token(token: &str) -> String {
368    format!("{:x}", Sha256::digest(token.as_bytes()))
369}
370
371/// Check if a stored value looks like a SHA-256 hash (64 hex chars)
372/// rather than a plaintext token.
373fn is_token_hash(value: &str) -> bool {
374    value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
375}
376
377/// Constant-time string comparison to prevent timing attacks.
378///
379/// This function is critical to the security of the pairing mechanism:
380/// when verifying the one-time pairing code, timing side-channels could
381/// allow an attacker to deduce the correct code character-by-character.
382///
383/// Implementation details that ensure constant-time execution:
384/// 1. Does not short-circuit on length mismatch — always iterates over
385///    the longer input to avoid leaking length information via timing.
386/// 2. Uses bitwise AND (&) instead of logical AND (&&) to ensure both
387///    comparisons always execute, preventing timing variations that could
388///    reveal whether the length check or byte comparison failed first.
389///
390/// SECURITY NOTE: The use of `&` instead of `&&` is intentional and
391/// required for constant-time behavior. Do not change to `&&` or clippy
392/// suggestions that would reintroduce short-circuit evaluation.
393#[allow(clippy::needless_bitwise_bool)]
394pub fn constant_time_eq(a: &str, b: &str) -> bool {
395    let a = a.as_bytes();
396    let b = b.as_bytes();
397
398    // Track length mismatch as a usize (non-zero = different lengths)
399    let len_diff = a.len() ^ b.len();
400
401    // XOR each byte, padding the shorter input with zeros.
402    // Iterates over max(a.len(), b.len()) to avoid timing differences.
403    let max_len = a.len().max(b.len());
404    let mut byte_diff = 0u8;
405    for i in 0..max_len {
406        let x = *a.get(i).unwrap_or(&0);
407        let y = *b.get(i).unwrap_or(&0);
408        byte_diff |= x ^ y;
409    }
410    // Intentional use of bitwise & (not &&) to ensure constant-time execution
411    // and prevent timing side-channel attacks. Both comparisons must execute.
412    (len_diff == 0) & (byte_diff == 0)
413}
414
415/// Check if a host string represents a non-localhost bind address.
416pub fn is_public_bind(host: &str) -> bool {
417    !matches!(
418        host,
419        "127.0.0.1" | "localhost" | "::1" | "[::1]" | "0:0:0:0:0:0:0:1"
420    )
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use tokio::test;
427
428    // ── PairingGuard ─────────────────────────────────────────
429
430    #[test]
431    async fn new_guard_generates_code_when_no_tokens() {
432        let guard = PairingGuard::new(true, &[]);
433        assert!(guard.pairing_code().is_some());
434        assert!(!guard.is_paired());
435    }
436
437    #[test]
438    async fn new_guard_no_code_when_tokens_exist() {
439        let guard = PairingGuard::new(true, &["zc_existing".into()]);
440        assert!(guard.pairing_code().is_none());
441        assert!(guard.is_paired());
442    }
443
444    #[test]
445    async fn new_guard_no_code_when_pairing_disabled() {
446        let guard = PairingGuard::new(false, &[]);
447        assert!(guard.pairing_code().is_none());
448    }
449
450    #[test]
451    async fn try_pair_correct_code() {
452        let guard = PairingGuard::new(true, &[]);
453        let code = guard.pairing_code().unwrap().to_string();
454        let token = guard.try_pair(&code, "test_client").await.unwrap();
455        assert!(token.is_some());
456        assert!(token.unwrap().starts_with("zc_"));
457        assert!(guard.is_paired());
458    }
459
460    #[test]
461    async fn try_pair_wrong_code() {
462        let guard = PairingGuard::new(true, &[]);
463        let result = guard.try_pair("000000", "test_client").await.unwrap();
464        // Might succeed if code happens to be 000000, but extremely unlikely
465        // Just check it returns Ok(None) normally
466        let _ = result;
467    }
468
469    #[test]
470    async fn try_pair_empty_code() {
471        let guard = PairingGuard::new(true, &[]);
472        assert!(guard.try_pair("", "test_client").await.unwrap().is_none());
473    }
474
475    #[test]
476    async fn is_authenticated_with_valid_token() {
477        // Pass plaintext token — PairingGuard hashes it on load
478        let guard = PairingGuard::new(true, &["zc_valid".into()]);
479        assert!(guard.is_authenticated("zc_valid"));
480    }
481
482    #[test]
483    async fn is_authenticated_with_prehashed_token() {
484        // Pass an already-hashed token (64 hex chars)
485        let hashed = hash_token("zc_valid");
486        let guard = PairingGuard::new(true, &[hashed]);
487        assert!(guard.is_authenticated("zc_valid"));
488    }
489
490    #[test]
491    async fn is_authenticated_with_invalid_token() {
492        let guard = PairingGuard::new(true, &["zc_valid".into()]);
493        assert!(!guard.is_authenticated("zc_invalid"));
494    }
495
496    #[test]
497    async fn is_authenticated_when_pairing_disabled() {
498        let guard = PairingGuard::new(false, &[]);
499        assert!(guard.is_authenticated("anything"));
500        assert!(guard.is_authenticated(""));
501    }
502
503    #[test]
504    async fn tokens_returns_hashes() {
505        let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
506        let tokens = guard.tokens();
507        assert_eq!(tokens.len(), 2);
508        // Tokens should be stored as 64-char hex hashes, not plaintext
509        for t in &tokens {
510            assert_eq!(t.len(), 64, "Token should be a SHA-256 hash");
511            assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
512            assert!(!t.starts_with("zc_"), "Token should not be plaintext");
513        }
514    }
515
516    #[test]
517    async fn pair_then_authenticate() {
518        let guard = PairingGuard::new(true, &[]);
519        let code = guard.pairing_code().unwrap().to_string();
520        let token = guard.try_pair(&code, "test_client").await.unwrap().unwrap();
521        assert!(guard.is_authenticated(&token));
522        assert!(!guard.is_authenticated("wrong"));
523    }
524
525    // ── Token hashing ────────────────────────────────────────
526
527    #[test]
528    async fn hash_token_produces_64_hex_chars() {
529        let hash = hash_token("zc_test_token");
530        assert_eq!(hash.len(), 64);
531        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
532    }
533
534    #[test]
535    async fn hash_token_is_deterministic() {
536        assert_eq!(hash_token("zc_abc"), hash_token("zc_abc"));
537    }
538
539    #[test]
540    async fn hash_token_differs_for_different_inputs() {
541        assert_ne!(hash_token("zc_a"), hash_token("zc_b"));
542    }
543
544    #[test]
545    async fn is_token_hash_detects_hash_vs_plaintext() {
546        assert!(is_token_hash(&hash_token("zc_test")));
547        assert!(!is_token_hash("zc_test_token"));
548        assert!(!is_token_hash("too_short"));
549        assert!(!is_token_hash(""));
550    }
551
552    // ── is_public_bind ───────────────────────────────────────
553
554    #[test]
555    async fn localhost_variants_not_public() {
556        assert!(!is_public_bind("127.0.0.1"));
557        assert!(!is_public_bind("localhost"));
558        assert!(!is_public_bind("::1"));
559        assert!(!is_public_bind("[::1]"));
560    }
561
562    #[test]
563    async fn zero_zero_is_public() {
564        assert!(is_public_bind("0.0.0.0"));
565    }
566
567    #[test]
568    async fn real_ip_is_public() {
569        assert!(is_public_bind("192.168.1.100"));
570        assert!(is_public_bind("10.0.0.1"));
571    }
572
573    // ── constant_time_eq ─────────────────────────────────────
574
575    #[test]
576    async fn constant_time_eq_same() {
577        assert!(constant_time_eq("abc", "abc"));
578        assert!(constant_time_eq("", ""));
579    }
580
581    #[test]
582    async fn constant_time_eq_different() {
583        assert!(!constant_time_eq("abc", "abd"));
584        assert!(!constant_time_eq("abc", "ab"));
585        assert!(!constant_time_eq("a", ""));
586    }
587
588    // ── generate helpers ─────────────────────────────────────
589
590    #[test]
591    async fn generate_code_is_6_digits() {
592        let code = generate_code();
593        assert_eq!(code.len(), 6);
594        assert!(code.chars().all(|c| c.is_ascii_digit()));
595    }
596
597    #[test]
598    async fn generate_code_is_not_deterministic() {
599        // Two codes should differ with overwhelming probability. We try
600        // multiple pairs so a single 1-in-10^6 collision doesn't cause
601        // a flaky CI failure. All 10 pairs colliding is ~1-in-10^60.
602        for _ in 0..10 {
603            if generate_code() != generate_code() {
604                return; // Pass: found a non-matching pair.
605            }
606        }
607        panic!("Generated 10 pairs of codes and all were collisions — CSPRNG failure");
608    }
609
610    #[test]
611    async fn generate_token_has_prefix_and_hex_payload() {
612        let token = generate_token();
613        let payload = token
614            .strip_prefix("zc_")
615            .expect("Generated token should include zc_ prefix");
616
617        assert_eq!(payload.len(), 64, "Token payload should be 32 bytes in hex");
618        assert!(
619            payload
620                .chars()
621                .all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')),
622            "Token payload should be lowercase hex"
623        );
624    }
625
626    // ── Brute force protection ───────────────────────────────
627
628    #[test]
629    async fn brute_force_lockout_after_max_attempts() {
630        let guard = PairingGuard::new(true, &[]);
631        let client = "attacker_client";
632        // Exhaust all attempts with wrong codes
633        for i in 0..MAX_PAIR_ATTEMPTS {
634            let result = guard.try_pair(&format!("wrong_{i}"), client).await;
635            assert!(result.is_ok(), "Attempt {i} should not be locked out yet");
636        }
637        // Next attempt should be locked out
638        let result = guard.try_pair("another_wrong", client).await;
639        assert!(
640            result.is_err(),
641            "Should be locked out after {MAX_PAIR_ATTEMPTS} attempts"
642        );
643        let lockout_secs = result.unwrap_err();
644        assert!(lockout_secs > 0, "Lockout should have remaining seconds");
645        assert!(
646            lockout_secs <= PAIR_LOCKOUT_SECS,
647            "Lockout should not exceed max"
648        );
649    }
650
651    #[test]
652    async fn correct_code_resets_failed_attempts() {
653        let guard = PairingGuard::new(true, &[]);
654        let code = guard.pairing_code().unwrap().to_string();
655        let client = "test_client";
656        // Fail a few times
657        for _ in 0..3 {
658            let _ = guard.try_pair("wrong", client).await;
659        }
660        // Correct code should still work (under MAX_PAIR_ATTEMPTS)
661        let result = guard.try_pair(&code, client).await.unwrap();
662        assert!(result.is_some(), "Correct code should work before lockout");
663    }
664
665    #[test]
666    async fn lockout_returns_remaining_seconds() {
667        let guard = PairingGuard::new(true, &[]);
668        let client = "test_client";
669        for _ in 0..MAX_PAIR_ATTEMPTS {
670            let _ = guard.try_pair("wrong", client).await;
671        }
672        let err = guard.try_pair("wrong", client).await.unwrap_err();
673        // Should be close to PAIR_LOCKOUT_SECS (within a second)
674        assert!(
675            err >= PAIR_LOCKOUT_SECS - 1,
676            "Remaining lockout should be ~{PAIR_LOCKOUT_SECS}s, got {err}s"
677        );
678    }
679
680    #[test]
681    async fn successful_pair_resets_only_requesting_client_state() {
682        let guard = PairingGuard::new(true, &[]);
683        let code = guard.pairing_code().unwrap().to_string();
684        let client_a = "client_a";
685        let client_b = "client_b";
686
687        // Both clients fail a few times
688        for _ in 0..3 {
689            let _ = guard.try_pair("wrong", client_a).await;
690            let _ = guard.try_pair("wrong", client_b).await;
691        }
692
693        // client_a pairs successfully — only its state should reset
694        let result = guard.try_pair(&code, client_a).await.unwrap();
695        assert!(result.is_some(), "client_a should pair successfully");
696
697        // client_b's failed count should still be intact (3 failures recorded)
698        let state = guard.failed_attempts.lock();
699        let b_state = state.0.get(client_b);
700        assert!(b_state.is_some(), "client_b state should still exist");
701        assert_eq!(
702            b_state.unwrap().count,
703            3,
704            "client_b should still have 3 failures"
705        );
706
707        // client_a should have been removed
708        assert!(
709            !state.0.contains_key(client_a),
710            "client_a state should be cleared"
711        );
712    }
713
714    #[test]
715    async fn failed_attempt_state_is_bounded_by_max_clients() {
716        let guard = PairingGuard::new(true, &[]);
717
718        // Fill the map to MAX_TRACKED_CLIENTS with stale entries
719        {
720            let mut state = guard.failed_attempts.lock();
721            let past = Instant::now()
722                .checked_sub(std::time::Duration::from_secs(
723                    FAILED_ATTEMPT_RETENTION_SECS + 60,
724                ))
725                .unwrap_or_else(Instant::now);
726            for i in 0..MAX_TRACKED_CLIENTS {
727                state.0.insert(
728                    format!("stale_client_{i}"),
729                    FailedAttemptState {
730                        count: 1,
731                        lockout_until: None,
732                        last_attempt: past,
733                    },
734                );
735            }
736        }
737
738        // A new client triggers an attempt — should prune stale entries and fit
739        let result = guard.try_pair("wrong", "new_client").await;
740        assert!(result.is_ok(), "New client should not be blocked");
741
742        let state = guard.failed_attempts.lock();
743        assert!(
744            state.0.len() <= MAX_TRACKED_CLIENTS,
745            "Map size should stay within bound, got {}",
746            state.0.len()
747        );
748        assert!(
749            state.0.contains_key("new_client"),
750            "New client should be tracked"
751        );
752    }
753
754    #[test]
755    async fn failed_attempt_sweep_prunes_expired_clients() {
756        let guard = PairingGuard::new(true, &[]);
757
758        // Seed a stale entry and set last_sweep to long ago so sweep triggers
759        {
760            let mut state = guard.failed_attempts.lock();
761            let past = Instant::now()
762                .checked_sub(std::time::Duration::from_secs(
763                    FAILED_ATTEMPT_RETENTION_SECS + 60,
764                ))
765                .unwrap_or_else(Instant::now);
766            state.0.insert(
767                "stale_client".to_string(),
768                FailedAttemptState {
769                    count: 2,
770                    lockout_until: None,
771                    last_attempt: past,
772                },
773            );
774            // Force last_sweep to be old enough to trigger sweep
775            state.1 = Instant::now()
776                .checked_sub(std::time::Duration::from_secs(
777                    FAILED_ATTEMPT_SWEEP_INTERVAL_SECS + 1,
778                ))
779                .unwrap_or_else(Instant::now);
780        }
781
782        // Any attempt triggers sweep
783        let _ = guard.try_pair("wrong", "fresh_client").await;
784
785        let state = guard.failed_attempts.lock();
786        assert!(
787            !state.0.contains_key("stale_client"),
788            "Stale client should have been pruned by sweep"
789        );
790        assert!(
791            state.0.contains_key("fresh_client"),
792            "Fresh client should still be tracked"
793        );
794    }
795
796    #[test]
797    async fn lockout_is_per_client() {
798        let guard = PairingGuard::new(true, &[]);
799        let attacker = "attacker_ip";
800        let legitimate = "legitimate_ip";
801
802        // Attacker exhausts attempts
803        for i in 0..MAX_PAIR_ATTEMPTS {
804            let _ = guard.try_pair(&format!("wrong_{i}"), attacker).await;
805        }
806        // Attacker is locked out
807        assert!(guard.try_pair("wrong", attacker).await.is_err());
808
809        // Legitimate client is NOT locked out
810        let result = guard.try_pair("wrong", legitimate).await;
811        assert!(
812            result.is_ok(),
813            "Legitimate client should not be locked out by attacker"
814        );
815    }
816
817    // ── Token revocation ─────────────────────────────────────
818
819    /// Regression: revoked tokens MUST stop authenticating immediately.
820    /// This was the GHSA-f385-f6h2-3gqj follow-up gap — rotation surfaces
821    /// generated new codes but never removed the old token.
822    #[test]
823    async fn revoked_token_no_longer_authenticates() {
824        let guard = PairingGuard::new(true, &[]);
825        let code = guard.pairing_code().unwrap().to_string();
826        let token = guard.try_pair(&code, "c").await.unwrap().unwrap();
827        assert!(guard.is_authenticated(&token));
828
829        assert!(guard.revoke_token(&token));
830        assert!(!guard.is_authenticated(&token));
831        assert!(!guard.is_paired());
832    }
833
834    /// Enforcement: persisted view (`tokens()`) drops the revoked entry,
835    /// so a daemon restart cannot resurrect it from `gateway.paired_tokens`.
836    #[test]
837    async fn revoked_token_is_dropped_from_persistence_view() {
838        let guard = PairingGuard::new(true, &[]);
839        let code = guard.pairing_code().unwrap().to_string();
840        let token = guard.try_pair(&code, "c").await.unwrap().unwrap();
841        let expected_hash = hash_token(&token);
842        assert!(guard.tokens().contains(&expected_hash));
843
844        assert!(guard.revoke_token(&token));
845        assert!(!guard.tokens().contains(&expected_hash));
846    }
847
848    #[test]
849    async fn revoke_token_hash_matches_revoke_token() {
850        let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
851        let hash_a = hash_token("zc_a");
852        assert!(guard.revoke_token_hash(&hash_a));
853        assert!(!guard.is_authenticated("zc_a"));
854        assert!(guard.is_authenticated("zc_b"));
855    }
856
857    #[test]
858    async fn revoke_unknown_token_is_noop() {
859        let guard = PairingGuard::new(true, &["zc_a".into()]);
860        assert!(!guard.revoke_token("zc_never_paired"));
861        assert!(guard.is_authenticated("zc_a"));
862    }
863
864    /// Enforcement: revoking one paired client must not affect siblings.
865    #[test]
866    async fn revoke_is_scoped_to_target_token() {
867        let guard = PairingGuard::new(true, &["zc_keep".into(), "zc_drop".into()]);
868        assert!(guard.revoke_token("zc_drop"));
869        assert!(guard.is_authenticated("zc_keep"));
870        assert!(!guard.is_authenticated("zc_drop"));
871    }
872
873    /// `revoke_all_tokens` invalidates every paired token and reports the
874    /// count. This is the "rotate after compromise — nuke everything" path.
875    #[test]
876    async fn revoke_all_tokens_invalidates_every_token() {
877        let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into(), "zc_c".into()]);
878        assert_eq!(guard.revoke_all_tokens(), 3);
879        assert!(!guard.is_authenticated("zc_a"));
880        assert!(!guard.is_authenticated("zc_b"));
881        assert!(!guard.is_authenticated("zc_c"));
882        assert!(!guard.is_paired());
883        assert!(guard.tokens().is_empty());
884    }
885
886    #[test]
887    async fn revoke_all_tokens_on_empty_set_returns_zero() {
888        let guard = PairingGuard::new(true, &[]);
889        assert_eq!(guard.revoke_all_tokens(), 0);
890    }
891
892    // ── Atomic pairing-code generation ───────────────────────
893
894    #[test]
895    async fn generate_pairing_code_if_vacant_succeeds_when_slot_empty() {
896        let guard = PairingGuard::new(true, &["zc_existing".into()]);
897        // `new()` does not issue a code once paired; slot is empty here.
898        assert!(guard.pairing_code().is_none());
899        let code = guard.generate_pairing_code_if_vacant().unwrap();
900        assert_eq!(guard.pairing_code().as_deref(), Some(code.as_str()));
901    }
902
903    /// Regression: the check + write must be atomic. The earlier rotate
904    /// handler did `if pairing_code().is_some() { … } else { generate() }`
905    /// across two lock acquisitions; this test fails on that pattern.
906    #[test]
907    async fn generate_pairing_code_if_vacant_refuses_when_slot_occupied() {
908        let guard = PairingGuard::new(true, &[]);
909        let pre_existing = guard.pairing_code().expect("startup code");
910        let err = guard.generate_pairing_code_if_vacant().unwrap_err();
911        assert_eq!(err, GeneratePairingCodeError::Pending);
912        assert_eq!(
913            guard.pairing_code().as_deref(),
914            Some(pre_existing.as_str()),
915            "occupied slot must be preserved"
916        );
917    }
918
919    #[test]
920    async fn generate_pairing_code_if_vacant_refuses_when_pairing_disabled() {
921        let guard = PairingGuard::new(false, &[]);
922        let err = guard.generate_pairing_code_if_vacant().unwrap_err();
923        assert_eq!(err, GeneratePairingCodeError::PairingDisabled);
924    }
925}