feat(android): implement private Rust storage runtime (#90)
Dependency security audit / rustsec (push) Failing after 23s

This commit is contained in:
hugo committed 2026-10-08 14:36:05 +02:00
1 parent 180851087b
commit 874c8139a4
36 files changed
+2848 -39

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
//! Android cannot link anonymous temporary files through /proc under app SELinux.
#[cfg(not(any(target_os = "android", test)))]
pub(crate) use cap_tempfile::TempFile;
#[cfg(any(target_os = "android", test))]
mod named {
#[cfg(unix)]
use cap_std::fs::OpenOptionsExt;
use cap_std::fs::{Dir, File, OpenOptions};
use std::{
ffi::OsStr,
io::{self, Write},
};
pub(crate) struct TempFile<'a> {
directory: &'a Dir,
file: File,
name: String,
}
impl<'a> TempFile<'a> {
pub(crate) fn new(directory: &'a Dir) -> io::Result<Self> {
#[cfg(unix)]
let _creation = crate::file_lock::directory(directory)?;
for _ in 0..128 {
let name = format!(".ironstorage-atomic-{:032x}", rand::random::<u128>());
let mut options = OpenOptions::new();
options.read(true).write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
match directory.open_with(&name, &options) {
Ok(file) => {
let temporary = Self {
directory,
file,
name,
};
crate::file_lock::lock(&temporary.file.try_clone()?.into_std())?;
return Ok(temporary);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(io::ErrorKind::AlreadyExists.into())
}
pub(crate) fn as_file(&self) -> &File {
&self.file
}
pub(crate) fn replace(self, destination: impl AsRef<OsStr>) -> io::Result<()> {
self.directory
.rename(&self.name, self.directory, destination.as_ref())
}
}
impl Write for TempFile<'_> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.file.write(bytes)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
impl Drop for TempFile<'_> {
fn drop(&mut self) {
let _ = self.directory.remove_file(&self.name);
}
}
}
#[cfg(any(target_os = "android", test))]
pub(crate) use named::TempFile;
pub(crate) fn is_temporary(name: &str) -> bool {
name.strip_prefix(".ironstorage-atomic-")
.is_some_and(|suffix| suffix.len() == 32 && suffix.bytes().all(|b| b.is_ascii_hexdigit()))
}
#[cfg(test)]
mod tests {
use super::*;
use cap_std::{ambient_authority, fs::Dir};
use std::io::Write;
#[test]
fn replacement_is_atomic_private_locked_and_failures_preserve_the_target() {
let root = tempfile::tempdir().unwrap();
let directory = Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap();
directory.write("target", b"old").unwrap();
let mut temporary = TempFile::new(&directory).unwrap();
temporary.write_all(b"new").unwrap();
let name = directory
.entries()
.unwrap()
.map(|entry| entry.unwrap().file_name())
.find(|name| is_temporary(&name.to_string_lossy()))
.unwrap();
let other = directory.open(&name).unwrap().into_std();
assert_eq!(
crate::file_lock::try_lock(&other).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
temporary.as_file().sync_all().unwrap();
temporary.replace("target").unwrap();
assert_eq!(directory.read("target").unwrap(), b"new");
assert!(!directory.exists(&name));
let mut failed = TempFile::new(&directory).unwrap();
failed.write_all(b"partial").unwrap();
assert!(failed.replace("missing/target").is_err());
assert_eq!(directory.read("target").unwrap(), b"new");
assert_eq!(directory.entries().unwrap().count(), 1);
}
}
+57 -7
View File
@@ -8,11 +8,12 @@ use std::{
fs::OpenOptions,
io::{self, Write},
path::{Component, Path, PathBuf},
sync::OnceLock,
time::Duration,
};
use crate::atomic_file::TempFile;
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
use gix::bstr::ByteSlice as _;
use serde::Deserialize;
@@ -31,6 +32,7 @@ use crate::{
const APPLICATION_DIRECTORY: &str = "ironstorage";
const CONFIG_FILE: &str = "config.toml";
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
static PRIVATE_CONFIG_CONTEXT: OnceLock<ConfigLoader> = OnceLock::new();
/// Validated application configuration.
#[derive(Clone, Debug, PartialEq)]
@@ -131,6 +133,10 @@ impl ConfigSettings {
impl Config {
/// Load an explicit configuration file, or the native per-user default.
pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
if let Some(path) = explicit.filter(|path| path.is_absolute()) {
let parent = path.parent().unwrap_or(Path::new("/"));
return ConfigLoader::new(parent.to_owned(), parent.to_owned()).load(Some(path));
}
ConfigLoader::system()?.load(explicit)
}
@@ -713,6 +719,10 @@ impl Config {
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
#[cfg(unix)]
let creation = crate::file_lock::directory(&directory).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let temporary = (0..128_u8)
.find_map(|attempt| {
let path = parent.join(format!(
@@ -739,10 +749,29 @@ impl Config {
path: self.source.clone(),
})?;
let (temporary_path, mut temporary_file) = temporary;
crate::file_lock::lock(&temporary_file).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
#[cfg(unix)]
drop(creation);
let installed = temporary_file
.write_all(contents.as_bytes())
.and_then(|()| temporary_file.sync_all())
.and_then(|()| fs::hard_link(&temporary_path, parent.join(name)));
.and_then(|()| {
#[cfg(target_os = "android")]
return rustix::fs::renameat_with(
&directory,
temporary_path
.file_name()
.expect("temporary file has a name"),
&directory,
name,
rustix::fs::RenameFlags::NOREPLACE,
)
.map_err(Into::into);
#[cfg(not(target_os = "android"))]
fs::hard_link(&temporary_path, parent.join(name))
});
drop(temporary_file);
let _ = fs::remove_file(&temporary_path);
match installed {
@@ -829,10 +858,28 @@ pub struct ConfigLoader {
impl ConfigLoader {
pub fn system() -> Result<Self, ConfigError> {
let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?;
let native_config_directory =
native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?;
Ok(Self::new(current_directory, native_config_directory))
if let Some(context) = PRIVATE_CONFIG_CONTEXT.get() {
return Ok(context.clone());
}
#[cfg(target_os = "android")]
return Err(ConfigError::NativeConfigDirectory);
#[cfg(not(target_os = "android"))]
{
let current_directory =
env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?;
let native_config_directory =
native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?;
Ok(Self::new(current_directory, native_config_directory))
}
}
pub(crate) fn activate_private_root(root: &Path) -> bool {
let Some(parent) = root.parent() else {
return false;
};
let context = Self::new(root.to_owned(), parent.to_owned());
let _ = PRIVATE_CONFIG_CONTEXT.set(context.clone());
PRIVATE_CONFIG_CONTEXT.get() == Some(&context)
}
pub fn new(current_directory: PathBuf, native_config_directory: PathBuf) -> Self {
@@ -2328,7 +2375,10 @@ fn native_config_directory() -> Option<PathBuf> {
.map(|home| home.join("Library/Application Support"))
}
#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
#[cfg(all(
unix,
not(any(target_os = "ios", target_os = "macos", target_os = "android"))
))]
fn native_config_directory() -> Option<PathBuf> {
match env::var_os("XDG_CONFIG_HOME") {
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
+61
View File
@@ -0,0 +1,61 @@
//! Android's pinned std lacks file locks; retain identical advisory-lock semantics.
use std::{fs::File, io};
pub(crate) fn lock(file: &File) -> io::Result<()> {
#[cfg(target_os = "android")]
return rustix::fs::flock(file, rustix::fs::FlockOperation::LockExclusive).map_err(Into::into);
#[cfg(not(target_os = "android"))]
file.lock()
}
pub(crate) fn try_lock(file: &File) -> io::Result<()> {
#[cfg(target_os = "android")]
return rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive)
.map_err(Into::into);
#[cfg(not(target_os = "android"))]
file.try_lock().map_err(|error| match error {
std::fs::TryLockError::WouldBlock => io::ErrorKind::WouldBlock.into(),
std::fs::TryLockError::Error(error) => error,
})
}
#[cfg(unix)]
pub(crate) fn directory(directory: &cap_std::fs::Dir) -> io::Result<File> {
let file = directory.open(".")?.into_std();
lock(&file)?;
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn independent_owners_contend_until_the_lock_is_dropped() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("owner.lock");
let first = File::create(&path).unwrap();
let second = File::open(&path).unwrap();
lock(&first).unwrap();
assert_eq!(
try_lock(&second).unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
drop(first);
try_lock(&second).unwrap();
}
#[cfg(unix)]
#[test]
fn directory_gate_covers_file_creation_before_its_owner_lock() {
let root = tempfile::tempdir().unwrap();
let dir =
cap_std::fs::Dir::open_ambient_dir(root.path(), cap_std::ambient_authority()).unwrap();
let first = directory(&dir).unwrap();
let second = dir.open(".").unwrap().into_std();
assert_eq!(
try_lock(&second).unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
drop(first);
try_lock(&second).unwrap();
}
}
+6
View File
@@ -5,6 +5,8 @@
//!
//! This crate is the sole owner of stored and derived password-store objects.
#[cfg(feature = "full")]
mod atomic_file;
#[cfg(feature = "full")]
pub mod authentication;
#[cfg(feature = "full")]
@@ -18,6 +20,8 @@ pub mod desktop;
#[cfg(feature = "full")]
pub mod document;
#[cfg(feature = "full")]
mod file_lock;
#[cfg(feature = "full")]
pub mod generate;
#[cfg(feature = "full")]
pub mod git;
@@ -42,6 +46,8 @@ pub mod mobile_passwords;
#[cfg(feature = "full")]
pub mod mobile_platform;
#[cfg(feature = "full")]
pub mod mobile_private_storage;
#[cfg(feature = "full")]
pub mod mobile_totp;
pub mod mobile_watch;
#[cfg(feature = "full")]
+9 -1
View File
@@ -151,7 +151,11 @@ pub struct MobileShell {
impl MobileShell {
pub fn load() -> Self {
match Config::load(None) {
Self::from_configuration(Config::load(None))
}
pub fn from_configuration(configuration: Result<Config, ConfigError>) -> Self {
match configuration {
Ok(config) => match Repository::open(config.vault()) {
Ok(_) => Self::new(config.mobile_tab(), MobileShellState::Ready, None),
Err(RepositoryError::Io {
@@ -179,6 +183,10 @@ impl MobileShell {
Self::new(MobileTab::Home, state, None)
}
pub fn storage_failure(detail: String) -> Self {
Self::new(MobileTab::Home, MobileShellState::Error, Some(detail))
}
pub fn selected_tab(&self) -> MobileTab {
self.selected_tab
}
+1 -1
View File
@@ -2,8 +2,8 @@
use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path};
use crate::atomic_file::TempFile;
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
use sha2::{Digest as _, Sha256};
File diff suppressed because it is too large. Load diff
+59 -3
View File
@@ -4,7 +4,7 @@ use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fmt, fs,
io::Write as _,
io::{Read as _, Write as _},
path::Path,
sync::{
Mutex,
@@ -12,8 +12,8 @@ use std::{
},
};
use crate::atomic_file::TempFile;
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use data_encoding::HEXLOWER;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
@@ -27,6 +27,7 @@ use crate::{
};
const CACHE_VERSION: u32 = 1;
const MAX_CATALOG_BYTES: u64 = 32 * 1024 * 1024;
// ponytail: cache writes are rare; use per-cache locks only if profiling shows contention.
static CACHE_WRITE_LOCK: Mutex<()> = Mutex::new(());
@@ -613,7 +614,9 @@ fn inspect_entry(
fn load_cache(path: &Path, store: &str) -> (Option<CachedCatalog>, Option<String>) {
if let Ok(metadata) = fs::symlink_metadata(path)
&& (metadata.file_type().is_symlink() || !metadata.is_file())
&& (metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.len() > MAX_CATALOG_BYTES)
{
return (
None,
@@ -642,6 +645,59 @@ fn load_cache(path: &Path, store: &str) -> (Option<CachedCatalog>, Option<String
}
}
pub(crate) fn validate_private_catalog(
root: &Path,
path: &Path,
) -> Result<bool, crate::mobile_private_storage::PrivateStorageError> {
use crate::mobile_private_storage::PrivateStorageError;
let _lock = CACHE_WRITE_LOCK
.lock()
.map_err(|_| PrivateStorageError::Io)?;
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(PrivateStorageError::UnsafePath);
}
let store = store_identity(root, path);
let parent = Dir::open_ambient_dir(
path.parent().ok_or(PrivateStorageError::UnsafePath)?,
ambient_authority(),
)?;
let name = path.file_name().ok_or(PrivateStorageError::UnsafePath)?;
let file = parent.open(name)?;
crate::mobile_private_storage::private_permissions(&file, false)?;
let mut bytes = Vec::new();
if metadata.len() <= MAX_CATALOG_BYTES {
file.take(MAX_CATALOG_BYTES + 1).read_to_end(&mut bytes)?;
}
let source = std::str::from_utf8(&bytes).ok();
let valid = metadata.len() <= MAX_CATALOG_BYTES
&& bytes.len() as u64 <= MAX_CATALOG_BYTES
&& source
.and_then(|source| toml::from_str::<CachedCatalog>(source).ok())
.is_some_and(|catalog| {
catalog.version == CACHE_VERSION
&& catalog.store == store
&& catalog.entries.iter().all(|(entry, record)| {
EntryPath::parse(entry).is_ok()
&& record.ciphertext_hash.len() == 64
&& record
.ciphertext_hash
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
})
});
if valid {
return Ok(false);
}
parent.remove_file(name)?;
parent.open(".")?.sync_all()?;
Ok(true)
}
fn upsert_cache_record(
path: &Path,
store: &str,
+2 -2
View File
@@ -3,9 +3,9 @@
use std::{error::Error, fmt, path::Path};
#[cfg(feature = "full")]
use cap_std::{ambient_authority, fs::Dir};
use crate::atomic_file::TempFile;
#[cfg(feature = "full")]
use cap_tempfile::TempFile;
use cap_std::{ambient_authority, fs::Dir};
use data_encoding::HEXLOWER;
#[cfg(feature = "full")]
use data_encoding::HEXLOWER_PERMISSIVE;
+1 -1
View File
@@ -14,9 +14,9 @@ use std::collections::BTreeSet;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::atomic_file::TempFile;
pub use crate::secret::SecretBytes;
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
const ENTRY_EXTENSION: &str = "gpg";
const RECIPIENT_FILE: &str = ".gpg-id";
+4
View File
@@ -149,6 +149,7 @@ impl fmt::Debug for SecretReference {
/// server/application pair can retrieve its account without TOML metadata.
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
pub enum SecretLocator {
ApplicationBinding,
OpenPgpPassphrase {
fingerprint: String,
},
@@ -164,6 +165,7 @@ pub enum SecretLocator {
impl fmt::Debug for SecretLocator {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ApplicationBinding => formatter.write_str("SecretLocator::ApplicationBinding"),
Self::OpenPgpPassphrase { .. } => {
formatter.write_str("SecretLocator::OpenPgpPassphrase([REDACTED])")
}
@@ -180,6 +182,7 @@ impl fmt::Debug for SecretLocator {
impl SecretLocator {
fn service_and_user(&self) -> (&'static str, String) {
match self {
Self::ApplicationBinding => ("de.rfc1437.ironstorage.private-binding", "device".into()),
Self::OpenPgpPassphrase { fingerprint } => {
(OPENPGP_PASSPHRASE_SERVICE, fingerprint.clone())
}
@@ -246,6 +249,7 @@ impl SecretProtectionPolicy {
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
SecretLocator::HttpsGitCredential { .. } => self.git,
SecretLocator::SshKeyPassphrase { .. } => self.git,
SecretLocator::ApplicationBinding => SecretProtection::DeviceUnlocked,
}
}
}
+1 -1
View File
@@ -10,8 +10,8 @@ use std::{
time::Duration,
};
use crate::atomic_file::TempFile;
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use hmac::{Hmac, Mac as _};
use russh::{
ChannelMsg, Disconnect, Preferred, cipher, client, compression, kex,