631 lines
19 KiB
Rust
631 lines
19 KiB
Rust
//! 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()?;
|
|
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<EffectiveRecipients, RecipientPolicyError> {
|
|
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)
|
|
}
|
|
}
|