From cad5b66f77fa017524b68d72805164b0590ad8d6 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Wed, 24 Jun 2026 12:56:50 -0700 Subject: [PATCH] feat: secrets recipients rm with lockout guards (EGB-283) Co-Authored-By: Claude Sonnet 4.6 --- secrets | 52 +++++++++++++++++++++++++++++++++++++++++++- test/recipients.bats | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/secrets b/secrets index 7015a40..aa6212b 100755 --- a/secrets +++ b/secrets @@ -2077,7 +2077,8 @@ cmd_recipients() { case "$sub" in list) _recipients_list ;; add) _recipients_add "$@" ;; - *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add [--name N]]" ;; + rm|remove) _recipients_rm "$@" ;; + *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add [--name N]|rm [--yes]]" ;; esac } @@ -2137,6 +2138,55 @@ _recipients_add() { _reencrypt_all "recipients: add ${name:-$key}; re-encrypt all" } +# Rewrite recipients.txt canonically (one "# name"? + key per entry), dropping +# the entry whose key == . 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 [--yes]" ;; + *) if [ -z "$target" ]; then target="$1"; else die "Unexpected argument: $1"; fi; shift ;; + esac + done + [ -n "$target" ] || die "Usage: secrets recipients rm [--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() { resolve_store echo "store: $SECRETS_DIR" diff --git a/test/recipients.bats b/test/recipients.bats index 6c00c89..6067754 100644 --- a/test/recipients.bats +++ b/test/recipients.bats @@ -181,3 +181,51 @@ make_second_identity() { [ "$status" -ne 0 ] [[ "$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 +}