Files
IronStorage/crates/storage/src/desktop.rs

1122 lines
37 KiB
Rust

//! Storage-owned service boundary for the Iced desktop presentation adapter.
use std::{
error::Error,
fmt,
path::Path,
sync::{Arc, OnceLock},
};
use crate::{
authentication::{
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
},
command::{
CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, OtpInputSource,
OtpInsertRequest, RemoveRequest,
},
config::{Config, ConfigSettings, EditorCommand},
crypto::{KeyInfo, KeyStore, SecretProvider},
document::{
DocumentError, EntryDocument, EntryDocumentService, EntryFieldDraft, EntryFieldId,
EntryFieldKind,
},
git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitConflict,
GitConflictResolution, GitError, GitIdentity, GitOperationControl, GitProgressPhase,
GitRemoteCredentialOverride, GitRepository, GitSnapshot, PullOutcome, PushOutcome,
},
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
mutation::{MutationOutcome, TreeMutator},
otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri},
presentation::{ClipboardTimeout, QrMatrix},
read::{FindResults, GrepResults, TreeModel, VaultReader},
recipient::{
PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager,
RecipientPolicyOutcome,
},
repository::{DirectoryPath, Repository, SecretBytes},
secret_store::SecretProtectionPolicy,
write::{OverwriteDecision, VaultWriter, WriteError, WriteOutcome},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DesktopErrorKind {
Configuration,
Authentication,
KeyMaterial,
Repository,
Read,
Document,
Git,
Conflict,
Unchanged,
MissingDefaultKey,
EntryExists,
Mutation,
Otp,
Import,
}
#[derive(Debug)]
pub struct DesktopError {
kind: DesktopErrorKind,
message: String,
conflicts: Vec<GitConflict>,
git: Option<GitError>,
}
impl DesktopError {
pub fn kind(&self) -> DesktopErrorKind {
self.kind
}
pub fn conflicts(&self) -> &[GitConflict] {
&self.conflicts
}
pub fn git_error(&self) -> Option<&GitError> {
self.git.as_ref()
}
fn new(kind: DesktopErrorKind, error: impl fmt::Display) -> Self {
Self {
kind,
message: error.to_string(),
conflicts: Vec::new(),
git: None,
}
}
fn git(error: GitError) -> Self {
let (kind, conflicts) = match &error {
GitError::MergeConflicts { conflicts } => {
(DesktopErrorKind::Conflict, conflicts.clone())
}
_ => (DesktopErrorKind::Git, Vec::new()),
};
Self {
kind,
message: error.to_string(),
conflicts,
git: Some(error),
}
}
fn document(error: DocumentError) -> Self {
let kind = match &error {
DocumentError::Write(WriteError::ConcurrentModification { .. }) => {
DesktopErrorKind::Conflict
}
DocumentError::Write(WriteError::Unchanged) => DesktopErrorKind::Unchanged,
_ => DesktopErrorKind::Document,
};
Self::new(kind, error)
}
}
impl fmt::Display for DesktopError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for DesktopError {}
#[derive(Clone, Debug)]
pub struct DesktopStorage {
config: Config,
keys: Arc<OnceLock<KeyStore>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DesktopMutationRequest {
Remove(RemoveRequest),
Move(MoveRequest),
Copy(CopyRequest),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DesktopGitRequest {
Refresh,
Pull,
Push,
Sync,
Resolve(Vec<GitConflictResolution>),
}
impl DesktopGitRequest {
pub fn requires_authentication(&self) -> bool {
!matches!(self, Self::Refresh)
}
pub fn changes_worktree(&self) -> bool {
matches!(self, Self::Pull | Self::Sync | Self::Resolve(_))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DesktopGitOutcome {
Refreshed,
Pulled(PullOutcome),
Pushed(PushOutcome),
Synchronized {
pull: PullOutcome,
push: PushOutcome,
},
Resolved(PullOutcome),
}
#[derive(Clone, Debug)]
pub struct DesktopGitResult {
outcome: DesktopGitOutcome,
snapshot: GitSnapshot,
tree: Option<TreeModel>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DesktopOtpMetadata {
kind: OtpKind,
issuer: Option<String>,
account: String,
algorithm: OtpAlgorithm,
digits: u32,
}
impl DesktopOtpMetadata {
pub fn kind(&self) -> OtpKind {
self.kind
}
pub fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
pub fn account(&self) -> &str {
&self.account
}
pub fn algorithm(&self) -> OtpAlgorithm {
self.algorithm
}
pub fn digits(&self) -> u32 {
self.digits
}
}
pub struct DesktopOtpCode {
code: SecretBytes,
validity: OtpCodeValidity,
metadata: DesktopOtpMetadata,
tree: Option<TreeModel>,
document: Option<EntryDocument>,
}
impl DesktopOtpCode {
pub fn code(&self) -> &SecretBytes {
&self.code
}
pub fn validity(&self) -> OtpCodeValidity {
self.validity
}
pub fn metadata(&self) -> &DesktopOtpMetadata {
&self.metadata
}
pub fn into_parts(
self,
) -> (
SecretBytes,
OtpCodeValidity,
DesktopOtpMetadata,
Option<TreeModel>,
Option<EntryDocument>,
) {
(
self.code,
self.validity,
self.metadata,
self.tree,
self.document,
)
}
}
pub struct DesktopOtpUri {
payload: SecretBytes,
matrix: Option<QrMatrix>,
}
impl DesktopOtpUri {
pub fn payload(&self) -> &SecretBytes {
&self.payload
}
pub fn matrix(&self) -> Option<&QrMatrix> {
self.matrix.as_ref()
}
pub fn into_parts(self) -> (SecretBytes, Option<QrMatrix>) {
(self.payload, self.matrix)
}
}
pub struct DesktopOtpMutation {
entry: String,
document: EntryDocument,
tree: TreeModel,
}
impl DesktopOtpMutation {
pub fn entry(&self) -> &str {
&self.entry
}
pub fn into_parts(self) -> (String, EntryDocument, TreeModel) {
(self.entry, self.document, self.tree)
}
}
impl DesktopGitResult {
pub fn outcome(&self) -> &DesktopGitOutcome {
&self.outcome
}
pub fn snapshot(&self) -> &GitSnapshot {
&self.snapshot
}
pub fn into_parts(self) -> (DesktopGitOutcome, GitSnapshot, Option<TreeModel>) {
(self.outcome, self.snapshot, self.tree)
}
}
impl DesktopMutationRequest {
pub fn source(&self) -> &str {
match self {
Self::Remove(request) => &request.entry,
Self::Move(request) => &request.source,
Self::Copy(request) => &request.source,
}
}
}
impl DesktopStorage {
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
let config = Config::load(explicit)
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
Ok(Self {
config,
keys: Arc::new(OnceLock::new()),
})
}
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
Self::load(None)?.bootstrap()
}
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
let keys = self.keys()?;
let handle = keys
.resolve(self.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let key = keys
.infos()
.find(|key| key.fingerprint() == handle.fingerprint())
.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::MissingDefaultKey,
"the configured GPG key is unavailable",
)
})?;
let authentication = NativeAuthenticationSession::system(
SecretProtectionPolicy::default(),
self.config.authentication_timeout(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
Ok(DesktopBootstrap {
storage: self,
authentication,
key,
})
}
pub fn clipboard_timeout(&self) -> ClipboardTimeout {
self.config.clipboard_timeout()
}
pub fn authentication_timeout(&self) -> AuthenticationTimeout {
self.config.authentication_timeout()
}
pub fn vault(&self) -> &Path {
self.config.vault()
}
pub fn config_source(&self) -> &Path {
self.config.source()
}
pub fn default_key(&self) -> &str {
self.config.default_key().as_str()
}
pub fn configured_editor(&self) -> Option<&EditorCommand> {
self.config.configured_editor()
}
pub fn settings(&self) -> ConfigSettings {
self.config.settings()
}
pub fn update_settings(&self, settings: ConfigSettings) -> Result<Self, DesktopError> {
let storage = self.validate_settings(settings)?;
storage.persist()?;
Ok(storage)
}
pub fn update_settings_and_bootstrap(
&self,
settings: ConfigSettings,
) -> Result<DesktopBootstrap, DesktopError> {
let bootstrap = self.validate_settings(settings)?.bootstrap()?;
bootstrap.storage.persist()?;
Ok(bootstrap)
}
fn validate_settings(&self, mut settings: ConfigSettings) -> Result<Self, DesktopError> {
let repository = Repository::open(settings.vault())
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
settings.set_vault(repository.root_path().to_owned());
let config = self
.config
.with_settings(settings)
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
let storage = Self {
config,
keys: Arc::clone(&self.keys),
};
let keys = storage.keys()?;
keys.resolve(storage.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
storage.tree()?;
Ok(storage)
}
fn persist(&self) -> Result<(), DesktopError> {
self.config
.persist()
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))
}
/// Validate a selected folder through the same repository and key path as
/// normal desktop reads, then atomically update the shared configuration.
pub fn switch_vault(&self, vault: &Path) -> Result<Self, DesktopError> {
let mut settings = self.settings();
settings.set_vault(vault.to_owned());
self.update_settings(settings)
}
pub fn tree(&self) -> Result<TreeModel, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, keys)
.list(&DirectoryPath::root())
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn git_operation(
&self,
handle: Option<&NativeAuthenticationHandle>,
request: &DesktopGitRequest,
control: &GitOperationControl,
) -> Result<DesktopGitResult, DesktopError> {
self.git_operation_with_ssh_passphrase(handle, request, control, None)
}
pub fn git_operation_with_ssh_passphrase(
&self,
handle: Option<&NativeAuthenticationHandle>,
request: &DesktopGitRequest,
control: &GitOperationControl,
ssh_passphrase: Option<(&crate::config::SshFingerprint, &SecretBytes)>,
) -> Result<DesktopGitResult, DesktopError> {
control
.report(GitProgressPhase::Validating)
.map_err(DesktopError::git)?;
let repository = self.repository()?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
.map_err(DesktopError::git)?;
let configured = || {
self.config.git_remote(None).ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Configuration,
"no Git remote is configured",
)
})
};
let authenticated = || {
let handle = handle.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Authentication,
"authentication is required for Git credentials",
)
})?;
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
Ok(handle)
};
let (outcome, changed_tree) = match request {
DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false),
DesktopGitRequest::Pull => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let outcome = git
.pull_controlled(configured()?, None, &credentials, control)
.map_err(DesktopError::git)?;
(DesktopGitOutcome::Pulled(outcome), true)
}
DesktopGitRequest::Push => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let outcome = git
.push_controlled(configured()?, None, &credentials, control)
.map_err(DesktopError::git)?;
(DesktopGitOutcome::Pushed(outcome), false)
}
DesktopGitRequest::Sync => {
let handle = authenticated()?;
let credentials = ssh_passphrase.map_or_else(
|| GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
);
let (pull, push) = git
.sync_controlled(configured()?, &credentials, control)
.map_err(DesktopError::git)?;
(DesktopGitOutcome::Synchronized { pull, push }, true)
}
DesktopGitRequest::Resolve(resolutions) => {
let _handle = authenticated()?;
control
.report(GitProgressPhase::Integrating)
.map_err(DesktopError::git)?;
let outcome = git
.resolve_fetched(configured()?, None, resolutions)
.map_err(DesktopError::git)?;
(DesktopGitOutcome::Resolved(outcome), true)
}
};
let snapshot = git
.snapshot(self.config.git_remote(None), 10)
.map_err(DesktopError::git)?;
let tree = changed_tree.then(|| self.tree()).transpose()?;
Ok(DesktopGitResult {
outcome,
snapshot,
tree,
})
}
#[cfg(feature = "ssh")]
pub fn confirm_ssh_host(&self, host_key: &crate::git::SshHostKey) -> Result<(), DesktopError> {
let remote = self.config.git_remote(None).ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Configuration,
"no Git remote is configured",
)
})?;
crate::git::confirm_ssh_host(remote, host_key).map_err(DesktopError::git)
}
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, keys)
.find(&request.terms)
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn grep_active(
&self,
handle: &NativeAuthenticationHandle,
request: &GrepRequest,
) -> Result<GrepResults, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.grep(request, &mut provider)
}
pub fn grep(
&self,
request: &GrepRequest,
provider: &mut impl SecretProvider,
) -> Result<GrepResults, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, keys)
.grep(request, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn import_kdbx_active(
&self,
handle: &NativeAuthenticationHandle,
request: &KdbxImportRequest,
password: SecretBytes,
) -> Result<(KdbxImportOutcome, TreeModel), DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let repository = self.repository()?;
let keys = self.keys()?;
let mut provider = handle.clone();
let outcome = KdbxImporter::new(&repository, keys)
.import(request, password, &mut provider, GitIdentity::ironstorage())
.map_err(|error| DesktopError::new(DesktopErrorKind::Import, error))?;
let tree = VaultReader::new(&repository, keys)
.list(&DirectoryPath::root())
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))?;
Ok((outcome, tree))
}
pub fn otp_code_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
unix_seconds: u64,
confirm_hotp: bool,
) -> Result<DesktopOtpCode, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.otp_code(entry, unix_seconds, confirm_hotp, &mut provider)
}
pub fn otp_code(
&self,
entry: &str,
unix_seconds: u64,
confirm_hotp: bool,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpCode, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let service = OtpService::new(&repository, keys);
let uri = service
.uri(entry, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
if uri.kind() == OtpKind::Hotp && !confirm_hotp {
return Err(DesktopError::new(
DesktopErrorKind::Otp,
"HOTP generation requires explicit confirmation because it commits the advanced counter",
));
}
let metadata = DesktopOtpMetadata {
kind: uri.kind(),
issuer: uri.issuer().map(str::to_owned),
account: uri.account().to_owned(),
algorithm: uri.algorithm(),
digits: uri.digits(),
};
let outcome = service
.code_automatic(entry, unix_seconds, None, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let validity = outcome.validity();
let changed = validity.counter().is_some();
let tree = changed.then(|| self.tree()).transpose()?;
let document = changed
.then(|| {
EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)
})
.transpose()?;
Ok(DesktopOtpCode {
code: SecretBytes::new(outcome.code().expose().to_vec()),
validity,
metadata,
tree,
document,
})
}
pub fn otp_uri_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
qr: bool,
) -> Result<DesktopOtpUri, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.otp_uri(entry, qr, &mut provider)
}
pub fn otp_uri(
&self,
entry: &str,
qr: bool,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpUri, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let uri = OtpService::new(&repository, keys)
.uri(entry, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let payload = SecretBytes::new(uri.encoded().expose().to_vec());
let matrix = qr
.then(|| QrMatrix::encode(&payload))
.transpose()
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
Ok(DesktopOtpUri { payload, matrix })
}
pub fn import_otp_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
uri: SecretBytes,
replace: OverwriteDecision,
) -> Result<DesktopOtpMutation, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.import_otp(entry, uri, replace, &mut provider)
}
pub fn import_otp_qr_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
image: SecretBytes,
replace: OverwriteDecision,
) -> Result<DesktopOtpMutation, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.import_otp_qr(entry, image, replace, &mut provider)
}
pub fn import_otp_qr(
&self,
entry: &str,
image: SecretBytes,
replace: OverwriteDecision,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpMutation, DesktopError> {
let uri = QrMatrix::decode_image(&image)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
self.import_otp(entry, uri, replace, provider)
}
pub fn import_otp(
&self,
entry: &str,
uri: SecretBytes,
replace: OverwriteDecision,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpMutation, DesktopError> {
let parsed =
OtpUri::parse(uri).map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let encoded = parsed.encoded().expose().to_vec();
let repository = self.repository()?;
let keys = self.keys()?;
let exists = VaultWriter::new(&repository, keys)
.entry_exists(entry)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
if exists {
let mut document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
if let Some(field) = otp_field(&document)? {
if replace == OverwriteDecision::Decline {
return Err(DesktopError::new(
DesktopErrorKind::EntryExists,
format!("entry already contains an OTP URI: {entry}"),
));
}
document
.replace_field_value(field, encoded.clone())
.map_err(DesktopError::document)?;
} else {
let index = document.fields().len();
document
.add(
index,
EntryFieldDraft::otp_uri(encoded.clone())
.map_err(DesktopError::document)?,
)
.map_err(DesktopError::document)?;
}
self.save_document(&document)?;
let document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
return Ok(DesktopOtpMutation {
entry: entry.to_owned(),
document,
tree: self.tree()?,
});
}
let request = OtpInsertRequest {
entry: Some(entry.to_owned()),
force: false,
echo: true,
source: OtpInputSource::Uri,
};
let input = OtpInput::line(encoded)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let service = OtpService::new(&repository, keys);
let plan = service
.prepare_insert(&request, input)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, entry, GitIdentity::ironstorage())
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
service
.finish_insert(
plan,
OverwriteDecision::Allow,
OverwriteDecision::Decline,
None,
&mut committer,
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
Ok(DesktopOtpMutation {
entry: entry.to_owned(),
document,
tree: self.tree()?,
})
}
pub fn remove_otp_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
) -> Result<DesktopOtpMutation, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.remove_otp(entry, &mut provider)
}
pub fn remove_otp(
&self,
entry: &str,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpMutation, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let mut document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
let field = otp_field(&document)?.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Otp,
format!("entry does not contain an OTP URI: {entry}"),
)
})?;
document.remove(field).map_err(DesktopError::document)?;
self.save_document(&document)?;
let document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
Ok(DesktopOtpMutation {
entry: entry.to_owned(),
document,
tree: self.tree()?,
})
}
pub fn mutate_active(
&self,
handle: &NativeAuthenticationHandle,
request: &DesktopMutationRequest,
overwrite: OverwriteDecision,
) -> Result<MutationOutcome, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.mutate(request, overwrite, &mut provider)
}
pub fn mutate(
&self,
request: &DesktopMutationRequest,
overwrite: OverwriteDecision,
provider: &mut impl SecretProvider,
) -> Result<MutationOutcome, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let mut committer = AutomaticTreeCommitter::for_source(
&repository,
request.source(),
GitIdentity::ironstorage(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
let mutator = TreeMutator::new(&repository, keys);
match request {
DesktopMutationRequest::Remove(request) => {
mutator.remove(request, overwrite, &mut committer)
}
DesktopMutationRequest::Move(request) => {
mutator.move_tree(request, overwrite, None, provider, &mut committer)
}
DesktopMutationRequest::Copy(request) => {
mutator.copy(request, overwrite, None, provider, &mut committer)
}
}
.map_err(|error| DesktopError::new(DesktopErrorKind::Mutation, error))
}
/// Enumerate storage-validated encryption keys for recipient selection.
pub fn key_infos(&self) -> Result<Vec<KeyInfo>, DesktopError> {
Ok(self.keys()?.infos().filter(KeyInfo::can_encrypt).collect())
}
/// Apply an upstream-compatible root or nested `.gpg-id` policy while the
/// shared authentication lease is active.
pub fn apply_active_recipient_policy(
&self,
handle: &NativeAuthenticationHandle,
request: &InitRequest,
default_key: &str,
) -> Result<(Self, RecipientPolicyOutcome), DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.apply_recipient_policy(request, default_key, &mut provider)
}
pub fn apply_recipient_policy(
&self,
request: &InitRequest,
default_key: &str,
provider: &mut impl SecretProvider,
) -> Result<(Self, RecipientPolicyOutcome), DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let default = keys
.infos()
.find(|key| key.fingerprint().as_str() == default_key)
.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::MissingDefaultKey,
"the selected default GPG key is unavailable",
)
})?;
if !default.has_secret() {
return Err(DesktopError::new(
DesktopErrorKind::MissingDefaultKey,
"the selected default GPG key has no secret key material",
));
}
let mut settings = self.settings();
settings.set_default_key(default_key.to_owned());
let replacement = self.validate_settings(settings)?;
let directory = request.path.as_deref().unwrap_or_default();
let committer = AutomaticPolicyCommitter::for_directory(
&repository,
directory,
GitIdentity::ironstorage(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
let mut committer = ConfigPolicyCommitter {
git: committer,
previous: &self.config,
replacement: &replacement.config,
persisted: false,
};
let outcome = RecipientPolicyManager::new(&repository, keys)
.apply_init(request, None, provider, &mut committer)
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
if !committer.persisted {
replacement.persist()?;
}
Ok((replacement, outcome))
}
/// Open a collision-free in-memory draft. No repository bytes are written
/// until the structured document is explicitly saved.
pub fn create_active_document(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
) -> Result<EntryDocument, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.create_document(entry, &mut provider)
}
pub fn create_document(
&self,
entry: &str,
provider: &mut impl SecretProvider,
) -> Result<EntryDocument, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
if VaultWriter::new(&repository, keys)
.entry_exists(entry)
.map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))?
{
return Err(DesktopError::new(
DesktopErrorKind::EntryExists,
format!("password-store entry already exists: {entry}"),
));
}
EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)
}
pub fn open_document(
&self,
entry: &str,
secrets: &mut impl SecretProvider,
) -> Result<EntryDocument, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
EntryDocumentService::new(&repository, keys)
.open(entry, secrets)
.map_err(DesktopError::document)
}
pub fn save_document(&self, document: &EntryDocument) -> Result<WriteOutcome, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let entry = document.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage())
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
EntryDocumentService::new(&repository, keys)
.save_recoverable(document, None, &mut committer)
.map_err(DesktopError::document)
}
pub fn save_active_document(
&self,
handle: &NativeAuthenticationHandle,
document: &EntryDocument,
) -> Result<WriteOutcome, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
self.save_document(document)
}
fn repository(&self) -> Result<Repository, DesktopError> {
Repository::open(self.config.vault())
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))
}
fn keys(&self) -> Result<&KeyStore, DesktopError> {
if let Some(keys) = self.keys.get() {
return Ok(keys);
}
let keys = KeyStore::load(self.config.key_material())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let _already_initialized = self.keys.set(keys);
Ok(self
.keys
.get()
.expect("the key store was initialized by this or another caller"))
}
}
fn otp_field(document: &EntryDocument) -> Result<Option<EntryFieldId>, DesktopError> {
let mut fields = document
.fields()
.iter()
.filter(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
.map(|field| field.id());
let first = fields.next();
if fields.next().is_some() {
return Err(DesktopError::new(
DesktopErrorKind::Otp,
format!("entry contains multiple OTP URIs: {}", document.path()),
));
}
Ok(first)
}
struct ConfigPolicyCommitter<'a> {
git: AutomaticPolicyCommitter,
previous: &'a Config,
replacement: &'a Config,
persisted: bool,
}
impl PolicyCommitter for ConfigPolicyCommitter<'_> {
fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> {
self.replacement
.persist()
.map_err(|error| PolicyCommitError::new(error.to_string()))?;
match self.git.commit(change) {
Ok(()) => {
self.persisted = true;
Ok(())
}
Err(operation) => match self.previous.persist() {
Ok(()) => Err(operation),
Err(rollback) => Err(PolicyCommitError::new(format!(
"recipient-policy commit failed ({operation}) and configuration rollback failed ({rollback})"
))),
},
}
}
}
pub struct DesktopBootstrap {
storage: DesktopStorage,
authentication: NativeAuthenticationSession,
key: KeyInfo,
}
impl DesktopBootstrap {
pub fn into_parts(self) -> (DesktopStorage, NativeAuthenticationSession, KeyInfo) {
(self.storage, self.authentication, self.key)
}
}