//! OS facts accepted by mobile frontends. Storage validates and interprets them. use std::{ error::Error, fmt, path::{Component, Path, PathBuf}, }; use crate::{ presentation::{QrError, QrMatrix}, repository::SecretBytes, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobilePlatformError { InvalidPaths, InvalidCredentialRequest, } impl fmt::Display for MobilePlatformError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::InvalidPaths => "invalid mobile platform paths", Self::InvalidCredentialRequest => "invalid credential-provider request", }) } } impl Error for MobilePlatformError {} /// The OS supplies private roots; the repository layout is selected here. #[derive(Clone, Eq, PartialEq)] pub struct MobilePlatformPaths { data: PathBuf, cache: PathBuf, } impl MobilePlatformPaths { pub fn new(data: PathBuf, cache: PathBuf) -> Result { for path in [&data, &cache] { if !path.is_absolute() || path.parent().is_none() || path.components().any(|component| { !matches!(component, Component::RootDir | Component::Normal(_)) }) || path .to_str() .is_none_or(|value| value.len() > 4096 || value.contains('\0')) { return Err(MobilePlatformError::InvalidPaths); } } if data.starts_with(&cache) || cache.starts_with(&data) { return Err(MobilePlatformError::InvalidPaths); } Ok(Self { data, cache }) } pub fn data(&self) -> &Path { &self.data } pub fn cache(&self) -> &Path { &self.cache } pub fn configuration(&self) -> PathBuf { self.data.join("config.toml") } pub fn vault(&self) -> PathBuf { self.data.join("vault") } pub fn keys(&self) -> PathBuf { self.data.join("keys") } } impl fmt::Debug for MobilePlatformPaths { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("MobilePlatformPaths([REDACTED])") } } /// A Y plane, with OS-provided row/pixel strides. No camera or QR policy lives in the UI. pub struct MobileCameraFrame { pub width: u32, pub height: u32, pub row_stride: u32, pub pixel_stride: u32, pub luminance: SecretBytes, } impl MobileCameraFrame { pub fn decode(&self) -> Result { let width = self.width as usize; let height = self.height as usize; let row = self.row_stride as usize; let pixel = self.pixel_stride as usize; let bytes = self.luminance.expose(); if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) || !(1..=4).contains(&pixel) || bytes.len() > 32 * 1024 * 1024 { return Err(QrError::InvalidImage); } let last_column = (width - 1) .checked_mul(pixel) .ok_or(QrError::InvalidImage)?; let last = (height - 1) .checked_mul(row) .and_then(|offset| offset.checked_add(last_column)) .ok_or(QrError::InvalidImage)?; if row <= last_column || last >= bytes.len() { return Err(QrError::InvalidImage); } QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel]) } } impl fmt::Debug for MobileCameraFrame { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("MobileCameraFrame([REDACTED])") } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileLifecycleEvent { Foreground, Background, DeviceLocked, MemoryPressure, ProcessRecreated, UserActivity, Cancel, } /// A caller identity is an OS assertion, never an authorization grant. #[derive(Clone, Eq, PartialEq)] pub struct MobileCredentialRequest { request_id: String, package: String, signing_certificate_sha256: String, origins: Vec, } impl MobileCredentialRequest { pub fn new( request_id: String, package: String, certificate: String, origins: Vec, ) -> Result { let invalid = MobilePlatformError::InvalidCredentialRequest; if request_id.is_empty() || request_id.len() > 256 || request_id.chars().any(char::is_control) || package.len() > 255 || !package.contains('.') || package.split('.').any(|part| { part.is_empty() || !part.bytes().enumerate().all(|(i, ch)| { ch == b'_' || ch.is_ascii_alphabetic() || (i > 0 && ch.is_ascii_digit()) }) }) || certificate.len() != 64 || !certificate.bytes().all(|ch| ch.is_ascii_hexdigit()) || origins.len() > 64 { return Err(invalid); } let mut validated = Vec::new(); for origin in origins { if origin.len() > 4096 || origin.chars().any(char::is_whitespace) || origin.chars().any(char::is_control) { return Err(invalid); } let url = url::Url::parse(&origin).map_err(|_| invalid)?; if !matches!(url.scheme(), "https" | "http") || url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() || url.path() != "/" { return Err(invalid); } let origin = url.origin().ascii_serialization(); if !validated.contains(&origin) { validated.push(origin); } } Ok(Self { request_id, package, signing_certificate_sha256: certificate.to_ascii_lowercase(), origins: validated, }) } pub fn request_id(&self) -> &str { &self.request_id } pub fn package(&self) -> &str { &self.package } pub fn signing_certificate_sha256(&self) -> &str { &self.signing_certificate_sha256 } pub fn origins(&self) -> &[String] { &self.origins } } impl fmt::Debug for MobileCredentialRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("MobileCredentialRequest([REDACTED])") } } #[cfg(test)] mod tests { use super::*; #[test] fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() { let paths = MobilePlatformPaths::new("/private/app/no-backup".into(), "/private/app/cache".into()) .unwrap(); assert_eq!( paths.configuration(), Path::new("/private/app/no-backup/config.toml") ); for (data, cache) in [ ("relative", "/cache"), ("/app/../other", "/cache"), ("/app", "/app/cache"), ("/", "/cache"), ] { assert!(MobilePlatformPaths::new(data.into(), cache.into()).is_err()); } let request = MobileCredentialRequest::new( "opaque-id".into(), "de.example.app".into(), "A".repeat(64), vec!["https://EXAMPLE.test:443/".into()], ) .unwrap(); assert_eq!(request.origins(), ["https://example.test"]); for origin in [ "https://user:secret@example.test", "file:///tmp", "https://example.test/path", "https://example.test/?token=secret", ] { assert!( MobileCredentialRequest::new( "id".into(), "de.example.app".into(), "A".repeat(64), vec![origin.into()] ) .is_err() ); } assert!(!format!("{request:?}").contains("example")); } #[test] fn strided_camera_frame_decodes_in_rust_and_rejects_truncated_or_oversized_planes() { let secret = SecretBytes::new(b"otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP".to_vec()); let qr = QrMatrix::encode(&secret).unwrap(); let side = (qr.width() + 8) * 8; let row = side * 2 + 7; let mut plane = vec![255; side * row]; for y in 0..side { for x in 0..side { if x / 8 >= 4 && y / 8 >= 4 && qr.is_dark(x / 8 - 4, y / 8 - 4) == Some(true) { plane[y * row + x * 2] = 0; } } } let mut frame = MobileCameraFrame { width: side as u32, height: side as u32, row_stride: row as u32, pixel_stride: 2, luminance: SecretBytes::new(plane), }; assert_eq!(frame.decode().unwrap().expose(), secret.expose()); frame.row_stride = 1; assert!(frame.decode().is_err()); frame.width = 4097; assert!(frame.decode().is_err()); } }