From c6ea724ddbd36b002b75a15bc1a1497c04fcd9f6 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Sun, 7 Jun 2026 13:12:27 -0700 Subject: [PATCH] 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 (/.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. --- secrets | 85 ++++++++++++++++++++-------------------------- test/manifest.bats | 33 ++++++++++++++++++ 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/secrets b/secrets index 7e01e91..ba2adce 100755 --- a/secrets +++ b/secrets @@ -1607,19 +1607,20 @@ cmd_list() { # Skip hidden dirs [[ "$project" == .* ]] && continue echo "$project:" - for f in "$dir"*.age "$dir".*.age; do + # Recurse the whole project tree so nested manifest blobs + # (/.age) are visible, not just top-level entries. + # External blobs (external/.age) are labelled distinctly. + while IFS= read -r f; do [ -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 - done - # 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 < <(find "$dir" -type f -name '*.age' | sort) done if [ "$found" -eq 0 ]; then @@ -1681,29 +1682,23 @@ cmd_rekey() { project=$(basename "$dir") [[ "$project" == .* ]] && continue 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 (/.age) and external blobs live in + # /external/.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 - local name - name=$(basename "$f" .age) - if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/$name" "$f"; then - die "Decryption failed for $project/$name. Rekey aborted. Old key preserved." + local rel dest + rel=${f#"$dir"} # path relative to the project dir (keeps .age) + rel=${rel%.age} # strip the .age suffix → original relpath + 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 file_count=$((file_count + 1)) - done - # 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 < <(find "$dir" -type f -name '*.age') done if [ "$file_count" -eq 0 ]; then @@ -1723,29 +1718,23 @@ cmd_rekey() { info "Re-encrypting all files with new key..." - # Re-encrypt all files. The ".*" glob is required: dotenv files decrypt - # to dotfiles ("$tmpdir/p/.env") that a bare "*" would silently skip, - # leaving their blobs on the old key (undecryptable after rotation). + # Re-encrypt all files. `find -type f` recurses into nested dotenv dirs and + # external/ and natively includes dotfiles (decrypted dotenv files like + # "$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 [ -d "$dir" ] || continue local project project=$(basename "$dir") mkdir -p "$SECRETS_DIR/$project" - for f in "$dir"* "$dir".*; do + while IFS= read -r f; do [ -f "$f" ] || continue - local name - name=$(basename "$f") - age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" - done - if [ -d "${dir}external" ]; then - 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 + local rel + rel=${f#"$dir"} # path relative to the project temp dir + mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")" + age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$f" + done < <(find "$dir" -type f) done # Commit and push (heal .gitignore first so add -A can't stage key.txt) diff --git a/test/manifest.bats b/test/manifest.bats index e273479..4459a6c 100644 --- a/test/manifest.bats +++ b/test/manifest.bats @@ -698,3 +698,36 @@ m_nojq_path() { run jq -r '.external | length' .secrets.json [ "$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 /.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 +}