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 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 return host_local && peer_local != Some(false);
186 }
187
188 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
196fn 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
260const DEVICE_TOKEN_PREFIX: &str = "bd1_";
269const DEVICE_ID_PREFIX: &str = "bamboo_";
271const DEVICE_ID_HEADER: &str = "x-device-id";
274
275fn 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
292fn 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
299pub(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 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
327pub(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 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
349fn 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
360fn 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
389fn 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
446const 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 "/healthz"
469 | "/readyz"
470 | "/v2/pair"
474 | "/v2/stream"
485 )
486}
487
488pub(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 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 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 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 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(¤t_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 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#[derive(Debug, Deserialize)]
695pub struct PairDeviceRequest {
696 #[serde(default)]
698 pub root_password: String,
699 #[serde(default)]
703 pub code: String,
704 #[serde(default)]
706 pub label: String,
707}
708
709#[derive(Serialize)]
710pub struct PairDeviceResponse {
711 pub device_id: String,
712 pub device_token: String,
714 pub expires_hint: &'static str,
715}
716
717pub 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 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
755async 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 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 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
803async fn pair_device_with_code(
807 app_state: &AppState,
808 code: &str,
809 label: &str,
810) -> Result<HttpResponse, AppError> {
811 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 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 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 app_state.pairing_code_guard.record_success();
843 persist_new_device(app_state, label).await
844}
845
846async 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 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 Ok(HttpResponse::Ok().json(PairDeviceResponse {
869 device_id,
870 device_token: token,
871 expires_hint: "rotate-on-demand",
872 }))
873}
874
875const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
879const PAIRING_FAILURE_THRESHOLD: u32 = 10;
881const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
883
884#[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 pub fn is_expired(&self) -> bool {
901 Instant::now() >= self.expires_at
902 }
903}
904
905#[derive(Debug, Default)]
926pub struct PairingCodeGuard {
927 inner: Mutex<PairingGuardState>,
928}
929
930#[derive(Debug, Default)]
931struct PairingGuardState {
932 failures: u32,
933 cooldown_until: Option<Instant>,
935}
936
937impl PairingCodeGuard {
938 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 state.cooldown_until = None;
947 state.failures = 0;
948 false
949 }
950 None => false,
951 }
952 }
953
954 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 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
975const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
994const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
996const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
1002
1003#[derive(Debug, Default, Clone)]
1005struct RootAttemptState {
1006 failures: u32,
1007 cooldown_until: Option<Instant>,
1009}
1010
1011#[derive(Debug, Default)]
1031pub struct RootPasswordGuard {
1032 inner: dashmap::DashMap<String, RootAttemptState>,
1033}
1034
1035pub enum RootGuardDecision {
1037 Allow,
1039 Cooldown { retry_after_secs: u64 },
1041}
1042
1043impl RootPasswordGuard {
1044 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 entry.failures = 0;
1056 entry.cooldown_until = None;
1057 }
1058 }
1059 RootGuardDecision::Allow
1060 }
1061
1062 pub fn record_failure(&self, key: &str) {
1065 let now = Instant::now();
1066 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 if matches!(entry.cooldown_until, Some(until) if now < until) {
1077 return;
1078 }
1079 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 pub fn record_success(&self, key: &str) {
1092 self.inner.remove(key);
1093 }
1094}
1095
1096fn 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
1109fn 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
1121fn generate_pairing_code() -> String {
1127 let n = rand::rng().random_range(0..1_000_000);
1128 format!("{n:06}")
1129}
1130
1131fn 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 pub ttl: u64,
1141}
1142
1143pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1151 purge_expired_codes(&app_state.pairing_codes);
1153
1154 let code = generate_pairing_code();
1155 let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1156 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#[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
1191pub 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
1209pub 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 {
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
1254pub 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 {
1270 let config = app_state.config.read().await;
1271 let exists = config
1272 .access_control
1273 .as_ref()
1274 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1275 .unwrap_or(false);
1276 if !exists {
1277 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1278 }
1279 }
1280
1281 let (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 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 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()) .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 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 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 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 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 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 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 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 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 #[test]
1563 fn request_is_authorized_local_is_always_allowed() {
1564 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 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 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 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 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 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 assert!(
1664 !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
1665 "{prefix}/bamboo/access/password must stay gated"
1666 );
1667 }
1668 }
1669
1670 #[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 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
1688 assert!(!fresh.is_expired());
1689
1690 let zero = PairingCodeEntry::new(Duration::from_secs(0));
1692 assert!(zero.is_expired());
1693
1694 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 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1725 assert!(!guard.record_failure());
1726 assert!(!guard.in_cooldown());
1727 }
1728 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 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 {
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 assert!(!guard.in_cooldown());
1756 assert!(!guard.record_failure(), "counter was reset to 0");
1757 }
1758
1759 #[test]
1762 fn root_guard_trips_cooldown_after_threshold_per_key() {
1763 let guard = RootPasswordGuard::default();
1764 let key = "203.0.113.7";
1765 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1767 guard.record_failure(key);
1768 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1769 }
1770 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 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 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 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 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 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1826 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 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 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 assert!(root_throttle_key(&local_req()).is_none());
1862
1863 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 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 let serialized = serde_json::to_string(&summary).unwrap();
1897 assert!(!serialized.contains(&cred.token_hash));
1898 assert!(!serialized.contains(&cred.token_salt));
1899 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}