From 34ce731716ba28307fca457129a3889061769c3c Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 9 Aug 2026 22:12:43 +0000 Subject: [PATCH] Implement hierarchical recipient policies (#5) --- README.md | 3 + crates/storage/src/crypto.rs | 52 ++ crates/storage/src/lib.rs | 1 + crates/storage/src/recipient.rs | 621 +++++++++++++++++++++++ crates/storage/src/repository.rs | 141 +++++ crates/storage/tests/recipient_policy.rs | 409 +++++++++++++++ docs/recipient-policies.md | 57 +++ 7 files changed, 1284 insertions(+) create mode 100644 crates/storage/src/recipient.rs create mode 100644 crates/storage/tests/recipient_policy.rs create mode 100644 docs/recipient-policies.md diff --git a/README.md b/README.md index 8fae5fd..aeeda85 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ documented in [`docs/repository-core.md`](docs/repository-core.md). The embedded OpenPGP backend, exported-key model, secret-provider boundary, and GnuPG compatibility evidence are documented in [`docs/cryptography.md`](docs/cryptography.md). +Hierarchical `.gpg-id` resolution, signed policies, selective reencryption, and +the rollback/commit contract are documented in +[`docs/recipient-policies.md`](docs/recipient-policies.md). ## Project layout diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index ada6dcd..fd991c1 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -346,6 +346,47 @@ impl KeyStore { .map_err(|_| CryptoError::EncryptionFailed) } + /// Report whether every public-key session packet names exactly the requested primary + /// certificates. Unknown, anonymous, symmetric, missing, or extra recipients do not match. + pub fn is_encrypted_for( + &self, + ciphertext: &EncryptedEntry, + recipients: &[KeyHandle], + ) -> Result { + let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes())) + .map_err(|_| CryptoError::CorruptMessage)?; + let Message::Encrypted { esk, .. } = message else { + return Err(CryptoError::CorruptMessage); + }; + let expected = recipients + .iter() + .map(|recipient| { + self.material(recipient)?; + Ok(recipient.0.clone()) + }) + .collect::, CryptoError>>()?; + if expected.is_empty() { + return Ok(false); + } + let mut actual = BTreeSet::new(); + for packet in esk { + let Esk::PublicKeyEncryptedSessionKey(packet) = packet else { + return Ok(false); + }; + let matches = self + .keys + .iter() + .filter(|(_, material)| packet_matches_public(&packet, &material.public)) + .map(|(fingerprint, _)| fingerprint) + .collect::>(); + if matches.len() != 1 { + return Ok(false); + } + actual.insert((*matches[0]).clone()); + } + Ok(actual == expected) + } + /// Decrypt a pass entry with only the secret keys named by its PKESK packets. pub fn decrypt( &self, @@ -760,6 +801,17 @@ fn message_matches_secret(message: &Message<'_>, key: &SignedSecretKey) -> bool }) } +fn packet_matches_public( + packet: &pgp::packet::PublicKeyEncryptedSessionKey, + key: &SignedPublicKey, +) -> bool { + packet.match_identity(&key.primary_key) + || key + .public_subkeys + .iter() + .any(|subkey| packet.match_identity(&subkey.key)) +} + fn sign_data( key: &K, password: &Password, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index fce8359..223f2be 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,6 +8,7 @@ pub mod command; pub mod config; pub mod crypto; +pub mod recipient; pub mod repository; /// Product name shared by the presentation adapters. diff --git a/crates/storage/src/recipient.rs b/crates/storage/src/recipient.rs new file mode 100644 index 0000000..51c56a8 --- /dev/null +++ b/crates/storage/src/recipient.rs @@ -0,0 +1,621 @@ +//! 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()?; + 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) + } +} diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index f989d85..c6a8431 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -327,6 +327,10 @@ impl RepositorySnapshot { } } + pub fn recipient_policy_at(&self, directory: &DirectoryPath) -> Option<&RecipientPolicy> { + self.recipients.get(directory) + } + pub fn git_repository(&self, entry: &EntryPath) -> Option<&GitRepository> { let mut directory = entry.parent(); loop { @@ -409,6 +413,95 @@ impl Repository { Ok(EncryptedEntry(bytes)) } + pub(crate) fn read_policy_file( + &self, + directory: &DirectoryPath, + signature: bool, + ) -> Result>, RepositoryError> { + let opened = match self.open_directory(&directory.0) { + Ok(opened) => opened, + Err(RepositoryError::NotFound { .. }) => return Ok(None), + Err(error) => return Err(error), + }; + let name = if signature { + RECIPIENT_SIGNATURE_FILE + } else { + RECIPIENT_FILE + }; + let path = directory.0.join(name); + let Some(metadata) = child_metadata(&opened, name, &path)? else { + return Ok(None); + }; + require_regular_file(metadata, &path)?; + let mut file = opened + .open(name) + .map_err(|error| io_error("open recipient policy", &path, error))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| io_error("read recipient policy", &path, error))?; + Ok(Some(bytes)) + } + + pub(crate) fn replace_policy_file( + &self, + directory: &DirectoryPath, + signature: bool, + contents: Option<&[u8]>, + ) -> Result<(), RepositoryError> { + let name = if signature { + RECIPIENT_SIGNATURE_FILE + } else { + RECIPIENT_FILE + }; + let path = directory.0.join(name); + let (opened, created) = if contents.is_some() { + self.create_directory_path(directory)? + } else { + match self.open_directory(&directory.0) { + Ok(opened) => (opened, Vec::new()), + Err(RepositoryError::NotFound { .. }) => return Ok(()), + Err(error) => return Err(error), + } + }; + let result = (|| { + if let Some(contents) = contents { + if let Some(metadata) = child_metadata(&opened, name, &path)? { + require_regular_file(metadata, &path)?; + } + let mut temporary = TempFile::new(&opened) + .map_err(|error| io_error("create atomic recipient policy", &path, error))?; + set_private_permissions(&temporary, &path)?; + temporary + .write_all(contents) + .map_err(|error| io_error("write atomic recipient policy", &path, error))?; + temporary + .as_file() + .sync_all() + .map_err(|error| io_error("sync atomic recipient policy", &path, error))?; + temporary + .replace(name) + .map_err(|error| io_error("replace recipient policy", &path, error))?; + sync_directory(&opened, &directory.0) + .map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() }) + } else { + let Some(metadata) = child_metadata(&opened, name, &path)? else { + return Ok(()); + }; + require_regular_file(metadata, &path)?; + opened + .remove_file(name) + .map_err(|error| io_error("remove recipient policy", &path, error))?; + sync_directory(&opened, &directory.0) + .map_err(|_| RepositoryError::DurabilityUncertain { path: path.clone() }) + } + })(); + match result { + Ok(()) => Ok(()), + Err(error) if error.committed() => Err(error), + Err(error) => self.rollback_created(created, error), + } + } + /// Atomically replace an encrypted entry and durably commit its containing directory. pub fn write_entry( &self, @@ -604,6 +697,54 @@ impl Repository { unreachable!("EntryPath has at least one component") } + fn create_directory_path( + &self, + path: &DirectoryPath, + ) -> Result<(Dir, Vec), RepositoryError> { + let mut directory = self + .root + .try_clone() + .map_err(|error| io_error("clone repository root", Path::new("."), error))?; + let mut relative = PathBuf::new(); + let mut created = Vec::new(); + for component in path.0.components() { + let Component::Normal(name) = component else { + unreachable!("DirectoryPath is validated") + }; + if let Err(error) = reject_entry_directory_collision(&directory, name, &relative) { + return self.rollback_created(created, error); + } + relative.push(name); + match child_metadata(&directory, name, &relative) { + Ok(Some(metadata)) => { + if let Err(error) = require_directory(metadata, &relative) { + return self.rollback_created(created, error); + } + } + Ok(None) => { + if let Err(error) = create_private_directory(&directory, name, &relative) { + return self.rollback_created(created, error); + } + created.push(relative.clone()); + if let Err(error) = sync_directory(&directory, &relative) { + return self.rollback_created(created, error); + } + } + Err(error) => return self.rollback_created(created, error), + } + match directory.open_dir(name) { + Ok(opened) => directory = opened, + Err(error) => { + return self.rollback_created( + created, + io_error("open recipient policy directory", &relative, error), + ); + } + } + } + Ok((directory, created)) + } + fn rollback_created( &self, mut created: Vec, diff --git a/crates/storage/tests/recipient_policy.rs b/crates/storage/tests/recipient_policy.rs new file mode 100644 index 0000000..3684ec9 --- /dev/null +++ b/crates/storage/tests/recipient_policy.rs @@ -0,0 +1,409 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +use ironstorage::{ + command::InitRequest, + crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + recipient::{ + NoGitCommitter, PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyError, + RecipientPolicyManager, SigningPolicy, + }, + repository::{EntryPath, Repository, SecretBytes}, +}; +use support::compatibility::{FixtureSet, TestResult}; + +#[derive(Default)] +struct FixtureSecrets { + values: BTreeMap>, +} + +impl FixtureSecrets { + fn all(fixture: &FixtureSet) -> Self { + Self { + values: fixture + .generated + .keys + .iter() + .map(|key| { + ( + key.primary_fingerprint.clone(), + key.passphrase.as_bytes().to_vec(), + ) + }) + .collect(), + } + } +} + +impl SecretProvider for FixtureSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.values + .get(key.fingerprint().as_str()) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretProviderError::Unavailable) + } +} + +#[derive(Default)] +struct RecordingCommitter { + changes: Vec, + fail: bool, +} + +impl PolicyCommitter for RecordingCommitter { + fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { + self.changes.push(change.clone()); + if self.fail { + Err(PolicyCommitError::new("simulated embedded Git failure")) + } else { + Ok(()) + } + } +} + +#[test] +fn resolves_nearest_root_and_nested_recipient_policies() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let alice = fixture.key("alice")?; + let bob = fixture.key("bob")?; + + let root = manager.resolve_for_entry(&EntryPath::parse("email/personal")?, None)?; + assert_eq!(root.policy_directory().as_path(), Path::new("")); + assert_eq!( + root.recipients()[0].fingerprint().as_str(), + alice.primary_fingerprint + ); + + let team = manager.resolve_for_entry(&EntryPath::parse("team/service")?, None)?; + assert_eq!(team.policy_directory().as_path(), Path::new("team")); + assert_eq!( + team.recipients()[0].fingerprint().as_str(), + bob.primary_fingerprint + ); + + let shared = manager.resolve_for_entry(&EntryPath::parse("shared/multiple")?, None)?; + assert_eq!(shared.policy_directory().as_path(), Path::new("shared")); + assert_eq!(shared.recipients().len(), 2); + Ok(()) +} + +#[test] +fn root_init_reencrypts_only_entries_not_shielded_by_overrides() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let bob = fixture.key("bob")?; + let mut provider = FixtureSecrets::all(&fixture); + let mut committer = RecordingCommitter::default(); + + let outcome = manager.apply_init( + &init(None, &[&bob.primary_fingerprint]), + None, + &mut provider, + &mut committer, + )?; + assert_eq!(outcome.reencrypted_entries().len(), 4); + assert_eq!(committer.changes.len(), 1); + assert_eq!( + committer.changes[0].changed_paths(), + vec![ + PathBuf::from(".gpg-id"), + PathBuf::from("email/personal.gpg"), + PathBuf::from("otp/hotp.gpg"), + PathBuf::from("otp/totp.gpg"), + PathBuf::from("unicode/咖啡.gpg"), + ] + ); + assert_eq!( + fs::read(store.path().join(".gpg-id"))?, + format!("{}\n", bob.primary_fingerprint).as_bytes() + ); + + let bob_recipient = [keys.resolve(&bob.primary_fingerprint)?]; + for path in ["email/personal", "otp/hotp", "otp/totp", "unicode/咖啡"] { + assert!(keys.is_encrypted_for( + &repository.read_entry(&EntryPath::parse(path)?)?, + &bob_recipient + )?); + } + let before_commits = committer.changes.len(); + let no_op = manager.apply_init( + &init(None, &[&bob.primary_fingerprint]), + None, + &mut provider, + &mut committer, + )?; + assert!(!no_op.committed()); + assert_eq!(committer.changes.len(), before_commits); + Ok(()) +} + +#[test] +fn nested_multiple_recipients_and_removed_override_preserve_plaintext() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let alice = fixture.key("alice")?; + let bob = fixture.key("bob")?; + let path = EntryPath::parse("team/service")?; + let expected = fixture.read("expected/basic/team/service.txt")?; + let mut provider = FixtureSecrets::all(&fixture); + let mut committer = RecordingCommitter::default(); + + let multiple = manager.apply_init( + &init( + Some("team"), + &[&alice.primary_fingerprint, &bob.primary_fingerprint], + ), + None, + &mut provider, + &mut committer, + )?; + assert_eq!(multiple.reencrypted_entries(), std::slice::from_ref(&path)); + assert_eq!(multiple.recipients().len(), 2); + assert_eq!( + fs::read(store.path().join("team/.gpg-id"))?, + format!( + "{}\n{}\n", + alice.primary_fingerprint, bob.primary_fingerprint + ) + .as_bytes() + ); + let plaintext = keys.decrypt(&repository.read_entry(&path)?, &mut provider)?; + assert_eq!(plaintext.expose(), expected); + + let removed = manager.apply_init( + &init(Some("team"), &[""]), + None, + &mut provider, + &mut committer, + )?; + assert_eq!(removed.reencrypted_entries(), std::slice::from_ref(&path)); + assert!(!store.path().join("team/.gpg-id").exists()); + assert!( + store.path().join("team/.gpg-id.sig").exists(), + "upstream pass leaves an existing detached signature when an override is removed" + ); + assert_eq!( + removed.recipients()[0].fingerprint().as_str(), + alice.primary_fingerprint + ); + let plaintext = keys.decrypt(&repository.read_entry(&path)?, &mut provider)?; + assert_eq!(plaintext.expose(), expected); + Ok(()) +} + +#[test] +fn creates_and_authenticates_signed_recipient_policies() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let alice = fixture.key("alice")?; + let bob = fixture.key("bob")?; + let alice_handle = keys.resolve(&alice.primary_fingerprint)?; + let bob_handle = keys.resolve(&bob.primary_fingerprint)?; + let alice_policy = SigningPolicy::new(alice_handle.clone(), vec![alice_handle.clone()]); + + let resolved = + manager.resolve_for_entry(&EntryPath::parse("email/personal")?, Some(&alice_policy))?; + assert_eq!(resolved.policy_directory().as_path(), Path::new("")); + + let before = tree_bytes(store.path())?; + let wrong_trust = SigningPolicy::new(alice_handle, vec![bob_handle]); + let mut provider = FixtureSecrets::all(&fixture); + let error = manager + .apply_init( + &init(None, &[&bob.primary_fingerprint]), + Some(&wrong_trust), + &mut provider, + &mut NoGitCommitter, + ) + .unwrap_err(); + assert!(matches!( + error, + RecipientPolicyError::Crypto(CryptoError::InvalidSignature) + )); + assert_eq!(tree_bytes(store.path())?, before); + + let alice_handle = keys.resolve(&alice.primary_fingerprint)?; + let signing = SigningPolicy::new(alice_handle.clone(), vec![alice_handle]); + manager.apply_init( + &init(None, &[&bob.primary_fingerprint]), + Some(&signing), + &mut provider, + &mut NoGitCommitter, + )?; + assert!(store.path().join(".gpg-id.sig").is_file()); + manager.resolve_for_entry(&EntryPath::parse("email/personal")?, Some(&signing))?; + Ok(()) +} + +#[test] +fn invalid_recipients_and_commit_failure_leave_the_tree_byte_exact() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let bob = fixture.key("bob")?; + let before = tree_bytes(store.path())?; + let mut provider = FixtureSecrets::all(&fixture); + let mut committer = RecordingCommitter::default(); + + assert!(matches!( + manager.apply_init( + &init(None, &["missing@ironstorage.invalid"]), + None, + &mut provider, + &mut committer, + ), + Err(RecipientPolicyError::Crypto( + CryptoError::MissingIdentity { .. } + )) + )); + assert_eq!(tree_bytes(store.path())?, before); + assert!(committer.changes.is_empty()); + + assert!(matches!( + manager.apply_init(&init(None, &[""]), None, &mut provider, &mut committer,), + Err(RecipientPolicyError::MissingRecipientPolicy { .. }) + )); + assert_eq!(tree_bytes(store.path())?, before); + + committer.fail = true; + assert!(matches!( + manager.apply_init( + &init(None, &[&bob.primary_fingerprint]), + None, + &mut provider, + &mut committer, + ), + Err(RecipientPolicyError::Commit(_)) + )); + assert_eq!(tree_bytes(store.path())?, before); + Ok(()) +} + +#[test] +fn empty_store_supports_root_and_path_init_and_removal() -> TestResult { + let fixture = FixtureSet::load()?; + let temporary = tempfile::tempdir()?; + let repository = Repository::open(temporary.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let alice = fixture.key("alice")?; + let bob = fixture.key("bob")?; + let mut provider = FixtureSecrets::all(&fixture); + let mut committer = RecordingCommitter::default(); + + manager.apply_init( + &init( + None, + &[&alice.primary_fingerprint, &bob.primary_fingerprint], + ), + None, + &mut provider, + &mut committer, + )?; + manager.apply_init( + &init(Some("clients/acme"), &[&bob.primary_fingerprint]), + None, + &mut provider, + &mut committer, + )?; + assert!(temporary.path().join("clients/acme/.gpg-id").is_file()); + + manager.apply_init( + &init(Some("clients/acme"), &[""]), + None, + &mut provider, + &mut committer, + )?; + assert!(!temporary.path().join("clients").exists()); + assert!(matches!( + manager.apply_init( + &init(Some("clients/acme"), &[""]), + None, + &mut provider, + &mut committer, + ), + Err(RecipientPolicyError::PolicyNotFound { .. }) + )); + Ok(()) +} + +#[test] +fn failed_commit_removes_new_empty_policy_directories() -> TestResult { + let fixture = FixtureSet::load()?; + let temporary = tempfile::tempdir()?; + let repository = Repository::open(temporary.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let manager = RecipientPolicyManager::new(&repository, &keys); + let alice = fixture.key("alice")?; + let mut provider = FixtureSecrets::all(&fixture); + let mut committer = RecordingCommitter { + fail: true, + ..RecordingCommitter::default() + }; + + assert!(matches!( + manager.apply_init( + &init(Some("new/deep"), &[&alice.primary_fingerprint]), + None, + &mut provider, + &mut committer, + ), + Err(RecipientPolicyError::Commit(_)) + )); + assert!(fs::read_dir(temporary.path())?.next().is_none()); + Ok(()) +} + +fn init(path: Option<&str>, identities: &[&str]) -> InitRequest { + InitRequest { + path: path.map(str::to_owned), + key_identities: identities + .iter() + .map(|identity| (*identity).to_owned()) + .collect(), + } +} + +fn tree_bytes(root: &Path) -> TestResult>> { + fn visit(root: &Path, current: &Path, files: &mut BTreeMap>) -> TestResult { + let mut entries = fs::read_dir(current)?.collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let kind = entry.file_type()?; + if kind.is_dir() { + visit(root, &path, files)?; + } else if kind.is_file() { + files.insert(path.strip_prefix(root)?.to_owned(), fs::read(path)?); + } + } + Ok(()) + } + + let mut files = BTreeMap::new(); + visit(root, root, &mut files)?; + Ok(files) +} diff --git a/docs/recipient-policies.md b/docs/recipient-policies.md new file mode 100644 index 0000000..329ea26 --- /dev/null +++ b/docs/recipient-policies.md @@ -0,0 +1,57 @@ +# Recipient policies and selective reencryption + +`RecipientPolicyManager` implements `pass init` inside `crates/storage`. It +accepts the storage-owned `InitRequest`, so root initialization and `--path` / +`-p` initialization share the same validated relative-path rules as every +other repository operation. One empty identity removes the policy at that +exact directory; any other identity list is written one item per line with a +final newline, matching upstream `pass`. + +## Hierarchy + +An entry inherits the nearest `.gpg-id` in its directory or an ancestor. +Nested files override their parent for the complete nested subtree. Exact +fingerprints, key IDs, and user IDs are resolved by `KeyStore`; comments, +whitespace, duplicates, missing identities, and ambiguous identities use the +same rules as encryption elsewhere in storage. + +Changing a policy considers only entries below that directory which are not +shielded by a deeper override. Removing an override makes those entries inherit +the nearest policy above it. Removal is rejected before mutation if an affected +entry would have no policy. Existing public-key session packets are compared to +the resolved certificate set, so entries already encrypted for exactly the +requested recipients are not needlessly rewritten. + +As upstream does, removing `.gpg-id` does not remove a pre-existing detached +`.gpg-id.sig`; without a configured signing-key policy the orphan is ignored. +Reinitializing with signing enabled replaces it. + +## Authentication + +When `PASSWORD_STORE_SIGNING_KEY` behavior is configured, `SigningPolicy` +contains the key used to create a detached signature and the explicit set of +trusted primary fingerprints. A new `.gpg-id` is signed and immediately +verified against that set before filesystem mutation. Inherited policies must +have a signature which verifies against the same trust set. Missing, malformed, +untrusted, and cryptographically invalid signatures are distinct pre-mutation +failures. + +## Transaction and Git boundary + +Resolution, signature work, decryption, and replacement encryption all finish +in memory before the first repository write. Every affected entry retains its +original encrypted bytes. Repository writes use private, synced temporary files +and atomic per-file replacement. If any write or the storage commit hook fails, +the policy, signature, and every entry are restored in reverse order; new empty +policy directories are removed. A rollback failure is surfaced separately from +the original operation error. + +`PolicyCommitter` is the integration boundary for the embedded Git engine. It +receives the exact changed paths and compatible commit intent after repository +bytes have been installed. It is called once for a real change, never for a +no-op, and must restore any staging state before returning an error. The policy +manager then restores repository bytes. `NoGitCommitter` represents a path not +contained in a Git work tree; it is not used for a discovered repository. + +The later embedded-Git implementation owns concrete staging and commits, while +this module owns the all-or-nothing storage mutation contract it invokes.