diff --git a/CHANGELOG.md b/CHANGELOG.md index ce00bf8..d37cd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 [--name N]` adds a key and immediately + re-encrypts the whole store; `secrets recipients rm [--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 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 9693fde..a7afbda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files ```bash 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) ``` @@ -56,7 +56,7 @@ skips security specialist + red team, and Step 11 skips adversarial review. ## 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) - 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 - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` - 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 `) 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 ## Project Structure @@ -79,6 +97,7 @@ test/ manifest.bats # EGB-677 .secrets.json manifest tests (83 tests) migrate.bats # EGB-703 store-format-v2 migration tests (35 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 README.md # User-facing documentation CLAUDE.md # This file diff --git a/README.md b/README.md index aa4662c..4ebb17b 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ to run `secrets pull` in any project. ### 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) 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 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 ### Daily workflow @@ -181,12 +183,16 @@ secrets clear | `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 rm ` | 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 --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 --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 recipients list` | List the store's recipient public keys (and names if set) | +| `secrets recipients add [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set | +| `secrets recipients rm [--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 --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 `/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 `.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:/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 - **`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 ```bash -# Run the test suite (237 tests across three files) +# Run the test suite (272 tests across four files) brew install bats-core bats test/ diff --git a/VERSION b/VERSION index 584db57..e413a4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.4.0 +0.7.5.0 diff --git a/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md b/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md new file mode 100644 index 0000000..54a38d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md @@ -0,0 +1,1207 @@ +# Multi-recipient age encryption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a single `secrets` store encrypt every blob to N age recipient public keys (a per-store team-key set) instead of one shared key, managed via `secrets recipients add/rm/list`, with full backward compatibility for existing single-key stores. + +**Architecture:** A committed, store-scoped `recipients.txt` (age `-R` format) holds the recipient set. A new `_load_recipients` parses + validates it into a global `RECIPIENT_ARGS=(-r k1 -r k2 …)` array that every encrypt site uses; absence of the file means legacy single-key behavior (`-r $(get_pubkey)`). A shared `_reencrypt_all` engine (factored from today's `rekey`) decrypts the whole store with the local key and re-encrypts to the current set, and is called by `recipients add/rm`, the new `reencrypt`, and multi-recipient `rekey`. Decryption is unchanged — each member uses their own `key.txt`. + +**Tech Stack:** Single POSIX-ish bash script (`secrets`), system bash 3.2 compatible (indexed arrays OK, NO associative arrays). `age` / `age-keygen` for crypto. `git` for the store. `bats-core` for tests. `jq` is NOT introduced anywhere in this feature (recipients.txt is plain text, keeping store ops jq-free). + +## Global Constraints + +- **bash 3.2 only:** no associative arrays, no bash-4 features. Indexed arrays (`RECIPIENT_ARGS=()`, `arr+=(x)`, `"${arr[@]}"`) are fine. +- **bats `[[ ]]` gotcha:** every standalone `[[ … ]]` assertion in a test MUST end with `|| false`. Single-bracket `[ ]` is unaffected. +- **age recipient format accepted:** native age X25519 only — `age1` + exactly 58 chars of `[0-9a-z]`. SSH recipients are out of scope (reject them). This regex/charset is also the injection rail: it cannot contain shell metacharacters, whitespace, or extra flags. +- **`recipients.txt` is committed, NOT gitignored** (public keys are not secret). The store `.gitignore` only blocks `key.txt` and plaintext env files, so the file is tracked automatically — do not add it to `.gitignore`. +- **Security-review policy (`.ship-policy.json`, CLAUDE.md):** adversarial fixtures in this plan are ordinary bats regression tests, NOT AI red-team passes. Do NOT run `./test/run-security.sh` on the user's behalf. Before ship, the human operator runs it and signs off. +- **Re-encrypt invariant:** any change to the recipient set re-encrypts the WHOLE store in one commit. `RECIPIENT_ARGS` is always populated by `_load_recipients` before any `age "${RECIPIENT_ARGS[@]}"` call (never reference the array empty under `set -u`). +- **Commit cadence:** one commit per task (TDD: test → impl → green → commit). + +## File map + +- `secrets` — all code changes (helpers, `recipients`/`reencrypt` commands, encrypt-site refactor, `init`/`which`/`verify`/`rekey` edits, dispatch + help). +- `test/recipients.bats` — NEW suite for all multi-recipient behavior + security fixtures. +- `CLAUDE.md`, `README.md` — docs + test counts. + +## Conventions referenced + +- Constants like `SECRETS_FILES_NAME=".secrets-files"` live ~line 360; `KEY_FILE` is set both as a global default (~line 20) and re-set inside `resolve_store` (~line 301). Mirror this for `RECIPIENTS_FILE`. +- Existing encrypt sites (all `age -r "$pubkey" -o …`): `push_dir_to_project` (~1207), `cmd_push` inline (~1357), `push_external_files` (~655 and ~684), `cmd_rekey` (~1778). `get_pubkey` (~98) derives the pubkey from `key.txt`. +- Tests run via `run "$SECRETS_BIN" ` with isolated `$HOME` and `$SECRETS_DIR`; helpers `init_with_remote`, `create_project_dir` live in `test/test_helper.bash`. + +--- + +### Task 1: Recipient core (`RECIPIENTS_FILE`, validation, `_load_recipients`) wired into the push encrypt path + +**Files:** +- Modify: `secrets` (constants ~line 19-21; `resolve_store` ~301; new helpers after `get_pubkey` ~99; encrypt sites ~655, ~684, ~1207, ~1357; `cmd_push` ~1268; `cmd_push_workspaces` ~1428; `push_dir_to_project`/`push_external_files` signatures) +- Test: `test/recipients.bats` (new) + +**Interfaces:** +- Produces: global `RECIPIENT_ARGS` (indexed array of `-r ` pairs); `RECIPIENTS_FILE` / `RECIPIENTS_FILE_NAME`; `_validate_age_recipient ` (0 = valid age1 key); `_load_recipients` (populates `RECIPIENT_ARGS`, dies on bad/symlinked/empty file). +- Consumes: `get_pubkey`, `SECRETS_DIR`, `KEY_FILE`. + +- [ ] **Step 1: Write failing tests** in new `test/recipients.bats`: + +```bash +#!/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 without recipients.txt stays single-key (legacy behavior)" { + init_with_remote + create_project_dir myproj + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + # No recipients.txt was created by push. + [ ! -e "$SECRETS_DIR/recipients.txt" ] + # Blob decrypts 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" + ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt" + create_project_dir myproj + run "$SECRETS_BIN" push + [ "$status" -ne 0 ] + [[ "$output" == *"symlink"* ]] || false +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bats test/recipients.bats` +Expected: FAIL (recipients.txt is ignored today; multi-recipient blob won't decrypt with bob's key; invalid/symlink cases don't error). + +- [ ] **Step 3: Add the constant + `RECIPIENTS_FILE` plumbing** + +Near `KEY_FILE="$SECRETS_DIR/key.txt"` (~line 20), add the name constant just above it and the path just below: + +```bash +RECIPIENTS_FILE_NAME="recipients.txt" +KEY_FILE="$SECRETS_DIR/key.txt" +RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME" +``` + +Inside `resolve_store`, right after the line that re-sets `KEY_FILE="$SECRETS_DIR/key.txt"` (~line 301), add: + +```bash + RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME" +``` + +- [ ] **Step 4: Add `_validate_age_recipient` and `_load_recipients`** immediately after `get_pubkey` (~line 99): + +```bash +# 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 +} + +# Populate the global RECIPIENT_ARGS array with one "-r " 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 `) 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 [ ! -e "$RECIPIENTS_FILE" ]; then + RECIPIENT_ARGS=(-r "$(get_pubkey)") + return 0 + fi + if [ -L "$RECIPIENTS_FILE" ]; then + die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)." + 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 '." + fi +} +``` + +- [ ] **Step 5: Route every encrypt site through `RECIPIENT_ARGS`** + +In `push_dir_to_project`, change the encrypt line (~1207): +```bash + age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${name}.age" "$f" +``` +and delete its now-unused `local pubkey="$3"` line (~1192). + +In `cmd_push`, change the inline encrypt (~1357): +```bash + age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel" +``` + +In `push_external_files`, change both encrypt lines (~655 and ~684) to `age "${RECIPIENT_ARGS[@]}" -o …` (keep the rest of each line identical) and delete its `local pubkey="$3"` from the signature line `local root="$1" project="$2" pubkey="$3"` → `local root="$1" project="$2"`. + +- [ ] **Step 6: Load recipients in the push commands and drop the old `pubkey` threading** + +In `cmd_push` (~1268-1269) replace: +```bash + local pubkey + pubkey=$(get_pubkey) +``` +with: +```bash + _load_recipients +``` +and change the external call (~1365) `push_external_files "$PWD" "$project"` (drop `"$pubkey"`). + +In `cmd_push_workspaces` (~1428) replace the `pubkey=$(get_pubkey)` pair with `_load_recipients`, and drop the `"$pubkey"` argument from the `push_dir_to_project …` (~1432, ~1443) and `push_external_files …` (~1450) calls. + +- [ ] **Step 7: Run the tests** + +Run: `bats test/recipients.bats` +Expected: PASS (4 tests). + +- [ ] **Step 8: Run the full suite to confirm no regression** + +Run: `bats test/` +Expected: PASS (all existing tests still green — legacy push/pull unchanged). + +- [ ] **Step 9: Commit** + +```bash +git add secrets test/recipients.bats +git commit -m "feat: multi-recipient encrypt core + recipients.txt (EGB-283)" +``` + +--- + +### Task 2: `secrets recipients list` + +**Files:** +- Modify: `secrets` (new `_recipients_dump`, `cmd_recipients`, `_recipients_list`; dispatch + nothing in help yet) +- Test: `test/recipients.bats` + +**Interfaces:** +- Produces: `_recipients_dump` (emits `\t` per recipient, name = nearest preceding `# ` comment or empty); `cmd_recipients …` (routes `list`); `_recipients_list`. +- Consumes: `_load_recipients`, `RECIPIENTS_FILE`, `get_pubkey`. + +- [ ] **Step 1: Write failing tests** + +```bash +@test "recipients list on a legacy store shows the single derived key" { + init_with_remote + 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 +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bats test/recipients.bats -f "recipients list"` +Expected: FAIL ("Unknown command: recipients"). + +- [ ] **Step 3: Add `_recipients_dump`** (place after `_load_recipients`): + +```bash +# Emit "\t" for each recipient in recipients.txt. is the most +# recent preceding "# " 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" +} +``` + +- [ ] **Step 4: Add `cmd_recipients` + `_recipients_list`** (place near `cmd_which`): + +```bash +cmd_recipients() { + resolve_store + local sub="${1:-list}" + [ $# -gt 0 ] && shift + case "$sub" in + list) _recipients_list ;; + *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list]" ;; + 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) +} +``` + +- [ ] **Step 5: Wire dispatch.** In the `case "${1:-help}"` block, add above `which|where|status`: +```bash + recipients) shift; cmd_recipients "$@" ;; +``` + +- [ ] **Step 6: Run the tests** + +Run: `bats test/recipients.bats -f "recipients list"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add secrets test/recipients.bats +git commit -m "feat: secrets recipients list (EGB-283)" +``` + +--- + +### Task 3: Shared `_reencrypt_all` engine + `secrets reencrypt` + dual `rekey` + +**Files:** +- Modify: `secrets` (new `_reencrypt_all`, `cmd_reencrypt`; rewrite `cmd_rekey` head to branch; dispatch) +- Test: `test/recipients.bats` + +**Interfaces:** +- Produces: `_reencrypt_all ` (decrypt whole store with `KEY_FILE`, re-encrypt to `RECIPIENT_ARGS`, commit + push; aborts with store intact on decrypt failure; no-op on empty store); `cmd_reencrypt`. +- Consumes: `_load_recipients`, `RECIPIENT_ARGS`, `KEY_FILE`, `ensure_store_protections`. + +- [ ] **Step 1: Write failing tests** + +```bash +@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") + 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 ] +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bats test/recipients.bats -f "reencrypt|rekey on"` +Expected: FAIL ("Unknown command: reencrypt"; multi rekey generates a new key today). + +- [ ] **Step 3: Add `_reencrypt_all`** (place just before `cmd_rekey`): + +```bash +# 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 + _load_recipients + _reencrypt_all "reencrypt: re-encrypt all to current recipients" +} +``` + +- [ ] **Step 4: Branch `cmd_rekey`.** Replace the head of `cmd_rekey` — from its `check_cmd age` line down to and including the `info "Decrypting all files with current key..."` line — with the block below. **Leave the rest of the existing legacy body (temp dir, decrypt loop, keygen, re-encrypt loop, commit/push) exactly as-is** below this insertion: + +```bash +cmd_rekey() { + check_cmd age + check_cmd git + resolve_store + check_initialized + 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) ── + info "Decrypting all files with current key..." +``` + +- [ ] **Step 5: Wire dispatch.** Add near `rekey)`: +```bash + reencrypt) cmd_reencrypt ;; +``` + +- [ ] **Step 6: Run the tests** + +Run: `bats test/recipients.bats -f "reencrypt|rekey"` +Expected: PASS (3 tests). + +- [ ] **Step 7: Run the full suite** (the legacy rekey tests in `secrets.bats` must still pass) + +Run: `bats test/` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add secrets test/recipients.bats +git commit -m "feat: shared _reencrypt_all + reencrypt cmd + dual rekey (EGB-283)" +``` + +--- + +### Task 4: `secrets recipients add` + +**Files:** +- Modify: `secrets` (`_recipients_add`, `_validate_recipient_name`; extend `cmd_recipients` case) +- Test: `test/recipients.bats` + +**Interfaces:** +- Produces: `_recipients_add [--name