@@ -209,6 +209,20 @@ ui_progress_gauge() {
return "$status"
}
+#--------------------------------------------------------------------------
+# ui_reencrypt_volume_gauge <device> <index> <count>
+#
+# Shows a gauge for one encrypted volume, fed with percentages on stdin
+# (see sys_luks_reencrypt_volume).
+#--------------------------------------------------------------------------
+ui_reencrypt_volume_gauge() {
+ local device="$1"
+ local index="$2"
+ local count="$3"
+
+ ui_progress_gauge "Securing volume $index/$count ($device) with a new encryption key.\nDo not power off."
+}
+
#--------------------------------------------------------------------------
# ui_stop_progress_gauge
#
@@ -273,18 +273,215 @@ sys_bmap_options_for_image() {
fi
}
+#--------------------------------------------------------------------------
+# sys_luks_list_volumes <target_device>
+#
+# Prints the LUKS2 volumes found on target_device (and its partitions),
+# one per line, once the target has been verified to be offline and every
+# volume to be a pristine provisioning container: a single keyslot, no
+# tokens and no pending reencryption. Unencrypted targets yield no output
+# and need neither cryptsetup nor jq; encrypted ones yield no output (with a
+# warning) when either is missing, so that key rotation is skipped.
+#
+# Returns:
+# 0 on success (also when tools are missing, see above), 1 when the
+# target is busy or a volume is unsupported (reason on stderr).
+#--------------------------------------------------------------------------
+sys_luks_list_volumes() {
+ local target_device="$1"
+ local devices device mounts fstype metadata probe_status
+ local holders
+ local volumes=()
+
+ partprobe "$target_device" || true
+ ! command -v udevadm >/dev/null || udevadm settle || true
+
+ devices=$(lsblk --list --noheadings --paths --output NAME "$target_device") || return 1
+ while IFS= read -r device; do
+ mounts=$(lsblk --nodeps --noheadings --output MOUNTPOINTS "$device")
+ if [ -n "${mounts//[[:space:]]/}" ]; then
+ echo "Error: $device is mounted" >&2
+ return 1
+ fi
+ holders=(/sys/class/block/"${device##*/}"/holders/*)
+ if [ -e "${holders[0]}" ]; then
+ echo "Error: $device has active holders" >&2
+ return 1
+ fi
+
+ probe_status=0
+ fstype=$(blkid --probe --output value --match-tag TYPE "$device" 2>/dev/null) || probe_status=$?
+ if [ "$probe_status" != 0 ] && [ "$probe_status" != 2 ]; then
+ echo "Error: cannot probe $device" >&2
+ return 1
+ fi
+ [ "$fstype" = crypto_LUKS ] || continue
+
+ # Optional tools: without them keys are not rotated (see sys_luks_rotate_keys).
+ if ! command -v cryptsetup >/dev/null || ! command -v jq >/dev/null; then
+ echo "Warning: $device is encrypted but cryptsetup/jq are missing, skipping key rotation" >&2
+ return 0
+ fi
+ if ! cryptsetup isLuks --type luks2 "$device"; then
+ echo "Error: $device is not a LUKS2 volume" >&2
+ return 1
+ fi
+ metadata=$(cryptsetup luksDump --dump-json-metadata "$device") || return 1
+ if ! jq -e '
+ (.tokens | length) == 0 and
+ (.keyslots | length) == 1 and
+ all(.keyslots[]; .type == "luks2") and
+ (.segments | length) == 1 and
+ all(.segments[]; .type == "crypt" and ((.flags // []) | length) == 0)
+ ' <<< "$metadata" >/dev/null; then
+ echo "Error: unsupported or interrupted LUKS state on $device" >&2
+ return 1
+ fi
+ volumes+=("$device")
+ done <<< "$devices"
+
+ [ ${#volumes[@]} -eq 0 ] || printf '%s\n' "${volumes[@]}"
+}
+
+#--------------------------------------------------------------------------
+# sys_luks_make_keyfile <device>
+#
+# Creates a private key file holding the initial passphrase of <device> and
+# prints its path. The passphrase is empty unless the optional hook
+# sys_luks_passphrase_hook is defined; it is called with the device and
+# must print the passphrase on stdout (no trailing newline is added).
+#--------------------------------------------------------------------------
+sys_luks_make_keyfile() {
+ local device="$1"
+ local keyfile passphrase=""
+
+ if declare -F sys_luks_passphrase_hook >/dev/null; then
+ passphrase=$(sys_luks_passphrase_hook "$device") || return 1
+ fi
+ keyfile=$(mktemp /run/installer-luks-key.XXXXXX) || return 1
+ printf '%s' "$passphrase" > "$keyfile" || { rm -f -- "$keyfile"; return 1; }
+ echo "$keyfile"
+}
+
+#--------------------------------------------------------------------------
+# sys_luks_reencrypt_volume <device>
+#
+# Generates a new volume key for a provisioning LUKS2 volume, unlocking it
+# with its initial passphrase (see sys_luks_make_keyfile), and emits one
+# integer percentage per line on stdout so callers can drive a progress
+# indicator. The volume is offline and a failed run requires reinstalling,
+# so no resilience journal is kept (this halves the amount of data written).
+#
+# Returns:
+# 0 when the volume was reencrypted and verified, 1 otherwise.
+#--------------------------------------------------------------------------
+sys_luks_reencrypt_volume() {
+ local device="$1"
+ local keyfile log uuid metadata status
+
+ keyfile=$(sys_luks_make_keyfile "$device") || return 1
+ uuid=$(cryptsetup luksUUID "$device") || { rm -f -- "$keyfile"; return 1; }
+ log=$(mktemp /run/installer-luks-reencrypt.XXXXXX) || { rm -f -- "$keyfile"; return 1; }
+
+ # Progress is printed on stdout, errors on stderr (kept for reporting).
+ cryptsetup reencrypt --batch-mode --force-offline-reencrypt \
+ --resilience none --progress-json --progress-frequency 1 \
+ --key-file "$keyfile" "$device" 2> "$log" |
+ jq -R --unbuffered -r '
+ fromjson? | select(.device_size != null) |
+ (((.device_bytes | tonumber) * 100) / (.device_size | tonumber) | floor)'
+ status=${PIPESTATUS[0]}
+ [ "$status" = 0 ] || cat "$log" >&2
+ rm -f -- "$log"
+
+ if [ "$status" = 0 ]; then
+ [ "$(cryptsetup luksUUID "$device")" = "$uuid" ] || status=1
+ fi
+ if [ "$status" = 0 ]; then
+ cryptsetup open --test-passphrase --key-file "$keyfile" "$device" || status=1
+ fi
+ if [ "$status" = 0 ]; then
+ metadata=$(cryptsetup luksDump --dump-json-metadata "$device") || status=1
+ jq -e 'all(.keyslots[]; .type != "reencrypt") and
+ all(.segments[]; .type == "crypt" and ((.flags // []) | length) == 0)' \
+ <<< "$metadata" >/dev/null || status=1
+ fi
+
+ rm -f -- "$keyfile"
+ [ "$status" = 0 ] || echo "Error: reencryption of $device failed" >&2
+ [ "$status" = 0 ]
+}
+
+#--------------------------------------------------------------------------
+# sys_luks_rotate_keys <target_device>
+#
+# Installed images share the volume keys of the image they were built
+# from. Reencrypt every LUKS2 volume of target_device so the installed
+# system uses its own master keys. Must run while the target is offline.
+#
+# Optional hooks (if defined):
+# sys_luks_passphrase_hook <device>
+# prints the initial passphrase of <device> (default: empty)
+# ui_reencrypt_volume_gauge <device> <index> <count>
+# reads percentages from stdin while a volume is reencrypted
+#
+# Returns:
+# 0 when all volumes were rotated, none are encrypted, or rotation was
+# skipped because cryptsetup or jq is missing (warning on stderr);
+# 1 otherwise. On failure the target must not be booted and must be
+# reinstalled.
+#--------------------------------------------------------------------------
+sys_luks_rotate_keys() {
+ local target_device="$1"
+ local volumes=()
+ local device keyfile listing status index=0 count
+
+ listing=$(sys_luks_list_volumes "$target_device") || return 1
+ [ -n "$listing" ] || return 0
+ mapfile -t volumes <<< "$listing"
+ count=${#volumes[@]}
+
+ # Fail before touching any volume when the initial credential is wrong.
+ for device in "${volumes[@]}"; do
+ keyfile=$(sys_luks_make_keyfile "$device") || return 1
+ if ! cryptsetup open --test-passphrase --key-file "$keyfile" "$device"; then
+ echo "Error: initial unlock credential is unavailable for $device" >&2
+ rm -f -- "$keyfile"
+ return 1
+ fi
+ rm -f -- "$keyfile"
+ done
+
+ for device in "${volumes[@]}"; do
+ index=$((index + 1))
+ if declare -F ui_reencrypt_volume_gauge >/dev/null; then
+ sys_luks_reencrypt_volume "$device" | ui_reencrypt_volume_gauge "$device" "$index" "$count"
+ status=${PIPESTATUS[0]}
+ else
+ echo "Reencrypting $device ($index/$count); do not power off."
+ sys_luks_reencrypt_volume "$device" | while read -r percent; do
+ printf '\r%s: %s%%' "$device" "$percent"
+ done
+ status=${PIPESTATUS[0]}
+ echo
+ fi
+ [ "$status" = 0 ] || return 1
+ done
+}
+
#--------------------------------------------------------------------------
# sys_install_image_with_lock <image_path> <target_device>
#
# Performs the actual bmaptool copy, serializing concurrent installer
-# consoles via flock. When UI hook functions are available, this API
-# uses them to present a progress gauge in attended mode. Consoles that
-# do not get the lock wait for its holder and report its result, which is
-# kept in /run/installer.result.
+# consoles via flock, then rotates the LUKS volume keys of the target
+# (see sys_luks_rotate_keys) before the lock is released. When UI hook
+# functions are available, this API uses them to present progress gauges
+# in attended mode.
#
# Optional UI hooks (if defined):
# ui_start_progress_gauge <pipe_path>
# ui_stop_progress_gauge
+# ui_reencrypt_volume_gauge <device> <index> <count>
#--------------------------------------------------------------------------
sys_install_image_with_lock() {
local image_path="$1"
@@ -335,6 +532,13 @@ sys_install_image_with_lock() {
ui_stop_progress_gauge
fi
+ # Still under the lock: give the installed system its own volume keys.
+ if ! sys_luks_rotate_keys "$target_device"; then
+ echo "Volume-key rotation failed. Installation is incomplete; do not boot the target." >&2
+ echo failed > "$result_file"
+ return 1
+ fi
+
echo ok > "$result_file"
else
echo "Installation already running in another console. Waiting for it to finish..."