298 lines
9.1 KiB
Rust
298 lines
9.1 KiB
Rust
//! 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<Self, MobilePlatformError> {
|
|
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<SecretBytes, QrError> {
|
|
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<String>,
|
|
}
|
|
|
|
impl MobileCredentialRequest {
|
|
pub fn new(
|
|
request_id: String,
|
|
package: String,
|
|
certificate: String,
|
|
origins: Vec<String>,
|
|
) -> Result<Self, MobilePlatformError> {
|
|
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());
|
|
}
|
|
}
|