Implement hierarchical recipient policies (#5)
This commit is contained in:
409
crates/storage/tests/recipient_policy.rs
Normal file
409
crates/storage/tests/recipient_policy.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user