diff --git a/secrets b/secrets index b10f956..123c556 100755 --- a/secrets +++ b/secrets @@ -1772,6 +1772,78 @@ cmd_rm() { 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() { check_cmd age check_cmd git @@ -1779,6 +1851,18 @@ cmd_rekey() { check_initialized 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 local tmpdir tmpdir=$(mktemp -d) @@ -2611,7 +2695,8 @@ case "${1:-help}" in add) cmd_add "${2:-}" ;; list) cmd_list ;; rm) cmd_rm "${2:-}" ;; - rekey) cmd_rekey ;; + rekey) cmd_rekey ;; + reencrypt) cmd_reencrypt ;; verify) shift; cmd_verify "$@" ;; migrate) shift; cmd_migrate "$@" ;; recipients) shift; cmd_recipients "$@" ;; diff --git a/test/recipients.bats b/test/recipients.bats index 87e5491..c5be2dd 100644 --- a/test/recipients.bats +++ b/test/recipients.bats @@ -76,3 +76,50 @@ make_second_identity() { [[ "$output" == *"alice"* ]] || 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 ] +}