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