@@ -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
7995impl 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