1use std::collections::BTreeSet;
2use std::net::IpAddr;
3use std::sync::Mutex;
4use std::time::{Duration, Instant};
5
6use bamboo_domain::poison::PoisonRecover;
7
8use actix_web::{
9 body::{EitherBody, MessageBody},
10 cookie::{time::Duration as CookieDuration, Cookie, SameSite},
11 dev::{ServiceRequest, ServiceResponse},
12 http::header,
13 middleware::Next,
14 web, HttpMessage, HttpRequest, HttpResponse, ResponseError,
15};
16use chrono::{SecondsFormat, Utc};
17use rand::RngExt;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::{app_state::AppState, error::AppError};
22use bamboo_config::{Config, DeviceCredential};
23
24fn credential_revision(app_state: &AppState) -> Result<u64, AppError> {
25 bamboo_config::CredentialStore::open(&app_state.app_data_dir)
26 .revision()
27 .map_err(|error| {
28 AppError::InternalError(anyhow::anyhow!(
29 "access-control credential revision unavailable: {error}"
30 ))
31 })
32}
33
34#[derive(Serialize)]
35pub struct AccessStatusResponse {
36 pub password_enabled: bool,
37 pub local_bypass: bool,
38 pub requires_password: bool,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct VerifyPasswordRequest {
43 pub password: String,
44}
45
46#[derive(Serialize)]
47pub struct VerifyPasswordResponse {
48 pub success: bool,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct UpdatePasswordRequest {
53 #[serde(default)]
54 pub current_password: String,
55 #[serde(default)]
56 pub new_password: String,
57}
58
59#[derive(Serialize)]
60pub struct UpdatePasswordResponse {
61 pub success: bool,
62 pub password_enabled: bool,
63}
64
65const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
66const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
67const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
68
69fn normalize_ip(ip: &str) -> &str {
70 let ip = ip.trim();
71 ip.strip_prefix("::ffff:").unwrap_or(ip)
72}
73
74fn split_host_and_port(value: &str) -> &str {
75 let candidate = value.trim();
76 if candidate.is_empty() {
77 return candidate;
78 }
79
80 let without_brackets = candidate
81 .strip_prefix('[')
82 .and_then(|v| v.strip_suffix(']'))
83 .unwrap_or(candidate);
84
85 if without_brackets.parse::<IpAddr>().is_ok() {
86 return without_brackets;
87 }
88
89 without_brackets
90 .split(':')
91 .next()
92 .unwrap_or(without_brackets)
93 .trim()
94}
95
96fn is_local_host(host: &str) -> bool {
97 let normalized = split_host_and_port(host)
98 .trim()
99 .trim_end_matches('.')
100 .to_lowercase();
101 if normalized.is_empty() {
102 return false;
103 }
104
105 if normalized == "localhost" || normalized.ends_with(".local") {
106 return true;
107 }
108
109 let normalized = normalize_ip(&normalized);
110 match normalized.parse::<IpAddr>() {
111 Ok(IpAddr::V4(v4)) => {
112 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
113 }
114 Ok(IpAddr::V6(v6)) => {
115 v6.is_loopback()
116 || v6.is_unique_local()
117 || v6.is_unicast_link_local()
118 || v6.is_unspecified()
119 }
120 Err(_) => false,
121 }
122}
123
124fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
125 let mut candidates = Vec::new();
126
127 for header_name in [
128 header::HOST,
129 header::HeaderName::from_static("x-forwarded-host"),
130 header::HeaderName::from_static("x-original-host"),
131 ] {
132 if let Some(value) = req
133 .headers()
134 .get(&header_name)
135 .and_then(|v| v.to_str().ok())
136 {
137 for part in value.split(',') {
138 let host = part.trim();
139 if !host.is_empty() {
140 candidates.push(host.to_string());
141 }
142 }
143 }
144 }
145
146 if let Some(uri_host) = req.uri().host() {
147 let host = uri_host.trim();
148 if !host.is_empty() {
149 candidates.push(host.to_string());
150 }
151 }
152
153 candidates
154}
155
156fn is_local_request(req: &HttpRequest) -> bool {
157 let peer_local: Option<bool> = req
168 .peer_addr()
169 .map(|peer| is_local_host(&peer.ip().to_string()));
170
171 let host_candidates = request_host_candidates(req);
172 if !host_candidates.is_empty() {
173 let host_local = host_candidates.iter().all(|host| is_local_host(host));
174 return host_local && peer_local != Some(false);
194 }
195
196 if let Some(local) = peer_local {
198 return local;
199 }
200 let conn = req.connection_info();
201 conn.peer_addr().map(is_local_host).unwrap_or(false)
202}
203
204fn client_ip_key(req: &HttpRequest) -> Option<String> {
222 if let Some(peer) = req.peer_addr() {
223 return Some(normalize_ip(&peer.ip().to_string()).to_string());
224 }
225
226 let conn = req.connection_info();
227 for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
228 .into_iter()
229 .flatten()
230 {
231 let normalized = normalize_ip(candidate).trim();
232 if !normalized.is_empty() {
233 return Some(normalized.to_string());
234 }
235 }
236
237 None
238}
239
240fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
241 let salt = hex::decode(salt_hex).ok()?;
242 let mut hasher = Sha256::new();
243 hasher.update(&salt);
244 hasher.update(password.as_bytes());
245 Some(hex::encode(hasher.finalize()))
246}
247
248fn verify_password(config: &Config, password: &str) -> bool {
249 let Some(access) = config.access_control.as_ref() else {
250 return false;
251 };
252 if !access.password_enabled {
253 return false;
254 }
255
256 let (Some(hash), Some(salt)) = (
257 access.password_hash.as_deref(),
258 access.password_salt.as_deref(),
259 ) else {
260 return false;
261 };
262
263 compute_password_hash(password, salt)
264 .map(|computed| computed == hash)
265 .unwrap_or(false)
266}
267
268const DEVICE_TOKEN_PREFIX: &str = "bd1_";
277const DEVICE_ID_PREFIX: &str = "bamboo_";
279const DEVICE_ID_HEADER: &str = "x-device-id";
282
283fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
290 if a.len() != b.len() {
291 return false;
292 }
293 let mut diff: u8 = 0;
294 for (x, y) in a.iter().zip(b.iter()) {
295 diff |= x ^ y;
296 }
297 diff == 0
298}
299
300fn random_hex(len: usize) -> String {
302 let mut bytes = vec![0_u8; len];
303 rand::rng().fill(&mut bytes);
304 hex::encode(bytes)
305}
306
307pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
313 let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
314 let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
315 let salt_hex = random_hex(16);
316 let token_hash =
320 compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
321 let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
322
323 let credential = DeviceCredential {
324 device_id,
325 label: label.to_string(),
326 token_hash,
327 token_salt: salt_hex,
328 token_credential_ref: None,
329 token_configured: false,
330 created_at,
331 last_used_at: None,
332 revoked: false,
333 };
334 (credential, token)
335}
336
337pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
342 let Some(access) = config.access_control.as_ref() else {
343 return false;
344 };
345 let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
348 return false;
349 };
350 if device.revoked {
351 return false;
352 }
353 let Some(computed) = compute_password_hash(token, &device.token_salt) else {
354 return false;
355 };
356 constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
357}
358
359fn has_active_devices(config: &Config) -> bool {
363 config
364 .access_control
365 .as_ref()
366 .map(|access| access.devices.iter().any(|d| !d.revoked))
367 .unwrap_or(false)
368}
369
370fn presented_bearer_token(req: &HttpRequest) -> Option<&str> {
378 let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
379 Some(
380 auth.strip_prefix("Bearer ")
381 .or_else(|| auth.strip_prefix("bearer "))?
382 .trim(),
383 )
384}
385
386fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
387 let token = presented_bearer_token(req)?;
388 if !token.starts_with(DEVICE_TOKEN_PREFIX) {
389 return None;
390 }
391 let device_id = req
392 .headers()
393 .get(DEVICE_ID_HEADER)?
394 .to_str()
395 .ok()?
396 .trim()
397 .to_string();
398 if device_id.is_empty() {
399 return None;
400 }
401 Some((device_id, token.to_string()))
402}
403
404fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
406 match presented_device_token(req) {
407 Some((device_id, token)) => verify_device_token(config, &device_id, &token),
408 None => false,
409 }
410}
411
412fn access_verification_cookie_value(config: &Config) -> Option<String> {
413 let access = config.access_control.as_ref()?;
414 if !access.password_enabled {
415 return None;
416 }
417
418 let hash = access.password_hash.as_deref()?.trim();
419 let salt = access.password_salt.as_deref()?.trim();
420 if hash.is_empty() || salt.is_empty() {
421 return None;
422 }
423
424 let mut hasher = Sha256::new();
425 hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
426 hasher.update(b":");
427 hasher.update(hash.as_bytes());
428 hasher.update(b":");
429 hasher.update(salt.as_bytes());
430 Some(format!(
431 "{}:{}",
432 ACCESS_VERIFIED_COOKIE_VERSION,
433 hex::encode(hasher.finalize())
434 ))
435}
436
437fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
438 let expected = match access_verification_cookie_value(config) {
439 Some(value) => value,
440 None => return false,
441 };
442
443 req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
444 .map(|cookie| cookie.value() == expected)
445 .unwrap_or(false)
446}
447
448fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
449 let value = access_verification_cookie_value(config)?;
450 Some(
451 Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
452 .path("/")
453 .http_only(true)
454 .same_site(SameSite::Lax)
455 .secure(secure)
456 .max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
457 .finish(),
458 )
459}
460
461const PUBLIC_VERSIONED_SUFFIXES: &[&str] =
468 &["/health", "/bamboo/access/status", "/bamboo/access/verify"];
469
470fn is_public_access_route(path: &str) -> bool {
471 for prefix in ["/api/v1", "/v1"] {
472 if let Some(suffix) = path.strip_prefix(prefix) {
473 if PUBLIC_VERSIONED_SUFFIXES.contains(&suffix) {
474 return true;
475 }
476 }
477 }
478
479 matches!(
480 path,
481 "/healthz"
484 | "/readyz"
485 | "/v2/pair"
489 | "/v2/stream"
500 )
501}
502
503pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
512 !build_access_status(config, req).requires_password
513 || request_has_verified_access_cookie(req, config)
514 || request_has_valid_device_token(req, config)
515}
516
517pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
518 req: ServiceRequest,
519 next: Next<B>,
520) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
521 let path = req.path().to_string();
522 if is_public_access_route(&path) {
523 return next
524 .call(req)
525 .await
526 .map(ServiceResponse::map_into_left_body);
527 }
528
529 let app_state = match req.app_data::<web::Data<AppState>>() {
530 Some(state) => state.clone(),
531 None => {
532 return next
533 .call(req)
534 .await
535 .map(ServiceResponse::map_into_left_body)
536 }
537 };
538
539 if let Some(token) = presented_bearer_token(req.request()) {
544 if token.starts_with(crate::codex_run_tokens::CODEX_RUN_TOKEN_PREFIX) {
545 let scoped_path = matches!(path.as_str(), "/openai/v1/responses" | "/openai/v1/models");
546 if scoped_path {
547 if let Some(context) = app_state.codex_run_tokens.verify(token) {
548 req.extensions_mut().insert(context);
549 return next
550 .call(req)
551 .await
552 .map(ServiceResponse::map_into_left_body);
553 }
554 }
555 let response = AppError::Unauthorized(
556 "invalid, expired, or out-of-scope Codex run credential".to_string(),
557 )
558 .error_response()
559 .map_into_right_body();
560 return Ok(req.into_response(response));
561 }
562 }
563
564 let config = app_state.config.read().await.clone();
565 if request_is_authorized(req.request(), &config) {
572 return next
573 .call(req)
574 .await
575 .map(ServiceResponse::map_into_left_body);
576 }
577
578 let response = AppError::Unauthorized("access credential verification required".to_string())
579 .error_response()
580 .map_into_right_body();
581 Ok(req.into_response(response))
582}
583
584fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
585 let password_enabled = config
586 .access_control
587 .as_ref()
588 .map(|access| {
589 access.password_enabled
590 && access
591 .password_hash
592 .as_deref()
593 .map(|value| !value.trim().is_empty())
594 .unwrap_or(false)
595 && access
596 .password_salt
597 .as_deref()
598 .map(|value| !value.trim().is_empty())
599 .unwrap_or(false)
600 })
601 .unwrap_or(false);
602 let local_bypass = is_local_request(req);
603 let credential_required = password_enabled || has_active_devices(config);
607
608 AccessStatusResponse {
609 password_enabled,
610 local_bypass,
611 requires_password: credential_required && !local_bypass,
612 }
613}
614
615pub async fn get_access_status(
616 req: HttpRequest,
617 app_state: web::Data<AppState>,
618) -> Result<HttpResponse, AppError> {
619 let config = app_state.config.read().await.clone();
620 Ok(HttpResponse::Ok().json(build_access_status(&config, &req)))
621}
622
623pub async fn verify_access_password(
624 req: HttpRequest,
625 payload: web::Json<VerifyPasswordRequest>,
626 app_state: web::Data<AppState>,
627) -> Result<HttpResponse, AppError> {
628 let password = payload.password.trim();
629 if password.is_empty() {
630 return Err(AppError::BadRequest("password is required".to_string()));
631 }
632
633 let throttle_key = root_throttle_key(&req);
637 if let Some(key) = throttle_key.as_deref() {
638 if let RootGuardDecision::Cooldown { retry_after_secs } =
639 app_state.root_password_guard.check(key)
640 {
641 return Ok(too_many_requests_response(retry_after_secs));
642 }
643 }
644
645 let config = app_state.config.read().await.clone();
646 if !verify_password(&config, password) {
647 if let Some(key) = throttle_key.as_deref() {
648 app_state.root_password_guard.record_failure(key);
649 }
650 return Err(AppError::Unauthorized("invalid password".to_string()));
651 }
652
653 if let Some(key) = throttle_key.as_deref() {
655 app_state.root_password_guard.record_success(key);
656 }
657
658 let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
659 let cookie = build_access_verified_cookie(&config, secure)
660 .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
661
662 Ok(HttpResponse::Ok()
663 .cookie(cookie)
664 .json(VerifyPasswordResponse { success: true }))
665}
666
667pub async fn update_access_password(
668 req: HttpRequest,
669 app_state: web::Data<AppState>,
670 payload: web::Json<UpdatePasswordRequest>,
671) -> Result<HttpResponse, AppError> {
672 let local_bypass = is_local_request(&req);
673 let new_password = payload.new_password.trim();
674
675 if new_password.is_empty() {
676 return Err(AppError::BadRequest("new_password is required".to_string()));
677 }
678
679 let current_config = app_state.config.read().await.clone();
680 let password_already_enabled = current_config
681 .access_control
682 .as_ref()
683 .map(|access| access.password_enabled)
684 .unwrap_or(false);
685
686 if password_already_enabled && !local_bypass {
687 let current_password = payload.current_password.trim();
688 if current_password.is_empty() {
689 return Err(AppError::Unauthorized(
690 "current_password is required".to_string(),
691 ));
692 }
693 if !verify_password(¤t_config, current_password) {
694 return Err(AppError::Unauthorized(
695 "invalid current password".to_string(),
696 ));
697 }
698 }
699
700 let mut salt_bytes = [0_u8; 16];
701 rand::rng().fill(&mut salt_bytes);
702 let salt_hex = hex::encode(salt_bytes);
703 let password_hash = compute_password_hash(new_password, &salt_hex).ok_or_else(|| {
704 AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
705 })?;
706 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
707
708 let expected_revision = credential_revision(&app_state)?;
709 app_state
710 .update_access_control_credentials(
711 expected_revision,
712 true,
713 BTreeSet::new(),
714 move |config| {
715 let access = config.access_control.get_or_insert_with(Default::default);
720 access.password_enabled = true;
721 access.password_hash = Some(password_hash.clone());
722 access.password_salt = Some(salt_hex.clone());
723 access.updated_at = Some(updated_at.clone());
724 Ok(())
725 },
726 )
727 .await?;
728
729 Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
730 success: true,
731 password_enabled: true,
732 }))
733}
734
735#[derive(Debug, Deserialize)]
738pub struct PairDeviceRequest {
739 #[serde(default)]
741 pub root_password: String,
742 #[serde(default)]
746 pub code: String,
747 #[serde(default)]
749 pub label: String,
750}
751
752#[derive(Serialize)]
753pub struct PairDeviceResponse {
754 pub device_id: String,
755 pub device_token: String,
757 pub expires_hint: &'static str,
758}
759
760pub async fn pair_device(
772 req: HttpRequest,
773 payload: web::Json<PairDeviceRequest>,
774 app_state: web::Data<AppState>,
775) -> Result<HttpResponse, AppError> {
776 let label = payload.label.trim();
777 if label.is_empty() {
778 return Err(AppError::BadRequest("label is required".to_string()));
779 }
780
781 let code = payload.code.trim();
782 let root_password = payload.root_password.trim();
783
784 if !code.is_empty() {
787 return pair_device_with_code(&app_state, code, label).await;
788 }
789 if !root_password.is_empty() {
790 return pair_device_with_root_password(&req, &app_state, root_password, label).await;
791 }
792
793 Err(AppError::BadRequest(
794 "provide either a root_password or a one-time pairing code".to_string(),
795 ))
796}
797
798async fn pair_device_with_root_password(
801 req: &HttpRequest,
802 app_state: &AppState,
803 root_password: &str,
804 label: &str,
805) -> Result<HttpResponse, AppError> {
806 let throttle_key = root_throttle_key(req);
810 if let Some(key) = throttle_key.as_deref() {
811 if let RootGuardDecision::Cooldown { retry_after_secs } =
812 app_state.root_password_guard.check(key)
813 {
814 return Ok(too_many_requests_response(retry_after_secs));
815 }
816 }
817
818 let config = app_state.config.read().await.clone();
819
820 let password_enabled = config
821 .access_control
822 .as_ref()
823 .map(|access| access.password_enabled)
824 .unwrap_or(false);
825 if !password_enabled {
826 return Err(AppError::BadRequest(
827 "set an access password first: the owner root password is required to authorize device pairing".to_string(),
828 ));
829 }
830
831 if !verify_password(&config, root_password) {
832 if let Some(key) = throttle_key.as_deref() {
833 app_state.root_password_guard.record_failure(key);
834 }
835 return Err(AppError::Unauthorized("invalid root password".to_string()));
836 }
837
838 if let Some(key) = throttle_key.as_deref() {
840 app_state.root_password_guard.record_success(key);
841 }
842
843 persist_new_device(app_state, label).await
844}
845
846async fn pair_device_with_code(
850 app_state: &AppState,
851 code: &str,
852 label: &str,
853) -> Result<HttpResponse, AppError> {
854 if app_state.pairing_code_guard.in_cooldown() {
857 return Err(AppError::Unauthorized(
858 "too many failed pairing attempts — try again later".to_string(),
859 ));
860 }
861
862 let consumed = app_state.pairing_codes.remove(code);
867 let valid = match consumed {
868 Some((_k, entry)) => !entry.is_expired(),
869 None => false,
870 };
871
872 if !valid {
873 if app_state.pairing_code_guard.record_failure() {
877 app_state.pairing_codes.clear();
878 }
879 return Err(AppError::Unauthorized(
880 "invalid or expired pairing code".to_string(),
881 ));
882 }
883
884 app_state.pairing_code_guard.record_success();
886 persist_new_device(app_state, label).await
887}
888
889async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
893 let (credential, token) = issue_device_token(label);
894 let device_id = credential.device_id.clone();
895
896 let expected_revision = credential_revision(app_state)?;
897 app_state
898 .update_access_control_credentials(
899 expected_revision,
900 false,
901 BTreeSet::from([device_id.clone()]),
902 move |config| {
903 let access = config.access_control.get_or_insert_with(Default::default);
906 access.devices.push(credential.clone());
907 Ok(())
908 },
909 )
910 .await?;
911
912 Ok(HttpResponse::Ok().json(PairDeviceResponse {
915 device_id,
916 device_token: token,
917 expires_hint: "rotate-on-demand",
918 }))
919}
920
921const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
925const PAIRING_FAILURE_THRESHOLD: u32 = 10;
927const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
929
930#[derive(Debug, Clone)]
933pub struct PairingCodeEntry {
934 expires_at: Instant,
935}
936
937impl PairingCodeEntry {
938 pub(crate) fn new(ttl: Duration) -> Self {
939 Self {
940 expires_at: Instant::now() + ttl,
941 }
942 }
943
944 pub fn is_expired(&self) -> bool {
947 Instant::now() >= self.expires_at
948 }
949}
950
951#[derive(Debug, Default)]
972pub struct PairingCodeGuard {
973 inner: Mutex<PairingGuardState>,
974}
975
976#[derive(Debug, Default)]
977struct PairingGuardState {
978 failures: u32,
979 cooldown_until: Option<Instant>,
981}
982
983impl PairingCodeGuard {
984 pub fn in_cooldown(&self) -> bool {
987 let mut state = self.inner.lock().recover_poison();
988 match state.cooldown_until {
989 Some(until) if Instant::now() < until => true,
990 Some(_) => {
991 state.cooldown_until = None;
993 state.failures = 0;
994 false
995 }
996 None => false,
997 }
998 }
999
1000 pub fn record_failure(&self) -> bool {
1003 let mut state = self.inner.lock().recover_poison();
1004 state.failures = state.failures.saturating_add(1);
1005 if state.failures >= PAIRING_FAILURE_THRESHOLD {
1006 state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
1007 true
1008 } else {
1009 false
1010 }
1011 }
1012
1013 pub fn record_success(&self) {
1015 let mut state = self.inner.lock().recover_poison();
1016 state.failures = 0;
1017 state.cooldown_until = None;
1018 }
1019}
1020
1021const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
1040const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
1042const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
1048
1049#[derive(Debug, Default, Clone)]
1051struct RootAttemptState {
1052 failures: u32,
1053 cooldown_until: Option<Instant>,
1055}
1056
1057#[derive(Debug, Default)]
1077pub struct RootPasswordGuard {
1078 inner: dashmap::DashMap<String, RootAttemptState>,
1079}
1080
1081pub enum RootGuardDecision {
1083 Allow,
1085 Cooldown { retry_after_secs: u64 },
1087}
1088
1089impl RootPasswordGuard {
1090 pub fn check(&self, key: &str) -> RootGuardDecision {
1093 let now = Instant::now();
1094 if let Some(mut entry) = self.inner.get_mut(key) {
1095 if let Some(until) = entry.cooldown_until {
1096 if now < until {
1097 let retry_after_secs = (until - now).as_secs().max(1);
1098 return RootGuardDecision::Cooldown { retry_after_secs };
1099 }
1100 entry.failures = 0;
1102 entry.cooldown_until = None;
1103 }
1104 }
1105 RootGuardDecision::Allow
1106 }
1107
1108 pub fn record_failure(&self, key: &str) {
1111 let now = Instant::now();
1112 if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1116 self.inner
1117 .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1118 }
1119 let mut entry = self.inner.entry(key.to_string()).or_default();
1120 if matches!(entry.cooldown_until, Some(until) if now < until) {
1123 return;
1124 }
1125 if entry.cooldown_until.is_some() {
1127 entry.failures = 0;
1128 entry.cooldown_until = None;
1129 }
1130 entry.failures = entry.failures.saturating_add(1);
1131 if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1132 entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1133 }
1134 }
1135
1136 pub fn record_success(&self, key: &str) {
1138 self.inner.remove(key);
1139 }
1140}
1141
1142fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1149 if is_local_request(req) {
1150 return None;
1151 }
1152 Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1153}
1154
1155fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1158 HttpResponse::TooManyRequests()
1159 .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1160 .json(serde_json::json!({
1161 "error": crate::error::error_value(
1162 "too many failed password attempts — try again later"
1163 )
1164 }))
1165}
1166
1167fn generate_pairing_code() -> String {
1173 let n = rand::rng().random_range(0..1_000_000);
1174 format!("{n:06}")
1175}
1176
1177fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1179 codes.retain(|_code, entry| !entry.is_expired());
1180}
1181
1182#[derive(Serialize)]
1183pub struct PairingCodeResponse {
1184 pub code: String,
1185 pub ttl: u64,
1187}
1188
1189pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1197 purge_expired_codes(&app_state.pairing_codes);
1199
1200 let code = generate_pairing_code();
1201 let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1202 app_state.pairing_codes.insert(code.clone(), entry);
1204
1205 Ok(HttpResponse::Ok().json(PairingCodeResponse {
1206 code,
1207 ttl: PAIRING_CODE_TTL.as_secs(),
1208 }))
1209}
1210
1211#[derive(Serialize)]
1217pub struct DeviceSummary {
1218 pub device_id: String,
1219 pub label: String,
1220 pub created_at: String,
1221 pub last_used_at: Option<String>,
1222 pub revoked: bool,
1223}
1224
1225impl DeviceSummary {
1226 fn from_credential(d: &DeviceCredential) -> Self {
1227 Self {
1228 device_id: d.device_id.clone(),
1229 label: d.label.clone(),
1230 created_at: d.created_at.clone(),
1231 last_used_at: d.last_used_at.clone(),
1232 revoked: d.revoked,
1233 }
1234 }
1235}
1236
1237pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1240 let config = app_state.config.read().await.clone();
1241 let devices: Vec<DeviceSummary> = config
1242 .access_control
1243 .as_ref()
1244 .map(|access| {
1245 access
1246 .devices
1247 .iter()
1248 .map(DeviceSummary::from_credential)
1249 .collect()
1250 })
1251 .unwrap_or_default();
1252 Ok(HttpResponse::Ok().json(devices))
1253}
1254
1255pub async fn revoke_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 target = device_id.clone();
1282 let expected_revision = credential_revision(&app_state)?;
1283 app_state
1284 .update_access_control_credentials(
1285 expected_revision,
1286 false,
1287 BTreeSet::new(),
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.revoked = true;
1293 }
1294 }
1295 Ok(())
1296 },
1297 )
1298 .await?;
1299
1300 Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1301}
1302
1303pub async fn rotate_device(
1311 path: web::Path<String>,
1312 app_state: web::Data<AppState>,
1313) -> Result<HttpResponse, AppError> {
1314 let device_id = path.into_inner();
1315
1316 {
1319 let config = app_state.config.read().await;
1320 let exists = config
1321 .access_control
1322 .as_ref()
1323 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1324 .unwrap_or(false);
1325 if !exists {
1326 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1327 }
1328 }
1329
1330 let (fresh, token) = issue_device_token("");
1333
1334 let target = device_id.clone();
1335 let expected_revision = credential_revision(&app_state)?;
1336 app_state
1337 .update_access_control_credentials(
1338 expected_revision,
1339 false,
1340 BTreeSet::from([device_id.clone()]),
1341 move |config| {
1342 if let Some(access) = config.access_control.as_mut() {
1343 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1344 {
1345 device.token_hash = fresh.token_hash.clone();
1346 device.token_salt = fresh.token_salt.clone();
1347 device.revoked = false;
1348 device.last_used_at = None;
1349 }
1350 }
1351 Ok(())
1352 },
1353 )
1354 .await?;
1355
1356 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1358 device_id,
1359 device_token: token,
1360 expires_hint: "rotate-on-demand",
1361 }))
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367 use actix_web::{
1368 body::to_bytes,
1369 http::StatusCode,
1370 middleware::from_fn,
1371 test::{self, TestRequest},
1372 App,
1373 };
1374 use bamboo_config::AccessControlConfig;
1375 use bamboo_engine::external_agents::actor_adapter::CodexRunTokenAuthority as _;
1376
1377 macro_rules! test_config {
1378 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
1379 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
1380 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
1381 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
1382 ($($field:ident: $value:expr),* $(,)?) => {{
1383 let mut config = Config::default();
1384 $(test_config!(@assign config, $field, $value);)*
1385 config
1386 }};
1387 }
1388
1389 #[actix_web::test]
1390 async fn codex_run_token_is_path_and_session_scoped_and_revocation_beats_loopback_bypass() {
1391 async fn probe(req: HttpRequest) -> HttpResponse {
1392 let session_id = req
1393 .extensions()
1394 .get::<crate::codex_run_tokens::CodexRunAuthContext>()
1395 .map(|context| context.session_id.clone())
1396 .unwrap_or_else(|| "missing-context".to_string());
1397 HttpResponse::Ok().body(session_id)
1398 }
1399
1400 let data_dir = tempfile::tempdir().unwrap();
1401 let state = AppState::new(data_dir.path().to_path_buf()).await.unwrap();
1402 let tokens = state.codex_run_tokens.clone();
1403 let issued = tokens.issue("codex-child-570").unwrap();
1404 let app = test::init_service(
1405 App::new()
1406 .app_data(web::Data::new(state))
1407 .wrap(from_fn(enforce_access_password_middleware))
1408 .route("/openai/v1/responses", web::post().to(probe))
1409 .route("/openai/v1/chat/completions", web::post().to(probe)),
1410 )
1411 .await;
1412
1413 let valid = TestRequest::post()
1414 .uri("/openai/v1/responses")
1415 .peer_addr("127.0.0.1:5700".parse().unwrap())
1416 .insert_header((header::HOST, "localhost:9562"))
1417 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
1418 .to_request();
1419 let valid = test::call_service(&app, valid).await;
1420 assert_eq!(valid.status(), StatusCode::OK);
1421 assert_eq!(
1422 to_bytes(valid.into_body()).await.unwrap(),
1423 "codex-child-570".as_bytes()
1424 );
1425
1426 let out_of_scope = TestRequest::post()
1427 .uri("/openai/v1/chat/completions")
1428 .peer_addr("127.0.0.1:5700".parse().unwrap())
1429 .insert_header((header::HOST, "localhost:9562"))
1430 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
1431 .to_request();
1432 assert_eq!(
1433 test::call_service(&app, out_of_scope).await.status(),
1434 StatusCode::UNAUTHORIZED
1435 );
1436
1437 tokens.revoke(&issued.token_id);
1438 let revoked_on_loopback = TestRequest::post()
1439 .uri("/openai/v1/responses")
1440 .peer_addr("127.0.0.1:5700".parse().unwrap())
1441 .insert_header((header::HOST, "localhost:9562"))
1442 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
1443 .to_request();
1444 assert_eq!(
1445 test::call_service(&app, revoked_on_loopback).await.status(),
1446 StatusCode::UNAUTHORIZED,
1447 "revoked bcx1_ credentials must never fall through to loopback bypass"
1448 );
1449 }
1450
1451 #[test]
1452 fn loopback_request_is_local() {
1453 let req = TestRequest::default()
1454 .peer_addr("127.0.0.1:12345".parse().unwrap())
1455 .insert_header((header::HOST, "localhost:9562"))
1456 .to_http_request();
1457 assert!(is_local_request(&req));
1458 }
1459
1460 #[test]
1461 fn private_lan_host_is_local() {
1462 let req = TestRequest::default()
1463 .insert_header((header::HOST, "192.168.0.10:9562"))
1464 .to_http_request();
1465 assert!(is_local_request(&req));
1466 }
1467
1468 #[test]
1469 fn remote_host_is_not_local_even_when_peer_is_loopback() {
1470 let req = TestRequest::default()
1471 .peer_addr("127.0.0.1:12345".parse().unwrap())
1472 .insert_header((header::HOST, "bamboo.example.com"))
1473 .to_http_request();
1474 assert!(!is_local_request(&req));
1475 }
1476
1477 #[test]
1478 fn spoofed_local_host_from_remote_peer_is_not_local() {
1479 for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
1483 let req = TestRequest::default()
1484 .peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
1486 .to_http_request();
1487 assert!(
1488 !is_local_request(&req),
1489 "remote peer + spoofed Host '{spoof}' must not be local"
1490 );
1491 let req2 = TestRequest::default()
1493 .peer_addr("203.0.113.5:40000".parse().unwrap())
1494 .insert_header(("x-forwarded-host", spoof))
1495 .to_http_request();
1496 assert!(
1497 !is_local_request(&req2),
1498 "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
1499 );
1500 }
1501 }
1502
1503 #[test]
1504 fn loopback_peer_with_no_host_is_local() {
1505 let req = TestRequest::default()
1506 .peer_addr("127.0.0.1:5000".parse().unwrap())
1507 .to_http_request();
1508 assert!(is_local_request(&req));
1509 }
1510
1511 #[test]
1512 fn password_hash_roundtrip_verifies() {
1513 let salt_hex = hex::encode([1_u8; 16]);
1514 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1515 let config = test_config! {
1516 access_control: Some(AccessControlConfig {
1517 password_enabled: true,
1518 password_hash: Some(hash),
1519 password_salt: Some(salt_hex),
1520 password_credential_ref: None,
1521 password_configured: false,
1522 updated_at: None,
1523 devices: Vec::new(),
1524 }),
1525 };
1526
1527 assert!(verify_password(&config, "secret"));
1528 assert!(!verify_password(&config, "wrong"));
1529 }
1530
1531 fn config_with_password() -> Config {
1534 let salt_hex = hex::encode([1_u8; 16]);
1535 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1536 test_config! {
1537 access_control: Some(AccessControlConfig {
1538 password_enabled: true,
1539 password_hash: Some(hash),
1540 password_salt: Some(salt_hex),
1541 password_credential_ref: None,
1542 password_configured: false,
1543 updated_at: None,
1544 devices: Vec::new(),
1545 }),
1546 }
1547 }
1548
1549 #[test]
1550 fn constant_time_eq_matches_and_rejects() {
1551 assert!(constant_time_eq(b"abcd", b"abcd"));
1552 assert!(!constant_time_eq(b"abcd", b"abce"));
1553 assert!(!constant_time_eq(b"abc", b"abcd"));
1554 }
1555
1556 #[test]
1557 fn issued_token_has_expected_format_and_verifies() {
1558 let (cred, token) = issue_device_token("iPhone 15");
1559 assert!(token.starts_with("bd1_"));
1560 assert_eq!(token.len(), "bd1_".len() + 32);
1561 assert!(cred.device_id.starts_with("bamboo_"));
1562 assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
1563 assert_eq!(cred.label, "iPhone 15");
1564 assert!(!cred.revoked);
1565 assert_ne!(cred.token_hash, token);
1567
1568 let mut config = config_with_password();
1569 config
1570 .access_control
1571 .as_mut()
1572 .unwrap()
1573 .devices
1574 .push(cred.clone());
1575
1576 assert!(verify_device_token(&config, &cred.device_id, &token));
1577 assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
1578 assert!(!verify_device_token(&config, "bamboo_unknown", &token));
1579 }
1580
1581 #[test]
1582 fn revoked_token_is_rejected() {
1583 let (mut cred, token) = issue_device_token("iPad");
1584 cred.revoked = true;
1585 let mut config = config_with_password();
1586 let device_id = cred.device_id.clone();
1587 config.access_control.as_mut().unwrap().devices.push(cred);
1588 assert!(!verify_device_token(&config, &device_id, &token));
1589 }
1590
1591 #[test]
1592 fn has_active_devices_ignores_revoked() {
1593 let mut config = config_with_password();
1594 assert!(!has_active_devices(&config));
1595 let (mut cred, _t) = issue_device_token("d");
1596 cred.revoked = true;
1597 config
1598 .access_control
1599 .as_mut()
1600 .unwrap()
1601 .devices
1602 .push(cred.clone());
1603 assert!(!has_active_devices(&config));
1604 let (cred2, _t2) = issue_device_token("d2");
1605 config.access_control.as_mut().unwrap().devices.push(cred2);
1606 assert!(has_active_devices(&config));
1607 }
1608
1609 fn remote_req() -> HttpRequest {
1610 TestRequest::default()
1611 .insert_header((header::HOST, "bamboo.example.com"))
1612 .to_http_request()
1613 }
1614
1615 fn local_req() -> HttpRequest {
1616 TestRequest::default()
1617 .insert_header((header::HOST, "localhost:9562"))
1618 .to_http_request()
1619 }
1620
1621 #[test]
1622 fn no_devices_no_password_does_not_require_credential() {
1623 let config = Config::default();
1626 assert!(!build_access_status(&config, &remote_req()).requires_password);
1627 }
1628
1629 #[test]
1630 fn password_only_gate_matches_prior_behavior() {
1631 let config = config_with_password();
1632 assert!(build_access_status(&config, &remote_req()).requires_password);
1633 assert!(!build_access_status(&config, &local_req()).requires_password);
1634 }
1635
1636 #[test]
1637 fn device_presence_requires_credential_even_without_password() {
1638 let (cred, _t) = issue_device_token("d");
1640 let config = test_config! {
1641 access_control: Some(AccessControlConfig {
1642 password_enabled: false,
1643 password_hash: None,
1644 password_salt: None,
1645 password_credential_ref: None,
1646 password_configured: false,
1647 updated_at: None,
1648 devices: vec![cred],
1649 }),
1650 };
1651 assert!(build_access_status(&config, &remote_req()).requires_password);
1652 assert!(!build_access_status(&config, &local_req()).requires_password);
1654 }
1655
1656 #[test]
1657 fn valid_device_token_on_request_authenticates() {
1658 let (cred, token) = issue_device_token("d");
1659 let device_id = cred.device_id.clone();
1660 let mut config = config_with_password();
1661 config.access_control.as_mut().unwrap().devices.push(cred);
1662
1663 let req = TestRequest::default()
1664 .insert_header((header::HOST, "bamboo.example.com"))
1665 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1666 .insert_header((DEVICE_ID_HEADER, device_id))
1667 .to_http_request();
1668 assert!(request_has_valid_device_token(&req, &config));
1669
1670 let bad = TestRequest::default()
1672 .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
1673 .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
1674 .to_http_request();
1675 assert!(!request_has_valid_device_token(&bad, &config));
1676
1677 let no_id = TestRequest::default()
1679 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1680 .to_http_request();
1681 assert!(!request_has_valid_device_token(&no_id, &config));
1682 }
1683
1684 #[test]
1691 fn request_is_authorized_local_is_always_allowed() {
1692 let config = config_with_password();
1694 assert!(request_is_authorized(&local_req(), &config));
1695 }
1696
1697 #[test]
1698 fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
1699 let (cred, _t) = issue_device_token("d");
1701 let config = test_config! {
1702 access_control: Some(AccessControlConfig {
1703 password_enabled: false,
1704 password_hash: None,
1705 password_salt: None,
1706 password_credential_ref: None,
1707 password_configured: false,
1708 updated_at: None,
1709 devices: vec![cred],
1710 }),
1711 };
1712 assert!(!request_is_authorized(&remote_req(), &config));
1713 }
1714
1715 #[test]
1716 fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
1717 let config = config_with_password();
1718 assert!(!request_is_authorized(&remote_req(), &config));
1719 }
1720
1721 #[test]
1722 fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
1723 let config = config_with_password();
1724 let cookie_value =
1725 access_verification_cookie_value(&config).expect("password config yields a cookie");
1726 let req = TestRequest::default()
1727 .insert_header((header::HOST, "bamboo.example.com"))
1728 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
1729 .to_http_request();
1730 assert!(request_is_authorized(&req, &config));
1731 }
1732
1733 #[test]
1734 fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
1735 let (cred, token) = issue_device_token("d");
1736 let device_id = cred.device_id.clone();
1737 let mut config = config_with_password();
1738 config.access_control.as_mut().unwrap().devices.push(cred);
1739
1740 let req = TestRequest::default()
1741 .insert_header((header::HOST, "bamboo.example.com"))
1742 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1743 .insert_header((DEVICE_ID_HEADER, device_id))
1744 .to_http_request();
1745 assert!(request_is_authorized(&req, &config));
1746 }
1747
1748 #[test]
1749 fn request_is_authorized_no_password_no_devices_is_open() {
1750 let config = Config::default();
1753 assert!(request_is_authorized(&remote_req(), &config));
1754 }
1755
1756 #[test]
1757 fn stream_is_public_but_sibling_routes_are_not() {
1758 assert!(is_public_access_route("/v2/stream"));
1760 assert!(is_public_access_route("/v2/pair"));
1761 assert!(!is_public_access_route("/v2/pair/code"));
1762 assert!(!is_public_access_route("/v2/devices"));
1763 assert!(!is_public_access_route("/v2/devices/bamboo_x"));
1764 }
1765
1766 #[test]
1767 fn health_probes_are_public() {
1768 assert!(is_public_access_route("/healthz"));
1771 assert!(is_public_access_route("/readyz"));
1772 assert!(is_public_access_route("/api/v1/health"));
1773 }
1774
1775 #[test]
1776 fn public_access_status_routes_are_public_under_both_version_prefixes() {
1777 for prefix in ["/v1", "/api/v1"] {
1783 assert!(
1784 is_public_access_route(&format!("{prefix}/bamboo/access/status")),
1785 "{prefix}/bamboo/access/status must be public"
1786 );
1787 assert!(
1788 is_public_access_route(&format!("{prefix}/bamboo/access/verify")),
1789 "{prefix}/bamboo/access/verify must be public"
1790 );
1791 assert!(
1794 !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
1795 "{prefix}/bamboo/access/password must stay gated"
1796 );
1797 }
1798 }
1799
1800 #[test]
1803 fn generated_pairing_code_is_six_digits() {
1804 for _ in 0..1000 {
1805 let code = generate_pairing_code();
1806 assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
1807 assert!(
1808 code.chars().all(|c| c.is_ascii_digit()),
1809 "code {code:?} must be all digits"
1810 );
1811 }
1812 }
1813
1814 #[test]
1815 fn pairing_code_expiry_predicate() {
1816 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
1818 assert!(!fresh.is_expired());
1819
1820 let zero = PairingCodeEntry::new(Duration::from_secs(0));
1822 assert!(zero.is_expired());
1823
1824 let past = PairingCodeEntry {
1826 expires_at: Instant::now() - Duration::from_secs(1),
1827 };
1828 assert!(past.is_expired());
1829 }
1830
1831 #[test]
1832 fn purge_expired_codes_drops_only_expired() {
1833 let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
1834 codes.insert(
1835 "live".into(),
1836 PairingCodeEntry::new(Duration::from_secs(120)),
1837 );
1838 codes.insert(
1839 "dead".into(),
1840 PairingCodeEntry {
1841 expires_at: Instant::now() - Duration::from_secs(1),
1842 },
1843 );
1844 purge_expired_codes(&codes);
1845 assert!(codes.contains_key("live"));
1846 assert!(!codes.contains_key("dead"));
1847 }
1848
1849 #[test]
1850 fn guard_trips_cooldown_after_threshold() {
1851 let guard = PairingCodeGuard::default();
1852 assert!(!guard.in_cooldown());
1853 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1855 assert!(!guard.record_failure());
1856 assert!(!guard.in_cooldown());
1857 }
1858 assert!(guard.record_failure());
1860 assert!(guard.in_cooldown());
1861 }
1862
1863 #[test]
1864 fn guard_success_resets_failures() {
1865 let guard = PairingCodeGuard::default();
1866 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1867 guard.record_failure();
1868 }
1869 guard.record_success();
1870 assert!(!guard.record_failure());
1872 assert!(!guard.in_cooldown());
1873 }
1874
1875 #[test]
1876 fn guard_clears_elapsed_cooldown() {
1877 let guard = PairingCodeGuard::default();
1878 {
1880 let mut state = guard.inner.lock().unwrap();
1881 state.failures = PAIRING_FAILURE_THRESHOLD;
1882 state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
1883 }
1884 assert!(!guard.in_cooldown());
1886 assert!(!guard.record_failure(), "counter was reset to 0");
1887 }
1888
1889 #[test]
1892 fn root_guard_trips_cooldown_after_threshold_per_key() {
1893 let guard = RootPasswordGuard::default();
1894 let key = "203.0.113.7";
1895 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1897 guard.record_failure(key);
1898 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1899 }
1900 guard.record_failure(key);
1902 match guard.check(key) {
1903 RootGuardDecision::Cooldown { retry_after_secs } => {
1904 assert!(retry_after_secs >= 1);
1905 assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
1906 }
1907 RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
1908 }
1909 }
1910
1911 #[test]
1912 fn root_guard_keys_are_independent() {
1913 let guard = RootPasswordGuard::default();
1915 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1916 guard.record_failure("198.51.100.1");
1917 }
1918 assert!(matches!(
1919 guard.check("198.51.100.1"),
1920 RootGuardDecision::Cooldown { .. }
1921 ));
1922 assert!(matches!(
1924 guard.check("198.51.100.2"),
1925 RootGuardDecision::Allow
1926 ));
1927 }
1928
1929 #[test]
1930 fn root_guard_success_resets_key() {
1931 let guard = RootPasswordGuard::default();
1932 let key = "203.0.113.9";
1933 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1934 guard.record_failure(key);
1935 }
1936 guard.record_success(key);
1937 guard.record_failure(key);
1939 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1940 }
1941
1942 #[test]
1943 fn root_guard_clears_elapsed_cooldown() {
1944 let guard = RootPasswordGuard::default();
1945 let key = "203.0.113.10";
1946 guard.inner.insert(
1948 key.to_string(),
1949 RootAttemptState {
1950 failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
1951 cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
1952 },
1953 );
1954 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1956 guard.record_failure(key);
1958 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1959 }
1960
1961 #[test]
1962 fn root_guard_evicts_inert_keys_past_the_cap() {
1963 let guard = RootPasswordGuard::default();
1964 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1967 guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
1968 }
1969 assert!(
1970 guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
1971 "inert keys must be swept so the map stays bounded (was {})",
1972 guard.inner.len()
1973 );
1974 let hot = "203.0.113.200";
1976 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1977 guard.record_failure(hot);
1978 }
1979 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1980 guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
1981 }
1982 assert!(
1983 matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
1984 "a key in active cooldown must survive eviction sweeps"
1985 );
1986 }
1987
1988 #[test]
1989 fn root_throttle_key_exempts_loopback_and_keys_remote() {
1990 assert!(root_throttle_key(&local_req()).is_none());
1992
1993 let remote = TestRequest::default()
1995 .peer_addr("203.0.113.5:443".parse().unwrap())
1996 .insert_header((header::HOST, "bamboo.example.com"))
1997 .to_http_request();
1998 assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
1999 }
2000
2001 #[test]
2002 fn client_ip_key_strips_v4_mapped_prefix() {
2003 let req = TestRequest::default()
2004 .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
2005 .to_http_request();
2006 assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
2007 }
2008
2009 #[test]
2010 fn device_summary_excludes_secret_material() {
2011 let (cred, _t) = issue_device_token("iPhone");
2014 let summary = DeviceSummary::from_credential(&cred);
2015 let json = serde_json::to_value(&summary).unwrap();
2016 let obj = json.as_object().unwrap();
2017 assert!(
2018 !obj.contains_key("token_hash"),
2019 "must not expose token_hash"
2020 );
2021 assert!(
2022 !obj.contains_key("token_salt"),
2023 "must not expose token_salt"
2024 );
2025 let serialized = serde_json::to_string(&summary).unwrap();
2027 assert!(!serialized.contains(&cred.token_hash));
2028 assert!(!serialized.contains(&cred.token_salt));
2029 assert!(obj.contains_key("device_id"));
2031 assert!(obj.contains_key("label"));
2032 assert!(obj.contains_key("created_at"));
2033 assert!(obj.contains_key("revoked"));
2034 }
2035}