From 0ce81738510c479626393beae3d67627bdeb8078 Mon Sep 17 00:00:00 2001 From: Sean Rhodes Date: Wed, 26 Aug 2026 23:43:34 +0100 Subject: [PATCH 1/2] initrd: Unlock TCG Opal disks before boot discovery Add a kernel-ioctl-backed helper and an immutable gate before every normal, basic, or network boot script. Unlock each locked TCG Opal disk as Admin1. Keep credentials out of arguments, the environment, and production Bash variables. Retain them only in anonymous file descriptors until every disk is ready so failures can relock earlier disks before recovery. Optionally compile in coreboot's OPAL S3 APMC v1 service, and fail closed if setup or rollback cannot be completed. Signed-off-by: Sean Rhodes --- Makefile | 30 ++ doc/config.md | 2 + doc/index.md | 1 + doc/opal.md | 59 +++ initrd/bin/heads-opal-unlock.sh | 268 ++++++++++++++ initrd/init | 12 + tests/opal/test_heads_opal.c | 132 +++++++ tests/opal/test_heads_opal.sh | 234 ++++++++++++ util/heads-opal.c | 638 ++++++++++++++++++++++++++++++++ 9 files changed, 1376 insertions(+) create mode 100644 doc/opal.md create mode 100755 initrd/bin/heads-opal-unlock.sh create mode 100644 tests/opal/test_heads_opal.c create mode 100755 tests/opal/test_heads_opal.sh create mode 100644 util/heads-opal.c diff --git a/Makefile b/Makefile index 49d1c9eac..f800a39e6 100644 --- a/Makefile +++ b/Makefile @@ -777,6 +777,36 @@ bin_modules-$(CONFIG_ZSTD) += zstd bin_modules-$(CONFIG_E2FSPROGS) += e2fsprogs bin_modules-$(CONFIG_EXFATPROGS) += exfatprogs +ifeq "$(CONFIG_HEADS_OPAL)" "y" +heads_opal_bin := $(build)/heads-opal/heads-opal +heads_opal_config_stamp := $(build)/heads-opal/.config-validated + +$(heads_opal_config_stamp): $(linux_kconfig) $(CONFIG) Makefile + @mkdir -p "$(dir $@)" + @if [ "$(CONFIG_HEADS_OPAL_TEST_FIXTURE)" != "y" ] && \ + ! grep -q '^CONFIG_BLK_SED_OPAL=y$$' "$<"; then \ + echo "CONFIG_HEADS_OPAL requires CONFIG_BLK_SED_OPAL=y in $<" >&2; \ + exit 1; \ + fi + @if [ "$(CONFIG_HEADS_OPAL_TEST_FIXTURE)" != "y" ] && \ + [ "$(CONFIG_HEADS_OPAL_S3_APMC_V1)" = "y" ] && \ + ! grep -q '^CONFIG_PROC_PAGE_MONITOR=y$$' "$<"; then \ + echo "CONFIG_HEADS_OPAL_S3_APMC_V1 requires CONFIG_PROC_PAGE_MONITOR=y in $<" >&2; \ + exit 1; \ + fi + @touch "$@" + +$(heads_opal_bin): util/heads-opal.c $(heads_opal_config_stamp) \ + $(INSTALL)/include/linux/limits.h \ + $(build)/$(musl-cross-make_dir)/.build + @mkdir -p "$(dir $@)" + $(heads_cc) -Os -Wall -Wextra -Werror \ + $(if $(filter y,$(CONFIG_HEADS_OPAL_S3_APMC_V1)),-DHEADS_OPAL_S3_APMC_V1) \ + -o "$@" "$<" + +$(eval $(call initrd_bin_add,$(heads_opal_bin))) +endif + $(foreach m, $(bin_modules-y), \ $(call map,initrd_bin_add,$(call bins,$m)) \ ) diff --git a/doc/config.md b/doc/config.md index 0bbace935..f47c96e1c 100644 --- a/doc/config.md +++ b/doc/config.md @@ -73,6 +73,8 @@ These are not intended to be changed in user config. | CONFIG_CRYPTSETUP2 | cryptsetup2 tools (used for LUKS) | | CONFIG_DROPBEAR | DropBear SSH server (for debug / troubleshooting) | | CONFIG_FBWHIPTAIL | fbwhiptail, framebuffer-based graphical whiptail implementation. Alternative is CONFIG_NEWT | +| CONFIG_HEADS_OPAL | Build an immutable gate for every boot mode that discovers and unlocks TCG Opal disks. Requires Linux 6.1 or later with CONFIG_BLK_SED_OPAL=y. | +| CONFIG_HEADS_OPAL_S3_APMC_V1 | Compile coreboot's OPAL S3 APMC ABI v1 into the helper. Requires compatible coreboot and CONFIG_PROC_PAGE_MONITOR=y. | :point_right: TODO: document these: diff --git a/doc/index.md b/doc/index.md index 0be106bb1..52dc7f9a7 100644 --- a/doc/index.md +++ b/doc/index.md @@ -32,6 +32,7 @@ Quick reference: read the relevant doc when working on a topic. | `gpg.md` | GPG tool operation for firmware signing and verification | | `hotp.md` | HOTP-based remote attestation of firmware state | | `keys.md` | Key management for firmware signing | +| `opal.md` | TCG Opal disk unlock and optional coreboot S3 credential handoff | | `security-model.md` | TPM measured boot, trust chain, flash write protection | | `TPM_GPIO_Reset_Approaches.md` | Eight approaches for resetting TPM via GPIO | | `TPM_GPIO_Reset_Vulnerability.md` | TPM GPIO reset vulnerability analysis | diff --git a/doc/opal.md b/doc/opal.md new file mode 100644 index 000000000..f270147a3 --- /dev/null +++ b/doc/opal.md @@ -0,0 +1,59 @@ +# TCG Opal disk unlock + +Heads can unlock TCG Opal disks before any normal, basic, or network boot +script starts. Enable the boot gate with: + +```make +export CONFIG_HEADS_OPAL=y +``` + +The board must use Linux 6.1 or later and its Linux configuration must enable +`CONFIG_BLK_SED_OPAL=y`. The build rejects a production configuration that +enables the gate without this kernel support. The `heads-opal` helper uses the +kernel's `IOC_OPAL_GET_STATUS` and `IOC_OPAL_LOCK_UNLOCK` interfaces. It +unlocks the global locking range as `Admin1`. + +The gate is keyed to the helper installed in the immutable initrd, not the +mutable runtime user configuration. Production always uses `/bin/heads-opal` +and the built-in password dialog. A board may replace those fixed paths in its +initrd overlay for a fixture, but user configuration cannot select another +backend or disable the gate. + +The prompt completes before the unlock begins. Heads opens and unlinks its +mode-0600 password file before passing the anonymous file descriptor to the C +helper, so the password is not placed in command arguments, the environment, +or a production Bash variable. The helper locks and clears its credential +buffers. If a later disk or S3 handoff fails, Heads uses the retained anonymous +descriptors to relock earlier disks before recovery. A rollback failure powers +the machine off instead of exposing an unlocked disk to a recovery shell. + +coreboot provides the optional OPAL S3 APMC service through +[change 91045](https://review.coreboot.org/c/coreboot/+/91045). Boards built +with that service can explicitly select its version 1 ABI with: + +```make +export CONFIG_HEADS_OPAL_S3_APMC_V1=y +``` + +Only enable this option when the running coreboot contains that ABI: APMC port +`0xb2`, command `0xee`, context signature `OPS3`, and context version 1. The +option makes failure to install or clear the password in coreboot SMM fatal. +The Linux configuration must additionally enable `CONFIG_PROC_PAGE_MONITOR=y`, +and the ABI limits passwords to 32 bytes. This selection is compiled into the +helper and cannot be disabled through runtime user configuration. The S3 +handoff path currently supports NVMe devices only; ordinary cold-boot unlock +uses the kernel OPAL interface for any supported block device. + +## QEMU fixture + +`qemu-coreboot-fbwhiptail-tpm2-opal` overlays the fixed helper and prompt paths +with deterministic fixtures. Its explicit `CONFIG_HEADS_OPAL_TEST_FIXTURE=y` +setting is the only exception to the production kernel-configuration check. It +exercises Heads' +scan parsing, secret transport, unlock and rollback gates, post-unlock status +check, S3-handoff request, and transition to the boot script. + +Upstream QEMU does not emulate a TCG Opal device or this SMM service. The +fixture therefore does not exercise the production OPAL ioctls, NVMe Security +Receive, physical-address lookup, or SMI handler. Those operations and S3 +resume require hardware validation on a system with an Opal SSD. diff --git a/initrd/bin/heads-opal-unlock.sh b/initrd/bin/heads-opal-unlock.sh new file mode 100755 index 000000000..c6e66a8c4 --- /dev/null +++ b/initrd/bin/heads-opal-unlock.sh @@ -0,0 +1,268 @@ +#!/bin/bash + +if [ "${1:-}" = "--test-fixture" ]; then + [ "$#" -eq 2 ] || exit 64 + opal_fixture_root="$2" + opal_functions_sh="$opal_fixture_root/functions.sh" + opal_gui_functions_sh="$opal_fixture_root/gui_functions.sh" + opal_tool="$opal_fixture_root/backend" + opal_prompt="$opal_fixture_root/prompt" + opal_password_file="$opal_fixture_root/password" + shift 2 +else + opal_functions_sh=/etc/functions.sh + opal_gui_functions_sh=/etc/gui_functions.sh + opal_tool=/bin/heads-opal + opal_prompt=/bin/heads-opal-prompt + opal_password_file=/tmp/secret/heads-opal-password +fi + +# shellcheck source=/dev/null +. "$opal_functions_sh" +# shellcheck source=/dev/null +. "$opal_gui_functions_sh" + +opal_discard_password_file() { + local output_file="$1" + + [ -e "$output_file" ] || return 0 + if ! : >"$output_file"; then + WARN "Unable to clear TCG Opal password file" + return 1 + fi + if ! rm -f "$output_file"; then + WARN "Unable to remove TCG Opal password file" + return 1 + fi +} + +opal_prompt_password() { + local device="$1" + local output_file="$2" + local rc + + umask 077 + opal_discard_password_file "$output_file" || return 1 + if [ -x "$opal_prompt" ]; then + "$opal_prompt" "$device" >"$output_file" + rc=$? + elif whiptail_type normal --title "TCG Opal Disk Unlock" \ + --passwordbox "Enter the TCG Opal password for $device" 0 80 \ + 2>"$output_file"; then + rc=0 + else + rc=$? + fi + if [ "$rc" -eq 0 ] && [ ! -s "$output_file" ]; then + rc=5 + fi + return "$rc" +} + +opal_close_password_fd() { + local password_fd="$1" + local rc=0 + + if ! : >"/proc/self/fd/$password_fd"; then + WARN "Unable to clear anonymous TCG Opal password storage" + rc=1 + fi + exec {password_fd}>&- + return "$rc" +} + +opal_relock_device() { + local tool="$1" + local device="$2" + local password_fd="$3" + local state + if ! "$tool" lock "$device" <"/proc/self/fd/$password_fd" \ + >>/tmp/heads-opal-output 2>&1; then + WARN "Unable to relock TCG Opal disk $device" + return 1 + fi + state=$("$tool" status "$device" 2>>/tmp/heads-opal-output) || return 1 + if [ "$state" != "locked" ]; then + WARN "$device remained in TCG Opal state '$state' after rollback" + return 1 + fi + return 0 +} + +heads_opal_unlock_device() { + local tool="$1" + local device="$2" + local attempts=3 + local attempt=1 + local output_file="$opal_password_file" + local password_fd + local prompt_rc + local rc + local state + + HEADS_OPAL_PASSWORD_FD= + + while [ "$attempt" -le "$attempts" ]; do + opal_prompt_password "$device" "$output_file" + prompt_rc=$? + + case "$prompt_rc" in + 0) + ;; + 5) + opal_discard_password_file "$output_file" || return 2 + whiptail_error --title "TCG Opal Unlock Failed" \ + --msgbox "The disk password cannot be empty." 0 80 + attempt=$((attempt + 1)) + continue + ;; + *) + opal_discard_password_file "$output_file" || true + WARN "TCG Opal unlock cancelled for $device" + return 1 + ;; + esac + + if ! exec {password_fd}<>"$output_file"; then + WARN "Unable to open TCG Opal password storage" + opal_discard_password_file "$output_file" || true + return 1 + fi + if ! rm -f "$output_file"; then + WARN "Unable to unlink TCG Opal password storage" + opal_close_password_fd "$password_fd" || true + return 1 + fi + "$tool" unlock "$device" <&"$password_fd" \ + >/tmp/heads-opal-output 2>&1 + rc=$? + + case "$rc" in + 0) + state=$("$tool" status "$device" 2>>/tmp/heads-opal-output) || { + WARN "Unable to confirm TCG Opal state for $device" + opal_relock_device "$tool" "$device" "$password_fd" || return 2 + opal_close_password_fd "$password_fd" || return 2 + return 1 + } + if [ "$state" != "unlocked" ]; then + WARN "$device remained in TCG Opal state '$state' after unlock" + opal_relock_device "$tool" "$device" "$password_fd" || return 2 + opal_close_password_fd "$password_fd" || return 2 + return 1 + fi + STATUS_OK "Unlocked TCG Opal disk $device" + HEADS_OPAL_PASSWORD_FD="$password_fd" + return 0 + ;; + 3) + opal_close_password_fd "$password_fd" || return 2 + whiptail_error --title "TCG Opal Unlock Failed" \ + --msgbox "The password for $device was rejected. Attempt $attempt of $attempts." 0 80 + ;; + 4) + opal_close_password_fd "$password_fd" || return 2 + WARN "TCG Opal S3 metadata or compatibility check failed for $device before unlock" + whiptail_error --title "TCG Opal Resume Protection Failed" \ + --msgbox "The disk was not unlocked because its firmware resume metadata could not be prepared." 0 80 + return 1 + ;; + 5) + if ! opal_relock_device "$tool" "$device" "$password_fd"; then + opal_close_password_fd "$password_fd" || true + return 2 + fi + opal_close_password_fd "$password_fd" || return 2 + WARN "TCG Opal S3 handoff failed for $device; the disk was relocked" + whiptail_error --title "TCG Opal Resume Protection Failed" \ + --msgbox "The firmware resume handoff failed, so the disk was relocked and boot has stopped." 0 80 + return 1 + ;; + *) + if ! opal_relock_device "$tool" "$device" "$password_fd"; then + opal_close_password_fd "$password_fd" || true + return 2 + fi + opal_close_password_fd "$password_fd" || return 2 + WARN "TCG Opal backend failed for $device (status $rc)" + return 1 + ;; + esac + attempt=$((attempt + 1)) + done + + WARN "TCG Opal password retries exhausted for $device" + return 1 +} + +heads_opal_main() { + local tool="$opal_tool" + local scan_output + local device + local state + local extra + local found_locked=n + local unlock_rc + local index + local -a unlocked_devices=() + local -a password_fds=() + + if [ ! -x "$tool" ]; then + WARN "TCG Opal backend is missing: $tool" + return 1 + fi + if ! scan_output=$("$tool" scan 2>/tmp/heads-opal-output); then + WARN "Unable to scan disks for TCG Opal state" + return 1 + fi + + while read -r device state extra; do + [ -n "$device" ] || continue + if [ -n "$extra" ]; then + WARN "Invalid TCG Opal scan result" + return 1 + fi + case "$state" in + disabled | unlocked | locked) + ;; + *) + WARN "Unknown TCG Opal state '$state' for $device" + return 1 + ;; + esac + done <<<"$scan_output" + + while read -r device state extra; do + [ -n "$device" ] || continue + case "$state" in + disabled | unlocked) + ;; + locked) + found_locked=y + heads_opal_unlock_device "$tool" "$device" + unlock_rc=$? + if [ "$unlock_rc" -ne 0 ]; then + for ((index = ${#unlocked_devices[@]} - 1; index >= 0; index--)); do + opal_relock_device "$tool" "${unlocked_devices[index]}" \ + "${password_fds[index]}" || unlock_rc=2 + opal_close_password_fd "${password_fds[index]}" || unlock_rc=2 + done + return "$unlock_rc" + fi + unlocked_devices+=("$device") + password_fds+=("$HEADS_OPAL_PASSWORD_FD") + ;; + esac + done <<<"$scan_output" + + for password_fd in "${password_fds[@]}"; do + opal_close_password_fd "$password_fd" || return 2 + done + + if [ "$found_locked" = "y" ]; then + STATUS_OK "All locked TCG Opal disks are ready" + fi + return 0 +} + +heads_opal_main "$@" diff --git a/initrd/init b/initrd/init index 74ac6cfcd..a53515167 100755 --- a/initrd/init +++ b/initrd/init @@ -256,6 +256,18 @@ else DEBUG "No board-init.sh found; skipping board-specific init" fi +if [ -x /bin/heads-opal ]; then + /bin/heads-opal-unlock.sh + opal_rc=$? + if [ "$opal_rc" -eq 2 ]; then + WARN "TCG Opal rollback failed; powering off without entering recovery" + poweroff -f + while true; do sleep 3600; done + elif [ "$opal_rc" -ne 0 ]; then + recovery "TCG Opal disk unlock failed" + fi +fi + if [ ! -x "$CONFIG_BOOTSCRIPT" -a ! -x "$CONFIG_BOOTSCRIPT_NETWORK" ]; then DEBUG "No boot script found (CONFIG_BOOTSCRIPT=$CONFIG_BOOTSCRIPT, CONFIG_BOOTSCRIPT_NETWORK=$CONFIG_BOOTSCRIPT_NETWORK); entering recovery" recovery 'Boot script missing? Entering recovery shell' diff --git a/tests/opal/test_heads_opal.c b/tests/opal/test_heads_opal.c new file mode 100644 index 000000000..d5f781c48 --- /dev/null +++ b/tests/opal/test_heads_opal.c @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-2.0-only + +#include +#include +#include +#include + +#define main heads_opal_program_main +#include "../../util/heads-opal.c" +#undef main + +static void test_unlock_request(void) +{ + const uint8_t password[] = "correct horse"; + struct opal_lock_unlock request; + + fill_lock_request(&request, password, sizeof(password) - 1, OPAL_RW); + assert(request.session.who == OPAL_ADMIN1); + assert(request.session.sum == 0); + assert(request.session.opal_key.lr == 0); + assert(request.session.opal_key.key_len == sizeof(password) - 1); + assert(memcmp(request.session.opal_key.key, password, + sizeof(password) - 1) == 0); + assert(request.l_state == OPAL_RW); + + fill_lock_request(&request, password, sizeof(password) - 1, OPAL_LK); + assert(request.l_state == OPAL_LK); +} + +static void test_status_names(void) +{ + struct opal_status status = {0}; + + assert(strcmp(status_name(&status), "disabled") == 0); + status.flags = OPAL_FL_LOCKING_SUPPORTED | OPAL_FL_LOCKING_ENABLED | + OPAL_FL_LOCKED; + assert(strcmp(status_name(&status), "locked") == 0); + status.flags &= ~OPAL_FL_LOCKED; + assert(strcmp(status_name(&status), "unlocked") == 0); +} + +static void test_error_classes(void) +{ + assert(unsupported_errno(ENOTTY)); + assert(unsupported_errno(EOPNOTSUPP)); + assert(!unsupported_errno(EINVAL)); + assert(!unsupported_errno(EIO)); + assert(unavailable_errno(ENOMEDIUM)); + assert(unavailable_errno(ENODEV)); + assert(unavailable_errno(ENXIO)); + assert(!unavailable_errno(EACCES)); +} + +static void test_discovery_parser(void) +{ + uint8_t discovery[64] = {0}; + uint16_t comid = 0; + + discovery[3] = 56; + discovery[48] = 0x02; + discovery[49] = 0x03; + discovery[51] = 4; + discovery[52] = 0x12; + discovery[53] = 0x34; + assert(parse_discovery_comid(discovery, sizeof(discovery), &comid) == 0); + assert(comid == 0x1234); + + discovery[51] = 16; + assert(parse_discovery_comid(discovery, sizeof(discovery), &comid) != 0); + discovery[51] = 4; + discovery[3] = 47; + assert(parse_discovery_comid(discovery, sizeof(discovery), &comid) != 0); +} + +static void test_pci_parser(void) +{ + struct pci_bdf bdf = {0}; + + assert(parse_pci_component("0000:5d:1f.7", &bdf) == 0); + assert(bdf.bus == 0x5d); + assert(bdf.device == 0x1f); + assert(bdf.function == 7); + assert(parse_pci_component("0001:00:01.0", &bdf) != 0); + assert(parse_pci_component("0000:00:20.0", &bdf) != 0); + assert(parse_pci_component("not-pci", &bdf) != 0); +} + +static void test_password_reader(void) +{ + uint8_t password[4] = {0}; + size_t length = 0; + int input[2]; + int saved_stdin; + + assert(pipe(input) == 0); + assert(write(input[1], "abc\n", 4) == 4); + close(input[1]); + saved_stdin = dup(STDIN_FILENO); + assert(saved_stdin >= 0); + assert(dup2(input[0], STDIN_FILENO) == STDIN_FILENO); + close(input[0]); + assert(read_password(password, 3, &length) == 0); + assert(length == 3); + assert(memcmp(password, "abc", 3) == 0); + assert(dup2(saved_stdin, STDIN_FILENO) == STDIN_FILENO); + close(saved_stdin); + + memset(password, 0, sizeof(password)); + length = 0; + assert(pipe(input) == 0); + assert(write(input[1], "abcd", 4) == 4); + close(input[1]); + saved_stdin = dup(STDIN_FILENO); + assert(saved_stdin >= 0); + assert(dup2(input[0], STDIN_FILENO) == STDIN_FILENO); + close(input[0]); + assert(read_password(password, 3, &length) == HEADS_OPAL_ERROR); + assert(dup2(saved_stdin, STDIN_FILENO) == STDIN_FILENO); + close(saved_stdin); +} + +int main(void) +{ + test_unlock_request(); + test_status_names(); + test_error_classes(); + test_discovery_parser(); + test_pci_parser(); + test_password_reader(); + puts("heads-opal C tests: PASS"); + return 0; +} diff --git a/tests/opal/test_heads_opal.sh b/tests/opal/test_heads_opal.sh new file mode 100755 index 000000000..63e62a437 --- /dev/null +++ b/tests/opal/test_heads_opal.sh @@ -0,0 +1,234 @@ +#!/bin/bash + +set -euo pipefail + +repo=$(cd "$(dirname "$0")/../.." && pwd) +test_root="$repo/.opal-test.$$" +trap 'rm -rf "$test_root"' EXIT +mkdir -p "$test_root" + +cat >"$test_root/functions.sh" <<'EOF' +STATUS_OK() { printf 'STATUS_OK: %s\n' "$*"; } +WARN() { printf 'WARN: %s\n' "$*" >&2; } +EOF + +cat >"$test_root/gui_functions.sh" <<'EOF' +whiptail_error() { printf 'WHIPTAIL_ERROR\n' >&2; } +EOF + +cat >"$test_root/prompt" <<'EOF' +#!/bin/bash +count_file="$OPAL_TEST_ROOT/prompt-count" +count=$(cat "$count_file" 2>/dev/null || echo 0) +count=$((count + 1)) +printf '%s' "$count" >"$count_file" +touch "$OPAL_TEST_ROOT/prompt-called" +if [ "${PROMPT_CANCEL:-n}" = y ]; then + exit 1 +fi +if [ "${PROMPT_EMPTY:-n}" = y ]; then + exit 5 +fi +if [ "${PROMPT_FAIL_AFTER_OUTPUT:-n}" = y ]; then + printf '%s' correct-password + exit 1 +fi +if [ "${PROMPT_ALWAYS_WRONG:-n}" = y ] || + { [ "${PROMPT_WRONG_FIRST:-n}" = y ] && [ "$count" -eq 1 ]; }; then + printf '%s' wrong-password +else + printf '%s' correct-password +fi +EOF +chmod +x "$test_root/prompt" + +cat >"$test_root/backend" <<'EOF' +#!/bin/bash +set -u +printf '%s\n' "$*" >>"$OPAL_TEST_ROOT/backend-args" +case "${1:-}" in +scan) + case "$SCENARIO" in + none) ;; + unlocked) printf '/dev/mock0 unlocked\n' ;; + locked | wrong-first | always-wrong | backend-fail | s3-pre-fail | s3-fail | relock-fail) + if [ -e "$OPAL_TEST_ROOT/unlocked" ]; then + printf '/dev/mock0 unlocked\n' + else + printf '/dev/mock0 locked\n' + fi + ;; + multiple-later-fail) + for dev in mock0 mock1; do + if [ -e "$OPAL_TEST_ROOT/$dev-unlocked" ]; then + printf '/dev/%s unlocked\n' "$dev" + else + printf '/dev/%s locked\n' "$dev" + fi + done + ;; + multiple) + for dev in mock0 mock1; do + if [ -e "$OPAL_TEST_ROOT/$dev-unlocked" ]; then + printf '/dev/%s unlocked\n' "$dev" + else + printf '/dev/%s locked\n' "$dev" + fi + done + ;; + malformed) printf '/dev/mock0 locked extra\n' ;; + scan-fail) exit 1 ;; + esac + ;; +status) + name=${2##*/} + if [ -e "$OPAL_TEST_ROOT/unlocked" ] || + [ -e "$OPAL_TEST_ROOT/$name-unlocked" ]; then + printf 'unlocked\n' + else + printf 'locked\n' + fi + ;; +unlock) + device=${2:-} + [ "$#" -eq 2 ] || exit 1 + [ "$SCENARIO" != backend-fail ] || exit 1 + [ "$SCENARIO" != relock-fail ] || exit 1 + [ "$SCENARIO" != s3-pre-fail ] || exit 4 + [ ! -e "$OPAL_TEST_ROOT/password" ] || exit 1 + if [ "$SCENARIO" = multiple-later-fail ] && [ "$device" = /dev/mock1 ]; then + exit 1 + fi + password=$(cat) + case "$(tr '\0' ' ' >"$OPAL_TEST_ROOT/unlock-devices" + if [ "$password" != correct-password ]; then + unset password + exit 3 + fi + unset password + if [ "$SCENARIO" = multiple ] || [ "$SCENARIO" = multiple-later-fail ]; then + touch "$OPAL_TEST_ROOT/${device##*/}-unlocked" + else + touch "$OPAL_TEST_ROOT/unlocked" + fi + [ "$SCENARIO" != s3-fail ] || exit 5 + ;; +lock) + device=${2:-} + [ "$#" -eq 2 ] || exit 1 + [ ! -e "$OPAL_TEST_ROOT/password" ] || exit 1 + printf '%s\n' "$device" >>"$OPAL_TEST_ROOT/lock-devices" + password=$(cat) + [ "$password" = correct-password ] || exit 3 + unset password + [ "$SCENARIO" != relock-fail ] || exit 1 + rm -f "$OPAL_TEST_ROOT/unlocked" \ + "$OPAL_TEST_ROOT/${device##*/}-unlocked" + ;; +*) exit 64 ;; +esac +EOF +chmod +x "$test_root/backend" + +run_case() { + local scenario="$1" + rm -f "$test_root"/{backend-args,prompt-count,prompt-called,unlocked,unlock-devices,lock-devices,mock0-unlocked,mock1-unlocked} + OPAL_TEST_ROOT="$test_root" \ + SCENARIO="$scenario" \ + PROMPT_WRONG_FIRST="${PROMPT_WRONG_FIRST:-n}" \ + PROMPT_ALWAYS_WRONG="${PROMPT_ALWAYS_WRONG:-n}" \ + PROMPT_CANCEL="${PROMPT_CANCEL:-n}" \ + PROMPT_EMPTY="${PROMPT_EMPTY:-n}" \ + PROMPT_FAIL_AFTER_OUTPUT="${PROMPT_FAIL_AFTER_OUTPUT:-n}" \ + "$repo/initrd/bin/heads-opal-unlock.sh" --test-fixture "$test_root" +} + +run_case none +[ ! -e "$test_root/prompt-called" ] + +run_case unlocked +[ ! -e "$test_root/prompt-called" ] + +run_case locked +[ "$(cat "$test_root/prompt-count")" = 1 ] +grep -qx 'unlock /dev/mock0' "$test_root/backend-args" + +PROMPT_WRONG_FIRST=y run_case wrong-first +[ "$(cat "$test_root/prompt-count")" = 2 ] + +if PROMPT_ALWAYS_WRONG=y run_case always-wrong; then + exit 1 +fi +[ "$(cat "$test_root/prompt-count")" = 3 ] + +if PROMPT_CANCEL=y run_case locked; then + exit 1 +fi +[ "$(cat "$test_root/prompt-count")" = 1 ] + +if PROMPT_EMPTY=y run_case locked; then + exit 1 +fi +[ "$(cat "$test_root/prompt-count")" = 3 ] +[ ! -e "$test_root/password" ] + +if PROMPT_FAIL_AFTER_OUTPUT=y run_case locked; then + exit 1 +fi +[ ! -e "$test_root/unlock-devices" ] +[ ! -e "$test_root/password" ] + +if run_case backend-fail; then + exit 1 +fi +[ "$(cat "$test_root/prompt-count")" = 1 ] + +set +e +run_case relock-fail +[ "$?" -eq 2 ] || exit 1 +set -e + +if run_case s3-pre-fail; then + exit 1 +fi +[ ! -e "$test_root/unlocked" ] +if run_case s3-fail; then + exit 1 +fi +grep -qx '/dev/mock0' "$test_root/lock-devices" +[ ! -e "$test_root/unlocked" ] +if run_case scan-fail; then + exit 1 +fi +if run_case malformed; then + exit 1 +fi + +run_case multiple +[ "$(wc -l <"$test_root/unlock-devices")" -eq 2 ] + +if run_case multiple-later-fail; then + exit 1 +fi +grep -qx '/dev/mock0' "$test_root/lock-devices" +[ ! -e "$test_root/mock0-unlocked" ] + +opal_line=$(grep -n '/bin/heads-opal-unlock.sh' "$repo/initrd/init" | cut -d: -f1) +bootscript_line=$(grep -n '^if \[ ! -x ' "$repo/initrd/init" | cut -d: -f1) +[ "$opal_line" -lt "$bootscript_line" ] +grep -q '^if \[ -x /bin/heads-opal \]' "$repo/initrd/init" +grep -q 'heads-opal-unlock.sh' "$repo/initrd/bin/gui-init.sh" && exit 1 + +tmpdir="$test_root/compiler-tmp" +mkdir -p "$tmpdir" +TMPDIR="$tmpdir" gcc -std=gnu11 -O2 -Wall -Wextra -Werror \ + -o "$test_root/test-heads-opal" "$repo/tests/opal/test_heads_opal.c" +"$test_root/test-heads-opal" + +printf 'heads-opal shell tests: PASS\n' diff --git a/util/heads-opal.c b/util/heads-opal.c new file mode 100644 index 000000000..749c0b84a --- /dev/null +++ b/util/heads-opal.c @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: GPL-2.0-only + +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__x86_64__) +#include +#endif + +#define ARRAY_SIZE(array) (sizeof(array) / sizeof((array)[0])) + +#define OPAL_DISCOVERY_BUFFER_SIZE 2048 +#define OPAL_DISCOVERY_HEADER_SIZE 48 +#define OPAL_DISCOVERY_COMID 0x0001 +#define OPAL_SECURITY_PROTOCOL 0x01 +#define OPAL_FEATURE_V100 0x0200 +#define OPAL_FEATURE_V200 0x0203 +#define OPAL_METHOD_NOT_AUTHORIZED 0x01 + +#define NVME_ADMIN_SECURITY_RECEIVE 0x82 + +#define OPAL_S3_APM_PORT 0xb2 +#define OPAL_S3_APM_COMMAND 0xee +#define OPAL_S3_SUBCOMMAND_SET_SECRET 0x01 +#define OPAL_S3_SUBCOMMAND_CLEAR_SECRET 0x02 +#define OPAL_S3_CONTEXT_SIGNATURE 0x3353504fU +#define OPAL_S3_CONTEXT_VERSION 0x0001 +#define OPAL_S3_PASSWORD_MAX 32 +#define OPAL_S3_PAGE_ATTEMPTS 256 + +#if defined(HEADS_OPAL_S3_APMC_V1) +#define OPAL_S3_ENABLED true +#else +#define OPAL_S3_ENABLED false +#endif + +enum { + HEADS_OPAL_ERROR = 1, + HEADS_OPAL_UNSUPPORTED = 2, + HEADS_OPAL_AUTH_FAILED = 3, + HEADS_OPAL_S3_PRE_UNLOCK_FAILED = 4, + HEADS_OPAL_S3_POST_UNLOCK_FAILED = 5, + HEADS_OPAL_USAGE = 64, +}; + +struct opal_s3_context { + uint32_t signature; + uint16_t version; + uint16_t size; + uint8_t bus; + uint8_t device; + uint8_t function; + uint8_t reserved0; + uint16_t base_comid; + uint16_t reserved1; + uint8_t password_length; + uint8_t reserved2[3]; + uint8_t password[OPAL_S3_PASSWORD_MAX]; +} __attribute__((packed)); + +_Static_assert(sizeof(struct opal_s3_context) == 52, + "coreboot OPAL S3 context ABI changed"); + +struct pci_bdf { + uint8_t bus; + uint8_t device; + uint8_t function; +}; + +static void secure_clear(void *buffer, size_t length) +{ + volatile uint8_t *cursor = buffer; + + while (length-- > 0) + *cursor++ = 0; +} + +static uint16_t read_be16(const uint8_t *buffer) +{ + return ((uint16_t)buffer[0] << 8) | buffer[1]; +} + +static uint32_t read_be32(const uint8_t *buffer) +{ + return ((uint32_t)buffer[0] << 24) | + ((uint32_t)buffer[1] << 16) | + ((uint32_t)buffer[2] << 8) | + buffer[3]; +} + +static bool unsupported_errno(int error) +{ + return error == ENOTTY || error == EOPNOTSUPP; +} + +static bool unavailable_errno(int error) +{ + return error == ENOMEDIUM || error == ENODEV || error == ENXIO; +} + +static int get_opal_status(int fd, struct opal_status *status) +{ + memset(status, 0, sizeof(*status)); + if (ioctl(fd, IOC_OPAL_GET_STATUS, status) == 0) + return 0; + if (unsupported_errno(errno)) + return HEADS_OPAL_UNSUPPORTED; + return HEADS_OPAL_ERROR; +} + +static const char *status_name(const struct opal_status *status) +{ + if (!(status->flags & OPAL_FL_LOCKING_SUPPORTED) || + !(status->flags & OPAL_FL_LOCKING_ENABLED)) + return "disabled"; + if (status->flags & OPAL_FL_LOCKED) + return "locked"; + return "unlocked"; +} + +static int status_device(const char *path, bool print_path) +{ + struct opal_status status; + int fd; + int rc; + + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + if (print_path && unavailable_errno(errno)) + return HEADS_OPAL_UNSUPPORTED; + fprintf(stderr, "%s: open failed: %s\n", path, strerror(errno)); + return HEADS_OPAL_ERROR; + } + + rc = get_opal_status(fd, &status); + close(fd); + if (rc != 0) { + if (rc != HEADS_OPAL_UNSUPPORTED) + fprintf(stderr, "%s: OPAL status failed: %s\n", path, + strerror(errno)); + return rc; + } + + if (print_path) + printf("%s %s\n", path, status_name(&status)); + else + printf("%s\n", status_name(&status)); + return 0; +} + +static int scan_devices(void) +{ + glob_t blocks = {0}; + size_t index; + int glob_rc; + int rc = 0; + + glob_rc = glob("/sys/class/block/*", 0, NULL, &blocks); + if (glob_rc == GLOB_NOMATCH) + return 0; + if (glob_rc != 0) + return HEADS_OPAL_ERROR; + + for (index = 0; index < blocks.gl_pathc; index++) { + const char *name = strrchr(blocks.gl_pathv[index], '/'); + char partition_path[512]; + char device_path[512]; + char resolved[4096]; + int one_rc; + + if (!name || name[1] == '\0') + continue; + if (realpath(blocks.gl_pathv[index], resolved) && + strstr(resolved, "/virtual/block/")) + continue; + name++; + if (snprintf(partition_path, sizeof(partition_path), "%s/partition", + blocks.gl_pathv[index]) >= (int)sizeof(partition_path)) { + rc = HEADS_OPAL_ERROR; + break; + } + if (access(partition_path, F_OK) == 0) + continue; + if (snprintf(device_path, sizeof(device_path), "/dev/%s", name) >= + (int)sizeof(device_path)) { + rc = HEADS_OPAL_ERROR; + break; + } + + one_rc = status_device(device_path, true); + if (one_rc == HEADS_OPAL_UNSUPPORTED) + continue; + if (one_rc != 0) { + rc = one_rc; + break; + } + } + + globfree(&blocks); + return rc; +} + +static int read_password(uint8_t *password, size_t capacity, size_t *length) +{ + size_t used = 0; + bool overflow = false; + uint8_t byte; + ssize_t got; + + while ((got = read(STDIN_FILENO, &byte, 1)) == 1) { + if (byte == '\n') + break; + if (used >= capacity) { + overflow = true; + break; + } + password[used++] = byte; + } + if (got < 0) { + fprintf(stderr, "reading password failed: %s\n", strerror(errno)); + return HEADS_OPAL_ERROR; + } + if (overflow) { + fprintf(stderr, "password exceeds %zu bytes\n", capacity); + return HEADS_OPAL_ERROR; + } + if (used == 0) { + fprintf(stderr, "empty passwords are not accepted\n"); + return HEADS_OPAL_ERROR; + } + + *length = used; + return 0; +} + +static void fill_lock_request(struct opal_lock_unlock *request, + const uint8_t *password, size_t password_length, + enum opal_lock_state lock_state) +{ + memset(request, 0, sizeof(*request)); + request->session.who = OPAL_ADMIN1; + request->session.opal_key.lr = 0; + request->session.opal_key.key_len = (uint8_t)password_length; + memcpy(request->session.opal_key.key, password, password_length); + request->l_state = lock_state; +} + +static int parse_pci_component(const char *component, struct pci_bdf *bdf) +{ + unsigned int domain; + unsigned int bus; + unsigned int device; + unsigned int function; + int consumed = 0; + + if (sscanf(component, "%x:%x:%x.%x%n", &domain, &bus, &device, + &function, &consumed) != 4 || component[consumed] != '\0') + return -1; + if (domain != 0 || bus > UINT8_MAX || device > 0x1f || function > 7) + return -1; + + bdf->bus = (uint8_t)bus; + bdf->device = (uint8_t)device; + bdf->function = (uint8_t)function; + return 0; +} + +static int device_pci_bdf(int fd, struct pci_bdf *bdf) +{ + struct stat stat_buffer; + char link_path[128]; + char resolved[4096]; + char path_copy[4096]; + char *component; + char *saveptr = NULL; + int found = -1; + + if (fstat(fd, &stat_buffer) != 0 || !S_ISBLK(stat_buffer.st_mode)) + return -1; + if (snprintf(link_path, sizeof(link_path), "/sys/dev/block/%u:%u/device", + major(stat_buffer.st_rdev), minor(stat_buffer.st_rdev)) >= + (int)sizeof(link_path)) + return -1; + if (!realpath(link_path, resolved)) + return -1; + + memcpy(path_copy, resolved, strlen(resolved) + 1); + for (component = strtok_r(path_copy, "/", &saveptr); component; + component = strtok_r(NULL, "/", &saveptr)) { + struct pci_bdf candidate; + + if (parse_pci_component(component, &candidate) == 0) { + *bdf = candidate; + found = 0; + } + } + return found; +} + +static int parse_discovery_comid(const uint8_t *buffer, size_t buffer_length, + uint16_t *base_comid) +{ + size_t cursor = OPAL_DISCOVERY_HEADER_SIZE; + uint32_t discovery_length; + bool found = false; + + if (buffer_length < OPAL_DISCOVERY_HEADER_SIZE) + return -1; + discovery_length = read_be32(buffer); + if (discovery_length < OPAL_DISCOVERY_HEADER_SIZE || + discovery_length > buffer_length) + return -1; + + while (cursor < discovery_length) { + uint16_t code; + uint8_t length; + + if (discovery_length - cursor < 4) + return -1; + code = read_be16(buffer + cursor); + length = buffer[cursor + 3]; + cursor += 4; + if (length > discovery_length - cursor) + return -1; + + if ((code == OPAL_FEATURE_V100 || code == OPAL_FEATURE_V200) && + length >= 4) { + *base_comid = read_be16(buffer + cursor); + found = true; + } + cursor += length; + } + + return found ? 0 : -1; +} + +static int nvme_discovery_comid(int fd, uint16_t *base_comid) +{ + uint8_t buffer[OPAL_DISCOVERY_BUFFER_SIZE] = {0}; + struct nvme_admin_cmd command = {0}; + + command.opcode = NVME_ADMIN_SECURITY_RECEIVE; + command.nsid = 0; + command.addr = (uintptr_t)buffer; + command.data_len = sizeof(buffer); + command.cdw10 = ((uint32_t)OPAL_SECURITY_PROTOCOL << 24) | + ((uint32_t)OPAL_DISCOVERY_COMID << 8); + command.cdw11 = sizeof(buffer); + + if (ioctl(fd, NVME_IOCTL_ADMIN_CMD, &command) != 0) + return -1; + return parse_discovery_comid(buffer, sizeof(buffer), base_comid); +} + +static int virtual_to_physical(const void *address, uint64_t *physical) +{ + uint64_t entry; + long page_size = sysconf(_SC_PAGESIZE); + uintptr_t virtual_address = (uintptr_t)address; + off_t offset; + int fd; + + if (page_size <= 0) + return -1; + offset = (off_t)((virtual_address / (uintptr_t)page_size) * sizeof(entry)); + fd = open("/proc/self/pagemap", O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + if (pread(fd, &entry, sizeof(entry), offset) != (ssize_t)sizeof(entry)) { + close(fd); + return -1; + } + close(fd); + + if (!(entry & (UINT64_C(1) << 63))) + return -1; + entry &= (UINT64_C(1) << 55) - 1; + if (entry == 0) + return -1; + *physical = entry * (uint64_t)page_size + + virtual_address % (uintptr_t)page_size; + return 0; +} + +static void *allocate_smi_page(uint64_t *physical, size_t *page_size_out) +{ + void *pages[OPAL_S3_PAGE_ATTEMPTS] = {0}; + long page_size = sysconf(_SC_PAGESIZE); + void *selected = NULL; + size_t index; + + if (page_size <= 0) + return NULL; + for (index = 0; index < ARRAY_SIZE(pages); index++) { + pages[index] = mmap(NULL, (size_t)page_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (pages[index] == MAP_FAILED) { + pages[index] = NULL; + break; + } + memset(pages[index], 0, (size_t)page_size); + if (virtual_to_physical(pages[index], physical) == 0 && + *physical <= UINT32_MAX && + mlock(pages[index], (size_t)page_size) == 0 && + virtual_to_physical(pages[index], physical) == 0 && + *physical <= UINT32_MAX) { + selected = pages[index]; + break; + } + } + + for (index = 0; index < ARRAY_SIZE(pages); index++) { + if (!pages[index] || pages[index] == selected) + continue; + munmap(pages[index], (size_t)page_size); + } + if (selected) + *page_size_out = (size_t)page_size; + return selected; +} + +#if defined(__x86_64__) +static unsigned long trigger_smi(unsigned long command, unsigned long argument) +{ + unsigned long result = command; + + __asm__ volatile("outb %%al, $0xb2" + : "+a"(result) + : "b"(argument) + : "memory"); + return result; +} +#endif + +static int call_s3_service(uint8_t subcommand, unsigned long argument) +{ +#if defined(__x86_64__) + unsigned long command; + unsigned long result; + + if (ioperm(OPAL_S3_APM_PORT, 1, 1) != 0) { + fprintf(stderr, "enabling APMC access failed: %s\n", strerror(errno)); + return -1; + } + + command = ((unsigned long)subcommand << 8) | OPAL_S3_APM_COMMAND; + result = trigger_smi(command, argument); + ioperm(OPAL_S3_APM_PORT, 1, 0); + if (result == command) { + fprintf(stderr, "coreboot did not handle the OPAL S3 request\n"); + return -1; + } + if (result != 0) { + fprintf(stderr, "coreboot rejected the OPAL S3 request: 0x%lx\n", + result); + return -1; + } + return 0; +#else + (void)subcommand; + (void)argument; + fprintf(stderr, "OPAL S3 handoff is only supported on x86_64\n"); + return -1; +#endif +} + +static int clear_s3_secrets(void) +{ + return call_s3_service(OPAL_S3_SUBCOMMAND_CLEAR_SECRET, 0); +} + +static int handoff_s3_secret(const struct pci_bdf *bdf, uint16_t base_comid, + const uint8_t *password, size_t password_length) +{ +#if defined(__x86_64__) + struct opal_s3_context *context; + uint64_t physical = 0; + size_t page_size = 0; + int rc; + + context = allocate_smi_page(&physical, &page_size); + if (!context) { + fprintf(stderr, "could not allocate a low physical page for OPAL S3 handoff\n"); + return -1; + } + + context->signature = OPAL_S3_CONTEXT_SIGNATURE; + context->version = OPAL_S3_CONTEXT_VERSION; + context->size = sizeof(*context); + context->bus = bdf->bus; + context->device = bdf->device; + context->function = bdf->function; + context->base_comid = base_comid; + context->password_length = (uint8_t)password_length; + memcpy(context->password, password, password_length); + + rc = call_s3_service(OPAL_S3_SUBCOMMAND_SET_SECRET, + (unsigned long)physical); + + secure_clear(context, sizeof(*context)); + munlock(context, page_size); + munmap(context, page_size); + return rc; +#else + (void)bdf; + (void)base_comid; + (void)password; + (void)password_length; + fprintf(stderr, "OPAL S3 handoff is only supported on x86_64\n"); + return -1; +#endif +} + +static int change_lock_state(const char *path, bool unlock, bool s3_handoff) +{ + struct opal_lock_unlock request; + struct pci_bdf bdf = {0}; + uint8_t password[OPAL_KEY_MAX] = {0}; + uint16_t base_comid = 0; + size_t password_length = 0; + int fd = -1; + int ioctl_rc; + int rc; + bool s3_clear_failed = false; + + if (mlock(password, sizeof(password)) != 0) { + fprintf(stderr, "locking password memory failed: %s\n", strerror(errno)); + return HEADS_OPAL_ERROR; + } + fd = open(path, O_RDWR | O_CLOEXEC); + if (fd < 0) { + fprintf(stderr, "%s: open failed: %s\n", path, strerror(errno)); + rc = HEADS_OPAL_ERROR; + goto out; + } + + if (unlock && s3_handoff) { + if (device_pci_bdf(fd, &bdf) != 0 || + nvme_discovery_comid(fd, &base_comid) != 0) { + fprintf(stderr, "%s: could not obtain NVMe OPAL S3 metadata\n", path); + rc = HEADS_OPAL_S3_PRE_UNLOCK_FAILED; + goto out; + } + } + + rc = read_password(password, OPAL_KEY_MAX - 1, &password_length); + if (rc != 0) + goto out; + if (unlock && s3_handoff && password_length > OPAL_S3_PASSWORD_MAX) { + fprintf(stderr, "coreboot OPAL S3 handoff accepts at most %u bytes\n", + OPAL_S3_PASSWORD_MAX); + rc = HEADS_OPAL_S3_PRE_UNLOCK_FAILED; + goto out; + } + + if (!unlock && s3_handoff && clear_s3_secrets() != 0) + s3_clear_failed = true; + + fill_lock_request(&request, password, password_length, + unlock ? OPAL_RW : OPAL_LK); + ioctl_rc = ioctl(fd, IOC_OPAL_LOCK_UNLOCK, &request); + if (ioctl_rc != 0) { + if (ioctl_rc == OPAL_METHOD_NOT_AUTHORIZED) { + fprintf(stderr, "%s: OPAL password was not authorized\n", path); + rc = HEADS_OPAL_AUTH_FAILED; + } else if (ioctl_rc < 0) { + fprintf(stderr, "%s: OPAL %s failed: %s\n", path, + unlock ? "unlock" : "lock", strerror(errno)); + rc = HEADS_OPAL_ERROR; + } else { + fprintf(stderr, "%s: OPAL %s failed with method status 0x%x\n", + path, unlock ? "unlock" : "lock", ioctl_rc); + rc = HEADS_OPAL_ERROR; + } + goto out_request; + } + if (unlock && s3_handoff && + handoff_s3_secret(&bdf, base_comid, password, password_length) != 0) { + fill_lock_request(&request, password, password_length, OPAL_LK); + if (ioctl(fd, IOC_OPAL_LOCK_UNLOCK, &request) != 0) + fprintf(stderr, "%s: rollback lock failed after S3 handoff error\n", + path); + if (clear_s3_secrets() != 0) + fprintf(stderr, "clearing coreboot OPAL S3 secrets failed\n"); + rc = HEADS_OPAL_S3_POST_UNLOCK_FAILED; + goto out_request; + } + rc = s3_clear_failed ? HEADS_OPAL_ERROR : 0; + +out_request: + secure_clear(&request, sizeof(request)); +out: + secure_clear(password, sizeof(password)); + munlock(password, sizeof(password)); + if (fd >= 0) + close(fd); + return rc; +} + +static void usage(const char *program) +{ + fprintf(stderr, + "usage: %s scan\n" + " %s status DEVICE\n" + " %s unlock DEVICE\n" + " %s lock DEVICE\n", + program, program, program, program); +} + +int main(int argc, char **argv) +{ + if (argc == 2 && strcmp(argv[1], "scan") == 0) + return scan_devices(); + if (argc == 3 && strcmp(argv[1], "status") == 0) + return status_device(argv[2], false); + if (argc == 3 && strcmp(argv[1], "unlock") == 0) + return change_lock_state(argv[2], true, OPAL_S3_ENABLED); + if (argc == 3 && strcmp(argv[1], "lock") == 0) + return change_lock_state(argv[2], false, OPAL_S3_ENABLED); + + usage(argv[0]); + return HEADS_OPAL_USAGE; +} From 80482307da08bde3124e43519af6297631cd394b Mon Sep 17 00:00:00 2001 From: Sean Rhodes Date: Wed, 26 Aug 2026 23:43:41 +0100 Subject: [PATCH 2/2] boards: Add a QEMU fixture for TCG Opal unlock Overlay deterministic prompt and disk backends for the pre-boot unlock gate, retries, failure rollback, S3 handoff requests, post-unlock verification, and secret transport without requiring an emulated Opal device. Run the host fixture and build the QEMU board in CI. QEMU covers the Heads control flow; production OPAL ioctls, NVMe security commands, SMI handling, and S3 resume remain hardware-only paths. Signed-off-by: Sean Rhodes --- .circleci/config.yml | 18 +++++ .../initrd/bin/heads-opal | 66 +++++++++++++++++++ .../initrd/bin/heads-opal-prompt | 3 + .../qemu-coreboot-fbwhiptail-tpm2-opal.config | 8 +++ 4 files changed, 95 insertions(+) create mode 100755 boards/qemu-coreboot-fbwhiptail-tpm2-opal/initrd/bin/heads-opal create mode 100755 boards/qemu-coreboot-fbwhiptail-tpm2-opal/initrd/bin/heads-opal-prompt create mode 100644 boards/qemu-coreboot-fbwhiptail-tpm2-opal/qemu-coreboot-fbwhiptail-tpm2-opal.config diff --git a/.circleci/config.yml b/.circleci/config.yml index 191855454..f34b2665a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -65,6 +65,14 @@ commands: jobs: + opal_tests: + executor: heads-docker + steps: + - checkout + - run: + name: TCG Opal functional fixture + command: ./tests/opal/test_heads_opal.sh + # ═══════════════════════════════════════════════════════════════════════════ # Glossary (see doc/circleci.md for full cache model) # ═══════════════════════════════════════════════════════════════════════════ @@ -418,6 +426,9 @@ workflows: - create_hashes: name: create_hashes [cache keys] + - opal_tests: + name: TCG Opal functional fixture + # ── x86 blobs ─────────────────────────────────────────────────────── # Chain: create_hashes -> x86_blobs -> x86_musl_cross_make -> x86_coreboot - x86_blobs: @@ -738,6 +749,13 @@ workflows: requires: - EOL_t480-hotp-maximized [seed:coreboot-25.09] + - build: + name: qemu-coreboot-fbwhiptail-tpm2-opal + target: qemu-coreboot-fbwhiptail-tpm2-opal + subcommand: "" + requires: + - EOL_t480-hotp-maximized [seed:coreboot-25.09] + # ── purism boards (alphabetical) ────────────────────────────────────── - build: name: EOL_librem_13v2 diff --git a/boards/qemu-coreboot-fbwhiptail-tpm2-opal/initrd/bin/heads-opal b/boards/qemu-coreboot-fbwhiptail-tpm2-opal/initrd/bin/heads-opal new file mode 100755 index 000000000..d72ef8078 --- /dev/null +++ b/boards/qemu-coreboot-fbwhiptail-tpm2-opal/initrd/bin/heads-opal @@ -0,0 +1,66 @@ +#!/bin/bash + +state_file=/tmp/heads-opal-qemu-unlocked +device=/dev/nvme0n1 + +log_serial() { + printf '%s\n' "$*" >/dev/ttyS0 2>/dev/null || true +} + +case "${1:-}" in +scan) + log_serial "HEADS_OPAL_QEMU: mock scan completed" + if [ -e "$state_file" ]; then + printf '%s unlocked\n' "$device" + else + printf '%s locked\n' "$device" + fi + ;; +status) + [ "${2:-}" = "$device" ] || exit 1 + if [ -e "$state_file" ]; then + printf '%s\n' unlocked + log_serial "HEADS_OPAL_QEMU: post-unlock state confirmed" + else + printf '%s\n' locked + fi + ;; +unlock) + [ "${2:-}" = "$device" ] || exit 1 + [ "$#" -eq 2 ] || exit 1 + if [ -e /tmp/secret/heads-opal-password ]; then + log_serial "HEADS_OPAL_QEMU: password file remained linked during unlock" + exit 1 + fi + password=$(cat) + case "$(tr '\0' ' '