410 lines
13 KiB
Rust
410 lines
13 KiB
Rust
#![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)
|
|
}
|