Skip to content

Commit fa4e0aa

Browse files
committed
fix(otp): stop reporting a key that answered as a key we couldn't reach
An OTP command against a Token2 key supplied without the on-device OTP function told the user "the OTP applet is not reachable over HID or CCID — HID may be disabled on the key". That advice could not help: the debug trace in the report shows a complete HID exchange, with the key answering status word 0105. HID was working; the key was declining. The cause was a discarded error. detect() probes the applet over HID, falls back to CCID, and when both fail reported NoUsableInterface unconditionally — an `Err(_)` arm that threw away whether the probe had been answered at all. A device-level refusal and a dead interface became the same message. Keep the probe error and classify it. An applet status word proves the interface carried a full request and response, so it now reports the key declining and quotes the status word; only a probe that got no answer (silence, framing damage, a failed open) still blames the interface. The decision moved into a pure function so it is testable — the original bug lived in an inline match arm no test could reach. Also refuse earlier where we already know better. The USB product id encodes which function set a key shipped with, so for a configuration with no OTP function the command is now refused before any APDU, naming the reason. Unrecognised ids are still probed, so this cannot lock out a model the table has not learned yet. Adds OtpError::status_word() as the inverse of check(); the typed error had been discarding the value the transport needs to tell these cases apart. re #95
1 parent 1f75a31 commit fa4e0aa

3 files changed

Lines changed: 223 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ All notable changes to keyroost are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
55
follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
- **A key that answers is no longer reported as a key that can't be reached.**
11+
Running an OTP command against a Token2 key supplied *without* the on-device
12+
OTP function said "the OTP applet is not reachable over HID or CCID — HID may
13+
be disabled on the key", which sent people to enable an interface that was
14+
already working: the key had completed a full USB-HID exchange and simply
15+
declined. keyroost now distinguishes the two — an applet that answers with a
16+
status word means the interface works and the *key* refused, and the message
17+
says so and quotes the status word. Where the USB product id already
18+
identifies a configuration with no OTP function, the command is refused up
19+
front, naming the real reason, without sending an APDU that could only fail.
20+
Unrecognised product ids are still probed. ([#95])
21+
722
## [0.7.7] - 2026-08-04
823

924
### Added
@@ -783,6 +798,7 @@ multi-vendor hardware-security-key manager, then took its neutral name. Highligh
783798
[#81]: https://github.com/framefilter/keyroost/issues/81
784799
[#82]: https://github.com/framefilter/keyroost/issues/82
785800
[#83]: https://github.com/framefilter/keyroost/issues/83
801+
[#95]: https://github.com/framefilter/keyroost/issues/95
786802
[Unreleased]: https://github.com/framefilter/keyroost/compare/v0.7.7...HEAD
787803
[0.7.7]: https://github.com/framefilter/keyroost/compare/v0.7.6...v0.7.7
788804
[0.7.6]: https://github.com/framefilter/keyroost/compare/v0.7.5...v0.7.6

crates/keyroost-token2otp/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,29 @@ impl OtpError {
140140
pub fn is_empty_token(&self) -> bool {
141141
matches!(self, OtpError::EntryNotFound)
142142
}
143+
144+
/// The status word this error was mapped from — the inverse of
145+
/// [`check`](OtpError::check).
146+
///
147+
/// Every variant here came from one, but only `BadStatusCode` kept it, so
148+
/// callers that needed the raw value had to discard the typed error. The
149+
/// transport needs it to tell "the applet answered and declined" apart from
150+
/// "the interface never worked": a key that returns a status word has a
151+
/// working interface, and blaming the transport sends the user off to
152+
/// enable something that is already on (issue #95).
153+
///
154+
/// `EntryNotFound` reports the primary `6A80`; it is also produced by the
155+
/// `6A83` alias, so this is a lossy inverse for that one variant.
156+
#[must_use]
157+
pub fn status_word(&self) -> u16 {
158+
match self {
159+
OtpError::EntryNotFound => sw::ENTRY_NOT_FOUND,
160+
OtpError::NotEnoughSpace => sw::NOT_ENOUGH_SPACE,
161+
OtpError::ButtonPressRequired => sw::BUTTON_TIMEOUT,
162+
OtpError::HidNotSupported => sw::HID_NOT_SUPPORTED,
163+
OtpError::BadStatusCode(sw) => *sw,
164+
}
165+
}
143166
}
144167

145168
impl std::fmt::Display for OtpError {

crates/keyroost-transport/src/token2otp.rs

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ pub enum OtpTransportError {
7474
/// flag must never drive unbounded host work (audit KEY-009), so this is
7575
/// treated as a buggy or hostile token rather than looped on forever.
7676
EnumerationCapExceeded,
77+
/// The connected key's USB product id identifies a Token2 product
78+
/// configuration supplied *without* the on-device OTP function, so no OTP
79+
/// command can succeed on it. Refused before any APDU is sent so the user
80+
/// gets the real reason rather than a transport error (issue #95).
81+
OtpFunctionNotFitted {
82+
pid: u16,
83+
model: Option<&'static str>,
84+
},
85+
/// The OTP applet answered over HID but declined the read-only detect
86+
/// probe with a non-`9000` status word, and no CCID fallback was
87+
/// available. Distinct from [`NoUsableInterface`](Self::NoUsableInterface)
88+
/// on purpose: a status word proves the interface carried a full
89+
/// request/response, so this is the key refusing rather than a connection
90+
/// problem, and telling the user to enable HID would send them the wrong
91+
/// way (issue #95).
92+
HidProbeDeclined { sw: u16 },
7793
}
7894

7995
impl std::fmt::Display for OtpTransportError {
@@ -102,6 +118,27 @@ impl std::fmt::Display for OtpTransportError {
102118
"the device kept reporting more OTP entries than any supported \
103119
token stores; aborting enumeration"
104120
),
121+
OtpTransportError::OtpFunctionNotFitted { pid, model } => write!(
122+
f,
123+
"this key does not have the on-device OTP function: USB product id \
124+
{:#06X}{} is a Token2 configuration supplied without it. Token2 keys \
125+
aren't upgradable after purchase — OTP is a separate product \
126+
configuration, not something that can be switched on later",
127+
pid,
128+
match model {
129+
Some(m) => format!(" ({})", m),
130+
None => String::new(),
131+
}
132+
),
133+
OtpTransportError::HidProbeDeclined { sw } => write!(
134+
f,
135+
"the key answered over USB-HID but declined the OTP applet with status \
136+
word {:#06X}. The interface is working — this is the key refusing, not \
137+
a connection problem — most often because it was supplied without the \
138+
on-device OTP function, which cannot be enabled after purchase. Run \
139+
`keyroostctl list` to see what this key reports",
140+
sw
141+
),
105142
}
106143
}
107144
}
@@ -251,6 +288,19 @@ impl HidOtpTransport {
251288
|| d.product_id == t2::USB_PID)
252289
});
253290
let dev = found.ok_or(OtpTransportError::TokenNotDetected)?;
291+
// The PID encodes which function set the key was supplied with. On a
292+
// configuration that has no OTP function, every OTP command is doomed,
293+
// and probing anyway produces a transport-shaped error that blames the
294+
// interface for the key's own product configuration (issue #95). Refuse
295+
// here, before a single APDU, so the message names the real reason.
296+
// Fail-open: an unrecognised PID is still probed (`token2_pid_may_have_otp`
297+
// returns true when the table has no entry).
298+
if !keyroost_proto::token2_pid_may_have_otp(dev.product_id) {
299+
return Err(OtpTransportError::OtpFunctionNotFitted {
300+
pid: dev.product_id,
301+
model: keyroost_proto::token2_pid_label(dev.product_id),
302+
});
303+
}
254304
Self::open_path(&dev.path)
255305
}
256306

@@ -756,6 +806,34 @@ fn probe_hid_owned(
756806
}
757807
}
758808

809+
/// Which error to report when the HID probe failed *and* the CCID fallback was
810+
/// unavailable, so the probe outcome is the only evidence there is.
811+
///
812+
/// The distinction this makes is the whole point. If the applet returned a
813+
/// status word, the HID interface carried a complete request and response —
814+
/// it demonstrably works, and the key simply declined. Reporting that as
815+
/// [`NoUsableInterface`](OtpTransportError::NoUsableInterface) tells the user
816+
/// to enable an interface that is already enabled, which is what issue #95
817+
/// reported: a key answering `0105` was diagnosed as "HID may be disabled".
818+
/// Only a probe that never got an answer (framing error, timeout, I/O failure)
819+
/// is evidence about the interface itself.
820+
///
821+
/// Pure and total so the classification is unit-testable without hardware —
822+
/// the original defect was a `_ =>` arm in an inline match that no test could
823+
/// reach.
824+
fn detect_failure_from(probe_err: OtpTransportError) -> OtpTransportError {
825+
match probe_err {
826+
// The applet spoke. Carry the status word so the message can say what
827+
// the key actually answered.
828+
OtpTransportError::Applet(e) => OtpTransportError::HidProbeDeclined {
829+
sw: e.status_word(),
830+
},
831+
// Silence, framing damage, or the interface never opening: these are
832+
// genuinely about the transport.
833+
_ => OtpTransportError::NoUsableInterface,
834+
}
835+
}
836+
759837
/// Confirm the OTP applet answers over HID using the read-only
760838
/// `GET_ECDH_PUBKEY` command (supported by every model, changes nothing). A
761839
/// non-`9000` status word or any transport error means HID isn't usable for the
@@ -795,15 +873,18 @@ impl Token2OtpSession {
795873
transport: Box::new(t),
796874
is_pcsc: false,
797875
}),
798-
Err(_) => {
799-
// HID present but applet unreachable (HID disabled on the
800-
// device, or the probe timed out) — try CCID instead.
876+
Err((probe_err, _)) => {
877+
// HID present but the applet didn't accept the probe
878+
// (HID disabled on the device, the probe timed out, or
879+
// the key declined) — try CCID instead.
801880
match PcScOtpTransport::open_first_debug(debug) {
802881
Ok(p) => Ok(Self {
803882
transport: Box::new(p),
804883
is_pcsc: true,
805884
}),
806-
Err(_) => Err(OtpTransportError::NoUsableInterface),
885+
// No CCID either, so the probe outcome is all we
886+
// know — and it matters which kind it was.
887+
Err(_) => Err(detect_failure_from(probe_err)),
807888
}
808889
}
809890
}
@@ -1417,3 +1498,102 @@ mod hidraw_bounded_read_tests {
14171498
assert!(start.elapsed() < Duration::from_secs(5));
14181499
}
14191500
}
1501+
1502+
/// Issue #95: a key that answers must not be reported as a key that cannot be
1503+
/// reached. The reporter's Bio3 Dual (FIDO + PGP) completed a full HID exchange
1504+
/// and returned `0105`, and keyroost told them "HID may be disabled on the key"
1505+
/// — advice that could not have helped, because HID was working.
1506+
#[cfg(test)]
1507+
mod declined_probe_is_not_an_unusable_interface {
1508+
use super::*;
1509+
use keyroost_token2otp::sw;
1510+
1511+
#[test]
1512+
fn an_applet_status_word_reports_the_key_declining() {
1513+
// Exactly the reporter's case.
1514+
let err = detect_failure_from(OtpTransportError::Applet(OtpError::BadStatusCode(0x0105)));
1515+
assert!(
1516+
matches!(err, OtpTransportError::HidProbeDeclined { sw: 0x0105 }),
1517+
"an answering applet must not be classified as an unusable interface, got {err:?}"
1518+
);
1519+
let msg = err.to_string();
1520+
assert!(
1521+
msg.contains("0x0105"),
1522+
"the status word belongs in the message: {msg}"
1523+
);
1524+
assert!(
1525+
!msg.contains("disabled"),
1526+
"must not tell the user to enable an interface that just carried a \
1527+
full request and response: {msg}"
1528+
);
1529+
}
1530+
1531+
#[test]
1532+
fn every_applet_error_keeps_its_status_word() {
1533+
// status_word() is the inverse of check(), so the transport can always
1534+
// recover what the key said regardless of which variant it mapped to.
1535+
for sw in [
1536+
sw::ENTRY_NOT_FOUND,
1537+
sw::NOT_ENOUGH_SPACE,
1538+
sw::BUTTON_TIMEOUT,
1539+
sw::HID_NOT_SUPPORTED,
1540+
0x0105,
1541+
0x6A80,
1542+
] {
1543+
let e = OtpError::check(sw).expect_err("non-9000 must map to an error");
1544+
match detect_failure_from(OtpTransportError::Applet(e)) {
1545+
OtpTransportError::HidProbeDeclined { sw: got } => {
1546+
assert_eq!(got, sw, "status word must survive the round trip");
1547+
}
1548+
other => panic!("expected HidProbeDeclined for {sw:#06X}, got {other:?}"),
1549+
}
1550+
}
1551+
}
1552+
1553+
#[test]
1554+
fn a_silent_or_broken_interface_still_blames_the_interface() {
1555+
// The other half of the distinction: no answer really is a transport
1556+
// problem, and must keep the original message.
1557+
for probe_err in [
1558+
OtpTransportError::TokenNotDetected,
1559+
OtpTransportError::EmptyResponse,
1560+
OtpTransportError::TransportUnavailable("no such device".into()),
1561+
] {
1562+
assert!(
1563+
matches!(
1564+
detect_failure_from(probe_err),
1565+
OtpTransportError::NoUsableInterface
1566+
),
1567+
"a probe that got no answer is evidence about the interface"
1568+
);
1569+
}
1570+
}
1571+
1572+
#[test]
1573+
fn the_reporters_product_id_is_refused_before_any_apdu() {
1574+
// 0x0204 is Bio3 Dual (FIDO + PGP) — supplied without OTP.
1575+
assert!(
1576+
!keyroost_proto::token2_pid_may_have_otp(0x0204),
1577+
"0x0204 has no OTP function and must not be probed"
1578+
);
1579+
let err = OtpTransportError::OtpFunctionNotFitted {
1580+
pid: 0x0204,
1581+
model: keyroost_proto::token2_pid_label(0x0204),
1582+
};
1583+
let msg = err.to_string();
1584+
assert!(msg.contains("0x0204"), "name the product id: {msg}");
1585+
assert!(
1586+
msg.contains("does not have the on-device OTP function"),
1587+
"state the real reason: {msg}"
1588+
);
1589+
}
1590+
1591+
#[test]
1592+
fn an_unknown_product_id_is_still_probed() {
1593+
// Fail-open: a PID we have never seen must not be refused offline.
1594+
assert!(
1595+
keyroost_proto::token2_pid_may_have_otp(0xFFFF),
1596+
"an unrecognised PID must fall through to a live probe"
1597+
);
1598+
}
1599+
}

0 commit comments

Comments
 (0)