Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-08 13:25:09 +02:00
1 parent fcb36a0684
commit 180851087b
52 files changed
+8932 -4263

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
//! 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,
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());
}
}