feat: secrets recipients rm with lockout guards (EGB-283)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 12:56:50 -07:00
parent 4ba0234bd7
commit cad5b66f77
2 changed files with 99 additions and 1 deletions

52
secrets
View file

@ -2077,7 +2077,8 @@ cmd_recipients() {
case "$sub" in case "$sub" in
list) _recipients_list ;; list) _recipients_list ;;
add) _recipients_add "$@" ;; add) _recipients_add "$@" ;;
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]]" ;; rm|remove) _recipients_rm "$@" ;;
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]|rm <age1...|name> [--yes]]" ;;
esac esac
} }
@ -2137,6 +2138,55 @@ _recipients_add() {
_reencrypt_all "recipients: add ${name:-$key}; re-encrypt all" _reencrypt_all "recipients: add ${name:-$key}; re-encrypt all"
} }
# 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"
}
cmd_which() { cmd_which() {
resolve_store resolve_store
echo "store: $SECRETS_DIR" echo "store: $SECRETS_DIR"

View file

@ -181,3 +181,51 @@ make_second_identity() {
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"--name requires a value"* ]] || false [[ "$output" == *"--name requires a value"* ]] || false
} }
@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
}