From 9a492fb26d7588b16fbce954dbed5eed9a74352d Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 19 May 2026 16:48:48 -0400 Subject: [PATCH] initrd: fix TPM1 counter auth regression and add DA lockout handling - tpm1_counter_increment: empty -pwdc '' bypasses _tpm_auth_retry and calls tpm directly per TCG spec. Non-empty passphrase or no -pwdc falls through to owner-auth retry path (migration for counters created by pre-fix code). - tpm1_bad_auth: emit STATUS when lockout detected or counter ID missing, instead of silent DEBUG-only return. STATUS deduplicated with marker file for loop safety. On lockout, queries da_state and shows remaining duration when available. - tpm1_da_state: convert raw hex firmware version (0x0D.0x0C) to human-readable decimal (13.12) in all output and log messages. - tpm1_da_state: when state=1 but timer is 0 or empty, print "duration unknown on this TPM" instead of misleading "0s remaining". - tpm1_da_state: declare rev_major_dec/rev_minor_dec as local variables and normalize variable names to match tpm2_da_state conventions. - tpm2_da_state: declare interval_hex, recovery_hex, interval, recovery as local variables (previously leaked into global scope). - da_state output in bad_auth: extract only the => summary line for STATUS on console and the DA: machine line for DEBUG in log. Avoids duplicate re-emission of diagnostic lines already logged internally. - tpm1_unseal: detect DA lockout from unseal failure output ("defend lock"), emit WARN, query da_state for remaining time, set marker file so callers show lockout-specific guidance instead of generic reseal. - gui-init.sh update_totp: when marker present, show DA-lockout-specific whiptail with TCG exponential backoff guidance instead of generic OEM reset message. Shows exact remaining time when da_state provides it. - unseal-totp.sh: check DA lockout marker and show lockout-specific fail_unseal message. - recovery shell: show concise DA state summary at STATUS (not INFO) level so it reaches console in Quiet mode. Signed-off-by: Thierry Laurion --- doc/tpm.md | 130 ++++++++- initrd/bin/gui-init.sh | 37 +++ initrd/bin/oem-factory-reset.sh | 2 +- initrd/bin/tpm-reset.sh | 9 + initrd/bin/tpmr.sh | 457 ++++++++++++++++++++++++++++++-- initrd/bin/unseal-totp.sh | 6 + initrd/etc/functions.sh | 133 ++++++++-- 7 files changed, 727 insertions(+), 47 deletions(-) diff --git a/doc/tpm.md b/doc/tpm.md index 90f7ec064..5421b0f6e 100644 --- a/doc/tpm.md +++ b/doc/tpm.md @@ -10,8 +10,35 @@ See also: [architecture.md](architecture.md), [boot-process.md](boot-process.md) ## tpmr — unified TPM abstraction `initrd/bin/tpmr.sh` is a shell script wrapper that presents a single interface -over both TPM 1.2 (`tpm` / `trousers`) and TPM 2.0 (`tpm2-tools`). All Heads -scripts call `tpmr.sh` rather than invoking `tpm` or `tpm2` directly. +over both TPM 1.2 and TPM 2.0. All Heads scripts call `tpmr.sh` rather than +invoking TPM tools directly. + +### Boot chain and TPM tool selection + +```text +initrd/init (PID 1) + └─ CONFIG_BOOTSCRIPT → /bin/gui-init.sh [board config] + ├─ source /etc/functions.sh [shared TPM helpers] + ├─ source /etc/gui_functions.sh [whiptail wrappers] + └─ calls initrd/bin/tpmr.sh [TPM abstraction] + ├─ TPM1: calls `tpm` (tpmtotp util/tpm) [CONFIG_TPM2_TOOLS != y] + │ modules/tpmtotp → output: totp hotp qrenc util/tpm + │ + └─ TPM2: calls `tpm2` (single binary, subcommands) [CONFIG_TPM2_TOOLS=y] + modules/tpm2-tss + modules/tpm2-tools +``` + +TPM1 support comes exclusively from the `tpmtotp` module (`modules/tpmtotp`), +which builds `util/tpm` as part of its outputs. This binary is installed to +the initrd as `tpm` and supports subcommands such as `physicalpresence`, +`forceclear`, `takeown -pwdo`, `counter_create`, `counter_increment`, etc. + +TPM2 support comes from `modules/tpm2-tss` (TSS software stack) and +`modules/tpm2-tools` (`tpm2` binary with subcommands like `getcap`, +`nvdefine`, `nvincrement`). + +Both TPM1 and TPM2 boards may also enable `CONFIG_TPMTOTP=y` for the +`totp` and `hotp` utilities, which are independent of the TPM version. ### PCR sizes @@ -271,9 +298,11 @@ The rollback counter prevents **TPM swap attacks** and **/boot disk swap attacks ### How it works -The counter is stored **in the TPM** (NVRAM index `0x3135106223`), ensuring -hardware binding. A SHA-256 hash of the counter value is stored on **/boot** -(`/boot/kexec_rollback.txt`). This creates a two-way binding: +The counter value is stored **in the TPM** at a persistent NVRAM index +(stored in `/boot/kexec_rollback.txt`; the index is a well-known value +for TPM1 and randomly generated per TPM2 at provisioning time). A SHA-256 +hash of the counter value is stored on **/boot** (`/boot/kexec_rollback.txt`). +This creates a two-way binding: - Cannot swap TPM without breaking /boot consistency - Cannot swap /boot without breaking TPM consistency @@ -398,3 +427,94 @@ To verify that a new board's coreboot config matches the expected RoT: | Auth sessions | Not used | Required for policy-based unseal | | `kexec_finalize` | No-op | Extends PCRs, then `tpm2 shutdown` | | `startsession` | No-op | Creates encryption session | + +### TPM1 auth retry and error detection + +`_tpm_auth_retry()` in `initrd/bin/tpmr.sh` provides shared retry logic for +both TPM1 and TPM2 operations that need authorization. On auth failure +(wrong passphrase), the passphrase cache is shredded and the user is +re-prompted up to 3 times before giving up. + +Auth failure is detected by grepping the command output for known error +patterns. TPM1 (tpmtotp) errors go to stdout via `printf()` with +`TPM_GetErrMsg()` strings. TPM2 (tpm2-tools) errors go to stderr via +`LOG_ERR()` and may include raw TPM response codes. + +| Pattern | Type | TPM version | Example error | +| --- | --- | --- | --- | +| `authorization|auth|bad|permission` | English words | TPM1+TPM2 | `TPM_AUTHFAIL`, `bad passphrase` | +| `defend` | English word | TPM1 | `Defend lock running` | +| `0x98e|0x149` | Hex codes | TPM2 | `TPM2_RC_AUTH_FAIL`, `TPM2_RC_NV_AUTHORIZATION` | + +### TPM1 reset defend lock + +`TPM_DEFEND_LOCK_RUNNING` (`tpm_error.h`: `TPM_BASE + TPM_NON_FATAL + 3`) +is a standard TPM 1.2 error raised when the TPM's dictionary-attack +protection is active. After too many failed authorization attempts, the +TPM enters a time-out period and refuses all authorization operations -- +including `tpm takeown` even after a successful `tpm forceclear` +(forceclear clears the owner but not the dictionary attack counter on +some implementations, particularly Infineon TPMs). + +tpmtotp's `tpm takeown` outputs: +``` +Error Defend lock running from TPM_TakeOwnership +``` + +`tpm1_reset()` in `initrd/bin/tpmr.sh` detects "defend lock" in the +`takeown` output and attempts one recovery: cycling physical presence +(`physicaldisable` / `physicalenable` / `physicalpresence` / +`physicalsetdeactivated`) to re-assert PP before retrying `takeown`. +This works on some chipsets where software presence was not properly +honoured by the first `forceclear`. + +If PP cycling also fails, no software-based recovery is available. +Further attempts (second forceclear, `TPM_ResetLockValue` with empty +auth, sleep+retry) will not help. Reset the TPM via `tpm-reset.sh` from +the recovery shell to clear the DA state. + +### TPM1 physical presence + +TPM1.2 forceclear requires physical presence to be asserted. The +`tpm1_reset()` function does this with `tpm physicalpresence -s` (software +presence). On some platforms (e.g., Dell OptiPlex, some Infineon TPMs), +software physical presence may not work — the TPM firmware only accepts +hardware-asserted presence (GPIO set by BIOS). In that case, `forceclear` +returns success but may not fully reset the TPM, or `takeown` may fail +with unexpected errors. + +When software physical presence fails, the LOG shows: +``` +tpm1_reset: unable to set physical presence +``` + +This is logged but not fatal — `tpm forceclear` is still attempted. +If the TPM firmware ignores software physical presence, the reset fails +and the user must use the platform's hardware TPM reset mechanism +(typically a BIOS option or jumper). + +### TPM reset methods + +Heads has two TPM reset methods with different scope: + +**`tpm-reset.sh`** (CLI, recovery shell): +- Prompts for new owner passphrase, calls `tpmr.sh reset` +- TPM clear + re-ownership only +- No counter creation, no /boot signing, no TOTP/HOTP generation +- Intended for headless recovery or clearing a defend lock before running + the full GUI flow + +**`reset_tpm()`** (GUI, via Options -> TPM/TOTP/HOTP -> Reset the TPM in +`initrd/bin/gui-init.sh`): +- Prompts for new owner passphrase, calls `tpmr.sh reset` +- Removes stale `/boot/kexec_rollback.txt` and `/boot/kexec_primhdl_hash.txt` +- Creates new TPM rollback counter via `check_tpm_counter()` +- Increments the new counter +- Re-signs /boot with the GPG signing key +- Generates new TOTP/HOTP secrets +- Reseals TPM Disk Unlock Key (DUK) to LUKS +- Regenerates TPM2 encrypted sessions + +After `tpm-reset.sh`, the TPM is cleared but the system is not fully +provisioned — the user must complete the GUI `reset_tpm()` or OEM Factory +Reset to restore counter, signing, and secrets. diff --git a/initrd/bin/gui-init.sh b/initrd/bin/gui-init.sh index fe026173c..32dfa586f 100755 --- a/initrd/bin/gui-init.sh +++ b/initrd/bin/gui-init.sh @@ -380,6 +380,42 @@ update_totp() { DEBUG "TPM state at TOTP failure:" DEBUG "$(pcrs)" + if [ -f /tmp/secret/tpm_da_lockout ]; then + rm -f /tmp/secret/tpm_da_lockout + da_lockout_msg="" + if [ -f /tmp/secret/tpm_da_lockout_msg ]; then + da_lockout_msg=$(cat /tmp/secret/tpm_da_lockout_msg) + rm -f /tmp/secret/tpm_da_lockout_msg + fi + totp_menu_text=$( + cat </tmp/whiptail || recovery "GUI menu failed" + else totp_menu_text=$( cat </tmp/whiptail || recovery "GUI menu failed" + fi option=$(cat /tmp/whiptail) case "$option" in diff --git a/initrd/bin/oem-factory-reset.sh b/initrd/bin/oem-factory-reset.sh index ece2f8f59..5dc315965 100755 --- a/initrd/bin/oem-factory-reset.sh +++ b/initrd/bin/oem-factory-reset.sh @@ -868,7 +868,7 @@ generate_checksums() { if [ "$CONFIG_TPM" = "y" ]; then if [ "$CONFIG_IGNORE_ROLLBACK" != "y" ]; then tpmr.sh counter_create \ - -pwdc "${TPM_PASS:-}" \ + -pwdc '' \ -la -3135106223 | tee /tmp/counter >/dev/null 2>&1 || whiptail_error_die "Unable to create TPM counter" diff --git a/initrd/bin/tpm-reset.sh b/initrd/bin/tpm-reset.sh index 047d49ef0..426012863 100755 --- a/initrd/bin/tpm-reset.sh +++ b/initrd/bin/tpm-reset.sh @@ -6,3 +6,12 @@ NOTE "This will erase all keys and secrets from the TPM" prompt_new_owner_password tpmr.sh reset "$tpm_owner_passphrase" + +# TODO: move the TPM reset + full reprovision flow (counter creation, /boot +# signing, TOTP/HOTP generation, DUK reseal) from gui-init.sh's reset_tpm() +# into a reusable function in functions.sh. Then tpm-reset.sh and the GUI +# reset_tpm() can both call the same code, eliminating the inconsistency +# between CLI and GUI reset paths. + +NOTE "TPM cleared. The TPM rollback counter was destroyed. /boot/kexec_rollback.txt still references the old counter." +NOTE "Restore full functionality from the GUI: Options -> TPM/TOTP/HOTP Options -> Reset the TPM" diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index 46f4581d8..6b4366fea 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -63,15 +63,16 @@ tpm2_password_hex() { # -a: Append to file. Default is to overwrite. tpm2_pcrread() { TRACE_FUNC + local append_mode if [ "$1" = "-a" ]; then - APPEND=y + append_mode=y shift fi index="$1" file="$2" - if [ -z "$APPEND" ]; then + if [ -z "$append_mode" ]; then # Don't append - truncate file now so real command always # overwrites true >"$file" @@ -81,15 +82,16 @@ tpm2_pcrread() { } tpm1_pcrread() { TRACE_FUNC + local append_mode if [ "$1" = "-a" ]; then - APPEND=y + append_mode=y shift fi index="$1" file="$2" - if [ -z "$APPEND" ]; then + if [ -z "$append_mode" ]; then # Don't append - truncate file now so real command always # overwrites true >"$file" @@ -354,7 +356,7 @@ tpm2_counter_inc() { rm -f "$tmp_err_file" shred -n 10 -z -u /tmp/secret/tpm_owner_passphrase 2>/dev/null || true DEBUG "tpm2_counter_inc attempt $attempt failed. Stderr: $tmp_err_content" - if ! echo "$tmp_err_content" | grep -qiE 'authorization|auth|bad|permission|0x98e|0x149'; then + if ! echo "$tmp_err_content" | grep -qiE 'authorization|auth|bad|permission|defend|0x98e|0x149'; then DIE "Can't increment TPM counter for $index, access denied." fi WARN "Authentication failed, retrying..." @@ -362,7 +364,7 @@ tpm2_counter_inc() { DIE "Can't increment TPM counter for $index after 3 attempts, access denied." } -# _tpm_auth_retry - Shared retry helper for TPM commands needing owner auth. +# _tpm_auth_retry - Shared retry helper for TPM commands needing authorization. # # Handles both TPM1 (tpmtotp: errors to stdout, uses -pwdo/-pwdc flags) # and TPM2 (tpm2-tools: errors to stderr, uses -P parameter). @@ -370,16 +372,26 @@ tpm2_counter_inc() { # Caching: prompt_tpm_owner_password reuses cached passphrase if available. # On auth failure the cache is shredded; next prompt will ask the user. # +# Error stream selection: +# TPM1 (tpmtotp): errors go to stdout via printf() — capture stdout+stderr +# TPM2 (tpm2-tools): errors go to stderr via LOG_ERR() — capture stderr only +# +# Auth detection grep patterns: +# English words — TPM1 (TPM_GetErrMsg returns "Authentication failed...") +# — TPM2 (tpm2-tools LOG_ERR returns "TPM2_RC_AUTH_FAIL...") +# defend — TPM1 "Defend lock running" (TPM_DEFEND_LOCK_RUNNING) +# 0x98e, 0x149 — TPM2 raw hex codes (TPM2_RC_AUTH_FAIL, TPM2_RC_NV_AUTHORIZATION) +# # Usage: _tpm_auth_retry