//! Persistent paired-device access model. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; use thiserror::Error; use uuid::Uuid; const SESSION_TOKEN_HASH_PREFIX: &str = "sha256:"; const SESSION_TOKEN_HASH_CONTEXT: &[u8] = b"idea-session-v1\0"; const SESSION_TOKEN_HASH_HEX_LEN: usize = 64; /// Unique identifier for a paired device. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct DeviceId(Uuid); impl DeviceId { /// Builds a device id from an existing UUID. #[must_use] pub const fn from_uuid(value: Uuid) -> Self { Self(value) } /// Returns the wrapped UUID. #[must_use] pub const fn as_uuid(self) -> Uuid { self.0 } } impl std::fmt::Display for DeviceId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } /// Device display name supplied by the newly paired device. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct DeviceName(String); impl DeviceName { /// Validates and stores a device name. /// /// Names are required and limited to 40 Unicode scalar values. pub fn new(value: impl Into) -> Result { let value = value.into().trim().to_owned(); let len = value.chars().count(); if len == 0 { return Err(DeviceError::InvalidName( "device name is required".to_owned(), )); } if len > 40 { return Err(DeviceError::InvalidName( "device name must be 40 characters or fewer".to_owned(), )); } Ok(Self(value)) } /// Returns the stored name. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// Hash of a random 256-bit session token. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct SessionTokenHash(String); impl SessionTokenHash { /// Hashes raw token bytes as `SHA-256("idea-session-v1\0" || token_bytes)`. #[must_use] pub fn from_token_bytes(token_bytes: &[u8]) -> Self { let mut hasher = Sha256::new(); hasher.update(SESSION_TOKEN_HASH_CONTEXT); hasher.update(token_bytes); let digest = hasher.finalize(); Self(format!( "{SESSION_TOKEN_HASH_PREFIX}{}", hex::encode(digest) )) } /// Validates an already persisted hash string. pub fn new(value: impl Into) -> Result { let value = value.into(); validate_hash_string(&value)?; Ok(Self(value)) } /// Returns the persisted hash string. #[must_use] pub fn as_str(&self) -> &str { &self.0 } /// Constant-time verification of raw token bytes against this stored hash. #[must_use] pub fn verify_token_bytes(&self, token_bytes: &[u8]) -> bool { let candidate = Self::from_token_bytes(token_bytes); self.constant_time_eq(&candidate) } /// Constant-time equality for two valid persisted token hashes. #[must_use] pub fn constant_time_eq(&self, other: &Self) -> bool { self.0.as_bytes().ct_eq(other.0.as_bytes()).into() } } /// A paired device persisted in the app-data security store. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PairedDevice { /// Persistent device id. pub device_id: DeviceId, /// User-facing device name. pub name: DeviceName, /// Pairing timestamp as epoch milliseconds. pub paired_at_ms: u64, /// Last successful authenticated access timestamp as epoch milliseconds. pub last_seen_at_ms: u64, /// Hash of the bearer session token. pub session_token_hash: SessionTokenHash, } /// Successful session authentication result. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthenticatedDevice { /// Authenticated device id. pub device_id: DeviceId, /// Matched token hash. pub token_hash: SessionTokenHash, } /// Device-domain validation errors. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum DeviceError { /// Invalid device name. #[error("invalid device name: {0}")] InvalidName(String), /// Invalid persisted token hash. #[error("invalid session token hash")] InvalidSessionTokenHash, } fn validate_hash_string(value: &str) -> Result<(), DeviceError> { let Some(hex) = value.strip_prefix(SESSION_TOKEN_HASH_PREFIX) else { return Err(DeviceError::InvalidSessionTokenHash); }; if hex.len() != SESSION_TOKEN_HASH_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(DeviceError::InvalidSessionTokenHash); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn session_token_hash_is_prefixed_sha256_and_verifies_constant_time() { let token = [7_u8; 32]; let hash = SessionTokenHash::from_token_bytes(&token); assert!(hash.as_str().starts_with("sha256:")); assert_eq!(hash.as_str().len(), "sha256:".len() + 64); assert!(hash.verify_token_bytes(&token)); assert!(!hash.verify_token_bytes(&[8_u8; 32])); assert!(hash.constant_time_eq(&SessionTokenHash::new(hash.as_str()).unwrap())); } #[test] fn device_name_is_required_and_limited() { assert!(DeviceName::new("phone").is_ok()); assert!(DeviceName::new(" ").is_err()); assert!(DeviceName::new("a".repeat(41)).is_err()); } }