Merge pull request 'v0.7.5.0 feat: multi-recipient age encryption (recipients/reencrypt, EGB-283)' (#14) from brian/egb-283-secrets-multi-recipient-age-encryption-multiple-keys-per into main

This commit is contained in:
EGBT Technologies 2026-06-24 23:55:29 +02:00
commit 2558ea3c23
9 changed files with 2369 additions and 22 deletions

View file

@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
## [0.7.5.0] - 2026-06-24
### Added
- **Multi-recipient age encryption (EGB-283)** — a store-scoped, committed
`recipients.txt` (age `-R` format, with `# name` comment lines) lets one
store encrypt every blob to N age public keys — one per team member.
`secrets recipients add <age1…> [--name N]` adds a key and immediately
re-encrypts the whole store; `secrets recipients rm <key|name> [--yes]`
removes one and re-encrypts; `secrets recipients list` shows the current
set (or a note that the store is still single-key). A new `secrets
reencrypt` command re-encrypts every blob to the current recipients without
changing the set (idempotent heal / backfill after a manual edit). Absence
of `recipients.txt` preserves exact legacy single-key behavior; the first
`recipients add` on a legacy store bootstraps the file seeded with the
local pubkey plus the new key. `init` now seeds `recipients.txt` born-multi
with the freshly generated pubkey.
### Changed
- **`secrets rekey` on a multi-recipient store** no longer generates a new
keypair — instead it re-encrypts all blobs to the current `recipients.txt`
set (the shared `_reencrypt_all` engine). On a legacy store (no
`recipients.txt`) `rekey` keeps today's generate-new-keypair behavior.
- **`secrets which`** now prints a `recipients: N (name, …)` line, or
`recipients: single-key (no recipients.txt)` for a legacy store.
- **`secrets verify` / `verify --all`** assert that each blob's age
recipient-stanza count equals the number of entries in `recipients.txt`
(skipped on legacy stores). Exits non-zero on any count mismatch so it can
gate CI or a migration.
## [0.7.4.0] - 2026-06-18 ## [0.7.4.0] - 2026-06-18
### Added ### Added

View file

@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files
```bash ```bash
brew install bats-core brew install bats-core
bats test/ # runs secrets.bats + manifest.bats + migrate.bats bats test/ # runs secrets.bats + manifest.bats + migrate.bats + upgrade.bats + recipients.bats
./test/run-security.sh # security regression subset + operator sign-off (see below) ./test/run-security.sh # security regression subset + operator sign-off (see below)
``` ```
@ -56,7 +56,7 @@ skips security specialist + red team, and Step 11 skips adversarial review.
## Architecture ## Architecture
Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, upgrade. Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt, upgrade.
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable) - Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/` - Storage: Private git repo at `~/.secrets/`
@ -67,6 +67,24 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below - External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) - Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`)
- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R`
format, `# name` comments) lets one store encrypt every blob to N age keys —
one per team member. Managed via `secrets recipients add/rm/list`; absence of
the file ⇒ legacy single-key behavior (recipients = the pubkey derived from
`key.txt`). The file is parsed by us (never `age -R <path>`) into a validated
`RECIPIENT_ARGS` array (native age X25519 only, `age1[0-9a-z]{58}`; SSH
recipients rejected; symlinked file refused) — same conservative posture as
`.secrets-store`/`.secrets-files`. `_load_recipients` populates the array;
every encrypt site routes through it. Any recipient change re-encrypts the
WHOLE store in one commit via the shared `_reencrypt_all` engine (also used by
the new `secrets reencrypt` and by `rekey` on a multi-recipient store, where
rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's
generate-new-keypair behavior). `init` seeds `recipients.txt` born-multi.
`which` prints `recipients: N`; `verify`/`verify --all` assert each blob's
age recipient-stanza count equals `recipients.txt`'s length. Removal takes
effect going forward — git history stays readable by an old key, so rotate
genuinely-sensitive values. Decryption is unchanged (each member uses their
own `key.txt`).
- Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features - Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features
## Project Structure ## Project Structure
@ -79,6 +97,7 @@ test/
manifest.bats # EGB-677 .secrets.json manifest tests (83 tests) manifest.bats # EGB-677 .secrets.json manifest tests (83 tests)
migrate.bats # EGB-703 store-format-v2 migration tests (35 tests) migrate.bats # EGB-703 store-format-v2 migration tests (35 tests)
upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests) upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests)
recipients.bats # EGB-283 multi-recipient age encryption tests (34 tests)
test_helper.bash # Shared setup/teardown test_helper.bash # Shared setup/teardown
README.md # User-facing documentation README.md # User-facing documentation
CLAUDE.md # This file CLAUDE.md # This file

View file

@ -129,7 +129,7 @@ to run `secrets pull` in any project.
### Sharing with teammates ### Sharing with teammates
To share secrets with a teammate, they need: **Simple approach (shared key):** To share secrets with a teammate, they need:
1. Access to your private secrets repo (add them as a collaborator) 1. Access to your private secrets repo (add them as a collaborator)
2. A copy of `key.txt` (send it directly — AirDrop, USB, or in-person) 2. A copy of `key.txt` (send it directly — AirDrop, USB, or in-person)
@ -148,6 +148,8 @@ git -C ~/dev/secrets pull
If your store was last written by a newer client than yours, `secrets` prints a If your store was last written by a newer client than yours, `secrets` prints a
one-line version-skew nudge — that's your cue to run the command above. one-line version-skew nudge — that's your cue to run the command above.
**Per-teammate keys (recommended for teams):** Use `secrets recipients add` so each person keeps their own private key — no key sharing needed. See [Onboarding and offboarding teammates](#onboarding-and-offboarding-teammates) below.
## Usage ## Usage
### Daily workflow ### Daily workflow
@ -181,12 +183,16 @@ secrets clear
| `secrets list` | Show all projects that have stored secrets | | `secrets list` | Show all projects that have stored secrets |
| `secrets list --json` | Same listing as a machine-readable JSON object (`{store, projects[].entries[]}`, each entry `dotenv`/`external`) for tooling and CI. JSON goes to stdout; notices to stderr | | `secrets list --json` | Same listing as a machine-readable JSON object (`{store, projects[].entries[]}`, each entry `dotenv`/`external`) for tooling and CI. JSON goes to stdout; notices to stderr |
| `secrets rm <project>` | Delete a project's secrets from the store | | `secrets rm <project>` | Delete a project's secrets from the store |
| `secrets rekey` | Generate a new encryption key and re-encrypt everything | | `secrets rekey` | Generate a new encryption key and re-encrypt everything (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) |
| `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory | | `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory |
| `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep | | `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep |
| `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) | | `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) |
| `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready | | `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready |
| `secrets migrate --finalize` | **Optional GC** — drop the old v1 blobs and mark the store pure v2. Never required: upgraded clients dual-write and read-fall-back, so not finalizing never cuts anyone off | | `secrets migrate --finalize` | **Optional GC** — drop the old v1 blobs and mark the store pure v2. Never required: upgraded clients dual-write and read-fall-back, so not finalizing never cuts anyone off |
| `secrets recipients list` | List the store's recipient public keys (and names if set) |
| `secrets recipients add <age1…> [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set |
| `secrets recipients rm <key\|name> [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key |
| `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) |
| `secrets upgrade` | Self-update the tool: `git pull --ff-only` on the `secrets` checkout, report old → new version, then re-check store version-skew. No auto-update, no background checks | | `secrets upgrade` | Self-update the tool: `git pull --ff-only` on the `secrets` checkout, report old → new version, then re-check store version-skew. No auto-update, no background checks |
| `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing | | `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing |
@ -478,6 +484,64 @@ Some external secrets are whole binary files — an Android upload keystore, a c
On `secrets push` the file is encrypted into `<project>/external/`. On `secrets pull` it is restored to the same path with mode `600`; if a different version already exists there, it is backed up to `<name>.secrets-bak` first. The same path rules apply (inside `$HOME`, no `..`, no symlinks). Like merged Gradle keys, restored files are permanent plaintext on disk — `secrets clear` does not remove them. On `secrets push` the file is encrypted into `<project>/external/`. On `secrets pull` it is restored to the same path with mode `600`; if a different version already exists there, it is backed up to `<name>.secrets-bak` first. The same path rules apply (inside `$HOME`, no `..`, no symlinks). Like merged Gradle keys, restored files are permanent plaintext on disk — `secrets clear` does not remove them.
### Onboarding and offboarding teammates
By default every team member uses the **same** `key.txt` (one shared private key). The multi-recipient feature lets each teammate have their **own** keypair while still sharing one store — so you never hand out a secret key to a new hire, and removing an ex-teammate's access is one command.
#### Onboarding a teammate
```bash
# 1. Teammate generates their own keypair on their machine (never shares the private key)
age-keygen -o ~/.secrets/key.txt # writes key.txt; prints the public key
# 2. Teammate sends you their PUBLIC key (printed by age-keygen, starts with age1…)
# — over Slack, email, whatever. Public keys are not secret.
# 3. An existing member adds the public key to the store
secrets recipients add age1theirpublickey --name alice
# => Adds alice to recipients.txt, re-encrypts every blob to the full set, pushes.
# 4. Teammate clones the store repo and drops their key.txt in place
git clone git@github.com:<you>/my-secrets.git ~/.secrets
# (key.txt already generated in step 1 — nothing to copy)
# 5. Teammate pulls into any project
cd ~/myapp
secrets pull
# => Their key matches one recipient stanza in every blob — it just works.
```
Run `secrets recipients list` to confirm who has access:
```
alice age1theirpublickey…
you age1yourpublickey…
```
#### Offboarding a teammate
```bash
# Remove the recipient by name (or public key) and re-encrypt the store
secrets recipients rm alice
# => Removes alice from recipients.txt, re-encrypts every blob, pushes.
# Existing blobs are re-encrypted; the removed key can no longer decrypt them.
```
> **Important:** git history can't be un-shared. If alice had access during a period when genuinely sensitive values were stored, rotate those values now (update them in the external system and run `secrets push`). The re-encrypt prevents future access; history is permanent.
#### Managing recipients
```bash
secrets recipients list # show all recipient keys and names
secrets recipients add age1… # add a key (bootstraps recipients.txt on a legacy store)
secrets recipients add age1… --name bob # attach a human-readable label
secrets recipients rm bob # remove by name
secrets recipients rm age1… # remove by public key
secrets reencrypt # re-encrypt to current recipients (idempotent heal)
```
`secrets which` shows a `recipients: N (alice, bob, …)` line so you can always confirm the active set from any project directory.
## Safety features ## Safety features
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C - **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
@ -531,7 +595,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
## Development ## Development
```bash ```bash
# Run the test suite (237 tests across three files) # Run the test suite (272 tests across four files)
brew install bats-core brew install bats-core
bats test/ bats test/

View file

@ -1 +1 @@
0.7.4.0 0.7.5.0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
# EGB-283 — Multi-recipient age encryption
**Date:** 2026-06-24
**Issue:** [EGB-283](https://linear.app/egbt/issue/EGB-283) — secrets: multi-recipient age encryption (multiple keys per file)
**Status:** Design approved, ready for implementation plan
**Related:** EGB-281 (multi-store), EGB-677/EGB-703 (manifest + store-format v2)
## Problem
Today every blob in a store is encrypted to exactly one age public key (`age -r "$pubkey"`,
where `$pubkey` is derived from the store's single `key.txt`). The whole team shares one
private key. EGB-281's multi-store work lets users separate work/personal/client secrets into
different stores, each with its own key — but *within* a single store there is still only one key,
so onboarding/offboarding a teammate means sharing or rotating one secret by hand.
age natively supports multiple recipients: `age -r KEY1 -r KEY2 -o file.age input` writes one
recipient stanza per key, and any matching identity decrypts. This lets a single store have N
members, each with their own keypair. Adding/removing a teammate becomes a re-encrypt against the
current recipient set — no shared password.
## Goals
- A store can encrypt every blob to N recipient public keys.
- Recipient set is managed with first-class commands (`secrets recipients add/rm/list`).
- The recipient set is **singular and consistent per store**: every blob is always readable by
exactly the current set.
- Fully backward compatible: existing single-key stores keep working untouched; the feature is
opt-in and detected by file presence (no store-format-marker bump).
- Decryption path is unchanged (members use their own `key.txt`).
## Non-goals (YAGNI — explicit scope cuts)
- **SSH recipients** (`ssh-ed25519` / `ssh-rsa`). Native age X25519 keys cover the team-key use
case; SSH adds a parsing/format axis. Clean future follow-up.
- **Per-file or per-project recipient subsets.** The whole store shares one recipient set.
- **Key discovery / distribution.** Public keys are pasted in out of band, exactly as `key.txt`
is shared today.
- **Merging recipients into a project-level config** (`.secrets.json` / `.secrets-files`). See
"Why recipients are not in the project manifest" below.
## Design decisions (resolved during brainstorming)
1. **Storage:** committed `recipients.txt` at the store root, managed via
`secrets recipients add/rm/list` subcommands.
2. **Re-encrypt scope:** `add`/`rm` re-encrypt the **entire store immediately** to the new set in
one commit. The store is always consistent.
3. **Backward compatibility:** absence of `recipients.txt` ⇒ exact current single-key behavior.
First `recipients add` on a legacy store bootstraps the file seeded with the local pubkey plus
the new key. `init` going forward seeds `recipients.txt` with the freshly generated pubkey
(born-multi).
4. **`rekey` semantics:** on a multi-recipient store, `rekey` becomes "re-encrypt all to the
current `recipients.txt` set" (no new keypair). On a legacy store it keeps today's behavior
(generate a new keypair, re-encrypt to it). One shared re-encrypt engine.
5. **Store config shape:** keep `recipients.txt` as its own plain, age-native file (jq-free),
alongside the existing one-line `.secrets-format` marker — matching the repo's
small-single-purpose-plain-file convention. Not folded into a JSON store-config.
## Why recipients are not in the project manifest
The tool has two config planes in two different git repos:
| Plane | Location | Files | Scope |
| ----------- | -------------------------------- | -------------------------------------------------- | --------------------------- |
| **Project** | `$PWD` (the project's own repo) | `.secrets.json` (absorbs legacy `.secrets-files`), `.secrets-store` | *What this project syncs* |
| **Store** | `$SECRETS_DIR` (`~/.secrets`) | `.secrets-format`, **`recipients.txt`** (new) | *Metadata about the encrypted repo* |
Recipients are **store-scoped** — who can decrypt *this store*, shared by every project in it.
Putting them in a project-level manifest would let each project carry its own copy and **diverge**,
the exact inconsistency the "always re-encrypt the whole store to one set" rule prevents. It also
collides with the deliberate EGB-703 decision that *the store holds no project manifest*. So the
recipient set lives with the store, next to `.secrets-format`.
## `recipients.txt` format and security rails
Lives at `$SECRETS_DIR/recipients.txt`, **committed** (public keys are not secret; the store
`.gitignore` only blocks `key.txt` and plaintext env files, so the file is tracked automatically).
age `-R` format: one recipient per line, `# comment` and blank lines allowed.
```
# alice (laptop)
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9qsxxxxxx
# bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqxxxxxx
```
**We do not pass the file path to `age -R`.** A committed file is an injection surface, so the
tool parses it itself into a validated indexed array `RECIPIENT_ARGS=(-r age1… -r age1…)`,
mirroring the conservative posture of `.secrets-store` / `.secrets-files`:
- Each non-comment, non-blank line (after trim) must match a native age X25519 recipient exactly:
`^age1[0-9a-z]{58}$`. Anything else — SSH recipients, shell metacharacters, whitespace inside the
token, control/ANSI characters, `-r`-injection look-alikes — is **rejected with a clear error**.
No shell expansion, ever.
- A symlinked `recipients.txt` is refused (same rail as the manifests).
- `--name` labels (written as `# <name>` comment lines above the key) are restricted to
`[A-Za-z0-9 ._-]`; anything else is rejected. This blocks comment-injection into the file.
- The parser is pure bash (bash-3.2 safe). Indexed arrays are fine on bash 3.2; only *associative*
arrays are bash-4.
Validation is the single source of truth — both the `recipients` subcommands and `_load_recipients`
(below) route through the same validator, so an externally hand-edited malicious file is caught on
the next encrypt, not just at `add` time.
## Components
### `_load_recipients()` — populate `RECIPIENT_ARGS`
Called once per command that encrypts. Populates the global indexed array `RECIPIENT_ARGS`:
- `recipients.txt` present → validated array of every key in the file (error out on any invalid
line; refuse an empty/all-comment file).
- absent (legacy store) → `(-r <derived-local-pubkey>)`, identical to today's single-recipient
behavior.
### `_reencrypt_all()` — shared re-encrypt engine
Factored out of today's `cmd_rekey` decrypt→re-encrypt loop:
1. Decrypt every `*.age` in the store (recursive `find -type f -name '*.age'`, covering nested
manifest dotenv blobs and `external/` blobs) with the local `key.txt` into a tmpdir. The
operator must be a current recipient; a decryption failure aborts with the old state preserved.
2. Re-encrypt each file with `age "${RECIPIENT_ARGS[@]}"` back to its relpath.
3. `ensure_store_protections`, `git add -A`, commit, push (if a remote exists).
All recipient-changing paths call it:
| Command | Behavior |
| ------------------------------- | -------------------------------------------------------------------- |
| `recipients add` / `rm` | edit `recipients.txt``_load_recipients``_reencrypt_all` |
| `rekey` (multi-recipient store) | `_reencrypt_all` to current set, **no new keypair** |
| `rekey` (legacy store) | today's behavior: generate new keypair, set recipients to it, re-encrypt |
| `reencrypt` (new, idempotent) | `_reencrypt_all` — heal/backfill after a manual edit |
### `secrets recipients` subcommand
- `recipients list` — prints names + keys from `recipients.txt` (read-only, jq-free). On a legacy
store, prints the single derived pubkey with a "single-key (no recipients.txt)" note.
- `recipients add <age1…> [--name <label>]` — validates the key, refuses duplicates, appends
(with the optional `# <label>` comment), bootstraps the file with `{local pubkey, new key}` if
the store is still legacy, then `_reencrypt_all`.
- `recipients rm <age1…|name>` — removes the matching entry, then `_reencrypt_all`. **Guards:**
refuses to remove the last recipient; removing *your own* key (which would lock you out of future
pulls) requires `--yes`. Removal takes effect going forward — git history can't be un-shared
(documented, same caveat as today's `rekey`).
### Encryption call-site changes
Every existing `age -r "$pubkey" -o …` site becomes `age "${RECIPIENT_ARGS[@]}" -o …`:
- `push_dir_to_project` (dotenv) and the nested manifest dotenv path
- `push_external_files` (external `properties` and `file` blobs)
- `cmd_init` (seeds the store; born-multi)
- `cmd_rekey` re-encrypt loop (now `_reencrypt_all`)
Functions that currently take `pubkey` as a positional arg are updated to rely on the
`RECIPIENT_ARGS` global populated by `_load_recipients` at command entry, avoiding array-passing
gymnastics on bash 3.2. **Decryption paths (`pull`, `verify`) are unchanged** — `age -d -i
"$KEY_FILE"` already tries the identity against all recipient stanzas.
### `init` / `which` / `verify` integration
- **`init`** writes `recipients.txt` seeded with the freshly generated pubkey (born-multi), staged
like `.secrets-format`.
- **`which`** prints a `recipients: N (alice, bob, …)` line, or `recipients: single-key (no
recipients.txt)` for a legacy store.
- **`verify`** already decrypt-tests with the local key — works as-is for a member. **Added cheap
invariant:** count the `-> X25519` recipient stanzas in each blob header and assert it equals the
number of entries in `recipients.txt`. age exposes no way to list *which* recipients a file
targets (X25519 stanzas are ephemeral), so a count check is the strongest machine-checkable
invariant; full-identity consistency relies on the always-re-encrypt-all rule. The check is
skipped on legacy stores (no `recipients.txt`).
## Data flow
**Onboarding a teammate**
1. Teammate runs `age-keygen` locally, sends their **public** key out of band.
2. An existing member: `secrets recipients add age1theirpub --name them` → store re-encrypts to
`{existing…, them}` in one commit, pushed.
3. Teammate clones the store repo, drops their own `key.txt` in place, and `secrets pull` works —
their key matches one stanza in every blob.
**Offboarding**
1. `secrets recipients rm them` → store re-encrypts to the remaining set, pushed. New blobs are no
longer readable by the removed key. (Historical git revisions remain readable by their old key —
rotate any still-sensitive secret values, same as today.)
## Error handling
- Invalid/duplicate key on `add` → reject before any re-encrypt; store untouched.
- Invalid line discovered by `_load_recipients` during any encrypt → abort the command with a clear
pointer to the offending line; nothing written.
- `_reencrypt_all` decryption failure (operator not a current recipient, or corrupt blob) → abort,
old store state preserved (mirrors today's `rekey` safety).
- `rm` last recipient → refused. `rm` own key → requires `--yes`.
- Empty/all-comment `recipients.txt` → treated as an error (a store with zero recipients can encrypt
nothing).
## Testing
New `test/recipients.bats` suite plus additions to existing suites:
- **Happy path:** `init` born-multi; `add` bootstraps a legacy store; `add`/`rm` round-trip; a blob
encrypted to 3 keys decrypts with each of the 3 identities; `list` output; `rekey` on a
multi-recipient store keeps the set and generates no new key; `reencrypt` is idempotent.
- **Backward compat:** a legacy store (no `recipients.txt`) still pushes/pulls/rekeys exactly as
before; an old single-key client decrypts a recipients.txt-store where its key is a recipient.
- **`verify`:** stanza-count invariant passes on a healthy multi-recipient store and flags a blob
whose recipient count drifted.
- **Security regression fixtures** (`recipients.txt` is a new committed attack surface): non-age /
malformed keys, shell metacharacters, control/ANSI characters, `-r`-injection look-alikes,
symlinked `recipients.txt`, bad `--name`, remove-last-recipient, remove-self-without-`--yes`.
Per the repo security-review policy (`.ship-policy.json`, CLAUDE.md): these adversarial fixtures are
written as **ordinary bats regression tests**, not AI red-team/adversarial-review passes. Before any
ship/PR, the human operator runs `./test/run-security.sh` and completes the SIGNOFF prompt;
`./test/run-security.sh` is operator-local and is **not** run on the user's behalf.
## Backward compatibility / migration
No store-format-marker bump. Multi-recipient is purely additive and detected by the **presence** of
`recipients.txt`, mirroring the `.secrets-format` "absence implies v1" pattern. Existing stores keep
working with zero action; a store becomes multi-recipient the first time `recipients add` (or `init`
on a fresh store) writes `recipients.txt`.
## Documentation
- `CLAUDE.md` Architecture section: add a multi-recipient bullet (store-scoped `recipients.txt`,
`recipients` subcommand, shared `_reencrypt_all` engine, `rekey` dual semantics, security rails).
- `README.md`: onboarding/offboarding a teammate; `recipients add/rm/list`.
- Test counts in `CLAUDE.md` Project Structure updated.
## Open questions
None blocking. (SSH-recipient support and a JSON store-config remain possible future follow-ups,
explicitly out of scope here.)

411
secrets
View file

@ -17,7 +17,9 @@ set -euo pipefail
# while letting .secrets-store files take precedence per project. # while letting .secrets-store files take precedence per project.
_USER_SECRETS_DIR="${SECRETS_DIR:-}" _USER_SECRETS_DIR="${SECRETS_DIR:-}"
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}" SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
RECIPIENTS_FILE_NAME="recipients.txt"
KEY_FILE="$SECRETS_DIR/key.txt" KEY_FILE="$SECRETS_DIR/key.txt"
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Set by resolve_store(). Reports which rule chose SECRETS_DIR. # Set by resolve_store(). Reports which rule chose SECRETS_DIR.
@ -99,6 +101,88 @@ get_pubkey() {
age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE" age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
} }
# A native age X25519 recipient: "age1" + exactly 58 chars of [0-9a-z].
# This is also the injection rail — it cannot hold shell metacharacters,
# whitespace, control chars, or extra flags. SSH recipients are intentionally
# unsupported (EGB-283 scope cut).
_validate_age_recipient() {
case "$1" in
age1*) : ;;
*) return 1 ;;
esac
local body="${1#age1}"
[ "${#body}" -eq 58 ] || return 1
case "$body" in
*[!0-9a-z]*) return 1 ;;
esac
return 0
}
# Recipient display names become "# <name>" comment lines in recipients.txt.
# Restrict to a safe charset so a name can't inject extra lines/metacharacters.
_validate_recipient_name() {
case "$1" in
*[!A-Za-z0-9\ ._-]*) return 1 ;;
*) return 0 ;;
esac
}
# Populate the global RECIPIENT_ARGS array with one "-r <key>" per store
# recipient. recipients.txt present -> validated keys from the file (the store
# is multi-recipient). Absent -> the single pubkey derived from key.txt (legacy
# single-key store, exactly today's behavior). We parse the file ourselves
# (never `age -R <path>`) because it is committed = an injection surface; every
# line is validated and the file is refused if symlinked. Dies on any problem.
RECIPIENT_ARGS=()
_load_recipients() {
RECIPIENT_ARGS=()
if [ -L "$RECIPIENTS_FILE" ]; then
die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)."
fi
if [ ! -e "$RECIPIENTS_FILE" ]; then
RECIPIENT_ARGS=(-r "$(get_pubkey)")
return 0
fi
local line trimmed n=0
while IFS= read -r line || [ -n "$line" ]; do
trimmed="${line#"${line%%[![:space:]]*}"}" # lstrip
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rstrip
[ -z "$trimmed" ] && continue
case "$trimmed" in '#'*) continue ;; esac
if ! _validate_age_recipient "$trimmed"; then
die "Invalid recipient in $RECIPIENTS_FILE_NAME: '$trimmed' (expected a native age key: age1...)."
fi
RECIPIENT_ARGS+=(-r "$trimmed")
n=$((n + 1))
done < "$RECIPIENTS_FILE"
if [ "$n" -eq 0 ]; then
die "$RECIPIENTS_FILE_NAME has no recipients — a store must have at least one. Run 'secrets recipients add <age1...>'."
fi
}
# Emit "<key>\t<name>" for each recipient in recipients.txt. <name> is the most
# recent preceding "# <name>" comment, or empty. Read-only; no validation
# (callers that need rails call _load_recipients separately).
_recipients_dump() {
[ -e "$RECIPIENTS_FILE" ] || return 0
local line trimmed name=""
while IFS= read -r line || [ -n "$line" ]; do
trimmed="${line#"${line%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
[ -z "$trimmed" ] && continue
case "$trimmed" in
'#'*)
name="${trimmed#\#}"
name="${name#"${name%%[![:space:]]*}"}"
;;
*)
printf '%s\t%s\n' "$trimmed" "$name"
name=""
;;
esac
done < "$RECIPIENTS_FILE"
}
derive_project_name() { derive_project_name() {
local explicit="${1:-}" local explicit="${1:-}"
if [ -n "$explicit" ]; then if [ -n "$explicit" ]; then
@ -300,6 +384,7 @@ resolve_store() {
SECRETS_DIR="$resolved" SECRETS_DIR="$resolved"
KEY_FILE="$SECRETS_DIR/key.txt" KEY_FILE="$SECRETS_DIR/key.txt"
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
STORE_SOURCE="$source" STORE_SOURCE="$source"
} }
@ -727,7 +812,7 @@ merge_gradle_keys() {
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if # <project>/external/. Returns 0 if at least one entry was pushed, 1 if
# there is no usable manifest. Dies on unsafe targets or all-missing keys. # there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() { push_external_files() {
local root="$1" project="$2" pubkey="$3" local root="$1" project="$2"
# Entries come from .secrets.json (EGB-677) plus any legacy # Entries come from .secrets.json (EGB-677) plus any legacy
# .secrets-files entries the manifest doesn't cover yet. # .secrets-files entries the manifest doesn't cover yet.
local entries local entries
@ -749,10 +834,12 @@ push_external_files() {
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe). # EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
mkdir -p "$SECRETS_DIR/$project/external" mkdir -p "$SECRETS_DIR/$project/external"
local fslug; fslug=$(_secrets_files_slug "$mpath") local fslug; fslug=$(_secrets_files_slug "$mpath")
# EGB-712 dual-write × EGB-283 multi-recipient: write every target
# (v2 + any v1 twin) encrypted to the full recipient set.
local wt local wt
while IFS= read -r wt; do while IFS= read -r wt; do
[ -n "$wt" ] || continue [ -n "$wt" ] || continue
age -r "$pubkey" -o "$wt" "$expanded" age "${RECIPIENT_ARGS[@]}" -o "$wt" "$expanded"
done < <(_external_blob_write_targets "$project" "$fslug" file) done < <(_external_blob_write_targets "$project" "$fslug" file)
info "Encrypted file $mpath" info "Encrypted file $mpath"
pushed=$((pushed + 1)) pushed=$((pushed + 1))
@ -782,10 +869,12 @@ push_external_files() {
fi fi
mkdir -p "$SECRETS_DIR/$project/external" mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath") local slug; slug=$(_secrets_files_slug "$mpath")
# EGB-712 dual-write × EGB-283 multi-recipient: write every target
# (v2 + any v1 twin) encrypted to the full recipient set.
local wt local wt
while IFS= read -r wt; do while IFS= read -r wt; do
[ -n "$wt" ] || continue [ -n "$wt" ] || continue
age -r "$pubkey" -o "$wt" "$tmp" age "${RECIPIENT_ARGS[@]}" -o "$wt" "$tmp"
done < <(_external_blob_write_targets "$project" "$slug" "$mtype") done < <(_external_blob_write_targets "$project" "$slug" "$mtype")
rm -f "$tmp" rm -f "$tmp"
info "Extracted $found key(s) from $mpath" info "Extracted $found key(s) from $mpath"
@ -1315,6 +1404,11 @@ Your key file has been left untouched."
local pubkey local pubkey
pubkey=$(get_pubkey) pubkey=$(get_pubkey)
# EGB-283: born-multi — seed recipients.txt with this store's public key so
# the store is multi-recipient-ready from day one. Committed (not gitignored),
# staged by the first push like .secrets-format.
printf '# self\n%s\n' "$pubkey" > "$RECIPIENTS_FILE"
info "Done! Your public key is:" info "Done! Your public key is:"
echo " $pubkey" echo " $pubkey"
@ -1477,7 +1571,6 @@ cmd_join() {
push_dir_to_project() { push_dir_to_project() {
local source_dir="$1" local source_dir="$1"
local project="$2" local project="$2"
local pubkey="$3"
if ! collect_env_files "$source_dir"; then if ! collect_env_files "$source_dir"; then
return 1 return 1
@ -1492,7 +1585,7 @@ push_dir_to_project() {
for f in "${COLLECTED_FILES[@]}"; do for f in "${COLLECTED_FILES[@]}"; do
local name local name
name=$(basename "$f") name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done done
return 0 return 0
} }
@ -1554,8 +1647,7 @@ cmd_push() {
info "Pushing secrets for project: $project" info "Pushing secrets for project: $project"
echo_store_if_non_default echo_store_if_non_default
local pubkey _load_recipients
pubkey=$(get_pubkey)
# ── Manifest read (validated; absence = bootstrap) ── # ── Manifest read (validated; absence = bootstrap) ──
# jq is required only when a manifest exists (authoritative, can't be # jq is required only when a manifest exists (authoritative, can't be
@ -1643,7 +1735,7 @@ cmd_push() {
*/*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;; */*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;;
*) mkdir -p "$SECRETS_DIR/$project" ;; *) mkdir -p "$SECRETS_DIR/$project" ;;
esac esac
age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel" age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
echo " $rel" echo " $rel"
count=$((count + 1)) count=$((count + 1))
done <<< "$sync_list" done <<< "$sync_list"
@ -1651,7 +1743,7 @@ cmd_push() {
local did=0 local did=0
[ "$count" -gt 0 ] && did=1 [ "$count" -gt 0 ] && did=1
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi if push_external_files "$PWD" "$project"; then did=1; fi
if [ "$did" -eq 0 ]; then if [ "$did" -eq 0 ]; then
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD" die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
fi fi
@ -1727,12 +1819,11 @@ cmd_push_workspaces() {
info "Pushing workspaces for monorepo: $monorepo_name" info "Pushing workspaces for monorepo: $monorepo_name"
echo_store_if_non_default echo_store_if_non_default
local pubkey _load_recipients
pubkey=$(get_pubkey)
local total=0 local total=0
# Push root env files (if any) # Push root env files (if any)
if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then if push_dir_to_project "$root" "$monorepo_name"; then
total=$((total + ${#COLLECTED_FILES[@]})) total=$((total + ${#COLLECTED_FILES[@]}))
fi fi
@ -1743,14 +1834,14 @@ cmd_push_workspaces() {
[ -n "$ws" ] || continue [ -n "$ws" ] || continue
local ws_dir="$root/$ws" local ws_dir="$root/$ws"
local ws_project="$monorepo_name/$ws" local ws_project="$monorepo_name/$ws"
if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then if push_dir_to_project "$ws_dir" "$ws_project"; then
total=$((total + ${#COLLECTED_FILES[@]})) total=$((total + ${#COLLECTED_FILES[@]}))
fi fi
done <<< "$workspaces" done <<< "$workspaces"
# External files (.secrets-files) are monorepo-root-scoped, like # External files (.secrets-files) are monorepo-root-scoped, like
# .secrets-store — handle once, not per-workspace. # .secrets-store — handle once, not per-workspace.
if push_external_files "$root" "$monorepo_name" "$pubkey"; then if push_external_files "$root" "$monorepo_name"; then
total=$((total + 1)) total=$((total + 1))
fi fi
@ -2105,6 +2196,81 @@ cmd_rm() {
fi fi
} }
# Decrypt every blob in the store with the local key and re-encrypt each to the
# currently-loaded RECIPIENT_ARGS, then commit + push. The caller MUST have run
# _load_recipients (or set RECIPIENT_ARGS) and check_key first. Aborts with the
# store untouched on any decrypt failure (you must be a current recipient).
# Shared by recipients add/rm, reencrypt, and multi-recipient rekey.
_reencrypt_all() {
local commit_msg="$1"
local tmpdir
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
info "Decrypting all blobs with your key..."
local file_count=0 dir project f rel dest
for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue
project=$(basename "$dir")
case "$project" in .*) continue ;; esac
mkdir -p "$tmpdir/$project"
while IFS= read -r f; do
[ -f "$f" ] || continue
rel=${f#"$dir"}; rel=${rel%.age}
dest="$tmpdir/$project/$rel"
mkdir -p "$(dirname "$dest")"
if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
die "Decryption failed for $project/$rel (are you a current recipient?). Aborted; store unchanged."
fi
file_count=$((file_count + 1))
done < <(find "$dir" -type f -name '*.age')
done
if [ "$file_count" -eq 0 ]; then
rm -rf "$tmpdir"; trap - EXIT INT TERM
info "No encrypted blobs in the store — nothing to re-encrypt."
return 0
fi
local rc=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
info "Re-encrypting $file_count blob(s) to $rc recipient(s)..."
for dir in "$tmpdir"/*/; do
[ -d "$dir" ] || continue
project=$(basename "$dir")
mkdir -p "$SECRETS_DIR/$project"
while IFS= read -r f; do
[ -f "$f" ] || continue
rel=${f#"$dir"}
mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
done < <(find "$dir" -type f)
done
ensure_store_protections
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "$commit_msg" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" push >/dev/null 2>&1
info "Pushed re-encrypted secrets to remote"
else
info "Committed re-encrypted secrets locally (no remote configured)"
fi
rm -rf "$tmpdir"; trap - EXIT INT TERM
}
cmd_reencrypt() {
check_cmd age
check_cmd git
resolve_store
check_initialized
check_key
if [ ! -e "$RECIPIENTS_FILE" ]; then
info "No $RECIPIENTS_FILE_NAME — single-key store; re-encrypting to your own key only. Add teammates with 'secrets recipients add'."
fi
_load_recipients
_reencrypt_all "reencrypt: re-encrypt all to current recipients"
}
cmd_rekey() { cmd_rekey() {
check_cmd age check_cmd age
check_cmd git check_cmd git
@ -2112,6 +2278,18 @@ cmd_rekey() {
check_initialized check_initialized
check_key check_key
# EGB-283: on a multi-recipient store, rekey means "re-encrypt every blob to
# the current recipients.txt set" — NOT a new keypair (rotating an identity is
# the member's own age-keygen + recipients rm/add). Legacy stores (no
# recipients.txt) keep the original generate-new-keypair behavior below.
if [ -e "$RECIPIENTS_FILE" ]; then
_load_recipients
info "Multi-recipient store — re-encrypting to $RECIPIENTS_FILE_NAME (no new key generated)."
_reencrypt_all "rekey: re-encrypt all to current recipients"
return 0
fi
# ── Legacy single-key rotation (unchanged) ──
# Create temp dir with cleanup trap # Create temp dir with cleanup trap
local tmpdir local tmpdir
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
@ -2311,6 +2489,131 @@ cmd_run() {
exit "$rc" exit "$rc"
} }
cmd_recipients() {
resolve_store
local sub="${1:-list}"
[ $# -gt 0 ] && shift
case "$sub" in
list) _recipients_list ;;
add) _recipients_add "$@" ;;
rm|remove) _recipients_rm "$@" ;;
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]|rm <age1...|name> [--yes]]" ;;
esac
}
_recipients_list() {
check_initialized
if [ ! -e "$RECIPIENTS_FILE" ]; then
check_key
echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)"
echo " $(get_pubkey)"
return 0
fi
_load_recipients # validates the file (dies on bad key / symlink)
local count=0 k n
while IFS=$'\t' read -r k n; do count=$((count + 1)); done < <(_recipients_dump)
echo "recipients: $count (from $RECIPIENTS_FILE_NAME)"
while IFS=$'\t' read -r k n; do
if [ -n "$n" ]; then echo " $k ($n)"; else echo " $k"; fi
done < <(_recipients_dump)
}
_recipients_add() {
check_cmd age
check_cmd git
check_initialized
check_key
local key="" name=""
while [ $# -gt 0 ]; do
case "$1" in
--name) [ $# -ge 2 ] || die "--name requires a value."; name="$2"; shift 2 ;;
-*) die "Unknown flag: $1. Usage: secrets recipients add <age1...> [--name <label>]" ;;
*) if [ -z "$key" ]; then key="$1"; else die "Unexpected argument: $1"; fi; shift ;;
esac
done
[ -n "$key" ] || die "Usage: secrets recipients add <age1...> [--name <label>]"
_validate_age_recipient "$key" || die "Not a valid age recipient: '$key' (expected age1..., 62 chars; SSH keys unsupported)."
if [ -n "$name" ]; then
# Reject a whitespace-only label (would write a blank "# " comment line).
local _name_stripped="${name//[[:space:]]/}"
[ -n "$_name_stripped" ] || die "Invalid --name: must contain a non-space character."
_validate_recipient_name "$name" || die "Invalid --name '$name' (allowed: letters, digits, space, . _ -)."
fi
if [ -L "$RECIPIENTS_FILE" ]; then
die "Refusing to write symlinked $RECIPIENTS_FILE_NAME."
fi
# Validate BEFORE any mutation: a hand-corrupted file dies here, leaving
# recipients.txt untouched (no half-mutate / blobs-not-reencrypted skew).
# On a legacy store (no recipients.txt) _load_recipients succeeds via the
# derived-pubkey path and does NOT die — so the bootstrap path still works.
_load_recipients
# Bootstrap a legacy store: seed this machine's key first so the operator
# stays a recipient (and can decrypt to re-encrypt).
if [ ! -e "$RECIPIENTS_FILE" ]; then
printf '# self\n%s\n' "$(get_pubkey)" > "$RECIPIENTS_FILE"
fi
local k _n
while IFS=$'\t' read -r k _n; do
[ "$k" = "$key" ] && die "Recipient already present: $key"
done < <(_recipients_dump)
{ [ -n "$name" ] && printf '# %s\n' "$name"; printf '%s\n' "$key"; } >> "$RECIPIENTS_FILE"
info "Added recipient${name:+ ($name)}: $key"
_load_recipients
_reencrypt_all "recipients: add ${name:-$key}; re-encrypt all"
}
# Rewrite recipients.txt canonically (one "# name"? + key per entry), dropping
# the entry whose key == <drop>. Atomic-ish via temp file in the same dir.
_recipients_write_without() {
local drop="$1" tmp k n
tmp=$(mktemp "$SECRETS_DIR/.recipients.XXXXXX")
while IFS=$'\t' read -r k n; do
[ "$k" = "$drop" ] && continue
[ -n "$n" ] && printf '# %s\n' "$n" >> "$tmp"
printf '%s\n' "$k" >> "$tmp"
done < <(_recipients_dump)
mv "$tmp" "$RECIPIENTS_FILE"
}
_recipients_rm() {
check_cmd age
check_cmd git
check_initialized
check_key
local target="" assume_yes=false
while [ $# -gt 0 ]; do
case "$1" in
--yes|-y) assume_yes=true; shift ;;
-*) die "Unknown flag: $1. Usage: secrets recipients rm <age1...|name> [--yes]" ;;
*) if [ -z "$target" ]; then target="$1"; else die "Unexpected argument: $1"; fi; shift ;;
esac
done
[ -n "$target" ] || die "Usage: secrets recipients rm <age1...|name> [--yes]"
[ -e "$RECIPIENTS_FILE" ] || die "No $RECIPIENTS_FILE_NAME — store is single-key; nothing to remove."
[ -L "$RECIPIENTS_FILE" ] && die "Refusing to write symlinked $RECIPIENTS_FILE_NAME."
# Validate BEFORE any mutation: a hand-corrupted file dies here, leaving
# recipients.txt untouched (no half-mutate / blobs-not-reencrypted skew).
_load_recipients
local k n match="" count=0 total=0
while IFS=$'\t' read -r k n; do
total=$((total + 1))
if [ "$k" = "$target" ] || { [ -n "$n" ] && [ "$n" = "$target" ]; }; then
match="$k"; count=$((count + 1))
fi
done < <(_recipients_dump)
[ "$count" -eq 0 ] && die "No recipient matches '$target'."
[ "$count" -gt 1 ] && die "'$target' matches $count recipients by name — remove by key (age1...) instead."
[ "$total" -le 1 ] && die "Refusing to remove the last recipient — a store must have at least one."
local self; self="$(get_pubkey)"
if [ "$match" = "$self" ] && [ "$assume_yes" != true ]; then
die "Refusing to remove your own key (you would lose access to future pushes). Re-run with --yes to confirm."
fi
_recipients_write_without "$match"
info "Removed recipient: $match"
_load_recipients
_reencrypt_all "recipients: remove $match; re-encrypt all"
}
cmd_which() { cmd_which() {
resolve_store resolve_store
echo "store: $SECRETS_DIR" echo "store: $SECRETS_DIR"
@ -2328,6 +2631,22 @@ cmd_which() {
fi fi
fi fi
# EGB-283: surface the recipient set (store-scoped; one key per team member).
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
local rcount=0 rk rn rnames=""
while IFS=$'\t' read -r rk rn; do
rcount=$((rcount + 1))
[ -n "$rn" ] && rnames="${rnames:+$rnames, }$rn"
done < <(_recipients_dump)
if [ -n "$rnames" ]; then
echo "recipients: $rcount ($rnames)"
else
echo "recipients: $rcount"
fi
else
echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)"
fi
# v2 manifest (.secrets.json): validate and summarize. Validation here # v2 manifest (.secrets.json): validate and summarize. Validation here
# is deliberately fatal (symlink / malformed / unsupported version) so # is deliberately fatal (symlink / malformed / unsupported version) so
# `secrets which` doubles as the manifest linter. # `secrets which` doubles as the manifest linter.
@ -2466,12 +2785,45 @@ _verify_blob_decrypts() {
age -d -i "$KEY_FILE" "$1" >/dev/null 2>&1 age -d -i "$KEY_FILE" "$1" >/dev/null 2>&1
} }
# Count age recipient stanzas ("-> ...") in a blob's header. The age v1 header
# is ASCII and ends at the "--- <mac>" line, so reading line-by-line stops
# before any binary body. Echoes the count.
_blob_recipient_count() {
local f="$1" line count=0
while IFS= read -r line; do
case "$line" in
'--- '*) break ;;
'-> '*) count=$((count + 1)) ;;
esac
done < "$f"
echo "$count"
}
# If the store is multi-recipient (expected non-empty), assert <blob> was
# encrypted to exactly <expected> recipients. Echoes a FINDING and returns 1 on
# mismatch; returns 0 otherwise (incl. legacy stores where expected is empty).
_check_blob_recipient_count() {
local blob="$1" rel="$2" expected="$3"
[ -n "$expected" ] || return 0
local actual; actual=$(_blob_recipient_count "$blob")
if [ "$actual" != "$expected" ]; then
echo "FINDING: $rel is encrypted to $actual recipient(s) but $RECIPIENTS_FILE_NAME has $expected — run 'secrets reencrypt'." >&2
return 1
fi
return 0
}
# `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob # `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob
# in every project. No manifest consistency check: the store carries only # in every project. No manifest consistency check: the store carries only
# ciphertext (manifests live in each project's repo), so orphan/missing # ciphertext (manifests live in each project's repo), so orphan/missing
# detection is impossible store-wide. This is the migration integrity gate. # detection is impossible store-wide. This is the migration integrity gate.
_verify_all() { _verify_all() {
local checked=0 failed=0 dir project f rel local checked=0 failed=0 dir project f rel
local rexpected=""
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
_load_recipients # validates; dies on a bad recipients.txt
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
fi
for dir in "$SECRETS_DIR"/*/; do for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
project=$(basename "$dir") project=$(basename "$dir")
@ -2486,6 +2838,12 @@ _verify_all() {
rel="${f#"$SECRETS_DIR"/}" rel="${f#"$SECRETS_DIR"/}"
echo "FAIL: $rel does not decrypt with the current key." >&2 echo "FAIL: $rel does not decrypt with the current key." >&2
failed=$((failed + 1)) failed=$((failed + 1))
else
# Only check recipient count when the blob actually decrypted — an
# undecryptable blob would report "0 recipients" which is spurious.
if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
failed=$((failed + 1))
fi
fi fi
done < <(find "$dir" -type f -name '*.age') done < <(find "$dir" -type f -name '*.age')
done done
@ -2494,7 +2852,7 @@ _verify_all() {
return 0 return 0
fi fi
if [ "$failed" -gt 0 ]; then if [ "$failed" -gt 0 ]; then
echo "verify --all: $failed of $checked blob(s) failed to decrypt." >&2 echo "verify --all: $failed of $checked blob(s) failed (decrypt or recipient-count)." >&2
return 1 return 1
fi fi
info "verify --all: OK — all $checked blob(s) decrypt with the current key (integrity only; run 'secrets verify' in a project for manifest consistency)." info "verify --all: OK — all $checked blob(s) decrypt with the current key (integrity only; run 'secrets verify' in a project for manifest consistency)."
@ -2519,6 +2877,11 @@ _verify_project() {
local pdir="$SECRETS_DIR/$project" local pdir="$SECRETS_DIR/$project"
local findings=0 checked=0 local findings=0 checked=0
local rexpected=""
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
_load_recipients # validates; dies on a bad recipients.txt
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
fi
# `expected` accumulates the store-relative blob paths the manifest implies, # `expected` accumulates the store-relative blob paths the manifest implies,
# newline-framed (leading + trailing \n per entry) so the orphan walk can # newline-framed (leading + trailing \n per entry) so the orphan walk can
# test membership. bash 3.2 has no associative arrays — this string-set + # test membership. bash 3.2 has no associative arrays — this string-set +
@ -2548,6 +2911,12 @@ _verify_project() {
if ! _verify_blob_decrypts "$blob"; then if ! _verify_blob_decrypts "$blob"; then
echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2 echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2
findings=$((findings + 1)) findings=$((findings + 1))
else
# Only check recipient count when the blob actually decrypted — an
# undecryptable blob would report "0 recipients" which is spurious.
if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
findings=$((findings + 1))
fi
fi fi
done < <(jq -r '.dotenv // [] | .[]' "$manifest") done < <(jq -r '.dotenv // [] | .[]' "$manifest")
@ -2573,6 +2942,12 @@ _verify_project() {
if ! _verify_blob_decrypts "$eblob"; then if ! _verify_blob_decrypts "$eblob"; then
echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2 echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2
findings=$((findings + 1)) findings=$((findings + 1))
else
# Only check recipient count when the blob actually decrypted — an
# undecryptable blob would report "0 recipients" which is spurious.
if ! _check_blob_recipient_count "$eblob" "$project/$erel" "$rexpected"; then
findings=$((findings + 1))
fi
fi fi
done < <(_json_external_entries "$manifest") done < <(_json_external_entries "$manifest")
@ -2864,6 +3239,10 @@ Usage:
secrets migrate [--dry-run] Copy-forward this project's v1 blobs to store format v2 secrets migrate [--dry-run] Copy-forward this project's v1 blobs to store format v2
secrets migrate --status Survey every project's v2 readiness (finalize gate) secrets migrate --status Survey every project's v2 readiness (finalize gate)
secrets migrate --finalize Optional GC: drop v1 blobs and mark the store pure v2 secrets migrate --finalize Optional GC: drop v1 blobs and mark the store pure v2
secrets recipients list List the store's recipient keys
secrets recipients add KEY [--name N] Add a recipient and re-encrypt the store
secrets recipients rm KEY|NAME [--yes] Remove a recipient and re-encrypt the store
secrets reencrypt Re-encrypt every blob to the current recipients
secrets which Show the active store, manifest, and external entries secrets which Show the active store, manifest, and external entries
secrets where Alias for `which` secrets where Alias for `which`
secrets status Alias for `which` secrets status Alias for `which`
@ -3035,8 +3414,10 @@ case "${1:-help}" in
list) shift; cmd_list "$@" ;; list) shift; cmd_list "$@" ;;
rm) cmd_rm "${2:-}" ;; rm) cmd_rm "${2:-}" ;;
rekey) cmd_rekey ;; rekey) cmd_rekey ;;
reencrypt) cmd_reencrypt ;;
verify) shift; cmd_verify "$@" ;; verify) shift; cmd_verify "$@" ;;
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;
recipients) shift; cmd_recipients "$@" ;;
which|where|status) cmd_which ;; which|where|status) cmd_which ;;
upgrade) shift; cmd_upgrade "$@" ;; upgrade) shift; cmd_upgrade "$@" ;;
help|--help|-h) cmd_help ;; help|--help|-h) cmd_help ;;

407
test/recipients.bats Normal file
View file

@ -0,0 +1,407 @@
#!/usr/bin/env bats
load test_helper
# A throwaway second identity for "another teammate".
make_second_identity() {
age-keygen -o "$TEST_TMPDIR/bob.txt" 2>/dev/null
BOB_PUB=$(age-keygen -y "$TEST_TMPDIR/bob.txt")
}
@test "push with only-self recipients.txt encrypts to the store key (born-multi)" {
init_with_remote
# init now seeds recipients.txt with self — born-multi store.
[ -e "$SECRETS_DIR/recipients.txt" ]
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
# Blob must still decrypt with the store's own key.
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "push with a hand-written recipients.txt encrypts to every listed key" {
init_with_remote
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '# self\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
# Bob (a recipient) can decrypt the pushed blob with HIS key.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
# And the store key still can too.
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "push refuses a recipients.txt with an invalid key" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\nnot-an-age-key\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
}
@test "push refuses a symlinked recipients.txt" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
# Remove the born-multi recipients.txt so we can replace it with a symlink.
rm -f "$SECRETS_DIR/recipients.txt"
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}
@test "recipients list on a legacy store shows the single derived key" {
init_with_remote
# Simulate a legacy store by removing the born-multi recipients.txt.
rm -f "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients list
[ "$status" -eq 0 ]
[[ "$output" == *"single-key"* ]] || false
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
[[ "$output" == *"$STORE_PUB"* ]] || false
}
@test "recipients list shows names and keys from recipients.txt" {
init_with_remote
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '# alice\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients list
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: 2"* ]] || false
[[ "$output" == *"alice"* ]] || false
[[ "$output" == *"bob"* ]] || false
}
@test "reencrypt re-encrypts existing blobs to a newly added recipient line" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push # single-key blob (project name = "myproj"; blob at $SECRETS_DIR/myproj/.env.age)
[ "$status" -eq 0 ]
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
# Bob cannot read the old single-key blob yet.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -ne 0 ]
run "$SECRETS_BIN" reencrypt
[ "$status" -eq 0 ]
# Now he can.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "rekey on a multi-recipient store keeps recipients and the same key" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
before=$(cat "$SECRETS_DIR/key.txt")
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
# No new keypair was generated.
[ "$(cat "$SECRETS_DIR/key.txt")" = "$before" ]
# Both recipients can decrypt.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "rekey on a legacy store still rotates to a new key (unchanged)" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
before=$(cat "$SECRETS_DIR/key.txt")
# Simulate a legacy store by removing the born-multi recipients.txt.
rm -f "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
[ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ]
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "recipients add bootstraps a legacy store and re-encrypts" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
# Simulate a legacy store (no recipients.txt) so recipients add triggers the
# bootstrap branch (if [ ! -e "$RECIPIENTS_FILE" ]) rather than the append path.
rm -f "$SECRETS_DIR/recipients.txt"
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
[ "$status" -eq 0 ]
[ -e "$SECRETS_DIR/recipients.txt" ]
# recipients.txt now has self + bob (2 keys).
run "$SECRETS_BIN" recipients list
[[ "$output" == *"recipients: 2"* ]] || false
[[ "$output" == *"bob"* ]] || false
# Existing blob re-encrypted: bob can read it.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "recipients add rejects a non-age key" {
init_with_remote
run "$SECRETS_BIN" recipients add "ssh-ed25519 AAAAfoo"
[ "$status" -ne 0 ]
[[ "$output" == *"valid age recipient"* ]] || false
}
@test "recipients add rejects a duplicate" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
[ "$status" -eq 0 ]
run "$SECRETS_BIN" recipients add "$BOB_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"already present"* ]] || false
}
@test "recipients add rejects an unsafe --name" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name 'bob; rm -rf ~'
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid --name"* ]] || false
}
@test "recipients add rejects a whitespace-only --name" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name ' '
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid --name"* ]] || false
}
@test "recipients add --name with no value errors" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name
[ "$status" -ne 0 ]
[[ "$output" == *"--name requires a value"* ]] || false
}
@test "recipients rm removes a recipient and re-encrypts to the rest" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
run "$SECRETS_BIN" recipients rm bob
[ "$status" -eq 0 ]
run "$SECRETS_BIN" recipients list
[[ "$output" == *"recipients: 1"* ]] || false
# Store key still reads its own blobs.
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "recipients rm refuses to remove the last recipient" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # store = self + bob
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" recipients rm bob # back to self only
[ "$status" -eq 0 ]
run "$SECRETS_BIN" recipients rm "$STORE_PUB" # would be the last
[ "$status" -ne 0 ]
[[ "$output" == *"last recipient"* ]] || false
}
@test "recipients rm of your own key requires --yes" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" recipients rm "$STORE_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"your own key"* ]] || false
run "$SECRETS_BIN" recipients rm "$STORE_PUB" --yes
[ "$status" -eq 0 ]
}
@test "recipients rm of a non-existent target errors" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
run "$SECRETS_BIN" recipients rm carol
[ "$status" -ne 0 ]
[[ "$output" == *"No recipient matches"* ]] || false
}
@test "init seeds recipients.txt with the new store key (born-multi)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -e "$SECRETS_DIR/recipients.txt" ]
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
run cat "$SECRETS_DIR/recipients.txt"
[[ "$output" == *"$STORE_PUB"* ]] || false
}
@test "which reports the recipient count" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
create_project_dir myproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: 2"* ]] || false
[[ "$output" == *"bob"* ]] || false
}
@test "which reports single-key for a legacy store" {
init_with_remote
rm -f "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: single-key"* ]] || false
}
@test "verify --all passes on a healthy multi-recipient store" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # re-encrypts to 2
run "$SECRETS_BIN" verify --all
[ "$status" -eq 0 ]
}
@test "verify flags a blob whose recipient count drifted" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push # single-key blob (1 stanza)
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
# Declare 2 recipients but do NOT re-encrypt — the on-disk blob still has 1.
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" verify --all
[ "$status" -ne 0 ]
[[ "$output" == *"recipient"* ]] || false
}
@test "SECURITY: a dangling symlink recipients.txt is refused, not silently ignored" {
init_with_remote
rm -f "$SECRETS_DIR/recipients.txt"
ln -s "$TEST_TMPDIR/does-not-exist.txt" "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}
@test "SECURITY: recipients.txt with shell metacharacters is rejected, no execution" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\nage1$(touch %s/pwned)\n' "$STORE_PUB" "$TEST_TMPDIR" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[ ! -e "$TEST_TMPDIR/pwned" ]
[[ "$output" == *"Invalid recipient"* ]] || false
}
@test "SECURITY: recipients.txt line that looks like an extra age flag is rejected" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n-i /etc/passwd\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
# The push must have aborted before encrypting — no blob should exist.
[ ! -e "$SECRETS_DIR/myproj/.env.age" ]
}
@test "SECURITY: control/ANSI characters in recipients.txt are rejected" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\nage1%b\n' "$STORE_PUB" 'aaaa\033[31mevil' > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
}
@test "SECURITY: recipients add rejects a key with embedded whitespace" {
init_with_remote
run "$SECRETS_BIN" recipients add "age1aaaa bbbb"
[ "$status" -ne 0 ]
[[ "$output" == *"valid age recipient"* ]] || false
}
# ── Fix 1: validate-before-mutate ────────────────────────────────────────────
@test "recipients rm dies without mutating a hand-corrupted recipients.txt" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # valid: self + bob
# Corrupt the file by hand.
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt"
before=$(cat "$SECRETS_DIR/recipients.txt")
run "$SECRETS_BIN" recipients rm bob
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
# File unchanged (no half-mutation).
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
}
@test "recipients add dies without mutating a hand-corrupted recipients.txt" {
init_with_remote
make_second_identity
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt" # init seeded self; now corrupt
before=$(cat "$SECRETS_DIR/recipients.txt")
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
}
# ── Fix 4: reencrypt advisory on a legacy store ───────────────────────────────
@test "reencrypt on a legacy store prints a single-key advisory" {
init_with_remote
rm -f "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
run "$SECRETS_BIN" reencrypt
[ "$status" -eq 0 ]
[[ "$output" == *"single-key"* ]] || false
}
# ── Fix 5: ambiguous-name rm coverage ────────────────────────────────────────
@test "recipients rm by an ambiguous name is refused" {
init_with_remote
make_second_identity
age-keygen -o "$TEST_TMPDIR/carol.txt" 2>/dev/null
CAROL_PUB=$(age-keygen -y "$TEST_TMPDIR/carol.txt")
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name dup
run "$SECRETS_BIN" recipients add "$CAROL_PUB" --name dup
run "$SECRETS_BIN" recipients rm dup
[ "$status" -ne 0 ]
[[ "$output" == *"matches"* ]] || false
}
@test "SECURITY: a symlinked recipients.txt is refused on add and rm too" {
init_with_remote
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
rm -f "$SECRETS_DIR/recipients.txt"
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients add "$BOB_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}

View file

@ -19,6 +19,14 @@ setup() {
# not be a real ancestor of /tmp). EGB-281 F9. # not be a real ancestor of /tmp). EGB-281 F9.
export HOME="$TEST_TMPDIR" export HOME="$TEST_TMPDIR"
# Provide git author identity so `git commit` works with the fresh temp HOME
# (no ~/.gitconfig is present in the isolated dir). GIT_* env vars override
# any global config and survive the HOME redirect.
export GIT_AUTHOR_NAME="Test User"
export GIT_AUTHOR_EMAIL="test@example.com"
export GIT_COMMITTER_NAME="Test User"
export GIT_COMMITTER_EMAIL="test@example.com"
# Secrets repo lives in temp # Secrets repo lives in temp
export SECRETS_DIR="$TEST_TMPDIR/secrets-repo" export SECRETS_DIR="$TEST_TMPDIR/secrets-repo"