Onboard iPhone password-store clone
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
665
crates/storage/src/mobile_onboarding.rs
Normal file
665
crates/storage/src/mobile_onboarding.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user