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"
);
}
}

View File

@@ -788,18 +788,91 @@ impl FetchOutcome {
}
impl GitRepository {
pub fn discover_remote_branches(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
Self::discover_remote_branches_with_transport(
parent,
identity,
configured,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn discover_remote_branches_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
ensure_clone_parent(parent)?;
let temporary = private_temporary_directory(parent, "probe")?;
let result = (|| {
let store = Repository::open(&temporary).map_err(invalid)?;
gix::ThreadSafeRepository::init_opts(
store.root_path(),
gix::create::Kind::WithWorktree,
gix::create::Options::default(),
isolated_options(),
)
.map_err(invalid)?;
let mut repository = Self::open(&store, identity)?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.fetch_with_transport_controlled(
configured,
credentials,
transport,
control,
)?;
repository.remote_branch_names(configured.name().as_str())
})();
let _ = fs::remove_dir_all(&temporary);
result
}
pub fn clone_into(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
) -> Result<Self, GitError> {
Self::clone_into_with_transport(
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
None,
credentials,
&EmbeddedFetchTransport,
&GitOperationControl::default(),
)
}
pub fn clone_into_controlled(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
branch: &str,
credentials: &impl GitCredentialProvider,
control: &GitOperationControl,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
Some(branch),
credentials,
&EmbeddedFetchTransport,
control,
)
}
@@ -810,17 +883,35 @@ impl GitRepository {
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
None,
credentials,
transport,
&GitOperationControl::default(),
)
}
pub fn clone_into_with_transport_controlled(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Self, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
if let Some(branch) = branch {
validate_remote_name(branch)?;
}
let parent = destination.parent().ok_or_else(|| GitError::InvalidPath {
path: destination.to_owned(),
})?;
let metadata =
fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
path: parent.to_owned(),
});
}
ensure_clone_parent(parent)?;
if let Ok(metadata) = fs::symlink_metadata(destination) {
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
@@ -835,18 +926,7 @@ impl GitRepository {
return Err(GitError::DirtyWorktree);
}
}
let temporary = (0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-clone-{}-{attempt}", rand::random::<u64>());
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => Some(Ok(candidate)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(io("create clone directory", parent))),
}
})
.transpose()?
.ok_or_else(|| io("create clone directory", parent))?;
let temporary = private_temporary_directory(parent, "clone")?;
let cloned = (|| {
let store = Repository::open(&temporary).map_err(invalid)?;
gix::ThreadSafeRepository::init_opts(
@@ -858,17 +938,23 @@ impl GitRepository {
.map_err(invalid)?;
let mut repository = Self::open(&store, identity.clone())?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.pull_with_transport(configured, None, credentials, transport)?;
Ok(repository)
repository.pull_with_transport_controlled(
configured,
branch,
credentials,
transport,
control,
)?;
control.report(GitProgressPhase::Refreshing)?;
Ok(())
})();
if let Err(error) = cloned {
let _ = fs::remove_dir_all(&temporary);
return Err(error);
}
drop(cloned);
if destination.exists() {
fs::remove_dir(destination)
.map_err(|_| io("prepare clone destination", destination))?;
if destination.exists() && fs::remove_dir(destination).is_err() {
let _ = fs::remove_dir_all(&temporary);
return Err(io("prepare clone destination", destination));
}
if fs::rename(&temporary, destination).is_err() {
let _ = fs::remove_dir_all(&temporary);
@@ -880,6 +966,42 @@ impl GitRepository {
Self::open_at(destination.to_owned(), identity)
}
fn remote_branch_names(&self, remote: &str) -> Result<Vec<String>, GitError> {
validate_remote_name(remote)?;
let prefix = format!("refs/remotes/{remote}/");
let mut branches = self
.repository
.references()
.map_err(invalid)?
.remote_branches()
.map_err(invalid)?
.filter_map(|reference| {
let reference = match reference {
Ok(reference) => reference,
Err(error) => return Some(Err(invalid(error))),
};
let name = match reference.name().as_bstr().to_str() {
Ok(name) => name,
Err(error) => return Some(Err(invalid(error))),
};
name.strip_prefix(&prefix)
.filter(|name| *name != "HEAD")
.map(|name| {
validate_remote_name(name)?;
Ok(name.to_owned())
})
})
.collect::<Result<Vec<_>, GitError>>()?;
branches.sort();
branches.dedup();
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches)
}
pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
let root = store.root_path().to_owned();
if root.join(".git").exists() {
@@ -957,12 +1079,13 @@ impl GitRepository {
}
fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
let repository =
let mut repository =
gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
if repository.is_bare() {
return Err(GitError::BareRepository);
}
validate_local_config_security(&load_local_config(&repository)?)?;
apply_in_memory_identity(&mut repository, &identity)?;
Ok(Self {
root,
repository,
@@ -1204,18 +1327,18 @@ impl GitRepository {
return GitError::Cancelled;
}
let text = error.to_string();
let lower = text.to_ascii_lowercase();
if text.contains("401")
|| text.contains("403")
|| text.to_ascii_lowercase().contains("authentication")
|| lower.contains("authentication")
|| (lower.contains("credential") && lower.contains("not accepted"))
{
GitError::AuthenticationFailed
} else if text.to_ascii_lowercase().contains("certificate")
|| text.to_ascii_lowercase().contains("tls")
{
} else if lower.contains("certificate") || lower.contains("tls") {
GitError::TlsFailed
} else if text.to_ascii_lowercase().contains("network")
|| text.to_ascii_lowercase().contains("connect")
|| text.to_ascii_lowercase().contains("dns")
} else if lower.contains("network")
|| lower.contains("connect")
|| lower.contains("dns")
{
GitError::NetworkUnavailable
} else {
@@ -1878,6 +2001,7 @@ impl GitRepository {
validate_local_config_security(&config)?;
write_local_config(&self.repository, &config)?;
self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
apply_in_memory_identity(&mut self.repository, &self.identity)?;
Ok(())
}
@@ -3032,6 +3156,43 @@ fn validate_remote_name(name: &str) -> Result<(), GitError> {
Ok(())
}
fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> {
let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
path: parent.to_owned(),
});
}
Ok(())
}
fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf, GitError> {
(0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700))
.is_err()
{
let _ = fs::remove_dir(&candidate);
return Some(Err(io("secure private Git directory", parent)));
}
}
Some(Ok(candidate))
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(io("create private Git directory", parent))),
}
})
.transpose()?
.ok_or_else(|| io("create private Git directory", parent))
}
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
if parsed.scheme() != "https"
@@ -3215,9 +3376,45 @@ fn invalid(error: impl fmt::Display) -> GitError {
GitError::InvalidRepository(error.to_string())
}
fn apply_in_memory_identity(
repository: &mut gix::Repository,
identity: &GitIdentity,
) -> Result<(), GitError> {
let mut config = repository.config_snapshot_mut();
config
.set_raw_value("user.name", identity.name())
.map_err(invalid)?;
config
.set_raw_value("user.email", identity.email())
.map_err(invalid)?;
drop(config);
if repository.committer().is_none() {
return Err(GitError::InvalidIdentity);
}
Ok(())
}
fn io(operation: &'static str, path: &Path) -> GitError {
GitError::Io {
operation,
path: path.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::{GitIdentity, GitRepository};
use crate::repository::Repository;
#[test]
fn embedded_identity_survives_local_config_updates() {
let temporary = tempfile::tempdir().expect("temporary repository");
let store = Repository::open(temporary.path()).expect("store");
let mut repository =
GitRepository::init(&store, GitIdentity::ironstorage()).expect("initialize Git");
repository
.add_remote("origin", "https://example.test/password-store.git")
.expect("add remote");
assert!(repository.repository.committer().is_some());
}
}

View File

@@ -15,6 +15,7 @@ pub mod generate;
pub mod git;
pub mod kdbx;
pub mod mobile;
pub mod mobile_onboarding;
pub mod mutation;
pub mod otp;
pub mod presentation;

View File

@@ -0,0 +1,665 @@
//! Storage-owned first-run setup for the native iPhone application.
use std::{
error::Error,
fmt, fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use sha2::{Digest as _, Sha256};
use url::Url;
use crate::{
config::{Config, ConfigError, ConfigLoader, GitRemote},
git::{
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository,
},
repository::{DirectoryPath, Repository, SecretBytes},
secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileOnboardingPhase {
Validating,
Authenticating,
Receiving,
Integrating,
Finishing,
}
impl From<GitProgressPhase> for MobileOnboardingPhase {
fn from(phase: GitProgressPhase) -> Self {
match phase {
GitProgressPhase::Validating => Self::Validating,
GitProgressPhase::Authenticating => Self::Authenticating,
GitProgressPhase::Receiving => Self::Receiving,
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
GitProgressPhase::Refreshing => Self::Finishing,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingProgress {
phase: MobileOnboardingPhase,
title: String,
detail: String,
}
impl MobileOnboardingProgress {
pub fn phase(&self) -> MobileOnboardingPhase {
self.phase
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
}
pub struct MobileOnboardingOperation {
control: GitOperationControl,
phase: Arc<Mutex<MobileOnboardingPhase>>,
}
impl Default for MobileOnboardingOperation {
fn default() -> Self {
let phase = Arc::new(Mutex::new(MobileOnboardingPhase::Validating));
let observed = Arc::clone(&phase);
Self {
control: GitOperationControl::new(move |phase| {
if let Ok(mut current) = observed.lock() {
*current = phase.into();
}
}),
phase,
}
}
}
impl MobileOnboardingOperation {
pub fn cancel(&self) {
self.control.cancel();
}
pub fn progress(&self) -> MobileOnboardingProgress {
progress_copy(
self.phase
.lock()
.map_or(MobileOnboardingPhase::Validating, |phase| *phase),
)
}
pub fn discover(
&self,
request: &MobileOnboardingRequest,
) -> Result<MobileOnboardingDiscovery, MobileOnboardingError> {
let paths = MobileOnboardingPaths::system()?;
paths.prepare_root()?;
let branches = GitRepository::discover_remote_branches(
&paths.root,
GitIdentity::ironstorage(),
&request.remote,
request,
&self.control,
)
.map_err(MobileOnboardingError::from_git)?;
let selected_branch = branches
.iter()
.position(|branch| branch == "main")
.unwrap_or_default();
Ok(MobileOnboardingDiscovery {
branches,
selected_branch,
})
}
pub fn setup(
&self,
request: &MobileOnboardingRequest,
branch: &str,
use_existing: bool,
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
match Config::load(None) {
Ok(_) => return Err(MobileOnboardingError::already_configured()),
Err(ConfigError::NotFound { .. }) => {}
Err(error) => return Err(MobileOnboardingError::from_config(error)),
}
let paths = MobileOnboardingPaths::system()?;
paths.prepare_root()?;
let repository = if use_existing {
let repository = Repository::open(&paths.vault)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
.map_err(MobileOnboardingError::from_git)?;
let actual = git
.remote_url(request.remote.name().as_str())
.map_err(MobileOnboardingError::from_git)?;
if actual != request.remote.url().as_str()
|| git.current_branch().ok().as_deref() != Some(branch)
{
return Err(MobileOnboardingError::different_existing_clone());
}
repository
} else {
if paths
.vault
.read_dir()
.ok()
.is_some_and(|mut entries| entries.next().is_some())
{
return Err(MobileOnboardingError::existing_clone());
}
GitRepository::clone_into_controlled(
&paths.vault,
GitIdentity::ironstorage(),
&request.remote,
branch,
request,
&self.control,
)
.map_err(MobileOnboardingError::from_git)?;
Repository::open(&paths.vault)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?
};
self.control
.report(GitProgressPhase::Refreshing)
.map_err(MobileOnboardingError::from_git)?;
fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
let default_key = default_key(&repository)?;
store_application_token(request)?;
Config::create_mobile_clone(
paths.config,
&paths.vault,
&paths.keys,
&default_key,
&request.remote,
)
.map_err(MobileOnboardingError::from_config)?;
Ok(MobileOnboardingOutcome {
title: "Password Store Ready".to_owned(),
detail: format!("The {} branch is available locally.", branch),
})
}
}
pub struct MobileOnboardingRequest {
account: String,
token: SecretBytes,
remote: GitRemote,
}
impl MobileOnboardingRequest {
pub fn new(
server_url: String,
account: String,
repository_path: String,
token: Vec<u8>,
) -> Result<Self, MobileOnboardingError> {
let token = SecretBytes::new(token);
GitCredential::new(account.clone(), token.expose().to_vec())
.map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
let remote_url = repository_url(&server_url, &repository_path)?;
let server_id = stable_identifier("server", remote_origin(&remote_url).as_bytes());
let application_id = stable_identifier("repository", remote_url.as_str().as_bytes());
let remote = GitRemote::https("origin", remote_url.to_string(), server_id, application_id)
.map_err(MobileOnboardingError::from_config)?;
Ok(Self {
account,
token,
remote,
})
}
pub fn remote(&self) -> &GitRemote {
&self.remote
}
}
pub fn replace_configured_application_token(
account: String,
token: Vec<u8>,
) -> Result<(), MobileOnboardingError> {
let credential = GitCredential::new(account, token)
.map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
let config = Config::load(None).map_err(MobileOnboardingError::for_token_update)?;
let remote = config
.git_remotes()
.iter()
.find(|remote| remote.name().as_str() == "origin")
.ok_or_else(MobileOnboardingError::token_configuration)?;
store_git_credential(remote, &credential)
}
impl fmt::Debug for MobileOnboardingRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MobileOnboardingRequest")
.field("account", &self.account)
.field("remote", &self.remote)
.field("token", &"[REDACTED]")
.finish()
}
}
impl GitCredentialProvider for MobileOnboardingRequest {
fn credential(
&self,
server: &crate::config::ServerId,
application: &crate::config::ApplicationId,
) -> Result<GitCredential, GitError> {
if server != self.remote.server_id() || application != self.remote.application_id() {
return Err(GitError::CredentialsUnavailable);
}
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingDiscovery {
branches: Vec<String>,
selected_branch: usize,
}
impl MobileOnboardingDiscovery {
pub fn branches(&self) -> &[String] {
&self.branches
}
pub fn selected_branch(&self) -> usize {
self.selected_branch
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingOutcome {
title: String,
detail: String,
}
impl MobileOnboardingOutcome {
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileOnboardingErrorKind {
InvalidInput,
UnsupportedRemote,
Authentication,
Repository,
ExistingClone,
Interrupted,
SecureStorage,
Configuration,
AlreadyConfigured,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingError {
kind: MobileOnboardingErrorKind,
title: &'static str,
detail: String,
}
impl MobileOnboardingError {
pub fn kind(&self) -> MobileOnboardingErrorKind {
self.kind
}
pub fn title(&self) -> &str {
self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
fn invalid(field: &'static str) -> Self {
Self {
kind: MobileOnboardingErrorKind::InvalidInput,
title: "Check Setup Details",
detail: format!("{field} is invalid."),
}
}
fn existing_clone() -> Self {
Self {
kind: MobileOnboardingErrorKind::ExistingClone,
title: "Local Clone Already Exists",
detail: "Choose Use Existing to open it without replacing any files.".to_owned(),
}
}
fn different_existing_clone() -> Self {
Self {
kind: MobileOnboardingErrorKind::ExistingClone,
title: "Different Local Clone",
detail: "The existing clone uses a different remote or branch and was not changed."
.to_owned(),
}
}
fn inaccessible_repository() -> Self {
Self {
kind: MobileOnboardingErrorKind::Repository,
title: "Password Store Is Inaccessible",
detail: "The local password-store repository could not be opened.".to_owned(),
}
}
fn configuration() -> Self {
Self {
kind: MobileOnboardingErrorKind::Configuration,
title: "Setup Was Not Saved",
detail: "IronStorage could not save its local configuration.".to_owned(),
}
}
fn token_configuration() -> Self {
Self {
kind: MobileOnboardingErrorKind::Configuration,
title: "Token Was Not Updated",
detail: "Set up the password store before replacing its application token.".to_owned(),
}
}
fn for_token_update(_error: ConfigError) -> Self {
Self::token_configuration()
}
fn already_configured() -> Self {
Self {
kind: MobileOnboardingErrorKind::AlreadyConfigured,
title: "IronStorage Is Already Configured",
detail: "The existing configuration and local clone were not replaced.".to_owned(),
}
}
fn from_git(error: GitError) -> Self {
match error {
GitError::ForbiddenRemoteUrl => Self {
kind: MobileOnboardingErrorKind::UnsupportedRemote,
title: "HTTPS Required",
detail: "Use a credential-free HTTPS server URL. SSH and helper transports are not supported."
.to_owned(),
},
GitError::AuthenticationFailed
| GitError::CredentialsUnavailable
| GitError::CredentialAccessDenied
| GitError::CredentialCancelled => Self {
kind: MobileOnboardingErrorKind::Authentication,
title: "Authentication Failed",
detail: "Check the account and application token, then try again.".to_owned(),
},
GitError::Cancelled => Self {
kind: MobileOnboardingErrorKind::Interrupted,
title: "Setup Interrupted",
detail: "No existing local clone or configuration was replaced.".to_owned(),
},
GitError::DirtyWorktree => Self::existing_clone(),
GitError::NetworkUnavailable | GitError::TlsFailed => Self {
kind: MobileOnboardingErrorKind::Repository,
title: "Server Unavailable",
detail: "Check the HTTPS server and network connection, then try again.".to_owned(),
},
_ => Self {
kind: MobileOnboardingErrorKind::Repository,
title: "Repository Could Not Be Opened",
detail: "Check the repository path and selected branch, then try again.".to_owned(),
},
}
}
fn from_config(error: ConfigError) -> Self {
match error {
ConfigError::InvalidRemoteUrl { .. } => Self::from_git(GitError::ForbiddenRemoteUrl),
ConfigError::AlreadyConfigured { .. } => Self::already_configured(),
_ => Self::configuration(),
}
}
fn from_secret(error: SecretStoreError) -> Self {
let detail = match error {
SecretStoreError::Denied => "Access to secure token storage was denied.",
SecretStoreError::Cancelled => "Secure token storage was cancelled.",
_ => "Secure application-token storage is unavailable.",
};
Self {
kind: MobileOnboardingErrorKind::SecureStorage,
title: "Token Was Not Saved",
detail: detail.to_owned(),
}
}
}
impl fmt::Display for MobileOnboardingError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.title, self.detail)
}
}
impl Error for MobileOnboardingError {}
struct MobileOnboardingPaths {
root: PathBuf,
config: PathBuf,
vault: PathBuf,
keys: PathBuf,
}
impl MobileOnboardingPaths {
fn system() -> Result<Self, MobileOnboardingError> {
let config = ConfigLoader::system()
.map_err(MobileOnboardingError::from_config)?
.default_path();
let root = config
.parent()
.map(Path::to_owned)
.ok_or_else(MobileOnboardingError::configuration)?;
Ok(Self {
vault: root.join("vault"),
keys: root.join("keys"),
root,
config,
})
}
fn prepare_root(&self) -> Result<(), MobileOnboardingError> {
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
}
}
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
let mut server =
Url::parse(server_url).map_err(|_| MobileOnboardingError::invalid("HTTPS server URL"))?;
if server.scheme() != "https"
|| server.host_str().is_none()
|| !server.username().is_empty()
|| server.password().is_some()
|| server.query().is_some()
|| server.fragment().is_some()
{
return Err(MobileOnboardingError::from_git(
GitError::ForbiddenRemoteUrl,
));
}
let parts = repository_path.split('/').collect::<Vec<_>>();
if parts.len() < 2
|| parts.iter().any(|part| {
part.is_empty()
|| matches!(*part, "." | "..")
|| !part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
})
{
return Err(MobileOnboardingError::invalid("Repository path"));
}
let mut path = server.path().trim_end_matches('/').to_owned();
path.push('/');
path.push_str(&parts.join("/"));
if !path.ends_with(".git") {
path.push_str(".git");
}
server.set_path(&path);
Ok(server)
}
fn remote_origin(url: &Url) -> String {
format!(
"{}://{}:{}",
url.scheme(),
url.host_str().unwrap_or_default(),
url.port_or_known_default().unwrap_or(443)
)
}
fn stable_identifier(prefix: &str, value: &[u8]) -> String {
let digest = Sha256::digest(value);
format!("{prefix}-{}", hex_prefix(&digest, 16))
}
fn hex_prefix(bytes: &[u8], length: usize) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
bytes
.iter()
.flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]])
.take(length)
.map(char::from)
.collect()
}
fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError> {
let root =
DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let contents = repository
.read_policy_file(&root, false)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?
.ok_or_else(MobileOnboardingError::inaccessible_repository)?;
let text = std::str::from_utf8(&contents)
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
text.lines()
.map(|line| line.split('#').next().unwrap_or_default().trim())
.find(|identity| !identity.is_empty())
.filter(|identity| identity.len() <= 512 && !identity.chars().any(char::is_control))
.map(str::to_owned)
.ok_or_else(MobileOnboardingError::inaccessible_repository)
}
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
let credential = request
.credential(request.remote.server_id(), request.remote.application_id())
.map_err(MobileOnboardingError::from_git)?;
store_git_credential(&request.remote, &credential)
}
fn store_git_credential(
remote: &GitRemote,
credential: &GitCredential,
) -> Result<(), MobileOnboardingError> {
let store = NativeSecretStore::system(
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.map_err(MobileOnboardingError::from_secret)?;
store.unlock().map_err(MobileOnboardingError::from_secret)?;
store
.store_https_git_credential(
remote.server_id(),
remote.application_id(),
credential.username(),
SecretBytes::new(credential.password().to_vec()),
)
.map_err(MobileOnboardingError::from_secret)?;
store.lock().map_err(MobileOnboardingError::from_secret)
}
fn progress_copy(phase: MobileOnboardingPhase) -> MobileOnboardingProgress {
let (title, detail) = match phase {
MobileOnboardingPhase::Validating => ("Checking Setup", "Validating the HTTPS repository."),
MobileOnboardingPhase::Authenticating => (
"Authenticating",
"Using the application token from protected memory.",
),
MobileOnboardingPhase::Receiving => {
("Downloading Store", "Receiving Git objects securely.")
}
MobileOnboardingPhase::Integrating => ("Opening Store", "Preparing the selected branch."),
MobileOnboardingPhase::Finishing => ("Finishing Setup", "Saving protected local state."),
};
MobileOnboardingProgress {
phase,
title: title.to_owned(),
detail: detail.to_owned(),
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::{
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
MobileOnboardingRequest,
};
#[test]
fn request_is_https_only_stable_and_secret_safe() {
let request = MobileOnboardingRequest::new(
"https://example.test/gitea".to_owned(),
"alice".to_owned(),
"team/passwords".to_owned(),
b"DO-NOT-RENDER".to_vec(),
)
.expect("valid request");
assert_eq!(
request.remote().url().as_str(),
"https://example.test/gitea/team/passwords.git"
);
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
let error = MobileOnboardingRequest::new(
"ssh://example.test".to_owned(),
"alice".to_owned(),
"team/passwords".to_owned(),
b"DO-NOT-RENDER".to_vec(),
)
.expect_err("SSH must be rejected");
assert_eq!(error.kind(), MobileOnboardingErrorKind::UnsupportedRemote);
assert!(!error.to_string().contains("DO-NOT-RENDER"));
}
#[test]
fn operation_cancellation_is_visible_before_transport_work() {
let operation = MobileOnboardingOperation::default();
assert_eq!(
operation.progress().phase(),
MobileOnboardingPhase::Validating
);
operation.cancel();
assert!(operation.control.is_cancelled());
}
}

View File

@@ -474,6 +474,47 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(())
}
/// Create or replace one HTTPS Git account/token record without exposing
/// the previously stored account or token to a frontend.
pub fn store_https_git_credential(
&self,
server: &ServerId,
application: &ApplicationId,
account: impl Into<String>,
value: SecretBytes,
) -> Result<(), SecretStoreError> {
validate_secret(&value)?;
let reference =
SecretReference::https_git_credential(server.as_str(), application.as_str(), account)?;
let locator = reference.locator();
let encoded = encode_record(&reference, &value)?;
let mut state = self.unlocked_state()?;
match self
.backend
.retrieve(&locator, self.protections.for_reference(&reference))
{
Ok(existing) => {
let existing = decode_record(existing)?;
if existing.reference.locator() != locator {
return Err(SecretStoreError::Corrupted);
}
self.backend.replace(
&locator,
self.protections.for_reference(&reference),
encoded.expose(),
)?;
}
Err(SecretStoreError::Missing) => self.backend.create(
&locator,
self.protections.for_reference(&reference),
encoded.expose(),
)?,
Err(error) => return Err(error),
}
self.cache_insert(&mut state, locator, encoded);
Ok(())
}
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
let locator = reference.locator();
let mut state = self.unlocked_state()?;