Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -196,14 +212,16 @@ git add <path>.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.
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions scripts/check-functional-core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
203 changes: 195 additions & 8 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand Down Expand Up @@ -70,6 +73,11 @@ enum Command {
#[arg(required = true, value_name = "PATH")]
paths: Vec<String>,
},
/// 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
Expand Down Expand Up @@ -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<String>,
/// Public age X25519 recipients to authorize
#[arg(required = true, value_name = "AGE_RECIPIENT")]
recipients: Vec<String>,
},
/// 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<String>,
/// Public age X25519 recipients to revoke
#[arg(required = true, value_name = "AGE_RECIPIENT")]
recipients: Vec<String>,
},
}

#[derive(Debug, Subcommand)]
enum IdentityCommand {
/// Generate one native age identity without replacing an existing file
Expand Down Expand Up @@ -228,6 +258,7 @@ pub fn run(cli: Cli) -> Result<RunOutcome> {
profile.as_deref(),
recipient_policy.as_deref(),
),
Command::Recipient { command } => run_recipient(&current, &gitveil_binary, command),
Command::Open { profile, paths } => {
run_open(&current, &gitveil_binary, &paths, profile.as_deref())
}
Expand Down Expand Up @@ -283,6 +314,152 @@ fn run_identity(
Ok(RunOutcome::Success)
}

fn run_recipient(
current: &Path,
gitveil_binary: &Path,
command: RecipientCommand,
) -> Result<RunOutcome> {
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<String>,
pending_removals: Vec<String>,
}

/// 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<String>, value: &str) {
if !values.iter().any(|existing| existing == value) {
values.push(value.to_owned());
}
}

fn run_initialize(current: &Path, policy: &str, recipients: &[String]) -> Result<RunOutcome> {
let outcome = initialize(current, policy, recipients)?;
println!(
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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;
Expand Down
Loading