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::{
22 app_state::AppState,
23 error::AppError,
24 handlers::settings::credential_action::{
25 credential_status_view, CredentialState, CredentialStatusView,
26 },
27};
28use bamboo_config::{Config, DeviceCredential};
29
30fn access_section_revision(app_state: &AppState) -> Result<u64, AppError> {
31 let facade = app_state.config_facade.as_ref().ok_or_else(|| {
32 AppError::BadRequest("access settings require the modular configuration facade".to_string())
33 })?;
34 Ok(facade.registry().access_control.snapshot().revision)
35}
36
37#[derive(Serialize)]
38pub(crate) struct AccessStatusResponse {
39 pub password_enabled: bool,
40 pub local_bypass: bool,
41 pub requires_password: bool,
42}
43
44#[derive(Serialize)]
45pub(crate) struct AccessStatusEnvelope {
46 #[serde(flatten)]
47 pub runtime: AccessStatusResponse,
48 pub revision: u64,
51 pub status: bamboo_config::SectionStatus,
52 pub source_kind: bamboo_config::SectionSourceKind,
53 pub loaded_at: chrono::DateTime<Utc>,
54 pub last_error: Option<String>,
55 pub password_configured: bool,
56 pub credential_state: CredentialState,
57 pub credential_ref: Option<String>,
58 pub credential_source: Option<bamboo_config::CredentialSource>,
59 pub credential_updated_at: Option<String>,
60 pub credential_health: bamboo_config::CredentialStoreHealth,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct VerifyPasswordRequest {
65 pub password: String,
66}
67
68#[derive(Serialize)]
69pub struct VerifyPasswordResponse {
70 pub success: bool,
71}
72
73#[derive(Clone, Copy, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum AccessPasswordAction {
76 Replace,
77 Clear,
78}
79
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct UpdatePasswordRequest {
83 pub expected_revision: u64,
85 #[serde(default)]
88 pub action: Option<AccessPasswordAction>,
89 #[serde(default)]
90 pub current_password: String,
91 #[serde(default)]
92 pub new_password: String,
93 #[serde(default)]
94 pub value: String,
95}
96
97impl std::fmt::Debug for UpdatePasswordRequest {
98 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 formatter
100 .debug_struct("UpdatePasswordRequest")
101 .field("expected_revision", &self.expected_revision)
102 .field(
103 "action",
104 &self.action.map(|action| match action {
105 AccessPasswordAction::Replace => "replace",
106 AccessPasswordAction::Clear => "clear",
107 }),
108 )
109 .field(
110 "current_password",
111 &(!self.current_password.is_empty()).then_some("[REDACTED]"),
112 )
113 .field(
114 "replacement",
115 &(!(self.value.is_empty() && self.new_password.is_empty())).then_some("[REDACTED]"),
116 )
117 .finish()
118 }
119}
120
121#[derive(Serialize)]
122pub(crate) struct UpdatePasswordResponse {
123 pub success: bool,
124 pub password_enabled: bool,
125 pub revision: u64,
126 pub section: bamboo_config::SectionEnvelope<serde_json::Value>,
127 pub credential: CredentialStatusView,
128 pub credential_health: bamboo_config::CredentialStoreHealth,
129}
130
131const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
132const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
133const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
134
135fn normalize_ip(ip: &str) -> &str {
136 let ip = ip.trim();
137 ip.strip_prefix("::ffff:").unwrap_or(ip)
138}
139
140fn split_host_and_port(value: &str) -> &str {
141 let candidate = value.trim();
142 if candidate.is_empty() {
143 return candidate;
144 }
145
146 let without_brackets = candidate
147 .strip_prefix('[')
148 .and_then(|v| v.strip_suffix(']'))
149 .unwrap_or(candidate);
150
151 if without_brackets.parse::<IpAddr>().is_ok() {
152 return without_brackets;
153 }
154
155 without_brackets
156 .split(':')
157 .next()
158 .unwrap_or(without_brackets)
159 .trim()
160}
161
162fn is_local_host(host: &str) -> bool {
163 let normalized = split_host_and_port(host)
164 .trim()
165 .trim_end_matches('.')
166 .to_lowercase();
167 if normalized.is_empty() {
168 return false;
169 }
170
171 if normalized == "localhost" || normalized.ends_with(".local") {
172 return true;
173 }
174
175 let normalized = normalize_ip(&normalized);
176 match normalized.parse::<IpAddr>() {
177 Ok(IpAddr::V4(v4)) => {
178 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
179 }
180 Ok(IpAddr::V6(v6)) => {
181 v6.is_loopback()
182 || v6.is_unique_local()
183 || v6.is_unicast_link_local()
184 || v6.is_unspecified()
185 }
186 Err(_) => false,
187 }
188}
189
190fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
191 let mut candidates = Vec::new();
192
193 for header_name in [
194 header::HOST,
195 header::HeaderName::from_static("x-forwarded-host"),
196 header::HeaderName::from_static("x-original-host"),
197 ] {
198 if let Some(value) = req
199 .headers()
200 .get(&header_name)
201 .and_then(|v| v.to_str().ok())
202 {
203 for part in value.split(',') {
204 let host = part.trim();
205 if !host.is_empty() {
206 candidates.push(host.to_string());
207 }
208 }
209 }
210 }
211
212 if let Some(uri_host) = req.uri().host() {
213 let host = uri_host.trim();
214 if !host.is_empty() {
215 candidates.push(host.to_string());
216 }
217 }
218
219 candidates
220}
221
222fn is_local_request(req: &HttpRequest) -> bool {
223 let peer_local: Option<bool> = req
234 .peer_addr()
235 .map(|peer| is_local_host(&peer.ip().to_string()));
236
237 let host_candidates = request_host_candidates(req);
238 if !host_candidates.is_empty() {
239 let host_local = host_candidates.iter().all(|host| is_local_host(host));
240 return host_local && peer_local != Some(false);
260 }
261
262 if let Some(local) = peer_local {
264 return local;
265 }
266 let conn = req.connection_info();
267 conn.peer_addr().map(is_local_host).unwrap_or(false)
268}
269
270fn client_ip_key(req: &HttpRequest) -> Option<String> {
288 if let Some(peer) = req.peer_addr() {
289 return Some(normalize_ip(&peer.ip().to_string()).to_string());
290 }
291
292 let conn = req.connection_info();
293 for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
294 .into_iter()
295 .flatten()
296 {
297 let normalized = normalize_ip(candidate).trim();
298 if !normalized.is_empty() {
299 return Some(normalized.to_string());
300 }
301 }
302
303 None
304}
305
306fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
307 let salt = hex::decode(salt_hex).ok()?;
308 let mut hasher = Sha256::new();
309 hasher.update(&salt);
310 hasher.update(password.as_bytes());
311 Some(hex::encode(hasher.finalize()))
312}
313
314fn verify_password(config: &Config, password: &str) -> bool {
315 let Some(access) = config.access_control.as_ref() else {
316 return false;
317 };
318 if access.repair_required || !access.password_enabled {
319 return false;
320 }
321
322 let (Some(hash), Some(salt)) = (
323 access.password_hash.as_deref(),
324 access.password_salt.as_deref(),
325 ) else {
326 return false;
327 };
328
329 compute_password_hash(password, salt)
330 .map(|computed| computed == hash)
331 .unwrap_or(false)
332}
333
334const DEVICE_TOKEN_PREFIX: &str = "bd1_";
343const DEVICE_ID_PREFIX: &str = "bamboo_";
345const DEVICE_ID_HEADER: &str = "x-device-id";
348
349fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
356 if a.len() != b.len() {
357 return false;
358 }
359 let mut diff: u8 = 0;
360 for (x, y) in a.iter().zip(b.iter()) {
361 diff |= x ^ y;
362 }
363 diff == 0
364}
365
366fn random_hex(len: usize) -> String {
368 let mut bytes = vec![0_u8; len];
369 rand::rng().fill(&mut bytes);
370 hex::encode(bytes)
371}
372
373pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
379 let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
380 let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
381 let salt_hex = random_hex(16);
382 let token_hash =
386 compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
387 let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
388
389 let credential = DeviceCredential {
390 device_id,
391 label: label.to_string(),
392 token_hash,
393 token_salt: salt_hex,
394 token_credential_ref: None,
395 token_configured: false,
396 created_at,
397 last_used_at: None,
398 revoked: false,
399 };
400 (credential, token)
401}
402
403pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
408 let Some(access) = config.access_control.as_ref() else {
409 return false;
410 };
411 if access.repair_required {
412 return false;
413 }
414 let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
417 return false;
418 };
419 if device.revoked {
420 return false;
421 }
422 let Some(computed) = compute_password_hash(token, &device.token_salt) else {
423 return false;
424 };
425 constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
426}
427
428fn has_active_devices(config: &Config) -> bool {
432 config
433 .access_control
434 .as_ref()
435 .map(|access| access.devices.iter().any(|d| !d.revoked))
436 .unwrap_or(false)
437}
438
439fn access_repair_required(config: &Config) -> bool {
440 config
441 .access_control
442 .as_ref()
443 .is_some_and(|access| access.repair_required)
444}
445
446fn presented_bearer_token(req: &HttpRequest) -> Option<&str> {
454 let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
455 Some(
456 auth.strip_prefix("Bearer ")
457 .or_else(|| auth.strip_prefix("bearer "))?
458 .trim(),
459 )
460}
461
462fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
463 let token = presented_bearer_token(req)?;
464 if !token.starts_with(DEVICE_TOKEN_PREFIX) {
465 return None;
466 }
467 let device_id = req
468 .headers()
469 .get(DEVICE_ID_HEADER)?
470 .to_str()
471 .ok()?
472 .trim()
473 .to_string();
474 if device_id.is_empty() {
475 return None;
476 }
477 Some((device_id, token.to_string()))
478}
479
480fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
482 match presented_device_token(req) {
483 Some((device_id, token)) => verify_device_token(config, &device_id, &token),
484 None => false,
485 }
486}
487
488fn access_verification_cookie_value(config: &Config) -> Option<String> {
489 let access = config.access_control.as_ref()?;
490 if access.repair_required || !access.password_enabled {
491 return None;
492 }
493
494 let hash = access.password_hash.as_deref()?.trim();
495 let salt = access.password_salt.as_deref()?.trim();
496 if hash.is_empty() || salt.is_empty() {
497 return None;
498 }
499
500 let mut hasher = Sha256::new();
501 hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
502 hasher.update(b":");
503 hasher.update(hash.as_bytes());
504 hasher.update(b":");
505 hasher.update(salt.as_bytes());
506 Some(format!(
507 "{}:{}",
508 ACCESS_VERIFIED_COOKIE_VERSION,
509 hex::encode(hasher.finalize())
510 ))
511}
512
513fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
514 let expected = match access_verification_cookie_value(config) {
515 Some(value) => value,
516 None => return false,
517 };
518
519 req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
520 .map(|cookie| cookie.value() == expected)
521 .unwrap_or(false)
522}
523
524fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
525 let value = access_verification_cookie_value(config)?;
526 Some(
527 Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
528 .path("/")
529 .http_only(true)
530 .same_site(SameSite::Lax)
531 .secure(secure)
532 .max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
533 .finish(),
534 )
535}
536
537const PUBLIC_VERSIONED_SUFFIXES: &[&str] =
544 &["/health", "/bamboo/access/status", "/bamboo/access/verify"];
545
546fn is_public_access_route(path: &str) -> bool {
547 for prefix in ["/api/v1", "/v1"] {
548 if let Some(suffix) = path.strip_prefix(prefix) {
549 if PUBLIC_VERSIONED_SUFFIXES.contains(&suffix) {
550 return true;
551 }
552 }
553 }
554
555 matches!(
556 path,
557 "/healthz"
560 | "/readyz"
561 | "/v2/pair"
565 | "/v2/stream"
576 )
577}
578
579pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
588 if access_repair_required(config) {
589 return is_local_request(req);
595 }
596 !build_access_status(config, req).requires_password
597 || request_has_verified_access_cookie(req, config)
598 || request_has_valid_device_token(req, config)
599}
600
601pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
602 req: ServiceRequest,
603 next: Next<B>,
604) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
605 let path = req.path().to_string();
606 if is_public_access_route(&path) {
607 return next
608 .call(req)
609 .await
610 .map(ServiceResponse::map_into_left_body);
611 }
612
613 let app_state = match req.app_data::<web::Data<AppState>>() {
614 Some(state) => state.clone(),
615 None => {
616 return next
617 .call(req)
618 .await
619 .map(ServiceResponse::map_into_left_body)
620 }
621 };
622
623 if let Some(token) = presented_bearer_token(req.request()) {
628 if token.starts_with(crate::codex_run_tokens::CODEX_RUN_TOKEN_PREFIX) {
629 let scoped_path = matches!(path.as_str(), "/openai/v1/responses" | "/openai/v1/models");
630 if scoped_path {
631 if let Some(context) = app_state.codex_run_tokens.verify(token) {
632 req.extensions_mut().insert(context);
633 return next
634 .call(req)
635 .await
636 .map(ServiceResponse::map_into_left_body);
637 }
638 }
639 let response = AppError::Unauthorized(
640 "invalid, expired, or out-of-scope Codex run credential".to_string(),
641 )
642 .error_response()
643 .map_into_right_body();
644 return Ok(req.into_response(response));
645 }
646 }
647
648 let config = app_state.config.read().await.clone();
649 if request_is_authorized(req.request(), &config) {
656 return next
657 .call(req)
658 .await
659 .map(ServiceResponse::map_into_left_body);
660 }
661
662 let response = AppError::Unauthorized("access credential verification required".to_string())
663 .error_response()
664 .map_into_right_body();
665 Ok(req.into_response(response))
666}
667
668fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
669 let password_enabled = config
670 .access_control
671 .as_ref()
672 .map(|access| access.password_enabled)
673 .unwrap_or(false);
674 let local_bypass = is_local_request(req);
675 let credential_required =
679 password_enabled || has_active_devices(config) || access_repair_required(config);
680
681 AccessStatusResponse {
682 password_enabled,
683 local_bypass,
684 requires_password: credential_required && !local_bypass,
685 }
686}
687
688fn build_exact_access_status(
689 config: &Config,
690 _statuses: &[bamboo_config::CredentialStatus],
691 _health: &bamboo_config::CredentialStoreHealth,
692 req: &HttpRequest,
693) -> AccessStatusResponse {
694 let password_enabled = config
698 .access_control
699 .as_ref()
700 .is_some_and(|access| access.password_enabled);
701 let has_device = config
702 .access_control
703 .as_ref()
704 .is_some_and(|access| access.devices.iter().any(|device| !device.revoked));
705 let repair_required = access_repair_required(config);
706 let local_bypass = is_local_request(req);
707 AccessStatusResponse {
708 password_enabled,
709 local_bypass,
710 requires_password: (password_enabled || has_device || repair_required) && !local_bypass,
711 }
712}
713
714pub async fn get_access_status(
715 req: HttpRequest,
716 app_state: web::Data<AppState>,
717) -> Result<HttpResponse, AppError> {
718 let exact = app_state
719 .read_exact_credential_section(bamboo_config::SectionId::AccessControl)
720 .await?;
721 let section = exact.section;
722 let config = exact.config;
723 let reference = config
724 .access_control
725 .as_ref()
726 .and_then(|access| access.password_credential_ref.clone());
727 let statuses = exact.metadata.credential_statuses;
728 let credential_health = exact.metadata.credential_health;
729 let credential = reference.as_ref().and_then(|reference| {
730 statuses
731 .iter()
732 .find(|status| &status.credential_ref == reference)
733 });
734 let expected_configured = config
735 .access_control
736 .as_ref()
737 .is_some_and(|access| access.password_configured);
738 let credential = credential_status_view(
739 reference.as_ref(),
740 expected_configured,
741 credential,
742 &credential_health,
743 );
744 Ok(HttpResponse::Ok().json(AccessStatusEnvelope {
745 runtime: build_exact_access_status(&config, &statuses, &credential_health, &req),
746 revision: section.revision,
747 status: section.status,
748 source_kind: section.source_kind,
749 loaded_at: section.loaded_at,
750 last_error: section.last_error,
751 password_configured: credential.configured,
752 credential_state: credential.state,
753 credential_ref: credential.credential_ref,
754 credential_source: credential.source,
755 credential_updated_at: credential
756 .updated_at
757 .map(|updated_at| updated_at.to_rfc3339()),
758 credential_health,
759 }))
760}
761
762pub async fn verify_access_password(
763 req: HttpRequest,
764 payload: web::Json<VerifyPasswordRequest>,
765 app_state: web::Data<AppState>,
766) -> Result<HttpResponse, AppError> {
767 let password = payload.password.trim();
768 if password.is_empty() {
769 return Err(AppError::BadRequest("password is required".to_string()));
770 }
771
772 let throttle_key = root_throttle_key(&req);
776 if let Some(key) = throttle_key.as_deref() {
777 if let RootGuardDecision::Cooldown { retry_after_secs } =
778 app_state.root_password_guard.check(key)
779 {
780 return Ok(too_many_requests_response(retry_after_secs));
781 }
782 }
783
784 let config = app_state.config.read().await.clone();
785 if !verify_password(&config, password) {
786 if let Some(key) = throttle_key.as_deref() {
787 app_state.root_password_guard.record_failure(key);
788 }
789 return Err(AppError::Unauthorized("invalid password".to_string()));
790 }
791
792 if let Some(key) = throttle_key.as_deref() {
794 app_state.root_password_guard.record_success(key);
795 }
796
797 let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
798 let cookie = build_access_verified_cookie(&config, secure)
799 .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
800
801 Ok(HttpResponse::Ok()
802 .cookie(cookie)
803 .json(VerifyPasswordResponse { success: true }))
804}
805
806pub async fn update_access_password(
807 req: HttpRequest,
808 app_state: web::Data<AppState>,
809 payload: web::Json<UpdatePasswordRequest>,
810) -> Result<HttpResponse, AppError> {
811 let local_bypass = is_local_request(&req);
812 let payload = payload.into_inner();
813 let action = payload.action.unwrap_or(AccessPasswordAction::Replace);
814 if !payload.value.is_empty() && !payload.new_password.is_empty() {
815 return Err(AppError::BadRequest(
816 "password replace must use either value or new_password, not both".to_string(),
817 ));
818 }
819 let replacement = if payload.value.is_empty() {
820 payload.new_password.trim()
821 } else {
822 payload.value.trim()
823 };
824 if matches!(action, AccessPasswordAction::Replace) && replacement.is_empty() {
825 return Err(AppError::BadRequest(
826 "password replace requires a nonempty value".to_string(),
827 ));
828 }
829 if matches!(action, AccessPasswordAction::Replace)
830 && bamboo_config::patch::is_masked_api_key(replacement)
831 {
832 return Err(AppError::BadRequest(
833 "password value must not be a mask".to_string(),
834 ));
835 }
836 if matches!(action, AccessPasswordAction::Clear)
837 && !(payload.value.is_empty() && payload.new_password.is_empty())
838 {
839 return Err(AppError::BadRequest(
840 "password clear must not include a replacement value".to_string(),
841 ));
842 }
843
844 let expected_revision = payload.expected_revision;
845 let current_password = payload.current_password.trim().to_string();
846 let (password_hash, salt_hex) = if matches!(action, AccessPasswordAction::Replace) {
847 let mut salt_bytes = [0_u8; 16];
848 rand::rng().fill(&mut salt_bytes);
849 let salt_hex = hex::encode(salt_bytes);
850 let password_hash = compute_password_hash(replacement, &salt_hex).ok_or_else(|| {
851 AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
852 })?;
853 (Some(password_hash), Some(salt_hex))
854 } else {
855 (None, None)
856 };
857 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
858
859 let (updated, revision, metadata, section) = app_state
860 .update_access_control_credentials(
861 expected_revision,
862 true,
863 BTreeSet::new(),
864 move |config| {
865 let password_already_enabled = config
869 .access_control
870 .as_ref()
871 .is_some_and(|access| access.password_enabled);
872 if password_already_enabled && !local_bypass {
873 if current_password.is_empty() {
874 return Err(AppError::Unauthorized(
875 "current_password is required".to_string(),
876 ));
877 }
878 if !verify_password(config, ¤t_password) {
879 return Err(AppError::Unauthorized(
880 "invalid current password".to_string(),
881 ));
882 }
883 }
884 let access = config.access_control.get_or_insert_with(Default::default);
889 access.password_enabled = matches!(action, AccessPasswordAction::Replace);
890 access.password_hash = password_hash.clone();
891 access.password_salt = salt_hex.clone();
892 access.updated_at = Some(updated_at.clone());
893 Ok(())
894 },
895 )
896 .await?;
897 let configured = updated
898 .access_control
899 .as_ref()
900 .is_some_and(|access| access.password_configured);
901 let section = section.ok_or_else(|| {
902 AppError::InternalError(anyhow::anyhow!(
903 "access-control mutation completed without a typed section envelope"
904 ))
905 })?;
906 let reference =
907 bamboo_config::config_crypto::access_password_credential_ref().map_err(|error| {
908 AppError::InternalError(anyhow::anyhow!(
909 "access-control credential reference is invalid: {error}"
910 ))
911 })?;
912 let credential_status = metadata.status(&reference);
913 let credential = credential_status_view(
914 Some(&reference),
915 configured,
916 Some(&credential_status),
917 &metadata.credential_health,
918 );
919
920 Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
921 success: true,
922 password_enabled: configured,
923 revision,
924 section,
925 credential,
926 credential_health: metadata.credential_health,
927 }))
928}
929
930#[derive(Debug, Deserialize)]
933pub struct PairDeviceRequest {
934 #[serde(default)]
936 pub root_password: String,
937 #[serde(default)]
941 pub code: String,
942 #[serde(default)]
944 pub label: String,
945}
946
947#[derive(Serialize)]
948pub struct PairDeviceResponse {
949 pub device_id: String,
950 pub device_token: String,
952 pub expires_hint: &'static str,
953}
954
955pub async fn pair_device(
967 req: HttpRequest,
968 payload: web::Json<PairDeviceRequest>,
969 app_state: web::Data<AppState>,
970) -> Result<HttpResponse, AppError> {
971 let label = payload.label.trim();
972 if label.is_empty() {
973 return Err(AppError::BadRequest("label is required".to_string()));
974 }
975
976 let code = payload.code.trim();
977 let root_password = payload.root_password.trim();
978
979 if !code.is_empty() {
982 return pair_device_with_code(&app_state, code, label).await;
983 }
984 if !root_password.is_empty() {
985 return pair_device_with_root_password(&req, &app_state, root_password, label).await;
986 }
987
988 Err(AppError::BadRequest(
989 "provide either a root_password or a one-time pairing code".to_string(),
990 ))
991}
992
993async fn pair_device_with_root_password(
996 req: &HttpRequest,
997 app_state: &AppState,
998 root_password: &str,
999 label: &str,
1000) -> Result<HttpResponse, AppError> {
1001 let throttle_key = root_throttle_key(req);
1005 if let Some(key) = throttle_key.as_deref() {
1006 if let RootGuardDecision::Cooldown { retry_after_secs } =
1007 app_state.root_password_guard.check(key)
1008 {
1009 return Ok(too_many_requests_response(retry_after_secs));
1010 }
1011 }
1012
1013 let config = app_state.config.read().await.clone();
1014
1015 let password_enabled = config
1016 .access_control
1017 .as_ref()
1018 .map(|access| access.password_enabled)
1019 .unwrap_or(false);
1020 if !password_enabled {
1021 return Err(AppError::BadRequest(
1022 "set an access password first: the owner root password is required to authorize device pairing".to_string(),
1023 ));
1024 }
1025
1026 if !verify_password(&config, root_password) {
1027 if let Some(key) = throttle_key.as_deref() {
1028 app_state.root_password_guard.record_failure(key);
1029 }
1030 return Err(AppError::Unauthorized("invalid root password".to_string()));
1031 }
1032
1033 if let Some(key) = throttle_key.as_deref() {
1035 app_state.root_password_guard.record_success(key);
1036 }
1037
1038 persist_new_device(app_state, label).await
1039}
1040
1041async fn pair_device_with_code(
1045 app_state: &AppState,
1046 code: &str,
1047 label: &str,
1048) -> Result<HttpResponse, AppError> {
1049 if app_state.pairing_code_guard.in_cooldown() {
1052 return Err(AppError::Unauthorized(
1053 "too many failed pairing attempts — try again later".to_string(),
1054 ));
1055 }
1056
1057 let consumed = app_state.pairing_codes.remove(code);
1062 let valid = match consumed {
1063 Some((_k, entry)) => !entry.is_expired(),
1064 None => false,
1065 };
1066
1067 if !valid {
1068 if app_state.pairing_code_guard.record_failure() {
1072 app_state.pairing_codes.clear();
1073 }
1074 return Err(AppError::Unauthorized(
1075 "invalid or expired pairing code".to_string(),
1076 ));
1077 }
1078
1079 app_state.pairing_code_guard.record_success();
1081 persist_new_device(app_state, label).await
1082}
1083
1084async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
1088 let (credential, token) = issue_device_token(label);
1089 let device_id = credential.device_id.clone();
1090
1091 let expected_revision = access_section_revision(app_state)?;
1092 app_state
1093 .update_access_control_credentials(
1094 expected_revision,
1095 false,
1096 BTreeSet::from([device_id.clone()]),
1097 move |config| {
1098 let access = config.access_control.get_or_insert_with(Default::default);
1101 access.devices.push(credential.clone());
1102 Ok(())
1103 },
1104 )
1105 .await?;
1106
1107 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1110 device_id,
1111 device_token: token,
1112 expires_hint: "rotate-on-demand",
1113 }))
1114}
1115
1116const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
1120const PAIRING_FAILURE_THRESHOLD: u32 = 10;
1122const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
1124
1125#[derive(Debug, Clone)]
1128pub struct PairingCodeEntry {
1129 expires_at: Instant,
1130}
1131
1132impl PairingCodeEntry {
1133 pub(crate) fn new(ttl: Duration) -> Self {
1134 Self {
1135 expires_at: Instant::now() + ttl,
1136 }
1137 }
1138
1139 pub fn is_expired(&self) -> bool {
1142 Instant::now() >= self.expires_at
1143 }
1144}
1145
1146#[derive(Debug, Default)]
1167pub struct PairingCodeGuard {
1168 inner: Mutex<PairingGuardState>,
1169}
1170
1171#[derive(Debug, Default)]
1172struct PairingGuardState {
1173 failures: u32,
1174 cooldown_until: Option<Instant>,
1176}
1177
1178impl PairingCodeGuard {
1179 pub fn in_cooldown(&self) -> bool {
1182 let mut state = self.inner.lock().recover_poison();
1183 match state.cooldown_until {
1184 Some(until) if Instant::now() < until => true,
1185 Some(_) => {
1186 state.cooldown_until = None;
1188 state.failures = 0;
1189 false
1190 }
1191 None => false,
1192 }
1193 }
1194
1195 pub fn record_failure(&self) -> bool {
1198 let mut state = self.inner.lock().recover_poison();
1199 state.failures = state.failures.saturating_add(1);
1200 if state.failures >= PAIRING_FAILURE_THRESHOLD {
1201 state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
1202 true
1203 } else {
1204 false
1205 }
1206 }
1207
1208 pub fn record_success(&self) {
1210 let mut state = self.inner.lock().recover_poison();
1211 state.failures = 0;
1212 state.cooldown_until = None;
1213 }
1214}
1215
1216const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
1235const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
1237const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
1243
1244#[derive(Debug, Default, Clone)]
1246struct RootAttemptState {
1247 failures: u32,
1248 cooldown_until: Option<Instant>,
1250}
1251
1252#[derive(Debug, Default)]
1272pub struct RootPasswordGuard {
1273 inner: dashmap::DashMap<String, RootAttemptState>,
1274}
1275
1276pub enum RootGuardDecision {
1278 Allow,
1280 Cooldown { retry_after_secs: u64 },
1282}
1283
1284impl RootPasswordGuard {
1285 pub fn check(&self, key: &str) -> RootGuardDecision {
1288 let now = Instant::now();
1289 if let Some(mut entry) = self.inner.get_mut(key) {
1290 if let Some(until) = entry.cooldown_until {
1291 if now < until {
1292 let retry_after_secs = (until - now).as_secs().max(1);
1293 return RootGuardDecision::Cooldown { retry_after_secs };
1294 }
1295 entry.failures = 0;
1297 entry.cooldown_until = None;
1298 }
1299 }
1300 RootGuardDecision::Allow
1301 }
1302
1303 pub fn record_failure(&self, key: &str) {
1306 let now = Instant::now();
1307 if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1311 self.inner
1312 .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1313 }
1314 let mut entry = self.inner.entry(key.to_string()).or_default();
1315 if matches!(entry.cooldown_until, Some(until) if now < until) {
1318 return;
1319 }
1320 if entry.cooldown_until.is_some() {
1322 entry.failures = 0;
1323 entry.cooldown_until = None;
1324 }
1325 entry.failures = entry.failures.saturating_add(1);
1326 if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1327 entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1328 }
1329 }
1330
1331 pub fn record_success(&self, key: &str) {
1333 self.inner.remove(key);
1334 }
1335}
1336
1337fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1344 if is_local_request(req) {
1345 return None;
1346 }
1347 Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1348}
1349
1350fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1353 HttpResponse::TooManyRequests()
1354 .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1355 .json(serde_json::json!({
1356 "error": crate::error::error_value(
1357 "too many failed password attempts — try again later"
1358 )
1359 }))
1360}
1361
1362fn generate_pairing_code() -> String {
1368 let n = rand::rng().random_range(0..1_000_000);
1369 format!("{n:06}")
1370}
1371
1372fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1374 codes.retain(|_code, entry| !entry.is_expired());
1375}
1376
1377#[derive(Serialize)]
1378pub struct PairingCodeResponse {
1379 pub code: String,
1380 pub ttl: u64,
1382}
1383
1384pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1392 purge_expired_codes(&app_state.pairing_codes);
1394
1395 let code = generate_pairing_code();
1396 let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1397 app_state.pairing_codes.insert(code.clone(), entry);
1399
1400 Ok(HttpResponse::Ok().json(PairingCodeResponse {
1401 code,
1402 ttl: PAIRING_CODE_TTL.as_secs(),
1403 }))
1404}
1405
1406#[derive(Serialize)]
1412pub struct DeviceSummary {
1413 pub device_id: String,
1414 pub label: String,
1415 pub created_at: String,
1416 pub last_used_at: Option<String>,
1417 pub revoked: bool,
1418}
1419
1420impl DeviceSummary {
1421 fn from_credential(d: &DeviceCredential) -> Self {
1422 Self {
1423 device_id: d.device_id.clone(),
1424 label: d.label.clone(),
1425 created_at: d.created_at.clone(),
1426 last_used_at: d.last_used_at.clone(),
1427 revoked: d.revoked,
1428 }
1429 }
1430}
1431
1432pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1435 let config = app_state.config.read().await.clone();
1436 let devices: Vec<DeviceSummary> = config
1437 .access_control
1438 .as_ref()
1439 .map(|access| {
1440 access
1441 .devices
1442 .iter()
1443 .map(DeviceSummary::from_credential)
1444 .collect()
1445 })
1446 .unwrap_or_default();
1447 Ok(HttpResponse::Ok().json(devices))
1448}
1449
1450pub async fn revoke_device(
1457 path: web::Path<String>,
1458 app_state: web::Data<AppState>,
1459) -> Result<HttpResponse, AppError> {
1460 let device_id = path.into_inner();
1461
1462 {
1465 let config = app_state.config.read().await;
1466 let exists = config
1467 .access_control
1468 .as_ref()
1469 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1470 .unwrap_or(false);
1471 if !exists {
1472 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1473 }
1474 }
1475
1476 let target = device_id.clone();
1477 let expected_revision = access_section_revision(&app_state)?;
1478 app_state
1479 .update_access_control_credentials(
1480 expected_revision,
1481 false,
1482 BTreeSet::new(),
1483 move |config| {
1484 if let Some(access) = config.access_control.as_mut() {
1485 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1486 {
1487 device.revoked = true;
1488 }
1489 }
1490 Ok(())
1491 },
1492 )
1493 .await?;
1494
1495 Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1496}
1497
1498pub async fn rotate_device(
1506 path: web::Path<String>,
1507 app_state: web::Data<AppState>,
1508) -> Result<HttpResponse, AppError> {
1509 let device_id = path.into_inner();
1510
1511 {
1514 let config = app_state.config.read().await;
1515 let exists = config
1516 .access_control
1517 .as_ref()
1518 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1519 .unwrap_or(false);
1520 if !exists {
1521 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1522 }
1523 }
1524
1525 let (fresh, token) = issue_device_token("");
1528
1529 let target = device_id.clone();
1530 let expected_revision = access_section_revision(&app_state)?;
1531 app_state
1532 .update_access_control_credentials(
1533 expected_revision,
1534 false,
1535 BTreeSet::from([device_id.clone()]),
1536 move |config| {
1537 if let Some(access) = config.access_control.as_mut() {
1538 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1539 {
1540 device.token_hash = fresh.token_hash.clone();
1541 device.token_salt = fresh.token_salt.clone();
1542 device.revoked = false;
1543 device.last_used_at = None;
1544 }
1545 }
1546 Ok(())
1547 },
1548 )
1549 .await?;
1550
1551 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1553 device_id,
1554 device_token: token,
1555 expires_hint: "rotate-on-demand",
1556 }))
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561 use super::*;
1562 use actix_web::{
1563 body::to_bytes,
1564 http::StatusCode,
1565 middleware::from_fn,
1566 test::{self, TestRequest},
1567 App,
1568 };
1569 use bamboo_config::AccessControlConfig;
1570 use bamboo_engine::external_agents::actor_adapter::CodexRunTokenAuthority as _;
1571
1572 macro_rules! test_config {
1573 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
1574 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
1575 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
1576 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
1577 ($($field:ident: $value:expr),* $(,)?) => {{
1578 let mut config = Config::default();
1579 $(test_config!(@assign config, $field, $value);)*
1580 config
1581 }};
1582 }
1583
1584 #[actix_web::test]
1585 async fn public_access_status_exposes_revision_without_section_data_or_paths() {
1586 let dir = tempfile::tempdir().unwrap();
1587 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1588 state.config.write().await.access_control = Some(AccessControlConfig {
1589 devices: vec![bamboo_config::DeviceCredential {
1590 device_id: "private-device-id".to_string(),
1591 label: "private-device-label".to_string(),
1592 token_hash: "private-token-hash".to_string(),
1593 token_salt: "private-token-salt".to_string(),
1594 token_credential_ref: None,
1595 token_configured: false,
1596 created_at: "2026-07-27T00:00:00Z".to_string(),
1597 last_used_at: None,
1598 revoked: false,
1599 }],
1600 ..Default::default()
1601 });
1602 let app = test::init_service(
1603 App::new()
1604 .app_data(state)
1605 .route("/access/status", web::get().to(get_access_status)),
1606 )
1607 .await;
1608
1609 let response = test::call_service(
1610 &app,
1611 TestRequest::get()
1612 .uri("/access/status")
1613 .insert_header((header::HOST, "bamboo.example.com"))
1614 .to_request(),
1615 )
1616 .await;
1617 assert_eq!(response.status(), StatusCode::OK);
1618 let body: serde_json::Value = test::read_body_json(response).await;
1619 assert_eq!(body["revision"], 0);
1620 assert!(body.get("status").is_some());
1621 assert!(body.get("source_kind").is_some());
1622 assert!(body.get("section").is_none());
1623 assert!(body.get("source_path").is_none());
1624 let body = body.to_string();
1625 let private_path = dir.path().to_string_lossy().to_string();
1626 for private in [
1627 private_path.as_str(),
1628 "private-device-id",
1629 "private-device-label",
1630 "private-token-hash",
1631 "private-token-salt",
1632 ] {
1633 assert!(!body.contains(private));
1634 }
1635 }
1636
1637 #[actix_web::test]
1638 async fn quarantined_access_is_fail_closed_and_recovery_credentials_stay_private() {
1639 let _key = bamboo_config::encryption::set_test_encryption_key([0xb7; 32]);
1640 let dir = tempfile::tempdir().unwrap();
1641 let secret_fragment = "server-api-private-access-fragment";
1642 let mut root = serde_json::to_value(Config::default()).unwrap();
1643 root["access_control"] = serde_json::json!({
1644 "password_enabled": "yes",
1645 "password_hash": secret_fragment
1646 });
1647 std::fs::write(
1648 dir.path().join("config.json"),
1649 serde_json::to_vec_pretty(&root).unwrap(),
1650 )
1651 .unwrap();
1652
1653 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1654 assert!(state
1655 .config
1656 .read()
1657 .await
1658 .access_control
1659 .as_ref()
1660 .is_some_and(|access| access.repair_required));
1661 let app = test::init_service(
1662 App::new()
1663 .app_data(state)
1664 .route("/access/status", web::get().to(get_access_status))
1665 .route(
1666 "/credentials",
1667 web::get().to(crate::handlers::settings::list_credentials),
1668 )
1669 .route(
1670 "/credentials/{credential_ref}",
1671 web::get().to(crate::handlers::settings::get_credential_status),
1672 ),
1673 )
1674 .await;
1675
1676 let status = test::call_service(
1677 &app,
1678 TestRequest::get()
1679 .uri("/access/status")
1680 .peer_addr("203.0.113.8:5700".parse().unwrap())
1681 .insert_header((header::HOST, "bamboo.example.com"))
1682 .to_request(),
1683 )
1684 .await;
1685 assert_eq!(status.status(), StatusCode::OK);
1686 let status: serde_json::Value = test::read_body_json(status).await;
1687 assert_eq!(status["requires_password"], true);
1688 assert_eq!(status["status"], "degraded");
1689 let serialized_status = status.to_string();
1690 assert!(!serialized_status.contains("access_repair."));
1691 assert!(!serialized_status.contains(secret_fragment));
1692
1693 let list =
1694 test::call_service(&app, TestRequest::get().uri("/credentials").to_request()).await;
1695 assert_eq!(list.status(), StatusCode::OK);
1696 let serialized_list = String::from_utf8(test::read_body(list).await.to_vec()).unwrap();
1697 assert!(!serialized_list.contains("access_repair."));
1698 assert!(!serialized_list.contains(secret_fragment));
1699
1700 let direct = test::call_service(
1701 &app,
1702 TestRequest::get()
1703 .uri("/credentials/access_repair.root.payload")
1704 .to_request(),
1705 )
1706 .await;
1707 assert_eq!(direct.status(), StatusCode::BAD_REQUEST);
1708 let direct = String::from_utf8(test::read_body(direct).await.to_vec()).unwrap();
1709 assert!(!direct.contains(secret_fragment));
1710 assert!(!direct.contains("access_repair.root.payload"));
1711 }
1712
1713 #[actix_web::test]
1714 async fn access_status_reports_valid_ciphertext_with_invalid_verifier_as_error() {
1715 let dir = tempfile::tempdir().unwrap();
1716 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1717 state
1718 .update_access_control_credentials(0, true, BTreeSet::new(), |config| {
1719 config.access_control = Some(AccessControlConfig {
1720 password_enabled: true,
1721 password_hash: Some("a".repeat(64)),
1722 password_salt: Some("11".repeat(16)),
1723 password_configured: true,
1724 ..Default::default()
1725 });
1726 Ok(())
1727 })
1728 .await
1729 .unwrap();
1730 let reference = bamboo_config::config_crypto::access_password_credential_ref().unwrap();
1731 state
1732 .credential_store
1733 .replace(
1734 reference.clone(),
1735 r#"{"hash":"not-a-valid-hash","salt":"11"}"#,
1736 bamboo_config::CredentialSource::User,
1737 1,
1738 )
1739 .unwrap();
1740 let access_path = dir.path().join("access-control.json");
1741 let credential_path = dir.path().join("credentials.json");
1742 let access_before = std::fs::read(&access_path).unwrap();
1743 let credentials_before = std::fs::read(&credential_path).unwrap();
1744 let keep = state
1745 .update_access_control_credentials(1, false, BTreeSet::new(), |config| {
1746 config.access_control.as_mut().unwrap().updated_at =
1747 Some("metadata-keep-must-fail".to_string());
1748 Ok(())
1749 })
1750 .await;
1751 assert!(keep.is_err());
1752 assert_eq!(std::fs::read(&access_path).unwrap(), access_before);
1753 assert_eq!(std::fs::read(&credential_path).unwrap(), credentials_before);
1754
1755 let app = test::init_service(
1756 App::new()
1757 .app_data(state.clone())
1758 .route("/access/status", web::get().to(get_access_status)),
1759 )
1760 .await;
1761 let response = test::call_service(
1762 &app,
1763 TestRequest::get()
1764 .uri("/access/status")
1765 .peer_addr("203.0.113.8:5700".parse().unwrap())
1766 .insert_header((header::HOST, "bamboo.example.com"))
1767 .to_request(),
1768 )
1769 .await;
1770 assert_eq!(response.status(), StatusCode::OK);
1771 let body: serde_json::Value = test::read_body_json(response).await;
1772 assert_eq!(body["revision"], 1);
1773 assert_eq!(body["password_enabled"], true);
1774 assert_eq!(body["requires_password"], true);
1775 assert_eq!(body["password_configured"], false);
1776 assert_eq!(body["credential_state"], "error");
1777 assert_eq!(body["status"], "degraded");
1778 assert_eq!(
1779 body["last_error"],
1780 "access-control credential repair is required"
1781 );
1782
1783 let (_, revision, metadata, section) = state
1784 .update_access_control_credentials(1, true, BTreeSet::new(), |config| {
1785 let access = config.access_control.as_mut().unwrap();
1786 access.password_enabled = false;
1787 access.password_hash = None;
1788 access.password_salt = None;
1789 access.password_configured = false;
1790 Ok(())
1791 })
1792 .await
1793 .unwrap();
1794 assert_eq!(revision, 2);
1795 assert!(!metadata.status(&reference).configured);
1796 let section = section.unwrap();
1797 assert_eq!(section.revision, 2);
1798 assert!(section.data["password_credential_ref"].is_null());
1799 }
1800
1801 #[actix_web::test]
1802 async fn password_replace_and_clear_use_access_revision_and_preserve_devices() {
1803 let _key = bamboo_config::encryption::set_test_encryption_key([0xd3; 32]);
1804 let dir = tempfile::tempdir().unwrap();
1805 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1806 let (device, _token) = issue_device_token("paired-device");
1807 let device_id = device.device_id.clone();
1808 let device_intent = device_id.clone();
1809 state
1810 .update_access_control_credentials(
1811 0,
1812 false,
1813 BTreeSet::from([device_intent]),
1814 move |config| {
1815 config.access_control = Some(AccessControlConfig {
1816 devices: vec![device],
1817 ..Default::default()
1818 });
1819 Ok(())
1820 },
1821 )
1822 .await
1823 .unwrap();
1824 let mut events = state.account_sink.subscribe();
1825 let app = test::init_service(
1826 App::new()
1827 .app_data(state.clone())
1828 .route("/access/password", web::post().to(update_access_password)),
1829 )
1830 .await;
1831
1832 let replace = test::call_service(
1833 &app,
1834 TestRequest::post()
1835 .uri("/access/password")
1836 .set_json(serde_json::json!({
1837 "expected_revision": 1,
1838 "action": "replace",
1839 "value": "root-replacement-secret"
1840 }))
1841 .to_request(),
1842 )
1843 .await;
1844 assert_eq!(replace.status(), StatusCode::OK);
1845 let replace: serde_json::Value = test::read_body_json(replace).await;
1846 assert_eq!(replace["revision"], 2);
1847 assert_eq!(replace["section"]["revision"], 2);
1848 assert_eq!(replace["credential"]["configured"], true);
1849 assert_eq!(replace["credential"]["state"], "configured");
1850 assert!(!replace.to_string().contains("root-replacement-secret"));
1851 assert!(!replace.to_string().contains("********"));
1852 let event = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1853 loop {
1854 let event = events.recv().await.unwrap();
1855 if matches!(
1856 &event.event,
1857 bamboo_agent_core::AgentEvent::ConfigChanged { section, revision: 2 }
1858 if section == "access-control"
1859 ) {
1860 break event;
1861 }
1862 }
1863 })
1864 .await
1865 .unwrap();
1866 assert!(matches!(
1867 event.event,
1868 bamboo_agent_core::AgentEvent::ConfigChanged { ref section, revision: 2 }
1869 if section == "access-control"
1870 ));
1871
1872 let clear = test::call_service(
1873 &app,
1874 TestRequest::post()
1875 .uri("/access/password")
1876 .set_json(serde_json::json!({
1877 "expected_revision": 2,
1878 "action": "clear",
1879 "current_password": "root-replacement-secret"
1880 }))
1881 .to_request(),
1882 )
1883 .await;
1884 assert_eq!(clear.status(), StatusCode::OK);
1885 let clear: serde_json::Value = test::read_body_json(clear).await;
1886 assert_eq!(clear["revision"], 3);
1887 assert_eq!(clear["section"]["revision"], 3);
1888 assert_eq!(clear["password_enabled"], false);
1889 assert_eq!(clear["credential"]["configured"], false);
1890 assert_eq!(clear["credential"]["state"], "missing");
1891 let access = state.config.read().await.access_control.clone().unwrap();
1892 assert_eq!(access.devices.len(), 1);
1893 assert_eq!(access.devices[0].device_id, device_id);
1894 assert!(!access.password_enabled);
1895
1896 let stale = test::call_service(
1897 &app,
1898 TestRequest::post()
1899 .uri("/access/password")
1900 .set_json(serde_json::json!({
1901 "expected_revision": 2,
1902 "action": "replace",
1903 "value": "stale-root-secret"
1904 }))
1905 .to_request(),
1906 )
1907 .await;
1908 assert_eq!(stale.status(), StatusCode::CONFLICT);
1909 let stale = String::from_utf8(test::read_body(stale).await.to_vec()).unwrap();
1910 assert!(!stale.contains("stale-root-secret"));
1911 let access = state.config.read().await.access_control.clone().unwrap();
1912 assert_eq!(access.devices.len(), 1);
1913 assert!(!access.password_enabled);
1914
1915 let missing_revision = test::call_service(
1916 &app,
1917 TestRequest::post()
1918 .uri("/access/password")
1919 .set_json(serde_json::json!({
1920 "action": "replace",
1921 "value": "missing-revision-secret"
1922 }))
1923 .to_request(),
1924 )
1925 .await;
1926 assert_eq!(missing_revision.status(), StatusCode::BAD_REQUEST);
1927
1928 let masked = test::call_service(
1929 &app,
1930 TestRequest::post()
1931 .uri("/access/password")
1932 .set_json(serde_json::json!({
1933 "expected_revision": 3,
1934 "action": "replace",
1935 "value": "****...****"
1936 }))
1937 .to_request(),
1938 )
1939 .await;
1940 assert_eq!(masked.status(), StatusCode::BAD_REQUEST);
1941
1942 let ambiguous = test::call_service(
1943 &app,
1944 TestRequest::post()
1945 .uri("/access/password")
1946 .set_json(serde_json::json!({
1947 "expected_revision": 3,
1948 "action": "replace",
1949 "value": "ambiguous-value-secret",
1950 "new_password": "ambiguous-legacy-secret"
1951 }))
1952 .to_request(),
1953 )
1954 .await;
1955 assert_eq!(ambiguous.status(), StatusCode::BAD_REQUEST);
1956
1957 let clear_with_value = test::call_service(
1958 &app,
1959 TestRequest::post()
1960 .uri("/access/password")
1961 .set_json(serde_json::json!({
1962 "expected_revision": 3,
1963 "action": "clear",
1964 "value": "unexpected-clear-secret"
1965 }))
1966 .to_request(),
1967 )
1968 .await;
1969 assert_eq!(clear_with_value.status(), StatusCode::BAD_REQUEST);
1970
1971 let access_file = std::fs::read_to_string(dir.path().join("access-control.json")).unwrap();
1972 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
1973 for secret in [
1974 "root-replacement-secret",
1975 "stale-root-secret",
1976 "missing-revision-secret",
1977 "ambiguous-value-secret",
1978 "ambiguous-legacy-secret",
1979 "unexpected-clear-secret",
1980 "****...****",
1981 ] {
1982 assert!(!access_file.contains(secret));
1983 assert!(!credentials.contains(secret));
1984 }
1985 }
1986
1987 #[actix_web::test]
1988 async fn stale_process_old_password_cannot_authorize_newer_access_generation() {
1989 let dir = tempfile::tempdir().unwrap();
1990 let writer = AppState::new(dir.path().to_path_buf()).await.unwrap();
1991
1992 let old_password = uuid::Uuid::new_v4().to_string();
1993 let old_salt = "11".repeat(16);
1994 let old_hash = compute_password_hash(&old_password, &old_salt).unwrap();
1995 writer
1996 .update_access_control_credentials(0, true, BTreeSet::new(), move |config| {
1997 config.access_control = Some(AccessControlConfig {
1998 password_enabled: true,
1999 password_hash: Some(old_hash),
2000 password_salt: Some(old_salt),
2001 password_configured: true,
2002 ..Default::default()
2003 });
2004 Ok(())
2005 })
2006 .await
2007 .unwrap();
2008
2009 let mut stale = AppState::new(dir.path().to_path_buf()).await.unwrap();
2010 stale.stop_config_watcher_for_test();
2011 assert_eq!(
2012 stale
2013 .config_facade
2014 .as_ref()
2015 .unwrap()
2016 .registry()
2017 .access_control
2018 .snapshot()
2019 .revision,
2020 1
2021 );
2022 {
2023 let stale_config = stale.config.read().await;
2024 assert!(verify_password(&stale_config, &old_password));
2025 }
2026
2027 let new_password = uuid::Uuid::new_v4().to_string();
2028 let new_salt = "22".repeat(16);
2029 let new_hash = compute_password_hash(&new_password, &new_salt).unwrap();
2030 writer
2031 .update_access_control_credentials(1, true, BTreeSet::new(), move |config| {
2032 let access = config.access_control.get_or_insert_with(Default::default);
2033 access.password_enabled = true;
2034 access.password_hash = Some(new_hash);
2035 access.password_salt = Some(new_salt);
2036 access.password_configured = true;
2037 Ok(())
2038 })
2039 .await
2040 .unwrap();
2041 {
2042 let stale_config = stale.config.read().await;
2043 assert!(verify_password(&stale_config, &old_password));
2044 assert!(!verify_password(&stale_config, &new_password));
2045 }
2046
2047 let app = test::init_service(
2048 App::new()
2049 .app_data(web::Data::new(stale))
2050 .route("/access/password", web::post().to(update_access_password)),
2051 )
2052 .await;
2053 let response = test::call_service(
2054 &app,
2055 TestRequest::post()
2056 .uri("/access/password")
2057 .peer_addr("203.0.113.7:5700".parse().unwrap())
2058 .insert_header((header::HOST, "bamboo.example.com"))
2059 .set_json(serde_json::json!({
2060 "expected_revision": 2,
2061 "action": "replace",
2062 "current_password": old_password,
2063 "value": "unauthorized-third-secret"
2064 }))
2065 .to_request(),
2066 )
2067 .await;
2068 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2069
2070 let exact = bamboo_config::read_exact_credential_section_snapshot(
2071 dir.path(),
2072 bamboo_config::SectionId::AccessControl,
2073 Some(2),
2074 )
2075 .unwrap();
2076 let mut durable = Config::default();
2077 exact.install_into(&mut durable);
2078 assert!(verify_password(&durable, &new_password));
2079 assert!(!verify_password(&durable, &old_password));
2080 assert!(!verify_password(&durable, "unauthorized-third-secret"));
2081 assert!(
2082 !std::fs::read_to_string(dir.path().join("credentials.json"))
2083 .unwrap()
2084 .contains("unauthorized-third-secret")
2085 );
2086 }
2087
2088 #[actix_web::test]
2089 async fn codex_run_token_is_path_and_session_scoped_and_revocation_beats_loopback_bypass() {
2090 async fn probe(req: HttpRequest) -> HttpResponse {
2091 let session_id = req
2092 .extensions()
2093 .get::<crate::codex_run_tokens::CodexRunAuthContext>()
2094 .map(|context| context.session_id.clone())
2095 .unwrap_or_else(|| "missing-context".to_string());
2096 HttpResponse::Ok().body(session_id)
2097 }
2098
2099 let data_dir = tempfile::tempdir().unwrap();
2100 let state = AppState::new(data_dir.path().to_path_buf()).await.unwrap();
2101 let tokens = state.codex_run_tokens.clone();
2102 let issued = tokens.issue("codex-child-570").unwrap();
2103 let app = test::init_service(
2104 App::new()
2105 .app_data(web::Data::new(state))
2106 .wrap(from_fn(enforce_access_password_middleware))
2107 .route("/openai/v1/responses", web::post().to(probe))
2108 .route("/openai/v1/chat/completions", web::post().to(probe)),
2109 )
2110 .await;
2111
2112 let valid = TestRequest::post()
2113 .uri("/openai/v1/responses")
2114 .peer_addr("127.0.0.1:5700".parse().unwrap())
2115 .insert_header((header::HOST, "localhost:9562"))
2116 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2117 .to_request();
2118 let valid = test::call_service(&app, valid).await;
2119 assert_eq!(valid.status(), StatusCode::OK);
2120 assert_eq!(
2121 to_bytes(valid.into_body()).await.unwrap(),
2122 "codex-child-570".as_bytes()
2123 );
2124
2125 let out_of_scope = TestRequest::post()
2126 .uri("/openai/v1/chat/completions")
2127 .peer_addr("127.0.0.1:5700".parse().unwrap())
2128 .insert_header((header::HOST, "localhost:9562"))
2129 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2130 .to_request();
2131 assert_eq!(
2132 test::call_service(&app, out_of_scope).await.status(),
2133 StatusCode::UNAUTHORIZED
2134 );
2135
2136 tokens.revoke(&issued.token_id);
2137 let revoked_on_loopback = TestRequest::post()
2138 .uri("/openai/v1/responses")
2139 .peer_addr("127.0.0.1:5700".parse().unwrap())
2140 .insert_header((header::HOST, "localhost:9562"))
2141 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2142 .to_request();
2143 assert_eq!(
2144 test::call_service(&app, revoked_on_loopback).await.status(),
2145 StatusCode::UNAUTHORIZED,
2146 "revoked bcx1_ credentials must never fall through to loopback bypass"
2147 );
2148 }
2149
2150 #[test]
2151 fn loopback_request_is_local() {
2152 let req = TestRequest::default()
2153 .peer_addr("127.0.0.1:12345".parse().unwrap())
2154 .insert_header((header::HOST, "localhost:9562"))
2155 .to_http_request();
2156 assert!(is_local_request(&req));
2157 }
2158
2159 #[test]
2160 fn private_lan_host_is_local() {
2161 let req = TestRequest::default()
2162 .insert_header((header::HOST, "192.168.0.10:9562"))
2163 .to_http_request();
2164 assert!(is_local_request(&req));
2165 }
2166
2167 #[test]
2168 fn remote_host_is_not_local_even_when_peer_is_loopback() {
2169 let req = TestRequest::default()
2170 .peer_addr("127.0.0.1:12345".parse().unwrap())
2171 .insert_header((header::HOST, "bamboo.example.com"))
2172 .to_http_request();
2173 assert!(!is_local_request(&req));
2174 }
2175
2176 #[test]
2177 fn spoofed_local_host_from_remote_peer_is_not_local() {
2178 for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
2182 let req = TestRequest::default()
2183 .peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
2185 .to_http_request();
2186 assert!(
2187 !is_local_request(&req),
2188 "remote peer + spoofed Host '{spoof}' must not be local"
2189 );
2190 let req2 = TestRequest::default()
2192 .peer_addr("203.0.113.5:40000".parse().unwrap())
2193 .insert_header(("x-forwarded-host", spoof))
2194 .to_http_request();
2195 assert!(
2196 !is_local_request(&req2),
2197 "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
2198 );
2199 }
2200 }
2201
2202 #[test]
2203 fn loopback_peer_with_no_host_is_local() {
2204 let req = TestRequest::default()
2205 .peer_addr("127.0.0.1:5000".parse().unwrap())
2206 .to_http_request();
2207 assert!(is_local_request(&req));
2208 }
2209
2210 #[test]
2211 fn password_hash_roundtrip_verifies() {
2212 let salt_hex = hex::encode([1_u8; 16]);
2213 let hash = compute_password_hash("secret", &salt_hex).unwrap();
2214 let config = test_config! {
2215 access_control: Some(AccessControlConfig {
2216 password_enabled: true,
2217 repair_required: false,
2218 password_hash: Some(hash),
2219 password_salt: Some(salt_hex),
2220 password_credential_ref: None,
2221 password_configured: false,
2222 updated_at: None,
2223 devices: Vec::new(),
2224 }),
2225 };
2226
2227 assert!(verify_password(&config, "secret"));
2228 assert!(!verify_password(&config, "wrong"));
2229 }
2230
2231 fn config_with_password() -> Config {
2234 let salt_hex = hex::encode([1_u8; 16]);
2235 let hash = compute_password_hash("secret", &salt_hex).unwrap();
2236 test_config! {
2237 access_control: Some(AccessControlConfig {
2238 password_enabled: true,
2239 repair_required: false,
2240 password_hash: Some(hash),
2241 password_salt: Some(salt_hex),
2242 password_credential_ref: None,
2243 password_configured: false,
2244 updated_at: None,
2245 devices: Vec::new(),
2246 }),
2247 }
2248 }
2249
2250 #[test]
2251 fn constant_time_eq_matches_and_rejects() {
2252 assert!(constant_time_eq(b"abcd", b"abcd"));
2253 assert!(!constant_time_eq(b"abcd", b"abce"));
2254 assert!(!constant_time_eq(b"abc", b"abcd"));
2255 }
2256
2257 #[test]
2258 fn issued_token_has_expected_format_and_verifies() {
2259 let (cred, token) = issue_device_token("iPhone 15");
2260 assert!(token.starts_with("bd1_"));
2261 assert_eq!(token.len(), "bd1_".len() + 32);
2262 assert!(cred.device_id.starts_with("bamboo_"));
2263 assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
2264 assert_eq!(cred.label, "iPhone 15");
2265 assert!(!cred.revoked);
2266 assert_ne!(cred.token_hash, token);
2268
2269 let mut config = config_with_password();
2270 config
2271 .access_control
2272 .as_mut()
2273 .unwrap()
2274 .devices
2275 .push(cred.clone());
2276
2277 assert!(verify_device_token(&config, &cred.device_id, &token));
2278 assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
2279 assert!(!verify_device_token(&config, "bamboo_unknown", &token));
2280 }
2281
2282 #[test]
2283 fn revoked_token_is_rejected() {
2284 let (mut cred, token) = issue_device_token("iPad");
2285 cred.revoked = true;
2286 let mut config = config_with_password();
2287 let device_id = cred.device_id.clone();
2288 config.access_control.as_mut().unwrap().devices.push(cred);
2289 assert!(!verify_device_token(&config, &device_id, &token));
2290 }
2291
2292 #[test]
2293 fn has_active_devices_ignores_revoked() {
2294 let mut config = config_with_password();
2295 assert!(!has_active_devices(&config));
2296 let (mut cred, _t) = issue_device_token("d");
2297 cred.revoked = true;
2298 config
2299 .access_control
2300 .as_mut()
2301 .unwrap()
2302 .devices
2303 .push(cred.clone());
2304 assert!(!has_active_devices(&config));
2305 let (cred2, _t2) = issue_device_token("d2");
2306 config.access_control.as_mut().unwrap().devices.push(cred2);
2307 assert!(has_active_devices(&config));
2308 }
2309
2310 fn remote_req() -> HttpRequest {
2311 TestRequest::default()
2312 .insert_header((header::HOST, "bamboo.example.com"))
2313 .to_http_request()
2314 }
2315
2316 fn local_req() -> HttpRequest {
2317 TestRequest::default()
2318 .insert_header((header::HOST, "localhost:9562"))
2319 .to_http_request()
2320 }
2321
2322 #[test]
2323 fn no_devices_no_password_does_not_require_credential() {
2324 let config = Config::default();
2327 assert!(!build_access_status(&config, &remote_req()).requires_password);
2328 }
2329
2330 #[test]
2331 fn password_only_gate_matches_prior_behavior() {
2332 let config = config_with_password();
2333 assert!(build_access_status(&config, &remote_req()).requires_password);
2334 assert!(!build_access_status(&config, &local_req()).requires_password);
2335 }
2336
2337 #[test]
2338 fn enabled_password_with_missing_verifier_fails_closed_for_remote_requests() {
2339 let config = test_config! {
2340 access_control: Some(AccessControlConfig {
2341 password_enabled: true,
2342 repair_required: false,
2343 password_hash: None,
2344 password_salt: None,
2345 password_credential_ref: None,
2346 password_configured: false,
2347 updated_at: None,
2348 devices: Vec::new(),
2349 }),
2350 };
2351 let remote = remote_req();
2352 let local = local_req();
2353 assert!(build_access_status(&config, &remote).password_enabled);
2354 assert!(build_access_status(&config, &remote).requires_password);
2355 assert!(!verify_password(&config, "any-password"));
2356 assert!(!request_is_authorized(&remote, &config));
2357 assert!(request_is_authorized(&local, &config));
2358 }
2359
2360 #[test]
2361 fn device_presence_requires_credential_even_without_password() {
2362 let (cred, _t) = issue_device_token("d");
2364 let config = test_config! {
2365 access_control: Some(AccessControlConfig {
2366 password_enabled: false,
2367 repair_required: false,
2368 password_hash: None,
2369 password_salt: None,
2370 password_credential_ref: None,
2371 password_configured: false,
2372 updated_at: None,
2373 devices: vec![cred],
2374 }),
2375 };
2376 assert!(build_access_status(&config, &remote_req()).requires_password);
2377 assert!(!build_access_status(&config, &local_req()).requires_password);
2379 }
2380
2381 #[test]
2382 fn valid_device_token_on_request_authenticates() {
2383 let (cred, token) = issue_device_token("d");
2384 let device_id = cred.device_id.clone();
2385 let mut config = config_with_password();
2386 config.access_control.as_mut().unwrap().devices.push(cred);
2387
2388 let req = TestRequest::default()
2389 .insert_header((header::HOST, "bamboo.example.com"))
2390 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2391 .insert_header((DEVICE_ID_HEADER, device_id))
2392 .to_http_request();
2393 assert!(request_has_valid_device_token(&req, &config));
2394
2395 let bad = TestRequest::default()
2397 .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
2398 .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
2399 .to_http_request();
2400 assert!(!request_has_valid_device_token(&bad, &config));
2401
2402 let no_id = TestRequest::default()
2404 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2405 .to_http_request();
2406 assert!(!request_has_valid_device_token(&no_id, &config));
2407 }
2408
2409 #[test]
2416 fn request_is_authorized_local_is_always_allowed() {
2417 let config = config_with_password();
2419 assert!(request_is_authorized(&local_req(), &config));
2420 }
2421
2422 #[test]
2423 fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
2424 let (cred, _t) = issue_device_token("d");
2426 let config = test_config! {
2427 access_control: Some(AccessControlConfig {
2428 password_enabled: false,
2429 repair_required: false,
2430 password_hash: None,
2431 password_salt: None,
2432 password_credential_ref: None,
2433 password_configured: false,
2434 updated_at: None,
2435 devices: vec![cred],
2436 }),
2437 };
2438 assert!(!request_is_authorized(&remote_req(), &config));
2439 }
2440
2441 #[test]
2442 fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
2443 let config = config_with_password();
2444 assert!(!request_is_authorized(&remote_req(), &config));
2445 }
2446
2447 #[test]
2448 fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
2449 let config = config_with_password();
2450 let cookie_value =
2451 access_verification_cookie_value(&config).expect("password config yields a cookie");
2452 let req = TestRequest::default()
2453 .insert_header((header::HOST, "bamboo.example.com"))
2454 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
2455 .to_http_request();
2456 assert!(request_is_authorized(&req, &config));
2457 }
2458
2459 #[test]
2460 fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
2461 let (cred, token) = issue_device_token("d");
2462 let device_id = cred.device_id.clone();
2463 let mut config = config_with_password();
2464 config.access_control.as_mut().unwrap().devices.push(cred);
2465
2466 let req = TestRequest::default()
2467 .insert_header((header::HOST, "bamboo.example.com"))
2468 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2469 .insert_header((DEVICE_ID_HEADER, device_id))
2470 .to_http_request();
2471 assert!(request_is_authorized(&req, &config));
2472 }
2473
2474 #[test]
2475 fn repair_required_rejects_valid_remote_credentials_but_keeps_local_bypass() {
2476 let (cred, token) = issue_device_token("repair-device");
2477 let device_id = cred.device_id.clone();
2478 let mut config = config_with_password();
2479 config.access_control.as_mut().unwrap().devices.push(cred);
2480 let previously_valid_cookie = access_verification_cookie_value(&config)
2481 .expect("healthy password verifier produces a cookie");
2482 config.access_control.as_mut().unwrap().repair_required = true;
2483
2484 let remote_cookie = TestRequest::default()
2485 .insert_header((header::HOST, "bamboo.example.com"))
2486 .cookie(Cookie::new(
2487 ACCESS_VERIFIED_COOKIE_NAME,
2488 previously_valid_cookie,
2489 ))
2490 .to_http_request();
2491 let remote_device = TestRequest::default()
2492 .insert_header((header::HOST, "bamboo.example.com"))
2493 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2494 .insert_header((DEVICE_ID_HEADER, device_id.clone()))
2495 .to_http_request();
2496
2497 assert!(build_access_status(&config, &remote_cookie).requires_password);
2498 assert!(!build_access_status(&config, &local_req()).requires_password);
2499 assert!(!verify_password(&config, "secret"));
2500 assert!(!verify_device_token(&config, &device_id, &token));
2501 assert!(access_verification_cookie_value(&config).is_none());
2502 assert!(!request_is_authorized(&remote_cookie, &config));
2503 assert!(!request_is_authorized(&remote_device, &config));
2504 assert!(request_is_authorized(&local_req(), &config));
2505 }
2506
2507 #[test]
2508 fn request_is_authorized_no_password_no_devices_is_open() {
2509 let config = Config::default();
2512 assert!(request_is_authorized(&remote_req(), &config));
2513 }
2514
2515 #[test]
2516 fn stream_is_public_but_sibling_routes_are_not() {
2517 assert!(is_public_access_route("/v2/stream"));
2519 assert!(is_public_access_route("/v2/pair"));
2520 assert!(!is_public_access_route("/v2/pair/code"));
2521 assert!(!is_public_access_route("/v2/devices"));
2522 assert!(!is_public_access_route("/v2/devices/bamboo_x"));
2523 }
2524
2525 #[test]
2526 fn health_probes_are_public() {
2527 assert!(is_public_access_route("/healthz"));
2530 assert!(is_public_access_route("/readyz"));
2531 assert!(is_public_access_route("/api/v1/health"));
2532 }
2533
2534 #[test]
2535 fn public_access_status_routes_are_public_under_both_version_prefixes() {
2536 for prefix in ["/v1", "/api/v1"] {
2542 assert!(
2543 is_public_access_route(&format!("{prefix}/bamboo/access/status")),
2544 "{prefix}/bamboo/access/status must be public"
2545 );
2546 assert!(
2547 is_public_access_route(&format!("{prefix}/bamboo/access/verify")),
2548 "{prefix}/bamboo/access/verify must be public"
2549 );
2550 assert!(
2553 !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
2554 "{prefix}/bamboo/access/password must stay gated"
2555 );
2556 }
2557 }
2558
2559 #[test]
2562 fn generated_pairing_code_is_six_digits() {
2563 for _ in 0..1000 {
2564 let code = generate_pairing_code();
2565 assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
2566 assert!(
2567 code.chars().all(|c| c.is_ascii_digit()),
2568 "code {code:?} must be all digits"
2569 );
2570 }
2571 }
2572
2573 #[test]
2574 fn pairing_code_expiry_predicate() {
2575 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
2577 assert!(!fresh.is_expired());
2578
2579 let zero = PairingCodeEntry::new(Duration::from_secs(0));
2581 assert!(zero.is_expired());
2582
2583 let past = PairingCodeEntry {
2585 expires_at: Instant::now() - Duration::from_secs(1),
2586 };
2587 assert!(past.is_expired());
2588 }
2589
2590 #[test]
2591 fn purge_expired_codes_drops_only_expired() {
2592 let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
2593 codes.insert(
2594 "live".into(),
2595 PairingCodeEntry::new(Duration::from_secs(120)),
2596 );
2597 codes.insert(
2598 "dead".into(),
2599 PairingCodeEntry {
2600 expires_at: Instant::now() - Duration::from_secs(1),
2601 },
2602 );
2603 purge_expired_codes(&codes);
2604 assert!(codes.contains_key("live"));
2605 assert!(!codes.contains_key("dead"));
2606 }
2607
2608 #[test]
2609 fn guard_trips_cooldown_after_threshold() {
2610 let guard = PairingCodeGuard::default();
2611 assert!(!guard.in_cooldown());
2612 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
2614 assert!(!guard.record_failure());
2615 assert!(!guard.in_cooldown());
2616 }
2617 assert!(guard.record_failure());
2619 assert!(guard.in_cooldown());
2620 }
2621
2622 #[test]
2623 fn guard_success_resets_failures() {
2624 let guard = PairingCodeGuard::default();
2625 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
2626 guard.record_failure();
2627 }
2628 guard.record_success();
2629 assert!(!guard.record_failure());
2631 assert!(!guard.in_cooldown());
2632 }
2633
2634 #[test]
2635 fn guard_clears_elapsed_cooldown() {
2636 let guard = PairingCodeGuard::default();
2637 {
2639 let mut state = guard.inner.lock().unwrap();
2640 state.failures = PAIRING_FAILURE_THRESHOLD;
2641 state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
2642 }
2643 assert!(!guard.in_cooldown());
2645 assert!(!guard.record_failure(), "counter was reset to 0");
2646 }
2647
2648 #[test]
2651 fn root_guard_trips_cooldown_after_threshold_per_key() {
2652 let guard = RootPasswordGuard::default();
2653 let key = "203.0.113.7";
2654 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
2656 guard.record_failure(key);
2657 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2658 }
2659 guard.record_failure(key);
2661 match guard.check(key) {
2662 RootGuardDecision::Cooldown { retry_after_secs } => {
2663 assert!(retry_after_secs >= 1);
2664 assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
2665 }
2666 RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
2667 }
2668 }
2669
2670 #[test]
2671 fn root_guard_keys_are_independent() {
2672 let guard = RootPasswordGuard::default();
2674 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
2675 guard.record_failure("198.51.100.1");
2676 }
2677 assert!(matches!(
2678 guard.check("198.51.100.1"),
2679 RootGuardDecision::Cooldown { .. }
2680 ));
2681 assert!(matches!(
2683 guard.check("198.51.100.2"),
2684 RootGuardDecision::Allow
2685 ));
2686 }
2687
2688 #[test]
2689 fn root_guard_success_resets_key() {
2690 let guard = RootPasswordGuard::default();
2691 let key = "203.0.113.9";
2692 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
2693 guard.record_failure(key);
2694 }
2695 guard.record_success(key);
2696 guard.record_failure(key);
2698 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2699 }
2700
2701 #[test]
2702 fn root_guard_clears_elapsed_cooldown() {
2703 let guard = RootPasswordGuard::default();
2704 let key = "203.0.113.10";
2705 guard.inner.insert(
2707 key.to_string(),
2708 RootAttemptState {
2709 failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
2710 cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
2711 },
2712 );
2713 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2715 guard.record_failure(key);
2717 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2718 }
2719
2720 #[test]
2721 fn root_guard_evicts_inert_keys_past_the_cap() {
2722 let guard = RootPasswordGuard::default();
2723 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
2726 guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
2727 }
2728 assert!(
2729 guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
2730 "inert keys must be swept so the map stays bounded (was {})",
2731 guard.inner.len()
2732 );
2733 let hot = "203.0.113.200";
2735 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
2736 guard.record_failure(hot);
2737 }
2738 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
2739 guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
2740 }
2741 assert!(
2742 matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
2743 "a key in active cooldown must survive eviction sweeps"
2744 );
2745 }
2746
2747 #[test]
2748 fn root_throttle_key_exempts_loopback_and_keys_remote() {
2749 assert!(root_throttle_key(&local_req()).is_none());
2751
2752 let remote = TestRequest::default()
2754 .peer_addr("203.0.113.5:443".parse().unwrap())
2755 .insert_header((header::HOST, "bamboo.example.com"))
2756 .to_http_request();
2757 assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
2758 }
2759
2760 #[test]
2761 fn client_ip_key_strips_v4_mapped_prefix() {
2762 let req = TestRequest::default()
2763 .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
2764 .to_http_request();
2765 assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
2766 }
2767
2768 #[test]
2769 fn device_summary_excludes_secret_material() {
2770 let (cred, _t) = issue_device_token("iPhone");
2773 let summary = DeviceSummary::from_credential(&cred);
2774 let json = serde_json::to_value(&summary).unwrap();
2775 let obj = json.as_object().unwrap();
2776 assert!(
2777 !obj.contains_key("token_hash"),
2778 "must not expose token_hash"
2779 );
2780 assert!(
2781 !obj.contains_key("token_salt"),
2782 "must not expose token_salt"
2783 );
2784 let serialized = serde_json::to_string(&summary).unwrap();
2786 assert!(!serialized.contains(&cred.token_hash));
2787 assert!(!serialized.contains(&cred.token_salt));
2788 assert!(obj.contains_key("device_id"));
2790 assert!(obj.contains_key("label"));
2791 assert!(obj.contains_key("created_at"));
2792 assert!(obj.contains_key("revoked"));
2793 }
2794}