feat: secrets recipients add (EGB-283)

Add `secrets recipients add <age1...> [--name <label>]`: validates the
age key and optional display name, bootstraps recipients.txt with the
local pubkey on a legacy store (keeping the operator as a recipient),
rejects duplicates, appends the new key (with optional name comment),
then re-encrypts the entire store to the updated recipient list.

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

48
secrets
View file

@ -117,6 +117,15 @@ _validate_age_recipient() {
return 0
}
# 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
}
# 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
@ -2067,7 +2076,8 @@ cmd_recipients() {
[ $# -gt 0 ] && shift
case "$sub" in
list) _recipients_list ;;
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list]" ;;
add) _recipients_add "$@" ;;
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]]" ;;
esac
}
@ -2088,6 +2098,42 @@ _recipients_list() {
done < <(_recipients_dump)
}
_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"
}
cmd_which() {
resolve_store
echo "store: $SECRETS_DIR"

View file

@ -123,3 +123,45 @@ make_second_identity() {
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@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
}