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