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