use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use thiserror::Error;
use tracing::{debug, info, warn};
use uvb_core::{TenantId, UserId};
#[derive(Debug, Error)]
pub enum DeviceBindingError {
#[error("Storage error: {0}")]
Storage(String),
#[error("Device not found: {0}")]
DeviceNotFound(String),
#[error("Device trust expired (expired at: {0})")]
TrustExpired(DateTime<Utc>),
#[error("Device requires re-authentication (last auth: {0})")]
ReauthRequired(DateTime<Utc>),
#[error("Device is revoked (reason: {0})")]
DeviceRevoked(String),
#[error("MFA required for device registration")]
MfaRequired,
#[error("Operation {0} requires MFA even on trusted devices")]
SensitiveOperationBlocked(String),
#[error("Risk score too high for trusted device: {score} (threshold: {threshold})")]
RiskTooHigh { score: u8, threshold: u8 },
#[error("Device limit reached: {current} (max: {max})")]
DeviceLimitReached { current: usize, max: usize },
#[error("Invalid device fingerprint")]
InvalidFingerprint,
#[error("Device trust not established")]
NotTrusted,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum DeviceType {
Desktop,
Mobile,
Tablet,
Unknown,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[allow(non_camel_case_types)]
pub enum DevicePlatform {
Windows,
MacOS,
Linux,
iOS,
Android,
ChromeOS,
Unknown(String),
}
impl DevicePlatform {
pub fn from_user_agent(user_agent: &str) -> Self {
let ua_lower = user_agent.to_lowercase();
if ua_lower.contains("windows") {
Self::Windows
} else if ua_lower.contains("mac os") || ua_lower.contains("macos") {
Self::MacOS
} else if ua_lower.contains("linux") && !ua_lower.contains("android") {
Self::Linux
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") {
Self::iOS
} else if ua_lower.contains("android") {
Self::Android
} else if ua_lower.contains("cros") {
Self::ChromeOS
} else {
Self::Unknown(user_agent.to_string())
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum BrowserType {
Chrome,
Firefox,
Safari,
Edge,
Opera,
Brave,
Unknown(String),
}
impl BrowserType {
pub fn from_user_agent(user_agent: &str) -> Self {
let ua_lower = user_agent.to_lowercase();
if ua_lower.contains("edg/") || ua_lower.contains("edge/") {
Self::Edge
} else if ua_lower.contains("brave") {
Self::Brave
} else if ua_lower.contains("opr/") || ua_lower.contains("opera") {
Self::Opera
} else if ua_lower.contains("chrome") || ua_lower.contains("crios") {
Self::Chrome
} else if ua_lower.contains("firefox") || ua_lower.contains("fxios") {
Self::Firefox
} else if ua_lower.contains("safari") {
Self::Safari
} else {
Self::Unknown(user_agent.to_string())
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFingerprint {
pub user_agent: String,
pub platform: DevicePlatform,
pub browser: BrowserType,
pub screen_resolution: Option<String>,
pub timezone_offset: Option<i32>,
pub language: Option<String>,
pub hardware_concurrency: Option<u32>,
pub webgl_vendor: Option<String>,
pub webgl_renderer: Option<String>,
pub canvas_hash: Option<String>,
pub fonts_hash: Option<String>,
pub plugins: Vec<String>,
pub touch_support: bool,
pub color_depth: Option<u8>,
}
impl DeviceFingerprint {
pub fn generate_device_id(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.user_agent.as_bytes());
if let Some(ref res) = self.screen_resolution {
hasher.update(res.as_bytes());
}
if let Some(ref webgl_vendor) = self.webgl_vendor {
hasher.update(webgl_vendor.as_bytes());
}
if let Some(ref webgl_renderer) = self.webgl_renderer {
hasher.update(webgl_renderer.as_bytes());
}
if let Some(ref canvas) = self.canvas_hash {
hasher.update(canvas.as_bytes());
}
if let Some(ref fonts) = self.fonts_hash {
hasher.update(fonts.as_bytes());
}
hex::encode(hasher.finalize())
}
pub fn similarity(&self, other: &DeviceFingerprint) -> f64 {
let mut matches = 0;
let mut total = 0;
total += 1;
if self.user_agent == other.user_agent {
matches += 1;
}
if self.screen_resolution.is_some() && other.screen_resolution.is_some() {
total += 1;
if self.screen_resolution == other.screen_resolution {
matches += 1;
}
}
if self.webgl_vendor.is_some() && other.webgl_vendor.is_some() {
total += 1;
if self.webgl_vendor == other.webgl_vendor {
matches += 1;
}
}
if self.webgl_renderer.is_some() && other.webgl_renderer.is_some() {
total += 1;
if self.webgl_renderer == other.webgl_renderer {
matches += 1;
}
}
if self.canvas_hash.is_some() && other.canvas_hash.is_some() {
total += 2; if self.canvas_hash == other.canvas_hash {
matches += 2;
}
}
if self.fonts_hash.is_some() && other.fonts_hash.is_some() {
total += 1;
if self.fonts_hash == other.fonts_hash {
matches += 1;
}
}
if self.hardware_concurrency.is_some() && other.hardware_concurrency.is_some() {
total += 1;
if self.hardware_concurrency == other.hardware_concurrency {
matches += 1;
}
}
if total == 0 {
return 0.0;
}
matches as f64 / total as f64
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrustedDevice {
pub device_id: String,
pub user_id: UserId,
pub tenant_id: TenantId,
pub fingerprint: DeviceFingerprint,
pub device_type: DeviceType,
pub device_name: Option<String>,
pub registered_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_auth_at: DateTime<Utc>,
pub last_ip: Option<String>,
pub last_location: Option<String>,
pub is_trusted: bool,
pub revoked_reason: Option<String>,
pub revoked_at: Option<DateTime<Utc>>,
pub usage_count: u64,
pub risk_score: u8,
}
impl TrustedDevice {
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn requires_reauth(&self, reauth_interval: Duration) -> bool {
let next_auth_time = self.last_auth_at + reauth_interval;
Utc::now() > next_auth_time
}
pub fn is_revoked(&self) -> bool {
self.revoked_reason.is_some()
}
pub fn is_valid(&self) -> Result<(), DeviceBindingError> {
if self.is_revoked() {
return Err(DeviceBindingError::DeviceRevoked(
self.revoked_reason.clone().unwrap_or_default(),
));
}
if self.is_expired() {
return Err(DeviceBindingError::TrustExpired(self.expires_at));
}
if !self.is_trusted {
return Err(DeviceBindingError::NotTrusted);
}
Ok(())
}
pub fn age_days(&self) -> i64 {
(Utc::now() - self.registered_at).num_days()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceBindingConfig {
pub enabled: bool,
pub trust_expiration_days: i64,
pub reauth_interval_days: i64,
pub max_devices_per_user: usize,
pub risk_threshold: u8,
pub require_mfa_for_registration: bool,
pub auto_revoke_on_suspicion: bool,
pub sensitive_operations: Vec<String>,
pub min_fingerprint_similarity: f64,
pub enable_location_risk: bool,
pub enable_ip_risk: bool,
}
impl DeviceBindingConfig {
pub fn new_default() -> Self {
Self {
enabled: true,
trust_expiration_days: 30,
reauth_interval_days: 7,
max_devices_per_user: 10,
risk_threshold: 70,
require_mfa_for_registration: true,
auto_revoke_on_suspicion: true,
sensitive_operations: vec![
"change_password".to_string(),
"change_email".to_string(),
"change_phone".to_string(),
"add_payment_method".to_string(),
"delete_account".to_string(),
"change_mfa_settings".to_string(),
"transfer_funds".to_string(),
],
min_fingerprint_similarity: 0.8,
enable_location_risk: true,
enable_ip_risk: true,
}
}
pub fn strict() -> Self {
let mut config = Self::new_default();
config.trust_expiration_days = 14; config.reauth_interval_days = 3; config.max_devices_per_user = 5;
config.risk_threshold = 50; config.min_fingerprint_similarity = 0.9; config
}
pub fn lenient() -> Self {
let mut config = Self::new_default();
config.trust_expiration_days = 365; config.reauth_interval_days = 30;
config.max_devices_per_user = 50;
config.risk_threshold = 90; config.require_mfa_for_registration = false;
config.auto_revoke_on_suspicion = false;
config.sensitive_operations = vec![]; config.min_fingerprint_similarity = 0.5;
config.enable_location_risk = false;
config.enable_ip_risk = false;
config
}
pub fn is_sensitive_operation(&self, operation: &str) -> bool {
self.sensitive_operations.iter().any(|op| op == operation)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceRegistrationRequest {
pub user_id: UserId,
pub tenant_id: TenantId,
pub fingerprint: DeviceFingerprint,
pub device_type: DeviceType,
pub device_name: Option<String>,
pub ip_address: Option<String>,
pub location: Option<String>,
pub mfa_verified: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceTrustResult {
pub device_id: String,
pub is_trusted: bool,
pub requires_mfa: bool,
pub reason: String,
pub risk_score: u8,
pub expires_at: Option<DateTime<Utc>>,
}
#[async_trait]
pub trait DeviceBindingStorage: Send + Sync {
async fn save_device(&self, device: &TrustedDevice) -> Result<(), DeviceBindingError>;
async fn get_device(&self, device_id: &str) -> Result<TrustedDevice, DeviceBindingError>;
async fn get_user_devices(
&self,
user_id: &UserId,
) -> Result<Vec<TrustedDevice>, DeviceBindingError>;
async fn update_last_auth(
&self,
device_id: &str,
timestamp: DateTime<Utc>,
) -> Result<(), DeviceBindingError>;
async fn revoke_device(
&self,
device_id: &str,
reason: String,
revoked_at: DateTime<Utc>,
) -> Result<(), DeviceBindingError>;
async fn delete_device(&self, device_id: &str) -> Result<(), DeviceBindingError>;
async fn find_similar_devices(
&self,
user_id: &UserId,
fingerprint: &DeviceFingerprint,
min_similarity: f64,
) -> Result<Vec<TrustedDevice>, DeviceBindingError>;
}
pub struct InMemoryDeviceStorage {
devices: tokio::sync::RwLock<HashMap<String, TrustedDevice>>,
}
impl InMemoryDeviceStorage {
pub fn new() -> Self {
Self {
devices: tokio::sync::RwLock::new(HashMap::new()),
}
}
}
impl Default for InMemoryDeviceStorage {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DeviceBindingStorage for InMemoryDeviceStorage {
async fn save_device(&self, device: &TrustedDevice) -> Result<(), DeviceBindingError> {
let mut devices = self.devices.write().await;
devices.insert(device.device_id.clone(), device.clone());
Ok(())
}
async fn get_device(&self, device_id: &str) -> Result<TrustedDevice, DeviceBindingError> {
let devices = self.devices.read().await;
devices
.get(device_id)
.cloned()
.ok_or_else(|| DeviceBindingError::DeviceNotFound(device_id.to_string()))
}
async fn get_user_devices(
&self,
user_id: &UserId,
) -> Result<Vec<TrustedDevice>, DeviceBindingError> {
let devices = self.devices.read().await;
Ok(devices
.values()
.filter(|d| d.user_id == *user_id)
.cloned()
.collect())
}
async fn update_last_auth(
&self,
device_id: &str,
timestamp: DateTime<Utc>,
) -> Result<(), DeviceBindingError> {
let mut devices = self.devices.write().await;
if let Some(device) = devices.get_mut(device_id) {
device.last_auth_at = timestamp;
device.usage_count += 1;
Ok(())
} else {
Err(DeviceBindingError::DeviceNotFound(device_id.to_string()))
}
}
async fn revoke_device(
&self,
device_id: &str,
reason: String,
revoked_at: DateTime<Utc>,
) -> Result<(), DeviceBindingError> {
let mut devices = self.devices.write().await;
if let Some(device) = devices.get_mut(device_id) {
device.is_trusted = false;
device.revoked_reason = Some(reason);
device.revoked_at = Some(revoked_at);
Ok(())
} else {
Err(DeviceBindingError::DeviceNotFound(device_id.to_string()))
}
}
async fn delete_device(&self, device_id: &str) -> Result<(), DeviceBindingError> {
let mut devices = self.devices.write().await;
devices
.remove(device_id)
.ok_or_else(|| DeviceBindingError::DeviceNotFound(device_id.to_string()))?;
Ok(())
}
async fn find_similar_devices(
&self,
user_id: &UserId,
fingerprint: &DeviceFingerprint,
min_similarity: f64,
) -> Result<Vec<TrustedDevice>, DeviceBindingError> {
let devices = self.devices.read().await;
Ok(devices
.values()
.filter(|d| {
d.user_id == *user_id && fingerprint.similarity(&d.fingerprint) >= min_similarity
})
.cloned()
.collect())
}
}
pub struct DeviceBindingManager<S: DeviceBindingStorage> {
storage: S,
config: DeviceBindingConfig,
}
impl<S: DeviceBindingStorage> DeviceBindingManager<S> {
pub fn new(storage: S, config: DeviceBindingConfig) -> Self {
Self { storage, config }
}
pub async fn register_device(
&self,
request: DeviceRegistrationRequest,
) -> Result<TrustedDevice, DeviceBindingError> {
if !self.config.enabled {
return Err(DeviceBindingError::Storage(
"Device binding disabled".to_string(),
));
}
if self.config.require_mfa_for_registration && !request.mfa_verified {
return Err(DeviceBindingError::MfaRequired);
}
let existing_devices = self.storage.get_user_devices(&request.user_id).await?;
if existing_devices.len() >= self.config.max_devices_per_user {
return Err(DeviceBindingError::DeviceLimitReached {
current: existing_devices.len(),
max: self.config.max_devices_per_user,
});
}
let device_id = request.fingerprint.generate_device_id();
let expires_at = Utc::now() + Duration::days(self.config.trust_expiration_days);
let device = TrustedDevice {
device_id: device_id.clone(),
user_id: request.user_id,
tenant_id: request.tenant_id,
fingerprint: request.fingerprint,
device_type: request.device_type,
device_name: request.device_name,
registered_at: Utc::now(),
expires_at,
last_auth_at: Utc::now(),
last_ip: request.ip_address,
last_location: request.location,
is_trusted: true,
revoked_reason: None,
revoked_at: None,
usage_count: 0,
risk_score: 0, };
self.storage.save_device(&device).await?;
info!(
"Registered new device {} for user {:?}",
device_id, device.user_id
);
Ok(device)
}
pub async fn validate_device_trust(
&self,
device_id: &str,
operation: &str,
) -> Result<DeviceTrustResult, DeviceBindingError> {
if !self.config.enabled {
return Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: false,
requires_mfa: true,
reason: "Device binding disabled".to_string(),
risk_score: 0,
expires_at: None,
});
}
if self.config.is_sensitive_operation(operation) {
return Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: false,
requires_mfa: true,
reason: format!("Sensitive operation '{}' requires MFA", operation),
risk_score: 100,
expires_at: None,
});
}
let device = self.storage.get_device(device_id).await?;
if let Err(e) = device.is_valid() {
return Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: false,
requires_mfa: true,
reason: e.to_string(),
risk_score: 100,
expires_at: Some(device.expires_at),
});
}
let reauth_interval = Duration::days(self.config.reauth_interval_days);
if device.requires_reauth(reauth_interval) {
return Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: true, requires_mfa: true,
reason: "Periodic re-authentication required".to_string(),
risk_score: device.risk_score,
expires_at: Some(device.expires_at),
});
}
if device.risk_score > self.config.risk_threshold {
warn!(
"Device {} risk score {} exceeds threshold {}",
device_id, device.risk_score, self.config.risk_threshold
);
if self.config.auto_revoke_on_suspicion {
self.storage
.revoke_device(
device_id,
format!(
"Automatic revocation due to high risk score: {}",
device.risk_score
),
Utc::now(),
)
.await?;
return Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: false,
requires_mfa: true,
reason: "Device revoked due to high risk".to_string(),
risk_score: device.risk_score,
expires_at: None,
});
}
return Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: false,
requires_mfa: true,
reason: format!(
"Risk score {} exceeds threshold {}",
device.risk_score, self.config.risk_threshold
),
risk_score: device.risk_score,
expires_at: Some(device.expires_at),
});
}
Ok(DeviceTrustResult {
device_id: device_id.to_string(),
is_trusted: true,
requires_mfa: false,
reason: "Device is trusted".to_string(),
risk_score: device.risk_score,
expires_at: Some(device.expires_at),
})
}
pub async fn record_authentication(&self, device_id: &str) -> Result<(), DeviceBindingError> {
self.storage.update_last_auth(device_id, Utc::now()).await?;
debug!("Recorded authentication for device {}", device_id);
Ok(())
}
pub async fn revoke_device(
&self,
device_id: &str,
reason: String,
) -> Result<(), DeviceBindingError> {
self.storage
.revoke_device(device_id, reason.clone(), Utc::now())
.await?;
info!("Revoked device {}: {}", device_id, reason);
Ok(())
}
pub async fn delete_device(&self, device_id: &str) -> Result<(), DeviceBindingError> {
self.storage.delete_device(device_id).await?;
info!("Deleted device {}", device_id);
Ok(())
}
pub async fn get_user_devices(
&self,
user_id: &UserId,
) -> Result<Vec<TrustedDevice>, DeviceBindingError> {
self.storage.get_user_devices(user_id).await
}
pub async fn find_device_by_fingerprint(
&self,
user_id: &UserId,
fingerprint: &DeviceFingerprint,
) -> Result<Option<TrustedDevice>, DeviceBindingError> {
let similar = self
.storage
.find_similar_devices(user_id, fingerprint, self.config.min_fingerprint_similarity)
.await?;
Ok(similar.into_iter().next())
}
pub async fn cleanup_expired_devices(
&self,
user_id: &UserId,
) -> Result<usize, DeviceBindingError> {
let devices = self.storage.get_user_devices(user_id).await?;
let mut removed = 0;
for device in devices {
if device.is_expired() {
self.storage.delete_device(&device.device_id).await?;
removed += 1;
}
}
if removed > 0 {
info!(
"Cleaned up {} expired devices for user {:?}",
removed, user_id
);
}
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_fingerprint() -> DeviceFingerprint {
DeviceFingerprint {
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)".to_string(),
platform: DevicePlatform::MacOS,
browser: BrowserType::Chrome,
screen_resolution: Some("1920x1080".to_string()),
timezone_offset: Some(-480),
language: Some("en-US".to_string()),
hardware_concurrency: Some(8),
webgl_vendor: Some("Intel Inc.".to_string()),
webgl_renderer: Some("Intel Iris Pro".to_string()),
canvas_hash: Some("abc123".to_string()),
fonts_hash: Some("def456".to_string()),
plugins: vec![],
touch_support: false,
color_depth: Some(24),
}
}
#[test]
fn test_device_id_generation() {
let fp = create_test_fingerprint();
let id1 = fp.generate_device_id();
let id2 = fp.generate_device_id();
assert_eq!(id1, id2);
assert_eq!(id1.len(), 64); }
#[test]
fn test_fingerprint_similarity() {
let fp1 = create_test_fingerprint();
let mut fp2 = fp1.clone();
assert_eq!(fp1.similarity(&fp2), 1.0);
fp2.user_agent = "Different".to_string();
assert!(fp1.similarity(&fp2) < 1.0);
}
#[test]
fn test_platform_detection() {
assert_eq!(
DevicePlatform::from_user_agent("Mozilla/5.0 (Windows NT 10.0)"),
DevicePlatform::Windows
);
assert_eq!(
DevicePlatform::from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"),
DevicePlatform::MacOS
);
assert_eq!(
DevicePlatform::from_user_agent("Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)"),
DevicePlatform::iOS
);
}
#[test]
fn test_browser_detection() {
assert_eq!(
BrowserType::from_user_agent("Chrome/91.0.4472.124"),
BrowserType::Chrome
);
assert_eq!(
BrowserType::from_user_agent("Firefox/89.0"),
BrowserType::Firefox
);
assert_eq!(
BrowserType::from_user_agent("Edg/91.0.864.59"),
BrowserType::Edge
);
}
#[test]
fn test_config_presets() {
let default_config = DeviceBindingConfig::new_default();
assert_eq!(default_config.trust_expiration_days, 30);
assert!(default_config.require_mfa_for_registration);
let strict_config = DeviceBindingConfig::strict();
assert_eq!(strict_config.trust_expiration_days, 14);
assert_eq!(strict_config.reauth_interval_days, 3);
let lenient_config = DeviceBindingConfig::lenient();
assert_eq!(lenient_config.trust_expiration_days, 365);
assert!(!lenient_config.require_mfa_for_registration);
}
#[test]
fn test_sensitive_operations() {
let config = DeviceBindingConfig::new_default();
assert!(config.is_sensitive_operation("change_password"));
assert!(config.is_sensitive_operation("delete_account"));
assert!(!config.is_sensitive_operation("view_profile"));
}
#[tokio::test]
async fn test_device_registration() {
let storage = InMemoryDeviceStorage::new();
let config = DeviceBindingConfig::new_default();
let manager = DeviceBindingManager::new(storage, config);
let user_id = UserId::new("test_user");
let tenant_id = TenantId::new("test_tenant");
let fingerprint = create_test_fingerprint();
let request = DeviceRegistrationRequest {
user_id: user_id.clone(),
tenant_id,
fingerprint,
device_type: DeviceType::Desktop,
device_name: Some("My Laptop".to_string()),
ip_address: Some("192.0.2.1".to_string()),
location: Some("San Francisco, CA".to_string()),
mfa_verified: true,
};
let device = manager.register_device(request).await.unwrap();
assert_eq!(device.user_id, user_id);
assert!(device.is_trusted);
assert!(!device.is_expired());
}
#[tokio::test]
async fn test_device_limit_enforcement() {
let storage = InMemoryDeviceStorage::new();
let mut config = DeviceBindingConfig::new_default();
config.max_devices_per_user = 2;
let manager = DeviceBindingManager::new(storage, config);
let user_id = UserId::new("test_user");
for i in 0..2 {
let mut fp = create_test_fingerprint();
fp.user_agent = format!("Device {}", i);
let request = DeviceRegistrationRequest {
user_id: user_id.clone(),
tenant_id: TenantId::new("test_tenant"),
fingerprint: fp,
device_type: DeviceType::Desktop,
device_name: Some(format!("Device {}", i)),
ip_address: None,
location: None,
mfa_verified: true,
};
manager.register_device(request).await.unwrap();
}
let mut fp = create_test_fingerprint();
fp.user_agent = "Device 3".to_string();
let request = DeviceRegistrationRequest {
user_id,
tenant_id: TenantId::new("test_tenant"),
fingerprint: fp,
device_type: DeviceType::Desktop,
device_name: Some("Device 3".to_string()),
ip_address: None,
location: None,
mfa_verified: true,
};
let result = manager.register_device(request).await;
assert!(matches!(
result,
Err(DeviceBindingError::DeviceLimitReached { .. })
));
}
#[tokio::test]
async fn test_sensitive_operation_blocks() {
let storage = InMemoryDeviceStorage::new();
let config = DeviceBindingConfig::new_default();
let manager = DeviceBindingManager::new(storage, config);
let user_id = UserId::new("test_user");
let fingerprint = create_test_fingerprint();
let request = DeviceRegistrationRequest {
user_id,
tenant_id: TenantId::new("test_tenant"),
fingerprint,
device_type: DeviceType::Desktop,
device_name: Some("Test Device".to_string()),
ip_address: None,
location: None,
mfa_verified: true,
};
let device = manager.register_device(request).await.unwrap();
let result = manager
.validate_device_trust(&device.device_id, "change_password")
.await
.unwrap();
assert!(result.requires_mfa);
assert!(result.reason.contains("Sensitive operation"));
}
}