44 KiB
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.txtis committed, NOT gitignored (public keys are not secret). The store.gitignoreonly blockskey.txtand 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.shon 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_ARGSis always populated by_load_recipientsbefore anyage "${RECIPIENT_ARGS[@]}"call (never reference the array empty underset -u). - Commit cadence: one commit per task (TDD: test → impl → green → commit).
File map
secrets— all code changes (helpers,recipients/reencryptcommands, encrypt-site refactor,init/which/verify/rekeyedits, 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_FILEis set both as a global default (~line 20) and re-set insideresolve_store(~line 301). Mirror this forRECIPIENTS_FILE. - Existing encrypt sites (all
age -r "$pubkey" -o …):push_dir_to_project(~1207),cmd_pushinline (~1357),push_external_files(~655 and ~684),cmd_rekey(~1778).get_pubkey(~98) derives the pubkey fromkey.txt. - Tests run via
run "$SECRETS_BIN" <args>with isolated$HOMEand$SECRETS_DIR; helpersinit_with_remote,create_project_dirlive intest/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 afterget_pubkey~99; encrypt sites ~655, ~684, ~1207, ~1357;cmd_push~1268;cmd_push_workspaces~1428;push_dir_to_project/push_external_filessignatures) - 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(populatesRECIPIENT_ARGS, dies on bad/symlinked/empty file). -
Consumes:
get_pubkey,SECRETS_DIR,KEY_FILE. -
Step 1: Write failing tests in new
test/recipients.bats:
#!/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_FILEplumbing
Near KEY_FILE="$SECRETS_DIR/key.txt" (~line 20), add the name constant just above it and the path just below:
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:
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
- Step 4: Add
_validate_age_recipientand_load_recipientsimmediately afterget_pubkey(~line 99):
# 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):
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):
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
pubkeythreading
In cmd_push (~1268-1269) replace:
local pubkey
pubkey=$(get_pubkey)
with:
_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
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> …(routeslist);_recipients_list. -
Consumes:
_load_recipients,RECIPIENTS_FILE,get_pubkey. -
Step 1: Write failing tests
@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):
# 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 nearcmd_which):
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 abovewhich|where|status:
recipients) shift; cmd_recipients "$@" ;;
- Step 6: Run the tests
Run: bats test/recipients.bats -f "recipients list"
Expected: PASS.
- Step 7: Commit
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; rewritecmd_rekeyhead to branch; dispatch) - Test:
test/recipients.bats
Interfaces:
-
Produces:
_reencrypt_all <commit-msg>(decrypt whole store withKEY_FILE, re-encrypt toRECIPIENT_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
@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 beforecmd_rekey):
# 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 ofcmd_rekey— from itscheck_cmd ageline down to and including theinfo "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:
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):
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.batsmust still pass)
Run: bats test/
Expected: PASS.
- Step 8: Commit
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; extendcmd_recipientscase) - 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
@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/work/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):
# 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):
_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 theadd)arm:
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
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; extendcmd_recipientscase) - 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
@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/work/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):
# 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 therm)/remove)arm:
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
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.txtseeded atinit; arecipients:line inwhichoutput. -
Step 1: Write failing tests
@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. Afterpubkey=$(get_pubkey)(~1176) and before theinfo "Done!..."banner, add:
# 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
whichline. Incmd_which, right afterecho "format: v$(_store_format)"(~1913), add:
# 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
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
@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):
# 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(afterlocal checked=0 failed=0 …), add:
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:
if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
failed=$((failed + 1))
fi
- Step 5: Same in
_verify_project. After itslocal findings=0 checked=0add the samerexpectedblock. Then after EACH of the two_verify_blob_decryptschecks (the dotenvblobloop and the externaleblobloop), add a count check that incrementsfindings:
# dotenv loop, after the decrypt check:
if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
findings=$((findings + 1))
fi
# 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
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:
test/recipients.bats(adversarial fixtures only — nosecretschanges expected; if a case slips through, fix the rail insecretshere)
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 1: Write the fixtures
@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
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 runsage-keygen, sends their public key; an existing member runssecrets recipients add age1… --name them(store re-encrypts + pushes); the teammate clones the store, drops their ownkey.txt, andsecrets pullworks. Offboarding:secrets recipients rm them(+ rotate still-sensitive values, since history stays readable by the old key). Documentrecipients listandreencrypt. -
Step 4: Version bump. If the repo carries a version constant/
VERSION(recent commits bumped0.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
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_ARGSat every encrypt site → Task 1. ✓_reencrypt_allshared engine + add/rm/rekey/reencrypt routing → Tasks 3, 4, 5. ✓recipients list/add/rmwith guards (last, self/--yes) → Tasks 2, 4, 5. ✓- Backward compat (absence = legacy; bootstrap on first add) → Tasks 1, 4. ✓
initborn-multi → Task 6. ✓rekeydual semantics → Task 3. ✓whichrecipients line → Task 6. ✓verifystanza-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).