feat: shared _reencrypt_all + reencrypt cmd + dual rekey (EGB-283)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 12:44:44 -07:00
parent 649aa78063
commit a10f390519
2 changed files with 133 additions and 1 deletions

85
secrets
View file

@ -1772,6 +1772,78 @@ cmd_rm() {
fi fi
} }
# Decrypt every blob in the store with the local key and re-encrypt each to the
# currently-loaded RECIPIENT_ARGS, then commit + push. The caller MUST have run
# _load_recipients (or set RECIPIENT_ARGS) and check_key first. Aborts with the
# store untouched on any decrypt failure (you must be a current recipient).
# Shared by recipients add/rm, reencrypt, and multi-recipient rekey.
_reencrypt_all() {
local commit_msg="$1"
local tmpdir
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
info "Decrypting all blobs with your key..."
local file_count=0 dir project f rel dest
for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue
project=$(basename "$dir")
case "$project" in .*) continue ;; esac
mkdir -p "$tmpdir/$project"
while IFS= read -r f; do
[ -f "$f" ] || continue
rel=${f#"$dir"}; rel=${rel%.age}
dest="$tmpdir/$project/$rel"
mkdir -p "$(dirname "$dest")"
if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
die "Decryption failed for $project/$rel (are you a current recipient?). Aborted; store unchanged."
fi
file_count=$((file_count + 1))
done < <(find "$dir" -type f -name '*.age')
done
if [ "$file_count" -eq 0 ]; then
rm -rf "$tmpdir"; trap - EXIT INT TERM
info "No encrypted blobs in the store — nothing to re-encrypt."
return 0
fi
local rc=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
info "Re-encrypting $file_count blob(s) to $rc recipient(s)..."
for dir in "$tmpdir"/*/; do
[ -d "$dir" ] || continue
project=$(basename "$dir")
mkdir -p "$SECRETS_DIR/$project"
while IFS= read -r f; do
[ -f "$f" ] || continue
rel=${f#"$dir"}
mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
done < <(find "$dir" -type f)
done
ensure_store_protections
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "$commit_msg" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" push >/dev/null 2>&1
info "Pushed re-encrypted secrets to remote"
else
info "Committed re-encrypted secrets locally (no remote configured)"
fi
rm -rf "$tmpdir"; trap - EXIT INT TERM
}
cmd_reencrypt() {
check_cmd age
check_cmd git
resolve_store
check_initialized
check_key
_load_recipients
_reencrypt_all "reencrypt: re-encrypt all to current recipients"
}
cmd_rekey() { cmd_rekey() {
check_cmd age check_cmd age
check_cmd git check_cmd git
@ -1779,6 +1851,18 @@ cmd_rekey() {
check_initialized check_initialized
check_key check_key
# EGB-283: on a multi-recipient store, rekey means "re-encrypt every blob to
# the current recipients.txt set" — NOT a new keypair (rotating an identity is
# the member's own age-keygen + recipients rm/add). Legacy stores (no
# recipients.txt) keep the original generate-new-keypair behavior below.
if [ -e "$RECIPIENTS_FILE" ]; then
_load_recipients
info "Multi-recipient store — re-encrypting to $RECIPIENTS_FILE_NAME (no new key generated)."
_reencrypt_all "rekey: re-encrypt all to current recipients"
return 0
fi
# ── Legacy single-key rotation (unchanged) ──
# Create temp dir with cleanup trap # Create temp dir with cleanup trap
local tmpdir local tmpdir
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
@ -2612,6 +2696,7 @@ case "${1:-help}" in
list) cmd_list ;; list) cmd_list ;;
rm) cmd_rm "${2:-}" ;; rm) cmd_rm "${2:-}" ;;
rekey) cmd_rekey ;; rekey) cmd_rekey ;;
reencrypt) cmd_reencrypt ;;
verify) shift; cmd_verify "$@" ;; verify) shift; cmd_verify "$@" ;;
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;
recipients) shift; cmd_recipients "$@" ;; recipients) shift; cmd_recipients "$@" ;;

View file

@ -76,3 +76,50 @@ make_second_identity() {
[[ "$output" == *"alice"* ]] || false [[ "$output" == *"alice"* ]] || false
[[ "$output" == *"bob"* ]] || false [[ "$output" == *"bob"* ]] || false
} }
@test "reencrypt re-encrypts existing blobs to a newly added recipient line" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push # single-key blob (project name = "myproj"; blob at $SECRETS_DIR/myproj/.env.age)
[ "$status" -eq 0 ]
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
# Bob cannot read the old single-key blob yet.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -ne 0 ]
run "$SECRETS_BIN" reencrypt
[ "$status" -eq 0 ]
# Now he can.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "rekey on a multi-recipient store keeps recipients and the same key" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
before=$(cat "$SECRETS_DIR/key.txt")
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
# No new keypair was generated.
[ "$(cat "$SECRETS_DIR/key.txt")" = "$before" ]
# Both recipients can decrypt.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "rekey on a legacy store still rotates to a new key (unchanged)" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
before=$(cat "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
[ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ]
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}