#!/bin/bash

get_credential() {
	local var="${1:?}"
	local name="${2:?}"
	local keyid
	local value=''
	keyid="$(keyctl id %user:"$name" 2> /dev/null)" || true

	# A plain `read` splits on IFS and stops at the first newline,
	# so it silently mangles a password that has leading or
	# trailing blanks, or more than one line.  Reading until NUL
	# keeps the payload verbatim
	if [ -n "$CREDENTIALS_DIRECTORY" ] && [ -e "$CREDENTIALS_DIRECTORY/$name" ]; then
		IFS= read -r -d '' value < "$CREDENTIALS_DIRECTORY/$name"
	elif [ -n "$keyid" ]; then
		IFS= read -r -d '' value < <(keyctl pipe "$keyid")
	fi

	# Only the single trailing newline that a credential file
	# usually ends with is removed
	printf -v "$var" '%s' "${value%$'\n'}"
}

have_luks2() {
	lsblk --noheadings -o FSTYPE | grep -q crypto_LUKS
}

write_issue_file()
{
	local content="$1"
	# The recovery key and the recovery PIN are different secrets
	# that can both be produced by the same run, so they cannot
	# share a file: the second call would truncate the first
	local name="${2:-90-recovery-key}"
	local issuefile="/run/issue.d/$name.issue"

	if [ -x '/usr/sbin/issue-generator' ]; then
		issuefile="/run/issue.d/$name.conf"
	fi
	mkdir -p "/run/issue.d"
	echo "$content" > "$issuefile"
	# Not a trailing `[ -x ... ] && issue-generator`: as the last
	# command of the function that returns 1 when the generator is
	# not installed, and the callers read this status
	if [ -x '/usr/sbin/issue-generator' ]; then
		issue-generator
	fi
}

# `systemd-pcrlock` generates a recovery PIN for the NV index when
# nothing supplies one, and sdbootutil only prints it.  Under
# sdbootutil-enroll.service that means the journal, where nobody looks,
# and it is the credential needed to re-authorize the policy later.
# When a recovery key was enrolled first it is reused as the PIN, and
# then there is nothing new to show
write_recovery_pin()
{
	grep -q "^Recovery PIN:" <<< "$1" || return 0
	write_issue_file "$1" 91-recovery-pin
}

# Remove the keyslot that disk-encryption-tool left behind, which it
# marks with a token of its own, of type "enrollment-key", and only
# when it generated the key itself: with a user-supplied key there is
# no token, the keyslot is an ordinary passphrase and it is left alone
#
# The type comes from the LUKS2 JSON and not from the "SLOT TYPE" table
# that systemd-cryptenroll prints, where every token it does not know
# is "other".  A Clevis or a tang binding also prints "other", and
# wiping that removes somebody else's way into the device
#
# The slot to remove is the one that carries the token, and not the
# slot 0: they are the same only when disk-encryption-tool was the first
# to write to the header.  When it was not, wiping the slot 0 removes
# the password of whoever owns the machine and keeps the transient key,
# which is exactly backwards
#
# And it is removed only once a slot that somebody can actually use is
# left.  systemd-cryptenroll does refuse to empty a header, but counting
# slots is not enough here: the key of this one is random, never shown,
# and gone when the boot ends, so a device left with only that is a
# device nobody can open
#
# Nothing is reported from here, so that this function can be the same
# in jeos-firstboot-enroll, which has a dialog instead of a terminal.
# Returns 1 when the slot was kept because nothing else opens the
# device, and 2 when the wipe itself failed
wipe_enrollment_key()
{
	local dev="${1:?}"
	local slot header usable
	local transient=()
	local rc=0

	# luksDump reads the metadata, so there is no password to give
	header="$(cryptsetup luksDump --dump-json-metadata "$dev" 2> /dev/null)" || return 2

	# The keyslots the tokens point at ($t) and the keyslots that
	# exist ($s).  Only the intersection is wiped, as wiping a slot
	# that is not there is just an error.  cryptsetup on its own does
	# not produce that: it refuses to import a token naming a missing
	# slot, and empties the "keyslots" of a token whose slot is
	# killed.  But this header is written by several tools
	# shellcheck disable=SC2016 # $t and $s are jq variables
	local q='[.tokens[]? | select(.type == "enrollment-key") | .keyslots[]?] as $t
		| [.keyslots | keys[]] as $s | '

	mapfile -t transient < <(jq -r "$q"'($s - ($s - $t))[]' <<< "$header")

	[ "${#transient[@]}" -ne 0 ] || return 0

	# Everything else opens the device: a keyslot with no token is a
	# passphrase, and one with a token of another type belongs to
	# whoever enrolled it
	usable="$(jq -r "$q"'($s - $t) | length' <<< "$header")" || return 2

	[ "$usable" -gt 0 ] || return 1

	for slot in "${transient[@]}"; do
		systemd-cryptenroll --wipe-slot="$slot" "$dev" || rc=2
	done

	return "$rc"
}


[ ! -e "/var/lib/YaST2/reconfig_system" ] || exit 0
have_luks2 || exit 0

# The password that already opens the devices.  disk-encryption-tool
# leaves the key it generated in %user:cryptenroll, which is also where
# systemd-cryptenroll and sdbootutil look for it, so normally there is
# nothing to provide.
#
# Neither this one nor the recovery PIN below is exported: an exported
# secret is in the environment of every child process from here on, and
# stays readable in /proc/PID/environ for as long as they live.  They
# are passed as a prefix to the single command that needs them, like
# the KEY, PW and PIN below
current_pw=
get_credential current_pw "sdbootutil-enroll.current-pw"

# disk-encryption-tool-dracut uses "cryptenroll" for the keyring
# parameter, if we use a different one we need to move it back to
# "cryptenroll", to do the enrollment via sdbootutil without
# requesting a password
enroll_keyid="$(keyctl id %user:cryptenroll 2> /dev/null)" || true
[ -n "$enroll_keyid$current_pw" ] || exit 0

# Proceed with the enrollment
rk=
get_credential rk "sdbootutil-enroll.rk"

pw=
get_credential pw "sdbootutil-enroll.pw"

tpm2_pin=
get_credential tpm2_pin "sdbootutil-enroll.tpm2+pin"

tpm2=
get_credential tpm2 "sdbootutil-enroll.tpm2"

fido2=
get_credential fido2 "sdbootutil-enroll.fido2"

# The recovery PIN re-authorizes the pcrlock NVIndex when the
# prediction stops matching the system.  Without one systemd-pcrlock
# generates it, and it is only shown in the issue file.  Every method
# gets it, not only the TPM2 ones: any of them can rebuild the initrd,
# and sdbootutil regenerates the predictions when that happens
recovery_pin=
get_credential recovery_pin "sdbootutil-enroll.recovery-pin"

# Without a single credential there is nothing to enroll, and the
# keyslot that disk-encryption-tool left behind would be wiped without
# anything to replace it.  Not wiping it does not make the machine
# bootable either, as that key is random and is never shown to anyone,
# but it is still the better end: the key is in the keyring while this
# boot lasts, so it can be piped out and enrolled by hand.  A device
# with no keyslot at all is lost even with the key in hand.
#
# This is not an error: the service is enabled by the image, while the
# credentials come from the deployment
[ -n "$rk$pw$tpm2_pin$tpm2$fido2" ] || {
	echo "No enrollment method requested. The device is still opened only by the key that disk-encryption-tool generated, readable from %user:cryptenroll until this boot ends" > /dev/stderr
	exit 0
}

# A method that fails must not be mistaken for one that worked: the
# keyslot that disk-encryption-tool left behind is wiped at the end of
# this script, and it is the last one that still opens the device
error=0

[ -z "$rk" ] || {
	echo "Enrolling recovery key"
	# Note that if --no-reuse-initrd is used, then a new initrd
	# will be created and will break the measurement of the
	# initial components if later the TPM2 enrollment is called
	extra=
	if [ -z "$tpm2_pin" ] && [ -z "$tpm2" ] && [ -z "$fido2" ]; then
		extra="--no-reuse-initrd"
	fi
	# Like ".pw" and ".tpm2+pin", this credential can carry the
	# secret itself.  A plain "1" (or "yes"/"true") only requests the
	# method, and then systemd generates a random recovery key
	key=
	case "$rk" in
		1|yes|true) ;;
		*) key="$rk" ;;
	esac
	# A failure leaves no key in the output, so the issue file would
	# be empty.  With more than one device it can instead hold the
	# key of the devices that were enrolled before the one that
	# failed, which is worse: it announces a recovery key for a
	# machine that cannot use it to boot
	# Nothing is printed when the key was provided, and there is no
	# point in announcing a secret that the deployment already knows
	if recovery_key="$(CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \
			   KEY="$key" sdbootutil enroll --method=recovery-key "$extra")"; then
		[ -z "$recovery_key" ] || write_issue_file "$recovery_key"
	else
		echo "Failed to enroll the recovery key" > /dev/stderr
		error=1
	fi
}

[ -z "$pw" ] || {
	echo "Enrolling password"
	# Note that if --no-reuse-initrd is used, then a new initrd
	# will be created and will break the measurement of the
	# initial components if later the TPM2 enrollment is called
	extra=
	if [ -z "$tpm2_pin" ] && [ -z "$tpm2" ] && [ -z "$fido2" ]; then
		extra="--no-reuse-initrd"
	fi
	CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \
		PW="$pw" sdbootutil enroll --method=password "$extra" || {
		echo "Failed to enroll the password" > /dev/stderr
		error=1
	}
}

if [ -n "$tpm2_pin" ]; then
	echo "Enrolling TPM2 with PIN"
	if out="$(CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \
		  SDB_ADD_INITIAL_COMPONENT=1 PIN="$tpm2_pin" sdbootutil enroll --method=tpm2+pin)"; then
		write_recovery_pin "$out"
	else
		echo "Failed to enroll the TPM2 with PIN" > /dev/stderr
		error=1
	fi
elif [ -n "$tpm2" ]; then
	echo "Enrolling TPM2"
	if out="$(CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \
		  SDB_ADD_INITIAL_COMPONENT=1 sdbootutil enroll --method=tpm2)"; then
		write_recovery_pin "$out"
	else
		echo "Failed to enroll the TPM2" > /dev/stderr
		error=1
	fi
fi

[ -z "$fido2" ] || {
	echo "Enrolling a FIDO2 key"
	CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \
		sdbootutil enroll --method=fido2 || {
		echo "Failed to enroll the FIDO2 key" > /dev/stderr
		error=1
	}
}

# Clean the enrollment key that disk-encryption-tool created
#
# This is the point of no return: that slot is what still opens the
# device, so it is removed only once there is something else that can
# open it.  Exiting non-zero also makes the failure visible, as
# sdbootutil-enroll.service is the only trace that this ran
[ "$error" -eq 0 ] || {
	echo "Keeping the enrollment key, as some enrollment failed" > /dev/stderr
	exit 1
}

# The enrollments reported success, but that is what they think they
# did.  The header is what decides, and it is read per device: a device
# that "sdbootutil enroll" never reached still lists here
while read -r dev; do
	wipe_enrollment_key "$dev"
	case "$?" in
		1) echo "Keeping the enrollment key of $dev, as no other method can open it" > /dev/stderr
		   error=1 ;;
		2) echo "Failed to remove the enrollment key of $dev" > /dev/stderr
		   error=1 ;;
	esac
done < <(sdbootutil list-devices)

exit "$error"
