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
446fn is_public_access_route(path: &str) -> bool {
447 matches!(
448 path,
449 "/api/v1/health"
450 | "/healthz"
453 | "/readyz"
454 | "/v1/bamboo/access/status"
455 | "/v1/bamboo/access/verify"
456 | "/v2/pair"
460 | "/v2/stream"
471 )
472}
473
474pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
483 !build_access_status(config, req).requires_password
484 || request_has_verified_access_cookie(req, config)
485 || request_has_valid_device_token(req, config)
486}
487
488pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
489 req: ServiceRequest,
490 next: Next<B>,
491) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
492 let path = req.path().to_string();
493 if is_public_access_route(&path) {
494 return next
495 .call(req)
496 .await
497 .map(ServiceResponse::map_into_left_body);
498 }
499
500 let app_state = match req.app_data::<web::Data<AppState>>() {
501 Some(state) => state.clone(),
502 None => {
503 return next
504 .call(req)
505 .await
506 .map(ServiceResponse::map_into_left_body)
507 }
508 };
509
510 let config = app_state.config.read().await.clone();
511 if request_is_authorized(req.request(), &config) {
518 return next
519 .call(req)
520 .await
521 .map(ServiceResponse::map_into_left_body);
522 }
523
524 let response = AppError::Unauthorized("access credential verification required".to_string())
525 .error_response()
526 .map_into_right_body();
527 Ok(req.into_response(response))
528}
529
530fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
531 let password_enabled = config
532 .access_control
533 .as_ref()
534 .map(|access| {
535 access.password_enabled
536 && access
537 .password_hash
538 .as_deref()
539 .map(|value| !value.trim().is_empty())
540 .unwrap_or(false)
541 && access
542 .password_salt
543 .as_deref()
544 .map(|value| !value.trim().is_empty())
545 .unwrap_or(false)
546 })
547 .unwrap_or(false);
548 let local_bypass = is_local_request(req);
549 let credential_required = password_enabled || has_active_devices(config);
553
554 AccessStatusResponse {
555 password_enabled,
556 local_bypass,
557 requires_password: credential_required && !local_bypass,
558 }
559}
560
561pub async fn get_access_status(
562 req: HttpRequest,
563 app_state: web::Data<AppState>,
564) -> Result<HttpResponse, AppError> {
565 let config = app_state.config.read().await.clone();
566 Ok(HttpResponse::Ok().json(build_access_status(&config, &req)))
567}
568
569pub async fn verify_access_password(
570 req: HttpRequest,
571 payload: web::Json<VerifyPasswordRequest>,
572 app_state: web::Data<AppState>,
573) -> Result<HttpResponse, AppError> {
574 let password = payload.password.trim();
575 if password.is_empty() {
576 return Err(AppError::BadRequest("password is required".to_string()));
577 }
578
579 let throttle_key = root_throttle_key(&req);
583 if let Some(key) = throttle_key.as_deref() {
584 if let RootGuardDecision::Cooldown { retry_after_secs } =
585 app_state.root_password_guard.check(key)
586 {
587 return Ok(too_many_requests_response(retry_after_secs));
588 }
589 }
590
591 let config = app_state.config.read().await.clone();
592 if !verify_password(&config, password) {
593 if let Some(key) = throttle_key.as_deref() {
594 app_state.root_password_guard.record_failure(key);
595 }
596 return Err(AppError::Unauthorized("invalid password".to_string()));
597 }
598
599 if let Some(key) = throttle_key.as_deref() {
601 app_state.root_password_guard.record_success(key);
602 }
603
604 let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
605 let cookie = build_access_verified_cookie(&config, secure)
606 .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
607
608 Ok(HttpResponse::Ok()
609 .cookie(cookie)
610 .json(VerifyPasswordResponse { success: true }))
611}
612
613pub async fn update_access_password(
614 req: HttpRequest,
615 app_state: web::Data<AppState>,
616 payload: web::Json<UpdatePasswordRequest>,
617) -> Result<HttpResponse, AppError> {
618 let local_bypass = is_local_request(&req);
619 let new_password = payload.new_password.trim();
620
621 if new_password.is_empty() {
622 return Err(AppError::BadRequest("new_password is required".to_string()));
623 }
624
625 let current_config = app_state.config.read().await.clone();
626 let password_already_enabled = current_config
627 .access_control
628 .as_ref()
629 .map(|access| access.password_enabled)
630 .unwrap_or(false);
631
632 if password_already_enabled && !local_bypass {
633 let current_password = payload.current_password.trim();
634 if current_password.is_empty() {
635 return Err(AppError::Unauthorized(
636 "current_password is required".to_string(),
637 ));
638 }
639 if !verify_password(¤t_config, current_password) {
640 return Err(AppError::Unauthorized(
641 "invalid current password".to_string(),
642 ));
643 }
644 }
645
646 let mut salt_bytes = [0_u8; 16];
647 rand::rng().fill(&mut salt_bytes);
648 let salt_hex = hex::encode(salt_bytes);
649 let password_hash = compute_password_hash(new_password, &salt_hex).ok_or_else(|| {
650 AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
651 })?;
652 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
653
654 app_state
655 .update_config(
656 move |config| {
657 let access = config.access_control.get_or_insert_with(Default::default);
662 access.password_enabled = true;
663 access.password_hash = Some(password_hash.clone());
664 access.password_salt = Some(salt_hex.clone());
665 access.updated_at = Some(updated_at.clone());
666 Ok(())
667 },
668 ConfigUpdateEffects::default(),
669 )
670 .await?;
671
672 Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
673 success: true,
674 password_enabled: true,
675 }))
676}
677
678#[derive(Debug, Deserialize)]
681pub struct PairDeviceRequest {
682 #[serde(default)]
684 pub root_password: String,
685 #[serde(default)]
689 pub code: String,
690 #[serde(default)]
692 pub label: String,
693}
694
695#[derive(Serialize)]
696pub struct PairDeviceResponse {
697 pub device_id: String,
698 pub device_token: String,
700 pub expires_hint: &'static str,
701}
702
703pub async fn pair_device(
715 req: HttpRequest,
716 payload: web::Json<PairDeviceRequest>,
717 app_state: web::Data<AppState>,
718) -> Result<HttpResponse, AppError> {
719 let label = payload.label.trim();
720 if label.is_empty() {
721 return Err(AppError::BadRequest("label is required".to_string()));
722 }
723
724 let code = payload.code.trim();
725 let root_password = payload.root_password.trim();
726
727 if !code.is_empty() {
730 return pair_device_with_code(&app_state, code, label).await;
731 }
732 if !root_password.is_empty() {
733 return pair_device_with_root_password(&req, &app_state, root_password, label).await;
734 }
735
736 Err(AppError::BadRequest(
737 "provide either a root_password or a one-time pairing code".to_string(),
738 ))
739}
740
741async fn pair_device_with_root_password(
744 req: &HttpRequest,
745 app_state: &AppState,
746 root_password: &str,
747 label: &str,
748) -> Result<HttpResponse, AppError> {
749 let throttle_key = root_throttle_key(req);
753 if let Some(key) = throttle_key.as_deref() {
754 if let RootGuardDecision::Cooldown { retry_after_secs } =
755 app_state.root_password_guard.check(key)
756 {
757 return Ok(too_many_requests_response(retry_after_secs));
758 }
759 }
760
761 let config = app_state.config.read().await.clone();
762
763 let password_enabled = config
764 .access_control
765 .as_ref()
766 .map(|access| access.password_enabled)
767 .unwrap_or(false);
768 if !password_enabled {
769 return Err(AppError::BadRequest(
770 "set an access password first: the owner root password is required to authorize device pairing".to_string(),
771 ));
772 }
773
774 if !verify_password(&config, root_password) {
775 if let Some(key) = throttle_key.as_deref() {
776 app_state.root_password_guard.record_failure(key);
777 }
778 return Err(AppError::Unauthorized("invalid root password".to_string()));
779 }
780
781 if let Some(key) = throttle_key.as_deref() {
783 app_state.root_password_guard.record_success(key);
784 }
785
786 persist_new_device(app_state, label).await
787}
788
789async fn pair_device_with_code(
793 app_state: &AppState,
794 code: &str,
795 label: &str,
796) -> Result<HttpResponse, AppError> {
797 if app_state.pairing_code_guard.in_cooldown() {
800 return Err(AppError::Unauthorized(
801 "too many failed pairing attempts — try again later".to_string(),
802 ));
803 }
804
805 let consumed = app_state.pairing_codes.remove(code);
810 let valid = match consumed {
811 Some((_k, entry)) => !entry.is_expired(),
812 None => false,
813 };
814
815 if !valid {
816 if app_state.pairing_code_guard.record_failure() {
820 app_state.pairing_codes.clear();
821 }
822 return Err(AppError::Unauthorized(
823 "invalid or expired pairing code".to_string(),
824 ));
825 }
826
827 app_state.pairing_code_guard.record_success();
829 persist_new_device(app_state, label).await
830}
831
832async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
836 let (credential, token) = issue_device_token(label);
837 let device_id = credential.device_id.clone();
838
839 app_state
840 .update_config(
841 move |config| {
842 let access = config.access_control.get_or_insert_with(Default::default);
845 access.devices.push(credential.clone());
846 Ok(())
847 },
848 ConfigUpdateEffects::default(),
849 )
850 .await?;
851
852 Ok(HttpResponse::Ok().json(PairDeviceResponse {
855 device_id,
856 device_token: token,
857 expires_hint: "rotate-on-demand",
858 }))
859}
860
861const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
865const PAIRING_FAILURE_THRESHOLD: u32 = 10;
867const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
869
870#[derive(Debug, Clone)]
873pub struct PairingCodeEntry {
874 expires_at: Instant,
875}
876
877impl PairingCodeEntry {
878 pub(crate) fn new(ttl: Duration) -> Self {
879 Self {
880 expires_at: Instant::now() + ttl,
881 }
882 }
883
884 pub fn is_expired(&self) -> bool {
887 Instant::now() >= self.expires_at
888 }
889}
890
891#[derive(Debug, Default)]
912pub struct PairingCodeGuard {
913 inner: Mutex<PairingGuardState>,
914}
915
916#[derive(Debug, Default)]
917struct PairingGuardState {
918 failures: u32,
919 cooldown_until: Option<Instant>,
921}
922
923impl PairingCodeGuard {
924 pub fn in_cooldown(&self) -> bool {
927 let mut state = self.inner.lock().recover_poison();
928 match state.cooldown_until {
929 Some(until) if Instant::now() < until => true,
930 Some(_) => {
931 state.cooldown_until = None;
933 state.failures = 0;
934 false
935 }
936 None => false,
937 }
938 }
939
940 pub fn record_failure(&self) -> bool {
943 let mut state = self.inner.lock().recover_poison();
944 state.failures = state.failures.saturating_add(1);
945 if state.failures >= PAIRING_FAILURE_THRESHOLD {
946 state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
947 true
948 } else {
949 false
950 }
951 }
952
953 pub fn record_success(&self) {
955 let mut state = self.inner.lock().recover_poison();
956 state.failures = 0;
957 state.cooldown_until = None;
958 }
959}
960
961const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
980const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
982const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
988
989#[derive(Debug, Default, Clone)]
991struct RootAttemptState {
992 failures: u32,
993 cooldown_until: Option<Instant>,
995}
996
997#[derive(Debug, Default)]
1017pub struct RootPasswordGuard {
1018 inner: dashmap::DashMap<String, RootAttemptState>,
1019}
1020
1021pub enum RootGuardDecision {
1023 Allow,
1025 Cooldown { retry_after_secs: u64 },
1027}
1028
1029impl RootPasswordGuard {
1030 pub fn check(&self, key: &str) -> RootGuardDecision {
1033 let now = Instant::now();
1034 if let Some(mut entry) = self.inner.get_mut(key) {
1035 if let Some(until) = entry.cooldown_until {
1036 if now < until {
1037 let retry_after_secs = (until - now).as_secs().max(1);
1038 return RootGuardDecision::Cooldown { retry_after_secs };
1039 }
1040 entry.failures = 0;
1042 entry.cooldown_until = None;
1043 }
1044 }
1045 RootGuardDecision::Allow
1046 }
1047
1048 pub fn record_failure(&self, key: &str) {
1051 let now = Instant::now();
1052 if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1056 self.inner
1057 .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1058 }
1059 let mut entry = self.inner.entry(key.to_string()).or_default();
1060 if matches!(entry.cooldown_until, Some(until) if now < until) {
1063 return;
1064 }
1065 if entry.cooldown_until.is_some() {
1067 entry.failures = 0;
1068 entry.cooldown_until = None;
1069 }
1070 entry.failures = entry.failures.saturating_add(1);
1071 if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1072 entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1073 }
1074 }
1075
1076 pub fn record_success(&self, key: &str) {
1078 self.inner.remove(key);
1079 }
1080}
1081
1082fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1089 if is_local_request(req) {
1090 return None;
1091 }
1092 Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1093}
1094
1095fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1098 HttpResponse::TooManyRequests()
1099 .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1100 .json(serde_json::json!({
1101 "error": {
1102 "message": "too many failed password attempts — try again later",
1103 "type": "api_error",
1104 }
1105 }))
1106}
1107
1108fn generate_pairing_code() -> String {
1114 let n = rand::rng().random_range(0..1_000_000);
1115 format!("{n:06}")
1116}
1117
1118fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1120 codes.retain(|_code, entry| !entry.is_expired());
1121}
1122
1123#[derive(Serialize)]
1124pub struct PairingCodeResponse {
1125 pub code: String,
1126 pub ttl: u64,
1128}
1129
1130pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1138 purge_expired_codes(&app_state.pairing_codes);
1140
1141 let code = generate_pairing_code();
1142 let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1143 app_state.pairing_codes.insert(code.clone(), entry);
1145
1146 Ok(HttpResponse::Ok().json(PairingCodeResponse {
1147 code,
1148 ttl: PAIRING_CODE_TTL.as_secs(),
1149 }))
1150}
1151
1152#[derive(Serialize)]
1158pub struct DeviceSummary {
1159 pub device_id: String,
1160 pub label: String,
1161 pub created_at: String,
1162 pub last_used_at: Option<String>,
1163 pub revoked: bool,
1164}
1165
1166impl DeviceSummary {
1167 fn from_credential(d: &DeviceCredential) -> Self {
1168 Self {
1169 device_id: d.device_id.clone(),
1170 label: d.label.clone(),
1171 created_at: d.created_at.clone(),
1172 last_used_at: d.last_used_at.clone(),
1173 revoked: d.revoked,
1174 }
1175 }
1176}
1177
1178pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1181 let config = app_state.config.read().await.clone();
1182 let devices: Vec<DeviceSummary> = config
1183 .access_control
1184 .as_ref()
1185 .map(|access| {
1186 access
1187 .devices
1188 .iter()
1189 .map(DeviceSummary::from_credential)
1190 .collect()
1191 })
1192 .unwrap_or_default();
1193 Ok(HttpResponse::Ok().json(devices))
1194}
1195
1196pub async fn revoke_device(
1203 path: web::Path<String>,
1204 app_state: web::Data<AppState>,
1205) -> Result<HttpResponse, AppError> {
1206 let device_id = path.into_inner();
1207
1208 {
1211 let config = app_state.config.read().await;
1212 let exists = config
1213 .access_control
1214 .as_ref()
1215 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1216 .unwrap_or(false);
1217 if !exists {
1218 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1219 }
1220 }
1221
1222 let target = device_id.clone();
1223 app_state
1224 .update_config(
1225 move |config| {
1226 if let Some(access) = config.access_control.as_mut() {
1227 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1228 {
1229 device.revoked = true;
1230 }
1231 }
1232 Ok(())
1233 },
1234 ConfigUpdateEffects::default(),
1235 )
1236 .await?;
1237
1238 Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1239}
1240
1241pub async fn rotate_device(
1249 path: web::Path<String>,
1250 app_state: web::Data<AppState>,
1251) -> Result<HttpResponse, AppError> {
1252 let device_id = path.into_inner();
1253
1254 {
1257 let config = app_state.config.read().await;
1258 let exists = config
1259 .access_control
1260 .as_ref()
1261 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1262 .unwrap_or(false);
1263 if !exists {
1264 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1265 }
1266 }
1267
1268 let (fresh, token) = issue_device_token("");
1271
1272 let target = device_id.clone();
1273 app_state
1274 .update_config(
1275 move |config| {
1276 if let Some(access) = config.access_control.as_mut() {
1277 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1278 {
1279 device.token_hash = fresh.token_hash.clone();
1280 device.token_salt = fresh.token_salt.clone();
1281 device.revoked = false;
1282 device.last_used_at = None;
1283 }
1284 }
1285 Ok(())
1286 },
1287 ConfigUpdateEffects::default(),
1288 )
1289 .await?;
1290
1291 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1293 device_id,
1294 device_token: token,
1295 expires_hint: "rotate-on-demand",
1296 }))
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use actix_web::test::TestRequest;
1303 use bamboo_config::AccessControlConfig;
1304
1305 #[test]
1306 fn loopback_request_is_local() {
1307 let req = TestRequest::default()
1308 .peer_addr("127.0.0.1:12345".parse().unwrap())
1309 .insert_header((header::HOST, "localhost:9562"))
1310 .to_http_request();
1311 assert!(is_local_request(&req));
1312 }
1313
1314 #[test]
1315 fn private_lan_host_is_local() {
1316 let req = TestRequest::default()
1317 .insert_header((header::HOST, "192.168.0.10:9562"))
1318 .to_http_request();
1319 assert!(is_local_request(&req));
1320 }
1321
1322 #[test]
1323 fn remote_host_is_not_local_even_when_peer_is_loopback() {
1324 let req = TestRequest::default()
1325 .peer_addr("127.0.0.1:12345".parse().unwrap())
1326 .insert_header((header::HOST, "bamboo.example.com"))
1327 .to_http_request();
1328 assert!(!is_local_request(&req));
1329 }
1330
1331 #[test]
1332 fn spoofed_local_host_from_remote_peer_is_not_local() {
1333 for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
1337 let req = TestRequest::default()
1338 .peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
1340 .to_http_request();
1341 assert!(
1342 !is_local_request(&req),
1343 "remote peer + spoofed Host '{spoof}' must not be local"
1344 );
1345 let req2 = TestRequest::default()
1347 .peer_addr("203.0.113.5:40000".parse().unwrap())
1348 .insert_header(("x-forwarded-host", spoof))
1349 .to_http_request();
1350 assert!(
1351 !is_local_request(&req2),
1352 "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
1353 );
1354 }
1355 }
1356
1357 #[test]
1358 fn loopback_peer_with_no_host_is_local() {
1359 let req = TestRequest::default()
1360 .peer_addr("127.0.0.1:5000".parse().unwrap())
1361 .to_http_request();
1362 assert!(is_local_request(&req));
1363 }
1364
1365 #[test]
1366 fn password_hash_roundtrip_verifies() {
1367 let salt_hex = hex::encode([1_u8; 16]);
1368 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1369 let config = Config {
1370 access_control: Some(AccessControlConfig {
1371 password_enabled: true,
1372 password_hash: Some(hash),
1373 password_salt: Some(salt_hex),
1374 updated_at: None,
1375 devices: Vec::new(),
1376 }),
1377 ..Config::default()
1378 };
1379
1380 assert!(verify_password(&config, "secret"));
1381 assert!(!verify_password(&config, "wrong"));
1382 }
1383
1384 fn config_with_password() -> Config {
1387 let salt_hex = hex::encode([1_u8; 16]);
1388 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1389 Config {
1390 access_control: Some(AccessControlConfig {
1391 password_enabled: true,
1392 password_hash: Some(hash),
1393 password_salt: Some(salt_hex),
1394 updated_at: None,
1395 devices: Vec::new(),
1396 }),
1397 ..Config::default()
1398 }
1399 }
1400
1401 #[test]
1402 fn constant_time_eq_matches_and_rejects() {
1403 assert!(constant_time_eq(b"abcd", b"abcd"));
1404 assert!(!constant_time_eq(b"abcd", b"abce"));
1405 assert!(!constant_time_eq(b"abc", b"abcd"));
1406 }
1407
1408 #[test]
1409 fn issued_token_has_expected_format_and_verifies() {
1410 let (cred, token) = issue_device_token("iPhone 15");
1411 assert!(token.starts_with("bd1_"));
1412 assert_eq!(token.len(), "bd1_".len() + 32);
1413 assert!(cred.device_id.starts_with("bamboo_"));
1414 assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
1415 assert_eq!(cred.label, "iPhone 15");
1416 assert!(!cred.revoked);
1417 assert_ne!(cred.token_hash, token);
1419
1420 let mut config = config_with_password();
1421 config
1422 .access_control
1423 .as_mut()
1424 .unwrap()
1425 .devices
1426 .push(cred.clone());
1427
1428 assert!(verify_device_token(&config, &cred.device_id, &token));
1429 assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
1430 assert!(!verify_device_token(&config, "bamboo_unknown", &token));
1431 }
1432
1433 #[test]
1434 fn revoked_token_is_rejected() {
1435 let (mut cred, token) = issue_device_token("iPad");
1436 cred.revoked = true;
1437 let mut config = config_with_password();
1438 let device_id = cred.device_id.clone();
1439 config.access_control.as_mut().unwrap().devices.push(cred);
1440 assert!(!verify_device_token(&config, &device_id, &token));
1441 }
1442
1443 #[test]
1444 fn has_active_devices_ignores_revoked() {
1445 let mut config = config_with_password();
1446 assert!(!has_active_devices(&config));
1447 let (mut cred, _t) = issue_device_token("d");
1448 cred.revoked = true;
1449 config
1450 .access_control
1451 .as_mut()
1452 .unwrap()
1453 .devices
1454 .push(cred.clone());
1455 assert!(!has_active_devices(&config));
1456 let (cred2, _t2) = issue_device_token("d2");
1457 config.access_control.as_mut().unwrap().devices.push(cred2);
1458 assert!(has_active_devices(&config));
1459 }
1460
1461 fn remote_req() -> HttpRequest {
1462 TestRequest::default()
1463 .insert_header((header::HOST, "bamboo.example.com"))
1464 .to_http_request()
1465 }
1466
1467 fn local_req() -> HttpRequest {
1468 TestRequest::default()
1469 .insert_header((header::HOST, "localhost:9562"))
1470 .to_http_request()
1471 }
1472
1473 #[test]
1474 fn no_devices_no_password_does_not_require_credential() {
1475 let config = Config::default();
1478 assert!(!build_access_status(&config, &remote_req()).requires_password);
1479 }
1480
1481 #[test]
1482 fn password_only_gate_matches_prior_behavior() {
1483 let config = config_with_password();
1484 assert!(build_access_status(&config, &remote_req()).requires_password);
1485 assert!(!build_access_status(&config, &local_req()).requires_password);
1486 }
1487
1488 #[test]
1489 fn device_presence_requires_credential_even_without_password() {
1490 let (cred, _t) = issue_device_token("d");
1492 let config = Config {
1493 access_control: Some(AccessControlConfig {
1494 password_enabled: false,
1495 password_hash: None,
1496 password_salt: None,
1497 updated_at: None,
1498 devices: vec![cred],
1499 }),
1500 ..Config::default()
1501 };
1502 assert!(build_access_status(&config, &remote_req()).requires_password);
1503 assert!(!build_access_status(&config, &local_req()).requires_password);
1505 }
1506
1507 #[test]
1508 fn valid_device_token_on_request_authenticates() {
1509 let (cred, token) = issue_device_token("d");
1510 let device_id = cred.device_id.clone();
1511 let mut config = config_with_password();
1512 config.access_control.as_mut().unwrap().devices.push(cred);
1513
1514 let req = TestRequest::default()
1515 .insert_header((header::HOST, "bamboo.example.com"))
1516 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1517 .insert_header((DEVICE_ID_HEADER, device_id))
1518 .to_http_request();
1519 assert!(request_has_valid_device_token(&req, &config));
1520
1521 let bad = TestRequest::default()
1523 .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
1524 .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
1525 .to_http_request();
1526 assert!(!request_has_valid_device_token(&bad, &config));
1527
1528 let no_id = TestRequest::default()
1530 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1531 .to_http_request();
1532 assert!(!request_has_valid_device_token(&no_id, &config));
1533 }
1534
1535 #[test]
1542 fn request_is_authorized_local_is_always_allowed() {
1543 let config = config_with_password();
1545 assert!(request_is_authorized(&local_req(), &config));
1546 }
1547
1548 #[test]
1549 fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
1550 let (cred, _t) = issue_device_token("d");
1552 let config = Config {
1553 access_control: Some(AccessControlConfig {
1554 password_enabled: false,
1555 password_hash: None,
1556 password_salt: None,
1557 updated_at: None,
1558 devices: vec![cred],
1559 }),
1560 ..Config::default()
1561 };
1562 assert!(!request_is_authorized(&remote_req(), &config));
1563 }
1564
1565 #[test]
1566 fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
1567 let config = config_with_password();
1568 assert!(!request_is_authorized(&remote_req(), &config));
1569 }
1570
1571 #[test]
1572 fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
1573 let config = config_with_password();
1574 let cookie_value =
1575 access_verification_cookie_value(&config).expect("password config yields a cookie");
1576 let req = TestRequest::default()
1577 .insert_header((header::HOST, "bamboo.example.com"))
1578 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
1579 .to_http_request();
1580 assert!(request_is_authorized(&req, &config));
1581 }
1582
1583 #[test]
1584 fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
1585 let (cred, token) = issue_device_token("d");
1586 let device_id = cred.device_id.clone();
1587 let mut config = config_with_password();
1588 config.access_control.as_mut().unwrap().devices.push(cred);
1589
1590 let req = TestRequest::default()
1591 .insert_header((header::HOST, "bamboo.example.com"))
1592 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1593 .insert_header((DEVICE_ID_HEADER, device_id))
1594 .to_http_request();
1595 assert!(request_is_authorized(&req, &config));
1596 }
1597
1598 #[test]
1599 fn request_is_authorized_no_password_no_devices_is_open() {
1600 let config = Config::default();
1603 assert!(request_is_authorized(&remote_req(), &config));
1604 }
1605
1606 #[test]
1607 fn stream_is_public_but_sibling_routes_are_not() {
1608 assert!(is_public_access_route("/v2/stream"));
1610 assert!(is_public_access_route("/v2/pair"));
1611 assert!(!is_public_access_route("/v2/pair/code"));
1612 assert!(!is_public_access_route("/v2/devices"));
1613 assert!(!is_public_access_route("/v2/devices/bamboo_x"));
1614 }
1615
1616 #[test]
1617 fn health_probes_are_public() {
1618 assert!(is_public_access_route("/healthz"));
1621 assert!(is_public_access_route("/readyz"));
1622 assert!(is_public_access_route("/api/v1/health"));
1623 }
1624
1625 #[test]
1628 fn generated_pairing_code_is_six_digits() {
1629 for _ in 0..1000 {
1630 let code = generate_pairing_code();
1631 assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
1632 assert!(
1633 code.chars().all(|c| c.is_ascii_digit()),
1634 "code {code:?} must be all digits"
1635 );
1636 }
1637 }
1638
1639 #[test]
1640 fn pairing_code_expiry_predicate() {
1641 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
1643 assert!(!fresh.is_expired());
1644
1645 let zero = PairingCodeEntry::new(Duration::from_secs(0));
1647 assert!(zero.is_expired());
1648
1649 let past = PairingCodeEntry {
1651 expires_at: Instant::now() - Duration::from_secs(1),
1652 };
1653 assert!(past.is_expired());
1654 }
1655
1656 #[test]
1657 fn purge_expired_codes_drops_only_expired() {
1658 let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
1659 codes.insert(
1660 "live".into(),
1661 PairingCodeEntry::new(Duration::from_secs(120)),
1662 );
1663 codes.insert(
1664 "dead".into(),
1665 PairingCodeEntry {
1666 expires_at: Instant::now() - Duration::from_secs(1),
1667 },
1668 );
1669 purge_expired_codes(&codes);
1670 assert!(codes.contains_key("live"));
1671 assert!(!codes.contains_key("dead"));
1672 }
1673
1674 #[test]
1675 fn guard_trips_cooldown_after_threshold() {
1676 let guard = PairingCodeGuard::default();
1677 assert!(!guard.in_cooldown());
1678 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1680 assert!(!guard.record_failure());
1681 assert!(!guard.in_cooldown());
1682 }
1683 assert!(guard.record_failure());
1685 assert!(guard.in_cooldown());
1686 }
1687
1688 #[test]
1689 fn guard_success_resets_failures() {
1690 let guard = PairingCodeGuard::default();
1691 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1692 guard.record_failure();
1693 }
1694 guard.record_success();
1695 assert!(!guard.record_failure());
1697 assert!(!guard.in_cooldown());
1698 }
1699
1700 #[test]
1701 fn guard_clears_elapsed_cooldown() {
1702 let guard = PairingCodeGuard::default();
1703 {
1705 let mut state = guard.inner.lock().unwrap();
1706 state.failures = PAIRING_FAILURE_THRESHOLD;
1707 state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
1708 }
1709 assert!(!guard.in_cooldown());
1711 assert!(!guard.record_failure(), "counter was reset to 0");
1712 }
1713
1714 #[test]
1717 fn root_guard_trips_cooldown_after_threshold_per_key() {
1718 let guard = RootPasswordGuard::default();
1719 let key = "203.0.113.7";
1720 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1722 guard.record_failure(key);
1723 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1724 }
1725 guard.record_failure(key);
1727 match guard.check(key) {
1728 RootGuardDecision::Cooldown { retry_after_secs } => {
1729 assert!(retry_after_secs >= 1);
1730 assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
1731 }
1732 RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
1733 }
1734 }
1735
1736 #[test]
1737 fn root_guard_keys_are_independent() {
1738 let guard = RootPasswordGuard::default();
1740 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1741 guard.record_failure("198.51.100.1");
1742 }
1743 assert!(matches!(
1744 guard.check("198.51.100.1"),
1745 RootGuardDecision::Cooldown { .. }
1746 ));
1747 assert!(matches!(
1749 guard.check("198.51.100.2"),
1750 RootGuardDecision::Allow
1751 ));
1752 }
1753
1754 #[test]
1755 fn root_guard_success_resets_key() {
1756 let guard = RootPasswordGuard::default();
1757 let key = "203.0.113.9";
1758 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1759 guard.record_failure(key);
1760 }
1761 guard.record_success(key);
1762 guard.record_failure(key);
1764 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1765 }
1766
1767 #[test]
1768 fn root_guard_clears_elapsed_cooldown() {
1769 let guard = RootPasswordGuard::default();
1770 let key = "203.0.113.10";
1771 guard.inner.insert(
1773 key.to_string(),
1774 RootAttemptState {
1775 failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
1776 cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
1777 },
1778 );
1779 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1781 guard.record_failure(key);
1783 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1784 }
1785
1786 #[test]
1787 fn root_guard_evicts_inert_keys_past_the_cap() {
1788 let guard = RootPasswordGuard::default();
1789 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1792 guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
1793 }
1794 assert!(
1795 guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
1796 "inert keys must be swept so the map stays bounded (was {})",
1797 guard.inner.len()
1798 );
1799 let hot = "203.0.113.200";
1801 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1802 guard.record_failure(hot);
1803 }
1804 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1805 guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
1806 }
1807 assert!(
1808 matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
1809 "a key in active cooldown must survive eviction sweeps"
1810 );
1811 }
1812
1813 #[test]
1814 fn root_throttle_key_exempts_loopback_and_keys_remote() {
1815 assert!(root_throttle_key(&local_req()).is_none());
1817
1818 let remote = TestRequest::default()
1820 .peer_addr("203.0.113.5:443".parse().unwrap())
1821 .insert_header((header::HOST, "bamboo.example.com"))
1822 .to_http_request();
1823 assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
1824 }
1825
1826 #[test]
1827 fn client_ip_key_strips_v4_mapped_prefix() {
1828 let req = TestRequest::default()
1829 .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
1830 .to_http_request();
1831 assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
1832 }
1833
1834 #[test]
1835 fn device_summary_excludes_secret_material() {
1836 let (cred, _t) = issue_device_token("iPhone");
1839 let summary = DeviceSummary::from_credential(&cred);
1840 let json = serde_json::to_value(&summary).unwrap();
1841 let obj = json.as_object().unwrap();
1842 assert!(
1843 !obj.contains_key("token_hash"),
1844 "must not expose token_hash"
1845 );
1846 assert!(
1847 !obj.contains_key("token_salt"),
1848 "must not expose token_salt"
1849 );
1850 let serialized = serde_json::to_string(&summary).unwrap();
1852 assert!(!serialized.contains(&cred.token_hash));
1853 assert!(!serialized.contains(&cred.token_salt));
1854 assert!(obj.contains_key("device_id"));
1856 assert!(obj.contains_key("label"));
1857 assert!(obj.contains_key("created_at"));
1858 assert!(obj.contains_key("revoked"));
1859 }
1860}