Implement hierarchical recipient policies (#5)

This commit is contained in:
Hermes Agent
2026-08-09 22:12:43 +00:00
parent 08bc244a4a
commit 34ce731716
7 changed files with 1284 additions and 0 deletions

View File

@@ -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 The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
GnuPG compatibility evidence are documented in GnuPG compatibility evidence are documented in
[`docs/cryptography.md`](docs/cryptography.md). [`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 ## Project layout

View File

@@ -346,6 +346,47 @@ impl KeyStore {
.map_err(|_| CryptoError::EncryptionFailed) .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<bool, CryptoError> {
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::<Result<BTreeSet<_>, 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::<Vec<_>>();
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. /// Decrypt a pass entry with only the secret keys named by its PKESK packets.
pub fn decrypt( pub fn decrypt(
&self, &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<K: SigningKey>( fn sign_data<K: SigningKey>(
key: &K, key: &K,
password: &Password, password: &Password,

View File

@@ -8,6 +8,7 @@
pub mod command; pub mod command;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod recipient;
pub mod repository; pub mod repository;
/// Product name shared by the presentation adapters. /// Product name shared by the presentation adapters.

View File

@@ -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<KeyHandle>,
}
impl SigningPolicy {
pub fn new(signer: KeyHandle, trusted_signers: Vec<KeyHandle>) -> 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<EntryPath>,
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<PathBuf> {
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::<Vec<_>>();
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<String>) -> 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<KeyHandle>,
}
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<KeyHandle>,
reencrypted_entries: Vec<EntryPath>,
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<EffectiveRecipients, RecipientPolicyError> {
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<RecipientPolicyOutcome, RecipientPolicyError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<Vec<KeyHandle>, 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<T>(
&self,
operation: RecipientPolicyError,
directory: &DirectoryPath,
entries: &[EntryMutation],
original_policy: Option<&[u8]>,
original_signature: Option<&[u8]>,
policy_changed: bool,
signature_changed: bool,
) -> Result<T, RecipientPolicyError> {
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<u8> {
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<DirectoryPath> {
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<RecipientPolicyError>,
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<RepositoryError> for RecipientPolicyError {
fn from(error: RepositoryError) -> Self {
Self::Repository(error)
}
}
impl From<CryptoError> for RecipientPolicyError {
fn from(error: CryptoError) -> Self {
Self::Crypto(error)
}
}

View File

@@ -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> { pub fn git_repository(&self, entry: &EntryPath) -> Option<&GitRepository> {
let mut directory = entry.parent(); let mut directory = entry.parent();
loop { loop {
@@ -409,6 +413,95 @@ impl Repository {
Ok(EncryptedEntry(bytes)) Ok(EncryptedEntry(bytes))
} }
pub(crate) fn read_policy_file(
&self,
directory: &DirectoryPath,
signature: bool,
) -> Result<Option<Vec<u8>>, 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. /// Atomically replace an encrypted entry and durably commit its containing directory.
pub fn write_entry( pub fn write_entry(
&self, &self,
@@ -604,6 +697,54 @@ impl Repository {
unreachable!("EntryPath has at least one component") unreachable!("EntryPath has at least one component")
} }
fn create_directory_path(
&self,
path: &DirectoryPath,
) -> Result<(Dir, Vec<PathBuf>), 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<T>( fn rollback_created<T>(
&self, &self,
mut created: Vec<PathBuf>, mut created: Vec<PathBuf>,

View File

@@ -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<String, Vec<u8>>,
}
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<SecretBytes, SecretProviderError> {
self.values
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
#[derive(Default)]
struct RecordingCommitter {
changes: Vec<PolicyCommit>,
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<BTreeMap<PathBuf, Vec<u8>>> {
fn visit(root: &Path, current: &Path, files: &mut BTreeMap<PathBuf, Vec<u8>>) -> TestResult {
let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
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)
}

View File

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