Onboard iPhone password-store clone

This commit is contained in:
2026-08-11 16:33:08 +02:00
parent db898152bf
commit daf122aff3
11 changed files with 2770 additions and 38 deletions

View File

@@ -5,6 +5,7 @@ use std::{
env,
error::Error,
fmt, fs,
fs::OpenOptions,
io::Write,
path::{Component, Path, PathBuf},
time::Duration,
@@ -167,6 +168,73 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist()
}
pub(crate) fn create_mobile_clone(
source: PathBuf,
vault: &Path,
key_material: &Path,
default_key: &str,
remote: &GitRemote,
) -> Result<Self, ConfigError> {
let base = source
.parent()
.ok_or(ConfigError::InvalidField { field: "source" })?;
let vault = vault
.strip_prefix(base)
.map_err(|_| ConfigError::InvalidField { field: "vault" })?;
let key_material =
key_material
.strip_prefix(base)
.map_err(|_| ConfigError::InvalidField {
field: "key_material",
})?;
let mut root = toml::Table::new();
root.insert(
"vault".to_owned(),
toml::Value::String(path_text(vault, "vault")?),
);
root.insert(
"default_key".to_owned(),
toml::Value::String(default_key.to_owned()),
);
root.insert(
"key_material".to_owned(),
toml::Value::String(path_text(key_material, "key_material")?),
);
let mut configured = toml::Table::new();
configured.insert(
"name".to_owned(),
toml::Value::String(remote.name().as_str().to_owned()),
);
configured.insert(
"url".to_owned(),
toml::Value::String(remote.url().to_string()),
);
configured.insert(
"server_id".to_owned(),
toml::Value::String(remote.server_id().as_str().to_owned()),
);
configured.insert(
"application_id".to_owned(),
toml::Value::String(remote.application_id().as_str().to_owned()),
);
let mut git = toml::Table::new();
git.insert(
"remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(configured)]),
);
root.insert("git".to_owned(), toml::Value::Table(git));
let document = toml::Value::Table(root);
let raw = document
.clone()
.try_into::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: source.clone(),
})?;
let config = validate_config(source, document, raw)?;
config.persist_new()?;
Ok(config)
}
/// Select a configured remote by name, or the configured default (first
/// remote) when no name was requested.
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
@@ -313,6 +381,72 @@ impl Config {
path: self.source.clone(),
})
}
fn persist_new(&self) -> Result<(), ConfigError> {
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
if self.source.exists() {
return Err(ConfigError::AlreadyConfigured {
path: self.source.clone(),
});
}
fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
set_private_directory(parent).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let temporary = (0..128_u8)
.find_map(|attempt| {
let path = parent.join(format!(
".ironstorage-config-{}-{attempt}",
rand::random::<u64>()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
match options.open(&path) {
Ok(file) => Some(Ok((path, file))),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(ConfigError::Write {
path: self.source.clone(),
})),
}
})
.transpose()?
.ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let (temporary_path, mut temporary_file) = temporary;
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)));
drop(temporary_file);
let _ = fs::remove_file(&temporary_path);
match installed {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(ConfigError::AlreadyConfigured {
path: self.source.clone(),
})
}
Err(_) => Err(ConfigError::Write {
path: self.source.clone(),
}),
}
}
}
/// Deterministic path context for configuration loading.
@@ -432,6 +566,21 @@ pub struct GitRemote {
}
impl GitRemote {
pub fn https(
name: impl Into<String>,
url: impl Into<String>,
server_id: impl Into<String>,
application_id: impl Into<String>,
) -> Result<Self, ConfigError> {
let mut remotes = validate_remotes(vec![RawGitRemote {
name: name.into(),
url: url.into(),
server_id: server_id.into(),
application_id: application_id.into(),
}])?;
Ok(remotes.remove(0))
}
pub fn name(&self) -> &RemoteName {
&self.name
}
@@ -505,6 +654,7 @@ pub enum ConfigError {
VaultUnavailable { path: PathBuf },
VaultIsNotDirectory { path: PathBuf },
Write { path: PathBuf },
AlreadyConfigured { path: PathBuf },
KeyMaterialNotFound { path: PathBuf },
InvalidKeyMaterial { path: PathBuf },
DuplicateRemote { name: String },
@@ -575,6 +725,11 @@ impl fmt::Display for ConfigError {
path.display()
)
}
Self::AlreadyConfigured { path } => write!(
formatter,
"configuration already exists and was not replaced: {}",
path.display()
),
Self::KeyMaterialNotFound { path } => write!(
formatter,
"exported key material does not exist: {}",
@@ -760,6 +915,23 @@ fn resolve_required_path(
Ok(resolve_path(base, &value))
}
fn path_text(path: &Path, field: &'static str) -> Result<String, ConfigError> {
path.to_str()
.map(str::to_owned)
.ok_or(ConfigError::InvalidField { field })
}
fn set_private_directory(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
let trimmed = value.trim();
if trimmed.is_empty()
@@ -1014,7 +1186,14 @@ fn native_config_directory() -> Option<PathBuf> {
.map(|home| home.join("Library/Application Support"))
}
#[cfg(all(unix, not(target_os = "macos")))]
#[cfg(target_os = "ios")]
fn native_config_directory() -> Option<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join("Library/Application Support"))
}
#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
fn native_config_directory() -> Option<PathBuf> {
match env::var_os("XDG_CONFIG_HOME") {
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
@@ -1031,3 +1210,53 @@ fn native_config_directory() -> Option<PathBuf> {
fn native_config_directory() -> Option<PathBuf> {
None
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{Config, ConfigError, GitRemote};
#[test]
fn mobile_clone_configuration_is_secret_free_and_never_replaced() {
let temporary = tempfile::tempdir().expect("temporary directory");
let original = temporary.path().join("original");
let relocated = temporary.path().join("relocated");
let vault = original.join("vault");
let keys = original.join("keys");
let source = original.join("config.toml");
fs::create_dir_all(&vault).expect("vault");
fs::create_dir(&keys).expect("keys");
let remote = GitRemote::https(
"origin",
"https://example.test/team/passwords.git",
"server-example",
"repository-example",
)
.expect("remote");
Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
.expect("create config");
let contents = fs::read_to_string(&source).expect("read config");
assert!(!contents.contains("token ="));
assert!(!contents.contains("password ="));
assert!(!contents.contains(&original.to_string_lossy().into_owned()));
assert!(contents.contains("vault = \"vault\""));
assert!(contents.contains("key_material = \"keys\""));
assert!(contents.contains("https://example.test/team/passwords.git"));
assert_eq!(
Config::create_mobile_clone(source.clone(), &vault, &keys, "BOB", &remote)
.expect_err("must not replace existing config"),
ConfigError::AlreadyConfigured {
path: source.clone()
}
);
fs::rename(&original, &relocated).expect("relocate app container");
assert_eq!(
Config::load(Some(&relocated.join("config.toml")))
.expect("reload config")
.default_key()
.as_str(),
"ALICE"
);
}
}