//! Hierarchical `.gpg-id` policy and rollback-safe selective reencryption. use std::{ error::Error, fmt, path::{Path, PathBuf}, }; use crate::{ command::InitRequest, crypto::{CryptoError, DetachedSignatureBytes, KeyHandle, KeyStore, SecretProvider}, repository::{ DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, RepositorySnapshot, }, }; #[derive(Clone, Debug, Eq, PartialEq)] pub struct SigningPolicy { signer: KeyHandle, trusted_signers: Vec, } impl SigningPolicy { pub fn new(signer: KeyHandle, trusted_signers: Vec) -> Self { Self { signer, trusted_signers, } } pub fn signer(&self) -> &KeyHandle { &self.signer } pub fn trusted_signers(&self) -> &[KeyHandle] { &self.trusted_signers } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PolicyAction { Set, Remove, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct PolicyCommit { directory: DirectoryPath, action: PolicyAction, changed_entries: Vec, policy_changed: bool, signature_changed: bool, message: String, } impl PolicyCommit { pub fn directory(&self) -> &DirectoryPath { &self.directory } pub fn action(&self) -> PolicyAction { self.action } pub fn changed_entries(&self) -> &[EntryPath] { &self.changed_entries } pub fn policy_changed(&self) -> bool { self.policy_changed } pub fn signature_changed(&self) -> bool { self.signature_changed } pub fn message(&self) -> &str { &self.message } pub fn changed_paths(&self) -> Vec { let mut paths = self .changed_entries .iter() .map(|entry| { let mut path = entry.as_path().to_owned(); let mut name = path .file_name() .expect("validated entry has a file name") .to_os_string(); name.push(".gpg"); path.set_file_name(name); path }) .collect::>(); if self.policy_changed { paths.push(self.directory.as_path().join(".gpg-id")); } if self.signature_changed { paths.push(self.directory.as_path().join(".gpg-id.sig")); } paths.sort(); paths } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct PolicyCommitError { message: String, } impl PolicyCommitError { pub fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl fmt::Display for PolicyCommitError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message) } } impl Error for PolicyCommitError {} /// Boundary implemented by embedded Git support. Returning an error promises that the /// committer also restored any staging state it changed; repository bytes are restored here. pub trait PolicyCommitter { fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError>; } /// Use when the affected path is not inside a Git repository. #[derive(Default)] pub struct NoGitCommitter; impl PolicyCommitter for NoGitCommitter { fn commit(&mut self, _change: &PolicyCommit) -> Result<(), PolicyCommitError> { Ok(()) } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct EffectiveRecipients { policy_directory: DirectoryPath, recipients: Vec, } impl EffectiveRecipients { pub fn policy_directory(&self) -> &DirectoryPath { &self.policy_directory } pub fn recipients(&self) -> &[KeyHandle] { &self.recipients } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RecipientPolicyOutcome { directory: DirectoryPath, action: PolicyAction, recipients: Vec, reencrypted_entries: Vec, committed: bool, } impl RecipientPolicyOutcome { pub fn directory(&self) -> &DirectoryPath { &self.directory } pub fn action(&self) -> PolicyAction { self.action } pub fn recipients(&self) -> &[KeyHandle] { &self.recipients } pub fn reencrypted_entries(&self) -> &[EntryPath] { &self.reencrypted_entries } pub fn committed(&self) -> bool { self.committed } } pub struct RecipientPolicyManager<'a> { repository: &'a Repository, keys: &'a KeyStore, } impl<'a> RecipientPolicyManager<'a> { pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { Self { repository, keys } } /// Resolve and, when configured, authenticate the nearest ancestor policy for an entry. pub fn resolve_for_entry( &self, entry: &EntryPath, signing: Option<&SigningPolicy>, ) -> Result { let snapshot = self.repository.snapshot()?; self.resolve_for_entry_in_snapshot(&snapshot, entry, signing) } pub(crate) fn resolve_for_entry_in_snapshot( &self, snapshot: &RepositorySnapshot, entry: &EntryPath, signing: Option<&SigningPolicy>, ) -> Result { let policy = snapshot.recipient_policy(entry).ok_or_else(|| { RecipientPolicyError::MissingRecipientPolicy { entry: entry.clone(), } })?; let recipients = self.load_policy(policy.directory(), signing)?; Ok(EffectiveRecipients { policy_directory: policy.directory().clone(), recipients, }) } /// Apply `pass init`, including an empty single identity for policy removal. pub fn apply_init( &self, request: &InitRequest, signing: Option<&SigningPolicy>, provider: &mut impl SecretProvider, committer: &mut impl PolicyCommitter, ) -> Result { let directory = DirectoryPath::parse(request.path.as_deref().unwrap_or_default())?; if request.key_identities.is_empty() { return Err(RecipientPolicyError::MissingIdentities); } let remove = request.key_identities.len() == 1 && request.key_identities[0].is_empty(); let action = if remove { PolicyAction::Remove } else { PolicyAction::Set }; let snapshot = self.repository.snapshot()?; let original_policy = self.repository.read_policy_file(&directory, false)?; if remove && original_policy.is_none() { return Err(RecipientPolicyError::PolicyNotFound { directory: directory.clone(), }); } let original_signature = self.repository.read_policy_file(&directory, true)?; let (replacement_policy, replacement_signature, requested_recipients) = if remove { (None, original_signature.clone(), Vec::new()) } else { let contents = serialize_identities(&request.key_identities); let recipients = self.keys.resolve_recipients(&contents)?; let signature = if let Some(signing) = signing { let signature = self.keys.sign(&contents, signing.signer(), provider)?; self.keys .verify(&contents, &signature, signing.trusted_signers())?; Some(signature.into_bytes()) } else { original_signature.clone() }; (Some(contents), signature, recipients) }; let controlled_entries = snapshot .entries() .filter(|record| entry_is_controlled_by(record.path(), &directory, &snapshot)) .collect::>(); let inherited = if remove && !controlled_entries.is_empty() { nearest_parent_policy(&snapshot, &directory) .map(|policy_directory| { self.load_policy(&policy_directory, signing) .map(|recipients| (policy_directory, recipients)) }) .transpose()? } else { None }; let mut entries = Vec::new(); for record in controlled_entries { let recipients = if remove { inherited .as_ref() .map(|(_, recipients)| recipients) .ok_or_else(|| RecipientPolicyError::MissingRecipientPolicy { entry: record.path().clone(), })? } else { &requested_recipients }; let original = self.repository.read_entry(record.path())?; if self.keys.is_encrypted_for(&original, recipients)? { continue; } let plaintext = self.keys.decrypt(&original, provider)?; let replacement = self.keys.encrypt(plaintext, recipients)?; entries.push(EntryMutation { path: record.path().clone(), original, replacement, }); } let policy_changed = original_policy != replacement_policy; let signature_changed = original_signature != replacement_signature; let changed_entries = entries .iter() .map(|entry| entry.path.clone()) .collect::>(); let change = PolicyCommit { directory: directory.clone(), action, changed_entries: changed_entries.clone(), policy_changed, signature_changed, message: commit_message(action, &directory, &request.key_identities), }; if !policy_changed && !signature_changed && entries.is_empty() { return Ok(RecipientPolicyOutcome { directory, action, recipients: requested_recipients, reencrypted_entries: Vec::new(), committed: false, }); } let applied = self.apply_files( &directory, &entries, policy_changed .then_some(replacement_policy.as_deref()) .flatten(), policy_changed && replacement_policy.is_none(), signature_changed .then_some(replacement_signature.as_deref()) .flatten(), signature_changed && replacement_signature.is_none(), ); if let Err(operation) = applied { return self.rollback_error( operation.into(), &directory, &entries, original_policy.as_deref(), original_signature.as_deref(), policy_changed, signature_changed, ); } if remove && let Err(operation) = self.repository.cleanup_empty_directories(&directory) { return self.rollback_error( operation.into(), &directory, &entries, original_policy.as_deref(), original_signature.as_deref(), policy_changed, signature_changed, ); } if let Err(error) = committer.commit(&change) { return self.rollback_error( RecipientPolicyError::Commit(error), &directory, &entries, original_policy.as_deref(), original_signature.as_deref(), policy_changed, signature_changed, ); } Ok(RecipientPolicyOutcome { directory, action, recipients: if remove { inherited.map_or_else(Vec::new, |(_, recipients)| recipients) } else { requested_recipients }, reencrypted_entries: changed_entries, committed: true, }) } fn load_policy( &self, directory: &DirectoryPath, signing: Option<&SigningPolicy>, ) -> Result, RecipientPolicyError> { let contents = self .repository .read_policy_file(directory, false)? .ok_or_else(|| RecipientPolicyError::PolicyNotFound { directory: directory.clone(), })?; if let Some(signing) = signing { let signature = self .repository .read_policy_file(directory, true)? .ok_or_else(|| RecipientPolicyError::MissingSignature { directory: directory.clone(), })?; self.keys.verify( &contents, &DetachedSignatureBytes::new(signature), signing.trusted_signers(), )?; } Ok(self.keys.resolve_recipients(&contents)?) } fn apply_files( &self, directory: &DirectoryPath, entries: &[EntryMutation], policy: Option<&[u8]>, remove_policy: bool, signature: Option<&[u8]>, remove_signature: bool, ) -> Result<(), RepositoryError> { for entry in entries { self.repository .write_entry(&entry.path, &entry.replacement)?; } if policy.is_some() || remove_policy { self.repository .replace_policy_file(directory, false, policy)?; } if signature.is_some() || remove_signature { self.repository .replace_policy_file(directory, true, signature)?; } Ok(()) } #[allow(clippy::too_many_arguments)] fn rollback_error( &self, operation: RecipientPolicyError, directory: &DirectoryPath, entries: &[EntryMutation], original_policy: Option<&[u8]>, original_signature: Option<&[u8]>, policy_changed: bool, signature_changed: bool, ) -> Result { let mut rollback = None; if signature_changed && let Err(error) = self.repository .replace_policy_file(directory, true, original_signature) { rollback.get_or_insert(error); } if policy_changed && let Err(error) = self.repository .replace_policy_file(directory, false, original_policy) { rollback.get_or_insert(error); } for entry in entries.iter().rev() { if let Err(error) = self.repository.write_entry(&entry.path, &entry.original) { rollback.get_or_insert(error); } } if original_policy.is_none() { let _ = self.repository.cleanup_empty_directories(directory); } match rollback { Some(rollback) => Err(RecipientPolicyError::RollbackFailed { operation: Box::new(operation), rollback, }), None => Err(operation), } } } struct EntryMutation { path: EntryPath, original: EncryptedEntry, replacement: EncryptedEntry, } fn serialize_identities(identities: &[String]) -> Vec { let mut contents = identities.join("\n").into_bytes(); contents.push(b'\n'); contents } fn entry_is_controlled_by( entry: &EntryPath, target: &DirectoryPath, snapshot: &RepositorySnapshot, ) -> bool { let parent = entry.as_path().parent().unwrap_or_else(|| Path::new("")); if !parent.starts_with(target.as_path()) { return false; } !snapshot.recipient_policies().any(|policy| { policy.directory() != target && policy.directory().as_path().starts_with(target.as_path()) && parent.starts_with(policy.directory().as_path()) }) } fn nearest_parent_policy( snapshot: &RepositorySnapshot, directory: &DirectoryPath, ) -> Option { let mut current = directory.as_path().parent(); while let Some(path) = current { let candidate = DirectoryPath::parse(path).expect("ancestor of validated directory"); if snapshot.recipient_policy_at(&candidate).is_some() { return Some(candidate); } if path.as_os_str().is_empty() { break; } current = path.parent(); } None } fn commit_message( action: PolicyAction, directory: &DirectoryPath, identities: &[String], ) -> String { let suffix = if directory.as_path().as_os_str().is_empty() { String::new() } else { format!(" ({directory})") }; match action { PolicyAction::Set => format!( "Set GPG id to {}{suffix} and selectively reencrypt password store.", identities.join(", ") ), PolicyAction::Remove => { format!( "Deinitialize recipient policy{suffix} and selectively reencrypt password store." ) } } } #[derive(Debug)] pub enum RecipientPolicyError { Repository(RepositoryError), Crypto(CryptoError), MissingIdentities, PolicyNotFound { directory: DirectoryPath, }, MissingRecipientPolicy { entry: EntryPath, }, MissingSignature { directory: DirectoryPath, }, Commit(PolicyCommitError), RollbackFailed { operation: Box, rollback: RepositoryError, }, } impl fmt::Display for RecipientPolicyError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Repository(error) => error.fmt(formatter), Self::Crypto(error) => error.fmt(formatter), Self::MissingIdentities => formatter.write_str("at least one GPG identity is required"), Self::PolicyNotFound { directory } => { write!(formatter, "recipient policy does not exist at {directory}") } Self::MissingRecipientPolicy { entry } => { write!(formatter, "no recipient policy applies to entry {entry}") } Self::MissingSignature { directory } => { write!( formatter, "recipient policy at {directory} has no signature" ) } Self::Commit(error) => write!(formatter, "cannot commit recipient policy: {error}"), Self::RollbackFailed { operation, rollback, } => write!( formatter, "recipient policy operation failed ({operation}) and rollback failed ({rollback})" ), } } } impl Error for RecipientPolicyError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Repository(error) => Some(error), Self::Crypto(error) => Some(error), Self::Commit(error) => Some(error), Self::RollbackFailed { operation, .. } => Some(operation), _ => None, } } } impl From for RecipientPolicyError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From for RecipientPolicyError { fn from(error: CryptoError) -> Self { Self::Crypto(error) } }