Skip to content

Commit f73b9b0

Browse files
authored
Merge pull request #2116 from linuxboot/nk3_hotp_slot_detection
initrd/bin/seal-hotpkey.sh: always query fresh PIN retry counter from hotp_verification info
2 parents c1ed061 + d92e9bd commit f73b9b0

2 files changed

Lines changed: 115 additions & 30 deletions

File tree

doc/hotp.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# HOTP & USB Security Dongle
2+
3+
This document covers how `seal-hotpkey.sh` interacts with `hotp_verification`
4+
and the NK3-specific behavior Heads scripts need to handle.
5+
6+
See also: [security-model.md](security-model.md), [ux-patterns.md](ux-patterns.md),
7+
[tpm.md](tpm.md). Return code definitions live in
8+
`build/x86/hotp-verification-*/src/return_codes.h`.
9+
10+
---
11+
12+
## NK3 `info` command output and exit codes
13+
14+
`hotp_verification info` communicates over CCID (NK3) or HID (Pro/Storage/Librem
15+
Key). stdout lines are tab-indented (`\t`). The presence check in
16+
`seal-hotpkey.sh` looks for `Connected device status:` in the output, regardless
17+
of exit code.
18+
19+
| Scenario | Exit code | `Connected device status:` | Counter line |
20+
|---|---|---|---|
21+
| Slot configured | 0 | yes | `Secrets app PIN counter: N` |
22+
| Slot unconfigured, OATH alive | 0 | yes | `PIN is not set - set PIN...` |
23+
| OATH SELECT fails (Path B) | 3 | no (returns early) | not printed |
24+
| No dongle connected | 1 | no | not printed |
25+
26+
**Path A** (unconfigured slot, applet present): `status_ccid()` returns
27+
`RET_NO_PIN_ATTEMPTS` (31), `parse_cmd_and_run()` converts to exit 0.
28+
The counter line reads `"PIN is not set - set PIN before the first use"`.
29+
30+
**Path B** (OATH SELECT fails): `send_select_ccid()` returns non-0x9000,
31+
`status_ccid()` returns `RET_COMM_ERROR` (29), `check_ret()` returns early.
32+
No `Connected device status:` is printed; exit code 3.
33+
34+
## NK3 PIN counter mapping
35+
36+
| PIN type | Source | Counter path | Factory default |
37+
|---|---|---|---|
38+
| Secrets App PIN | OATH applet (`Tag_PINCounter` TLV) | `Secrets app PIN counter:` | 8 |
39+
| GPG Admin PIN | PGP applet (ISO 7816 `0xCA`) | `GPG Card counters: Admin` | 3 |
40+
| GPG User PIN | PGP applet (same `0xCA`) | `GPG Card counters: User` | 3 |
41+
42+
`seal-hotpkey.sh` uses the Secrets App PIN (called `admin_pin_retries` for
43+
historical reasons).
44+
45+
## seal-hotpkey.sh retry logic
46+
47+
All counter queries go through `query_pin_retries()` which retries
48+
`hotp_verification info` until a valid numeric counter is obtained.
49+
If the dongle is present but no numeric counter is found (unconfigured
50+
slot), `query_pin_retries` dies immediately.
51+
52+
| Call site | Max retries | Purpose |
53+
|---|---|---|
54+
| Initial presence check | 1 (+ INPUT retry) | Get first counter, prompt reinsert on failure |
55+
| `show_pin_retries()` | 3 per PIN attempt | Display fresh count before each try |
56+
| `max_attempts` re-read | 1 | Seed attempt ceiling with current value |
57+
58+
```
59+
NK3 (factory): 8 attempts -> default-PIN skip at < 3, max_attempts = min(retries-1, 3)
60+
NK3 (decremented): 7 attempts -> max_attempts = 3 (capped)
61+
NK3 (decremented): 2 attempts -> max_attempts = 1 (min(2-1, 3))
62+
Pre-NK3 (factory): 3 attempts -> max_attempts = min(3-1, 3) = 2
63+
Pre-NK3 (decrem.): 2 attempts -> max_attempts = min(2-1, 3) = 1
64+
```
65+
66+
## Pre-NK3 vs NK3 PIN behavior
67+
68+
- **Pre-NK3** (Pro, Storage, Librem Key): Admin PIN counter starts at 3
69+
(hardware-enforced), maps directly to OATH credential creation PIN.
70+
- **NK3**: Secrets App PIN counter starts at 8 (configurable), protects
71+
OATH credential operations.
72+
73+
## Firmware version display
74+
75+
`hotpkey_fw_display` in `initrd/etc/functions.sh` parses `hotp_verification info`
76+
output to show the dongle firmware version with color coding. Minimum-version
77+
thresholds are in `initrd/etc/dongle-versions`.
78+
See [ux-patterns.md](ux-patterns.md#color-coded-version-checks).

initrd/bin/seal-hotpkey.sh

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,41 @@ TRACE_FUNC
6565
# Make sure no conflicting GPG related services are running, gpg-agent will respawn
6666
DO_WITH_DEBUG killall gpg-agent scdaemon >/dev/null 2>&1 || true
6767

68-
# While making sure the key is inserted, capture the status so we can check how
69-
# many PIN attempts remain
68+
# Query hotp_verification info and extract numeric PIN retry counter.
69+
# Retries up to N times on communication failure. Dies if the dongle
70+
# is present but no numeric counter can be extracted.
71+
query_pin_retries() {
72+
local var_name="$1" dongle="$2" max="${3:-3}"
73+
local attempt=0 info counter
74+
while [ $attempt -lt "$max" ]; do
75+
attempt=$((attempt + 1))
76+
info="$(hotp_verification info 2>/dev/null)" || true
77+
echo "$info" | grep -q "Connected device status:" || continue
78+
HOTP_TOKEN_INFO="$info"
79+
if [ "$dongle" = "Nitrokey 3" ]; then
80+
counter=$(echo "$info" | grep "Secrets app PIN counter:" | grep -o '[0-9][0-9]*')
81+
else
82+
counter=$(echo "$info" | grep "Card counters: Admin" | grep -o 'Admin [0-9]*' | grep -o '[0-9]*')
83+
fi
84+
if [ -n "$counter" ]; then
85+
eval "$var_name=\$counter"
86+
return 0
87+
fi
88+
# Dongle is present but counter is missing — slot not configured.
89+
DIE "$DONGLE_BRAND HOTP slot is not configured"
90+
done
91+
eval "$var_name="
92+
return 1
93+
}
94+
95+
# Initial query — if the dongle is not responding, prompt for reinsertion
96+
# and retry once.
7097
STATUS "Checking $DONGLE_BRAND presence for HOTP setup"
71-
if ! hotp_token_info="$(hotp_verification info)"; then
98+
if ! query_pin_retries admin_pin_retries "$DONGLE_BRAND" 1; then
7299
INPUT "Insert your $DONGLE_BRAND and press Enter to configure it"
73-
if ! hotp_token_info="$(hotp_verification info)"; then
74-
# don't leak key on failure
100+
if ! query_pin_retries admin_pin_retries "$DONGLE_BRAND" 1; then
75101
shred -n 10 -z -u "$HOTP_SECRET" 2>/dev/null
76-
DIE "Unable to find $DONGLE_BRAND"
102+
DIE "Unable to communicate with $DONGLE_BRAND"
77103
fi
78104
fi
79105
STATUS_OK "$DONGLE_BRAND is present for HOTP setup"
@@ -99,31 +125,18 @@ now_date="$(date '+%s')"
99125
# NK3 uses "Secrets app PIN counter" (factory default: 8 attempts);
100126
# all pre-NK3 devices use "Card counters: Admin" (factory default: 3 attempts).
101127
if [ "$DONGLE_BRAND" = "Nitrokey 3" ]; then
102-
admin_pin_retries=$(echo "$hotp_token_info" | grep "Secrets app PIN counter:" | cut -d ':' -f 2 | tr -d ' ')
103128
prompt_message="Secrets app"
104129
else
105-
admin_pin_retries=$(echo "$hotp_token_info" | grep "Card counters: Admin" | grep -o 'Admin [0-9]*' | grep -o '[0-9]*')
106130
prompt_message="GPG Admin"
107131
fi
108-
109-
admin_pin_retries="${admin_pin_retries:-0}"
110132
DEBUG "HOTP related PIN retry counter is $admin_pin_retries"
111133
# Show dongle firmware version with color coding so users know when to upgrade
112-
hotpkey_fw_display "$hotp_token_info" "$DONGLE_BRAND"
134+
hotpkey_fw_display "$HOTP_TOKEN_INFO" "$DONGLE_BRAND"
113135

114136
# Re-query and display the current PIN retry counter before each manual prompt.
115-
# Updates the global $admin_pin_retries (no local keyword) so callers can use
116-
# the fresh value for decisions (e.g. max_attempts calculation below).
117137
# prompt_message is already set for the device type (NK3 vs older), reuse it.
118138
show_pin_retries() {
119-
local info
120-
info="$(hotp_verification info 2>/dev/null)" || true
121-
if [ "$DONGLE_BRAND" = "Nitrokey 3" ]; then
122-
admin_pin_retries=$(echo "$info" | grep "Secrets app PIN counter:" | cut -d ':' -f 2 | tr -d ' ')
123-
else
124-
admin_pin_retries=$(echo "$info" | grep "Card counters: Admin" | grep -o 'Admin [0-9]*' | grep -o '[0-9]*')
125-
fi
126-
admin_pin_retries="${admin_pin_retries:-0}"
139+
query_pin_retries admin_pin_retries "$DONGLE_BRAND" 3
127140
STATUS "$DONGLE_BRAND ${prompt_message} PIN retries remaining: $(pin_color "$admin_pin_retries")${admin_pin_retries}\033[0m"
128141
}
129142

@@ -182,15 +195,9 @@ if [ "$admin_pin_status" -ne 0 ]; then
182195
# Default PIN skipped (key >1 month old) -> max_attempts = min(3-1, 3) = 2
183196
# Default PIN tried & failed (3 -> 2 remaining) -> max_attempts = min(2-1, 3) = 1
184197
# Counter read failed (0 or empty) -> max_attempts = 3 (fallback, don't block)
185-
# Re-read counter without displaying (loop will show it)
186-
info="$(hotp_verification info 2>/dev/null)" || true
187-
if [ "$DONGLE_BRAND" = "Nitrokey 3" ]; then
188-
admin_pin_retries=$(echo "$info" | grep "Secrets app PIN counter:" | cut -d ':' -f 2 | tr -d ' ')
189-
else
190-
admin_pin_retries=$(echo "$info" | grep "Card counters: Admin" | grep -o 'Admin [0-9]*' | grep -o '[0-9]*')
191-
fi
192-
admin_pin_retries="${admin_pin_retries:-0}"
193-
if [ "$admin_pin_retries" -ge 2 ]; then
198+
# Re-read counter without displaying (loop will show it).
199+
query_pin_retries admin_pin_retries "$DONGLE_BRAND" 1
200+
if [ -n "$admin_pin_retries" ] && [ "$admin_pin_retries" -ge 2 ]; then
194201
max_attempts=$((admin_pin_retries - 1))
195202
[ "$max_attempts" -gt 3 ] && max_attempts=3
196203
else

0 commit comments

Comments
 (0)