From c53815405cb34bcd839a09137c5cf78ba53b82a2 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 15:53:34 +0800 Subject: [PATCH 1/3] Make recipient authorization explicit and fail data commands closed on drift Seal and resolve no longer consume recipient drift: seal refuses keylessly before any effect, resolve preserves the ours-side recipient set and reports remaining drift. The new gitveil recipient add/remove commands are the only authorization entry points; they require every effective per-file recipient change to be named on the command line, rejecting injected manifest edits by printing the unexplained recipient. A first seal under a policy additionally requires every existing ciphertext of that policy to be aligned. The manifest publishes before file convergence, so any mid-run failure leaves refusable drift that an idempotent rerun converges. --- README.md | 30 +- docs/getting-started.md | 2 +- scripts/check-functional-core.py | 1 + src/cli.rs | 132 ++++++++- src/configure.rs | 29 +- src/lib.rs | 1 + src/manifest.rs | 60 ++++ src/recipient.rs | 38 +-- src/recipients.rs | 436 +++++++++++++++++++++++++++++ src/recipients/plan.rs | 418 ++++++++++++++++++++++++++++ src/resolve.rs | 67 +++-- src/seal.rs | 148 +++++----- src/seal/plan.rs | 129 ++------- src/status.rs | 16 +- tests/cli_contract.rs | 42 ++- tests/integration_recipients.rs | 456 +++++++++++++++++++++++++++++++ tests/integration_resolve.rs | 169 ++---------- tests/integration_seal_open.rs | 255 ++++++----------- tests/integration_status.rs | 15 +- 19 files changed, 1853 insertions(+), 591 deletions(-) create mode 100644 src/recipients.rs create mode 100644 src/recipients/plan.rs create mode 100644 tests/integration_recipients.rs diff --git a/README.md b/README.md index bf1d880..c80a51c 100644 --- a/README.md +++ b/README.md @@ -147,17 +147,33 @@ Gitveil does not currently provide a managed-entry removal command. Manually rem ## Identity management -Gitveil generates native age identities only when `gitveil identity generate` is explicitly invoked. It does not overwrite, synchronize, distribute, back up, or automatically rotate them. Keep identity files at mode `0600`, back them up securely outside the repository, and have each team member generate an independent identity. Exchange only public recipients, never private keys. The repository stores only public `age1...` recipients. `status` and `verify` remain available without an identity. To add a member, add the member's public recipient to the manifest policy and run `gitveil seal` to rewrap access. +Gitveil generates native age identities only when `gitveil identity generate` is explicitly invoked. It does not overwrite, synchronize, distribute, back up, or automatically rotate them. Keep identity files at mode `0600`, back them up securely outside the repository, and have each team member generate an independent identity. Exchange only public recipients, never private keys. The repository stores only public `age1...` recipients. `status` and `verify` remain available without an identity. -To remove a member, first rotate the actual secret values in plaintext, remove the member's recipient from the manifest policy, and run `gitveil seal`. When Gitveil detects a recipient removal, it encrypts the entire file under a fresh data key and reports `sealed; data key rotated`; neither the new values nor the new key are decryptable by the removed identity. Addition-only changes keep the existing data key and preserve encrypted leaf bytes. +Authorization changes only through the explicit recipient commands. To add a member or machine, name its public recipient on the command line: + +```bash +gitveil recipient add --policy team --recipient age1... +``` + +The command updates the manifest policy and rewraps every affected ciphertext in one step; addition-only changes keep the existing data key and preserve encrypted leaf bytes. To remove a member, first rotate the actual secret values in plaintext and seal them, then revoke the recipient: + +```bash +gitveil recipient remove --policy team --recipient age1... +``` + +A removal re-encrypts every affected file under a fresh data key and reports `data key rotated`; neither later values nor the new key are decryptable by the removed identity. Editing `.gitveilrc.json` by hand grants nothing: the resulting drift blocks `seal` until a recipient command names the difference explicitly, and a recipient the command line does not name is rejected with its full value printed for review. ## Daily usage ```bash -# Plaintext to adjacent ciphertext: create, incrementally edit, or align recipients +# Plaintext to adjacent ciphertext: create or incrementally edit gitveil seal gitveil seal --profile dev +# Change who can decrypt: the only authorization entry points +gitveil recipient add --recipient age1... +gitveil recipient remove --policy team --recipient age1... + # Ciphertext to plaintext: merge by key against the local baseline gitveil open gitveil open --profile dev @@ -174,7 +190,7 @@ gitveil verify --range origin/main..HEAD gitveil resolve ``` -`seal` preserves encrypted bytes for unchanged keys and preserves the entire ciphertext byte-for-byte when plaintext semantics, layout, and recipients are unchanged. After a recipient-policy change, `status` reports recipient drift. Addition-only changes use SOPS `updatekeys` to rewrap the same data key without re-encrypting data or layout leaves. Any removal creates a fresh data key and re-encrypts the entire file so removed identities cannot decrypt later versions. +`seal` preserves encrypted bytes for unchanged keys and preserves the entire ciphertext byte-for-byte when plaintext semantics and layout are unchanged. Data commands never change authorization: when the manifest policy and a ciphertext's recipient set differ, `seal` fails closed and `status` reports the drift, both pointing to `gitveil recipient`. A first seal of a new file additionally requires every existing ciphertext under the same policy to be aligned, so a tampered policy cannot capture new files while old ones show drift. `recipient add` uses SOPS `updatekeys` to rewrap the same data key without re-encrypting data or layout leaves; `recipient remove` creates a fresh data key and re-encrypts the entire file so removed identities cannot decrypt later versions. `open` does not overwrite an entire existing plaintext file. Ciphertext-only changes synchronize automatically, plaintext-only changes remain local, and concurrent changes to the same key keep the local value and report a conflict. The baseline at `.git/gitveil/state/` contains only salted digests; when it is missing, Gitveil uses a conservative merge mode. @@ -196,14 +212,16 @@ git add .gitveil git commit ``` -Gitveil reads index stages but never runs `git add`. +Gitveil reads index stages but never runs `git add`. The merged ciphertext keeps the recipient set of the local (ours) side; if that set differs from the manifest policy, `resolve` reports the remaining drift for `gitveil recipient` to converge. ## Security boundaries - Secret values, private identities, and decrypted comments never enter argv, Git configuration, errors, reports, baselines, or tracked metadata. - Source keys, hierarchy, scalar types, recipients, and approximate ciphertext lengths are public metadata. - Gitveil enforces SOPS `encrypted_regex: .*`; every source scalar and layout/comment value is encrypted. -- Each ciphertext file uses one data key. Removing a recipient causes `seal` and `resolve` to rotate that key, preventing the removed identity from decrypting later versions. Access to historical versions cannot be revoked; forward exclusion also requires rotating the actual secret values. +- Data commands never change authorization. A recipient gains the ability to decrypt existing ciphertext only when an identity holder names it explicitly in `gitveil recipient add`; manifest edits alone produce drift that blocks `seal` instead of granting access. +- Each ciphertext file uses one data key. `gitveil recipient remove` rotates that key, preventing the removed identity from decrypting later versions. Access to historical versions cannot be revoked; forward exclusion also requires rotating the actual secret values. +- Before the first ciphertext exists under a policy, no cryptographic anchor can detect manifest tampering: the first seal encrypts to the policy as committed. Review every change to `.gitveilrc.json`, and seal promptly after `init`. Once a policy has ciphertext, new files refuse to seal while any of it shows drift. - Paths undergo workspace-relative validation and descriptor-relative confinement; symlink and submodule escapes are rejected. - SOPS and age-keygen subprocesses have deadlines. Unknown sidecar stderr is redacted and converted into fixed, typed diagnostics rather than being forwarded verbatim. - Temporary files and IPC endpoints live in owner-only runtime directories. diff --git a/docs/getting-started.md b/docs/getting-started.md index ad580f8..29d3200 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,7 +43,7 @@ gitveil init \ --recipient age1bob... ``` -Recipients in one policy provide OR authorization: any matching identity can decrypt. Removing or rotating a current recipient cannot revoke access to ciphertext already present in Git history. +Recipients in one policy provide OR authorization: any matching identity can decrypt. After initialization, membership changes only through the explicit authorization commands: `gitveil recipient add --recipient age1...` grants access and rewraps every affected ciphertext, and `gitveil recipient remove --recipient age1...` revokes access and rotates each file's data key. Editing `.gitveilrc.json` by hand grants nothing; the resulting drift blocks `seal` until a recipient command names the difference. Removing or rotating a current recipient cannot revoke access to ciphertext already present in Git history. ## Complete repository flow diff --git a/scripts/check-functional-core.py b/scripts/check-functional-core.py index cfdd82e..d31e656 100755 --- a/scripts/check-functional-core.py +++ b/scripts/check-functional-core.py @@ -24,6 +24,7 @@ "src/semantic/mod.rs", "src/manifest.rs", "src/configure/plan.rs", + "src/recipients/plan.rs", "src/baseline/record.rs", "src/seal/plan.rs", "src/open/plan.rs", diff --git a/src/cli.rs b/src/cli.rs index 7748c5d..dfba8b2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,7 @@ use crate::configure::{AddOutcomeKind, add, initialize}; use crate::error::{ErrorCategory, GitveilError, Result}; use crate::identity; use crate::open::{OpenOutcome, open}; +use crate::recipients::{self, GrantEcho, RecipientFileOutcome}; use crate::resolve::{ResolveOutcome, resolve}; use crate::runtime::{read_editor_token, run_internal_editor}; use crate::seal::{SealOutcome, seal}; @@ -18,6 +19,8 @@ Examples: gitveil identity generate gitveil init --recipient age1... gitveil add .env --format dotenv + gitveil recipient add --recipient age1... + gitveil recipient remove --policy team --recipient age1... gitveil open gitveil seal --profile dev gitveil seal packages/service/.env @@ -70,6 +73,11 @@ enum Command { #[arg(required = true, value_name = "PATH")] paths: Vec, }, + /// Change which recipients can decrypt files under a recipient policy + Recipient { + #[command(subcommand)] + command: RecipientCommand, + }, /// Decrypt managed files into their plaintext siblings (key-wise merge) Open { /// Select only managed files in this profile @@ -117,6 +125,28 @@ enum Command { }, } +#[derive(Debug, Subcommand)] +enum RecipientCommand { + /// Grant decryption to explicitly named public age recipients + Add { + /// Named recipient policy (inferred only when exactly one exists) + #[arg(long, value_name = "POLICY")] + policy: Option, + /// Public age X25519 recipient to authorize (repeatable) + #[arg(long, required = true, action = clap::ArgAction::Append, value_name = "AGE_RECIPIENT")] + recipient: Vec, + }, + /// Revoke recipients and rotate each affected file's data key + Remove { + /// Named recipient policy (inferred only when exactly one exists) + #[arg(long, value_name = "POLICY")] + policy: Option, + /// Public age X25519 recipient to revoke (repeatable) + #[arg(long, required = true, action = clap::ArgAction::Append, value_name = "AGE_RECIPIENT")] + recipient: Vec, + }, +} + #[derive(Debug, Subcommand)] enum IdentityCommand { /// Generate one native age identity without replacing an existing file @@ -228,6 +258,7 @@ pub fn run(cli: Cli) -> Result { profile.as_deref(), recipient_policy.as_deref(), ), + Command::Recipient { command } => run_recipient(¤t, &gitveil_binary, command), Command::Open { profile, paths } => { run_open(¤t, &gitveil_binary, &paths, profile.as_deref()) } @@ -283,6 +314,73 @@ fn run_identity( Ok(RunOutcome::Success) } +fn run_recipient( + current: &Path, + gitveil_binary: &Path, + command: RecipientCommand, +) -> Result { + let outcome = match command { + RecipientCommand::Add { policy, recipient } => { + recipients::add(current, gitveil_binary, policy.as_deref(), &recipient)? + } + RecipientCommand::Remove { policy, recipient } => { + recipients::remove(current, gitveil_binary, policy.as_deref(), &recipient)? + } + }; + let styled = stdout_styled(); + // Authorization always names its subject: the full public recipient. + for grant in &outcome.grants { + match grant { + GrantEcho::Added(recipient) => { + println!("policy {}: added {recipient}", outcome.policy); + } + GrantEcho::AlreadyAuthorized(recipient) => { + println!("policy {}: {recipient} already authorized", outcome.policy); + } + GrantEcho::Removed(recipient) => { + println!("policy {}: removed {recipient}", outcome.policy); + } + GrantEcho::RemovedFromCiphertext(recipient) => { + println!( + "policy {}: removed {recipient} (was present only on ciphertext)", + outcome.policy + ); + } + } + } + let mut run = RunOutcome::Success; + for report in &outcome.reports { + match &report.result { + Ok(RecipientFileOutcome::Rewrapped) => { + println!( + "{}: {}", + report.path, + paint("rewrapped", Tone::Good, styled) + ); + } + Ok(RecipientFileOutcome::DataKeyRotated) => { + println!( + "{}: {}", + report.path, + paint("data key rotated", Tone::Good, styled) + ); + } + Ok(RecipientFileOutcome::AlreadyAligned) => { + println!( + "{}: {}", + report.path, + paint("already aligned", Tone::Quiet, styled) + ); + } + Err(error) => { + run = RunOutcome::Attention; + report_error(&report.path, error); + } + } + } + Ok(run) +} + fn run_initialize(current: &Path, policy: &str, recipients: &[String]) -> Result { let outcome = initialize(current, policy, recipients)?; println!( @@ -405,13 +503,8 @@ fn run_seal( let mut outcome = RunOutcome::Success; for report in seal(&workspace, paths, profile)? { match report.result { - Ok(SealOutcome::Sealed { data_key_rotated }) => { - let label = if data_key_rotated { - "sealed; data key rotated" - } else { - "sealed" - }; - println!("{}: {}", report.path, paint(label, Tone::Good, styled)); + Ok(SealOutcome::Sealed) => { + println!("{}: {}", report.path, paint("sealed", Tone::Good, styled)); } Ok(SealOutcome::Unchanged) => { println!( @@ -483,13 +576,24 @@ fn run_resolve(current: &Path, gitveil_binary: &Path, paths: &[String]) -> Resul let mut outcome = RunOutcome::Success; for report in resolve(&workspace, paths)? { match report.result { - Ok(ResolveOutcome::Resolved { data_key_rotated }) => { - let label = if data_key_rotated { - "resolved; data key rotated; review and git add" - } else { - "resolved; review and git add" - }; - println!("{}: {}", report.path, paint(label, Tone::Good, styled)); + Ok(ResolveOutcome::Resolved { recipient_drift }) => { + println!( + "{}: {}", + report.path, + paint("resolved; review and git add", Tone::Good, styled) + ); + if recipient_drift { + outcome = RunOutcome::Attention; + println!( + "{}: {}", + report.path, + paint( + "recipient drift remains; run gitveil recipient add or gitveil recipient remove", + Tone::Attention, + styled + ) + ); + } } Ok(ResolveOutcome::ConflictWritten(node)) => { outcome = RunOutcome::Attention; diff --git a/src/configure.rs b/src/configure.rs index 40d552c..0f38e6d 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -183,10 +183,10 @@ fn add_with_publication_fault( Ok(add_outcomes(&plan, &existing_plaintext)) } -struct ManifestPreparation { - path: ManagedPath, - original: Vec, - mode: u32, +pub(crate) struct ManifestPreparation { + pub(crate) path: ManagedPath, + pub(crate) original: Vec, + pub(crate) mode: u32, } struct IgnorePreparation { @@ -204,7 +204,7 @@ struct RegistrationPublication<'a> { manifest_candidate: &'a [u8], } -fn load_manifest(root: &Path) -> Result<(ManifestPreparation, Manifest)> { +pub(crate) fn load_manifest(root: &Path) -> Result<(ManifestPreparation, Manifest)> { let path = ManagedPath::new(MANIFEST_FILE_NAME) .map_err(|error| GitveilError::configuration(error.to_string()))?; let bytes = read_optional(root, &path)?.ok_or_else(|| { @@ -240,7 +240,8 @@ fn registration_plan( .transpose() .map_err(|error| GitveilError::configuration(error.to_string()))? .unwrap_or_default(); - let recipient_policy = select_recipient_policy(&manifest, recipient_policy)?; + let recipient_policy = + select_recipient_policy(&manifest, recipient_policy, "--recipient-policy")?; let managed_paths = paths .iter() .map(|path| validate_registration_path(path)) @@ -429,7 +430,7 @@ fn ignore_states(matches: HashMap) -> HashMap Result> { +pub(crate) fn serialize_manifest(manifest: &Manifest) -> Result> { manifest.to_json_bytes().map_err(|_| { GitveilError::new( ErrorCategory::Integrity, @@ -551,7 +552,11 @@ fn add_outcomes( .collect() } -fn select_recipient_policy(manifest: &Manifest, requested: Option<&str>) -> Result { +pub(crate) fn select_recipient_policy( + manifest: &Manifest, + requested: Option<&str>, + flag: &str, +) -> Result { if let Some(requested) = requested { let requested = PolicyName::new(requested) .map_err(|error| GitveilError::configuration(error.to_string()))?; @@ -577,12 +582,12 @@ fn select_recipient_policy(manifest: &Manifest, requested: Option<&str>) -> Resu ))) } else { Err(GitveilError::configuration(format!( - "multiple recipient policies are declared ({names}); pass --recipient-policy" + "multiple recipient policies are declared ({names}); pass {flag}" ))) } } -fn mutation_repository(current: &Path) -> Result { +pub(crate) fn mutation_repository(current: &Path) -> Result { let repository = match Repository::discover(current) { Ok(repository) => repository, Err(RepositoryDiscoveryError::NotRepository) => { @@ -634,7 +639,7 @@ fn mutation_repository(current: &Path) -> Result { Ok(repository) } -fn read_optional(root: &Path, path: &ManagedPath) -> Result>> { +pub(crate) fn read_optional(root: &Path, path: &ManagedPath) -> Result>> { let Some(mut file) = confine::open_existing(root, path)? else { return Ok(None); }; @@ -664,7 +669,7 @@ fn file_mode(root: &Path, path: &ManagedPath) -> Result> { }) } -fn write_atomic( +pub(crate) fn write_atomic( root: &Path, path: &ManagedPath, bytes: &[u8], diff --git a/src/lib.rs b/src/lib.rs index 1c1f0a8..e06ae7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub(crate) mod open; pub mod path; pub mod profile; pub mod recipient; +pub(crate) mod recipients; pub(crate) mod resolve; pub mod runtime; pub(crate) mod seal; diff --git a/src/manifest.rs b/src/manifest.rs index 7389119..92a27e4 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -95,6 +95,10 @@ impl<'a> ResolvedManifestEntry<'a> { self.recipient_policy } + pub(crate) fn policy_name(&self) -> &PolicyName { + self.entry.recipient_policy() + } + pub(crate) fn ciphertext_path(&self) -> ManagedPath { self.entry.ciphertext_path() } @@ -310,6 +314,28 @@ impl Manifest { Ok(ManifestEntryAddOutcome::Added) } + /// Replaces the recipient list of an existing policy. + /// + /// This is the manifest side of `gitveil recipient add`/`remove`; policy + /// invariants (non-empty, unique recipients) are enforced by the same + /// domain constructor the reader uses. + pub(crate) fn set_policy_recipients( + &mut self, + name: &PolicyName, + recipients: Vec, + ) -> Result<(), ManifestMutationError> { + let Some(slot) = self.recipient_policies.get_mut(name) else { + return Err(ManifestMutationError::MissingPolicy(name.clone())); + }; + *slot = AgeRecipientPolicy::new(name.clone(), recipients).map_err(|source| { + ManifestMutationError::Policy { + policy: name.clone(), + source, + } + })?; + Ok(()) + } + pub(crate) fn to_json_bytes(&self) -> Result, ManifestWriteError> { let recipient_policies = self .recipient_policies @@ -434,6 +460,11 @@ pub(crate) enum ManifestMutationError { CaseCollision(ManagedPath), #[error("recipient policy {0} is not declared in {MANIFEST_FILE_NAME}")] MissingPolicy(PolicyName), + #[error("invalid recipient policy {policy}: {source}")] + Policy { + policy: PolicyName, + source: RecipientError, + }, } #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] @@ -575,6 +606,35 @@ mod tests { )); } + #[test] + fn policy_recipient_replacement_enforces_the_reader_invariants() { + let mut manifest = manifest(); + let name = PolicyName::new("default").expect("policy name"); + let recipient = crate::recipient::AgeRecipient::new(RECIPIENT).expect("recipient"); + assert!(matches!( + manifest.set_policy_recipients( + &PolicyName::new("missing").expect("policy name"), + vec![recipient.clone()] + ), + Err(ManifestMutationError::MissingPolicy(_)) + )); + assert!(matches!( + manifest.set_policy_recipients(&name, Vec::new()), + Err(ManifestMutationError::Policy { .. }) + )); + manifest + .set_policy_recipients(&name, vec![recipient.clone()]) + .expect("replacement"); + assert_eq!( + manifest + .recipient_policy(&name) + .expect("policy") + .recipients(), + &[recipient] + ); + manifest.to_json_bytes().expect("writer round-trip"); + } + #[test] fn writer_reports_reader_disagreement_as_an_internal_invariant() { let mut manifest = manifest(); diff --git a/src/recipient.rs b/src/recipient.rs index 635754a..6445c76 100644 --- a/src/recipient.rs +++ b/src/recipient.rs @@ -141,35 +141,10 @@ pub struct RecipientSetDiff { pub(crate) removed: usize, } -/// How an envelope's recipient set is brought to a policy. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum RecipientAction { - /// The sets already agree. - Aligned, - /// Only additions are needed: the same data key is rewrapped and every - /// encrypted leaf stays byte-for-byte stable. - Rewrap, - /// The envelope carries a recipient outside the policy. Exclusion is only - /// real under a data key the removed party cannot unwrap, so the envelope - /// must be re-encrypted under a fresh one. - Rotate, -} - impl RecipientSetDiff { pub(crate) const fn is_empty(self) -> bool { self.added == 0 && self.removed == 0 } - - /// Any removal forces a fresh data key; additions alone rewrap. - pub(crate) const fn action(self) -> RecipientAction { - if self.removed > 0 { - RecipientAction::Rotate - } else if self.added > 0 { - RecipientAction::Rewrap - } else { - RecipientAction::Aligned - } - } } #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] @@ -189,16 +164,15 @@ pub enum RecipientError { #[cfg(test)] mod tests { - use super::{AgeRecipient, RecipientAction, RecipientError, RecipientSetDiff}; + use super::{AgeRecipient, RecipientError, RecipientSetDiff}; #[test] - fn set_diff_action_rotates_on_any_removal_and_rewraps_on_additions_only() { + fn set_diff_emptiness_reflects_both_directions() { let drift = |added, removed| RecipientSetDiff { added, removed }; - assert_eq!(drift(0, 0).action(), RecipientAction::Aligned); - assert_eq!(drift(1, 0).action(), RecipientAction::Rewrap); - assert_eq!(drift(0, 1).action(), RecipientAction::Rotate); - // Mixed additions and removals (including a policy switch) rotate. - assert_eq!(drift(2, 1).action(), RecipientAction::Rotate); + assert!(drift(0, 0).is_empty()); + assert!(!drift(1, 0).is_empty()); + assert!(!drift(0, 1).is_empty()); + assert!(!drift(2, 1).is_empty()); } /// HRP `age`, 32-byte payload, bech32 checksum: the only shape age accepts. diff --git a/src/recipients.rs b/src/recipients.rs new file mode 100644 index 0000000..30ac102 --- /dev/null +++ b/src/recipients.rs @@ -0,0 +1,436 @@ +//! `gitveil recipient add` / `gitveil recipient remove`: the only commands +//! that change which recipients can decrypt managed ciphertext. +//! +//! Data commands (`seal`, `resolve`) fail closed on recipient drift; this +//! module is the single authorization entry point. The operator's command +//! line names every recipient whose access changes, and the pure plan +//! rejects any manifest/envelope difference that the command line does not +//! explain. The manifest is published first: any mid-run failure leaves +//! drift that `seal` refuses and an idempotent rerun converges. + +mod plan; + +use std::path::Path; +use std::time::Duration; + +use crate::baseline::{BaselineRecord, BaselineStore, CipherSummary}; +use crate::configure::{ + ManifestPreparation, load_manifest, mutation_repository, read_optional, + select_recipient_policy, serialize_manifest, write_atomic, +}; +use crate::envelope::{CiphertextEnvelope, DecryptedEnvelope}; +use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; +use crate::manifest::{MANIFEST_FILE_NAME, Manifest, ManifestEntry, ResolvedManifestEntry}; +use crate::path::ManagedPath; +use crate::recipient::{AgeRecipient, AgeRecipientPolicy, PolicyName}; +use crate::runtime::{PrivateRuntime, acquire_lock}; +use crate::seal::decrypt_source; +use crate::sops::{SopsBinary, SopsClient, SopsPaths}; +use crate::source::SourceDocument; + +pub(crate) use plan::{ConvergenceAction, GrantEcho}; +use plan::{FileRecipientFacts, RecipientMutation, plan_authorization}; + +const LOCK_TIMEOUT: Duration = Duration::from_secs(30); +const CIPHERTEXT_MODE: u32 = 0o644; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RecipientFileOutcome { + Rewrapped, + DataKeyRotated, + AlreadyAligned, +} + +pub(crate) struct RecipientFileReport { + pub path: ManagedPath, + pub result: Result, +} + +pub(crate) struct AuthorizationOutcome { + pub policy: PolicyName, + pub grants: Vec, + pub reports: Vec, +} + +pub(crate) fn add( + current: &Path, + gitveil_binary: &Path, + policy: Option<&str>, + recipients: &[String], +) -> Result { + authorize(current, gitveil_binary, policy, recipients, Direction::Add) +} + +pub(crate) fn remove( + current: &Path, + gitveil_binary: &Path, + policy: Option<&str>, + recipients: &[String], +) -> Result { + authorize( + current, + gitveil_binary, + policy, + recipients, + Direction::Remove, + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Direction { + Add, + Remove, +} + +struct AffectedFile<'a> { + entry: &'a ManifestEntry, + ciphertext: Vec, +} + +fn authorize( + current: &Path, + gitveil_binary: &Path, + requested_policy: Option<&str>, + requested: &[String], + direction: Direction, +) -> Result { + let parsed = parse_requested(requested)?; + let repository = mutation_repository(current)?; + let runtime = PrivateRuntime::open(repository.runtime_path().to_path_buf())?; + let _lock = acquire_lock(&runtime, LOCK_TIMEOUT)?; + let root = repository.root().to_path_buf(); + let store = BaselineStore::new(repository.runtime_path().join("state")); + + let (manifest_preparation, manifest) = load_manifest(&root)?; + let policy_name = select_recipient_policy(&manifest, requested_policy, "--policy")?; + let policy = manifest.recipient_policy(&policy_name).ok_or_else(|| { + GitveilError::new( + ErrorCategory::Integrity, + "selected recipient policy disappeared from the parsed manifest", + ) + })?; + + let files = collect_policy_files(&root, &manifest, &policy_name)?; + let mut reports = files.broken; + + let mutation = match direction { + Direction::Add => RecipientMutation::Add(parsed), + Direction::Remove => RecipientMutation::Remove(parsed), + }; + let plan = plan_authorization(policy, &mutation, &files.facts) + .map_err(|rejection| GitveilError::configuration(rejection.to_string()))?; + + let desired_policy = + AgeRecipientPolicy::new(policy_name.clone(), plan.desired_recipients.clone()).map_err( + |error| { + GitveilError::new( + ErrorCategory::Integrity, + format!("authorization plan produced an invalid policy: {error}"), + ) + }, + )?; + let mut candidate = manifest.clone(); + candidate + .set_policy_recipients(&policy_name, plan.desired_recipients.clone()) + .map_err(|error| GitveilError::new(ErrorCategory::Integrity, error.to_string()))?; + let candidate_bytes = serialize_manifest(&candidate)?; + + let mut convergences = Vec::new(); + for (path, action) in &plan.files { + if *action == ConvergenceAction::AlreadyAligned { + reports.push(RecipientFileReport { + path: path.clone(), + result: Ok(RecipientFileOutcome::AlreadyAligned), + }); + continue; + } + let file = files + .affected + .iter() + .find(|file| file.entry.path() == path) + .ok_or_else(|| { + GitveilError::new( + ErrorCategory::Integrity, + format!("authorization plan references unknown file {path}"), + ) + })?; + convergences.push((file, *action)); + } + + let engine = ConvergenceEngine { + root: &root, + runtime: &runtime, + gitveil_binary, + store: &store, + manifest: &manifest, + preparation: &manifest_preparation, + candidate: &candidate_bytes, + manifest_changed: plan.manifest_changed, + desired_policy: &desired_policy, + }; + engine.run(convergences, &mut reports)?; + + Ok(AuthorizationOutcome { + policy: policy_name, + grants: plan.grants, + reports, + }) +} + +fn parse_requested(requested: &[String]) -> Result> { + requested + .iter() + .enumerate() + .map(|(index, value)| { + AgeRecipient::new(value).map_err(|error| { + GitveilError::configuration(format!( + "invalid public age recipient at index {index}: {error}" + )) + }) + }) + .collect() +} + +struct PolicyFiles<'a> { + facts: Vec, + affected: Vec>, + /// Per-file reports for ciphertexts that could not be parsed; they are + /// excluded from the plan and their drift stays visible to `seal`. + broken: Vec, +} + +fn collect_policy_files<'a>( + root: &Path, + manifest: &'a Manifest, + policy_name: &PolicyName, +) -> Result> { + let mut files = PolicyFiles { + facts: Vec::new(), + affected: Vec::new(), + broken: Vec::new(), + }; + for entry in manifest.entries() { + if entry.recipient_policy() != policy_name { + continue; + } + let cipher_path = entry.ciphertext_path(); + match read_optional(root, &cipher_path)? { + None => files.facts.push(FileRecipientFacts { + path: entry.path().clone(), + envelope_recipients: None, + }), + Some(bytes) => match CiphertextEnvelope::parse(&bytes, entry.format()) { + Ok(envelope) => { + files.facts.push(FileRecipientFacts { + path: entry.path().clone(), + envelope_recipients: Some(envelope.age_recipients().to_vec()), + }); + files.affected.push(AffectedFile { + entry, + ciphertext: bytes, + }); + } + Err(error) => files.broken.push(RecipientFileReport { + path: entry.path().clone(), + result: Err(GitveilError::ciphertext(&cipher_path, &error.to_string())), + }), + }, + } + } + Ok(files) +} + +struct ConvergenceEngine<'a> { + root: &'a Path, + runtime: &'a PrivateRuntime, + gitveil_binary: &'a Path, + store: &'a BaselineStore, + manifest: &'a Manifest, + preparation: &'a ManifestPreparation, + candidate: &'a [u8], + manifest_changed: bool, + desired_policy: &'a AgeRecipientPolicy, +} + +impl ConvergenceEngine<'_> { + fn run( + &self, + convergences: Vec<(&AffectedFile<'_>, ConvergenceAction)>, + reports: &mut Vec, + ) -> Result<()> { + if convergences.is_empty() { + return self.publish_manifest_if_changed(); + } + let sops = SopsClient::new( + SopsBinary::discover(None, self.gitveil_binary)?, + SopsPaths { + repository: self.root.to_path_buf(), + gitveil_binary: self.gitveil_binary.to_path_buf(), + }, + self.runtime.clone(), + )?; + + // Preflight: prove the current envelopes are decryptable before any + // publication. When nothing can be decrypted (no identity, or every + // file failed) the manifest is not published and nothing changes. + let mut ready = Vec::new(); + let mut failures = Vec::new(); + for (file, action) in convergences { + let resolved = self.manifest.resolve_entry(file.entry).ok_or_else(|| { + GitveilError::new( + ErrorCategory::Integrity, + format!( + "manifest entry {} lost its recipient policy", + file.entry.path() + ), + ) + })?; + match decrypt_source(&sops, &file.ciphertext, &resolved) { + Ok(document) => ready.push((file, action, resolved, document)), + Err(error) => failures.push(RecipientFileReport { + path: file.entry.path().clone(), + result: Err(error), + }), + } + } + if ready.is_empty() { + let failure = failures + .into_iter() + .next() + .and_then(|report| report.result.err()) + .unwrap_or_else(|| { + GitveilError::new( + ErrorCategory::Integrity, + "recipient convergence preflight produced no result", + ) + }); + return Err(failure); + } + + self.publish_manifest_if_changed()?; + + for (file, action, resolved, document) in ready { + let result = self.converge_file(&sops, &resolved, &file.ciphertext, &document, action); + reports.push(RecipientFileReport { + path: file.entry.path().clone(), + result, + }); + } + reports.extend(failures); + Ok(()) + } + + fn publish_manifest_if_changed(&self) -> Result<()> { + if !self.manifest_changed { + return Ok(()); + } + let current = read_optional(self.root, &self.preparation.path)?; + if current.as_deref() != Some(self.preparation.original.as_slice()) { + return Err(GitveilError::new( + ErrorCategory::Concurrency, + format!("{MANIFEST_FILE_NAME} changed while gitveil recipient was running; retry"), + )); + } + write_atomic( + self.root, + &self.preparation.path, + self.candidate, + self.preparation.mode, + true, + ) + } + + fn converge_file( + &self, + sops: &SopsClient, + resolved: &ResolvedManifestEntry<'_>, + ciphertext: &[u8], + document: &SourceDocument, + action: ConvergenceAction, + ) -> Result { + let path = resolved.path(); + let cipher_path = resolved.ciphertext_path(); + let (converged, outcome) = match action { + ConvergenceAction::AlreadyAligned => return Ok(RecipientFileOutcome::AlreadyAligned), + ConvergenceAction::Rewrap => { + let rewrapped = sops.rewrap(ciphertext, self.desired_policy)?; + self.verify_rewrap(ciphertext, &rewrapped, resolved)?; + (rewrapped, RecipientFileOutcome::Rewrapped) + } + ConvergenceAction::Rotate => { + // The content truth source is the decrypted current envelope; + // workspace plaintext (and any unsealed local edit) is never + // read or consumed by an authorization command. + let desired_bytes = DecryptedEnvelope::from_source(document) + .and_then(|envelope| envelope.to_yaml()) + .map(SecretBytes::new) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + let rotated = sops.encrypt_new(&desired_bytes, path, self.desired_policy)?; + let envelope = CiphertextEnvelope::parse(&rotated, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + self.verify_desired_recipients(&envelope, path)?; + let roundtrip = decrypt_source(sops, &rotated, resolved)?; + if !roundtrip.semantic_eq(document) { + return Err(GitveilError::new( + ErrorCategory::Integrity, + format!("rotated ciphertext failed semantic verification at {path}"), + )); + } + (rotated, RecipientFileOutcome::DataKeyRotated) + } + }; + write_atomic(self.root, &cipher_path, &converged, CIPHERTEXT_MODE, true)?; + let envelope = CiphertextEnvelope::parse(&converged, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + let record = BaselineRecord::capture( + document, + &CipherSummary::from_envelope(&envelope), + BaselineStore::fresh_salt(), + ); + self.store.save(path, &record)?; + Ok(outcome) + } + + /// Verifies a rewrap changed only the wrapped data key: the recipient + /// set is exactly the desired policy and every encrypted leaf stayed + /// byte-for-byte stable. + fn verify_rewrap( + &self, + before: &[u8], + after: &[u8], + resolved: &ResolvedManifestEntry<'_>, + ) -> Result<()> { + let path = resolved.path(); + let before = CiphertextEnvelope::parse(before, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + let after = CiphertextEnvelope::parse(after, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + self.verify_desired_recipients(&after, path)?; + if before.leaf_ciphertexts() != after.leaf_ciphertexts() + || before.layout_ciphertext() != after.layout_ciphertext() + { + return Err(GitveilError::new( + ErrorCategory::Integrity, + format!("SOPS recipient update changed encrypted data at {path}"), + )); + } + Ok(()) + } + + fn verify_desired_recipients( + &self, + envelope: &CiphertextEnvelope, + path: &ManagedPath, + ) -> Result<()> { + if self.desired_policy.matches(envelope.age_recipients()) { + return Ok(()); + } + Err(GitveilError::new( + ErrorCategory::Integrity, + format!( + "SOPS result recipients do not match the authorized set of policy {} at {path}", + self.desired_policy.name() + ), + )) + } +} diff --git a/src/recipients/plan.rs b/src/recipients/plan.rs new file mode 100644 index 0000000..78e1366 --- /dev/null +++ b/src/recipients/plan.rs @@ -0,0 +1,418 @@ +//! Pure authorization planning for `gitveil recipient add` and +//! `gitveil recipient remove`. +//! +//! Contract: a recipient gains or loses the ability to decrypt an existing +//! ciphertext only when the operator names it explicitly on the command +//! line. Every effective per-file recipient change must be explained by the +//! requested mutation; an unexplained difference rejects the whole command +//! and names the offending recipient (age recipients are public metadata). + +use std::collections::HashSet; + +use thiserror::Error; + +use crate::path::ManagedPath; +use crate::recipient::{AgeRecipient, AgeRecipientPolicy}; + +/// The requested mutation, taken verbatim from argv. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum RecipientMutation { + Add(Vec), + Remove(Vec), +} + +/// Public envelope facts for one manifest entry referencing the policy. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FileRecipientFacts { + pub path: ManagedPath, + /// `None` when the ciphertext sibling does not exist yet. + pub envelope_recipients: Option>, +} + +/// How one existing ciphertext is brought to the desired recipient set. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConvergenceAction { + AlreadyAligned, + /// Additions only: the same data key is rewrapped and every encrypted + /// leaf stays byte-for-byte stable. + Rewrap, + /// A removal: exclusion is only real under a fresh data key the removed + /// party cannot unwrap, so the envelope is re-encrypted. + Rotate, +} + +/// Echo of one requested recipient's actual effect, rendered by the CLI so +/// the authorization action always names its subject. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum GrantEcho { + Added(AgeRecipient), + AlreadyAuthorized(AgeRecipient), + Removed(AgeRecipient), + /// Absent from the policy but present on at least one ciphertext; the + /// removal converges that drift. + RemovedFromCiphertext(AgeRecipient), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct AuthorizationPlan { + /// Final ordered recipient list for the policy. + pub desired_recipients: Vec, + /// Whether the manifest policy itself changes. + pub manifest_changed: bool, + /// Convergence action per entry with an existing ciphertext. + pub files: Vec<(ManagedPath, ConvergenceAction)>, + pub grants: Vec, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub(super) enum AuthorizationRejection { + #[error("recipient {0} is requested more than once")] + DuplicateRequest(AgeRecipient), + #[error("recipient {0} is neither in the policy nor on any managed ciphertext")] + RemoveNotMember(AgeRecipient), + #[error("a policy must keep at least one recipient; this removal would empty it")] + EmptyPolicy, + #[error( + "the policy grants {recipient} but {path}.gitveil does not include it and this command did not add it; remove it from .gitveilrc.json or name it explicitly with gitveil recipient add" + )] + UnexplainedAddition { + path: ManagedPath, + recipient: AgeRecipient, + }, + #[error( + "{path}.gitveil includes {recipient} but the policy does not, and this command did not remove it; restore it in .gitveilrc.json or name it explicitly with gitveil recipient remove" + )] + UnexplainedRemoval { + path: ManagedPath, + recipient: AgeRecipient, + }, +} + +fn desired_and_grants( + current: &[AgeRecipient], + mutation: &RecipientMutation, + files: &[FileRecipientFacts], +) -> Result<(Vec, Vec), AuthorizationRejection> { + match mutation { + RecipientMutation::Add(additions) => { + let mut desired = current.to_vec(); + let mut grants = Vec::with_capacity(additions.len()); + for addition in additions { + if current.contains(addition) { + grants.push(GrantEcho::AlreadyAuthorized(addition.clone())); + } else { + desired.push(addition.clone()); + grants.push(GrantEcho::Added(addition.clone())); + } + } + Ok((desired, grants)) + } + RecipientMutation::Remove(removals) => { + let mut grants = Vec::with_capacity(removals.len()); + for removal in removals { + if current.contains(removal) { + grants.push(GrantEcho::Removed(removal.clone())); + } else if files.iter().any(|facts| { + facts + .envelope_recipients + .as_deref() + .is_some_and(|envelope| envelope.contains(removal)) + }) { + grants.push(GrantEcho::RemovedFromCiphertext(removal.clone())); + } else { + return Err(AuthorizationRejection::RemoveNotMember(removal.clone())); + } + } + let desired = current + .iter() + .filter(|recipient| !removals.contains(recipient)) + .cloned() + .collect::>(); + if desired.is_empty() { + return Err(AuthorizationRejection::EmptyPolicy); + } + Ok((desired, grants)) + } + } +} + +pub(super) fn plan_authorization( + policy: &AgeRecipientPolicy, + mutation: &RecipientMutation, + files: &[FileRecipientFacts], +) -> Result { + let requested = match mutation { + RecipientMutation::Add(recipients) | RecipientMutation::Remove(recipients) => recipients, + }; + let mut seen = HashSet::with_capacity(requested.len()); + if let Some(duplicate) = requested + .iter() + .find(|recipient| !seen.insert(recipient.as_str())) + { + return Err(AuthorizationRejection::DuplicateRequest(duplicate.clone())); + } + + let current = policy.recipients(); + let (desired, grants) = desired_and_grants(current, mutation, files)?; + let manifest_changed = desired.as_slice() != current; + + let empty: &[AgeRecipient] = &[]; + let (named_additions, named_removals) = match mutation { + RecipientMutation::Add(recipients) => (recipients.as_slice(), empty), + RecipientMutation::Remove(recipients) => (empty, recipients.as_slice()), + }; + + let mut file_actions = Vec::with_capacity(files.len()); + for facts in files { + let Some(envelope) = facts.envelope_recipients.as_deref() else { + continue; + }; + let additions_needed = desired + .iter() + .filter(|recipient| !envelope.contains(recipient)) + .collect::>(); + let removals_needed = envelope + .iter() + .filter(|recipient| !desired.contains(recipient)) + .collect::>(); + if let Some(recipient) = additions_needed + .iter() + .find(|recipient| !named_additions.contains(recipient)) + { + return Err(AuthorizationRejection::UnexplainedAddition { + path: facts.path.clone(), + recipient: (*recipient).clone(), + }); + } + if let Some(recipient) = removals_needed + .iter() + .find(|recipient| !named_removals.contains(recipient)) + { + return Err(AuthorizationRejection::UnexplainedRemoval { + path: facts.path.clone(), + recipient: (*recipient).clone(), + }); + } + let action = if removals_needed.is_empty() { + if additions_needed.is_empty() { + ConvergenceAction::AlreadyAligned + } else { + ConvergenceAction::Rewrap + } + } else { + ConvergenceAction::Rotate + }; + file_actions.push((facts.path.clone(), action)); + } + Ok(AuthorizationPlan { + desired_recipients: desired, + manifest_changed, + files: file_actions, + grants, + }) +} + +#[cfg(test)] +mod tests { + use bech32::{Bech32, Hrp}; + + use crate::path::ManagedPath; + use crate::recipient::{AgeRecipient, AgeRecipientPolicy, PolicyName}; + + use super::{ + AuthorizationRejection, ConvergenceAction, FileRecipientFacts, GrantEcho, + RecipientMutation, plan_authorization, + }; + + fn recipient(seed: u8) -> AgeRecipient { + let encoded = bech32::encode::(Hrp::parse_unchecked("age"), &[seed; 32]) + .expect("encode age recipient"); + AgeRecipient::new(encoded).expect("valid age recipient") + } + + fn policy(recipients: &[&AgeRecipient]) -> AgeRecipientPolicy { + AgeRecipientPolicy::new( + PolicyName::new("team").expect("policy name"), + recipients.iter().map(|value| (*value).clone()).collect(), + ) + .expect("policy") + } + + fn file(path: &str, envelope: Option<&[&AgeRecipient]>) -> FileRecipientFacts { + FileRecipientFacts { + path: ManagedPath::new(path).expect("managed path"), + envelope_recipients: envelope + .map(|recipients| recipients.iter().map(|value| (*value).clone()).collect()), + } + } + + #[test] + fn adding_to_an_aligned_state_rewraps_every_existing_ciphertext() { + let (a, b) = (recipient(1), recipient(2)); + let plan = plan_authorization( + &policy(&[&a]), + &RecipientMutation::Add(vec![b.clone()]), + &[file(".env", Some(&[&a])), file("fresh.env", None)], + ) + .expect("plan"); + assert_eq!(plan.desired_recipients, vec![a, b.clone()]); + assert!(plan.manifest_changed); + assert_eq!(plan.files.len(), 1); + assert_eq!(plan.files[0].1, ConvergenceAction::Rewrap); + assert_eq!(plan.grants, vec![GrantEcho::Added(b)]); + } + + #[test] + fn an_idempotent_add_reports_already_authorized_and_already_aligned() { + let (a, b) = (recipient(1), recipient(2)); + let plan = plan_authorization( + &policy(&[&a, &b]), + &RecipientMutation::Add(vec![b.clone()]), + &[file(".env", Some(&[&a, &b]))], + ) + .expect("plan"); + assert!(!plan.manifest_changed); + assert_eq!(plan.files[0].1, ConvergenceAction::AlreadyAligned); + assert_eq!(plan.grants, vec![GrantEcho::AlreadyAuthorized(b)]); + } + + #[test] + fn add_converges_a_hand_edited_manifest_when_the_difference_is_named() { + let (a, b) = (recipient(1), recipient(2)); + let plan = plan_authorization( + &policy(&[&a, &b]), + &RecipientMutation::Add(vec![b.clone()]), + &[file(".env", Some(&[&a]))], + ) + .expect("plan"); + assert!(!plan.manifest_changed); + assert_eq!(plan.files[0].1, ConvergenceAction::Rewrap); + } + + #[test] + fn an_injected_manifest_addition_is_rejected_and_named() { + let (a, b, injected) = (recipient(1), recipient(2), recipient(9)); + assert_eq!( + plan_authorization( + &policy(&[&a, &injected]), + &RecipientMutation::Add(vec![b]), + &[file(".env", Some(&[&a]))], + ), + Err(AuthorizationRejection::UnexplainedAddition { + path: ManagedPath::new(".env").expect("managed path"), + recipient: injected, + }) + ); + } + + #[test] + fn an_injected_manifest_removal_is_rejected_by_add() { + let (a, b, c) = (recipient(1), recipient(2), recipient(3)); + assert_eq!( + plan_authorization( + &policy(&[&a]), + &RecipientMutation::Add(vec![c]), + &[file(".env", Some(&[&a, &b]))], + ), + Err(AuthorizationRejection::UnexplainedRemoval { + path: ManagedPath::new(".env").expect("managed path"), + recipient: b, + }) + ); + } + + #[test] + fn removing_a_policy_member_rotates_every_existing_ciphertext() { + let (a, b) = (recipient(1), recipient(2)); + let plan = plan_authorization( + &policy(&[&a, &b]), + &RecipientMutation::Remove(vec![b.clone()]), + &[file(".env", Some(&[&a, &b]))], + ) + .expect("plan"); + assert_eq!(plan.desired_recipients, vec![a]); + assert!(plan.manifest_changed); + assert_eq!(plan.files[0].1, ConvergenceAction::Rotate); + assert_eq!(plan.grants, vec![GrantEcho::Removed(b)]); + } + + #[test] + fn removing_a_ciphertext_only_recipient_converges_removal_drift() { + let (a, b) = (recipient(1), recipient(2)); + let plan = plan_authorization( + &policy(&[&a]), + &RecipientMutation::Remove(vec![b.clone()]), + &[file(".env", Some(&[&a, &b]))], + ) + .expect("plan"); + assert!(!plan.manifest_changed); + assert_eq!(plan.files[0].1, ConvergenceAction::Rotate); + assert_eq!(plan.grants, vec![GrantEcho::RemovedFromCiphertext(b)]); + } + + #[test] + fn remove_rejects_unknown_recipients_and_an_emptied_policy() { + let (a, b, unknown) = (recipient(1), recipient(2), recipient(9)); + assert_eq!( + plan_authorization( + &policy(&[&a, &b]), + &RecipientMutation::Remove(vec![unknown.clone()]), + &[file(".env", Some(&[&a, &b]))], + ), + Err(AuthorizationRejection::RemoveNotMember(unknown.clone())) + ); + assert_eq!( + plan_authorization( + &policy(&[&a]), + &RecipientMutation::Remove(vec![a.clone()]), + &[file(".env", Some(&[&a]))], + ), + Err(AuthorizationRejection::EmptyPolicy) + ); + } + + #[test] + fn remove_rejects_an_unexplained_manifest_addition() { + // The attacker added `injected` to the manifest; a removal of `b` + // must not silently wrap the data key to `injected`. + let (a, b, injected) = (recipient(1), recipient(2), recipient(9)); + assert_eq!( + plan_authorization( + &policy(&[&a, &b, &injected]), + &RecipientMutation::Remove(vec![b.clone()]), + &[file(".env", Some(&[&a, &b]))], + ), + Err(AuthorizationRejection::UnexplainedAddition { + path: ManagedPath::new(".env").expect("managed path"), + recipient: injected, + }) + ); + } + + #[test] + fn duplicate_argv_recipients_are_rejected() { + let a = recipient(1); + let b = recipient(2); + assert_eq!( + plan_authorization( + &policy(&[&a]), + &RecipientMutation::Add(vec![b.clone(), b.clone()]), + &[], + ), + Err(AuthorizationRejection::DuplicateRequest(b)) + ); + } + + #[test] + fn entries_without_ciphertext_only_change_the_manifest() { + let (a, b) = (recipient(1), recipient(2)); + let plan = plan_authorization( + &policy(&[&a]), + &RecipientMutation::Add(vec![b]), + &[file(".env", None)], + ) + .expect("plan"); + assert!(plan.manifest_changed); + assert!(plan.files.is_empty()); + } +} diff --git a/src/resolve.rs b/src/resolve.rs index 7f35fc7..8b0ad60 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -7,8 +7,8 @@ use crate::envelope::{CiphertextEnvelope, DecryptedEnvelope}; use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; use crate::manifest::ResolvedManifestEntry; use crate::path::ManagedPath; -use crate::recipient::RecipientAction; -use crate::seal::{decrypt_source, rewrap_updatekeys, verify_recipient_policy}; +use crate::recipient::AgeRecipient; +use crate::seal::decrypt_source; use crate::sops::SopsClient; use crate::source::{Layout, LineEnding, Node, NodePath, SourceDocument}; use crate::workspace::Workspace; @@ -22,7 +22,9 @@ const MARKER_SIZE: usize = 7; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ResolveOutcome { /// The merge was completed; ciphertext and plaintext were rewritten. - Resolved { data_key_rotated: bool }, + /// `recipient_drift` reports that the preserved recipient set still + /// differs from the manifest policy; `gitveil recipient` converges it. + Resolved { recipient_drift: bool }, /// Same-key conflicts remain: the plaintext holds a conflict document to /// edit, the working ciphertext was normalized to the local (ours) side, /// and `gitveil seal` finishes the resolution. @@ -101,30 +103,15 @@ fn resolve_entry( .and_then(|envelope| envelope.to_yaml()) .map(SecretBytes::new) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - // The alignment direction is decided from the ours-side envelope - // an edit would preserve. A removal re-encrypts the - // merged result directly under a fresh data key the removed party - // cannot unwrap; the old key never touches the merged values. + // Resolve is a data command: the merged envelope keeps the + // ours-side recipient set byte-for-byte in its wrapped keys. + // Authorization changes only through `gitveil recipient`. let ours_envelope = CiphertextEnvelope::parse(ours, entry.format()) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - let recipient_action = entry - .recipient_policy() - .diff(ours_envelope.age_recipients()) - .action(); - let data_key_rotated = matches!(recipient_action, RecipientAction::Rotate); - let ciphertext = match recipient_action { - RecipientAction::Rotate => { - sops.encrypt_new(&desired, path, entry.recipient_policy())? - } - RecipientAction::Aligned => sops.edit(ours, &desired, path)?, - RecipientAction::Rewrap => { - let edited = sops.edit(ours, &desired, path)?; - rewrap_updatekeys(sops, &edited, entry)? - } - }; + let ciphertext = sops.edit(ours, &desired, path)?; let envelope = CiphertextEnvelope::parse(&ciphertext, entry.format()) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - verify_recipient_policy(&envelope, entry)?; + verify_recipients_preserved(&ours_envelope, &envelope, path)?; let verified = decrypt_source(sops, &ciphertext, entry)?; if !verified.semantic_eq(&merged) { return Err(GitveilError::new( @@ -145,7 +132,11 @@ fn resolve_entry( ); store.save(path, &record)?; } - Ok(ResolveOutcome::Resolved { data_key_rotated }) + let recipient_drift = !entry + .recipient_policy() + .diff(envelope.age_recipients()) + .is_empty(); + Ok(ResolveOutcome::Resolved { recipient_drift }) } MergePlan::Conflict { path: node, @@ -161,6 +152,34 @@ fn resolve_entry( } } +/// The merge must not change authorization: the output envelope's +/// recipient set must equal the ours-side set it was edited from. +fn verify_recipients_preserved( + before: &CiphertextEnvelope, + after: &CiphertextEnvelope, + path: &ManagedPath, +) -> Result<()> { + let mut before = before + .age_recipients() + .iter() + .map(AgeRecipient::as_str) + .collect::>(); + let mut after = after + .age_recipients() + .iter() + .map(AgeRecipient::as_str) + .collect::>(); + before.sort_unstable(); + after.sort_unstable(); + if before == after { + return Ok(()); + } + Err(GitveilError::new( + ErrorCategory::Integrity, + format!("merge changed the envelope recipient set at {path}"), + )) +} + fn decrypt_stage( sops: &SopsClient, ciphertext: &[u8], diff --git a/src/seal.rs b/src/seal.rs index 9cd43b2..6b0105f 100644 --- a/src/seal.rs +++ b/src/seal.rs @@ -13,12 +13,12 @@ mod plan; pub(crate) use plan::ciphertext_has_conflict_markers; -use crate::recipient::RecipientAction; -use plan::{SealAction, SealBaseline, plan_seal, resembles_envelope, result_matches}; +use crate::recipient::RecipientSetDiff; +use plan::{SealAction, plan_seal, resembles_envelope, result_matches}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SealOutcome { - Sealed { data_key_rotated: bool }, + Sealed, Unchanged, } @@ -93,23 +93,29 @@ fn seal_entry( .map_err(|error| GitveilError::ciphertext(&cipher_path, &error.to_string())) }) .transpose()?; - // Every alignment direction requires decrypting the existing ciphertext, + // Seal is a data command and never changes authorization: any recipient + // drift fails closed here, keyless, before any decryption or effect. + // Authorization changes only through `gitveil recipient add/remove`. + match &baseline_envelope { + Some(envelope) => { + let drift = entry.recipient_policy().diff(envelope.age_recipients()); + if !drift.is_empty() { + return Err(recipient_drift_error(&cipher_path, entry, drift)); + } + } + // A first seal has no envelope to anchor authorization, so the + // policy's other existing ciphertexts serve as the anchor: drift + // there blocks encrypting new files to a possibly tampered policy. + None => ensure_policy_ciphertexts_aligned(workspace, entry)?, + } + // The incremental edit path requires decrypting the existing ciphertext, // so an unavailable identity fails here without touching any file; seal // never blindly rebuilds ciphertext it cannot read. let baseline_document = baseline_bytes .as_deref() .map(|bytes| decrypt_source(sops, bytes, entry)) .transpose()?; - let plan = plan_seal( - &desired, - baseline_envelope - .as_ref() - .zip(baseline_document.as_ref()) - .map(|(envelope, document)| SealBaseline { - document, - recipient_drift: entry.recipient_policy().diff(envelope.age_recipients()), - }), - ); + let action = plan_seal(&desired, baseline_document.as_ref()); let baseline_ciphertext = || { baseline_bytes.as_deref().ok_or_else(|| { @@ -119,36 +125,77 @@ fn seal_entry( ) }) }; - let mut ciphertext = match plan.action { - // First-time seal and rotation both encrypt the desired plaintext - // directly to the policy; a rotation's fresh data key is what makes - // the exclusion real, and pending edits ride along in the same commit - // without ever touching the old key. + let ciphertext = match action { SealAction::EncryptNew => sops.encrypt_new( &desired_envelope(&desired, path)?, path, entry.recipient_policy(), )?, - SealAction::PreserveBaseline => baseline_ciphertext()?.to_vec(), + SealAction::PreserveBaseline => { + // Aligned and semantically unchanged: byte-idempotent. + update_baseline(store, entry, &desired, baseline_ciphertext()?)?; + return Ok(SealOutcome::Unchanged); + } SealAction::EditExisting => sops.edit( baseline_ciphertext()?, &desired_envelope(&desired, path)?, path, )?, }; - if plan.recipient_action == RecipientAction::Rewrap { - ciphertext = rewrap_updatekeys(sops, &ciphertext, entry)?; - } else if matches!(plan.action, SealAction::PreserveBaseline) { - // Aligned and semantically unchanged: byte-idempotent. - update_baseline(store, entry, &desired, &ciphertext)?; - return Ok(SealOutcome::Unchanged); - } verify_result(sops, &ciphertext, &desired, entry)?; workspace.write_ciphertext(&cipher_path, &ciphertext)?; update_baseline(store, entry, &desired, &ciphertext)?; - Ok(SealOutcome::Sealed { - data_key_rotated: matches!(plan.recipient_action, RecipientAction::Rotate), - }) + Ok(SealOutcome::Sealed) +} + +fn recipient_drift_error( + cipher_path: &ManagedPath, + entry: &ResolvedManifestEntry<'_>, + drift: RecipientSetDiff, +) -> GitveilError { + GitveilError::configuration(format!( + "{cipher_path} recipient set differs from policy {} (add {}, remove {}); \ + data commands never change authorization; run gitveil recipient add or \ + gitveil recipient remove first", + entry.recipient_policy().name(), + drift.added, + drift.removed + )) +} + +/// First-seal policy cross-check: every other existing ciphertext under the +/// same policy must be aligned before a new file is encrypted to it. +fn ensure_policy_ciphertexts_aligned( + workspace: &Workspace, + entry: &ResolvedManifestEntry<'_>, +) -> Result<()> { + for other in workspace.manifest().entries() { + if other.path() == entry.path() || other.recipient_policy() != entry.policy_name() { + continue; + } + let cipher = other.ciphertext_path(); + let Some(bytes) = workspace.read(&cipher)? else { + continue; + }; + let envelope = CiphertextEnvelope::parse(&bytes, other.format()).map_err(|error| { + GitveilError::ciphertext( + &cipher, + &format!("cannot verify policy alignment before a first seal: {error}"), + ) + })?; + let drift = entry.recipient_policy().diff(envelope.age_recipients()); + if !drift.is_empty() { + return Err(GitveilError::configuration(format!( + "policy {} has drifted ciphertext at {cipher} (add {}, remove {}); \ + run gitveil recipient add or gitveil recipient remove before \ + sealing new files under this policy", + entry.policy_name(), + drift.added, + drift.removed + ))); + } + } + Ok(()) } fn update_baseline( @@ -218,46 +265,7 @@ fn verify_result( Ok(()) } -/// Rewraps the same data key to the entry policy via `updatekeys` and -/// verifies every encrypted leaf stayed byte-for-byte stable. -/// -/// This handles additions only; a removal must instead re-encrypt from its -/// plaintext truth source under a fresh data key. -pub(crate) fn rewrap_updatekeys( - sops: &SopsClient, - ciphertext: &[u8], - entry: &ResolvedManifestEntry<'_>, -) -> Result> { - let rewrapped = sops.rewrap(ciphertext, entry.recipient_policy())?; - verify_rewrap_result(ciphertext, &rewrapped, entry)?; - Ok(rewrapped) -} - -fn verify_rewrap_result( - before: &[u8], - after: &[u8], - entry: &ResolvedManifestEntry<'_>, -) -> Result<()> { - let before = CiphertextEnvelope::parse(before, entry.format()) - .map_err(|error| GitveilError::ciphertext(entry.path(), &error.to_string()))?; - let after = CiphertextEnvelope::parse(after, entry.format()) - .map_err(|error| GitveilError::ciphertext(entry.path(), &error.to_string()))?; - verify_recipient_policy(&after, entry)?; - if before.leaf_ciphertexts() != after.leaf_ciphertexts() - || before.layout_ciphertext() != after.layout_ciphertext() - { - return Err(GitveilError::new( - ErrorCategory::Integrity, - format!( - "SOPS recipient update changed encrypted data at {}", - entry.path() - ), - )); - } - Ok(()) -} - -pub(crate) fn verify_recipient_policy( +fn verify_recipient_policy( envelope: &CiphertextEnvelope, entry: &ResolvedManifestEntry<'_>, ) -> Result<()> { diff --git a/src/seal/plan.rs b/src/seal/plan.rs index 89c711a..a2e5da7 100644 --- a/src/seal/plan.rs +++ b/src/seal/plan.rs @@ -1,8 +1,10 @@ -//! //! Pure seal policy: turns the desired document and the decrypted state of -//! the existing ciphertext into a directly executable plan. +//! the existing ciphertext into a directly executable action. +//! +//! Recipient drift never reaches this plan: `seal` is a data command and +//! fails closed on any manifest/envelope recipient difference before +//! decrypting; authorization changes only through `gitveil recipient`. -use crate::recipient::{RecipientAction, RecipientSetDiff}; use crate::source::SourceDocument; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -12,47 +14,12 @@ pub(super) enum SealAction { EditExisting, } -/// The decrypted state of an existing ciphertext: its semantic document and -/// its recipient drift against the entry policy. -pub(super) struct SealBaseline<'a> { - pub document: &'a SourceDocument, - pub recipient_drift: RecipientSetDiff, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct SealPlan { - /// The executable action. Never contradicted by `recipient_action`: a - /// rotation collapses to [`SealAction::EncryptNew`] here. - pub action: SealAction, - pub recipient_action: RecipientAction, -} - -pub(super) fn plan_seal(desired: &SourceDocument, baseline: Option>) -> SealPlan { - let Some(baseline) = baseline else { - // No envelope exists: there is no recipient set to align and - // `EncryptNew` encrypts directly to the policy. - return SealPlan { - action: SealAction::EncryptNew, - recipient_action: RecipientAction::Aligned, - }; - }; - let recipient_action = baseline.recipient_drift.action(); - let action = match recipient_action { - // Exclusion re-encrypts the desired plaintext under a fresh data key, - // so the executable action is a new envelope regardless of - // whether the content changed. - RecipientAction::Rotate => SealAction::EncryptNew, - RecipientAction::Aligned | RecipientAction::Rewrap => { - if desired.semantic_eq(baseline.document) { - SealAction::PreserveBaseline - } else { - SealAction::EditExisting - } - } - }; - SealPlan { - action, - recipient_action, +pub(super) fn plan_seal(desired: &SourceDocument, baseline: Option<&SourceDocument>) -> SealAction { + match baseline { + // No envelope exists: encrypt directly to the policy. + None => SealAction::EncryptNew, + Some(existing) if desired.semantic_eq(existing) => SealAction::PreserveBaseline, + Some(_) => SealAction::EditExisting, } } @@ -82,90 +49,30 @@ pub(crate) fn ciphertext_has_conflict_markers(input: &[u8]) -> bool { #[cfg(test)] mod tests { use crate::config::SourceFormat; - use crate::source::parse; - - use crate::recipient::{RecipientAction, RecipientSetDiff}; use crate::source::SourceDocument; + use crate::source::parse; use super::{ - SealAction, SealBaseline, SealPlan, ciphertext_has_conflict_markers, plan_seal, - resembles_envelope, result_matches, + SealAction, ciphertext_has_conflict_markers, plan_seal, resembles_envelope, result_matches, }; fn document(value: &str) -> SourceDocument { parse(SourceFormat::Dotenv, format!("A={value}\n").as_bytes()).expect("document") } - const fn baseline(document: &SourceDocument, added: usize, removed: usize) -> SealBaseline<'_> { - SealBaseline { - document, - recipient_drift: RecipientSetDiff { added, removed }, - } - } - #[test] fn seal_plan_selects_new_preserve_and_edit_actions() { let desired = document("desired"); let same = document("desired"); let changed = document("baseline"); + assert_eq!(plan_seal(&desired, None), SealAction::EncryptNew); assert_eq!( - plan_seal(&desired, None), - SealPlan { - action: SealAction::EncryptNew, - recipient_action: RecipientAction::Aligned, - } - ); - assert_eq!( - plan_seal(&desired, Some(baseline(&same, 0, 0))), - SealPlan { - action: SealAction::PreserveBaseline, - recipient_action: RecipientAction::Aligned, - } - ); - assert_eq!( - plan_seal(&desired, Some(baseline(&changed, 0, 0))), - SealPlan { - action: SealAction::EditExisting, - recipient_action: RecipientAction::Aligned, - } - ); - } - - #[test] - fn recipient_drift_shapes_the_executable_action() { - let desired = document("desired"); - let same = document("desired"); - let changed = document("old"); - // Additions keep the incremental action; only the alignment differs. - assert_eq!( - plan_seal(&desired, Some(baseline(&same, 1, 0))), - SealPlan { - action: SealAction::PreserveBaseline, - recipient_action: RecipientAction::Rewrap, - } - ); - assert_eq!( - plan_seal(&desired, Some(baseline(&changed, 1, 0))), - SealPlan { - action: SealAction::EditExisting, - recipient_action: RecipientAction::Rewrap, - } - ); - // Any removal collapses to EncryptNew, even with unchanged content: - // the executable action never contradicts the rotation. - assert_eq!( - plan_seal(&desired, Some(baseline(&same, 0, 1))), - SealPlan { - action: SealAction::EncryptNew, - recipient_action: RecipientAction::Rotate, - } + plan_seal(&desired, Some(&same)), + SealAction::PreserveBaseline ); assert_eq!( - plan_seal(&desired, Some(baseline(&changed, 1, 1))), - SealPlan { - action: SealAction::EncryptNew, - recipient_action: RecipientAction::Rotate, - } + plan_seal(&desired, Some(&changed)), + SealAction::EditExisting ); } diff --git a/src/status.rs b/src/status.rs index 09d6767..ef284c7 100644 --- a/src/status.rs +++ b/src/status.rs @@ -119,14 +119,14 @@ impl fmt::Display for PairStatus { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}", self.data)?; if let RecipientStatus::Drift { policy, diff } = &self.recipients { - // Announce what the coming seal will do by consuming the same - // decision seal executes, not by re-deriving it. - let action = match diff.action() { - crate::recipient::RecipientAction::Rotate => { - "run gitveil seal (rotates the file data key)" - } - crate::recipient::RecipientAction::Aligned - | crate::recipient::RecipientAction::Rewrap => "run gitveil seal", + // Data commands fail closed on drift; only the authorization + // commands converge it, and a removal rotates the data key. + let action = if diff.removed > 0 && diff.added > 0 { + "run gitveil recipient add and gitveil recipient remove (removal rotates the file data key)" + } else if diff.removed > 0 { + "run gitveil recipient remove (rotates the file data key)" + } else { + "run gitveil recipient add" }; write!( formatter, diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs index 1149ab1..0f8900e 100644 --- a/tests/cli_contract.rs +++ b/tests/cli_contract.rs @@ -10,12 +10,20 @@ fn binary() -> std::path::PathBuf { } #[test] -fn help_lists_exactly_the_eight_public_commands() { +fn help_lists_exactly_the_nine_public_commands() { let output = command_output(Command::new(binary()).arg("--help")); assert_eq!(output.status.code(), Some(0)); let help = String::from_utf8(output.stdout).expect("help UTF-8"); for command in [ - "identity", "init", "add", "open", "seal", "status", "verify", "resolve", + "identity", + "init", + "add", + "recipient", + "open", + "seal", + "status", + "verify", + "resolve", ] { assert!(help.contains(command), "help must list {command}: {help}"); } @@ -63,6 +71,36 @@ fn identity_help_lists_generation_and_recipient_derivation() { assert!(!normalized.contains("SOPS_AGE_KEY_CMD")); } +#[test] +fn recipient_help_names_the_explicit_authorization_surface() { + let output = command_output(Command::new(binary()).args(["recipient", "--help"])); + assert_eq!(output.status.code(), Some(0)); + let help = String::from_utf8(output.stdout).expect("recipient help UTF-8"); + assert!( + help.lines() + .any(|line| line.trim_start().starts_with("add")) + ); + assert!( + help.lines() + .any(|line| line.trim_start().starts_with("remove")) + ); + + for subcommand in ["add", "remove"] { + let output = + command_output(Command::new(binary()).args(["recipient", subcommand, "--help"])); + assert_eq!(output.status.code(), Some(0)); + let help = String::from_utf8_lossy(&output.stdout); + assert!(help.contains("--policy"), "{subcommand} help: {help}"); + assert!(help.contains("--recipient"), "{subcommand} help: {help}"); + } + + // The recipient is the required, explicit authorization statement. + for subcommand in ["add", "remove"] { + let output = command_output(Command::new(binary()).args(["recipient", subcommand])); + assert_eq!(output.status.code(), Some(2), "{subcommand} without value"); + } +} + #[test] fn profile_is_exposed_only_on_the_scoped_pair_commands() { for command in ["open", "seal", "status"] { diff --git a/tests/integration_recipients.rs b/tests/integration_recipients.rs new file mode 100644 index 0000000..defc821 --- /dev/null +++ b/tests/integration_recipients.rs @@ -0,0 +1,456 @@ +//! `gitveil recipient add` / `gitveil recipient remove`: the explicit +//! authorization commands, and the fail-closed drift contract they anchor. + +pub mod support; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use gitveil::config::SourceFormat; +use gitveil::envelope::CiphertextEnvelope; +use gitveil::recipient::AgeRecipient; + +use support::{ + GitFixture, assert_success, command_output, contains, encrypted_leaf, generate_age_identity, + sops_binary, +}; + +const CANARY: &str = "gitveil-recipient-authorization-canary"; + +fn write(fixture: &GitFixture, path: &str, body: &str) { + fs::write(fixture.root().join(path), body).expect("write fixture file"); +} + +fn read(fixture: &GitFixture, path: &str) -> Vec { + fs::read(fixture.root().join(path)).expect("read fixture file") +} + +fn recipients_of(fixture: &GitFixture, cipher: &str) -> Vec { + let envelope = CiphertextEnvelope::parse(&read(fixture, cipher), SourceFormat::Dotenv) + .expect("valid envelope"); + let mut recipients = envelope + .age_recipients() + .iter() + .map(|recipient| recipient.as_str().to_owned()) + .collect::>(); + recipients.sort_unstable(); + recipients +} + +fn manifest_json(fixture: &GitFixture) -> serde_json::Value { + serde_json::from_slice(&read(fixture, ".gitveilrc.json")).expect("manifest JSON") +} + +/// A recipient whose private identity the fixture never holds: the attacker. +fn foreign_recipient() -> String { + let directory = tempfile::tempdir().expect("foreign identity directory"); + generate_age_identity(&directory.path().join("foreign.txt")) +} + +fn no_op_updatekeys_wrapper(directory: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = directory.join("sops"); + let real_sops = sops_binary().to_string_lossy().replace('\'', "'\\''"); + let script = format!( + "#!/bin/sh\nfor argument in \"$@\"; do\n if [ \"$argument\" = updatekeys ]; then\n exit 0\n fi\ndone\nexec '{real_sops}' \"$@\"\n" + ); + fs::write(&path, script).expect("write SOPS fault wrapper"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("secure SOPS fault wrapper"); + path +} + +#[test] +fn recipient_add_rewraps_every_policy_file_and_echoes_the_grant() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", &format!("A={CANARY}\nB=stable\n")); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let before = read(&fixture, "secret.env.gitveil"); + let plaintext_before = read(&fixture, "secret.env"); + + let (second, second_identity) = fixture.add_identity_with_path(); + let added = assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "recipient add", + ); + let stdout = String::from_utf8_lossy(&added.stdout); + // Authorization names its subject: the full public recipient. + assert!( + stdout.contains(&format!("policy team: added {second}")), + "stdout: {stdout}" + ); + assert!(stdout.contains("secret.env: rewrapped"), "stdout: {stdout}"); + + // Manifest and envelope both converged in one command. + assert_eq!( + manifest_json(&fixture)["recipientPolicies"]["team"]["age"] + .as_array() + .expect("age array") + .len(), + 2 + ); + let after = read(&fixture, "secret.env.gitveil"); + let mut expected = vec![fixture.recipient().to_owned(), second.clone()]; + expected.sort_unstable(); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil"), expected); + + // Additions rewrap the same data key: every leaf stays byte-stable. + for leaf in ["A", "B", "layout"] { + assert_eq!(encrypted_leaf(&after, leaf), encrypted_leaf(&before, leaf)); + } + // The authorization command never touches workspace plaintext. + assert_eq!(read(&fixture, "secret.env"), plaintext_before); + + // The newly authorized identity decrypts through the product binary. + let mut opened: Command = fixture.command_without_identity(fixture.binary()); + opened + .env("SOPS_AGE_KEY_FILE", &second_identity) + .args(["open"]); + assert_success(command_output(&mut opened), "open with the added identity"); + + // An idempotent rerun changes nothing. + let rerun = assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "idempotent rerun", + ); + let stdout = String::from_utf8_lossy(&rerun.stdout); + assert!(stdout.contains("already authorized"), "stdout: {stdout}"); + assert!(stdout.contains("already aligned"), "stdout: {stdout}"); + assert_eq!(read(&fixture, "secret.env.gitveil"), after); + + // Sealing still works after the convergence: no drift remains. + assert_success(fixture.run_gitveil(&["seal"]), "seal after add"); +} + +#[test] +fn recipient_remove_rotates_the_data_key_and_excludes_the_removed_identity() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", &format!("A={CANARY}\nB=two\n")); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + + // Snapshot the first identity alone before the fixture key file gains + // the second identity. + let removed_only = tempfile::tempdir().expect("removed identity directory"); + let removed_identity = removed_only.path().join("removed.txt"); + fs::copy(fixture.identity(), &removed_identity).expect("snapshot first identity"); + + let (second, second_identity) = fixture.add_identity_with_path(); + assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "authorize the second identity", + ); + let before_removal = read(&fixture, "secret.env.gitveil"); + + // A pending local edit must not be consumed by the authorization command. + write( + &fixture, + "secret.env", + &format!("A={CANARY}-edited\nB=two\n"), + ); + + let first = fixture.recipient().to_owned(); + let removed = assert_success( + fixture.run_gitveil(&["recipient", "remove", "--recipient", first.as_str()]), + "recipient remove", + ); + let stdout = String::from_utf8_lossy(&removed.stdout); + assert!( + stdout.contains(&format!("policy team: removed {first}")), + "stdout: {stdout}" + ); + assert!( + stdout.contains("secret.env: data key rotated"), + "stdout: {stdout}" + ); + + // A fresh data key re-encrypts every leaf. + let rotated = read(&fixture, "secret.env.gitveil"); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil"), vec![second]); + for leaf in ["A", "B", "layout"] { + assert_ne!( + encrypted_leaf(&rotated, leaf), + encrypted_leaf(&before_removal, leaf), + "leaf {leaf} must be re-encrypted under the fresh data key" + ); + } + // The local edit stayed local: the rotated ciphertext still carries the + // old value, and the plaintext still carries the edit for a later seal. + assert_eq!( + read(&fixture, "secret.env"), + format!("A={CANARY}-edited\nB=two\n").into_bytes() + ); + + // The kept identity decrypts; the local edit survives the open because + // the refreshed baseline still adjudicates it as a local-only change. + let mut kept: Command = fixture.command_without_identity(fixture.binary()); + kept.env("SOPS_AGE_KEY_FILE", &second_identity) + .args(["open"]); + assert_success(command_output(&mut kept), "open with the kept identity"); + assert_eq!( + read(&fixture, "secret.env"), + format!("A={CANARY}-edited\nB=two\n").into_bytes() + ); + let mut excluded: Command = fixture.command_without_identity(fixture.binary()); + excluded + .env("SOPS_AGE_KEY_FILE", &removed_identity) + .args(["open"]); + let output = command_output(&mut excluded); + assert_eq!( + output.status.code(), + Some(1), + "the removed identity must not decrypt the rotated envelope" + ); + assert!(!contains(&output.stdout, CANARY.as_bytes())); + assert!(!contains(&output.stderr, CANARY.as_bytes())); + + // The pending edit seals normally afterwards (drift is fully converged). + let sealed = assert_success(fixture.run_gitveil(&["seal"]), "seal the pending edit"); + assert!(String::from_utf8_lossy(&sealed.stdout).contains("sealed")); +} + +// The confused-deputy case D8 exists for: a manifest edit alone must never +// become effective authorization, even when an identity holder runs the +// authorization command for an unrelated recipient. +#[test] +fn an_injected_manifest_recipient_is_rejected_and_named() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", &format!("A={CANARY}\n")); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let ciphertext = read(&fixture, "secret.env.gitveil"); + + // The attacker writes their recipient into the tracked manifest. + let attacker = foreign_recipient(); + let first = fixture.recipient().to_owned(); + fixture.write_manifest_with_recipients( + &[("secret.env", "dotenv")], + &[first.as_str(), attacker.as_str()], + ); + let tampered_manifest = read(&fixture, ".gitveilrc.json"); + + // The user grants a legitimate new machine; the injected recipient is + // not named, so the whole command is rejected and names the attacker. + let (second, _) = fixture.add_identity_with_path(); + let refused = fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]); + assert_eq!(refused.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains(attacker.as_str()), + "the unexplained recipient must be named in full: {stderr}" + ); + assert!( + stderr.contains("did not add it"), + "the error explains the rejection: {stderr}" + ); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + assert_eq!(read(&fixture, ".gitveilrc.json"), tampered_manifest); + + // The attacker cannot decrypt, and daily seal stays blocked, so the + // injected recipient never becomes effective. + let refused_seal = fixture.run_gitveil(&["seal"]); + assert_eq!(refused_seal.status.code(), Some(1)); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); +} + +#[test] +fn an_injected_manifest_removal_requires_an_explicit_remove() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", "A=one\n"); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let (second, _) = fixture.add_identity_with_path(); + assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "authorize the second identity", + ); + + // The manifest is hand-edited to drop the second recipient (attacker or + // accident); an unrelated add must not silently revoke it. + let first = fixture.recipient().to_owned(); + fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[first.as_str()]); + let (third, _) = fixture.add_identity_with_path(); + let refused = fixture.run_gitveil(&["recipient", "add", "--recipient", third.as_str()]); + assert_eq!(refused.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!(stderr.contains(second.as_str()), "stderr: {stderr}"); + assert!( + stderr.contains("gitveil recipient remove"), + "stderr: {stderr}" + ); + + // Naming the removal explicitly converges it and rotates the data key. + let removed = assert_success( + fixture.run_gitveil(&["recipient", "remove", "--recipient", second.as_str()]), + "explicit removal", + ); + let stdout = String::from_utf8_lossy(&removed.stdout); + assert!( + stdout.contains(&format!( + "policy team: removed {second} (was present only on ciphertext)" + )), + "stdout: {stdout}" + ); + assert!( + stdout.contains("secret.env: data key rotated"), + "stdout: {stdout}" + ); + assert_eq!( + recipients_of(&fixture, "secret.env.gitveil"), + vec![first.clone()] + ); + assert_success(fixture.run_gitveil(&["seal"]), "seal after convergence"); +} + +// Mid-run failure semantics: the manifest publishes first, a failed file +// convergence leaves drift that seal refuses, and a rerun converges it. +#[test] +fn a_failed_convergence_leaves_refusable_drift_and_a_rerun_converges() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", &format!("A={CANARY}\n")); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let ciphertext = read(&fixture, "secret.env.gitveil"); + + let (second, _) = fixture.add_identity_with_path(); + let wrapper_directory = tempfile::tempdir().expect("SOPS fault wrapper directory"); + let wrapper = no_op_updatekeys_wrapper(wrapper_directory.path()); + let mut faulted = fixture.gitveil(); + faulted + .env("SOPS_BIN", wrapper) + .args(["recipient", "add", "--recipient", second.as_str()]); + let output = command_output(&mut faulted); + assert_eq!( + output.status.code(), + Some(1), + "a rewrap that did not produce the authorized set must fail" + ); + assert!(!contains(&output.stdout, CANARY.as_bytes())); + assert!(!contains(&output.stderr, CANARY.as_bytes())); + // The ciphertext is untouched; the manifest was already published, so + // the observable state is exactly recipient drift. + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + assert_eq!( + manifest_json(&fixture)["recipientPolicies"]["team"]["age"] + .as_array() + .expect("age array") + .len(), + 2 + ); + let refused_seal = fixture.run_gitveil(&["seal"]); + assert_eq!( + refused_seal.status.code(), + Some(1), + "drift left by the failure keeps seal fail-closed" + ); + + // The idempotent rerun (without the fault) converges the drift. + let rerun = assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "converging rerun", + ); + assert!(String::from_utf8_lossy(&rerun.stdout).contains("secret.env: rewrapped")); + assert_success(fixture.run_gitveil(&["seal"]), "seal after recovery"); +} + +// Preflight fails closed: without any usable identity nothing is published, +// including the manifest. +#[test] +fn no_identity_means_no_manifest_publication_and_no_file_changes() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", "A=one\n"); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let ciphertext = read(&fixture, "secret.env.gitveil"); + let manifest = read(&fixture, ".gitveilrc.json"); + + let (second, _) = fixture.add_identity_with_path(); + let mut command: Command = fixture.command_without_identity(fixture.binary()); + command.args(["recipient", "add", "--recipient", second.as_str()]); + let output = command_output(&mut command); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + assert_eq!(read(&fixture, ".gitveilrc.json"), manifest); +} + +#[test] +fn recipient_argument_validation_fails_before_any_effect() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", "A=one\n"); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let manifest = read(&fixture, ".gitveilrc.json"); + let first = fixture.recipient().to_owned(); + + // Invalid and private-identity values are rejected. + for invalid in ["not-a-recipient", "AGE-SECRET-KEY-PRIVATE"] { + let output = fixture.run_gitveil(&["recipient", "add", "--recipient", invalid]); + assert_eq!(output.status.code(), Some(1), "{invalid}"); + } + // Removing the last recipient would leave the policy unable to encrypt. + let output = fixture.run_gitveil(&["recipient", "remove", "--recipient", first.as_str()]); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("at least one recipient"), + "the emptied-policy rejection must be explained" + ); + // Removing an unknown recipient is rejected. + let unknown = foreign_recipient(); + let output = fixture.run_gitveil(&["recipient", "remove", "--recipient", unknown.as_str()]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(read(&fixture, ".gitveilrc.json"), manifest); + + // A usage error without any --recipient exits 2 through clap. + let output = fixture.run_gitveil(&["recipient", "add"]); + assert_eq!(output.status.code(), Some(2)); +} + +// Multi-file policies converge together; entries without ciphertext only +// change the manifest and are sealed to the converged policy afterwards. +#[test] +fn recipient_add_converges_multiple_files_and_predeclared_entries() { + let fixture = GitFixture::new(); + fixture.initialize(); + fixture.write_manifest_with_recipients( + &[ + ("secret.env", "dotenv"), + ("other.env", "dotenv"), + ("pending.env", "dotenv"), + ], + &[fixture.recipient()], + ); + write(&fixture, "secret.env", "A=one\n"); + write(&fixture, "other.env", "B=two\n"); + assert_success( + fixture.run_gitveil(&["seal", "secret.env", "other.env"]), + "seal existing files", + ); + + let (second, _) = fixture.add_identity_with_path(); + let added = assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "recipient add across the policy", + ); + let stdout = String::from_utf8_lossy(&added.stdout); + assert!(stdout.contains("secret.env: rewrapped"), "stdout: {stdout}"); + assert!(stdout.contains("other.env: rewrapped"), "stdout: {stdout}"); + assert!( + !stdout.contains("pending.env:"), + "entries without ciphertext have nothing to converge: {stdout}" + ); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil").len(), 2); + assert_eq!(recipients_of(&fixture, "other.env.gitveil").len(), 2); + + // The predeclared entry seals directly to the converged policy. + write(&fixture, "pending.env", "C=three\n"); + assert_success( + fixture.run_gitveil(&["seal", "pending.env"]), + "first seal after convergence", + ); + assert_eq!(recipients_of(&fixture, "pending.env.gitveil").len(), 2); + + let _ = AgeRecipient::new(second).expect("recipient remains canonical"); +} diff --git a/tests/integration_resolve.rs b/tests/integration_resolve.rs index 39bf2a1..3217de9 100644 --- a/tests/integration_resolve.rs +++ b/tests/integration_resolve.rs @@ -1,15 +1,12 @@ pub mod support; use std::fs; -use std::path::{Path, PathBuf}; use gitveil::config::SourceFormat; use gitveil::envelope::CiphertextEnvelope; use gitveil::recipient::AgeRecipient; -use support::{GitFixture, assert_success, command_output, contains, encrypted_leaf, sops_binary}; - -const CANARY: &str = "gitveil-resolve-recipient-canary"; +use support::{GitFixture, assert_success, contains, encrypted_leaf}; fn write(fixture: &GitFixture, path: &str, body: &str) { fs::write(fixture.root().join(path), body).expect("write fixture file"); @@ -19,35 +16,6 @@ fn read(fixture: &GitFixture, path: &str) -> Vec { fs::read(fixture.root().join(path)).expect("read fixture file") } -fn state_snapshot(fixture: &GitFixture) -> Vec<(std::ffi::OsString, Vec)> { - let mut files = fs::read_dir(fixture.state_dir()) - .expect("read state") - .map(|entry| { - let entry = entry.expect("state entry"); - ( - entry.file_name(), - fs::read(entry.path()).expect("read state entry"), - ) - }) - .collect::>(); - files.sort_by(|left, right| left.0.cmp(&right.0)); - files -} - -fn no_op_updatekeys_wrapper(directory: &Path) -> PathBuf { - use std::os::unix::fs::PermissionsExt; - - let path = directory.join("sops"); - let real_sops = sops_binary().to_string_lossy().replace('\'', "'\\''"); - let script = format!( - "#!/bin/sh\nfor argument in \"$@\"; do\n if [ \"$argument\" = updatekeys ]; then\n exit 0\n fi\ndone\nexec '{real_sops}' \"$@\"\n" - ); - fs::write(&path, script).expect("write SOPS fault wrapper"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) - .expect("secure SOPS fault wrapper"); - path -} - fn seal_and_commit(fixture: &GitFixture, body: &str, message: &str) { write(fixture, "secret.env", body); assert_success(fixture.run_gitveil(&["seal"]), "gitveil seal"); @@ -124,56 +92,11 @@ fn different_key_conflict_resolves_into_one_merged_pair() { ); } +// Resolve is a data command: the merged envelope keeps the ours-side +// recipient set even when the manifest policy drifted, and the remaining +// drift is reported for the authorization command to converge. #[test] -fn resolve_rejects_successful_updatekeys_output_with_the_wrong_recipients() { - let fixture = conflicted_fixture( - &format!("A={CANARY}-ours\nB=base\n"), - &format!("A=base\nB={CANARY}-theirs\n"), - ); - let ciphertext_before = read(&fixture, "secret.env.gitveil"); - let plaintext_before = read(&fixture, "secret.env"); - let state_before = state_snapshot(&fixture); - let index_before = assert_success( - fixture.run_git(&["ls-files", "-u", "--", "secret.env.gitveil"]), - "read conflicted index", - ) - .stdout; - - // Addition-only drift keeps resolve on the updatekeys path whose output - // this test corrupts; removals take the rotation path instead. - let second_recipient = fixture.add_identity(); - let first_recipient = fixture.recipient().to_owned(); - fixture.write_manifest_with_recipients( - &[("secret.env", "dotenv")], - &[first_recipient.as_str(), second_recipient.as_str()], - ); - let wrapper_directory = tempfile::tempdir().expect("SOPS fault wrapper directory"); - let wrapper = no_op_updatekeys_wrapper(wrapper_directory.path()); - let mut command = fixture.gitveil(); - command.env("SOPS_BIN", wrapper).arg("resolve"); - let output = command_output(&mut command); - - assert!( - !output.status.success(), - "wrong recipients must be rejected" - ); - assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext_before); - assert_eq!(read(&fixture, "secret.env"), plaintext_before); - assert_eq!(state_snapshot(&fixture), state_before); - let index_after = assert_success( - fixture.run_git(&["ls-files", "-u", "--", "secret.env.gitveil"]), - "read conflicted index after failure", - ) - .stdout; - assert_eq!(index_after, index_before); - assert!(!contains(&output.stdout, CANARY.as_bytes())); - assert!(!contains(&output.stderr, CANARY.as_bytes())); -} - -// An addition-only recipient drift rewraps the merged result in place; keys -// taking the ours value keep the ours leaf bytes. -#[test] -fn resolve_rewraps_recipient_additions_with_stable_leaves() { +fn resolve_preserves_ours_recipients_and_reports_policy_drift() { let fixture = conflicted_fixture("A=ours\nB=base\n", "A=base\nB=theirs\n"); let ours_cipher = assert_success( fixture.run_git(&["show", ":2:secret.env.gitveil"]), @@ -188,82 +111,48 @@ fn resolve_rewraps_recipient_additions_with_stable_leaves() { &[first.as_str(), second.as_str()], ); - let resolved = assert_success(fixture.run_gitveil(&["resolve"]), "gitveil resolve"); - let stdout = String::from_utf8_lossy(&resolved.stdout); - assert!(stdout.contains("resolved"), "stdout: {stdout}"); - assert!( - !stdout.contains("data key rotated"), - "additions must not rotate the data key: {stdout}" - ); - assert_eq!(read(&fixture, "secret.env"), b"A=ours\nB=theirs\n"); - - let merged_cipher = read(&fixture, "secret.env.gitveil"); - let envelope = CiphertextEnvelope::parse(&merged_cipher, SourceFormat::Dotenv) - .expect("rewrapped envelope"); - assert_eq!(envelope.age_recipients().len(), 2); - // The same data key is kept: the ours-side leaf stays byte-for-byte. + let resolved = fixture.run_gitveil(&["resolve"]); assert_eq!( - encrypted_leaf(&merged_cipher, "A"), - encrypted_leaf(&ours_cipher, "A") + resolved.status.code(), + Some(1), + "remaining drift is an attention outcome" ); -} - -// When the policy removed a recipient, the merged result is written under a -// fresh data key and the removed identity cannot decrypt it. -#[test] -fn resolve_rotates_the_data_key_when_the_policy_removed_a_recipient() { - let fixture = conflicted_fixture("A=ours\nB=base\n", "A=base\nB=theirs\n"); - let ours_cipher = assert_success( - fixture.run_git(&["show", ":2:secret.env.gitveil"]), - "read ours stage", - ) - .stdout; - - // Snapshot the original identity alone before the fixture key file gains - // the replacement identity. - let removed_only = tempfile::tempdir().expect("removed identity directory"); - let removed_identity = removed_only.path().join("removed.txt"); - fs::copy(fixture.identity(), &removed_identity).expect("snapshot first identity"); - - let (replacement, replacement_identity) = fixture.add_identity_with_path(); - fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[replacement.as_str()]); - - let resolved = assert_success(fixture.run_gitveil(&["resolve"]), "gitveil resolve"); let stdout = String::from_utf8_lossy(&resolved.stdout); assert!(stdout.contains("resolved"), "stdout: {stdout}"); assert!( - stdout.contains("data key rotated"), - "rotation must be reported: {stdout}" + stdout.contains("recipient drift remains"), + "stdout: {stdout}" ); + assert!(stdout.contains("gitveil recipient"), "stdout: {stdout}"); assert_eq!(read(&fixture, "secret.env"), b"A=ours\nB=theirs\n"); + // The merge changed data only: the ours-side recipient set is kept and + // keys taking the ours value keep the ours leaf bytes. let merged_cipher = read(&fixture, "secret.env.gitveil"); let envelope = - CiphertextEnvelope::parse(&merged_cipher, SourceFormat::Dotenv).expect("rotated envelope"); + CiphertextEnvelope::parse(&merged_cipher, SourceFormat::Dotenv).expect("merged envelope"); assert_eq!( envelope.age_recipients(), - &[AgeRecipient::new(&replacement).expect("recipient")] + &[AgeRecipient::new(&first).expect("recipient")] ); - // A fresh data key re-encrypts every leaf, including the ours-side value. - assert_ne!( + assert_eq!( encrypted_leaf(&merged_cipher, "A"), encrypted_leaf(&ours_cipher, "A") ); - // The kept identity decrypts the merged result; the removed one does not. - let mut kept = fixture.command_without_identity(fixture.binary()); - kept.env("SOPS_AGE_KEY_FILE", &replacement_identity) - .arg("open"); - assert_success(command_output(&mut kept), "open with the kept identity"); - let mut excluded = fixture.command_without_identity(fixture.binary()); - excluded - .env("SOPS_AGE_KEY_FILE", &removed_identity) - .arg("open"); - let output = command_output(&mut excluded); + // The authorization command converges the reported drift. + assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "converge drift after resolve", + ); + let converged = read(&fixture, "secret.env.gitveil"); + let envelope = + CiphertextEnvelope::parse(&converged, SourceFormat::Dotenv).expect("converged envelope"); + assert_eq!(envelope.age_recipients().len(), 2); assert_eq!( - output.status.code(), - Some(1), - "the removed identity must not decrypt the merged result" + encrypted_leaf(&converged, "A"), + encrypted_leaf(&ours_cipher, "A"), + "an addition-only convergence keeps the merged leaves" ); } diff --git a/tests/integration_seal_open.rs b/tests/integration_seal_open.rs index a0518b8..83fe4d1 100644 --- a/tests/integration_seal_open.rs +++ b/tests/integration_seal_open.rs @@ -271,212 +271,128 @@ fn repository_sops_config_is_ignored_even_when_it_is_invalid() { } #[test] -fn seal_rewraps_recipient_additions_without_changing_encrypted_data_leaves() { +fn seal_fails_closed_on_recipient_drift_in_both_directions() { let fixture = GitFixture::new(); fixture.initialize(); write(&fixture, "secret.env", "A=one\nB=stable\n"); assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); - let original = read(&fixture, "secret.env.gitveil"); - let original_a = encrypted_leaf(&original, "A"); - let original_b = encrypted_leaf(&original, "B"); - let original_layout = encrypted_leaf(&original, "layout"); + let ciphertext = read(&fixture, "secret.env.gitveil"); + let plaintext = read(&fixture, "secret.env"); + let state = state_snapshot(&fixture); let second = fixture.add_identity(); let first = fixture.recipient().to_owned(); - fixture.write_manifest_with_recipients( - &[("secret.env", "dotenv")], - &[first.as_str(), second.as_str()], - ); - let rewrapped = assert_success(fixture.run_gitveil(&["seal"]), "add recipient"); - let stdout = String::from_utf8_lossy(&rewrapped.stdout); - assert!(stdout.contains("sealed")); + for recipients in [ + vec![first.as_str(), second.as_str()], // addition direction + vec![second.as_str()], // removal direction + ] { + fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &recipients); + let output = fixture.run_gitveil(&["seal"]); + assert_eq!(output.status.code(), Some(1), "seal must fail closed"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("recipient set differs from policy team"), + "stderr: {stderr}" + ); + assert!( + stderr.contains("gitveil recipient"), + "the error must point to the authorization command: {stderr}" + ); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + assert_eq!(read(&fixture, "secret.env"), plaintext); + assert_eq!(state_snapshot(&fixture), state); + } + + // The refusal is keyless: it fires identically without any identity. + let mut keyless: Command = fixture.command_without_identity(fixture.binary()); + keyless.arg("seal"); + let output = command_output(&mut keyless); + assert_eq!(output.status.code(), Some(1)); assert!( - !stdout.contains("data key rotated"), - "additions must not rotate the data key" + String::from_utf8_lossy(&output.stderr).contains("recipient set differs"), + "the drift refusal must not require an identity" ); - let with_two = read(&fixture, "secret.env.gitveil"); - let envelope = CiphertextEnvelope::parse(&with_two, SourceFormat::Dotenv) - .expect("valid rewrapped envelope"); - assert_eq!(envelope.age_recipients().len(), 2); - assert_eq!(encrypted_leaf(&with_two, "A"), original_a); - assert_eq!(encrypted_leaf(&with_two, "B"), original_b); - assert_eq!(encrypted_leaf(&with_two, "layout"), original_layout); - - let unchanged = assert_success(fixture.run_gitveil(&["seal"]), "idempotent after rewrap"); - assert!(String::from_utf8_lossy(&unchanged.stdout).contains("unchanged")); - assert_eq!(read(&fixture, "secret.env.gitveil"), with_two); } -// A pure removal with unchanged content must still rotate; the seal must not -// degrade into the byte-idempotent unchanged path. +// A pending content edit must not ride around the drift refusal; after the +// explicit authorization converges the drift, the same seal succeeds. #[test] -fn seal_rotates_the_data_key_on_a_pure_removal_without_content_changes() { +fn content_edits_do_not_bypass_the_recipient_drift_refusal() { let fixture = GitFixture::new(); fixture.initialize(); - write(&fixture, "secret.env", "A=one\nB=two\n"); + write(&fixture, "secret.env", "A=one\nB=stable\n"); assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let before = read(&fixture, "secret.env.gitveil"); - let removed_only = tempfile::tempdir().expect("removed identity directory"); - let removed_identity = removed_only.path().join("removed.txt"); - fs::copy(fixture.identity(), &removed_identity).expect("snapshot first identity"); - - let (second, second_identity) = fixture.add_identity_with_path(); + let second = fixture.add_identity(); let first = fixture.recipient().to_owned(); fixture.write_manifest_with_recipients( &[("secret.env", "dotenv")], &[first.as_str(), second.as_str()], ); - assert_success(fixture.run_gitveil(&["seal"]), "add second recipient"); - let before_removal = read(&fixture, "secret.env.gitveil"); - - // Remove the first recipient; the plaintext is untouched. - fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[second.as_str()]); - let sealed = assert_success(fixture.run_gitveil(&["seal"]), "pure removal"); - let stdout = String::from_utf8_lossy(&sealed.stdout); - assert!( - stdout.contains("data key rotated"), - "a pure removal must rotate: {stdout}" - ); - assert!( - !stdout.contains("unchanged"), - "a pure removal must not report the idempotent path: {stdout}" - ); - - let rotated = read(&fixture, "secret.env.gitveil"); - let envelope = - CiphertextEnvelope::parse(&rotated, SourceFormat::Dotenv).expect("rotated envelope"); - assert_eq!( - envelope.age_recipients(), - &[AgeRecipient::new(&second).expect("recipient")] - ); - for leaf in ["A", "B", "layout"] { - assert_ne!( - encrypted_leaf(&rotated, leaf), - encrypted_leaf(&before_removal, leaf), - "leaf {leaf} must be re-encrypted under the fresh data key" - ); - } + write(&fixture, "secret.env", "A=changed\nB=stable\n"); + let refused = fixture.run_gitveil(&["seal"]); + assert_eq!(refused.status.code(), Some(1)); + assert_eq!(read(&fixture, "secret.env.gitveil"), before); + assert_eq!(read(&fixture, "secret.env"), b"A=changed\nB=stable\n"); - let mut kept: Command = fixture.command_without_identity(fixture.binary()); - kept.env("SOPS_AGE_KEY_FILE", &second_identity).arg("open"); - assert_success(command_output(&mut kept), "open with the kept identity"); - let mut excluded: Command = fixture.command_without_identity(fixture.binary()); - excluded - .env("SOPS_AGE_KEY_FILE", &removed_identity) - .arg("open"); - assert_eq!( - command_output(&mut excluded).status.code(), - Some(1), - "the removed identity must not decrypt the rotated envelope" + assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "authorize the addition explicitly", ); + assert_success(fixture.run_gitveil(&["seal"]), "seal after convergence"); + let after = read(&fixture, "secret.env.gitveil"); + assert_ne!(encrypted_leaf(&after, "A"), encrypted_leaf(&before, "A")); + assert_eq!(encrypted_leaf(&after, "B"), encrypted_leaf(&before, "B")); + let envelope = CiphertextEnvelope::parse(&after, SourceFormat::Dotenv).expect("valid envelope"); + assert_eq!(envelope.age_recipients().len(), 2); } -// Removing a recipient rotates the data key in the same seal that applies -// pending edits, and the removed identity really loses access to the -// new envelope. The exclusion assertion runs the product binary with the -// removed identity only. +// A first seal has no envelope to anchor authorization, so the policy's +// other existing ciphertexts are the anchor: drift there blocks encrypting +// new files until the authorization command converges it. #[test] -fn seal_rotates_the_data_key_when_a_recipient_is_removed() { +fn first_seal_requires_aligned_policy_siblings() { let fixture = GitFixture::new(); fixture.initialize(); - write(&fixture, "secret.env", "A=one\nB=stable\n"); - assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); - - // Snapshot the first identity alone before the fixture key file gains the - // second identity. - let removed_only = tempfile::tempdir().expect("removed identity directory"); - let removed_identity = removed_only.path().join("removed.txt"); - fs::copy(fixture.identity(), &removed_identity).expect("snapshot first identity"); + write(&fixture, "secret.env", "A=one\n"); + assert_success(fixture.run_gitveil(&["seal"]), "seal the existing file"); - let (second, second_identity) = fixture.add_identity_with_path(); + let second = fixture.add_identity(); let first = fixture.recipient().to_owned(); fixture.write_manifest_with_recipients( - &[("secret.env", "dotenv")], + &[("secret.env", "dotenv"), ("fresh.env", "dotenv")], &[first.as_str(), second.as_str()], ); - assert_success(fixture.run_gitveil(&["seal"]), "add second recipient"); - let before_removal = read(&fixture, "secret.env.gitveil"); + write(&fixture, "fresh.env", "B=two\n"); - // Remove the first recipient and edit a value in the same seal. - write( - &fixture, - "secret.env", - &format!("A={CANARY}-post-removal\nB=stable\n"), - ); - fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[second.as_str()]); - let sealed = assert_success(fixture.run_gitveil(&["seal"]), "remove recipient"); - assert!( - String::from_utf8_lossy(&sealed.stdout).contains("data key rotated"), - "rotation must be reported" - ); - - let rotated = read(&fixture, "secret.env.gitveil"); - let envelope = - CiphertextEnvelope::parse(&rotated, SourceFormat::Dotenv).expect("rotated envelope"); + let refused = fixture.run_gitveil(&["seal", "fresh.env"]); assert_eq!( - envelope.age_recipients(), - &[AgeRecipient::new(&second).expect("recipient")] - ); - // A fresh data key re-encrypts every leaf, including untouched ones. - assert_ne!( - encrypted_leaf(&rotated, "B"), - encrypted_leaf(&before_removal, "B") + refused.status.code(), + Some(1), + "first seal must fail closed" ); - assert_ne!( - encrypted_leaf(&rotated, "layout"), - encrypted_leaf(&before_removal, "layout") + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains("drifted ciphertext at secret.env.gitveil"), + "stderr: {stderr}" ); - - // A completed rotation is idempotent. - let unchanged = assert_success(fixture.run_gitveil(&["seal"]), "idempotent after rotation"); - assert!(String::from_utf8_lossy(&unchanged.stdout).contains("unchanged")); - assert_eq!(read(&fixture, "secret.env.gitveil"), rotated); - - // The kept identity still decrypts. - let mut kept: Command = fixture.command_without_identity(fixture.binary()); - kept.env("SOPS_AGE_KEY_FILE", &second_identity).arg("open"); - assert_success(command_output(&mut kept), "open with the kept identity"); - - // The removed identity is excluded from the rotated envelope. - let mut excluded: Command = fixture.command_without_identity(fixture.binary()); - excluded - .env("SOPS_AGE_KEY_FILE", &removed_identity) - .arg("open"); - let output = command_output(&mut excluded); - assert_eq!( - output.status.code(), - Some(1), - "the removed identity must not decrypt the rotated envelope" + assert!( + !fixture.root().join("fresh.env.gitveil").exists(), + "no ciphertext may be created under a drifted policy" ); - assert!(!contains(&output.stdout, CANARY.as_bytes())); - assert!(!contains(&output.stderr, CANARY.as_bytes())); -} -#[test] -fn content_edit_and_recipient_drift_commit_as_one_verified_ciphertext() { - let fixture = GitFixture::new(); - fixture.initialize(); - write(&fixture, "secret.env", "A=one\nB=stable\n"); - assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); - let before = read(&fixture, "secret.env.gitveil"); - - let second = fixture.add_identity(); - let first = fixture.recipient().to_owned(); - fixture.write_manifest_with_recipients( - &[("secret.env", "dotenv")], - &[first.as_str(), second.as_str()], + assert_success( + fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + "converge the policy", ); - write(&fixture, "secret.env", "A=changed\nB=stable\n"); - assert_success(fixture.run_gitveil(&["seal"]), "edit and rewrap"); - - let after = read(&fixture, "secret.env.gitveil"); - assert_ne!(encrypted_leaf(&after, "A"), encrypted_leaf(&before, "A")); - assert_eq!(encrypted_leaf(&after, "B"), encrypted_leaf(&before, "B")); - assert_eq!( - encrypted_leaf(&after, "layout"), - encrypted_leaf(&before, "layout") + assert_success( + fixture.run_gitveil(&["seal", "fresh.env"]), + "first seal after convergence", ); - let envelope = CiphertextEnvelope::parse(&after, SourceFormat::Dotenv).expect("valid envelope"); + let envelope = + CiphertextEnvelope::parse(&read(&fixture, "fresh.env.gitveil"), SourceFormat::Dotenv) + .expect("valid envelope"); assert_eq!(envelope.age_recipients().len(), 2); } @@ -491,11 +407,12 @@ fn seal_without_any_envelope_identity_leaves_all_three_files_unchanged() { let second = fixture.add_identity(); let first = fixture.recipient().to_owned(); - // Both drift directions require decrypting the existing ciphertext first - // (rewrap and rotation alike), so both fail closed without an identity. + // Recipient drift now fails closed before any decryption, and a seal + // without drift still needs an identity to decrypt its baseline; both + // directions leave every file untouched without an identity. for recipients in [ - vec![first.as_str(), second.as_str()], // addition: rewrap direction - vec![second.as_str()], // removal: rotation direction + vec![first.as_str(), second.as_str()], // addition: drift refusal + vec![second.as_str()], // removal: drift refusal ] { fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &recipients); let mut command: Command = fixture.command_without_identity(fixture.binary()); diff --git a/tests/integration_status.rs b/tests/integration_status.rs index 8384eba..48bc59e 100644 --- a/tests/integration_status.rs +++ b/tests/integration_status.rs @@ -208,7 +208,12 @@ fn status_reports_recipient_drift_alongside_data_state() { assert!(stdout.contains("policy team"), "stdout: {stdout}"); assert!(stdout.contains("add 1"), "stdout: {stdout}"); assert!(stdout.contains("remove 0"), "stdout: {stdout}"); - // Additions rewrap in place; only removals rotate the data key. + // Data commands fail closed on drift; status points to the + // authorization command, and only removals rotate the data key. + assert!( + stdout.contains("run gitveil recipient add"), + "stdout: {stdout}" + ); assert!( !stdout.contains("rotates the file data key"), "stdout: {stdout}" @@ -226,10 +231,16 @@ fn status_reports_recipient_drift_alongside_data_state() { assert!(stdout.contains("local edits"), "stdout: {stdout}"); assert!(stdout.contains("recipient drift"), "stdout: {stdout}"); - // A drift that removes a recipient announces the coming rotation. + // A drift that removes a recipient announces the coming rotation; this + // one also adds, so both authorization commands are named. fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[second.as_str()]); let (_, stdout) = status_line(&fixture); + assert!(stdout.contains("add 1"), "stdout: {stdout}"); assert!(stdout.contains("remove 1"), "stdout: {stdout}"); + assert!( + stdout.contains("gitveil recipient add and gitveil recipient remove"), + "stdout: {stdout}" + ); assert!( stdout.contains("rotates the file data key"), "stdout: {stdout}" From dbc117639c54c53b83f81a3528b29fa4e4936653 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 16:59:14 +0800 Subject: [PATCH 2/3] Respond to review: per-direction authorization, non-extending resolve, honest output recipient add/remove now converge each envelope only in their own direction (envelope union/minus the named recipients), so mixed drift converges as two sequential one-sided commands instead of deadlocking; recipients are positional arguments. resolve encrypts merged content to the intersection of the ours envelope and the policy, rotating the data key so removed recipients never receive merged values, and refuses a disjoint policy. Policy-level output renders after per-file effects and only for published state; partial failures are called out and a faulted removal no longer claims an effective revocation. The all-failed preflight path keeps per-file reports and withholds the manifest. The manifest read-modify-republish boundary moved to manifest_document, and ciphertext writes share the workspace write policy. --- README.md | 14 +- docs/getting-started.md | 2 +- src/cli.rs | 209 +++++++++++----- src/configure.rs | 297 ++--------------------- src/lib.rs | 1 + src/manifest.rs | 4 - src/manifest_document.rs | 310 ++++++++++++++++++++++++ src/recipients.rs | 283 ++++++++++++---------- src/recipients/plan.rs | 413 +++++++++++++++++--------------- src/resolve.rs | 86 +++++-- src/seal.rs | 6 +- src/workspace.rs | 66 ++--- tests/cli_contract.rs | 4 +- tests/integration_recipients.rs | 365 ++++++++++++++++++++++------ tests/integration_resolve.rs | 127 +++++++++- tests/integration_seal_open.rs | 38 +-- 16 files changed, 1405 insertions(+), 820 deletions(-) create mode 100644 src/manifest_document.rs diff --git a/README.md b/README.md index c80a51c..b608cac 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,16 @@ Gitveil generates native age identities only when `gitveil identity generate` is Authorization changes only through the explicit recipient commands. To add a member or machine, name its public recipient on the command line: ```bash -gitveil recipient add --policy team --recipient age1... +gitveil recipient add age1... ``` The command updates the manifest policy and rewraps every affected ciphertext in one step; addition-only changes keep the existing data key and preserve encrypted leaf bytes. To remove a member, first rotate the actual secret values in plaintext and seal them, then revoke the recipient: ```bash -gitveil recipient remove --policy team --recipient age1... +gitveil recipient remove age1... ``` -A removal re-encrypts every affected file under a fresh data key and reports `data key rotated`; neither later values nor the new key are decryptable by the removed identity. Editing `.gitveilrc.json` by hand grants nothing: the resulting drift blocks `seal` until a recipient command names the difference explicitly, and a recipient the command line does not name is rejected with its full value printed for review. +A removal re-encrypts every affected file under a fresh data key and reports `data key rotated`; neither later values nor the new key are decryptable by the removed identity. Each command changes envelopes only in its own direction and only for the recipients it names: `add` never removes anyone, `remove` never grants anyone. Editing `.gitveilrc.json` by hand grants nothing: an unnamed recipient is never wrapped, and the remaining difference is printed in full as residual drift that keeps `seal` fail-closed until an explicit `recipient add` or `recipient remove` converges it. Drift in both directions at once converges as two sequential commands, in either order. ## Daily usage @@ -171,8 +171,8 @@ gitveil seal gitveil seal --profile dev # Change who can decrypt: the only authorization entry points -gitveil recipient add --recipient age1... -gitveil recipient remove --policy team --recipient age1... +gitveil recipient add age1... +gitveil recipient remove --policy team age1... # Ciphertext to plaintext: merge by key against the local baseline gitveil open @@ -212,14 +212,14 @@ git add .gitveil git commit ``` -Gitveil reads index stages but never runs `git add`. The merged ciphertext keeps the recipient set of the local (ours) side; if that set differs from the manifest policy, `resolve` reports the remaining drift for `gitveil recipient` to converge. +Gitveil reads index stages but never runs `git add`. The merged ciphertext is encrypted to the intersection of the local (ours) recipient set and the manifest policy: a recipient the policy already removed never receives the merged values (`resolve` rotates the data key to exclude it), and no new recipient is granted anything. Pending additions are reported for `gitveil recipient add` to converge; a policy with no recipient in common with the local envelope makes `resolve` refuse until authorization is converged first. ## Security boundaries - Secret values, private identities, and decrypted comments never enter argv, Git configuration, errors, reports, baselines, or tracked metadata. - Source keys, hierarchy, scalar types, recipients, and approximate ciphertext lengths are public metadata. - Gitveil enforces SOPS `encrypted_regex: .*`; every source scalar and layout/comment value is encrypted. -- Data commands never change authorization. A recipient gains the ability to decrypt existing ciphertext only when an identity holder names it explicitly in `gitveil recipient add`; manifest edits alone produce drift that blocks `seal` instead of granting access. +- Data commands never extend access. A recipient gains the ability to decrypt existing or merged ciphertext only when an identity holder names it explicitly in `gitveil recipient add`; manifest edits alone produce drift that blocks `seal` instead of granting access, and `resolve` narrows merged content to recipients the policy still authorizes. - Each ciphertext file uses one data key. `gitveil recipient remove` rotates that key, preventing the removed identity from decrypting later versions. Access to historical versions cannot be revoked; forward exclusion also requires rotating the actual secret values. - Before the first ciphertext exists under a policy, no cryptographic anchor can detect manifest tampering: the first seal encrypts to the policy as committed. Review every change to `.gitveilrc.json`, and seal promptly after `init`. Once a policy has ciphertext, new files refuse to seal while any of it shows drift. - Paths undergo workspace-relative validation and descriptor-relative confinement; symlink and submodule escapes are rejected. diff --git a/docs/getting-started.md b/docs/getting-started.md index 29d3200..53f4c22 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,7 +43,7 @@ gitveil init \ --recipient age1bob... ``` -Recipients in one policy provide OR authorization: any matching identity can decrypt. After initialization, membership changes only through the explicit authorization commands: `gitveil recipient add --recipient age1...` grants access and rewraps every affected ciphertext, and `gitveil recipient remove --recipient age1...` revokes access and rotates each file's data key. Editing `.gitveilrc.json` by hand grants nothing; the resulting drift blocks `seal` until a recipient command names the difference. Removing or rotating a current recipient cannot revoke access to ciphertext already present in Git history. +Recipients in one policy provide OR authorization: any matching identity can decrypt. After initialization, membership changes only through the explicit authorization commands: `gitveil recipient add age1...` grants access and rewraps every affected ciphertext, and `gitveil recipient remove age1...` revokes access and rotates each file's data key. Each command converges only the recipients it names; editing `.gitveilrc.json` by hand grants nothing, and the remaining difference blocks `seal` until a recipient command names it. Removing or rotating a current recipient cannot revoke access to ciphertext already present in Git history. ## Complete repository flow diff --git a/src/cli.rs b/src/cli.rs index dfba8b2..deae6f4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,7 @@ use crate::configure::{AddOutcomeKind, add, initialize}; use crate::error::{ErrorCategory, GitveilError, Result}; use crate::identity; use crate::open::{OpenOutcome, open}; -use crate::recipients::{self, GrantEcho, RecipientFileOutcome}; +use crate::recipients::{self, ManifestDisposition, PolicyDelta, RecipientFileOutcome}; use crate::resolve::{ResolveOutcome, resolve}; use crate::runtime::{read_editor_token, run_internal_editor}; use crate::seal::{SealOutcome, seal}; @@ -19,8 +19,8 @@ Examples: gitveil identity generate gitveil init --recipient age1... gitveil add .env --format dotenv - gitveil recipient add --recipient age1... - gitveil recipient remove --policy team --recipient age1... + gitveil recipient add age1... + gitveil recipient remove --policy team age1... gitveil open gitveil seal --profile dev gitveil seal packages/service/.env @@ -127,23 +127,23 @@ enum Command { #[derive(Debug, Subcommand)] enum RecipientCommand { - /// Grant decryption to explicitly named public age recipients + /// Grant decryption to the named public age recipients Add { /// Named recipient policy (inferred only when exactly one exists) #[arg(long, value_name = "POLICY")] policy: Option, - /// Public age X25519 recipient to authorize (repeatable) - #[arg(long, required = true, action = clap::ArgAction::Append, value_name = "AGE_RECIPIENT")] - recipient: Vec, + /// Public age X25519 recipients to authorize + #[arg(required = true, value_name = "AGE_RECIPIENT")] + recipients: Vec, }, - /// Revoke recipients and rotate each affected file's data key + /// Revoke the named recipients and rotate each affected file's data key Remove { /// Named recipient policy (inferred only when exactly one exists) #[arg(long, value_name = "POLICY")] policy: Option, - /// Public age X25519 recipient to revoke (repeatable) - #[arg(long, required = true, action = clap::ArgAction::Append, value_name = "AGE_RECIPIENT")] - recipient: Vec, + /// Public age X25519 recipients to revoke + #[arg(required = true, value_name = "AGE_RECIPIENT")] + recipients: Vec, }, } @@ -320,65 +320,144 @@ fn run_recipient( command: RecipientCommand, ) -> Result { let outcome = match command { - RecipientCommand::Add { policy, recipient } => { - recipients::add(current, gitveil_binary, policy.as_deref(), &recipient)? + RecipientCommand::Add { policy, recipients } => { + recipients::add(current, gitveil_binary, policy.as_deref(), &recipients)? } - RecipientCommand::Remove { policy, recipient } => { - recipients::remove(current, gitveil_binary, policy.as_deref(), &recipient)? + RecipientCommand::Remove { policy, recipients } => { + recipients::remove(current, gitveil_binary, policy.as_deref(), &recipients)? } }; - let styled = stdout_styled(); - // Authorization always names its subject: the full public recipient. - for grant in &outcome.grants { - match grant { - GrantEcho::Added(recipient) => { - println!("policy {}: added {recipient}", outcome.policy); - } - GrantEcho::AlreadyAuthorized(recipient) => { - println!("policy {}: {recipient} already authorized", outcome.policy); - } - GrantEcho::Removed(recipient) => { - println!("policy {}: removed {recipient}", outcome.policy); - } - GrantEcho::RemovedFromCiphertext(recipient) => { - println!( - "policy {}: removed {recipient} (was present only on ciphertext)", - outcome.policy - ); + let rendered = render_recipient_reports(&outcome.reports); + let mut run = rendered.run; + + match outcome.manifest { + ManifestDisposition::Withheld => { + run = RunOutcome::Attention; + eprintln!( + "{}", + paint( + "gitveil: nothing was published; fix the errors above and retry", + Tone::Bad, + stderr_styled() + ) + ); + } + ManifestDisposition::Published | ManifestDisposition::Unchanged => { + for delta in &outcome.deltas { + match delta { + PolicyDelta::AddedToPolicy(recipient) => { + println!("policy {}: added {recipient}", outcome.policy); + } + PolicyDelta::AlreadyInPolicy(recipient) => { + println!("policy {}: {recipient} already in policy", outcome.policy); + } + PolicyDelta::RemovedFromPolicy(recipient) => { + println!("policy {}: removed {recipient}", outcome.policy); + } + PolicyDelta::RemovedFromCiphertextOnly(recipient) => { + println!( + "policy {}: removed {recipient} (was present only on ciphertext)", + outcome.policy + ); + } + } } } } - let mut run = RunOutcome::Success; - for report in &outcome.reports { + + if !rendered.pending_additions.is_empty() { + println!( + "next: gitveil recipient add {}", + rendered.pending_additions.join(" ") + ); + } + if !rendered.pending_removals.is_empty() { + println!( + "next: gitveil recipient remove {}", + rendered.pending_removals.join(" ") + ); + } + if rendered.failed && outcome.manifest == ManifestDisposition::Published { + eprintln!( + "{}", + paint( + "gitveil: authorization is not fully effective; rerun gitveil recipient after fixing the errors above", + Tone::Attention, + stderr_styled() + ) + ); + } + Ok(run) +} + +struct RenderedRecipientReports { + run: RunOutcome, + failed: bool, + pending_additions: Vec, + pending_removals: Vec, +} + +/// Per-file effects render first: every later policy-level line is a claim +/// about state the reader has already seen committed or failed. +fn render_recipient_reports( + reports: &[crate::recipients::RecipientFileReport], +) -> RenderedRecipientReports { + let styled = stdout_styled(); + let mut rendered = RenderedRecipientReports { + run: RunOutcome::Success, + failed: false, + pending_additions: Vec::new(), + pending_removals: Vec::new(), + }; + for report in reports { match &report.result { - Ok(RecipientFileOutcome::Rewrapped) => { - println!( - "{}: {}", - report.path, - paint("rewrapped", Tone::Good, styled) - ); - } - Ok(RecipientFileOutcome::DataKeyRotated) => { - println!( - "{}: {}", - report.path, - paint("data key rotated", Tone::Good, styled) - ); - } - Ok(RecipientFileOutcome::AlreadyAligned) => { - println!( - "{}: {}", - report.path, - paint("already aligned", Tone::Quiet, styled) - ); + Ok(convergence) => { + let label = match convergence.outcome { + RecipientFileOutcome::Rewrapped => "rewrapped", + RecipientFileOutcome::DataKeyRotated => "data key rotated", + RecipientFileOutcome::NoChangeNeeded => "no change needed", + }; + let mut drift = Vec::new(); + for recipient in &convergence.pending_additions { + drift.push(format!("add {recipient}")); + push_unique(&mut rendered.pending_additions, recipient.as_str()); + } + for recipient in &convergence.pending_removals { + drift.push(format!("remove {recipient}")); + push_unique(&mut rendered.pending_removals, recipient.as_str()); + } + let (line, tone) = if drift.is_empty() { + ( + label.to_owned(), + if convergence.outcome == RecipientFileOutcome::NoChangeNeeded { + Tone::Quiet + } else { + Tone::Good + }, + ) + } else { + rendered.run = RunOutcome::Attention; + ( + format!("{label}; drift remains ({})", drift.join("; ")), + Tone::Attention, + ) + }; + println!("{}: {}", report.path, paint(&line, tone, styled)); } Err(error) => { - run = RunOutcome::Attention; + rendered.run = RunOutcome::Attention; + rendered.failed = true; report_error(&report.path, error); } } } - Ok(run) + rendered +} + +fn push_unique(values: &mut Vec, value: &str) { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_owned()); + } } fn run_initialize(current: &Path, policy: &str, recipients: &[String]) -> Result { @@ -576,12 +655,16 @@ fn run_resolve(current: &Path, gitveil_binary: &Path, paths: &[String]) -> Resul let mut outcome = RunOutcome::Success; for report in resolve(&workspace, paths)? { match report.result { - Ok(ResolveOutcome::Resolved { recipient_drift }) => { - println!( - "{}: {}", - report.path, - paint("resolved; review and git add", Tone::Good, styled) - ); + Ok(ResolveOutcome::Resolved { + data_key_rotated, + recipient_drift, + }) => { + let label = if data_key_rotated { + "resolved; data key rotated; review and git add" + } else { + "resolved; review and git add" + }; + println!("{}: {}", report.path, paint(label, Tone::Good, styled)); if recipient_drift { outcome = RunOutcome::Attention; println!( diff --git a/src/configure.rs b/src/configure.rs index 0f38e6d..a36b2d3 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -4,17 +4,21 @@ mod plan; use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::{Read, Write}; use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Duration; use crate::config::SourceFormat; use crate::confine; use crate::envelope::CiphertextEnvelope; use crate::error::{ErrorCategory, GitveilError, Result}; -use crate::git::{IgnoreMatch, Repository, RepositoryDiscoveryError}; +use crate::git::{IgnoreMatch, Repository}; use crate::manifest::{CIPHERTEXT_SUFFIX, GITIGNORE_FILE_NAME, MANIFEST_FILE_NAME, Manifest}; +use crate::manifest_document::{ + DEFAULT_MODE, ManifestPreparation, NoPublicationFault, PublicationFault, file_mode, + load_manifest, mutation_repository, read_optional, select_recipient_policy, serialize_manifest, + sync_directory, write_atomic, write_atomic_with_fault, +}; use crate::path::ManagedPath; use crate::profile::ProfileName; use crate::recipient::{AgeRecipient, AgeRecipientPolicy, PolicyName}; @@ -27,23 +31,6 @@ use plan::{ }; const LOCK_TIMEOUT: Duration = Duration::from_secs(30); -const DEFAULT_MODE: u32 = 0o644; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PublicationPoint { - BeforePersist, - BeforeDirectorySync, -} - -trait PublicationFault { - fn check(&self, _path: &ManagedPath, _point: PublicationPoint) -> Result<()> { - Ok(()) - } -} - -struct NoPublicationFault; - -impl PublicationFault for NoPublicationFault {} pub(crate) struct InitOutcome { policy: PolicyName, @@ -183,12 +170,6 @@ fn add_with_publication_fault( Ok(add_outcomes(&plan, &existing_plaintext)) } -pub(crate) struct ManifestPreparation { - pub(crate) path: ManagedPath, - pub(crate) original: Vec, - pub(crate) mode: u32, -} - struct IgnorePreparation { path: ManagedPath, original: Option>, @@ -204,27 +185,6 @@ struct RegistrationPublication<'a> { manifest_candidate: &'a [u8], } -pub(crate) fn load_manifest(root: &Path) -> Result<(ManifestPreparation, Manifest)> { - let path = ManagedPath::new(MANIFEST_FILE_NAME) - .map_err(|error| GitveilError::configuration(error.to_string()))?; - let bytes = read_optional(root, &path)?.ok_or_else(|| { - GitveilError::configuration(format!( - "{MANIFEST_FILE_NAME} not found at the repository root; run gitveil init first" - )) - })?; - let manifest = - Manifest::parse(&bytes).map_err(|error| GitveilError::configuration(error.to_string()))?; - let mode = file_mode(root, &path)?.unwrap_or(DEFAULT_MODE); - Ok(( - ManifestPreparation { - path, - original: bytes, - mode, - }, - manifest, - )) -} - fn registration_plan( manifest: Manifest, paths: &[String], @@ -387,9 +347,9 @@ fn finish_registration_publication( } write_atomic_with_fault( root, - &publication.manifest.path, + publication.manifest.path(), publication.manifest_candidate, - publication.manifest.mode, + publication.manifest.mode(), true, fault, ) @@ -430,15 +390,6 @@ fn ignore_states(matches: HashMap) -> HashMap Result> { - manifest.to_json_bytes().map_err(|_| { - GitveilError::new( - ErrorCategory::Integrity, - "Gitveil manifest writer violated its internal reader contract", - ) - }) -} - fn ensure_configuration_fresh( root: &Path, manifest: &ManifestPreparation, @@ -457,7 +408,7 @@ fn ensure_configuration_fresh( } fn manifest_is_fresh(root: &Path, manifest: &ManifestPreparation) -> Result { - Ok(read_optional(root, &manifest.path)?.as_deref() == Some(manifest.original.as_slice())) + manifest.is_fresh(root) } fn ensure_ignore_candidate_fresh(root: &Path, ignore: &IgnorePreparation) -> Result<()> { @@ -552,194 +503,6 @@ fn add_outcomes( .collect() } -pub(crate) fn select_recipient_policy( - manifest: &Manifest, - requested: Option<&str>, - flag: &str, -) -> Result { - if let Some(requested) = requested { - let requested = PolicyName::new(requested) - .map_err(|error| GitveilError::configuration(error.to_string()))?; - if manifest.recipient_policy(&requested).is_none() { - return Err(GitveilError::configuration(format!( - "recipient policy {requested} is not declared in {MANIFEST_FILE_NAME}" - ))); - } - return Ok(requested); - } - if let Some(only) = manifest.only_policy_name() { - return Ok(only.clone()); - } - let names = manifest - .policy_names() - .into_iter() - .map(PolicyName::as_str) - .collect::>() - .join(", "); - if names.is_empty() { - Err(GitveilError::configuration(format!( - "{MANIFEST_FILE_NAME} has no recipient policy; declare one before adding files" - ))) - } else { - Err(GitveilError::configuration(format!( - "multiple recipient policies are declared ({names}); pass {flag}" - ))) - } -} - -pub(crate) fn mutation_repository(current: &Path) -> Result { - let repository = match Repository::discover(current) { - Ok(repository) => repository, - Err(RepositoryDiscoveryError::NotRepository) => { - if fs::symlink_metadata(current.join(".git")).is_ok() { - return Err(GitveilError::new( - ErrorCategory::Process, - "Git repository metadata exists but could not be read", - )); - } - return Err(GitveilError::configuration( - "no Git repository metadata found in the current directory; run this command from a Git repository root", - )); - } - Err(RepositoryDiscoveryError::Failure(error)) => return Err(error), - }; - let current = current.canonicalize().map_err(|error| { - GitveilError::io( - "resolve current directory", - Some(current.to_path_buf()), - &error, - ) - })?; - let root = repository.root().canonicalize().map_err(|error| { - GitveilError::io( - "resolve Git repository root", - Some(repository.root().to_path_buf()), - &error, - ) - })?; - if current != root { - return Err(GitveilError::configuration(format!( - "configuration commands must be run from the Git repository root: {}", - root.display() - ))); - } - let marker = root.join(".git"); - let metadata = fs::symlink_metadata(&marker).map_err(|_| { - GitveilError::configuration( - "no Git repository metadata found in the current directory; expected .git", - ) - })?; - let file_type = metadata.file_type(); - if file_type.is_symlink() || !(file_type.is_file() || file_type.is_dir()) { - return Err(GitveilError::configuration( - "Git repository metadata .git must be a regular file or directory", - )); - } - repository.ensure_supported_version()?; - Ok(repository) -} - -pub(crate) fn read_optional(root: &Path, path: &ManagedPath) -> Result>> { - let Some(mut file) = confine::open_existing(root, path)? else { - return Ok(None); - }; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes).map_err(|error| { - GitveilError::io( - "read repository configuration", - Some(path.join_to(root)), - &error, - ) - })?; - Ok(Some(bytes)) -} - -fn file_mode(root: &Path, path: &ManagedPath) -> Result> { - let Some(file) = confine::open_existing(root, path)? else { - return Ok(None); - }; - file.metadata() - .map(|metadata| Some(metadata.permissions().mode() & 0o777)) - .map_err(|error| { - GitveilError::io( - "read repository file metadata", - Some(path.join_to(root)), - &error, - ) - }) -} - -pub(crate) fn write_atomic( - root: &Path, - path: &ManagedPath, - bytes: &[u8], - mode: u32, - replace: bool, -) -> Result<()> { - write_atomic_with_fault(root, path, bytes, mode, replace, &NoPublicationFault) -} - -fn write_atomic_with_fault( - root: &Path, - path: &ManagedPath, - bytes: &[u8], - mode: u32, - replace: bool, - fault: &F, -) -> Result<()> { - confine::validate_managed_path(root, path)?; - let target = path.join_to(root); - let parent = target - .parent() - .ok_or_else(|| GitveilError::configuration("repository path has no parent"))?; - let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { - GitveilError::io( - "create repository configuration replacement", - Some(parent.to_path_buf()), - &error, - ) - })?; - temporary - .as_file() - .set_permissions(fs::Permissions::from_mode(mode)) - .and_then(|()| temporary.write_all(bytes)) - .and_then(|()| temporary.as_file_mut().sync_all()) - .map_err(|error| { - GitveilError::io( - "write repository configuration replacement", - Some(target.clone()), - &error, - ) - })?; - fault.check(path, PublicationPoint::BeforePersist)?; - if replace { - temporary.persist(&target).map_err(|error| { - GitveilError::io( - "replace repository configuration", - Some(target.clone()), - &error.error, - ) - })?; - } else { - temporary.persist_noclobber(&target).map_err(|error| { - if error.error.kind() == std::io::ErrorKind::AlreadyExists { - GitveilError::configuration(format!( - "{path} already exists; init never replaces a manifest" - )) - } else { - GitveilError::io( - "create repository configuration", - Some(target.clone()), - &error.error, - ) - } - })?; - } - fault.check(path, PublicationPoint::BeforeDirectorySync)?; - sync_directory(parent)?; - Ok(()) -} - fn restore_optional( root: &Path, path: &ManagedPath, @@ -763,18 +526,6 @@ fn restore_optional( } } -fn sync_directory(directory: &Path) -> Result<()> { - fs::File::open(directory) - .and_then(|file| file.sync_all()) - .map_err(|error| { - GitveilError::io( - "sync repository directory", - Some(PathBuf::from(directory)), - &error, - ) - }) -} - #[cfg(test)] mod tests { use std::collections::HashSet; @@ -783,13 +534,15 @@ mod tests { use super::{ ErrorCategory, GITIGNORE_FILE_NAME, GitveilError, IgnorePreparation, MANIFEST_FILE_NAME, - ManagedPath, Manifest, ManifestPreparation, PublicationFault, PublicationPoint, - RegistrationPublication, finish_registration_publication, manifest_paths, - registration_plan, render_managed_ignore, serialize_manifest, + ManagedPath, Manifest, ManifestPreparation, PublicationFault, RegistrationPublication, + finish_registration_publication, manifest_paths, registration_plan, render_managed_ignore, + serialize_manifest, }; const RECIPIENT: &str = "age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq"; + use crate::manifest_document::PublicationPoint; + struct ManifestPublicationFault(PublicationPoint); impl PublicationFault for ManifestPublicationFault { @@ -838,11 +591,11 @@ mod tests { .expect("ignore candidate"); fs::write(root.path().join(GITIGNORE_FILE_NAME), &ignore_candidate) .expect("published ignore"); - let manifest_preparation = ManifestPreparation { - path: ManagedPath::new(MANIFEST_FILE_NAME).expect("manifest path"), - original: original.clone(), - mode: 0o644, - }; + let manifest_preparation = ManifestPreparation::new( + ManagedPath::new(MANIFEST_FILE_NAME).expect("manifest path"), + original.clone(), + 0o644, + ); let ignore_preparation = IgnorePreparation { path: ignore_path, original: None, @@ -891,11 +644,11 @@ mod tests { .expect("retry plan"); let retry_candidate = serialize_manifest(retry_plan.manifest()).expect("retry candidate"); - let retry_preparation = ManifestPreparation { - path: ManagedPath::new(MANIFEST_FILE_NAME).expect("manifest path"), - original: after_failure, - mode: 0o644, - }; + let retry_preparation = ManifestPreparation::new( + ManagedPath::new(MANIFEST_FILE_NAME).expect("manifest path"), + after_failure, + 0o644, + ); finish_registration_publication( root.path(), &RegistrationPublication { diff --git a/src/lib.rs b/src/lib.rs index e06ae7c..e21056b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod error; pub(crate) mod git; pub(crate) mod identity; pub mod manifest; +pub(crate) mod manifest_document; pub(crate) mod open; pub mod path; pub mod profile; diff --git a/src/manifest.rs b/src/manifest.rs index 92a27e4..ad336be 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -95,10 +95,6 @@ impl<'a> ResolvedManifestEntry<'a> { self.recipient_policy } - pub(crate) fn policy_name(&self) -> &PolicyName { - self.entry.recipient_policy() - } - pub(crate) fn ciphertext_path(&self) -> ManagedPath { self.entry.ciphertext_path() } diff --git a/src/manifest_document.rs b/src/manifest_document.rs new file mode 100644 index 0000000..5377289 --- /dev/null +++ b/src/manifest_document.rs @@ -0,0 +1,310 @@ +//! Safe read-modify-republish of repository configuration documents. +//! +//! Both configuration-mutation features (`configure` for `init`/`add`, +//! `recipients` for authorization changes) share this boundary: root-only +//! repository discovery, manifest loading that retains the original bytes +//! and mode for freshness checks, canonical serialization, and atomic +//! publication that never overwrites a concurrently changed document. + +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use crate::confine; +use crate::error::{ErrorCategory, GitveilError, Result}; +use crate::git::{Repository, RepositoryDiscoveryError}; +use crate::manifest::{MANIFEST_FILE_NAME, Manifest}; +use crate::path::ManagedPath; +use crate::recipient::PolicyName; + +pub(crate) const DEFAULT_MODE: u32 = 0o644; + +/// Injection point identifiers for publication fault tests. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PublicationPoint { + BeforePersist, + BeforeDirectorySync, +} + +pub(crate) trait PublicationFault { + fn check(&self, _path: &ManagedPath, _point: PublicationPoint) -> Result<()> { + Ok(()) + } +} + +pub(crate) struct NoPublicationFault; + +impl PublicationFault for NoPublicationFault {} + +/// A loaded manifest document: parsed content plus the original bytes and +/// mode needed to republish it without clobbering concurrent writes. +pub(crate) struct ManifestPreparation { + path: ManagedPath, + original: Vec, + mode: u32, +} + +impl ManifestPreparation { + pub(crate) const fn new(path: ManagedPath, original: Vec, mode: u32) -> Self { + Self { + path, + original, + mode, + } + } + + pub(crate) fn path(&self) -> &ManagedPath { + &self.path + } + + pub(crate) const fn mode(&self) -> u32 { + self.mode + } + + /// Whether the document on disk still carries the loaded bytes. + pub(crate) fn is_fresh(&self, root: &Path) -> Result { + Ok(read_optional(root, &self.path)?.as_deref() == Some(self.original.as_slice())) + } +} + +pub(crate) fn load_manifest(root: &Path) -> Result<(ManifestPreparation, Manifest)> { + let path = ManagedPath::new(MANIFEST_FILE_NAME) + .map_err(|error| GitveilError::configuration(error.to_string()))?; + let bytes = read_optional(root, &path)?.ok_or_else(|| { + GitveilError::configuration(format!( + "{MANIFEST_FILE_NAME} not found at the repository root; run gitveil init first" + )) + })?; + let manifest = + Manifest::parse(&bytes).map_err(|error| GitveilError::configuration(error.to_string()))?; + let mode = file_mode(root, &path)?.unwrap_or(DEFAULT_MODE); + Ok((ManifestPreparation::new(path, bytes, mode), manifest)) +} + +pub(crate) fn serialize_manifest(manifest: &Manifest) -> Result> { + manifest.to_json_bytes().map_err(|_| { + GitveilError::new( + ErrorCategory::Integrity, + "Gitveil manifest writer violated its internal reader contract", + ) + }) +} + +/// Publishes a manifest candidate atomically, refusing when the document +/// changed since it was loaded. +pub(crate) fn publish_if_fresh( + root: &Path, + preparation: &ManifestPreparation, + candidate: &[u8], + command: &str, +) -> Result<()> { + if !preparation.is_fresh(root)? { + return Err(GitveilError::new( + ErrorCategory::Concurrency, + format!("{MANIFEST_FILE_NAME} changed while {command} was running; retry"), + )); + } + write_atomic(root, &preparation.path, candidate, preparation.mode, true) +} + +pub(crate) fn select_recipient_policy( + manifest: &Manifest, + requested: Option<&str>, + flag: &str, +) -> Result { + if let Some(requested) = requested { + let requested = PolicyName::new(requested) + .map_err(|error| GitveilError::configuration(error.to_string()))?; + if manifest.recipient_policy(&requested).is_none() { + return Err(GitveilError::configuration(format!( + "recipient policy {requested} is not declared in {MANIFEST_FILE_NAME}" + ))); + } + return Ok(requested); + } + if let Some(only) = manifest.only_policy_name() { + return Ok(only.clone()); + } + let names = manifest + .policy_names() + .into_iter() + .map(PolicyName::as_str) + .collect::>() + .join(", "); + if names.is_empty() { + Err(GitveilError::configuration(format!( + "{MANIFEST_FILE_NAME} has no recipient policy; declare one before adding files" + ))) + } else { + Err(GitveilError::configuration(format!( + "multiple recipient policies are declared ({names}); pass {flag}" + ))) + } +} + +/// Root-only repository discovery for configuration-mutation commands. +pub(crate) fn mutation_repository(current: &Path) -> Result { + let repository = match Repository::discover(current) { + Ok(repository) => repository, + Err(RepositoryDiscoveryError::NotRepository) => { + if fs::symlink_metadata(current.join(".git")).is_ok() { + return Err(GitveilError::new( + ErrorCategory::Process, + "Git repository metadata exists but could not be read", + )); + } + return Err(GitveilError::configuration( + "no Git repository metadata found in the current directory; run this command from a Git repository root", + )); + } + Err(RepositoryDiscoveryError::Failure(error)) => return Err(error), + }; + let current = current.canonicalize().map_err(|error| { + GitveilError::io( + "resolve current directory", + Some(current.to_path_buf()), + &error, + ) + })?; + let root = repository.root().canonicalize().map_err(|error| { + GitveilError::io( + "resolve Git repository root", + Some(repository.root().to_path_buf()), + &error, + ) + })?; + if current != root { + return Err(GitveilError::configuration(format!( + "configuration commands must be run from the Git repository root: {}", + root.display() + ))); + } + let marker = root.join(".git"); + let metadata = fs::symlink_metadata(&marker).map_err(|_| { + GitveilError::configuration( + "no Git repository metadata found in the current directory; expected .git", + ) + })?; + let file_type = metadata.file_type(); + if file_type.is_symlink() || !(file_type.is_file() || file_type.is_dir()) { + return Err(GitveilError::configuration( + "Git repository metadata .git must be a regular file or directory", + )); + } + repository.ensure_supported_version()?; + Ok(repository) +} + +pub(crate) fn read_optional(root: &Path, path: &ManagedPath) -> Result>> { + let Some(mut file) = confine::open_existing(root, path)? else { + return Ok(None); + }; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).map_err(|error| { + GitveilError::io( + "read repository configuration", + Some(path.join_to(root)), + &error, + ) + })?; + Ok(Some(bytes)) +} + +pub(crate) fn file_mode(root: &Path, path: &ManagedPath) -> Result> { + let Some(file) = confine::open_existing(root, path)? else { + return Ok(None); + }; + file.metadata() + .map(|metadata| Some(metadata.permissions().mode() & 0o777)) + .map_err(|error| { + GitveilError::io( + "read repository file metadata", + Some(path.join_to(root)), + &error, + ) + }) +} + +pub(crate) fn write_atomic( + root: &Path, + path: &ManagedPath, + bytes: &[u8], + mode: u32, + replace: bool, +) -> Result<()> { + write_atomic_with_fault(root, path, bytes, mode, replace, &NoPublicationFault) +} + +pub(crate) fn write_atomic_with_fault( + root: &Path, + path: &ManagedPath, + bytes: &[u8], + mode: u32, + replace: bool, + fault: &F, +) -> Result<()> { + confine::validate_managed_path(root, path)?; + let target = path.join_to(root); + let parent = target + .parent() + .ok_or_else(|| GitveilError::configuration("repository path has no parent"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { + GitveilError::io( + "create repository configuration replacement", + Some(parent.to_path_buf()), + &error, + ) + })?; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(mode)) + .and_then(|()| temporary.write_all(bytes)) + .and_then(|()| temporary.as_file_mut().sync_all()) + .map_err(|error| { + GitveilError::io( + "write repository configuration replacement", + Some(target.clone()), + &error, + ) + })?; + fault.check(path, PublicationPoint::BeforePersist)?; + if replace { + temporary.persist(&target).map_err(|error| { + GitveilError::io( + "replace repository configuration", + Some(target.clone()), + &error.error, + ) + })?; + } else { + temporary.persist_noclobber(&target).map_err(|error| { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + GitveilError::configuration(format!( + "{path} already exists; init never replaces a manifest" + )) + } else { + GitveilError::io( + "create repository configuration", + Some(target.clone()), + &error.error, + ) + } + })?; + } + fault.check(path, PublicationPoint::BeforeDirectorySync)?; + sync_directory(parent)?; + Ok(()) +} + +pub(crate) fn sync_directory(directory: &Path) -> Result<()> { + fs::File::open(directory) + .and_then(|file| file.sync_all()) + .map_err(|error| { + GitveilError::io( + "sync repository directory", + Some(PathBuf::from(directory)), + &error, + ) + }) +} diff --git a/src/recipients.rs b/src/recipients.rs index 30ac102..391c870 100644 --- a/src/recipients.rs +++ b/src/recipients.rs @@ -2,11 +2,12 @@ //! that change which recipients can decrypt managed ciphertext. //! //! Data commands (`seal`, `resolve`) fail closed on recipient drift; this -//! module is the single authorization entry point. The operator's command -//! line names every recipient whose access changes, and the pure plan -//! rejects any manifest/envelope difference that the command line does not -//! explain. The manifest is published first: any mid-run failure leaves -//! drift that `seal` refuses and an idempotent rerun converges. +//! module is the single authorization entry point. Each command converges +//! envelopes only in its own direction and only for the recipients named on +//! the command line; residual drift against the policy is reported with +//! full recipient values and keeps `seal` fail-closed. The manifest is +//! published before file convergence, so any mid-run failure leaves drift +//! that an idempotent rerun converges. mod plan; @@ -14,41 +15,62 @@ use std::path::Path; use std::time::Duration; use crate::baseline::{BaselineRecord, BaselineStore, CipherSummary}; -use crate::configure::{ - ManifestPreparation, load_manifest, mutation_repository, read_optional, - select_recipient_policy, serialize_manifest, write_atomic, -}; use crate::envelope::{CiphertextEnvelope, DecryptedEnvelope}; use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; -use crate::manifest::{MANIFEST_FILE_NAME, Manifest, ManifestEntry, ResolvedManifestEntry}; +use crate::manifest::{Manifest, ManifestEntry, ResolvedManifestEntry}; +use crate::manifest_document::{ + load_manifest, mutation_repository, publish_if_fresh, read_optional, select_recipient_policy, + serialize_manifest, +}; use crate::path::ManagedPath; use crate::recipient::{AgeRecipient, AgeRecipientPolicy, PolicyName}; use crate::runtime::{PrivateRuntime, acquire_lock}; use crate::seal::decrypt_source; use crate::sops::{SopsBinary, SopsClient, SopsPaths}; use crate::source::SourceDocument; +use crate::workspace::write_ciphertext_file; -pub(crate) use plan::{ConvergenceAction, GrantEcho}; -use plan::{FileRecipientFacts, RecipientMutation, plan_authorization}; +pub(crate) use plan::{ConvergenceAction, PolicyDelta}; +use plan::{FilePlan, FileRecipientFacts, RecipientMutation, plan_authorization}; const LOCK_TIMEOUT: Duration = Duration::from_secs(30); -const CIPHERTEXT_MODE: u32 = 0o644; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RecipientFileOutcome { Rewrapped, DataKeyRotated, - AlreadyAligned, + /// The named recipients required no change on this envelope. + NoChangeNeeded, +} + +/// One file's committed convergence result plus the residual drift that +/// remains against the policy (full values: the operator acts on them). +pub(crate) struct FileConvergence { + pub outcome: RecipientFileOutcome, + pub pending_additions: Vec, + pub pending_removals: Vec, } pub(crate) struct RecipientFileReport { pub path: ManagedPath, - pub result: Result, + pub result: Result, +} + +/// What happened to the manifest document itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ManifestDisposition { + /// The policy already carried the requested state. + Unchanged, + Published, + /// Nothing was published: every required convergence failed preflight. + Withheld, } pub(crate) struct AuthorizationOutcome { pub policy: PolicyName, - pub grants: Vec, + /// Manifest-level effects; the CLI renders them only after publication. + pub deltas: Vec, + pub manifest: ManifestDisposition, pub reports: Vec, } @@ -120,15 +142,6 @@ fn authorize( let plan = plan_authorization(policy, &mutation, &files.facts) .map_err(|rejection| GitveilError::configuration(rejection.to_string()))?; - let desired_policy = - AgeRecipientPolicy::new(policy_name.clone(), plan.desired_recipients.clone()).map_err( - |error| { - GitveilError::new( - ErrorCategory::Integrity, - format!("authorization plan produced an invalid policy: {error}"), - ) - }, - )?; let mut candidate = manifest.clone(); candidate .set_policy_recipients(&policy_name, plan.desired_recipients.clone()) @@ -136,25 +149,32 @@ fn authorize( let candidate_bytes = serialize_manifest(&candidate)?; let mut convergences = Vec::new(); - for (path, action) in &plan.files { - if *action == ConvergenceAction::AlreadyAligned { + for file_plan in &plan.files { + if file_plan.action == ConvergenceAction::AlreadyAligned { reports.push(RecipientFileReport { - path: path.clone(), - result: Ok(RecipientFileOutcome::AlreadyAligned), + path: file_plan.path.clone(), + result: Ok(FileConvergence { + outcome: RecipientFileOutcome::NoChangeNeeded, + pending_additions: file_plan.pending_additions.clone(), + pending_removals: file_plan.pending_removals.clone(), + }), }); continue; } let file = files .affected .iter() - .find(|file| file.entry.path() == path) + .find(|file| file.entry.path() == &file_plan.path) .ok_or_else(|| { GitveilError::new( ErrorCategory::Integrity, - format!("authorization plan references unknown file {path}"), + format!( + "authorization plan references unknown file {}", + file_plan.path + ), ) })?; - convergences.push((file, *action)); + convergences.push((file, file_plan)); } let engine = ConvergenceEngine { @@ -163,16 +183,22 @@ fn authorize( gitveil_binary, store: &store, manifest: &manifest, - preparation: &manifest_preparation, - candidate: &candidate_bytes, - manifest_changed: plan.manifest_changed, - desired_policy: &desired_policy, + policy_name: &policy_name, }; - engine.run(convergences, &mut reports)?; + let manifest_disposition = + engine.run(convergences, &mut reports, plan.manifest_changed, || { + publish_if_fresh( + &root, + &manifest_preparation, + &candidate_bytes, + "gitveil recipient", + ) + })?; Ok(AuthorizationOutcome { policy: policy_name, - grants: plan.grants, + deltas: plan.deltas, + manifest: manifest_disposition, reports, }) } @@ -246,20 +272,29 @@ struct ConvergenceEngine<'a> { gitveil_binary: &'a Path, store: &'a BaselineStore, manifest: &'a Manifest, - preparation: &'a ManifestPreparation, - candidate: &'a [u8], - manifest_changed: bool, - desired_policy: &'a AgeRecipientPolicy, + policy_name: &'a PolicyName, } impl ConvergenceEngine<'_> { fn run( &self, - convergences: Vec<(&AffectedFile<'_>, ConvergenceAction)>, + convergences: Vec<(&AffectedFile<'_>, &FilePlan)>, reports: &mut Vec, - ) -> Result<()> { + manifest_changed: bool, + publish: impl FnOnce() -> Result<()>, + ) -> Result { + let disposition = |published: bool| { + if published { + ManifestDisposition::Published + } else { + ManifestDisposition::Unchanged + } + }; if convergences.is_empty() { - return self.publish_manifest_if_changed(); + if manifest_changed { + publish()?; + } + return Ok(disposition(manifest_changed)); } let sops = SopsClient::new( SopsBinary::discover(None, self.gitveil_binary)?, @@ -272,10 +307,10 @@ impl ConvergenceEngine<'_> { // Preflight: prove the current envelopes are decryptable before any // publication. When nothing can be decrypted (no identity, or every - // file failed) the manifest is not published and nothing changes. + // file failed) the manifest is withheld and nothing changes. let mut ready = Vec::new(); let mut failures = Vec::new(); - for (file, action) in convergences { + for (file, file_plan) in convergences { let resolved = self.manifest.resolve_entry(file.entry).ok_or_else(|| { GitveilError::new( ErrorCategory::Integrity, @@ -286,7 +321,7 @@ impl ConvergenceEngine<'_> { ) })?; match decrypt_source(&sops, &file.ciphertext, &resolved) { - Ok(document) => ready.push((file, action, resolved, document)), + Ok(document) => ready.push((file, file_plan, resolved, document)), Err(error) => failures.push(RecipientFileReport { path: file.entry.path().clone(), result: Err(error), @@ -294,50 +329,29 @@ impl ConvergenceEngine<'_> { } } if ready.is_empty() { - let failure = failures - .into_iter() - .next() - .and_then(|report| report.result.err()) - .unwrap_or_else(|| { - GitveilError::new( - ErrorCategory::Integrity, - "recipient convergence preflight produced no result", - ) - }); - return Err(failure); + reports.extend(failures); + return Ok(ManifestDisposition::Withheld); } - self.publish_manifest_if_changed()?; + if manifest_changed { + publish()?; + } - for (file, action, resolved, document) in ready { - let result = self.converge_file(&sops, &resolved, &file.ciphertext, &document, action); + for (file, file_plan, resolved, document) in ready { + let result = self + .converge_file(&sops, &resolved, &file.ciphertext, &document, file_plan) + .map(|outcome| FileConvergence { + outcome, + pending_additions: file_plan.pending_additions.clone(), + pending_removals: file_plan.pending_removals.clone(), + }); reports.push(RecipientFileReport { path: file.entry.path().clone(), result, }); } reports.extend(failures); - Ok(()) - } - - fn publish_manifest_if_changed(&self) -> Result<()> { - if !self.manifest_changed { - return Ok(()); - } - let current = read_optional(self.root, &self.preparation.path)?; - if current.as_deref() != Some(self.preparation.original.as_slice()) { - return Err(GitveilError::new( - ErrorCategory::Concurrency, - format!("{MANIFEST_FILE_NAME} changed while gitveil recipient was running; retry"), - )); - } - write_atomic( - self.root, - &self.preparation.path, - self.candidate, - self.preparation.mode, - true, - ) + Ok(disposition(manifest_changed)) } fn converge_file( @@ -346,15 +360,27 @@ impl ConvergenceEngine<'_> { resolved: &ResolvedManifestEntry<'_>, ciphertext: &[u8], document: &SourceDocument, - action: ConvergenceAction, + file_plan: &FilePlan, ) -> Result { let path = resolved.path(); let cipher_path = resolved.ciphertext_path(); - let (converged, outcome) = match action { - ConvergenceAction::AlreadyAligned => return Ok(RecipientFileOutcome::AlreadyAligned), + let target = AgeRecipientPolicy::new( + self.policy_name.clone(), + file_plan.target_recipients.clone(), + ) + .map_err(|error| { + GitveilError::new( + ErrorCategory::Integrity, + format!("authorization plan produced an invalid target set: {error}"), + ) + })?; + let (converged, outcome) = match file_plan.action { + ConvergenceAction::AlreadyAligned => { + return Ok(RecipientFileOutcome::NoChangeNeeded); + } ConvergenceAction::Rewrap => { - let rewrapped = sops.rewrap(ciphertext, self.desired_policy)?; - self.verify_rewrap(ciphertext, &rewrapped, resolved)?; + let rewrapped = sops.rewrap(ciphertext, &target)?; + verify_rewrap(ciphertext, &rewrapped, resolved, &target)?; (rewrapped, RecipientFileOutcome::Rewrapped) } ConvergenceAction::Rotate => { @@ -365,10 +391,10 @@ impl ConvergenceEngine<'_> { .and_then(|envelope| envelope.to_yaml()) .map(SecretBytes::new) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - let rotated = sops.encrypt_new(&desired_bytes, path, self.desired_policy)?; + let rotated = sops.encrypt_new(&desired_bytes, path, &target)?; let envelope = CiphertextEnvelope::parse(&rotated, resolved.format()) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - self.verify_desired_recipients(&envelope, path)?; + verify_target_recipients(&envelope, &target, path)?; let roundtrip = decrypt_source(sops, &rotated, resolved)?; if !roundtrip.semantic_eq(document) { return Err(GitveilError::new( @@ -379,7 +405,7 @@ impl ConvergenceEngine<'_> { (rotated, RecipientFileOutcome::DataKeyRotated) } }; - write_atomic(self.root, &cipher_path, &converged, CIPHERTEXT_MODE, true)?; + write_ciphertext_file(self.root, &cipher_path, &converged)?; let envelope = CiphertextEnvelope::parse(&converged, resolved.format()) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; let record = BaselineRecord::capture( @@ -390,47 +416,44 @@ impl ConvergenceEngine<'_> { self.store.save(path, &record)?; Ok(outcome) } +} - /// Verifies a rewrap changed only the wrapped data key: the recipient - /// set is exactly the desired policy and every encrypted leaf stayed - /// byte-for-byte stable. - fn verify_rewrap( - &self, - before: &[u8], - after: &[u8], - resolved: &ResolvedManifestEntry<'_>, - ) -> Result<()> { - let path = resolved.path(); - let before = CiphertextEnvelope::parse(before, resolved.format()) - .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - let after = CiphertextEnvelope::parse(after, resolved.format()) - .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - self.verify_desired_recipients(&after, path)?; - if before.leaf_ciphertexts() != after.leaf_ciphertexts() - || before.layout_ciphertext() != after.layout_ciphertext() - { - return Err(GitveilError::new( - ErrorCategory::Integrity, - format!("SOPS recipient update changed encrypted data at {path}"), - )); - } - Ok(()) +/// Verifies a rewrap changed only the wrapped data key: the recipient set +/// is exactly the per-file target and every encrypted leaf stayed +/// byte-for-byte stable. +fn verify_rewrap( + before: &[u8], + after: &[u8], + resolved: &ResolvedManifestEntry<'_>, + target: &AgeRecipientPolicy, +) -> Result<()> { + let path = resolved.path(); + let before = CiphertextEnvelope::parse(before, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + let after = CiphertextEnvelope::parse(after, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + verify_target_recipients(&after, target, path)?; + if before.leaf_ciphertexts() != after.leaf_ciphertexts() + || before.layout_ciphertext() != after.layout_ciphertext() + { + return Err(GitveilError::new( + ErrorCategory::Integrity, + format!("SOPS recipient update changed encrypted data at {path}"), + )); } + Ok(()) +} - fn verify_desired_recipients( - &self, - envelope: &CiphertextEnvelope, - path: &ManagedPath, - ) -> Result<()> { - if self.desired_policy.matches(envelope.age_recipients()) { - return Ok(()); - } - Err(GitveilError::new( - ErrorCategory::Integrity, - format!( - "SOPS result recipients do not match the authorized set of policy {} at {path}", - self.desired_policy.name() - ), - )) +fn verify_target_recipients( + envelope: &CiphertextEnvelope, + target: &AgeRecipientPolicy, + path: &ManagedPath, +) -> Result<()> { + if target.matches(envelope.age_recipients()) { + return Ok(()); } + Err(GitveilError::new( + ErrorCategory::Integrity, + format!("SOPS result recipients do not match the authorized target set at {path}"), + )) } diff --git a/src/recipients/plan.rs b/src/recipients/plan.rs index 78e1366..33353df 100644 --- a/src/recipients/plan.rs +++ b/src/recipients/plan.rs @@ -1,11 +1,14 @@ //! Pure authorization planning for `gitveil recipient add` and //! `gitveil recipient remove`. //! -//! Contract: a recipient gains or loses the ability to decrypt an existing -//! ciphertext only when the operator names it explicitly on the command -//! line. Every effective per-file recipient change must be explained by the -//! requested mutation; an unexplained difference rejects the whole command -//! and names the offending recipient (age recipients are public metadata). +//! Contract: each command changes envelopes only in its own direction and +//! only for the recipients named on the command line. `add` computes each +//! file's target as `envelope ∪ named`, `remove` as `envelope ∖ named`; a +//! command never grants or revokes anything it did not name, so a tampered +//! manifest entry stays ineffective until someone names it explicitly. +//! Residual difference against the final policy is reported per file with +//! full recipient values (age recipients are public metadata) and keeps +//! `seal` fail-closed until it is converged. use std::collections::HashSet; @@ -29,7 +32,7 @@ pub(super) struct FileRecipientFacts { pub envelope_recipients: Option>, } -/// How one existing ciphertext is brought to the desired recipient set. +/// How one existing ciphertext converges to its per-file target set. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ConvergenceAction { AlreadyAligned, @@ -41,27 +44,40 @@ pub(crate) enum ConvergenceAction { Rotate, } -/// Echo of one requested recipient's actual effect, rendered by the CLI so -/// the authorization action always names its subject. +/// The manifest-level effect of one requested recipient, rendered by the +/// CLI after publication so every claim is about a committed state. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum GrantEcho { - Added(AgeRecipient), - AlreadyAuthorized(AgeRecipient), - Removed(AgeRecipient), +pub(crate) enum PolicyDelta { + AddedToPolicy(AgeRecipient), + AlreadyInPolicy(AgeRecipient), + RemovedFromPolicy(AgeRecipient), /// Absent from the policy but present on at least one ciphertext; the - /// removal converges that drift. - RemovedFromCiphertext(AgeRecipient), + /// removal converges that envelope-side drift. + RemovedFromCiphertextOnly(AgeRecipient), +} + +/// One file's convergence: the exact target set and the residual drift +/// against the final policy that remains after this command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FilePlan { + pub path: ManagedPath, + pub action: ConvergenceAction, + /// `envelope ± named`: the set this file's envelope converges to. + pub target_recipients: Vec, + /// Policy recipients not on the target: granting them needs `add`. + pub pending_additions: Vec, + /// Target recipients not in the policy: revoking them needs `remove`. + pub pending_removals: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct AuthorizationPlan { - /// Final ordered recipient list for the policy. + /// Final ordered recipient list for the manifest policy. pub desired_recipients: Vec, - /// Whether the manifest policy itself changes. pub manifest_changed: bool, - /// Convergence action per entry with an existing ciphertext. - pub files: Vec<(ManagedPath, ConvergenceAction)>, - pub grants: Vec, + /// Plans for every entry with an existing, parseable ciphertext. + pub files: Vec, + pub deltas: Vec, } #[derive(Clone, Debug, Error, PartialEq, Eq)] @@ -73,52 +89,126 @@ pub(super) enum AuthorizationRejection { #[error("a policy must keep at least one recipient; this removal would empty it")] EmptyPolicy, #[error( - "the policy grants {recipient} but {path}.gitveil does not include it and this command did not add it; remove it from .gitveilrc.json or name it explicitly with gitveil recipient add" + "removing these recipients would leave {0}.gitveil with no recipient able to decrypt; run gitveil recipient add first" )] - UnexplainedAddition { - path: ManagedPath, - recipient: AgeRecipient, - }, - #[error( - "{path}.gitveil includes {recipient} but the policy does not, and this command did not remove it; restore it in .gitveilrc.json or name it explicitly with gitveil recipient remove" - )] - UnexplainedRemoval { - path: ManagedPath, - recipient: AgeRecipient, - }, + EmptiesEnvelope(ManagedPath), } -fn desired_and_grants( +pub(super) fn plan_authorization( + policy: &AgeRecipientPolicy, + mutation: &RecipientMutation, + files: &[FileRecipientFacts], +) -> Result { + let requested = match mutation { + RecipientMutation::Add(recipients) | RecipientMutation::Remove(recipients) => recipients, + }; + let mut seen = HashSet::with_capacity(requested.len()); + if let Some(duplicate) = requested + .iter() + .find(|recipient| !seen.insert(recipient.as_str())) + { + return Err(AuthorizationRejection::DuplicateRequest(duplicate.clone())); + } + + let current = policy.recipients(); + let (desired, deltas) = manifest_effect(current, mutation, files)?; + let manifest_changed = desired.as_slice() != current; + + let mut file_plans = Vec::with_capacity(files.len()); + for facts in files { + let Some(envelope) = facts.envelope_recipients.as_deref() else { + continue; + }; + let (target, action) = match mutation { + RecipientMutation::Add(additions) => { + let mut target = envelope.to_vec(); + target.extend( + additions + .iter() + .filter(|recipient| !envelope.contains(recipient)) + .cloned(), + ); + let action = if target.len() == envelope.len() { + ConvergenceAction::AlreadyAligned + } else { + ConvergenceAction::Rewrap + }; + (target, action) + } + RecipientMutation::Remove(removals) => { + let target = envelope + .iter() + .filter(|recipient| !removals.contains(recipient)) + .cloned() + .collect::>(); + if target.is_empty() { + return Err(AuthorizationRejection::EmptiesEnvelope(facts.path.clone())); + } + let action = if target.len() == envelope.len() { + ConvergenceAction::AlreadyAligned + } else { + ConvergenceAction::Rotate + }; + (target, action) + } + }; + let pending_additions = desired + .iter() + .filter(|recipient| !target.contains(recipient)) + .cloned() + .collect(); + let pending_removals = target + .iter() + .filter(|recipient| !desired.contains(recipient)) + .cloned() + .collect(); + file_plans.push(FilePlan { + path: facts.path.clone(), + action, + target_recipients: target, + pending_additions, + pending_removals, + }); + } + Ok(AuthorizationPlan { + desired_recipients: desired, + manifest_changed, + files: file_plans, + deltas, + }) +} + +fn manifest_effect( current: &[AgeRecipient], mutation: &RecipientMutation, files: &[FileRecipientFacts], -) -> Result<(Vec, Vec), AuthorizationRejection> { +) -> Result<(Vec, Vec), AuthorizationRejection> { match mutation { RecipientMutation::Add(additions) => { let mut desired = current.to_vec(); - let mut grants = Vec::with_capacity(additions.len()); + let mut deltas = Vec::with_capacity(additions.len()); for addition in additions { if current.contains(addition) { - grants.push(GrantEcho::AlreadyAuthorized(addition.clone())); + deltas.push(PolicyDelta::AlreadyInPolicy(addition.clone())); } else { desired.push(addition.clone()); - grants.push(GrantEcho::Added(addition.clone())); + deltas.push(PolicyDelta::AddedToPolicy(addition.clone())); } } - Ok((desired, grants)) + Ok((desired, deltas)) } RecipientMutation::Remove(removals) => { - let mut grants = Vec::with_capacity(removals.len()); + let mut deltas = Vec::with_capacity(removals.len()); for removal in removals { if current.contains(removal) { - grants.push(GrantEcho::Removed(removal.clone())); + deltas.push(PolicyDelta::RemovedFromPolicy(removal.clone())); } else if files.iter().any(|facts| { facts .envelope_recipients .as_deref() .is_some_and(|envelope| envelope.contains(removal)) }) { - grants.push(GrantEcho::RemovedFromCiphertext(removal.clone())); + deltas.push(PolicyDelta::RemovedFromCiphertextOnly(removal.clone())); } else { return Err(AuthorizationRejection::RemoveNotMember(removal.clone())); } @@ -131,85 +221,9 @@ fn desired_and_grants( if desired.is_empty() { return Err(AuthorizationRejection::EmptyPolicy); } - Ok((desired, grants)) - } - } -} - -pub(super) fn plan_authorization( - policy: &AgeRecipientPolicy, - mutation: &RecipientMutation, - files: &[FileRecipientFacts], -) -> Result { - let requested = match mutation { - RecipientMutation::Add(recipients) | RecipientMutation::Remove(recipients) => recipients, - }; - let mut seen = HashSet::with_capacity(requested.len()); - if let Some(duplicate) = requested - .iter() - .find(|recipient| !seen.insert(recipient.as_str())) - { - return Err(AuthorizationRejection::DuplicateRequest(duplicate.clone())); - } - - let current = policy.recipients(); - let (desired, grants) = desired_and_grants(current, mutation, files)?; - let manifest_changed = desired.as_slice() != current; - - let empty: &[AgeRecipient] = &[]; - let (named_additions, named_removals) = match mutation { - RecipientMutation::Add(recipients) => (recipients.as_slice(), empty), - RecipientMutation::Remove(recipients) => (empty, recipients.as_slice()), - }; - - let mut file_actions = Vec::with_capacity(files.len()); - for facts in files { - let Some(envelope) = facts.envelope_recipients.as_deref() else { - continue; - }; - let additions_needed = desired - .iter() - .filter(|recipient| !envelope.contains(recipient)) - .collect::>(); - let removals_needed = envelope - .iter() - .filter(|recipient| !desired.contains(recipient)) - .collect::>(); - if let Some(recipient) = additions_needed - .iter() - .find(|recipient| !named_additions.contains(recipient)) - { - return Err(AuthorizationRejection::UnexplainedAddition { - path: facts.path.clone(), - recipient: (*recipient).clone(), - }); + Ok((desired, deltas)) } - if let Some(recipient) = removals_needed - .iter() - .find(|recipient| !named_removals.contains(recipient)) - { - return Err(AuthorizationRejection::UnexplainedRemoval { - path: facts.path.clone(), - recipient: (*recipient).clone(), - }); - } - let action = if removals_needed.is_empty() { - if additions_needed.is_empty() { - ConvergenceAction::AlreadyAligned - } else { - ConvergenceAction::Rewrap - } - } else { - ConvergenceAction::Rotate - }; - file_actions.push((facts.path.clone(), action)); } - Ok(AuthorizationPlan { - desired_recipients: desired, - manifest_changed, - files: file_actions, - grants, - }) } #[cfg(test)] @@ -220,7 +234,7 @@ mod tests { use crate::recipient::{AgeRecipient, AgeRecipientPolicy, PolicyName}; use super::{ - AuthorizationRejection, ConvergenceAction, FileRecipientFacts, GrantEcho, + AuthorizationRejection, ConvergenceAction, FileRecipientFacts, PolicyDelta, RecipientMutation, plan_authorization, }; @@ -247,7 +261,7 @@ mod tests { } #[test] - fn adding_to_an_aligned_state_rewraps_every_existing_ciphertext() { + fn add_targets_envelope_plus_named_and_rewraps() { let (a, b) = (recipient(1), recipient(2)); let plan = plan_authorization( &policy(&[&a]), @@ -255,103 +269,133 @@ mod tests { &[file(".env", Some(&[&a])), file("fresh.env", None)], ) .expect("plan"); - assert_eq!(plan.desired_recipients, vec![a, b.clone()]); + assert_eq!(plan.desired_recipients, vec![a.clone(), b.clone()]); assert!(plan.manifest_changed); assert_eq!(plan.files.len(), 1); - assert_eq!(plan.files[0].1, ConvergenceAction::Rewrap); - assert_eq!(plan.grants, vec![GrantEcho::Added(b)]); + assert_eq!(plan.files[0].action, ConvergenceAction::Rewrap); + assert_eq!(plan.files[0].target_recipients, vec![a, b.clone()]); + assert!(plan.files[0].pending_additions.is_empty()); + assert!(plan.files[0].pending_removals.is_empty()); + assert_eq!(plan.deltas, vec![PolicyDelta::AddedToPolicy(b)]); } + // The confused-deputy case: an injected manifest recipient is never + // wrapped by an add that does not name it; it surfaces as residual drift. #[test] - fn an_idempotent_add_reports_already_authorized_and_already_aligned() { - let (a, b) = (recipient(1), recipient(2)); + fn add_grants_only_named_recipients_and_reports_injected_ones_as_pending() { + let (a, b, injected) = (recipient(1), recipient(2), recipient(9)); let plan = plan_authorization( - &policy(&[&a, &b]), + &policy(&[&a, &injected]), &RecipientMutation::Add(vec![b.clone()]), + &[file(".env", Some(&[&a]))], + ) + .expect("plan"); + assert_eq!(plan.files[0].target_recipients, vec![a, b]); + assert_eq!(plan.files[0].pending_additions, vec![injected]); + assert_eq!(plan.files[0].action, ConvergenceAction::Rewrap); + } + + // An add never executes a removal, even when the manifest lost a + // recipient (attack or accident): the envelope keeps it, reported as + // pending removal. + #[test] + fn add_never_removes_envelope_recipients() { + let (a, b, c) = (recipient(1), recipient(2), recipient(3)); + let plan = plan_authorization( + &policy(&[&a]), + &RecipientMutation::Add(vec![c.clone()]), &[file(".env", Some(&[&a, &b]))], ) .expect("plan"); - assert!(!plan.manifest_changed); - assert_eq!(plan.files[0].1, ConvergenceAction::AlreadyAligned); - assert_eq!(plan.grants, vec![GrantEcho::AlreadyAuthorized(b)]); + assert_eq!(plan.files[0].target_recipients, vec![a, b.clone(), c]); + assert_eq!(plan.files[0].pending_removals, vec![b]); + assert_eq!(plan.files[0].action, ConvergenceAction::Rewrap); } #[test] - fn add_converges_a_hand_edited_manifest_when_the_difference_is_named() { + fn remove_targets_envelope_minus_named_and_rotates() { let (a, b) = (recipient(1), recipient(2)); let plan = plan_authorization( &policy(&[&a, &b]), - &RecipientMutation::Add(vec![b.clone()]), - &[file(".env", Some(&[&a]))], + &RecipientMutation::Remove(vec![b.clone()]), + &[file(".env", Some(&[&a, &b]))], ) .expect("plan"); - assert!(!plan.manifest_changed); - assert_eq!(plan.files[0].1, ConvergenceAction::Rewrap); + assert_eq!(plan.desired_recipients, vec![a.clone()]); + assert!(plan.manifest_changed); + assert_eq!(plan.files[0].action, ConvergenceAction::Rotate); + assert_eq!(plan.files[0].target_recipients, vec![a]); + assert_eq!(plan.deltas, vec![PolicyDelta::RemovedFromPolicy(b)]); } + // Mixed drift converges as two sequential one-sided commands: the user's + // "remove Y, then add X" intuition, each step naming exactly its change. #[test] - fn an_injected_manifest_addition_is_rejected_and_named() { - let (a, b, injected) = (recipient(1), recipient(2), recipient(9)); + fn mixed_drift_converges_by_sequential_remove_then_add() { + let (a, x, y) = (recipient(1), recipient(2), recipient(3)); + // State: policy [A, X] (X pulled in via manifest), envelope [A, Y]. + let first = plan_authorization( + &policy(&[&a, &x]), + &RecipientMutation::Remove(vec![y.clone()]), + &[file(".env", Some(&[&a, &y]))], + ) + .expect("remove plan"); + assert_eq!(first.files[0].action, ConvergenceAction::Rotate); + assert_eq!(first.files[0].target_recipients, vec![a.clone()]); + assert_eq!(first.files[0].pending_additions, vec![x.clone()]); + assert!(!first.manifest_changed, "Y was never in the policy"); assert_eq!( - plan_authorization( - &policy(&[&a, &injected]), - &RecipientMutation::Add(vec![b]), - &[file(".env", Some(&[&a]))], - ), - Err(AuthorizationRejection::UnexplainedAddition { - path: ManagedPath::new(".env").expect("managed path"), - recipient: injected, - }) + first.deltas, + vec![PolicyDelta::RemovedFromCiphertextOnly(y)] ); - } - #[test] - fn an_injected_manifest_removal_is_rejected_by_add() { - let (a, b, c) = (recipient(1), recipient(2), recipient(3)); - assert_eq!( - plan_authorization( - &policy(&[&a]), - &RecipientMutation::Add(vec![c]), - &[file(".env", Some(&[&a, &b]))], - ), - Err(AuthorizationRejection::UnexplainedRemoval { - path: ManagedPath::new(".env").expect("managed path"), - recipient: b, - }) - ); + // After the rotation the envelope is [A]; the add converges fully. + let second = plan_authorization( + &policy(&[&a, &x]), + &RecipientMutation::Add(vec![x.clone()]), + &[file(".env", Some(&[&a]))], + ) + .expect("add plan"); + assert_eq!(second.files[0].action, ConvergenceAction::Rewrap); + assert_eq!(second.files[0].target_recipients, vec![a, x.clone()]); + assert!(second.files[0].pending_additions.is_empty()); + assert!(second.files[0].pending_removals.is_empty()); + assert_eq!(second.deltas, vec![PolicyDelta::AlreadyInPolicy(x)]); } #[test] - fn removing_a_policy_member_rotates_every_existing_ciphertext() { + fn idempotent_add_is_already_aligned() { let (a, b) = (recipient(1), recipient(2)); let plan = plan_authorization( &policy(&[&a, &b]), - &RecipientMutation::Remove(vec![b.clone()]), + &RecipientMutation::Add(vec![b.clone()]), &[file(".env", Some(&[&a, &b]))], ) .expect("plan"); - assert_eq!(plan.desired_recipients, vec![a]); - assert!(plan.manifest_changed); - assert_eq!(plan.files[0].1, ConvergenceAction::Rotate); - assert_eq!(plan.grants, vec![GrantEcho::Removed(b)]); + assert!(!plan.manifest_changed); + assert_eq!(plan.files[0].action, ConvergenceAction::AlreadyAligned); + assert_eq!(plan.deltas, vec![PolicyDelta::AlreadyInPolicy(b)]); } + // Cleaning an injected manifest addition is itself in-product: removing + // it touches only the manifest because no envelope carries it. #[test] - fn removing_a_ciphertext_only_recipient_converges_removal_drift() { - let (a, b) = (recipient(1), recipient(2)); + fn removing_a_manifest_only_recipient_changes_no_envelope() { + let (a, injected) = (recipient(1), recipient(9)); let plan = plan_authorization( - &policy(&[&a]), - &RecipientMutation::Remove(vec![b.clone()]), - &[file(".env", Some(&[&a, &b]))], + &policy(&[&a, &injected]), + &RecipientMutation::Remove(vec![injected.clone()]), + &[file(".env", Some(&[&a]))], ) .expect("plan"); - assert!(!plan.manifest_changed); - assert_eq!(plan.files[0].1, ConvergenceAction::Rotate); - assert_eq!(plan.grants, vec![GrantEcho::RemovedFromCiphertext(b)]); + assert!(plan.manifest_changed); + assert_eq!(plan.files[0].action, ConvergenceAction::AlreadyAligned); + assert!(plan.files[0].pending_additions.is_empty()); + assert_eq!(plan.deltas, vec![PolicyDelta::RemovedFromPolicy(injected)]); } #[test] - fn remove_rejects_unknown_recipients_and_an_emptied_policy() { + fn remove_rejections_cover_membership_emptiness_and_envelope_emptying() { let (a, b, unknown) = (recipient(1), recipient(2), recipient(9)); assert_eq!( plan_authorization( @@ -359,7 +403,7 @@ mod tests { &RecipientMutation::Remove(vec![unknown.clone()]), &[file(".env", Some(&[&a, &b]))], ), - Err(AuthorizationRejection::RemoveNotMember(unknown.clone())) + Err(AuthorizationRejection::RemoveNotMember(unknown)) ); assert_eq!( plan_authorization( @@ -369,23 +413,16 @@ mod tests { ), Err(AuthorizationRejection::EmptyPolicy) ); - } - - #[test] - fn remove_rejects_an_unexplained_manifest_addition() { - // The attacker added `injected` to the manifest; a removal of `b` - // must not silently wrap the data key to `injected`. - let (a, b, injected) = (recipient(1), recipient(2), recipient(9)); + // The policy keeps a member, but this envelope would lose everyone. assert_eq!( plan_authorization( - &policy(&[&a, &b, &injected]), + &policy(&[&a, &b]), &RecipientMutation::Remove(vec![b.clone()]), - &[file(".env", Some(&[&a, &b]))], + &[file("orphan.env", Some(&[&b]))], ), - Err(AuthorizationRejection::UnexplainedAddition { - path: ManagedPath::new(".env").expect("managed path"), - recipient: injected, - }) + Err(AuthorizationRejection::EmptiesEnvelope( + ManagedPath::new("orphan.env").expect("managed path") + )) ); } diff --git a/src/resolve.rs b/src/resolve.rs index 8b0ad60..6317f18 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -7,7 +7,7 @@ use crate::envelope::{CiphertextEnvelope, DecryptedEnvelope}; use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; use crate::manifest::ResolvedManifestEntry; use crate::path::ManagedPath; -use crate::recipient::AgeRecipient; +use crate::recipient::{AgeRecipient, AgeRecipientPolicy}; use crate::seal::decrypt_source; use crate::sops::SopsClient; use crate::source::{Layout, LineEnding, Node, NodePath, SourceDocument}; @@ -22,9 +22,13 @@ const MARKER_SIZE: usize = 7; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ResolveOutcome { /// The merge was completed; ciphertext and plaintext were rewritten. - /// `recipient_drift` reports that the preserved recipient set still - /// differs from the manifest policy; `gitveil recipient` converges it. - Resolved { recipient_drift: bool }, + /// `data_key_rotated` reports that policy-removed recipients were + /// excluded from the merged envelope under a fresh data key; + /// `recipient_drift` reports pending additions for `gitveil recipient`. + Resolved { + data_key_rotated: bool, + recipient_drift: bool, + }, /// Same-key conflicts remain: the plaintext holds a conflict document to /// edit, the working ciphertext was normalized to the local (ours) side, /// and `gitveil seal` finishes the resolution. @@ -103,15 +107,47 @@ fn resolve_entry( .and_then(|envelope| envelope.to_yaml()) .map(SecretBytes::new) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - // Resolve is a data command: the merged envelope keeps the - // ours-side recipient set byte-for-byte in its wrapped keys. - // Authorization changes only through `gitveil recipient`. + // Resolve is a data command: it never extends access. The merged + // content (which includes theirs-side values) is encrypted to the + // intersection of the ours-side envelope set and the policy, so a + // policy-removed recipient never gains the merged values, and no + // recipient outside the ours envelope is granted anything. + // Pending additions stay for `gitveil recipient add`. let ours_envelope = CiphertextEnvelope::parse(ours, entry.format()) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - let ciphertext = sops.edit(ours, &desired, path)?; + let policy_recipients = entry.recipient_policy().recipients(); + let target = ours_envelope + .age_recipients() + .iter() + .filter(|recipient| policy_recipients.contains(recipient)) + .cloned() + .collect::>(); + if target.is_empty() { + return Err(GitveilError::configuration(format!( + "no recipient of {cipher_path} remains in policy {}; converge \ + authorization with gitveil recipient before resolving", + entry.recipient_policy().name() + ))); + } + let data_key_rotated = target.len() != ours_envelope.age_recipients().len(); + let ciphertext = if data_key_rotated { + let narrowed = AgeRecipientPolicy::new( + entry.recipient_policy().name().clone(), + target.clone(), + ) + .map_err(|error| { + GitveilError::new( + ErrorCategory::Integrity, + format!("narrowed recipient set is invalid at {path}: {error}"), + ) + })?; + sops.encrypt_new(&desired, path, &narrowed)? + } else { + sops.edit(ours, &desired, path)? + }; let envelope = CiphertextEnvelope::parse(&ciphertext, entry.format()) .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; - verify_recipients_preserved(&ours_envelope, &envelope, path)?; + verify_recipients_exact(&envelope, &target, path)?; let verified = decrypt_source(sops, &ciphertext, entry)?; if !verified.semantic_eq(&merged) { return Err(GitveilError::new( @@ -136,7 +172,10 @@ fn resolve_entry( .recipient_policy() .diff(envelope.age_recipients()) .is_empty(); - Ok(ResolveOutcome::Resolved { recipient_drift }) + Ok(ResolveOutcome::Resolved { + data_key_rotated, + recipient_drift, + }) } MergePlan::Conflict { path: node, @@ -152,31 +191,28 @@ fn resolve_entry( } } -/// The merge must not change authorization: the output envelope's -/// recipient set must equal the ours-side set it was edited from. -fn verify_recipients_preserved( - before: &CiphertextEnvelope, - after: &CiphertextEnvelope, +/// The merged envelope's recipient set must equal the computed narrowed +/// target exactly: nothing outside the ours envelope, nothing the policy +/// already removed. +fn verify_recipients_exact( + envelope: &CiphertextEnvelope, + target: &[AgeRecipient], path: &ManagedPath, ) -> Result<()> { - let mut before = before - .age_recipients() - .iter() - .map(AgeRecipient::as_str) - .collect::>(); - let mut after = after + let mut actual = envelope .age_recipients() .iter() .map(AgeRecipient::as_str) .collect::>(); - before.sort_unstable(); - after.sort_unstable(); - if before == after { + let mut expected = target.iter().map(AgeRecipient::as_str).collect::>(); + actual.sort_unstable(); + expected.sort_unstable(); + if actual == expected { return Ok(()); } Err(GitveilError::new( ErrorCategory::Integrity, - format!("merge changed the envelope recipient set at {path}"), + format!("merge produced an envelope outside the narrowed recipient set at {path}"), )) } diff --git a/src/seal.rs b/src/seal.rs index 6b0105f..2083e60 100644 --- a/src/seal.rs +++ b/src/seal.rs @@ -170,7 +170,9 @@ fn ensure_policy_ciphertexts_aligned( entry: &ResolvedManifestEntry<'_>, ) -> Result<()> { for other in workspace.manifest().entries() { - if other.path() == entry.path() || other.recipient_policy() != entry.policy_name() { + if other.path() == entry.path() + || other.recipient_policy() != entry.recipient_policy().name() + { continue; } let cipher = other.ciphertext_path(); @@ -189,7 +191,7 @@ fn ensure_policy_ciphertexts_aligned( "policy {} has drifted ciphertext at {cipher} (add {}, remove {}); \ run gitveil recipient add or gitveil recipient remove before \ sealing new files under this policy", - entry.policy_name(), + entry.recipient_policy().name(), drift.added, drift.removed ))); diff --git a/src/workspace.rs b/src/workspace.rs index 3a3c3ea..71ff999 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -153,41 +153,12 @@ impl Workspace { /// Atomically writes a plaintext file with owner-only permissions. pub(crate) fn write_plaintext(&self, path: &ManagedPath, bytes: &[u8]) -> Result<()> { - self.write(path, bytes, 0o600) + write_managed_file(&self.root, path, bytes, 0o600) } /// Atomically writes a ciphertext file with conventional permissions. pub(crate) fn write_ciphertext(&self, path: &ManagedPath, bytes: &[u8]) -> Result<()> { - self.write(path, bytes, 0o644) - } - - fn write(&self, path: &ManagedPath, bytes: &[u8], mode: u32) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - confine::validate_managed_path(&self.root, path)?; - let target = path.join_to(&self.root); - let parent = target - .parent() - .ok_or_else(|| GitveilError::configuration("workspace path has no parent"))?; - let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { - GitveilError::io( - "create workspace replacement", - Some(parent.to_path_buf()), - &error, - ) - })?; - temporary - .as_file() - .set_permissions(fs::Permissions::from_mode(mode)) - .and_then(|()| temporary.write_all(bytes)) - .and_then(|()| temporary.as_file_mut().sync_all()) - .map_err(|error| { - GitveilError::io("write workspace file", Some(target.clone()), &error) - })?; - temporary.persist(&target).map_err(|error| { - GitveilError::io("replace workspace file", Some(target), &error.error) - })?; - Ok(()) + write_ciphertext_file(&self.root, path, bytes) } /// Baseline store; `None` outside a Git repository (conservative mode). @@ -216,6 +187,39 @@ impl Workspace { } } +/// Single source of the ciphertext write policy (mode `0644`, confinement, +/// atomic replacement) for every writer of managed ciphertext files. +pub(crate) fn write_ciphertext_file(root: &Path, path: &ManagedPath, bytes: &[u8]) -> Result<()> { + write_managed_file(root, path, bytes, 0o644) +} + +fn write_managed_file(root: &Path, path: &ManagedPath, bytes: &[u8], mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + confine::validate_managed_path(root, path)?; + let target = path.join_to(root); + let parent = target + .parent() + .ok_or_else(|| GitveilError::configuration("workspace path has no parent"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { + GitveilError::io( + "create workspace replacement", + Some(parent.to_path_buf()), + &error, + ) + })?; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(mode)) + .and_then(|()| temporary.write_all(bytes)) + .and_then(|()| temporary.as_file_mut().sync_all()) + .map_err(|error| GitveilError::io("write workspace file", Some(target.clone()), &error))?; + temporary + .persist(&target) + .map_err(|error| GitveilError::io("replace workspace file", Some(target), &error.error))?; + Ok(()) +} + fn find_manifest_root(start: &Path) -> Result { let start = start.canonicalize().map_err(|error| { GitveilError::io( diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs index 0f8900e..ff34400 100644 --- a/tests/cli_contract.rs +++ b/tests/cli_contract.rs @@ -91,7 +91,9 @@ fn recipient_help_names_the_explicit_authorization_surface() { assert_eq!(output.status.code(), Some(0)); let help = String::from_utf8_lossy(&output.stdout); assert!(help.contains("--policy"), "{subcommand} help: {help}"); - assert!(help.contains("--recipient"), "{subcommand} help: {help}"); + // Recipients are the positional direct object, not a flag. + assert!(help.contains("AGE_RECIPIENT"), "{subcommand} help: {help}"); + assert!(!help.contains("--recipient"), "{subcommand} help: {help}"); } // The recipient is the required, explicit authorization statement. diff --git a/tests/integration_recipients.rs b/tests/integration_recipients.rs index defc821..4b62abf 100644 --- a/tests/integration_recipients.rs +++ b/tests/integration_recipients.rs @@ -1,5 +1,10 @@ //! `gitveil recipient add` / `gitveil recipient remove`: the explicit //! authorization commands, and the fail-closed drift contract they anchor. +//! +//! Semantics under test: each command converges envelopes only in its own +//! direction and only for the recipients named on the command line; residual +//! drift against the policy is reported with full values and keeps `seal` +//! fail-closed until it is explicitly converged. pub mod support; @@ -9,7 +14,6 @@ use std::process::Command; use gitveil::config::SourceFormat; use gitveil::envelope::CiphertextEnvelope; -use gitveil::recipient::AgeRecipient; use support::{ GitFixture, assert_success, command_output, contains, encrypted_leaf, generate_age_identity, @@ -38,8 +42,17 @@ fn recipients_of(fixture: &GitFixture, cipher: &str) -> Vec { recipients } -fn manifest_json(fixture: &GitFixture) -> serde_json::Value { - serde_json::from_slice(&read(fixture, ".gitveilrc.json")).expect("manifest JSON") +fn manifest_recipients(fixture: &GitFixture) -> Vec { + let manifest: serde_json::Value = + serde_json::from_slice(&read(fixture, ".gitveilrc.json")).expect("manifest JSON"); + let mut recipients = manifest["recipientPolicies"]["team"]["age"] + .as_array() + .expect("age array") + .iter() + .map(|value| value.as_str().expect("recipient string").to_owned()) + .collect::>(); + recipients.sort_unstable(); + recipients } /// A recipient whose private identity the fixture never holds: the attacker. @@ -62,6 +75,22 @@ fn no_op_updatekeys_wrapper(directory: &Path) -> PathBuf { path } +/// A SOPS wrapper that fails every encryption, so a removal's data-key +/// rotation fails after the manifest already published. +fn failing_encrypt_wrapper(directory: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = directory.join("sops"); + let real_sops = sops_binary().to_string_lossy().replace('\'', "'\\''"); + let script = format!( + "#!/bin/sh\nfor argument in \"$@\"; do\n if [ \"$argument\" = --encrypt ]; then\n exit 1\n fi\ndone\nexec '{real_sops}' \"$@\"\n" + ); + fs::write(&path, script).expect("write SOPS fault wrapper"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("secure SOPS fault wrapper"); + path +} + #[test] fn recipient_add_rewraps_every_policy_file_and_echoes_the_grant() { let fixture = GitFixture::new(); @@ -73,31 +102,32 @@ fn recipient_add_rewraps_every_policy_file_and_echoes_the_grant() { let (second, second_identity) = fixture.add_identity_with_path(); let added = assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "recipient add", ); let stdout = String::from_utf8_lossy(&added.stdout); - // Authorization names its subject: the full public recipient. + // Authorization names its subject, and the per-file effect renders + // before the policy-level claim. assert!( stdout.contains(&format!("policy team: added {second}")), "stdout: {stdout}" ); assert!(stdout.contains("secret.env: rewrapped"), "stdout: {stdout}"); + let file_line = stdout.find("secret.env: rewrapped").expect("file line"); + let policy_line = stdout.find("policy team: added").expect("policy line"); + assert!( + file_line < policy_line, + "file effects must render before policy claims: {stdout}" + ); // Manifest and envelope both converged in one command. - assert_eq!( - manifest_json(&fixture)["recipientPolicies"]["team"]["age"] - .as_array() - .expect("age array") - .len(), - 2 - ); - let after = read(&fixture, "secret.env.gitveil"); let mut expected = vec![fixture.recipient().to_owned(), second.clone()]; expected.sort_unstable(); + assert_eq!(manifest_recipients(&fixture), expected); assert_eq!(recipients_of(&fixture, "secret.env.gitveil"), expected); // Additions rewrap the same data key: every leaf stays byte-stable. + let after = read(&fixture, "secret.env.gitveil"); for leaf in ["A", "B", "layout"] { assert_eq!(encrypted_leaf(&after, leaf), encrypted_leaf(&before, leaf)); } @@ -113,12 +143,12 @@ fn recipient_add_rewraps_every_policy_file_and_echoes_the_grant() { // An idempotent rerun changes nothing. let rerun = assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "idempotent rerun", ); let stdout = String::from_utf8_lossy(&rerun.stdout); - assert!(stdout.contains("already authorized"), "stdout: {stdout}"); - assert!(stdout.contains("already aligned"), "stdout: {stdout}"); + assert!(stdout.contains("already in policy"), "stdout: {stdout}"); + assert!(stdout.contains("no change needed"), "stdout: {stdout}"); assert_eq!(read(&fixture, "secret.env.gitveil"), after); // Sealing still works after the convergence: no drift remains. @@ -140,7 +170,7 @@ fn recipient_remove_rotates_the_data_key_and_excludes_the_removed_identity() { let (second, second_identity) = fixture.add_identity_with_path(); assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "authorize the second identity", ); let before_removal = read(&fixture, "secret.env.gitveil"); @@ -154,7 +184,7 @@ fn recipient_remove_rotates_the_data_key_and_excludes_the_removed_identity() { let first = fixture.recipient().to_owned(); let removed = assert_success( - fixture.run_gitveil(&["recipient", "remove", "--recipient", first.as_str()]), + fixture.run_gitveil(&["recipient", "remove", first.as_str()]), "recipient remove", ); let stdout = String::from_utf8_lossy(&removed.stdout); @@ -213,15 +243,15 @@ fn recipient_remove_rotates_the_data_key_and_excludes_the_removed_identity() { } // The confused-deputy case D8 exists for: a manifest edit alone must never -// become effective authorization, even when an identity holder runs the -// authorization command for an unrelated recipient. +// become effective authorization. An add that does not name the injected +// recipient proceeds for what it names, never wraps the injected one, and +// surfaces it with its full value until it is explicitly added or removed. #[test] -fn an_injected_manifest_recipient_is_rejected_and_named() { +fn an_injected_manifest_recipient_is_never_wrapped_and_is_surfaced() { let fixture = GitFixture::new(); fixture.initialize(); write(&fixture, "secret.env", &format!("A={CANARY}\n")); assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); - let ciphertext = read(&fixture, "secret.env.gitveil"); // The attacker writes their recipient into the tracked manifest. let attacker = foreign_recipient(); @@ -230,32 +260,59 @@ fn an_injected_manifest_recipient_is_rejected_and_named() { &[("secret.env", "dotenv")], &[first.as_str(), attacker.as_str()], ); - let tampered_manifest = read(&fixture, ".gitveilrc.json"); - // The user grants a legitimate new machine; the injected recipient is - // not named, so the whole command is rejected and names the attacker. + // The user grants a legitimate new machine. The command converges only + // what it names; the injected recipient stays off the envelope and is + // reported in full for review. let (second, _) = fixture.add_identity_with_path(); - let refused = fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]); - assert_eq!(refused.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&refused.stderr); + let output = fixture.run_gitveil(&["recipient", "add", second.as_str()]); + assert_eq!( + output.status.code(), + Some(1), + "residual drift is an attention outcome" + ); + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stderr.contains(attacker.as_str()), - "the unexplained recipient must be named in full: {stderr}" + stdout.contains(&format!("drift remains (add {attacker})")), + "the unexplained recipient must be named in full: {stdout}" ); assert!( - stderr.contains("did not add it"), - "the error explains the rejection: {stderr}" + stdout.contains(&format!("next: gitveil recipient add {attacker}")), + "stdout: {stdout}" + ); + let mut expected = vec![first.clone(), second.clone()]; + expected.sort_unstable(); + assert_eq!( + recipients_of(&fixture, "secret.env.gitveil"), + expected, + "the injected recipient must never be wrapped" ); - assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); - assert_eq!(read(&fixture, ".gitveilrc.json"), tampered_manifest); - // The attacker cannot decrypt, and daily seal stays blocked, so the - // injected recipient never becomes effective. + // Daily seal stays blocked while the injected recipient is unresolved. let refused_seal = fixture.run_gitveil(&["seal"]); assert_eq!(refused_seal.status.code(), Some(1)); - assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + + // Cleaning up the injection is itself in-product: removing it touches + // only the manifest because no envelope carries it. + let cleaned = assert_success( + fixture.run_gitveil(&["recipient", "remove", attacker.as_str()]), + "remove the injected recipient", + ); + let stdout = String::from_utf8_lossy(&cleaned.stdout); + assert!( + stdout.contains(&format!("policy team: removed {attacker}")), + "stdout: {stdout}" + ); + assert_eq!(manifest_recipients(&fixture), { + let mut expected = vec![first, second]; + expected.sort_unstable(); + expected + }); + assert_success(fixture.run_gitveil(&["seal"]), "seal after cleanup"); } +// The removal direction of an injected manifest edit: an unrelated add never +// executes the removal; revoking requires naming the recipient explicitly. #[test] fn an_injected_manifest_removal_requires_an_explicit_remove() { let fixture = GitFixture::new(); @@ -264,27 +321,34 @@ fn an_injected_manifest_removal_requires_an_explicit_remove() { assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); let (second, _) = fixture.add_identity_with_path(); assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "authorize the second identity", ); // The manifest is hand-edited to drop the second recipient (attacker or - // accident); an unrelated add must not silently revoke it. + // accident); an unrelated add proceeds but never executes the removal. let first = fixture.recipient().to_owned(); fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[first.as_str()]); let (third, _) = fixture.add_identity_with_path(); - let refused = fixture.run_gitveil(&["recipient", "add", "--recipient", third.as_str()]); - assert_eq!(refused.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&refused.stderr); - assert!(stderr.contains(second.as_str()), "stderr: {stderr}"); + let output = fixture.run_gitveil(&["recipient", "add", third.as_str()]); + assert_eq!(output.status.code(), Some(1), "residual drift remains"); + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stderr.contains("gitveil recipient remove"), - "stderr: {stderr}" + stdout.contains(&format!("drift remains (remove {second})")), + "the pending removal must be named in full: {stdout}" ); + assert!( + stdout.contains(&format!("next: gitveil recipient remove {second}")), + "stdout: {stdout}" + ); + // The second identity keeps its access until the removal is explicit. + let mut on_envelope = vec![first.clone(), second.clone(), third.clone()]; + on_envelope.sort_unstable(); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil"), on_envelope); // Naming the removal explicitly converges it and rotates the data key. let removed = assert_success( - fixture.run_gitveil(&["recipient", "remove", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "remove", second.as_str()]), "explicit removal", ); let stdout = String::from_utf8_lossy(&removed.stdout); @@ -298,17 +362,91 @@ fn an_injected_manifest_removal_requires_an_explicit_remove() { stdout.contains("secret.env: data key rotated"), "stdout: {stdout}" ); + let mut expected = vec![first, third]; + expected.sort_unstable(); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil"), expected); + assert_success(fixture.run_gitveil(&["seal"]), "seal after convergence"); +} + +// Mixed drift (both directions at once, e.g. after a pulled policy change +// plus a resolved merge) converges as two sequential one-sided commands. +#[test] +fn mixed_drift_converges_by_sequential_remove_then_add() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", &format!("A={CANARY}\n")); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + + // Y is a real envelope recipient; snapshot its standalone identity. + let (y, y_identity) = fixture.add_identity_with_path(); + assert_success( + fixture.run_gitveil(&["recipient", "add", y.as_str()]), + "authorize Y", + ); + + // The manifest arrives with X in and Y out (teammate's policy change). + let (x, x_identity) = fixture.add_identity_with_path(); + let first = fixture.recipient().to_owned(); + fixture + .write_manifest_with_recipients(&[("secret.env", "dotenv")], &[first.as_str(), x.as_str()]); + + // Both data commands refuse; the state is converged by two explicit + // one-sided authorization commands, in the user's natural order. + assert_eq!(fixture.run_gitveil(&["seal"]).status.code(), Some(1)); + + let removed = fixture.run_gitveil(&["recipient", "remove", y.as_str()]); + assert_eq!( + removed.status.code(), + Some(1), + "the pending addition still needs attention" + ); + let stdout = String::from_utf8_lossy(&removed.stdout); + assert!( + stdout.contains("secret.env: data key rotated"), + "stdout: {stdout}" + ); + assert!( + stdout.contains(&format!("drift remains (add {x})")), + "stdout: {stdout}" + ); assert_eq!( recipients_of(&fixture, "secret.env.gitveil"), vec![first.clone()] ); + + let added = assert_success( + fixture.run_gitveil(&["recipient", "add", x.as_str()]), + "grant X explicitly", + ); + assert!(String::from_utf8_lossy(&added.stdout).contains("secret.env: rewrapped")); + let mut expected = vec![first, x]; + expected.sort_unstable(); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil"), expected); assert_success(fixture.run_gitveil(&["seal"]), "seal after convergence"); + + // X decrypts; Y does not. + let mut granted: Command = fixture.command_without_identity(fixture.binary()); + granted.env("SOPS_AGE_KEY_FILE", &x_identity).args(["open"]); + assert_success( + command_output(&mut granted), + "open with the granted identity", + ); + let mut revoked: Command = fixture.command_without_identity(fixture.binary()); + revoked.env("SOPS_AGE_KEY_FILE", &y_identity).args(["open"]); + let output = command_output(&mut revoked); + assert_eq!( + output.status.code(), + Some(1), + "the revoked identity must not decrypt" + ); + assert!(!contains(&output.stdout, CANARY.as_bytes())); + assert!(!contains(&output.stderr, CANARY.as_bytes())); } -// Mid-run failure semantics: the manifest publishes first, a failed file -// convergence leaves drift that seal refuses, and a rerun converges it. +// Mid-run add failure: the manifest publishes first, a failed rewrap leaves +// drift that seal refuses, and a rerun converges it. #[test] -fn a_failed_convergence_leaves_refusable_drift_and_a_rerun_converges() { +fn a_failed_rewrap_leaves_refusable_drift_and_a_rerun_converges() { let fixture = GitFixture::new(); fixture.initialize(); write(&fixture, "secret.env", &format!("A={CANARY}\n")); @@ -321,7 +459,7 @@ fn a_failed_convergence_leaves_refusable_drift_and_a_rerun_converges() { let mut faulted = fixture.gitveil(); faulted .env("SOPS_BIN", wrapper) - .args(["recipient", "add", "--recipient", second.as_str()]); + .args(["recipient", "add", second.as_str()]); let output = command_output(&mut faulted); assert_eq!( output.status.code(), @@ -330,50 +468,127 @@ fn a_failed_convergence_leaves_refusable_drift_and_a_rerun_converges() { ); assert!(!contains(&output.stdout, CANARY.as_bytes())); assert!(!contains(&output.stderr, CANARY.as_bytes())); + assert!( + String::from_utf8_lossy(&output.stderr).contains("not fully effective"), + "a partial failure must be called out" + ); // The ciphertext is untouched; the manifest was already published, so // the observable state is exactly recipient drift. assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + let mut expected = vec![fixture.recipient().to_owned(), second.clone()]; + expected.sort_unstable(); + assert_eq!(manifest_recipients(&fixture), expected); assert_eq!( - manifest_json(&fixture)["recipientPolicies"]["team"]["age"] - .as_array() - .expect("age array") - .len(), - 2 - ); - let refused_seal = fixture.run_gitveil(&["seal"]); - assert_eq!( - refused_seal.status.code(), + fixture.run_gitveil(&["seal"]).status.code(), Some(1), "drift left by the failure keeps seal fail-closed" ); // The idempotent rerun (without the fault) converges the drift. let rerun = assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "converging rerun", ); assert!(String::from_utf8_lossy(&rerun.stdout).contains("secret.env: rewrapped")); assert_success(fixture.run_gitveil(&["seal"]), "seal after recovery"); } -// Preflight fails closed: without any usable identity nothing is published, -// including the manifest. +// Mid-run remove failure: the output must not claim an effective revocation +// while the removed identity still decrypts the file. #[test] -fn no_identity_means_no_manifest_publication_and_no_file_changes() { +fn a_failed_rotation_does_not_claim_effective_revocation() { let fixture = GitFixture::new(); fixture.initialize(); - write(&fixture, "secret.env", "A=one\n"); + write(&fixture, "secret.env", &format!("A={CANARY}\n")); assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let removed_only = tempfile::tempdir().expect("removed identity directory"); + let removed_identity = removed_only.path().join("removed.txt"); + fs::copy(fixture.identity(), &removed_identity).expect("snapshot first identity"); + let (second, _) = fixture.add_identity_with_path(); + assert_success( + fixture.run_gitveil(&["recipient", "add", second.as_str()]), + "authorize the second identity", + ); let ciphertext = read(&fixture, "secret.env.gitveil"); + + let first = fixture.recipient().to_owned(); + let wrapper_directory = tempfile::tempdir().expect("SOPS fault wrapper directory"); + let wrapper = failing_encrypt_wrapper(wrapper_directory.path()); + let mut faulted = fixture.gitveil(); + faulted + .env("SOPS_BIN", wrapper) + .args(["recipient", "remove", first.as_str()]); + let output = command_output(&mut faulted); + assert_eq!(output.status.code(), Some(1), "the rotation must fail"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stdout.contains("data key rotated"), + "no rotation may be claimed: {stdout}" + ); + assert!( + stderr.contains("not fully effective"), + "the ineffective revocation must be called out: {stderr}" + ); + // The removed identity still decrypts the untouched file: assert the + // real state the output must not contradict. + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + let mut still_able: Command = fixture.command_without_identity(fixture.binary()); + still_able + .env("SOPS_AGE_KEY_FILE", &removed_identity) + .args(["open"]); + assert_success( + command_output(&mut still_able), + "the identity still decrypts until the rotation really happens", + ); + + // The rerun converges and only then excludes the identity. + let rerun = assert_success( + fixture.run_gitveil(&["recipient", "remove", first.as_str()]), + "converging rerun", + ); + assert!(String::from_utf8_lossy(&rerun.stdout).contains("secret.env: data key rotated")); + let mut excluded: Command = fixture.command_without_identity(fixture.binary()); + excluded + .env("SOPS_AGE_KEY_FILE", &removed_identity) + .args(["open"]); + assert_eq!(command_output(&mut excluded).status.code(), Some(1)); +} + +// Preflight fails closed with per-file reporting: without any usable +// identity nothing is published and every affected file is reported. +#[test] +fn no_identity_reports_every_file_and_publishes_nothing() { + let fixture = GitFixture::new(); + fixture.initialize(); + fixture.write_manifest_with_recipients( + &[("secret.env", "dotenv"), ("other.env", "dotenv")], + &[fixture.recipient()], + ); + write(&fixture, "secret.env", "A=one\n"); + write(&fixture, "other.env", "B=two\n"); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); let manifest = read(&fixture, ".gitveilrc.json"); + let first_cipher = read(&fixture, "secret.env.gitveil"); + let second_cipher = read(&fixture, "other.env.gitveil"); let (second, _) = fixture.add_identity_with_path(); let mut command: Command = fixture.command_without_identity(fixture.binary()); - command.args(["recipient", "add", "--recipient", second.as_str()]); + command.args(["recipient", "add", second.as_str()]); let output = command_output(&mut command); assert_eq!(output.status.code(), Some(1)); - assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("secret.env") && stderr.contains("other.env"), + "every affected file must be reported: {stderr}" + ); + assert!( + stderr.contains("nothing was published"), + "the withheld publication must be explicit: {stderr}" + ); assert_eq!(read(&fixture, ".gitveilrc.json"), manifest); + assert_eq!(read(&fixture, "secret.env.gitveil"), first_cipher); + assert_eq!(read(&fixture, "other.env.gitveil"), second_cipher); } #[test] @@ -387,11 +602,11 @@ fn recipient_argument_validation_fails_before_any_effect() { // Invalid and private-identity values are rejected. for invalid in ["not-a-recipient", "AGE-SECRET-KEY-PRIVATE"] { - let output = fixture.run_gitveil(&["recipient", "add", "--recipient", invalid]); + let output = fixture.run_gitveil(&["recipient", "add", invalid]); assert_eq!(output.status.code(), Some(1), "{invalid}"); } // Removing the last recipient would leave the policy unable to encrypt. - let output = fixture.run_gitveil(&["recipient", "remove", "--recipient", first.as_str()]); + let output = fixture.run_gitveil(&["recipient", "remove", first.as_str()]); assert_eq!(output.status.code(), Some(1)); assert!( String::from_utf8_lossy(&output.stderr).contains("at least one recipient"), @@ -399,13 +614,15 @@ fn recipient_argument_validation_fails_before_any_effect() { ); // Removing an unknown recipient is rejected. let unknown = foreign_recipient(); - let output = fixture.run_gitveil(&["recipient", "remove", "--recipient", unknown.as_str()]); + let output = fixture.run_gitveil(&["recipient", "remove", unknown.as_str()]); assert_eq!(output.status.code(), Some(1)); assert_eq!(read(&fixture, ".gitveilrc.json"), manifest); - // A usage error without any --recipient exits 2 through clap. - let output = fixture.run_gitveil(&["recipient", "add"]); - assert_eq!(output.status.code(), Some(2)); + // A usage error without any recipient exits 2 through clap. + for subcommand in ["add", "remove"] { + let output = fixture.run_gitveil(&["recipient", subcommand]); + assert_eq!(output.status.code(), Some(2), "{subcommand}"); + } } // Multi-file policies converge together; entries without ciphertext only @@ -431,7 +648,7 @@ fn recipient_add_converges_multiple_files_and_predeclared_entries() { let (second, _) = fixture.add_identity_with_path(); let added = assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "recipient add across the policy", ); let stdout = String::from_utf8_lossy(&added.stdout); @@ -451,6 +668,4 @@ fn recipient_add_converges_multiple_files_and_predeclared_entries() { "first seal after convergence", ); assert_eq!(recipients_of(&fixture, "pending.env.gitveil").len(), 2); - - let _ = AgeRecipient::new(second).expect("recipient remains canonical"); } diff --git a/tests/integration_resolve.rs b/tests/integration_resolve.rs index 3217de9..d54b51b 100644 --- a/tests/integration_resolve.rs +++ b/tests/integration_resolve.rs @@ -6,7 +6,7 @@ use gitveil::config::SourceFormat; use gitveil::envelope::CiphertextEnvelope; use gitveil::recipient::AgeRecipient; -use support::{GitFixture, assert_success, contains, encrypted_leaf}; +use support::{GitFixture, assert_success, command_output, contains, encrypted_leaf}; fn write(fixture: &GitFixture, path: &str, body: &str) { fs::write(fixture.root().join(path), body).expect("write fixture file"); @@ -142,7 +142,7 @@ fn resolve_preserves_ours_recipients_and_reports_policy_drift() { // The authorization command converges the reported drift. assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "converge drift after resolve", ); let converged = read(&fixture, "secret.env.gitveil"); @@ -156,6 +156,129 @@ fn resolve_preserves_ours_recipients_and_reports_policy_drift() { ); } +/// Builds a ciphertext merge conflict whose stage envelopes carry two +/// recipients, so a policy removal can be exercised against the merge. +/// Returns the fixture, the second recipient, the second-only identity +/// file, and a first-only identity file snapshot for exclusion assertions. +fn conflicted_two_recipient_fixture() -> (GitFixture, String, std::path::PathBuf, std::path::PathBuf) +{ + let fixture = GitFixture::new(); + fixture.initialize(); + seal_and_commit(&fixture, "A=base\nB=base\n", "base"); + // Snapshot the first identity alone before the fixture key file gains + // the second identity. + let first_only = fixture.root().join("..").join("first-only-identity.txt"); + fs::copy(fixture.identity(), &first_only).expect("snapshot first identity"); + let (second, second_identity) = fixture.add_identity_with_path(); + assert_success( + fixture.run_gitveil(&["recipient", "add", second.as_str()]), + "authorize the second identity", + ); + assert_success( + fixture.run_git(&["add", ".gitveilrc.json", "secret.env.gitveil"]), + "git add converged configuration", + ); + assert_success( + fixture.run_git(&["commit", "-qm", "add second recipient"]), + "commit second recipient", + ); + assert_success( + fixture.run_git(&["checkout", "-q", "-b", "feature"]), + "create feature branch", + ); + seal_and_commit(&fixture, "A=base\nB=theirs\n", "feature change"); + assert_success( + fixture.run_git(&["checkout", "-q", "main"]), + "checkout main", + ); + seal_and_commit(&fixture, "A=ours\nB=base\n", "main change"); + let merge = fixture.run_git(&["merge", "--no-edit", "feature"]); + assert!( + !merge.status.success(), + "resealed envelopes must conflict textually" + ); + (fixture, second, second_identity, first_only) +} + +// A policy that removed a recipient before the merge: resolve encrypts the +// merged content to the intersection of the ours envelope and the policy, +// so the removed identity never gains the merged (theirs-side) values. +#[test] +fn resolve_narrows_to_the_policy_intersection_and_excludes_removed_recipients() { + let (fixture, second, second_identity, removed_identity) = conflicted_two_recipient_fixture(); + + // The manifest drops the first recipient (pulled policy change). + fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[second.as_str()]); + + let resolved = assert_success(fixture.run_gitveil(&["resolve"]), "gitveil resolve"); + let stdout = String::from_utf8_lossy(&resolved.stdout); + assert!( + stdout.contains("data key rotated"), + "the narrowing must be reported: {stdout}" + ); + assert!( + !stdout.contains("recipient drift remains"), + "a pure removal narrows to the full policy: {stdout}" + ); + assert_eq!(read(&fixture, "secret.env"), b"A=ours\nB=theirs\n"); + + let merged_cipher = read(&fixture, "secret.env.gitveil"); + let envelope = + CiphertextEnvelope::parse(&merged_cipher, SourceFormat::Dotenv).expect("merged envelope"); + assert_eq!( + envelope.age_recipients(), + &[AgeRecipient::new(&second).expect("recipient")], + "the removed recipient must not be on the merged envelope" + ); + + // The kept identity decrypts the merged result through the product + // binary; the removed identity cannot: the merged theirs-side value was + // never encrypted under a key the removed party holds. + let mut kept = fixture.command_without_identity(fixture.binary()); + kept.env("SOPS_AGE_KEY_FILE", &second_identity).arg("open"); + assert_success(command_output(&mut kept), "open with the kept identity"); + let mut excluded = fixture.command_without_identity(fixture.binary()); + excluded + .env("SOPS_AGE_KEY_FILE", &removed_identity) + .arg("open"); + assert_eq!( + command_output(&mut excluded).status.code(), + Some(1), + "the removed identity must not decrypt the merged result" + ); +} + +// A policy with no recipient in common with the ours envelope cannot accept +// merged content anywhere safely: resolve refuses with zero side effects. +#[test] +fn resolve_refuses_a_disjoint_policy_with_zero_side_effects() { + let fixture = conflicted_fixture("A=ours\nB=base\n", "A=base\nB=theirs\n"); + let replacement = fixture.add_identity(); + fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[replacement.as_str()]); + let ciphertext_before = read(&fixture, "secret.env.gitveil"); + let plaintext_before = read(&fixture, "secret.env"); + let index_before = assert_success( + fixture.run_git(&["ls-files", "-u", "--", "secret.env.gitveil"]), + "read conflicted index", + ) + .stdout; + + let output = fixture.run_gitveil(&["resolve"]); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("gitveil recipient"), + "the refusal must route to the authorization command" + ); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext_before); + assert_eq!(read(&fixture, "secret.env"), plaintext_before); + let index_after = assert_success( + fixture.run_git(&["ls-files", "-u", "--", "secret.env.gitveil"]), + "read conflicted index after refusal", + ) + .stdout; + assert_eq!(index_after, index_before); +} + #[test] fn same_key_conflict_writes_markers_to_plaintext_and_normalizes_ciphertext() { let fixture = conflicted_fixture("A=ours-value\nB=base\n", "A=theirs-value\nB=base\n"); diff --git a/tests/integration_seal_open.rs b/tests/integration_seal_open.rs index 83fe4d1..fbf6819 100644 --- a/tests/integration_seal_open.rs +++ b/tests/integration_seal_open.rs @@ -337,7 +337,7 @@ fn content_edits_do_not_bypass_the_recipient_drift_refusal() { assert_eq!(read(&fixture, "secret.env"), b"A=changed\nB=stable\n"); assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "authorize the addition explicitly", ); assert_success(fixture.run_gitveil(&["seal"]), "seal after convergence"); @@ -383,7 +383,7 @@ fn first_seal_requires_aligned_policy_siblings() { ); assert_success( - fixture.run_gitveil(&["recipient", "add", "--recipient", second.as_str()]), + fixture.run_gitveil(&["recipient", "add", second.as_str()]), "converge the policy", ); assert_success( @@ -396,6 +396,9 @@ fn first_seal_requires_aligned_policy_siblings() { assert_eq!(envelope.age_recipients().len(), 2); } +// An aligned seal still requires an identity to decrypt its incremental +// baseline; without one it fails and leaves plaintext, ciphertext, and +// baseline state untouched. #[test] fn seal_without_any_envelope_identity_leaves_all_three_files_unchanged() { let fixture = GitFixture::new(); @@ -405,23 +408,20 @@ fn seal_without_any_envelope_identity_leaves_all_three_files_unchanged() { let ciphertext = read(&fixture, "secret.env.gitveil"); let state = state_snapshot(&fixture); - let second = fixture.add_identity(); - let first = fixture.recipient().to_owned(); - // Recipient drift now fails closed before any decryption, and a seal - // without drift still needs an identity to decrypt its baseline; both - // directions leave every file untouched without an identity. - for recipients in [ - vec![first.as_str(), second.as_str()], // addition: drift refusal - vec![second.as_str()], // removal: drift refusal - ] { - fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &recipients); - let mut command: Command = fixture.command_without_identity(fixture.binary()); - command.arg("seal"); - let output = support::command_output(&mut command); - assert_eq!(output.status.code(), Some(1)); - assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); - assert_eq!(state_snapshot(&fixture), state); - } + // The manifest stays aligned with the envelope: only the identity is + // missing, and only the plaintext has a pending edit. + write(&fixture, "secret.env", "A=edited\n"); + let mut command: Command = fixture.command_without_identity(fixture.binary()); + command.arg("seal"); + let output = support::command_output(&mut command); + assert_eq!(output.status.code(), Some(1)); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("recipient set differs"), + "an aligned seal must fail on the identity, not on drift" + ); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + assert_eq!(read(&fixture, "secret.env"), b"A=edited\n"); + assert_eq!(state_snapshot(&fixture), state); } #[test] From 304a176c0f569ca80f618ea4eb68481650d28aae Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 17:10:09 +0800 Subject: [PATCH 3/3] Close coverage gaps found in the test audit Add integration coverage for policy-scoped authorization (explicit --policy selection, other policies byte-untouched, mandatory selector with several policies), per-file reporting of an unparseable ciphertext while the rest of the policy converges, the emptied-envelope removal refusal, and the resolve case that rotates and reports a pending addition in one run. Add a unit test for the concurrency check in manifest publication and assign manifest_document unit tests to the host suite. Reword the emptied-envelope error so the positional CLI cannot misread it. --- .config/nextest.toml | 4 +- src/manifest_document.rs | 55 +++++++++++ src/recipients/plan.rs | 2 +- tests/integration_recipients.rs | 159 ++++++++++++++++++++++++++++++++ tests/integration_resolve.rs | 59 ++++++++++++ 5 files changed, 276 insertions(+), 3 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index c7fe19b..834d143 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -4,12 +4,12 @@ nextest-version = "0.9.140" # Tests under tests/ default to the host profile unless deliberately named contract_*. [profile.ci-core] fail-fast = false -default-filter = 'binary(/^contract_/) | (binary(gitveil) & not test(/^(configure::tests|git::repository::tests|identity::tests|runtime::sidecar::tests)/))' +default-filter = 'binary(/^contract_/) | (binary(gitveil) & not test(/^(configure::tests|git::repository::tests|identity::tests|manifest_document::tests|runtime::sidecar::tests)/))' # Host tests are the exact complement of core tests and exercise external effects. [profile.ci-host] fail-fast = false -default-filter = 'not (binary(/^contract_/) | (binary(gitveil) & not test(/^(configure::tests|git::repository::tests|identity::tests|runtime::sidecar::tests)/)))' +default-filter = 'not (binary(/^contract_/) | (binary(gitveil) & not test(/^(configure::tests|git::repository::tests|identity::tests|manifest_document::tests|runtime::sidecar::tests)/)))' # Nextest runs every test in its own process, so serial_test's in-process # lock does not serialize across tests here. This group preserves the same diff --git a/src/manifest_document.rs b/src/manifest_document.rs index 5377289..63a4c48 100644 --- a/src/manifest_document.rs +++ b/src/manifest_document.rs @@ -308,3 +308,58 @@ pub(crate) fn sync_directory(directory: &Path) -> Result<()> { ) }) } + +#[cfg(test)] +mod tests { + use std::fs; + + use crate::error::ErrorCategory; + + use super::{load_manifest, publish_if_fresh}; + + const RECIPIENT: &str = "age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq"; + + fn manifest_bytes(policy: &str) -> Vec { + format!( + r#"{{ + "version": 1, + "recipientPolicies": {{ "{policy}": {{ "age": ["{RECIPIENT}"] }} }}, + "files": [] + }}"# + ) + .into_bytes() + } + + #[test] + fn publication_refuses_a_concurrently_changed_manifest_and_preserves_it() { + let root = tempfile::tempdir().expect("repository root"); + fs::write(root.path().join(".gitveilrc.json"), manifest_bytes("team")) + .expect("write manifest"); + let (preparation, _) = load_manifest(root.path()).expect("load manifest"); + + // A concurrent writer replaces the document after it was loaded. + let concurrent = manifest_bytes("other"); + fs::write(root.path().join(".gitveilrc.json"), &concurrent).expect("concurrent write"); + + let error = publish_if_fresh(root.path(), &preparation, b"candidate", "gitveil recipient") + .expect_err("stale publication must be refused"); + assert_eq!(error.category(), ErrorCategory::Concurrency); + assert_eq!( + fs::read(root.path().join(".gitveilrc.json")).expect("manifest"), + concurrent, + "the concurrent write must be preserved" + ); + + // With a fresh document the same candidate publishes. + fs::write(root.path().join(".gitveilrc.json"), manifest_bytes("team")) + .expect("restore manifest"); + let (preparation, _) = load_manifest(root.path()).expect("reload manifest"); + publish_if_fresh( + root.path(), + &preparation, + &manifest_bytes("team"), + "gitveil recipient", + ) + .expect("fresh publication succeeds"); + } +} diff --git a/src/recipients/plan.rs b/src/recipients/plan.rs index 33353df..e36ef7f 100644 --- a/src/recipients/plan.rs +++ b/src/recipients/plan.rs @@ -89,7 +89,7 @@ pub(super) enum AuthorizationRejection { #[error("a policy must keep at least one recipient; this removal would empty it")] EmptyPolicy, #[error( - "removing these recipients would leave {0}.gitveil with no recipient able to decrypt; run gitveil recipient add first" + "removing these recipients would leave {0}.gitveil with no recipient able to decrypt; grant a replacement with gitveil recipient add before removing them" )] EmptiesEnvelope(ManagedPath), } diff --git a/tests/integration_recipients.rs b/tests/integration_recipients.rs index 4b62abf..d586584 100644 --- a/tests/integration_recipients.rs +++ b/tests/integration_recipients.rs @@ -669,3 +669,162 @@ fn recipient_add_converges_multiple_files_and_predeclared_entries() { ); assert_eq!(recipients_of(&fixture, "pending.env.gitveil").len(), 2); } + +/// Writes a two-policy manifest: `team` guards `secret.env`, `ops` guards +/// `ops.env`. +fn write_two_policy_manifest(fixture: &GitFixture, team: &[&str], ops: &[&str]) { + let manifest = serde_json::json!({ + "version": 1, + "recipientPolicies": { + "team": { "age": team }, + "ops": { "age": ops } + }, + "files": [ + { "path": "secret.env", "format": "dotenv", "recipientPolicy": "team" }, + { "path": "ops.env", "format": "dotenv", "recipientPolicy": "ops" } + ] + }); + fs::write( + fixture.root().join(".gitveilrc.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); +} + +// An authorization command touches exactly the selected policy: files under +// other policies keep their bytes, and with several policies the selector +// is mandatory. +#[test] +fn recipient_commands_are_scoped_to_the_selected_policy() { + let fixture = GitFixture::new(); + fixture.initialize(); + let first = fixture.recipient().to_owned(); + let (ops_member, _) = fixture.add_identity_with_path(); + write_two_policy_manifest(&fixture, &[first.as_str()], &[ops_member.as_str()]); + write(&fixture, "secret.env", "A=one\n"); + write(&fixture, "ops.env", "B=two\n"); + assert_success(fixture.run_gitveil(&["seal"]), "seal both policies"); + let ops_cipher = read(&fixture, "ops.env.gitveil"); + + // Several policies: the selector is required. + let (third, _) = fixture.add_identity_with_path(); + let output = fixture.run_gitveil(&["recipient", "add", third.as_str()]); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("pass --policy"), + "the selector requirement must be explained: {stderr}" + ); + assert_eq!(read(&fixture, "ops.env.gitveil"), ops_cipher); + + // The explicit selector converges only the selected policy's files. + let added = assert_success( + fixture.run_gitveil(&["recipient", "add", "--policy", "team", third.as_str()]), + "recipient add scoped to team", + ); + let stdout = String::from_utf8_lossy(&added.stdout); + assert!(stdout.contains("secret.env: rewrapped"), "stdout: {stdout}"); + assert!( + !stdout.contains("ops.env"), + "the other policy's file must not appear: {stdout}" + ); + assert_eq!( + read(&fixture, "ops.env.gitveil"), + ops_cipher, + "the other policy's ciphertext must keep its bytes" + ); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil").len(), 2); + assert_eq!( + recipients_of(&fixture, "ops.env.gitveil"), + vec![ops_member.clone()] + ); + let manifest: serde_json::Value = + serde_json::from_slice(&read(&fixture, ".gitveilrc.json")).expect("manifest JSON"); + assert_eq!( + manifest["recipientPolicies"]["ops"]["age"], + serde_json::json!([ops_member]), + "the other policy must be untouched" + ); + assert_success(fixture.run_gitveil(&["seal"]), "seal after scoped add"); +} + +// A ciphertext that cannot be parsed is reported per file while the rest of +// the policy converges; repairing it and rerunning converges the leftover. +#[test] +fn a_broken_ciphertext_is_reported_per_file_while_others_converge() { + let fixture = GitFixture::new(); + fixture.initialize(); + fixture.write_manifest_with_recipients( + &[("secret.env", "dotenv"), ("other.env", "dotenv")], + &[fixture.recipient()], + ); + write(&fixture, "secret.env", "A=one\n"); + write(&fixture, "other.env", "B=two\n"); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let intact = read(&fixture, "other.env.gitveil"); + write(&fixture, "other.env.gitveil", "not an envelope\n"); + + let (second, _) = fixture.add_identity_with_path(); + let output = fixture.run_gitveil(&["recipient", "add", second.as_str()]); + assert_eq!(output.status.code(), Some(1), "the broken file must fail"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("secret.env: rewrapped"), "stdout: {stdout}"); + assert!( + stderr.contains("other.env"), + "the broken file must be reported: {stderr}" + ); + assert!( + stderr.contains("not fully effective"), + "the partial convergence must be called out: {stderr}" + ); + assert_eq!(recipients_of(&fixture, "secret.env.gitveil").len(), 2); + let mut expected = vec![fixture.recipient().to_owned(), second.clone()]; + expected.sort_unstable(); + assert_eq!(manifest_recipients(&fixture), expected); + + // Repairing the ciphertext and rerunning converges the leftover drift. + fs::write(fixture.root().join("other.env.gitveil"), &intact).expect("repair ciphertext"); + let rerun = assert_success( + fixture.run_gitveil(&["recipient", "add", second.as_str()]), + "converging rerun", + ); + let stdout = String::from_utf8_lossy(&rerun.stdout); + assert!(stdout.contains("other.env: rewrapped"), "stdout: {stdout}"); + assert!( + stdout.contains("secret.env: no change needed"), + "stdout: {stdout}" + ); + assert_eq!(recipients_of(&fixture, "other.env.gitveil").len(), 2); + assert_success(fixture.run_gitveil(&["seal"]), "seal after repair"); +} + +// Removing the only recipient an envelope carries is refused even when the +// policy itself keeps other members: someone must be granted first. +#[test] +fn removing_an_envelopes_last_recipient_is_refused_with_zero_side_effects() { + let fixture = GitFixture::new(); + fixture.initialize(); + write(&fixture, "secret.env", "A=one\n"); + assert_success(fixture.run_gitveil(&["seal"]), "initial seal"); + let ciphertext = read(&fixture, "secret.env.gitveil"); + + // The manifest gained another member without envelope convergence + // (hand edit); the envelope still carries only the first recipient. + let injected = foreign_recipient(); + let first = fixture.recipient().to_owned(); + fixture.write_manifest_with_recipients( + &[("secret.env", "dotenv")], + &[injected.as_str(), first.as_str()], + ); + let manifest = read(&fixture, ".gitveilrc.json"); + + let output = fixture.run_gitveil(&["recipient", "remove", first.as_str()]); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("no recipient able to decrypt"), + "the emptied-envelope refusal must be explained" + ); + assert_eq!(read(&fixture, "secret.env.gitveil"), ciphertext); + assert_eq!(read(&fixture, ".gitveilrc.json"), manifest); +} diff --git a/tests/integration_resolve.rs b/tests/integration_resolve.rs index d54b51b..109412c 100644 --- a/tests/integration_resolve.rs +++ b/tests/integration_resolve.rs @@ -248,6 +248,65 @@ fn resolve_narrows_to_the_policy_intersection_and_excludes_removed_recipients() ); } +// Mixed drift at resolve: the removal direction narrows under a fresh data +// key while the pending addition is reported for gitveil recipient add. +#[test] +fn resolve_narrows_and_reports_the_pending_addition_together() { + let (fixture, second, second_identity, removed_identity) = conflicted_two_recipient_fixture(); + + // The pulled policy removed the first recipient and added a new one the + // envelopes never carried. + let pending = fixture.add_identity(); + fixture.write_manifest_with_recipients( + &[("secret.env", "dotenv")], + &[second.as_str(), pending.as_str()], + ); + + let resolved = fixture.run_gitveil(&["resolve"]); + assert_eq!( + resolved.status.code(), + Some(1), + "the pending addition keeps the outcome at attention" + ); + let stdout = String::from_utf8_lossy(&resolved.stdout); + assert!(stdout.contains("data key rotated"), "stdout: {stdout}"); + assert!( + stdout.contains("recipient drift remains"), + "stdout: {stdout}" + ); + assert_eq!(read(&fixture, "secret.env"), b"A=ours\nB=theirs\n"); + let envelope = + CiphertextEnvelope::parse(&read(&fixture, "secret.env.gitveil"), SourceFormat::Dotenv) + .expect("merged envelope"); + assert_eq!( + envelope.age_recipients(), + &[AgeRecipient::new(&second).expect("recipient")], + "the merged envelope carries exactly the intersection" + ); + + // The removed identity is excluded; the kept identity converges the + // pending addition afterwards. + let mut excluded = fixture.command_without_identity(fixture.binary()); + excluded + .env("SOPS_AGE_KEY_FILE", &removed_identity) + .arg("open"); + assert_eq!(command_output(&mut excluded).status.code(), Some(1)); + let mut converge = fixture.command_without_identity(fixture.binary()); + converge.env("SOPS_AGE_KEY_FILE", &second_identity).args([ + "recipient", + "add", + pending.as_str(), + ]); + assert_success( + command_output(&mut converge), + "converge the pending addition", + ); + let envelope = + CiphertextEnvelope::parse(&read(&fixture, "secret.env.gitveil"), SourceFormat::Dotenv) + .expect("converged envelope"); + assert_eq!(envelope.age_recipients().len(), 2); +} + // A policy with no recipient in common with the ours envelope cannot accept // merged content anywhere safely: resolve refuses with zero side effects. #[test]