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