1122 lines
37 KiB
Rust
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)
|
|
}
|
|
}
|