From e6e0ca62759bcad292e2170a2df61a41fb8f3a1b Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 9 Sep 2026 19:45:46 -0700 Subject: [PATCH 1/2] v0.1.5 - Migrated to hidapi (removed ykpers) - Added nitrokey 1st pass proper (needs testing) --- CHANGELOG.md | 6 + crypto/hardware.go | 17 + crypto/hardware_test.go | 15 + go.mod | 2 + go.sum | 7 +- hardware/chalresp/README.md | 84 ++-- hardware/chalresp/chalresp.go | 277 ++++++------- hardware/chalresp/chalresp_hardware_test.go | 120 ++++++ hardware/chalresp/chalresp_hid.go | 424 ++++++++++++++++++++ hardware/chalresp/chalresp_pcsc.go | 195 +++++++++ hardware/chalresp/chalresp_pcsc_stub.go | 16 + hardware/chalresp/chalresp_test.go | 179 +++++++++ 12 files changed, 1146 insertions(+), 196 deletions(-) create mode 100644 hardware/chalresp/chalresp_hardware_test.go create mode 100644 hardware/chalresp/chalresp_hid.go create mode 100644 hardware/chalresp/chalresp_pcsc.go create mode 100644 hardware/chalresp/chalresp_pcsc_stub.go create mode 100644 hardware/chalresp/chalresp_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b51c75b..489a8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # CHANGELOG +**v0.1.5 - 09-10-2026** + +- Migrated to hidapi (removed ykpers) +- Added nitrokey 1st pass proper (needs testing) + + **v0.1.4 - 09-09-2026** - Fixed Windows identity store diff --git a/crypto/hardware.go b/crypto/hardware.go index b126189..b4573f4 100644 --- a/crypto/hardware.go +++ b/crypto/hardware.go @@ -53,6 +53,13 @@ func DeriveHardwareKEKBytes(pass, hwResponse []byte) ([]byte, error) { if len(hwResponse) == 0 { return nil, fmt.Errorf("empty hardware response") } + // Reject a degenerate all-zero response: it would nullify the hardware factor + // (the KEK would be derivable from the passphrase alone). A genuine HMAC-SHA1 + // is never all-zero; an all-zero salt signals a device malfunction or a + // non-genuine token, so fail closed rather than seal/unlock with it. + if allZero(hwResponse) { + return nil, fmt.Errorf("degenerate (all-zero) hardware response") + } // NOTE: an empty passphrase is intentionally allowed. Keychain-origin // identities (HWKEKNone) derive the KEK from the hardware response alone, // with the OS keychain (Android Keystore / macOS Keychain) as the second @@ -68,3 +75,13 @@ func DeriveHardwareKEKBytes(pass, hwResponse []byte) ([]byte, error) { } return kek, nil } + +// allZero reports whether every byte of b is zero. +func allZero(b []byte) bool { + for _, x := range b { + if x != 0 { + return false + } + } + return true +} diff --git a/crypto/hardware_test.go b/crypto/hardware_test.go index 992e007..cf2a102 100644 --- a/crypto/hardware_test.go +++ b/crypto/hardware_test.go @@ -56,6 +56,21 @@ func TestDeriveHardwareKEK_EmptyResponse(t *testing.T) { } } +// An all-zero hardware response is degenerate (it would nullify the hardware +// factor) and must be rejected — a genuine HMAC-SHA1 is never all-zero; an +// all-zero salt signals a malfunctioning or non-genuine device. +func TestDeriveHardwareKEK_AllZeroResponseRejected(t *testing.T) { + if _, err := DeriveHardwareKEK("pass", make([]byte, 20)); err == nil { + t.Errorf("expected error for all-zero hardware response") + } + // A single non-zero byte makes it non-degenerate and derives normally. + nonzero := make([]byte, 20) + nonzero[19] = 0x01 + if _, err := DeriveHardwareKEK("pass", nonzero); err != nil { + t.Errorf("non-degenerate response should derive: %v", err) + } +} + // An EMPTY passphrase must be accepted: keychain-origin identities (HWKEKNone) // derive the KEK from the hardware response alone, with the OS keychain as the // second factor. Rejecting it here breaks HW-only keychain unlock (regression diff --git a/go.mod b/go.mod index 2ae1752..2317e7f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/makiuchi-d/gozxing v0.1.1 github.com/pquerna/otp v1.5.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/sstallion/go-hid v0.15.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.50.0 ) @@ -25,6 +26,7 @@ require ( github.com/awnumar/memcall v0.4.0 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/danieljoos/wincred v1.2.2 // indirect + github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/go.sum b/go.sum index 34d8bc4..9336b54 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ= github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI= +github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+ESu1goRp4B3fdhemWMMrS4g= +github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -33,8 +35,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/keys-pub/go-libfido2 v1.5.3 h1:vtgHxlSB43u6lj0TSuA3VvT6z3E7VI+L1a2hvMFdECk= -github.com/keys-pub/go-libfido2 v1.5.3/go.mod h1:P0V19qHwJNY0htZwZDe9Ilvs/nokGhdFX7faKFyZ6+U= github.com/keys-pub/go-libfido2 v1.5.4-0.20251021061633-bf2d0535e75c h1:KqxSEYvMTbdiv7KvnXMl83kfGBWfkcOr7jt+cRhwzlc= github.com/keys-pub/go-libfido2 v1.5.4-0.20251021061633-bf2d0535e75c/go.mod h1:92J9LtSBl0UyUWljElJpTbMMNhC6VeY8dshsu40qjjo= github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= @@ -47,6 +47,8 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w= +github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -60,7 +62,6 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= diff --git a/hardware/chalresp/README.md b/hardware/chalresp/README.md index 51365a1..f3e8b04 100644 --- a/hardware/chalresp/README.md +++ b/hardware/chalresp/README.md @@ -1,45 +1,55 @@ # chalresp -HMAC-SHA1 challenge-response against the OTP slot 2 of Yubikey-compatible -hardware keys (Yubikey, NitroKey Pro/Storage, OnlyKey). CGO wrapper around -[Yubico's libykpers][libykpers] — the same C library KeePassXC, ykman, and -pam_yubico build on. Implements `crypto.HardwareKey` for icfx. +HMAC-SHA1 challenge-response against OTP slot 2 of Yubico-compatible hardware +keys, implementing `crypto.HardwareKey` for icfx. -icfx does NOT program slots. Slot 2 must be pre-programmed for HMAC-SHA1 -challenge-response by the user's preferred tool (`ykman otp chalresp ---generate 2`, KeePassXC's setup flow, `ykpersonalize`, etc.) before icfx -can use it. - -## Build dependency - -| Platform | Install | -|---|---| -| Linux (Arch / CachyOS) | `sudo pacman -S yubikey-personalization` | -| Linux (Debian / Ubuntu) | `sudo apt install libykpers-1-dev` | -| Linux (Fedora / RHEL) | `sudo dnf install ykpers-devel` | -| macOS | `brew install ykpers` | -| Windows | install the [yubikey-personalization Windows build][ykpers-win] (bundles the DLL); link via mingw or vcpkg | - -`pkg-config --modversion ykpers-1` should report a version after install. -The `#cgo pkg-config: ykpers-1` directive in `chalresp.go` resolves the -include path and link flags from there. +It speaks the wire protocols directly — **no libykpers, no libusb** — via +[hidapi][gohid] (and optionally [PC/SC][scard]). This is what `ykman` and +KeePassXC do, and it works on macOS/Windows/Linux/BSD without claiming the USB +interface or any code-signing/entitlement. -**Don't need hardware keys?** Build the whole module with `-tags nohw` (see the top-level README) to compile the -pure-Go stub instead — then libykpers-1 is not required and cgo can be disabled entirely. - -## Single-device today - -`chalresp.List()` opens via libykpers' `yk_open_first_key`, which picks the -first detected device. Multi-device support (plug in two keys, choose one -by serial) is post-MVP — the existing `DeviceDescriptor.Serial` field is -populated for forward compatibility. +icfx does NOT program slots. Slot 2 must be pre-programmed for HMAC-SHA1 +challenge-response by the user's tool (`ykman otp chalresp --generate 2`, +Yubico Authenticator, KeePassXC's setup, the OnlyKey app, `nitropy nk3 secrets +add-challenge-response 2 …`) before icfx can use it. + +## Supported devices + +| Device | Transport | Build | Notes | +|---|---|---|---| +| **YubiKey** (OTP-capable models) | HID (keyboard interface) | default | reference implementation; hardware-validated | +| **OnlyKey** (firmware ≥2.1.0) | HID (keyboard interface) | default | firmware mirrors the YubiKey protocol byte-for-byte | +| **Nitrokey 3** | CCID / PC-SC (Yubico OATH applet) | `-tags pcsc` | implemented to spec, **untested pending hardware** | +| YubiKey over CCID | CCID / PC-SC | `-tags pcsc` | bonus of the PC/SC path | + +**Not supported:** Nitrokey Pro / Storage (they have no YubiKey-style HMAC-SHA1 +challenge-response — the earlier "NitroKey Pro/Storage" claim was never real). + +## Build dependencies + +- **Default (HID: YubiKey + OnlyKey)** — cgo via `sstallion/go-hid`, which + bundles hidapi. Linux uses the hidraw backend (a `libudev` dev package may be + needed to build; a `/dev/hidraw*` udev rule for VID `1050`/`1d50` for runtime + access). macOS/Windows need no extra packages. +- **`-tags pcsc` (adds Nitrokey 3 + YubiKey-CCID)** — cgo via `ebfe/scard`. + macOS (`PCSC.framework`) and Windows (`WinSCard`) ship PC/SC in the system; + **Linux needs `libpcsclite`-dev to build and `pcscd` running at runtime.** + Keep `github.com/ebfe/scard` in `go.mod` — a bare `go mod tidy` (without the + `pcsc` tag) will prune it and break the tagged build. +- **No hardware keys?** Build the module with `-tags nohw` for the pure-Go stub + — cgo can be disabled entirely and neither dependency is required. + +## Multi-transport enumeration + +`chalresp.List()` merges HID and (when built with `-tags pcsc`) PC/SC results; +`Open`/`Challenge`/`IsSlot2Programmed` route to the transport the device was +found on. PC/SC enumeration is best-effort — a missing `pcscd` never breaks HID. ## Mobile (Android / iOS) is out of scope -Mobile USB / NFC access does not flow through libykpers — Android requires -the Yubico Android SDK (Java/Kotlin via JNI) and iOS requires the Yubico -iOS SDK (Swift, with NFC for non-MFi access). Each is a separate flugo -bridge effort. Desktop only here. +The desktop files are `//go:build !android && !ios && !nohw`; mobile uses the +`chalresp_mobile.go` stub. Actual mobile HW-key support lives in flugo +(`flugo/pkg/hardware/nfckey`) via the Yubico Android/iOS SDKs. -[libykpers]: https://github.com/Yubico/yubikey-personalization -[ykpers-win]: https://developers.yubico.com/yubikey-personalization/Releases/ +[gohid]: https://github.com/sstallion/go-hid +[scard]: https://github.com/ebfe/scard diff --git a/hardware/chalresp/chalresp.go b/hardware/chalresp/chalresp.go index bd93491..f18a267 100644 --- a/hardware/chalresp/chalresp.go +++ b/hardware/chalresp/chalresp.go @@ -1,118 +1,81 @@ //go:build !android && !ios && !nohw -// Package chalresp performs HMAC-SHA1 challenge-response against the OTP -// slot 2 of a Yubikey (or compatible: NitroKey Pro/Storage, OnlyKey). +// Package chalresp performs HMAC-SHA1 challenge-response against OTP slot 2 of a +// YubiKey (or a compatible device) to derive a hardware-augmented KEK. // -// This is a thin CGO wrapper around Yubico's libykpers — the same C library -// that KeePassXC, ykman, and pam_yubico build on. We delegate the wire -// protocol entirely; this package's job is just to expose libykpers as a Go -// API that satisfies icfx's crypto.HardwareKey contract. +// It speaks the YubiKey OTP-over-HID feature-report protocol directly via hidapi +// (github.com/sstallion/go-hid) — no libykpers, no libusb. This is what KeePassXC +// and ykman do, and it works on macOS/Windows/Linux/BSD without claiming the USB +// interface or any entitlement. icfx does not provision hardware keys; slot +// programming is delegated to the user's tool (Yubico Authenticator, ykman, +// KeePassXC, the OnlyKey app, etc.). // -// Build dependency: +// Supported today (verified against device firmware / KeePassXC): +// - YubiKey — the reference implementation of this HID protocol. +// - OnlyKey (firmware >=2.1.0) — implements the identical HID protocol. // -// Linux (Arch): pacman -S yubikey-personalization -// Linux (Debian): apt install libykpers-1-dev -// Linux (Fedora): dnf install ykpers-devel -// macOS: brew install ykpers -// Windows: install yubikey-personalization (mingw or vcpkg) +// Nitrokey 3 uses a different transport (CCID/PC-SC) and is handled by a separate +// backend (added in a follow-up); Nitrokey Pro/Storage do not support HMAC-SHA1 +// challenge-response at all. // -// Single-device assumption: this package picks the first detected device -// (libykpers' yk_open_first_key). Multi-device support is post-MVP. +// The exported surface is unchanged from the former libykpers binding, so +// ic-cli and ic-app compile without modification. package chalresp -/* -#cgo pkg-config: ykpers-1 -#include -#include -#include -#include -#include -*/ -import "C" - import ( "errors" "fmt" - "sync" - "unsafe" ) -// hmacSHA1OutLen is the byte length of an HMAC-SHA1 response. -const hmacSHA1OutLen = 20 - -// responseBufSize is what we hand to libykpers as the response buffer. -// libykpers internally reads response data in 7-byte chunks until it sees -// a termination marker, and the buffer must be at least 28 bytes (20-byte -// HMAC + 2-byte CRC, padded to the next multiple of 7). KeePassXC uses 64 -// — we mirror that for headroom. -const responseBufSize = 64 - var ( // ErrSlotNotConfigured is returned when the targeted slot is empty. ErrSlotNotConfigured = errors.New("chalresp: slot not configured") - // ErrTouchTimeout is returned when the device is configured to require - // a button press and the user does not touch it within libykpers' - // internal wait window. + // ErrTouchTimeout is returned when a slot configured to require a button + // press is not touched within the wait window. ErrTouchTimeout = errors.New("chalresp: timeout waiting for device touch") // ErrNoDevice is returned when no compatible hardware key is detected. ErrNoDevice = errors.New("chalresp: no compatible hardware key detected") // ErrNotSupported mirrors the stub build's symbol so callers can reference - // chalresp.ErrNotSupported in ANY build (cgo desktop, mobile, or -tags nohw). - // The desktop cgo path never returns it (hardware IS supported here); the - // mobile/nohw stub returns it from every operation. + // chalresp.ErrNotSupported in ANY build (desktop, mobile, or -tags nohw). + // The desktop path never returns it (hardware IS supported here). ErrNotSupported = errors.New("chalresp: hardware key challenge-response is not supported in this build") ) -var ( - initOnce sync.Once - initErr error -) +// backendKind selects the transport a descriptor was found on. The zero value +// is backendHID, so HID descriptors need not set it explicitly. +type backendKind uint8 -// ensureInit lazily initializes libykpers. yk_init() / yk_release() are -// global; we init once for the process lifetime and never explicitly release -// (the OS reclaims on exit). -func ensureInit() error { - initOnce.Do(func() { - if C.yk_init() == 0 { - initErr = errors.New("chalresp: yk_init failed (libykpers/libusb couldn't initialize)") - } - }) - return initErr -} +const ( + backendHID backendKind = iota // YubiKey / OnlyKey over the OTP HID interface + backendPCSC // Nitrokey 3 / YubiKey over CCID (PC/SC), build tag `pcsc` +) -// DeviceDescriptor identifies a connected hardware key. With the -// single-device assumption baked in here, the descriptor is a thin marker -// — Family is filled in for display purposes; Serial may be empty if the -// device's firmware doesn't expose one. Future multi-device support will -// add path / index fields. +// DeviceDescriptor identifies a connected hardware key. Family is filled in for +// display; Serial may be empty if the firmware doesn't expose one. The +// unexported routing fields locate the device for Open/Challenge. type DeviceDescriptor struct { - Family string // always "yubikey" today (libykpers auto-detects compatible devices) - Serial string // device serial; empty if not available + Family string // "yubikey", "onlykey", or "nitrokey" + Serial string // device serial; empty if unavailable + + backend backendKind + path string // HID device path (backendHID) + //lint:ignore U1000 set/read only in the pcsc-tagged backend (chalresp_pcsc.go) + reader string // PC/SC reader name (backendPCSC) } -// List returns descriptors for connected hardware keys. Currently returns -// at most one descriptor (the first device libykpers can open). Returns -// nil, nil if no device is present — the empty list signals "none" without -// being an error condition. +// List returns descriptors for connected hardware keys across all transports. +// Returns an empty slice (not an error) when none are present. PC/SC is +// best-effort: a missing pcscd / no readers must never break HID enumeration. func List() ([]DeviceDescriptor, error) { - if err := ensureInit(); err != nil { + hids, err := hidList() + if err != nil { return nil, err } - yk := C.yk_open_first_key() - if yk == nil { - return nil, nil - } - defer C.yk_close_key(yk) - - desc := DeviceDescriptor{Family: "yubikey"} - if s, err := readSerial(yk); err == nil { - desc.Serial = s - } - return []DeviceDescriptor{desc}, nil + pcs, _ := pcscList() // best-effort; errors (no PC/SC service) are non-fatal + return append(hids, pcs...), nil } -// Detect returns the first connected supported device, or ErrNoDevice if -// none is found. +// Detect returns the first connected supported device, or ErrNoDevice. func Detect() (*DeviceDescriptor, error) { devices, err := List() if err != nil { @@ -124,106 +87,108 @@ func Detect() (*DeviceDescriptor, error) { return &devices[0], nil } -// IsSlot2Programmed reports whether slot 2 has a valid HMAC-SHA1 -// configuration on the device, by reading YK_STATUS.touchLevel and -// checking the CONFIG2_VALID bit. No challenge issued, no touch required. -func IsSlot2Programmed(_ DeviceDescriptor) (bool, error) { - if err := ensureInit(); err != nil { - return false, err - } - yk := C.yk_open_first_key() - if yk == nil { - return false, ErrNoDevice - } - defer C.yk_close_key(yk) - - status := C.ykds_alloc() - defer C.ykds_free(status) - if C.yk_get_status(yk, status) == 0 { - return false, errors.New("chalresp: yk_get_status failed") +// IsSlot2Programmed reports whether slot 2 has a valid HMAC-SHA1 configuration, +// by reading the device status and checking CONFIG2_VALID. No challenge issued, +// no touch required. +func IsSlot2Programmed(desc DeviceDescriptor) (bool, error) { + if desc.backend == backendPCSC { + return pcscIsSlot2Programmed(desc) } - touchLevel := C.ykds_touch_level(status) - return (touchLevel & C.CONFIG2_VALID) != 0, nil + return hidIsSlot2Programmed(desc) } -// Key implements crypto.HardwareKey. It records a descriptor at Open time -// but defers the actual USB device handle to each Challenge call — this -// matches KeePassXC's pattern and lets concurrent applications (ykman, -// other ic-cli invocations) take turns with the device cleanly. +// Key implements crypto.HardwareKey. It records a descriptor at Open time and +// opens the device per Challenge call. In-process calls are serialized (the +// device's OTP state machine is global to the physical key), and the per-call +// open lets other applications take turns with the device between calls. type Key struct { desc DeviceDescriptor } -// Open prepares a Key bound to the given device. The actual libykpers -// device handle is opened per Challenge call. +// Open prepares a Key bound to the given device. func Open(desc DeviceDescriptor) (*Key, error) { - if err := ensureInit(); err != nil { - return nil, err - } return &Key{desc: desc}, nil } -// Challenge implements crypto.HardwareKey. Sends the challenge bytes to -// slot 2 over libykpers and returns the 20-byte HMAC-SHA1 response. -// -// may_block is set to true so that slots configured to require a button -// touch (KeePassXC's recommended setup) will wait for the user to touch -// rather than failing immediately. +// Challenge sends the challenge bytes to slot 2 and returns the 20-byte +// HMAC-SHA1 response. Slots configured to require a button touch (recommended) +// will wait for the user to touch rather than failing immediately. func (k *Key) Challenge(challenge []byte) ([]byte, error) { if len(challenge) == 0 { return nil, errors.New("chalresp: empty challenge") } - - yk := C.yk_open_first_key() - if yk == nil { - return nil, ErrNoDevice + if len(challenge) > frameDataSize { + return nil, fmt.Errorf("chalresp: challenge too long (%d > %d bytes)", len(challenge), frameDataSize) } - defer C.yk_close_key(yk) - - resp := make([]byte, responseBufSize) - res := C.yk_challenge_response( - yk, - C.uint8_t(C.SLOT_CHAL_HMAC2), - C.int(1), // may_block: wait for touch if slot requires it - C.uint(len(challenge)), - (*C.uchar)(unsafe.Pointer(&challenge[0])), - C.uint(len(resp)), - (*C.uchar)(unsafe.Pointer(&resp[0])), - ) - if res == 0 { - // Map the one reliably-distinguishable libykpers errno to the documented - // sentinel; touch-required slots return YK_EWOULDBLOCK when the user - // doesn't press in time. (libykpers has no distinct "slot not configured" - // errno, so that case stays a generic failure.) - if C.yk_errno == C.YK_EWOULDBLOCK { - return nil, ErrTouchTimeout - } - return nil, fmt.Errorf("chalresp: yk_challenge_response failed: %s (slot empty? device removed? wrong slot configuration?)", C.GoString(C.yk_strerror(C.yk_errno))) + if k.desc.backend == backendPCSC { + return pcscChallenge(k.desc, challenge) } - return resp[:hmacSHA1OutLen], nil + return hidChallenge(k.desc, challenge, true /* may_block: wait for touch if required */) } -// Serial returns the device's serial number string. May be empty if the -// device firmware doesn't expose it via the OTP HID interface. +// Serial returns the device's serial number string (may be empty). func (k *Key) Serial() string { return k.desc.Serial } -// Type returns the device family string. Currently always "yubikey" since -// libykpers' detection is family-agnostic for compatible devices. +// Type returns the device family string ("yubikey" / "onlykey"). func (k *Key) Type() string { return k.desc.Family } // Descriptor returns a copy of the device descriptor. func (k *Key) Descriptor() DeviceDescriptor { return k.desc } -// readSerial queries the device's serial number via libykpers. Returns -// the empty string + nil error if the device doesn't expose a serial -// (older firmware, NitroKey, OnlyKey). -func readSerial(yk *C.YK_KEY) (string, error) { - var serial C.uint - if C.yk_get_serial(yk, 0, 0, &serial) == 0 { - return "", nil +// wipe best-effort zeroes an intermediate buffer that held the secret HMAC +// response. The final returned copy is wiped by the caller (DeriveHardwareKEK); +// this clears the transient accumulators before they're garbage-collected. +func wipe(b []byte) { + for i := range b { + b[i] = 0 } - if serial == 0 { - return "", nil +} + +// APDU status words + GET RESPONSE chaining, used by the PC/SC backend. Kept +// here (transport-agnostic, no scard dependency) so the DoS bounds are unit- +// testable without a PC/SC stack. +const ( + insGetResp = 0xc0 + swMore = 0x61 // "61 xx": xx more bytes available via GET RESPONSE + swOKHi = 0x90 + swOKLo = 0x00 + + // Bound the GET RESPONSE chain so a malicious/buggy card can't hang the + // unlock forever (bare "61 01") or grow the buffer without bound. The + // expected HMAC response is 20 bytes; both caps are far above any legit exchange. + maxAPDUChain = 16 + maxAPDUBytes = 4096 +) + +// transmitChain sends an APDU via transmit and follows ISO-7816 "61 xx" GET +// RESPONSE chaining, returning the accumulated data (without status words). It +// errors on any status word other than 90 00 / 61 xx, and bounds both the chain +// length and total size. +func transmitChain(transmit func(apdu []byte) ([]byte, error), apdu []byte) ([]byte, error) { + var out []byte + for i := 0; ; i++ { + if i >= maxAPDUChain { + return nil, errors.New("chalresp: too many GET RESPONSE chunks") + } + resp, err := transmit(apdu) + if err != nil { + return nil, fmt.Errorf("chalresp: APDU transmit: %w", err) + } + if len(resp) < 2 { + return nil, errors.New("chalresp: truncated APDU response") + } + sw1, sw2 := resp[len(resp)-2], resp[len(resp)-1] + out = append(out, resp[:len(resp)-2]...) + if len(out) > maxAPDUBytes { + return nil, errors.New("chalresp: APDU response too large") + } + switch { + case sw1 == swOKHi && sw2 == swOKLo: + return out, nil + case sw1 == swMore: + apdu = []byte{0x00, insGetResp, 0x00, 0x00, sw2} + default: + return nil, fmt.Errorf("chalresp: APDU error SW=%02x%02x", sw1, sw2) + } } - return fmt.Sprintf("%d", uint32(serial)), nil } diff --git a/hardware/chalresp/chalresp_hardware_test.go b/hardware/chalresp/chalresp_hardware_test.go new file mode 100644 index 0000000..8d82fa5 --- /dev/null +++ b/hardware/chalresp/chalresp_hardware_test.go @@ -0,0 +1,120 @@ +//go:build hardware && !nohw && !android && !ios + +// Hardware acceptance tests for the HID chalresp backend. These require a real +// device (YubiKey, or OnlyKey) with slot 2 programmed for HMAC-SHA1 and are +// excluded from normal builds. Run with: +// +// go test -tags hardware ./hardware/chalresp/ -v +// +// The decisive real-world check is separate: unlock a pre-existing HW-protected +// identity with a build using this package (`icc id show `) — that proves +// byte-compatibility with whatever sealed it. +package chalresp + +import ( + "bytes" + "encoding/hex" + "os/exec" + "strings" + "testing" +) + +func hwKey(t *testing.T) *Key { + t.Helper() + devs, err := List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(devs) == 0 { + t.Skip("no compatible hardware key attached") + } + t.Logf("device: family=%s serial=%q", devs[0].Family, devs[0].Serial) + prog, err := IsSlot2Programmed(devs[0]) + if err != nil { + t.Fatalf("IsSlot2Programmed: %v", err) + } + if !prog { + t.Skip("slot 2 is not programmed for HMAC-SHA1 challenge-response") + } + k, err := Open(devs[0]) + if err != nil { + t.Fatalf("Open: %v", err) + } + return k +} + +// TestHardwareChallengeSmoke exercises the protocol across challenge lengths, +// including a 32-byte challenge ending in 0x00 (the padding edge case). The +// responses are logged for debugging — these are throwaway TEST challenges, not +// a real identity's; never log a production identity's challenge or response. +func TestHardwareChallengeSmoke(t *testing.T) { + k := hwKey(t) + cases := []struct { + name string + ch []byte + }{ + {"len1", []byte{0x42}}, + {"len20", bytes.Repeat([]byte{0x11}, 20)}, + {"len32", bytes.Repeat([]byte{0x22}, 32)}, + {"len63", bytes.Repeat([]byte{0x33}, 63)}, + {"len64", bytes.Repeat([]byte{0x44}, 64)}, + {"len32-zero-ending", append(bytes.Repeat([]byte{0x55}, 31), 0x00)}, + } + for _, c := range cases { + resp, err := k.Challenge(c.ch) + if err != nil { + t.Fatalf("[%s] Challenge: %v", c.name, err) + } + if len(resp) != hmacLen { + t.Fatalf("[%s] response length = %d, want %d", c.name, len(resp), hmacLen) + } + t.Logf("[%s] challenge=%x -> %x", c.name, c.ch, resp) + } +} + +// TestHardwareDeterministic confirms the same challenge yields the same response +// (each call needs a touch on touch-configured slots). +func TestHardwareDeterministic(t *testing.T) { + k := hwKey(t) + ch := bytes.Repeat([]byte{0xA5}, 32) + r1, err := k.Challenge(ch) + if err != nil { + t.Fatalf("Challenge #1: %v", err) + } + r2, err := k.Challenge(ch) + if err != nil { + t.Fatalf("Challenge #2: %v", err) + } + if !bytes.Equal(r1, r2) { + t.Fatalf("non-deterministic response: %x != %x", r1, r2) + } +} + +// TestHardwareVsYkman cross-checks against yubikit/ykman (the "standard" impl). +// Uses an ASCII challenge (no trailing 0x00) so `ykman otp calculate` hashes the +// same bytes regardless of hex/string interpretation. Skips if ykman is absent +// or its output can't be parsed — some ykman versions differ, so treat a skip as +// "verify the invocation", and only a genuine byte mismatch as an impl bug. +func TestHardwareVsYkman(t *testing.T) { + if _, err := exec.LookPath("ykman"); err != nil { + t.Skip("ykman not in PATH") + } + k := hwKey(t) + challenge := []byte("instacrypt-chalresp-oracle") + resp, err := k.Challenge(challenge) + if err != nil { + t.Fatalf("Challenge: %v", err) + } + out, err := exec.Command("ykman", "otp", "calculate", "2", string(challenge)).Output() + if err != nil { + t.Skipf("ykman invocation failed (adjust for your version): %v", err) + } + want, err := hex.DecodeString(strings.TrimSpace(string(out))) + if err != nil { + t.Skipf("could not parse ykman output %q as hex: %v", strings.TrimSpace(string(out)), err) + } + if !bytes.Equal(resp, want) { + t.Fatalf("mismatch vs ykman: got %x, ykman %x", resp, want) + } + t.Logf("matches ykman: %x", resp) +} diff --git a/hardware/chalresp/chalresp_hid.go b/hardware/chalresp/chalresp_hid.go new file mode 100644 index 0000000..476a1b0 --- /dev/null +++ b/hardware/chalresp/chalresp_hid.go @@ -0,0 +1,424 @@ +//go:build !android && !ios && !nohw + +package chalresp + +// HID backend: speaks the YubiKey OTP-over-HID challenge-response protocol +// directly, via hidapi (github.com/sstallion/go-hid). This covers YubiKey and +// OnlyKey (firmware >=2.1.0), whose firmware implements the identical wire +// protocol on the same HID keyboard interface. No libykpers, no libusb, no +// device-claiming — so it works on macOS/Windows/Linux without entitlements. +// +// Wire protocol verified against Yubico ykpers (ykcore/ykcore.c, ykdef.h), +// yubikit-core, and yubico-c's CRC (ykcrc.c); OnlyKey's firmware mirrors it +// byte-for-byte. All multi-byte fields are little-endian on the wire. + +import ( + "errors" + "fmt" + "sync" + "time" + + hid "github.com/sstallion/go-hid" +) + +// --- USB identifiers for devices speaking the YubiKey OTP-HID protocol ------- +const ( + vidYubico = 0x1050 // Yubico — all OTP-capable YubiKeys + vidOnlyKey = 0x1d50 // OpenMoko-assigned VID used by OnlyKey + pidOnlyKey = 0x60fc // OnlyKey + + // The OTP challenge-response interface is the HID *keyboard* collection on + // both YubiKey and OnlyKey — NOT the FIDO interface (usage page 0xf1d0). + usagePageKeyboard = 0x0001 // Generic Desktop + usageKeyboard = 0x0006 // Keyboard +) + +// --- OTP HID feature-report protocol constants (ykpers ykdef.h) -------------- +const ( + featureRptSize = 8 // 7 data bytes + 1 flags byte + recordDataSize = featureRptSize - 1 // 7 payload bytes per report + frameDataSize = 64 // SLOT_DATA_SIZE — challenge payload area + frameSize = 70 // payload(64) + slot(1) + crc(2) + filler(3) = 10 records + + slotWriteFlag = 0x80 // host sets on each write record; device clears when consumed + respPendingFlag = 0x40 // set on read reports carrying response bytes + respTimeoutWaitFlag = 0x20 // device is waiting for a touch (secs in low 5 bits) + dummyReport = 0x8f // reset/abort flags byte (DUMMY_REPORT_WRITE) + sequenceMask = 0x1f // low 5 bits of the flags byte = sequence number + + slotChalHMAC2 = 0x38 // SLOT_CHAL_HMAC2 (slot 2) == `ykchalresp -2` + + statusTouchOffset = 0x05 // STATUS_OFFSET_TOUCH_LOW — CONFIG{1,2}_VALID live here + config2Valid = 0x02 // CONFIG2_VALID — slot 2 is programmed + + hmacLen = 20 // SHA1_DIGEST_SIZE + crcResidual = 0xf0b8 // YK_CRC_OK_RESIDUAL: CRC over (data||crc) of a valid frame +) + +// Client-side poll deadlines. These bound our own waiting only; they don't +// affect the wire bytes or the resulting HMAC. +const ( + writeReadyTimeout = 2 * time.Second // wait for the device to clear SLOT_WRITE_FLAG + processTimeout = 2 * time.Second // wait for RESP_PENDING on a no-touch slot + touchTimeout = 20 * time.Second // additional wait when the slot requires a touch + pollMin = 1 * time.Millisecond + pollMax = 100 * time.Millisecond +) + +var ( + hidInitOnce sync.Once + hidInitErr error + // hidMu serializes device access. A YubiKey's OTP state machine (the + // SLOT_WRITE_FLAG / RESP_PENDING flags + sequence counter) is global to the + // physical key, so two concurrent in-process operations would interleave and + // corrupt each other's frames. + hidMu sync.Mutex +) + +// hidEnsureInit initializes hidapi once for the process lifetime. hidapi is +// otherwise lazily initialized; we do it explicitly for concurrency safety and +// never call hid.Exit (the OS reclaims on exit), matching the old ykpers path. +func hidEnsureInit() error { + hidInitOnce.Do(func() { + if err := hid.Init(); err != nil { + hidInitErr = fmt.Errorf("chalresp: hidapi init failed: %w", err) + } + }) + return hidInitErr +} + +// hidList enumerates connected devices that expose the YubiKey OTP keyboard +// interface. Returns an empty slice (not an error) when none are present. +func hidList() ([]DeviceDescriptor, error) { + if err := hidEnsureInit(); err != nil { + return nil, err + } + 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 { + return nil + } + out = append(out, DeviceDescriptor{ + Family: family, + Serial: info.SerialNbr, + backend: backendHID, + path: info.Path, + }) + return nil + } + } + if err := hid.Enumerate(vidYubico, hid.ProductIDAny, collect("yubikey")); err != nil { + return nil, fmt.Errorf("chalresp: enumerating YubiKey: %w", err) + } + if err := hid.Enumerate(vidOnlyKey, pidOnlyKey, collect("onlykey")); err != nil { + return nil, fmt.Errorf("chalresp: enumerating OnlyKey: %w", err) + } + return out, nil +} + +// hidIsSlot2Programmed reads the device status report and checks CONFIG2_VALID. +func hidIsSlot2Programmed(desc DeviceDescriptor) (bool, error) { + hidMu.Lock() + defer hidMu.Unlock() + var programmed bool + err := withDevice(desc, func(dev *hid.Device) error { + st, err := readStatus(dev) + if err != nil { + return err + } + programmed = st[statusTouchOffset]&config2Valid != 0 + return nil + }) + return programmed, err +} + +// hidChallenge runs a slot-2 HMAC-SHA1 challenge-response. mayBlock=true waits +// for a physical touch on slots configured to require one. +func hidChallenge(desc DeviceDescriptor, challenge []byte, mayBlock bool) ([]byte, error) { + hidMu.Lock() + defer hidMu.Unlock() + var resp []byte + err := withDevice(desc, func(dev *hid.Device) error { + frame := buildFrame(challenge, slotChalHMAC2) + if err := sendFrame(dev, frame); err != nil { + return err + } + r, err := readResponse(dev, mayBlock) + if err != nil { + return err + } + resp = r + return nil + }) + if err != nil { + return nil, err + } + return resp, nil +} + +// 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 { + if err := hidEnsureInit(); err != nil { + return err + } + path := desc.path + if path == "" { + devs, err := hidList() + if err != nil { + return err + } + if len(devs) == 0 { + return ErrNoDevice + } + path = devs[0].path + } + dev, err := hid.OpenPath(path) + if err != nil { + return fmt.Errorf("chalresp: opening device: %w", err) + } + defer dev.Close() + return fn(dev) +} + +// --- feature report I/O ------------------------------------------------------ +// hidapi's report-ID convention: the buffer's first byte is the report ID +// (0x00 for the YubiKey's unnumbered reports), so an 8-byte device report is a +// 9-byte Go buffer with the 8 report bytes at [1:9]. + +func writeReport(dev *hid.Device, rep [featureRptSize]byte) error { + buf := make([]byte, featureRptSize+1) + copy(buf[1:], rep[:]) // buf[0] = 0x00 report ID + if _, err := dev.SendFeatureReport(buf); err != nil { + return fmt.Errorf("chalresp: send feature report: %w", err) + } + return nil +} + +func readStatus(dev *hid.Device) ([featureRptSize]byte, error) { + var st [featureRptSize]byte + buf := make([]byte, featureRptSize+1) + // buf[0] = 0x00 report ID to read + if _, err := dev.GetFeatureReport(buf); err != nil { + return st, fmt.Errorf("chalresp: get feature report: %w", err) + } + copy(st[:], buf[1:]) + return st, nil +} + +// writeReset issues DUMMY_REPORT_WRITE to clear the device's read/write state. +func writeReset(dev *hid.Device) { + var rep [featureRptSize]byte + rep[featureRptSize-1] = dummyReport + _ = writeReport(dev, rep) // best-effort +} + +// --- frame construction ------------------------------------------------------ + +// padChallenge pads a challenge (<=64 bytes) to 64 using the yubikit rule: pad +// with 0x00, or 0x01 if the challenge ends in 0x00 (so a slot in HMAC_LT64 +// variable-length mode recovers the exact length instead of stripping a real +// trailing zero). Correctness, not security. +func padChallenge(challenge []byte) [frameDataSize]byte { + var p [frameDataSize]byte + copy(p[:], challenge) + if len(challenge) < frameDataSize { + pad := byte(0x00) + if len(challenge) > 0 && challenge[len(challenge)-1] == 0x00 { + pad = 0x01 + } + for i := len(challenge); i < frameDataSize; i++ { + p[i] = pad + } + } + return p +} + +// buildFrame assembles the 70-byte frame: padded payload | slot | CRC16(payload) +// little-endian | 3 filler zeros. +func buildFrame(challenge []byte, slot byte) [frameSize]byte { + payload := padChallenge(challenge) + var frame [frameSize]byte + copy(frame[:frameDataSize], payload[:]) + frame[frameDataSize] = slot + crc := yubikeyCRC16(frame[:frameDataSize]) + frame[frameDataSize+1] = byte(crc & 0xff) + frame[frameDataSize+2] = byte(crc >> 8) + // frame[67..69] remain zero (filler) + return frame +} + +// yubikeyCRC16 is Yubico's CRC-16 (yubico-c ykcrc.c): init 0xFFFF, reflected, +// poly 0x8408; no final xor. A valid (data||crc) frame has residual 0xF0B8. +func yubikeyCRC16(buf []byte) uint16 { + crc := uint16(0xffff) + for _, b := range buf { + crc ^= uint16(b) + for i := 0; i < 8; i++ { + j := crc & 1 + crc >>= 1 + if j == 1 { + crc ^= 0x8408 + } + } + } + return crc +} + +func crcValid(buf []byte) bool { return yubikeyCRC16(buf) == crcResidual } + +// --- write / read state machine --------------------------------------------- + +// sendFrame writes the 70-byte frame as 10 seven-byte records. An all-zero +// record is skipped except the first (seq 0) and last (seq 9); the last always +// goes because it carries the slot command + CRC. Each write waits for the +// device to clear SLOT_WRITE_FLAG first. +func sendFrame(dev *hid.Device, frame [frameSize]byte) error { + for seq := 0; seq < frameSize/recordDataSize; seq++ { + var rec [recordDataSize]byte + copy(rec[:], frame[seq*recordDataSize:(seq+1)*recordDataSize]) + if isZero(rec[:]) && seq > 0 && seq != (frameSize/recordDataSize)-1 { + continue + } + if err := waitForWriteReady(dev); err != nil { + return err + } + var rep [featureRptSize]byte + copy(rep[:recordDataSize], rec[:]) + rep[featureRptSize-1] = byte(seq) | slotWriteFlag + if err := writeReport(dev, rep); err != nil { + return err + } + } + return nil +} + +// waitForWriteReady polls the status report until SLOT_WRITE_FLAG is clear, +// meaning the device has consumed the previous write record. +func waitForWriteReady(dev *hid.Device) error { + deadline := time.Now().Add(writeReadyTimeout) + sleep := pollMin + for { + st, err := readStatus(dev) + if err != nil { + return err + } + if st[featureRptSize-1]&slotWriteFlag == 0 { + return nil + } + if time.Now().After(deadline) { + return errors.New("chalresp: timeout waiting for device to accept write") + } + time.Sleep(sleep) + if sleep = sleep * 2; sleep > pollMax { + sleep = pollMax + } + } +} + +// readResponse waits for RESP_PENDING, then reads the response in 7-byte chunks +// until the terminator (RESP_PENDING set with sequence 0), verifies the CRC, +// and returns the 20-byte HMAC-SHA1. It always resets the device afterward. +func readResponse(dev *hid.Device, mayBlock bool) ([]byte, error) { + first, err := waitForResponse(dev, mayBlock) + if err != nil { + return nil, err + } + buf := make([]byte, 0, frameSize) + buf = append(buf, first[:recordDataSize]...) + + // Bounded by buffer growth (~9 chunks); the explicit counter is belt-and- + // suspenders against a flooding device if the frame constants ever change. + const maxReads = 16 + for reads := 0; len(buf)+recordDataSize <= frameSize; reads++ { + if reads >= maxReads { + writeReset(dev) + wipe(buf) + return nil, errors.New("chalresp: too many response reports") + } + rep, err := readStatus(dev) + if err != nil { + writeReset(dev) + wipe(buf) + return nil, err + } + flags := rep[featureRptSize-1] + if flags&respPendingFlag == 0 { + writeReset(dev) + wipe(buf) + return nil, errors.New("chalresp: response aborted by device") + } + if flags&sequenceMask == 0 { + // Terminator. The response is 20-byte HMAC + 2-byte CRC. + const respLen = hmacLen + 2 + if len(buf) < respLen || !crcValid(buf[:respLen]) { + writeReset(dev) + wipe(buf) + return nil, errors.New("chalresp: response CRC check failed (slot empty or wrong config?)") + } + writeReset(dev) + resp := append([]byte(nil), buf[:hmacLen]...) + wipe(buf) // clear the intermediate secret accumulator + return resp, nil + } + buf = append(buf, rep[:recordDataSize]...) + } + writeReset(dev) + wipe(buf) + return nil, errors.New("chalresp: response exceeded expected length") +} + +// waitForResponse polls until RESP_PENDING is set, returning the report that +// carries the first response bytes. If the slot requires a touch +// (RESP_TIMEOUT_WAIT_FLAG) and mayBlock is false, it returns ErrTouchTimeout +// immediately; if mayBlock, it extends the deadline to wait for the touch. +func waitForResponse(dev *hid.Device, mayBlock bool) ([featureRptSize]byte, error) { + var last [featureRptSize]byte + deadline := time.Now().Add(processTimeout) + extended := false + sleep := pollMin + for { + st, err := readStatus(dev) + if err != nil { + return st, err + } + last = st + flags := st[featureRptSize-1] + if flags&respPendingFlag != 0 { + return st, nil + } + if flags&respTimeoutWaitFlag != 0 { + // Device is waiting for a physical touch. + if !mayBlock { + writeReset(dev) + return st, ErrTouchTimeout + } + if !extended { + extended = true + deadline = time.Now().Add(touchTimeout) + } + } + if time.Now().After(deadline) { + writeReset(dev) + if extended { + return last, ErrTouchTimeout + } + return last, errors.New("chalresp: timeout waiting for response") + } + time.Sleep(sleep) + if sleep = sleep * 2; sleep > pollMax { + sleep = pollMax + } + } +} + +func isZero(b []byte) bool { + for _, x := range b { + if x != 0 { + return false + } + } + return true +} diff --git a/hardware/chalresp/chalresp_pcsc.go b/hardware/chalresp/chalresp_pcsc.go new file mode 100644 index 0000000..3a170d6 --- /dev/null +++ b/hardware/chalresp/chalresp_pcsc.go @@ -0,0 +1,195 @@ +//go:build pcsc && !android && !ios && !nohw + +package chalresp + +// PC/SC backend: HMAC-SHA1 challenge-response over CCID via the Yubico OATH +// applet. Covers Nitrokey 3 (its Trussed "Secrets App" reimplements the applet) +// and YubiKey-over-CCID. Opt-in via `-tags pcsc` because it needs a PC/SC stack +// (PCSC.framework on macOS, WinSCard on Windows — both system; libpcsclite + +// pcscd on Linux). +// +// Wire sequence verified against KeePassXC's YubiKeyInterfacePCSC: +// +// SELECT 00 A4 04 00 07 A0 00 00 05 27 21 01 (OATH AID; also try …27 20 01) +// HMAC 00 01 38 00 40 <64-byte padded challenge> (P1=0x38 slot 2) +// response 20-byte HMAC-SHA1 + SW 90 00; on 61 xx, GET RESPONSE 00 C0 00 00 xx +// +// STATUS: implemented to the verified spec but UNTESTED without a Nitrokey 3. +// The exact challenge padding the applet hashes (it does NOT strip like the OTP +// slot's HMAC_LT64) must be confirmed against real hardware before relying on it +// for cross-tool interop; for icfx's own seal+unlock it only needs determinism. + +import ( + "fmt" + "strings" + + "github.com/ebfe/scard" +) + +// Yubico OATH applet AIDs. KeePassXC tries both; a device answering either can +// do HMAC challenge-response. 2721 01 is the OATH AID (Nitrokey 3 + YubiKey); +// 2720 01 is the legacy YubiKey AID. +var oathAIDs = [][]byte{ + {0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01}, + {0xa0, 0x00, 0x00, 0x05, 0x27, 0x20, 0x01}, +} + +const insAPIReq = 0x01 // YubiKey/OATH HMAC command class instruction +// (APDU status words + GET RESPONSE caps live in chalresp.go with transmitChain.) + +// pcscList enumerates PC/SC readers whose card answers an OATH-applet SELECT. +func pcscList() ([]DeviceDescriptor, error) { + ctx, err := scard.EstablishContext() + if err != nil { + return nil, nil // no PC/SC service ⇒ no devices (non-fatal) + } + defer ctx.Release() + + readers, err := ctx.ListReaders() + if err != nil || len(readers) == 0 { + return nil, nil + } + var out []DeviceDescriptor + for _, reader := range readers { + card, err := ctx.Connect(reader, scard.ShareShared, scard.ProtocolAny) + if err != nil { + continue // no card / in use + } + _, selErr := selectOATH(card) + card.Disconnect(scard.LeaveCard) + if selErr != nil { + continue // not an OATH/HMAC-capable card + } + out = append(out, DeviceDescriptor{ + Family: familyForReader(reader), + backend: backendPCSC, + reader: reader, + }) + } + return out, nil +} + +// pcscIsSlot2Programmed reports whether the card can perform slot-2 HMAC. The +// OATH applet has no separate "is slot 2 valid" query like the OTP status +// report; a device that SELECTs the applet is treated as capable (the actual +// Challenge surfaces a clear error if the credential is absent). +func pcscIsSlot2Programmed(desc DeviceDescriptor) (bool, error) { + return withCard(desc, func(card *scard.Card) (bool, error) { + if _, err := selectOATH(card); err != nil { + return false, err + } + return true, nil + }) +} + +// pcscChallenge runs slot-2 HMAC-SHA1 over CCID and returns the 20-byte HMAC. +func pcscChallenge(desc DeviceDescriptor, challenge []byte) ([]byte, error) { + var resp []byte + _, err := withCard(desc, func(card *scard.Card) (struct{}, error) { + if _, err := selectOATH(card); err != nil { + return struct{}{}, err + } + r, err := sendHMAC(card, slotChalHMAC2, challenge) + if err != nil { + return struct{}{}, err + } + resp = r + return struct{}{}, nil + }) + if err != nil { + return nil, err + } + return resp, nil +} + +// withCard connects to the descriptor's reader, runs fn, and disconnects. +func withCard[T any](desc DeviceDescriptor, fn func(*scard.Card) (T, error)) (T, error) { + var zero T + ctx, err := scard.EstablishContext() + if err != nil { + return zero, fmt.Errorf("chalresp: PC/SC unavailable: %w", err) + } + defer ctx.Release() + + reader := desc.reader + if reader == "" { + readers, err := ctx.ListReaders() + if err != nil || len(readers) == 0 { + return zero, ErrNoDevice + } + reader = readers[0] + } + card, err := ctx.Connect(reader, scard.ShareShared, scard.ProtocolAny) + if err != nil { + return zero, fmt.Errorf("chalresp: connecting to %q: %w", reader, err) + } + defer card.Disconnect(scard.LeaveCard) + // Bracket the SELECT→HMAC sequence in a transaction so a concurrent local + // process (ykman/KeePassXC/PIV tool sharing the card) can't swap the selected + // applet underneath us and feed a wrong response into the KEK. Keeps + // ShareShared (coexistence) while making our two-APDU atom exclusive. + if err := card.BeginTransaction(); err != nil { + return zero, fmt.Errorf("chalresp: begin card transaction: %w", err) + } + defer card.EndTransaction(scard.LeaveCard) + return fn(card) +} + +// selectOATH tries each known OATH AID until one is accepted (SW 90 00). +func selectOATH(card *scard.Card) ([]byte, error) { + var lastErr error + for _, aid := range oathAIDs { + apdu := append([]byte{0x00, 0xa4, 0x04, 0x00, byte(len(aid))}, aid...) + data, err := transmitFull(card, apdu) + if err == nil { + return data, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = ErrNoDevice + } + return nil, lastErr +} + +// sendHMAC issues the HMAC challenge-response APDU for the given slot command +// (0x38 slot 2 / 0x30 slot 1) and returns the 20-byte response. +func sendHMAC(card *scard.Card, slot byte, challenge []byte) ([]byte, error) { + payload := padChallenge(challenge) // 64 bytes, yubikit rule + apdu := append([]byte{0x00, insAPIReq, slot, 0x00, byte(len(payload))}, payload[:]...) + data, err := transmitFull(card, apdu) + if err != nil { + return nil, err + } + // The OATH HMAC command returns EXACTLY a 20-byte HMAC-SHA1 (+ SW). Require + // that and fail closed on anything else rather than silently truncating — + // if a real Nitrokey 3 wraps the response, validate + adjust against + // hardware before trusting this path. + if len(data) != hmacLen { + wipe(data) + return nil, fmt.Errorf("chalresp: unexpected HMAC response length %d (want %d; slot empty or wrong config?)", len(data), hmacLen) + } + resp := append([]byte(nil), data...) + wipe(data) // clear the intermediate secret buffer; caller wipes the returned copy + return resp, nil +} + +// transmitFull sends an APDU over the card, following "61 xx" GET RESPONSE +// chaining via the bounded, transport-agnostic transmitChain helper. +func transmitFull(card *scard.Card, apdu []byte) ([]byte, error) { + return transmitChain(card.Transmit, apdu) +} + +// familyForReader guesses the device family from the PC/SC reader name (used +// only for display; matching is by AID at Challenge time). +func familyForReader(reader string) string { + r := strings.ToLower(reader) + switch { + case strings.Contains(r, "nitrokey"): + return "nitrokey" + case strings.Contains(r, "yubikey") || strings.Contains(r, "yubico"): + return "yubikey" + default: + return "pcsc" + } +} diff --git a/hardware/chalresp/chalresp_pcsc_stub.go b/hardware/chalresp/chalresp_pcsc_stub.go new file mode 100644 index 0000000..99369c3 --- /dev/null +++ b/hardware/chalresp/chalresp_pcsc_stub.go @@ -0,0 +1,16 @@ +//go:build !pcsc && !android && !ios && !nohw + +package chalresp + +// The PC/SC backend (Nitrokey 3 + YubiKey-CCID) is compiled in only with +// `-tags pcsc`, which pulls in a PC/SC stack (libpcsclite + pcscd on Linux). +// Without that tag these no-ops keep the default HID-only desktop build free of +// any PC/SC dependency: List() surfaces no PC/SC devices, so no descriptor is +// ever backendPCSC and the Challenge/IsSlot2Programmed dispatch never routes +// here. Build the release (and Nitrokey support) with `-tags pcsc`. + +func pcscList() ([]DeviceDescriptor, error) { return nil, nil } + +func pcscIsSlot2Programmed(_ DeviceDescriptor) (bool, error) { return false, ErrNotSupported } + +func pcscChallenge(_ DeviceDescriptor, _ []byte) ([]byte, error) { return nil, ErrNotSupported } diff --git a/hardware/chalresp/chalresp_test.go b/hardware/chalresp/chalresp_test.go new file mode 100644 index 0000000..823a400 --- /dev/null +++ b/hardware/chalresp/chalresp_test.go @@ -0,0 +1,179 @@ +//go:build !android && !ios && !nohw + +package chalresp + +import ( + "bytes" + "testing" +) + +// TestCRC16Residual verifies the two CRC conventions the protocol uses: +// - WRITE frame (buildFrame): the CRC is stored un-complemented, exactly as +// ykpers' yk_write_to_key does, so CRC-over-(payload||crc) self-cancels to 0. +// - RESPONSE (crcValid): the device stores the COMPLEMENTED CRC (the OTP-token +// / X.25 convention), giving residual YK_CRC_OK_RESIDUAL (0xF0B8) — which is +// what yk_read_response_from_key (and crcValid) check. +func TestCRC16Residual(t *testing.T) { + cases := [][]byte{ + {0x01, 0x02, 0x03, 0x04}, + make([]byte, 20), // 20-byte HMAC response length + {0xde, 0xad, 0xbe, 0xef}, + bytes.Repeat([]byte{0xAB}, 64), + } + for _, data := range cases { + crc := yubikeyCRC16(data) + + writeFrame := append(append([]byte{}, data...), byte(crc&0xff), byte(crc>>8)) + if got := yubikeyCRC16(writeFrame); got != 0 { + t.Fatalf("write-frame residual for %x = 0x%04x, want 0x0000", data, got) + } + + comp := crc ^ 0xffff + response := append(append([]byte{}, data...), byte(comp&0xff), byte(comp>>8)) + if got := yubikeyCRC16(response); got != crcResidual { + t.Fatalf("response residual for %x = 0x%04x, want 0x%04x", data, got, crcResidual) + } + if !crcValid(response) { + t.Fatalf("crcValid(response) = false for %x", data) + } + } +} + +// TestCRC16KnownVector pins yubikeyCRC16 to a known-good value so a refactor +// can't silently change the polynomial/init. Verified against yubico-c's +// yubikey_crc16: CRC16(0x01) = 0x1e0e. +func TestCRC16KnownVector(t *testing.T) { + if got := yubikeyCRC16([]byte{0x01}); got != 0x1e0e { + t.Fatalf("CRC16(0x01) = 0x%04x, want 0x1e0e", got) + } +} + +// TestPadChallenge covers the yubikit "differ-from-last" padding rule. +func TestPadChallenge(t *testing.T) { + cases := []struct { + name string + in []byte + wantPad byte // expected padding byte (only checked when len(in) < 64) + }{ + {"short non-zero-ending", []byte{0x01, 0x02, 0x03}, 0x00}, + {"short zero-ending", []byte{0x01, 0x02, 0x00}, 0x01}, + {"32 non-zero-ending", append(bytes.Repeat([]byte{0x07}, 31), 0x09), 0x00}, + {"32 zero-ending", append(bytes.Repeat([]byte{0x07}, 31), 0x00), 0x01}, + {"empty", []byte{}, 0x00}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := padChallenge(c.in) + if !bytes.Equal(p[:len(c.in)], c.in) { + t.Fatalf("payload prefix = %x, want %x", p[:len(c.in)], c.in) + } + for i := len(c.in); i < frameDataSize; i++ { + if p[i] != c.wantPad { + t.Fatalf("pad byte at %d = 0x%02x, want 0x%02x", i, p[i], c.wantPad) + } + } + }) + } +} + +// TestPadChallengeFull64 confirms a 64-byte challenge is sent verbatim (no pad). +func TestPadChallengeFull64(t *testing.T) { + in := bytes.Repeat([]byte{0x5a}, 64) + in[63] = 0x00 // ends in 0x00, but full length ⇒ no padding at all + p := padChallenge(in) + if !bytes.Equal(p[:], in) { + t.Fatalf("64-byte challenge altered: got %x", p[:]) + } +} + +// TestBuildFrame checks the 70-byte frame layout: payload, slot byte, CRC (LE +// over the 64-byte payload), and zero filler. +func TestBuildFrame(t *testing.T) { + challenge := bytes.Repeat([]byte{0x11}, 32) + frame := buildFrame(challenge, slotChalHMAC2) + + if !bytes.Equal(frame[:len(challenge)], challenge) { + t.Fatalf("payload prefix wrong: %x", frame[:len(challenge)]) + } + if frame[frameDataSize] != slotChalHMAC2 { + t.Fatalf("slot byte = 0x%02x, want 0x%02x", frame[frameDataSize], slotChalHMAC2) + } + wantCRC := yubikeyCRC16(frame[:frameDataSize]) + gotCRC := uint16(frame[frameDataSize+1]) | uint16(frame[frameDataSize+2])<<8 + if gotCRC != wantCRC { + t.Fatalf("frame CRC = 0x%04x, want 0x%04x (little-endian over payload)", gotCRC, wantCRC) + } + for i := frameDataSize + 3; i < frameSize; i++ { + if frame[i] != 0 { + t.Fatalf("filler byte %d = 0x%02x, want 0", i, frame[i]) + } + } +} + +// TestTransmitChainOK: single response + SW 9000. +func TestTransmitChainOK(t *testing.T) { + calls := 0 + out, err := transmitChain(func([]byte) ([]byte, error) { + calls++ + return []byte{0xde, 0xad, swOKHi, swOKLo}, nil + }, []byte{0x00, 0xa4}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, []byte{0xde, 0xad}) || calls != 1 { + t.Fatalf("out=%x calls=%d", out, calls) + } +} + +// TestTransmitChainGetResponse: "61 xx" chaining concatenates the data. +func TestTransmitChainGetResponse(t *testing.T) { + seq := 0 + out, err := transmitChain(func([]byte) ([]byte, error) { + seq++ + if seq == 1 { + return []byte{0xaa, swMore, 0x02}, nil // 1 data byte + "61 02" + } + return []byte{0xbb, 0xcc, swOKHi, swOKLo}, nil + }, []byte{0x00, 0x01}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, []byte{0xaa, 0xbb, 0xcc}) { + t.Fatalf("out=%x", out) + } +} + +// TestTransmitChainHangBounded: a card that returns bare "61 01" forever must +// error (bounded), not hang — the HIGH-severity DoS fix. +func TestTransmitChainHangBounded(t *testing.T) { + calls := 0 + _, err := transmitChain(func([]byte) ([]byte, error) { + calls++ + return []byte{swMore, 0x01}, nil // len 2: no data, "more" + }, []byte{0x00, 0x01}) + if err == nil { + t.Fatal("expected error on an unbounded 61xx chain") + } + if calls > maxAPDUChain+1 { + t.Fatalf("chain not bounded: %d calls", calls) + } +} + +// TestTransmitChainSizeBounded: a card flooding data must hit a cap and error. +func TestTransmitChainSizeBounded(t *testing.T) { + _, err := transmitChain(func([]byte) ([]byte, error) { + return append(make([]byte, 253), swMore, 0xff), nil + }, []byte{0x00, 0x01}) + if err == nil { + t.Fatal("expected error on a flooding card") + } +} + +// TestTransmitChainErrorSW: a non-9000/61xx status word fails closed. +func TestTransmitChainErrorSW(t *testing.T) { + if _, err := transmitChain(func([]byte) ([]byte, error) { + return []byte{0x6a, 0x82}, nil // file/applet not found + }, []byte{0x00, 0xa4}); err == nil { + t.Fatal("expected an APDU error SW to fail") + } +} From e46980a05fa617e3b46d00dc3b81eaf0803b9a96 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 9 Sep 2026 20:22:35 -0700 Subject: [PATCH 2/2] Updated CI and README --- .github/workflows/ci.yml | 10 ++++++---- README.md | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcadd54..6d16069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,11 +19,13 @@ jobs: go-version-file: go.mod cache: true - # cgo hardware backends need their C libs: hardware/chalresp uses - # `pkg-config: ykpers-1`, hardware/fido2 uses keys-pub/go-libfido2 (libfido2). - # Without these, `go build ./...` fails compiling those packages. + # cgo hardware backends need their C libs: hardware/chalresp uses go-hid, + # whose Linux hidraw backend needs libudev (libudev-dev); hardware/fido2 + # uses keys-pub/go-libfido2 (libfido2-dev). Without these, `go build ./...` + # fails compiling those packages. (The optional PC/SC chalresp backend is + # build-tagged `pcsc` and not built here, so libpcsclite isn't needed.) - name: Install native deps - run: sudo apt-get update && sudo apt-get install -y pkg-config libykpers-1-dev libfido2-dev + run: sudo apt-get update && sudo apt-get install -y pkg-config libudev-dev libfido2-dev - name: Build run: go build ./... diff --git a/README.md b/README.md index 96507d1..cd0c599 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ A Go library providing post-quantum ready encryption, signing, key management, a | `sharing` | Transport for encrypted file shares — uploads/downloads pre-encrypted `.icfx` ciphertext (does no cryptography itself) | | `cloud` | Go client SDK for Instacrypt Cloud: transport, auth, billing, encrypted blob CRUD, directory, notification sync | | `keystore` | Abstract key storage — OS keychain or encrypted file backend | -| `hardware/chalresp` | Optional Yubikey/compatible HMAC-SHA1 challenge-response (libykpers) | +| `hardware/chalresp` | Optional YubiKey/compatible HMAC-SHA1 challenge-response over HID (go-hid); optional PC/SC backend for Nitrokey 3 | | `hardware/fido2` | Optional FIDO2/WebAuthn security-key client (libfido2) — registration and login | | `config` | TOML configuration and XDG-compliant path management | | `profile` | Full profile backup/restore via encrypted tarballs | @@ -72,12 +72,15 @@ A Go library providing post-quantum ready encryption, signing, key management, a (On macOS the OS keychain still uses the system Security.framework; use file-based key storage for a fully cgo-free build there.) - OS keychain (optional — falls back to file-based storage) -- libykpers-1 (optional — required only if you import `hardware/chalresp` for hardware-key support) - - Linux (Debian/Ubuntu): `sudo apt install libykpers-1-dev` - - Linux (Arch): `sudo pacman -S yubikey-personalization` - - Linux (Fedora): `sudo dnf install ykpers-devel` - - macOS: `brew install ykpers` - - Windows: install from [Yubico's yubikey-personalization releases][ykpers-win]; link via mingw or vcpkg +- libudev (optional — required only if you import `hardware/chalresp` for hardware-key support). `hardware/chalresp` + talks to the device directly over HID via [go-hid](https://github.com/sstallion/go-hid); on Linux its hidraw + backend links libudev. macOS and Windows use the system HID APIs — no extra library needed there. + - Linux (Debian/Ubuntu): `sudo apt install libudev-dev` + - Linux (Arch): `sudo pacman -S systemd` (provides libudev) + - Linux (Fedora): `sudo dnf install systemd-devel` + - macOS / Windows: none (system HID) + - Optional PC/SC backend (Nitrokey 3): build with `-tags pcsc`. On Linux this additionally needs + `libpcsclite-dev` + a running `pcscd`; macOS/Windows use the system PC/SC service. - See `hardware/chalresp/README.md` for details - libfido2 (optional — required only if you import `hardware/fido2` for FIDO2/WebAuthn security keys) - Linux (Debian/Ubuntu): `sudo apt install libfido2-dev` @@ -92,8 +95,6 @@ A Go library providing post-quantum ready encryption, signing, key management, a ``` - Windows: install libfido2; link via mingw or vcpkg -[ykpers-win]: https://developers.yubico.com/yubikey-personalization/Releases/ - ### ⚗️ Tech Stack ---