Skip to content

Make recipient authorization explicit; data commands fail closed on drift - #5

Merged
Sukitly merged 3 commits into
mainfrom
feat/explicit-recipient-authorization
Aug 24, 2026
Merged

Sukitly merged 3 commits into
mainfrom
feat/explicit-recipient-authorization

Conversation

@Sukitly

@Sukitly Sukitly commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

Authorization changes are decoupled from data commands. Previously, seal and resolve silently consumed recipient drift between the manifest policy and an envelope: anyone who could edit .gitveilrc.json (a merged PR, a compromised write path) could inject their public recipient, and the next routine seal by an identity holder would wrap the file data key to it — a confused-deputy grant with no review point.

New authorization model

  • gitveil recipient add AGE_RECIPIENT... and gitveil recipient remove AGE_RECIPIENT... are the only entry points that change who can decrypt. The command line names every recipient whose access changes; recipients are positional arguments (--policy optional, inferred when exactly one policy exists).
  • Each command converges envelopes only in its own direction: add targets envelope ∪ named (never removes anyone), remove targets envelope ∖ named (never grants anyone). A tampered manifest entry is therefore never wrapped or revoked until someone names it explicitly; it surfaces as residual drift, printed with the full recipient value plus a next: action line. Mixed drift converges as two sequential one-sided commands in either order.
  • add rewraps via SOPS updatekeys with byte-stable encrypted leaves; remove rotates the data key and re-encrypts, so removed identities cannot decrypt later versions. Authorization commands never read or write workspace plaintext.
  • The manifest publishes before per-file convergence: any mid-run failure leaves drift that seal refuses and an idempotent rerun converges. When no envelope can be decrypted (no identity), nothing is published and every affected file is reported.

Data commands never extend access

  • seal fails closed (keylessly, before any decryption) on any recipient drift and points to the recipient commands. 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.
  • resolve encrypts merged content to the intersection of the ours-side envelope set and the policy: a policy-removed recipient never receives merged (theirs-side) values — the data key rotates to exclude it — and no new recipient is granted anything. A disjoint policy makes resolve refuse with zero side effects.
  • Output is honest about effect vs. intent: per-file results render before policy-level claims, policy lines print only for published state, and partial failures append an explicit "authorization is not fully effective" warning.

Internal changes

  • New pure plan module src/recipients/plan.rs (registered in the functional-core check) owns the per-direction convergence targets, residual-drift computation, and rejection rules.
  • The manifest read-modify-republish boundary moved to src/manifest_document.rs, shared by configure and recipients; ciphertext write policy is single-sourced in workspace::write_ciphertext_file.

Breaking change

The previously documented flows "edit the manifest, then run gitveil seal to rewrap/rotate" no longer perform authorization; seal now refuses with guidance. SealOutcome/resolve output labels changed accordingly. No compatibility path is provided by design: the security invariant requires a single authorization entry point.

Tests

  • Colocated plan matrix: per-direction targets, injected additions/removals, sequential mixed-drift convergence, EmptiesEnvelope/EmptyPolicy/RemoveNotMember/duplicate rejections.
  • New tests/integration_recipients.rs (real SOPS, real decryption assertions): add rewrap with byte-stable leaves and second-identity decryption; remove rotation with real exclusion of the removed identity; injected manifest recipients never wrapped and surfaced in full; mixed drift end-to-end (remove then add, granted identity decrypts, revoked identity does not); faulted rewrap and faulted rotation leave refusable drift, never claim effective revocation, and converge on rerun; all-failed preflight reports every file and withholds the manifest; argument validation; multi-file and predeclared-entry convergence.
  • integration_seal_open.rs: drift refusal in both directions (keyless), content edits cannot bypass the refusal, first-seal policy cross-check, aligned seal without an identity leaves plaintext/ciphertext/baseline unchanged.
  • integration_resolve.rs: intersection narrowing with real exclusion of the removed identity, disjoint-policy refusal with zero side effects, addition-drift preservation with drift reporting.
  • Docs updated: README (identity management runbook, daily usage, security boundaries incl. the first-ciphertext window disclosure) and docs/getting-started.md.

Checks run locally

  • cargo fmt --check, cargo clippy --all-targets (zero warnings)
  • scripts/check-functional-core.py (25 modules), scripts/check-doc-links.py
  • cargo nextest run -E 'not (binary(package_contract) | binary(release_installer_contract))': 272/272 passed

Not run locally: package_contract and release_installer_contract (release-build suites; packaging is untouched) — covered by the Linux/macOS CI gates.

…n drift

Seal and resolve no longer consume recipient drift: seal refuses keylessly
before any effect, resolve preserves the ours-side recipient set and
reports remaining drift. The new gitveil recipient add/remove commands are
the only authorization entry points; they require every effective per-file
recipient change to be named on the command line, rejecting injected
manifest edits by printing the unexplained recipient. A first seal under a
policy additionally requires every existing ciphertext of that policy to be
aligned. The manifest publishes before file convergence, so any mid-run
failure leaves refusable drift that an idempotent rerun converges.
…, honest output

recipient add/remove now converge each envelope only in their own
direction (envelope union/minus the named recipients), so mixed drift
converges as two sequential one-sided commands instead of deadlocking;
recipients are positional arguments. resolve encrypts merged content to
the intersection of the ours envelope and the policy, rotating the data
key so removed recipients never receive merged values, and refuses a
disjoint policy. Policy-level output renders after per-file effects and
only for published state; partial failures are called out and a faulted
removal no longer claims an effective revocation. The all-failed
preflight path keeps per-file reports and withholds the manifest. The
manifest read-modify-republish boundary moved to manifest_document, and
ciphertext writes share the workspace write policy.
Add integration coverage for policy-scoped authorization (explicit
--policy selection, other policies byte-untouched, mandatory selector
with several policies), per-file reporting of an unparseable ciphertext
while the rest of the policy converges, the emptied-envelope removal
refusal, and the resolve case that rotates and reports a pending
addition in one run. Add a unit test for the concurrency check in
manifest publication and assign manifest_document unit tests to the
host suite. Reword the emptied-envelope error so the positional CLI
cannot misread it.
@Sukitly
Sukitly merged commit e5e7b57 into main Aug 24, 2026
3 checks passed
@Sukitly
Sukitly deleted the feat/explicit-recipient-authorization branch August 24, 2026 09:13
@Sukitly Sukitly mentioned this pull request Aug 24, 2026
Sukitly added a commit that referenced this pull request Aug 24, 2026
## Summary

Prepares the v0.2.0 release per `docs/releasing.md`:

- `Cargo.toml` / `Cargo.lock`: version 0.1.0 -> 0.2.0.
- README: pinned installer download examples now reference `v0.2.0`
(valid once the release is published).

0.2.0 is a minor bump carrying the explicit recipient authorization
model (#5): `gitveil recipient add/remove` are the only authorization
entry points, data commands fail closed on recipient drift, and
`resolve` never extends access. Breaking relative to 0.1.0: hand-editing
the manifest and running `seal` no longer performs rewrap or rotation.

Because this PR touches `Cargo.toml`/`Cargo.lock`, the release workflow
pull-request path also builds all four native target archives as a
pre-release rehearsal.

## After merge

```bash
git switch main && git pull --ff-only origin main
git tag -a v0.2.0 -m "Gitveil v0.2.0"
git push origin v0.2.0
```

Then approve the `release` environment in Actions, verify the draft
assets (`SHA256SUMS` and attestations), and publish the draft through
the GitHub Releases interface.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant