diff --git a/hardware/chalresp/chalresp_hid.go b/hardware/chalresp/chalresp_hid.go index 476a1b0..76782d8 100644 --- a/hardware/chalresp/chalresp_hid.go +++ b/hardware/chalresp/chalresp_hid.go @@ -15,6 +15,7 @@ package chalresp import ( "errors" "fmt" + "runtime" "sync" "time" @@ -82,7 +83,15 @@ func hidEnsureInit() error { hidInitOnce.Do(func() { if err := hid.Init(); err != nil { hidInitErr = fmt.Errorf("chalresp: hidapi init failed: %w", err) + return } + // macOS: open the OTP interface NON-exclusively. hidapi's darwin backend + // defaults to kIOHIDOptionsTypeSeizeDevice ("backward compatibility"), but + // *seizing* a keyboard-class device needs more privilege than Input + // Monitoring grants, so IOHIDDeviceOpen returns kIOReturnNotPermitted even + // with the permission granted. ykman opens non-exclusively and works — match + // it. No-op on non-darwin backends. (hidConfigureOpen is platform-split.) + hidConfigureOpen() }) return hidInitErr } @@ -96,8 +105,16 @@ func hidList() ([]DeviceDescriptor, error) { var out []DeviceDescriptor collect := func(family string) hid.EnumFunc { return func(info *hid.DeviceInfo) error { - // Only the keyboard collection carries chalresp; skip FIDO/other. - if info.UsagePage != usagePageKeyboard || info.Usage != usageKeyboard { + // The OTP/chalresp interface is the HID keyboard collection. The hidraw + // backend (Linux) populates UsagePage/Usage, so match the keyboard there. + // The libusb backend (macOS/*BSD) does NOT fill usage (hidapi's + // INVASIVE_GET_USAGE is off), leaving both 0 — there, fall back to USB + // interface 0, which is the OTP interface on YubiKey/OnlyKey whenever it's + // enabled (FIDO/CCID are later interfaces). This keeps us off the FIDO + // collection on both backends. + isKeyboard := info.UsagePage == usagePageKeyboard && info.Usage == usageKeyboard + usageUnknown := info.UsagePage == 0 && info.Usage == 0 + if !isKeyboard && !(usageUnknown && info.InterfaceNbr == 0) { return nil } out = append(out, DeviceDescriptor{ @@ -161,6 +178,15 @@ func hidChallenge(desc DeviceDescriptor, challenge []byte, mayBlock bool) ([]byt // withDevice opens the device (by its enumerated path, or the first available // one if the descriptor carries none), runs fn, and closes it. func withDevice(desc DeviceDescriptor, fn func(*hid.Device) error) error { + // Pin this goroutine to its OS thread for the whole device interaction. On + // macOS, IOKit's IOHIDDeviceGetReport/SetReport do mach calls that Go's async + // preemption (SIGURG, Go 1.14+) can interrupt, surfacing as a transient + // kIOReturnError (0xE00002BC) — the report backends (hidraw/HID.dll) are + // unaffected, but LockOSThread is harmless there. Keeping every open + feature + // report + close on one thread eliminates the interruption. (See go-hid #15.) + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := hidEnsureInit(); err != nil { return err } diff --git a/hardware/chalresp/chalresp_hid_darwin.go b/hardware/chalresp/chalresp_hid_darwin.go new file mode 100644 index 0000000..1a05c64 --- /dev/null +++ b/hardware/chalresp/chalresp_hid_darwin.go @@ -0,0 +1,16 @@ +//go:build darwin && !android && !ios && !nohw + +package chalresp + +import hid "github.com/sstallion/go-hid" + +// hidConfigureOpen makes the macOS backend open devices NON-exclusively +// (kIOHIDOptionsTypeNone) instead of hidapi's default kIOHIDOptionsTypeSeizeDevice. +// Seizing a keyboard-class HID device (the YubiKey OTP interface) requires more +// privilege than the "Input Monitoring" permission grants, so a seizing open fails +// with kIOReturnNotPermitted (0xE00002C1) even when Input Monitoring IS granted. +// Opening non-exclusively — as ykman does — succeeds with the permission. This only +// affects challenge-response feature reports, which never need exclusive access. +func hidConfigureOpen() { + hid.SetOpenExclusive(false) +} diff --git a/hardware/chalresp/chalresp_hid_other.go b/hardware/chalresp/chalresp_hid_other.go new file mode 100644 index 0000000..7ea856d --- /dev/null +++ b/hardware/chalresp/chalresp_hid_other.go @@ -0,0 +1,8 @@ +//go:build !darwin && !android && !ios && !nohw + +package chalresp + +// hidConfigureOpen is a no-op on the non-darwin HID backends. Linux (hidraw) and +// Windows (hid.dll) don't have hidapi's macOS seize-on-open behavior, and go-hid's +// SetOpenExclusive is a darwin-only symbol, so there's nothing to configure here. +func hidConfigureOpen() {} diff --git a/hardware/chalresp/chalresp_pcsc.go b/hardware/chalresp/chalresp_pcsc.go index 3a170d6..6d604aa 100644 --- a/hardware/chalresp/chalresp_pcsc.go +++ b/hardware/chalresp/chalresp_pcsc.go @@ -51,6 +51,13 @@ func pcscList() ([]DeviceDescriptor, error) { } var out []DeviceDescriptor for _, reader := range readers { + // YubiKey slot-2 HMAC challenge-response is HID-only; its CCID OATH applet + // answers SELECT but NOT the HMAC APDU (returns 6B00). The HID backend handles + // YubiKeys, so never surface one here — otherwise on macOS (where the OS PC/SC + // service exposes the YubiKey's CCID iface) it would shadow the HID path. + if familyForReader(reader) == "yubikey" { + continue + } card, err := ctx.Connect(reader, scard.ShareShared, scard.ProtocolAny) if err != nil { continue // no card / in use