diff --git a/doc/tpm.md b/doc/tpm.md index 8cecfb55b..0e092c9ba 100644 --- a/doc/tpm.md +++ b/doc/tpm.md @@ -522,3 +522,236 @@ 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, 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 +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 --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 +passphrase, bumping the TPM's DA failedTries counter on demand. +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. + +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 +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. + +### 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*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 + 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 +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 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 (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. + +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 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. +* 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. diff --git a/doc/ux-patterns.md b/doc/ux-patterns.md index 08998243a..f5bc041c2 100644 --- a/doc/ux-patterns.md +++ b/doc/ux-patterns.md @@ -81,6 +81,54 @@ 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 @@ -359,6 +407,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/gui-init.sh b/initrd/bin/gui-init.sh index ce03d953b..7cc7019db 100755 --- a/initrd/bin/gui-init.sh +++ b/initrd/bin/gui-init.sh @@ -286,8 +286,95 @@ update_totp() { DEBUG "TPM state at TOTP failure:" DEBUG "$(pcrs)" - totp_menu_text=$( - cat </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" - - option=$(cat /tmp/whiptail) - case "$option" in - g) + ) + 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 + + # 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' \ @@ -347,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 @@ -908,6 +1003,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="$(run_with_timeout 3 tpmr.sh da_state 2>/dev/null || true)" + if [ -n "$da_state_out" ]; then + # 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#=> }" + fi + fi +fi + if [ -x /bin/hotp_verification ]; then # HOTP required by board config, always detect branding detect_usb_security_dongle_branding @@ -947,23 +1060,125 @@ 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) 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 + # 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 + 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 + 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" + 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 @@ -972,7 +1187,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' \ diff --git a/initrd/bin/tpmr.sh b/initrd/bin/tpmr.sh index 80bd605eb..98471ede1 100755 --- a/initrd/bin/tpmr.sh +++ b/initrd/bin/tpmr.sh @@ -865,14 +865,29 @@ 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 - WARN "Unable to unseal secret from TPM NVRAM" - + -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 -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 + rm -f "$TMP_STDERR" rm -f "$TMP_ERR_FILE" } @@ -964,6 +979,30 @@ 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 + # 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#=> }" + fi + fi + fi if [ "$HEADS_NONFATAL_UNSEAL" = "y" ]; then DEBUG "nonfatal tpm1_unseal failure: unable to unseal TPM NVRAM blob" return 1 @@ -1139,10 +1178,700 @@ 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). +# +# 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 +# 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). + 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/.*: *//' || 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)" + 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 + DEBUG "tpm1_da_state: DA state not available (rc=$rc)" + _da_report "TPM DA state: unavailable" + return 1 + fi + _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" + _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" -gt 0 ] 2>/dev/null; then + _da_report "=> TPM DEFEND LOCK ACTIVE (${timer}s remaining)" + else + _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)" + _da_report "=> Above threshold: $current/$threshold failures (auth failures will trigger lockout)" + elif [ -n "$current" ] && [ -n "$threshold" ]; then + _da_report "=> Within lockout threshold ($current/$threshold failures used)" + else + _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. + _da_report "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. 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="" 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 + 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 + 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" + _da_report "TPM2 DA state: unavailable" + return 1 + fi + _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 + _da_report " recoveryTime (seconds before one failure is forgotten): $interval ($((interval / 60)) min)" + else + _da_report " recoveryTime (seconds before one failure is forgotten): $interval" + fi + _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)" + _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 + _da_report " lockoutAuth blocked until TPM reset (lockoutRecovery=0)" + fi + # 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)" + _da_report "=> Within lockout threshold ($counter/$max_auth failures used)" + _da_report "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 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 + + 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 + # 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 + 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 + + 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//')" + 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 + + # 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) + # 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" + 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" + 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 + echo "bad_auth (TPM1): attempting increment with WRONG auth on 0x$rollback_counter_id..." >&2 + 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..." + # 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 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) + 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:-}" + # 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" + 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" + 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 + # (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 "bad_auth (TPM2): no BEFORE state capture — nvincrement is the actual test, not the da_state query" + 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 + echo "bad_auth (TPM2): attempting nvincrement with WRONG auth on 0x$counter_id..." >&2 + 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..." + # 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 @@ -1213,6 +1942,17 @@ if [ "$CONFIG_TPM2_TOOLS" != "y" ]; then tpm1_reset "$@" STATUS_OK "TPM reset completed" ;; + da_state) + shift + tpm1_da_state "$@" || true + ;; + da_remaining) + tpm_da_remaining + ;; + bad_auth) + shift + tpm1_bad_auth "$@" + ;; kexec_finalize) ;; # Nothing on TPM1. shutdown) ;; # Nothing on TPM1. *) @@ -1263,6 +2003,15 @@ counter_increment) counter_create) tpm2_counter_create "$@" ;; + da_state) + tpm2_da_state "$@" || true + ;; +da_remaining) + tpm_da_remaining + ;; +bad_auth) + tpm2_bad_auth "$@" + ;; destroy) tpm2_destroy "$@" ;; diff --git a/initrd/etc/functions.sh b/initrd/etc/functions.sh index 2f5f6a15e..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 @@ -1131,6 +1239,38 @@ 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_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 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 + 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 @@ -1572,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() { @@ -2066,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 @@ -2097,8 +2255,102 @@ 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" \ + >"$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; 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 + + # 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" + da_line="$(echo "$da_state_out" | grep '^DA: ' || true)" + # 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" ] && \ + [ "$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 + # 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 + # 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 + + # 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 @@ -2146,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" @@ -2166,6 +2418,72 @@ 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="$(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 + 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)." + 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)" + 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)" + 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 + 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 @@ -2176,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, @@ -2186,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" @@ -2210,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" @@ -2236,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" }