From da810af7ea16709b43e4cf94ee9934b666a1eae6 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 12:01:44 -0400 Subject: [PATCH 01/17] tpmr.sh: add da_state and bad_auth helpers for TPM1 and TPM2 tpm1_da_state queries TPM_CAP_DA_LOGIC (0x19) for TPM 1.2 chips and returns a human-readable summary plus a machine-parsable "DA: state=... current=... threshold=... timer=..." line. tpm2_da_state queries getcap properties-variable for TPM 2.0 and returns LOCKOUT_COUNTER, MAX_AUTH_FAIL, LOCKOUT_INTERVAL, and LOCKOUT_RECOVERY plus the same machine-parsable summary line. When the counter is at or above threshold, the function estimates time-to-unlock as (counter - maxAuth + 1) * interval. Both functions gracefully report "unavailable" when the TPM does not support the underlying query (e.g. STM TPM1 returning TPM_BAD_MODE 44), and emit a DEBUG line with vendor / firmware identification for diagnostic logging. tpm1_bad_auth and tpm2_bad_auth deliberately trigger an auth failure on the rollback counter so the dictionary-attack counter is bumped on demand. They distinguish auth-failure from active lockout via da_state output and are the primary tool for reproducing and verifying lockout detection end-to-end on both TPM versions without waiting for organic hits. Dispatcher entries expose the new subcommands under the existing tpmr.sh case branches so callers can invoke any of: tpmr.sh da_state tpmr.sh bad_auth [] This is the foundation needed by callers that need to distinguish DA lockout from other TPM read failures -- in particular the rollback-counter preflight gate, the increment-tpm-counter reseal path, the tpm1_unseal / tpm2_unseal paths, and the recovery-shell entry -- all of which land in follow-up commits. Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 262 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index 80bd605eb..17c571e0e 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1139,6 +1139,254 @@ tpm2_shutdown() { tpm2 shutdown -Q --clear } +# Query TPM1 dictionary attack state via TPM_CAP_DA_LOGIC (0x19). +# +# Returns a human-readable summary plus a machine-parsable +# "DA: state=… current=… threshold=… timer=…" line for callers +# (preflight guard, scripts, recovery shell display). +# +# Some TPMs (e.g., STM) return TPM_BAD_MODE (44) on the subcap and +# do not expose DA state via this query -- we report "unavailable" +# so callers know to rely on other detection methods (e.g., an +# increment failure with "Defend lock running" output). +tpm1_da_state() { + TRACE_FUNC + local da_out rc=0 + local state current threshold timer + local ver_output vendor_id rev_major rev_minor rev_major_dec rev_minor_dec + + # Log TPM chip identity for diagnostics (works on all TPM 1.2 chips). + ver_output="$(tpm getcapability -cap 0x1a 2>/dev/null)" || true + if [ -n "$ver_output" ]; then + vendor_id="$(echo "$ver_output" | grep 'VendorID' | tail -1 | sed 's/.*: *//')" + rev_major="$(echo "$ver_output" | grep 'revMajor' | sed 's/.*: 0x//')" + rev_minor="$(echo "$ver_output" | grep 'revMinor' | sed 's/.*: 0x//')" + rev_major_dec=$(printf '%d' "0x${rev_major:-0}" 2>/dev/null) + rev_minor_dec=$(printf '%d' "0x${rev_minor:-0}" 2>/dev/null) + DEBUG "tpm1_da_state: TPM vendor=\"$vendor_id\" firmware=$rev_major_dec.$rev_minor_dec" + fi + + da_out="$(tpm getcapability -cap 0x19 -scap 0x0000 2>/dev/null)" || rc=$? + if [ -z "$da_out" ] || ! echo "$da_out" | grep -q 'State'; then + [ -n "${rc-}" ] && DEBUG "tpm1_da_state: getcapability exit=$rc" + if [ "${rc-}" = "44" ]; then + DEBUG "tpm1_da_state: TPM does not support DA state queries (TPM_BAD_MODE)" + echo "TPM DA state: unavailable (this TPM does not report DA state)" + [ -n "${vendor_id-}" ] && echo "TPM chip: $vendor_id (firmware ${rev_major_dec:-?}.${rev_minor_dec:-?})" + else + DEBUG "tpm1_da_state: DA state not available (exit=${rc-})" + echo "TPM DA state: unavailable" + fi + return 1 + fi + echo "$da_out" + [ -n "${vendor_id-}" ] && echo "TPM chip: $vendor_id (firmware ${rev_major_dec:-?}.${rev_minor_dec:-?})" + state=$(echo "$da_out" | grep 'State' | awk '{print $NF}') + current=$(echo "$da_out" | grep 'currentCount' | awk '{print $NF}') + threshold=$(echo "$da_out" | grep 'thresholdCount' | awk '{print $NF}') + timer=$(echo "$da_out" | grep 'actionDependValue' | awk '{print $NF}') + DEBUG "tpm1_da_state: state=$state current=$current threshold=$threshold timer=$timer" + echo "" + echo "DA policy:" + echo " thresholdCount (max failures before defend): $threshold" + echo " currentCount (current failure count): $current" + echo " actionDependValue (lockout seconds remaining): ${timer:-0}" + echo " state (DA logic: 0=inactive, 1=active): $state" + if [ "$state" = "1" ]; then + if [ -n "$timer" ] && [ "$timer" -ge 3600 ] 2>/dev/null; then + echo "=> TPM DEFEND LOCK ACTIVE (~$((timer / 3600)) hour(s) remaining)" + elif [ -n "$timer" ] && [ "$timer" -ge 60 ] 2>/dev/null; then + echo "=> TPM DEFEND LOCK ACTIVE (~$((timer / 60)) min remaining)" + elif [ -n "$timer" ] && [ "$timer" -gt 0 ] 2>/dev/null; then + echo "=> TPM DEFEND LOCK ACTIVE (${timer}s remaining)" + else + echo "=> TPM DEFEND LOCK ACTIVE (duration unknown on this TPM)" + fi + elif [ -n "$current" ] && [ -n "$threshold" ] && [ "$current" -ge "$threshold" ] 2>/dev/null; then + DEBUG "tpm1_da_state: above threshold, not locked (timer=$timer)" + echo "=> Above threshold: $current/$threshold failures (auth failures will trigger lockout)" + elif [ -n "$current" ] && [ -n "$threshold" ]; then + echo "=> Within lockout threshold ($current/$threshold failures used)" + else + echo "=> DA state: limited info (state=$state)" + fi + # Machine-parsable line for callers (preflight guard, scripts). + # timer= field is always emitted; value empty when unavailable so + # the preflight guard's sed -n /p returns empty and skips lockout check. + echo "DA: state=${state:-} current=${current:-} threshold=${threshold:-} timer=${timer:-}" +} + +# Query TPM2 dictionary attack state via getcap properties-variable. +# Returns the four DA properties (LOCKOUT_COUNTER, MAX_AUTH_FAIL, +# LOCKOUT_INTERVAL, LOCKOUT_RECOVERY) plus a summary and a machine- +# parsable "DA: current=… threshold=… timer=…" line for callers. +# +# When counter >= maxAuth, the TPM is locked out. Time-to-unlock is +# estimated as (counter - maxAuth + 1) * interval -- the TPM decrements +# the counter every interval seconds once auth is blocked. +tpm2_da_state() { + TRACE_FUNC + local cap_out da_out counter_hex max_auth_hex interval_hex recovery_hex counter max_auth interval recovery fw_ver + cap_out="$(tpm2 getcap properties-variable 2>/dev/null)" || { + WARN "Unable to query TPM2 dictionary attack state" + DEBUG "tpm2_da_state: getcap properties-variable failed (no TPM access?)" + return 1 + } + fw_ver="$(echo "$cap_out" | grep 'TPM2_PT_FIRMWARE_VERSION_1' | sed 's/.*0x//')" + [ -n "$fw_ver" ] && DEBUG "tpm2_da_state: TPM firmware version: $(printf '%d.%d' $((0x${fw_ver}>>16)) $((0x${fw_ver}&0xffff)) 2>/dev/null)" + da_out="$(echo "$cap_out" | grep -E \ + 'TPM2_PT_LOCKOUT_COUNTER|TPM2_PT_MAX_AUTH_FAIL|TPM2_PT_LOCKOUT_INTERVAL|TPM2_PT_LOCKOUT_RECOVERY')" || true + if [ -z "$da_out" ]; then + DEBUG "tpm2_da_state: no matching properties found in getcap output" + echo "TPM2 DA state: unavailable" + return 1 + fi + echo "$da_out" + counter_hex=$(echo "$da_out" | grep 'LOCKOUT_COUNTER' | sed 's/.*0x//') + max_auth_hex=$(echo "$da_out" | grep 'MAX_AUTH_FAIL' | sed 's/.*0x//') + interval_hex=$(echo "$da_out" | grep 'LOCKOUT_INTERVAL' | sed 's/.*0x//') + recovery_hex=$(echo "$da_out" | grep 'LOCKOUT_RECOVERY' | sed 's/.*0x//') + counter=$((0x${counter_hex:-0})) + max_auth=$((0x${max_auth_hex:-0})) + interval=$((0x${interval_hex:-0})) + recovery=$((0x${recovery_hex:-0})) + echo "" + echo "DA policy:" + echo " maxTries (max auth fails before lockout): $max_auth" + if [ "$interval" -ge 60 ]; then + echo " recoveryTime (seconds before one failure is forgotten): $interval ($((interval / 60)) min)" + else + echo " recoveryTime (seconds before one failure is forgotten): $interval" + fi + echo " lockoutRecovery (seconds lockout auth blocked after failure): $recovery" + echo " failedTries (current auth failure count): $counter" + if [ -n "$counter_hex" ] && [ -n "$max_auth_hex" ] && [ "$counter" -ge "$max_auth" ] 2>/dev/null; then + DEBUG "tpm2_da_state: LOCKOUT ACTIVE (counter=$counter threshold=$max_auth)" + echo "=> TPM LOCKOUT ACTIVE ($counter/$max_auth failures)" + local need=$((counter - max_auth + 1)) + local estimate=$((need * interval)) + if [ "$estimate" -ge 3600 ]; then + echo "=> Estimated unlock in ~$((estimate / 3600)) hour(s) (if no new failures)" + elif [ "$estimate" -ge 60 ]; then + echo "=> Estimated unlock in ~$((estimate / 60)) min (if no new failures)" + else + echo "=> Estimated unlock in ~${estimate}s (if no new failures)" + fi + echo "DA: current=${counter:-} threshold=${max_auth:-} timer=${estimate}" + else + DEBUG "tpm2_da_state: within threshold (counter=$counter threshold=$max_auth)" + echo "=> Within lockout threshold ($counter/$max_auth failures used)" + echo "DA: current=${counter:-} threshold=${max_auth:-}" + fi +} + +# bad_auth - deliberately attempt TPM counter increment with wrong auth, +# for verifying dictionary-attack lockout detection end-to-end. Useful +# as a manual reproducer on both TPM1 and TPM2: +# +# tpmr.sh bad_auth # uses counter from /boot/kexec_rollback.txt +# tpmr.sh bad_auth # explicit counter +# +# On TPM1, each failed auth bumps the TPM's global DA failedTries +# counter; after the vendor threshold, the TPM returns +# TPM_DEFEND_LOCK_RUNNING ("Defend lock running"). The function +# distinguishes auth-failure (counter incremented, lockout not yet +# active) from lockout-active via tpm1_da_state output. +# +# On TPM2, NV index auth failure (-P) increments LOCKOUT_COUNTER +# without requiring owner hierarchy auth, which is the simplest way +# to bump the counter on chips whose owner hierarchy auth doesn't +# bump DA. Caller is responsible for resetting the TPM afterwards. +tpm1_bad_auth() { + TRACE_FUNC + local rollback_counter_id="${1:-}" + local increment_exit_code increment_command_output + local ver_output vendor_id rev_major rev_minor rev_major_dec rev_minor_dec + + if [ -z "$rollback_counter_id" ] && [ -r /boot/kexec_rollback.txt ]; then + rollback_counter_id=$(grep -Eo 'counter-[0-9a-fA-F]+' /boot/kexec_rollback.txt | \ + sed 's/counter-//' | head -1) + fi + DEBUG "=== BAD AUTH TEST (TPM1) ===" + DEBUG "Counter: ${rollback_counter_id:-}" + if [ -z "$rollback_counter_id" ]; then + if [ ! -f /tmp/.tpmr_bad_auth_no_counter_warned ]; then + WARN "No TPM counter ID: mount /boot partition then rerun, or pass ID directly: tpmr.sh bad_auth " + touch /tmp/.tpmr_bad_auth_no_counter_warned + fi + return 1 + fi + + ver_output="$(tpm getcapability -cap 0x1a 2>/dev/null)" || true + if [ -n "$ver_output" ]; then + vendor_id="$(echo "$ver_output" | grep 'VendorID' | tail -1 | sed 's/.*: *//')" + rev_major="$(echo "$ver_output" | grep 'revMajor' | sed 's/.*: 0x//')" + rev_minor="$(echo "$ver_output" | grep 'revMinor' | sed 's/.*: 0x//')" + rev_major_dec=$(printf '%d' "0x${rev_major:-0}" 2>/dev/null) + rev_minor_dec=$(printf '%d' "0x${rev_minor:-0}" 2>/dev/null) + DEBUG "bad_auth: TPM vendor=\"$vendor_id\" firmware=$rev_major_dec.$rev_minor_dec" + fi + + if ! tpm counter_read -ix "$rollback_counter_id" >/dev/null 2>&1; then + WARN "Counter $rollback_counter_id not found on this TPM" + return 1 + fi + + # TPM1 exit codes (per tpmtotp counter_increment.c): + # 0 = success (unexpected with wrong password) + # 1 = TPM_AUTHFAIL (wrong password rejected, DA counter bumped) + # 255 = TPM_DEFEND_LOCK_RUNNING (0x803) -- DA lockout active + increment_exit_code=0 + increment_command_output=$(tpm counter_increment -ix "$rollback_counter_id" -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) || increment_exit_code=$? + DEBUG "bad_auth: counter_increment rc=$increment_exit_code output='$increment_command_output'" + if [ "$increment_exit_code" -ne 0 ]; then + if echo "$increment_command_output" | grep -qi 'defend\|lock'; then + DEBUG "bad_auth: DA LOCKOUT ACTIVE (rc=$increment_exit_code)" + STATUS "bad_auth: DA lockout confirmed. TPM rejected increment (rc=255 = defend lock running)." + local da_state_output da_summary + da_state_output="$(tpm1_da_state 2>/dev/null)" || true + if [ -n "$da_state_output" ]; then + da_summary="$(echo "$da_state_output" | grep '^=> ' | head -1)" + [ -n "$da_summary" ] && STATUS "TPM DA: ${da_summary#=> }" + fi + else + DEBUG "bad_auth: auth failure (rc=$increment_exit_code = TPM_AUTHFAIL)" + echo "Auth failure (rc=$increment_exit_code = TPM_AUTHFAIL, expected with wrong password)." + echo "Run again to accumulate failures toward DA lockout." + fi + else + DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS)" + echo "UNEXPECTED: wrong password was accepted (rc=0 = TPM_SUCCESS)." + fi +} + +tpm2_bad_auth() { + TRACE_FUNC + local counter_id="${1:-}" + if [ -z "$counter_id" ] && [ -r /boot/kexec_rollback.txt ]; then + counter_id=$(grep -Eo 'counter-[0-9a-fA-F]+' /boot/kexec_rollback.txt | \ + sed 's/counter-//' | head -1) + fi + DEBUG "=== BAD AUTH TEST (TPM2) ===" + DEBUG "Counter: ${counter_id:-}" + DEBUG "DA state BEFORE bad auth:" + tpm2_da_state + if [ -z "$counter_id" ]; then + DEBUG "No counter ID found. Use tpmr.sh bad_auth ." + return 1 + fi + if ! tpm2 nvread "0x$counter_id" >/dev/null 2>&1; then + DEBUG "Counter 0x$counter_id does not exist on this TPM." + return 1 + fi + DEBUG "Attempting increment with wrong passphrase..." + # NV index auth failure (-P) bumps LOCKOUT_COUNTER. Owner auth failure + # (-C o -P) does not bump DA on some TPM2 implementations, so we use -P. + tpm2 nvincrement -P "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" "0x$counter_id" 2>&1 || true + DEBUG "DA state AFTER bad auth:" + tpm2_da_state +} + if [ "$CONFIG_TPM" != "y" ]; then DIE "No TPM!" fi @@ -1213,6 +1461,14 @@ if [ "$CONFIG_TPM2_TOOLS" != "y" ]; then tpm1_reset "$@" STATUS_OK "TPM reset completed" ;; + da_state) + shift + tpm1_da_state "$@" + ;; + bad_auth) + shift + tpm1_bad_auth "$@" + ;; kexec_finalize) ;; # Nothing on TPM1. shutdown) ;; # Nothing on TPM1. *) @@ -1263,6 +1519,12 @@ counter_increment) counter_create) tpm2_counter_create "$@" ;; +da_state) + tpm2_da_state "$@" + ;; +bad_auth) + tpm2_bad_auth "$@" + ;; destroy) tpm2_destroy "$@" ;; From 86647c805dae0b0fb63b5b4f48172fd6eb804e95 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 12:02:06 -0400 Subject: [PATCH 02/17] preflight: diagnose TPM DA lockout when rollback counter read fails preflight_rollback_counter_before_reseal is the gating function every Heads boot runs before showing the TOTP / HOTP prompt. When it cannot read the rollback counter NV index, the previous code silently discarded the tpm2 stderr (`tpmr.sh counter_read ... >/dev/null 2>&1`) and surfaced a generic "TPM swap attack" message regardless of the actual cause -- leaving users unable to tell apart real disk or TPM swap from TPM dictionary-attack (DA) lockout or other transient errors. This commit rewires the failure path to: 1. Capture the tpm2 stderr to /tmp/debug.log (LOG level) via DO_WITH_DEBUG so the actual response code is preserved for post-mortem reports without polluting the console. 2. Query the new tpmr.sh da_state helper (TPM1 + TPM2) and parse the machine-parsable DA: policy line. When the TPM reports DA lockout (timer > 0, or counter >= threshold), set the /tmp/secret/tpm_da_lockout marker file and surface a focused message that includes the remaining backoff time, common causes (repeated auth failures, repeated unclean shutdowns that skipped TPM2_Shutdown -- particularly observed on Intel PTT and similar firmware TPMs), and an actionable recommendation: wait the timer, or reset the TPM from the GUI if the wait is long. 3. Keep the existing reset recommendation for genuine non-lockout failures (counter reset, TPM replaced, da_state unavailable) but drop the alarmist "TPM swap attack" wording and append a pointer to /tmp/debug.log for the captured tpm2 error. Logging follows doc/logging.md: LOG for verbose dumps (tpm2 stderr, full da_state output), DEBUG for decision points (lockout_detected yes/no). User-facing whiptail stays short with 76-column wrap delegated to _whiptail_preprocess_args per doc/ux-patterns.md, and points to /tmp/debug.log for full diagnostics. Scope note: this commit is functions.sh only. The consumer in gui-init.sh (the preflight error menu at line 949) currently strips the "Reset TPM from GUI" action guidance from our message and appends a generic "TPM swap attack" warning block. A follow-up commit will fix the consumer so the calm UX actually reaches the user. Signed-off-by: Thierry Laurion --- initrd/etc/functions.sh | 82 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/initrd/etc/functions.sh b/initrd/etc/functions.sh index 2f5f6a15e..621c4b386 100644 --- a/initrd/etc/functions.sh +++ b/initrd/etc/functions.sh @@ -2097,8 +2097,86 @@ preflight_rollback_counter_before_reseal() { fi DEBUG "Preflight: validating rollback counter $counter_id before protected operations" - if ! tpmr.sh counter_read -ix "$counter_id" >/dev/null 2>&1; then - fail_preflight "TPM integrity counter cannot be read. Possible cause: TPM was swapped or reset. This could indicate a TPM swap attack. Reset TPM from GUI (Options -> TPM/TOTP/HOTP Options -> Reset the TPM)." + # Capture stderr from counter_read so the actual tpm2 error lands in debug.log + # (issue #2205: pre-fix code did `>/dev/null 2>&1` and swallowed the tpm2 rc, + # making DA-lockout-vs-handle-missing-and-everything-else indistinguishable). + # DO_WITH_DEBUG logs the command's stdout/stderr at LOG level (debug.log only). + # We also stash stderr locally so the preflight can branch on DA lockout. + preflight_counter_err="$(mktemp)" + if DO_WITH_DEBUG tpmr.sh counter_read -ix "$counter_id" \ + 2>"$preflight_counter_err" >/dev/null; then + rm -f "$preflight_counter_err" + else + preflight_counter_msg="$(cat "$preflight_counter_err" 2>/dev/null || true)" + rm -f "$preflight_counter_err" + LOG "Preflight: counter_read failed; tpm2 stderr: $preflight_counter_msg" + + # Diagnose: DA lockout vs other failure. PR #2124 added + # tpmr.sh da_state (TPM1+TPM2) emitting a machine-parsable + # "DA: state=… current=… threshold=… timer=…" line. timer>0 means + # currently locked (TCG backoff countdown). Full output goes to + # debug.log at LOG level; we only extract the policy fields here. + da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" + LOG "Preflight: da_state output:\n$da_state_out" + local da_line da_current da_threshold da_timer lockout_detected timer_display + da_line="$(echo "$da_state_out" | grep '^DA: ' || true)" + da_current=$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/') + da_threshold=$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/') + da_timer=$(echo "$da_line" | sed -n 's/.*timer=\([^ ]*\).*/\1/p') + lockout_detected="n" + if [ -n "$da_timer" ] && [ "$da_timer" -gt 0 ] 2>/dev/null; then + lockout_detected="y" + elif [ -n "$da_current" ] && [ -n "$da_threshold" ] && \ + [ "$da_current" -ge "$da_threshold" ] 2>/dev/null; then + lockout_detected="y" + fi + DEBUG "Preflight: lockout_detected=$lockout_detected (da_current='$da_current' da_threshold='$da_threshold' da_timer='$da_timer')" + + if [ "$lockout_detected" = "y" ]; then + # Marker file signals the GUI menu loop (gui-init.sh update_totp) + # to use PR #2124's DA-lockout-specific whiptail instead of the + # generic reset-required dialog. Consistent with tpm1_unseal / + # tpm2_unseal which also set this marker on lockout. + mkdir -p /tmp/secret || true + : >/tmp/secret/tpm_da_lockout + [ -n "$preflight_counter_msg" ] && \ + echo "$preflight_counter_msg" >/tmp/secret/tpm_da_lockout_msg 2>/dev/null || true + timer_display="${da_timer}s" + if [ "${da_timer:-0}" -ge 3600 ] 2>/dev/null; then + timer_display="~$((da_timer / 3600)) hour(s)" + elif [ "${da_timer:-0}" -ge 60 ] 2>/dev/null; then + timer_display="~$((da_timer / 60)) min" + fi + # Short whiptail -- full probable causes and "what not to do" + # guidance live in /tmp/debug.log (LOG above). The 76-column + # word wrap is handled by _whiptail_preprocess_args. + fail_preflight "TPM is in dictionary-attack lockout (DA $da_current/$da_threshold). + +Time until next auth can succeed: ${timer_display}. + +Common causes: repeated auth failures, or repeated unclean shutdowns +that skipped TPM2_Shutdown (notably on Intel PTT). + +Wait ${timer_display} then reboot, or reset the TPM from the GUI +(Options -> TPM/TOTP/HOTP Options -> Reset the TPM). + +Full diagnostics in /tmp/debug.log." + return 1 + fi + + # Not lockout (or da_state unavailable -- e.g. STM TPM1 with no + # TPM_CAP_DA_LOGIC support): genuine counter / TPM replacement or + # some other read failure. Keep reset guidance but drop the + # "TPM swap attack" alarmism; full captured error lives in + # debug.log so future #2205-style reports are diagnosable from + # the same log the user already attached. + fail_preflight "TPM rollback counter $counter_id cannot be read. + +The counter may have been reset or removed, or the TPM may have been +replaced. The captured tpm2 error is in /tmp/debug.log. + +Recommended: reset the TPM from the GUI +(Options -> TPM/TOTP/HOTP Options -> Reset the TPM)." return 1 fi From 2efe8e1847d816ec05ba535f0ad2b29cfd133d61 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 12:03:00 -0400 Subject: [PATCH 03/17] unseal: detect DA lockout in tpm1_unseal / tpm2_unseal and signal via marker file tpm2_unseal now captures its stderr to a temp file so it can grep for the TPM2_RC_LOCKOUT pattern ("lockout", "lock", "auth.*fail", "0x98e", "0x149") before falling through to the generic "unable to unseal" warning. When detected, sets /tmp/secret/tpm_da_lockout so the GUI menu loop in gui-init.sh can route to a lockout-specific dialog instead of the generic TOTP failure dialog (the consumer side lands in a follow-up commit). tpm1_unseal gets symmetric detection: tpmtotp prints errors to stdout (not stderr), and "Defend lock running" is the canonical TPM 1.2 defend-lock signal. This path matters for chips that don't expose DA state via TPM_CAP_DA_LOGIC (e.g. STM TPM1 returning TPM_BAD_MODE 44) -- the unseal failure output is the only reliable signal there. On lockout detection, the marker is set and a STATUS line is emitted with the da_state summary so the user sees remaining backoff time, not just the alarm. The marker file is consumed by: - gui-init.sh update_totp (commit 6): routes to lockout dialog - recovery() (commit 5): displays DA state summary This commit is tpmr.sh only. Callers (unseal-totp.sh, unseal-hotp.sh, seal-hotpkey.sh, kexec-unseal-key.sh) need no change -- they already treat unseal failure as exit 1, and the marker file is set BEFORE exit so it persists for the consumer. Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index 17c571e0e..c7adb8a3d 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -865,14 +865,27 @@ tpm2_unseal() { fi # tpm2 unseal will write the unsealed data to stdout and any errors to - # stderr; capture stderr to log. + # stderr. Capture stderr to a temp file for both lockout detection + # (TPM2_RC_LOCKOUT) and post-mortem logging. + TMP_STDERR="$(mktemp)" if ! tpm2 unseal -Q -c "$handle" -p "session:$POLICY_SESSION$UNSEAL_PASS_SUFFIX" \ - -S "$ENC_SESSION_FILE" >"$file" 2> >(SINK_LOG "tpm2 stderr"); then + -S "$ENC_SESSION_FILE" >"$file" 2>"$TMP_STDERR"; then + LOG "tpm2 unseal stderr: $(cat "$TMP_STDERR")" + # Detect DA lockout (TPM2 returns TPM2_RC_LOCKOUT). Set the marker + # file so callers (gui-init.sh update_totp) can route to a + # lockout-specific dialog instead of the generic TOTP failure + # dialog. The marker is consumed by commit 6. + if grep -qi 'lockout\|lock\|auth.*fail\|0x98e\|0x149' "$TMP_STDERR" 2>/dev/null; then + WARN "TPM2 dictionary attack lockout active. Unseal rejected." + mkdir -p /tmp/secret 2>/dev/null || true + touch /tmp/secret/tpm_da_lockout 2>/dev/null || true + fi + rm -f "$TMP_STDERR" WARN "Unable to unseal secret from TPM NVRAM" - # should succeed, exit if it doesn't exit 1 fi + rm -f "$TMP_STDERR" rm -f "$TMP_ERR_FILE" } @@ -964,6 +977,28 @@ tpm1_unseal() { return 0 fi DEBUG "tpm1_unseal unsealfile output: $(cat "$TMP_UNSEAL_OUT")" + # Detect DA lockout from unseal failure output. Works on TPMs that + # don't expose DA state via TPM_CAP_DA_LOGIC (e.g. STM TPM1 returning + # TPM_BAD_MODE 44), where the only signal is the "Defend lock running" + # error from tpmtotp. Set the marker file so callers (gui-init.sh + # update_totp) can route to a lockout-specific dialog instead of the + # generic TOTP failure dialog. The marker is consumed by commit 6. + if grep -qi 'defend\|lock' "$TMP_UNSEAL_OUT" 2>/dev/null; then + WARN "TPM dictionary attack lockout active. Unseal rejected." + mkdir -p /tmp/secret 2>/dev/null || true + touch /tmp/secret/tpm_da_lockout 2>/dev/null || true + # Best-effort: stash a da_state summary line so the GUI dialog + # can show the remaining backoff time, not just "lockout active". + local da_state_output da_summary + da_state_output="$(tpm1_da_state 2>/dev/null)" || true + if [ -n "$da_state_output" ]; then + da_summary="$(echo "$da_state_output" | grep '^=> ' | head -1)" + if [ -n "$da_summary" ]; then + STATUS "TPM DA: ${da_summary#=> }" + echo "${da_summary#=> }" >/tmp/secret/tpm_da_lockout_msg 2>/dev/null || true + fi + fi + fi if [ "$HEADS_NONFATAL_UNSEAL" = "y" ]; then DEBUG "nonfatal tpm1_unseal failure: unable to unseal TPM NVRAM blob" return 1 From 6f276235eeb563510f1a846494563a43161890e6 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 12:03:51 -0400 Subject: [PATCH 04/17] increment_tpm_counter: preflight DA state, set marker, fail fast on lockout before reseal increment_tpm_counter is called from: - gui-init.sh:819 inside the reset_tpm flow - oem-factory-reset.sh:884 inside counter validation - kexec-sign-config.sh:103 inside update_checksums (the path that fires when re-signing /boot files) Without a DA preflight, any of these paths would attempt an increment on a locked TPM and either extend the lockout (TPM1) or fail with a confusing generic error after a needless auth attempt. This commit queries tpmr.sh da_state (TPM1 + TPM2) at the start of increment_tpm_counter. When DA lockout is active (timer > 0, or counter >= threshold on TPM2): - Sets /tmp/secret/tpm_da_lockout so the recovery shell (commit 5) and gui-init.sh (commit 6) can display remaining time - Writes the timer string to /tmp/secret/tpm_da_lockout_msg for the GUI lockout dialog - DIE with a clear, time-aware message rather than letting the generic increment error fire For TPM1 chips that don't expose DA state via TPM_CAP_DA_LOGIC (e.g. STM returning TPM_BAD_MODE 44), da_state returns unavailable and the guard is a no-op -- the increment will still be attempted and lockout will be detected from the increment failure output (handled in the increment block). This matches the preflight gate's fallback behavior. Threshold warnings (above threshold, nearing threshold) emit WARN without aborting so users approaching lockout still get useful guidance without losing the increment. Logging follows doc/logging.md: DEBUG for decision points (lockout detected, threshold status, da_state unavailable), WARN/DIE for user-visible signals. Signed-off-by: Thierry Laurion --- initrd/etc/functions.sh | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/initrd/etc/functions.sh b/initrd/etc/functions.sh index 621c4b386..2151c22f5 100644 --- a/initrd/etc/functions.sh +++ b/initrd/etc/functions.sh @@ -2244,6 +2244,58 @@ increment_tpm_counter() { tpm_passphrase="$(cat /tmp/secret/tpm_owner_passphrase)" fi + # Preflight DA state check before incrementing. Catches TPM lockout + # before we attempt any operation that would extend it. + # + # TPM1: timer=0 means state inactive; timer>0 means locked. + # NOTE: some TPM1 chips (e.g., STM, some Infineon) do not + # support TPM_CAP_DA_LOGIC and return TPM_BAD_MODE (44). + # da_state returns "unavailable" and the guard has no DA info + # -- lockout is detected only when the increment itself fails + # (handled in the increment block below). + # TPM2: timer absent when count0 when locked. + # TPM1 timer>0 or TPM2 timer>0: set marker, DIE with remaining time. + # TPM1 timer=0, count>=threshold: above threshold but not locked, WARN. + # Both: count>=threshold-1 without lockout: WARN. + if [ "$CONFIG_TPM" = "y" ]; then + local da_line da_current da_threshold da_timer lockout_msg + da_line="$(tpmr.sh da_state 2>/dev/null | grep '^DA: ')" + da_current=$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/') + da_threshold=$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/') + # With sed -n /p, da_timer stays empty when timer= field absent (TPM2 clean) + da_timer=$(echo "$da_line" | sed -n 's/.*timer=\([^ ]*\).*/\1/p') + if [ -n "$da_current" ] && [ -n "$da_threshold" ]; then + if [ -n "$da_timer" ] && [ "$da_timer" -gt 0 ] 2>/dev/null; then + local timer_display="${da_timer}s" + if [ "$da_timer" -ge 3600 ] 2>/dev/null; then + timer_display="~$((da_timer / 3600)) hour(s)" + elif [ "$da_timer" -ge 60 ] 2>/dev/null; then + timer_display="~$((da_timer / 60)) min" + fi + DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (locked, ${timer_display})" + # Set marker before DIE so recovery shell (commit 5) can + # display the DA state, and so gui-init.sh (commit 6) + # sees the lockout marker if the failure cascades. + mkdir -p /tmp/secret 2>/dev/null || true + touch /tmp/secret/tpm_da_lockout 2>/dev/null || true + lockout_msg="TPM dictionary attack lockout active (DA $da_current/$da_threshold, ${timer_display} remaining). Wait for the timer, or reset the TPM from GUI: Options -> TPM/TOTP/HOTP Options -> Reset the TPM." + echo "${timer_display}" >/tmp/secret/tpm_da_lockout_msg 2>/dev/null || true + DIE "$lockout_msg" + fi + if [ "$da_current" -ge "$da_threshold" ] 2>/dev/null; then + DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (above threshold, not locked)" + WARN "DA counter above threshold ($da_current/$da_threshold). Auth failures will trigger lockout." + elif [ "$da_current" -ge $((da_threshold - 1)) ] 2>/dev/null; then + DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (nearing threshold)" + WARN "DA counter nearing threshold ($da_current/$da_threshold). One more auth failure may trigger lockout." + else + DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (within threshold)" + fi + else + DEBUG "increment_tpm_counter: TPM DA state unavailable or limited" + fi + fi + # Try to increment the counter. We normally hide the verbose # output of tpmr.sh commands to avoid overwhelming the console, but we # must *not* swallow any interactive prompts. The previous implementation From 7d0908010da82e0339d8c4e26355e69b8dcae567 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 12:04:35 -0400 Subject: [PATCH 05/17] recovery shell: display DA state summary so users can see remaining lockout time recovery() is the universal fallback path -- it is reached when: - The TOTP/HOTP preflight fails (commit 2) - The unseal fails (commit 3, with marker file set) - The user explicitly chooses 'Exit to recovery shell' from any menu - The boot script is missing or fails - /init or /bin/reboot.sh decides to drop to a shell A user who lands in recovery shell due to DA lockout (the most common 'stuck' scenario) currently sees no signal explaining why their TPM auth is failing. The shell is also the only place they can run tpmr.sh manually for diagnosis, so showing the DA state up-front gives them both the explanation and the command they can re-run for deeper inspection. This commit: - Queries tpmr.sh da_state (TPM1 + TPM2) inside recovery(), just before the shell starts - Routes the full multi-line da_state output to LOG (debug.log only) per doc/logging.md - Surfaces the single-line '=>' summary (e.g. 'TPM LOCKOUT ACTIVE (5/5 failures)' or 'TPM DEFEND LOCK ACTIVE (~5 min remaining)') at STATUS level so it reaches console in all modes including Quiet - Falls back to the self-descriptive 'TPM DA state: unavailable' line when no summary is available (STM TPM1 case) Consumes the /tmp/secret/tpm_da_lockout marker set by commits 2, 3, and 4 indirectly: when lockout is active, the marker is on disk, the da_state query returns the lockout summary, and the user sees it the moment they reach the shell. Signed-off-by: Thierry Laurion --- initrd/etc/functions.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/initrd/etc/functions.sh b/initrd/etc/functions.sh index 2151c22f5..79292e420 100644 --- a/initrd/etc/functions.sh +++ b/initrd/etc/functions.sh @@ -1131,6 +1131,32 @@ recovery() { done fi + # Show DA lockout state so users who landed in recovery due to + # repeated auth failures see lockout status and remaining time. + # Full detail goes to debug.log at LOG level; a single STATUS + # line summarizes the user-relevant state on console (visible + # in Quiet mode, since STATUS always reaches console). + if [ "$CONFIG_TPM" = "y" ]; then + local da_output da_summary da_unavail + da_output="$(tpmr.sh da_state 2>/dev/null)" || true + if [ -n "$da_output" ]; then + # Full detail to debug.log only. + echo "$da_output" | while IFS= read -r line; do + LOG "$line" + done + da_summary="$(echo "$da_output" | grep '^=> ' | head -1)" + if [ -n "$da_summary" ]; then + STATUS "TPM DA: ${da_summary#=> }" + else + # No => line means DA state unavailable on this TPM + # (e.g. STM TPM1 with no TPM_CAP_DA_LOGIC support); + # surface the self-descriptive "TPM DA state: ..." line. + da_unavail="$(echo "$da_output" | grep '^TPM DA state:' | head -1)" + [ -n "$da_unavail" ] && STATUS "$da_unavail" + fi + fi + fi + # Drain any queued serial input before starting the interactive shell. # This avoids stale bytes being interpreted as bash commands on entry. # NOTE: -t 0 in BusyBox returns immediately (poll-only, does not consume From d6a5b800a107c3724e90b7e483ae74b438664ab7 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 12:10:57 -0400 Subject: [PATCH 06/17] gui-init.sh: surface DA lockout early on boot, fix preflight error menu UX, route lockout marker to dedicated dialog Three UX changes, all gated on the /tmp/secret/tpm_da_lockout marker file set by commits 2, 3, and 4: 1. Early STATUS line at the start of gui-init.sh (right after TRACE_FUNC, before HOTP detection) so the user sees remaining backoff time on screen immediately, before any auth attempt that could extend the lockout. Non-mutating tpmr.sh da_state query; silent on healthy boots, STATUS line only when lockout is active. 2. Fix the preflight error menu UX. The previous code stripped the 'Reset TPM from GUI...' action guidance from our preflight message and unconditionally appended a generic block: Possible causes: TPM was reset or replaced, /boot disk was swapped or restored, TPM state tampering occurred. WARNING: If none of the above were intentional, treat /boot as UNTRUSTED. A disk or TPM swap attack cannot be ruled out. That alarming text appears even when the cause is a recoverable DA lockout. Now, when the marker file is set, we skip the generic block and show a lockout-specific dialog explaining common causes (auth failures, unclean shutdowns on Intel PTT) with the timer, and offering reset / OEM Factory Reset / continue to main menu without 'Show integrity report' (which makes no sense during lockout). 3. Route the lockout marker to a dedicated dialog inside update_totp so users hitting lockout through the TOTP-unseal path see the same focused UX as users hitting it through the preflight path. Without this, the marker would be set (commit 3) but the generic 'TOTP Generation Failed!' dialog with 'THIS COULD INDICATE TAMPERING!' would still be shown -- directly contradicting the calm UX the marker file is supposed to signal. The marker is consumed (deleted) by whichever dialog reads it. This makes the protocol one-shot per failure, so re-preflight failures re-set it cleanly from scratch via the gate. All three changes share the marker-file contract; bundling them keeps the protocol change atomic. Signed-off-by: Thierry Laurion --- initrd/bin/gui-init.sh | 149 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 10 deletions(-) diff --git a/initrd/bin/gui-init.sh b/initrd/bin/gui-init.sh index ce03d953b..39215b512 100755 --- a/initrd/bin/gui-init.sh +++ b/initrd/bin/gui-init.sh @@ -286,8 +286,45 @@ update_totp() { DEBUG "TPM state at TOTP failure:" DEBUG "$(pcrs)" - totp_menu_text=$( - cat </tmp/whiptail || recovery "GUI menu failed" + else + totp_menu_text=$( + cat </tmp/whiptail || recovery "GUI menu failed" + ) + whiptail_error --title "ERROR: TOTP Generation Failed!" \ + --menu "$totp_menu_text" 0 80 4 \ + 'g' ' Generate new HOTP/TOTP secret' \ + 'p' ' Reset the TPM' \ + 'i' ' Ignore error and continue to main menu' \ + 'x' ' Exit to recovery shell' \ + 2>/tmp/whiptail || recovery "GUI menu failed" + fi option=$(cat /tmp/whiptail) case "$option" in @@ -908,6 +946,24 @@ force_unsafe_boot() { # gui-init start TRACE_FUNC +# Surface TPM DA lockout status as early as possible, before any auth +# attempt that could extend the lockout. Non-mutating query -- safe to +# run before HOTP detection. If lockout is active, print a STATUS line +# so the user sees remaining backoff time immediately. If no lockout, +# silently continue (no noise on every boot). +if [ "$CONFIG_TPM" = "y" ]; then + da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" + if [ -n "$da_state_out" ]; then + da_summary="$(echo "$da_state_out" | grep '^=> ' | head -1)" + if [ -n "$da_summary" ]; then + STATUS "TPM DA: ${da_summary#=> }" + else + da_unavail="$(echo "$da_state_out" | grep '^TPM DA state:' | head -1)" + [ -n "$da_unavail" ] && STATUS "$da_unavail" + fi + fi +fi + if [ -x /bin/hotp_verification ]; then # HOTP required by board config, always detect branding detect_usb_security_dongle_branding @@ -962,8 +1018,81 @@ Recommended first step: Choose an action: EOF ) + + # If the preflight gate detected DA lockout (commit 2 sets the marker), + # the generic "TPM swap attack" warning block above is misleading -- + # the user is not under attack, the TPM is in lockout and will recover. + # Replace the menu text with a lockout-specific version. The marker + # file is consumed here (deleted after the dialog) so subsequent _preflight_report_shown="n" while [ "$rollback_preflight_failed" = "y" ]; do + # If the preflight gate detected DA lockout (commit 2 sets the + # marker), show a lockout-specific dialog instead of the generic + # "TPM swap attack" menu. The marker is consumed here (deleted + # after the dialog) so subsequent re-preflight failures re-set + # it from scratch. + if [ -f /tmp/secret/tpm_da_lockout ]; then + rm -f /tmp/secret/tpm_da_lockout + preflight_da_msg="" + if [ -f /tmp/secret/tpm_da_lockout_msg ]; then + preflight_da_msg="$(cat /tmp/secret/tpm_da_lockout_msg)" + rm -f /tmp/secret/tpm_da_lockout_msg + fi + lockout_menu_text=$( + cat <' \ + 'm' ' Continue to main menu' \ + 2>/tmp/whiptail || recovery "GUI menu failed" + option=$(cat /tmp/whiptail) + case "$option" in + r) + if reset_tpm && preflight_rollback_counter_before_reseal /boot/kexec_rollback.txt "" return; then + rollback_preflight_failed="n" + BG_COLOR_MAIN_MENU="normal" + fi + ;; + o) + INTEGRITY_REPORT_ALREADY_SHOWN=1 oem-factory-reset.sh + if preflight_rollback_counter_before_reseal /boot/kexec_rollback.txt "" return; then + rollback_preflight_failed="n" + BG_COLOR_MAIN_MENU="normal" + fi + ;; + m | *) + break + ;; + esac + if [ "$rollback_preflight_failed" = "y" ]; then + preflight_error_msg="$(cat /tmp/rollback_preflight_error 2>/dev/null)" + [ -n "$preflight_error_msg" ] && DEBUG "Rollback preflight failure: $preflight_error_msg" + fi + # Skip the generic preflight dialog this iteration. + continue + fi + # After the user has seen the integrity report, drop the recommendation # and mark it shown so oem-factory-reset.sh skips it. if [ "$_preflight_report_shown" = "y" ]; then From 5c740e6d0401151611c7c49c3d57f7abeb674574 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 13:40:08 -0400 Subject: [PATCH 07/17] doc/tpm.md: document DA state query, lockout behavior, marker-file protocol, early-status display Signed-off-by: Thierry Laurion --- doc/tpm.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/doc/tpm.md b/doc/tpm.md index 8cecfb55b..16abd6660 100644 --- a/doc/tpm.md +++ b/doc/tpm.md @@ -522,3 +522,103 @@ None are invoked by Heads, so their absence has no functional impact: | GetTime (0x187) | TPM time attestation | | EC_Ephemeral (0x18E), ZGen_2Phase (0x18D) | ECC 2-phase operations | | FieldUpgradeStart (0x18F), FieldUpgradeData (0x190) | Firmware field upgrade | + +--- + +## TPM dictionary-attack (DA) lockout detection + +Heads detects and reports TPM DA lockout at every TPM-gated code +path that can hit it: rollback-counter preflight (the gate every +boot runs before TOTP/HOTP), the TOTP/HOTP unseal path, the +increment-counter reseal path (`update_checksums`, +`oem-factory-reset.sh`), and the recovery shell. + +### `tpmr.sh da_state` — DA state query + +``` +tpmr.sh da_state +``` + +Returns the TPM's current dictionary-attack state for both TPM1 +and TPM2, plus a machine-parsable summary line: + +* TPM1 (`tpm1_da_state`): queries `TPM_CAP_DA_LOGIC` (0x19). + Outputs `currentCount`, `thresholdCount`, and + `actionDependValue` (seconds remaining for TPM 1.2 defend + lock). When the TPM does not support `TPM_CAP_DA_LOGIC` (e.g. + STM returning `TPM_BAD_MODE 44`), reports "unavailable". +* TPM2 (`tpm2_da_state`): queries `getcap properties-variable` + for `TPM2_PT_LOCKOUT_COUNTER`, `MAX_AUTH_FAIL`, + `LOCKOUT_INTERVAL`, and `LOCKOUT_RECOVERY`. When locked, + estimates time-to-unlock as `(counter - maxAuth + 1) * interval`. + +Both functions emit a final `DA: state=… current=… threshold=… +timer=…` line for machine parsing. The preflight guard and the +recovery shell rely on this line. + +### `tpmr.sh bad_auth` — manual reproducer + +``` +tpmr.sh bad_auth # uses counter from /boot/kexec_rollback.txt +tpmr.sh bad_auth # explicit counter +``` + +Deliberately attempts a counter increment with a wrong +passphrase, bumping the TPM's DA failedTries counter on demand. +Distinguishes auth-failure from active lockout via the +`da_state` output. Primary tool for reproducing and verifying +lockout detection end-to-end on both TPM versions. + +### Marker-file protocol + +When any TPM-gated code path detects lockout, it sets the +marker file `/tmp/secret/tpm_da_lockout` and (if a timer is +known) `/tmp/secret/tpm_da_lockout_msg`. The marker is consumed +by: + +* `gui-init.sh` early boot STATUS line — surfaces the timer + before any auth attempt that could extend it. +* `preflight_rollback_counter_before_reseal` error menu (in + `gui-init.sh`) — replaces the generic "TPM swap attack" + warning with a lockout-specific dialog explaining common + causes and showing remaining backoff time. +* `update_totp` (in `gui-init.sh`) — replaces the generic + "TOTP Generation Failed!" alarming message with a lockout- + specific dialog. +* `recovery()` (in `functions.sh`) — emits a STATUS line with + the `da_state` summary so users who drop to recovery see + remaining time immediately. + +The marker is consumed (deleted) by whichever dialog reads it. +This makes the protocol one-shot per failure, so re-preflight +failures re-set it cleanly from scratch via the gate. + +### Common lockout causes + +* **Repeated failed auth attempts** — wrong TPM owner + passphrase entered at the TOTP/HOTP prompt enough times. +* **Unclean shutdowns on Intel PTT and similar firmware TPMs** + — interrupted boots (long-press power button, hard reset + during kexec, recovery-shell exit) skip `TPM2_Shutdown` and + can bump the PTT DA counter. After enough of these, the TPM + enters lockout even though no user-facing auth was attempted. + Observed on T480 (ThinkPad) and other Intel PTT platforms. + +### Recovery + +* TPM2 lockout clears after the TCG backoff timer expires + (TCG-standard exponential: seconds → minutes → hours). Power + cycling does **not** clear TPM2 lockout state. +* TPM1 defend lock clears on power cycle on some firmwares + but not all (Infineon in particular). `tpm-reset.sh` from + the recovery shell clears the DA counter. +* For long timers, "Reset the TPM" from the GUI (Options → + TPM/TOTP/HOTP Options → Reset the TPM) is faster than + waiting. This requires the user to know (or set) the owner + passphrase and re-provision TOTP/HOTP secrets. + +For TPM1 chips that do not expose DA state via +`TPM_CAP_DA_LOGIC` (STM and some Infineon), the preflight +guard is a no-op — lockout is detected only when the +increment / unseal itself fails and reports "Defend lock +running" / TPM_RC_LOCKOUT. From 3edce47402a57ace2f50f6404d38b10aa0db511b Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 13:54:47 -0400 Subject: [PATCH 08/17] bad_auth: state NV region + emit pre/post DA state for both TPM1 and TPM2 Three improvements on top of the existing bad_auth: 1. State the NV region clearly in both pre/post debug lines. The NV index range is the primary indicator of what kind of NV space the counter lives in -- and crucially whether it is even writable by the user. TPM1 classification: 0x0001xxxx : permanent (TPM-reserved) 0x0002xxxx : legacy user NV 0x0003xxxx : legacy user NV 0x4xxxxxxx : platform-reserved 0x8xxxxxxx : transport/reserved default : user NV (Heads counter lives here) TPM2 classification: 0x01xxxxxx : user-defined (0x01000000-0x01FFFFFF) 0x40xxxxxx : TPM-reserved (0x40000000-0x400FFFFF) 0x80xxxxxx : persistent (0x80000000-0x803FFFFF) 0x10xxxxxx : platform (0x10000000-0x10000FFF) default : other Note: counter_id has the 0x prefix stripped during discovery (counter_id='${probe_index#0x}') so case patterns match without the 0x prefix. The DEBUG output adds the prefix back for clarity. 2. Emit DA state BEFORE the bad-auth attempt for both TPM1 and TPM2 (was missing on TPM1). The TPM1 branch only printed state on lockout detection, leaving users blind to whether the attempt actually fired. 3. Emit DA state AFTER the bad-auth attempt for both TPM1 and TPM2 (was missing the NV region annotation on TPM2, missing entirely on TPM1). The user can now see the counter visibly increment in the after-state, confirming the bad-auth fired. Both state prints now include the NV region and counter index so it's unambiguous which NV slot is being exercised. Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 114 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index c7adb8a3d..ffb2b2c38 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1342,6 +1342,25 @@ tpm1_bad_auth() { rollback_counter_id=$(grep -Eo 'counter-[0-9a-fA-F]+' /boot/kexec_rollback.txt | \ sed 's/counter-//' | head -1) fi + # Fallback: enumerate NV indices and probe for a counter. Counters in + # TPM 1.2 return exactly 4 bytes (8 hex chars) from nv_readvalue; sealed + # objects return larger blobs. This lets bad_auth work from the + # recovery shell even when /boot is not mounted. + if [ -z "$rollback_counter_id" ]; then + local probe_index probe_val + for probe_index in $(tpm getcapability -cap 0x11 2>/dev/null | \ + grep -oE '0x[0-9a-fA-F]+'); do + probe_val="$(tpm nv_readvalue -ix "$probe_index" 2>/dev/null | \ + xxd -pc8 2>/dev/null)" || continue + # 4 bytes = 8 hex chars = TPM1 counter. Anything else (sealed + # blobs are 200+ chars) is not a counter. + if [ "${#probe_val}" -eq 8 ]; then + rollback_counter_id="${probe_index#0x}" + DEBUG "bad_auth: discovered TPM1 counter at $probe_index via NV enumeration" + break + fi + done + fi DEBUG "=== BAD AUTH TEST (TPM1) ===" DEBUG "Counter: ${rollback_counter_id:-}" if [ -z "$rollback_counter_id" ]; then @@ -1372,18 +1391,30 @@ tpm1_bad_auth() { # 1 = TPM_AUTHFAIL (wrong password rejected, DA counter bumped) # 255 = TPM_DEFEND_LOCK_RUNNING (0x803) -- DA lockout active increment_exit_code=0 + # Classify NV index by TPM 1.2 NV range: + # 0x0001xxxx : permanent (TPM-reserved) + # 0x0002xxxx : legacy user NV + # 0x0003xxxx : legacy user NV + # 0x4xxxxxxx : platform-reserved + # 0x8xxxxxxx : transport/reserved + local nv_region_tpm1="unknown" + case "$rollback_counter_id" in + 0001*) nv_region_tpm1="permanent (0x0001xxxx, TPM-reserved)" ;; + 0002*) nv_region_tpm1="legacy user NV (0x0002xxxx)" ;; + 0003*) nv_region_tpm1="legacy user NV (0x0003xxxx)" ;; + 4*) nv_region_tpm1="platform-reserved (0x4xxxxxxx)" ;; + 8*) nv_region_tpm1="transport/reserved (0x8xxxxxxx)" ;; + *) nv_region_tpm1="user NV" ;; + esac + DEBUG "bad_auth: TPM1 NV region for 0x$rollback_counter_id: $nv_region_tpm1" + DEBUG "DA state BEFORE bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" + tpm1_da_state increment_command_output=$(tpm counter_increment -ix "$rollback_counter_id" -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) || increment_exit_code=$? DEBUG "bad_auth: counter_increment rc=$increment_exit_code output='$increment_command_output'" if [ "$increment_exit_code" -ne 0 ]; then if echo "$increment_command_output" | grep -qi 'defend\|lock'; then DEBUG "bad_auth: DA LOCKOUT ACTIVE (rc=$increment_exit_code)" STATUS "bad_auth: DA lockout confirmed. TPM rejected increment (rc=255 = defend lock running)." - local da_state_output da_summary - da_state_output="$(tpm1_da_state 2>/dev/null)" || true - if [ -n "$da_state_output" ]; then - da_summary="$(echo "$da_state_output" | grep '^=> ' | head -1)" - [ -n "$da_summary" ] && STATUS "TPM DA: ${da_summary#=> }" - fi else DEBUG "bad_auth: auth failure (rc=$increment_exit_code = TPM_AUTHFAIL)" echo "Auth failure (rc=$increment_exit_code = TPM_AUTHFAIL, expected with wrong password)." @@ -1393,6 +1424,8 @@ tpm1_bad_auth() { DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS)" echo "UNEXPECTED: wrong password was accepted (rc=0 = TPM_SUCCESS)." fi + DEBUG "DA state AFTER bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" + tpm1_da_state } tpm2_bad_auth() { @@ -1402,6 +1435,38 @@ tpm2_bad_auth() { counter_id=$(grep -Eo 'counter-[0-9a-fA-F]+' /boot/kexec_rollback.txt | \ sed 's/counter-//' | head -1) fi + # Fallback: enumerate NV indices and probe for a counter. Counters in + # TPM2 return exactly 8 bytes (16 hex chars) from nvread; sealed + # objects return larger blobs. Verification uses nvreadpublic to + # confirm TPMA_NV_COUNTER (bit 4 of attributes, value 0x10) so we + # don't mistake a DUK or sealed secret for the rollback counter. + if [ -z "$counter_id" ]; then + local probe_index probe_val probe_attrs + for probe_index in $(tpm2 getcap handles-nv-index 2>/dev/null | \ + grep -oE '0x[0-9a-fA-F]+'); do + # Counter nvread returns exactly 8 bytes (16 hex chars). + probe_val="$(tpm2 nvread "$probe_index" 2>/dev/null | \ + xxd -pc8 2>/dev/null)" || continue + if [ "${#probe_val}" -ne 16 ]; then + continue + fi + # Confirm counter attribute via nvreadpublic. TPMA_NV_COUNTER + # is bit 4 (value 0x10); the attributes field is the second + # numeric column in nvreadpublic output. Match on hex ending + # in 0 or other digits where bit 4 is set. + probe_attrs="$(tpm2 nvreadpublic "$probe_index" 2>/dev/null | \ + awk '/0x[0-9a-fA-F]+/{print}' | grep -oE '0x[0-9a-fA-F]+' | tail -1)" || continue + # Parse the attributes value: TPMA_NV_COUNTER is bit 4 (0x10). + # Strip leading 0x and check that bit 4 is set. + local attrs_hex="${probe_attrs#0x}" + if [ -n "$attrs_hex" ] && \ + [ $((0x${attrs_hex} & 0x10)) -ne 0 ] 2>/dev/null; then + counter_id="${probe_index#0x}" + DEBUG "bad_auth: discovered TPM2 counter at $probe_index via NV enumeration (attrs=$probe_attrs)" + break + fi + done + fi DEBUG "=== BAD AUTH TEST (TPM2) ===" DEBUG "Counter: ${counter_id:-}" DEBUG "DA state BEFORE bad auth:" @@ -1415,9 +1480,40 @@ tpm2_bad_auth() { return 1 fi DEBUG "Attempting increment with wrong passphrase..." - # NV index auth failure (-P) bumps LOCKOUT_COUNTER. Owner auth failure - # (-C o -P) does not bump DA on some TPM2 implementations, so we use -P. - tpm2 nvincrement -P "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" "0x$counter_id" 2>&1 || true + # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. + # Use -C explicitly so the auth context is unambiguous across tpm2-tools + # versions; without -C, -P can be interpreted as owner hierarchy auth on + # older releases. Capture exit code and distinguish lockout from auth- + # failure vs. silent success so the user can tell whether the test + # actually exercised the TPM. + local tpm2_increment_rc tpm2_increment_output + tpm2_increment_output=$(tpm2 nvincrement \ + -C "0x$counter_id" \ + -P "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) \ + || tpm2_increment_rc=$? + tpm2_increment_rc="${tpm2_increment_rc:-0}" + DEBUG "bad_auth: nvincrement rc=$tpm2_increment_rc output='$tpm2_increment_output'" + if [ "$tpm2_increment_rc" -ne 0 ]; then + if echo "$tpm2_increment_output" | grep -qi 'lockout\|lock'; then + DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x22d)" + STATUS "bad_auth: DA lockout confirmed. TPM rejected increment (rc=$tpm2_increment_rc = lockout)." + local da_state_output da_summary + da_state_output="$(tpm2_da_state 2>/dev/null)" || true + if [ -n "$da_state_output" ]; then + da_summary="$(echo "$da_state_output" | grep '^=> ' | head -1)" + [ -n "$da_summary" ] && STATUS "TPM DA: ${da_summary#=> }" + fi + else + DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x22e or similar)" + echo "Auth failure (rc=$tpm2_increment_rc = expected with wrong NV index auth, DA counter bumped)." + echo "Run again to accumulate failures toward DA lockout." + fi + else + DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS) -- wrong password was accepted!" + echo "UNEXPECTED: wrong NV index auth was accepted (rc=0)." + echo "This means the counter does not require NV index auth (authValue is empty)," + echo "or this TPM does not enforce NV index auth on increment. Bad-auth test inconclusive." + fi DEBUG "DA state AFTER bad auth:" tpm2_da_state } From cee20831f3abe9d00a8602bfd8397d469ea11d15 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 13:54:53 -0400 Subject: [PATCH 09/17] gui-init.sh: preflight error menu - auto-height and drop redundant content The preflight error menu was hardcoded to '26 80 4' height. This predates doc/ux-patterns.md's window-sizing rule ('Use 0 for height in all dialogs') and breaks when dialog content grows -- which is exactly what happened when the DA lockout diagnostic was added. On the kgpe-d16_server-whiptail boards (CONFIG_VGA_TEXT_FRAMEBUFFER=y, serial console / VGA text mode 80x25), 26 rows overflows the 25-line terminal and the menu items fall off the visible area. Fix: change '26 80 4' to '0 80 4' (auto-height per doc convention), drop redundant 'Possible causes' and 'WARNING' blocks since preflight_reason already enumerates causes. Signed-off-by: Thierry Laurion --- initrd/bin/gui-init.sh | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/initrd/bin/gui-init.sh b/initrd/bin/gui-init.sh index 39215b512..0fc115d2c 100755 --- a/initrd/bin/gui-init.sh +++ b/initrd/bin/gui-init.sh @@ -1003,15 +1003,6 @@ Cannot verify TPM rollback protection. $preflight_reason -Possible causes: - - TPM was reset or replaced - - /boot disk was swapped or restored - - TPM state tampering occurred - -WARNING: If none of the above were intentional, treat /boot as -UNTRUSTED. A disk or TPM swap attack cannot be ruled out. -Verify integrity before trusting any boot files. - Recommended first step: - Show integrity report (TOTP/HOTP + /boot) @@ -1101,7 +1092,7 @@ EOF _menu_text="$preflight_menu_text" fi whiptail_error --title 'ERROR: TPM State Inconsistent' \ - --menu "$_menu_text" 26 80 4 \ + --menu "$_menu_text" 0 80 4 \ 'i' ' Show integrity report -->' \ 'o' ' OEM Factory Reset / Re-Ownership -->' \ 't' ' Reset the TPM' \ From cd3c92966ebeb1b36a46fda4dc8436665938ccd9 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 13:54:59 -0400 Subject: [PATCH 10/17] doc/ux-patterns.md: document screen-size floor and the 0-height rule Documents the 80x25 VGA text mode floor and references the actual coreboot board configs that hit it (kgpe-d16_server-*). Adds guidance to always use 0 for height in whiptail dialogs. Signed-off-by: Thierry Laurion --- doc/ux-patterns.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/doc/ux-patterns.md b/doc/ux-patterns.md index 08998243a..052e4b97e 100644 --- a/doc/ux-patterns.md +++ b/doc/ux-patterns.md @@ -81,6 +81,47 @@ constants. Any values passed are silently discarded. - For dialogs with static text, use a fixed width (typically `80`). This produces a stable, readable layout in newt and is a no-op in fbwhiptail. +**Always use `0` for height.** Hardcoded heights (e.g. `26 80 4`) are a +historical leftover from before the doc convention existed; they were +tuned for a specific dialog length and silently overflow when content +changes. See "Minimal supported screen sizes" below for the floor that +hardcoded heights can collide with. + +### Minimal supported screen sizes + +Heads must render correctly on the smallest screen configuration any +supported board might use, since dialogs run before the boot menu +reaches the user and a dialog overflow can leave the device stuck. + +| Backend | Floor | Boards that hit it | +|---|---|---| +| fbwhiptail (linear framebuffer) | any (auto-sizes from content) | all boards with `CONFIG_LINEAR_FRAMEBUFFER=y` | +| newt (text framebuffer / serial console) | **80×25** (Linux VGA text mode default; some setups reach 80×50) | `config/coreboot-kgpe-d16_server*.config` (`CONFIG_VGA_TEXT_FRAMEBUFFER=y`) | + +Linear framebuffer sizes currently configured in `config/coreboot*.config`: + +| Resolution | Count | Boards | +|---|---|---| +| 2560×1600 | 21 | T420 / T430 / T440p / **T480** / T480s / OptiPlex 7019/9010 | +| 3840×2160 | 10 | Librem 11/13/14/15, M900, others | + +fbwhiptail backends have plenty of room; the constraint is the newt +text-mode case (kgpe-d16_server boards) where the default 25-row terminal +is the floor. **Dialogs longer than 25 lines will overflow on the +serial console / VGA text-mode boards** even with auto-height, because +newt's `guessSize()` clamps to the terminal size. + +In practice: + +- Aim for **≤15 visible lines** for any user-facing dialog so it fits + comfortably on a 25-line text-mode terminal with margin for title + bar and menu items. +- One paragraph of context, one actionable recommendation, and the + menu items. Avoid restating what the menu items already imply. +- If you find yourself needing more than ~10 lines of context, the + detail probably belongs in `/tmp/debug.log` (LOG level) with a + one-line pointer in the dialog. + In practice: ```bash From 7ad4fa27235367eeb788528ede924331659a8f49 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 13:58:24 -0400 Subject: [PATCH 11/17] bad_auth: fix TPM2 NV region classification -- patterns had 0x prefix The tpm2_bad_auth function strips the '0x' prefix from the counter_id during discovery (counter_id=${probe_index#0x}) because the index is then reused as '0x$counter_id' for tpm2 invocations. But the case patterns in the NV region classifier still started with '0x' (e.g. '0x01*'), so they never matched. For a counter like 0x1180918 (counter_id='1180918'), the function fell through to '*)' and reported 'other' -- wrong. Fix: drop the '0x' prefix from case patterns so they match the stripped counter_id. TPM1 patterns had the same bug but accidentally still resolved to a sensible label ('user NV') via the wildcard fallback, so the misreport went unnoticed. Both TPMs are now consistent. Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index ffb2b2c38..e44a44dce 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1469,7 +1469,18 @@ tpm2_bad_auth() { fi DEBUG "=== BAD AUTH TEST (TPM2) ===" DEBUG "Counter: ${counter_id:-}" - DEBUG "DA state BEFORE bad auth:" + # Classify NV index by TPM2 hierarchy. counter_id has the 0x prefix + # stripped during discovery, so case patterns match without the prefix. + local nv_region_tpm2="unknown" + case "$counter_id" in + 1*) nv_region_tpm2="user-defined (0x01000000-0x01FFFFFF)" ;; + 40*) nv_region_tpm2="TPM-reserved (0x40000000-0x400FFFFF)" ;; + 80*) nv_region_tpm2="persistent (0x80000000-0x803FFFFF)" ;; + 10*) nv_region_tpm2="platform (0x10000000-0x10000FFF)" ;; + *) nv_region_tpm2="other" ;; + esac + DEBUG "TPM2 NV region for 0x$counter_id: $nv_region_tpm2" + DEBUG "DA state BEFORE bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state if [ -z "$counter_id" ]; then DEBUG "No counter ID found. Use tpmr.sh bad_auth ." From 230c1496b11c7688da08feec92d7f3ec3247bd43 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 14:09:46 -0400 Subject: [PATCH 12/17] bad_auth: skip redundant nvread existence check; add STATUS-level progress The post-discovery 'tpm2 nvread "0x$counter_id" >/dev/null 2>&1' existence check is failing silently when TPM is in lockout -- tpm2 nvread returns TPM_RC_LOCKOUT in that state, so the if branches into the WRONG 'Counter does not exist' message and returns 1 without ever attempting the bad auth. This is exactly the failure mode bad_auth is supposed to detect and exercise. Fix: drop the redundant existence check entirely. The NV enumeration in the discovery loop already probed each index with tpm2 nvread and confirmed existence. If the counter was deleted between discovery and now, tpm2 nvincrement will fail with TPM_RC_HANDLE -- still reported through the outcome block. Also add STATUS-level progress lines so the user can see in the kernel ring buffer (not just /tmp/debug.log) that bad_auth is running and what it's doing: - bad_auth (TPM2): testing NV index 0x$counter_id - bad_auth (TPM2): ABORTED -- no counter ID (if discovery failed) Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index e44a44dce..3f0657bb2 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1480,17 +1480,21 @@ tpm2_bad_auth() { *) nv_region_tpm2="other" ;; esac DEBUG "TPM2 NV region for 0x$counter_id: $nv_region_tpm2" + STATUS "bad_auth (TPM2): testing NV index 0x$counter_id" DEBUG "DA state BEFORE bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state if [ -z "$counter_id" ]; then DEBUG "No counter ID found. Use tpmr.sh bad_auth ." + STATUS "bad_auth (TPM2): ABORTED -- no counter ID" return 1 fi - if ! tpm2 nvread "0x$counter_id" >/dev/null 2>&1; then - DEBUG "Counter 0x$counter_id does not exist on this TPM." - return 1 - fi - DEBUG "Attempting increment with wrong passphrase..." + # Skip the existence check. Discovery already proved the counter exists + # (NV enumeration probed each index). A bare 'tpm2 nvread' here would + # silently fail with TPM_RC_LOCKOUT when the TPM is in lockout, causing + # us to exit with "Counter does not exist" without ever running the + # actual bad-auth attempt -- exactly the failure mode this tool exists + # to test. The discovery loop has the check; this one doesn't need it. + DEBUG "Attempting increment with wrong NV index auth on 0x$counter_id..." # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. # Use -C explicitly so the auth context is unambiguous across tpm2-tools # versions; without -C, -P can be interpreted as owner hierarchy auth on @@ -1525,7 +1529,7 @@ tpm2_bad_auth() { echo "This means the counter does not require NV index auth (authValue is empty)," echo "or this TPM does not enforce NV index auth on increment. Bad-auth test inconclusive." fi - DEBUG "DA state AFTER bad auth:" + DEBUG "DA state AFTER bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state } From 87d6ef8ef426f17c3145cf458db95bf83d7bc03a Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 14:13:00 -0400 Subject: [PATCH 13/17] bad_auth: STATUS-level progress lines so test status is visible in all log modes Previous fix added a single STATUS line at the start of bad_auth but the rest of the function's progress was DEBUG-only. In quiet mode (the default for production boots), DEBUG does not go to console -- so the user can see 'bad_auth starting' but not whether nvincrement actually ran, what it returned, or whether the function completed. Replace the critical progress lines with STATUS so the user can follow the entire test in console output (not just /tmp/debug.log): STATUS bad_auth (TPM2): starting -- counter=0x... region=... STATUS bad_auth (TPM2): BEFORE state captured STATUS bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x... STATUS bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=...) STATUS bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=...) -- DA counter bumped STATUS bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive STATUS bad_auth (TPM2): AFTER state -- capturing... STATUS bad_auth (TPM2): DONE If 'DONE' is missing from the console output, the function crashed or hung -- which is now obviously diagnosable from the last STATUS line seen. Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index 3f0657bb2..e9d0aeee0 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1480,9 +1480,10 @@ tpm2_bad_auth() { *) nv_region_tpm2="other" ;; esac DEBUG "TPM2 NV region for 0x$counter_id: $nv_region_tpm2" - STATUS "bad_auth (TPM2): testing NV index 0x$counter_id" + STATUS "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" DEBUG "DA state BEFORE bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state + STATUS "bad_auth (TPM2): BEFORE state captured" if [ -z "$counter_id" ]; then DEBUG "No counter ID found. Use tpmr.sh bad_auth ." STATUS "bad_auth (TPM2): ABORTED -- no counter ID" @@ -1494,7 +1495,7 @@ tpm2_bad_auth() { # us to exit with "Counter does not exist" without ever running the # actual bad-auth attempt -- exactly the failure mode this tool exists # to test. The discovery loop has the check; this one doesn't need it. - DEBUG "Attempting increment with wrong NV index auth on 0x$counter_id..." + STATUS "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. # Use -C explicitly so the auth context is unambiguous across tpm2-tools # versions; without -C, -P can be interpreted as owner hierarchy auth on @@ -1511,26 +1512,24 @@ tpm2_bad_auth() { if [ "$tpm2_increment_rc" -ne 0 ]; then if echo "$tpm2_increment_output" | grep -qi 'lockout\|lock'; then DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x22d)" - STATUS "bad_auth: DA lockout confirmed. TPM rejected increment (rc=$tpm2_increment_rc = lockout)." - local da_state_output da_summary - da_state_output="$(tpm2_da_state 2>/dev/null)" || true - if [ -n "$da_state_output" ]; then - da_summary="$(echo "$da_state_output" | grep '^=> ' | head -1)" - [ -n "$da_summary" ] && STATUS "TPM DA: ${da_summary#=> }" - fi + STATUS "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" else DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x22e or similar)" + STATUS "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" echo "Auth failure (rc=$tpm2_increment_rc = expected with wrong NV index auth, DA counter bumped)." echo "Run again to accumulate failures toward DA lockout." fi else DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS) -- wrong password was accepted!" + STATUS "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" echo "UNEXPECTED: wrong NV index auth was accepted (rc=0)." echo "This means the counter does not require NV index auth (authValue is empty)," echo "or this TPM does not enforce NV index auth on increment. Bad-auth test inconclusive." fi + STATUS "bad_auth (TPM2): AFTER state -- capturing..." DEBUG "DA state AFTER bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state + STATUS "bad_auth (TPM2): DONE" } if [ "$CONFIG_TPM" != "y" ]; then From cc11fb5e903522e0cd68fd2e5ce1f47aeb7d0960 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 16:37:50 -0400 Subject: [PATCH 14/17] bad_auth: emit progress via echo >&2 so it reaches /dev/kmsg; drop TPM1 lockout-exit check STATUS only writes to /dev/console and /tmp/debug.log, so progress lines were invisible in /dev/kmsg capture; the TPM1 counter_read existence check also silently exited on DA lockout. Signed-off-by: Thierry Laurion --- initrd/bin/tpmr.sh | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index e9d0aeee0..fbd2ed506 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1381,11 +1381,11 @@ tpm1_bad_auth() { DEBUG "bad_auth: TPM vendor=\"$vendor_id\" firmware=$rev_major_dec.$rev_minor_dec" fi - if ! tpm counter_read -ix "$rollback_counter_id" >/dev/null 2>&1; then - WARN "Counter $rollback_counter_id not found on this TPM" - return 1 - fi - + # Skip the existence check. A 'tpm counter_read -ix' here would silently + # fail when the TPM is in DA lockout (TPM_DEFEND_LOCK_RUNNING), causing us + # to exit with "Counter not found" without ever running the actual + # bad-auth attempt -- exactly the failure mode this tool exists to test. + # The NV enumeration fallback above already proved the counter exists. # TPM1 exit codes (per tpmtotp counter_increment.c): # 0 = success (unexpected with wrong password) # 1 = TPM_AUTHFAIL (wrong password rejected, DA counter bumped) @@ -1407,25 +1407,32 @@ tpm1_bad_auth() { *) nv_region_tpm1="user NV" ;; esac DEBUG "bad_auth: TPM1 NV region for 0x$rollback_counter_id: $nv_region_tpm1" + echo "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" >&2 DEBUG "DA state BEFORE bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" tpm1_da_state + echo "bad_auth (TPM1): BEFORE state captured" >&2 + echo "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." >&2 increment_command_output=$(tpm counter_increment -ix "$rollback_counter_id" -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) || increment_exit_code=$? DEBUG "bad_auth: counter_increment rc=$increment_exit_code output='$increment_command_output'" if [ "$increment_exit_code" -ne 0 ]; then if echo "$increment_command_output" | grep -qi 'defend\|lock'; then DEBUG "bad_auth: DA LOCKOUT ACTIVE (rc=$increment_exit_code)" - STATUS "bad_auth: DA lockout confirmed. TPM rejected increment (rc=255 = defend lock running)." + echo "bad_auth (TPM1): increment REJECTED by TPM lockout (rc=$increment_exit_code = defend lock running)" >&2 else DEBUG "bad_auth: auth failure (rc=$increment_exit_code = TPM_AUTHFAIL)" + echo "bad_auth (TPM1): increment FAILED with wrong auth (rc=$increment_exit_code) -- DA counter bumped" >&2 echo "Auth failure (rc=$increment_exit_code = TPM_AUTHFAIL, expected with wrong password)." echo "Run again to accumulate failures toward DA lockout." fi else DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS)" + echo "bad_auth (TPM1): increment SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" >&2 echo "UNEXPECTED: wrong password was accepted (rc=0 = TPM_SUCCESS)." fi + echo "bad_auth (TPM1): AFTER state -- capturing..." >&2 DEBUG "DA state AFTER bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" tpm1_da_state + echo "bad_auth (TPM1): DONE" >&2 } tpm2_bad_auth() { @@ -1480,10 +1487,10 @@ tpm2_bad_auth() { *) nv_region_tpm2="other" ;; esac DEBUG "TPM2 NV region for 0x$counter_id: $nv_region_tpm2" - STATUS "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" + echo "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" >&2 DEBUG "DA state BEFORE bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state - STATUS "bad_auth (TPM2): BEFORE state captured" + echo "bad_auth (TPM2): BEFORE state captured" >&2 if [ -z "$counter_id" ]; then DEBUG "No counter ID found. Use tpmr.sh bad_auth ." STATUS "bad_auth (TPM2): ABORTED -- no counter ID" @@ -1495,7 +1502,7 @@ tpm2_bad_auth() { # us to exit with "Counter does not exist" without ever running the # actual bad-auth attempt -- exactly the failure mode this tool exists # to test. The discovery loop has the check; this one doesn't need it. - STATUS "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." + echo "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." >&2 # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. # Use -C explicitly so the auth context is unambiguous across tpm2-tools # versions; without -C, -P can be interpreted as owner hierarchy auth on @@ -1512,24 +1519,24 @@ tpm2_bad_auth() { if [ "$tpm2_increment_rc" -ne 0 ]; then if echo "$tpm2_increment_output" | grep -qi 'lockout\|lock'; then DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x22d)" - STATUS "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" + echo "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" >&2 else DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x22e or similar)" - STATUS "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" + echo "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" >&2 echo "Auth failure (rc=$tpm2_increment_rc = expected with wrong NV index auth, DA counter bumped)." echo "Run again to accumulate failures toward DA lockout." fi else DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS) -- wrong password was accepted!" - STATUS "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" + echo "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" >&2 echo "UNEXPECTED: wrong NV index auth was accepted (rc=0)." echo "This means the counter does not require NV index auth (authValue is empty)," echo "or this TPM does not enforce NV index auth on increment. Bad-auth test inconclusive." fi - STATUS "bad_auth (TPM2): AFTER state -- capturing..." + echo "bad_auth (TPM2): AFTER state -- capturing..." >&2 DEBUG "DA state AFTER bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state - STATUS "bad_auth (TPM2): DONE" + echo "bad_auth (TPM2): DONE" >&2 } if [ "$CONFIG_TPM" != "y" ]; then From fac9233a1f37053942156ea0023480515bc15398 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 17:34:35 -0400 Subject: [PATCH 15/17] bad_auth: dual-emit progress markers to DEBUG for /dev/kmsg visibility STATUS and 'echo >&2' progress lines in tpm1_bad_auth and tpm2_bad_auth reach /dev/console and /tmp/debug.log, but neither channel reaches /dev/kmsg in any output mode (see doc/logging.md). When CONFIG_DEBUG_OUTPUT=y, the kernel forwards /dev/kmsg to all registered consoles -- including serial and BMC -- so a user capturing /dev/kmsg for post-mortem analysis sees only the existing DEBUG lines, missing the structured progress markers that announce what bad_auth is doing and what it observed. Add a DEBUG "..." companion line immediately after each progress marker (start, BEFORE state captured, attempt, REJECTED/FAILED/ SUCCEEDED, AFTER state capturing, DONE, ABORTED) in both functions. The text matches the STATUS/echo text verbatim. The DEBUG channel writes to /tmp/debug.log always and to /dev/kmsg + /dev/console when CONFIG_DEBUG_OUTPUT=y, so every progress marker now lands in any /dev/kmsg capture alongside the existing diagnostic DEBUG lines. doc/tpm.md: add '### Output and visibility' subsection under the tpmr.sh bad_auth section explaining the dual-channel pattern and why both are emitted. Doc now matches code. Signed-off-by: Thierry Laurion --- doc/tpm.md | 22 ++++++++++++++++++++++ initrd/bin/tpmr.sh | 17 +++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/doc/tpm.md b/doc/tpm.md index 16abd6660..665ee78f8 100644 --- a/doc/tpm.md +++ b/doc/tpm.md @@ -569,6 +569,28 @@ Distinguishes auth-failure from active lockout via the `da_state` output. Primary tool for reproducing and verifying lockout detection end-to-end on both TPM versions. +### Output and visibility + +Each progress marker (start, BEFORE state captured, attempt, +outcome, AFTER state capturing, DONE, ABORTED) is emitted in +two channels so it reaches both the user's terminal and any +`/dev/kmsg` capture (e.g. for post-mortem analysis without +serial access): + +* `STATUS` (or `echo ... >&2`) writes to `/dev/console` and + `/tmp/debug.log` — always visible to a user on the framebuffer + console in any output mode (see doc/logging.md). +* A matching `DEBUG "..."` line writes to `/tmp/debug.log` and + (when `CONFIG_DEBUG_OUTPUT=y`) also to `/dev/kmsg` + + `/dev/console`. The `DEBUG` companion is the channel that + reaches `/dev/kmsg`, which `/dev/console` and `STATUS` do not. + +The two channels carry identical text, so the order in any +post-mortem log file matches the order on screen. If only the +`DEBUG` line is visible (e.g. capture started mid-test), the +missing `STATUS`/echo line is implied by the surrounding DEBUG +context. + ### Marker-file protocol When any TPM-gated code path detects lockout, it sets the diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index fbd2ed506..dd3ddf7cc 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1408,31 +1408,39 @@ tpm1_bad_auth() { esac DEBUG "bad_auth: TPM1 NV region for 0x$rollback_counter_id: $nv_region_tpm1" echo "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" >&2 + DEBUG "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" DEBUG "DA state BEFORE bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" tpm1_da_state echo "bad_auth (TPM1): BEFORE state captured" >&2 + DEBUG "bad_auth (TPM1): BEFORE state captured" echo "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." >&2 + DEBUG "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." increment_command_output=$(tpm counter_increment -ix "$rollback_counter_id" -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) || increment_exit_code=$? DEBUG "bad_auth: counter_increment rc=$increment_exit_code output='$increment_command_output'" if [ "$increment_exit_code" -ne 0 ]; then if echo "$increment_command_output" | grep -qi 'defend\|lock'; then DEBUG "bad_auth: DA LOCKOUT ACTIVE (rc=$increment_exit_code)" echo "bad_auth (TPM1): increment REJECTED by TPM lockout (rc=$increment_exit_code = defend lock running)" >&2 + DEBUG "bad_auth (TPM1): increment REJECTED by TPM lockout (rc=$increment_exit_code = defend lock running)" else DEBUG "bad_auth: auth failure (rc=$increment_exit_code = TPM_AUTHFAIL)" echo "bad_auth (TPM1): increment FAILED with wrong auth (rc=$increment_exit_code) -- DA counter bumped" >&2 + DEBUG "bad_auth (TPM1): increment FAILED with wrong auth (rc=$increment_exit_code) -- DA counter bumped" echo "Auth failure (rc=$increment_exit_code = TPM_AUTHFAIL, expected with wrong password)." echo "Run again to accumulate failures toward DA lockout." fi else DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS)" echo "bad_auth (TPM1): increment SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" >&2 + DEBUG "bad_auth (TPM1): increment SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" echo "UNEXPECTED: wrong password was accepted (rc=0 = TPM_SUCCESS)." fi echo "bad_auth (TPM1): AFTER state -- capturing..." >&2 + DEBUG "bad_auth (TPM1): AFTER state -- capturing..." DEBUG "DA state AFTER bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" tpm1_da_state echo "bad_auth (TPM1): DONE" >&2 + DEBUG "bad_auth (TPM1): DONE" } tpm2_bad_auth() { @@ -1488,12 +1496,15 @@ tpm2_bad_auth() { esac DEBUG "TPM2 NV region for 0x$counter_id: $nv_region_tpm2" echo "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" >&2 + DEBUG "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" DEBUG "DA state BEFORE bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state echo "bad_auth (TPM2): BEFORE state captured" >&2 + DEBUG "bad_auth (TPM2): BEFORE state captured" if [ -z "$counter_id" ]; then DEBUG "No counter ID found. Use tpmr.sh bad_auth ." STATUS "bad_auth (TPM2): ABORTED -- no counter ID" + DEBUG "bad_auth (TPM2): ABORTED -- no counter ID" return 1 fi # Skip the existence check. Discovery already proved the counter exists @@ -1503,6 +1514,7 @@ tpm2_bad_auth() { # actual bad-auth attempt -- exactly the failure mode this tool exists # to test. The discovery loop has the check; this one doesn't need it. echo "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." >&2 + DEBUG "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. # Use -C explicitly so the auth context is unambiguous across tpm2-tools # versions; without -C, -P can be interpreted as owner hierarchy auth on @@ -1520,23 +1532,28 @@ tpm2_bad_auth() { if echo "$tpm2_increment_output" | grep -qi 'lockout\|lock'; then DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x22d)" echo "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" >&2 + DEBUG "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" else DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x22e or similar)" echo "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" >&2 + DEBUG "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" echo "Auth failure (rc=$tpm2_increment_rc = expected with wrong NV index auth, DA counter bumped)." echo "Run again to accumulate failures toward DA lockout." fi else DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS) -- wrong password was accepted!" echo "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" >&2 + DEBUG "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" echo "UNEXPECTED: wrong NV index auth was accepted (rc=0)." echo "This means the counter does not require NV index auth (authValue is empty)," echo "or this TPM does not enforce NV index auth on increment. Bad-auth test inconclusive." fi echo "bad_auth (TPM2): AFTER state -- capturing..." >&2 + DEBUG "bad_auth (TPM2): AFTER state -- capturing..." DEBUG "DA state AFTER bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" tpm2_da_state echo "bad_auth (TPM2): DONE" >&2 + DEBUG "bad_auth (TPM2): DONE" } if [ "$CONFIG_TPM" != "y" ]; then From a76df9c6a7560a2d1fc6c9b4af993ce63cc32af2 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Tue, 8 Sep 2026 19:58:40 -0400 Subject: [PATCH 16/17] bad_auth: fix TPM2 lockout detection hex constant and restructure for reliable detection The TPM2 lockout grep used 0x22d which is not a valid TPM2_RC. TPM_RC_LOCKOUT is defined as 0x921 (TPM2_RC_WARN 0x900 + 0x021) in tpm2-tss headers. On a real locked TPM, tpm2-tools prints Esys Finish ErrorCode (0x00000921) -- the old pattern never matched and lockout was silently misclassified as an auth failure. Fix the grep to match 0x921 (the real TCG-spec constant) alongside the textual lockout/TPM_RC_LOCKOUT patterns. Fix all 0x22d references in code and doc to 0x921. Fix the sample tpm2 error output in doc to match the actual ESAPI format string. Restructure bad_auth (both TPM1 and TPM2) to do the increment attempt FIRST and capture da_state only AFTER. The BEFORE da_state capture was removed because tpm2 getcap properties-variable blocks indefinitely against a wedged PTT (Esys_GetCapability forces timeout=-1 in tpm2-tss). The increment attempt itself is the test -- it returns promptly with TPM_RC_LOCKOUT 0x921 (TPM2) or TPM_DEFEND_LOCK_RUNNING 0x803 (TPM1) when the TPM is already locked out. The AFTER da_state query runs unconditionally and captures the timer for recovery guidance. Also: capture stderr from lockout-detection TPM queries instead of redirecting to /dev/null. The 4 getcapability sites (tpm1_da_state version, tpm1_da_state DA, tpm2_da_state, tpm1_bad_auth version) now use TMP_STDERR capture with DEBUG logging and unconditional cleanup. The 3 cleanup shapes (simple, rc-capturing, if/then/else) are documented in ux-patterns.md. doc/tpm.md: update Output and visibility to drop the obsolete BEFORE state marker; add Strategy markers subsection documenting the DEBUG-only design choice; add Lockout detection on TPM2 subsection explaining the 0x921 hex pattern and why it is needed. doc/ux-patterns.md: add Capturing TPM command stderr subsection with the three acceptable TMP_STDERR cleanup shapes (A/B/C). Signed-off-by: Thierry Laurion --- doc/tpm.md | 79 ++++++++++++++++++++++++++++++---- doc/ux-patterns.md | 104 +++++++++++++++++++++++++++++++++++++++++++++ initrd/bin/tpmr.sh | 55 ++++++++++++++++-------- 3 files changed, 211 insertions(+), 27 deletions(-) diff --git a/doc/tpm.md b/doc/tpm.md index 665ee78f8..37c5a7f7c 100644 --- a/doc/tpm.md +++ b/doc/tpm.md @@ -565,17 +565,22 @@ tpmr.sh bad_auth # explicit counter Deliberately attempts a counter increment with a wrong passphrase, bumping the TPM's DA failedTries counter on demand. -Distinguishes auth-failure from active lockout via the -`da_state` output. Primary tool for reproducing and verifying -lockout detection end-to-end on both TPM versions. +Distinguishes auth-failure from active lockout by the +return code of the increment itself — if the TPM is already +in DA lockout, nvincrement returns TPM_RC_LOCKOUT (TPM2) or +TPM_DEFEND_LOCK_RUNNING (TPM1) immediately, and bad_auth +reports that without spending extra time on a TPM query that +would hang against a wedged response. After a real auth +attempt, `da_state` is queried once for the AFTER timer. +Primary tool for reproducing and verifying lockout detection +end-to-end on both TPM versions. ### Output and visibility -Each progress marker (start, BEFORE state captured, attempt, -outcome, AFTER state capturing, DONE, ABORTED) is emitted in -two channels so it reaches both the user's terminal and any -`/dev/kmsg` capture (e.g. for post-mortem analysis without -serial access): +Each user-facing progress marker (start, attempt, outcome, AFTER +state capturing, DONE, ABORTED) is emitted in two channels so it +reaches both the user's terminal and any `/dev/kmsg` capture (e.g. +for post-mortem analysis without serial access): * `STATUS` (or `echo ... >&2`) writes to `/dev/console` and `/tmp/debug.log` — always visible to a user on the framebuffer @@ -588,9 +593,65 @@ serial access): The two channels carry identical text, so the order in any post-mortem log file matches the order on screen. If only the `DEBUG` line is visible (e.g. capture started mid-test), the -missing `STATUS`/echo line is implied by the surrounding DEBUG +missing `STATUS`/`echo` line is implied by the surrounding DEBUG context. +### Strategy markers — DEBUG only + +Two markers in `bad_auth` are emitted as DEBUG-only (no +`echo >&2` companion) because they describe test strategy, not +a user-visible event: + +* `bad_auth (TPM1): no BEFORE state capture — counter_increment is the + actual test, not the da_state query` +* `bad_auth (TPM2): no BEFORE state capture — nvincrement is the + actual test, not the da_state query` + +`bad_auth` deliberately skips the BEFORE `da_state` capture because +`tpm2 getcap properties-variable` and the TPM1 `getcapability +-cap 0x19` query both block indefinitely against a wedged PTT +lockout state (see `Esys_GetCapability` in tpm2-tss which forces +`timeout = -1` for `_Finish`). The increment attempt itself is +the actual test -- it returns promptly with +`TPM_RC_LOCKOUT 0x921` (TPM2, exit 1) or +`TPM_DEFEND_LOCK_RUNNING 0x803` (TPM1, exit 255) when the +TPM is already locked out. The AFTER `da_state` query runs +unconditionally after every attempt and captures the current +DA counter / threshold / timer -- on a wedged TPM this +call may also block, but it is the only path to surface the +timer information for lockout recovery guidance. + +### Lockout detection on TPM2 — what to look for + +`tpm2-tools` 5.6 does NOT print the literal string "lockout" when +`nvincrement` is rejected by a locked TPM. Its error output looks +like: + +``` +ERROR: Failed to increment NV counter at index 0x1180918 +ERROR: Esys Finish failed: Tss2_ESys_NV_Increment (0x00000921) +``` + +`tpm2_bad_auth` matches the lockout case with +`grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*22d'`, which catches: + +* `lockout`/`TPM_RC_LOCKOUT` — sometimes emitted by the kernel TPM + driver at the transport layer (PTT) or by the tpm2-tools build + when configured for verbose diagnostics. +* `0x921` / `0x00000921` / `0921` — the TCG-spec constant for + `TPM_RC_LOCKOUT`, always emitted by tpm2 `Esys Finish` on a + locked TPM. + +Without the hex pattern, the detection degenerates to "does the +kernel driver happen to surface the word `lock`?" — which is not +reliable across PTT, CR50, swtpm, and discrete TPMs. The hex +pattern is the spec-anchored fallback. + +On TPM1, `tpm counter_increment` returns cleanly with exit code 255 +and prints `TPM_DEFEND_LOCK_RUNNING` in its error message — the +existing `grep -qi 'defend\|lock'` in `tpm1_bad_auth` matches that +reliably without an rc-string patch. + ### Marker-file protocol When any TPM-gated code path detects lockout, it sets the diff --git a/doc/ux-patterns.md b/doc/ux-patterns.md index 052e4b97e..6fc4b4a3e 100644 --- a/doc/ux-patterns.md +++ b/doc/ux-patterns.md @@ -400,6 +400,110 @@ echo "$index: $hex_val" echo "$index: $(tpm2 nvread 0x$index | xxd -pc8)" ``` +### Capturing TPM command stderr + +For TPM queries whose stderr is the diagnostic (e.g. `tpm2 getcap`, +`tpm getcapability`, `tpm2 dictionarylockout`), **do not redirect +stderr to `/dev/null`** — that swallows the specific failure reason +the user needs to diagnose lockout, auth-fail, or device-busy errors. + +Use a captured stderr file with explicit logging at DEBUG level: + +```bash +# CORRECT — stderr captured to file, logged on failure +TMP_STDERR="$(mktemp)" +if cap_out="$(tpm2 getcap properties-variable 2>"$TMP_STDERR")"; then + rm -f "$TMP_STDERR" +else + local rc=$? + DEBUG "tpm2_da_state: getcap stderr: $(cat "$TMP_STDERR" 2>/dev/null)" + rm -f "$TMP_STDERR" + return 1 +fi + +# WRONG — silent stderr suppression hides TPM_RC_LOCKOUT vs. busy vs. no-access +cap_out="$(tpm2 getcap properties-variable 2>/dev/null)" || return 1 +``` + +The captured stderr file means the diagnostic reaches both the +debug log (and `/dev/kmsg` in debug mode, where the user captures +it) and any subsequent `grep` for specific error patterns like +`lockout|lock|RC_LOCKOUT`. + +`2>/dev/null` IS appropriate for enumeration loops (probing every +NV index, where most probes are expected to fail) — see +`tpm2_bad_auth` and `tpm1_bad_auth` counter-discovery loops for +examples of legitimately-quiet enumeration with `|| continue`. + +Mirror this rule in any TPM-gated code path; do not rely on the +script's caller to interpret a swallowed failure mode. + +### Three acceptable shapes for TPM command stderr + +The codebase uses three shapes for `TMP_STDERR` capture in TPM +queries. All three log stderr at DEBUG on failure and clean up +the temp file on every code path; choose based on what else the +call site needs: + +#### Shape A: simple stderr capture (no rc needed) + +For version / identity queries whose rc the caller doesn't +inspect, use the lightweight shape: + +```bash +TMP_STDERR="$(mktemp)" +ver_output="$(tpm getcapability -cap 0x1a 2>"$TMP_STDERR")" || { + DEBUG "...failed (rc=$?, stderr: $(cat "$TMP_STDERR" 2>/dev/null))" +} +[ -s "$TMP_STDERR" ] && DEBUG "...stderr: $(cat "$TMP_STDERR")" +rm -f "$TMP_STDERR" +``` + +Used at: `tpm1_da_state` and `tpm1_bad_auth` version-query sites. + +#### Shape B: capture rc + stderr + +When the caller inspects the exit code (e.g. distinguishes +`TPM_BAD_MODE` 44 from other failures), keep the `|| rc=$?` +pattern and add stderr capture: + +```bash +TMP_STDERR="$(mktemp)" +da_out="$(tpm getcapability -cap 0x19 -scap 0x0000 2>"$TMP_STDERR")" || rc=$? +if [ -n "$TMP_STDERR" ]; then + [ -s "$TMP_STDERR" ] && DEBUG "...stderr: $(cat "$TMP_STDERR")" + rm -f "$TMP_STDERR" +fi +``` + +Used at: `tpm1_da_state` DA-query site. + +#### Shape C: if/then/else on the assignment + +For TPM2 queries whose success/failure branches diverge visibly +(log different messages, return early, escalate to WARN): + +```bash +TMP_STDERR="$(mktemp)" +if cap_out="$(tpm2 getcap properties-variable 2>"$TMP_STDERR")"; then + rm -f "$TMP_STDERR" +else + local rc=$? + WARN "... (tpm2 getcap rc=$rc)" + DEBUG "...stderr: $(cat "$TMP_STDERR" 2>/dev/null)" + rm -f "$TMP_STDERR" + return 1 +fi +``` + +Used at: `tpm2_da_state`. + +All three shapes share two invariants: + +1. `TMP_STDERR=$(mktemp)` is created exactly once per call site. +2. `rm -f "$TMP_STDERR"` runs on every code path (success, failure, + early return, branch exit). + --- ## `HEADS_TTY` — terminal device routing diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index dd3ddf7cc..2e8620d23 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -1191,7 +1191,12 @@ tpm1_da_state() { local ver_output vendor_id rev_major rev_minor rev_major_dec rev_minor_dec # Log TPM chip identity for diagnostics (works on all TPM 1.2 chips). - ver_output="$(tpm getcapability -cap 0x1a 2>/dev/null)" || true + TMP_STDERR="$(mktemp)" + ver_output="$(tpm getcapability -cap 0x1a 2>"$TMP_STDERR")" || { + DEBUG "tpm1_da_state: getcapability -cap 0x1a failed (rc=$?, stderr: $(cat "$TMP_STDERR" 2>/dev/null))" + } + [ -s "$TMP_STDERR" ] && DEBUG "tpm1_da_state: getcapability stderr: $(cat "$TMP_STDERR")" + rm -f "$TMP_STDERR" if [ -n "$ver_output" ]; then vendor_id="$(echo "$ver_output" | grep 'VendorID' | tail -1 | sed 's/.*: *//')" rev_major="$(echo "$ver_output" | grep 'revMajor' | sed 's/.*: 0x//')" @@ -1201,7 +1206,12 @@ tpm1_da_state() { DEBUG "tpm1_da_state: TPM vendor=\"$vendor_id\" firmware=$rev_major_dec.$rev_minor_dec" fi - da_out="$(tpm getcapability -cap 0x19 -scap 0x0000 2>/dev/null)" || rc=$? + TMP_STDERR="$(mktemp)" + da_out="$(tpm getcapability -cap 0x19 -scap 0x0000 2>"$TMP_STDERR")" || rc=$? + if [ -n "$TMP_STDERR" ]; then + [ -s "$TMP_STDERR" ] && DEBUG "tpm1_da_state: getcapability stderr: $(cat "$TMP_STDERR")" + rm -f "$TMP_STDERR" + fi if [ -z "$da_out" ] || ! echo "$da_out" | grep -q 'State'; then [ -n "${rc-}" ] && DEBUG "tpm1_da_state: getcapability exit=$rc" if [ "${rc-}" = "44" ]; then @@ -1262,11 +1272,16 @@ tpm1_da_state() { tpm2_da_state() { TRACE_FUNC local cap_out da_out counter_hex max_auth_hex interval_hex recovery_hex counter max_auth interval recovery fw_ver - cap_out="$(tpm2 getcap properties-variable 2>/dev/null)" || { - WARN "Unable to query TPM2 dictionary attack state" - DEBUG "tpm2_da_state: getcap properties-variable failed (no TPM access?)" + TMP_STDERR="$(mktemp)" + if cap_out="$(tpm2 getcap properties-variable 2>"$TMP_STDERR")"; then + rm -f "$TMP_STDERR" + else + local rc=$? + WARN "Unable to query TPM2 dictionary attack state (tpm2 getcap rc=$rc)" + DEBUG "tpm2_da_state: getcap properties-variable stderr: $(cat "$TMP_STDERR" 2>/dev/null)" + rm -f "$TMP_STDERR" return 1 - } + fi fw_ver="$(echo "$cap_out" | grep 'TPM2_PT_FIRMWARE_VERSION_1' | sed 's/.*0x//')" [ -n "$fw_ver" ] && DEBUG "tpm2_da_state: TPM firmware version: $(printf '%d.%d' $((0x${fw_ver}>>16)) $((0x${fw_ver}&0xffff)) 2>/dev/null)" da_out="$(echo "$cap_out" | grep -E \ @@ -1371,7 +1386,12 @@ tpm1_bad_auth() { return 1 fi - ver_output="$(tpm getcapability -cap 0x1a 2>/dev/null)" || true + TMP_STDERR="$(mktemp)" + ver_output="$(tpm getcapability -cap 0x1a 2>"$TMP_STDERR")" || { + DEBUG "tpm1_bad_auth: getcapability -cap 0x1a failed (rc=$?, stderr: $(cat "$TMP_STDERR" 2>/dev/null))" + } + [ -s "$TMP_STDERR" ] && DEBUG "tpm1_bad_auth: getcapability stderr: $(cat "$TMP_STDERR")" + rm -f "$TMP_STDERR" if [ -n "$ver_output" ]; then vendor_id="$(echo "$ver_output" | grep 'VendorID' | tail -1 | sed 's/.*: *//')" rev_major="$(echo "$ver_output" | grep 'revMajor' | sed 's/.*: 0x//')" @@ -1409,10 +1429,7 @@ tpm1_bad_auth() { DEBUG "bad_auth: TPM1 NV region for 0x$rollback_counter_id: $nv_region_tpm1" echo "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" >&2 DEBUG "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" - DEBUG "DA state BEFORE bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" - tpm1_da_state - echo "bad_auth (TPM1): BEFORE state captured" >&2 - DEBUG "bad_auth (TPM1): BEFORE state captured" + DEBUG "bad_auth (TPM1): no BEFORE state capture — counter_increment is the actual test, not the da_state query" echo "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." >&2 DEBUG "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." increment_command_output=$(tpm counter_increment -ix "$rollback_counter_id" -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) || increment_exit_code=$? @@ -1497,10 +1514,6 @@ tpm2_bad_auth() { DEBUG "TPM2 NV region for 0x$counter_id: $nv_region_tpm2" echo "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" >&2 DEBUG "bad_auth (TPM2): starting -- counter=0x$counter_id region=$nv_region_tpm2" - DEBUG "DA state BEFORE bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" - tpm2_da_state - echo "bad_auth (TPM2): BEFORE state captured" >&2 - DEBUG "bad_auth (TPM2): BEFORE state captured" if [ -z "$counter_id" ]; then DEBUG "No counter ID found. Use tpmr.sh bad_auth ." STATUS "bad_auth (TPM2): ABORTED -- no counter ID" @@ -1513,6 +1526,7 @@ tpm2_bad_auth() { # us to exit with "Counter does not exist" without ever running the # actual bad-auth attempt -- exactly the failure mode this tool exists # to test. The discovery loop has the check; this one doesn't need it. + DEBUG "bad_auth (TPM2): no BEFORE state capture — nvincrement is the actual test, not the da_state query" echo "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." >&2 DEBUG "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. @@ -1529,12 +1543,17 @@ tpm2_bad_auth() { tpm2_increment_rc="${tpm2_increment_rc:-0}" DEBUG "bad_auth: nvincrement rc=$tpm2_increment_rc output='$tpm2_increment_output'" if [ "$tpm2_increment_rc" -ne 0 ]; then - if echo "$tpm2_increment_output" | grep -qi 'lockout\|lock'; then - DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x22d)" + # Match either the kernel/driver's text ("lockout", "lock") or the + # canonical TPM_RC_LOCKOUT return code (0x921) printed by tpm2-tools. + # The lockout text is unreliable across TPM2 stacks (PTT, CR50, + # swtpm all differ); the 0x921 code is the TCG-spec constant and is + # reliably emitted by tpm2 Esys Finish on a locked TPM. + if echo "$tpm2_increment_output" | grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*921'; then + DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x921)" echo "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" >&2 DEBUG "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" else - DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x22e or similar)" + DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x91C or similar)" echo "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" >&2 DEBUG "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" echo "Auth failure (rc=$tpm2_increment_rc = expected with wrong NV index auth, DA counter bumped)." From 2625de34c73a9e38573ec4fdd7993e45c7b17316 Mon Sep 17 00:00:00 2001 From: Thierry Laurion Date: Thu, 10 Sep 2026 19:04:55 -0400 Subject: [PATCH 17/17] DA lockout: canonical user copy, wait-by-default countdown dialogs, honest recovery docs - da_lockout_msg: canonical "X of N allowed auth attempts used; one attempt frees up after Z; ~Z until auth works again" body, shared by the boot dialog, TOTP failure dialog, preflight WARN, and recovery shell; extended with causes and reset reseal guidance lines. - Both lockout dialogs (boot menu and update_totp) default to Waiting with a live countdown refresh option ("w"), re-query da_state each pass, and lift automatically once the counter drops below maxTries (TPM2 self-heal). - set_tpm_reset_required / increment_tpm_counter / fail_preflight gate the DA marker file and route lockout to the dedicated dialog instead of generic reseal asks or rollback-counter wording. - da_state/da_remaining report raw seconds from the TPM policy (LOCKOUT_INTERVAL, not recovery), with getcapability fallback and truthful "duration unknown" handling. - doc/tpm.md + doc/ux-patterns.md: document LOCKOUT_INTERVAL semantics, fixed-policy self-heal (one failed attempt forgotten per recoveryTime), the marker-file protocol, and the 80x25 screen-size floor. Signed-off-by: Thierry Laurion --- doc/tpm.md | 76 ++++- doc/ux-patterns.md | 7 + initrd/bin/gui-init.sh | 277 +++++++++++++------ initrd/bin/tpmr.sh | 595 ++++++++++++++++++++++++++++++---------- initrd/etc/functions.sh | 327 ++++++++++++++++++---- 5 files changed, 975 insertions(+), 307 deletions(-) diff --git a/doc/tpm.md b/doc/tpm.md index 37c5a7f7c..0e092c9ba 100644 --- a/doc/tpm.md +++ b/doc/tpm.md @@ -549,8 +549,14 @@ and TPM2, plus a machine-parsable summary line: STM returning `TPM_BAD_MODE 44`), reports "unavailable". * TPM2 (`tpm2_da_state`): queries `getcap properties-variable` for `TPM2_PT_LOCKOUT_COUNTER`, `MAX_AUTH_FAIL`, - `LOCKOUT_INTERVAL`, and `LOCKOUT_RECOVERY`. When locked, - estimates time-to-unlock as `(counter - maxAuth + 1) * interval`. + `LOCKOUT_INTERVAL`, and `LOCKOUT_RECOVERY`. When locked, the + `timer=` field carries `LOCKOUT_INTERVAL` (seconds between DA + counter decrements — the recoveryTime Heads deploys), not + `LOCKOUT_RECOVERY` (that governs lockoutAuth password blocking + and is often 0). No TPM2 command reports remaining lockout + time, so the user-facing countdown is computed as + interval − elapsed since lockout start, clamped to + [0, interval] (`tpmr.sh da_remaining`). Both functions emit a final `DA: state=… current=… threshold=… timer=…` line for machine parsing. The preflight guard and the @@ -559,8 +565,10 @@ recovery shell rely on this line. ### `tpmr.sh bad_auth` — manual reproducer ``` -tpmr.sh bad_auth # uses counter from /boot/kexec_rollback.txt -tpmr.sh bad_auth # explicit counter +tpmr.sh bad_auth # uses counter from /boot/kexec_rollback.txt +tpmr.sh bad_auth # explicit counter +tpmr.sh bad_auth --until-lockout [counter_id] # loop until DA lockout is reported +tpmr.sh bad_auth --until-esc [counter_id] # loop, printing da_state, until ESC ``` Deliberately attempts a counter increment with a wrong @@ -575,6 +583,30 @@ attempt, `da_state` is queried once for the AFTER timer. Primary tool for reproducing and verifying lockout detection end-to-end on both TPM versions. +Two optional, mutually-exclusive loop modes extend the single +increment: + +* `--until-lockout` loops the deliberate bad-auth attempt, bumping the + DA counter each iteration until the increment itself reports DA + lockout. The stop condition is the increment output (`defend|lock` + on TPM1, `lockout|TPM_RC_LOCKOUT|0x?0*921` on TPM2) — it does not + depend on `getcap`/`da_state`, so it stops promptly even on a wedged + PTT where `getcap` would block. On lockout it stops and reports via + the normal AFTER `da_state` path. +* `--until-esc` loops the deliberate bad-auth attempt, printing + `da_state` output (the real remaining lockout time) after each + increment, until the user presses ESC. + +Both modes reproduce a lockout from a clean state without the caller +writing a manual `while` loop — useful for confirming the lockout +threshold, the prompt `TPM_RC_LOCKOUT` return, and the AFTER +`da_state` timer on real hardware. + +Before the first increment, `bad_auth` prints a warning that the +test deliberately triggers DA lockout and requires confirmation +(press Enter to continue, or ESC to cancel) so the operator cannot +lock the TPM out by accident. + ### Output and visibility Each user-facing progress marker (start, attempt, outcome, AFTER @@ -633,7 +665,7 @@ ERROR: Esys Finish failed: Tss2_ESys_NV_Increment (0x00000921) ``` `tpm2_bad_auth` matches the lockout case with -`grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*22d'`, which catches: +`grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*921'`, which catches: * `lockout`/`TPM_RC_LOCKOUT` — sometimes emitted by the kernel TPM driver at the transport layer (PTT) or by the tpm2-tools build @@ -655,19 +687,25 @@ reliably without an rc-string patch. ### Marker-file protocol When any TPM-gated code path detects lockout, it sets the -marker file `/tmp/secret/tpm_da_lockout` and (if a timer is -known) `/tmp/secret/tpm_da_lockout_msg`. The marker is consumed +marker file `/tmp/secret/tpm_da_lockout`. The marker is consumed by: * `gui-init.sh` early boot STATUS line — surfaces the timer before any auth attempt that could extend it. * `preflight_rollback_counter_before_reseal` error menu (in `gui-init.sh`) — replaces the generic "TPM swap attack" - warning with a lockout-specific dialog explaining common - causes and showing remaining backoff time. + warning with a lockout-specific dialog built by + `da_lockout_msg`: the X/N failed-attempts count, the + recovery duration (Z), a line3 describing when auth + becomes available again, plus a one-line cause hint + (repeated auth failures, unclean power off/reset) and a + reset note (reseals secrets). **Waiting is the default** — + the first menu item (`w`) re-queries `da_remaining` and + re-displays the countdown, exiting automatically once the + counter drops back below maxTries. * `update_totp` (in `gui-init.sh`) — replaces the generic "TOTP Generation Failed!" alarming message with a lockout- - specific dialog. + specific dialog (same `da_lockout_msg` shape). * `recovery()` (in `functions.sh`) — emits a STATUS line with the `da_state` summary so users who drop to recovery see remaining time immediately. @@ -689,9 +727,21 @@ failures re-set it cleanly from scratch via the gate. ### Recovery -* TPM2 lockout clears after the TCG backoff timer expires - (TCG-standard exponential: seconds → minutes → hours). Power - cycling does **not** clear TPM2 lockout state. + + +* TPM2 lockout self-heals: with no new failures, the DA counter + decrements by one every recoveryTime (Heads resets it to + `recoveryTime=3600`, i.e. `LOCKOUT_INTERVAL`), so one attempt + frees up after that window and lockout lifts once the counter + drops below maxTries. Power cycling does **not** clear TPM2 + lockout state. * TPM1 defend lock clears on power cycle on some firmwares but not all (Infineon in particular). `tpm-reset.sh` from the recovery shell clears the DA counter. diff --git a/doc/ux-patterns.md b/doc/ux-patterns.md index 6fc4b4a3e..f5bc041c2 100644 --- a/doc/ux-patterns.md +++ b/doc/ux-patterns.md @@ -87,6 +87,13 @@ tuned for a specific dialog length and silently overflow when content changes. See "Minimal supported screen sizes" below for the floor that hardcoded heights can collide with. + + ### Minimal supported screen sizes Heads must render correctly on the smallest screen configuration any diff --git a/initrd/bin/gui-init.sh b/initrd/bin/gui-init.sh index 0fc115d2c..7cc7019db 100755 --- a/initrd/bin/gui-init.sh +++ b/initrd/bin/gui-init.sh @@ -287,41 +287,91 @@ update_totp() { DEBUG "$(pcrs)" # If the unseal path set the DA lockout marker (commit 3), - # route to a lockout-specific dialog with remaining time - # instead of the generic "TOTP Generation Failed!" alarming - # message. This matches the preflight gate's behavior and - # avoids telling the user "THIS COULD INDICATE TAMPERING!" - # when the real cause is a recoverable TPM lockout. + # route to a lockout-specific dialog instead of the generic + # "TOTP Generation Failed!" alarming message. This matches + # the preflight gate's behavior and avoids telling the user + # "THIS COULD INDICATE TAMPERING!" when the real cause is a + # recoverable TPM lockout. if [ -f /tmp/secret/tpm_da_lockout ]; then rm -f /tmp/secret/tpm_da_lockout - local 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" + # Consume the marker file here (the start-time file + # persists -- it records when the lockout was first + # detected, so subsequent boots can compute elapsed time). + rm -f /tmp/secret/tpm_da_lockout_msg + # Preserve the verbose cause/backoff explanation on the + # console/log (debug.log) instead of inflating the dialog. + LOG "TPM dictionary-attack lockout prevented TOTP unseal." + LOG "Cause: repeated bad TPM auth attempts, typically from an incorrect TPM owner passphrase." + LOG "Lockout self-heals: one failed attempt is forgotten per recoveryTime (Heads sets ~1 hour); lockout lifts once the counter drops below maxTries." + # Waiting is the default action: 'w' re-queries the live + # countdown each pass (the TPM self-heals ~one attempt per + # recoveryTime) and re-displays until the lockout lifts + # (TOTP then unseals in place) or the user picks a real + # option. ESC still escapes to the recovery shell. + lockout_lifted="n" + while true; do + # Source cached DA policy (counter, max_auth, interval) + # for the dialog body when getcap is blocked in lockout. + if [ -s /tmp/secret/tpm_da_props ]; then + . /tmp/secret/tpm_da_props + fi + # tpmr.sh da_remaining returns final seconds remaining + # (TPM2: recovery - elapsed; TPM1: actionDependValue). + rem="$(tpmr.sh da_remaining 2>/dev/null || true)" + if [ -n "$rem" ] && echo "$rem" | grep -qE '^[0-9]+$'; then + _lockout_timer_line="about $(format_human_duration "$rem") until the TPM accepts auth again." + elif [ "$CONFIG_TPM2_TOOLS" = "y" ]; then + _int_raw="$(tpm2 getcap properties-variable 2>/dev/null \ + | grep 'TPM2_PT_LOCKOUT_INTERVAL' \ + | sed -n 's/.*: *//p' || true)" + _int_dec="$(echo "$_int_raw" | sed 's/^0x//' | awk '{print $1}' || true)" + if echo "$_int_raw" | grep -qE '^0x'; then + _int=$((16#$_int_dec)) + else + _int=$_int_dec + fi + if ! echo "$_int" | grep -qE '^[0-9]+$' || [ "$_int" -le 0 ] 2>/dev/null; then + _int="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + _lockout_timer_line="typically about $(format_human_duration "$_int") until the TPM accepts auth again." + else + _lockout_timer_line="Lockout countdown unavailable." + fi + totp_menu_text="$(da_lockout_msg "${counter:-}" "${max_auth:-}" "${_int:-}" "$_lockout_timer_line")" + whiptail_error --title 'ERROR: TPM Dictionary Attack Lockout' \ + --menu "$totp_menu_text" 0 80 5 \ + 'w' ' Wait -- refresh countdown' \ + 'p' ' Reset the TPM' \ + 'i' ' Ignore error and continue to main menu' \ + 's' ' Recovery shell' \ + 'x' ' Exit to recovery shell' \ + 2>/tmp/whiptail || recovery "GUI menu failed" + option=$(cat /tmp/whiptail) + case "$option" in + w) + # Refresh: re-check whether the lockout lifted + # (counter back below maxTries) and if so retry + # the unseal in place. + da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" + da_line="$(echo "$da_state_out" | grep '^DA: ' || true)" + da_cur="$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/' || true)" + da_max="$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/' || true)" + if echo "$da_cur" | grep -qE '^[0-9]+$' && echo "$da_max" | grep -qE '^[0-9]+$' && \ + [ "$da_cur" -lt "$da_max" ] 2>/dev/null; then + STATUS "TPM lockout lifted (${da_cur} of ${da_max} failures); retrying TOTP unseal." + TOTP="$(HEADS_NONFATAL_UNSEAL=y unseal-totp.sh)" + if [ $? -eq 0 ]; then + BG_COLOR_MAIN_MENU="normal" + lockout_lifted="y" + break + fi + fi + ;; + *) + break + ;; + esac + done else totp_menu_text=$( cat </tmp/whiptail || recovery "GUI menu failed" fi - option=$(cat /tmp/whiptail) - case "$option" in - g) + # Skip the option dispatch when the lockout lifted and the + # in-place TOTP retry already succeeded (lockout_lifted=y). + if [ "$lockout_lifted" != "y" ]; then + option=$(cat /tmp/whiptail) + case "$option" in + g) if tpm_reset_required; then debug_tpm_reset_required_state whiptail_error --title 'ERROR: TPM Reset Required' \ @@ -385,10 +438,14 @@ EOF reseal_tpm_disk_decryption_key || prompt_missing_gpg_key_action fi ;; + s) + recovery "User requested recovery shell" + ;; x) recovery "User requested recovery shell" ;; esac + fi else INTEGRITY_GATE_REQUIRED="n" fi @@ -952,14 +1009,14 @@ TRACE_FUNC # so the user sees remaining backoff time immediately. If no lockout, # silently continue (no noise on every boot). if [ "$CONFIG_TPM" = "y" ]; then - da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" + da_state_out="$(run_with_timeout 3 tpmr.sh da_state 2>/dev/null || true)" if [ -n "$da_state_out" ]; then - da_summary="$(echo "$da_state_out" | grep '^=> ' | head -1)" + # Only surface a STATUS line when lockout is actually active -- + # da_state always emits a '=>' summary, but most boots are + # "Within lockout threshold" which should stay silent. + da_summary="$(echo "$da_state_out" | grep -E '^=> (TPM LOCKOUT ACTIVE|TPM DEFEND LOCK ACTIVE)' | head -1)" if [ -n "$da_summary" ]; then STATUS "TPM DA: ${da_summary#=> }" - else - da_unavail="$(echo "$da_state_out" | grep '^TPM DA state:' | head -1)" - [ -n "$da_unavail" ] && STATUS "$da_unavail" fi fi fi @@ -1024,58 +1081,96 @@ EOF # it from scratch. if [ -f /tmp/secret/tpm_da_lockout ]; then rm -f /tmp/secret/tpm_da_lockout - preflight_da_msg="" - if [ -f /tmp/secret/tpm_da_lockout_msg ]; then - preflight_da_msg="$(cat /tmp/secret/tpm_da_lockout_msg)" - rm -f /tmp/secret/tpm_da_lockout_msg - fi - lockout_menu_text=$( - cat <' \ - 'm' ' Continue to main menu' \ - 2>/tmp/whiptail || recovery "GUI menu failed" - option=$(cat /tmp/whiptail) - case "$option" in - r) - if reset_tpm && preflight_rollback_counter_before_reseal /boot/kexec_rollback.txt "" return; then - rollback_preflight_failed="n" - BG_COLOR_MAIN_MENU="normal" + # Consume the marker file here (the start-time file + # persists -- it records when the lockout was first + # detected, so subsequent boots can compute elapsed time). + rm -f /tmp/secret/tpm_da_lockout_msg + # Waiting is the default action: 'w' re-queries the live + # countdown each pass (the TPM self-heals ~one attempt per + # recoveryTime) and re-displays until the lockout lifts or + # the user picks a real option. + lockout_lifted="n" + while true; do + # Source cached DA policy (counter, max_auth, interval) + # for the dialog body when getcap is blocked in lockout. + if [ -s /tmp/secret/tpm_da_props ]; then + . /tmp/secret/tpm_da_props fi - ;; - o) - INTEGRITY_REPORT_ALREADY_SHOWN=1 oem-factory-reset.sh - if preflight_rollback_counter_before_reseal /boot/kexec_rollback.txt "" return; then - rollback_preflight_failed="n" - BG_COLOR_MAIN_MENU="normal" + rem="$(tpmr.sh da_remaining 2>/dev/null || true)" + if [ -n "$rem" ] && echo "$rem" | grep -qE '^[0-9]+$'; then + _lockout_timer_line="about $(format_human_duration "$rem") until the TPM accepts auth again." + elif [ "$CONFIG_TPM2_TOOLS" = "y" ]; then + _int_raw="$(tpm2 getcap properties-variable 2>/dev/null \ + | grep 'TPM2_PT_LOCKOUT_INTERVAL' \ + | sed -n 's/.*: *//p' || true)" + _int_dec="$(echo "$_int_raw" | sed 's/^0x//' | awk '{print $1}' || true)" + if echo "$_int_raw" | grep -qE '^0x'; then + _int=$((16#$_int_dec)) + else + _int=$_int_dec + fi + if ! echo "$_int" | grep -qE '^[0-9]+$' || [ "$_int" -le 0 ] 2>/dev/null; then + _int="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + _lockout_timer_line="typically about $(format_human_duration "$_int") until the TPM accepts auth again." + else + _lockout_timer_line="Lockout countdown unavailable." fi - ;; - m | *) - break - ;; - esac + lockout_menu_text="$(da_lockout_msg "${counter:-}" "${max_auth:-}" "${_int:-}" "$_lockout_timer_line")" + whiptail_error --title 'ERROR: TPM DA Lockout' \ + --menu "$lockout_menu_text" 0 80 5 \ + 'w' ' Wait -- refresh countdown' \ + 'r' ' Reset the TPM' \ + 'o' ' OEM Factory Reset / Re-Ownership -->' \ + 's' ' Recovery shell' \ + 'm' ' Continue to main menu' \ + 2>/tmp/whiptail || recovery "GUI menu failed" + option=$(cat /tmp/whiptail) + case "$option" in + w) + # Refresh: re-check whether the lockout lifted + # (counter back below maxTries) and exit if so. + da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" + da_line="$(echo "$da_state_out" | grep '^DA: ' || true)" + da_cur="$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/' || true)" + da_max="$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/' || true)" + if echo "$da_cur" | grep -qE '^[0-9]+$' && echo "$da_max" | grep -qE '^[0-9]+$' && \ + [ "$da_cur" -lt "$da_max" ] 2>/dev/null; then + STATUS "TPM lockout lifted (${da_cur} of ${da_max} failures); retrying preflight." + rollback_preflight_failed="n" + BG_COLOR_MAIN_MENU="normal" + lockout_lifted="y" + break + fi + ;; + *) + break + ;; + esac + done + if [ "$lockout_lifted" != "y" ]; then + case "$option" in + r) + if reset_tpm && preflight_rollback_counter_before_reseal /boot/kexec_rollback.txt "" return; then + rollback_preflight_failed="n" + BG_COLOR_MAIN_MENU="normal" + fi + ;; + o) + INTEGRITY_REPORT_ALREADY_SHOWN=1 oem-factory-reset.sh + if preflight_rollback_counter_before_reseal /boot/kexec_rollback.txt "" return; then + rollback_preflight_failed="n" + BG_COLOR_MAIN_MENU="normal" + fi + ;; + s) + recovery "User requested recovery shell" + ;; + m | *) + break + ;; + esac + fi if [ "$rollback_preflight_failed" = "y" ]; then preflight_error_msg="$(cat /tmp/rollback_preflight_error 2>/dev/null)" [ -n "$preflight_error_msg" ] && DEBUG "Rollback preflight failure: $preflight_error_msg" diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index 2e8620d23..98471ede1 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -875,13 +875,15 @@ tpm2_unseal() { # file so callers (gui-init.sh update_totp) can route to a # lockout-specific dialog instead of the generic TOTP failure # dialog. The marker is consumed by commit 6. - if grep -qi 'lockout\|lock\|auth.*fail\|0x98e\|0x149' "$TMP_STDERR" 2>/dev/null; then - WARN "TPM2 dictionary attack lockout active. Unseal rejected." - mkdir -p /tmp/secret 2>/dev/null || true - touch /tmp/secret/tpm_da_lockout 2>/dev/null || true - fi - rm -f "$TMP_STDERR" - WARN "Unable to unseal secret from TPM NVRAM" + if grep -Eqi 'lockout\|TPM_RC_LOCKOUT\|0x?0*921' "$TMP_STDERR" 2>/dev/null; then + WARN "TPM2 dictionary attack lockout active. Unseal rejected." + mkdir -p /tmp/secret 2>/dev/null || true + touch /tmp/secret/tpm_da_lockout 2>/dev/null || true + # Record when the lockout started (first detection wins). + [ -f /tmp/secret/tpm_da_lockout_start ] || date +%s > /tmp/secret/tpm_da_lockout_start + fi + rm -f "$TMP_STDERR" + WARN "Unable to unseal secret from TPM NVRAM" # should succeed, exit if it doesn't exit 1 fi @@ -987,15 +989,17 @@ tpm1_unseal() { WARN "TPM dictionary attack lockout active. Unseal rejected." mkdir -p /tmp/secret 2>/dev/null || true touch /tmp/secret/tpm_da_lockout 2>/dev/null || true - # Best-effort: stash a da_state summary line so the GUI dialog - # can show the remaining backoff time, not just "lockout active". + # Record when the lockout started (first detection wins). + [ -f /tmp/secret/tpm_da_lockout_start ] || date +%s > /tmp/secret/tpm_da_lockout_start + # Best-effort: surface a da_state summary line on the console/log. + # The real remaining-time figure is queried fresh (and timeout- + # guarded) by the gui-init.sh dialog via `tpmr.sh da_remaining`. local da_state_output da_summary da_state_output="$(tpm1_da_state 2>/dev/null)" || true if [ -n "$da_state_output" ]; then da_summary="$(echo "$da_state_output" | grep '^=> ' | head -1)" if [ -n "$da_summary" ]; then STATUS "TPM DA: ${da_summary#=> }" - echo "${da_summary#=> }" >/tmp/secret/tpm_da_lockout_msg 2>/dev/null || true fi fi fi @@ -1180,6 +1184,34 @@ tpm2_shutdown() { # "DA: state=… current=… threshold=… timer=…" line for callers # (preflight guard, scripts, recovery shell display). # +# Parse a numeric value from a DA property line in tpm2 getcap output. +# Handles both hex (TPM2_PT_LOCKOUT_RECOVERY: 0x00000177) and +# decimal (TPM2_PT_LOCKOUT_RECOVERY: 375) output formats. +_parse_prop() { + local _pf="$1" + local _pv + _pv=$(echo "$da_out" | grep "$_pf" | sed -n 's/.*: *//p') || true + [ -z "$_pv" ] && { echo 0; return; } + local _stripped + _stripped=$(echo "$_pv" | sed 's/^0x//' | awk '{print $1}') + if echo "$_pv" | grep -qE '^0x'; then + echo $((16#$_stripped)) + else + echo "$_stripped" + fi +} + +# Emit one report block to BOTH stdout (console/machine contract) +# and the DEBUG channel, so boot captures see the full da_state +# report. Safe under set -e -o pipefail (pipeline exit = while = 0). +_da_report() { + local _line + printf '%s\n' "$*" | while IFS= read -r _line; do + DEBUG "da_state: $_line" + done + printf '%s\n' "$*" +} + # Some TPMs (e.g., STM) return TPM_BAD_MODE (44) on the subcap and # do not expose DA state via this query -- we report "unavailable" # so callers know to rely on other detection methods (e.g., an @@ -1198,67 +1230,64 @@ tpm1_da_state() { [ -s "$TMP_STDERR" ] && DEBUG "tpm1_da_state: getcapability stderr: $(cat "$TMP_STDERR")" rm -f "$TMP_STDERR" if [ -n "$ver_output" ]; then - vendor_id="$(echo "$ver_output" | grep 'VendorID' | tail -1 | sed 's/.*: *//')" - rev_major="$(echo "$ver_output" | grep 'revMajor' | sed 's/.*: 0x//')" - rev_minor="$(echo "$ver_output" | grep 'revMinor' | sed 's/.*: 0x//')" + vendor_id="$(echo "$ver_output" | grep 'VendorID' | tail -1 | sed 's/.*: *//' || true)" + rev_major="$(echo "$ver_output" | grep 'revMajor' | sed 's/.*: 0x//' || true)" + rev_minor="$(echo "$ver_output" | grep 'revMinor' | sed 's/.*: 0x//' || true)" rev_major_dec=$(printf '%d' "0x${rev_major:-0}" 2>/dev/null) rev_minor_dec=$(printf '%d' "0x${rev_minor:-0}" 2>/dev/null) DEBUG "tpm1_da_state: TPM vendor=\"$vendor_id\" firmware=$rev_major_dec.$rev_minor_dec" fi TMP_STDERR="$(mktemp)" - da_out="$(tpm getcapability -cap 0x19 -scap 0x0000 2>"$TMP_STDERR")" || rc=$? - if [ -n "$TMP_STDERR" ]; then - [ -s "$TMP_STDERR" ] && DEBUG "tpm1_da_state: getcapability stderr: $(cat "$TMP_STDERR")" + trap 'rm -f "$TMP_STDERR"' RETURN + if da_out="$(tpm getcapability -cap 0x19 -scap 0x0000 2>"$TMP_STDERR")"; then + rm -f "$TMP_STDERR" + trap - RETURN + else + rc=$? + LOG "tpm1_da_state: getcapability failed rc=$rc stderr=$(cat "$TMP_STDERR" 2>/dev/null | tr '\n' ';')" rm -f "$TMP_STDERR" + trap - RETURN + _da_report "DA: current= threshold= timer=" + _da_report "=> TPM DA state: unavailable" + return 1 fi if [ -z "$da_out" ] || ! echo "$da_out" | grep -q 'State'; then - [ -n "${rc-}" ] && DEBUG "tpm1_da_state: getcapability exit=$rc" - if [ "${rc-}" = "44" ]; then - DEBUG "tpm1_da_state: TPM does not support DA state queries (TPM_BAD_MODE)" - echo "TPM DA state: unavailable (this TPM does not report DA state)" - [ -n "${vendor_id-}" ] && echo "TPM chip: $vendor_id (firmware ${rev_major_dec:-?}.${rev_minor_dec:-?})" - else - DEBUG "tpm1_da_state: DA state not available (exit=${rc-})" - echo "TPM DA state: unavailable" - fi + DEBUG "tpm1_da_state: DA state not available (rc=$rc)" + _da_report "TPM DA state: unavailable" return 1 fi - echo "$da_out" - [ -n "${vendor_id-}" ] && echo "TPM chip: $vendor_id (firmware ${rev_major_dec:-?}.${rev_minor_dec:-?})" + _da_report "$da_out" + [ -n "${vendor_id-}" ] && _da_report "TPM chip: $vendor_id (firmware ${rev_major_dec:-?}.${rev_minor_dec:-?})" state=$(echo "$da_out" | grep 'State' | awk '{print $NF}') current=$(echo "$da_out" | grep 'currentCount' | awk '{print $NF}') threshold=$(echo "$da_out" | grep 'thresholdCount' | awk '{print $NF}') timer=$(echo "$da_out" | grep 'actionDependValue' | awk '{print $NF}') DEBUG "tpm1_da_state: state=$state current=$current threshold=$threshold timer=$timer" - echo "" - echo "DA policy:" - echo " thresholdCount (max failures before defend): $threshold" - echo " currentCount (current failure count): $current" - echo " actionDependValue (lockout seconds remaining): ${timer:-0}" - echo " state (DA logic: 0=inactive, 1=active): $state" + _da_report "" + _da_report "DA policy:" + _da_report " thresholdCount (max failures before defend): $threshold" + _da_report " currentCount (current failure count): $current" + _da_report " actionDependValue (lockout seconds remaining): ${timer:-0}" + _da_report " state (DA logic: 0=inactive, 1=active): $state" if [ "$state" = "1" ]; then - if [ -n "$timer" ] && [ "$timer" -ge 3600 ] 2>/dev/null; then - echo "=> TPM DEFEND LOCK ACTIVE (~$((timer / 3600)) hour(s) remaining)" - elif [ -n "$timer" ] && [ "$timer" -ge 60 ] 2>/dev/null; then - echo "=> TPM DEFEND LOCK ACTIVE (~$((timer / 60)) min remaining)" - elif [ -n "$timer" ] && [ "$timer" -gt 0 ] 2>/dev/null; then - echo "=> TPM DEFEND LOCK ACTIVE (${timer}s remaining)" + if [ -n "$timer" ] && [ "$timer" -gt 0 ] 2>/dev/null; then + _da_report "=> TPM DEFEND LOCK ACTIVE (${timer}s remaining)" else - echo "=> TPM DEFEND LOCK ACTIVE (duration unknown on this TPM)" + _da_report "=> TPM DEFEND LOCK ACTIVE (duration unknown on this TPM)" fi elif [ -n "$current" ] && [ -n "$threshold" ] && [ "$current" -ge "$threshold" ] 2>/dev/null; then DEBUG "tpm1_da_state: above threshold, not locked (timer=$timer)" - echo "=> Above threshold: $current/$threshold failures (auth failures will trigger lockout)" + _da_report "=> Above threshold: $current/$threshold failures (auth failures will trigger lockout)" elif [ -n "$current" ] && [ -n "$threshold" ]; then - echo "=> Within lockout threshold ($current/$threshold failures used)" + _da_report "=> Within lockout threshold ($current/$threshold failures used)" else - echo "=> DA state: limited info (state=$state)" + _da_report "=> DA state: limited info (state=$state)" fi # Machine-parsable line for callers (preflight guard, scripts). # timer= field is always emitted; value empty when unavailable so # the preflight guard's sed -n /p returns empty and skips lockout check. - echo "DA: state=${state:-} current=${current:-} threshold=${threshold:-} timer=${timer:-}" + _da_report "DA: state=${state:-} current=${current:-} threshold=${threshold:-} timer=${timer:-}" } # Query TPM2 dictionary attack state via getcap properties-variable. @@ -1266,67 +1295,84 @@ tpm1_da_state() { # LOCKOUT_INTERVAL, LOCKOUT_RECOVERY) plus a summary and a machine- # parsable "DA: current=… threshold=… timer=…" line for callers. # -# When counter >= maxAuth, the TPM is locked out. Time-to-unlock is -# estimated as (counter - maxAuth + 1) * interval -- the TPM decrements -# the counter every interval seconds once auth is blocked. +# When counter >= maxAuth, the TPM is locked out. The timer= field +# uses TPM2_PT_LOCKOUT_INTERVAL (seconds between DA counter +# decrements), not TPM2_PT_LOCKOUT_RECOVERY which governs +# lockoutAuth password blocking and is often 0 on many chips. +# tpm_da_remaining computes remaining from interval - elapsed. tpm2_da_state() { TRACE_FUNC - local cap_out da_out counter_hex max_auth_hex interval_hex recovery_hex counter max_auth interval recovery fw_ver + local cap_out="" rc=0 da_out counter max_auth interval recovery fw_ver + mkdir -p /tmp/secret 2>/dev/null || true TMP_STDERR="$(mktemp)" + trap 'rm -f "$TMP_STDERR"' RETURN if cap_out="$(tpm2 getcap properties-variable 2>"$TMP_STDERR")"; then rm -f "$TMP_STDERR" else - local rc=$? - WARN "Unable to query TPM2 dictionary attack state (tpm2 getcap rc=$rc)" - DEBUG "tpm2_da_state: getcap properties-variable stderr: $(cat "$TMP_STDERR" 2>/dev/null)" + rc=$? + LOG "tpm2_da_state: getcap failed rc=$rc stderr=$(cat "$TMP_STDERR" 2>/dev/null | tr '\n' ';')" rm -f "$TMP_STDERR" + if [ -s /tmp/secret/tpm_da_props ]; then + . /tmp/secret/tpm_da_props + _da_report "DA: current=${counter:-} threshold=${max_auth:-} timer=" + _da_report "=> TPM LOCKOUT ACTIVE (cached policy; getcap blocked in lockout)" + return 0 + fi + _da_report "DA: current= threshold= timer=" + _da_report "=> TPM DA state: unavailable" return 1 fi - fw_ver="$(echo "$cap_out" | grep 'TPM2_PT_FIRMWARE_VERSION_1' | sed 's/.*0x//')" + rm -f "$TMP_STDERR" + trap - RETURN + fw_ver="$(echo "$cap_out" | grep 'TPM2_PT_FIRMWARE_VERSION_1' | sed 's/.*0x//' || true)" [ -n "$fw_ver" ] && DEBUG "tpm2_da_state: TPM firmware version: $(printf '%d.%d' $((0x${fw_ver}>>16)) $((0x${fw_ver}&0xffff)) 2>/dev/null)" da_out="$(echo "$cap_out" | grep -E \ 'TPM2_PT_LOCKOUT_COUNTER|TPM2_PT_MAX_AUTH_FAIL|TPM2_PT_LOCKOUT_INTERVAL|TPM2_PT_LOCKOUT_RECOVERY')" || true if [ -z "$da_out" ]; then DEBUG "tpm2_da_state: no matching properties found in getcap output" - echo "TPM2 DA state: unavailable" + _da_report "TPM2 DA state: unavailable" return 1 fi - echo "$da_out" - counter_hex=$(echo "$da_out" | grep 'LOCKOUT_COUNTER' | sed 's/.*0x//') - max_auth_hex=$(echo "$da_out" | grep 'MAX_AUTH_FAIL' | sed 's/.*0x//') - interval_hex=$(echo "$da_out" | grep 'LOCKOUT_INTERVAL' | sed 's/.*0x//') - recovery_hex=$(echo "$da_out" | grep 'LOCKOUT_RECOVERY' | sed 's/.*0x//') - counter=$((0x${counter_hex:-0})) - max_auth=$((0x${max_auth_hex:-0})) - interval=$((0x${interval_hex:-0})) - recovery=$((0x${recovery_hex:-0})) - echo "" - echo "DA policy:" - echo " maxTries (max auth fails before lockout): $max_auth" + _da_report "$da_out" + counter=$(_parse_prop 'LOCKOUT_COUNTER') + max_auth=$(_parse_prop 'MAX_AUTH_FAIL') + interval=$(_parse_prop 'LOCKOUT_INTERVAL') + recovery=$(_parse_prop 'LOCKOUT_RECOVERY') + # Write cache so callers can use it when getcap is blocked in lockout. + { echo "counter=$counter"; echo "max_auth=$max_auth"; echo "interval=$interval"; echo "recovery=$recovery"; } > /tmp/secret/tpm_da_props 2>/dev/null || true + _da_report "" + _da_report "DA policy:" + _da_report " maxTries (max auth fails before lockout): $max_auth" if [ "$interval" -ge 60 ]; then - echo " recoveryTime (seconds before one failure is forgotten): $interval ($((interval / 60)) min)" + _da_report " recoveryTime (seconds before one failure is forgotten): $interval ($((interval / 60)) min)" else - echo " recoveryTime (seconds before one failure is forgotten): $interval" + _da_report " recoveryTime (seconds before one failure is forgotten): $interval" fi - echo " lockoutRecovery (seconds lockout auth blocked after failure): $recovery" - echo " failedTries (current auth failure count): $counter" - if [ -n "$counter_hex" ] && [ -n "$max_auth_hex" ] && [ "$counter" -ge "$max_auth" ] 2>/dev/null; then + _da_report " lockoutRecovery (seconds lockout auth blocked after failure): $recovery" + _da_report " failedTries (current auth failure count): $counter" + if [ -n "$counter" ] && [ -n "$max_auth" ] && [ "$counter" -ge "$max_auth" ] 2>/dev/null; then DEBUG "tpm2_da_state: LOCKOUT ACTIVE (counter=$counter threshold=$max_auth)" - echo "=> TPM LOCKOUT ACTIVE ($counter/$max_auth failures)" - local need=$((counter - max_auth + 1)) - local estimate=$((need * interval)) - if [ "$estimate" -ge 3600 ]; then - echo "=> Estimated unlock in ~$((estimate / 3600)) hour(s) (if no new failures)" - elif [ "$estimate" -ge 60 ]; then - echo "=> Estimated unlock in ~$((estimate / 60)) min (if no new failures)" + _da_report "=> TPM LOCKOUT ACTIVE ($counter/$max_auth failures)" + if [ "$recovery" -gt 0 ] 2>/dev/null; then + _da_report " lockoutAuth blocked ${recovery}s after lockoutAuth failure" else - echo "=> Estimated unlock in ~${estimate}s (if no new failures)" + _da_report " lockoutAuth blocked until TPM reset (lockoutRecovery=0)" fi - echo "DA: current=${counter:-} threshold=${max_auth:-} timer=${estimate}" + # Compute a realistic remaining estimate from LOCKOUT_INTERVAL + # (seconds between DA counter decrements), not LOCKOUT_RECOVERY + # which is 0 on many chips and governs only lockoutAuth password. + local _est _int + _int="${interval:-0}" + if ! echo "$_int" | grep -qE '^[0-9]+$' || [ "$_int" -le 0 ] 2>/dev/null; then + _int="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + _est="$_int" + _da_report "=> Clears in about $(format_human_duration "$_est")" + _da_report "DA: current=${counter:-} threshold=${max_auth:-} timer=${interval:-}" else DEBUG "tpm2_da_state: within threshold (counter=$counter threshold=$max_auth)" - echo "=> Within lockout threshold ($counter/$max_auth failures used)" - echo "DA: current=${counter:-} threshold=${max_auth:-}" + _da_report "=> Within lockout threshold ($counter/$max_auth failures used)" + _da_report "DA: current=${counter:-} threshold=${max_auth:-}" fi } @@ -1349,7 +1395,33 @@ tpm2_da_state() { # bump DA. Caller is responsible for resetting the TPM afterwards. tpm1_bad_auth() { TRACE_FUNC - local rollback_counter_id="${1:-}" + local until_lockout=0 until_esc=0 attempt=0 _ch="" + local rollback_counter_id="" + # Parse loop-mode flags (mutually exclusive): + # --until-lockout increment until the TPM reports DA lockout + # --until-esc increment, printing da_state, until ESC is pressed + while [ $# -gt 0 ]; do + case "$1" in + --help|-h) + cat <&2; return 1 ;; + *) rollback_counter_id="$1"; shift ;; + esac + done + if [ "$until_lockout" -eq 1 ] && [ "$until_esc" -eq 1 ]; then + echo "bad_auth: --until-lockout and --until-esc are mutually exclusive" >&2 + return 1 + fi local increment_exit_code increment_command_output local ver_output vendor_id rev_major rev_minor rev_major_dec rev_minor_dec @@ -1430,39 +1502,104 @@ tpm1_bad_auth() { echo "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" >&2 DEBUG "bad_auth (TPM1): starting -- counter=0x$rollback_counter_id region=$nv_region_tpm1" DEBUG "bad_auth (TPM1): no BEFORE state capture — counter_increment is the actual test, not the da_state query" - echo "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." >&2 - DEBUG "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." - increment_command_output=$(tpm counter_increment -ix "$rollback_counter_id" -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) || increment_exit_code=$? - DEBUG "bad_auth: counter_increment rc=$increment_exit_code output='$increment_command_output'" - if [ "$increment_exit_code" -ne 0 ]; then - if echo "$increment_command_output" | grep -qi 'defend\|lock'; then - DEBUG "bad_auth: DA LOCKOUT ACTIVE (rc=$increment_exit_code)" - echo "bad_auth (TPM1): increment REJECTED by TPM lockout (rc=$increment_exit_code = defend lock running)" >&2 - DEBUG "bad_auth (TPM1): increment REJECTED by TPM lockout (rc=$increment_exit_code = defend lock running)" + WARN "This deliberately triggers TPM dictionary-attack (DA) lockout." + WARN "The TPM will reject auth for the lockout window until it expires." + while true; do + attempt=$((attempt + 1)) + if [ "$until_lockout" -eq 1 ] || [ "$until_esc" -eq 1 ]; then + echo "bad_auth (TPM1): attempt $attempt -- counter_increment with WRONG auth (press ESC to stop)..." >&2 else - DEBUG "bad_auth: auth failure (rc=$increment_exit_code = TPM_AUTHFAIL)" - echo "bad_auth (TPM1): increment FAILED with wrong auth (rc=$increment_exit_code) -- DA counter bumped" >&2 - DEBUG "bad_auth (TPM1): increment FAILED with wrong auth (rc=$increment_exit_code) -- DA counter bumped" - echo "Auth failure (rc=$increment_exit_code = TPM_AUTHFAIL, expected with wrong password)." - echo "Run again to accumulate failures toward DA lockout." + echo "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." >&2 fi - else - DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS)" - echo "bad_auth (TPM1): increment SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" >&2 - DEBUG "bad_auth (TPM1): increment SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" - echo "UNEXPECTED: wrong password was accepted (rc=0 = TPM_SUCCESS)." - fi + DEBUG "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id (attempt $attempt)" + + local increment_exit_code=0 increment_command_output + increment_command_output=$(tpm counter_increment \ + -ix "$rollback_counter_id" \ + -pwdc "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) \ + || increment_exit_code=$? + DEBUG "bad_auth: counter_increment rc=$increment_exit_code output='$increment_command_output'" + + local lockout_detected=0 + if [ "$increment_exit_code" -ne 0 ]; then + if echo "$increment_command_output" | grep -qi 'defend\|lock'; then + DEBUG "bad_auth: DA LOCKOUT ACTIVE (rc=$increment_exit_code)" + echo "bad_auth (TPM1): increment REJECTED by TPM lockout (rc=$increment_exit_code = defend lock running)" >&2 + lockout_detected=1 + else + DEBUG "bad_auth: auth failure (rc=$increment_exit_code = TPM_AUTHFAIL)" + echo "bad_auth (TPM1): increment FAILED with wrong auth (rc=$increment_exit_code) -- DA counter bumped" >&2 + fi + else + DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS)" + echo "bad_auth (TPM1): increment SUCCEEDED with wrong auth (rc=0) -- test inconclusive" >&2 + fi + + # Default mode (no loop flag): single attempt, then report DA state. + if [ "$until_lockout" -eq 0 ] && [ "$until_esc" -eq 0 ]; then + break + fi + + # --until-lockout: stop when the increment itself reports lockout. + # The stop condition is the increment output, NOT getcap/da_state. + if [ "$until_lockout" -eq 1 ] && [ "$lockout_detected" -eq 1 ]; then + echo "bad_auth (TPM1): DA lockout achieved after $attempt attempt(s)" >&2 + DEBUG "bad_auth (TPM1): DA lockout achieved after $attempt attempt(s)" + break + fi + + # --until-esc: print the real remaining lockout time after each + # increment before waiting for ESC. + if [ "$until_esc" -eq 1 ]; then + tpm1_da_state || true + fi + + # Wait up to 1s for ESC (doubles as pacing between attempts). + if read -t 1 -n 1 _ch 2>/dev/null && [ "$_ch" = "$(printf '\033')" ]; then + echo "bad_auth (TPM1): stopped by user (ESC) after $attempt attempt(s)" >&2 + DEBUG "bad_auth (TPM1): stopped by user (ESC) after $attempt attempt(s)" + break + fi + done echo "bad_auth (TPM1): AFTER state -- capturing..." >&2 DEBUG "bad_auth (TPM1): AFTER state -- capturing..." - DEBUG "DA state AFTER bad auth (TPM1 NV 0x$rollback_counter_id, region: $nv_region_tpm1):" - tpm1_da_state + # tpm1_da_state handles all lockout reporting: "=>" summary lines with + # the real remaining lockout time, and DA: machine-readable line for callers. + # See doc/tpm.md "TPM Dictionary-Attack (DA) Lockout Detection". + tpm1_da_state || true echo "bad_auth (TPM1): DONE" >&2 DEBUG "bad_auth (TPM1): DONE" } tpm2_bad_auth() { TRACE_FUNC - local counter_id="${1:-}" + local until_lockout=0 until_esc=0 attempt=0 _ch="" + local counter_id="" + # Parse loop-mode flags (mutually exclusive): + # --until-lockout increment until the TPM reports DA lockout + # --until-esc increment, printing da_state, until ESC is pressed + while [ $# -gt 0 ]; do + case "$1" in + --help|-h) + cat <&2; return 1 ;; + *) counter_id="$1"; shift ;; + esac + done + if [ "$until_lockout" -eq 1 ] && [ "$until_esc" -eq 1 ]; then + echo "bad_auth: --until-lockout and --until-esc are mutually exclusive" >&2 + return 1 + fi if [ -z "$counter_id" ] && [ -r /boot/kexec_rollback.txt ]; then counter_id=$(grep -Eo 'counter-[0-9a-fA-F]+' /boot/kexec_rollback.txt | \ sed 's/counter-//' | head -1) @@ -1527,58 +1664,214 @@ tpm2_bad_auth() { # actual bad-auth attempt -- exactly the failure mode this tool exists # to test. The discovery loop has the check; this one doesn't need it. DEBUG "bad_auth (TPM2): no BEFORE state capture — nvincrement is the actual test, not the da_state query" - echo "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." >&2 - DEBUG "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." - # NV index auth failure (-C -P ) bumps LOCKOUT_COUNTER. - # Use -C explicitly so the auth context is unambiguous across tpm2-tools - # versions; without -C, -P can be interpreted as owner hierarchy auth on - # older releases. Capture exit code and distinguish lockout from auth- - # failure vs. silent success so the user can tell whether the test - # actually exercised the TPM. - local tpm2_increment_rc tpm2_increment_output - tpm2_increment_output=$(tpm2 nvincrement \ - -C "0x$counter_id" \ - -P "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" 2>&1) \ - || tpm2_increment_rc=$? - tpm2_increment_rc="${tpm2_increment_rc:-0}" - DEBUG "bad_auth: nvincrement rc=$tpm2_increment_rc output='$tpm2_increment_output'" - if [ "$tpm2_increment_rc" -ne 0 ]; then - # Match either the kernel/driver's text ("lockout", "lock") or the - # canonical TPM_RC_LOCKOUT return code (0x921) printed by tpm2-tools. - # The lockout text is unreliable across TPM2 stacks (PTT, CR50, - # swtpm all differ); the 0x921 code is the TCG-spec constant and is - # reliably emitted by tpm2 Esys Finish on a locked TPM. - if echo "$tpm2_increment_output" | grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*921'; then - DEBUG "bad_auth: DA LOCKOUT already active (TPM_RC_LOCKOUT 0x921)" - echo "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" >&2 - DEBUG "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" + WARN "This deliberately triggers TPM dictionary-attack (DA) lockout." + WARN "The TPM will reject auth for the lockout window until it expires." + while true; do + attempt=$((attempt + 1)) + if [ "$until_lockout" -eq 1 ] || [ "$until_esc" -eq 1 ]; then + echo "bad_auth (TPM2): attempt $attempt -- nvincrement with WRONG auth (press ESC to stop)..." >&2 else - DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL 0x91C or similar)" - echo "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" >&2 - DEBUG "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" - echo "Auth failure (rc=$tpm2_increment_rc = expected with wrong NV index auth, DA counter bumped)." - echo "Run again to accumulate failures toward DA lockout." + echo "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." >&2 fi - else - DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS) -- wrong password was accepted!" - echo "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" >&2 - DEBUG "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- counter has no auth, test inconclusive" - echo "UNEXPECTED: wrong NV index auth was accepted (rc=0)." - echo "This means the counter does not require NV index auth (authValue is empty)," - echo "or this TPM does not enforce NV index auth on increment. Bad-auth test inconclusive." - fi + DEBUG "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id (attempt $attempt)" + # -P provides the wrong auth value. The NV index is the positional argument + # (NOT -C, which is for hierarchy). See tpm2_nvincrement.c source. + local tpm2_increment_rc tpm2_increment_output + tpm2_increment_output=$(tpm2 nvincrement \ + -P "TPM_DEFEND_LOCK_TEST_WRONG_PASSWORD" \ + "0x$counter_id" 2>&1) \ + || tpm2_increment_rc=$? + tpm2_increment_rc="${tpm2_increment_rc:-0}" + DEBUG "bad_auth: nvincrement rc=$tpm2_increment_rc output='$tpm2_increment_output'" + + local lockout_detected=0 + if [ "$tpm2_increment_rc" -ne 0 ]; then + # Match either the kernel/driver's text ("lockout", "lock") or the + # canonical TPM_RC_LOCKOUT return code (0x921) printed by tpm2-tools. + if echo "$tpm2_increment_output" | grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*921'; then + DEBUG "bad_auth: DA LOCKOUT active (TPM_RC_LOCKOUT 0x921)" + echo "bad_auth (TPM2): nvincrement REJECTED by TPM lockout (rc=$tpm2_increment_rc)" >&2 + lockout_detected=1 + elif echo "$tpm2_increment_output" | grep -Eqi '0x?0*184|handle.*out.*range|not.*correct.*context'; then + # TPM_RC_HANDLE (0x184): NV index not found or not accessible. + # This is NOT an auth failure -- DA counter was NOT bumped. + DEBUG "bad_auth: handle error (rc=$tpm2_increment_rc = TPM_RC_HANDLE 0x184)" + echo "bad_auth (TPM2): nvincrement FAILED -- NV index 0x$counter_id not accessible (rc=$tpm2_increment_rc)" >&2 + else + DEBUG "bad_auth: auth failure (rc=$tpm2_increment_rc = TPM_RC_AUTH_FAIL)" + echo "bad_auth (TPM2): nvincrement FAILED with wrong auth (rc=$tpm2_increment_rc) -- DA counter bumped" >&2 + fi + else + DEBUG "bad_auth: UNEXPECTED SUCCESS (rc=0 = TPM_SUCCESS) -- wrong password was accepted!" + echo "bad_auth (TPM2): nvincrement SUCCEEDED with wrong auth (rc=0) -- test inconclusive" >&2 + fi + + # Default mode (no loop flag): single attempt, then report DA state. + if [ "$until_lockout" -eq 0 ] && [ "$until_esc" -eq 0 ]; then + break + fi + + # --until-lockout: stop when the increment itself reports lockout. + # The stop condition is the increment output, NOT getcap/da_state. + if [ "$until_lockout" -eq 1 ] && [ "$lockout_detected" -eq 1 ]; then + echo "bad_auth (TPM2): DA lockout achieved after $attempt attempt(s)" >&2 + DEBUG "bad_auth (TPM2): DA lockout achieved after $attempt attempt(s)" + break + fi + + # --until-esc: print the real remaining lockout time after each + # increment before waiting for ESC. + if [ "$until_esc" -eq 1 ]; then + tpm2_da_state || true + fi + + # Wait up to 1s for ESC (doubles as pacing between attempts). + if read -t 1 -n 1 _ch 2>/dev/null && [ "$_ch" = "$(printf '\033')" ]; then + echo "bad_auth (TPM2): stopped by user (ESC) after $attempt attempt(s)" >&2 + DEBUG "bad_auth (TPM2): stopped by user (ESC) after $attempt attempt(s)" + break + fi + done echo "bad_auth (TPM2): AFTER state -- capturing..." >&2 DEBUG "bad_auth (TPM2): AFTER state -- capturing..." - DEBUG "DA state AFTER bad auth (TPM2 NV 0x$counter_id, region: $nv_region_tpm2):" - tpm2_da_state + # tpm2_da_state handles all lockout reporting: "=>" summary lines with + # the real remaining lockout time, and DA: machine-readable line for callers. + # See doc/tpm.md "TPM Dictionary-Attack (DA) Lockout Detection". + tpm2_da_state || true echo "bad_auth (TPM2): DONE" >&2 DEBUG "bad_auth (TPM2): DONE" } +# tpm_da_remaining - print the real remaining DA-lockout time (seconds) for +# the current TPM, or nothing on failure/timeout. Used by the lockout +# whiptail dialogs to show a real countdown rather than an estimate. +# +# TPM2: TPM2_PT_LOCKOUT_INTERVAL -- seconds between DA counter +# decrements (recoveryTime, typically 3600). Read from +# `tpm2 getcap properties-variable`; tpm2-tools 5.6's +# `tpm2 getcap` accepts only one capability group (no +# property filter), so we query the variable-properties set +# and parse the single property out of it, exactly as +# tpm2_da_state does. The remaining time is +# interval - elapsed since lockout start, clamped to +# [0, interval]. TPM2_PT_LOCKOUT_RECOVERY is NOT used +# because it governs lockoutAuth password blocking and is +# often 0 on many chips. +# TPM1: TPM_DA_INFO.actionDependValue -- per TCG TPM 1.2 Part 2, "the TPM +# will be in a locked state for actionDependValue seconds"; it is a +# dynamic value that counts down as the lock runs. +# +# getcap/getcapability can block indefinitely during lockout on some TPMs +# (notably Intel PTT), so every query is bounded by run_with_timeout(3). +tpm_da_remaining() { + TRACE_FUNC + local out rc=0 + if [ "$CONFIG_TPM2_TOOLS" = "y" ]; then + out="$(run_with_timeout 3 tpm2 getcap properties-variable 2>/dev/null)" || rc=$? + # Compute remaining time from TPM2_PT_LOCKOUT_INTERVAL + # (seconds between DA counter decrements), NOT + # TPM2_PT_LOCKOUT_RECOVERY which governs lockoutAuth + # password blocking and is often 0. + local _int _int_raw _int_dec + if [ -n "$out" ]; then + _int_raw=$(echo "$out" | grep 'TPM2_PT_LOCKOUT_INTERVAL' | sed -n 's/.*: *//p') || true + if [ -n "$_int_raw" ]; then + _int_dec=$(echo "$_int_raw" | sed 's/^0x//' | awk '{print $1}') || true + if echo "$_int_raw" | grep -qE '^0x'; then + _int=$((16#$_int_dec)) + else + _int=$_int_dec + fi + fi + fi + if ! echo "$_int" | grep -qE '^[0-9]+$' || [ "$_int" -le 0 ] 2>/dev/null; then + _int="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + # If getcap failed (lockout), try cache. + if [ -z "$out" ] || [ "$rc" -ne 0 ]; then + if [ -s /tmp/secret/tpm_da_props ]; then + . /tmp/secret/tpm_da_props + if echo "$interval" | grep -qE '^[0-9]+$' && [ "$interval" -gt 0 ] 2>/dev/null; then + _int="$interval" + else + LOG "da_remaining: no valid interval in cache (cache=$( [ -s /tmp/secret/tpm_da_props ] && echo yes || echo no ))" + return 1 + fi + else + LOG "da_remaining: no cache available (cache=$( [ -s /tmp/secret/tpm_da_props ] && echo yes || echo no ))" + return 1 + fi + fi + # Compute final seconds remaining: interval minus + # elapsed since lockout start, clamped to [0, interval]. + local now start elapsed rem + now=$(date +%s) + start=$(cat /tmp/secret/tpm_da_lockout_start 2>/dev/null || true) + if [ -n "$start" ] && echo "$start" | grep -qE '^[0-9]+$'; then + elapsed=$((now - start)) + else + elapsed=0 + fi + rem=$((_int - elapsed)) + [ "$rem" -lt 0 ] 2>/dev/null && rem=0 + [ "$rem" -gt "$_int" ] 2>/dev/null && rem=$_int + echo "$rem" + else + out="$(run_with_timeout 3 tpm getcapability -cap 0x19 2>/dev/null)" || rc=$? + if [ -z "$out" ] || [ "$rc" -ne 0 ]; then + DEBUG "tpm_da_remaining: tpm getcapability unavailable or timed out (rc=$rc)" + return 1 + fi + local state timer + state=$(echo "$out" | grep 'State' | awk '{print $NF}') || true + timer=$(echo "$out" | grep 'actionDependValue' | awk '{print $NF}') || true + # actionDependValue IS the remaining seconds (live counter) for TPM1. + if [ "$state" != "1" ]; then + DEBUG "tpm_da_remaining: DA logic inactive (state=$state)" + return 1 + fi + if [ -z "$timer" ] || [ "$timer" -le 0 ] 2>/dev/null; then + DEBUG "tpm_da_remaining: no actionDependValue remaining (timer=$timer)" + return 1 + fi + echo "$timer" + fi +} + if [ "$CONFIG_TPM" != "y" ]; then DIE "No TPM!" fi +# Show usage when called with no arguments. +if [ $# -eq 0 ]; then + cat <<'EOF' +Usage: tpmr.sh [args...] + +TPM wrapper commands: + pcrread [-a] Read PCR binary data to file + pcrsize Print PCR size in bytes (20 for TPM1, 32 for TPM2) + calcfuturepcr [...] Replay PCR future value + extend -ic Extend PCR with string content + extend -if Extend PCR with file content + counter_read -ix Read TPM counter value + counter_increment -ix Increment TPM counter + counter_create -pwdc '' -la Create TPM counter (empty auth per TCG spec) + da_state Show TPM dictionary-attack state and policy + da_remaining Print real remaining DA-lockout time (seconds) + bad_auth [--until-lockout|--until-esc] [id] Test bad-auth (loop modes) + destroy Reset TPM (TPM1 only) + seal ... Seal secret to TPM NVRAM + startsession Start auth/policy session (TPM2 only) + unseal ... Unseal secret from TPM NVRAM + reset Reset TPM (clear + re-ownership) + kexec_finalize Finalize TPM state for kexec + shutdown Shutdown TPM + +See doc/tpm.md for TPM command details and doc/logging.md for logging conventions. +EOF + exit 0 +fi + # TPM1 - most commands forward directly to tpm, but some are still wrapped for # consistency with tpm2. if [ "$CONFIG_TPM2_TOOLS" != "y" ]; then @@ -1651,7 +1944,10 @@ if [ "$CONFIG_TPM2_TOOLS" != "y" ]; then ;; da_state) shift - tpm1_da_state "$@" + tpm1_da_state "$@" || true + ;; + da_remaining) + tpm_da_remaining ;; bad_auth) shift @@ -1707,8 +2003,11 @@ counter_increment) counter_create) tpm2_counter_create "$@" ;; -da_state) - tpm2_da_state "$@" + da_state) + tpm2_da_state "$@" || true + ;; +da_remaining) + tpm_da_remaining ;; bad_auth) tpm2_bad_auth "$@" diff --git a/initrd/etc/functions.sh b/initrd/etc/functions.sh index 79292e420..29a147a4e 100644 --- a/initrd/etc/functions.sh +++ b/initrd/etc/functions.sh @@ -432,6 +432,114 @@ INPUT() { fi } +# format_dhms -> D:H:M:S +# +# Convert an integer number of seconds to a colon-separated +# "days:hours:minutes:seconds" string (e.g. `0:0:2:15`). No zero-padding +# and no unit labels -- used for the TPM DA-lockout countdown where a real, +# machine-read value is preferred over an approximate "~N min". +# Non-numeric or empty input is treated as 0. +format_dhms() { + local total="${1:-0}" + case "$total" in + ''|*[!0-9]*) total=0 ;; + esac + local d h m s + d=$((total / 86400)) + total=$((total % 86400)) + h=$((total / 3600)) + total=$((total % 3600)) + m=$((total / 60)) + s=$((total % 60)) + printf '%d:%d:%d:%d' "$d" "$h" "$m" "$s" +} + +# Human-readable duration for lockout estimates: "about 1 hour", +# "about 59 minutes", "less than a minute". Machine-facing output +# (da_remaining seconds, DA: timer=) stays numeric. +format_human_duration() { + local s="$1" m h d hm rem out + case "$s" in (*[!0-9]*) s=0;; esac + [ -n "$s" ] || s=0 + if [ "$s" -lt 60 ]; then echo "less than a minute"; return 0; fi + m=$(( (s + 30) / 60 )) # round to nearest minute + if [ "$m" -lt 60 ]; then + [ "$m" -eq 1 ] && echo "1 minute" || echo "$m minutes" + return 0 + fi + d=$((m / 1440)); hm=$((m % 1440)); h=$((hm / 60)); rem=$((hm % 60)) + if [ "$d" -gt 0 ]; then + out="${d} day"; [ "$d" -ne 1 ] && out="${out}s" + if [ "$h" -gt 0 ]; then out="${out} ${h} hour"; [ "$h" -ne 1 ] && out="${out}s"; fi + elif [ "$h" -gt 0 ]; then + out="${h} hour"; [ "$h" -ne 1 ] && out="${out}s" + if [ "$rem" -gt 0 ]; then out="${out} ${rem} min"; fi + else + out="${m} minutes" + fi + echo "$out" +} + +# Shared DA-lockout message body (dialogs + preflight). Args: +# $1 counter (X) $2 threshold (N) $3 interval-seconds (Z) $4 line3 +# Output: status lines, a one-line cause hint, and a reset note -- kept +# short so it fits the 80x25 newt floor (x230 / qemu use text mode). +# Never exits non-zero on normal input; the interval-guard is set -e-safe. +da_lockout_msg() { + local counter="${1:-}" threshold="${2:-}" interval="${3:-}" line3="${4:-}" z out + if [ -n "$counter" ] && [ -n "$threshold" ] && \ + echo "$counter" | grep -qE '^[0-9]+$' && echo "$threshold" | grep -qE '^[0-9]+$'; then + out="TPM lockout: ${counter} of ${threshold} allowed auth attempts used." + else + out="TPM lockout: allowed auth attempts exhausted." + fi + if [ -z "$interval" ] || [ "$interval" -le 0 ] 2>/dev/null; then + interval="$HEADS_TPM2_DA_RECOVERY_TIME" + fi + z="$(format_human_duration "$interval")" + printf '%s\n%s\n%s\n\n%s\n%s\n\n%s\n' \ + "$out" \ + "One attempt frees up after ${z} without new failures;" \ + "$line3" \ + "Causes: repeated auth failures, unclean power off/reset." \ + "Reset the TPM now (reseals secrets) if you can't wait." \ + "Choose an option below." +} + +# Keep in sync with tpmr.sh tpm2_reset() --recovery-time=3600. +# Defaults to 3600 (1 hour); tpmr.sh sets it on TPM2 reset. +[ -z "${HEADS_TPM2_DA_RECOVERY_TIME:-}" ] && HEADS_TPM2_DA_RECOVERY_TIME=3600 + +# run_with_timeout +# +# Run and return after at most , killing it if it +# exceeds the deadline. stdout/stderr pass through to the caller (so +# command substitution captures stdout normally). Returns the command's +# exit code, or 124 if it was killed for exceeding the timeout. +# +# Heads' busybox is built with CONFIG_TIMEOUT unset, so the standard +# `timeout` tool is unavailable; this is the in-shell equivalent. It is +# used to bound `tpm2 getcap` / `tpm getcapability`, which can block +# indefinitely during DA lockout on some TPMs (notably Intel PTT). +run_with_timeout() { + local secs="$1" pid waited=0 rc + shift + "$@" & + pid=$! + while kill -0 "$pid" 2>/dev/null; do + waited=$((waited + 1)) + if [ "$waited" -ge "$secs" ]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 1 + done + rc=0 + wait "$pid" 2>/dev/null || rc=$? + return "$rc" +} + # Filter known harmless LVM warning noise while preserving all other stderr. # Messages that are expected during device scanning (e.g. "not an LVM PV") are # redirected to the debug log only - they are not errors and should not appear @@ -1144,13 +1252,19 @@ recovery() { echo "$da_output" | while IFS= read -r line; do LOG "$line" done - da_summary="$(echo "$da_output" | grep '^=> ' | head -1)" + # da_state always emits a '=>' summary, but only the + # lockout-active lines warrant surfacing on the console. + # Everything else (within threshold, above threshold, + # limited info) is routine and stays in debug.log only. + da_summary="$(echo "$da_output" | grep -E '^=> (TPM LOCKOUT ACTIVE|TPM DEFEND LOCK ACTIVE)' | head -1)" if [ -n "$da_summary" ]; then STATUS "TPM DA: ${da_summary#=> }" else - # No => line means DA state unavailable on this TPM - # (e.g. STM TPM1 with no TPM_CAP_DA_LOGIC support); - # surface the self-descriptive "TPM DA state: ..." line. + # No lockout-active => line: either DA state is + # unavailable on this TPM (e.g. STM TPM1 with no + # TPM_CAP_DA_LOGIC support), or the TPM is simply + # within/above threshold but not currently locked. + # Only surface the self-descriptive unavailable line. da_unavail="$(echo "$da_output" | grep '^TPM DA state:' | head -1)" [ -n "$da_unavail" ] && STATUS "$da_unavail" fi @@ -1598,15 +1712,32 @@ debug_tpm_reset_required_state() { set_tpm_reset_required() { TRACE_FUNC - local reason source - reason="${1:-TPM state marked invalid by unknown caller}" - source="${2:-unknown}" + local reason="${1:-TPM state marked invalid by unknown caller}" + local source="${2:-unknown}" + local da_counter="${3:-}" da_threshold="${4:-}" da_interval="${5:-}" mkdir -p /tmp/secret || true echo "$reason" >"$(tpm_reset_required_reason_path)" 2>/dev/null || true echo "$source" >"$(tpm_reset_required_source_path)" 2>/dev/null || true date -u "+%Y-%m-%d %H:%M:%S UTC" >"$(tpm_reset_required_timestamp_path)" 2>/dev/null || true : >"$(tpm_reset_required_marker_path)" - WARN "TPM reset required: $reason" + if [ -n "$da_counter" ] && [ -n "$da_threshold" ] && \ + echo "$da_counter" | grep -qE '^[0-9]+$' && echo "$da_threshold" | grep -qE '^[0-9]+$'; then + local z="${da_interval:-}" + if [ -z "$z" ] || [ "$z" -le 0 ] 2>/dev/null; then + z="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + z="$(format_human_duration "$z")" + WARN "TPM lockout: ${da_counter} of ${da_threshold} attempts used; one attempt frees up after ${z} without new failures." + else + # Missing numbers — if interval is available, this is a DA context + local z="${da_interval:-}" + if [ -n "$z" ] && echo "$z" | grep -qE '^[0-9]+$' && [ "$z" -gt 0 ] 2>/dev/null; then + z="$(format_human_duration "$z")" + WARN "TPM lockout: attempts exhausted; one attempt frees up after ${z} without new failures." + else + WARN "TPM reset required: $reason" + fi + fi } clear_tpm_reset_required() { @@ -2092,9 +2223,10 @@ preflight_rollback_counter_before_reseal() { fail_preflight() { local message="$1" + local da_c="${2:-}" da_t="${3:-}" da_i="${4:-}" mkdir -p /tmp/secret || true : >"$reset_required_marker" - set_tpm_reset_required "$message" "preflight_rollback_counter_before_reseal" + set_tpm_reset_required "$message" "preflight_rollback_counter_before_reseal" "$da_c" "$da_t" "$da_i" if [ "$on_error" = "return" ]; then echo "$message" >"$error_file" return 1 @@ -2130,26 +2262,46 @@ preflight_rollback_counter_before_reseal() { # We also stash stderr locally so the preflight can branch on DA lockout. preflight_counter_err="$(mktemp)" if DO_WITH_DEBUG tpmr.sh counter_read -ix "$counter_id" \ - 2>"$preflight_counter_err" >/dev/null; then + >"$preflight_counter_err" 2>&1; then rm -f "$preflight_counter_err" else preflight_counter_msg="$(cat "$preflight_counter_err" 2>/dev/null || true)" rm -f "$preflight_counter_err" - LOG "Preflight: counter_read failed; tpm2 stderr: $preflight_counter_msg" + LOG "Preflight: counter_read failed; output: $preflight_counter_msg" + + # Detect DA lockout directly from the failed counter_read's own + # output, exactly as tpm1_unseal / tpm2_unseal do. During lockout + # the TPM rejects the read with TPM_RC_LOCKOUT (TPM2: "0x921", + # "lockout") or TPM_DEFEND_LOCK_RUNNING (TPM1: "defend"). This is + # the authoritative signal -- issue #2205 was precisely that the + # pre-fix code discarded this output (">/dev/null 2>&1"), making + # lockout indistinguishable from a swapped/missing counter. TPM1 + # tpmtotp prints errors to stdout and TPM2 tpm2-tools to stderr, + # so both streams are captured above and grepped together. + local da_line da_current da_threshold da_timer lockout_detected timer_display + lockout_detected="n" + if echo "$preflight_counter_msg" | grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*921|defend'; then + lockout_detected="y" + fi - # Diagnose: DA lockout vs other failure. PR #2124 added - # tpmr.sh da_state (TPM1+TPM2) emitting a machine-parsable - # "DA: state=… current=… threshold=… timer=…" line. timer>0 means - # currently locked (TCG backoff countdown). Full output goes to - # debug.log at LOG level; we only extract the policy fields here. + # Best-effort policy/timer enrichment from da_state. getcap can fail + # or block during lockout on some TPMs (notably Intel PTT), so this + # is non-fatal: the output grep above decides the branch; this only + # supplies current/threshold/remaining-backoff when available. da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" LOG "Preflight: da_state output:\n$da_state_out" - local da_line da_current da_threshold da_timer lockout_detected timer_display da_line="$(echo "$da_state_out" | grep '^DA: ' || true)" - da_current=$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/') - da_threshold=$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/') - da_timer=$(echo "$da_line" | sed -n 's/.*timer=\([^ ]*\).*/\1/p') - lockout_detected="n" + # Guard sed parse: an empty or malformed DA: line must + # yield empty fields, not the whole line. + if [ -n "$da_line" ]; then + da_current=$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/') + da_threshold=$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/') + da_timer=$(echo "$da_line" | sed -n 's/.*timer=\([^ ]*\).*/\1/p') + else + da_current="" + da_threshold="" + da_timer="" + fi if [ -n "$da_timer" ] && [ "$da_timer" -gt 0 ] 2>/dev/null; then lockout_detected="y" elif [ -n "$da_current" ] && [ -n "$da_threshold" ] && \ @@ -2165,28 +2317,24 @@ preflight_rollback_counter_before_reseal() { # tpm2_unseal which also set this marker on lockout. mkdir -p /tmp/secret || true : >/tmp/secret/tpm_da_lockout - [ -n "$preflight_counter_msg" ] && \ - echo "$preflight_counter_msg" >/tmp/secret/tpm_da_lockout_msg 2>/dev/null || true - timer_display="${da_timer}s" - if [ "${da_timer:-0}" -ge 3600 ] 2>/dev/null; then - timer_display="~$((da_timer / 3600)) hour(s)" - elif [ "${da_timer:-0}" -ge 60 ] 2>/dev/null; then - timer_display="~$((da_timer / 60)) min" + # Record when the lockout started (first detection wins). + [ -f /tmp/secret/tpm_da_lockout_start ] || date +%s > /tmp/secret/tpm_da_lockout_start + # Resolve Z: da_timer (numeric>0) or fall back to HEADS_TPM2_DA_RECOVERY_TIME + local Z="${da_timer:-}" + if ! echo "$Z" | grep -qE '^[0-9]+$' || [ "$Z" -le 0 ] 2>/dev/null; then + Z="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" fi - # Short whiptail -- full probable causes and "what not to do" - # guidance live in /tmp/debug.log (LOG above). The 76-column - # word wrap is handled by _whiptail_preprocess_args. - fail_preflight "TPM is in dictionary-attack lockout (DA $da_current/$da_threshold). - -Time until next auth can succeed: ${timer_display}. - -Common causes: repeated auth failures, or repeated unclean shutdowns -that skipped TPM2_Shutdown (notably on Intel PTT). - -Wait ${timer_display} then reboot, or reset the TPM from the GUI -(Options -> TPM/TOTP/HOTP Options -> Reset the TPM). - -Full diagnostics in /tmp/debug.log." + # Resolve line3: live remaining if available, else typically about Z + local line3 rem + rem="$(tpmr.sh da_remaining 2>/dev/null || true)" + if [ -n "$rem" ] && echo "$rem" | grep -qE '^[0-9]+$' && [ "$rem" -gt 0 ] 2>/dev/null; then + line3="about $(format_human_duration "$rem") until the TPM accepts auth again." + else + line3="typically about $(format_human_duration "$Z") until the TPM accepts auth again." + fi + local preflight_da_msg + preflight_da_msg="$(da_lockout_msg "${da_current:-}" "${da_threshold:-}" "$Z" "$line3")" + fail_preflight "$preflight_da_msg" "$da_current" "$da_threshold" "$Z" return 1 fi @@ -2250,7 +2398,7 @@ read_tpm_counter() { increment_tpm_counter() { TRACE_FUNC - local counter_id counter_present tpm_passphrase increment_ok + local counter_id counter_present tpm_passphrase increment_ok inc_err counter_id="$(echo "$1" | tr -d '\n')" tpm_passphrase="$2" counter_present="n" @@ -2285,35 +2433,49 @@ increment_tpm_counter() { # Both: count>=threshold-1 without lockout: WARN. if [ "$CONFIG_TPM" = "y" ]; then local da_line da_current da_threshold da_timer lockout_msg - da_line="$(tpmr.sh da_state 2>/dev/null | grep '^DA: ')" + da_line="$(run_with_timeout 3 tpmr.sh da_state 2>/dev/null | grep '^DA: ' || true)" da_current=$(echo "$da_line" | sed 's/.*current=\([^ ]*\).*/\1/') da_threshold=$(echo "$da_line" | sed 's/.*threshold=\([^ ]*\).*/\1/') # With sed -n /p, da_timer stays empty when timer= field absent (TPM2 clean) da_timer=$(echo "$da_line" | sed -n 's/.*timer=\([^ ]*\).*/\1/p') if [ -n "$da_current" ] && [ -n "$da_threshold" ]; then if [ -n "$da_timer" ] && [ "$da_timer" -gt 0 ] 2>/dev/null; then - local timer_display="${da_timer}s" - if [ "$da_timer" -ge 3600 ] 2>/dev/null; then - timer_display="~$((da_timer / 3600)) hour(s)" - elif [ "$da_timer" -ge 60 ] 2>/dev/null; then - timer_display="~$((da_timer / 60)) min" - fi + local timer_display + timer_display="$(format_human_duration "$da_timer")" DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (locked, ${timer_display})" # Set marker before DIE so recovery shell (commit 5) can # display the DA state, and so gui-init.sh (commit 6) # sees the lockout marker if the failure cascades. mkdir -p /tmp/secret 2>/dev/null || true touch /tmp/secret/tpm_da_lockout 2>/dev/null || true - lockout_msg="TPM dictionary attack lockout active (DA $da_current/$da_threshold, ${timer_display} remaining). Wait for the timer, or reset the TPM from GUI: Options -> TPM/TOTP/HOTP Options -> Reset the TPM." - echo "${timer_display}" >/tmp/secret/tpm_da_lockout_msg 2>/dev/null || true + lockout_msg="TPM dictionary attack lockout active (DA $da_current/$da_threshold)." + if [ -n "$da_timer" ] && echo "$da_timer" | grep -qE '^[0-9]+$' && [ "$da_timer" -gt 0 ] 2>/dev/null; then + lockout_msg="$lockout_msg + +Clears in about $(format_human_duration "$da_timer")." + else + lockout_msg="$lockout_msg + +Typically clears in about $(format_human_duration "$HEADS_TPM2_DA_RECOVERY_TIME")." + fi DIE "$lockout_msg" fi if [ "$da_current" -ge "$da_threshold" ] 2>/dev/null; then DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (above threshold, not locked)" - WARN "DA counter above threshold ($da_current/$da_threshold). Auth failures will trigger lockout." + local z="${da_timer:-}" + if [ -z "$z" ] || [ "$z" -le 0 ] 2>/dev/null; then + z="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + z="$(format_human_duration "$z")" + WARN "TPM lockout: ${da_current} of ${da_threshold} attempts used; one attempt frees up after ${z} without new failures." elif [ "$da_current" -ge $((da_threshold - 1)) ] 2>/dev/null; then DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (nearing threshold)" - WARN "DA counter nearing threshold ($da_current/$da_threshold). One more auth failure may trigger lockout." + local z="${da_timer:-}" + if [ -z "$z" ] || [ "$z" -le 0 ] 2>/dev/null; then + z="${HEADS_TPM2_DA_RECOVERY_TIME:-3600}" + fi + z="$(format_human_duration "$z")" + WARN "TPM lockout: ${da_current} of ${da_threshold} attempts used; one attempt frees up after ${z} without new failures." else DEBUG "increment_tpm_counter: DA $da_current/$da_threshold (within threshold)" fi @@ -2332,6 +2494,11 @@ increment_tpm_counter() { # file while still letting stdout appear on the console (and logging # stderr to debug log). DEBUG "incrementing TPM counter $counter_id" + # Capture the increment's own stderr (TPM2) so a DA-lockout rejection can + # be distinguished from a swapped/missing counter (issue #2205). TPM1 + # tpmtotp prints errors to stdout, which lands in /tmp/counter-$counter_id + # via the tee below, so only TPM2 needs this stderr temp file. + inc_err="$(mktemp)" if [ "$CONFIG_TPM2_TOOLS" = "y" ]; then # TPM2: counter_increment tries bare nvincrement (index auth) first, @@ -2342,7 +2509,7 @@ increment_tpm_counter() { set -o pipefail DO_WITH_DEBUG --mask-position 5 \ tpmr.sh counter_increment -ix "$counter_id" -pwdc "${tpm_passphrase:-}" \ - 2>/dev/null | + 2>"$inc_err" | tee /tmp/counter-"$counter_id" >/dev/null ); then increment_ok="y" @@ -2366,6 +2533,55 @@ increment_tpm_counter() { fi if [ "$increment_ok" != "y" ]; then + # Detect DA lockout from the increment's own failure output, exactly + # as the preflight guard and tpm1_unseal / tpm2_unseal do. During + # lockout the TPM rejects the increment with TPM_RC_LOCKOUT (TPM2: + # "0x921", "lockout") or TPM_DEFEND_LOCK_RUNNING (TPM1: "defend"). + # TPM2 tpm2-tools prints errors to stderr (captured in $inc_err); + # TPM1 tpmtotp prints errors to stdout (captured in + # /tmp/counter-$counter_id via tee). The pre-increment da_state guard + # above can't catch this reliably because getcap fails or blocks + # during lockout on some TPMs (notably Intel PTT). + local da_state_out da_line da_timer timer_display lockout_detected lockout_msg + lockout_detected="n" + if [ "$CONFIG_TPM2_TOOLS" = "y" ]; then + if grep -Eqi 'lockout|TPM_RC_LOCKOUT|0x?0*921' "$inc_err" 2>/dev/null; then + lockout_detected="y" + fi + else + if grep -Eqi 'defend|lock' "/tmp/counter-$counter_id" 2>/dev/null; then + lockout_detected="y" + fi + fi + rm -f "$inc_err" + DEBUG "increment_tpm_counter: lockout_detected=$lockout_detected" + + if [ "$lockout_detected" = "y" ]; then + mkdir -p /tmp/secret || true + touch /tmp/secret/tpm_da_lockout 2>/dev/null || true + # Best-effort policy/timer enrichment from da_state. getcap can + # fail or block during lockout on some TPMs (notably Intel PTT), + # so this is non-fatal; the output grep above decides the branch. + da_state_out="$(tpmr.sh da_state 2>/dev/null || true)" + da_line="$(echo "$da_state_out" | grep '^DA: ' || true)" + da_timer=$(echo "$da_line" | sed -n 's/.*timer=\([^ ]*\).*/\1/p') + timer_display="" + if [ -n "$da_timer" ] && [ "$da_timer" -gt 0 ] 2>/dev/null; then + timer_display="$(format_human_duration "$da_timer")" + fi + lockout_msg="TPM is in dictionary-attack lockout." + if [ -n "$da_timer" ] && echo "$da_timer" | grep -qE '^[0-9]+$' && [ "$da_timer" -gt 0 ] 2>/dev/null; then + lockout_msg="$lockout_msg + +Clears in about $(format_human_duration "$da_timer")." + else + lockout_msg="$lockout_msg + +Typically clears in about $(format_human_duration "$HEADS_TPM2_DA_RECOVERY_TIME")." + fi + DIE "$lockout_msg" + fi + if [ "$counter_present" = "y" ]; then mkdir -p /tmp/secret || true : >"$reset_required_marker" @@ -2392,6 +2608,7 @@ increment_tpm_counter() { DIE "TPM counter increment failed for rollback prevention. Reset the TPM using the GUI menu (Options -> TPM/TOTP/HOTP Options -> Reset the TPM) to clear the counter and allow a fresh one to be created." fi + rm -f "$inc_err" DEBUG "TPM counter incremented successfully for index $counter_id" }