diff --git a/secrets b/secrets index 02a3415..a2f5209 100755 --- a/secrets +++ b/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 " 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 `) 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 '." + 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() { # /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 diff --git a/test/recipients.bats b/test/recipients.bats new file mode 100644 index 0000000..a8ae7c6 --- /dev/null +++ b/test/recipients.bats @@ -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 +} diff --git a/test/test_helper.bash b/test/test_helper.bash index 1470caf..d9846af 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -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"