Prepare Apple App Store distribution (#59)
This commit is contained in:
@@ -4,6 +4,8 @@ description = "Pure Rust password-store repository management"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
|
||||
@@ -403,6 +403,58 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub(crate) fn update_mobile_remote(
|
||||
&self,
|
||||
remote: Option<&GitRemote>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let git = root
|
||||
.entry("git")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "git" })?;
|
||||
match remote {
|
||||
Some(remote) => {
|
||||
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()),
|
||||
);
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
git.remove("remotes");
|
||||
}
|
||||
}
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
fn current_document(&self) -> Result<toml::Value, ConfigError> {
|
||||
Self::load(Some(&self.source)).map(|config| config.document)
|
||||
}
|
||||
@@ -413,6 +465,25 @@ impl Config {
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
remote: &GitRemote,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::create_mobile(source, vault, key_material, default_key, Some(remote))
|
||||
}
|
||||
|
||||
pub(crate) fn create_mobile_local(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::create_mobile(source, vault, key_material, default_key, None)
|
||||
}
|
||||
|
||||
fn create_mobile(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
remote: Option<&GitRemote>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let base = source
|
||||
.parent()
|
||||
@@ -439,29 +510,31 @@ impl Config {
|
||||
"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));
|
||||
if let Some(remote) = remote {
|
||||
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()
|
||||
|
||||
@@ -11,16 +11,17 @@ use std::{
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use pgp::{
|
||||
composed::{
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, Esk, Message,
|
||||
MessageBuilder, PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
|
||||
SubpacketConfig, TheRing,
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps, Esk,
|
||||
KeyType, Message, MessageBuilder, PublicOrSecret, SecretKeyParamsBuilder, SignedPublicKey,
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
|
||||
},
|
||||
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
packet::{SignatureType, Subpacket, SubpacketData},
|
||||
ser::Serialize as _,
|
||||
types::{KeyDetails as _, Password, SigningKey, Timestamp, VerifyingKey},
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::repository::{EncryptedEntry, SecretBytes};
|
||||
|
||||
@@ -62,6 +63,21 @@ pub struct KeyInfo {
|
||||
can_sign: bool,
|
||||
}
|
||||
|
||||
pub struct GeneratedKey {
|
||||
info: KeyInfo,
|
||||
armor: SecretBytes,
|
||||
}
|
||||
|
||||
impl GeneratedKey {
|
||||
pub fn info(&self) -> &KeyInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
pub fn into_armor(self) -> SecretBytes {
|
||||
self.armor
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyInfo {
|
||||
pub fn fingerprint(&self) -> &KeyFingerprint {
|
||||
&self.fingerprint
|
||||
@@ -168,6 +184,61 @@ impl KeyStore {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Generate one pass-compatible protected signing certificate with a
|
||||
/// Curve25519 encryption subkey and return transferable private armor.
|
||||
pub fn generate(user_id: &str, passphrase: &SecretBytes) -> Result<GeneratedKey, CryptoError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty()
|
||||
|| user_id.len() > 240
|
||||
|| user_id.chars().any(char::is_control)
|
||||
|| passphrase.expose().is_empty()
|
||||
{
|
||||
return Err(CryptoError::InvalidGeneratedKeyInput);
|
||||
}
|
||||
let mut passphrase = String::from_utf8(passphrase.expose().to_vec())
|
||||
.map_err(|_| CryptoError::InvalidGeneratedKeyInput)?;
|
||||
let generated = (|| {
|
||||
let subkey = SubkeyParamsBuilder::default()
|
||||
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
|
||||
.can_encrypt(EncryptionCaps::All)
|
||||
.passphrase(Some(passphrase.clone()))
|
||||
.build()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let secret = SecretKeyParamsBuilder::default()
|
||||
.key_type(KeyType::Ed25519Legacy)
|
||||
.can_certify(true)
|
||||
.can_sign(true)
|
||||
.primary_user_id(user_id.to_owned())
|
||||
.preferred_symmetric_algorithms(
|
||||
[SymmetricKeyAlgorithm::AES256].into_iter().collect(),
|
||||
)
|
||||
.preferred_hash_algorithms([HashAlgorithm::Sha256].into_iter().collect())
|
||||
.preferred_compression_algorithms(Default::default())
|
||||
.passphrase(Some(passphrase.clone()))
|
||||
.subkey(subkey)
|
||||
.build()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?
|
||||
.generate(OsRng)
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
secret
|
||||
.verify_bindings()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let armor = secret
|
||||
.to_armored_bytes(ArmorOptions::default())
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let material = KeyMaterial {
|
||||
public: secret.to_public_key(),
|
||||
secret: Some(secret),
|
||||
};
|
||||
Ok(GeneratedKey {
|
||||
info: key_info(&material),
|
||||
armor: SecretBytes::new(armor),
|
||||
})
|
||||
})();
|
||||
passphrase.zeroize();
|
||||
generated
|
||||
}
|
||||
|
||||
/// Load a regular exported-key file or a directory tree made only of exported-key files.
|
||||
pub fn load(path: impl AsRef<Path>) -> Result<Self, CryptoError> {
|
||||
let path = path.as_ref();
|
||||
@@ -671,6 +742,8 @@ pub enum CryptoError {
|
||||
NoKeyMaterial,
|
||||
CorruptKeyMaterial,
|
||||
InvalidKeyBindings,
|
||||
InvalidGeneratedKeyInput,
|
||||
KeyGenerationFailed,
|
||||
DuplicateFingerprint {
|
||||
fingerprint: KeyFingerprint,
|
||||
},
|
||||
@@ -722,6 +795,10 @@ impl fmt::Display for CryptoError {
|
||||
Self::NoKeyMaterial => formatter.write_str("no OpenPGP key material was found"),
|
||||
Self::CorruptKeyMaterial => formatter.write_str("OpenPGP key material is malformed"),
|
||||
Self::InvalidKeyBindings => formatter.write_str("OpenPGP key bindings are invalid"),
|
||||
Self::InvalidGeneratedKeyInput => {
|
||||
formatter.write_str("the GPG key name and passphrase are required")
|
||||
}
|
||||
Self::KeyGenerationFailed => formatter.write_str("the GPG key could not be generated"),
|
||||
Self::DuplicateFingerprint { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
|
||||
@@ -150,6 +150,7 @@ pub enum MobileWatchPreferenceState {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobilePreferences {
|
||||
sync_configured: bool,
|
||||
repository_title: String,
|
||||
repository_url: String,
|
||||
server_title: String,
|
||||
@@ -166,6 +167,10 @@ pub struct MobilePreferences {
|
||||
}
|
||||
|
||||
impl MobilePreferences {
|
||||
pub fn sync_configured(&self) -> bool {
|
||||
self.sync_configured
|
||||
}
|
||||
|
||||
pub fn repository_title(&self) -> &str {
|
||||
&self.repository_title
|
||||
}
|
||||
@@ -562,8 +567,7 @@ impl MobileAuthentication {
|
||||
.git_remotes()
|
||||
.iter()
|
||||
.find(|remote| remote.name().as_str() == "origin")
|
||||
.or_else(|| self.config.git_remotes().first())
|
||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||
.or_else(|| self.config.git_remotes().first());
|
||||
let handle = self
|
||||
.keys
|
||||
.resolve(self.config.default_key().as_str())
|
||||
@@ -587,20 +591,34 @@ impl MobileAuthentication {
|
||||
let biometric_unlock_enabled = status.biometric_unlock_enabled;
|
||||
let appearance = status.appearance;
|
||||
drop(status);
|
||||
let server_title = remote.url().host_str().unwrap_or("HTTPS server").to_owned();
|
||||
let repository_title = remote
|
||||
.url()
|
||||
.path_segments()
|
||||
.and_then(Iterator::last)
|
||||
.unwrap_or("Password Store")
|
||||
.trim_end_matches(".git")
|
||||
.to_owned();
|
||||
let repository_title = remote.map_or_else(
|
||||
|| "Local Password Store".to_owned(),
|
||||
|remote| {
|
||||
remote
|
||||
.url()
|
||||
.path_segments()
|
||||
.and_then(Iterator::last)
|
||||
.unwrap_or("Password Store")
|
||||
.trim_end_matches(".git")
|
||||
.to_owned()
|
||||
},
|
||||
);
|
||||
Ok(MobilePreferences {
|
||||
sync_configured: remote.is_some(),
|
||||
repository_title,
|
||||
repository_url: remote.url().to_string(),
|
||||
server_title,
|
||||
server_identity: remote.server_id().as_str().to_owned(),
|
||||
application_account: application_account(remote)?,
|
||||
repository_url: remote.map_or_else(
|
||||
|| "Stored and versioned on this iPhone".to_owned(),
|
||||
|remote| remote.url().to_string(),
|
||||
),
|
||||
server_title: remote
|
||||
.and_then(|remote| remote.url().host_str())
|
||||
.unwrap_or("Git Sync Not Configured")
|
||||
.to_owned(),
|
||||
server_identity: remote.map_or_else(
|
||||
|| "Add an HTTPS remote when you want to sync".to_owned(),
|
||||
|remote| remote.server_id().as_str().to_owned(),
|
||||
),
|
||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
||||
default_key_title: key
|
||||
.user_ids()
|
||||
.first()
|
||||
@@ -1073,6 +1091,44 @@ impl MobileAuthentication {
|
||||
self.store_editor(MobileEntryDraft::new(document, true))
|
||||
}
|
||||
|
||||
pub fn create_directory(
|
||||
&self,
|
||||
parent: &str,
|
||||
name: &str,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let parent = DirectoryPath::parse(parent).map_err(entry_error)?;
|
||||
let leaf = DirectoryPath::parse(name).map_err(entry_error)?;
|
||||
if leaf
|
||||
.as_path()
|
||||
.parent()
|
||||
.is_some_and(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Folder Name Is Invalid",
|
||||
"enter a name without a folder separator",
|
||||
));
|
||||
}
|
||||
let directory =
|
||||
DirectoryPath::parse(parent.as_path().join(leaf.as_path())).map_err(entry_error)?;
|
||||
if self
|
||||
.repository
|
||||
.snapshot()
|
||||
.map_err(entry_error)?
|
||||
.directories()
|
||||
.any(|record| record.path() == &directory)
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Folder Already Exists",
|
||||
"Choose a different folder name.",
|
||||
));
|
||||
}
|
||||
self.repository
|
||||
.ensure_directory(&directory)
|
||||
.map_err(|error| entry_detail("Folder Could Not Be Created", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::{
|
||||
config::{Config, ConfigError, GitRemote},
|
||||
git::{
|
||||
FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, PushOutcome,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome,
|
||||
},
|
||||
mobile_authentication::{
|
||||
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
||||
@@ -219,6 +220,7 @@ impl MobileHomeNotice {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomePage {
|
||||
remote_configured: bool,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
summaries: Vec<MobileHomeSummaryRow>,
|
||||
@@ -230,6 +232,10 @@ pub struct MobileHomePage {
|
||||
}
|
||||
|
||||
impl MobileHomePage {
|
||||
pub fn remote_configured(&self) -> bool {
|
||||
self.remote_configured
|
||||
}
|
||||
|
||||
pub fn freshness(&self) -> MobileHomeFreshness {
|
||||
self.freshness
|
||||
}
|
||||
@@ -364,6 +370,9 @@ impl MobileHomeOperation {
|
||||
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let storage = MobileHomeStorage::load()?;
|
||||
let now = unix_seconds()?;
|
||||
if storage.remote.is_none() {
|
||||
return storage.page(MobileHomeFreshness::Current, Some(now), None);
|
||||
}
|
||||
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
|
||||
return storage.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
@@ -429,7 +438,7 @@ impl MobileHomeOperation {
|
||||
struct MobileHomeStorage {
|
||||
config: Config,
|
||||
git: GitRepository,
|
||||
remote: GitRemote,
|
||||
remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
impl MobileHomeStorage {
|
||||
@@ -439,10 +448,7 @@ impl MobileHomeStorage {
|
||||
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
||||
let git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let remote = config
|
||||
.git_remote(None)
|
||||
.cloned()
|
||||
.ok_or_else(MobileHomeError::missing_remote)?;
|
||||
let remote = config.git_remote(None).cloned();
|
||||
Ok(Self {
|
||||
config,
|
||||
git,
|
||||
@@ -478,9 +484,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let fetched = self.git.fetch_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
control,
|
||||
@@ -501,9 +508,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pulled = self.git.pull_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
@@ -526,9 +534,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pushed = self.git.push_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::ReqwestGitTransport,
|
||||
@@ -556,6 +565,12 @@ impl MobileHomeStorage {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn remote(&self) -> Result<&GitRemote, MobileHomeError> {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.ok_or_else(MobileHomeError::missing_remote)
|
||||
}
|
||||
|
||||
fn current_page(
|
||||
&self,
|
||||
now: i64,
|
||||
@@ -585,16 +600,29 @@ impl MobileHomeStorage {
|
||||
refreshed_at: Option<i64>,
|
||||
notice: Option<MobileHomeNotice>,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let divergence = self
|
||||
.git
|
||||
.divergence(&self.remote, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_divergence(
|
||||
&divergence,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
if let Some(remote) = self.remote.as_ref() {
|
||||
let divergence = self
|
||||
.git
|
||||
.divergence(remote, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_divergence(
|
||||
&divergence,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
} else {
|
||||
let snapshot = self
|
||||
.git
|
||||
.snapshot(None, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_local_snapshot(
|
||||
&snapshot,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,6 +959,7 @@ fn page_from_divergence(
|
||||
},
|
||||
];
|
||||
MobileHomePage {
|
||||
remote_configured: true,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
summaries,
|
||||
@@ -950,6 +979,89 @@ fn page_from_divergence(
|
||||
}
|
||||
}
|
||||
|
||||
fn page_from_local_snapshot(
|
||||
snapshot: &GitSnapshot,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
notice: Option<MobileHomeNotice>,
|
||||
) -> MobileHomePage {
|
||||
let local_paths = snapshot
|
||||
.status()
|
||||
.staged()
|
||||
.iter()
|
||||
.chain(snapshot.status().unstaged())
|
||||
.map(|change| change.path().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let commit_count = snapshot.recent().len();
|
||||
let latest = snapshot
|
||||
.recent()
|
||||
.first()
|
||||
.and_then(|commit| {
|
||||
commit
|
||||
.message()
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
})
|
||||
.map(|line| display_text(line.trim(), 160));
|
||||
MobileHomePage {
|
||||
remote_configured: false,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
summaries: vec![
|
||||
MobileHomeSummaryRow {
|
||||
id: "local-branch".to_owned(),
|
||||
title: snapshot.branch().to_owned(),
|
||||
detail: "Local Git branch · no sync remote configured".to_owned(),
|
||||
system_image: "externaldrive.fill".to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "local-history".to_owned(),
|
||||
title: format!(
|
||||
"{} Local Commit{}",
|
||||
commit_count,
|
||||
if commit_count == 1 { "" } else { "s" }
|
||||
),
|
||||
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
||||
system_image: "clock.arrow.circlepath".to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
working_tree_summary(local_paths.len()),
|
||||
],
|
||||
incoming: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
incoming_total: 0,
|
||||
outgoing_total: 0,
|
||||
notice,
|
||||
}
|
||||
}
|
||||
|
||||
fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
|
||||
MobileHomeSummaryRow {
|
||||
id: "working-tree".to_owned(),
|
||||
title: if local_count == 0 {
|
||||
"Working Tree Clean".to_owned()
|
||||
} else {
|
||||
format!("{} Local", change_count(local_count))
|
||||
},
|
||||
detail: if local_count == 0 {
|
||||
"No uncommitted password-store changes".to_owned()
|
||||
} else {
|
||||
"Commit these local password-store changes".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit {
|
||||
let commit = activity.commit();
|
||||
let title = commit
|
||||
|
||||
@@ -181,7 +181,7 @@ impl MobileKeyTransferService {
|
||||
}
|
||||
|
||||
pub struct MobileKeyTransferImport {
|
||||
config: Config,
|
||||
destination: MobileKeyTransferDestination,
|
||||
digest: Option<String>,
|
||||
total: Option<usize>,
|
||||
chunks: BTreeMap<usize, Vec<u8>>,
|
||||
@@ -189,10 +189,26 @@ pub struct MobileKeyTransferImport {
|
||||
key: Option<MobileKeyTransferKey>,
|
||||
}
|
||||
|
||||
enum MobileKeyTransferDestination {
|
||||
Configured(Box<Config>),
|
||||
LocalSetup,
|
||||
}
|
||||
|
||||
impl MobileKeyTransferImport {
|
||||
fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
destination: MobileKeyTransferDestination::Configured(Box::new(config)),
|
||||
digest: None,
|
||||
total: None,
|
||||
chunks: BTreeMap::new(),
|
||||
complete: None,
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_setup() -> Self {
|
||||
Self {
|
||||
destination: MobileKeyTransferDestination::LocalSetup,
|
||||
digest: None,
|
||||
total: None,
|
||||
chunks: BTreeMap::new(),
|
||||
@@ -280,6 +296,7 @@ impl MobileKeyTransferImport {
|
||||
&mut self,
|
||||
passphrase: Option<SecretBytes>,
|
||||
make_default: bool,
|
||||
overwrite: bool,
|
||||
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferError> {
|
||||
let armor = self
|
||||
.complete
|
||||
@@ -298,20 +315,46 @@ impl MobileKeyTransferImport {
|
||||
return Err(MobileKeyTransferError::PublicDefault);
|
||||
}
|
||||
|
||||
let existing = KeyStore::load(self.config.key_material())?;
|
||||
if matches!(self.destination, MobileKeyTransferDestination::LocalSetup) {
|
||||
if key.kind != MobileKeyTransferKind::Private || !make_default {
|
||||
return Err(MobileKeyTransferError::PublicDefault);
|
||||
}
|
||||
let outcome = crate::mobile_onboarding::MobileOnboardingOperation::default()
|
||||
.setup_local_imported(
|
||||
SecretBytes::new(armor.expose().to_vec()),
|
||||
passphrase,
|
||||
overwrite,
|
||||
)
|
||||
.map_err(MobileKeyTransferError::LocalSetup)?;
|
||||
return Ok(MobileKeyTransferOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
});
|
||||
}
|
||||
let MobileKeyTransferDestination::Configured(config) = &self.destination else {
|
||||
unreachable!("local setup returned above")
|
||||
};
|
||||
let existing = KeyStore::load(config.key_material())?;
|
||||
if let Some(found) = existing
|
||||
.infos()
|
||||
.find(|info| info.fingerprint().as_str() == key.fingerprint)
|
||||
&& (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
|
||||
&& !overwrite
|
||||
{
|
||||
return Err(MobileKeyTransferError::DuplicateKey);
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
|
||||
persist_key_material(self.config.key_material(), key, armor.expose())?;
|
||||
persist_armored_key(
|
||||
config.key_material(),
|
||||
&key.fingerprint,
|
||||
key.kind == MobileKeyTransferKind::Private,
|
||||
armor.expose(),
|
||||
overwrite,
|
||||
)?;
|
||||
if make_default {
|
||||
let mut settings = self.config.settings();
|
||||
let mut settings = config.settings();
|
||||
settings.set_default_key(key.fingerprint.clone());
|
||||
self.config.with_settings(settings)?.persist()?;
|
||||
config.with_settings(settings)?.persist()?;
|
||||
}
|
||||
let kind = if key.kind == MobileKeyTransferKind::Private {
|
||||
"private"
|
||||
@@ -357,12 +400,13 @@ pub enum MobileKeyTransferError {
|
||||
AlreadyComplete,
|
||||
ChecksumMismatch,
|
||||
MismatchedKeys,
|
||||
DuplicateKey,
|
||||
ExistingKey,
|
||||
PublicDefault,
|
||||
InvalidKeyMaterial,
|
||||
IncorrectPassphrase,
|
||||
QrPayload,
|
||||
Write,
|
||||
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileKeyTransferError {
|
||||
@@ -381,12 +425,15 @@ impl fmt::Display for MobileKeyTransferError {
|
||||
"the reconstructed key transfer did not pass its integrity check"
|
||||
}
|
||||
Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys",
|
||||
Self::DuplicateKey => "this GPG key is already installed",
|
||||
Self::ExistingKey => {
|
||||
"this GPG key is already installed; confirm replacement to continue"
|
||||
}
|
||||
Self::PublicDefault => "a public-only key cannot become the default GPG key",
|
||||
Self::InvalidKeyMaterial => "the GPG key material is invalid",
|
||||
Self::IncorrectPassphrase => "the GPG key passphrase is incorrect",
|
||||
Self::QrPayload => "the GPG key could not be represented as QR codes",
|
||||
Self::Write => "the GPG key could not be stored safely",
|
||||
Self::LocalSetup(error) => return error.fmt(formatter),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -499,7 +546,7 @@ fn parse_positive(value: Option<&str>) -> Result<usize, MobileKeyTransferError>
|
||||
.ok_or(MobileKeyTransferError::InvalidFrame)
|
||||
}
|
||||
|
||||
fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
pub(crate) fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
let text =
|
||||
std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?;
|
||||
let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
|
||||
@@ -522,18 +569,19 @@ fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_key_material(
|
||||
pub(crate) fn persist_armored_key(
|
||||
configured: &Path,
|
||||
key: &MobileKeyTransferKey,
|
||||
fingerprint: &str,
|
||||
secret: bool,
|
||||
armor: &[u8],
|
||||
overwrite: bool,
|
||||
) -> Result<(), MobileKeyTransferError> {
|
||||
if configured.is_dir() {
|
||||
let suffix = if key.kind == MobileKeyTransferKind::Private {
|
||||
"secret"
|
||||
} else {
|
||||
"public"
|
||||
};
|
||||
let name = format!("{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase());
|
||||
let suffix = if secret { "secret" } else { "public" };
|
||||
let name = format!("{}-{suffix}.asc", fingerprint.to_ascii_lowercase());
|
||||
if configured.join(&name).exists() && !overwrite {
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
atomic_replace(configured, Path::new(&name), armor)
|
||||
} else {
|
||||
let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?;
|
||||
@@ -689,7 +737,7 @@ mod tests {
|
||||
);
|
||||
assert!(progress.received() < progress.total());
|
||||
assert_eq!(
|
||||
importer.import(None, false),
|
||||
importer.import(None, false, false),
|
||||
Err(MobileKeyTransferError::IncompleteTransfer)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -706,13 +754,14 @@ mod tests {
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
assert_eq!(
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true),
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false),
|
||||
Err(MobileKeyTransferError::IncorrectPassphrase)
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
let keys = KeyStore::load(&fixture.key_path)?;
|
||||
assert!(keys.infos().any(|key| key.has_secret()));
|
||||
@@ -722,6 +771,32 @@ mod tests {
|
||||
.as_str(),
|
||||
ALICE_FINGERPRINT
|
||||
);
|
||||
let before = std::fs::read(fixture.key_path.join(format!(
|
||||
"{}-secret.asc",
|
||||
ALICE_FINGERPRINT.to_ascii_lowercase()
|
||||
)))?;
|
||||
let mut replacement = MobileKeyTransferImport::new(fixture.config);
|
||||
replacement.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
assert_eq!(
|
||||
replacement.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
Err(MobileKeyTransferError::ExistingKey)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.key_path.join(format!(
|
||||
"{}-secret.asc",
|
||||
ALICE_FINGERPRINT.to_ascii_lowercase()
|
||||
)))?,
|
||||
before
|
||||
);
|
||||
replacement.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
true,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,20 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
command::InitRequest,
|
||||
config::{Config, ConfigError, ConfigLoader, GitRemote},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
git::{
|
||||
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||
GitProgressPhase, GitRepository,
|
||||
},
|
||||
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{DirectoryPath, Repository, SecretBytes},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
@@ -189,6 +194,180 @@ impl MobileOnboardingOperation {
|
||||
detail: format!("The {} branch is available locally.", branch),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connect_local_store(
|
||||
&self,
|
||||
request: &MobileOnboardingRequest,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let config = Config::load(None).map_err(MobileOnboardingError::from_config)?;
|
||||
self.connect_local_store_at(&config, request, || store_application_token(request))
|
||||
}
|
||||
|
||||
fn connect_local_store_at<F>(
|
||||
&self,
|
||||
config: &Config,
|
||||
request: &MobileOnboardingRequest,
|
||||
store_token: F,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError>
|
||||
where
|
||||
F: FnOnce() -> Result<(), MobileOnboardingError>,
|
||||
{
|
||||
if !config.git_remotes().is_empty() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
let repository = Repository::open(config.vault())
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let mut git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
git.add_remote(
|
||||
request.remote.name().as_str(),
|
||||
request.remote.url().as_str(),
|
||||
)
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
|
||||
let _ = git.remove_remote(request.remote.name().as_str());
|
||||
return Err(MobileOnboardingError::from_config(error));
|
||||
}
|
||||
if let Err(error) = store_token() {
|
||||
let _ = config.update_mobile_remote(None);
|
||||
let _ = git.remove_remote(request.remote.name().as_str());
|
||||
return Err(error);
|
||||
}
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Git Sync Configured".to_owned(),
|
||||
detail: "The local Git repository is connected to the HTTPS remote.".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setup_local_generated(
|
||||
&self,
|
||||
user_id: &str,
|
||||
passphrase: SecretBytes,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let generated =
|
||||
KeyStore::generate(user_id, &passphrase).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
self.setup_local_key(
|
||||
generated.into_armor(),
|
||||
Some(passphrase),
|
||||
&fingerprint,
|
||||
replace_existing_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn setup_local_imported(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
validate_transfer_armor(armor.expose())
|
||||
.map_err(|_| MobileOnboardingError::invalid_gpg_key())?;
|
||||
let mut keys = KeyStore::new();
|
||||
let infos = keys
|
||||
.import(armor.expose())
|
||||
.map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = infos
|
||||
.first()
|
||||
.filter(|_| infos.len() == 1)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, key, passphrase.as_ref())?;
|
||||
self.setup_local_key(
|
||||
armor,
|
||||
passphrase,
|
||||
key.fingerprint().as_str(),
|
||||
replace_existing_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn setup_local_key(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let paths = MobileOnboardingPaths::system()?;
|
||||
self.setup_local_key_at(paths, armor, passphrase, fingerprint, replace_existing_key)
|
||||
}
|
||||
|
||||
fn setup_local_key_at(
|
||||
&self,
|
||||
paths: MobileOnboardingPaths,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
if Config::load(Some(&paths.config)).is_ok() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
paths.prepare_root()?;
|
||||
if paths.config.exists() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
require_empty_directory(&paths.vault, false)?;
|
||||
require_empty_directory(&paths.keys, replace_existing_key)?;
|
||||
|
||||
let staging = paths.staging_path();
|
||||
fs::create_dir(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let result = (|| {
|
||||
let staged_vault = staging.join("vault");
|
||||
let staged_keys = staging.join("keys");
|
||||
fs::create_dir(&staged_vault)
|
||||
.and_then(|()| fs::create_dir(&staged_keys))
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staged_vault)
|
||||
.and_then(|()| set_private_directory(&staged_keys))
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
persist_armored_key(&staged_keys, fingerprint, true, armor.expose(), false)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let keys = KeyStore::load(&staged_keys).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = keys
|
||||
.infos()
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, &key, passphrase.as_ref())?;
|
||||
let repository = Repository::open(&staged_vault)
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let mut git = GitRepository::init(&repository, GitIdentity::ironstorage())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
RecipientPolicyManager::new(&repository, &keys)
|
||||
.apply_init(
|
||||
&InitRequest {
|
||||
path: None,
|
||||
key_identities: vec![fingerprint.to_owned()],
|
||||
},
|
||||
None,
|
||||
&mut NoSetupSecrets,
|
||||
&mut git,
|
||||
)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
|
||||
paths.install_staged(&staging)?;
|
||||
if let Err(error) = Config::create_mobile_local(
|
||||
paths.config.clone(),
|
||||
&paths.vault,
|
||||
&paths.keys,
|
||||
fingerprint,
|
||||
) {
|
||||
let _ = paths.restore_staged(&staging);
|
||||
return Err(MobileOnboardingError::from_config(error));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if staging.exists() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
}
|
||||
result?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Local Password Store Ready".to_owned(),
|
||||
detail: "A local Git repository and protected GPG key are ready on this iPhone."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileOnboardingRequest {
|
||||
@@ -302,6 +481,7 @@ pub enum MobileOnboardingErrorKind {
|
||||
Authentication,
|
||||
Repository,
|
||||
ExistingClone,
|
||||
ExistingKey,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
Configuration,
|
||||
@@ -344,6 +524,25 @@ impl MobileOnboardingError {
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_key() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::ExistingKey,
|
||||
title: "GPG Key Already Exists",
|
||||
detail: "Creating this local store would replace existing local key material. Confirm replacement to continue."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_gpg_key() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::InvalidInput,
|
||||
title: "GPG Key Could Not Be Used",
|
||||
detail:
|
||||
"Import one private GPG key with an encryption subkey and the correct passphrase."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn different_existing_clone() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::ExistingClone,
|
||||
@@ -432,6 +631,13 @@ impl MobileOnboardingError {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_crypto(error: CryptoError) -> Self {
|
||||
match error {
|
||||
CryptoError::InvalidGeneratedKeyInput => Self::invalid("GPG key name or passphrase"),
|
||||
_ => Self::invalid_gpg_key(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_secret(error: SecretStoreError) -> Self {
|
||||
let detail = match error {
|
||||
SecretStoreError::Denied => "Access to secure token storage was denied.",
|
||||
@@ -482,6 +688,124 @@ impl MobileOnboardingPaths {
|
||||
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
|
||||
}
|
||||
|
||||
fn staging_path(&self) -> PathBuf {
|
||||
let mut nonce = [0_u8; 8];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
self.root.join(format!(
|
||||
".local-setup-{}",
|
||||
nonce
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
))
|
||||
}
|
||||
|
||||
fn install_staged(&self, staging: &Path) -> Result<(), MobileOnboardingError> {
|
||||
let previous_vault = staging.join("previous-vault");
|
||||
let previous_keys = staging.join("previous-keys");
|
||||
let had_vault = self.vault.exists();
|
||||
let had_keys = self.keys.exists();
|
||||
if had_vault {
|
||||
fs::rename(&self.vault, &previous_vault)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
}
|
||||
if had_keys && fs::rename(&self.keys, &previous_keys).is_err() {
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
if fs::rename(staging.join("vault"), &self.vault).is_err() {
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
if had_keys {
|
||||
let _ = fs::rename(&previous_keys, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
if fs::rename(staging.join("keys"), &self.keys).is_err() {
|
||||
let _ = fs::rename(&self.vault, staging.join("vault"));
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
if had_keys {
|
||||
let _ = fs::rename(&previous_keys, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_staged(&self, staging: &Path) -> std::io::Result<()> {
|
||||
let failed_vault = staging.join("failed-vault");
|
||||
let failed_keys = staging.join("failed-keys");
|
||||
if self.vault.exists() {
|
||||
fs::rename(&self.vault, &failed_vault)?;
|
||||
}
|
||||
if self.keys.exists() {
|
||||
fs::rename(&self.keys, &failed_keys)?;
|
||||
}
|
||||
let previous_vault = staging.join("previous-vault");
|
||||
let previous_keys = staging.join("previous-keys");
|
||||
if previous_vault.exists() {
|
||||
fs::rename(previous_vault, &self.vault)?;
|
||||
}
|
||||
if previous_keys.exists() {
|
||||
fs::rename(previous_keys, &self.keys)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn require_empty_directory(
|
||||
path: &Path,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => return Err(MobileOnboardingError::configuration()),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
let nonempty = path
|
||||
.read_dir()
|
||||
.map_err(|_| MobileOnboardingError::configuration())?
|
||||
.next()
|
||||
.is_some();
|
||||
if !nonempty {
|
||||
return Ok(());
|
||||
}
|
||||
if path.file_name().is_some_and(|name| name == "vault") {
|
||||
return Err(MobileOnboardingError::existing_clone());
|
||||
}
|
||||
if !replace_existing_key {
|
||||
return Err(MobileOnboardingError::existing_key());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_local_key(
|
||||
keys: &KeyStore,
|
||||
key: &KeyInfo,
|
||||
passphrase: Option<&SecretBytes>,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
if !key.has_secret() || !key.can_encrypt() {
|
||||
return Err(MobileOnboardingError::invalid_gpg_key());
|
||||
}
|
||||
keys.validate_secret_passphrase(key.fingerprint().as_str(), passphrase)
|
||||
.map_err(|_| MobileOnboardingError::invalid_gpg_key())
|
||||
}
|
||||
|
||||
struct NoSetupSecrets;
|
||||
|
||||
impl SecretProvider for NoSetupSecrets {
|
||||
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
Err(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
|
||||
@@ -622,9 +946,18 @@ fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
crypto::KeyStore,
|
||||
git::{GitIdentity, GitRepository},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
use super::{
|
||||
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
|
||||
MobileOnboardingRequest,
|
||||
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPaths,
|
||||
MobileOnboardingPhase, MobileOnboardingRequest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -662,4 +995,132 @@ mod tests {
|
||||
operation.cancel();
|
||||
assert!(operation.control.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_setup_generates_a_versioned_pass_store_without_a_remote() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
let passphrase = b"correct horse battery staple";
|
||||
let generated = KeyStore::generate(
|
||||
"Local Reviewer <reviewer@demo.example.com>",
|
||||
&SecretBytes::new(passphrase.to_vec()),
|
||||
)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
MobileOnboardingOperation::default()
|
||||
.setup_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(SecretBytes::new(passphrase.to_vec())),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect("local setup");
|
||||
|
||||
let config = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
|
||||
.expect("local config");
|
||||
assert!(config.git_remotes().is_empty());
|
||||
assert_eq!(config.default_key().as_str(), fingerprint);
|
||||
let keys = KeyStore::load(config.key_material()).expect("stored key");
|
||||
keys.validate_secret_passphrase(&fingerprint, Some(&SecretBytes::new(passphrase.to_vec())))
|
||||
.expect("protected key");
|
||||
assert_eq!(
|
||||
fs::read_to_string(config.vault().join(".gpg-id")).expect("recipient policy"),
|
||||
format!("{fingerprint}\n")
|
||||
);
|
||||
let repository = crate::repository::Repository::open(config.vault()).expect("vault");
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
|
||||
assert!(git.log(None).expect("history").len() >= 2);
|
||||
|
||||
let request = MobileOnboardingRequest::new(
|
||||
"https://git.demo.example.com".to_owned(),
|
||||
"reviewer".to_owned(),
|
||||
"reviewer/passwords".to_owned(),
|
||||
b"test-token".to_vec(),
|
||||
)
|
||||
.expect("remote request");
|
||||
MobileOnboardingOperation::default()
|
||||
.connect_local_store_at(&config, &request, || Ok(()))
|
||||
.expect("connect remote");
|
||||
let configured = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
|
||||
.expect("updated config");
|
||||
assert_eq!(configured.git_remotes(), &[request.remote().clone()]);
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
|
||||
assert_eq!(
|
||||
git.remote_url("origin").expect("remote URL"),
|
||||
"https://git.demo.example.com/reviewer/passwords.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_setup_requires_confirmation_before_replacing_key_material() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
paths.prepare_root().expect("root");
|
||||
fs::create_dir(&paths.vault).expect("vault");
|
||||
fs::create_dir(&paths.keys).expect("keys");
|
||||
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
|
||||
let generated = KeyStore::generate(
|
||||
"Replacement Key <replacement@demo.example.com>",
|
||||
&SecretBytes::new(b"replacement passphrase".to_vec()),
|
||||
)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
let error = MobileOnboardingOperation::default()
|
||||
.setup_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(SecretBytes::new(b"replacement passphrase".to_vec())),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect_err("replacement must require confirmation");
|
||||
assert_eq!(error.kind(), MobileOnboardingErrorKind::ExistingKey);
|
||||
assert_eq!(
|
||||
fs::read(temporary.path().join("ironstorage/keys/existing.asc"))
|
||||
.expect("existing key preserved"),
|
||||
b"existing key material"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_staged_install_restores_existing_key_material() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root: root.clone(),
|
||||
};
|
||||
paths.prepare_root().expect("root");
|
||||
fs::create_dir(&paths.vault).expect("vault");
|
||||
fs::create_dir(&paths.keys).expect("keys");
|
||||
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
|
||||
let staging = root.join("staging");
|
||||
fs::create_dir(&staging).expect("staging");
|
||||
fs::create_dir(staging.join("vault")).expect("staged vault");
|
||||
|
||||
paths
|
||||
.install_staged(&staging)
|
||||
.expect_err("missing staged keys must fail");
|
||||
|
||||
assert!(paths.vault.is_dir());
|
||||
assert_eq!(
|
||||
fs::read(paths.keys.join("existing.asc")).expect("existing key preserved"),
|
||||
b"existing key material"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user