Implement Rust-owned adaptive Android password navigation (#96)
Android ARM verification / rust (push) Failing after 46m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 12:25:02 +02:00
1 parent 36e5f55283
commit 8c9341f214
26 files changed
+1553 -16

No files matched your search

+23
View File
@@ -5,6 +5,8 @@
mod private_storage;
pub use private_storage::*;
mod password_navigation;
pub use password_navigation::*;
use std::{
error::Error,
@@ -1747,6 +1749,27 @@ impl MobileAuthentication {
.map_err(Into::into)
}
pub fn navigation_entry(
&self,
directory: String,
row_id: String,
revision: String,
passphrase: Option<String>,
unix_seconds: u64,
) -> Result<MobileEntryPresentation, MobileAuthenticationFfiError> {
self.authentication
.navigation_entry(
&directory,
&row_id,
&revision,
passphrase
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
unix_seconds,
)
.map(Into::into)
.map_err(Into::into)
}
pub fn entry_page(
&self,
path: String,
@@ -0,0 +1,68 @@
//! Mechanical exports for revision-checked private-store navigation.
use crate::{MobileIcon, MobilePasswordFfiError, MobilePasswordRow, MobilePrivateRuntime};
use ironstorage::mobile_passwords::navigation::{NavigationRow, PasswordNavigation};
#[derive(Clone, uniffi::Record)]
pub struct MobileNavigationRow {
pub row: MobilePasswordRow,
pub revision: String,
}
impl From<NavigationRow> for MobileNavigationRow {
fn from(value: NavigationRow) -> Self {
let row = value.row;
Self {
revision: value.revision,
row: MobilePasswordRow {
id: row.id().into(),
path: row.path().into(),
title: row.title().into(),
detail: row.detail().into(),
icon: MobileIcon::from(row.icon()),
kind: row.kind().into(),
},
}
}
}
#[derive(Clone, uniffi::Record)]
pub struct MobilePasswordNavigation {
pub path: String,
pub title: String,
pub parent: Option<String>,
pub revision: String,
pub rows: Vec<MobileNavigationRow>,
pub selected: Option<MobileNavigationRow>,
pub stale: bool,
}
impl From<PasswordNavigation> for MobilePasswordNavigation {
fn from(value: PasswordNavigation) -> Self {
Self {
path: value.path,
title: value.title,
parent: value.parent,
revision: value.revision,
rows: value.rows.into_iter().map(Into::into).collect(),
selected: value.selected.map(Into::into),
stale: value.stale,
}
}
}
#[uniffi::export]
impl MobilePrivateRuntime {
pub fn password_navigation(
&self,
path: String,
selected: Option<String>,
revision: Option<String>,
activate: bool,
) -> Result<MobilePasswordNavigation, MobilePasswordFfiError> {
ironstorage::mobile_passwords::navigation::navigate(
&self.storage,
&path,
selected.as_deref(),
revision.as_deref(),
activate,
)
.map(Into::into)
.map_err(Into::into)
}
}
@@ -737,6 +737,50 @@ impl MobileAuthentication {
.map_err(MobileAuthenticationError::authentication)
}
/// Open only the entry selected from a still-current navigation page.
pub fn navigation_entry(
&self,
directory: &str,
row_id: &str,
revision: &str,
passphrase: Option<SecretBytes>,
unix_seconds: u64,
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
let validate = || {
let page = crate::mobile_passwords::navigation::navigate_repository(
&self.repository,
directory,
Some(row_id),
Some(revision),
false,
)
.map_err(|_| {
entry_detail(
"Selection Is Unavailable",
"Refresh the folder and select the entry again.",
)
})?;
page.selected
.filter(|row| {
row.row.kind() == crate::mobile_passwords::MobilePasswordRowKind::Entry
})
.ok_or_else(|| {
entry_detail(
"Selection Changed",
"Refresh the folder and select the entry again.",
)
})
};
let selected = validate()?;
self.unlock_entry(selected.row.path(), passphrase)?;
let presentation = self.entry_page(selected.row.path(), unix_seconds)?;
if let Err(error) = validate() {
self.manual_lock()?;
return Err(error);
}
Ok(presentation)
}
pub fn entry_page(
&self,
path: &str,
+4 -2
View File
@@ -2,6 +2,8 @@
use crate::mobile::MobileIcon;
pub mod navigation;
use std::{error::Error, fmt};
use crate::{
@@ -91,7 +93,7 @@ impl MobilePasswordPage {
&self.rows
}
fn from_repository(
pub(crate) fn from_repository(
repository: &Repository,
path: Option<&str>,
) -> Result<Self, MobilePasswordError> {
@@ -255,7 +257,7 @@ impl MobilePasswordError {
}
}
fn repository(error: RepositoryError) -> Self {
pub(crate) fn repository(error: RepositoryError) -> Self {
let kind = match error {
RepositoryError::InvalidPath { .. } => MobilePasswordErrorKind::InvalidPath,
RepositoryError::NotFound { .. } => MobilePasswordErrorKind::DirectoryMissing,
@@ -0,0 +1,233 @@
//! Revision-checked, authentication-free Android directory navigation.
use super::{
MobilePasswordError, MobilePasswordErrorKind, MobilePasswordPage, MobilePasswordRow,
MobilePasswordRowKind,
};
use crate::{
mobile_private_storage::{PrivateStorage, exclusive_lock},
repository::{DirectoryPath, Repository},
};
use cap_std::{ambient_authority, fs::Dir};
use data_encoding::HEXLOWER;
use sha2::{Digest, Sha256};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NavigationRow {
pub row: MobilePasswordRow,
pub revision: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PasswordNavigation {
pub path: String,
pub title: String,
pub parent: Option<String>,
pub revision: String,
pub rows: Vec<NavigationRow>,
pub selected: Option<NavigationRow>,
pub stale: bool,
}
/// Only identifiers and revisions cross this boundary; there is no secret provider.
pub fn navigate(
storage: &PrivateStorage,
path: &str,
selected: Option<&str>,
revision: Option<&str>,
activate: bool,
) -> Result<PasswordNavigation, MobilePasswordError> {
let root =
Dir::open_ambient_dir(storage.root(), ambient_authority()).map_err(|_| unavailable())?;
let _lock = exclusive_lock(&root, "runtime.lock").map_err(|_| unavailable())?;
let config = storage
.configuration()
.map_err(|_| unavailable())?
.ok_or_else(unavailable)?;
let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?;
navigate_repository(&repository, path, selected, revision, activate)
}
fn unavailable() -> MobilePasswordError {
MobilePasswordError {
kind: MobilePasswordErrorKind::Configuration,
title: "Passwords Are Unavailable".into(),
detail: "Unlock the device or recover its private store, then refresh.".into(),
}
}
pub(crate) fn navigate_repository(
repository: &Repository,
requested: &str,
selected: Option<&str>,
expected: Option<&str>,
activate: bool,
) -> Result<PasswordNavigation, MobilePasswordError> {
let mut path = DirectoryPath::parse(requested)
.map_err(MobilePasswordError::repository)?
.as_path()
.to_owned();
let page = loop {
match MobilePasswordPage::from_repository(repository, path.to_str()) {
Ok(page) => break page,
Err(error)
if error.kind() == MobilePasswordErrorKind::DirectoryMissing
&& !path.as_os_str().is_empty() =>
{
path.pop();
}
Err(error) => return Err(error),
}
};
let mut hash = Sha256::new();
hash.update(
repository
.navigation_revision(&path, false)
.map_err(MobilePasswordError::repository)?,
);
let rows = page
.rows()
.iter()
.map(|row| {
let revision = repository
.navigation_revision(
std::path::Path::new(row.path()),
row.kind() == MobilePasswordRowKind::Entry,
)
.map_err(MobilePasswordError::repository)?;
let revision = HEXLOWER.encode(&Sha256::digest(revision.as_bytes()));
hash.update(row.id().as_bytes());
hash.update([0]);
hash.update(revision.as_bytes());
Ok(NavigationRow {
row: row.clone(),
revision,
})
})
.collect::<Result<Vec<_>, MobilePasswordError>>()?;
let revision = HEXLOWER.encode(&hash.finalize());
let stale = page.path() != requested || expected.is_some_and(|value| value != revision);
let selection = if stale || expected.is_none() {
None
} else {
selected
.and_then(|id| rows.iter().find(|row| row.row.id() == id))
.cloned()
};
if activate
&& let Some(row) = &selection
&& row.row.kind() == MobilePasswordRowKind::Directory
{
return navigate_repository(repository, row.row.path(), None, None, false);
}
Ok(PasswordNavigation {
parent: path
.parent()
.map(|parent| parent.to_string_lossy().into_owned()),
path: page.path().into(),
title: page.title().into(),
rows,
revision,
stale: stale || selected.is_some() && selection.is_none(),
selected: selection,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn traverses_without_reading_or_decrypting_entries_and_restores_verified_selection() {
let temporary = tempdir().unwrap();
fs::create_dir_all(temporary.path().join("仕事/銀行/Empty")).unwrap();
fs::write(
temporary.path().join("仕事/銀行/a long Unicode 密碼.gpg"),
b"not even valid ciphertext",
)
.unwrap();
let repository = Repository::open(temporary.path()).unwrap();
let root = navigate_repository(&repository, "", None, None, false).unwrap();
let folder = navigate_repository(
&repository,
"",
Some(&root.rows[0].row.id),
Some(&root.revision),
true,
)
.unwrap();
assert_eq!(folder.path, "仕事");
let bank = navigate_repository(&repository, "仕事/銀行", None, None, false).unwrap();
let entry = bank
.rows
.iter()
.find(|row| row.row.kind() == MobilePasswordRowKind::Entry)
.unwrap();
let selected = navigate_repository(
&repository,
&bank.path,
Some(entry.row.id()),
Some(&bank.revision),
true,
)
.unwrap();
assert_eq!(
selected.selected.as_ref().unwrap().row.path(),
"仕事/銀行/a long Unicode 密碼"
);
assert_eq!(
selected,
navigate_repository(
&repository,
&selected.path,
Some(entry.row.id()),
Some(&selected.revision),
false
)
.unwrap()
);
let empty = navigate_repository(&repository, "仕事/銀行/Empty", None, None, false).unwrap();
assert!(empty.rows.is_empty());
assert_eq!(empty.parent.as_deref(), Some("仕事/銀行"));
let deep = (0..32)
.map(|index| format!("level-{index}"))
.collect::<Vec<_>>()
.join("/");
fs::create_dir_all(temporary.path().join(&deep)).unwrap();
let page = navigate_repository(&repository, &deep, None, None, false).unwrap();
assert_eq!(page.path, deep);
assert!(page.rows.is_empty());
}
#[test]
fn replacement_rows_and_disappearing_ancestors_cannot_reuse_stale_actions() {
let temporary = tempdir().unwrap();
fs::create_dir_all(temporary.path().join("a/b/c")).unwrap();
fs::write(temporary.path().join("a/b/login.gpg"), b"original").unwrap();
let repository = Repository::open(temporary.path()).unwrap();
let page = navigate_repository(&repository, "a/b", None, None, false).unwrap();
fs::write(temporary.path().join("replacement"), b"original").unwrap();
fs::rename(
temporary.path().join("replacement"),
temporary.path().join("a/b/login.gpg"),
)
.unwrap();
let stale = navigate_repository(
&repository,
"a/b",
Some("entry:a/b/login"),
Some(&page.revision),
true,
)
.unwrap();
assert!(stale.stale);
assert!(stale.selected.is_none());
assert_ne!(page.revision, stale.revision);
fs::remove_dir_all(temporary.path().join("a/b")).unwrap();
let restored =
navigate_repository(&repository, "a/b/c", None, Some(&page.revision), false).unwrap();
assert_eq!(restored.path, "a");
assert!(restored.stale);
assert!(navigate_repository(&repository, "../escape", None, None, false).is_err());
}
}
+21 -1
View File
@@ -687,7 +687,10 @@ pub(crate) fn private_permissions(
} else {
0o600 | (file.metadata()?.permissions().mode() & 0o100)
};
file.set_permissions(Permissions::from_mode(mode))?;
// Avoid changing object revisions when an already-private store is reopened.
if file.metadata()?.permissions().mode() & 0o7777 != mode {
file.set_permissions(Permissions::from_mode(mode))?;
}
}
#[cfg(not(unix))]
let _ = (file, directory);
@@ -700,6 +703,23 @@ mod tests {
use crate::{mobile::MobileShellState, repository::SecretBytes};
use std::os::unix::{fs::PermissionsExt, fs::symlink};
#[test]
fn enforcing_private_permissions_preserves_an_already_private_object_revision() {
use cap_std::fs::{MetadataExt, PermissionsExt as _};
let temporary = tempfile::tempdir().unwrap();
let directory = Dir::open_ambient_dir(temporary.path(), ambient_authority()).unwrap();
let file = directory.create("entry").unwrap();
private_permissions(&file, false).unwrap();
let before = file.metadata().unwrap();
private_permissions(&file, false).unwrap();
let after = file.metadata().unwrap();
assert_eq!(
(before.ctime(), before.ctime_nsec()),
(after.ctime(), after.ctime_nsec())
);
assert_eq!(after.permissions().mode() & 0o7777, 0o600);
}
fn roots(root: &Path) -> PrivateDirectories {
for name in ["files", "no-backup", "cache"] {
fs::create_dir_all(root.join(name)).unwrap();
+54
View File
@@ -391,6 +391,60 @@ impl Repository {
Ok(EncryptedEntry(bytes))
}
/// A non-secret object revision; navigation never opens encrypted contents.
pub(crate) fn navigation_revision(
&self,
path: &Path,
entry: bool,
) -> Result<String, RepositoryError> {
let relative = if entry {
EntryPath::parse(path)?.encrypted_relative_path()
} else {
DirectoryPath::parse(path)?.as_path().to_owned()
};
let metadata = if relative.as_os_str().is_empty() {
self.root
.dir_metadata()
.map_err(|error| io_error("inspect repository", path, error))?
} else {
let parent = self.open_directory(relative.parent().unwrap_or(Path::new("")))?;
child_metadata(
&parent,
relative.file_name().expect("relative object"),
&relative,
)?
.ok_or_else(|| RepositoryError::NotFound {
path: relative.clone(),
})?
};
if entry {
require_regular_file(metadata.clone(), &relative)?;
} else {
require_directory(metadata.clone(), &relative)?;
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
Ok(format!(
"{}:{}:{}:{}:{}:{}:{}",
metadata.dev(),
metadata.ino(),
metadata.len(),
metadata.mtime(),
metadata.mtime_nsec(),
if entry { metadata.ctime() } else { 0 },
if entry { metadata.ctime_nsec() } else { 0 }
))
}
#[cfg(not(unix))]
Ok(format!(
"{}:{:?}:{:?}",
metadata.len(),
metadata.modified(),
metadata.created()
))
}
pub(crate) fn read_policy_file(
&self,
directory: &DirectoryPath,