Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s
Dependency security audit / rustsec (push) Failing after 22s
This commit is contained in:
1 parent
fcb36a0684
commit
180851087b
52 files changed
+8932
-4263
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user