Implement Rust-owned adaptive Android password navigation (#96)
This commit is contained in:
1 parent
36e5f55283
commit
8c9341f214
26 files changed
+1553
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user