Conversation
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 [<counter_id>] 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 <insurgo@riseup.net>
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 <insurgo@riseup.net>
… 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 <insurgo@riseup.net>
…ockout 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 <insurgo@riseup.net>
…ockout 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 <insurgo@riseup.net>
…nu 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 <insurgo@riseup.net>
…otocol, early-status display Signed-off-by: Thierry Laurion <insurgo@riseup.net>
…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 <insurgo@riseup.net>
…ntent
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 <insurgo@riseup.net>
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 <insurgo@riseup.net>
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 <insurgo@riseup.net>
…gress 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 <insurgo@riseup.net>
…l 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 <insurgo@riseup.net>
…M1 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 <insurgo@riseup.net>
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 <insurgo@riseup.net>
… 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 <insurgo@riseup.net>
…onest 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 <insurgo@riseup.net>
tlaurion
force-pushed
the
tpm_da_lockout_rework
branch
from
September 10, 2026 23:15
13d45c5 to
2625de3
Compare
37 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2123, #2205
This is the continuation of the TPM dictionary-attack (DA) lockout work
(PR #2124, closed after its head branch was force-pushed). All the work lives
on the same branch (
tlaurion:tpm_da_lockout_rework), freshly rebased on topof current
master— 17 signed commits, clean merge-base.The TPM1 counter-auth regression fix that used to live in this series is
standalone #2117 (already merged), so it is not part of these commits.
Summary
Makes TPM DA lockout a first-class, user-understandable event in Heads. When
the TPM is in lockout, the boot flow and the TOTP flows detect it up front, tell
the user — in plain language — how many failed attempts are left and how long
until authentication works again, and default to waiting with a live
countdown instead of failing with generic TPM errors (the "no TOTP/HOTP prompt
after an interrupted passphrase" failure mode reported in #2205).
Why
A locked TPM today surfaces as scattered, technical errors (failed unseal,
rollback-counter reads, "TPM error") that don't explain what happened or what
to do next. Heads already sets the policy
maxTries=10/recoveryTime=3600(one failed attempt is forgotten every hour), so lockout self-heals — but
nothing told the user that, and nothing counted the time down for them.
What it does
dialog, the launch-time preflight warning, and the recovery shell: "X of N
allowed auth attempts used; one attempt frees up after Z; roughly Z until
auth works again", plus what caused it and how to reset.
Wait — refresh countdown the first (default) choice. Selecting it re-reads
the TPM state and shows an updated estimate; the flow proceeds automatically
the moment the counter drops below the threshold (the TPM self-heals). No
background polling — the refresh is user-driven.
increments, on unseal, and on bad-auth; a marker file routes the boot flow to
the lockout UI instead of generic re-seal/reset questions.
register, so the countdown is derived from the policy interval minus elapsed
time; where the state is genuinely unknown the UI says so rather than showing
a misleading "0s remaining".
field carries the interval, lockout self-heals, one failed attempt forgotten
per recoveryTime), the marker-file protocol, and the GUI's supported
screen-size floor.
Testing notes
tpmr.sh da_state/bad_authhelpers (TPM1 and TPM2) fromthe recovery shell; a locked-lockout boot on real TPM firmware (
qemu-tpm2or hardware) is the acceptance test and runs outside CI.
is also exposed as an explicit menu entry for discoverability.