Prepare Apple App Store distribution (#59)

This commit is contained in:
2026-08-16 17:55:59 +02:00
parent b50e47ab73
commit f894d43eb6
46 changed files with 20694 additions and 166 deletions

View File

@@ -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]

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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(())
}

View File

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