feat(android): implement private Rust storage runtime (#90)
Dependency security audit / rustsec (push) Failing after 23s
Dependency security audit / rustsec (push) Failing after 23s
This commit is contained in:
1 parent
180851087b
commit
874c8139a4
36 files changed
+2848
-39
No files matched your search
@@ -3,6 +3,9 @@
|
||||
|
||||
//! Shared UniFFI contract for native phone presentation. Watch exports are opt-in.
|
||||
|
||||
mod private_storage;
|
||||
pub use private_storage::*;
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt,
|
||||
|
||||
@@ -70,6 +70,7 @@ pub fn mobile_platform_layout(
|
||||
|
||||
#[derive(Clone, uniffi::Enum)]
|
||||
pub enum MobileSecretLocator {
|
||||
ApplicationBinding,
|
||||
OpenPgpPassphrase {
|
||||
fingerprint: String,
|
||||
},
|
||||
@@ -85,6 +86,7 @@ pub enum MobileSecretLocator {
|
||||
impl From<&SecretLocator> for MobileSecretLocator {
|
||||
fn from(locator: &SecretLocator) -> Self {
|
||||
match locator {
|
||||
SecretLocator::ApplicationBinding => Self::ApplicationBinding,
|
||||
SecretLocator::OpenPgpPassphrase { fingerprint } => Self::OpenPgpPassphrase {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
@@ -232,7 +234,7 @@ impl SecretStoreBackend for SecretBackendAdapter {
|
||||
pub trait MobileClock: Send + Sync {
|
||||
fn monotonic_milliseconds(&self) -> u64;
|
||||
}
|
||||
struct ClockAdapter(Arc<dyn MobileClock>);
|
||||
pub(crate) struct ClockAdapter(pub Arc<dyn MobileClock>);
|
||||
impl AuthenticationClock for ClockAdapter {
|
||||
fn now(&self) -> Duration {
|
||||
Duration::from_millis(self.0.monotonic_milliseconds())
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Mechanical exports for the storage-owned Android private runtime.
|
||||
use crate::{
|
||||
MobileAuthentication, MobileShell,
|
||||
platform::{ClockAdapter, MobileClock, MobileSecretBackend, SecretBackendAdapter},
|
||||
};
|
||||
use ironstorage::mobile_private_storage::{
|
||||
PrivateDirectories, PrivateStorage, PrivateStorageError,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePrivateDirectories {
|
||||
pub files: String,
|
||||
pub no_backup: String,
|
||||
pub cache: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobilePrivateStorageErrorKind {
|
||||
InvalidRoots,
|
||||
UnsafePath,
|
||||
MissingRoot,
|
||||
AccessDenied,
|
||||
ReadOnly,
|
||||
StorageFull,
|
||||
Busy,
|
||||
Io,
|
||||
CorruptLayout,
|
||||
MigrationRequired,
|
||||
CorruptConfiguration,
|
||||
ConfigurationMissingWithData,
|
||||
InterruptedSetup,
|
||||
RestoredWithoutKeystore,
|
||||
SecureStorageLocked,
|
||||
SecureStorageUnavailable,
|
||||
InvalidSecureBinding,
|
||||
}
|
||||
impl From<PrivateStorageError> for MobilePrivateStorageErrorKind {
|
||||
fn from(value: PrivateStorageError) -> Self {
|
||||
match value {
|
||||
PrivateStorageError::InvalidRoots => Self::InvalidRoots,
|
||||
PrivateStorageError::UnsafePath => Self::UnsafePath,
|
||||
PrivateStorageError::MissingRoot => Self::MissingRoot,
|
||||
PrivateStorageError::AccessDenied => Self::AccessDenied,
|
||||
PrivateStorageError::ReadOnly => Self::ReadOnly,
|
||||
PrivateStorageError::StorageFull => Self::StorageFull,
|
||||
PrivateStorageError::Busy => Self::Busy,
|
||||
PrivateStorageError::Io => Self::Io,
|
||||
PrivateStorageError::CorruptLayout => Self::CorruptLayout,
|
||||
PrivateStorageError::MigrationRequired => Self::MigrationRequired,
|
||||
PrivateStorageError::CorruptConfiguration => Self::CorruptConfiguration,
|
||||
PrivateStorageError::ConfigurationMissingWithData => Self::ConfigurationMissingWithData,
|
||||
PrivateStorageError::InterruptedSetup => Self::InterruptedSetup,
|
||||
PrivateStorageError::RestoredWithoutKeystore => Self::RestoredWithoutKeystore,
|
||||
PrivateStorageError::SecureStorageLocked => Self::SecureStorageLocked,
|
||||
PrivateStorageError::SecureStorageUnavailable => Self::SecureStorageUnavailable,
|
||||
PrivateStorageError::InvalidSecureBinding => Self::InvalidSecureBinding,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, uniffi::Error)]
|
||||
pub enum MobilePrivateStorageFfiError {
|
||||
Failed { kind: MobilePrivateStorageErrorKind },
|
||||
}
|
||||
impl std::fmt::Display for MobilePrivateStorageFfiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Private storage operation failed")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for MobilePrivateStorageFfiError {}
|
||||
impl From<PrivateStorageError> for MobilePrivateStorageFfiError {
|
||||
fn from(value: PrivateStorageError) -> Self {
|
||||
Self::Failed { kind: value.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePrivateStorageStatus {
|
||||
pub version: u32,
|
||||
pub configured: bool,
|
||||
pub recovered_temporary_files: bool,
|
||||
pub recovered_catalog: bool,
|
||||
pub secure_binding_present: bool,
|
||||
pub recovery: Option<MobilePrivateStorageErrorKind>,
|
||||
}
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePrivateStorageLayout {
|
||||
pub data: String,
|
||||
pub configuration: String,
|
||||
pub vault: String,
|
||||
pub keys: String,
|
||||
pub catalog: String,
|
||||
pub scratch: String,
|
||||
pub cache: String,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MobilePrivateRuntime {
|
||||
storage: PrivateStorage,
|
||||
}
|
||||
#[uniffi::export]
|
||||
impl MobilePrivateRuntime {
|
||||
pub fn activate(&self) -> Result<(), MobilePrivateStorageFfiError> {
|
||||
self.storage.activate().map_err(Into::into)
|
||||
}
|
||||
pub fn shell(&self) -> MobileShell {
|
||||
self.storage.shell().into()
|
||||
}
|
||||
pub fn status(&self) -> MobilePrivateStorageStatus {
|
||||
let status = self.storage.status();
|
||||
MobilePrivateStorageStatus {
|
||||
version: status.version,
|
||||
configured: status.configured,
|
||||
recovered_temporary_files: status.recovered_temporary_files,
|
||||
recovered_catalog: status.recovered_catalog,
|
||||
secure_binding_present: status.secure_binding_present,
|
||||
recovery: status.recovery.map(Into::into),
|
||||
}
|
||||
}
|
||||
pub fn layout(&self) -> MobilePrivateStorageLayout {
|
||||
let root = self.storage.root();
|
||||
let path = |name| root.join(name).to_string_lossy().into_owned();
|
||||
MobilePrivateStorageLayout {
|
||||
data: root.to_string_lossy().into_owned(),
|
||||
configuration: path("config.toml"),
|
||||
vault: path("vault"),
|
||||
keys: path("keys"),
|
||||
catalog: path("totp-catalog.toml"),
|
||||
scratch: path("scratch"),
|
||||
cache: self.storage.cache_path().to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
pub fn verify_secure_storage(
|
||||
&self,
|
||||
backend: Arc<dyn MobileSecretBackend>,
|
||||
) -> Result<(), MobilePrivateStorageFfiError> {
|
||||
self.storage
|
||||
.verify_secure_storage(&SecretBackendAdapter(backend))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
pub fn clear_disposable_cache(&self) -> Result<(), MobilePrivateStorageFfiError> {
|
||||
self.storage.clear_disposable_cache().map_err(Into::into)
|
||||
}
|
||||
pub fn authentication(
|
||||
&self,
|
||||
backend: Arc<dyn MobileSecretBackend>,
|
||||
clock: Arc<dyn MobileClock>,
|
||||
) -> Result<Arc<MobileAuthentication>, crate::MobileAuthenticationFfiError> {
|
||||
self.storage
|
||||
.verify_secure_storage(&SecretBackendAdapter(Arc::clone(&backend)))
|
||||
.map_err(private_auth_error)?;
|
||||
let config = self
|
||||
.storage
|
||||
.configuration()
|
||||
.map_err(private_auth_error)?
|
||||
.ok_or_else(|| private_auth_error(PrivateStorageError::ConfigurationMissingWithData))?;
|
||||
let authentication =
|
||||
ironstorage::mobile_authentication::MobileAuthentication::with_platform(
|
||||
config,
|
||||
Arc::new(SecretBackendAdapter(backend)),
|
||||
Arc::new(ClockAdapter(clock)),
|
||||
)?;
|
||||
Ok(Arc::new(MobileAuthentication {
|
||||
authentication: Arc::new(authentication),
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn private_auth_error(error: PrivateStorageError) -> crate::MobileAuthenticationFfiError {
|
||||
crate::MobileAuthenticationFfiError::Failed {
|
||||
kind: crate::MobileAuthenticationErrorKind::Configuration,
|
||||
title: "Private Storage Recovery Required".into(),
|
||||
detail: error.detail().into(),
|
||||
}
|
||||
}
|
||||
#[uniffi::export]
|
||||
pub fn open_mobile_private_runtime(
|
||||
directories: MobilePrivateDirectories,
|
||||
) -> Result<Arc<MobilePrivateRuntime>, MobilePrivateStorageFfiError> {
|
||||
let storage = PrivateStorage::open(PrivateDirectories {
|
||||
files: directories.files.into(),
|
||||
no_backup: directories.no_backup.into(),
|
||||
cache: directories.cache.into(),
|
||||
})?;
|
||||
Ok(Arc::new(MobilePrivateRuntime { storage }))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_private_storage_failure(error: MobilePrivateStorageErrorKind) -> MobileShell {
|
||||
let value = match error {
|
||||
MobilePrivateStorageErrorKind::InvalidRoots => PrivateStorageError::InvalidRoots,
|
||||
MobilePrivateStorageErrorKind::UnsafePath => PrivateStorageError::UnsafePath,
|
||||
MobilePrivateStorageErrorKind::MissingRoot => PrivateStorageError::MissingRoot,
|
||||
MobilePrivateStorageErrorKind::AccessDenied => PrivateStorageError::AccessDenied,
|
||||
MobilePrivateStorageErrorKind::ReadOnly => PrivateStorageError::ReadOnly,
|
||||
MobilePrivateStorageErrorKind::StorageFull => PrivateStorageError::StorageFull,
|
||||
MobilePrivateStorageErrorKind::Busy => PrivateStorageError::Busy,
|
||||
MobilePrivateStorageErrorKind::Io => PrivateStorageError::Io,
|
||||
MobilePrivateStorageErrorKind::CorruptLayout => PrivateStorageError::CorruptLayout,
|
||||
MobilePrivateStorageErrorKind::MigrationRequired => PrivateStorageError::MigrationRequired,
|
||||
MobilePrivateStorageErrorKind::CorruptConfiguration => {
|
||||
PrivateStorageError::CorruptConfiguration
|
||||
}
|
||||
MobilePrivateStorageErrorKind::ConfigurationMissingWithData => {
|
||||
PrivateStorageError::ConfigurationMissingWithData
|
||||
}
|
||||
MobilePrivateStorageErrorKind::InterruptedSetup => PrivateStorageError::InterruptedSetup,
|
||||
MobilePrivateStorageErrorKind::RestoredWithoutKeystore => {
|
||||
PrivateStorageError::RestoredWithoutKeystore
|
||||
}
|
||||
MobilePrivateStorageErrorKind::SecureStorageLocked => {
|
||||
PrivateStorageError::SecureStorageLocked
|
||||
}
|
||||
MobilePrivateStorageErrorKind::SecureStorageUnavailable => {
|
||||
PrivateStorageError::SecureStorageUnavailable
|
||||
}
|
||||
MobilePrivateStorageErrorKind::InvalidSecureBinding => {
|
||||
PrivateStorageError::InvalidSecureBinding
|
||||
}
|
||||
};
|
||||
ironstorage::mobile::MobileShell::storage_failure(value.detail().into()).into()
|
||||
}
|
||||
@@ -30,6 +30,7 @@ full = [
|
||||
"dep:regex",
|
||||
"dep:reqwest",
|
||||
"dep:rustls",
|
||||
"dep:rustix",
|
||||
"dep:rqrr",
|
||||
"dep:secret-service",
|
||||
"dep:security-framework",
|
||||
@@ -46,6 +47,7 @@ watch = []
|
||||
[dependencies]
|
||||
cap-std = { workspace = true, optional = true }
|
||||
cap-tempfile = { workspace = true, optional = true }
|
||||
rustix = { workspace = true, optional = true }
|
||||
clap = { workspace = true, optional = true }
|
||||
clap_complete = { workspace = true, optional = true }
|
||||
data-encoding.workspace = true
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#![forbid(unsafe_code)]
|
||||
use ironstorage::{
|
||||
config::{Config, ConfigLoader},
|
||||
mobile::{MobileShell, MobileShellState},
|
||||
mobile_onboarding::MobileOnboardingOperation,
|
||||
mobile_private_storage::{PrivateDirectories, PrivateStorage},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
use std::{error::Error, fs};
|
||||
|
||||
#[test]
|
||||
fn activated_os_roots_drive_existing_local_setup_and_configuration_apis()
|
||||
-> Result<(), Box<dyn Error>> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
for name in ["files", "no-backup", "cache"] {
|
||||
fs::create_dir(temp.path().join(name))?;
|
||||
}
|
||||
let storage = PrivateStorage::open(PrivateDirectories {
|
||||
files: fs::canonicalize(temp.path().join("files"))?,
|
||||
no_backup: fs::canonicalize(temp.path().join("no-backup"))?,
|
||||
cache: fs::canonicalize(temp.path().join("cache"))?,
|
||||
})?;
|
||||
storage.activate()?;
|
||||
assert_eq!(
|
||||
ConfigLoader::system()?.default_path(),
|
||||
storage.root().join("config.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
MobileShell::load().pages()[0].state(),
|
||||
MobileShellState::Empty
|
||||
);
|
||||
MobileOnboardingOperation::default().setup_local_generated(
|
||||
"Android private fixture <fixture@example.test>",
|
||||
SecretBytes::new(b"public-test-passphrase".to_vec()),
|
||||
false,
|
||||
)?;
|
||||
let config = Config::load(None)?;
|
||||
assert!(storage.status().configured);
|
||||
assert_eq!(config.vault(), storage.root().join("vault"));
|
||||
assert_eq!(config.key_material(), storage.root().join("keys"));
|
||||
assert_eq!(
|
||||
MobileShell::load().pages()[0].state(),
|
||||
MobileShellState::Ready
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.source()
|
||||
.strip_prefix(temp.path().join("files"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.source()
|
||||
.strip_prefix(temp.path().join("cache"))
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user