1207 lines
46 KiB
Markdown
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).
|