Skip to main content

bamboo_server/handlers/settings/
access_control.rs

1use std::collections::BTreeSet;
2use std::net::IpAddr;
3use std::sync::Mutex;
4use std::time::{Duration, Instant};
5
6use bamboo_domain::poison::PoisonRecover;
7
8use actix_web::{
9    body::{EitherBody, MessageBody},
10    cookie::{time::Duration as CookieDuration, Cookie, SameSite},
11    dev::{ServiceRequest, ServiceResponse},
12    http::header,
13    middleware::Next,
14    web, HttpMessage, HttpRequest, HttpResponse, ResponseError,
15};
16use chrono::{SecondsFormat, Utc};
17use rand::RngExt;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::{app_state::AppState, error::AppError};
22use bamboo_config::{Config, DeviceCredential};
23
24fn credential_revision(app_state: &AppState) -> Result<u64, AppError> {
25    bamboo_config::CredentialStore::open(&app_state.app_data_dir)
26        .revision()
27        .map_err(|error| {
28            AppError::InternalError(anyhow::anyhow!(
29                "access-control credential revision unavailable: {error}"
30            ))
31        })
32}
33
34#[derive(Serialize)]
35pub struct AccessStatusResponse {
36    pub password_enabled: bool,
37    pub local_bypass: bool,
38    pub requires_password: bool,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct VerifyPasswordRequest {
43    pub password: String,
44}
45
46#[derive(Serialize)]
47pub struct VerifyPasswordResponse {
48    pub success: bool,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct UpdatePasswordRequest {
53    #[serde(default)]
54    pub current_password: String,
55    #[serde(default)]
56    pub new_password: String,
57}
58
59#[derive(Serialize)]
60pub struct UpdatePasswordResponse {
61    pub success: bool,
62    pub password_enabled: bool,
63}
64
65const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
66const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
67const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
68
69fn normalize_ip(ip: &str) -> &str {
70    let ip = ip.trim();
71    ip.strip_prefix("::ffff:").unwrap_or(ip)
72}
73
74fn split_host_and_port(value: &str) -> &str {
75    let candidate = value.trim();
76    if candidate.is_empty() {
77        return candidate;
78    }
79
80    let without_brackets = candidate
81        .strip_prefix('[')
82        .and_then(|v| v.strip_suffix(']'))
83        .unwrap_or(candidate);
84
85    if without_brackets.parse::<IpAddr>().is_ok() {
86        return without_brackets;
87    }
88
89    without_brackets
90        .split(':')
91        .next()
92        .unwrap_or(without_brackets)
93        .trim()
94}
95
96fn is_local_host(host: &str) -> bool {
97    let normalized = split_host_and_port(host)
98        .trim()
99        .trim_end_matches('.')
100        .to_lowercase();
101    if normalized.is_empty() {
102        return false;
103    }
104
105    if normalized == "localhost" || normalized.ends_with(".local") {
106        return true;
107    }
108
109    let normalized = normalize_ip(&normalized);
110    match normalized.parse::<IpAddr>() {
111        Ok(IpAddr::V4(v4)) => {
112            v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
113        }
114        Ok(IpAddr::V6(v6)) => {
115            v6.is_loopback()
116                || v6.is_unique_local()
117                || v6.is_unicast_link_local()
118                || v6.is_unspecified()
119        }
120        Err(_) => false,
121    }
122}
123
124fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
125    let mut candidates = Vec::new();
126
127    for header_name in [
128        header::HOST,
129        header::HeaderName::from_static("x-forwarded-host"),
130        header::HeaderName::from_static("x-original-host"),
131    ] {
132        if let Some(value) = req
133            .headers()
134            .get(&header_name)
135            .and_then(|v| v.to_str().ok())
136        {
137            for part in value.split(',') {
138                let host = part.trim();
139                if !host.is_empty() {
140                    candidates.push(host.to_string());
141                }
142            }
143        }
144    }
145
146    if let Some(uri_host) = req.uri().host() {
147        let host = uri_host.trim();
148        if !host.is_empty() {
149            candidates.push(host.to_string());
150        }
151    }
152
153    candidates
154}
155
156fn is_local_request(req: &HttpRequest) -> bool {
157    // The real TCP peer is the source of truth for the local-bypass decision. A
158    // client-controlled `Host` / `X-Forwarded-Host` header MUST NOT upgrade a
159    // known-REMOTE peer to "local" — that was an auth bypass (#199): a request
160    // from the public internet carrying `Host: localhost` would be treated as
161    // local and skip the access password entirely.
162    //
163    // We deliberately trust ONLY the actual socket peer here, NOT
164    // `X-Forwarded-For` / realip (also client-controlled, and there is no
165    // trusted-proxy mode configured — bamboo terminates TLS itself per the v2
166    // design, so the socket peer IS the client).
167    let peer_local: Option<bool> = req
168        .peer_addr()
169        .map(|peer| is_local_host(&peer.ip().to_string()));
170
171    let host_candidates = request_host_candidates(req);
172    if !host_candidates.is_empty() {
173        let host_local = host_candidates.iter().all(|host| is_local_host(host));
174        // Local only when the Host says local AND the peer is not known-remote.
175        //
176        // A peer of `None` falls back to the Host signal (so loopback/LAN dev +
177        // unit tests without socket info still resolve local). This is NOT a
178        // remote-reachable bypass: actix populates `peer_addr()` for every
179        // accepted TCP/TLS socket, so a real internet client always yields
180        // `Some(_)`. `None` occurs only for unit `TestRequest`s and non-`net`
181        // transports (UDS / in-memory) — none of which a remote attacker can
182        // drive — so a spoofed `Host: localhost` from `None` cannot originate
183        // off-box.
184        //
185        // DEPLOYMENT CAVEAT: because we trust the socket peer (not `realip` /
186        // `X-Forwarded-For`), a reverse proxy on the SAME host (proxy→bamboo over
187        // loopback) makes every forwarded request's peer `127.0.0.1`. A normal
188        // proxy forwards the client's real `Host` (a public name → `host_local`
189        // false → still requires auth), but a proxy that rewrites `Host` to a
190        // local value would make all proxied clients local-bypass. The v2 design
191        // is "no proxy — bamboo terminates TLS itself", so this is acceptable;
192        // a trusted-proxy mode (trust `X-Forwarded-For`) would be a separate opt-in.
193        return host_local && peer_local != Some(false);
194    }
195
196    // No Host header: decide purely from the real socket peer.
197    if let Some(local) = peer_local {
198        return local;
199    }
200    let conn = req.connection_info();
201    conn.peer_addr().map(is_local_host).unwrap_or(false)
202}
203
204/// Best-effort client-IP key for per-IP throttling (#190).
205///
206/// Mirrors the precedence `is_local_request` uses to read the client address:
207/// `peer_addr` first (the real TCP peer — the hardest to spoof when there is no
208/// reverse proxy), then `connection_info().realip_remote_addr()` (an
209/// `X-Forwarded-For`-derived address, used when a trusted proxy fronts the app),
210/// then the raw `connection_info().peer_addr()`. The address is normalized
211/// (stripping an `::ffff:` v4-mapped prefix) so the same client maps to one key
212/// regardless of representation. Returns `None` when no address can be
213/// determined, in which case the caller falls back to a single shared key so the
214/// path is still rate-capped rather than unguarded.
215///
216/// CAVEAT: behind a proxy that does NOT set a trusted forwarded header, every
217/// request shares the proxy's peer IP and would collapse onto one key (global
218/// cap). And per-IP keying is inherently defeatable by an attacker who can rotate
219/// source IPs — this raises the cost of a brute force, it does not make it
220/// impossible. This is the documented trade-off of per-IP throttling.
221fn client_ip_key(req: &HttpRequest) -> Option<String> {
222    if let Some(peer) = req.peer_addr() {
223        return Some(normalize_ip(&peer.ip().to_string()).to_string());
224    }
225
226    let conn = req.connection_info();
227    for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
228        .into_iter()
229        .flatten()
230    {
231        let normalized = normalize_ip(candidate).trim();
232        if !normalized.is_empty() {
233            return Some(normalized.to_string());
234        }
235    }
236
237    None
238}
239
240fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
241    let salt = hex::decode(salt_hex).ok()?;
242    let mut hasher = Sha256::new();
243    hasher.update(&salt);
244    hasher.update(password.as_bytes());
245    Some(hex::encode(hasher.finalize()))
246}
247
248fn verify_password(config: &Config, password: &str) -> bool {
249    let Some(access) = config.access_control.as_ref() else {
250        return false;
251    };
252    if !access.password_enabled {
253        return false;
254    }
255
256    let (Some(hash), Some(salt)) = (
257        access.password_hash.as_deref(),
258        access.password_salt.as_deref(),
259    ) else {
260        return false;
261    };
262
263    compute_password_hash(password, salt)
264        .map(|computed| computed == hash)
265        .unwrap_or(false)
266}
267
268// ── v2-P2 per-device tokens (#181) ──────────────────────────────────────────
269//
270// A device token reuses the SAME hash construction as the access password
271// (`compute_password_hash` = SHA-256(salt || secret)); no new crypto dependency.
272// Plaintext tokens are returned to the client ONCE at pairing and are NEVER
273// stored or logged — only the hash is persisted.
274
275/// Device-token prefix. `bd1_` + 32 hex chars (16 random bytes).
276const DEVICE_TOKEN_PREFIX: &str = "bd1_";
277/// Device-id prefix. `bamboo_` + 12 hex chars (6 random bytes).
278const DEVICE_ID_PREFIX: &str = "bamboo_";
279/// HTTP header carrying the device id companion for a `Authorization: Bearer`
280/// device token (the token alone can't locate its per-device salt).
281const DEVICE_ID_HEADER: &str = "x-device-id";
282
283/// Constant-time comparison over two byte slices. Returns `false` immediately on
284/// a length mismatch (lengths are not secret here — both are fixed-width hex
285/// digests), then folds every byte so the loop time does not depend on where the
286/// first differing byte is. Used for the device-token hash compare as
287/// defense-in-depth for the new credential path (the password path predates this
288/// and keeps `==`).
289fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
290    if a.len() != b.len() {
291        return false;
292    }
293    let mut diff: u8 = 0;
294    for (x, y) in a.iter().zip(b.iter()) {
295        diff |= x ^ y;
296    }
297    diff == 0
298}
299
300/// Generate `len` random bytes as a lowercase hex string.
301fn random_hex(len: usize) -> String {
302    let mut bytes = vec![0_u8; len];
303    rand::rng().fill(&mut bytes);
304    hex::encode(bytes)
305}
306
307/// Issue a fresh device credential for `label`.
308///
309/// Returns the [`DeviceCredential`] to persist (hash only) and the plaintext
310/// `device_token` to return to the client ONCE. A fresh 16-byte salt is generated
311/// per device; `token_hash = SHA-256(salt || token)`.
312pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
313    let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
314    let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
315    let salt_hex = random_hex(16);
316    // compute_password_hash only returns None on a non-hex salt; ours is always
317    // valid hex, so the hash is infallible here. Fail loudly rather than persist
318    // an empty (dead) token_hash if that invariant is ever broken.
319    let token_hash =
320        compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
321    let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
322
323    let credential = DeviceCredential {
324        device_id,
325        label: label.to_string(),
326        token_hash,
327        token_salt: salt_hex,
328        token_credential_ref: None,
329        token_configured: false,
330        created_at,
331        last_used_at: None,
332        revoked: false,
333    };
334    (credential, token)
335}
336
337/// Verify a presented `(device_id, token)` pair against the stored devices.
338///
339/// Returns `false` if access control is unset, the device is unknown or revoked,
340/// or the hash does not match. The hash comparison is constant-time.
341pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
342    let Some(access) = config.access_control.as_ref() else {
343        return false;
344    };
345    // device_id is a public, non-secret companion id; a plain `==` lookup here is
346    // intentional. Only the token hash compare below must be constant-time.
347    let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
348        return false;
349    };
350    if device.revoked {
351        return false;
352    }
353    let Some(computed) = compute_password_hash(token, &device.token_salt) else {
354        return false;
355    };
356    constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
357}
358
359/// Whether the config has at least one non-revoked device. When true (even with
360/// no root password) the middleware must require a credential for non-local
361/// requests.
362fn has_active_devices(config: &Config) -> bool {
363    config
364        .access_control
365        .as_ref()
366        .map(|access| access.devices.iter().any(|d| !d.revoked))
367        .unwrap_or(false)
368}
369
370/// Extract a presented device token from a request.
371///
372/// Scheme (documented for clients): the token rides in
373/// `Authorization: Bearer bd1_<...>` and its companion device id in
374/// `X-Device-Id: bamboo_<...>` (the token alone cannot locate its per-device
375/// salt). Returns `(device_id, token)` when both are present and the
376/// Authorization value carries a `bd1_`-prefixed bearer token.
377fn presented_bearer_token(req: &HttpRequest) -> Option<&str> {
378    let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
379    Some(
380        auth.strip_prefix("Bearer ")
381            .or_else(|| auth.strip_prefix("bearer "))?
382            .trim(),
383    )
384}
385
386fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
387    let token = presented_bearer_token(req)?;
388    if !token.starts_with(DEVICE_TOKEN_PREFIX) {
389        return None;
390    }
391    let device_id = req
392        .headers()
393        .get(DEVICE_ID_HEADER)?
394        .to_str()
395        .ok()?
396        .trim()
397        .to_string();
398    if device_id.is_empty() {
399        return None;
400    }
401    Some((device_id, token.to_string()))
402}
403
404/// Whether the request carries a valid device-token credential.
405fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
406    match presented_device_token(req) {
407        Some((device_id, token)) => verify_device_token(config, &device_id, &token),
408        None => false,
409    }
410}
411
412fn access_verification_cookie_value(config: &Config) -> Option<String> {
413    let access = config.access_control.as_ref()?;
414    if !access.password_enabled {
415        return None;
416    }
417
418    let hash = access.password_hash.as_deref()?.trim();
419    let salt = access.password_salt.as_deref()?.trim();
420    if hash.is_empty() || salt.is_empty() {
421        return None;
422    }
423
424    let mut hasher = Sha256::new();
425    hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
426    hasher.update(b":");
427    hasher.update(hash.as_bytes());
428    hasher.update(b":");
429    hasher.update(salt.as_bytes());
430    Some(format!(
431        "{}:{}",
432        ACCESS_VERIFIED_COOKIE_VERSION,
433        hex::encode(hasher.finalize())
434    ))
435}
436
437fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
438    let expected = match access_verification_cookie_value(config) {
439        Some(value) => value,
440        None => return false,
441    };
442
443    req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
444        .map(|cookie| cookie.value() == expected)
445        .unwrap_or(false)
446}
447
448fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
449    let value = access_verification_cookie_value(config)?;
450    Some(
451        Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
452            .path("/")
453            .http_only(true)
454            .same_site(SameSite::Lax)
455            .secure(secure)
456            .max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
457            .finish(),
458    )
459}
460
461/// Public route suffixes reachable under BOTH of bamboo's native-API version
462/// prefixes (`/api/v1` canonical, `/v1` legacy alias — see
463/// `routes::bamboo_v1_routes` / #251 finding 1). Kept as one list so a route
464/// that's public under one prefix can't silently end up gated under its alias
465/// — the exact drift `is_public_access_route` used to be exposed to when each
466/// prefix's public paths were hand-listed separately (#251 finding 7).
467const PUBLIC_VERSIONED_SUFFIXES: &[&str] =
468    &["/health", "/bamboo/access/status", "/bamboo/access/verify"];
469
470fn is_public_access_route(path: &str) -> bool {
471    for prefix in ["/api/v1", "/v1"] {
472        if let Some(suffix) = path.strip_prefix(prefix) {
473            if PUBLIC_VERSIONED_SUFFIXES.contains(&suffix) {
474                return true;
475            }
476        }
477    }
478
479    matches!(
480        path,
481        // Unversioned liveness/readiness probes for load balancers / k8s —
482        // must be reachable without a credential. #251 (finding 6).
483        "/healthz"
484            | "/readyz"
485            // v2-P2 (#181): a brand-new device has no credential yet, so the
486            // pairing endpoint must be reachable unauthenticated. It self-gates
487            // by requiring the owner root password in its body.
488            | "/v2/pair"
489            // v2-P2 (#189): the WS upgrade opens unauthenticated, but the ws_v2
490            // handler then ENFORCES auth before serving ANY channel — it is
491            // pre-authorized when the upgrade itself carries a credential
492            // (local bypass / verified password cookie / device-token header),
493            // OR it must present a VERIFIED `hello` device token before any
494            // subscribe/stop is honored, and an unauthenticated socket is closed
495            // on a short deadline. Browsers cannot set headers on a WS upgrade,
496            // so this open-upgrade + hello-carrier path is the ONLY way a
497            // browser device-token client can authenticate over WS.
498            // `/v2/pair/code` + `/v2/devices*` STAY gated (not listed here).
499            | "/v2/stream"
500    )
501}
502
503/// The single source of truth for the access allow-decision, shared by
504/// `enforce_access_password_middleware` (every gated route) and the ws_v2
505/// handler (`/v2/stream` pre-auth). A request is authorized when no credential
506/// is required (no password + no devices, or a local bypass), OR it carries a
507/// verified password cookie, OR it carries a valid per-device token header.
508///
509/// This MUST stay a pure extraction of the middleware's prior allow expression:
510/// changing it changes the gate for every route at once.
511pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
512    !build_access_status(config, req).requires_password
513        || request_has_verified_access_cookie(req, config)
514        || request_has_valid_device_token(req, config)
515}
516
517pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
518    req: ServiceRequest,
519    next: Next<B>,
520) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
521    let path = req.path().to_string();
522    if is_public_access_route(&path) {
523        return next
524            .call(req)
525            .await
526            .map(ServiceResponse::map_into_left_body);
527    }
528
529    let app_state = match req.app_data::<web::Data<AppState>>() {
530        Some(state) => state.clone(),
531        None => {
532            return next
533                .call(req)
534                .await
535                .map(ServiceResponse::map_into_left_body)
536        }
537    };
538
539    // A `bcx1_` bearer opts into the narrower Codex run-token contract. It is
540    // validated even on loopback (where ordinary requests retain the existing
541    // desktop bypass), is accepted only for the OpenAI Responses surface, and
542    // never falls back to cookie/device auth if expired or revoked.
543    if let Some(token) = presented_bearer_token(req.request()) {
544        if token.starts_with(crate::codex_run_tokens::CODEX_RUN_TOKEN_PREFIX) {
545            let scoped_path = matches!(path.as_str(), "/openai/v1/responses" | "/openai/v1/models");
546            if scoped_path {
547                if let Some(context) = app_state.codex_run_tokens.verify(token) {
548                    req.extensions_mut().insert(context);
549                    return next
550                        .call(req)
551                        .await
552                        .map(ServiceResponse::map_into_left_body);
553                }
554            }
555            let response = AppError::Unauthorized(
556                "invalid, expired, or out-of-scope Codex run credential".to_string(),
557            )
558            .error_response()
559            .map_into_right_body();
560            return Ok(req.into_response(response));
561        }
562    }
563
564    let config = app_state.config.read().await.clone();
565    // Auth is required when a credential mechanism is configured (a root password
566    // OR at least one active device) AND the request is not a local bypass. An
567    // instance with NO devices + NO password behaves EXACTLY as before — zero
568    // regression. When required, accept EITHER a verified password cookie OR a
569    // valid per-device token (#181). The allow-decision is centralized in
570    // `request_is_authorized` so the ws_v2 handler enforces the SAME rule (#189).
571    if request_is_authorized(req.request(), &config) {
572        return next
573            .call(req)
574            .await
575            .map(ServiceResponse::map_into_left_body);
576    }
577
578    let response = AppError::Unauthorized("access credential verification required".to_string())
579        .error_response()
580        .map_into_right_body();
581    Ok(req.into_response(response))
582}
583
584fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
585    let password_enabled = config
586        .access_control
587        .as_ref()
588        .map(|access| {
589            access.password_enabled
590                && access
591                    .password_hash
592                    .as_deref()
593                    .map(|value| !value.trim().is_empty())
594                    .unwrap_or(false)
595                && access
596                    .password_salt
597                    .as_deref()
598                    .map(|value| !value.trim().is_empty())
599                    .unwrap_or(false)
600        })
601        .unwrap_or(false);
602    let local_bypass = is_local_request(req);
603    // v2-P2 (#181): once any device is paired, public access requires a
604    // credential even if the root password itself is unset — the device tokens
605    // become the gating mechanism. No devices + no password ⇒ unchanged behavior.
606    let credential_required = password_enabled || has_active_devices(config);
607
608    AccessStatusResponse {
609        password_enabled,
610        local_bypass,
611        requires_password: credential_required && !local_bypass,
612    }
613}
614
615pub async fn get_access_status(
616    req: HttpRequest,
617    app_state: web::Data<AppState>,
618) -> Result<HttpResponse, AppError> {
619    let config = app_state.config.read().await.clone();
620    Ok(HttpResponse::Ok().json(build_access_status(&config, &req)))
621}
622
623pub async fn verify_access_password(
624    req: HttpRequest,
625    payload: web::Json<VerifyPasswordRequest>,
626    app_state: web::Data<AppState>,
627) -> Result<HttpResponse, AppError> {
628    let password = payload.password.trim();
629    if password.is_empty() {
630        return Err(AppError::BadRequest("password is required".to_string()));
631    }
632
633    // #190: per-IP brute-force throttle. A local/desktop request is exempt
634    // (`root_throttle_key` returns None) so the desktop never locks itself out.
635    // If the key is in cooldown, reject with 429 + Retry-After BEFORE comparing.
636    let throttle_key = root_throttle_key(&req);
637    if let Some(key) = throttle_key.as_deref() {
638        if let RootGuardDecision::Cooldown { retry_after_secs } =
639            app_state.root_password_guard.check(key)
640        {
641            return Ok(too_many_requests_response(retry_after_secs));
642        }
643    }
644
645    let config = app_state.config.read().await.clone();
646    if !verify_password(&config, password) {
647        if let Some(key) = throttle_key.as_deref() {
648            app_state.root_password_guard.record_failure(key);
649        }
650        return Err(AppError::Unauthorized("invalid password".to_string()));
651    }
652
653    // Correct password resets this key's counter.
654    if let Some(key) = throttle_key.as_deref() {
655        app_state.root_password_guard.record_success(key);
656    }
657
658    let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
659    let cookie = build_access_verified_cookie(&config, secure)
660        .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
661
662    Ok(HttpResponse::Ok()
663        .cookie(cookie)
664        .json(VerifyPasswordResponse { success: true }))
665}
666
667pub async fn update_access_password(
668    req: HttpRequest,
669    app_state: web::Data<AppState>,
670    payload: web::Json<UpdatePasswordRequest>,
671) -> Result<HttpResponse, AppError> {
672    let local_bypass = is_local_request(&req);
673    let new_password = payload.new_password.trim();
674
675    if new_password.is_empty() {
676        return Err(AppError::BadRequest("new_password is required".to_string()));
677    }
678
679    let current_config = app_state.config.read().await.clone();
680    let password_already_enabled = current_config
681        .access_control
682        .as_ref()
683        .map(|access| access.password_enabled)
684        .unwrap_or(false);
685
686    if password_already_enabled && !local_bypass {
687        let current_password = payload.current_password.trim();
688        if current_password.is_empty() {
689            return Err(AppError::Unauthorized(
690                "current_password is required".to_string(),
691            ));
692        }
693        if !verify_password(&current_config, current_password) {
694            return Err(AppError::Unauthorized(
695                "invalid current password".to_string(),
696            ));
697        }
698    }
699
700    let mut salt_bytes = [0_u8; 16];
701    rand::rng().fill(&mut salt_bytes);
702    let salt_hex = hex::encode(salt_bytes);
703    let password_hash = compute_password_hash(new_password, &salt_hex).ok_or_else(|| {
704        AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
705    })?;
706    let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
707
708    let expected_revision = credential_revision(&app_state)?;
709    app_state
710        .update_access_control_credentials(
711            expected_revision,
712            true,
713            BTreeSet::new(),
714            move |config| {
715                // Mutate in place so an existing `access_control` keeps its paired
716                // `devices` across a root-password change. Replacing the whole
717                // struct with `devices: vec![]` would silently wipe every device
718                // token on every password update (#181).
719                let access = config.access_control.get_or_insert_with(Default::default);
720                access.password_enabled = true;
721                access.password_hash = Some(password_hash.clone());
722                access.password_salt = Some(salt_hex.clone());
723                access.updated_at = Some(updated_at.clone());
724                Ok(())
725            },
726        )
727        .await?;
728
729    Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
730        success: true,
731        password_enabled: true,
732    }))
733}
734
735// ── v2-P2 pairing (#181) ────────────────────────────────────────────────────
736
737#[derive(Debug, Deserialize)]
738pub struct PairDeviceRequest {
739    /// Owner root password — authorizes first-device pairing (slice 1 path).
740    #[serde(default)]
741    pub root_password: String,
742    /// One-time 6-digit pairing code — authorizes subsequent-device pairing
743    /// (slice 2 path). Requested by an already-authenticated device via
744    /// `POST /v2/pair/code`.
745    #[serde(default)]
746    pub code: String,
747    /// Human-readable device label, e.g. "iPhone 15".
748    #[serde(default)]
749    pub label: String,
750}
751
752#[derive(Serialize)]
753pub struct PairDeviceResponse {
754    pub device_id: String,
755    /// Plaintext token — returned ONCE; the server stores only its hash.
756    pub device_token: String,
757    pub expires_hint: &'static str,
758}
759
760/// `POST /v2/pair` — redeem a credential to pair a NEW device. Two paths:
761///
762/// - **code** (slice 2): a one-time 6-digit pairing code requested by an already
763///   authenticated device via `POST /v2/pair/code`. This is the public route a
764///   brand-new device with no credential uses, so it carries a brute-force guard
765///   (see [`PairingCodeGuard`]).
766/// - **root_password** (slice 1): the owner root password directly authorizes
767///   first-device pairing. Unchanged byte-for-byte from slice 1.
768///
769/// The endpoint is on the public whitelist (a new device has no credential), so
770/// it self-gates on one of the two credentials above.
771pub async fn pair_device(
772    req: HttpRequest,
773    payload: web::Json<PairDeviceRequest>,
774    app_state: web::Data<AppState>,
775) -> Result<HttpResponse, AppError> {
776    let label = payload.label.trim();
777    if label.is_empty() {
778        return Err(AppError::BadRequest("label is required".to_string()));
779    }
780
781    let code = payload.code.trim();
782    let root_password = payload.root_password.trim();
783
784    // Dispatch: a non-empty code takes the slice-2 code-redemption path; else a
785    // non-empty root password takes the unchanged slice-1 path; else 400.
786    if !code.is_empty() {
787        return pair_device_with_code(&app_state, code, label).await;
788    }
789    if !root_password.is_empty() {
790        return pair_device_with_root_password(&req, &app_state, root_password, label).await;
791    }
792
793    Err(AppError::BadRequest(
794        "provide either a root_password or a one-time pairing code".to_string(),
795    ))
796}
797
798/// Slice-1 root-password pairing path. Behavior is identical to slice 1, plus the
799/// #190 per-IP brute-force throttle in front of the password compare.
800async fn pair_device_with_root_password(
801    req: &HttpRequest,
802    app_state: &AppState,
803    root_password: &str,
804    label: &str,
805) -> Result<HttpResponse, AppError> {
806    // #190: per-IP brute-force throttle. Loopback/desktop is exempt
807    // (`root_throttle_key` returns None). If in cooldown, reject with 429 +
808    // Retry-After BEFORE comparing the password.
809    let throttle_key = root_throttle_key(req);
810    if let Some(key) = throttle_key.as_deref() {
811        if let RootGuardDecision::Cooldown { retry_after_secs } =
812            app_state.root_password_guard.check(key)
813        {
814            return Ok(too_many_requests_response(retry_after_secs));
815        }
816    }
817
818    let config = app_state.config.read().await.clone();
819
820    let password_enabled = config
821        .access_control
822        .as_ref()
823        .map(|access| access.password_enabled)
824        .unwrap_or(false);
825    if !password_enabled {
826        return Err(AppError::BadRequest(
827            "set an access password first: the owner root password is required to authorize device pairing".to_string(),
828        ));
829    }
830
831    if !verify_password(&config, root_password) {
832        if let Some(key) = throttle_key.as_deref() {
833            app_state.root_password_guard.record_failure(key);
834        }
835        return Err(AppError::Unauthorized("invalid root password".to_string()));
836    }
837
838    // Correct password resets this key's counter.
839    if let Some(key) = throttle_key.as_deref() {
840        app_state.root_password_guard.record_success(key);
841    }
842
843    persist_new_device(app_state, label).await
844}
845
846/// Slice-2 code-redemption pairing path. The code must EXIST and be UNEXPIRED in
847/// the ephemeral store and is consumed ONE-TIME (atomically removed on a
848/// successful match) so it cannot be reused. Guarded against brute force.
849async fn pair_device_with_code(
850    app_state: &AppState,
851    code: &str,
852    label: &str,
853) -> Result<HttpResponse, AppError> {
854    // Brute-force gate FIRST: if we are in a cooldown, reject before touching the
855    // store so an attacker can't probe code validity during the cooldown.
856    if app_state.pairing_code_guard.in_cooldown() {
857        return Err(AppError::Unauthorized(
858            "too many failed pairing attempts — try again later".to_string(),
859        ));
860    }
861
862    // One-time consume: `remove` is atomic in DashMap, so two concurrent redeems
863    // of the SAME code race on the single removal — exactly one wins the `Some`,
864    // the other gets `None` and is treated as an invalid code. After taking the
865    // entry we still check expiry (a stale-but-present entry must not pair).
866    let consumed = app_state.pairing_codes.remove(code);
867    let valid = match consumed {
868        Some((_k, entry)) => !entry.is_expired(),
869        None => false,
870    };
871
872    if !valid {
873        // Record the failure; trip the cooldown after the threshold and
874        // proactively invalidate outstanding codes so a near-miss attacker can't
875        // keep probing the rest of the (small) code space.
876        if app_state.pairing_code_guard.record_failure() {
877            app_state.pairing_codes.clear();
878        }
879        return Err(AppError::Unauthorized(
880            "invalid or expired pairing code".to_string(),
881        ));
882    }
883
884    // Success resets the failure counter.
885    app_state.pairing_code_guard.record_success();
886    persist_new_device(app_state, label).await
887}
888
889/// Issue a fresh device credential for `label`, append it to the persisted
890/// devices (preserving every existing field + device), and return the plaintext
891/// token ONCE.
892async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
893    let (credential, token) = issue_device_token(label);
894    let device_id = credential.device_id.clone();
895
896    let expected_revision = credential_revision(app_state)?;
897    app_state
898        .update_access_control_credentials(
899            expected_revision,
900            false,
901            BTreeSet::from([device_id.clone()]),
902            move |config| {
903                // Preserve every existing field + already-paired devices: append,
904                // never replace.
905                let access = config.access_control.get_or_insert_with(Default::default);
906                access.devices.push(credential.clone());
907                Ok(())
908            },
909        )
910        .await?;
911
912    // NOTE: `token` is the plaintext credential — it is returned to the client
913    // here ONCE and is never logged.
914    Ok(HttpResponse::Ok().json(PairDeviceResponse {
915        device_id,
916        device_token: token,
917        expires_hint: "rotate-on-demand",
918    }))
919}
920
921// ── v2-P2 pairing codes + brute-force guard (#181, slice 2) ──────────────────
922
923/// Default code lifetime (~2 minutes).
924const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
925/// Failed code-redemption attempts within the window before the cooldown trips.
926const PAIRING_FAILURE_THRESHOLD: u32 = 10;
927/// How long the code-redemption path stays locked once the threshold is hit.
928const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
929
930/// An in-memory one-time pairing code entry. Holds only an `Instant` expiry —
931/// the code itself is the DashMap key. PROCESS-EPHEMERAL: never persisted.
932#[derive(Debug, Clone)]
933pub struct PairingCodeEntry {
934    expires_at: Instant,
935}
936
937impl PairingCodeEntry {
938    pub(crate) fn new(ttl: Duration) -> Self {
939        Self {
940            expires_at: Instant::now() + ttl,
941        }
942    }
943
944    /// Whether this code has passed its TTL. Pure predicate over `Instant` —
945    /// directly unit-testable by constructing an already-elapsed expiry.
946    pub fn is_expired(&self) -> bool {
947        Instant::now() >= self.expires_at
948    }
949}
950
951/// Per-process brute-force guard for the public code-redemption path.
952///
953/// Design (flagged for review): a 6-digit numeric code is only ~1M wide, and
954/// `POST /v2/pair { code }` is public, so without a guard it is brute-forceable
955/// within a code's 120s TTL. The guard is a simple bounded failed-attempt
956/// counter with a cooldown:
957///
958/// - Each failed code redemption increments a counter.
959/// - After [`PAIRING_FAILURE_THRESHOLD`] (10) failures, a [`PAIRING_COOLDOWN`]
960///   (60s) lockout trips: all further code redemptions are rejected for the
961///   duration, AND the caller proactively clears outstanding codes (so a
962///   near-miss attacker can't resume probing the small space). The counter
963///   resets when the cooldown elapses, or on any successful redemption.
964///
965/// This is per-PROCESS, not per-IP (the public route sits behind no reverse
966/// proxy that reliably carries client IPs in this deployment), so it is a global
967/// rate cap on the code path. The root-password path is untouched (its own
968/// throttling is tracked separately in #190). Trade-off: a global cooldown means
969/// a determined attacker can also deny a legitimate device's pairing for 60s by
970/// burning failures — acceptable for a short, operator-initiated pairing window.
971#[derive(Debug, Default)]
972pub struct PairingCodeGuard {
973    inner: Mutex<PairingGuardState>,
974}
975
976#[derive(Debug, Default)]
977struct PairingGuardState {
978    failures: u32,
979    /// When set and still in the future, the code path is locked.
980    cooldown_until: Option<Instant>,
981}
982
983impl PairingCodeGuard {
984    /// Whether the code-redemption path is currently locked out. Clears an
985    /// elapsed cooldown (and its failure count) as a side effect.
986    pub fn in_cooldown(&self) -> bool {
987        let mut state = self.inner.lock().recover_poison();
988        match state.cooldown_until {
989            Some(until) if Instant::now() < until => true,
990            Some(_) => {
991                // Cooldown elapsed → reset.
992                state.cooldown_until = None;
993                state.failures = 0;
994                false
995            }
996            None => false,
997        }
998    }
999
1000    /// Record a failed redemption. Returns `true` IFF this failure tripped the
1001    /// cooldown (so the caller can invalidate outstanding codes).
1002    pub fn record_failure(&self) -> bool {
1003        let mut state = self.inner.lock().recover_poison();
1004        state.failures = state.failures.saturating_add(1);
1005        if state.failures >= PAIRING_FAILURE_THRESHOLD {
1006            state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
1007            true
1008        } else {
1009            false
1010        }
1011    }
1012
1013    /// Reset the guard after a successful redemption.
1014    pub fn record_success(&self) {
1015        let mut state = self.inner.lock().recover_poison();
1016        state.failures = 0;
1017        state.cooldown_until = None;
1018    }
1019}
1020
1021// ── #190: per-IP root-password brute-force guard ────────────────────────────
1022//
1023// Both public root-password-checking endpoints — `POST /v1/bamboo/access/verify`
1024// (`verify_access_password`) and `POST /v2/pair` on its root-password path
1025// (`pair_device_with_root_password`) — accept the owner root password with no
1026// rate limiting. `verify_password` is constant-time (no timing leak), but
1027// nothing caps the request RATE, so an attacker can brute-force the password.
1028//
1029// `RootPasswordGuard` is a per-client-IP failed-attempt counter with a cooldown.
1030// It mirrors the SHAPE of `PairingCodeGuard` (failure threshold → cooldown,
1031// self-healing decay, success resets) but is keyed per IP via a `DashMap` so one
1032// attacker cannot lock out every other client. A loopback/desktop request is
1033// exempted by the caller (see `is_local_request`) so the desktop can never lock
1034// itself out.
1035
1036/// Consecutive failed root-password attempts from one key before the cooldown
1037/// trips. Lower than the code path's threshold (10) — a root password is the
1038/// high-value secret and there is no legitimate reason to fail it 5 times.
1039const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
1040/// How long a key stays locked once the threshold is hit.
1041const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
1042/// Cap on tracked IP keys. Per-IP keying means an attacker rotating source IPs
1043/// could otherwise grow the map unbounded (slow memory DoS). When a NEW key
1044/// would exceed this, we first sweep keys not in an active cooldown (abandoned
1045/// partial-failures + elapsed cooldowns), which are inert anyway — so memory is
1046/// bounded to roughly the set of IPs actively in a 60s lockout.
1047const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
1048
1049/// Per-key attempt state for the root-password guard.
1050#[derive(Debug, Default, Clone)]
1051struct RootAttemptState {
1052    failures: u32,
1053    /// When set and still in the future, this key is locked.
1054    cooldown_until: Option<Instant>,
1055}
1056
1057/// Per-client-IP brute-force guard for the root-password endpoints (#190).
1058///
1059/// Keyed by a best-effort client-IP string (see `client_ip_key`) so a single
1060/// attacker only locks out their own key, not every client. Each key:
1061///
1062/// - increments a failure counter on a wrong password;
1063/// - after [`ROOT_PASSWORD_FAILURE_THRESHOLD`] (5) consecutive failures, trips a
1064///   [`ROOT_PASSWORD_COOLDOWN`] (60s) lockout — further attempts from that key
1065///   are rejected with HTTP 429 BEFORE the password is even compared;
1066/// - resets on any successful password check;
1067/// - self-heals: once the cooldown elapses the key's state is cleared, so a key
1068///   that simply made a few mistakes recovers automatically.
1069///
1070/// Loopback exemption is the CALLER's responsibility (it never calls into the
1071/// guard for a local request) so the desktop can never lock itself out.
1072///
1073/// This is per-PROCESS state and is NOT persisted — a restart clears all
1074/// counters by design. The code-redemption path keeps its own `PairingCodeGuard`
1075/// (a separate, global guard); this is strictly the root-password paths.
1076#[derive(Debug, Default)]
1077pub struct RootPasswordGuard {
1078    inner: dashmap::DashMap<String, RootAttemptState>,
1079}
1080
1081/// Outcome of consulting the guard for a key.
1082pub enum RootGuardDecision {
1083    /// Not locked — proceed to compare the password.
1084    Allow,
1085    /// Locked — reject with 429 and this many whole seconds in `Retry-After`.
1086    Cooldown { retry_after_secs: u64 },
1087}
1088
1089impl RootPasswordGuard {
1090    /// Check whether `key` is currently locked. Clears an elapsed cooldown (and
1091    /// its failure count) as a side effect so a recovered key returns `Allow`.
1092    pub fn check(&self, key: &str) -> RootGuardDecision {
1093        let now = Instant::now();
1094        if let Some(mut entry) = self.inner.get_mut(key) {
1095            if let Some(until) = entry.cooldown_until {
1096                if now < until {
1097                    let retry_after_secs = (until - now).as_secs().max(1);
1098                    return RootGuardDecision::Cooldown { retry_after_secs };
1099                }
1100                // Cooldown elapsed → reset this key.
1101                entry.failures = 0;
1102                entry.cooldown_until = None;
1103            }
1104        }
1105        RootGuardDecision::Allow
1106    }
1107
1108    /// Record a failed root-password attempt for `key`. Trips the cooldown once
1109    /// the threshold is reached.
1110    pub fn record_failure(&self, key: &str) {
1111        let now = Instant::now();
1112        // Bound memory: before adding a NEW key past the cap, drop every key not
1113        // in an active cooldown (those are inert — an elapsed cooldown or an
1114        // abandoned sub-threshold failure count contributes nothing to gating).
1115        if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1116            self.inner
1117                .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1118        }
1119        let mut entry = self.inner.entry(key.to_string()).or_default();
1120        // A still-live cooldown shouldn't be reachable here (the caller checks
1121        // first), but if it is, leave it; otherwise count the failure.
1122        if matches!(entry.cooldown_until, Some(until) if now < until) {
1123            return;
1124        }
1125        // If a previous cooldown elapsed, this is a fresh window.
1126        if entry.cooldown_until.is_some() {
1127            entry.failures = 0;
1128            entry.cooldown_until = None;
1129        }
1130        entry.failures = entry.failures.saturating_add(1);
1131        if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1132            entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1133        }
1134    }
1135
1136    /// Reset `key` after a successful root-password check.
1137    pub fn record_success(&self, key: &str) {
1138        self.inner.remove(key);
1139    }
1140}
1141
1142/// Resolve the throttle key for a request, honoring the loopback exemption.
1143///
1144/// Returns `None` for a local/loopback request (desktop is NEVER throttled), or
1145/// `Some(key)` for a remote request — the per-IP key when an address is
1146/// available, else a single shared `"unknown"` key so the path still has a
1147/// global rate cap rather than being silently unguarded.
1148fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1149    if is_local_request(req) {
1150        return None;
1151    }
1152    Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1153}
1154
1155/// Build the 429 response for a tripped root-password cooldown, with a
1156/// `Retry-After` header (whole seconds). The body carries no secret material.
1157fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1158    HttpResponse::TooManyRequests()
1159        .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1160        .json(serde_json::json!({
1161            "error": crate::error::error_value(
1162                "too many failed password attempts — try again later"
1163            )
1164        }))
1165}
1166
1167/// Generate a fresh 6-digit numeric code, e.g. "842913". Leading zeros are kept.
1168///
1169/// Uses `gen_range` (uniform rejection sampling) rather than `% 1_000_000` to
1170/// avoid the modulo bias that would make a handful of low codes very slightly
1171/// more probable. `thread_rng` is a CSPRNG, so codes are unpredictable.
1172fn generate_pairing_code() -> String {
1173    let n = rand::rng().random_range(0..1_000_000);
1174    format!("{n:06}")
1175}
1176
1177/// Drop every expired entry from the ephemeral code store (opportunistic GC).
1178fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1179    codes.retain(|_code, entry| !entry.is_expired());
1180}
1181
1182#[derive(Serialize)]
1183pub struct PairingCodeResponse {
1184    pub code: String,
1185    /// TTL in whole seconds.
1186    pub ttl: u64,
1187}
1188
1189/// `POST /v2/pair/code` — an ALREADY-AUTHENTICATED device/owner requests a
1190/// one-time pairing code for a new device.
1191///
1192/// GATED: this route sits behind `enforce_access_password_middleware` (NOT on
1193/// the public whitelist), so only a local_bypass desktop, a valid device token,
1194/// or the verified password cookie can reach it. The generated code is the
1195/// short-lived credential the brand-new device then redeems at `/v2/pair`.
1196pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1197    // Opportunistic GC so the store can't grow unbounded with stale codes.
1198    purge_expired_codes(&app_state.pairing_codes);
1199
1200    let code = generate_pairing_code();
1201    let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1202    // Overwrite on the astronomically-rare collision — the latest request wins.
1203    app_state.pairing_codes.insert(code.clone(), entry);
1204
1205    Ok(HttpResponse::Ok().json(PairingCodeResponse {
1206        code,
1207        ttl: PAIRING_CODE_TTL.as_secs(),
1208    }))
1209}
1210
1211// ── v2-P2 device management (#181, slice 2) ──────────────────────────────────
1212
1213/// Summary DTO for `GET /v2/devices`. CRITICAL: this MUST NOT carry
1214/// `token_hash`/`token_salt` — a credential leak here would let any reader of
1215/// the device list mint a matching token. Only non-secret metadata is exposed.
1216#[derive(Serialize)]
1217pub struct DeviceSummary {
1218    pub device_id: String,
1219    pub label: String,
1220    pub created_at: String,
1221    pub last_used_at: Option<String>,
1222    pub revoked: bool,
1223}
1224
1225impl DeviceSummary {
1226    fn from_credential(d: &DeviceCredential) -> Self {
1227        Self {
1228            device_id: d.device_id.clone(),
1229            label: d.label.clone(),
1230            created_at: d.created_at.clone(),
1231            last_used_at: d.last_used_at.clone(),
1232            revoked: d.revoked,
1233        }
1234    }
1235}
1236
1237/// `GET /v2/devices` — list paired devices (GATED). Returns the summary DTO with
1238/// NO secret material.
1239pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1240    let config = app_state.config.read().await.clone();
1241    let devices: Vec<DeviceSummary> = config
1242        .access_control
1243        .as_ref()
1244        .map(|access| {
1245            access
1246                .devices
1247                .iter()
1248                .map(DeviceSummary::from_credential)
1249                .collect()
1250        })
1251        .unwrap_or_default();
1252    Ok(HttpResponse::Ok().json(devices))
1253}
1254
1255/// `DELETE /v2/devices/{device_id}` — revoke a device (GATED).
1256///
1257/// Sets `revoked = true` (the audit row is KEPT, not removed) and persists.
1258/// Revocation is instant: `verify_device_token` already rejects revoked devices
1259/// and `has_active_devices` recomputes, so the revoked token stops working on
1260/// the very next request. Returns 404 if the device id is unknown.
1261pub async fn revoke_device(
1262    path: web::Path<String>,
1263    app_state: web::Data<AppState>,
1264) -> Result<HttpResponse, AppError> {
1265    let device_id = path.into_inner();
1266
1267    // Existence check up front so an unknown id is a clean 404 without a
1268    // (no-op) persist.
1269    {
1270        let config = app_state.config.read().await;
1271        let exists = config
1272            .access_control
1273            .as_ref()
1274            .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1275            .unwrap_or(false);
1276        if !exists {
1277            return Err(AppError::NotFound(format!("unknown device {device_id}")));
1278        }
1279    }
1280
1281    let target = device_id.clone();
1282    let expected_revision = credential_revision(&app_state)?;
1283    app_state
1284        .update_access_control_credentials(
1285            expected_revision,
1286            false,
1287            BTreeSet::new(),
1288            move |config| {
1289                if let Some(access) = config.access_control.as_mut() {
1290                    if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1291                    {
1292                        device.revoked = true;
1293                    }
1294                }
1295                Ok(())
1296            },
1297        )
1298        .await?;
1299
1300    Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1301}
1302
1303/// `POST /v2/devices/{device_id}/rotate` — issue a NEW token for the SAME device
1304/// (GATED).
1305///
1306/// Keeps `device_id`/`label`/`created_at`, resets `revoked = false`, and
1307/// replaces `token_hash`/`token_salt` with a fresh pair. The OLD token stops
1308/// verifying immediately (its salt is gone). Returns the new plaintext token
1309/// ONCE. Returns 404 if the device id is unknown.
1310pub async fn rotate_device(
1311    path: web::Path<String>,
1312    app_state: web::Data<AppState>,
1313) -> Result<HttpResponse, AppError> {
1314    let device_id = path.into_inner();
1315
1316    // Existence check up front so an unknown id is a clean 404 without persisting
1317    // a no-op config snapshot.
1318    {
1319        let config = app_state.config.read().await;
1320        let exists = config
1321            .access_control
1322            .as_ref()
1323            .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1324            .unwrap_or(false);
1325        if !exists {
1326            return Err(AppError::NotFound(format!("unknown device {device_id}")));
1327        }
1328    }
1329
1330    // Generate a brand-new credential, then graft its secret material onto the
1331    // existing device row (reusing `issue_device_token` for the fresh salt+hash).
1332    let (fresh, token) = issue_device_token("");
1333
1334    let target = device_id.clone();
1335    let expected_revision = credential_revision(&app_state)?;
1336    app_state
1337        .update_access_control_credentials(
1338            expected_revision,
1339            false,
1340            BTreeSet::from([device_id.clone()]),
1341            move |config| {
1342                if let Some(access) = config.access_control.as_mut() {
1343                    if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1344                    {
1345                        device.token_hash = fresh.token_hash.clone();
1346                        device.token_salt = fresh.token_salt.clone();
1347                        device.revoked = false;
1348                        device.last_used_at = None;
1349                    }
1350                }
1351                Ok(())
1352            },
1353        )
1354        .await?;
1355
1356    // NOTE: `token` is the plaintext credential — returned ONCE, never logged.
1357    Ok(HttpResponse::Ok().json(PairDeviceResponse {
1358        device_id,
1359        device_token: token,
1360        expires_hint: "rotate-on-demand",
1361    }))
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367    use actix_web::{
1368        body::to_bytes,
1369        http::StatusCode,
1370        middleware::from_fn,
1371        test::{self, TestRequest},
1372        App,
1373    };
1374    use bamboo_config::AccessControlConfig;
1375    use bamboo_engine::external_agents::actor_adapter::CodexRunTokenAuthority as _;
1376
1377    macro_rules! test_config {
1378        (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
1379        (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
1380        (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
1381        (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
1382        ($($field:ident: $value:expr),* $(,)?) => {{
1383            let mut config = Config::default();
1384            $(test_config!(@assign config, $field, $value);)*
1385            config
1386        }};
1387    }
1388
1389    #[actix_web::test]
1390    async fn codex_run_token_is_path_and_session_scoped_and_revocation_beats_loopback_bypass() {
1391        async fn probe(req: HttpRequest) -> HttpResponse {
1392            let session_id = req
1393                .extensions()
1394                .get::<crate::codex_run_tokens::CodexRunAuthContext>()
1395                .map(|context| context.session_id.clone())
1396                .unwrap_or_else(|| "missing-context".to_string());
1397            HttpResponse::Ok().body(session_id)
1398        }
1399
1400        let data_dir = tempfile::tempdir().unwrap();
1401        let state = AppState::new(data_dir.path().to_path_buf()).await.unwrap();
1402        let tokens = state.codex_run_tokens.clone();
1403        let issued = tokens.issue("codex-child-570").unwrap();
1404        let app = test::init_service(
1405            App::new()
1406                .app_data(web::Data::new(state))
1407                .wrap(from_fn(enforce_access_password_middleware))
1408                .route("/openai/v1/responses", web::post().to(probe))
1409                .route("/openai/v1/chat/completions", web::post().to(probe)),
1410        )
1411        .await;
1412
1413        let valid = TestRequest::post()
1414            .uri("/openai/v1/responses")
1415            .peer_addr("127.0.0.1:5700".parse().unwrap())
1416            .insert_header((header::HOST, "localhost:9562"))
1417            .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
1418            .to_request();
1419        let valid = test::call_service(&app, valid).await;
1420        assert_eq!(valid.status(), StatusCode::OK);
1421        assert_eq!(
1422            to_bytes(valid.into_body()).await.unwrap(),
1423            "codex-child-570".as_bytes()
1424        );
1425
1426        let out_of_scope = TestRequest::post()
1427            .uri("/openai/v1/chat/completions")
1428            .peer_addr("127.0.0.1:5700".parse().unwrap())
1429            .insert_header((header::HOST, "localhost:9562"))
1430            .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
1431            .to_request();
1432        assert_eq!(
1433            test::call_service(&app, out_of_scope).await.status(),
1434            StatusCode::UNAUTHORIZED
1435        );
1436
1437        tokens.revoke(&issued.token_id);
1438        let revoked_on_loopback = TestRequest::post()
1439            .uri("/openai/v1/responses")
1440            .peer_addr("127.0.0.1:5700".parse().unwrap())
1441            .insert_header((header::HOST, "localhost:9562"))
1442            .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
1443            .to_request();
1444        assert_eq!(
1445            test::call_service(&app, revoked_on_loopback).await.status(),
1446            StatusCode::UNAUTHORIZED,
1447            "revoked bcx1_ credentials must never fall through to loopback bypass"
1448        );
1449    }
1450
1451    #[test]
1452    fn loopback_request_is_local() {
1453        let req = TestRequest::default()
1454            .peer_addr("127.0.0.1:12345".parse().unwrap())
1455            .insert_header((header::HOST, "localhost:9562"))
1456            .to_http_request();
1457        assert!(is_local_request(&req));
1458    }
1459
1460    #[test]
1461    fn private_lan_host_is_local() {
1462        let req = TestRequest::default()
1463            .insert_header((header::HOST, "192.168.0.10:9562"))
1464            .to_http_request();
1465        assert!(is_local_request(&req));
1466    }
1467
1468    #[test]
1469    fn remote_host_is_not_local_even_when_peer_is_loopback() {
1470        let req = TestRequest::default()
1471            .peer_addr("127.0.0.1:12345".parse().unwrap())
1472            .insert_header((header::HOST, "bamboo.example.com"))
1473            .to_http_request();
1474        assert!(!is_local_request(&req));
1475    }
1476
1477    #[test]
1478    fn spoofed_local_host_from_remote_peer_is_not_local() {
1479        // #199: a request from a PUBLIC peer carrying `Host: localhost` (or any
1480        // local-looking Host / X-Forwarded-Host) must NOT be treated as local —
1481        // otherwise a remote attacker bypasses the access password entirely.
1482        for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
1483            let req = TestRequest::default()
1484                .peer_addr("203.0.113.5:40000".parse().unwrap()) // public peer
1485                .insert_header((header::HOST, spoof))
1486                .to_http_request();
1487            assert!(
1488                !is_local_request(&req),
1489                "remote peer + spoofed Host '{spoof}' must not be local"
1490            );
1491            // Same via X-Forwarded-Host.
1492            let req2 = TestRequest::default()
1493                .peer_addr("203.0.113.5:40000".parse().unwrap())
1494                .insert_header(("x-forwarded-host", spoof))
1495                .to_http_request();
1496            assert!(
1497                !is_local_request(&req2),
1498                "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
1499            );
1500        }
1501    }
1502
1503    #[test]
1504    fn loopback_peer_with_no_host_is_local() {
1505        let req = TestRequest::default()
1506            .peer_addr("127.0.0.1:5000".parse().unwrap())
1507            .to_http_request();
1508        assert!(is_local_request(&req));
1509    }
1510
1511    #[test]
1512    fn password_hash_roundtrip_verifies() {
1513        let salt_hex = hex::encode([1_u8; 16]);
1514        let hash = compute_password_hash("secret", &salt_hex).unwrap();
1515        let config = test_config! {
1516            access_control: Some(AccessControlConfig {
1517                password_enabled: true,
1518                password_hash: Some(hash),
1519                password_salt: Some(salt_hex),
1520                password_credential_ref: None,
1521                password_configured: false,
1522                updated_at: None,
1523                devices: Vec::new(),
1524            }),
1525        };
1526
1527        assert!(verify_password(&config, "secret"));
1528        assert!(!verify_password(&config, "wrong"));
1529    }
1530
1531    // ── v2-P2 device token primitives + gate (#181) ────────────────────────
1532
1533    fn config_with_password() -> Config {
1534        let salt_hex = hex::encode([1_u8; 16]);
1535        let hash = compute_password_hash("secret", &salt_hex).unwrap();
1536        test_config! {
1537            access_control: Some(AccessControlConfig {
1538                password_enabled: true,
1539                password_hash: Some(hash),
1540                password_salt: Some(salt_hex),
1541                password_credential_ref: None,
1542                password_configured: false,
1543                updated_at: None,
1544                devices: Vec::new(),
1545            }),
1546        }
1547    }
1548
1549    #[test]
1550    fn constant_time_eq_matches_and_rejects() {
1551        assert!(constant_time_eq(b"abcd", b"abcd"));
1552        assert!(!constant_time_eq(b"abcd", b"abce"));
1553        assert!(!constant_time_eq(b"abc", b"abcd"));
1554    }
1555
1556    #[test]
1557    fn issued_token_has_expected_format_and_verifies() {
1558        let (cred, token) = issue_device_token("iPhone 15");
1559        assert!(token.starts_with("bd1_"));
1560        assert_eq!(token.len(), "bd1_".len() + 32);
1561        assert!(cred.device_id.starts_with("bamboo_"));
1562        assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
1563        assert_eq!(cred.label, "iPhone 15");
1564        assert!(!cred.revoked);
1565        // The plaintext token must NOT be stored anywhere on the credential.
1566        assert_ne!(cred.token_hash, token);
1567
1568        let mut config = config_with_password();
1569        config
1570            .access_control
1571            .as_mut()
1572            .unwrap()
1573            .devices
1574            .push(cred.clone());
1575
1576        assert!(verify_device_token(&config, &cred.device_id, &token));
1577        assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
1578        assert!(!verify_device_token(&config, "bamboo_unknown", &token));
1579    }
1580
1581    #[test]
1582    fn revoked_token_is_rejected() {
1583        let (mut cred, token) = issue_device_token("iPad");
1584        cred.revoked = true;
1585        let mut config = config_with_password();
1586        let device_id = cred.device_id.clone();
1587        config.access_control.as_mut().unwrap().devices.push(cred);
1588        assert!(!verify_device_token(&config, &device_id, &token));
1589    }
1590
1591    #[test]
1592    fn has_active_devices_ignores_revoked() {
1593        let mut config = config_with_password();
1594        assert!(!has_active_devices(&config));
1595        let (mut cred, _t) = issue_device_token("d");
1596        cred.revoked = true;
1597        config
1598            .access_control
1599            .as_mut()
1600            .unwrap()
1601            .devices
1602            .push(cred.clone());
1603        assert!(!has_active_devices(&config));
1604        let (cred2, _t2) = issue_device_token("d2");
1605        config.access_control.as_mut().unwrap().devices.push(cred2);
1606        assert!(has_active_devices(&config));
1607    }
1608
1609    fn remote_req() -> HttpRequest {
1610        TestRequest::default()
1611            .insert_header((header::HOST, "bamboo.example.com"))
1612            .to_http_request()
1613    }
1614
1615    fn local_req() -> HttpRequest {
1616        TestRequest::default()
1617            .insert_header((header::HOST, "localhost:9562"))
1618            .to_http_request()
1619    }
1620
1621    #[test]
1622    fn no_devices_no_password_does_not_require_credential() {
1623        // Zero-regression baseline: an instance with neither password nor devices
1624        // never requires a credential, even for a remote request.
1625        let config = Config::default();
1626        assert!(!build_access_status(&config, &remote_req()).requires_password);
1627    }
1628
1629    #[test]
1630    fn password_only_gate_matches_prior_behavior() {
1631        let config = config_with_password();
1632        assert!(build_access_status(&config, &remote_req()).requires_password);
1633        assert!(!build_access_status(&config, &local_req()).requires_password);
1634    }
1635
1636    #[test]
1637    fn device_presence_requires_credential_even_without_password() {
1638        // A device paired but no root password still gates remote access.
1639        let (cred, _t) = issue_device_token("d");
1640        let config = test_config! {
1641            access_control: Some(AccessControlConfig {
1642                password_enabled: false,
1643                password_hash: None,
1644                password_salt: None,
1645                password_credential_ref: None,
1646                password_configured: false,
1647                updated_at: None,
1648                devices: vec![cred],
1649            }),
1650        };
1651        assert!(build_access_status(&config, &remote_req()).requires_password);
1652        // Local still bypasses.
1653        assert!(!build_access_status(&config, &local_req()).requires_password);
1654    }
1655
1656    #[test]
1657    fn valid_device_token_on_request_authenticates() {
1658        let (cred, token) = issue_device_token("d");
1659        let device_id = cred.device_id.clone();
1660        let mut config = config_with_password();
1661        config.access_control.as_mut().unwrap().devices.push(cred);
1662
1663        let req = TestRequest::default()
1664            .insert_header((header::HOST, "bamboo.example.com"))
1665            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1666            .insert_header((DEVICE_ID_HEADER, device_id))
1667            .to_http_request();
1668        assert!(request_has_valid_device_token(&req, &config));
1669
1670        // Wrong token rejected.
1671        let bad = TestRequest::default()
1672            .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
1673            .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
1674            .to_http_request();
1675        assert!(!request_has_valid_device_token(&bad, &config));
1676
1677        // Missing device-id header → not a credential.
1678        let no_id = TestRequest::default()
1679            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1680            .to_http_request();
1681        assert!(!request_has_valid_device_token(&no_id, &config));
1682    }
1683
1684    // ── v2-P2 shared allow-decision: request_is_authorized (#189) ──────────
1685    //
1686    // This is the SINGLE source of truth the middleware and the ws_v2 handler
1687    // both call. These tests pin its truth table so the open `/v2/stream`
1688    // upgrade enforces exactly what the middleware enforces everywhere else.
1689
1690    #[test]
1691    fn request_is_authorized_local_is_always_allowed() {
1692        // A local request bypasses regardless of configured credentials.
1693        let config = config_with_password();
1694        assert!(request_is_authorized(&local_req(), &config));
1695    }
1696
1697    #[test]
1698    fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
1699        // Remote + a credential mechanism configured + no presented credential.
1700        let (cred, _t) = issue_device_token("d");
1701        let config = test_config! {
1702            access_control: Some(AccessControlConfig {
1703                password_enabled: false,
1704                password_hash: None,
1705                password_salt: None,
1706                password_credential_ref: None,
1707                password_configured: false,
1708                updated_at: None,
1709                devices: vec![cred],
1710            }),
1711        };
1712        assert!(!request_is_authorized(&remote_req(), &config));
1713    }
1714
1715    #[test]
1716    fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
1717        let config = config_with_password();
1718        assert!(!request_is_authorized(&remote_req(), &config));
1719    }
1720
1721    #[test]
1722    fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
1723        let config = config_with_password();
1724        let cookie_value =
1725            access_verification_cookie_value(&config).expect("password config yields a cookie");
1726        let req = TestRequest::default()
1727            .insert_header((header::HOST, "bamboo.example.com"))
1728            .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
1729            .to_http_request();
1730        assert!(request_is_authorized(&req, &config));
1731    }
1732
1733    #[test]
1734    fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
1735        let (cred, token) = issue_device_token("d");
1736        let device_id = cred.device_id.clone();
1737        let mut config = config_with_password();
1738        config.access_control.as_mut().unwrap().devices.push(cred);
1739
1740        let req = TestRequest::default()
1741            .insert_header((header::HOST, "bamboo.example.com"))
1742            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1743            .insert_header((DEVICE_ID_HEADER, device_id))
1744            .to_http_request();
1745        assert!(request_is_authorized(&req, &config));
1746    }
1747
1748    #[test]
1749    fn request_is_authorized_no_password_no_devices_is_open() {
1750        // Zero-regression baseline: an instance with neither credential mechanism
1751        // never requires auth, so even a remote request is authorized.
1752        let config = Config::default();
1753        assert!(request_is_authorized(&remote_req(), &config));
1754    }
1755
1756    #[test]
1757    fn stream_is_public_but_sibling_routes_are_not() {
1758        // #189: the upgrade is whitelisted; the gated siblings are NOT.
1759        assert!(is_public_access_route("/v2/stream"));
1760        assert!(is_public_access_route("/v2/pair"));
1761        assert!(!is_public_access_route("/v2/pair/code"));
1762        assert!(!is_public_access_route("/v2/devices"));
1763        assert!(!is_public_access_route("/v2/devices/bamboo_x"));
1764    }
1765
1766    #[test]
1767    fn health_probes_are_public() {
1768        // #251 (finding 6): unversioned liveness/readiness probes must be
1769        // reachable by a load balancer without a credential.
1770        assert!(is_public_access_route("/healthz"));
1771        assert!(is_public_access_route("/readyz"));
1772        assert!(is_public_access_route("/api/v1/health"));
1773    }
1774
1775    #[test]
1776    fn public_access_status_routes_are_public_under_both_version_prefixes() {
1777        // #251 (finding 1 + 7): `/v1/bamboo/access/*` is aliased at the new
1778        // canonical `/api/v1/bamboo/access/*` prefix — a route that's public
1779        // under one prefix must stay public under its alias, or a legitimate
1780        // pre-auth client (checking whether a password is required at all)
1781        // would get locked out simply for calling the canonical path.
1782        for prefix in ["/v1", "/api/v1"] {
1783            assert!(
1784                is_public_access_route(&format!("{prefix}/bamboo/access/status")),
1785                "{prefix}/bamboo/access/status must be public"
1786            );
1787            assert!(
1788                is_public_access_route(&format!("{prefix}/bamboo/access/verify")),
1789                "{prefix}/bamboo/access/verify must be public"
1790            );
1791            // The sibling password-UPDATE route must stay gated under both
1792            // prefixes — only status/verify are pre-auth.
1793            assert!(
1794                !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
1795                "{prefix}/bamboo/access/password must stay gated"
1796            );
1797        }
1798    }
1799
1800    // ── v2-P2 pairing codes + brute-force guard (#181, slice 2) ────────────
1801
1802    #[test]
1803    fn generated_pairing_code_is_six_digits() {
1804        for _ in 0..1000 {
1805            let code = generate_pairing_code();
1806            assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
1807            assert!(
1808                code.chars().all(|c| c.is_ascii_digit()),
1809                "code {code:?} must be all digits"
1810            );
1811        }
1812    }
1813
1814    #[test]
1815    fn pairing_code_expiry_predicate() {
1816        // A fresh code with a positive TTL is not expired.
1817        let fresh = PairingCodeEntry::new(Duration::from_secs(120));
1818        assert!(!fresh.is_expired());
1819
1820        // A zero-TTL code is immediately expired (expires_at == now).
1821        let zero = PairingCodeEntry::new(Duration::from_secs(0));
1822        assert!(zero.is_expired());
1823
1824        // An entry whose expiry is in the past is expired.
1825        let past = PairingCodeEntry {
1826            expires_at: Instant::now() - Duration::from_secs(1),
1827        };
1828        assert!(past.is_expired());
1829    }
1830
1831    #[test]
1832    fn purge_expired_codes_drops_only_expired() {
1833        let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
1834        codes.insert(
1835            "live".into(),
1836            PairingCodeEntry::new(Duration::from_secs(120)),
1837        );
1838        codes.insert(
1839            "dead".into(),
1840            PairingCodeEntry {
1841                expires_at: Instant::now() - Duration::from_secs(1),
1842            },
1843        );
1844        purge_expired_codes(&codes);
1845        assert!(codes.contains_key("live"));
1846        assert!(!codes.contains_key("dead"));
1847    }
1848
1849    #[test]
1850    fn guard_trips_cooldown_after_threshold() {
1851        let guard = PairingCodeGuard::default();
1852        assert!(!guard.in_cooldown());
1853        // The first THRESHOLD-1 failures do not trip the cooldown.
1854        for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1855            assert!(!guard.record_failure());
1856            assert!(!guard.in_cooldown());
1857        }
1858        // The THRESHOLD-th failure trips it.
1859        assert!(guard.record_failure());
1860        assert!(guard.in_cooldown());
1861    }
1862
1863    #[test]
1864    fn guard_success_resets_failures() {
1865        let guard = PairingCodeGuard::default();
1866        for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1867            guard.record_failure();
1868        }
1869        guard.record_success();
1870        // After a reset, the counter starts over — one more failure does NOT trip.
1871        assert!(!guard.record_failure());
1872        assert!(!guard.in_cooldown());
1873    }
1874
1875    #[test]
1876    fn guard_clears_elapsed_cooldown() {
1877        let guard = PairingCodeGuard::default();
1878        // Force a cooldown that has already elapsed.
1879        {
1880            let mut state = guard.inner.lock().unwrap();
1881            state.failures = PAIRING_FAILURE_THRESHOLD;
1882            state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
1883        }
1884        // in_cooldown observes the elapsed deadline and resets.
1885        assert!(!guard.in_cooldown());
1886        assert!(!guard.record_failure(), "counter was reset to 0");
1887    }
1888
1889    // ── #190: per-IP root-password brute-force guard ───────────────────────
1890
1891    #[test]
1892    fn root_guard_trips_cooldown_after_threshold_per_key() {
1893        let guard = RootPasswordGuard::default();
1894        let key = "203.0.113.7";
1895        // The first THRESHOLD-1 failures do not trip the cooldown.
1896        for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1897            guard.record_failure(key);
1898            assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1899        }
1900        // The THRESHOLD-th failure trips it.
1901        guard.record_failure(key);
1902        match guard.check(key) {
1903            RootGuardDecision::Cooldown { retry_after_secs } => {
1904                assert!(retry_after_secs >= 1);
1905                assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
1906            }
1907            RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
1908        }
1909    }
1910
1911    #[test]
1912    fn root_guard_keys_are_independent() {
1913        // Per-IP isolation: tripping one key must NOT lock out a different key.
1914        let guard = RootPasswordGuard::default();
1915        for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1916            guard.record_failure("198.51.100.1");
1917        }
1918        assert!(matches!(
1919            guard.check("198.51.100.1"),
1920            RootGuardDecision::Cooldown { .. }
1921        ));
1922        // A different IP is untouched.
1923        assert!(matches!(
1924            guard.check("198.51.100.2"),
1925            RootGuardDecision::Allow
1926        ));
1927    }
1928
1929    #[test]
1930    fn root_guard_success_resets_key() {
1931        let guard = RootPasswordGuard::default();
1932        let key = "203.0.113.9";
1933        for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1934            guard.record_failure(key);
1935        }
1936        guard.record_success(key);
1937        // After a reset, the counter starts over — one more failure does NOT trip.
1938        guard.record_failure(key);
1939        assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1940    }
1941
1942    #[test]
1943    fn root_guard_clears_elapsed_cooldown() {
1944        let guard = RootPasswordGuard::default();
1945        let key = "203.0.113.10";
1946        // Force a cooldown that has already elapsed.
1947        guard.inner.insert(
1948            key.to_string(),
1949            RootAttemptState {
1950                failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
1951                cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
1952            },
1953        );
1954        // check() observes the elapsed deadline, resets, and allows.
1955        assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1956        // The counter was reset to 0 — one fresh failure does not re-trip.
1957        guard.record_failure(key);
1958        assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1959    }
1960
1961    #[test]
1962    fn root_guard_evicts_inert_keys_past_the_cap() {
1963        let guard = RootPasswordGuard::default();
1964        // Fill to the cap with single-failure (inert, no cooldown) keys, plus a
1965        // few extra to trip the sweep. The map must NOT grow unbounded.
1966        for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1967            guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
1968        }
1969        assert!(
1970            guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
1971            "inert keys must be swept so the map stays bounded (was {})",
1972            guard.inner.len()
1973        );
1974        // An actively cooling-down key survives a sweep.
1975        let hot = "203.0.113.200";
1976        for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1977            guard.record_failure(hot);
1978        }
1979        for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1980            guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
1981        }
1982        assert!(
1983            matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
1984            "a key in active cooldown must survive eviction sweeps"
1985        );
1986    }
1987
1988    #[test]
1989    fn root_throttle_key_exempts_loopback_and_keys_remote() {
1990        // Loopback/desktop is exempt → no key → never throttled.
1991        assert!(root_throttle_key(&local_req()).is_none());
1992
1993        // A remote request with a peer addr yields that IP as the key.
1994        let remote = TestRequest::default()
1995            .peer_addr("203.0.113.5:443".parse().unwrap())
1996            .insert_header((header::HOST, "bamboo.example.com"))
1997            .to_http_request();
1998        assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
1999    }
2000
2001    #[test]
2002    fn client_ip_key_strips_v4_mapped_prefix() {
2003        let req = TestRequest::default()
2004            .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
2005            .to_http_request();
2006        assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
2007    }
2008
2009    #[test]
2010    fn device_summary_excludes_secret_material() {
2011        // Serialized JSON of the GET /v2/devices DTO MUST NOT carry the token
2012        // hash or salt. Assert on the serialized keys/values directly.
2013        let (cred, _t) = issue_device_token("iPhone");
2014        let summary = DeviceSummary::from_credential(&cred);
2015        let json = serde_json::to_value(&summary).unwrap();
2016        let obj = json.as_object().unwrap();
2017        assert!(
2018            !obj.contains_key("token_hash"),
2019            "must not expose token_hash"
2020        );
2021        assert!(
2022            !obj.contains_key("token_salt"),
2023            "must not expose token_salt"
2024        );
2025        // And the actual secret VALUES must not leak under any key.
2026        let serialized = serde_json::to_string(&summary).unwrap();
2027        assert!(!serialized.contains(&cred.token_hash));
2028        assert!(!serialized.contains(&cred.token_salt));
2029        // Expected non-secret fields ARE present.
2030        assert!(obj.contains_key("device_id"));
2031        assert!(obj.contains_key("label"));
2032        assert!(obj.contains_key("created_at"));
2033        assert!(obj.contains_key("revoked"));
2034    }
2035}