Skip to main content

bamboo_server/handlers/settings/
access_control.rs

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