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::{
22    app_state::AppState,
23    error::AppError,
24    handlers::settings::credential_action::{
25        credential_status_view, CredentialState, CredentialStatusView,
26    },
27};
28use bamboo_config::{Config, DeviceCredential};
29
30fn access_section_revision(app_state: &AppState) -> Result<u64, AppError> {
31    let facade = app_state.config_facade.as_ref().ok_or_else(|| {
32        AppError::BadRequest("access settings require the modular configuration facade".to_string())
33    })?;
34    Ok(facade.registry().access_control.snapshot().revision)
35}
36
37#[derive(Serialize)]
38pub(crate) struct AccessStatusResponse {
39    pub password_enabled: bool,
40    pub local_bypass: bool,
41    pub requires_password: bool,
42}
43
44#[derive(Serialize)]
45pub(crate) struct AccessStatusEnvelope {
46    #[serde(flatten)]
47    pub runtime: AccessStatusResponse,
48    /// The pre-auth status route exposes the authoritative generation and
49    /// health, but never the Access section's device metadata or local path.
50    pub revision: u64,
51    pub status: bamboo_config::SectionStatus,
52    pub source_kind: bamboo_config::SectionSourceKind,
53    pub loaded_at: chrono::DateTime<Utc>,
54    pub last_error: Option<String>,
55    pub password_configured: bool,
56    pub credential_state: CredentialState,
57    pub credential_ref: Option<String>,
58    pub credential_source: Option<bamboo_config::CredentialSource>,
59    pub credential_updated_at: Option<String>,
60    pub credential_health: bamboo_config::CredentialStoreHealth,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct VerifyPasswordRequest {
65    pub password: String,
66}
67
68#[derive(Serialize)]
69pub struct VerifyPasswordResponse {
70    pub success: bool,
71}
72
73#[derive(Clone, Copy, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum AccessPasswordAction {
76    Replace,
77    Clear,
78}
79
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct UpdatePasswordRequest {
83    /// Access-control section revision required for every password mutation.
84    pub expected_revision: u64,
85    /// Explicit replace/clear intent. Omission retains the legacy replace
86    /// behavior of `new_password`.
87    #[serde(default)]
88    pub action: Option<AccessPasswordAction>,
89    #[serde(default)]
90    pub current_password: String,
91    #[serde(default)]
92    pub new_password: String,
93    #[serde(default)]
94    pub value: String,
95}
96
97impl std::fmt::Debug for UpdatePasswordRequest {
98    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        formatter
100            .debug_struct("UpdatePasswordRequest")
101            .field("expected_revision", &self.expected_revision)
102            .field(
103                "action",
104                &self.action.map(|action| match action {
105                    AccessPasswordAction::Replace => "replace",
106                    AccessPasswordAction::Clear => "clear",
107                }),
108            )
109            .field(
110                "current_password",
111                &(!self.current_password.is_empty()).then_some("[REDACTED]"),
112            )
113            .field(
114                "replacement",
115                &(!(self.value.is_empty() && self.new_password.is_empty())).then_some("[REDACTED]"),
116            )
117            .finish()
118    }
119}
120
121#[derive(Serialize)]
122pub(crate) struct UpdatePasswordResponse {
123    pub success: bool,
124    pub password_enabled: bool,
125    pub revision: u64,
126    pub section: bamboo_config::SectionEnvelope<serde_json::Value>,
127    pub credential: CredentialStatusView,
128    pub credential_health: bamboo_config::CredentialStoreHealth,
129}
130
131const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
132const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
133const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
134
135fn normalize_ip(ip: &str) -> &str {
136    let ip = ip.trim();
137    ip.strip_prefix("::ffff:").unwrap_or(ip)
138}
139
140fn split_host_and_port(value: &str) -> &str {
141    let candidate = value.trim();
142    if candidate.is_empty() {
143        return candidate;
144    }
145
146    let without_brackets = candidate
147        .strip_prefix('[')
148        .and_then(|v| v.strip_suffix(']'))
149        .unwrap_or(candidate);
150
151    if without_brackets.parse::<IpAddr>().is_ok() {
152        return without_brackets;
153    }
154
155    without_brackets
156        .split(':')
157        .next()
158        .unwrap_or(without_brackets)
159        .trim()
160}
161
162fn is_local_host(host: &str) -> bool {
163    let normalized = split_host_and_port(host)
164        .trim()
165        .trim_end_matches('.')
166        .to_lowercase();
167    if normalized.is_empty() {
168        return false;
169    }
170
171    if normalized == "localhost" || normalized.ends_with(".local") {
172        return true;
173    }
174
175    let normalized = normalize_ip(&normalized);
176    match normalized.parse::<IpAddr>() {
177        Ok(IpAddr::V4(v4)) => {
178            v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
179        }
180        Ok(IpAddr::V6(v6)) => {
181            v6.is_loopback()
182                || v6.is_unique_local()
183                || v6.is_unicast_link_local()
184                || v6.is_unspecified()
185        }
186        Err(_) => false,
187    }
188}
189
190fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
191    let mut candidates = Vec::new();
192
193    for header_name in [
194        header::HOST,
195        header::HeaderName::from_static("x-forwarded-host"),
196        header::HeaderName::from_static("x-original-host"),
197    ] {
198        if let Some(value) = req
199            .headers()
200            .get(&header_name)
201            .and_then(|v| v.to_str().ok())
202        {
203            for part in value.split(',') {
204                let host = part.trim();
205                if !host.is_empty() {
206                    candidates.push(host.to_string());
207                }
208            }
209        }
210    }
211
212    if let Some(uri_host) = req.uri().host() {
213        let host = uri_host.trim();
214        if !host.is_empty() {
215            candidates.push(host.to_string());
216        }
217    }
218
219    candidates
220}
221
222fn is_local_request(req: &HttpRequest) -> bool {
223    // The real TCP peer is the source of truth for the local-bypass decision. A
224    // client-controlled `Host` / `X-Forwarded-Host` header MUST NOT upgrade a
225    // known-REMOTE peer to "local" — that was an auth bypass (#199): a request
226    // from the public internet carrying `Host: localhost` would be treated as
227    // local and skip the access password entirely.
228    //
229    // We deliberately trust ONLY the actual socket peer here, NOT
230    // `X-Forwarded-For` / realip (also client-controlled, and there is no
231    // trusted-proxy mode configured — bamboo terminates TLS itself per the v2
232    // design, so the socket peer IS the client).
233    let peer_local: Option<bool> = req
234        .peer_addr()
235        .map(|peer| is_local_host(&peer.ip().to_string()));
236
237    let host_candidates = request_host_candidates(req);
238    if !host_candidates.is_empty() {
239        let host_local = host_candidates.iter().all(|host| is_local_host(host));
240        // Local only when the Host says local AND the peer is not known-remote.
241        //
242        // A peer of `None` falls back to the Host signal (so loopback/LAN dev +
243        // unit tests without socket info still resolve local). This is NOT a
244        // remote-reachable bypass: actix populates `peer_addr()` for every
245        // accepted TCP/TLS socket, so a real internet client always yields
246        // `Some(_)`. `None` occurs only for unit `TestRequest`s and non-`net`
247        // transports (UDS / in-memory) — none of which a remote attacker can
248        // drive — so a spoofed `Host: localhost` from `None` cannot originate
249        // off-box.
250        //
251        // DEPLOYMENT CAVEAT: because we trust the socket peer (not `realip` /
252        // `X-Forwarded-For`), a reverse proxy on the SAME host (proxy→bamboo over
253        // loopback) makes every forwarded request's peer `127.0.0.1`. A normal
254        // proxy forwards the client's real `Host` (a public name → `host_local`
255        // false → still requires auth), but a proxy that rewrites `Host` to a
256        // local value would make all proxied clients local-bypass. The v2 design
257        // is "no proxy — bamboo terminates TLS itself", so this is acceptable;
258        // a trusted-proxy mode (trust `X-Forwarded-For`) would be a separate opt-in.
259        return host_local && peer_local != Some(false);
260    }
261
262    // No Host header: decide purely from the real socket peer.
263    if let Some(local) = peer_local {
264        return local;
265    }
266    let conn = req.connection_info();
267    conn.peer_addr().map(is_local_host).unwrap_or(false)
268}
269
270/// Best-effort client-IP key for per-IP throttling (#190).
271///
272/// Mirrors the precedence `is_local_request` uses to read the client address:
273/// `peer_addr` first (the real TCP peer — the hardest to spoof when there is no
274/// reverse proxy), then `connection_info().realip_remote_addr()` (an
275/// `X-Forwarded-For`-derived address, used when a trusted proxy fronts the app),
276/// then the raw `connection_info().peer_addr()`. The address is normalized
277/// (stripping an `::ffff:` v4-mapped prefix) so the same client maps to one key
278/// regardless of representation. Returns `None` when no address can be
279/// determined, in which case the caller falls back to a single shared key so the
280/// path is still rate-capped rather than unguarded.
281///
282/// CAVEAT: behind a proxy that does NOT set a trusted forwarded header, every
283/// request shares the proxy's peer IP and would collapse onto one key (global
284/// cap). And per-IP keying is inherently defeatable by an attacker who can rotate
285/// source IPs — this raises the cost of a brute force, it does not make it
286/// impossible. This is the documented trade-off of per-IP throttling.
287fn client_ip_key(req: &HttpRequest) -> Option<String> {
288    if let Some(peer) = req.peer_addr() {
289        return Some(normalize_ip(&peer.ip().to_string()).to_string());
290    }
291
292    let conn = req.connection_info();
293    for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
294        .into_iter()
295        .flatten()
296    {
297        let normalized = normalize_ip(candidate).trim();
298        if !normalized.is_empty() {
299            return Some(normalized.to_string());
300        }
301    }
302
303    None
304}
305
306fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
307    let salt = hex::decode(salt_hex).ok()?;
308    let mut hasher = Sha256::new();
309    hasher.update(&salt);
310    hasher.update(password.as_bytes());
311    Some(hex::encode(hasher.finalize()))
312}
313
314fn verify_password(config: &Config, password: &str) -> bool {
315    let Some(access) = config.access_control.as_ref() else {
316        return false;
317    };
318    if access.repair_required || !access.password_enabled {
319        return false;
320    }
321
322    let (Some(hash), Some(salt)) = (
323        access.password_hash.as_deref(),
324        access.password_salt.as_deref(),
325    ) else {
326        return false;
327    };
328
329    compute_password_hash(password, salt)
330        .map(|computed| computed == hash)
331        .unwrap_or(false)
332}
333
334// ── v2-P2 per-device tokens (#181) ──────────────────────────────────────────
335//
336// A device token reuses the SAME hash construction as the access password
337// (`compute_password_hash` = SHA-256(salt || secret)); no new crypto dependency.
338// Plaintext tokens are returned to the client ONCE at pairing and are NEVER
339// stored or logged — only the hash is persisted.
340
341/// Device-token prefix. `bd1_` + 32 hex chars (16 random bytes).
342const DEVICE_TOKEN_PREFIX: &str = "bd1_";
343/// Device-id prefix. `bamboo_` + 12 hex chars (6 random bytes).
344const DEVICE_ID_PREFIX: &str = "bamboo_";
345/// HTTP header carrying the device id companion for a `Authorization: Bearer`
346/// device token (the token alone can't locate its per-device salt).
347const DEVICE_ID_HEADER: &str = "x-device-id";
348
349/// Constant-time comparison over two byte slices. Returns `false` immediately on
350/// a length mismatch (lengths are not secret here — both are fixed-width hex
351/// digests), then folds every byte so the loop time does not depend on where the
352/// first differing byte is. Used for the device-token hash compare as
353/// defense-in-depth for the new credential path (the password path predates this
354/// and keeps `==`).
355fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
356    if a.len() != b.len() {
357        return false;
358    }
359    let mut diff: u8 = 0;
360    for (x, y) in a.iter().zip(b.iter()) {
361        diff |= x ^ y;
362    }
363    diff == 0
364}
365
366/// Generate `len` random bytes as a lowercase hex string.
367fn random_hex(len: usize) -> String {
368    let mut bytes = vec![0_u8; len];
369    rand::rng().fill(&mut bytes);
370    hex::encode(bytes)
371}
372
373/// Issue a fresh device credential for `label`.
374///
375/// Returns the [`DeviceCredential`] to persist (hash only) and the plaintext
376/// `device_token` to return to the client ONCE. A fresh 16-byte salt is generated
377/// per device; `token_hash = SHA-256(salt || token)`.
378pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
379    let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
380    let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
381    let salt_hex = random_hex(16);
382    // compute_password_hash only returns None on a non-hex salt; ours is always
383    // valid hex, so the hash is infallible here. Fail loudly rather than persist
384    // an empty (dead) token_hash if that invariant is ever broken.
385    let token_hash =
386        compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
387    let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
388
389    let credential = DeviceCredential {
390        device_id,
391        label: label.to_string(),
392        token_hash,
393        token_salt: salt_hex,
394        token_credential_ref: None,
395        token_configured: false,
396        created_at,
397        last_used_at: None,
398        revoked: false,
399    };
400    (credential, token)
401}
402
403/// Verify a presented `(device_id, token)` pair against the stored devices.
404///
405/// Returns `false` if access control is unset, the device is unknown or revoked,
406/// or the hash does not match. The hash comparison is constant-time.
407pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
408    let Some(access) = config.access_control.as_ref() else {
409        return false;
410    };
411    if access.repair_required {
412        return false;
413    }
414    // device_id is a public, non-secret companion id; a plain `==` lookup here is
415    // intentional. Only the token hash compare below must be constant-time.
416    let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
417        return false;
418    };
419    if device.revoked {
420        return false;
421    }
422    let Some(computed) = compute_password_hash(token, &device.token_salt) else {
423        return false;
424    };
425    constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
426}
427
428/// Whether the config has at least one non-revoked device. When true (even with
429/// no root password) the middleware must require a credential for non-local
430/// requests.
431fn has_active_devices(config: &Config) -> bool {
432    config
433        .access_control
434        .as_ref()
435        .map(|access| access.devices.iter().any(|d| !d.revoked))
436        .unwrap_or(false)
437}
438
439fn access_repair_required(config: &Config) -> bool {
440    config
441        .access_control
442        .as_ref()
443        .is_some_and(|access| access.repair_required)
444}
445
446/// Extract a presented device token from a request.
447///
448/// Scheme (documented for clients): the token rides in
449/// `Authorization: Bearer bd1_<...>` and its companion device id in
450/// `X-Device-Id: bamboo_<...>` (the token alone cannot locate its per-device
451/// salt). Returns `(device_id, token)` when both are present and the
452/// Authorization value carries a `bd1_`-prefixed bearer token.
453fn presented_bearer_token(req: &HttpRequest) -> Option<&str> {
454    let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
455    Some(
456        auth.strip_prefix("Bearer ")
457            .or_else(|| auth.strip_prefix("bearer "))?
458            .trim(),
459    )
460}
461
462fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
463    let token = presented_bearer_token(req)?;
464    if !token.starts_with(DEVICE_TOKEN_PREFIX) {
465        return None;
466    }
467    let device_id = req
468        .headers()
469        .get(DEVICE_ID_HEADER)?
470        .to_str()
471        .ok()?
472        .trim()
473        .to_string();
474    if device_id.is_empty() {
475        return None;
476    }
477    Some((device_id, token.to_string()))
478}
479
480/// Whether the request carries a valid device-token credential.
481fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
482    match presented_device_token(req) {
483        Some((device_id, token)) => verify_device_token(config, &device_id, &token),
484        None => false,
485    }
486}
487
488fn access_verification_cookie_value(config: &Config) -> Option<String> {
489    let access = config.access_control.as_ref()?;
490    if access.repair_required || !access.password_enabled {
491        return None;
492    }
493
494    let hash = access.password_hash.as_deref()?.trim();
495    let salt = access.password_salt.as_deref()?.trim();
496    if hash.is_empty() || salt.is_empty() {
497        return None;
498    }
499
500    let mut hasher = Sha256::new();
501    hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
502    hasher.update(b":");
503    hasher.update(hash.as_bytes());
504    hasher.update(b":");
505    hasher.update(salt.as_bytes());
506    Some(format!(
507        "{}:{}",
508        ACCESS_VERIFIED_COOKIE_VERSION,
509        hex::encode(hasher.finalize())
510    ))
511}
512
513fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
514    let expected = match access_verification_cookie_value(config) {
515        Some(value) => value,
516        None => return false,
517    };
518
519    req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
520        .map(|cookie| cookie.value() == expected)
521        .unwrap_or(false)
522}
523
524fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
525    let value = access_verification_cookie_value(config)?;
526    Some(
527        Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
528            .path("/")
529            .http_only(true)
530            .same_site(SameSite::Lax)
531            .secure(secure)
532            .max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
533            .finish(),
534    )
535}
536
537/// Public route suffixes reachable under BOTH of bamboo's native-API version
538/// prefixes (`/api/v1` canonical, `/v1` legacy alias — see
539/// `routes::bamboo_v1_routes` / #251 finding 1). Kept as one list so a route
540/// that's public under one prefix can't silently end up gated under its alias
541/// — the exact drift `is_public_access_route` used to be exposed to when each
542/// prefix's public paths were hand-listed separately (#251 finding 7).
543const PUBLIC_VERSIONED_SUFFIXES: &[&str] =
544    &["/health", "/bamboo/access/status", "/bamboo/access/verify"];
545
546fn is_public_access_route(path: &str) -> bool {
547    for prefix in ["/api/v1", "/v1"] {
548        if let Some(suffix) = path.strip_prefix(prefix) {
549            if PUBLIC_VERSIONED_SUFFIXES.contains(&suffix) {
550                return true;
551            }
552        }
553    }
554
555    matches!(
556        path,
557        // Unversioned liveness/readiness probes for load balancers / k8s —
558        // must be reachable without a credential. #251 (finding 6).
559        "/healthz"
560            | "/readyz"
561            // v2-P2 (#181): a brand-new device has no credential yet, so the
562            // pairing endpoint must be reachable unauthenticated. It self-gates
563            // by requiring the owner root password in its body.
564            | "/v2/pair"
565            // v2-P2 (#189): the WS upgrade opens unauthenticated, but the ws_v2
566            // handler then ENFORCES auth before serving ANY channel — it is
567            // pre-authorized when the upgrade itself carries a credential
568            // (local bypass / verified password cookie / device-token header),
569            // OR it must present a VERIFIED `hello` device token before any
570            // subscribe/stop is honored, and an unauthenticated socket is closed
571            // on a short deadline. Browsers cannot set headers on a WS upgrade,
572            // so this open-upgrade + hello-carrier path is the ONLY way a
573            // browser device-token client can authenticate over WS.
574            // `/v2/pair/code` + `/v2/devices*` STAY gated (not listed here).
575            | "/v2/stream"
576    )
577}
578
579/// The single source of truth for the access allow-decision, shared by
580/// `enforce_access_password_middleware` (every gated route) and the ws_v2
581/// handler (`/v2/stream` pre-auth). A request is authorized when no credential
582/// is required (no password + no devices, or a local bypass), OR it carries a
583/// verified password cookie, OR it carries a valid per-device token header.
584///
585/// This MUST stay a pure extraction of the middleware's prior allow expression:
586/// changing it changes the gate for every route at once.
587pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
588    if access_repair_required(config) {
589        // A quarantined access document may still contain a verifier that was
590        // structurally recoverable. Do not let that partial recovery reopen
591        // remote access. The existing loopback/localhost bypass remains the
592        // only allow path until an explicit repair/reset transaction clears
593        // the server-owned flag.
594        return is_local_request(req);
595    }
596    !build_access_status(config, req).requires_password
597        || request_has_verified_access_cookie(req, config)
598        || request_has_valid_device_token(req, config)
599}
600
601pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
602    req: ServiceRequest,
603    next: Next<B>,
604) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
605    let path = req.path().to_string();
606    if is_public_access_route(&path) {
607        return next
608            .call(req)
609            .await
610            .map(ServiceResponse::map_into_left_body);
611    }
612
613    let app_state = match req.app_data::<web::Data<AppState>>() {
614        Some(state) => state.clone(),
615        None => {
616            return next
617                .call(req)
618                .await
619                .map(ServiceResponse::map_into_left_body)
620        }
621    };
622
623    // A `bcx1_` bearer opts into the narrower Codex run-token contract. It is
624    // validated even on loopback (where ordinary requests retain the existing
625    // desktop bypass), is accepted only for the OpenAI Responses surface, and
626    // never falls back to cookie/device auth if expired or revoked.
627    if let Some(token) = presented_bearer_token(req.request()) {
628        if token.starts_with(crate::codex_run_tokens::CODEX_RUN_TOKEN_PREFIX) {
629            let scoped_path = matches!(path.as_str(), "/openai/v1/responses" | "/openai/v1/models");
630            if scoped_path {
631                if let Some(context) = app_state.codex_run_tokens.verify(token) {
632                    req.extensions_mut().insert(context);
633                    return next
634                        .call(req)
635                        .await
636                        .map(ServiceResponse::map_into_left_body);
637                }
638            }
639            let response = AppError::Unauthorized(
640                "invalid, expired, or out-of-scope Codex run credential".to_string(),
641            )
642            .error_response()
643            .map_into_right_body();
644            return Ok(req.into_response(response));
645        }
646    }
647
648    let config = app_state.config.read().await.clone();
649    // Auth is required when a credential mechanism is configured (a root password
650    // OR at least one active device) AND the request is not a local bypass. An
651    // instance with NO devices + NO password behaves EXACTLY as before — zero
652    // regression. When required, accept EITHER a verified password cookie OR a
653    // valid per-device token (#181). The allow-decision is centralized in
654    // `request_is_authorized` so the ws_v2 handler enforces the SAME rule (#189).
655    if request_is_authorized(req.request(), &config) {
656        return next
657            .call(req)
658            .await
659            .map(ServiceResponse::map_into_left_body);
660    }
661
662    let response = AppError::Unauthorized("access credential verification required".to_string())
663        .error_response()
664        .map_into_right_body();
665    Ok(req.into_response(response))
666}
667
668fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
669    let password_enabled = config
670        .access_control
671        .as_ref()
672        .map(|access| access.password_enabled)
673        .unwrap_or(false);
674    let local_bypass = is_local_request(req);
675    // v2-P2 (#181): once any device is paired, public access requires a
676    // credential even if the root password itself is unset — the device tokens
677    // become the gating mechanism. No devices + no password ⇒ unchanged behavior.
678    let credential_required =
679        password_enabled || has_active_devices(config) || access_repair_required(config);
680
681    AccessStatusResponse {
682        password_enabled,
683        local_bypass,
684        requires_password: credential_required && !local_bypass,
685    }
686}
687
688fn build_exact_access_status(
689    config: &Config,
690    _statuses: &[bamboo_config::CredentialStatus],
691    _health: &bamboo_config::CredentialStoreHealth,
692    req: &HttpRequest,
693) -> AccessStatusResponse {
694    // Durable `password_enabled` is the authentication intent. Missing or
695    // unreadable verifier material is a degraded repair state, not permission
696    // to silently reopen remote access; verification will simply fail closed.
697    let password_enabled = config
698        .access_control
699        .as_ref()
700        .is_some_and(|access| access.password_enabled);
701    let has_device = config
702        .access_control
703        .as_ref()
704        .is_some_and(|access| access.devices.iter().any(|device| !device.revoked));
705    let repair_required = access_repair_required(config);
706    let local_bypass = is_local_request(req);
707    AccessStatusResponse {
708        password_enabled,
709        local_bypass,
710        requires_password: (password_enabled || has_device || repair_required) && !local_bypass,
711    }
712}
713
714pub async fn get_access_status(
715    req: HttpRequest,
716    app_state: web::Data<AppState>,
717) -> Result<HttpResponse, AppError> {
718    let exact = app_state
719        .read_exact_credential_section(bamboo_config::SectionId::AccessControl)
720        .await?;
721    let section = exact.section;
722    let config = exact.config;
723    let reference = config
724        .access_control
725        .as_ref()
726        .and_then(|access| access.password_credential_ref.clone());
727    let statuses = exact.metadata.credential_statuses;
728    let credential_health = exact.metadata.credential_health;
729    let credential = reference.as_ref().and_then(|reference| {
730        statuses
731            .iter()
732            .find(|status| &status.credential_ref == reference)
733    });
734    let expected_configured = config
735        .access_control
736        .as_ref()
737        .is_some_and(|access| access.password_configured);
738    let credential = credential_status_view(
739        reference.as_ref(),
740        expected_configured,
741        credential,
742        &credential_health,
743    );
744    Ok(HttpResponse::Ok().json(AccessStatusEnvelope {
745        runtime: build_exact_access_status(&config, &statuses, &credential_health, &req),
746        revision: section.revision,
747        status: section.status,
748        source_kind: section.source_kind,
749        loaded_at: section.loaded_at,
750        last_error: section.last_error,
751        password_configured: credential.configured,
752        credential_state: credential.state,
753        credential_ref: credential.credential_ref,
754        credential_source: credential.source,
755        credential_updated_at: credential
756            .updated_at
757            .map(|updated_at| updated_at.to_rfc3339()),
758        credential_health,
759    }))
760}
761
762pub async fn verify_access_password(
763    req: HttpRequest,
764    payload: web::Json<VerifyPasswordRequest>,
765    app_state: web::Data<AppState>,
766) -> Result<HttpResponse, AppError> {
767    let password = payload.password.trim();
768    if password.is_empty() {
769        return Err(AppError::BadRequest("password is required".to_string()));
770    }
771
772    // #190: per-IP brute-force throttle. A local/desktop request is exempt
773    // (`root_throttle_key` returns None) so the desktop never locks itself out.
774    // If the key is in cooldown, reject with 429 + Retry-After BEFORE comparing.
775    let throttle_key = root_throttle_key(&req);
776    if let Some(key) = throttle_key.as_deref() {
777        if let RootGuardDecision::Cooldown { retry_after_secs } =
778            app_state.root_password_guard.check(key)
779        {
780            return Ok(too_many_requests_response(retry_after_secs));
781        }
782    }
783
784    let config = app_state.config.read().await.clone();
785    if !verify_password(&config, password) {
786        if let Some(key) = throttle_key.as_deref() {
787            app_state.root_password_guard.record_failure(key);
788        }
789        return Err(AppError::Unauthorized("invalid password".to_string()));
790    }
791
792    // Correct password resets this key's counter.
793    if let Some(key) = throttle_key.as_deref() {
794        app_state.root_password_guard.record_success(key);
795    }
796
797    let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
798    let cookie = build_access_verified_cookie(&config, secure)
799        .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
800
801    Ok(HttpResponse::Ok()
802        .cookie(cookie)
803        .json(VerifyPasswordResponse { success: true }))
804}
805
806pub async fn update_access_password(
807    req: HttpRequest,
808    app_state: web::Data<AppState>,
809    payload: web::Json<UpdatePasswordRequest>,
810) -> Result<HttpResponse, AppError> {
811    let local_bypass = is_local_request(&req);
812    let payload = payload.into_inner();
813    let action = payload.action.unwrap_or(AccessPasswordAction::Replace);
814    if !payload.value.is_empty() && !payload.new_password.is_empty() {
815        return Err(AppError::BadRequest(
816            "password replace must use either value or new_password, not both".to_string(),
817        ));
818    }
819    let replacement = if payload.value.is_empty() {
820        payload.new_password.trim()
821    } else {
822        payload.value.trim()
823    };
824    if matches!(action, AccessPasswordAction::Replace) && replacement.is_empty() {
825        return Err(AppError::BadRequest(
826            "password replace requires a nonempty value".to_string(),
827        ));
828    }
829    if matches!(action, AccessPasswordAction::Replace)
830        && bamboo_config::patch::is_masked_api_key(replacement)
831    {
832        return Err(AppError::BadRequest(
833            "password value must not be a mask".to_string(),
834        ));
835    }
836    if matches!(action, AccessPasswordAction::Clear)
837        && !(payload.value.is_empty() && payload.new_password.is_empty())
838    {
839        return Err(AppError::BadRequest(
840            "password clear must not include a replacement value".to_string(),
841        ));
842    }
843
844    let expected_revision = payload.expected_revision;
845    let current_password = payload.current_password.trim().to_string();
846    let (password_hash, salt_hex) = if matches!(action, AccessPasswordAction::Replace) {
847        let mut salt_bytes = [0_u8; 16];
848        rand::rng().fill(&mut salt_bytes);
849        let salt_hex = hex::encode(salt_bytes);
850        let password_hash = compute_password_hash(replacement, &salt_hex).ok_or_else(|| {
851            AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
852        })?;
853        (Some(password_hash), Some(salt_hex))
854    } else {
855        (None, None)
856    };
857    let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
858
859    let (updated, revision, metadata, section) = app_state
860        .update_access_control_credentials(
861            expected_revision,
862            true,
863            BTreeSet::new(),
864            move |config| {
865                // Authorize against the exact durable AccessControl generation
866                // installed by the AppState updater. A stale process-local
867                // verifier must never authorize replacing a newer password.
868                let password_already_enabled = config
869                    .access_control
870                    .as_ref()
871                    .is_some_and(|access| access.password_enabled);
872                if password_already_enabled && !local_bypass {
873                    if current_password.is_empty() {
874                        return Err(AppError::Unauthorized(
875                            "current_password is required".to_string(),
876                        ));
877                    }
878                    if !verify_password(config, &current_password) {
879                        return Err(AppError::Unauthorized(
880                            "invalid current password".to_string(),
881                        ));
882                    }
883                }
884                // Mutate in place so an existing `access_control` keeps its paired
885                // `devices` across a root-password change. Replacing the whole
886                // struct with `devices: vec![]` would silently wipe every device
887                // token on every password update (#181).
888                let access = config.access_control.get_or_insert_with(Default::default);
889                access.password_enabled = matches!(action, AccessPasswordAction::Replace);
890                access.password_hash = password_hash.clone();
891                access.password_salt = salt_hex.clone();
892                access.updated_at = Some(updated_at.clone());
893                Ok(())
894            },
895        )
896        .await?;
897    let configured = updated
898        .access_control
899        .as_ref()
900        .is_some_and(|access| access.password_configured);
901    let section = section.ok_or_else(|| {
902        AppError::InternalError(anyhow::anyhow!(
903            "access-control mutation completed without a typed section envelope"
904        ))
905    })?;
906    let reference =
907        bamboo_config::config_crypto::access_password_credential_ref().map_err(|error| {
908            AppError::InternalError(anyhow::anyhow!(
909                "access-control credential reference is invalid: {error}"
910            ))
911        })?;
912    let credential_status = metadata.status(&reference);
913    let credential = credential_status_view(
914        Some(&reference),
915        configured,
916        Some(&credential_status),
917        &metadata.credential_health,
918    );
919
920    Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
921        success: true,
922        password_enabled: configured,
923        revision,
924        section,
925        credential,
926        credential_health: metadata.credential_health,
927    }))
928}
929
930// ── v2-P2 pairing (#181) ────────────────────────────────────────────────────
931
932#[derive(Debug, Deserialize)]
933pub struct PairDeviceRequest {
934    /// Owner root password — authorizes first-device pairing (slice 1 path).
935    #[serde(default)]
936    pub root_password: String,
937    /// One-time 6-digit pairing code — authorizes subsequent-device pairing
938    /// (slice 2 path). Requested by an already-authenticated device via
939    /// `POST /v2/pair/code`.
940    #[serde(default)]
941    pub code: String,
942    /// Human-readable device label, e.g. "iPhone 15".
943    #[serde(default)]
944    pub label: String,
945}
946
947#[derive(Serialize)]
948pub struct PairDeviceResponse {
949    pub device_id: String,
950    /// Plaintext token — returned ONCE; the server stores only its hash.
951    pub device_token: String,
952    pub expires_hint: &'static str,
953}
954
955/// `POST /v2/pair` — redeem a credential to pair a NEW device. Two paths:
956///
957/// - **code** (slice 2): a one-time 6-digit pairing code requested by an already
958///   authenticated device via `POST /v2/pair/code`. This is the public route a
959///   brand-new device with no credential uses, so it carries a brute-force guard
960///   (see [`PairingCodeGuard`]).
961/// - **root_password** (slice 1): the owner root password directly authorizes
962///   first-device pairing. Unchanged byte-for-byte from slice 1.
963///
964/// The endpoint is on the public whitelist (a new device has no credential), so
965/// it self-gates on one of the two credentials above.
966pub async fn pair_device(
967    req: HttpRequest,
968    payload: web::Json<PairDeviceRequest>,
969    app_state: web::Data<AppState>,
970) -> Result<HttpResponse, AppError> {
971    let label = payload.label.trim();
972    if label.is_empty() {
973        return Err(AppError::BadRequest("label is required".to_string()));
974    }
975
976    let code = payload.code.trim();
977    let root_password = payload.root_password.trim();
978
979    // Dispatch: a non-empty code takes the slice-2 code-redemption path; else a
980    // non-empty root password takes the unchanged slice-1 path; else 400.
981    if !code.is_empty() {
982        return pair_device_with_code(&app_state, code, label).await;
983    }
984    if !root_password.is_empty() {
985        return pair_device_with_root_password(&req, &app_state, root_password, label).await;
986    }
987
988    Err(AppError::BadRequest(
989        "provide either a root_password or a one-time pairing code".to_string(),
990    ))
991}
992
993/// Slice-1 root-password pairing path. Behavior is identical to slice 1, plus the
994/// #190 per-IP brute-force throttle in front of the password compare.
995async fn pair_device_with_root_password(
996    req: &HttpRequest,
997    app_state: &AppState,
998    root_password: &str,
999    label: &str,
1000) -> Result<HttpResponse, AppError> {
1001    // #190: per-IP brute-force throttle. Loopback/desktop is exempt
1002    // (`root_throttle_key` returns None). If in cooldown, reject with 429 +
1003    // Retry-After BEFORE comparing the password.
1004    let throttle_key = root_throttle_key(req);
1005    if let Some(key) = throttle_key.as_deref() {
1006        if let RootGuardDecision::Cooldown { retry_after_secs } =
1007            app_state.root_password_guard.check(key)
1008        {
1009            return Ok(too_many_requests_response(retry_after_secs));
1010        }
1011    }
1012
1013    let config = app_state.config.read().await.clone();
1014
1015    let password_enabled = config
1016        .access_control
1017        .as_ref()
1018        .map(|access| access.password_enabled)
1019        .unwrap_or(false);
1020    if !password_enabled {
1021        return Err(AppError::BadRequest(
1022            "set an access password first: the owner root password is required to authorize device pairing".to_string(),
1023        ));
1024    }
1025
1026    if !verify_password(&config, root_password) {
1027        if let Some(key) = throttle_key.as_deref() {
1028            app_state.root_password_guard.record_failure(key);
1029        }
1030        return Err(AppError::Unauthorized("invalid root password".to_string()));
1031    }
1032
1033    // Correct password resets this key's counter.
1034    if let Some(key) = throttle_key.as_deref() {
1035        app_state.root_password_guard.record_success(key);
1036    }
1037
1038    persist_new_device(app_state, label).await
1039}
1040
1041/// Slice-2 code-redemption pairing path. The code must EXIST and be UNEXPIRED in
1042/// the ephemeral store and is consumed ONE-TIME (atomically removed on a
1043/// successful match) so it cannot be reused. Guarded against brute force.
1044async fn pair_device_with_code(
1045    app_state: &AppState,
1046    code: &str,
1047    label: &str,
1048) -> Result<HttpResponse, AppError> {
1049    // Brute-force gate FIRST: if we are in a cooldown, reject before touching the
1050    // store so an attacker can't probe code validity during the cooldown.
1051    if app_state.pairing_code_guard.in_cooldown() {
1052        return Err(AppError::Unauthorized(
1053            "too many failed pairing attempts — try again later".to_string(),
1054        ));
1055    }
1056
1057    // One-time consume: `remove` is atomic in DashMap, so two concurrent redeems
1058    // of the SAME code race on the single removal — exactly one wins the `Some`,
1059    // the other gets `None` and is treated as an invalid code. After taking the
1060    // entry we still check expiry (a stale-but-present entry must not pair).
1061    let consumed = app_state.pairing_codes.remove(code);
1062    let valid = match consumed {
1063        Some((_k, entry)) => !entry.is_expired(),
1064        None => false,
1065    };
1066
1067    if !valid {
1068        // Record the failure; trip the cooldown after the threshold and
1069        // proactively invalidate outstanding codes so a near-miss attacker can't
1070        // keep probing the rest of the (small) code space.
1071        if app_state.pairing_code_guard.record_failure() {
1072            app_state.pairing_codes.clear();
1073        }
1074        return Err(AppError::Unauthorized(
1075            "invalid or expired pairing code".to_string(),
1076        ));
1077    }
1078
1079    // Success resets the failure counter.
1080    app_state.pairing_code_guard.record_success();
1081    persist_new_device(app_state, label).await
1082}
1083
1084/// Issue a fresh device credential for `label`, append it to the persisted
1085/// devices (preserving every existing field + device), and return the plaintext
1086/// token ONCE.
1087async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
1088    let (credential, token) = issue_device_token(label);
1089    let device_id = credential.device_id.clone();
1090
1091    let expected_revision = access_section_revision(app_state)?;
1092    app_state
1093        .update_access_control_credentials(
1094            expected_revision,
1095            false,
1096            BTreeSet::from([device_id.clone()]),
1097            move |config| {
1098                // Preserve every existing field + already-paired devices: append,
1099                // never replace.
1100                let access = config.access_control.get_or_insert_with(Default::default);
1101                access.devices.push(credential.clone());
1102                Ok(())
1103            },
1104        )
1105        .await?;
1106
1107    // NOTE: `token` is the plaintext credential — it is returned to the client
1108    // here ONCE and is never logged.
1109    Ok(HttpResponse::Ok().json(PairDeviceResponse {
1110        device_id,
1111        device_token: token,
1112        expires_hint: "rotate-on-demand",
1113    }))
1114}
1115
1116// ── v2-P2 pairing codes + brute-force guard (#181, slice 2) ──────────────────
1117
1118/// Default code lifetime (~2 minutes).
1119const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
1120/// Failed code-redemption attempts within the window before the cooldown trips.
1121const PAIRING_FAILURE_THRESHOLD: u32 = 10;
1122/// How long the code-redemption path stays locked once the threshold is hit.
1123const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
1124
1125/// An in-memory one-time pairing code entry. Holds only an `Instant` expiry —
1126/// the code itself is the DashMap key. PROCESS-EPHEMERAL: never persisted.
1127#[derive(Debug, Clone)]
1128pub struct PairingCodeEntry {
1129    expires_at: Instant,
1130}
1131
1132impl PairingCodeEntry {
1133    pub(crate) fn new(ttl: Duration) -> Self {
1134        Self {
1135            expires_at: Instant::now() + ttl,
1136        }
1137    }
1138
1139    /// Whether this code has passed its TTL. Pure predicate over `Instant` —
1140    /// directly unit-testable by constructing an already-elapsed expiry.
1141    pub fn is_expired(&self) -> bool {
1142        Instant::now() >= self.expires_at
1143    }
1144}
1145
1146/// Per-process brute-force guard for the public code-redemption path.
1147///
1148/// Design (flagged for review): a 6-digit numeric code is only ~1M wide, and
1149/// `POST /v2/pair { code }` is public, so without a guard it is brute-forceable
1150/// within a code's 120s TTL. The guard is a simple bounded failed-attempt
1151/// counter with a cooldown:
1152///
1153/// - Each failed code redemption increments a counter.
1154/// - After [`PAIRING_FAILURE_THRESHOLD`] (10) failures, a [`PAIRING_COOLDOWN`]
1155///   (60s) lockout trips: all further code redemptions are rejected for the
1156///   duration, AND the caller proactively clears outstanding codes (so a
1157///   near-miss attacker can't resume probing the small space). The counter
1158///   resets when the cooldown elapses, or on any successful redemption.
1159///
1160/// This is per-PROCESS, not per-IP (the public route sits behind no reverse
1161/// proxy that reliably carries client IPs in this deployment), so it is a global
1162/// rate cap on the code path. The root-password path is untouched (its own
1163/// throttling is tracked separately in #190). Trade-off: a global cooldown means
1164/// a determined attacker can also deny a legitimate device's pairing for 60s by
1165/// burning failures — acceptable for a short, operator-initiated pairing window.
1166#[derive(Debug, Default)]
1167pub struct PairingCodeGuard {
1168    inner: Mutex<PairingGuardState>,
1169}
1170
1171#[derive(Debug, Default)]
1172struct PairingGuardState {
1173    failures: u32,
1174    /// When set and still in the future, the code path is locked.
1175    cooldown_until: Option<Instant>,
1176}
1177
1178impl PairingCodeGuard {
1179    /// Whether the code-redemption path is currently locked out. Clears an
1180    /// elapsed cooldown (and its failure count) as a side effect.
1181    pub fn in_cooldown(&self) -> bool {
1182        let mut state = self.inner.lock().recover_poison();
1183        match state.cooldown_until {
1184            Some(until) if Instant::now() < until => true,
1185            Some(_) => {
1186                // Cooldown elapsed → reset.
1187                state.cooldown_until = None;
1188                state.failures = 0;
1189                false
1190            }
1191            None => false,
1192        }
1193    }
1194
1195    /// Record a failed redemption. Returns `true` IFF this failure tripped the
1196    /// cooldown (so the caller can invalidate outstanding codes).
1197    pub fn record_failure(&self) -> bool {
1198        let mut state = self.inner.lock().recover_poison();
1199        state.failures = state.failures.saturating_add(1);
1200        if state.failures >= PAIRING_FAILURE_THRESHOLD {
1201            state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
1202            true
1203        } else {
1204            false
1205        }
1206    }
1207
1208    /// Reset the guard after a successful redemption.
1209    pub fn record_success(&self) {
1210        let mut state = self.inner.lock().recover_poison();
1211        state.failures = 0;
1212        state.cooldown_until = None;
1213    }
1214}
1215
1216// ── #190: per-IP root-password brute-force guard ────────────────────────────
1217//
1218// Both public root-password-checking endpoints — `POST /v1/bamboo/access/verify`
1219// (`verify_access_password`) and `POST /v2/pair` on its root-password path
1220// (`pair_device_with_root_password`) — accept the owner root password with no
1221// rate limiting. `verify_password` is constant-time (no timing leak), but
1222// nothing caps the request RATE, so an attacker can brute-force the password.
1223//
1224// `RootPasswordGuard` is a per-client-IP failed-attempt counter with a cooldown.
1225// It mirrors the SHAPE of `PairingCodeGuard` (failure threshold → cooldown,
1226// self-healing decay, success resets) but is keyed per IP via a `DashMap` so one
1227// attacker cannot lock out every other client. A loopback/desktop request is
1228// exempted by the caller (see `is_local_request`) so the desktop can never lock
1229// itself out.
1230
1231/// Consecutive failed root-password attempts from one key before the cooldown
1232/// trips. Lower than the code path's threshold (10) — a root password is the
1233/// high-value secret and there is no legitimate reason to fail it 5 times.
1234const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
1235/// How long a key stays locked once the threshold is hit.
1236const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
1237/// Cap on tracked IP keys. Per-IP keying means an attacker rotating source IPs
1238/// could otherwise grow the map unbounded (slow memory DoS). When a NEW key
1239/// would exceed this, we first sweep keys not in an active cooldown (abandoned
1240/// partial-failures + elapsed cooldowns), which are inert anyway — so memory is
1241/// bounded to roughly the set of IPs actively in a 60s lockout.
1242const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
1243
1244/// Per-key attempt state for the root-password guard.
1245#[derive(Debug, Default, Clone)]
1246struct RootAttemptState {
1247    failures: u32,
1248    /// When set and still in the future, this key is locked.
1249    cooldown_until: Option<Instant>,
1250}
1251
1252/// Per-client-IP brute-force guard for the root-password endpoints (#190).
1253///
1254/// Keyed by a best-effort client-IP string (see `client_ip_key`) so a single
1255/// attacker only locks out their own key, not every client. Each key:
1256///
1257/// - increments a failure counter on a wrong password;
1258/// - after [`ROOT_PASSWORD_FAILURE_THRESHOLD`] (5) consecutive failures, trips a
1259///   [`ROOT_PASSWORD_COOLDOWN`] (60s) lockout — further attempts from that key
1260///   are rejected with HTTP 429 BEFORE the password is even compared;
1261/// - resets on any successful password check;
1262/// - self-heals: once the cooldown elapses the key's state is cleared, so a key
1263///   that simply made a few mistakes recovers automatically.
1264///
1265/// Loopback exemption is the CALLER's responsibility (it never calls into the
1266/// guard for a local request) so the desktop can never lock itself out.
1267///
1268/// This is per-PROCESS state and is NOT persisted — a restart clears all
1269/// counters by design. The code-redemption path keeps its own `PairingCodeGuard`
1270/// (a separate, global guard); this is strictly the root-password paths.
1271#[derive(Debug, Default)]
1272pub struct RootPasswordGuard {
1273    inner: dashmap::DashMap<String, RootAttemptState>,
1274}
1275
1276/// Outcome of consulting the guard for a key.
1277pub enum RootGuardDecision {
1278    /// Not locked — proceed to compare the password.
1279    Allow,
1280    /// Locked — reject with 429 and this many whole seconds in `Retry-After`.
1281    Cooldown { retry_after_secs: u64 },
1282}
1283
1284impl RootPasswordGuard {
1285    /// Check whether `key` is currently locked. Clears an elapsed cooldown (and
1286    /// its failure count) as a side effect so a recovered key returns `Allow`.
1287    pub fn check(&self, key: &str) -> RootGuardDecision {
1288        let now = Instant::now();
1289        if let Some(mut entry) = self.inner.get_mut(key) {
1290            if let Some(until) = entry.cooldown_until {
1291                if now < until {
1292                    let retry_after_secs = (until - now).as_secs().max(1);
1293                    return RootGuardDecision::Cooldown { retry_after_secs };
1294                }
1295                // Cooldown elapsed → reset this key.
1296                entry.failures = 0;
1297                entry.cooldown_until = None;
1298            }
1299        }
1300        RootGuardDecision::Allow
1301    }
1302
1303    /// Record a failed root-password attempt for `key`. Trips the cooldown once
1304    /// the threshold is reached.
1305    pub fn record_failure(&self, key: &str) {
1306        let now = Instant::now();
1307        // Bound memory: before adding a NEW key past the cap, drop every key not
1308        // in an active cooldown (those are inert — an elapsed cooldown or an
1309        // abandoned sub-threshold failure count contributes nothing to gating).
1310        if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1311            self.inner
1312                .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1313        }
1314        let mut entry = self.inner.entry(key.to_string()).or_default();
1315        // A still-live cooldown shouldn't be reachable here (the caller checks
1316        // first), but if it is, leave it; otherwise count the failure.
1317        if matches!(entry.cooldown_until, Some(until) if now < until) {
1318            return;
1319        }
1320        // If a previous cooldown elapsed, this is a fresh window.
1321        if entry.cooldown_until.is_some() {
1322            entry.failures = 0;
1323            entry.cooldown_until = None;
1324        }
1325        entry.failures = entry.failures.saturating_add(1);
1326        if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1327            entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1328        }
1329    }
1330
1331    /// Reset `key` after a successful root-password check.
1332    pub fn record_success(&self, key: &str) {
1333        self.inner.remove(key);
1334    }
1335}
1336
1337/// Resolve the throttle key for a request, honoring the loopback exemption.
1338///
1339/// Returns `None` for a local/loopback request (desktop is NEVER throttled), or
1340/// `Some(key)` for a remote request — the per-IP key when an address is
1341/// available, else a single shared `"unknown"` key so the path still has a
1342/// global rate cap rather than being silently unguarded.
1343fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1344    if is_local_request(req) {
1345        return None;
1346    }
1347    Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1348}
1349
1350/// Build the 429 response for a tripped root-password cooldown, with a
1351/// `Retry-After` header (whole seconds). The body carries no secret material.
1352fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1353    HttpResponse::TooManyRequests()
1354        .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1355        .json(serde_json::json!({
1356            "error": crate::error::error_value(
1357                "too many failed password attempts — try again later"
1358            )
1359        }))
1360}
1361
1362/// Generate a fresh 6-digit numeric code, e.g. "842913". Leading zeros are kept.
1363///
1364/// Uses `gen_range` (uniform rejection sampling) rather than `% 1_000_000` to
1365/// avoid the modulo bias that would make a handful of low codes very slightly
1366/// more probable. `thread_rng` is a CSPRNG, so codes are unpredictable.
1367fn generate_pairing_code() -> String {
1368    let n = rand::rng().random_range(0..1_000_000);
1369    format!("{n:06}")
1370}
1371
1372/// Drop every expired entry from the ephemeral code store (opportunistic GC).
1373fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1374    codes.retain(|_code, entry| !entry.is_expired());
1375}
1376
1377#[derive(Serialize)]
1378pub struct PairingCodeResponse {
1379    pub code: String,
1380    /// TTL in whole seconds.
1381    pub ttl: u64,
1382}
1383
1384/// `POST /v2/pair/code` — an ALREADY-AUTHENTICATED device/owner requests a
1385/// one-time pairing code for a new device.
1386///
1387/// GATED: this route sits behind `enforce_access_password_middleware` (NOT on
1388/// the public whitelist), so only a local_bypass desktop, a valid device token,
1389/// or the verified password cookie can reach it. The generated code is the
1390/// short-lived credential the brand-new device then redeems at `/v2/pair`.
1391pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1392    // Opportunistic GC so the store can't grow unbounded with stale codes.
1393    purge_expired_codes(&app_state.pairing_codes);
1394
1395    let code = generate_pairing_code();
1396    let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1397    // Overwrite on the astronomically-rare collision — the latest request wins.
1398    app_state.pairing_codes.insert(code.clone(), entry);
1399
1400    Ok(HttpResponse::Ok().json(PairingCodeResponse {
1401        code,
1402        ttl: PAIRING_CODE_TTL.as_secs(),
1403    }))
1404}
1405
1406// ── v2-P2 device management (#181, slice 2) ──────────────────────────────────
1407
1408/// Summary DTO for `GET /v2/devices`. CRITICAL: this MUST NOT carry
1409/// `token_hash`/`token_salt` — a credential leak here would let any reader of
1410/// the device list mint a matching token. Only non-secret metadata is exposed.
1411#[derive(Serialize)]
1412pub struct DeviceSummary {
1413    pub device_id: String,
1414    pub label: String,
1415    pub created_at: String,
1416    pub last_used_at: Option<String>,
1417    pub revoked: bool,
1418}
1419
1420impl DeviceSummary {
1421    fn from_credential(d: &DeviceCredential) -> Self {
1422        Self {
1423            device_id: d.device_id.clone(),
1424            label: d.label.clone(),
1425            created_at: d.created_at.clone(),
1426            last_used_at: d.last_used_at.clone(),
1427            revoked: d.revoked,
1428        }
1429    }
1430}
1431
1432/// `GET /v2/devices` — list paired devices (GATED). Returns the summary DTO with
1433/// NO secret material.
1434pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1435    let config = app_state.config.read().await.clone();
1436    let devices: Vec<DeviceSummary> = config
1437        .access_control
1438        .as_ref()
1439        .map(|access| {
1440            access
1441                .devices
1442                .iter()
1443                .map(DeviceSummary::from_credential)
1444                .collect()
1445        })
1446        .unwrap_or_default();
1447    Ok(HttpResponse::Ok().json(devices))
1448}
1449
1450/// `DELETE /v2/devices/{device_id}` — revoke a device (GATED).
1451///
1452/// Sets `revoked = true` (the audit row is KEPT, not removed) and persists.
1453/// Revocation is instant: `verify_device_token` already rejects revoked devices
1454/// and `has_active_devices` recomputes, so the revoked token stops working on
1455/// the very next request. Returns 404 if the device id is unknown.
1456pub async fn revoke_device(
1457    path: web::Path<String>,
1458    app_state: web::Data<AppState>,
1459) -> Result<HttpResponse, AppError> {
1460    let device_id = path.into_inner();
1461
1462    // Existence check up front so an unknown id is a clean 404 without a
1463    // (no-op) persist.
1464    {
1465        let config = app_state.config.read().await;
1466        let exists = config
1467            .access_control
1468            .as_ref()
1469            .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1470            .unwrap_or(false);
1471        if !exists {
1472            return Err(AppError::NotFound(format!("unknown device {device_id}")));
1473        }
1474    }
1475
1476    let target = device_id.clone();
1477    let expected_revision = access_section_revision(&app_state)?;
1478    app_state
1479        .update_access_control_credentials(
1480            expected_revision,
1481            false,
1482            BTreeSet::new(),
1483            move |config| {
1484                if let Some(access) = config.access_control.as_mut() {
1485                    if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1486                    {
1487                        device.revoked = true;
1488                    }
1489                }
1490                Ok(())
1491            },
1492        )
1493        .await?;
1494
1495    Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1496}
1497
1498/// `POST /v2/devices/{device_id}/rotate` — issue a NEW token for the SAME device
1499/// (GATED).
1500///
1501/// Keeps `device_id`/`label`/`created_at`, resets `revoked = false`, and
1502/// replaces `token_hash`/`token_salt` with a fresh pair. The OLD token stops
1503/// verifying immediately (its salt is gone). Returns the new plaintext token
1504/// ONCE. Returns 404 if the device id is unknown.
1505pub async fn rotate_device(
1506    path: web::Path<String>,
1507    app_state: web::Data<AppState>,
1508) -> Result<HttpResponse, AppError> {
1509    let device_id = path.into_inner();
1510
1511    // Existence check up front so an unknown id is a clean 404 without persisting
1512    // a no-op config snapshot.
1513    {
1514        let config = app_state.config.read().await;
1515        let exists = config
1516            .access_control
1517            .as_ref()
1518            .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1519            .unwrap_or(false);
1520        if !exists {
1521            return Err(AppError::NotFound(format!("unknown device {device_id}")));
1522        }
1523    }
1524
1525    // Generate a brand-new credential, then graft its secret material onto the
1526    // existing device row (reusing `issue_device_token` for the fresh salt+hash).
1527    let (fresh, token) = issue_device_token("");
1528
1529    let target = device_id.clone();
1530    let expected_revision = access_section_revision(&app_state)?;
1531    app_state
1532        .update_access_control_credentials(
1533            expected_revision,
1534            false,
1535            BTreeSet::from([device_id.clone()]),
1536            move |config| {
1537                if let Some(access) = config.access_control.as_mut() {
1538                    if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1539                    {
1540                        device.token_hash = fresh.token_hash.clone();
1541                        device.token_salt = fresh.token_salt.clone();
1542                        device.revoked = false;
1543                        device.last_used_at = None;
1544                    }
1545                }
1546                Ok(())
1547            },
1548        )
1549        .await?;
1550
1551    // NOTE: `token` is the plaintext credential — returned ONCE, never logged.
1552    Ok(HttpResponse::Ok().json(PairDeviceResponse {
1553        device_id,
1554        device_token: token,
1555        expires_hint: "rotate-on-demand",
1556    }))
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561    use super::*;
1562    use actix_web::{
1563        body::to_bytes,
1564        http::StatusCode,
1565        middleware::from_fn,
1566        test::{self, TestRequest},
1567        App,
1568    };
1569    use bamboo_config::AccessControlConfig;
1570    use bamboo_engine::external_agents::actor_adapter::CodexRunTokenAuthority as _;
1571
1572    macro_rules! test_config {
1573        (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
1574        (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
1575        (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
1576        (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
1577        ($($field:ident: $value:expr),* $(,)?) => {{
1578            let mut config = Config::default();
1579            $(test_config!(@assign config, $field, $value);)*
1580            config
1581        }};
1582    }
1583
1584    #[actix_web::test]
1585    async fn public_access_status_exposes_revision_without_section_data_or_paths() {
1586        let dir = tempfile::tempdir().unwrap();
1587        let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1588        state.config.write().await.access_control = Some(AccessControlConfig {
1589            devices: vec![bamboo_config::DeviceCredential {
1590                device_id: "private-device-id".to_string(),
1591                label: "private-device-label".to_string(),
1592                token_hash: "private-token-hash".to_string(),
1593                token_salt: "private-token-salt".to_string(),
1594                token_credential_ref: None,
1595                token_configured: false,
1596                created_at: "2026-07-27T00:00:00Z".to_string(),
1597                last_used_at: None,
1598                revoked: false,
1599            }],
1600            ..Default::default()
1601        });
1602        let app = test::init_service(
1603            App::new()
1604                .app_data(state)
1605                .route("/access/status", web::get().to(get_access_status)),
1606        )
1607        .await;
1608
1609        let response = test::call_service(
1610            &app,
1611            TestRequest::get()
1612                .uri("/access/status")
1613                .insert_header((header::HOST, "bamboo.example.com"))
1614                .to_request(),
1615        )
1616        .await;
1617        assert_eq!(response.status(), StatusCode::OK);
1618        let body: serde_json::Value = test::read_body_json(response).await;
1619        assert_eq!(body["revision"], 0);
1620        assert!(body.get("status").is_some());
1621        assert!(body.get("source_kind").is_some());
1622        assert!(body.get("section").is_none());
1623        assert!(body.get("source_path").is_none());
1624        let body = body.to_string();
1625        let private_path = dir.path().to_string_lossy().to_string();
1626        for private in [
1627            private_path.as_str(),
1628            "private-device-id",
1629            "private-device-label",
1630            "private-token-hash",
1631            "private-token-salt",
1632        ] {
1633            assert!(!body.contains(private));
1634        }
1635    }
1636
1637    #[actix_web::test]
1638    async fn quarantined_access_is_fail_closed_and_recovery_credentials_stay_private() {
1639        let _key = bamboo_config::encryption::set_test_encryption_key([0xb7; 32]);
1640        let dir = tempfile::tempdir().unwrap();
1641        let secret_fragment = "server-api-private-access-fragment";
1642        let mut root = serde_json::to_value(Config::default()).unwrap();
1643        root["access_control"] = serde_json::json!({
1644            "password_enabled": "yes",
1645            "password_hash": secret_fragment
1646        });
1647        std::fs::write(
1648            dir.path().join("config.json"),
1649            serde_json::to_vec_pretty(&root).unwrap(),
1650        )
1651        .unwrap();
1652
1653        let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1654        assert!(state
1655            .config
1656            .read()
1657            .await
1658            .access_control
1659            .as_ref()
1660            .is_some_and(|access| access.repair_required));
1661        let app = test::init_service(
1662            App::new()
1663                .app_data(state)
1664                .route("/access/status", web::get().to(get_access_status))
1665                .route(
1666                    "/credentials",
1667                    web::get().to(crate::handlers::settings::list_credentials),
1668                )
1669                .route(
1670                    "/credentials/{credential_ref}",
1671                    web::get().to(crate::handlers::settings::get_credential_status),
1672                ),
1673        )
1674        .await;
1675
1676        let status = test::call_service(
1677            &app,
1678            TestRequest::get()
1679                .uri("/access/status")
1680                .peer_addr("203.0.113.8:5700".parse().unwrap())
1681                .insert_header((header::HOST, "bamboo.example.com"))
1682                .to_request(),
1683        )
1684        .await;
1685        assert_eq!(status.status(), StatusCode::OK);
1686        let status: serde_json::Value = test::read_body_json(status).await;
1687        assert_eq!(status["requires_password"], true);
1688        assert_eq!(status["status"], "degraded");
1689        let serialized_status = status.to_string();
1690        assert!(!serialized_status.contains("access_repair."));
1691        assert!(!serialized_status.contains(secret_fragment));
1692
1693        let list =
1694            test::call_service(&app, TestRequest::get().uri("/credentials").to_request()).await;
1695        assert_eq!(list.status(), StatusCode::OK);
1696        let serialized_list = String::from_utf8(test::read_body(list).await.to_vec()).unwrap();
1697        assert!(!serialized_list.contains("access_repair."));
1698        assert!(!serialized_list.contains(secret_fragment));
1699
1700        let direct = test::call_service(
1701            &app,
1702            TestRequest::get()
1703                .uri("/credentials/access_repair.root.payload")
1704                .to_request(),
1705        )
1706        .await;
1707        assert_eq!(direct.status(), StatusCode::BAD_REQUEST);
1708        let direct = String::from_utf8(test::read_body(direct).await.to_vec()).unwrap();
1709        assert!(!direct.contains(secret_fragment));
1710        assert!(!direct.contains("access_repair.root.payload"));
1711    }
1712
1713    #[actix_web::test]
1714    async fn access_status_reports_valid_ciphertext_with_invalid_verifier_as_error() {
1715        let dir = tempfile::tempdir().unwrap();
1716        let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1717        state
1718            .update_access_control_credentials(0, true, BTreeSet::new(), |config| {
1719                config.access_control = Some(AccessControlConfig {
1720                    password_enabled: true,
1721                    password_hash: Some("a".repeat(64)),
1722                    password_salt: Some("11".repeat(16)),
1723                    password_configured: true,
1724                    ..Default::default()
1725                });
1726                Ok(())
1727            })
1728            .await
1729            .unwrap();
1730        let reference = bamboo_config::config_crypto::access_password_credential_ref().unwrap();
1731        state
1732            .credential_store
1733            .replace(
1734                reference.clone(),
1735                r#"{"hash":"not-a-valid-hash","salt":"11"}"#,
1736                bamboo_config::CredentialSource::User,
1737                1,
1738            )
1739            .unwrap();
1740        let access_path = dir.path().join("access-control.json");
1741        let credential_path = dir.path().join("credentials.json");
1742        let access_before = std::fs::read(&access_path).unwrap();
1743        let credentials_before = std::fs::read(&credential_path).unwrap();
1744        let keep = state
1745            .update_access_control_credentials(1, false, BTreeSet::new(), |config| {
1746                config.access_control.as_mut().unwrap().updated_at =
1747                    Some("metadata-keep-must-fail".to_string());
1748                Ok(())
1749            })
1750            .await;
1751        assert!(keep.is_err());
1752        assert_eq!(std::fs::read(&access_path).unwrap(), access_before);
1753        assert_eq!(std::fs::read(&credential_path).unwrap(), credentials_before);
1754
1755        let app = test::init_service(
1756            App::new()
1757                .app_data(state.clone())
1758                .route("/access/status", web::get().to(get_access_status)),
1759        )
1760        .await;
1761        let response = test::call_service(
1762            &app,
1763            TestRequest::get()
1764                .uri("/access/status")
1765                .peer_addr("203.0.113.8:5700".parse().unwrap())
1766                .insert_header((header::HOST, "bamboo.example.com"))
1767                .to_request(),
1768        )
1769        .await;
1770        assert_eq!(response.status(), StatusCode::OK);
1771        let body: serde_json::Value = test::read_body_json(response).await;
1772        assert_eq!(body["revision"], 1);
1773        assert_eq!(body["password_enabled"], true);
1774        assert_eq!(body["requires_password"], true);
1775        assert_eq!(body["password_configured"], false);
1776        assert_eq!(body["credential_state"], "error");
1777        assert_eq!(body["status"], "degraded");
1778        assert_eq!(
1779            body["last_error"],
1780            "access-control credential repair is required"
1781        );
1782
1783        let (_, revision, metadata, section) = state
1784            .update_access_control_credentials(1, true, BTreeSet::new(), |config| {
1785                let access = config.access_control.as_mut().unwrap();
1786                access.password_enabled = false;
1787                access.password_hash = None;
1788                access.password_salt = None;
1789                access.password_configured = false;
1790                Ok(())
1791            })
1792            .await
1793            .unwrap();
1794        assert_eq!(revision, 2);
1795        assert!(!metadata.status(&reference).configured);
1796        let section = section.unwrap();
1797        assert_eq!(section.revision, 2);
1798        assert!(section.data["password_credential_ref"].is_null());
1799    }
1800
1801    #[actix_web::test]
1802    async fn password_replace_and_clear_use_access_revision_and_preserve_devices() {
1803        let _key = bamboo_config::encryption::set_test_encryption_key([0xd3; 32]);
1804        let dir = tempfile::tempdir().unwrap();
1805        let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1806        let (device, _token) = issue_device_token("paired-device");
1807        let device_id = device.device_id.clone();
1808        let device_intent = device_id.clone();
1809        state
1810            .update_access_control_credentials(
1811                0,
1812                false,
1813                BTreeSet::from([device_intent]),
1814                move |config| {
1815                    config.access_control = Some(AccessControlConfig {
1816                        devices: vec![device],
1817                        ..Default::default()
1818                    });
1819                    Ok(())
1820                },
1821            )
1822            .await
1823            .unwrap();
1824        let mut events = state.account_sink.subscribe();
1825        let app = test::init_service(
1826            App::new()
1827                .app_data(state.clone())
1828                .route("/access/password", web::post().to(update_access_password)),
1829        )
1830        .await;
1831
1832        let replace = test::call_service(
1833            &app,
1834            TestRequest::post()
1835                .uri("/access/password")
1836                .set_json(serde_json::json!({
1837                    "expected_revision": 1,
1838                    "action": "replace",
1839                    "value": "root-replacement-secret"
1840                }))
1841                .to_request(),
1842        )
1843        .await;
1844        assert_eq!(replace.status(), StatusCode::OK);
1845        let replace: serde_json::Value = test::read_body_json(replace).await;
1846        assert_eq!(replace["revision"], 2);
1847        assert_eq!(replace["section"]["revision"], 2);
1848        assert_eq!(replace["credential"]["configured"], true);
1849        assert_eq!(replace["credential"]["state"], "configured");
1850        assert!(!replace.to_string().contains("root-replacement-secret"));
1851        assert!(!replace.to_string().contains("********"));
1852        let event = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1853            loop {
1854                let event = events.recv().await.unwrap();
1855                if matches!(
1856                    &event.event,
1857                    bamboo_agent_core::AgentEvent::ConfigChanged { section, revision: 2 }
1858                        if section == "access-control"
1859                ) {
1860                    break event;
1861                }
1862            }
1863        })
1864        .await
1865        .unwrap();
1866        assert!(matches!(
1867            event.event,
1868            bamboo_agent_core::AgentEvent::ConfigChanged { ref section, revision: 2 }
1869                if section == "access-control"
1870        ));
1871
1872        let clear = test::call_service(
1873            &app,
1874            TestRequest::post()
1875                .uri("/access/password")
1876                .set_json(serde_json::json!({
1877                    "expected_revision": 2,
1878                    "action": "clear",
1879                    "current_password": "root-replacement-secret"
1880                }))
1881                .to_request(),
1882        )
1883        .await;
1884        assert_eq!(clear.status(), StatusCode::OK);
1885        let clear: serde_json::Value = test::read_body_json(clear).await;
1886        assert_eq!(clear["revision"], 3);
1887        assert_eq!(clear["section"]["revision"], 3);
1888        assert_eq!(clear["password_enabled"], false);
1889        assert_eq!(clear["credential"]["configured"], false);
1890        assert_eq!(clear["credential"]["state"], "missing");
1891        let access = state.config.read().await.access_control.clone().unwrap();
1892        assert_eq!(access.devices.len(), 1);
1893        assert_eq!(access.devices[0].device_id, device_id);
1894        assert!(!access.password_enabled);
1895
1896        let stale = test::call_service(
1897            &app,
1898            TestRequest::post()
1899                .uri("/access/password")
1900                .set_json(serde_json::json!({
1901                    "expected_revision": 2,
1902                    "action": "replace",
1903                    "value": "stale-root-secret"
1904                }))
1905                .to_request(),
1906        )
1907        .await;
1908        assert_eq!(stale.status(), StatusCode::CONFLICT);
1909        let stale = String::from_utf8(test::read_body(stale).await.to_vec()).unwrap();
1910        assert!(!stale.contains("stale-root-secret"));
1911        let access = state.config.read().await.access_control.clone().unwrap();
1912        assert_eq!(access.devices.len(), 1);
1913        assert!(!access.password_enabled);
1914
1915        let missing_revision = test::call_service(
1916            &app,
1917            TestRequest::post()
1918                .uri("/access/password")
1919                .set_json(serde_json::json!({
1920                    "action": "replace",
1921                    "value": "missing-revision-secret"
1922                }))
1923                .to_request(),
1924        )
1925        .await;
1926        assert_eq!(missing_revision.status(), StatusCode::BAD_REQUEST);
1927
1928        let masked = test::call_service(
1929            &app,
1930            TestRequest::post()
1931                .uri("/access/password")
1932                .set_json(serde_json::json!({
1933                    "expected_revision": 3,
1934                    "action": "replace",
1935                    "value": "****...****"
1936                }))
1937                .to_request(),
1938        )
1939        .await;
1940        assert_eq!(masked.status(), StatusCode::BAD_REQUEST);
1941
1942        let ambiguous = test::call_service(
1943            &app,
1944            TestRequest::post()
1945                .uri("/access/password")
1946                .set_json(serde_json::json!({
1947                    "expected_revision": 3,
1948                    "action": "replace",
1949                    "value": "ambiguous-value-secret",
1950                    "new_password": "ambiguous-legacy-secret"
1951                }))
1952                .to_request(),
1953        )
1954        .await;
1955        assert_eq!(ambiguous.status(), StatusCode::BAD_REQUEST);
1956
1957        let clear_with_value = test::call_service(
1958            &app,
1959            TestRequest::post()
1960                .uri("/access/password")
1961                .set_json(serde_json::json!({
1962                    "expected_revision": 3,
1963                    "action": "clear",
1964                    "value": "unexpected-clear-secret"
1965                }))
1966                .to_request(),
1967        )
1968        .await;
1969        assert_eq!(clear_with_value.status(), StatusCode::BAD_REQUEST);
1970
1971        let access_file = std::fs::read_to_string(dir.path().join("access-control.json")).unwrap();
1972        let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
1973        for secret in [
1974            "root-replacement-secret",
1975            "stale-root-secret",
1976            "missing-revision-secret",
1977            "ambiguous-value-secret",
1978            "ambiguous-legacy-secret",
1979            "unexpected-clear-secret",
1980            "****...****",
1981        ] {
1982            assert!(!access_file.contains(secret));
1983            assert!(!credentials.contains(secret));
1984        }
1985    }
1986
1987    #[actix_web::test]
1988    async fn stale_process_old_password_cannot_authorize_newer_access_generation() {
1989        let dir = tempfile::tempdir().unwrap();
1990        let writer = AppState::new(dir.path().to_path_buf()).await.unwrap();
1991
1992        let old_password = uuid::Uuid::new_v4().to_string();
1993        let old_salt = "11".repeat(16);
1994        let old_hash = compute_password_hash(&old_password, &old_salt).unwrap();
1995        writer
1996            .update_access_control_credentials(0, true, BTreeSet::new(), move |config| {
1997                config.access_control = Some(AccessControlConfig {
1998                    password_enabled: true,
1999                    password_hash: Some(old_hash),
2000                    password_salt: Some(old_salt),
2001                    password_configured: true,
2002                    ..Default::default()
2003                });
2004                Ok(())
2005            })
2006            .await
2007            .unwrap();
2008
2009        let mut stale = AppState::new(dir.path().to_path_buf()).await.unwrap();
2010        stale.stop_config_watcher_for_test();
2011        assert_eq!(
2012            stale
2013                .config_facade
2014                .as_ref()
2015                .unwrap()
2016                .registry()
2017                .access_control
2018                .snapshot()
2019                .revision,
2020            1
2021        );
2022        {
2023            let stale_config = stale.config.read().await;
2024            assert!(verify_password(&stale_config, &old_password));
2025        }
2026
2027        let new_password = uuid::Uuid::new_v4().to_string();
2028        let new_salt = "22".repeat(16);
2029        let new_hash = compute_password_hash(&new_password, &new_salt).unwrap();
2030        writer
2031            .update_access_control_credentials(1, true, BTreeSet::new(), move |config| {
2032                let access = config.access_control.get_or_insert_with(Default::default);
2033                access.password_enabled = true;
2034                access.password_hash = Some(new_hash);
2035                access.password_salt = Some(new_salt);
2036                access.password_configured = true;
2037                Ok(())
2038            })
2039            .await
2040            .unwrap();
2041        {
2042            let stale_config = stale.config.read().await;
2043            assert!(verify_password(&stale_config, &old_password));
2044            assert!(!verify_password(&stale_config, &new_password));
2045        }
2046
2047        let app = test::init_service(
2048            App::new()
2049                .app_data(web::Data::new(stale))
2050                .route("/access/password", web::post().to(update_access_password)),
2051        )
2052        .await;
2053        let response = test::call_service(
2054            &app,
2055            TestRequest::post()
2056                .uri("/access/password")
2057                .peer_addr("203.0.113.7:5700".parse().unwrap())
2058                .insert_header((header::HOST, "bamboo.example.com"))
2059                .set_json(serde_json::json!({
2060                    "expected_revision": 2,
2061                    "action": "replace",
2062                    "current_password": old_password,
2063                    "value": "unauthorized-third-secret"
2064                }))
2065                .to_request(),
2066        )
2067        .await;
2068        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2069
2070        let exact = bamboo_config::read_exact_credential_section_snapshot(
2071            dir.path(),
2072            bamboo_config::SectionId::AccessControl,
2073            Some(2),
2074        )
2075        .unwrap();
2076        let mut durable = Config::default();
2077        exact.install_into(&mut durable);
2078        assert!(verify_password(&durable, &new_password));
2079        assert!(!verify_password(&durable, &old_password));
2080        assert!(!verify_password(&durable, "unauthorized-third-secret"));
2081        assert!(
2082            !std::fs::read_to_string(dir.path().join("credentials.json"))
2083                .unwrap()
2084                .contains("unauthorized-third-secret")
2085        );
2086    }
2087
2088    #[actix_web::test]
2089    async fn codex_run_token_is_path_and_session_scoped_and_revocation_beats_loopback_bypass() {
2090        async fn probe(req: HttpRequest) -> HttpResponse {
2091            let session_id = req
2092                .extensions()
2093                .get::<crate::codex_run_tokens::CodexRunAuthContext>()
2094                .map(|context| context.session_id.clone())
2095                .unwrap_or_else(|| "missing-context".to_string());
2096            HttpResponse::Ok().body(session_id)
2097        }
2098
2099        let data_dir = tempfile::tempdir().unwrap();
2100        let state = AppState::new(data_dir.path().to_path_buf()).await.unwrap();
2101        let tokens = state.codex_run_tokens.clone();
2102        let issued = tokens.issue("codex-child-570").unwrap();
2103        let app = test::init_service(
2104            App::new()
2105                .app_data(web::Data::new(state))
2106                .wrap(from_fn(enforce_access_password_middleware))
2107                .route("/openai/v1/responses", web::post().to(probe))
2108                .route("/openai/v1/chat/completions", web::post().to(probe)),
2109        )
2110        .await;
2111
2112        let valid = TestRequest::post()
2113            .uri("/openai/v1/responses")
2114            .peer_addr("127.0.0.1:5700".parse().unwrap())
2115            .insert_header((header::HOST, "localhost:9562"))
2116            .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2117            .to_request();
2118        let valid = test::call_service(&app, valid).await;
2119        assert_eq!(valid.status(), StatusCode::OK);
2120        assert_eq!(
2121            to_bytes(valid.into_body()).await.unwrap(),
2122            "codex-child-570".as_bytes()
2123        );
2124
2125        let out_of_scope = TestRequest::post()
2126            .uri("/openai/v1/chat/completions")
2127            .peer_addr("127.0.0.1:5700".parse().unwrap())
2128            .insert_header((header::HOST, "localhost:9562"))
2129            .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2130            .to_request();
2131        assert_eq!(
2132            test::call_service(&app, out_of_scope).await.status(),
2133            StatusCode::UNAUTHORIZED
2134        );
2135
2136        tokens.revoke(&issued.token_id);
2137        let revoked_on_loopback = TestRequest::post()
2138            .uri("/openai/v1/responses")
2139            .peer_addr("127.0.0.1:5700".parse().unwrap())
2140            .insert_header((header::HOST, "localhost:9562"))
2141            .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2142            .to_request();
2143        assert_eq!(
2144            test::call_service(&app, revoked_on_loopback).await.status(),
2145            StatusCode::UNAUTHORIZED,
2146            "revoked bcx1_ credentials must never fall through to loopback bypass"
2147        );
2148    }
2149
2150    #[test]
2151    fn loopback_request_is_local() {
2152        let req = TestRequest::default()
2153            .peer_addr("127.0.0.1:12345".parse().unwrap())
2154            .insert_header((header::HOST, "localhost:9562"))
2155            .to_http_request();
2156        assert!(is_local_request(&req));
2157    }
2158
2159    #[test]
2160    fn private_lan_host_is_local() {
2161        let req = TestRequest::default()
2162            .insert_header((header::HOST, "192.168.0.10:9562"))
2163            .to_http_request();
2164        assert!(is_local_request(&req));
2165    }
2166
2167    #[test]
2168    fn remote_host_is_not_local_even_when_peer_is_loopback() {
2169        let req = TestRequest::default()
2170            .peer_addr("127.0.0.1:12345".parse().unwrap())
2171            .insert_header((header::HOST, "bamboo.example.com"))
2172            .to_http_request();
2173        assert!(!is_local_request(&req));
2174    }
2175
2176    #[test]
2177    fn spoofed_local_host_from_remote_peer_is_not_local() {
2178        // #199: a request from a PUBLIC peer carrying `Host: localhost` (or any
2179        // local-looking Host / X-Forwarded-Host) must NOT be treated as local —
2180        // otherwise a remote attacker bypasses the access password entirely.
2181        for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
2182            let req = TestRequest::default()
2183                .peer_addr("203.0.113.5:40000".parse().unwrap()) // public peer
2184                .insert_header((header::HOST, spoof))
2185                .to_http_request();
2186            assert!(
2187                !is_local_request(&req),
2188                "remote peer + spoofed Host '{spoof}' must not be local"
2189            );
2190            // Same via X-Forwarded-Host.
2191            let req2 = TestRequest::default()
2192                .peer_addr("203.0.113.5:40000".parse().unwrap())
2193                .insert_header(("x-forwarded-host", spoof))
2194                .to_http_request();
2195            assert!(
2196                !is_local_request(&req2),
2197                "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
2198            );
2199        }
2200    }
2201
2202    #[test]
2203    fn loopback_peer_with_no_host_is_local() {
2204        let req = TestRequest::default()
2205            .peer_addr("127.0.0.1:5000".parse().unwrap())
2206            .to_http_request();
2207        assert!(is_local_request(&req));
2208    }
2209
2210    #[test]
2211    fn password_hash_roundtrip_verifies() {
2212        let salt_hex = hex::encode([1_u8; 16]);
2213        let hash = compute_password_hash("secret", &salt_hex).unwrap();
2214        let config = test_config! {
2215            access_control: Some(AccessControlConfig {
2216                password_enabled: true,
2217                repair_required: false,
2218                password_hash: Some(hash),
2219                password_salt: Some(salt_hex),
2220                password_credential_ref: None,
2221                password_configured: false,
2222                updated_at: None,
2223                devices: Vec::new(),
2224            }),
2225        };
2226
2227        assert!(verify_password(&config, "secret"));
2228        assert!(!verify_password(&config, "wrong"));
2229    }
2230
2231    // ── v2-P2 device token primitives + gate (#181) ────────────────────────
2232
2233    fn config_with_password() -> Config {
2234        let salt_hex = hex::encode([1_u8; 16]);
2235        let hash = compute_password_hash("secret", &salt_hex).unwrap();
2236        test_config! {
2237            access_control: Some(AccessControlConfig {
2238                password_enabled: true,
2239                repair_required: false,
2240                password_hash: Some(hash),
2241                password_salt: Some(salt_hex),
2242                password_credential_ref: None,
2243                password_configured: false,
2244                updated_at: None,
2245                devices: Vec::new(),
2246            }),
2247        }
2248    }
2249
2250    #[test]
2251    fn constant_time_eq_matches_and_rejects() {
2252        assert!(constant_time_eq(b"abcd", b"abcd"));
2253        assert!(!constant_time_eq(b"abcd", b"abce"));
2254        assert!(!constant_time_eq(b"abc", b"abcd"));
2255    }
2256
2257    #[test]
2258    fn issued_token_has_expected_format_and_verifies() {
2259        let (cred, token) = issue_device_token("iPhone 15");
2260        assert!(token.starts_with("bd1_"));
2261        assert_eq!(token.len(), "bd1_".len() + 32);
2262        assert!(cred.device_id.starts_with("bamboo_"));
2263        assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
2264        assert_eq!(cred.label, "iPhone 15");
2265        assert!(!cred.revoked);
2266        // The plaintext token must NOT be stored anywhere on the credential.
2267        assert_ne!(cred.token_hash, token);
2268
2269        let mut config = config_with_password();
2270        config
2271            .access_control
2272            .as_mut()
2273            .unwrap()
2274            .devices
2275            .push(cred.clone());
2276
2277        assert!(verify_device_token(&config, &cred.device_id, &token));
2278        assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
2279        assert!(!verify_device_token(&config, "bamboo_unknown", &token));
2280    }
2281
2282    #[test]
2283    fn revoked_token_is_rejected() {
2284        let (mut cred, token) = issue_device_token("iPad");
2285        cred.revoked = true;
2286        let mut config = config_with_password();
2287        let device_id = cred.device_id.clone();
2288        config.access_control.as_mut().unwrap().devices.push(cred);
2289        assert!(!verify_device_token(&config, &device_id, &token));
2290    }
2291
2292    #[test]
2293    fn has_active_devices_ignores_revoked() {
2294        let mut config = config_with_password();
2295        assert!(!has_active_devices(&config));
2296        let (mut cred, _t) = issue_device_token("d");
2297        cred.revoked = true;
2298        config
2299            .access_control
2300            .as_mut()
2301            .unwrap()
2302            .devices
2303            .push(cred.clone());
2304        assert!(!has_active_devices(&config));
2305        let (cred2, _t2) = issue_device_token("d2");
2306        config.access_control.as_mut().unwrap().devices.push(cred2);
2307        assert!(has_active_devices(&config));
2308    }
2309
2310    fn remote_req() -> HttpRequest {
2311        TestRequest::default()
2312            .insert_header((header::HOST, "bamboo.example.com"))
2313            .to_http_request()
2314    }
2315
2316    fn local_req() -> HttpRequest {
2317        TestRequest::default()
2318            .insert_header((header::HOST, "localhost:9562"))
2319            .to_http_request()
2320    }
2321
2322    #[test]
2323    fn no_devices_no_password_does_not_require_credential() {
2324        // Zero-regression baseline: an instance with neither password nor devices
2325        // never requires a credential, even for a remote request.
2326        let config = Config::default();
2327        assert!(!build_access_status(&config, &remote_req()).requires_password);
2328    }
2329
2330    #[test]
2331    fn password_only_gate_matches_prior_behavior() {
2332        let config = config_with_password();
2333        assert!(build_access_status(&config, &remote_req()).requires_password);
2334        assert!(!build_access_status(&config, &local_req()).requires_password);
2335    }
2336
2337    #[test]
2338    fn enabled_password_with_missing_verifier_fails_closed_for_remote_requests() {
2339        let config = test_config! {
2340            access_control: Some(AccessControlConfig {
2341                password_enabled: true,
2342                repair_required: false,
2343                password_hash: None,
2344                password_salt: None,
2345                password_credential_ref: None,
2346                password_configured: false,
2347                updated_at: None,
2348                devices: Vec::new(),
2349            }),
2350        };
2351        let remote = remote_req();
2352        let local = local_req();
2353        assert!(build_access_status(&config, &remote).password_enabled);
2354        assert!(build_access_status(&config, &remote).requires_password);
2355        assert!(!verify_password(&config, "any-password"));
2356        assert!(!request_is_authorized(&remote, &config));
2357        assert!(request_is_authorized(&local, &config));
2358    }
2359
2360    #[test]
2361    fn device_presence_requires_credential_even_without_password() {
2362        // A device paired but no root password still gates remote access.
2363        let (cred, _t) = issue_device_token("d");
2364        let config = test_config! {
2365            access_control: Some(AccessControlConfig {
2366                password_enabled: false,
2367                repair_required: false,
2368                password_hash: None,
2369                password_salt: None,
2370                password_credential_ref: None,
2371                password_configured: false,
2372                updated_at: None,
2373                devices: vec![cred],
2374            }),
2375        };
2376        assert!(build_access_status(&config, &remote_req()).requires_password);
2377        // Local still bypasses.
2378        assert!(!build_access_status(&config, &local_req()).requires_password);
2379    }
2380
2381    #[test]
2382    fn valid_device_token_on_request_authenticates() {
2383        let (cred, token) = issue_device_token("d");
2384        let device_id = cred.device_id.clone();
2385        let mut config = config_with_password();
2386        config.access_control.as_mut().unwrap().devices.push(cred);
2387
2388        let req = TestRequest::default()
2389            .insert_header((header::HOST, "bamboo.example.com"))
2390            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2391            .insert_header((DEVICE_ID_HEADER, device_id))
2392            .to_http_request();
2393        assert!(request_has_valid_device_token(&req, &config));
2394
2395        // Wrong token rejected.
2396        let bad = TestRequest::default()
2397            .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
2398            .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
2399            .to_http_request();
2400        assert!(!request_has_valid_device_token(&bad, &config));
2401
2402        // Missing device-id header → not a credential.
2403        let no_id = TestRequest::default()
2404            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2405            .to_http_request();
2406        assert!(!request_has_valid_device_token(&no_id, &config));
2407    }
2408
2409    // ── v2-P2 shared allow-decision: request_is_authorized (#189) ──────────
2410    //
2411    // This is the SINGLE source of truth the middleware and the ws_v2 handler
2412    // both call. These tests pin its truth table so the open `/v2/stream`
2413    // upgrade enforces exactly what the middleware enforces everywhere else.
2414
2415    #[test]
2416    fn request_is_authorized_local_is_always_allowed() {
2417        // A local request bypasses regardless of configured credentials.
2418        let config = config_with_password();
2419        assert!(request_is_authorized(&local_req(), &config));
2420    }
2421
2422    #[test]
2423    fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
2424        // Remote + a credential mechanism configured + no presented credential.
2425        let (cred, _t) = issue_device_token("d");
2426        let config = test_config! {
2427            access_control: Some(AccessControlConfig {
2428                password_enabled: false,
2429                repair_required: false,
2430                password_hash: None,
2431                password_salt: None,
2432                password_credential_ref: None,
2433                password_configured: false,
2434                updated_at: None,
2435                devices: vec![cred],
2436            }),
2437        };
2438        assert!(!request_is_authorized(&remote_req(), &config));
2439    }
2440
2441    #[test]
2442    fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
2443        let config = config_with_password();
2444        assert!(!request_is_authorized(&remote_req(), &config));
2445    }
2446
2447    #[test]
2448    fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
2449        let config = config_with_password();
2450        let cookie_value =
2451            access_verification_cookie_value(&config).expect("password config yields a cookie");
2452        let req = TestRequest::default()
2453            .insert_header((header::HOST, "bamboo.example.com"))
2454            .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
2455            .to_http_request();
2456        assert!(request_is_authorized(&req, &config));
2457    }
2458
2459    #[test]
2460    fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
2461        let (cred, token) = issue_device_token("d");
2462        let device_id = cred.device_id.clone();
2463        let mut config = config_with_password();
2464        config.access_control.as_mut().unwrap().devices.push(cred);
2465
2466        let req = TestRequest::default()
2467            .insert_header((header::HOST, "bamboo.example.com"))
2468            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2469            .insert_header((DEVICE_ID_HEADER, device_id))
2470            .to_http_request();
2471        assert!(request_is_authorized(&req, &config));
2472    }
2473
2474    #[test]
2475    fn repair_required_rejects_valid_remote_credentials_but_keeps_local_bypass() {
2476        let (cred, token) = issue_device_token("repair-device");
2477        let device_id = cred.device_id.clone();
2478        let mut config = config_with_password();
2479        config.access_control.as_mut().unwrap().devices.push(cred);
2480        let previously_valid_cookie = access_verification_cookie_value(&config)
2481            .expect("healthy password verifier produces a cookie");
2482        config.access_control.as_mut().unwrap().repair_required = true;
2483
2484        let remote_cookie = TestRequest::default()
2485            .insert_header((header::HOST, "bamboo.example.com"))
2486            .cookie(Cookie::new(
2487                ACCESS_VERIFIED_COOKIE_NAME,
2488                previously_valid_cookie,
2489            ))
2490            .to_http_request();
2491        let remote_device = TestRequest::default()
2492            .insert_header((header::HOST, "bamboo.example.com"))
2493            .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2494            .insert_header((DEVICE_ID_HEADER, device_id.clone()))
2495            .to_http_request();
2496
2497        assert!(build_access_status(&config, &remote_cookie).requires_password);
2498        assert!(!build_access_status(&config, &local_req()).requires_password);
2499        assert!(!verify_password(&config, "secret"));
2500        assert!(!verify_device_token(&config, &device_id, &token));
2501        assert!(access_verification_cookie_value(&config).is_none());
2502        assert!(!request_is_authorized(&remote_cookie, &config));
2503        assert!(!request_is_authorized(&remote_device, &config));
2504        assert!(request_is_authorized(&local_req(), &config));
2505    }
2506
2507    #[test]
2508    fn request_is_authorized_no_password_no_devices_is_open() {
2509        // Zero-regression baseline: an instance with neither credential mechanism
2510        // never requires auth, so even a remote request is authorized.
2511        let config = Config::default();
2512        assert!(request_is_authorized(&remote_req(), &config));
2513    }
2514
2515    #[test]
2516    fn stream_is_public_but_sibling_routes_are_not() {
2517        // #189: the upgrade is whitelisted; the gated siblings are NOT.
2518        assert!(is_public_access_route("/v2/stream"));
2519        assert!(is_public_access_route("/v2/pair"));
2520        assert!(!is_public_access_route("/v2/pair/code"));
2521        assert!(!is_public_access_route("/v2/devices"));
2522        assert!(!is_public_access_route("/v2/devices/bamboo_x"));
2523    }
2524
2525    #[test]
2526    fn health_probes_are_public() {
2527        // #251 (finding 6): unversioned liveness/readiness probes must be
2528        // reachable by a load balancer without a credential.
2529        assert!(is_public_access_route("/healthz"));
2530        assert!(is_public_access_route("/readyz"));
2531        assert!(is_public_access_route("/api/v1/health"));
2532    }
2533
2534    #[test]
2535    fn public_access_status_routes_are_public_under_both_version_prefixes() {
2536        // #251 (finding 1 + 7): `/v1/bamboo/access/*` is aliased at the new
2537        // canonical `/api/v1/bamboo/access/*` prefix — a route that's public
2538        // under one prefix must stay public under its alias, or a legitimate
2539        // pre-auth client (checking whether a password is required at all)
2540        // would get locked out simply for calling the canonical path.
2541        for prefix in ["/v1", "/api/v1"] {
2542            assert!(
2543                is_public_access_route(&format!("{prefix}/bamboo/access/status")),
2544                "{prefix}/bamboo/access/status must be public"
2545            );
2546            assert!(
2547                is_public_access_route(&format!("{prefix}/bamboo/access/verify")),
2548                "{prefix}/bamboo/access/verify must be public"
2549            );
2550            // The sibling password-UPDATE route must stay gated under both
2551            // prefixes — only status/verify are pre-auth.
2552            assert!(
2553                !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
2554                "{prefix}/bamboo/access/password must stay gated"
2555            );
2556        }
2557    }
2558
2559    // ── v2-P2 pairing codes + brute-force guard (#181, slice 2) ────────────
2560
2561    #[test]
2562    fn generated_pairing_code_is_six_digits() {
2563        for _ in 0..1000 {
2564            let code = generate_pairing_code();
2565            assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
2566            assert!(
2567                code.chars().all(|c| c.is_ascii_digit()),
2568                "code {code:?} must be all digits"
2569            );
2570        }
2571    }
2572
2573    #[test]
2574    fn pairing_code_expiry_predicate() {
2575        // A fresh code with a positive TTL is not expired.
2576        let fresh = PairingCodeEntry::new(Duration::from_secs(120));
2577        assert!(!fresh.is_expired());
2578
2579        // A zero-TTL code is immediately expired (expires_at == now).
2580        let zero = PairingCodeEntry::new(Duration::from_secs(0));
2581        assert!(zero.is_expired());
2582
2583        // An entry whose expiry is in the past is expired.
2584        let past = PairingCodeEntry {
2585            expires_at: Instant::now() - Duration::from_secs(1),
2586        };
2587        assert!(past.is_expired());
2588    }
2589
2590    #[test]
2591    fn purge_expired_codes_drops_only_expired() {
2592        let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
2593        codes.insert(
2594            "live".into(),
2595            PairingCodeEntry::new(Duration::from_secs(120)),
2596        );
2597        codes.insert(
2598            "dead".into(),
2599            PairingCodeEntry {
2600                expires_at: Instant::now() - Duration::from_secs(1),
2601            },
2602        );
2603        purge_expired_codes(&codes);
2604        assert!(codes.contains_key("live"));
2605        assert!(!codes.contains_key("dead"));
2606    }
2607
2608    #[test]
2609    fn guard_trips_cooldown_after_threshold() {
2610        let guard = PairingCodeGuard::default();
2611        assert!(!guard.in_cooldown());
2612        // The first THRESHOLD-1 failures do not trip the cooldown.
2613        for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
2614            assert!(!guard.record_failure());
2615            assert!(!guard.in_cooldown());
2616        }
2617        // The THRESHOLD-th failure trips it.
2618        assert!(guard.record_failure());
2619        assert!(guard.in_cooldown());
2620    }
2621
2622    #[test]
2623    fn guard_success_resets_failures() {
2624        let guard = PairingCodeGuard::default();
2625        for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
2626            guard.record_failure();
2627        }
2628        guard.record_success();
2629        // After a reset, the counter starts over — one more failure does NOT trip.
2630        assert!(!guard.record_failure());
2631        assert!(!guard.in_cooldown());
2632    }
2633
2634    #[test]
2635    fn guard_clears_elapsed_cooldown() {
2636        let guard = PairingCodeGuard::default();
2637        // Force a cooldown that has already elapsed.
2638        {
2639            let mut state = guard.inner.lock().unwrap();
2640            state.failures = PAIRING_FAILURE_THRESHOLD;
2641            state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
2642        }
2643        // in_cooldown observes the elapsed deadline and resets.
2644        assert!(!guard.in_cooldown());
2645        assert!(!guard.record_failure(), "counter was reset to 0");
2646    }
2647
2648    // ── #190: per-IP root-password brute-force guard ───────────────────────
2649
2650    #[test]
2651    fn root_guard_trips_cooldown_after_threshold_per_key() {
2652        let guard = RootPasswordGuard::default();
2653        let key = "203.0.113.7";
2654        // The first THRESHOLD-1 failures do not trip the cooldown.
2655        for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
2656            guard.record_failure(key);
2657            assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2658        }
2659        // The THRESHOLD-th failure trips it.
2660        guard.record_failure(key);
2661        match guard.check(key) {
2662            RootGuardDecision::Cooldown { retry_after_secs } => {
2663                assert!(retry_after_secs >= 1);
2664                assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
2665            }
2666            RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
2667        }
2668    }
2669
2670    #[test]
2671    fn root_guard_keys_are_independent() {
2672        // Per-IP isolation: tripping one key must NOT lock out a different key.
2673        let guard = RootPasswordGuard::default();
2674        for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
2675            guard.record_failure("198.51.100.1");
2676        }
2677        assert!(matches!(
2678            guard.check("198.51.100.1"),
2679            RootGuardDecision::Cooldown { .. }
2680        ));
2681        // A different IP is untouched.
2682        assert!(matches!(
2683            guard.check("198.51.100.2"),
2684            RootGuardDecision::Allow
2685        ));
2686    }
2687
2688    #[test]
2689    fn root_guard_success_resets_key() {
2690        let guard = RootPasswordGuard::default();
2691        let key = "203.0.113.9";
2692        for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
2693            guard.record_failure(key);
2694        }
2695        guard.record_success(key);
2696        // After a reset, the counter starts over — one more failure does NOT trip.
2697        guard.record_failure(key);
2698        assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2699    }
2700
2701    #[test]
2702    fn root_guard_clears_elapsed_cooldown() {
2703        let guard = RootPasswordGuard::default();
2704        let key = "203.0.113.10";
2705        // Force a cooldown that has already elapsed.
2706        guard.inner.insert(
2707            key.to_string(),
2708            RootAttemptState {
2709                failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
2710                cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
2711            },
2712        );
2713        // check() observes the elapsed deadline, resets, and allows.
2714        assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2715        // The counter was reset to 0 — one fresh failure does not re-trip.
2716        guard.record_failure(key);
2717        assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2718    }
2719
2720    #[test]
2721    fn root_guard_evicts_inert_keys_past_the_cap() {
2722        let guard = RootPasswordGuard::default();
2723        // Fill to the cap with single-failure (inert, no cooldown) keys, plus a
2724        // few extra to trip the sweep. The map must NOT grow unbounded.
2725        for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
2726            guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
2727        }
2728        assert!(
2729            guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
2730            "inert keys must be swept so the map stays bounded (was {})",
2731            guard.inner.len()
2732        );
2733        // An actively cooling-down key survives a sweep.
2734        let hot = "203.0.113.200";
2735        for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
2736            guard.record_failure(hot);
2737        }
2738        for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
2739            guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
2740        }
2741        assert!(
2742            matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
2743            "a key in active cooldown must survive eviction sweeps"
2744        );
2745    }
2746
2747    #[test]
2748    fn root_throttle_key_exempts_loopback_and_keys_remote() {
2749        // Loopback/desktop is exempt → no key → never throttled.
2750        assert!(root_throttle_key(&local_req()).is_none());
2751
2752        // A remote request with a peer addr yields that IP as the key.
2753        let remote = TestRequest::default()
2754            .peer_addr("203.0.113.5:443".parse().unwrap())
2755            .insert_header((header::HOST, "bamboo.example.com"))
2756            .to_http_request();
2757        assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
2758    }
2759
2760    #[test]
2761    fn client_ip_key_strips_v4_mapped_prefix() {
2762        let req = TestRequest::default()
2763            .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
2764            .to_http_request();
2765        assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
2766    }
2767
2768    #[test]
2769    fn device_summary_excludes_secret_material() {
2770        // Serialized JSON of the GET /v2/devices DTO MUST NOT carry the token
2771        // hash or salt. Assert on the serialized keys/values directly.
2772        let (cred, _t) = issue_device_token("iPhone");
2773        let summary = DeviceSummary::from_credential(&cred);
2774        let json = serde_json::to_value(&summary).unwrap();
2775        let obj = json.as_object().unwrap();
2776        assert!(
2777            !obj.contains_key("token_hash"),
2778            "must not expose token_hash"
2779        );
2780        assert!(
2781            !obj.contains_key("token_salt"),
2782            "must not expose token_salt"
2783        );
2784        // And the actual secret VALUES must not leak under any key.
2785        let serialized = serde_json::to_string(&summary).unwrap();
2786        assert!(!serialized.contains(&cred.token_hash));
2787        assert!(!serialized.contains(&cred.token_salt));
2788        // Expected non-secret fields ARE present.
2789        assert!(obj.contains_key("device_id"));
2790        assert!(obj.contains_key("label"));
2791        assert!(obj.contains_key("created_at"));
2792        assert!(obj.contains_key("revoked"));
2793    }
2794}