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:
parent
3a7eea5529
commit
0f9de1c2fd
3 changed files with 129 additions and 14 deletions
78
secrets
78
secrets
|
|
@ -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
57
test/recipients.bats
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue