1use std::net::IpAddr;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5use actix_web::{
6 body::{EitherBody, MessageBody},
7 cookie::{time::Duration as CookieDuration, Cookie, SameSite},
8 dev::{ServiceRequest, ServiceResponse},
9 http::header,
10 middleware::Next,
11 web, HttpRequest, HttpResponse, ResponseError,
12};
13use chrono::{SecondsFormat, Utc};
14use rand::{Rng, RngCore};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::{
19 app_state::{AppState, ConfigUpdateEffects},
20 error::AppError,
21};
22use bamboo_config::{Config, DeviceCredential};
23
24#[derive(Serialize)]
25pub struct AccessStatusResponse {
26 pub password_enabled: bool,
27 pub local_bypass: bool,
28 pub requires_password: bool,
29}
30
31#[derive(Debug, Deserialize)]
32pub struct VerifyPasswordRequest {
33 pub password: String,
34}
35
36#[derive(Serialize)]
37pub struct VerifyPasswordResponse {
38 pub success: bool,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct UpdatePasswordRequest {
43 #[serde(default)]
44 pub current_password: String,
45 #[serde(default)]
46 pub new_password: String,
47}
48
49#[derive(Serialize)]
50pub struct UpdatePasswordResponse {
51 pub success: bool,
52 pub password_enabled: bool,
53}
54
55const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
56const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
57const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
58
59fn normalize_ip(ip: &str) -> &str {
60 let ip = ip.trim();
61 ip.strip_prefix("::ffff:").unwrap_or(ip)
62}
63
64fn split_host_and_port(value: &str) -> &str {
65 let candidate = value.trim();
66 if candidate.is_empty() {
67 return candidate;
68 }
69
70 let without_brackets = candidate
71 .strip_prefix('[')
72 .and_then(|v| v.strip_suffix(']'))
73 .unwrap_or(candidate);
74
75 if without_brackets.parse::<IpAddr>().is_ok() {
76 return without_brackets;
77 }
78
79 without_brackets
80 .split(':')
81 .next()
82 .unwrap_or(without_brackets)
83 .trim()
84}
85
86fn is_local_host(host: &str) -> bool {
87 let normalized = split_host_and_port(host)
88 .trim()
89 .trim_end_matches('.')
90 .to_lowercase();
91 if normalized.is_empty() {
92 return false;
93 }
94
95 if normalized == "localhost" || normalized.ends_with(".local") {
96 return true;
97 }
98
99 let normalized = normalize_ip(&normalized);
100 match normalized.parse::<IpAddr>() {
101 Ok(IpAddr::V4(v4)) => {
102 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
103 }
104 Ok(IpAddr::V6(v6)) => {
105 v6.is_loopback()
106 || v6.is_unique_local()
107 || v6.is_unicast_link_local()
108 || v6.is_unspecified()
109 }
110 Err(_) => false,
111 }
112}
113
114fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
115 let mut candidates = Vec::new();
116
117 for header_name in [
118 header::HOST,
119 header::HeaderName::from_static("x-forwarded-host"),
120 header::HeaderName::from_static("x-original-host"),
121 ] {
122 if let Some(value) = req
123 .headers()
124 .get(&header_name)
125 .and_then(|v| v.to_str().ok())
126 {
127 for part in value.split(',') {
128 let host = part.trim();
129 if !host.is_empty() {
130 candidates.push(host.to_string());
131 }
132 }
133 }
134 }
135
136 if let Some(uri_host) = req.uri().host() {
137 let host = uri_host.trim();
138 if !host.is_empty() {
139 candidates.push(host.to_string());
140 }
141 }
142
143 candidates
144}
145
146fn is_local_request(req: &HttpRequest) -> bool {
147 let peer_local: Option<bool> = req
158 .peer_addr()
159 .map(|peer| is_local_host(&peer.ip().to_string()));
160
161 let host_candidates = request_host_candidates(req);
162 if !host_candidates.is_empty() {
163 let host_local = host_candidates.iter().all(|host| is_local_host(host));
164 return host_local && peer_local != Some(false);
184 }
185
186 if let Some(local) = peer_local {
188 return local;
189 }
190 let conn = req.connection_info();
191 conn.peer_addr().map(is_local_host).unwrap_or(false)
192}
193
194fn client_ip_key(req: &HttpRequest) -> Option<String> {
212 if let Some(peer) = req.peer_addr() {
213 return Some(normalize_ip(&peer.ip().to_string()).to_string());
214 }
215
216 let conn = req.connection_info();
217 for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
218 .into_iter()
219 .flatten()
220 {
221 let normalized = normalize_ip(candidate).trim();
222 if !normalized.is_empty() {
223 return Some(normalized.to_string());
224 }
225 }
226
227 None
228}
229
230fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
231 let salt = hex::decode(salt_hex).ok()?;
232 let mut hasher = Sha256::new();
233 hasher.update(&salt);
234 hasher.update(password.as_bytes());
235 Some(hex::encode(hasher.finalize()))
236}
237
238fn verify_password(config: &Config, password: &str) -> bool {
239 let Some(access) = config.access_control.as_ref() else {
240 return false;
241 };
242 if !access.password_enabled {
243 return false;
244 }
245
246 let (Some(hash), Some(salt)) = (
247 access.password_hash.as_deref(),
248 access.password_salt.as_deref(),
249 ) else {
250 return false;
251 };
252
253 compute_password_hash(password, salt)
254 .map(|computed| computed == hash)
255 .unwrap_or(false)
256}
257
258const DEVICE_TOKEN_PREFIX: &str = "bd1_";
267const DEVICE_ID_PREFIX: &str = "bamboo_";
269const DEVICE_ID_HEADER: &str = "x-device-id";
272
273fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
280 if a.len() != b.len() {
281 return false;
282 }
283 let mut diff: u8 = 0;
284 for (x, y) in a.iter().zip(b.iter()) {
285 diff |= x ^ y;
286 }
287 diff == 0
288}
289
290fn random_hex(len: usize) -> String {
292 let mut bytes = vec![0_u8; len];
293 rand::thread_rng().fill_bytes(&mut bytes);
294 hex::encode(bytes)
295}
296
297pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
303 let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
304 let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
305 let salt_hex = random_hex(16);
306 let token_hash =
310 compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
311 let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
312
313 let credential = DeviceCredential {
314 device_id,
315 label: label.to_string(),
316 token_hash,
317 token_salt: salt_hex,
318 created_at,
319 last_used_at: None,
320 revoked: false,
321 };
322 (credential, token)
323}
324
325pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
330 let Some(access) = config.access_control.as_ref() else {
331 return false;
332 };
333 let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
336 return false;
337 };
338 if device.revoked {
339 return false;
340 }
341 let Some(computed) = compute_password_hash(token, &device.token_salt) else {
342 return false;
343 };
344 constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
345}
346
347fn has_active_devices(config: &Config) -> bool {
351 config
352 .access_control
353 .as_ref()
354 .map(|access| access.devices.iter().any(|d| !d.revoked))
355 .unwrap_or(false)
356}
357
358fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
366 let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
367 let token = auth
368 .strip_prefix("Bearer ")
369 .or_else(|| auth.strip_prefix("bearer "))?
370 .trim();
371 if !token.starts_with(DEVICE_TOKEN_PREFIX) {
372 return None;
373 }
374 let device_id = req
375 .headers()
376 .get(DEVICE_ID_HEADER)?
377 .to_str()
378 .ok()?
379 .trim()
380 .to_string();
381 if device_id.is_empty() {
382 return None;
383 }
384 Some((device_id, token.to_string()))
385}
386
387fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
389 match presented_device_token(req) {
390 Some((device_id, token)) => verify_device_token(config, &device_id, &token),
391 None => false,
392 }
393}
394
395fn access_verification_cookie_value(config: &Config) -> Option<String> {
396 let access = config.access_control.as_ref()?;
397 if !access.password_enabled {
398 return None;
399 }
400
401 let hash = access.password_hash.as_deref()?.trim();
402 let salt = access.password_salt.as_deref()?.trim();
403 if hash.is_empty() || salt.is_empty() {
404 return None;
405 }
406
407 let mut hasher = Sha256::new();
408 hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
409 hasher.update(b":");
410 hasher.update(hash.as_bytes());
411 hasher.update(b":");
412 hasher.update(salt.as_bytes());
413 Some(format!(
414 "{}:{}",
415 ACCESS_VERIFIED_COOKIE_VERSION,
416 hex::encode(hasher.finalize())
417 ))
418}
419
420fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
421 let expected = match access_verification_cookie_value(config) {
422 Some(value) => value,
423 None => return false,
424 };
425
426 req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
427 .map(|cookie| cookie.value() == expected)
428 .unwrap_or(false)
429}
430
431fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
432 let value = access_verification_cookie_value(config)?;
433 Some(
434 Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
435 .path("/")
436 .http_only(true)
437 .same_site(SameSite::Lax)
438 .secure(secure)
439 .max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
440 .finish(),
441 )
442}
443
444fn is_public_access_route(path: &str) -> bool {
445 matches!(
446 path,
447 "/api/v1/health"
448 | "/v1/bamboo/access/status"
449 | "/v1/bamboo/access/verify"
450 | "/v2/pair"
454 | "/v2/stream"
465 )
466}
467
468pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
477 !build_access_status(config, req).requires_password
478 || request_has_verified_access_cookie(req, config)
479 || request_has_valid_device_token(req, config)
480}
481
482pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
483 req: ServiceRequest,
484 next: Next<B>,
485) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
486 let path = req.path().to_string();
487 if is_public_access_route(&path) {
488 return next
489 .call(req)
490 .await
491 .map(ServiceResponse::map_into_left_body);
492 }
493
494 let app_state = match req.app_data::<web::Data<AppState>>() {
495 Some(state) => state.clone(),
496 None => {
497 return next
498 .call(req)
499 .await
500 .map(ServiceResponse::map_into_left_body)
501 }
502 };
503
504 let config = app_state.config.read().await.clone();
505 if request_is_authorized(req.request(), &config) {
512 return next
513 .call(req)
514 .await
515 .map(ServiceResponse::map_into_left_body);
516 }
517
518 let response = AppError::Unauthorized("access credential verification required".to_string())
519 .error_response()
520 .map_into_right_body();
521 Ok(req.into_response(response))
522}
523
524fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
525 let password_enabled = config
526 .access_control
527 .as_ref()
528 .map(|access| {
529 access.password_enabled
530 && access
531 .password_hash
532 .as_deref()
533 .map(|value| !value.trim().is_empty())
534 .unwrap_or(false)
535 && access
536 .password_salt
537 .as_deref()
538 .map(|value| !value.trim().is_empty())
539 .unwrap_or(false)
540 })
541 .unwrap_or(false);
542 let local_bypass = is_local_request(req);
543 let credential_required = password_enabled || has_active_devices(config);
547
548 AccessStatusResponse {
549 password_enabled,
550 local_bypass,
551 requires_password: credential_required && !local_bypass,
552 }
553}
554
555pub async fn get_access_status(
556 req: HttpRequest,
557 app_state: web::Data<AppState>,
558) -> Result<HttpResponse, AppError> {
559 let config = app_state.config.read().await.clone();
560 Ok(HttpResponse::Ok().json(build_access_status(&config, &req)))
561}
562
563pub async fn verify_access_password(
564 req: HttpRequest,
565 payload: web::Json<VerifyPasswordRequest>,
566 app_state: web::Data<AppState>,
567) -> Result<HttpResponse, AppError> {
568 let password = payload.password.trim();
569 if password.is_empty() {
570 return Err(AppError::BadRequest("password is required".to_string()));
571 }
572
573 let throttle_key = root_throttle_key(&req);
577 if let Some(key) = throttle_key.as_deref() {
578 if let RootGuardDecision::Cooldown { retry_after_secs } =
579 app_state.root_password_guard.check(key)
580 {
581 return Ok(too_many_requests_response(retry_after_secs));
582 }
583 }
584
585 let config = app_state.config.read().await.clone();
586 if !verify_password(&config, password) {
587 if let Some(key) = throttle_key.as_deref() {
588 app_state.root_password_guard.record_failure(key);
589 }
590 return Err(AppError::Unauthorized("invalid password".to_string()));
591 }
592
593 if let Some(key) = throttle_key.as_deref() {
595 app_state.root_password_guard.record_success(key);
596 }
597
598 let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
599 let cookie = build_access_verified_cookie(&config, secure)
600 .ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
601
602 Ok(HttpResponse::Ok()
603 .cookie(cookie)
604 .json(VerifyPasswordResponse { success: true }))
605}
606
607pub async fn update_access_password(
608 req: HttpRequest,
609 app_state: web::Data<AppState>,
610 payload: web::Json<UpdatePasswordRequest>,
611) -> Result<HttpResponse, AppError> {
612 let local_bypass = is_local_request(&req);
613 let new_password = payload.new_password.trim();
614
615 if new_password.is_empty() {
616 return Err(AppError::BadRequest("new_password is required".to_string()));
617 }
618
619 let current_config = app_state.config.read().await.clone();
620 let password_already_enabled = current_config
621 .access_control
622 .as_ref()
623 .map(|access| access.password_enabled)
624 .unwrap_or(false);
625
626 if password_already_enabled && !local_bypass {
627 let current_password = payload.current_password.trim();
628 if current_password.is_empty() {
629 return Err(AppError::Unauthorized(
630 "current_password is required".to_string(),
631 ));
632 }
633 if !verify_password(¤t_config, current_password) {
634 return Err(AppError::Unauthorized(
635 "invalid current password".to_string(),
636 ));
637 }
638 }
639
640 let mut salt_bytes = [0_u8; 16];
641 rand::thread_rng().fill_bytes(&mut salt_bytes);
642 let salt_hex = hex::encode(salt_bytes);
643 let password_hash = compute_password_hash(new_password, &salt_hex).ok_or_else(|| {
644 AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
645 })?;
646 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
647
648 app_state
649 .update_config(
650 move |config| {
651 let access = config.access_control.get_or_insert_with(Default::default);
656 access.password_enabled = true;
657 access.password_hash = Some(password_hash.clone());
658 access.password_salt = Some(salt_hex.clone());
659 access.updated_at = Some(updated_at.clone());
660 Ok(())
661 },
662 ConfigUpdateEffects::default(),
663 )
664 .await?;
665
666 Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
667 success: true,
668 password_enabled: true,
669 }))
670}
671
672#[derive(Debug, Deserialize)]
675pub struct PairDeviceRequest {
676 #[serde(default)]
678 pub root_password: String,
679 #[serde(default)]
683 pub code: String,
684 #[serde(default)]
686 pub label: String,
687}
688
689#[derive(Serialize)]
690pub struct PairDeviceResponse {
691 pub device_id: String,
692 pub device_token: String,
694 pub expires_hint: &'static str,
695}
696
697pub async fn pair_device(
709 req: HttpRequest,
710 payload: web::Json<PairDeviceRequest>,
711 app_state: web::Data<AppState>,
712) -> Result<HttpResponse, AppError> {
713 let label = payload.label.trim();
714 if label.is_empty() {
715 return Err(AppError::BadRequest("label is required".to_string()));
716 }
717
718 let code = payload.code.trim();
719 let root_password = payload.root_password.trim();
720
721 if !code.is_empty() {
724 return pair_device_with_code(&app_state, code, label).await;
725 }
726 if !root_password.is_empty() {
727 return pair_device_with_root_password(&req, &app_state, root_password, label).await;
728 }
729
730 Err(AppError::BadRequest(
731 "provide either a root_password or a one-time pairing code".to_string(),
732 ))
733}
734
735async fn pair_device_with_root_password(
738 req: &HttpRequest,
739 app_state: &AppState,
740 root_password: &str,
741 label: &str,
742) -> Result<HttpResponse, AppError> {
743 let throttle_key = root_throttle_key(req);
747 if let Some(key) = throttle_key.as_deref() {
748 if let RootGuardDecision::Cooldown { retry_after_secs } =
749 app_state.root_password_guard.check(key)
750 {
751 return Ok(too_many_requests_response(retry_after_secs));
752 }
753 }
754
755 let config = app_state.config.read().await.clone();
756
757 let password_enabled = config
758 .access_control
759 .as_ref()
760 .map(|access| access.password_enabled)
761 .unwrap_or(false);
762 if !password_enabled {
763 return Err(AppError::BadRequest(
764 "set an access password first: the owner root password is required to authorize device pairing".to_string(),
765 ));
766 }
767
768 if !verify_password(&config, root_password) {
769 if let Some(key) = throttle_key.as_deref() {
770 app_state.root_password_guard.record_failure(key);
771 }
772 return Err(AppError::Unauthorized("invalid root password".to_string()));
773 }
774
775 if let Some(key) = throttle_key.as_deref() {
777 app_state.root_password_guard.record_success(key);
778 }
779
780 persist_new_device(app_state, label).await
781}
782
783async fn pair_device_with_code(
787 app_state: &AppState,
788 code: &str,
789 label: &str,
790) -> Result<HttpResponse, AppError> {
791 if app_state.pairing_code_guard.in_cooldown() {
794 return Err(AppError::Unauthorized(
795 "too many failed pairing attempts — try again later".to_string(),
796 ));
797 }
798
799 let consumed = app_state.pairing_codes.remove(code);
804 let valid = match consumed {
805 Some((_k, entry)) => !entry.is_expired(),
806 None => false,
807 };
808
809 if !valid {
810 if app_state.pairing_code_guard.record_failure() {
814 app_state.pairing_codes.clear();
815 }
816 return Err(AppError::Unauthorized(
817 "invalid or expired pairing code".to_string(),
818 ));
819 }
820
821 app_state.pairing_code_guard.record_success();
823 persist_new_device(app_state, label).await
824}
825
826async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
830 let (credential, token) = issue_device_token(label);
831 let device_id = credential.device_id.clone();
832
833 app_state
834 .update_config(
835 move |config| {
836 let access = config.access_control.get_or_insert_with(Default::default);
839 access.devices.push(credential.clone());
840 Ok(())
841 },
842 ConfigUpdateEffects::default(),
843 )
844 .await?;
845
846 Ok(HttpResponse::Ok().json(PairDeviceResponse {
849 device_id,
850 device_token: token,
851 expires_hint: "rotate-on-demand",
852 }))
853}
854
855const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
859const PAIRING_FAILURE_THRESHOLD: u32 = 10;
861const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
863
864#[derive(Debug, Clone)]
867pub struct PairingCodeEntry {
868 expires_at: Instant,
869}
870
871impl PairingCodeEntry {
872 pub(crate) fn new(ttl: Duration) -> Self {
873 Self {
874 expires_at: Instant::now() + ttl,
875 }
876 }
877
878 pub fn is_expired(&self) -> bool {
881 Instant::now() >= self.expires_at
882 }
883}
884
885#[derive(Debug, Default)]
906pub struct PairingCodeGuard {
907 inner: Mutex<PairingGuardState>,
908}
909
910#[derive(Debug, Default)]
911struct PairingGuardState {
912 failures: u32,
913 cooldown_until: Option<Instant>,
915}
916
917impl PairingCodeGuard {
918 pub fn in_cooldown(&self) -> bool {
921 let mut state = self.inner.lock().expect("pairing guard mutex poisoned");
922 match state.cooldown_until {
923 Some(until) if Instant::now() < until => true,
924 Some(_) => {
925 state.cooldown_until = None;
927 state.failures = 0;
928 false
929 }
930 None => false,
931 }
932 }
933
934 pub fn record_failure(&self) -> bool {
937 let mut state = self.inner.lock().expect("pairing guard mutex poisoned");
938 state.failures = state.failures.saturating_add(1);
939 if state.failures >= PAIRING_FAILURE_THRESHOLD {
940 state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
941 true
942 } else {
943 false
944 }
945 }
946
947 pub fn record_success(&self) {
949 let mut state = self.inner.lock().expect("pairing guard mutex poisoned");
950 state.failures = 0;
951 state.cooldown_until = None;
952 }
953}
954
955const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
974const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
976const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
982
983#[derive(Debug, Default, Clone)]
985struct RootAttemptState {
986 failures: u32,
987 cooldown_until: Option<Instant>,
989}
990
991#[derive(Debug, Default)]
1011pub struct RootPasswordGuard {
1012 inner: dashmap::DashMap<String, RootAttemptState>,
1013}
1014
1015pub enum RootGuardDecision {
1017 Allow,
1019 Cooldown { retry_after_secs: u64 },
1021}
1022
1023impl RootPasswordGuard {
1024 pub fn check(&self, key: &str) -> RootGuardDecision {
1027 let now = Instant::now();
1028 if let Some(mut entry) = self.inner.get_mut(key) {
1029 if let Some(until) = entry.cooldown_until {
1030 if now < until {
1031 let retry_after_secs = (until - now).as_secs().max(1);
1032 return RootGuardDecision::Cooldown { retry_after_secs };
1033 }
1034 entry.failures = 0;
1036 entry.cooldown_until = None;
1037 }
1038 }
1039 RootGuardDecision::Allow
1040 }
1041
1042 pub fn record_failure(&self, key: &str) {
1045 let now = Instant::now();
1046 if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
1050 self.inner
1051 .retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
1052 }
1053 let mut entry = self.inner.entry(key.to_string()).or_default();
1054 if matches!(entry.cooldown_until, Some(until) if now < until) {
1057 return;
1058 }
1059 if entry.cooldown_until.is_some() {
1061 entry.failures = 0;
1062 entry.cooldown_until = None;
1063 }
1064 entry.failures = entry.failures.saturating_add(1);
1065 if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
1066 entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
1067 }
1068 }
1069
1070 pub fn record_success(&self, key: &str) {
1072 self.inner.remove(key);
1073 }
1074}
1075
1076fn root_throttle_key(req: &HttpRequest) -> Option<String> {
1083 if is_local_request(req) {
1084 return None;
1085 }
1086 Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
1087}
1088
1089fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
1092 HttpResponse::TooManyRequests()
1093 .insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
1094 .json(serde_json::json!({
1095 "error": {
1096 "message": "too many failed password attempts — try again later",
1097 "type": "api_error",
1098 }
1099 }))
1100}
1101
1102fn generate_pairing_code() -> String {
1108 let n = rand::thread_rng().gen_range(0..1_000_000);
1109 format!("{n:06}")
1110}
1111
1112fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
1114 codes.retain(|_code, entry| !entry.is_expired());
1115}
1116
1117#[derive(Serialize)]
1118pub struct PairingCodeResponse {
1119 pub code: String,
1120 pub ttl: u64,
1122}
1123
1124pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1132 purge_expired_codes(&app_state.pairing_codes);
1134
1135 let code = generate_pairing_code();
1136 let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
1137 app_state.pairing_codes.insert(code.clone(), entry);
1139
1140 Ok(HttpResponse::Ok().json(PairingCodeResponse {
1141 code,
1142 ttl: PAIRING_CODE_TTL.as_secs(),
1143 }))
1144}
1145
1146#[derive(Serialize)]
1152pub struct DeviceSummary {
1153 pub device_id: String,
1154 pub label: String,
1155 pub created_at: String,
1156 pub last_used_at: Option<String>,
1157 pub revoked: bool,
1158}
1159
1160impl DeviceSummary {
1161 fn from_credential(d: &DeviceCredential) -> Self {
1162 Self {
1163 device_id: d.device_id.clone(),
1164 label: d.label.clone(),
1165 created_at: d.created_at.clone(),
1166 last_used_at: d.last_used_at.clone(),
1167 revoked: d.revoked,
1168 }
1169 }
1170}
1171
1172pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1175 let config = app_state.config.read().await.clone();
1176 let devices: Vec<DeviceSummary> = config
1177 .access_control
1178 .as_ref()
1179 .map(|access| {
1180 access
1181 .devices
1182 .iter()
1183 .map(DeviceSummary::from_credential)
1184 .collect()
1185 })
1186 .unwrap_or_default();
1187 Ok(HttpResponse::Ok().json(devices))
1188}
1189
1190pub async fn revoke_device(
1197 path: web::Path<String>,
1198 app_state: web::Data<AppState>,
1199) -> Result<HttpResponse, AppError> {
1200 let device_id = path.into_inner();
1201
1202 {
1205 let config = app_state.config.read().await;
1206 let exists = config
1207 .access_control
1208 .as_ref()
1209 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1210 .unwrap_or(false);
1211 if !exists {
1212 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1213 }
1214 }
1215
1216 let target = device_id.clone();
1217 app_state
1218 .update_config(
1219 move |config| {
1220 if let Some(access) = config.access_control.as_mut() {
1221 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1222 {
1223 device.revoked = true;
1224 }
1225 }
1226 Ok(())
1227 },
1228 ConfigUpdateEffects::default(),
1229 )
1230 .await?;
1231
1232 Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
1233}
1234
1235pub async fn rotate_device(
1243 path: web::Path<String>,
1244 app_state: web::Data<AppState>,
1245) -> Result<HttpResponse, AppError> {
1246 let device_id = path.into_inner();
1247
1248 {
1251 let config = app_state.config.read().await;
1252 let exists = config
1253 .access_control
1254 .as_ref()
1255 .map(|access| access.devices.iter().any(|d| d.device_id == device_id))
1256 .unwrap_or(false);
1257 if !exists {
1258 return Err(AppError::NotFound(format!("unknown device {device_id}")));
1259 }
1260 }
1261
1262 let (fresh, token) = issue_device_token("");
1265
1266 let target = device_id.clone();
1267 app_state
1268 .update_config(
1269 move |config| {
1270 if let Some(access) = config.access_control.as_mut() {
1271 if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
1272 {
1273 device.token_hash = fresh.token_hash.clone();
1274 device.token_salt = fresh.token_salt.clone();
1275 device.revoked = false;
1276 device.last_used_at = None;
1277 }
1278 }
1279 Ok(())
1280 },
1281 ConfigUpdateEffects::default(),
1282 )
1283 .await?;
1284
1285 Ok(HttpResponse::Ok().json(PairDeviceResponse {
1287 device_id,
1288 device_token: token,
1289 expires_hint: "rotate-on-demand",
1290 }))
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use actix_web::test::TestRequest;
1297 use bamboo_config::AccessControlConfig;
1298
1299 #[test]
1300 fn loopback_request_is_local() {
1301 let req = TestRequest::default()
1302 .peer_addr("127.0.0.1:12345".parse().unwrap())
1303 .insert_header((header::HOST, "localhost:9562"))
1304 .to_http_request();
1305 assert!(is_local_request(&req));
1306 }
1307
1308 #[test]
1309 fn private_lan_host_is_local() {
1310 let req = TestRequest::default()
1311 .insert_header((header::HOST, "192.168.0.10:9562"))
1312 .to_http_request();
1313 assert!(is_local_request(&req));
1314 }
1315
1316 #[test]
1317 fn remote_host_is_not_local_even_when_peer_is_loopback() {
1318 let req = TestRequest::default()
1319 .peer_addr("127.0.0.1:12345".parse().unwrap())
1320 .insert_header((header::HOST, "bamboo.example.com"))
1321 .to_http_request();
1322 assert!(!is_local_request(&req));
1323 }
1324
1325 #[test]
1326 fn spoofed_local_host_from_remote_peer_is_not_local() {
1327 for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
1331 let req = TestRequest::default()
1332 .peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
1334 .to_http_request();
1335 assert!(
1336 !is_local_request(&req),
1337 "remote peer + spoofed Host '{spoof}' must not be local"
1338 );
1339 let req2 = TestRequest::default()
1341 .peer_addr("203.0.113.5:40000".parse().unwrap())
1342 .insert_header(("x-forwarded-host", spoof))
1343 .to_http_request();
1344 assert!(
1345 !is_local_request(&req2),
1346 "remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
1347 );
1348 }
1349 }
1350
1351 #[test]
1352 fn loopback_peer_with_no_host_is_local() {
1353 let req = TestRequest::default()
1354 .peer_addr("127.0.0.1:5000".parse().unwrap())
1355 .to_http_request();
1356 assert!(is_local_request(&req));
1357 }
1358
1359 #[test]
1360 fn password_hash_roundtrip_verifies() {
1361 let salt_hex = hex::encode([1_u8; 16]);
1362 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1363 let config = Config {
1364 access_control: Some(AccessControlConfig {
1365 password_enabled: true,
1366 password_hash: Some(hash),
1367 password_salt: Some(salt_hex),
1368 updated_at: None,
1369 devices: Vec::new(),
1370 }),
1371 ..Config::default()
1372 };
1373
1374 assert!(verify_password(&config, "secret"));
1375 assert!(!verify_password(&config, "wrong"));
1376 }
1377
1378 fn config_with_password() -> Config {
1381 let salt_hex = hex::encode([1_u8; 16]);
1382 let hash = compute_password_hash("secret", &salt_hex).unwrap();
1383 Config {
1384 access_control: Some(AccessControlConfig {
1385 password_enabled: true,
1386 password_hash: Some(hash),
1387 password_salt: Some(salt_hex),
1388 updated_at: None,
1389 devices: Vec::new(),
1390 }),
1391 ..Config::default()
1392 }
1393 }
1394
1395 #[test]
1396 fn constant_time_eq_matches_and_rejects() {
1397 assert!(constant_time_eq(b"abcd", b"abcd"));
1398 assert!(!constant_time_eq(b"abcd", b"abce"));
1399 assert!(!constant_time_eq(b"abc", b"abcd"));
1400 }
1401
1402 #[test]
1403 fn issued_token_has_expected_format_and_verifies() {
1404 let (cred, token) = issue_device_token("iPhone 15");
1405 assert!(token.starts_with("bd1_"));
1406 assert_eq!(token.len(), "bd1_".len() + 32);
1407 assert!(cred.device_id.starts_with("bamboo_"));
1408 assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
1409 assert_eq!(cred.label, "iPhone 15");
1410 assert!(!cred.revoked);
1411 assert_ne!(cred.token_hash, token);
1413
1414 let mut config = config_with_password();
1415 config
1416 .access_control
1417 .as_mut()
1418 .unwrap()
1419 .devices
1420 .push(cred.clone());
1421
1422 assert!(verify_device_token(&config, &cred.device_id, &token));
1423 assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
1424 assert!(!verify_device_token(&config, "bamboo_unknown", &token));
1425 }
1426
1427 #[test]
1428 fn revoked_token_is_rejected() {
1429 let (mut cred, token) = issue_device_token("iPad");
1430 cred.revoked = true;
1431 let mut config = config_with_password();
1432 let device_id = cred.device_id.clone();
1433 config.access_control.as_mut().unwrap().devices.push(cred);
1434 assert!(!verify_device_token(&config, &device_id, &token));
1435 }
1436
1437 #[test]
1438 fn has_active_devices_ignores_revoked() {
1439 let mut config = config_with_password();
1440 assert!(!has_active_devices(&config));
1441 let (mut cred, _t) = issue_device_token("d");
1442 cred.revoked = true;
1443 config
1444 .access_control
1445 .as_mut()
1446 .unwrap()
1447 .devices
1448 .push(cred.clone());
1449 assert!(!has_active_devices(&config));
1450 let (cred2, _t2) = issue_device_token("d2");
1451 config.access_control.as_mut().unwrap().devices.push(cred2);
1452 assert!(has_active_devices(&config));
1453 }
1454
1455 fn remote_req() -> HttpRequest {
1456 TestRequest::default()
1457 .insert_header((header::HOST, "bamboo.example.com"))
1458 .to_http_request()
1459 }
1460
1461 fn local_req() -> HttpRequest {
1462 TestRequest::default()
1463 .insert_header((header::HOST, "localhost:9562"))
1464 .to_http_request()
1465 }
1466
1467 #[test]
1468 fn no_devices_no_password_does_not_require_credential() {
1469 let config = Config::default();
1472 assert!(!build_access_status(&config, &remote_req()).requires_password);
1473 }
1474
1475 #[test]
1476 fn password_only_gate_matches_prior_behavior() {
1477 let config = config_with_password();
1478 assert!(build_access_status(&config, &remote_req()).requires_password);
1479 assert!(!build_access_status(&config, &local_req()).requires_password);
1480 }
1481
1482 #[test]
1483 fn device_presence_requires_credential_even_without_password() {
1484 let (cred, _t) = issue_device_token("d");
1486 let config = Config {
1487 access_control: Some(AccessControlConfig {
1488 password_enabled: false,
1489 password_hash: None,
1490 password_salt: None,
1491 updated_at: None,
1492 devices: vec![cred],
1493 }),
1494 ..Config::default()
1495 };
1496 assert!(build_access_status(&config, &remote_req()).requires_password);
1497 assert!(!build_access_status(&config, &local_req()).requires_password);
1499 }
1500
1501 #[test]
1502 fn valid_device_token_on_request_authenticates() {
1503 let (cred, token) = issue_device_token("d");
1504 let device_id = cred.device_id.clone();
1505 let mut config = config_with_password();
1506 config.access_control.as_mut().unwrap().devices.push(cred);
1507
1508 let req = TestRequest::default()
1509 .insert_header((header::HOST, "bamboo.example.com"))
1510 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1511 .insert_header((DEVICE_ID_HEADER, device_id))
1512 .to_http_request();
1513 assert!(request_has_valid_device_token(&req, &config));
1514
1515 let bad = TestRequest::default()
1517 .insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
1518 .insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
1519 .to_http_request();
1520 assert!(!request_has_valid_device_token(&bad, &config));
1521
1522 let no_id = TestRequest::default()
1524 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1525 .to_http_request();
1526 assert!(!request_has_valid_device_token(&no_id, &config));
1527 }
1528
1529 #[test]
1536 fn request_is_authorized_local_is_always_allowed() {
1537 let config = config_with_password();
1539 assert!(request_is_authorized(&local_req(), &config));
1540 }
1541
1542 #[test]
1543 fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
1544 let (cred, _t) = issue_device_token("d");
1546 let config = Config {
1547 access_control: Some(AccessControlConfig {
1548 password_enabled: false,
1549 password_hash: None,
1550 password_salt: None,
1551 updated_at: None,
1552 devices: vec![cred],
1553 }),
1554 ..Config::default()
1555 };
1556 assert!(!request_is_authorized(&remote_req(), &config));
1557 }
1558
1559 #[test]
1560 fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
1561 let config = config_with_password();
1562 assert!(!request_is_authorized(&remote_req(), &config));
1563 }
1564
1565 #[test]
1566 fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
1567 let config = config_with_password();
1568 let cookie_value =
1569 access_verification_cookie_value(&config).expect("password config yields a cookie");
1570 let req = TestRequest::default()
1571 .insert_header((header::HOST, "bamboo.example.com"))
1572 .cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
1573 .to_http_request();
1574 assert!(request_is_authorized(&req, &config));
1575 }
1576
1577 #[test]
1578 fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
1579 let (cred, token) = issue_device_token("d");
1580 let device_id = cred.device_id.clone();
1581 let mut config = config_with_password();
1582 config.access_control.as_mut().unwrap().devices.push(cred);
1583
1584 let req = TestRequest::default()
1585 .insert_header((header::HOST, "bamboo.example.com"))
1586 .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
1587 .insert_header((DEVICE_ID_HEADER, device_id))
1588 .to_http_request();
1589 assert!(request_is_authorized(&req, &config));
1590 }
1591
1592 #[test]
1593 fn request_is_authorized_no_password_no_devices_is_open() {
1594 let config = Config::default();
1597 assert!(request_is_authorized(&remote_req(), &config));
1598 }
1599
1600 #[test]
1601 fn stream_is_public_but_sibling_routes_are_not() {
1602 assert!(is_public_access_route("/v2/stream"));
1604 assert!(is_public_access_route("/v2/pair"));
1605 assert!(!is_public_access_route("/v2/pair/code"));
1606 assert!(!is_public_access_route("/v2/devices"));
1607 assert!(!is_public_access_route("/v2/devices/bamboo_x"));
1608 }
1609
1610 #[test]
1613 fn generated_pairing_code_is_six_digits() {
1614 for _ in 0..1000 {
1615 let code = generate_pairing_code();
1616 assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
1617 assert!(
1618 code.chars().all(|c| c.is_ascii_digit()),
1619 "code {code:?} must be all digits"
1620 );
1621 }
1622 }
1623
1624 #[test]
1625 fn pairing_code_expiry_predicate() {
1626 let fresh = PairingCodeEntry::new(Duration::from_secs(120));
1628 assert!(!fresh.is_expired());
1629
1630 let zero = PairingCodeEntry::new(Duration::from_secs(0));
1632 assert!(zero.is_expired());
1633
1634 let past = PairingCodeEntry {
1636 expires_at: Instant::now() - Duration::from_secs(1),
1637 };
1638 assert!(past.is_expired());
1639 }
1640
1641 #[test]
1642 fn purge_expired_codes_drops_only_expired() {
1643 let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
1644 codes.insert(
1645 "live".into(),
1646 PairingCodeEntry::new(Duration::from_secs(120)),
1647 );
1648 codes.insert(
1649 "dead".into(),
1650 PairingCodeEntry {
1651 expires_at: Instant::now() - Duration::from_secs(1),
1652 },
1653 );
1654 purge_expired_codes(&codes);
1655 assert!(codes.contains_key("live"));
1656 assert!(!codes.contains_key("dead"));
1657 }
1658
1659 #[test]
1660 fn guard_trips_cooldown_after_threshold() {
1661 let guard = PairingCodeGuard::default();
1662 assert!(!guard.in_cooldown());
1663 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1665 assert!(!guard.record_failure());
1666 assert!(!guard.in_cooldown());
1667 }
1668 assert!(guard.record_failure());
1670 assert!(guard.in_cooldown());
1671 }
1672
1673 #[test]
1674 fn guard_success_resets_failures() {
1675 let guard = PairingCodeGuard::default();
1676 for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
1677 guard.record_failure();
1678 }
1679 guard.record_success();
1680 assert!(!guard.record_failure());
1682 assert!(!guard.in_cooldown());
1683 }
1684
1685 #[test]
1686 fn guard_clears_elapsed_cooldown() {
1687 let guard = PairingCodeGuard::default();
1688 {
1690 let mut state = guard.inner.lock().unwrap();
1691 state.failures = PAIRING_FAILURE_THRESHOLD;
1692 state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
1693 }
1694 assert!(!guard.in_cooldown());
1696 assert!(!guard.record_failure(), "counter was reset to 0");
1697 }
1698
1699 #[test]
1702 fn root_guard_trips_cooldown_after_threshold_per_key() {
1703 let guard = RootPasswordGuard::default();
1704 let key = "203.0.113.7";
1705 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1707 guard.record_failure(key);
1708 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1709 }
1710 guard.record_failure(key);
1712 match guard.check(key) {
1713 RootGuardDecision::Cooldown { retry_after_secs } => {
1714 assert!(retry_after_secs >= 1);
1715 assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
1716 }
1717 RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
1718 }
1719 }
1720
1721 #[test]
1722 fn root_guard_keys_are_independent() {
1723 let guard = RootPasswordGuard::default();
1725 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1726 guard.record_failure("198.51.100.1");
1727 }
1728 assert!(matches!(
1729 guard.check("198.51.100.1"),
1730 RootGuardDecision::Cooldown { .. }
1731 ));
1732 assert!(matches!(
1734 guard.check("198.51.100.2"),
1735 RootGuardDecision::Allow
1736 ));
1737 }
1738
1739 #[test]
1740 fn root_guard_success_resets_key() {
1741 let guard = RootPasswordGuard::default();
1742 let key = "203.0.113.9";
1743 for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
1744 guard.record_failure(key);
1745 }
1746 guard.record_success(key);
1747 guard.record_failure(key);
1749 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1750 }
1751
1752 #[test]
1753 fn root_guard_clears_elapsed_cooldown() {
1754 let guard = RootPasswordGuard::default();
1755 let key = "203.0.113.10";
1756 guard.inner.insert(
1758 key.to_string(),
1759 RootAttemptState {
1760 failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
1761 cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
1762 },
1763 );
1764 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1766 guard.record_failure(key);
1768 assert!(matches!(guard.check(key), RootGuardDecision::Allow));
1769 }
1770
1771 #[test]
1772 fn root_guard_evicts_inert_keys_past_the_cap() {
1773 let guard = RootPasswordGuard::default();
1774 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1777 guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
1778 }
1779 assert!(
1780 guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
1781 "inert keys must be swept so the map stays bounded (was {})",
1782 guard.inner.len()
1783 );
1784 let hot = "203.0.113.200";
1786 for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
1787 guard.record_failure(hot);
1788 }
1789 for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
1790 guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
1791 }
1792 assert!(
1793 matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
1794 "a key in active cooldown must survive eviction sweeps"
1795 );
1796 }
1797
1798 #[test]
1799 fn root_throttle_key_exempts_loopback_and_keys_remote() {
1800 assert!(root_throttle_key(&local_req()).is_none());
1802
1803 let remote = TestRequest::default()
1805 .peer_addr("203.0.113.5:443".parse().unwrap())
1806 .insert_header((header::HOST, "bamboo.example.com"))
1807 .to_http_request();
1808 assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
1809 }
1810
1811 #[test]
1812 fn client_ip_key_strips_v4_mapped_prefix() {
1813 let req = TestRequest::default()
1814 .peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
1815 .to_http_request();
1816 assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
1817 }
1818
1819 #[test]
1820 fn device_summary_excludes_secret_material() {
1821 let (cred, _t) = issue_device_token("iPhone");
1824 let summary = DeviceSummary::from_credential(&cred);
1825 let json = serde_json::to_value(&summary).unwrap();
1826 let obj = json.as_object().unwrap();
1827 assert!(
1828 !obj.contains_key("token_hash"),
1829 "must not expose token_hash"
1830 );
1831 assert!(
1832 !obj.contains_key("token_salt"),
1833 "must not expose token_salt"
1834 );
1835 let serialized = serde_json::to_string(&summary).unwrap();
1837 assert!(!serialized.contains(&cred.token_hash));
1838 assert!(!serialized.contains(&cred.token_salt));
1839 assert!(obj.contains_key("device_id"));
1841 assert!(obj.contains_key("label"));
1842 assert!(obj.contains_key("created_at"));
1843 assert!(obj.contains_key("revoked"));
1844 }
1845}