feat: multi-recipient encrypt core + recipients.txt (EGB-283)

- Add RECIPIENTS_FILE_NAME / RECIPIENTS_FILE constants; update resolve_store to re-derive RECIPIENTS_FILE after store resolution.
- Add _validate_age_recipient (native age1 X25519 key format check, injection rail).
- Add RECIPIENT_ARGS global array and _load_recipients (absent → single pubkey legacy path; present → parse+validate recipients.txt, refuse symlink, die on bad/empty).
- Rewire all 5 push encrypt sites (push_dir_to_project, cmd_push inline, push_external_files ×2, cmd_push_workspaces) to use RECIPIENT_ARGS; drop pubkey threading from push_dir_to_project and push_external_files signatures.
- New test/recipients.bats (4 tests): legacy single-key, multi-recipient decrypt, invalid key rejection, symlink rejection.
- Fix test/test_helper.bash: set GIT_AUTHOR/COMMITTER env vars so git commit works with isolated $HOME.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 12:33:05 -07:00
parent 3a7eea5529
commit 0f9de1c2fd
3 changed files with 129 additions and 14 deletions

78
secrets
View file

@ -17,7 +17,9 @@ set -euo pipefail
# while letting .secrets-store files take precedence per project.
_USER_SECRETS_DIR="${SECRETS_DIR:-}"
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
RECIPIENTS_FILE_NAME="recipients.txt"
KEY_FILE="$SECRETS_DIR/key.txt"
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Set by resolve_store(). Reports which rule chose SECRETS_DIR.
@ -98,6 +100,56 @@ get_pubkey() {
age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
}
# 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
}
derive_project_name() {
local explicit="${1:-}"
if [ -n "$explicit" ]; then
@ -299,6 +351,7 @@ resolve_store() {
SECRETS_DIR="$resolved"
KEY_FILE="$SECRETS_DIR/key.txt"
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
STORE_SOURCE="$source"
}
@ -630,7 +683,7 @@ merge_gradle_keys() {
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() {
local root="$1" project="$2" pubkey="$3"
local root="$1" project="$2"
# Entries come from .secrets.json (EGB-677) plus any legacy
# .secrets-files entries the manifest doesn't cover yet.
local entries
@ -652,7 +705,7 @@ push_external_files() {
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
mkdir -p "$SECRETS_DIR/$project/external"
local fslug; fslug=$(_secrets_files_slug "$mpath")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded"
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded"
info "Encrypted file $mpath"
pushed=$((pushed + 1))
continue
@ -681,7 +734,7 @@ push_external_files() {
fi
mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp"
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp"
rm -f "$tmp"
info "Extracted $found key(s) from $mpath"
pushed=$((pushed + 1))
@ -1189,7 +1242,6 @@ Your key file has been left untouched."
push_dir_to_project() {
local source_dir="$1"
local project="$2"
local pubkey="$3"
if ! collect_env_files "$source_dir"; then
return 1
@ -1204,7 +1256,7 @@ push_dir_to_project() {
for f in "${COLLECTED_FILES[@]}"; do
local name
name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done
return 0
}
@ -1265,8 +1317,7 @@ cmd_push() {
info "Pushing secrets for project: $project"
echo_store_if_non_default
local pubkey
pubkey=$(get_pubkey)
_load_recipients
# ── Manifest read (validated; absence = bootstrap) ──
# jq is required only when a manifest exists (authoritative, can't be
@ -1354,7 +1405,7 @@ cmd_push() {
*/*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;;
*) mkdir -p "$SECRETS_DIR/$project" ;;
esac
age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
echo " $rel"
count=$((count + 1))
done <<< "$sync_list"
@ -1362,7 +1413,7 @@ cmd_push() {
local did=0
[ "$count" -gt 0 ] && did=1
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
if push_external_files "$PWD" "$project"; then did=1; fi
if [ "$did" -eq 0 ]; then
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
fi
@ -1424,12 +1475,11 @@ cmd_push_workspaces() {
info "Pushing workspaces for monorepo: $monorepo_name"
echo_store_if_non_default
local pubkey
pubkey=$(get_pubkey)
_load_recipients
local total=0
# Push root env files (if any)
if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then
if push_dir_to_project "$root" "$monorepo_name"; then
total=$((total + ${#COLLECTED_FILES[@]}))
fi
@ -1440,14 +1490,14 @@ cmd_push_workspaces() {
[ -n "$ws" ] || continue
local ws_dir="$root/$ws"
local ws_project="$monorepo_name/$ws"
if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then
if push_dir_to_project "$ws_dir" "$ws_project"; then
total=$((total + ${#COLLECTED_FILES[@]}))
fi
done <<< "$workspaces"
# External files (.secrets-files) are monorepo-root-scoped, like
# .secrets-store — handle once, not per-workspace.
if push_external_files "$root" "$monorepo_name" "$pubkey"; then
if push_external_files "$root" "$monorepo_name"; then
total=$((total + 1))
fi

57
test/recipients.bats Normal file
View file

@ -0,0 +1,57 @@
#!/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
}

View file

@ -19,6 +19,14 @@ setup() {
# not be a real ancestor of /tmp). EGB-281 F9.
export HOME="$TEST_TMPDIR"
# Provide git author identity so `git commit` works with the fresh temp HOME
# (no ~/.gitconfig is present in the isolated dir). GIT_* env vars override
# any global config and survive the HOME redirect.
export GIT_AUTHOR_NAME="Test User"
export GIT_AUTHOR_EMAIL="test@example.com"
export GIT_COMMITTER_NAME="Test User"
export GIT_COMMITTER_EMAIL="test@example.com"
# Secrets repo lives in temp
export SECRETS_DIR="$TEST_TMPDIR/secrets-repo"