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

+6
View File
@@ -55,6 +55,12 @@ pub trait AuthenticationClock: Send + Sync + 'static {
fn now(&self) -> Duration;
}
impl<T: AuthenticationClock + ?Sized> AuthenticationClock for Arc<T> {
fn now(&self) -> Duration {
(**self).now()
}
}
/// Process-local monotonic clock used by production sessions.
#[derive(Clone, Debug)]
pub struct SystemAuthenticationClock {
+2
View File
@@ -40,6 +40,8 @@ pub mod mobile_onboarding;
#[cfg(feature = "full")]
pub mod mobile_passwords;
#[cfg(feature = "full")]
pub mod mobile_platform;
#[cfg(feature = "full")]
pub mod mobile_totp;
pub mod mobile_watch;
#[cfg(feature = "full")]
+41 -28
View File
@@ -5,6 +5,36 @@ use crate::{
repository::{Repository, RepositoryError},
};
/// Presentation intent. Native UIs choose their own assets and selected variants.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileIcon {
Home,
Key,
Search,
Timer,
Settings,
Warning,
Branch,
Success,
Sync,
Protected,
Repository,
History,
Incoming,
Outgoing,
Recipients,
Signature,
File,
Merge,
Refresh,
Folder,
User,
Email,
Website,
Text,
Note,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MobileTab {
#[default]
@@ -34,23 +64,13 @@ impl MobileTab {
}
}
pub const fn system_image(self) -> &'static str {
pub const fn icon(self) -> MobileIcon {
match self {
Self::Home => "house",
Self::Passwords => "key",
Self::Search => "magnifyingglass",
Self::Totp => "timer",
Self::Preferences => "gearshape",
}
}
pub const fn selected_system_image(self) -> &'static str {
match self {
Self::Home => "house.fill",
Self::Passwords => "key.fill",
Self::Search => "magnifyingglass",
Self::Totp => "timer",
Self::Preferences => "gearshape.fill",
Self::Home => MobileIcon::Home,
Self::Passwords => MobileIcon::Key,
Self::Search => MobileIcon::Search,
Self::Totp => MobileIcon::Timer,
Self::Preferences => MobileIcon::Settings,
}
}
@@ -91,8 +111,7 @@ pub enum MobileShellState {
pub struct MobilePage {
tab: MobileTab,
title: String,
system_image: String,
selected_system_image: String,
icon: MobileIcon,
state: MobileShellState,
state_title: String,
state_detail: String,
@@ -107,12 +126,8 @@ impl MobilePage {
&self.title
}
pub fn system_image(&self) -> &str {
&self.system_image
}
pub fn selected_system_image(&self) -> &str {
&self.selected_system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn state(&self) -> MobileShellState {
@@ -180,8 +195,7 @@ impl MobileShell {
MobilePage {
tab,
title: tab.title().to_owned(),
system_image: tab.system_image().to_owned(),
selected_system_image: tab.selected_system_image().to_owned(),
icon: tab.icon(),
state,
state_title,
state_detail,
@@ -268,8 +282,7 @@ mod tests {
assert!(shell.pages().iter().all(|page| {
page.state() == state
&& !page.title().is_empty()
&& !page.system_image().is_empty()
&& !page.selected_system_image().is_empty()
&& page.icon() == page.tab().icon()
&& !page.state_title().is_empty()
&& !page.state_detail().is_empty()
}));
+78 -21
View File
@@ -1,11 +1,17 @@
//! Shared mobile authentication state; Swift only supplies input and presents results.
//! Shared mobile authentication state; native frontends supply input and present results.
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
use std::{
collections::BTreeMap,
error::Error,
fmt,
sync::{Arc, Mutex},
time::Duration,
};
use crate::{
authentication::{
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
NativeAuthenticationSession,
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
AuthenticationTimeout, SystemAuthenticationClock,
},
config::{Config, ConfigError, GitRemote, MobileAppearance},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
@@ -33,7 +39,8 @@ use crate::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
},
secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
SecretStoreBackend, SecretStoreError,
},
write::{VaultWriter, WriteError},
};
@@ -279,7 +286,7 @@ impl MobileAuthenticationState {
}
struct ActiveMobileLease {
handle: NativeAuthenticationHandle,
handle: MobileAuthenticationHandle,
key: KeyInfo,
}
@@ -326,11 +333,17 @@ impl Drop for MobileRepositoryOperationGuard<'_> {
}
/// One process-wide mobile authentication lease shared by every tab and viewer.
type MobileAuthenticationSession =
AuthenticationSession<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
type MobileAuthenticationHandle =
AuthenticationHandle<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
pub struct MobileAuthentication {
config: Config,
repository: Repository,
keys: KeyStore,
session: NativeAuthenticationSession,
session: MobileAuthenticationSession,
backend: Arc<dyn SecretStoreBackend>,
status: Mutex<MobileAuthenticationStatus>,
totp_discovery: Mutex<()>,
}
@@ -344,13 +357,28 @@ impl MobileAuthentication {
error.to_string(),
)
})?;
let backend = Arc::new(NativeSecretBackend::new().map_err(preference_secret_error)?);
Self::with_platform(
config,
backend,
Arc::new(SystemAuthenticationClock::default()),
)
}
/// OS mechanics are injected; configuration, protection, leases and storage stay here.
pub fn with_platform(
config: Config,
backend: Arc<dyn SecretStoreBackend>,
clock: Arc<dyn AuthenticationClock>,
) -> Result<Self, MobileAuthenticationError> {
let repository = Repository::open(config.vault()).map_err(entry_error)?;
let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
let session = NativeAuthenticationSession::system(
let session = MobileAuthenticationSession::with_clock(
Arc::clone(&backend),
SecretProtectionPolicy::current_biometry_for_openpgp(),
config.authentication_timeout(),
)
.map_err(MobileAuthenticationError::authentication)?;
clock,
);
let watch_snapshot =
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
Ok(Self {
@@ -371,6 +399,7 @@ impl MobileAuthentication {
repository,
keys,
session,
backend,
totp_discovery: Mutex::new(()),
})
}
@@ -599,7 +628,7 @@ impl MobileAuthentication {
sync_configured: remote.is_some(),
repository_title,
repository_url: remote.map_or_else(
|| "Stored and versioned on this iPhone".to_owned(),
|| "Stored and versioned on this device".to_owned(),
|remote| remote.url().to_string(),
),
server_title: remote
@@ -621,7 +650,10 @@ impl MobileAuthentication {
)
},
),
application_account: remote.map(application_account).transpose()?.flatten(),
application_account: remote
.map(|remote| application_account(remote, Arc::clone(&self.backend)))
.transpose()?
.flatten(),
default_key_title: key
.user_ids()
.first()
@@ -674,7 +706,7 @@ impl MobileAuthentication {
.find(|remote| remote.name().as_str() == "origin")
.or_else(|| self.config.git_remotes().first())
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
let store = preference_secret_store()?;
let store = preference_secret_store(Arc::clone(&self.backend));
store.unlock().map_err(preference_secret_error)?;
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
config_detail("The configured Git remote does not use HTTPS credentials.")
@@ -1276,6 +1308,26 @@ impl MobileAuthentication {
Ok(())
}
pub fn lifecycle(
&self,
event: crate::mobile_platform::MobileLifecycleEvent,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
use crate::mobile_platform::MobileLifecycleEvent;
match event {
MobileLifecycleEvent::Foreground => {}
MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => {
self.manual_lock()?
}
MobileLifecycleEvent::UserActivity => {
if self.state()?.unlocked() {
self.touch_user_activity()?;
}
}
MobileLifecycleEvent::Cancel => self.cancel()?,
}
self.state()
}
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
self.session
.cancel()
@@ -1319,7 +1371,7 @@ impl MobileAuthentication {
fn set_active(
&self,
handle: NativeAuthenticationHandle,
handle: MobileAuthenticationHandle,
key: KeyInfo,
) -> Result<(), MobileAuthenticationError> {
self.status()?.active = Some(ActiveMobileLease { handle, key });
@@ -1529,12 +1581,12 @@ fn repository_operation_conflict(
}
struct KeyOnlyProvider<'a> {
handle: NativeAuthenticationHandle,
handle: MobileAuthenticationHandle,
fingerprint: &'a str,
}
impl<'a> KeyOnlyProvider<'a> {
fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self {
fn new(handle: MobileAuthenticationHandle, key: &'a KeyInfo) -> Self {
Self {
handle,
fingerprint: key.fingerprint().as_str(),
@@ -1567,19 +1619,24 @@ fn config_detail(detail: &str) -> MobileAuthenticationError {
)
}
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
NativeSecretStore::system(
fn preference_secret_store(
backend: Arc<dyn SecretStoreBackend>,
) -> SecretStore<Arc<dyn SecretStoreBackend>> {
SecretStore::new(
backend,
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.map_err(preference_secret_error)
}
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
fn application_account(
remote: &GitRemote,
backend: Arc<dyn SecretStoreBackend>,
) -> Result<Option<String>, MobileAuthenticationError> {
let Some((server_id, application_id)) = remote.https_credentials() else {
return Ok(None);
};
let store = preference_secret_store()?;
let store = preference_secret_store(backend);
store.unlock().map_err(preference_secret_error)?;
let result = store.https_git_credential_account(server_id, application_id);
let lock_result = store.lock();
+18 -16
View File
@@ -1,5 +1,7 @@
//! Storage-owned projection of lossless entry documents for native mobile viewers.
use crate::mobile::MobileIcon;
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
use crate::document::{
@@ -22,7 +24,7 @@ pub enum MobileEntrySectionKind {
pub struct MobileEntryField {
id: u64,
label: String,
system_image: String,
icon: MobileIcon,
value: Option<String>,
masked_value: String,
detail: Option<String>,
@@ -42,8 +44,8 @@ impl MobileEntryField {
&self.label
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn value(&self) -> Option<&str> {
@@ -121,7 +123,7 @@ pub struct MobileEntryEditorField {
kind: MobileEntryEditorFieldKind,
name: Option<String>,
label: String,
system_image: String,
icon: MobileIcon,
value: Option<String>,
masked_value: String,
diagnostic: Option<String>,
@@ -149,8 +151,8 @@ impl MobileEntryEditorField {
&self.label
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn value(&self) -> Option<&str> {
@@ -573,7 +575,7 @@ fn mobile_field(field: &EntryField) -> MobileEntryField {
MobileEntryField {
id: field.id().value(),
label: label(field),
system_image: system_image(metadata.kind()).to_owned(),
icon: icon(metadata.kind()),
value,
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
"Empty"
@@ -614,7 +616,7 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
kind,
name: metadata.name().map(str::to_owned),
label: label(field),
system_image: system_image(metadata.kind()).to_owned(),
icon: icon(metadata.kind()),
value: String::from_utf8(field.value().to_vec()).ok(),
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
"Empty"
@@ -705,15 +707,15 @@ fn display_name(name: &str) -> String {
}
}
fn system_image(kind: EntryFieldKind) -> &'static str {
fn icon(kind: EntryFieldKind) -> MobileIcon {
match kind {
EntryFieldKind::Password => "key.fill",
EntryFieldKind::Username => "person.fill",
EntryFieldKind::Email => "envelope.fill",
EntryFieldKind::Url => "globe",
EntryFieldKind::OtpUri => "timer",
EntryFieldKind::Field => "text.alignleft",
EntryFieldKind::Note => "note.text",
EntryFieldKind::Password => MobileIcon::Key,
EntryFieldKind::Username => MobileIcon::User,
EntryFieldKind::Email => MobileIcon::Email,
EntryFieldKind::Url => MobileIcon::Website,
EntryFieldKind::OtpUri => MobileIcon::Timer,
EntryFieldKind::Field => MobileIcon::Text,
EntryFieldKind::Note => MobileIcon::Note,
}
}
+59 -58
View File
@@ -1,4 +1,6 @@
//! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
//! Storage-owned remote activity and pull-then-push refresh for mobile Home.
use crate::mobile::MobileIcon;
use std::{
collections::BTreeSet,
@@ -62,7 +64,7 @@ pub enum MobileHomeActionKind {
pub struct MobileHomeAction {
kind: MobileHomeActionKind,
title: &'static str,
system_image: &'static str,
icon: MobileIcon,
}
impl MobileHomeAction {
@@ -74,8 +76,8 @@ impl MobileHomeAction {
self.title
}
pub fn system_image(&self) -> &str {
self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
}
@@ -94,7 +96,7 @@ pub struct MobileHomeSummaryRow {
id: String,
title: String,
detail: String,
system_image: String,
icon: MobileIcon,
actions: Vec<MobileHomeAction>,
}
@@ -111,8 +113,8 @@ impl MobileHomeSummaryRow {
&self.detail
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn actions(&self) -> &[MobileHomeAction] {
@@ -125,7 +127,7 @@ pub struct MobileHomeChange {
id: String,
title: String,
detail: String,
system_image: String,
icon: MobileIcon,
kind: MobileHomeChangeKind,
status: MobileHomeChangeStatus,
}
@@ -143,8 +145,8 @@ impl MobileHomeChange {
&self.detail
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn kind(&self) -> MobileHomeChangeKind {
@@ -161,7 +163,7 @@ pub struct MobileHomeCommit {
id: String,
title: String,
detail: String,
system_image: String,
icon: MobileIcon,
timestamp: i64,
changes: Vec<MobileHomeChange>,
actions: Vec<MobileHomeAction>,
@@ -180,8 +182,8 @@ impl MobileHomeCommit {
&self.detail
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn timestamp(&self) -> i64 {
@@ -201,7 +203,7 @@ impl MobileHomeCommit {
pub struct MobileHomeNotice {
title: String,
detail: String,
system_image: String,
icon: MobileIcon,
}
impl MobileHomeNotice {
@@ -213,8 +215,8 @@ impl MobileHomeNotice {
&self.detail
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
}
@@ -615,7 +617,7 @@ impl MobileHomeStorage {
.to_owned(),
detail: "The repository was updated, but the refresh time could not be cached."
.to_owned(),
system_image: "exclamationmark.triangle".to_owned(),
icon: MobileIcon::Warning,
})
};
self.page(MobileHomeFreshness::Current, Some(now), notice)
@@ -938,7 +940,7 @@ fn page_from_divergence(
id: "tracked-branch".to_owned(),
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
detail: "Tracked HTTPS branch".to_owned(),
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
icon: MobileIcon::Branch,
actions: vec![
mobile_action(MobileHomeActionKind::Fetch),
mobile_action(MobileHomeActionKind::Pull),
@@ -949,10 +951,10 @@ fn page_from_divergence(
id: "divergence".to_owned(),
title: divergence_title,
detail: divergence_detail,
system_image: if ahead == 0 && behind == 0 {
"checkmark.circle".to_owned()
icon: if ahead == 0 && behind == 0 {
MobileIcon::Success
} else {
"arrow.triangle.2.circlepath".to_owned()
MobileIcon::Sync
},
actions: [
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
@@ -974,10 +976,10 @@ fn page_from_divergence(
} else {
"Commit or discard these changes before pulling".to_owned()
},
system_image: if local_count == 0 {
"checkmark.shield".to_owned()
icon: if local_count == 0 {
MobileIcon::Protected
} else {
"exclamationmark.triangle".to_owned()
MobileIcon::Warning
},
actions: if local_count > 0 {
vec![mobile_action(MobileHomeActionKind::Commit)]
@@ -1040,7 +1042,7 @@ fn page_from_local_snapshot(
id: "local-branch".to_owned(),
title: snapshot.branch().to_owned(),
detail: "Local Git branch · no sync remote configured".to_owned(),
system_image: "externaldrive.fill".to_owned(),
icon: MobileIcon::Repository,
actions: Vec::new(),
},
MobileHomeSummaryRow {
@@ -1051,7 +1053,7 @@ fn page_from_local_snapshot(
if commit_count == 1 { "" } else { "s" }
),
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
system_image: "clock.arrow.circlepath".to_owned(),
icon: MobileIcon::History,
actions: Vec::new(),
},
working_tree_summary(local_paths.len()),
@@ -1077,10 +1079,10 @@ fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
} else {
"Commit these local password-store changes".to_owned()
},
system_image: if local_count == 0 {
"checkmark.shield".to_owned()
icon: if local_count == 0 {
MobileIcon::Protected
} else {
"exclamationmark.triangle".to_owned()
MobileIcon::Warning
},
actions: if local_count > 0 {
vec![mobile_action(MobileHomeActionKind::Commit)]
@@ -1124,10 +1126,10 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
},
change_count(changes.len())
),
system_image: if incoming {
"arrow.down.circle".to_owned()
icon: if incoming {
MobileIcon::Incoming
} else {
"arrow.up.circle".to_owned()
MobileIcon::Outgoing
},
timestamp: commit.timestamp(),
changes,
@@ -1147,33 +1149,33 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
MobileHomeChangeStatus::Deleted => "Deleted",
};
let file_name = path.file_name().and_then(|name| name.to_str());
let (title, detail, system_image, change_kind) =
let (title, detail, icon, change_kind) =
if path.extension().is_some_and(|extension| extension == "gpg") {
(
display_path(&path.with_extension("")),
format!("{status_name} password entry"),
"key".to_owned(),
MobileIcon::Key,
MobileHomeChangeKind::PasswordEntry,
)
} else if file_name == Some(".gpg-id") {
(
policy_title(path, "recipients"),
format!("{status_name} recipient policy"),
"person.2".to_owned(),
MobileIcon::Recipients,
MobileHomeChangeKind::RecipientPolicy,
)
} else if file_name == Some(".gpg-id.sig") {
(
policy_title(path, "recipient signature"),
format!("{status_name} recipient signature"),
"signature".to_owned(),
MobileIcon::Signature,
MobileHomeChangeKind::RecipientSignature,
)
} else {
(
display_path(path),
format!("{status_name} repository file"),
"doc".to_owned(),
MobileIcon::File,
MobileHomeChangeKind::RepositoryFile,
)
};
@@ -1181,7 +1183,7 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
id,
title,
detail,
system_image,
icon,
kind: change_kind,
status,
}
@@ -1228,27 +1230,27 @@ fn change_count(count: usize) -> String {
}
fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
let (title, detail, system_image) = match outcome {
let (title, detail, icon) = match outcome {
PullOutcome::UpToDate => (
"Already Up to Date",
"The local password store already contains every fetched commit.",
"checkmark.circle",
MobileIcon::Success,
),
PullOutcome::FastForward => (
"Password Store Updated",
"Remote commits were applied without changing local history.",
"arrow.down.circle",
MobileIcon::Incoming,
),
PullOutcome::Merged => (
"Remote Changes Merged",
"Remote and local commits were merged by storage.",
"arrow.triangle.merge",
MobileIcon::Merge,
),
};
MobileHomeNotice {
title: title.to_owned(),
detail: detail.to_owned(),
system_image: system_image.to_owned(),
icon,
}
}
@@ -1256,7 +1258,7 @@ fn commit_notice(commit: &str) -> MobileHomeNotice {
MobileHomeNotice {
title: "Changes Committed".to_owned(),
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
system_image: "checkmark.circle".to_owned(),
icon: MobileIcon::Success,
}
}
@@ -1276,7 +1278,7 @@ fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
} else {
format!("{} has no new objects for this clone.", outcome.remote())
},
system_image: "arrow.clockwise.circle".to_owned(),
icon: MobileIcon::Refresh,
}
}
@@ -1302,22 +1304,18 @@ fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
outcome.branch()
)
},
system_image: "arrow.up.circle".to_owned(),
icon: MobileIcon::Outgoing,
}
}
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
let (title, system_image) = match kind {
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
let (title, icon) = match kind {
MobileHomeActionKind::Commit => ("Commit", MobileIcon::Success),
MobileHomeActionKind::Fetch => ("Fetch", MobileIcon::Refresh),
MobileHomeActionKind::Pull => ("Pull", MobileIcon::Incoming),
MobileHomeActionKind::Push => ("Push", MobileIcon::Outgoing),
};
MobileHomeAction {
kind,
title,
system_image,
}
MobileHomeAction { kind, title, icon }
}
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
@@ -1393,7 +1391,10 @@ fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
mod tests {
use std::path::Path;
use crate::git::{GitChangeKind, GitError};
use crate::{
git::{GitChangeKind, GitError},
mobile::MobileIcon,
};
use super::{
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
@@ -1446,7 +1447,7 @@ mod tests {
fn actions_and_progress_are_typed_before_swift_presentation() {
let push = mobile_action(MobileHomeActionKind::Push);
assert_eq!(push.title(), "Push");
assert_eq!(push.system_image(), "arrow.up.circle");
assert_eq!(push.icon(), MobileIcon::Outgoing);
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
assert_eq!(progress.title(), "Pushing Changes");
+2 -2
View File
@@ -1,4 +1,4 @@
//! Storage-owned first-run setup for the native iPhone application.
//! Storage-owned first-run setup for the native mobile applications.
use std::{
error::Error,
@@ -363,7 +363,7 @@ impl MobileOnboardingOperation {
result?;
Ok(MobileOnboardingOutcome {
title: "Local Password Store Ready".to_owned(),
detail: "A local Git repository and protected GPG key are ready on this iPhone."
detail: "A local Git repository and protected GPG key are ready on this device."
.to_owned(),
})
}
+10 -8
View File
@@ -1,5 +1,7 @@
//! Storage-owned, locked password navigation for native mobile frontends.
use crate::mobile::MobileIcon;
use std::{error::Error, fmt};
use crate::{
@@ -20,7 +22,7 @@ pub struct MobilePasswordRow {
path: String,
title: String,
detail: String,
system_image: String,
icon: MobileIcon,
kind: MobilePasswordRowKind,
}
@@ -41,8 +43,8 @@ impl MobilePasswordRow {
&self.detail
}
pub fn system_image(&self) -> &str {
&self.system_image
pub fn icon(&self) -> MobileIcon {
self.icon
}
pub fn kind(&self) -> MobilePasswordRowKind {
@@ -168,25 +170,25 @@ fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePassword
path: text.to_owned(),
title,
detail,
system_image: "key.fill".to_owned(),
icon: MobileIcon::Key,
kind: MobilePasswordRowKind::Entry,
}
}
fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
let path = node.path().to_owned();
let (kind, prefix, detail, system_image) = match node.kind() {
let (kind, prefix, detail, icon) = match node.kind() {
TreeNodeKind::Directory => (
MobilePasswordRowKind::Directory,
"directory",
entry_count_detail(node),
"folder",
MobileIcon::Folder,
),
TreeNodeKind::Entry => (
MobilePasswordRowKind::Entry,
"entry",
"Locked password".to_owned(),
"key.fill",
MobileIcon::Key,
),
};
MobilePasswordRow {
@@ -194,7 +196,7 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
path,
title: node.name().to_owned(),
detail,
system_image: system_image.to_owned(),
icon,
kind,
}
}
+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());
}
}
+10 -2
View File
@@ -257,11 +257,19 @@ impl QrMatrix {
.decode()
.map_err(|_| QrError::InvalidImage)?
.into_luma8();
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
Self::decode_luminance(
grayscale.width() as usize,
grayscale.height() as usize,
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
);
)
}
pub(crate) fn decode_luminance(
width: usize,
height: usize,
pixel: impl Fn(usize, usize) -> u8,
) -> Result<SecretBytes, QrError> {
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(width, height, pixel);
let grid = prepared
.detect_grids()
.into_iter()
+45
View File
@@ -369,6 +369,51 @@ pub trait SecretStoreBackend: Send + Sync {
}
}
impl<T: SecretStoreBackend + ?Sized> SecretStoreBackend for std::sync::Arc<T> {
fn create(
&self,
locator: &SecretLocator,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
(**self).create(locator, protection, value)
}
fn retrieve(
&self,
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
(**self).retrieve(locator, protection)
}
fn replace(
&self,
locator: &SecretLocator,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
(**self).replace(locator, protection, value)
}
fn delete(
&self,
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<(), SecretStoreError> {
(**self).delete(locator, protection)
}
fn platform_https_git_credential(
&self,
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
(**self).platform_https_git_credential(endpoint)
}
fn lock(&self) -> Result<(), SecretStoreError> {
(**self).lock()
}
fn unlock(&self) -> Result<(), SecretStoreError> {
(**self).unlock()
}
}
struct CachedSecret {
value: SecretBytes,
expires_at: Instant,
+4 -2
View File
@@ -17,9 +17,11 @@ fn apple_sources_preserve_the_mobile_security_boundary() {
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
let bridges = format!(
"{}\n{}",
"{}\n{}\n{}\n{}",
workspace_file("crates/apple/src/lib.rs"),
workspace_file("crates/watch-apple/src/lib.rs")
workspace_file("crates/watch-apple/src/lib.rs"),
workspace_file("crates/mobile-bridge/src/lib.rs"),
workspace_file("crates/mobile-bridge/src/platform.rs")
);
for (name, source) in [
@@ -26,6 +26,74 @@ use support::compatibility::FixtureSet;
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
#[test]
fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_configuration()
-> TestResult {
use ironstorage::{
config::Config, mobile_authentication::MobileAuthentication,
mobile_platform::MobileLifecycleEvent,
};
let fixture = FixtureSet::load()?;
let vault = fixture.materialize_store("basic")?;
let root = tempfile::tempdir()?;
let source = root.path().join("config.toml");
std::fs::write(
&source,
format!(
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/team/passwords.git\"\nserver_id = \"server-example\"\napplication_id = \"repository-example\"\n",
vault.path().to_str().expect("fixture UTF8"),
fixture.path("keys").to_str().expect("fixture UTF8"),
fixture.key("alice")?.primary_fingerprint,
),
)?;
let backend = MemoryBackend::default();
let clock = ManualClock::default();
let authentication = MobileAuthentication::with_platform(
Config::load(Some(&source))?,
Arc::new(backend.clone()),
Arc::new(clock.clone()),
)?;
assert!(
!authentication
.lifecycle(MobileLifecycleEvent::Foreground)?
.unlocked()
);
assert_eq!(
authentication
.preferences(false, false, false)?
.application_account(),
None
);
assert_eq!(
backend.retrieves(),
1,
"preferences must use the injected backend"
);
authentication.set_authentication_timeout(30)?;
authentication.unlock_entry(
"email/personal",
Some(SecretBytes::new(
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
)),
)?;
assert!(authentication.state()?.unlocked());
clock.advance(Duration::from_secs(20));
assert_eq!(authentication.state()?.remaining_seconds(), 10);
authentication.lifecycle(MobileLifecycleEvent::UserActivity)?;
assert_eq!(authentication.state()?.remaining_seconds(), 30);
let editor = authentication.begin_entry_editor("email/personal")?;
authentication.lifecycle(MobileLifecycleEvent::Background)?;
assert!(!authentication.state()?.unlocked());
assert!(authentication.entry_editor(editor.id()).is_err());
let baseline_locks = backend.locks();
drop(authentication);
assert!(
backend.locks() > baseline_locks,
"final owner locks the OS backend"
);
Ok(())
}
#[derive(Default)]
struct BackendState {
values: BTreeMap<SecretLocator, SecretBytes>,
+2 -2
View File
@@ -183,7 +183,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
assert_eq!(mobile_custom[1].value(), Some(""));
let mobile_otp = mobile_fields
.iter()
.find(|field| field.system_image() == "timer")
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Timer)
.expect("OTP field");
assert!(mobile_otp.sensitive());
assert_eq!(mobile_otp.value(), None);
@@ -192,7 +192,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
}));
let mobile_note = mobile_fields
.iter()
.find(|field| field.system_image() == "note.text")
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Note)
.expect("multiline note");
assert!(!mobile_note.sensitive());
assert!(mobile_note.multiline());
+12 -2
View File
@@ -1,6 +1,8 @@
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
const ANDROID_MANIFEST: &str = include_str!("../../android/Cargo.toml");
const MOBILE_MANIFEST: &str = include_str!("../../mobile-bridge/Cargo.toml");
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
@@ -18,8 +20,16 @@ fn release_feature_and_advisory_boundaries_stay_explicit() {
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
assert!(manifest.contains("features = [\"ssh\"]"));
}
assert!(APPLE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
assert!(!APPLE_MANIFEST.contains("features = [\"ssh\"]"));
assert!(MOBILE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
assert!(
APPLE_MANIFEST.contains(
"ironstorage-mobile = { path = \"../mobile-bridge\", features = [\"watch\"] }"
)
);
assert!(ANDROID_MANIFEST.contains("ironstorage-mobile = { path = \"../mobile-bridge\" }"));
for manifest in [APPLE_MANIFEST, ANDROID_MANIFEST, MOBILE_MANIFEST] {
assert!(!manifest.contains("features = [\"ssh\"]"));
}
assert!(AUDIT_WORKFLOW.contains("tool: rust@1.92.0,cargo-audit@0.22.2"));
assert!(AUDIT_WORKFLOW.contains("fallback: none"));
assert!(!AUDIT_WORKFLOW.contains("cargo install"));