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/README.md b/README.md index bf1d880..b608cac 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 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 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. 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 ```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 age1... +gitveil recipient remove --policy team 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 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. -- 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 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. - 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..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. 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/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..deae6f4 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, ManifestDisposition, PolicyDelta, 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 age1... + gitveil recipient remove --policy team 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 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 recipients to authorize + #[arg(required = true, value_name = "AGE_RECIPIENT")] + recipients: Vec, + }, + /// 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 recipients to revoke + #[arg(required = true, value_name = "AGE_RECIPIENT")] + recipients: 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,152 @@ fn run_identity( Ok(RunOutcome::Success) } +fn run_recipient( + current: &Path, + gitveil_binary: &Path, + command: RecipientCommand, +) -> Result { + let outcome = match command { + RecipientCommand::Add { policy, recipients } => { + recipients::add(current, gitveil_binary, policy.as_deref(), &recipients)? + } + RecipientCommand::Remove { policy, recipients } => { + recipients::remove(current, gitveil_binary, policy.as_deref(), &recipients)? + } + }; + 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 + ); + } + } + } + } + } + + 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(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) => { + rendered.run = RunOutcome::Attention; + rendered.failed = true; + report_error(&report.path, error); + } + } + } + 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 { let outcome = initialize(current, policy, recipients)?; println!( @@ -405,13 +582,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 +655,28 @@ 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 }) => { + 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!( + "{}: {}", + 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..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)) } -struct ManifestPreparation { - path: ManagedPath, - original: Vec, - mode: u32, -} - struct IgnorePreparation { path: ManagedPath, original: Option>, @@ -204,27 +185,6 @@ struct RegistrationPublication<'a> { manifest_candidate: &'a [u8], } -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], @@ -240,7 +200,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)) @@ -386,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, ) @@ -429,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, @@ -456,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<()> { @@ -551,190 +503,6 @@ fn add_outcomes( .collect() } -fn select_recipient_policy(manifest: &Manifest, requested: Option<&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 --recipient-policy" - ))) - } -} - -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) -} - -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, - ) - }) -} - -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, @@ -758,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; @@ -778,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 { @@ -833,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, @@ -886,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 1c1f0a8..e21056b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,10 +12,12 @@ 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; 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..ad336be 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -310,6 +310,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 +456,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 +602,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/manifest_document.rs b/src/manifest_document.rs new file mode 100644 index 0000000..63a4c48 --- /dev/null +++ b/src/manifest_document.rs @@ -0,0 +1,365 @@ +//! 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, + ) + }) +} + +#[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/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..391c870 --- /dev/null +++ b/src/recipients.rs @@ -0,0 +1,459 @@ +//! `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. 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; + +use std::path::Path; +use std::time::Duration; + +use crate::baseline::{BaselineRecord, BaselineStore, CipherSummary}; +use crate::envelope::{CiphertextEnvelope, DecryptedEnvelope}; +use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; +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, PolicyDelta}; +use plan::{FilePlan, FileRecipientFacts, RecipientMutation, plan_authorization}; + +const LOCK_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RecipientFileOutcome { + Rewrapped, + DataKeyRotated, + /// 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, +} + +/// 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, + /// Manifest-level effects; the CLI renders them only after publication. + pub deltas: Vec, + pub manifest: ManifestDisposition, + 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 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 file_plan in &plan.files { + if file_plan.action == ConvergenceAction::AlreadyAligned { + reports.push(RecipientFileReport { + 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() == &file_plan.path) + .ok_or_else(|| { + GitveilError::new( + ErrorCategory::Integrity, + format!( + "authorization plan references unknown file {}", + file_plan.path + ), + ) + })?; + convergences.push((file, file_plan)); + } + + let engine = ConvergenceEngine { + root: &root, + runtime: &runtime, + gitveil_binary, + store: &store, + manifest: &manifest, + policy_name: &policy_name, + }; + 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, + deltas: plan.deltas, + manifest: manifest_disposition, + 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, + policy_name: &'a PolicyName, +} + +impl ConvergenceEngine<'_> { + fn run( + &self, + convergences: Vec<(&AffectedFile<'_>, &FilePlan)>, + reports: &mut Vec, + manifest_changed: bool, + publish: impl FnOnce() -> Result<()>, + ) -> Result { + let disposition = |published: bool| { + if published { + ManifestDisposition::Published + } else { + ManifestDisposition::Unchanged + } + }; + if convergences.is_empty() { + if manifest_changed { + publish()?; + } + return Ok(disposition(manifest_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 withheld and nothing changes. + let mut ready = Vec::new(); + let mut failures = Vec::new(); + for (file, file_plan) 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, file_plan, resolved, document)), + Err(error) => failures.push(RecipientFileReport { + path: file.entry.path().clone(), + result: Err(error), + }), + } + } + if ready.is_empty() { + reports.extend(failures); + return Ok(ManifestDisposition::Withheld); + } + + if manifest_changed { + publish()?; + } + + 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(disposition(manifest_changed)) + } + + fn converge_file( + &self, + sops: &SopsClient, + resolved: &ResolvedManifestEntry<'_>, + ciphertext: &[u8], + document: &SourceDocument, + file_plan: &FilePlan, + ) -> Result { + let path = resolved.path(); + let cipher_path = resolved.ciphertext_path(); + 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, &target)?; + verify_rewrap(ciphertext, &rewrapped, resolved, &target)?; + (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, &target)?; + let envelope = CiphertextEnvelope::parse(&rotated, resolved.format()) + .map_err(|error| GitveilError::ciphertext(path, &error.to_string()))?; + verify_target_recipients(&envelope, &target, 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_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( + 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 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_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 new file mode 100644 index 0000000..e36ef7f --- /dev/null +++ b/src/recipients/plan.rs @@ -0,0 +1,455 @@ +//! Pure authorization planning for `gitveil recipient add` and +//! `gitveil recipient remove`. +//! +//! 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; + +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 converges to its per-file target 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, +} + +/// 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 PolicyDelta { + AddedToPolicy(AgeRecipient), + AlreadyInPolicy(AgeRecipient), + RemovedFromPolicy(AgeRecipient), + /// Absent from the policy but present on at least one ciphertext; the + /// 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 manifest policy. + pub desired_recipients: Vec, + pub manifest_changed: bool, + /// Plans for every entry with an existing, parseable ciphertext. + pub files: Vec, + pub deltas: 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( + "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), +} + +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> { + match mutation { + RecipientMutation::Add(additions) => { + let mut desired = current.to_vec(); + let mut deltas = Vec::with_capacity(additions.len()); + for addition in additions { + if current.contains(addition) { + deltas.push(PolicyDelta::AlreadyInPolicy(addition.clone())); + } else { + desired.push(addition.clone()); + deltas.push(PolicyDelta::AddedToPolicy(addition.clone())); + } + } + Ok((desired, deltas)) + } + RecipientMutation::Remove(removals) => { + let mut deltas = Vec::with_capacity(removals.len()); + for removal in removals { + if current.contains(removal) { + deltas.push(PolicyDelta::RemovedFromPolicy(removal.clone())); + } else if files.iter().any(|facts| { + facts + .envelope_recipients + .as_deref() + .is_some_and(|envelope| envelope.contains(removal)) + }) { + deltas.push(PolicyDelta::RemovedFromCiphertextOnly(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, deltas)) + } + } +} + +#[cfg(test)] +mod tests { + use bech32::{Bech32, Hrp}; + + use crate::path::ManagedPath; + use crate::recipient::{AgeRecipient, AgeRecipientPolicy, PolicyName}; + + use super::{ + AuthorizationRejection, ConvergenceAction, FileRecipientFacts, PolicyDelta, + 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 add_targets_envelope_plus_named_and_rewraps() { + 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.clone(), b.clone()]); + assert!(plan.manifest_changed); + assert_eq!(plan.files.len(), 1); + 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 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, &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_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 remove_targets_envelope_minus_named_and_rotates() { + 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.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 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!( + first.deltas, + vec![PolicyDelta::RemovedFromCiphertextOnly(y)] + ); + + // 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 idempotent_add_is_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].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_manifest_only_recipient_changes_no_envelope() { + let (a, injected) = (recipient(1), recipient(9)); + let plan = plan_authorization( + &policy(&[&a, &injected]), + &RecipientMutation::Remove(vec![injected.clone()]), + &[file(".env", Some(&[&a]))], + ) + .expect("plan"); + 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_rejections_cover_membership_emptiness_and_envelope_emptying() { + 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)) + ); + assert_eq!( + plan_authorization( + &policy(&[&a]), + &RecipientMutation::Remove(vec![a.clone()]), + &[file(".env", Some(&[&a]))], + ), + Err(AuthorizationRejection::EmptyPolicy) + ); + // The policy keeps a member, but this envelope would lose everyone. + assert_eq!( + plan_authorization( + &policy(&[&a, &b]), + &RecipientMutation::Remove(vec![b.clone()]), + &[file("orphan.env", Some(&[&b]))], + ), + Err(AuthorizationRejection::EmptiesEnvelope( + ManagedPath::new("orphan.env").expect("managed path") + )) + ); + } + + #[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..6317f18 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, AgeRecipientPolicy}; +use crate::seal::decrypt_source; use crate::sops::SopsClient; use crate::source::{Layout, LineEnding, Node, NodePath, SourceDocument}; use crate::workspace::Workspace; @@ -22,7 +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. - Resolved { data_key_rotated: 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. @@ -101,30 +107,47 @@ 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: 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 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 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_recipient_policy(&envelope, entry)?; + verify_recipients_exact(&envelope, &target, path)?; let verified = decrypt_source(sops, &ciphertext, entry)?; if !verified.semantic_eq(&merged) { return Err(GitveilError::new( @@ -145,7 +168,14 @@ 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 { + data_key_rotated, + recipient_drift, + }) } MergePlan::Conflict { path: node, @@ -161,6 +191,31 @@ fn resolve_entry( } } +/// 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 actual = envelope + .age_recipients() + .iter() + .map(AgeRecipient::as_str) + .collect::>(); + 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 produced an envelope outside the narrowed recipient set at {path}"), + )) +} + fn decrypt_stage( sops: &SopsClient, ciphertext: &[u8], diff --git a/src/seal.rs b/src/seal.rs index 9cd43b2..2083e60 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,79 @@ 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.recipient_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.recipient_policy().name(), + drift.added, + drift.removed + ))); + } + } + Ok(()) } fn update_baseline( @@ -218,46 +267,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/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 1149ab1..ff34400 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,38 @@ 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}"); + // 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. + 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..d586584 --- /dev/null +++ b/tests/integration_recipients.rs @@ -0,0 +1,830 @@ +//! `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; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use gitveil::config::SourceFormat; +use gitveil::envelope::CiphertextEnvelope; + +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_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. +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 +} + +/// 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(); + 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", second.as_str()]), + "recipient add", + ); + let stdout = String::from_utf8_lossy(&added.stdout); + // 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. + 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)); + } + // 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", second.as_str()]), + "idempotent rerun", + ); + let stdout = String::from_utf8_lossy(&rerun.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. + 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", 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", 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. 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_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"); + + // 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()], + ); + + // 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 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!( + stdout.contains(&format!("drift remains (add {attacker})")), + "the unexplained recipient must be named in full: {stdout}" + ); + assert!( + 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" + ); + + // 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)); + + // 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(); + 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", second.as_str()]), + "authorize the second identity", + ); + + // The manifest is hand-edited to drop the second recipient (attacker or + // 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 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!( + 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", 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}" + ); + 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 add failure: the manifest publishes first, a failed rewrap leaves +// drift that seal refuses, and a rerun converges it. +#[test] +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")); + 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", 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())); + 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!( + 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", 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"); +} + +// Mid-run remove failure: the output must not claim an effective revocation +// while the removed identity still decrypts the file. +#[test] +fn a_failed_rotation_does_not_claim_effective_revocation() { + let fixture = GitFixture::new(); + fixture.initialize(); + 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", second.as_str()]); + let output = command_output(&mut command); + assert_eq!(output.status.code(), Some(1)); + 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] +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", 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", 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", 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. + 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 +// 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", 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); +} + +/// 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 39bf2a1..109412c 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, command_output, 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,85 +111,233 @@ 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 resolved = fixture.run_gitveil(&["resolve"]); + assert_eq!( + resolved.status.code(), + Some(1), + "remaining drift is an attention outcome" + ); 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}" + 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("rewrapped envelope"); - assert_eq!(envelope.age_recipients().len(), 2); - // The same data key is kept: the ours-side leaf stays byte-for-byte. + let envelope = + CiphertextEnvelope::parse(&merged_cipher, SourceFormat::Dotenv).expect("merged envelope"); + assert_eq!( + envelope.age_recipients(), + &[AgeRecipient::new(&first).expect("recipient")] + ); assert_eq!( encrypted_leaf(&merged_cipher, "A"), encrypted_leaf(&ours_cipher, "A") ); + + // The authorization command converges the reported drift. + assert_success( + fixture.run_gitveil(&["recipient", "add", 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!( + encrypted_leaf(&converged, "A"), + encrypted_leaf(&ours_cipher, "A"), + "an addition-only convergence keeps the merged leaves" + ); } -// 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; +/// 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) +} - // 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"); +// 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(); - let (replacement, replacement_identity) = fixture.add_identity_with_path(); - fixture.write_manifest_with_recipients(&[("secret.env", "dotenv")], &[replacement.as_str()]); + // 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("resolved"), "stdout: {stdout}"); assert!( stdout.contains("data key rotated"), - "rotation must be reported: {stdout}" + "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("rotated envelope"); + CiphertextEnvelope::parse(&merged_cipher, SourceFormat::Dotenv).expect("merged envelope"); assert_eq!( envelope.age_recipients(), - &[AgeRecipient::new(&replacement).expect("recipient")] - ); - // A fresh data key re-encrypts every leaf, including the ours-side value. - assert_ne!( - encrypted_leaf(&merged_cipher, "A"), - encrypted_leaf(&ours_cipher, "A") + &[AgeRecipient::new(&second).expect("recipient")], + "the removed recipient must not be on the merged envelope" ); - // The kept identity decrypts the merged result; the removed one does not. + // 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", &replacement_identity) - .arg("open"); + 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"); - let output = command_output(&mut excluded); assert_eq!( - output.status.code(), + command_output(&mut excluded).status.code(), Some(1), "the removed identity must not decrypt the merged result" ); } +// 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] +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 a0518b8..fbf6819 100644 --- a/tests/integration_seal_open.rs +++ b/tests/integration_seal_open.rs @@ -271,215 +271,134 @@ 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", 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", 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); } +// 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(); @@ -489,22 +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(); - // Both drift directions require decrypting the existing ciphertext first - // (rewrap and rotation alike), so both fail closed without an identity. - for recipients in [ - vec![first.as_str(), second.as_str()], // addition: rewrap direction - vec![second.as_str()], // removal: rotation direction - ] { - 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] 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}"