use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use bamboo_domain::poison::PoisonRecover;
use actix_web::{
body::{EitherBody, MessageBody},
cookie::{time::Duration as CookieDuration, Cookie, SameSite},
dev::{ServiceRequest, ServiceResponse},
http::header,
middleware::Next,
web, HttpRequest, HttpResponse, ResponseError,
};
use chrono::{SecondsFormat, Utc};
use rand::{Rng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{
app_state::{AppState, ConfigUpdateEffects},
error::AppError,
};
use bamboo_config::{Config, DeviceCredential};
#[derive(Serialize)]
pub struct AccessStatusResponse {
pub password_enabled: bool,
pub local_bypass: bool,
pub requires_password: bool,
}
#[derive(Debug, Deserialize)]
pub struct VerifyPasswordRequest {
pub password: String,
}
#[derive(Serialize)]
pub struct VerifyPasswordResponse {
pub success: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePasswordRequest {
#[serde(default)]
pub current_password: String,
#[serde(default)]
pub new_password: String,
}
#[derive(Serialize)]
pub struct UpdatePasswordResponse {
pub success: bool,
pub password_enabled: bool,
}
const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
fn normalize_ip(ip: &str) -> &str {
let ip = ip.trim();
ip.strip_prefix("::ffff:").unwrap_or(ip)
}
fn split_host_and_port(value: &str) -> &str {
let candidate = value.trim();
if candidate.is_empty() {
return candidate;
}
let without_brackets = candidate
.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.unwrap_or(candidate);
if without_brackets.parse::<IpAddr>().is_ok() {
return without_brackets;
}
without_brackets
.split(':')
.next()
.unwrap_or(without_brackets)
.trim()
}
fn is_local_host(host: &str) -> bool {
let normalized = split_host_and_port(host)
.trim()
.trim_end_matches('.')
.to_lowercase();
if normalized.is_empty() {
return false;
}
if normalized == "localhost" || normalized.ends_with(".local") {
return true;
}
let normalized = normalize_ip(&normalized);
match normalized.parse::<IpAddr>() {
Ok(IpAddr::V4(v4)) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
Ok(IpAddr::V6(v6)) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
}
Err(_) => false,
}
}
fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
let mut candidates = Vec::new();
for header_name in [
header::HOST,
header::HeaderName::from_static("x-forwarded-host"),
header::HeaderName::from_static("x-original-host"),
] {
if let Some(value) = req
.headers()
.get(&header_name)
.and_then(|v| v.to_str().ok())
{
for part in value.split(',') {
let host = part.trim();
if !host.is_empty() {
candidates.push(host.to_string());
}
}
}
}
if let Some(uri_host) = req.uri().host() {
let host = uri_host.trim();
if !host.is_empty() {
candidates.push(host.to_string());
}
}
candidates
}
fn is_local_request(req: &HttpRequest) -> bool {
let peer_local: Option<bool> = req
.peer_addr()
.map(|peer| is_local_host(&peer.ip().to_string()));
let host_candidates = request_host_candidates(req);
if !host_candidates.is_empty() {
let host_local = host_candidates.iter().all(|host| is_local_host(host));
return host_local && peer_local != Some(false);
}
if let Some(local) = peer_local {
return local;
}
let conn = req.connection_info();
conn.peer_addr().map(is_local_host).unwrap_or(false)
}
fn client_ip_key(req: &HttpRequest) -> Option<String> {
if let Some(peer) = req.peer_addr() {
return Some(normalize_ip(&peer.ip().to_string()).to_string());
}
let conn = req.connection_info();
for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
.into_iter()
.flatten()
{
let normalized = normalize_ip(candidate).trim();
if !normalized.is_empty() {
return Some(normalized.to_string());
}
}
None
}
fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
let salt = hex::decode(salt_hex).ok()?;
let mut hasher = Sha256::new();
hasher.update(&salt);
hasher.update(password.as_bytes());
Some(hex::encode(hasher.finalize()))
}
fn verify_password(config: &Config, password: &str) -> bool {
let Some(access) = config.access_control.as_ref() else {
return false;
};
if !access.password_enabled {
return false;
}
let (Some(hash), Some(salt)) = (
access.password_hash.as_deref(),
access.password_salt.as_deref(),
) else {
return false;
};
compute_password_hash(password, salt)
.map(|computed| computed == hash)
.unwrap_or(false)
}
const DEVICE_TOKEN_PREFIX: &str = "bd1_";
const DEVICE_ID_PREFIX: &str = "bamboo_";
const DEVICE_ID_HEADER: &str = "x-device-id";
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn random_hex(len: usize) -> String {
let mut bytes = vec![0_u8; len];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub(crate) fn issue_device_token(label: &str) -> (DeviceCredential, String) {
let device_id = format!("{DEVICE_ID_PREFIX}{}", random_hex(6));
let token = format!("{DEVICE_TOKEN_PREFIX}{}", random_hex(16));
let salt_hex = random_hex(16);
let token_hash =
compute_password_hash(&token, &salt_hex).expect("device salt is always valid hex");
let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
let credential = DeviceCredential {
device_id,
label: label.to_string(),
token_hash,
token_salt: salt_hex,
created_at,
last_used_at: None,
revoked: false,
};
(credential, token)
}
pub(crate) fn verify_device_token(config: &Config, device_id: &str, token: &str) -> bool {
let Some(access) = config.access_control.as_ref() else {
return false;
};
let Some(device) = access.devices.iter().find(|d| d.device_id == device_id) else {
return false;
};
if device.revoked {
return false;
}
let Some(computed) = compute_password_hash(token, &device.token_salt) else {
return false;
};
constant_time_eq(computed.as_bytes(), device.token_hash.as_bytes())
}
fn has_active_devices(config: &Config) -> bool {
config
.access_control
.as_ref()
.map(|access| access.devices.iter().any(|d| !d.revoked))
.unwrap_or(false)
}
fn presented_device_token(req: &HttpRequest) -> Option<(String, String)> {
let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
let token = auth
.strip_prefix("Bearer ")
.or_else(|| auth.strip_prefix("bearer "))?
.trim();
if !token.starts_with(DEVICE_TOKEN_PREFIX) {
return None;
}
let device_id = req
.headers()
.get(DEVICE_ID_HEADER)?
.to_str()
.ok()?
.trim()
.to_string();
if device_id.is_empty() {
return None;
}
Some((device_id, token.to_string()))
}
fn request_has_valid_device_token(req: &HttpRequest, config: &Config) -> bool {
match presented_device_token(req) {
Some((device_id, token)) => verify_device_token(config, &device_id, &token),
None => false,
}
}
fn access_verification_cookie_value(config: &Config) -> Option<String> {
let access = config.access_control.as_ref()?;
if !access.password_enabled {
return None;
}
let hash = access.password_hash.as_deref()?.trim();
let salt = access.password_salt.as_deref()?.trim();
if hash.is_empty() || salt.is_empty() {
return None;
}
let mut hasher = Sha256::new();
hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
hasher.update(b":");
hasher.update(hash.as_bytes());
hasher.update(b":");
hasher.update(salt.as_bytes());
Some(format!(
"{}:{}",
ACCESS_VERIFIED_COOKIE_VERSION,
hex::encode(hasher.finalize())
))
}
fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
let expected = match access_verification_cookie_value(config) {
Some(value) => value,
None => return false,
};
req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
.map(|cookie| cookie.value() == expected)
.unwrap_or(false)
}
fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
let value = access_verification_cookie_value(config)?;
Some(
Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure)
.max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
.finish(),
)
}
fn is_public_access_route(path: &str) -> bool {
matches!(
path,
"/api/v1/health"
| "/v1/bamboo/access/status"
| "/v1/bamboo/access/verify"
| "/v2/pair"
| "/v2/stream"
)
}
pub(crate) fn request_is_authorized(req: &HttpRequest, config: &Config) -> bool {
!build_access_status(config, req).requires_password
|| request_has_verified_access_cookie(req, config)
|| request_has_valid_device_token(req, config)
}
pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
req: ServiceRequest,
next: Next<B>,
) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
let path = req.path().to_string();
if is_public_access_route(&path) {
return next
.call(req)
.await
.map(ServiceResponse::map_into_left_body);
}
let app_state = match req.app_data::<web::Data<AppState>>() {
Some(state) => state.clone(),
None => {
return next
.call(req)
.await
.map(ServiceResponse::map_into_left_body)
}
};
let config = app_state.config.read().await.clone();
if request_is_authorized(req.request(), &config) {
return next
.call(req)
.await
.map(ServiceResponse::map_into_left_body);
}
let response = AppError::Unauthorized("access credential verification required".to_string())
.error_response()
.map_into_right_body();
Ok(req.into_response(response))
}
fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
let password_enabled = config
.access_control
.as_ref()
.map(|access| {
access.password_enabled
&& access
.password_hash
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
&& access
.password_salt
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
})
.unwrap_or(false);
let local_bypass = is_local_request(req);
let credential_required = password_enabled || has_active_devices(config);
AccessStatusResponse {
password_enabled,
local_bypass,
requires_password: credential_required && !local_bypass,
}
}
pub async fn get_access_status(
req: HttpRequest,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let config = app_state.config.read().await.clone();
Ok(HttpResponse::Ok().json(build_access_status(&config, &req)))
}
pub async fn verify_access_password(
req: HttpRequest,
payload: web::Json<VerifyPasswordRequest>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let password = payload.password.trim();
if password.is_empty() {
return Err(AppError::BadRequest("password is required".to_string()));
}
let throttle_key = root_throttle_key(&req);
if let Some(key) = throttle_key.as_deref() {
if let RootGuardDecision::Cooldown { retry_after_secs } =
app_state.root_password_guard.check(key)
{
return Ok(too_many_requests_response(retry_after_secs));
}
}
let config = app_state.config.read().await.clone();
if !verify_password(&config, password) {
if let Some(key) = throttle_key.as_deref() {
app_state.root_password_guard.record_failure(key);
}
return Err(AppError::Unauthorized("invalid password".to_string()));
}
if let Some(key) = throttle_key.as_deref() {
app_state.root_password_guard.record_success(key);
}
let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
let cookie = build_access_verified_cookie(&config, secure)
.ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
Ok(HttpResponse::Ok()
.cookie(cookie)
.json(VerifyPasswordResponse { success: true }))
}
pub async fn update_access_password(
req: HttpRequest,
app_state: web::Data<AppState>,
payload: web::Json<UpdatePasswordRequest>,
) -> Result<HttpResponse, AppError> {
let local_bypass = is_local_request(&req);
let new_password = payload.new_password.trim();
if new_password.is_empty() {
return Err(AppError::BadRequest("new_password is required".to_string()));
}
let current_config = app_state.config.read().await.clone();
let password_already_enabled = current_config
.access_control
.as_ref()
.map(|access| access.password_enabled)
.unwrap_or(false);
if password_already_enabled && !local_bypass {
let current_password = payload.current_password.trim();
if current_password.is_empty() {
return Err(AppError::Unauthorized(
"current_password is required".to_string(),
));
}
if !verify_password(¤t_config, current_password) {
return Err(AppError::Unauthorized(
"invalid current password".to_string(),
));
}
}
let mut salt_bytes = [0_u8; 16];
rand::thread_rng().fill_bytes(&mut salt_bytes);
let salt_hex = hex::encode(salt_bytes);
let password_hash = compute_password_hash(new_password, &salt_hex).ok_or_else(|| {
AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
})?;
let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
app_state
.update_config(
move |config| {
let access = config.access_control.get_or_insert_with(Default::default);
access.password_enabled = true;
access.password_hash = Some(password_hash.clone());
access.password_salt = Some(salt_hex.clone());
access.updated_at = Some(updated_at.clone());
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
success: true,
password_enabled: true,
}))
}
#[derive(Debug, Deserialize)]
pub struct PairDeviceRequest {
#[serde(default)]
pub root_password: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub label: String,
}
#[derive(Serialize)]
pub struct PairDeviceResponse {
pub device_id: String,
pub device_token: String,
pub expires_hint: &'static str,
}
pub async fn pair_device(
req: HttpRequest,
payload: web::Json<PairDeviceRequest>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let label = payload.label.trim();
if label.is_empty() {
return Err(AppError::BadRequest("label is required".to_string()));
}
let code = payload.code.trim();
let root_password = payload.root_password.trim();
if !code.is_empty() {
return pair_device_with_code(&app_state, code, label).await;
}
if !root_password.is_empty() {
return pair_device_with_root_password(&req, &app_state, root_password, label).await;
}
Err(AppError::BadRequest(
"provide either a root_password or a one-time pairing code".to_string(),
))
}
async fn pair_device_with_root_password(
req: &HttpRequest,
app_state: &AppState,
root_password: &str,
label: &str,
) -> Result<HttpResponse, AppError> {
let throttle_key = root_throttle_key(req);
if let Some(key) = throttle_key.as_deref() {
if let RootGuardDecision::Cooldown { retry_after_secs } =
app_state.root_password_guard.check(key)
{
return Ok(too_many_requests_response(retry_after_secs));
}
}
let config = app_state.config.read().await.clone();
let password_enabled = config
.access_control
.as_ref()
.map(|access| access.password_enabled)
.unwrap_or(false);
if !password_enabled {
return Err(AppError::BadRequest(
"set an access password first: the owner root password is required to authorize device pairing".to_string(),
));
}
if !verify_password(&config, root_password) {
if let Some(key) = throttle_key.as_deref() {
app_state.root_password_guard.record_failure(key);
}
return Err(AppError::Unauthorized("invalid root password".to_string()));
}
if let Some(key) = throttle_key.as_deref() {
app_state.root_password_guard.record_success(key);
}
persist_new_device(app_state, label).await
}
async fn pair_device_with_code(
app_state: &AppState,
code: &str,
label: &str,
) -> Result<HttpResponse, AppError> {
if app_state.pairing_code_guard.in_cooldown() {
return Err(AppError::Unauthorized(
"too many failed pairing attempts — try again later".to_string(),
));
}
let consumed = app_state.pairing_codes.remove(code);
let valid = match consumed {
Some((_k, entry)) => !entry.is_expired(),
None => false,
};
if !valid {
if app_state.pairing_code_guard.record_failure() {
app_state.pairing_codes.clear();
}
return Err(AppError::Unauthorized(
"invalid or expired pairing code".to_string(),
));
}
app_state.pairing_code_guard.record_success();
persist_new_device(app_state, label).await
}
async fn persist_new_device(app_state: &AppState, label: &str) -> Result<HttpResponse, AppError> {
let (credential, token) = issue_device_token(label);
let device_id = credential.device_id.clone();
app_state
.update_config(
move |config| {
let access = config.access_control.get_or_insert_with(Default::default);
access.devices.push(credential.clone());
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(PairDeviceResponse {
device_id,
device_token: token,
expires_hint: "rotate-on-demand",
}))
}
const PAIRING_CODE_TTL: Duration = Duration::from_secs(120);
const PAIRING_FAILURE_THRESHOLD: u32 = 10;
const PAIRING_COOLDOWN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct PairingCodeEntry {
expires_at: Instant,
}
impl PairingCodeEntry {
pub(crate) fn new(ttl: Duration) -> Self {
Self {
expires_at: Instant::now() + ttl,
}
}
pub fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
#[derive(Debug, Default)]
pub struct PairingCodeGuard {
inner: Mutex<PairingGuardState>,
}
#[derive(Debug, Default)]
struct PairingGuardState {
failures: u32,
cooldown_until: Option<Instant>,
}
impl PairingCodeGuard {
pub fn in_cooldown(&self) -> bool {
let mut state = self.inner.lock().recover_poison();
match state.cooldown_until {
Some(until) if Instant::now() < until => true,
Some(_) => {
state.cooldown_until = None;
state.failures = 0;
false
}
None => false,
}
}
pub fn record_failure(&self) -> bool {
let mut state = self.inner.lock().recover_poison();
state.failures = state.failures.saturating_add(1);
if state.failures >= PAIRING_FAILURE_THRESHOLD {
state.cooldown_until = Some(Instant::now() + PAIRING_COOLDOWN);
true
} else {
false
}
}
pub fn record_success(&self) {
let mut state = self.inner.lock().recover_poison();
state.failures = 0;
state.cooldown_until = None;
}
}
const ROOT_PASSWORD_FAILURE_THRESHOLD: u32 = 5;
const ROOT_PASSWORD_COOLDOWN: Duration = Duration::from_secs(60);
const ROOT_PASSWORD_MAX_KEYS: usize = 10_000;
#[derive(Debug, Default, Clone)]
struct RootAttemptState {
failures: u32,
cooldown_until: Option<Instant>,
}
#[derive(Debug, Default)]
pub struct RootPasswordGuard {
inner: dashmap::DashMap<String, RootAttemptState>,
}
pub enum RootGuardDecision {
Allow,
Cooldown { retry_after_secs: u64 },
}
impl RootPasswordGuard {
pub fn check(&self, key: &str) -> RootGuardDecision {
let now = Instant::now();
if let Some(mut entry) = self.inner.get_mut(key) {
if let Some(until) = entry.cooldown_until {
if now < until {
let retry_after_secs = (until - now).as_secs().max(1);
return RootGuardDecision::Cooldown { retry_after_secs };
}
entry.failures = 0;
entry.cooldown_until = None;
}
}
RootGuardDecision::Allow
}
pub fn record_failure(&self, key: &str) {
let now = Instant::now();
if !self.inner.contains_key(key) && self.inner.len() >= ROOT_PASSWORD_MAX_KEYS {
self.inner
.retain(|_, st| matches!(st.cooldown_until, Some(until) if now < until));
}
let mut entry = self.inner.entry(key.to_string()).or_default();
if matches!(entry.cooldown_until, Some(until) if now < until) {
return;
}
if entry.cooldown_until.is_some() {
entry.failures = 0;
entry.cooldown_until = None;
}
entry.failures = entry.failures.saturating_add(1);
if entry.failures >= ROOT_PASSWORD_FAILURE_THRESHOLD {
entry.cooldown_until = Some(now + ROOT_PASSWORD_COOLDOWN);
}
}
pub fn record_success(&self, key: &str) {
self.inner.remove(key);
}
}
fn root_throttle_key(req: &HttpRequest) -> Option<String> {
if is_local_request(req) {
return None;
}
Some(client_ip_key(req).unwrap_or_else(|| "unknown".to_string()))
}
fn too_many_requests_response(retry_after_secs: u64) -> HttpResponse {
HttpResponse::TooManyRequests()
.insert_header((header::RETRY_AFTER, retry_after_secs.to_string()))
.json(serde_json::json!({
"error": {
"message": "too many failed password attempts — try again later",
"type": "api_error",
}
}))
}
fn generate_pairing_code() -> String {
let n = rand::thread_rng().gen_range(0..1_000_000);
format!("{n:06}")
}
fn purge_expired_codes(codes: &dashmap::DashMap<String, PairingCodeEntry>) {
codes.retain(|_code, entry| !entry.is_expired());
}
#[derive(Serialize)]
pub struct PairingCodeResponse {
pub code: String,
pub ttl: u64,
}
pub async fn create_pairing_code(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
purge_expired_codes(&app_state.pairing_codes);
let code = generate_pairing_code();
let entry = PairingCodeEntry::new(PAIRING_CODE_TTL);
app_state.pairing_codes.insert(code.clone(), entry);
Ok(HttpResponse::Ok().json(PairingCodeResponse {
code,
ttl: PAIRING_CODE_TTL.as_secs(),
}))
}
#[derive(Serialize)]
pub struct DeviceSummary {
pub device_id: String,
pub label: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked: bool,
}
impl DeviceSummary {
fn from_credential(d: &DeviceCredential) -> Self {
Self {
device_id: d.device_id.clone(),
label: d.label.clone(),
created_at: d.created_at.clone(),
last_used_at: d.last_used_at.clone(),
revoked: d.revoked,
}
}
}
pub async fn list_devices(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
let config = app_state.config.read().await.clone();
let devices: Vec<DeviceSummary> = config
.access_control
.as_ref()
.map(|access| {
access
.devices
.iter()
.map(DeviceSummary::from_credential)
.collect()
})
.unwrap_or_default();
Ok(HttpResponse::Ok().json(devices))
}
pub async fn revoke_device(
path: web::Path<String>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let device_id = path.into_inner();
{
let config = app_state.config.read().await;
let exists = config
.access_control
.as_ref()
.map(|access| access.devices.iter().any(|d| d.device_id == device_id))
.unwrap_or(false);
if !exists {
return Err(AppError::NotFound(format!("unknown device {device_id}")));
}
}
let target = device_id.clone();
app_state
.update_config(
move |config| {
if let Some(access) = config.access_control.as_mut() {
if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
{
device.revoked = true;
}
}
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(serde_json::json!({ "device_id": device_id, "revoked": true })))
}
pub async fn rotate_device(
path: web::Path<String>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let device_id = path.into_inner();
{
let config = app_state.config.read().await;
let exists = config
.access_control
.as_ref()
.map(|access| access.devices.iter().any(|d| d.device_id == device_id))
.unwrap_or(false);
if !exists {
return Err(AppError::NotFound(format!("unknown device {device_id}")));
}
}
let (fresh, token) = issue_device_token("");
let target = device_id.clone();
app_state
.update_config(
move |config| {
if let Some(access) = config.access_control.as_mut() {
if let Some(device) = access.devices.iter_mut().find(|d| d.device_id == target)
{
device.token_hash = fresh.token_hash.clone();
device.token_salt = fresh.token_salt.clone();
device.revoked = false;
device.last_used_at = None;
}
}
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(PairDeviceResponse {
device_id,
device_token: token,
expires_hint: "rotate-on-demand",
}))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test::TestRequest;
use bamboo_config::AccessControlConfig;
#[test]
fn loopback_request_is_local() {
let req = TestRequest::default()
.peer_addr("127.0.0.1:12345".parse().unwrap())
.insert_header((header::HOST, "localhost:9562"))
.to_http_request();
assert!(is_local_request(&req));
}
#[test]
fn private_lan_host_is_local() {
let req = TestRequest::default()
.insert_header((header::HOST, "192.168.0.10:9562"))
.to_http_request();
assert!(is_local_request(&req));
}
#[test]
fn remote_host_is_not_local_even_when_peer_is_loopback() {
let req = TestRequest::default()
.peer_addr("127.0.0.1:12345".parse().unwrap())
.insert_header((header::HOST, "bamboo.example.com"))
.to_http_request();
assert!(!is_local_request(&req));
}
#[test]
fn spoofed_local_host_from_remote_peer_is_not_local() {
for spoof in ["localhost:9562", "127.0.0.1", "192.168.0.1"] {
let req = TestRequest::default()
.peer_addr("203.0.113.5:40000".parse().unwrap()) .insert_header((header::HOST, spoof))
.to_http_request();
assert!(
!is_local_request(&req),
"remote peer + spoofed Host '{spoof}' must not be local"
);
let req2 = TestRequest::default()
.peer_addr("203.0.113.5:40000".parse().unwrap())
.insert_header(("x-forwarded-host", spoof))
.to_http_request();
assert!(
!is_local_request(&req2),
"remote peer + spoofed X-Forwarded-Host '{spoof}' must not be local"
);
}
}
#[test]
fn loopback_peer_with_no_host_is_local() {
let req = TestRequest::default()
.peer_addr("127.0.0.1:5000".parse().unwrap())
.to_http_request();
assert!(is_local_request(&req));
}
#[test]
fn password_hash_roundtrip_verifies() {
let salt_hex = hex::encode([1_u8; 16]);
let hash = compute_password_hash("secret", &salt_hex).unwrap();
let config = Config {
access_control: Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(hash),
password_salt: Some(salt_hex),
updated_at: None,
devices: Vec::new(),
}),
..Config::default()
};
assert!(verify_password(&config, "secret"));
assert!(!verify_password(&config, "wrong"));
}
fn config_with_password() -> Config {
let salt_hex = hex::encode([1_u8; 16]);
let hash = compute_password_hash("secret", &salt_hex).unwrap();
Config {
access_control: Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(hash),
password_salt: Some(salt_hex),
updated_at: None,
devices: Vec::new(),
}),
..Config::default()
}
}
#[test]
fn constant_time_eq_matches_and_rejects() {
assert!(constant_time_eq(b"abcd", b"abcd"));
assert!(!constant_time_eq(b"abcd", b"abce"));
assert!(!constant_time_eq(b"abc", b"abcd"));
}
#[test]
fn issued_token_has_expected_format_and_verifies() {
let (cred, token) = issue_device_token("iPhone 15");
assert!(token.starts_with("bd1_"));
assert_eq!(token.len(), "bd1_".len() + 32);
assert!(cred.device_id.starts_with("bamboo_"));
assert_eq!(cred.device_id.len(), "bamboo_".len() + 12);
assert_eq!(cred.label, "iPhone 15");
assert!(!cred.revoked);
assert_ne!(cred.token_hash, token);
let mut config = config_with_password();
config
.access_control
.as_mut()
.unwrap()
.devices
.push(cred.clone());
assert!(verify_device_token(&config, &cred.device_id, &token));
assert!(!verify_device_token(&config, &cred.device_id, "bd1_wrong"));
assert!(!verify_device_token(&config, "bamboo_unknown", &token));
}
#[test]
fn revoked_token_is_rejected() {
let (mut cred, token) = issue_device_token("iPad");
cred.revoked = true;
let mut config = config_with_password();
let device_id = cred.device_id.clone();
config.access_control.as_mut().unwrap().devices.push(cred);
assert!(!verify_device_token(&config, &device_id, &token));
}
#[test]
fn has_active_devices_ignores_revoked() {
let mut config = config_with_password();
assert!(!has_active_devices(&config));
let (mut cred, _t) = issue_device_token("d");
cred.revoked = true;
config
.access_control
.as_mut()
.unwrap()
.devices
.push(cred.clone());
assert!(!has_active_devices(&config));
let (cred2, _t2) = issue_device_token("d2");
config.access_control.as_mut().unwrap().devices.push(cred2);
assert!(has_active_devices(&config));
}
fn remote_req() -> HttpRequest {
TestRequest::default()
.insert_header((header::HOST, "bamboo.example.com"))
.to_http_request()
}
fn local_req() -> HttpRequest {
TestRequest::default()
.insert_header((header::HOST, "localhost:9562"))
.to_http_request()
}
#[test]
fn no_devices_no_password_does_not_require_credential() {
let config = Config::default();
assert!(!build_access_status(&config, &remote_req()).requires_password);
}
#[test]
fn password_only_gate_matches_prior_behavior() {
let config = config_with_password();
assert!(build_access_status(&config, &remote_req()).requires_password);
assert!(!build_access_status(&config, &local_req()).requires_password);
}
#[test]
fn device_presence_requires_credential_even_without_password() {
let (cred, _t) = issue_device_token("d");
let config = Config {
access_control: Some(AccessControlConfig {
password_enabled: false,
password_hash: None,
password_salt: None,
updated_at: None,
devices: vec![cred],
}),
..Config::default()
};
assert!(build_access_status(&config, &remote_req()).requires_password);
assert!(!build_access_status(&config, &local_req()).requires_password);
}
#[test]
fn valid_device_token_on_request_authenticates() {
let (cred, token) = issue_device_token("d");
let device_id = cred.device_id.clone();
let mut config = config_with_password();
config.access_control.as_mut().unwrap().devices.push(cred);
let req = TestRequest::default()
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
.insert_header((DEVICE_ID_HEADER, device_id))
.to_http_request();
assert!(request_has_valid_device_token(&req, &config));
let bad = TestRequest::default()
.insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
.insert_header((DEVICE_ID_HEADER, "bamboo_unknown"))
.to_http_request();
assert!(!request_has_valid_device_token(&bad, &config));
let no_id = TestRequest::default()
.insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
.to_http_request();
assert!(!request_has_valid_device_token(&no_id, &config));
}
#[test]
fn request_is_authorized_local_is_always_allowed() {
let config = config_with_password();
assert!(request_is_authorized(&local_req(), &config));
}
#[test]
fn request_is_authorized_remote_with_devices_and_no_creds_is_denied() {
let (cred, _t) = issue_device_token("d");
let config = Config {
access_control: Some(AccessControlConfig {
password_enabled: false,
password_hash: None,
password_salt: None,
updated_at: None,
devices: vec![cred],
}),
..Config::default()
};
assert!(!request_is_authorized(&remote_req(), &config));
}
#[test]
fn request_is_authorized_remote_with_password_and_no_creds_is_denied() {
let config = config_with_password();
assert!(!request_is_authorized(&remote_req(), &config));
}
#[test]
fn request_is_authorized_remote_with_valid_cookie_is_allowed() {
let config = config_with_password();
let cookie_value =
access_verification_cookie_value(&config).expect("password config yields a cookie");
let req = TestRequest::default()
.insert_header((header::HOST, "bamboo.example.com"))
.cookie(Cookie::new(ACCESS_VERIFIED_COOKIE_NAME, cookie_value))
.to_http_request();
assert!(request_is_authorized(&req, &config));
}
#[test]
fn request_is_authorized_remote_with_valid_device_token_header_is_allowed() {
let (cred, token) = issue_device_token("d");
let device_id = cred.device_id.clone();
let mut config = config_with_password();
config.access_control.as_mut().unwrap().devices.push(cred);
let req = TestRequest::default()
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
.insert_header((DEVICE_ID_HEADER, device_id))
.to_http_request();
assert!(request_is_authorized(&req, &config));
}
#[test]
fn request_is_authorized_no_password_no_devices_is_open() {
let config = Config::default();
assert!(request_is_authorized(&remote_req(), &config));
}
#[test]
fn stream_is_public_but_sibling_routes_are_not() {
assert!(is_public_access_route("/v2/stream"));
assert!(is_public_access_route("/v2/pair"));
assert!(!is_public_access_route("/v2/pair/code"));
assert!(!is_public_access_route("/v2/devices"));
assert!(!is_public_access_route("/v2/devices/bamboo_x"));
}
#[test]
fn generated_pairing_code_is_six_digits() {
for _ in 0..1000 {
let code = generate_pairing_code();
assert_eq!(code.len(), 6, "code {code:?} must be 6 chars");
assert!(
code.chars().all(|c| c.is_ascii_digit()),
"code {code:?} must be all digits"
);
}
}
#[test]
fn pairing_code_expiry_predicate() {
let fresh = PairingCodeEntry::new(Duration::from_secs(120));
assert!(!fresh.is_expired());
let zero = PairingCodeEntry::new(Duration::from_secs(0));
assert!(zero.is_expired());
let past = PairingCodeEntry {
expires_at: Instant::now() - Duration::from_secs(1),
};
assert!(past.is_expired());
}
#[test]
fn purge_expired_codes_drops_only_expired() {
let codes: dashmap::DashMap<String, PairingCodeEntry> = dashmap::DashMap::new();
codes.insert(
"live".into(),
PairingCodeEntry::new(Duration::from_secs(120)),
);
codes.insert(
"dead".into(),
PairingCodeEntry {
expires_at: Instant::now() - Duration::from_secs(1),
},
);
purge_expired_codes(&codes);
assert!(codes.contains_key("live"));
assert!(!codes.contains_key("dead"));
}
#[test]
fn guard_trips_cooldown_after_threshold() {
let guard = PairingCodeGuard::default();
assert!(!guard.in_cooldown());
for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
assert!(!guard.record_failure());
assert!(!guard.in_cooldown());
}
assert!(guard.record_failure());
assert!(guard.in_cooldown());
}
#[test]
fn guard_success_resets_failures() {
let guard = PairingCodeGuard::default();
for _ in 0..(PAIRING_FAILURE_THRESHOLD - 1) {
guard.record_failure();
}
guard.record_success();
assert!(!guard.record_failure());
assert!(!guard.in_cooldown());
}
#[test]
fn guard_clears_elapsed_cooldown() {
let guard = PairingCodeGuard::default();
{
let mut state = guard.inner.lock().unwrap();
state.failures = PAIRING_FAILURE_THRESHOLD;
state.cooldown_until = Some(Instant::now() - Duration::from_secs(1));
}
assert!(!guard.in_cooldown());
assert!(!guard.record_failure(), "counter was reset to 0");
}
#[test]
fn root_guard_trips_cooldown_after_threshold_per_key() {
let guard = RootPasswordGuard::default();
let key = "203.0.113.7";
for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
guard.record_failure(key);
assert!(matches!(guard.check(key), RootGuardDecision::Allow));
}
guard.record_failure(key);
match guard.check(key) {
RootGuardDecision::Cooldown { retry_after_secs } => {
assert!(retry_after_secs >= 1);
assert!(retry_after_secs <= ROOT_PASSWORD_COOLDOWN.as_secs());
}
RootGuardDecision::Allow => panic!("key must be in cooldown after threshold"),
}
}
#[test]
fn root_guard_keys_are_independent() {
let guard = RootPasswordGuard::default();
for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
guard.record_failure("198.51.100.1");
}
assert!(matches!(
guard.check("198.51.100.1"),
RootGuardDecision::Cooldown { .. }
));
assert!(matches!(
guard.check("198.51.100.2"),
RootGuardDecision::Allow
));
}
#[test]
fn root_guard_success_resets_key() {
let guard = RootPasswordGuard::default();
let key = "203.0.113.9";
for _ in 0..(ROOT_PASSWORD_FAILURE_THRESHOLD - 1) {
guard.record_failure(key);
}
guard.record_success(key);
guard.record_failure(key);
assert!(matches!(guard.check(key), RootGuardDecision::Allow));
}
#[test]
fn root_guard_clears_elapsed_cooldown() {
let guard = RootPasswordGuard::default();
let key = "203.0.113.10";
guard.inner.insert(
key.to_string(),
RootAttemptState {
failures: ROOT_PASSWORD_FAILURE_THRESHOLD,
cooldown_until: Some(Instant::now() - Duration::from_secs(1)),
},
);
assert!(matches!(guard.check(key), RootGuardDecision::Allow));
guard.record_failure(key);
assert!(matches!(guard.check(key), RootGuardDecision::Allow));
}
#[test]
fn root_guard_evicts_inert_keys_past_the_cap() {
let guard = RootPasswordGuard::default();
for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
guard.record_failure(&format!("10.0.{}.{}", i / 256, i % 256));
}
assert!(
guard.inner.len() <= ROOT_PASSWORD_MAX_KEYS,
"inert keys must be swept so the map stays bounded (was {})",
guard.inner.len()
);
let hot = "203.0.113.200";
for _ in 0..ROOT_PASSWORD_FAILURE_THRESHOLD {
guard.record_failure(hot);
}
for i in 0..(ROOT_PASSWORD_MAX_KEYS + 50) {
guard.record_failure(&format!("172.16.{}.{}", i / 256, i % 256));
}
assert!(
matches!(guard.check(hot), RootGuardDecision::Cooldown { .. }),
"a key in active cooldown must survive eviction sweeps"
);
}
#[test]
fn root_throttle_key_exempts_loopback_and_keys_remote() {
assert!(root_throttle_key(&local_req()).is_none());
let remote = TestRequest::default()
.peer_addr("203.0.113.5:443".parse().unwrap())
.insert_header((header::HOST, "bamboo.example.com"))
.to_http_request();
assert_eq!(root_throttle_key(&remote).as_deref(), Some("203.0.113.5"));
}
#[test]
fn client_ip_key_strips_v4_mapped_prefix() {
let req = TestRequest::default()
.peer_addr("[::ffff:203.0.113.5]:443".parse().unwrap())
.to_http_request();
assert_eq!(client_ip_key(&req).as_deref(), Some("203.0.113.5"));
}
#[test]
fn device_summary_excludes_secret_material() {
let (cred, _t) = issue_device_token("iPhone");
let summary = DeviceSummary::from_credential(&cred);
let json = serde_json::to_value(&summary).unwrap();
let obj = json.as_object().unwrap();
assert!(
!obj.contains_key("token_hash"),
"must not expose token_hash"
);
assert!(
!obj.contains_key("token_salt"),
"must not expose token_salt"
);
let serialized = serde_json::to_string(&summary).unwrap();
assert!(!serialized.contains(&cred.token_hash));
assert!(!serialized.contains(&cred.token_salt));
assert!(obj.contains_key("device_id"));
assert!(obj.contains_key("label"));
assert!(obj.contains_key("created_at"));
assert!(obj.contains_key("revoked"));
}
}