fix: rekey and list recurse into nested manifest blobs (EGB-677 stage 1)

Pre-landing review (testing + checklist specialists, reproduced) caught a
data-loss bug: cmd_rekey's decrypt/re-encrypt globs were non-recursive and
only special-cased external/. Nested manifest dotenv blobs
(<project>/<relpath>.age, new this branch) were never visited, so after a key
rotation they stayed encrypted under the discarded old key = permanently
undecryptable. cmd_list had the same blind spot (cosmetic: nested entries
invisible in listings).

Both now walk the entire project tree with `find -type f` (bash 3.2 safe,
includes dotfiles natively), unifying top-level / nested / external blobs into
one recursive pass and dropping the now-redundant external/ special-casing.

Regression tests: nested-blob rekey round-trip (survives rotation) + list
shows nested entry. Full suite 193/193.
This commit is contained in:
Brian Majewski 2026-06-07 13:12:27 -07:00
parent 5489f89446
commit c6ea724ddb
2 changed files with 70 additions and 48 deletions

85
secrets
View file

@ -1607,19 +1607,20 @@ cmd_list() {
# Skip hidden dirs # Skip hidden dirs
[[ "$project" == .* ]] && continue [[ "$project" == .* ]] && continue
echo "$project:" echo "$project:"
for f in "$dir"*.age "$dir".*.age; do # Recurse the whole project tree so nested manifest blobs
# (<project>/<relpath>.age) are visible, not just top-level entries.
# External blobs (external/<slug>.age) are labelled distinctly.
while IFS= read -r f; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
echo " $(basename "$f" .age)" local rel
rel=${f#"$dir"}
rel=${rel%.age}
case "$rel" in
external/*) echo " [external] ${rel#external/}" ;;
*) echo " $rel" ;;
esac
found=1 found=1
done done < <(find "$dir" -type f -name '*.age' | sort)
# External files live in a subdir, invisible to the globs above.
if [ -d "${dir}external" ]; then
for f in "${dir}external"/*.age; do
[ -f "$f" ] || continue
echo " [external] $(basename "$f" .age)"
found=1
done
fi
done done
if [ "$found" -eq 0 ]; then if [ "$found" -eq 0 ]; then
@ -1681,29 +1682,23 @@ cmd_rekey() {
project=$(basename "$dir") project=$(basename "$dir")
[[ "$project" == .* ]] && continue [[ "$project" == .* ]] && continue
mkdir -p "$tmpdir/$project" mkdir -p "$tmpdir/$project"
for f in "$dir"*.age "$dir".*.age; do # Walk the WHOLE project tree, not just its top level. Manifest dotenv
# entries can nest (<project>/<relpath>.age) and external blobs live in
# <project>/external/<slug>.age. A non-recursive glob would skip both,
# leaving them encrypted under the old key = permanently undecryptable
# after rotation (silent data loss). `find` is bash-3.2 safe and recurses.
while IFS= read -r f; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
local name local rel dest
name=$(basename "$f" .age) rel=${f#"$dir"} # path relative to the project dir (keeps .age)
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/$name" "$f"; then rel=${rel%.age} # strip the .age suffix → original relpath
die "Decryption failed for $project/$name. Rekey aborted. Old key preserved." dest="$tmpdir/$project/$rel"
mkdir -p "$(dirname "$dest")"
if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
die "Decryption failed for $project/$rel. Rekey aborted. Old key preserved."
fi fi
file_count=$((file_count + 1)) file_count=$((file_count + 1))
done done < <(find "$dir" -type f -name '*.age')
# External files live in a subdir; rekey them too or they become
# undecryptable after rotation.
if [ -d "${dir}external" ]; then
mkdir -p "$tmpdir/$project/external"
for f in "${dir}external"/*.age; do
[ -f "$f" ] || continue
local ename
ename=$(basename "$f" .age)
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/external/$ename" "$f"; then
die "Decryption failed for $project/external/$ename. Rekey aborted. Old key preserved."
fi
file_count=$((file_count + 1))
done
fi
done done
if [ "$file_count" -eq 0 ]; then if [ "$file_count" -eq 0 ]; then
@ -1723,29 +1718,23 @@ cmd_rekey() {
info "Re-encrypting all files with new key..." info "Re-encrypting all files with new key..."
# Re-encrypt all files. The ".*" glob is required: dotenv files decrypt # Re-encrypt all files. `find -type f` recurses into nested dotenv dirs and
# to dotfiles ("$tmpdir/p/.env") that a bare "*" would silently skip, # external/ and natively includes dotfiles (decrypted dotenv files like
# leaving their blobs on the old key (undecryptable after rotation). # "$tmpdir/p/.env"), which a bare "*" glob would silently skip — leaving
# their blobs on the old key (undecryptable after rotation). The walk mirrors
# the recursive decrypt above so every blob round-trips back to its relpath.
for dir in "$tmpdir"/*/; do for dir in "$tmpdir"/*/; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
local project local project
project=$(basename "$dir") project=$(basename "$dir")
mkdir -p "$SECRETS_DIR/$project" mkdir -p "$SECRETS_DIR/$project"
for f in "$dir"* "$dir".*; do while IFS= read -r f; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
local name local rel
name=$(basename "$f") rel=${f#"$dir"} # path relative to the project temp dir
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
done age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
if [ -d "${dir}external" ]; then done < <(find "$dir" -type f)
mkdir -p "$SECRETS_DIR/$project/external"
for f in "${dir}external"/*; do
[ -f "$f" ] || continue
local ename
ename=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/${ename}.age" "$f"
done
fi
done done
# Commit and push (heal .gitignore first so add -A can't stage key.txt) # Commit and push (heal .gitignore first so add -A can't stage key.txt)

View file

@ -698,3 +698,36 @@ m_nojq_path() {
run jq -r '.external | length' .secrets.json run jq -r '.external | length' .secrets.json
[ "$output" = "1" ] [ "$output" = "1" ]
} }
# ─── J: rekey + list recurse into nested manifest blobs (data-loss guard) ──────
@test "rekey re-encrypts a nested manifest dotenv blob (survives rotation)" {
# Regression: cmd_rekey's non-recursive glob skipped <project>/<relpath>.age
# blobs, leaving them on the old key = permanently undecryptable after rotation.
init_with_remote
create_project_dir nestrekey
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
[ -f "$SECRETS_DIR/nestrekey/packages/web/.env.development.age" ]
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
rm -rf packages
run "$SECRETS_BIN" pull nestrekey
[ "$status" -eq 0 ]
[ -f packages/web/.env.development ]
[ "$(cat packages/web/.env.development)" = "N=nested" ]
}
@test "list shows a nested manifest blob" {
init_with_remote
create_project_dir nestlist
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"packages/web/.env.development"* ]] || false
}