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 #[test]
1319 fn loopback_request_is_local() {
1320 let req = TestRequest::default()
1321 .peer_addr("127.0.0.1:12345".parse().unwrap())
1322 .insert_header((header::HOST, "localhost:9562"))
1323 .to_http_request();
1324 assert!(is_local_request(&req));
1325 }
1326
1327 #[test]
1328 fn private_lan_host_is_local() {
1329 let req = TestRequest::default()
1330 .insert_header((header::HOST, "192.168.0.10:9562"))
1331 .to_http_request();
1332 assert!(is_local_request(&req));
1333 }
1334
1335 #[test]
1336 fn remote_host_is_not_local_even_when_peer_is_loopback() {
1337 let req = TestRequest::default()
1338 .peer_addr("127.0.0.1:12345".parse().unwrap())
1339 .insert_header((header::HOST, "bamboo.example.com"))
1340 .to_http_request();
1341 assert!(!is_local_request(&req));
1342 }
1343
1344 #[test]
1345 fn spoofed_local_host_from_remote_peer_is_not_local() {
1346 for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
1350 let req = TestRequest::default()
1351 .peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
1353 .to_http_request();
1354 assert!(
1355 !is_local_request(&req),
1356 "remote peer + spoofed Host '{spoof}' must not be local"
1357 );
1358 let req2 = TestRequest::default()
1360 .peer_addr("203.0.113.5:40000".parse().unwrap())
1361 .insert_header(("x-forwarded-host", spoof))
1362 .to_http_request();
1363 assert!(
1364 !is_local_request(&req2),
1365 "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
1366 );
1367 }
1368 }
1369
1370 #[test]
1371 fn loopback_peer_with_no_host_is_local() {
1372 let req = TestRequest::default()
1373 .peer_addr("127.0.0.1:5000".parse().unwrap())
1374 .to_http_request();
1375 assert!(is_local_request(&req));
1376 }
1377
1378 #[test]
1379 fn password_hash_roundtrip_verifies() {
1380 let salt_hex = hex::encode([1_u8; 16]);
1381 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1382 let config = Config {
1383 access_control: Some(AccessControlConfig {
1384 password_enabled: true,
1385 password_hash: Some(hash),
1386 password_salt: Some(salt_hex),
1387 updated_at: None,
1388 devices: Vec::new(),
1389 }),
1390 ..Config::default()
1391 };
1392
1393 assert!(verify_password(&config, "secret"));
1394 assert!(!verify_password(&config, "wrong"));
1395 }
1396
1397 fn config_with_password() -> Config {
1400 let salt_hex = hex::encode([1_u8; 16]);
1401 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1402 Config {
1403 access_control: Some(AccessControlConfig {
1404 password_enabled: true,
1405 password_hash: Some(hash),
1406 password_salt: Some(salt_hex),
1407 updated_at: None,
1408 devices: Vec::new(),
1409 }),
1410 ..Config::default()
1411 }
1412 }
1413
1414 #[test]
1415 fn constant_time_eq_matches_and_rejects() {
1416 assert!(constant_time_eq(b"abcd", b"abcd"));
1417 assert!(!constant_time_eq(b"abcd", b"abce"));
1418 assert!(!constant_time_eq(b"abc", b"abcd"));
1419 }
1420
1421 #[test]
1422 fn issued_token_has_expected_format_and_verifies() {
1423 let (cred, token) = issue_device_token("iPhone 15");
1424 assert!(token.starts_with("bd1_"));
1425 assert_eq!(token.len(), "bd1_".len() + 32);
1426 assert!(cred.device_id.starts_with("bamboo_"));
1427 assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
1428 assert_eq!(cred.label, "iPhone 15");
1429 assert!(!cred.revoked);
1430 assert_ne!(cred.token_hash, token);
1432
1433 let mut config = config_with_password();
1434 config
1435 .access_control
1436 .as_mut()
1437 .unwrap()
1438 .devices
1439 .push(cred.clone());
1440
1441 assert!(verify_device_token(&config, &cred.device_id, &token));
1442 assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
1443 assert!(!verify_device_token(&config, "bamboo_unknown", &token));
1444 }
1445
1446 #[test]
1447 fn revoked_token_is_rejected() {
1448 let (mut cred, token) = issue_device_token("iPad");
1449 cred.revoked = true;
1450 let mut config = config_with_password();
1451 let device_id = cred.device_id.clone();
1452 config.access_control.as_mut().unwrap().devices.push(cred);
1453 assert!(!verify_device_token(&config, &device_id, &token));
1454 }
1455
1456 #[test]
1457 fn has_active_devices_ignores_revoked() {
1458 let mut config = config_with_password();
1459 assert!(!has_active_devices(&config));
1460 let (mut cred, _t) = issue_device_token("d");
1461 cred.revoked = true;
1462 config
1463 .access_control
1464 .as_mut()
1465 .unwrap()
1466 .devices
1467 .push(cred.clone());
1468 assert!(!has_active_devices(&config));
1469 let (cred2, _t2) = issue_device_token("d2");
1470 config.access_control.as_mut().unwrap().devices.push(cred2);
1471 assert!(has_active_devices(&config));
1472 }
1473
1474 fn remote_req() -> HttpRequest {
1475 TestRequest::default()
1476 .insert_header((header::HOST, "bamboo.example.com"))
1477 .to_http_request()
1478 }
1479
1480 fn local_req() -> HttpRequest {
1481 TestRequest::default()
1482 .insert_header((header::HOST, "localhost:9562"))
1483 .to_http_request()
1484 }
1485
1486 #[test]
1487 fn no_devices_no_password_does_not_require_credential() {
1488 let config = Config::default();
1491 assert!(!build_access_status(&config, &remote_req()).requires_password);
1492 }
1493
1494 #[test]
1495 fn password_only_gate_matches_prior_behavior() {
1496 let config = config_with_password();
1497 assert!(build_access_status(&config, &remote_req()).requires_password);
1498 assert!(!build_access_status(&config, &local_req()).requires_password);
1499 }
1500
1501 #[test]
1502 fn device_presence_requires_credential_even_without_password() {
1503 let (cred, _t) = issue_device_token("d");
1505 let config = Config {
1506 access_control: Some(AccessControlConfig {
1507 password_enabled: false,
1508 password_hash: None,
1509 password_salt: None,
1510 updated_at: None,
1511 devices: vec![cred],
1512 }),
1513 ..Config::default()
1514 };
1515 assert!(build_access_status(&config, &remote_req()).requires_password);
1516 assert!(!build_access_status(&config, &local_req()).requires_password);
1518 }
1519
1520 #[test]
1521 fn valid_device_token_on_request_authenticates() {
1522 let (cred, token) = issue_device_token("d");
1523 let device_id = cred.device_id.clone();
1524 let mut config = config_with_password();
1525 config.access_control.as_mut().unwrap().devices.push(cred);
1526
1527 let req = TestRequest::default()
1528 .insert_header((header::HOST, "bamboo.example.com"))
1529 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1530 .insert_header((DEVICE_ID_HEADER, device_id))
1531 .to_http_request();
1532 assert!(request_has_valid_device_token(&req, &config));
1533
1534 let bad = TestRequest::default()
1536 .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
1537 .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
1538 .to_http_request();
1539 assert!(!request_has_valid_device_token(&bad, &config));
1540
1541 let no_id = TestRequest::default()
1543 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1544 .to_http_request();
1545 assert!(!request_has_valid_device_token(&no_id, &config));
1546 }
1547
1548 #[test]
1555 fn request_is_authorized_local_is_always_allowed() {
1556 let config = config_with_password();
1558 assert!(request_is_authorized(&local_req(), &config));
1559 }
1560
1561 #[test]
1562 fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
1563 let (cred, _t) = issue_device_token("d");
1565 let config = Config {
1566 access_control: Some(AccessControlConfig {
1567 password_enabled: false,
1568 password_hash: None,
1569 password_salt: None,
1570 updated_at: None,
1571 devices: vec![cred],
1572 }),
1573 ..Config::default()
1574 };
1575 assert!(!request_is_authorized(&remote_req(), &config));
1576 }
1577
1578 #[test]
1579 fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
1580 let config = config_with_password();
1581 assert!(!request_is_authorized(&remote_req(), &config));
1582 }
1583
1584 #[test]
1585 fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
1586 let config = config_with_password();
1587 let cookie_value =
1588 access_verification_cookie_value(&config).expect("password config yields a cookie");
1589 let req = TestRequest::default()
1590 .insert_header((header::HOST, "bamboo.example.com"))
1591 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
1592 .to_http_request();
1593 assert!(request_is_authorized(&req, &config));
1594 }
1595
1596 #[test]
1597 fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
1598 let (cred, token) = issue_device_token("d");
1599 let device_id = cred.device_id.clone();
1600 let mut config = config_with_password();
1601 config.access_control.as_mut().unwrap().devices.push(cred);
1602
1603 let req = TestRequest::default()
1604 .insert_header((header::HOST, "bamboo.example.com"))
1605 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1606 .insert_header((DEVICE_ID_HEADER, device_id))
1607 .to_http_request();
1608 assert!(request_is_authorized(&req, &config));
1609 }
1610
1611 #[test]
1612 fn request_is_authorized_no_password_no_devices_is_open() {
1613 let config = Config::default();
1616 assert!(request_is_authorized(&remote_req(), &config));
1617 }
1618
1619 #[test]
1620 fn stream_is_public_but_sibling_routes_are_not() {
1621 assert!(is_public_access_route("/v2/stream"));
1623 assert!(is_public_access_route("/v2/pair"));
1624 assert!(!is_public_access_route("/v2/pair/code"));
1625 assert!(!is_public_access_route("/v2/devices"));
1626 assert!(!is_public_access_route("/v2/devices/bamboo_x"));
1627 }
1628
1629 #[test]
1630 fn health_probes_are_public() {
1631 assert!(is_public_access_route("/healthz"));
1634 assert!(is_public_access_route("/readyz"));
1635 assert!(is_public_access_route("/api/v1/health"));
1636 }
1637
1638 #[test]
1639 fn public_access_status_routes_are_public_under_both_version_prefixes() {
1640 for prefix in ["/v1", "/api/v1"] {
1646 assert!(
1647 is_public_access_route(&format!("{prefix}/bamboo/access/status")),
1648 "{prefix}/bamboo/access/status must be public"
1649 );
1650 assert!(
1651 is_public_access_route(&format!("{prefix}/bamboo/access/verify")),
1652 "{prefix}/bamboo/access/verify must be public"
1653 );
1654 assert!(
1657 !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
1658 "{prefix}/bamboo/access/password must stay gated"
1659 );
1660 }
1661 }
1662
1663 #[test]
1666 fn generated_pairing_code_is_six_digits() {
1667 for _ in 0..1000 {
1668 let code = generate_pairing_code();
1669 assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
1670 assert!(
1671 code.chars().all(|c| c.is_ascii_digit()),
1672 "code {code:?} must be all digits"
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn pairing_code_expiry_predicate() {
1679 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
1681 assert!(!fresh.is_expired());
1682
1683 let zero = PairingCodeEntry::new(Duration::from_secs(0));
1685 assert!(zero.is_expired());
1686
1687 let past = PairingCodeEntry {
1689 expires_at: Instant::now() - Duration::from_secs(1),
1690 };
1691 assert!(past.is_expired());
1692 }
1693
1694 #[test]
1695 fn purge_expired_codes_drops_only_expired() {
1696 let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
1697 codes.insert(
1698 "live".into(),
1699 PairingCodeEntry::new(Duration::from_secs(120)),
1700 );
1701 codes.insert(
1702 "dead".into(),
1703 PairingCodeEntry {
1704 expires_at: Instant::now() - Duration::from_secs(1),
1705 },
1706 );
1707 purge_expired_codes(&codes);
1708 assert!(codes.contains_key("live"));
1709 assert!(!codes.contains_key("dead"));
1710 }
1711
1712 #[test]
1713 fn guard_trips_cooldown_after_threshold() {
1714 let guard = PairingCodeGuard::default();
1715 assert!(!guard.in_cooldown());
1716 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1718 assert!(!guard.record_failure());
1719 assert!(!guard.in_cooldown());
1720 }
1721 assert!(guard.record_failure());
1723 assert!(guard.in_cooldown());
1724 }
1725
1726 #[test]
1727 fn guard_success_resets_failures() {
1728 let guard = PairingCodeGuard::default();
1729 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1730 guard.record_failure();
1731 }
1732 guard.record_success();
1733 assert!(!guard.record_failure());
1735 assert!(!guard.in_cooldown());
1736 }
1737
1738 #[test]
1739 fn guard_clears_elapsed_cooldown() {
1740 let guard = PairingCodeGuard::default();
1741 {
1743 let mut state = guard.inner.lock().unwrap();
1744 state.failures = PAIRING_FAILURE_THRESHOLD;
1745 state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
1746 }
1747 assert!(!guard.in_cooldown());
1749 assert!(!guard.record_failure(), "counter was reset to 0");
1750 }
1751
1752 #[test]
1755 fn root_guard_trips_cooldown_after_threshold_per_key() {
1756 let guard = RootPasswordGuard::default();
1757 let key = "203.0.113.7";
1758 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1760 guard.record_failure(key);
1761 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1762 }
1763 guard.record_failure(key);
1765 match guard.check(key) {
1766 RootGuardDecision::Cooldown { retry_after_secs } => {
1767 assert!(retry_after_secs >= 1);
1768 assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
1769 }
1770 RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
1771 }
1772 }
1773
1774 #[test]
1775 fn root_guard_keys_are_independent() {
1776 let guard = RootPasswordGuard::default();
1778 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1779 guard.record_failure("198.51.100.1");
1780 }
1781 assert!(matches!(
1782 guard.check("198.51.100.1"),
1783 RootGuardDecision::Cooldown { .. }
1784 ));
1785 assert!(matches!(
1787 guard.check("198.51.100.2"),
1788 RootGuardDecision::Allow
1789 ));
1790 }
1791
1792 #[test]
1793 fn root_guard_success_resets_key() {
1794 let guard = RootPasswordGuard::default();
1795 let key = "203.0.113.9";
1796 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1797 guard.record_failure(key);
1798 }
1799 guard.record_success(key);
1800 guard.record_failure(key);
1802 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1803 }
1804
1805 #[test]
1806 fn root_guard_clears_elapsed_cooldown() {
1807 let guard = RootPasswordGuard::default();
1808 let key = "203.0.113.10";
1809 guard.inner.insert(
1811 key.to_string(),
1812 RootAttemptState {
1813 failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
1814 cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
1815 },
1816 );
1817 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1819 guard.record_failure(key);
1821 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1822 }
1823
1824 #[test]
1825 fn root_guard_evicts_inert_keys_past_the_cap() {
1826 let guard = RootPasswordGuard::default();
1827 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1830 guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
1831 }
1832 assert!(
1833 guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
1834 "inert keys must be swept so the map stays bounded (was {})",
1835 guard.inner.len()
1836 );
1837 let hot = "203.0.113.200";
1839 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1840 guard.record_failure(hot);
1841 }
1842 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1843 guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
1844 }
1845 assert!(
1846 matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
1847 "a key in active cooldown must survive eviction sweeps"
1848 );
1849 }
1850
1851 #[test]
1852 fn root_throttle_key_exempts_loopback_and_keys_remote() {
1853 assert!(root_throttle_key(&local_req()).is_none());
1855
1856 let remote = TestRequest::default()
1858 .peer_addr("203.0.113.5:443".parse().unwrap())
1859 .insert_header((header::HOST, "bamboo.example.com"))
1860 .to_http_request();
1861 assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
1862 }
1863
1864 #[test]
1865 fn client_ip_key_strips_v4_mapped_prefix() {
1866 let req = TestRequest::default()
1867 .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
1868 .to_http_request();
1869 assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
1870 }
1871
1872 #[test]
1873 fn device_summary_excludes_secret_material() {
1874 let (cred, _t) = issue_device_token("iPhone");
1877 let summary = DeviceSummary::from_credential(&cred);
1878 let json = serde_json::to_value(&summary).unwrap();
1879 let obj = json.as_object().unwrap();
1880 assert!(
1881 !obj.contains_key("token_hash"),
1882 "must not expose token_hash"
1883 );
1884 assert!(
1885 !obj.contains_key("token_salt"),
1886 "must not expose token_salt"
1887 );
1888 let serialized = serde_json::to_string(&summary).unwrap();
1890 assert!(!serialized.contains(&cred.token_hash));
1891 assert!(!serialized.contains(&cred.token_salt));
1892 assert!(obj.contains_key("device_id"));
1894 assert!(obj.contains_key("label"));
1895 assert!(obj.contains_key("created_at"));
1896 assert!(obj.contains_key("revoked"));
1897 }
1898}