secrets/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md
Brian Majewski 14cf86fba9 docs: plan fixups from Task 1 review (blob path, dangling-symlink fix to Task 8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:37:55 -07:00

1207 lines
46 KiB
Markdown

# 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" <args>` 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 <key>` pairs); `RECIPIENTS_FILE` / `RECIPIENTS_FILE_NAME`; `_validate_age_recipient <str>` (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 <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 [ ! -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 <age1...>'."
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 `<key>\t<name>` per recipient, name = nearest preceding `# <name>` comment or empty); `cmd_recipients <sub> …` (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 "<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"
}
```
- [ ] **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 <commit-msg>` (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 <age1…> [--name <label>]` (validate, bootstrap legacy store with self key, reject dup, append, re-encrypt all); `_validate_recipient_name`.
- Consumes: `_recipients_dump`, `_load_recipients`, `_reencrypt_all`, `get_pubkey`, `_validate_age_recipient`.
- [ ] **Step 1: Write failing tests**
```bash
@test "recipients add bootstraps a legacy store and re-encrypts" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
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
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `bats test/recipients.bats -f "recipients add"`
Expected: FAIL ("Unknown recipients subcommand: 'add'").
- [ ] **Step 3: Add `_validate_recipient_name`** (place near `_validate_age_recipient`):
```bash
# 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
}
```
- [ ] **Step 4: Add `_recipients_add`** (place after `_recipients_list`):
```bash
_recipients_add() {
check_cmd age
check_cmd git
check_initialized
check_key
local key="" name=""
while [ $# -gt 0 ]; do
case "$1" in
--name) 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
_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
# 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"
}
```
- [ ] **Step 5: Extend `cmd_recipients`** — add the `add)` arm:
```bash
list) _recipients_list ;;
add) _recipients_add "$@" ;;
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]]" ;;
```
- [ ] **Step 6: Run the tests**
Run: `bats test/recipients.bats -f "recipients add"`
Expected: PASS (4 tests).
- [ ] **Step 7: Commit**
```bash
git add secrets test/recipients.bats
git commit -m "feat: secrets recipients add (EGB-283)"
```
---
### Task 5: `secrets recipients rm` with last-recipient and self-lockout guards
**Files:**
- Modify: `secrets` (`_recipients_rm`, `_recipients_write_without`; extend `cmd_recipients` case)
- Test: `test/recipients.bats`
**Interfaces:**
- Produces: `_recipients_rm <age1…|name> [--yes]`; `_recipients_write_without <key>` (canonical rewrite dropping one key + its name comment).
- Consumes: `_recipients_dump`, `_load_recipients`, `_reencrypt_all`, `get_pubkey`.
- [ ] **Step 1: Write failing tests**
```bash
@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
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `bats test/recipients.bats -f "recipients rm"`
Expected: FAIL ("Unknown recipients subcommand: 'rm'").
- [ ] **Step 3: Add `_recipients_write_without` + `_recipients_rm`** (after `_recipients_add`):
```bash
# 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."
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"
}
```
- [ ] **Step 4: Extend `cmd_recipients`** — add the `rm)`/`remove)` arm:
```bash
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]]" ;;
```
- [ ] **Step 5: Run the tests**
Run: `bats test/recipients.bats -f "recipients rm"`
Expected: PASS (4 tests).
- [ ] **Step 6: Commit**
```bash
git add secrets test/recipients.bats
git commit -m "feat: secrets recipients rm with lockout guards (EGB-283)"
```
---
### Task 6: `init` born-multi seeding + `which` recipients line
**Files:**
- Modify: `secrets` (`cmd_init` ~1176; `cmd_which` ~1913)
- Test: `test/recipients.bats`
**Interfaces:**
- Consumes: `RECIPIENTS_FILE`, `get_pubkey`, `_recipients_dump`.
- Produces: a `recipients.txt` seeded at `init`; a `recipients:` line in `which` output.
- [ ] **Step 1: Write failing tests**
```bash
@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
}
```
Note: the first test will change `init`'s observable output — confirm no existing `secrets.bats` test asserts the *absence* of `recipients.txt` after `init`. If one does, update it in this task.
- [ ] **Step 2: Run to verify they fail**
Run: `bats test/recipients.bats -f "born-multi|which reports"`
Expected: FAIL.
- [ ] **Step 3: Seed in `cmd_init`.** After `pubkey=$(get_pubkey)` (~1176) and before the `info "Done!..."` banner, add:
```bash
# 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"
```
- [ ] **Step 4: Add the `which` line.** In `cmd_which`, right after `echo "format: v$(_store_format)"` (~1913), add:
```bash
# 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
```
- [ ] **Step 5: Run the tests + full suite**
Run: `bats test/recipients.bats -f "born-multi|which reports"` then `bats test/`
Expected: PASS. (If a legacy `secrets.bats`/`migrate.bats` test broke on the new `init` output or the new `which` line, fix that test to expect the new line and re-run.)
- [ ] **Step 6: Commit**
```bash
git add secrets test/
git commit -m "feat: init born-multi recipients.txt + which recipients line (EGB-283)"
```
---
### Task 7: `verify` recipient-count invariant
**Files:**
- Modify: `secrets` (`_blob_recipient_count`, `_check_blob_recipient_count`; `_verify_project` ~2017; `_verify_all` ~1970)
- Test: `test/recipients.bats`
**Interfaces:**
- Produces: `_blob_recipient_count <blob>` (count of `-> ` stanzas in the age header); `_check_blob_recipient_count <blob> <rel> <expected>` (echoes a FINDING + returns 1 on mismatch, 0 if expected empty/legacy).
- Consumes: `_load_recipients`, `RECIPIENT_ARGS`, `RECIPIENTS_FILE`.
- [ ] **Step 1: Write failing tests**
```bash
@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
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `bats test/recipients.bats -f "verify"`
Expected: the drift test FAILs (verify is green today even with a stale blob).
- [ ] **Step 3: Add the helpers** (place near `_verify_blob_decrypts` ~1961):
```bash
# 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
}
```
- [ ] **Step 4: Compute the expected count + check in `_verify_all`.** Near the top of `_verify_all` (after `local checked=0 failed=0 …`), add:
```bash
local rexpected=""
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
_load_recipients # validates; dies on a bad recipients.txt
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
fi
```
Then, inside its blob loop, right after the `_verify_blob_decrypts` block, add:
```bash
if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
failed=$((failed + 1))
fi
```
- [ ] **Step 5: Same in `_verify_project`.** After its `local findings=0 checked=0` add the same `rexpected` block. Then after EACH of the two `_verify_blob_decrypts` checks (the dotenv `blob` loop and the external `eblob` loop), add a count check that increments `findings`:
```bash
# dotenv loop, after the decrypt check:
if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
findings=$((findings + 1))
fi
```
```bash
# external loop, after the decrypt check:
if ! _check_blob_recipient_count "$eblob" "$project/$erel" "$rexpected"; then
findings=$((findings + 1))
fi
```
- [ ] **Step 6: Run the tests + full suite**
Run: `bats test/recipients.bats -f "verify"` then `bats test/`
Expected: PASS. (`_verify_project` tests in other suites run against single-key stores where `rexpected` is empty → checks are skipped, so they stay green.)
- [ ] **Step 7: Commit**
```bash
git add secrets test/recipients.bats
git commit -m "feat: verify asserts blob recipient-count matches recipients.txt (EGB-283)"
```
---
### Task 8: Security regression fixtures for `recipients.txt`
**Files:**
- Modify: `secrets` (one ordering fix in `_load_recipients`, carried over from the Task 1 review); `test/recipients.bats` (adversarial fixtures; if any other case slips through, fix the rail in `secrets` here)
**Interfaces:** none new — exercises `_load_recipients` / `_recipients_add` rails via the CLI.
> Policy reminder: these are ordinary defensive regression tests. Do NOT dispatch red-team/adversarial-review subagents and do NOT run `./test/run-security.sh` — that is operator-local.
- [ ] **Step 0: Fix the dangling-symlink ordering gap (from Task 1 review)**
In `_load_recipients`, the `[ ! -e "$RECIPIENTS_FILE" ]` check runs BEFORE the `[ -L "$RECIPIENTS_FILE" ]` check. A *dangling* symlink (link exists, target missing) makes `[ ! -e ]` true, so it bypasses the symlink refusal and silently degrades to legacy single-key mode. Swap the order so the symlink check comes first:
```bash
_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
# … rest unchanged (line-by-line parse + validate) …
```
Add a regression test alongside the other SECURITY fixtures:
```bash
@test "SECURITY: a dangling symlink recipients.txt is refused, not silently ignored" {
init_with_remote
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
}
```
- [ ] **Step 1: Write the fixtures**
```bash
@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
}
@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 ]
}
@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
}
@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"
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients add "$BOB_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}
```
- [ ] **Step 2: Run them**
Run: `bats test/recipients.bats -f "SECURITY"`
Expected: PASS. If any FAILS, the corresponding rail in `secrets` (`_validate_age_recipient`, the `-L` symlink guard in `_load_recipients`/`_recipients_add`/`_recipients_rm`) is too loose — tighten it, then re-run.
- [ ] **Step 3: Commit**
```bash
git add test/recipients.bats
git commit -m "test: recipients.txt security regression fixtures (EGB-283)"
```
---
### Task 9: Docs, help text, version bump
**Files:**
- Modify: `secrets` (`cmd_help`); `CLAUDE.md`; `README.md`; `VERSION`/version constant if one exists
**Interfaces:** none.
- [ ] **Step 1: Add help text.** In `cmd_help`, in the command list, add lines (match the existing column style):
```
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
```
- [ ] **Step 2: Update `CLAUDE.md`.** In the Architecture section add a multi-recipient bullet:
```
- 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`).
```
Also update the subcommand list line (`init, push, pull, list, rm, rekey, verify, migrate`) to include `recipients, reencrypt`, and bump the `secrets.bats`/suite test counts and `bats test/` description in Project Structure (recipients.bats is the 4th suite).
- [ ] **Step 3: Update `README.md`** — add an onboarding/offboarding section: a teammate runs `age-keygen`, sends their public key; an existing member runs `secrets recipients add age1… --name them` (store re-encrypts + pushes); the teammate clones the store, drops their own `key.txt`, and `secrets pull` works. Offboarding: `secrets recipients rm them` (+ rotate still-sensitive values, since history stays readable by the old key). Document `recipients list` and `reencrypt`.
- [ ] **Step 4: Version bump.** If the repo carries a version constant/`VERSION` (recent commits bumped `0.6.1.0`), bump it (e.g. `0.6.2.0`) and add a CHANGELOG entry per the repo's convention.
- [ ] **Step 5: Run the full suite once more**
Run: `bats test/`
Expected: PASS (all four suites).
- [ ] **Step 6: Commit**
```bash
git add secrets CLAUDE.md README.md
git commit -m "docs: multi-recipient age encryption (recipients/reencrypt) + version bump (EGB-283)"
```
---
## Self-Review
**Spec coverage:**
- recipients.txt format + rails → Tasks 1, 8. ✓
- `_load_recipients` / `RECIPIENT_ARGS` at every encrypt site → Task 1. ✓
- `_reencrypt_all` shared engine + add/rm/rekey/reencrypt routing → Tasks 3, 4, 5. ✓
- `recipients list/add/rm` with guards (last, self/--yes) → Tasks 2, 4, 5. ✓
- Backward compat (absence = legacy; bootstrap on first add) → Tasks 1, 4. ✓
- `init` born-multi → Task 6. ✓
- `rekey` dual semantics → Task 3. ✓
- `which` recipients line → Task 6. ✓
- `verify` stanza-count invariant → Task 7. ✓
- Security regression fixtures + policy note → Task 8. ✓
- Docs/help/version → Task 9. ✓
- Scope cuts (SSH, per-file subsets, key discovery) → enforced by `_validate_age_recipient` (rejects SSH) and simply not built. ✓
**Placeholder scan:** every code/test step carries real bash/bats. No TBD/TODO. ✓
**Type/name consistency:** `RECIPIENT_ARGS`, `RECIPIENTS_FILE`, `RECIPIENTS_FILE_NAME`, `_load_recipients`, `_validate_age_recipient`, `_validate_recipient_name`, `_recipients_dump`, `_recipients_list`, `_recipients_add`, `_recipients_rm`, `_recipients_write_without`, `_reencrypt_all`, `cmd_reencrypt`, `cmd_recipients`, `_blob_recipient_count`, `_check_blob_recipient_count` — used consistently across tasks. ✓
**Known caveat to verify during execution:** Task 6 changes `init` output (now writes `recipients.txt`) and adds a `which` line; if any existing test in `secrets.bats`/`migrate.bats` asserts on exact `init`/`which` output or the absence of store files, update it in Task 6 (called out in that task's steps).