#![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) }