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(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub(crate) enum BootstrapAuthPolicy {
53 Open,
56 CredentialRequired,
59 RepairRequired,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66#[serde(rename_all = "snake_case")]
67pub(crate) enum BootstrapRequestState {
68 LocalBypass,
70 Authenticated,
72 Unauthenticated,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
85pub(crate) struct BootstrapAccessSnapshot {
86 pub(crate) policy: BootstrapAuthPolicy,
87 pub(crate) request_state: BootstrapRequestState,
88 pub(crate) password_enabled: bool,
91 pub(crate) device_auth_enabled: bool,
95}
96
97#[derive(Serialize)]
98pub(crate) struct AccessStatusEnvelope {
99 #[serde(flatten)]
100 pub runtime: AccessStatusResponse,
101 pub revision: u64,
104 pub status: bamboo_config::SectionStatus,
105 pub source_kind: bamboo_config::SectionSourceKind,
106 pub loaded_at: chrono::DateTime<Utc>,
107 pub last_error: Option<String>,
108 pub password_configured: bool,
109 pub credential_state: CredentialState,
110 pub credential_ref: Option<String>,
111 pub credential_source: Option<bamboo_config::CredentialSource>,
112 pub credential_updated_at: Option<String>,
113 pub credential_health: bamboo_config::CredentialStoreHealth,
114}
115
116#[derive(Debug, Deserialize)]
117pub struct VerifyPasswordRequest {
118 pub password: String,
119}
120
121#[derive(Serialize)]
122pub struct VerifyPasswordResponse {
123 pub success: bool,
124}
125
126#[derive(Clone, Copy, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum AccessPasswordAction {
129 Replace,
130 Clear,
131}
132
133#[derive(Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct UpdatePasswordRequest {
136 pub expected_revision: u64,
138 #[serde(default)]
141 pub action: Option<AccessPasswordAction>,
142 #[serde(default)]
143 pub current_password: String,
144 #[serde(default)]
145 pub new_password: String,
146 #[serde(default)]
147 pub value: String,
148}
149
150impl std::fmt::Debug for UpdatePasswordRequest {
151 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 formatter
153 .debug_struct("UpdatePasswordRequest")
154 .field("expected_revision", &self.expected_revision)
155 .field(
156 "action",
157 &self.action.map(|action| match action {
158 AccessPasswordAction::Replace => "replace",
159 AccessPasswordAction::Clear => "clear",
160 }),
161 )
162 .field(
163 "current_password",
164 &(!self.current_password.is_empty()).then_some("[REDACTED]"),
165 )
166 .field(
167 "replacement",
168 &(!(self.value.is_empty() && self.new_password.is_empty())).then_some("[REDACTED]"),
169 )
170 .finish()
171 }
172}
173
174#[derive(Serialize)]
175pub(crate) struct UpdatePasswordResponse {
176 pub success: bool,
177 pub password_enabled: bool,
178 pub revision: u64,
179 pub section: bamboo_config::SectionEnvelope<serde_json::Value>,
180 pub credential: CredentialStatusView,
181 pub credential_health: bamboo_config::CredentialStoreHealth,
182}
183
184const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
185const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
186const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
187
188fn normalize_ip(ip: &str) -> &str {
189 let ip = ip.trim();
190 ip.strip_prefix("::ffff:").unwrap_or(ip)
191}
192
193fn split_host_and_port(value: &str) -> &str {
194 let candidate = value.trim();
195 if candidate.is_empty() {
196 return candidate;
197 }
198
199 let without_brackets = candidate
200 .strip_prefix('[')
201 .and_then(|v| v.strip_suffix(']'))
202 .unwrap_or(candidate);
203
204 if without_brackets.parse::<IpAddr>().is_ok() {
205 return without_brackets;
206 }
207
208 without_brackets
209 .split(':')
210 .next()
211 .unwrap_or(without_brackets)
212 .trim()
213}
214
215fn is_local_host(host: &str) -> bool {
216 let normalized = split_host_and_port(host)
217 .trim()
218 .trim_end_matches('.')
219 .to_lowercase();
220 if normalized.is_empty() {
221 return false;
222 }
223
224 if normalized == "localhost" || normalized.ends_with(".local") {
225 return true;
226 }
227
228 let normalized = normalize_ip(&normalized);
229 match normalized.parse::<IpAddr>() {
230 Ok(IpAddr::V4(v4)) => {
231 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
232 }
233 Ok(IpAddr::V6(v6)) => {
234 v6.is_loopback()
235 || v6.is_unique_local()
236 || v6.is_unicast_link_local()
237 || v6.is_unspecified()
238 }
239 Err(_) => false,
240 }
241}
242
243fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
244 let mut candidates = Vec::new();
245
246 for header_name in [
247 header::HOST,
248 header::HeaderName::from_static("x-forwarded-host"),
249 header::HeaderName::from_static("x-original-host"),
250 ] {
251 if let Some(value) = req
252 .headers()
253 .get(&header_name)
254 .and_then(|v| v.to_str().ok())
255 {
256 for part in value.split(',') {
257 let host = part.trim();
258 if !host.is_empty() {
259 candidates.push(host.to_string());
260 }
261 }
262 }
263 }
264
265 if let Some(uri_host) = req.uri().host() {
266 let host = uri_host.trim();
267 if !host.is_empty() {
268 candidates.push(host.to_string());
269 }
270 }
271
272 candidates
273}
274
275fn is_local_request(req: &HttpRequest) -> bool {
276 let peer_local: Option<bool> = req
287 .peer_addr()
288 .map(|peer| is_local_host(&peer.ip().to_string()));
289
290 let host_candidates = request_host_candidates(req);
291 if !host_candidates.is_empty() {
292 let host_local = host_candidates.iter().all(|host| is_local_host(host));
293 return host_local && peer_local != Some(false);
313 }
314
315 peer_local.unwrap_or(false)
320}
321
322fn client_ip_key(req: &HttpRequest) -> Option<String> {
340 if let Some(peer) = req.peer_addr() {
341 return Some(normalize_ip(&peer.ip().to_string()).to_string());
342 }
343
344 let conn = req.connection_info();
345 for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
346 .into_iter()
347 .flatten()
348 {
349 let normalized = normalize_ip(candidate).trim();
350 if !normalized.is_empty() {
351 return Some(normalized.to_string());
352 }
353 }
354
355 None
356}
357
358fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
359 let salt = hex::decode(salt_hex).ok()?;
360 let mut hasher = Sha256::new();
361 hasher.update(&salt);
362 hasher.update(password.as_bytes());
363 Some(hex::encode(hasher.finalize()))
364}
365
366fn verify_password(config: &Config, password: &str) -> bool {
367 let Some(access) = config.access_control.as_ref() else {
368 return false;
369 };
370 if access.repair_required || !access.password_enabled {
371 return false;
372 }
373
374 let (Some(hash), Some(salt)) = (
375 access.password_hash.as_deref(),
376 access.password_salt.as_deref(),
377 ) else {
378 return false;
379 };
380
381 compute_password_hash(password, salt)
382 .map(|computed| computed == hash)
383 .unwrap_or(false)
384}
385
386const DEVICE_TOKEN_PREFIX: &str = "bd1_";
395const DEVICE_ID_PREFIX: &str = "bamboo_";
397const DEVICE_ID_HEADER: &str = "x-device-id";
400
401fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
408 if a.len() != b.len() {
409 return false;
410 }
411 let mut diff: u8 = 0;
412 for (x, y) in a.iter().zip(b.iter()) {
413 diff |= x ^ y;
414 }
415 diff == 0
416}
417
418fn random_hex(len: usize) -> String {
420 let mut bytes = vec![0_u8; len];
421 rand::rng().fill(&mut bytes);
422 hex::encode(bytes)
423}
424
425pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
431 let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
432 let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
433 let salt_hex = random_hex(16);
434 let token_hash =
438 compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
439 let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
440
441 let credential = DeviceCredential {
442 device_id,
443 label: label.to_string(),
444 token_hash,
445 token_salt: salt_hex,
446 token_credential_ref: None,
447 token_configured: false,
448 created_at,
449 last_used_at: None,
450 revoked: false,
451 };
452 (credential, token)
453}
454
455pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
460 let Some(access) = config.access_control.as_ref() else {
461 return false;
462 };
463 if access.repair_required {
464 return false;
465 }
466 let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
469 return false;
470 };
471 if device.revoked {
472 return false;
473 }
474 let Some(computed) = compute_password_hash(token, &device.token_salt) else {
475 return false;
476 };
477 constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
478}
479
480fn has_active_devices(config: &Config) -> bool {
484 config
485 .access_control
486 .as_ref()
487 .map(|access| access.devices.iter().any(|d| !d.revoked))
488 .unwrap_or(false)
489}
490
491fn access_repair_required(config: &Config) -> bool {
492 config
493 .access_control
494 .as_ref()
495 .is_some_and(|access| access.repair_required)
496}
497
498fn presented_bearer_token(req: &HttpRequest) -> Option<&str> {
506 let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
507 Some(
508 auth.strip_prefix("Bearer ")
509 .or_else(|| auth.strip_prefix("bearer "))?
510 .trim(),
511 )
512}
513
514fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
515 let token = presented_bearer_token(req)?;
516 if !token.starts_with(DEVICE_TOKEN_PREFIX) {
517 return None;
518 }
519 let device_id = req
520 .headers()
521 .get(DEVICE_ID_HEADER)?
522 .to_str()
523 .ok()?
524 .trim()
525 .to_string();
526 if device_id.is_empty() {
527 return None;
528 }
529 Some((device_id, token.to_string()))
530}
531
532fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
534 match presented_device_token(req) {
535 Some((device_id, token)) => verify_device_token(config, &device_id, &token),
536 None => false,
537 }
538}
539
540fn access_verification_cookie_value(config: &Config) -> Option<String> {
541 let access = config.access_control.as_ref()?;
542 if access.repair_required || !access.password_enabled {
543 return None;
544 }
545
546 let hash = access.password_hash.as_deref()?.trim();
547 let salt = access.password_salt.as_deref()?.trim();
548 if hash.is_empty() || salt.is_empty() {
549 return None;
550 }
551
552 let mut hasher = Sha256::new();
553 hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
554 hasher.update(b":");
555 hasher.update(hash.as_bytes());
556 hasher.update(b":");
557 hasher.update(salt.as_bytes());
558 Some(format!(
559 "{}:{}",
560 ACCESS_VERIFIED_COOKIE_VERSION,
561 hex::encode(hasher.finalize())
562 ))
563}
564
565fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
566 let expected = match access_verification_cookie_value(config) {
567 Some(value) => value,
568 None => return false,
569 };
570
571 req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
572 .map(|cookie| cookie.value() == expected)
573 .unwrap_or(false)
574}
575
576fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
577 let value = access_verification_cookie_value(config)?;
578 Some(
579 Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
580 .path("/")
581 .http_only(true)
582 .same_site(SameSite::Lax)
583 .secure(secure)
584 .max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
585 .finish(),
586 )
587}
588
589const PUBLIC_VERSIONED_SUFFIXES: &[&str] =
596 &["/health", "/bamboo/access/status", "/bamboo/access/verify"];
597
598fn is_public_access_route(path: &str) -> bool {
599 if path == "/api/v1/bootstrap" {
603 return true;
604 }
605
606 for prefix in ["/api/v1", "/v1"] {
607 if let Some(suffix) = path.strip_prefix(prefix) {
608 if PUBLIC_VERSIONED_SUFFIXES.contains(&suffix) {
609 return true;
610 }
611 }
612 }
613
614 matches!(
615 path,
616 "/healthz"
619 | "/readyz"
620 | "/v2/pair"
624 | "/v2/stream"
635 )
636}
637
638pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
647 if access_repair_required(config) {
648 return is_local_request(req);
654 }
655 !build_access_status(config, req).requires_password
656 || request_has_verified_access_cookie(req, config)
657 || request_has_valid_device_token(req, config)
658}
659
660pub(crate) fn bootstrap_access_snapshot(
669 config: &Config,
670 req: &HttpRequest,
671) -> BootstrapAccessSnapshot {
672 let password_enabled = config
673 .access_control
674 .as_ref()
675 .is_some_and(|access| access.password_enabled);
676 let device_auth_enabled = has_active_devices(config);
677 let repair_required = access_repair_required(config);
678
679 let policy = if repair_required {
680 BootstrapAuthPolicy::RepairRequired
681 } else if password_enabled || device_auth_enabled {
682 BootstrapAuthPolicy::CredentialRequired
683 } else {
684 BootstrapAuthPolicy::Open
685 };
686
687 let request_state = if is_local_request(req) {
688 BootstrapRequestState::LocalBypass
689 } else if repair_required {
690 BootstrapRequestState::Unauthenticated
694 } else if request_has_verified_access_cookie(req, config)
695 || request_has_valid_device_token(req, config)
696 {
697 BootstrapRequestState::Authenticated
698 } else {
699 BootstrapRequestState::Unauthenticated
700 };
701
702 BootstrapAccessSnapshot {
703 policy,
704 request_state,
705 password_enabled,
706 device_auth_enabled,
707 }
708}
709
710pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
711 req: ServiceRequest,
712 next: Next<B>,
713) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
714 let path = req.path().to_string();
715 if is_public_access_route(&path) {
716 return next
717 .call(req)
718 .await
719 .map(ServiceResponse::map_into_left_body);
720 }
721
722 let app_state = match req.app_data::<web::Data<AppState>>() {
723 Some(state) => state.clone(),
724 None => {
725 return next
726 .call(req)
727 .await
728 .map(ServiceResponse::map_into_left_body)
729 }
730 };
731
732 if let Some(token) = presented_bearer_token(req.request()) {
737 if token.starts_with(crate::codex_run_tokens::CODEX_RUN_TOKEN_PREFIX) {
738 let scoped_path = matches!(path.as_str(), "/openai/v1/responses" | "/openai/v1/models");
739 if scoped_path {
740 if let Some(context) = app_state.codex_run_tokens.verify(token) {
741 req.extensions_mut().insert(context);
742 return next
743 .call(req)
744 .await
745 .map(ServiceResponse::map_into_left_body);
746 }
747 }
748 let response = AppError::Unauthorized(
749 "invalid, expired, or out-of-scope Codex run credential".to_string(),
750 )
751 .error_response()
752 .map_into_right_body();
753 return Ok(req.into_response(response));
754 }
755 }
756
757 let config = app_state.config.read().await.clone();
758 if request_is_authorized(req.request(), &config) {
765 return next
766 .call(req)
767 .await
768 .map(ServiceResponse::map_into_left_body);
769 }
770
771 let response = AppError::Unauthorized("access credential verification required".to_string())
772 .error_response()
773 .map_into_right_body();
774 Ok(req.into_response(response))
775}
776
777fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
778 let password_enabled = config
779 .access_control
780 .as_ref()
781 .map(|access| access.password_enabled)
782 .unwrap_or(false);
783 let local_bypass = is_local_request(req);
784 let credential_required =
788 password_enabled || has_active_devices(config) || access_repair_required(config);
789
790 AccessStatusResponse {
791 password_enabled,
792 local_bypass,
793 requires_password: credential_required && !local_bypass,
794 }
795}
796
797fn build_exact_access_status(
798 config: &Config,
799 _statuses: &[bamboo_config::CredentialStatus],
800 _health: &bamboo_config::CredentialStoreHealth,
801 req: &HttpRequest,
802) -> AccessStatusResponse {
803 let password_enabled = config
807 .access_control
808 .as_ref()
809 .is_some_and(|access| access.password_enabled);
810 let has_device = config
811 .access_control
812 .as_ref()
813 .is_some_and(|access| access.devices.iter().any(|device| !device.revoked));
814 let repair_required = access_repair_required(config);
815 let local_bypass = is_local_request(req);
816 AccessStatusResponse {
817 password_enabled,
818 local_bypass,
819 requires_password: (password_enabled || has_device || repair_required) && !local_bypass,
820 }
821}
822
823pub async fn get_access_status(
824 req: HttpRequest,
825 app_state: web::Data<AppState>,
826) -> Result<HttpResponse, AppError> {
827 let exact = app_state
828 .read_exact_credential_section(bamboo_config::SectionId::AccessControl)
829 .await?;
830 let section = exact.section;
831 let config = exact.config;
832 let reference = config
833 .access_control
834 .as_ref()
835 .and_then(|access| access.password_credential_ref.clone());
836 let statuses = exact.metadata.credential_statuses;
837 let credential_health = exact.metadata.credential_health;
838 let credential = reference.as_ref().and_then(|reference| {
839 statuses
840 .iter()
841 .find(|status| &status.credential_ref == reference)
842 });
843 let expected_configured = config
844 .access_control
845 .as_ref()
846 .is_some_and(|access| access.password_configured);
847 let credential = credential_status_view(
848 reference.as_ref(),
849 expected_configured,
850 credential,
851 &credential_health,
852 );
853 Ok(HttpResponse::Ok().json(AccessStatusEnvelope {
854 runtime: build_exact_access_status(&config, &statuses, &credential_health, &req),
855 revision: section.revision,
856 status: section.status,
857 source_kind: section.source_kind,
858 loaded_at: section.loaded_at,
859 last_error: section.last_error,
860 password_configured: credential.configured,
861 credential_state: credential.state,
862 credential_ref: credential.credential_ref,
863 credential_source: credential.source,
864 credential_updated_at: credential
865 .updated_at
866 .map(|updated_at| updated_at.to_rfc3339()),
867 credential_health,
868 }))
869}
870
871pub async fn verify_access_password(
872 req: HttpRequest,
873 payload: web::Json<VerifyPasswordRequest>,
874 app_state: web::Data<AppState>,
875) -> Result<HttpResponse, AppError> {
876 let password = payload.password.trim();
877 if password.is_empty() {
878 return Err(AppError::BadRequest("password is required".to_string()));
879 }
880
881 let throttle_key = root_throttle_key(&req);
885 if let Some(key) = throttle_key.as_deref() {
886 if let RootGuardDecision::Cooldown { retry_after_secs } =
887 app_state.root_password_guard.check(key)
888 {
889 return Ok(too_many_requests_response(retry_after_secs));
890 }
891 }
892
893 let config = app_state.config.read().await.clone();
894 if !verify_password(&config, password) {
895 if let Some(key) = throttle_key.as_deref() {
896 app_state.root_password_guard.record_failure(key);
897 }
898 return Err(AppError::Unauthorized("invalid password".to_string()));
899 }
900
901 if let Some(key) = throttle_key.as_deref() {
903 app_state.root_password_guard.record_success(key);
904 }
905
906 let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
907 let cookie = build_access_verified_cookie(&config, secure)
908 .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
909
910 Ok(HttpResponse::Ok()
911 .cookie(cookie)
912 .json(VerifyPasswordResponse { success: true }))
913}
914
915pub async fn update_access_password(
916 req: HttpRequest,
917 app_state: web::Data<AppState>,
918 payload: web::Json<UpdatePasswordRequest>,
919) -> Result<HttpResponse, AppError> {
920 let local_bypass = is_local_request(&req);
921 let payload = payload.into_inner();
922 let action = payload.action.unwrap_or(AccessPasswordAction::Replace);
923 if !payload.value.is_empty() && !payload.new_password.is_empty() {
924 return Err(AppError::BadRequest(
925 "password replace must use either value or new_password, not both".to_string(),
926 ));
927 }
928 let replacement = if payload.value.is_empty() {
929 payload.new_password.trim()
930 } else {
931 payload.value.trim()
932 };
933 if matches!(action, AccessPasswordAction::Replace) && replacement.is_empty() {
934 return Err(AppError::BadRequest(
935 "password replace requires a nonempty value".to_string(),
936 ));
937 }
938 if matches!(action, AccessPasswordAction::Replace)
939 && bamboo_config::patch::is_masked_api_key(replacement)
940 {
941 return Err(AppError::BadRequest(
942 "password value must not be a mask".to_string(),
943 ));
944 }
945 if matches!(action, AccessPasswordAction::Clear)
946 && !(payload.value.is_empty() && payload.new_password.is_empty())
947 {
948 return Err(AppError::BadRequest(
949 "password clear must not include a replacement value".to_string(),
950 ));
951 }
952
953 let expected_revision = payload.expected_revision;
954 let current_password = payload.current_password.trim().to_string();
955 let (password_hash, salt_hex) = if matches!(action, AccessPasswordAction::Replace) {
956 let mut salt_bytes = [0_u8; 16];
957 rand::rng().fill(&mut salt_bytes);
958 let salt_hex = hex::encode(salt_bytes);
959 let password_hash = compute_password_hash(replacement, &salt_hex).ok_or_else(|| {
960 AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
961 })?;
962 (Some(password_hash), Some(salt_hex))
963 } else {
964 (None, None)
965 };
966 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
967
968 let (updated, revision, metadata, section) = app_state
969 .update_access_control_credentials(
970 expected_revision,
971 true,
972 BTreeSet::new(),
973 move |config| {
974 let password_already_enabled = config
978 .access_control
979 .as_ref()
980 .is_some_and(|access| access.password_enabled);
981 if password_already_enabled && !local_bypass {
982 if current_password.is_empty() {
983 return Err(AppError::Unauthorized(
984 "current_password is required".to_string(),
985 ));
986 }
987 if !verify_password(config, ¤t_password) {
988 return Err(AppError::Unauthorized(
989 "invalid current password".to_string(),
990 ));
991 }
992 }
993 let access = config.access_control.get_or_insert_with(Default::default);
998 access.password_enabled = matches!(action, AccessPasswordAction::Replace);
999 access.password_hash = password_hash.clone();
1000 access.password_salt = salt_hex.clone();
1001 access.updated_at = Some(updated_at.clone());
1002 Ok(())
1003 },
1004 )
1005 .await?;
1006 let configured = updated
1007 .access_control
1008 .as_ref()
1009 .is_some_and(|access| access.password_configured);
1010 let section = section.ok_or_else(|| {
1011 AppError::InternalError(anyhow::anyhow!(
1012 "access-control mutation completed without a typed section envelope"
1013 ))
1014 })?;
1015 let reference =
1016 bamboo_config::config_crypto::access_password_credential_ref().map_err(|error| {
1017 AppError::InternalError(anyhow::anyhow!(
1018 "access-control credential reference is invalid: {error}"
1019 ))
1020 })?;
1021 let credential_status = metadata.status(&reference);
1022 let credential = credential_status_view(
1023 Some(&reference),
1024 configured,
1025 Some(&credential_status),
1026 &metadata.credential_health,
1027 );
1028
1029 Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
1030 success: true,
1031 password_enabled: configured,
1032 revision,
1033 section,
1034 credential,
1035 credential_health: metadata.credential_health,
1036 }))
1037}
1038
1039#[derive(Debug, Deserialize)]
1042pub struct PairDeviceRequest {
1043 #[serde(default)]
1045 pub root_password: String,
1046 #[serde(default)]
1050 pub code: String,
1051 #[serde(default)]
1053 pub label: String,
1054}
1055
1056#[derive(Serialize)]
1057pub struct PairDeviceResponse {
1058 pub device_id: String,
1059 pub device_token: String,
1061 pub expires_hint: &'static str,
1062}
1063
1064pub async fn pair_device(
1076 req: HttpRequest,
1077 payload: web::Json<PairDeviceRequest>,
1078 app_state: web::Data<AppState>,
1079) -> Result<HttpResponse, AppError> {
1080 let label = payload.label.trim();
1081 if label.is_empty() {
1082 return Err(AppError::BadRequest("label is required".to_string()));
1083 }
1084
1085 let code = payload.code.trim();
1086 let root_password = payload.root_password.trim();
1087
1088 if !code.is_empty() {
1091 return pair_device_with_code(&app_state, code, label).await;
1092 }
1093 if !root_password.is_empty() {
1094 return pair_device_with_root_password(&req, &app_state, root_password, label).await;
1095 }
1096
1097 Err(AppError::BadRequest(
1098 "provide either a root_password or a one-time pairing code".to_string(),
1099 ))
1100}
1101
1102async fn pair_device_with_root_password(
1105 req: &HttpRequest,
1106 app_state: &AppState,
1107 root_password: &str,
1108 label: &str,
1109) -> Result<HttpResponse, AppError> {
1110 let throttle_key = root_throttle_key(req);
1114 if let Some(key) = throttle_key.as_deref() {
1115 if let RootGuardDecision::Cooldown { retry_after_secs } =
1116 app_state.root_password_guard.check(key)
1117 {
1118 return Ok(too_many_requests_response(retry_after_secs));
1119 }
1120 }
1121
1122 let config = app_state.config.read().await.clone();
1123
1124 let password_enabled = config
1125 .access_control
1126 .as_ref()
1127 .map(|access| access.password_enabled)
1128 .unwrap_or(false);
1129 if !password_enabled {
1130 return Err(AppError::BadRequest(
1131 "set an access password first: the owner root password is required to authorize device pairing".to_string(),
1132 ));
1133 }
1134
1135 if !verify_password(&config, root_password) {
1136 if let Some(key) = throttle_key.as_deref() {
1137 app_state.root_password_guard.record_failure(key);
1138 }
1139 return Err(AppError::Unauthorized("invalid root password".to_string()));
1140 }
1141
1142 if let Some(key) = throttle_key.as_deref() {
1144 app_state.root_password_guard.record_success(key);
1145 }
1146
1147 persist_new_device(app_state, label).await
1148}
1149
1150async fn pair_device_with_code(
1154 app_state: &AppState,
1155 code: &str,
1156 label: &str,
1157) -> Result<HttpResponse, AppError> {
1158 if app_state.pairing_code_guard.in_cooldown() {
1161 return Err(AppError::Unauthorized(
1162 "too many failed pairing attempts — try again later".to_string(),
1163 ));
1164 }
1165
1166 let consumed = app_state.pairing_codes.remove(code);
1171 let valid = match consumed {
1172 Some((_k, entry)) => !entry.is_expired(),
1173 None => false,
1174 };
1175
1176 if !valid {
1177 if app_state.pairing_code_guard.record_failure() {
1181 app_state.pairing_codes.clear();
1182 }
1183 return Err(AppError::Unauthorized(
1184 "invalid or expired pairing code".to_string(),
1185 ));
1186 }
1187
1188 app_state.pairing_code_guard.record_success();
1190 persist_new_device(app_state, label).await
1191}
1192
1193async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
1197 let (credential, token) = issue_device_token(label);
1198 let device_id = credential.device_id.clone();
1199
1200 let expected_revision = access_section_revision(app_state)?;
1201 app_state
1202 .update_access_control_credentials(
1203 expected_revision,
1204 false,
1205 BTreeSet::from([device_id.clone()]),
1206 move |config| {
1207 let access = config.access_control.get_or_insert_with(Default::default);
1210 access.devices.push(credential.clone());
1211 Ok(())
1212 },
1213 )
1214 .await?;
1215
1216 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1219 device_id,
1220 device_token: token,
1221 expires_hint: "rotate-on-demand",
1222 }))
1223}
1224
1225const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
1229const PAIRING_FAILURE_THRESHOLD: u32 = 10;
1231const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
1233
1234#[derive(Debug, Clone)]
1237pub struct PairingCodeEntry {
1238 expires_at: Instant,
1239}
1240
1241impl PairingCodeEntry {
1242 pub(crate) fn new(ttl: Duration) -> Self {
1243 Self {
1244 expires_at: Instant::now() + ttl,
1245 }
1246 }
1247
1248 pub fn is_expired(&self) -> bool {
1251 Instant::now() >= self.expires_at
1252 }
1253}
1254
1255#[derive(Debug, Default)]
1276pub struct PairingCodeGuard {
1277 inner: Mutex<PairingGuardState>,
1278}
1279
1280#[derive(Debug, Default)]
1281struct PairingGuardState {
1282 failures: u32,
1283 cooldown_until: Option<Instant>,
1285}
1286
1287impl PairingCodeGuard {
1288 pub fn in_cooldown(&self) -> bool {
1291 let mut state = self.inner.lock().recover_poison();
1292 match state.cooldown_until {
1293 Some(until) if Instant::now() < until => true,
1294 Some(_) => {
1295 state.cooldown_until = None;
1297 state.failures = 0;
1298 false
1299 }
1300 None => false,
1301 }
1302 }
1303
1304 pub fn record_failure(&self) -> bool {
1307 let mut state = self.inner.lock().recover_poison();
1308 state.failures = state.failures.saturating_add(1);
1309 if state.failures >= PAIRING_FAILURE_THRESHOLD {
1310 state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
1311 true
1312 } else {
1313 false
1314 }
1315 }
1316
1317 pub fn record_success(&self) {
1319 let mut state = self.inner.lock().recover_poison();
1320 state.failures = 0;
1321 state.cooldown_until = None;
1322 }
1323}
1324
1325const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
1344const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
1346const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
1352
1353#[derive(Debug, Default, Clone)]
1355struct RootAttemptState {
1356 failures: u32,
1357 cooldown_until: Option<Instant>,
1359}
1360
1361#[derive(Debug, Default)]
1381pub struct RootPasswordGuard {
1382 inner: dashmap::DashMap<String, RootAttemptState>,
1383}
1384
1385pub enum RootGuardDecision {
1387 Allow,
1389 Cooldown { retry_after_secs: u64 },
1391}
1392
1393impl RootPasswordGuard {
1394 pub fn check(&self, key: &str) -> RootGuardDecision {
1397 let now = Instant::now();
1398 if let Some(mut entry) = self.inner.get_mut(key) {
1399 if let Some(until) = entry.cooldown_until {
1400 if now < until {
1401 let retry_after_secs = (until - now).as_secs().max(1);
1402 return RootGuardDecision::Cooldown { retry_after_secs };
1403 }
1404 entry.failures = 0;
1406 entry.cooldown_until = None;
1407 }
1408 }
1409 RootGuardDecision::Allow
1410 }
1411
1412 pub fn record_failure(&self, key: &str) {
1415 let now = Instant::now();
1416 if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1420 self.inner
1421 .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1422 }
1423 let mut entry = self.inner.entry(key.to_string()).or_default();
1424 if matches!(entry.cooldown_until, Some(until) if now < until) {
1427 return;
1428 }
1429 if entry.cooldown_until.is_some() {
1431 entry.failures = 0;
1432 entry.cooldown_until = None;
1433 }
1434 entry.failures = entry.failures.saturating_add(1);
1435 if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1436 entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1437 }
1438 }
1439
1440 pub fn record_success(&self, key: &str) {
1442 self.inner.remove(key);
1443 }
1444}
1445
1446fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1453 if is_local_request(req) {
1454 return None;
1455 }
1456 Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1457}
1458
1459fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1462 HttpResponse::TooManyRequests()
1463 .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1464 .json(serde_json::json!({
1465 "error": crate::error::error_value(
1466 "too many failed password attempts — try again later"
1467 )
1468 }))
1469}
1470
1471fn generate_pairing_code() -> String {
1477 let n = rand::rng().random_range(0..1_000_000);
1478 format!("{n:06}")
1479}
1480
1481fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1483 codes.retain(|_code, entry| !entry.is_expired());
1484}
1485
1486#[derive(Serialize)]
1487pub struct PairingCodeResponse {
1488 pub code: String,
1489 pub ttl: u64,
1491}
1492
1493pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1501 purge_expired_codes(&app_state.pairing_codes);
1503
1504 let code = generate_pairing_code();
1505 let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1506 app_state.pairing_codes.insert(code.clone(), entry);
1508
1509 Ok(HttpResponse::Ok().json(PairingCodeResponse {
1510 code,
1511 ttl: PAIRING_CODE_TTL.as_secs(),
1512 }))
1513}
1514
1515#[derive(Serialize)]
1521pub struct DeviceSummary {
1522 pub device_id: String,
1523 pub label: String,
1524 pub created_at: String,
1525 pub last_used_at: Option<String>,
1526 pub revoked: bool,
1527}
1528
1529impl DeviceSummary {
1530 fn from_credential(d: &DeviceCredential) -> Self {
1531 Self {
1532 device_id: d.device_id.clone(),
1533 label: d.label.clone(),
1534 created_at: d.created_at.clone(),
1535 last_used_at: d.last_used_at.clone(),
1536 revoked: d.revoked,
1537 }
1538 }
1539}
1540
1541pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1544 let config = app_state.config.read().await.clone();
1545 let devices: Vec<DeviceSummary> = config
1546 .access_control
1547 .as_ref()
1548 .map(|access| {
1549 access
1550 .devices
1551 .iter()
1552 .map(DeviceSummary::from_credential)
1553 .collect()
1554 })
1555 .unwrap_or_default();
1556 Ok(HttpResponse::Ok().json(devices))
1557}
1558
1559pub async fn revoke_device(
1566 path: web::Path<String>,
1567 app_state: web::Data<AppState>,
1568) -> Result<HttpResponse, AppError> {
1569 let device_id = path.into_inner();
1570
1571 {
1574 let config = app_state.config.read().await;
1575 let exists = config
1576 .access_control
1577 .as_ref()
1578 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1579 .unwrap_or(false);
1580 if !exists {
1581 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1582 }
1583 }
1584
1585 let target = device_id.clone();
1586 let expected_revision = access_section_revision(&app_state)?;
1587 app_state
1588 .update_access_control_credentials(
1589 expected_revision,
1590 false,
1591 BTreeSet::new(),
1592 move |config| {
1593 if let Some(access) = config.access_control.as_mut() {
1594 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1595 {
1596 device.revoked = true;
1597 }
1598 }
1599 Ok(())
1600 },
1601 )
1602 .await?;
1603
1604 Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1605}
1606
1607pub async fn rotate_device(
1615 path: web::Path<String>,
1616 app_state: web::Data<AppState>,
1617) -> Result<HttpResponse, AppError> {
1618 let device_id = path.into_inner();
1619
1620 {
1623 let config = app_state.config.read().await;
1624 let exists = config
1625 .access_control
1626 .as_ref()
1627 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1628 .unwrap_or(false);
1629 if !exists {
1630 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1631 }
1632 }
1633
1634 let (fresh, token) = issue_device_token("");
1637
1638 let target = device_id.clone();
1639 let expected_revision = access_section_revision(&app_state)?;
1640 app_state
1641 .update_access_control_credentials(
1642 expected_revision,
1643 false,
1644 BTreeSet::from([device_id.clone()]),
1645 move |config| {
1646 if let Some(access) = config.access_control.as_mut() {
1647 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1648 {
1649 device.token_hash = fresh.token_hash.clone();
1650 device.token_salt = fresh.token_salt.clone();
1651 device.revoked = false;
1652 device.last_used_at = None;
1653 }
1654 }
1655 Ok(())
1656 },
1657 )
1658 .await?;
1659
1660 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1662 device_id,
1663 device_token: token,
1664 expires_hint: "rotate-on-demand",
1665 }))
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670 use super::*;
1671 use actix_web::{
1672 body::to_bytes,
1673 http::StatusCode,
1674 middleware::from_fn,
1675 test::{self, TestRequest},
1676 App,
1677 };
1678 use bamboo_config::AccessControlConfig;
1679 use bamboo_engine::external_agents::actor_adapter::CodexRunTokenAuthority as _;
1680
1681 macro_rules! test_config {
1682 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
1683 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
1684 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
1685 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
1686 ($($field:ident: $value:expr),* $(,)?) => {{
1687 let mut config = Config::default();
1688 $(test_config!(@assign config, $field, $value);)*
1689 config
1690 }};
1691 }
1692
1693 #[actix_web::test]
1694 async fn public_access_status_exposes_revision_without_section_data_or_paths() {
1695 let dir = tempfile::tempdir().unwrap();
1696 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1697 state.config.write().await.access_control = Some(AccessControlConfig {
1698 devices: vec![bamboo_config::DeviceCredential {
1699 device_id: "private-device-id".to_string(),
1700 label: "private-device-label".to_string(),
1701 token_hash: "private-token-hash".to_string(),
1702 token_salt: "private-token-salt".to_string(),
1703 token_credential_ref: None,
1704 token_configured: false,
1705 created_at: "2026-07-27T00:00:00Z".to_string(),
1706 last_used_at: None,
1707 revoked: false,
1708 }],
1709 ..Default::default()
1710 });
1711 let app = test::init_service(
1712 App::new()
1713 .app_data(state)
1714 .route("/access/status", web::get().to(get_access_status)),
1715 )
1716 .await;
1717
1718 let response = test::call_service(
1719 &app,
1720 TestRequest::get()
1721 .uri("/access/status")
1722 .insert_header((header::HOST, "bamboo.example.com"))
1723 .to_request(),
1724 )
1725 .await;
1726 assert_eq!(response.status(), StatusCode::OK);
1727 let body: serde_json::Value = test::read_body_json(response).await;
1728 assert_eq!(body["revision"], 0);
1729 assert!(body.get("status").is_some());
1730 assert!(body.get("source_kind").is_some());
1731 assert!(body.get("section").is_none());
1732 assert!(body.get("source_path").is_none());
1733 let body = body.to_string();
1734 let private_path = dir.path().to_string_lossy().to_string();
1735 for private in [
1736 private_path.as_str(),
1737 "private-device-id",
1738 "private-device-label",
1739 "private-token-hash",
1740 "private-token-salt",
1741 ] {
1742 assert!(!body.contains(private));
1743 }
1744 }
1745
1746 #[actix_web::test]
1747 async fn quarantined_access_is_fail_closed_and_recovery_credentials_stay_private() {
1748 let _key = bamboo_config::encryption::set_test_encryption_key([0xb7; 32]);
1749 let dir = tempfile::tempdir().unwrap();
1750 let secret_fragment = "server-api-private-access-fragment";
1751 let mut root = serde_json::to_value(Config::default()).unwrap();
1752 root["access_control"] = serde_json::json!({
1753 "password_enabled": "yes",
1754 "password_hash": secret_fragment
1755 });
1756 std::fs::write(
1757 dir.path().join("config.json"),
1758 serde_json::to_vec_pretty(&root).unwrap(),
1759 )
1760 .unwrap();
1761
1762 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1763 assert!(state
1764 .config
1765 .read()
1766 .await
1767 .access_control
1768 .as_ref()
1769 .is_some_and(|access| access.repair_required));
1770 let app = test::init_service(
1771 App::new()
1772 .app_data(state)
1773 .route("/access/status", web::get().to(get_access_status))
1774 .route(
1775 "/credentials",
1776 web::get().to(crate::handlers::settings::list_credentials),
1777 )
1778 .route(
1779 "/credentials/{credential_ref}",
1780 web::get().to(crate::handlers::settings::get_credential_status),
1781 ),
1782 )
1783 .await;
1784
1785 let status = test::call_service(
1786 &app,
1787 TestRequest::get()
1788 .uri("/access/status")
1789 .peer_addr("203.0.113.8:5700".parse().unwrap())
1790 .insert_header((header::HOST, "bamboo.example.com"))
1791 .to_request(),
1792 )
1793 .await;
1794 assert_eq!(status.status(), StatusCode::OK);
1795 let status: serde_json::Value = test::read_body_json(status).await;
1796 assert_eq!(status["requires_password"], true);
1797 assert_eq!(status["status"], "degraded");
1798 let serialized_status = status.to_string();
1799 assert!(!serialized_status.contains("access_repair."));
1800 assert!(!serialized_status.contains(secret_fragment));
1801
1802 let list =
1803 test::call_service(&app, TestRequest::get().uri("/credentials").to_request()).await;
1804 assert_eq!(list.status(), StatusCode::OK);
1805 let serialized_list = String::from_utf8(test::read_body(list).await.to_vec()).unwrap();
1806 assert!(!serialized_list.contains("access_repair."));
1807 assert!(!serialized_list.contains(secret_fragment));
1808
1809 let direct = test::call_service(
1810 &app,
1811 TestRequest::get()
1812 .uri("/credentials/access_repair.root.payload")
1813 .to_request(),
1814 )
1815 .await;
1816 assert_eq!(direct.status(), StatusCode::BAD_REQUEST);
1817 let direct = String::from_utf8(test::read_body(direct).await.to_vec()).unwrap();
1818 assert!(!direct.contains(secret_fragment));
1819 assert!(!direct.contains("access_repair.root.payload"));
1820 }
1821
1822 #[actix_web::test]
1823 async fn access_status_reports_valid_ciphertext_with_invalid_verifier_as_error() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1826 state
1827 .update_access_control_credentials(0, true, BTreeSet::new(), |config| {
1828 config.access_control = Some(AccessControlConfig {
1829 password_enabled: true,
1830 password_hash: Some("a".repeat(64)),
1831 password_salt: Some("11".repeat(16)),
1832 password_configured: true,
1833 ..Default::default()
1834 });
1835 Ok(())
1836 })
1837 .await
1838 .unwrap();
1839 let reference = bamboo_config::config_crypto::access_password_credential_ref().unwrap();
1840 state
1841 .credential_store
1842 .replace(
1843 reference.clone(),
1844 r#"{"hash":"not-a-valid-hash","salt":"11"}"#,
1845 bamboo_config::CredentialSource::User,
1846 1,
1847 )
1848 .unwrap();
1849 let access_path = dir.path().join("access-control.json");
1850 let credential_path = dir.path().join("credentials.json");
1851 let access_before = std::fs::read(&access_path).unwrap();
1852 let credentials_before = std::fs::read(&credential_path).unwrap();
1853 let keep = state
1854 .update_access_control_credentials(1, false, BTreeSet::new(), |config| {
1855 config.access_control.as_mut().unwrap().updated_at =
1856 Some("metadata-keep-must-fail".to_string());
1857 Ok(())
1858 })
1859 .await;
1860 assert!(keep.is_err());
1861 assert_eq!(std::fs::read(&access_path).unwrap(), access_before);
1862 assert_eq!(std::fs::read(&credential_path).unwrap(), credentials_before);
1863
1864 let app = test::init_service(
1865 App::new()
1866 .app_data(state.clone())
1867 .route("/access/status", web::get().to(get_access_status)),
1868 )
1869 .await;
1870 let response = test::call_service(
1871 &app,
1872 TestRequest::get()
1873 .uri("/access/status")
1874 .peer_addr("203.0.113.8:5700".parse().unwrap())
1875 .insert_header((header::HOST, "bamboo.example.com"))
1876 .to_request(),
1877 )
1878 .await;
1879 assert_eq!(response.status(), StatusCode::OK);
1880 let body: serde_json::Value = test::read_body_json(response).await;
1881 assert_eq!(body["revision"], 1);
1882 assert_eq!(body["password_enabled"], true);
1883 assert_eq!(body["requires_password"], true);
1884 assert_eq!(body["password_configured"], false);
1885 assert_eq!(body["credential_state"], "error");
1886 assert_eq!(body["status"], "degraded");
1887 assert_eq!(
1888 body["last_error"],
1889 "access-control credential repair is required"
1890 );
1891
1892 let (_, revision, metadata, section) = state
1893 .update_access_control_credentials(1, true, BTreeSet::new(), |config| {
1894 let access = config.access_control.as_mut().unwrap();
1895 access.password_enabled = false;
1896 access.password_hash = None;
1897 access.password_salt = None;
1898 access.password_configured = false;
1899 Ok(())
1900 })
1901 .await
1902 .unwrap();
1903 assert_eq!(revision, 2);
1904 assert!(!metadata.status(&reference).configured);
1905 let section = section.unwrap();
1906 assert_eq!(section.revision, 2);
1907 assert!(section.data["password_credential_ref"].is_null());
1908 }
1909
1910 #[actix_web::test]
1911 async fn password_replace_and_clear_use_access_revision_and_preserve_devices() {
1912 let _key = bamboo_config::encryption::set_test_encryption_key([0xd3; 32]);
1913 let dir = tempfile::tempdir().unwrap();
1914 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
1915 let (device, _token) = issue_device_token("paired-device");
1916 let device_id = device.device_id.clone();
1917 let device_intent = device_id.clone();
1918 state
1919 .update_access_control_credentials(
1920 0,
1921 false,
1922 BTreeSet::from([device_intent]),
1923 move |config| {
1924 config.access_control = Some(AccessControlConfig {
1925 devices: vec![device],
1926 ..Default::default()
1927 });
1928 Ok(())
1929 },
1930 )
1931 .await
1932 .unwrap();
1933 let mut events = state.account_sink.subscribe();
1934 let app = test::init_service(
1935 App::new()
1936 .app_data(state.clone())
1937 .route("/access/password", web::post().to(update_access_password)),
1938 )
1939 .await;
1940
1941 let replace = test::call_service(
1942 &app,
1943 TestRequest::post()
1944 .uri("/access/password")
1945 .set_json(serde_json::json!({
1946 "expected_revision": 1,
1947 "action": "replace",
1948 "value": "root-replacement-secret"
1949 }))
1950 .to_request(),
1951 )
1952 .await;
1953 assert_eq!(replace.status(), StatusCode::OK);
1954 let replace: serde_json::Value = test::read_body_json(replace).await;
1955 assert_eq!(replace["revision"], 2);
1956 assert_eq!(replace["section"]["revision"], 2);
1957 assert_eq!(replace["credential"]["configured"], true);
1958 assert_eq!(replace["credential"]["state"], "configured");
1959 assert!(!replace.to_string().contains("root-replacement-secret"));
1960 assert!(!replace.to_string().contains("********"));
1961 let event = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1962 loop {
1963 let event = events.recv().await.unwrap();
1964 if matches!(
1965 &event.event,
1966 bamboo_agent_core::AgentEvent::ConfigChanged { section, revision: 2 }
1967 if section == "access-control"
1968 ) {
1969 break event;
1970 }
1971 }
1972 })
1973 .await
1974 .unwrap();
1975 assert!(matches!(
1976 event.event,
1977 bamboo_agent_core::AgentEvent::ConfigChanged { ref section, revision: 2 }
1978 if section == "access-control"
1979 ));
1980
1981 let clear = test::call_service(
1982 &app,
1983 TestRequest::post()
1984 .uri("/access/password")
1985 .set_json(serde_json::json!({
1986 "expected_revision": 2,
1987 "action": "clear",
1988 "current_password": "root-replacement-secret"
1989 }))
1990 .to_request(),
1991 )
1992 .await;
1993 assert_eq!(clear.status(), StatusCode::OK);
1994 let clear: serde_json::Value = test::read_body_json(clear).await;
1995 assert_eq!(clear["revision"], 3);
1996 assert_eq!(clear["section"]["revision"], 3);
1997 assert_eq!(clear["password_enabled"], false);
1998 assert_eq!(clear["credential"]["configured"], false);
1999 assert_eq!(clear["credential"]["state"], "missing");
2000 let access = state.config.read().await.access_control.clone().unwrap();
2001 assert_eq!(access.devices.len(), 1);
2002 assert_eq!(access.devices[0].device_id, device_id);
2003 assert!(!access.password_enabled);
2004
2005 let stale = test::call_service(
2006 &app,
2007 TestRequest::post()
2008 .uri("/access/password")
2009 .set_json(serde_json::json!({
2010 "expected_revision": 2,
2011 "action": "replace",
2012 "value": "stale-root-secret"
2013 }))
2014 .to_request(),
2015 )
2016 .await;
2017 assert_eq!(stale.status(), StatusCode::CONFLICT);
2018 let stale = String::from_utf8(test::read_body(stale).await.to_vec()).unwrap();
2019 assert!(!stale.contains("stale-root-secret"));
2020 let access = state.config.read().await.access_control.clone().unwrap();
2021 assert_eq!(access.devices.len(), 1);
2022 assert!(!access.password_enabled);
2023
2024 let missing_revision = test::call_service(
2025 &app,
2026 TestRequest::post()
2027 .uri("/access/password")
2028 .set_json(serde_json::json!({
2029 "action": "replace",
2030 "value": "missing-revision-secret"
2031 }))
2032 .to_request(),
2033 )
2034 .await;
2035 assert_eq!(missing_revision.status(), StatusCode::BAD_REQUEST);
2036
2037 let masked = test::call_service(
2038 &app,
2039 TestRequest::post()
2040 .uri("/access/password")
2041 .set_json(serde_json::json!({
2042 "expected_revision": 3,
2043 "action": "replace",
2044 "value": "****...****"
2045 }))
2046 .to_request(),
2047 )
2048 .await;
2049 assert_eq!(masked.status(), StatusCode::BAD_REQUEST);
2050
2051 let ambiguous = test::call_service(
2052 &app,
2053 TestRequest::post()
2054 .uri("/access/password")
2055 .set_json(serde_json::json!({
2056 "expected_revision": 3,
2057 "action": "replace",
2058 "value": "ambiguous-value-secret",
2059 "new_password": "ambiguous-legacy-secret"
2060 }))
2061 .to_request(),
2062 )
2063 .await;
2064 assert_eq!(ambiguous.status(), StatusCode::BAD_REQUEST);
2065
2066 let clear_with_value = test::call_service(
2067 &app,
2068 TestRequest::post()
2069 .uri("/access/password")
2070 .set_json(serde_json::json!({
2071 "expected_revision": 3,
2072 "action": "clear",
2073 "value": "unexpected-clear-secret"
2074 }))
2075 .to_request(),
2076 )
2077 .await;
2078 assert_eq!(clear_with_value.status(), StatusCode::BAD_REQUEST);
2079
2080 let access_file = std::fs::read_to_string(dir.path().join("access-control.json")).unwrap();
2081 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
2082 for secret in [
2083 "root-replacement-secret",
2084 "stale-root-secret",
2085 "missing-revision-secret",
2086 "ambiguous-value-secret",
2087 "ambiguous-legacy-secret",
2088 "unexpected-clear-secret",
2089 "****...****",
2090 ] {
2091 assert!(!access_file.contains(secret));
2092 assert!(!credentials.contains(secret));
2093 }
2094 }
2095
2096 #[actix_web::test]
2097 async fn stale_process_old_password_cannot_authorize_newer_access_generation() {
2098 let dir = tempfile::tempdir().unwrap();
2099 let writer = AppState::new(dir.path().to_path_buf()).await.unwrap();
2100
2101 let old_password = uuid::Uuid::new_v4().to_string();
2102 let old_salt = "11".repeat(16);
2103 let old_hash = compute_password_hash(&old_password, &old_salt).unwrap();
2104 writer
2105 .update_access_control_credentials(0, true, BTreeSet::new(), move |config| {
2106 config.access_control = Some(AccessControlConfig {
2107 password_enabled: true,
2108 password_hash: Some(old_hash),
2109 password_salt: Some(old_salt),
2110 password_configured: true,
2111 ..Default::default()
2112 });
2113 Ok(())
2114 })
2115 .await
2116 .unwrap();
2117
2118 let mut stale = AppState::new(dir.path().to_path_buf()).await.unwrap();
2119 stale.stop_config_watcher_for_test();
2120 assert_eq!(
2121 stale
2122 .config_facade
2123 .as_ref()
2124 .unwrap()
2125 .registry()
2126 .access_control
2127 .snapshot()
2128 .revision,
2129 1
2130 );
2131 {
2132 let stale_config = stale.config.read().await;
2133 assert!(verify_password(&stale_config, &old_password));
2134 }
2135
2136 let new_password = uuid::Uuid::new_v4().to_string();
2137 let new_salt = "22".repeat(16);
2138 let new_hash = compute_password_hash(&new_password, &new_salt).unwrap();
2139 writer
2140 .update_access_control_credentials(1, true, BTreeSet::new(), move |config| {
2141 let access = config.access_control.get_or_insert_with(Default::default);
2142 access.password_enabled = true;
2143 access.password_hash = Some(new_hash);
2144 access.password_salt = Some(new_salt);
2145 access.password_configured = true;
2146 Ok(())
2147 })
2148 .await
2149 .unwrap();
2150 {
2151 let stale_config = stale.config.read().await;
2152 assert!(verify_password(&stale_config, &old_password));
2153 assert!(!verify_password(&stale_config, &new_password));
2154 }
2155
2156 let app = test::init_service(
2157 App::new()
2158 .app_data(web::Data::new(stale))
2159 .route("/access/password", web::post().to(update_access_password)),
2160 )
2161 .await;
2162 let response = test::call_service(
2163 &app,
2164 TestRequest::post()
2165 .uri("/access/password")
2166 .peer_addr("203.0.113.7:5700".parse().unwrap())
2167 .insert_header((header::HOST, "bamboo.example.com"))
2168 .set_json(serde_json::json!({
2169 "expected_revision": 2,
2170 "action": "replace",
2171 "current_password": old_password,
2172 "value": "unauthorized-third-secret"
2173 }))
2174 .to_request(),
2175 )
2176 .await;
2177 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2178
2179 let exact = bamboo_config::read_exact_credential_section_snapshot(
2180 dir.path(),
2181 bamboo_config::SectionId::AccessControl,
2182 Some(2),
2183 )
2184 .unwrap();
2185 let mut durable = Config::default();
2186 exact.install_into(&mut durable);
2187 assert!(verify_password(&durable, &new_password));
2188 assert!(!verify_password(&durable, &old_password));
2189 assert!(!verify_password(&durable, "unauthorized-third-secret"));
2190 assert!(
2191 !std::fs::read_to_string(dir.path().join("credentials.json"))
2192 .unwrap()
2193 .contains("unauthorized-third-secret")
2194 );
2195 }
2196
2197 #[actix_web::test]
2198 async fn codex_run_token_is_path_and_session_scoped_and_revocation_beats_loopback_bypass() {
2199 async fn probe(req: HttpRequest) -> HttpResponse {
2200 let session_id = req
2201 .extensions()
2202 .get::<crate::codex_run_tokens::CodexRunAuthContext>()
2203 .map(|context| context.session_id.clone())
2204 .unwrap_or_else(|| "missing-context".to_string());
2205 HttpResponse::Ok().body(session_id)
2206 }
2207
2208 let data_dir = tempfile::tempdir().unwrap();
2209 let state = AppState::new(data_dir.path().to_path_buf()).await.unwrap();
2210 let tokens = state.codex_run_tokens.clone();
2211 let issued = tokens.issue("codex-child-570").unwrap();
2212 let app = test::init_service(
2213 App::new()
2214 .app_data(web::Data::new(state))
2215 .wrap(from_fn(enforce_access_password_middleware))
2216 .route("/openai/v1/responses", web::post().to(probe))
2217 .route("/openai/v1/chat/completions", web::post().to(probe)),
2218 )
2219 .await;
2220
2221 let valid = TestRequest::post()
2222 .uri("/openai/v1/responses")
2223 .peer_addr("127.0.0.1:5700".parse().unwrap())
2224 .insert_header((header::HOST, "localhost:9562"))
2225 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2226 .to_request();
2227 let valid = test::call_service(&app, valid).await;
2228 assert_eq!(valid.status(), StatusCode::OK);
2229 assert_eq!(
2230 to_bytes(valid.into_body()).await.unwrap(),
2231 "codex-child-570".as_bytes()
2232 );
2233
2234 let out_of_scope = TestRequest::post()
2235 .uri("/openai/v1/chat/completions")
2236 .peer_addr("127.0.0.1:5700".parse().unwrap())
2237 .insert_header((header::HOST, "localhost:9562"))
2238 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2239 .to_request();
2240 assert_eq!(
2241 test::call_service(&app, out_of_scope).await.status(),
2242 StatusCode::UNAUTHORIZED
2243 );
2244
2245 tokens.revoke(&issued.token_id);
2246 let revoked_on_loopback = TestRequest::post()
2247 .uri("/openai/v1/responses")
2248 .peer_addr("127.0.0.1:5700".parse().unwrap())
2249 .insert_header((header::HOST, "localhost:9562"))
2250 .insert_header((header::AUTHORIZATION, format!("Bearer {}", issued.token)))
2251 .to_request();
2252 assert_eq!(
2253 test::call_service(&app, revoked_on_loopback).await.status(),
2254 StatusCode::UNAUTHORIZED,
2255 "revoked bcx1_ credentials must never fall through to loopback bypass"
2256 );
2257 }
2258
2259 #[test]
2260 fn loopback_request_is_local() {
2261 let req = TestRequest::default()
2262 .peer_addr("127.0.0.1:12345".parse().unwrap())
2263 .insert_header((header::HOST, "localhost:9562"))
2264 .to_http_request();
2265 assert!(is_local_request(&req));
2266 }
2267
2268 #[test]
2269 fn private_lan_host_is_local() {
2270 let req = TestRequest::default()
2271 .insert_header((header::HOST, "192.168.0.10:9562"))
2272 .to_http_request();
2273 assert!(is_local_request(&req));
2274 }
2275
2276 #[test]
2277 fn remote_host_is_not_local_even_when_peer_is_loopback() {
2278 let req = TestRequest::default()
2279 .peer_addr("127.0.0.1:12345".parse().unwrap())
2280 .insert_header((header::HOST, "bamboo.example.com"))
2281 .to_http_request();
2282 assert!(!is_local_request(&req));
2283 }
2284
2285 #[test]
2286 fn spoofed_local_host_from_remote_peer_is_not_local() {
2287 for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
2291 let req = TestRequest::default()
2292 .peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
2294 .to_http_request();
2295 assert!(
2296 !is_local_request(&req),
2297 "remote peer + spoofed Host '{spoof}' must not be local"
2298 );
2299 let req2 = TestRequest::default()
2301 .peer_addr("203.0.113.5:40000".parse().unwrap())
2302 .insert_header(("x-forwarded-host", spoof))
2303 .to_http_request();
2304 assert!(
2305 !is_local_request(&req2),
2306 "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
2307 );
2308 }
2309 }
2310
2311 #[test]
2312 fn loopback_peer_with_no_host_is_local() {
2313 let req = TestRequest::default()
2314 .peer_addr("127.0.0.1:5000".parse().unwrap())
2315 .to_http_request();
2316 assert!(is_local_request(&req));
2317 }
2318
2319 #[test]
2320 fn request_without_host_or_peer_is_not_local() {
2321 let req = TestRequest::default().to_http_request();
2322 assert!(!is_local_request(&req));
2323 }
2324
2325 #[test]
2326 fn password_hash_roundtrip_verifies() {
2327 let salt_hex = hex::encode([1_u8; 16]);
2328 let hash = compute_password_hash("secret", &salt_hex).unwrap();
2329 let config = test_config! {
2330 access_control: Some(AccessControlConfig {
2331 password_enabled: true,
2332 repair_required: false,
2333 password_hash: Some(hash),
2334 password_salt: Some(salt_hex),
2335 password_credential_ref: None,
2336 password_configured: false,
2337 updated_at: None,
2338 devices: Vec::new(),
2339 }),
2340 };
2341
2342 assert!(verify_password(&config, "secret"));
2343 assert!(!verify_password(&config, "wrong"));
2344 }
2345
2346 fn config_with_password() -> Config {
2349 let salt_hex = hex::encode([1_u8; 16]);
2350 let hash = compute_password_hash("secret", &salt_hex).unwrap();
2351 test_config! {
2352 access_control: Some(AccessControlConfig {
2353 password_enabled: true,
2354 repair_required: false,
2355 password_hash: Some(hash),
2356 password_salt: Some(salt_hex),
2357 password_credential_ref: None,
2358 password_configured: false,
2359 updated_at: None,
2360 devices: Vec::new(),
2361 }),
2362 }
2363 }
2364
2365 #[test]
2366 fn constant_time_eq_matches_and_rejects() {
2367 assert!(constant_time_eq(b"abcd", b"abcd"));
2368 assert!(!constant_time_eq(b"abcd", b"abce"));
2369 assert!(!constant_time_eq(b"abc", b"abcd"));
2370 }
2371
2372 #[test]
2373 fn issued_token_has_expected_format_and_verifies() {
2374 let (cred, token) = issue_device_token("iPhone 15");
2375 assert!(token.starts_with("bd1_"));
2376 assert_eq!(token.len(), "bd1_".len() + 32);
2377 assert!(cred.device_id.starts_with("bamboo_"));
2378 assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
2379 assert_eq!(cred.label, "iPhone 15");
2380 assert!(!cred.revoked);
2381 assert_ne!(cred.token_hash, token);
2383
2384 let mut config = config_with_password();
2385 config
2386 .access_control
2387 .as_mut()
2388 .unwrap()
2389 .devices
2390 .push(cred.clone());
2391
2392 assert!(verify_device_token(&config, &cred.device_id, &token));
2393 assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
2394 assert!(!verify_device_token(&config, "bamboo_unknown", &token));
2395 }
2396
2397 #[test]
2398 fn revoked_token_is_rejected() {
2399 let (mut cred, token) = issue_device_token("iPad");
2400 cred.revoked = true;
2401 let mut config = config_with_password();
2402 let device_id = cred.device_id.clone();
2403 config.access_control.as_mut().unwrap().devices.push(cred);
2404 assert!(!verify_device_token(&config, &device_id, &token));
2405 }
2406
2407 #[test]
2408 fn has_active_devices_ignores_revoked() {
2409 let mut config = config_with_password();
2410 assert!(!has_active_devices(&config));
2411 let (mut cred, _t) = issue_device_token("d");
2412 cred.revoked = true;
2413 config
2414 .access_control
2415 .as_mut()
2416 .unwrap()
2417 .devices
2418 .push(cred.clone());
2419 assert!(!has_active_devices(&config));
2420 let (cred2, _t2) = issue_device_token("d2");
2421 config.access_control.as_mut().unwrap().devices.push(cred2);
2422 assert!(has_active_devices(&config));
2423 }
2424
2425 fn remote_req() -> HttpRequest {
2426 TestRequest::default()
2427 .insert_header((header::HOST, "bamboo.example.com"))
2428 .to_http_request()
2429 }
2430
2431 fn local_req() -> HttpRequest {
2432 TestRequest::default()
2433 .insert_header((header::HOST, "localhost:9562"))
2434 .to_http_request()
2435 }
2436
2437 #[test]
2438 fn no_devices_no_password_does_not_require_credential() {
2439 let config = Config::default();
2442 assert!(!build_access_status(&config, &remote_req()).requires_password);
2443 }
2444
2445 #[test]
2446 fn password_only_gate_matches_prior_behavior() {
2447 let config = config_with_password();
2448 assert!(build_access_status(&config, &remote_req()).requires_password);
2449 assert!(!build_access_status(&config, &local_req()).requires_password);
2450 }
2451
2452 #[test]
2453 fn enabled_password_with_missing_verifier_fails_closed_for_remote_requests() {
2454 let config = test_config! {
2455 access_control: Some(AccessControlConfig {
2456 password_enabled: true,
2457 repair_required: false,
2458 password_hash: None,
2459 password_salt: None,
2460 password_credential_ref: None,
2461 password_configured: false,
2462 updated_at: None,
2463 devices: Vec::new(),
2464 }),
2465 };
2466 let remote = remote_req();
2467 let local = local_req();
2468 assert!(build_access_status(&config, &remote).password_enabled);
2469 assert!(build_access_status(&config, &remote).requires_password);
2470 assert!(!verify_password(&config, "any-password"));
2471 assert!(!request_is_authorized(&remote, &config));
2472 assert!(request_is_authorized(&local, &config));
2473 }
2474
2475 #[test]
2476 fn device_presence_requires_credential_even_without_password() {
2477 let (cred, _t) = issue_device_token("d");
2479 let config = test_config! {
2480 access_control: Some(AccessControlConfig {
2481 password_enabled: false,
2482 repair_required: false,
2483 password_hash: None,
2484 password_salt: None,
2485 password_credential_ref: None,
2486 password_configured: false,
2487 updated_at: None,
2488 devices: vec![cred],
2489 }),
2490 };
2491 assert!(build_access_status(&config, &remote_req()).requires_password);
2492 assert!(!build_access_status(&config, &local_req()).requires_password);
2494 }
2495
2496 #[test]
2497 fn valid_device_token_on_request_authenticates() {
2498 let (cred, token) = issue_device_token("d");
2499 let device_id = cred.device_id.clone();
2500 let mut config = config_with_password();
2501 config.access_control.as_mut().unwrap().devices.push(cred);
2502
2503 let req = TestRequest::default()
2504 .insert_header((header::HOST, "bamboo.example.com"))
2505 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2506 .insert_header((DEVICE_ID_HEADER, device_id))
2507 .to_http_request();
2508 assert!(request_has_valid_device_token(&req, &config));
2509
2510 let bad = TestRequest::default()
2512 .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
2513 .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
2514 .to_http_request();
2515 assert!(!request_has_valid_device_token(&bad, &config));
2516
2517 let no_id = TestRequest::default()
2519 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2520 .to_http_request();
2521 assert!(!request_has_valid_device_token(&no_id, &config));
2522 }
2523
2524 #[test]
2531 fn request_is_authorized_local_is_always_allowed() {
2532 let config = config_with_password();
2534 assert!(request_is_authorized(&local_req(), &config));
2535 }
2536
2537 #[test]
2538 fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
2539 let (cred, _t) = issue_device_token("d");
2541 let config = test_config! {
2542 access_control: Some(AccessControlConfig {
2543 password_enabled: false,
2544 repair_required: false,
2545 password_hash: None,
2546 password_salt: None,
2547 password_credential_ref: None,
2548 password_configured: false,
2549 updated_at: None,
2550 devices: vec![cred],
2551 }),
2552 };
2553 assert!(!request_is_authorized(&remote_req(), &config));
2554 }
2555
2556 #[test]
2557 fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
2558 let config = config_with_password();
2559 assert!(!request_is_authorized(&remote_req(), &config));
2560 }
2561
2562 #[test]
2563 fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
2564 let config = config_with_password();
2565 let cookie_value =
2566 access_verification_cookie_value(&config).expect("password config yields a cookie");
2567 let req = TestRequest::default()
2568 .insert_header((header::HOST, "bamboo.example.com"))
2569 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
2570 .to_http_request();
2571 assert!(request_is_authorized(&req, &config));
2572 }
2573
2574 #[test]
2575 fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
2576 let (cred, token) = issue_device_token("d");
2577 let device_id = cred.device_id.clone();
2578 let mut config = config_with_password();
2579 config.access_control.as_mut().unwrap().devices.push(cred);
2580
2581 let req = TestRequest::default()
2582 .insert_header((header::HOST, "bamboo.example.com"))
2583 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2584 .insert_header((DEVICE_ID_HEADER, device_id))
2585 .to_http_request();
2586 assert!(request_is_authorized(&req, &config));
2587 }
2588
2589 #[test]
2590 fn repair_required_rejects_valid_remote_credentials_but_keeps_local_bypass() {
2591 let (cred, token) = issue_device_token("repair-device");
2592 let device_id = cred.device_id.clone();
2593 let mut config = config_with_password();
2594 config.access_control.as_mut().unwrap().devices.push(cred);
2595 let previously_valid_cookie = access_verification_cookie_value(&config)
2596 .expect("healthy password verifier produces a cookie");
2597 config.access_control.as_mut().unwrap().repair_required = true;
2598
2599 let remote_cookie = TestRequest::default()
2600 .insert_header((header::HOST, "bamboo.example.com"))
2601 .cookie(Cookie::new(
2602 ACCESS_VERIFIED_COOKIE_NAME,
2603 previously_valid_cookie,
2604 ))
2605 .to_http_request();
2606 let remote_device = TestRequest::default()
2607 .insert_header((header::HOST, "bamboo.example.com"))
2608 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
2609 .insert_header((DEVICE_ID_HEADER, device_id.clone()))
2610 .to_http_request();
2611
2612 assert!(build_access_status(&config, &remote_cookie).requires_password);
2613 assert!(!build_access_status(&config, &local_req()).requires_password);
2614 assert!(!verify_password(&config, "secret"));
2615 assert!(!verify_device_token(&config, &device_id, &token));
2616 assert!(access_verification_cookie_value(&config).is_none());
2617 assert!(!request_is_authorized(&remote_cookie, &config));
2618 assert!(!request_is_authorized(&remote_device, &config));
2619 assert!(request_is_authorized(&local_req(), &config));
2620 }
2621
2622 #[test]
2623 fn request_is_authorized_no_password_no_devices_is_open() {
2624 let config = Config::default();
2627 assert!(request_is_authorized(&remote_req(), &config));
2628 }
2629
2630 #[test]
2631 fn bootstrap_access_snapshot_reports_policy_and_request_state_matrix() {
2632 fn assert_snapshot(
2633 config: &Config,
2634 request: &HttpRequest,
2635 policy: BootstrapAuthPolicy,
2636 request_state: BootstrapRequestState,
2637 password_enabled: bool,
2638 device_auth_enabled: bool,
2639 ) {
2640 assert_eq!(
2641 bootstrap_access_snapshot(config, request),
2642 BootstrapAccessSnapshot {
2643 policy,
2644 request_state,
2645 password_enabled,
2646 device_auth_enabled,
2647 }
2648 );
2649 }
2650
2651 let open = Config::default();
2656 assert_snapshot(
2657 &open,
2658 &remote_req(),
2659 BootstrapAuthPolicy::Open,
2660 BootstrapRequestState::Unauthenticated,
2661 false,
2662 false,
2663 );
2664 assert_snapshot(
2665 &open,
2666 &local_req(),
2667 BootstrapAuthPolicy::Open,
2668 BootstrapRequestState::LocalBypass,
2669 false,
2670 false,
2671 );
2672
2673 let password_only = config_with_password();
2676 assert_snapshot(
2677 &password_only,
2678 &remote_req(),
2679 BootstrapAuthPolicy::CredentialRequired,
2680 BootstrapRequestState::Unauthenticated,
2681 true,
2682 false,
2683 );
2684 let cookie_value = access_verification_cookie_value(&password_only)
2685 .expect("healthy password config yields a cookie");
2686 let valid_cookie = TestRequest::default()
2687 .insert_header((header::HOST, "bamboo.example.com"))
2688 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
2689 .to_http_request();
2690 assert_snapshot(
2691 &password_only,
2692 &valid_cookie,
2693 BootstrapAuthPolicy::CredentialRequired,
2694 BootstrapRequestState::Authenticated,
2695 true,
2696 false,
2697 );
2698 assert_snapshot(
2699 &password_only,
2700 &local_req(),
2701 BootstrapAuthPolicy::CredentialRequired,
2702 BootstrapRequestState::LocalBypass,
2703 true,
2704 false,
2705 );
2706
2707 let (device, device_token) = issue_device_token("bootstrap-device");
2709 let device_id = device.device_id.clone();
2710 let device_only = test_config! {
2711 access_control: Some(AccessControlConfig {
2712 devices: vec![device.clone()],
2713 ..Default::default()
2714 }),
2715 };
2716 assert_snapshot(
2717 &device_only,
2718 &remote_req(),
2719 BootstrapAuthPolicy::CredentialRequired,
2720 BootstrapRequestState::Unauthenticated,
2721 false,
2722 true,
2723 );
2724 let valid_device = TestRequest::default()
2725 .insert_header((header::HOST, "bamboo.example.com"))
2726 .insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
2727 .insert_header((DEVICE_ID_HEADER, device_id.clone()))
2728 .to_http_request();
2729 assert_snapshot(
2730 &device_only,
2731 &valid_device,
2732 BootstrapAuthPolicy::CredentialRequired,
2733 BootstrapRequestState::Authenticated,
2734 false,
2735 true,
2736 );
2737
2738 let mut both = config_with_password();
2740 both.access_control
2741 .as_mut()
2742 .unwrap()
2743 .devices
2744 .push(device.clone());
2745 assert_snapshot(
2746 &both,
2747 &remote_req(),
2748 BootstrapAuthPolicy::CredentialRequired,
2749 BootstrapRequestState::Unauthenticated,
2750 true,
2751 true,
2752 );
2753
2754 let invalid_device = TestRequest::default()
2757 .insert_header((header::HOST, "bamboo.example.com"))
2758 .insert_header((header::AUTHORIZATION, "Bearer bd1_invalid"))
2759 .insert_header((DEVICE_ID_HEADER, device_id.clone()))
2760 .to_http_request();
2761 assert_snapshot(
2762 &device_only,
2763 &invalid_device,
2764 BootstrapAuthPolicy::CredentialRequired,
2765 BootstrapRequestState::Unauthenticated,
2766 false,
2767 true,
2768 );
2769
2770 let (mut revoked_device, revoked_token) = issue_device_token("revoked-device");
2773 let revoked_device_id = revoked_device.device_id.clone();
2774 revoked_device.revoked = true;
2775 let revoked_only = test_config! {
2776 access_control: Some(AccessControlConfig {
2777 devices: vec![revoked_device],
2778 ..Default::default()
2779 }),
2780 };
2781 let revoked_request = TestRequest::default()
2782 .insert_header((header::HOST, "bamboo.example.com"))
2783 .insert_header((header::AUTHORIZATION, format!("Bearer {revoked_token}")))
2784 .insert_header((DEVICE_ID_HEADER, revoked_device_id))
2785 .to_http_request();
2786 assert_snapshot(
2787 &revoked_only,
2788 &revoked_request,
2789 BootstrapAuthPolicy::Open,
2790 BootstrapRequestState::Unauthenticated,
2791 false,
2792 false,
2793 );
2794
2795 let stale_cookie = access_verification_cookie_value(&both)
2800 .expect("healthy pre-repair config yields a cookie");
2801 both.access_control.as_mut().unwrap().repair_required = true;
2802 let repair_remote = TestRequest::default()
2803 .insert_header((header::HOST, "bamboo.example.com"))
2804 .cookie(Cookie::new(
2805 ACCESS_VERIFIED_COOKIE_NAME,
2806 stale_cookie.clone(),
2807 ))
2808 .insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
2809 .insert_header((DEVICE_ID_HEADER, device_id.clone()))
2810 .to_http_request();
2811 assert_snapshot(
2812 &both,
2813 &repair_remote,
2814 BootstrapAuthPolicy::RepairRequired,
2815 BootstrapRequestState::Unauthenticated,
2816 true,
2817 true,
2818 );
2819 let repair_local = TestRequest::default()
2820 .insert_header((header::HOST, "localhost:9562"))
2821 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, stale_cookie))
2822 .insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
2823 .insert_header((DEVICE_ID_HEADER, device_id))
2824 .to_http_request();
2825 assert_snapshot(
2826 &both,
2827 &repair_local,
2828 BootstrapAuthPolicy::RepairRequired,
2829 BootstrapRequestState::LocalBypass,
2830 true,
2831 true,
2832 );
2833
2834 assert_eq!(
2836 serde_json::to_value(BootstrapAuthPolicy::CredentialRequired).unwrap(),
2837 serde_json::json!("credential_required")
2838 );
2839 assert_eq!(
2840 serde_json::to_value(BootstrapRequestState::LocalBypass).unwrap(),
2841 serde_json::json!("local_bypass")
2842 );
2843 }
2844
2845 #[test]
2846 fn stream_is_public_but_sibling_routes_are_not() {
2847 assert!(is_public_access_route("/v2/stream"));
2849 assert!(is_public_access_route("/v2/pair"));
2850 assert!(!is_public_access_route("/v2/pair/code"));
2851 assert!(!is_public_access_route("/v2/devices"));
2852 assert!(!is_public_access_route("/v2/devices/bamboo_x"));
2853 }
2854
2855 #[test]
2856 fn health_probes_are_public() {
2857 assert!(is_public_access_route("/healthz"));
2860 assert!(is_public_access_route("/readyz"));
2861 assert!(is_public_access_route("/api/v1/health"));
2862 }
2863
2864 #[test]
2865 fn bootstrap_is_public_only_at_the_exact_canonical_path() {
2866 assert!(is_public_access_route("/api/v1/bootstrap"));
2867 assert!(!is_public_access_route("/v1/bootstrap"));
2868 assert!(!is_public_access_route("/api/v1/bootstrap/extra"));
2869 }
2870
2871 #[test]
2872 fn public_access_status_routes_are_public_under_both_version_prefixes() {
2873 for prefix in ["/v1", "/api/v1"] {
2879 assert!(
2880 is_public_access_route(&format!("{prefix}/bamboo/access/status")),
2881 "{prefix}/bamboo/access/status must be public"
2882 );
2883 assert!(
2884 is_public_access_route(&format!("{prefix}/bamboo/access/verify")),
2885 "{prefix}/bamboo/access/verify must be public"
2886 );
2887 assert!(
2890 !is_public_access_route(&format!("{prefix}/bamboo/access/password")),
2891 "{prefix}/bamboo/access/password must stay gated"
2892 );
2893 }
2894 }
2895
2896 #[test]
2899 fn generated_pairing_code_is_six_digits() {
2900 for _ in 0..1000 {
2901 let code = generate_pairing_code();
2902 assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
2903 assert!(
2904 code.chars().all(|c| c.is_ascii_digit()),
2905 "code {code:?} must be all digits"
2906 );
2907 }
2908 }
2909
2910 #[test]
2911 fn pairing_code_expiry_predicate() {
2912 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
2914 assert!(!fresh.is_expired());
2915
2916 let zero = PairingCodeEntry::new(Duration::from_secs(0));
2918 assert!(zero.is_expired());
2919
2920 let past = PairingCodeEntry {
2922 expires_at: Instant::now() - Duration::from_secs(1),
2923 };
2924 assert!(past.is_expired());
2925 }
2926
2927 #[test]
2928 fn purge_expired_codes_drops_only_expired() {
2929 let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
2930 codes.insert(
2931 "live".into(),
2932 PairingCodeEntry::new(Duration::from_secs(120)),
2933 );
2934 codes.insert(
2935 "dead".into(),
2936 PairingCodeEntry {
2937 expires_at: Instant::now() - Duration::from_secs(1),
2938 },
2939 );
2940 purge_expired_codes(&codes);
2941 assert!(codes.contains_key("live"));
2942 assert!(!codes.contains_key("dead"));
2943 }
2944
2945 #[test]
2946 fn guard_trips_cooldown_after_threshold() {
2947 let guard = PairingCodeGuard::default();
2948 assert!(!guard.in_cooldown());
2949 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
2951 assert!(!guard.record_failure());
2952 assert!(!guard.in_cooldown());
2953 }
2954 assert!(guard.record_failure());
2956 assert!(guard.in_cooldown());
2957 }
2958
2959 #[test]
2960 fn guard_success_resets_failures() {
2961 let guard = PairingCodeGuard::default();
2962 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
2963 guard.record_failure();
2964 }
2965 guard.record_success();
2966 assert!(!guard.record_failure());
2968 assert!(!guard.in_cooldown());
2969 }
2970
2971 #[test]
2972 fn guard_clears_elapsed_cooldown() {
2973 let guard = PairingCodeGuard::default();
2974 {
2976 let mut state = guard.inner.lock().unwrap();
2977 state.failures = PAIRING_FAILURE_THRESHOLD;
2978 state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
2979 }
2980 assert!(!guard.in_cooldown());
2982 assert!(!guard.record_failure(), "counter was reset to 0");
2983 }
2984
2985 #[test]
2988 fn root_guard_trips_cooldown_after_threshold_per_key() {
2989 let guard = RootPasswordGuard::default();
2990 let key = "203.0.113.7";
2991 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
2993 guard.record_failure(key);
2994 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
2995 }
2996 guard.record_failure(key);
2998 match guard.check(key) {
2999 RootGuardDecision::Cooldown { retry_after_secs } => {
3000 assert!(retry_after_secs >= 1);
3001 assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
3002 }
3003 RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
3004 }
3005 }
3006
3007 #[test]
3008 fn root_guard_keys_are_independent() {
3009 let guard = RootPasswordGuard::default();
3011 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
3012 guard.record_failure("198.51.100.1");
3013 }
3014 assert!(matches!(
3015 guard.check("198.51.100.1"),
3016 RootGuardDecision::Cooldown { .. }
3017 ));
3018 assert!(matches!(
3020 guard.check("198.51.100.2"),
3021 RootGuardDecision::Allow
3022 ));
3023 }
3024
3025 #[test]
3026 fn root_guard_success_resets_key() {
3027 let guard = RootPasswordGuard::default();
3028 let key = "203.0.113.9";
3029 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
3030 guard.record_failure(key);
3031 }
3032 guard.record_success(key);
3033 guard.record_failure(key);
3035 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
3036 }
3037
3038 #[test]
3039 fn root_guard_clears_elapsed_cooldown() {
3040 let guard = RootPasswordGuard::default();
3041 let key = "203.0.113.10";
3042 guard.inner.insert(
3044 key.to_string(),
3045 RootAttemptState {
3046 failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
3047 cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
3048 },
3049 );
3050 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
3052 guard.record_failure(key);
3054 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
3055 }
3056
3057 #[test]
3058 fn root_guard_evicts_inert_keys_past_the_cap() {
3059 let guard = RootPasswordGuard::default();
3060 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
3063 guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
3064 }
3065 assert!(
3066 guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
3067 "inert keys must be swept so the map stays bounded (was {})",
3068 guard.inner.len()
3069 );
3070 let hot = "203.0.113.200";
3072 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
3073 guard.record_failure(hot);
3074 }
3075 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
3076 guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
3077 }
3078 assert!(
3079 matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
3080 "a key in active cooldown must survive eviction sweeps"
3081 );
3082 }
3083
3084 #[test]
3085 fn root_throttle_key_exempts_loopback_and_keys_remote() {
3086 assert!(root_throttle_key(&local_req()).is_none());
3088
3089 let remote = TestRequest::default()
3091 .peer_addr("203.0.113.5:443".parse().unwrap())
3092 .insert_header((header::HOST, "bamboo.example.com"))
3093 .to_http_request();
3094 assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
3095 }
3096
3097 #[test]
3098 fn client_ip_key_strips_v4_mapped_prefix() {
3099 let req = TestRequest::default()
3100 .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
3101 .to_http_request();
3102 assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
3103 }
3104
3105 #[test]
3106 fn device_summary_excludes_secret_material() {
3107 let (cred, _t) = issue_device_token("iPhone");
3110 let summary = DeviceSummary::from_credential(&cred);
3111 let json = serde_json::to_value(&summary).unwrap();
3112 let obj = json.as_object().unwrap();
3113 assert!(
3114 !obj.contains_key("token_hash"),
3115 "must not expose token_hash"
3116 );
3117 assert!(
3118 !obj.contains_key("token_salt"),
3119 "must not expose token_salt"
3120 );
3121 let serialized = serde_json::to_string(&summary).unwrap();
3123 assert!(!serialized.contains(&cred.token_hash));
3124 assert!(!serialized.contains(&cred.token_salt));
3125 assert!(obj.contains_key("device_id"));
3127 assert!(obj.contains_key("label"));
3128 assert!(obj.contains_key("created_at"));
3129 assert!(obj.contains_key("revoked"));
3130 }
3131}