Skip to content

initrd: TPM DA-lockout detection, gating, and wait-by-default countdown UX - #2206

Draft
tlaurion wants to merge 17 commits into
linuxboot:masterfrom
tlaurion:tpm_da_lockout_rework
Draft

tlaurion wants to merge 17 commits into
linuxboot:masterfrom
tlaurion:tpm_da_lockout_rework

Conversation

@tlaurion

@tlaurion tlaurion commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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 top
of 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

  • Canonical lockout message shared by the boot dialog, the TOTP failure
    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 by default, with a live countdown. Both lockout dialogs make
    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.
  • Detection and gating. DA state is captured at preflight, on TPM counter
    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.
  • Honest remaining-time handling. The TPM exposes no "time until unlocked"
    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".
  • Docs. The DA section now documents the actual policy semantics (the timer
    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

  • Exercised via tpmr.sh da_state / bad_auth helpers (TPM1 and TPM2) from
    the recovery shell; a locked-lockout boot on real TPM firmware (qemu-tpm2
    or hardware) is the acceptance test and runs outside CI.
  • Known limitation: escaping a lockout dialog drops to the recovery shell; it
    is also exposed as an explicit menu entry for discoverability.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant