# 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/work/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/work/myproj/.env.age" [ "$status" -eq 0 ] # And the store key still can too. run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/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 [ "$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/work/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/work/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/work/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/work/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