Skip to content

feat: add macOS support#77

Open
AnkitChahar wants to merge 4 commits intomax-baz:mainfrom
AnkitChahar:main
Open

feat: add macOS support#77
AnkitChahar wants to merge 4 commits intomax-baz:mainfrom
AnkitChahar:main

Conversation

@AnkitChahar
Copy link
Copy Markdown

Summary

This PR ports yubikey-touch-detector to macOS by introducing a strategy pattern that selects platform-specific detector and notifier implementations at compile time using Go build tags. All existing Linux behaviour is unchanged.

Motivation

macOS lacks the Linux-specific APIs this tool depends on (inotify, hidraw + ioctl, D-Bus, libnotify). Rather than a one-off hack, this PR introduces a clean platform abstraction so future OS ports (e.g. Windows) follow the same pattern.

Architecture

Strategy pattern

detector.DetectorStrategy is a struct of first-class functions populated by a platform-specific factory:

strategy := detector.NewDetectorStrategy() // returns Linux or Darwin impl
go strategy.WatchGPG(filesToWatch, requestGPGCheck, exits)
go strategy.WatchU2F(notifiers)
go strategy.WatchHMAC(notifiers)

Similarly, notifier.SetupPlatformNotifier() resolves to the correct desktop notification backend per OS.

New files

File Purpose
detector/strategy.go DetectorStrategy struct definition
detector/strategy_linux.go Wires existing Linux implementations
detector/strategy_darwin.go Wires new Darwin implementations
detector/gpg_common.go CheckGPGOnRequest extracted — portable, no build tag
detector/gpg_darwin.go GPG detection via gpg-agent Unix socket proxy
detector/u2f_common.go CTAPHID protocol constants + runU2FPacketWatcher(io.ReadCloser) — portable
detector/u2f_darwin.go U2F detection via IOKit HID (karalabe/hid)
detector/hmac_darwin.go HMAC stub (logs unsupported; see limitations)
notifier/macos.go macOS notifications via osascript
notifier/strategy_linux.go SetupPlatformNotifier → libnotify
notifier/strategy_darwin.go SetupPlatformNotifier → osascript
notifier/socket_dir_linux.go socketRuntimeDir()$XDG_RUNTIME_DIR
notifier/socket_dir_darwin.go socketRuntimeDir()$TMPDIR fallback
dbus_notifier_linux.go Thin shim so main.go can call D-Bus without build tags
dbus_notifier_darwin.go No-op shim with warning on --dbus

Modified files

File Change
detector/gpg.go //go:build linux; renamed WatchGPGWatchGPGLinux
detector/u2f.go //go:build linux; renamed WatchU2FWatchU2FLinux; device open extracted
detector/hmac.go //go:build linux; renamed WatchHMACWatchHMACLinux
detector/util.go //go:build linux
notifier/dbus.go //go:build linux
notifier/libnotify.go //go:build linux
notifier/unix_socket.go Replaced hardcoded $XDG_RUNTIME_DIR with socketRuntimeDir()
main.go Uses NewDetectorStrategy(); unified --notify flag (cross-platform alias for --libnotify)

macOS implementation details

GPG detection

Proxies the gpg-agent Unix socket (same technique the existing WatchSSH uses for the SSH agent socket). Every message through the proxy triggers CheckGPGOnRequest, which uses the existing GPGME Assuan LEARN command to confirm whether the YubiKey is actually waiting for a touch. Registers an exit handler to restore the original socket on graceful shutdown.

U2F detection

Enumerates HID devices via karalabe/hid (hidapi + IOKit). Identifies FIDO devices by UsagePage == 0xf1d0. The CTAPHID packet parsing logic (runU2FPacketWatcher) is shared with the Linux path via u2f_common.go.

Note: macOS 10.15+ restricts unprivileged access to FIDO HID devices. Users need to grant Input Monitoring permission in System Settings → Privacy & Security → Input Monitoring, or run with sudo.

HMAC detection

Not yet implemented on macOS. The Linux implementation relies on /sys/class/hidraw sysfs paths that don't exist on macOS. A stub is provided that logs a debug message. A full IOKit-based implementation is a follow-up TODO.

Notifications

Uses osascript to send native macOS Notification Center banners. Requires the terminal app to have notification permission in System Settings.

New flag

--notify is introduced as a cross-platform alias for --libnotify. Both flags work on Linux; only --notify has effect on macOS. The YUBIKEY_TOUCH_DETECTOR_NOTIFY env var is the macOS equivalent of YUBIKEY_TOUCH_DETECTOR_LIBNOTIFY.

Dependencies

  • Added github.com/karalabe/hid v1.0.0 for IOKit HID access on macOS.
  • brew install gpgme required on macOS (same requirement as Linux for libgpgme).

Disclaimer: I have used AI to actually build out this support but I have vetted the code manually.

@AnkitChahar AnkitChahar marked this pull request as ready for review March 12, 2026 04:44
@max-baz
Copy link
Copy Markdown
Owner

max-baz commented Mar 12, 2026

Thanks for your work! I can see that it is a different approach than what folks developing yknotify chose, have you considered pros & cons of using those compared to your implementation?

https://github.com/reo101/yknotify-rs?tab=readme-ov-file#detection-strategy
(Go implementation: https://github.com/noperator/yknotify)

Your FIDO approach is probably more resilient (though requires special permissions), and both PGP approaches are hacky and I'm not sure what's the lesser evil 😅 Are you able to check if PGP approach in this PR vs that code reacts better/consistently across PGP sign, PGP encrypt, as well as when ssh'ing to a remote server and using keypair from YubiKey?

@AnkitChahar
Copy link
Copy Markdown
Author

Thanks for the pointers to yknotify-rs and yknotify! I did look at both before implementing. Here's my thinking on the tradeoffs:

FIDO/U2F detection

yknotify uses macOS log stream to watch for IOHIDFamily kernel messages (startQueue/stopQueue). This is clever but fragile because the log messages are undocumented internals that could change across macOS versions. They also had to add workarounds to avoid false positives from non-YubiKey HID devices (filtering by AppleUserUSBHostHIDDevice and tracking client IDs).

This PR instead opens the FIDO HID device directly via hidapi/IOKit and parses actual CTAPHID packets. The packet-parsing logic (runU2FPacketWatcher in u2f_common.go) is shared with the Linux path, so both platforms use the same proven detection. The tradeoff is requiring Input Monitoring permission on macOS 10.15+, which I've documented.

PGP/GPG detection

This is where both approaches are admittedly hacky, just in different ways:

  • yknotify: Monitors log stream for "Time extension received" from usbsmartcardreaderd/CryptoTokenKit. Non-intrusive but heuristic depends on undocumented log messages, and the yknotify author notes rare false positives. SSH via YubiKey is also untested in their implementation.
  • This PR: Proxies the gpg-agent Unix socket and uses the existing Assuan LEARN timing check (same as the Linux path in gpg_common.go). More invasive (renames the socket file), but detects the actual touch-pending state at the protocol level rather than relying on log heuristics. The socket-proxy pattern is already established in this repo, WatchSSH uses the same technique.

I chose the socket-proxy approach because:

  • it reuses this repo's existing detection logic rather than introducing a completely different paradigm per-platform
  • it's protocol level rather than heuristic
  • the stale socket risk is mitigated with recovery logic on startup and an exit handler for cleanup

I've tested GPG sign (git commit), GPG decrypt, and SSH to remote servers using a YubiKey keypair, touch detection fires consistently across all three. SSH is handled by the existing WatchSSH detector which is already cross-platform.

@max-baz max-baz mentioned this pull request Mar 24, 2026
@max-baz
Copy link
Copy Markdown
Owner

max-baz commented Mar 24, 2026

I'm sorry for the delay, I just wanted to let you know that I didn't forget about it and I will get back to you soon!

Copy link
Copy Markdown
Owner

@max-baz max-baz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for your work and sorry it took me long to go through the code changes, you caught me during a very busy period 😅 Let me know what you think about the comments below and how you prefer to proceed. This looks interesting overall, I only feel that this PR should have a much smaller diff before it's in a state that could be merged.

Comment thread detector/strategy.go Outdated
Comment thread detector/u2f.go Outdated
if typ == HID_ITEM_TYPE_GLOBAL && tag == HID_GLOBAL_ITEM_TAG_USAGE_PAGE && val2b == FIDO_USAGE_PAGE {
isFido = true
} else if typ == HID_ITEM_TYPE_LOCAL && tag == HID_LOCAL_ITEM_TAG_USAGE && val1b == FIDO_USAGE_CTAPHID {
} else if typ == HID_ITEM_TYPE_LOCAL && tag == HID_LOCAL_ITEM_TAG_USAGE && val1b == FIDO_USAGE_U2F {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks suspicious on the first glance, can you give some context for this change?

Comment thread go.mod Outdated
github.com/vtolstov/go-ioctl v0.0.0-20151206205506-6be9cced4810
)

require github.com/karalabe/hid v1.0.0 // indirect
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how unfortunate, it looks like this repo is now archived, do you know if there are any available alternatives, or was it the only option?

Comment thread main.go
Comment thread notifier/socket_dir_darwin.go Outdated
// macOS does not have XDG_RUNTIME_DIR; fall back to $TMPDIR (guaranteed on macOS).
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return dir
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess if the comment is true and macOS doesn't have XDG_RUNTIME_DIR, there's no need to try to check it here in this _darwin.go file? 😜

Comment thread notifier/macos.go Outdated
func sendMacOSNotification(message, title string) {
imagePath := "/usr/local/share/yubikey-touch-detector/yubikey-touch-detector.png"
script := `display notification "` + message + `" with title "` + title + `" sound name "Ping" image from file "` + imagePath + `"`
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced this notifier should be part of the core app, as all it does is calls a shell script - it's trivial to build this kind of integration outside of the app, there are some examples on wiki. I was (and maybe still am 😂 ) skeptical about adding libnotify support in the first place, but at least it has plenty of logic about it, like auto-hiding notifications after the yubikey is pressed. I propose we focus on actual detection in this PR and if needed take the notifier part separately.

Comment thread detector/u2f_darwin.go Outdated
known := map[string]bool{}

for {
devices := hid.Enumerate(0, 0) // enumerate all HID devices
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, this seems to be a cross-platform library, so we could even use this code on linux, and avoid splitting the code for linux vs darwin, could we not? I'm not worried about splitting like you did (especially in light of us maybe having to find another library, see another comment), but if the library we pick is cross-platform, it could be a way to save on some lines of code :)

@AnkitChahar
Copy link
Copy Markdown
Author

Just a small update, I am taking a look at the review comments. I will try to resolve them soon since I am having a bit of a busy time!

Remove the strategy pattern (DetectorStrategy struct + factory
functions) in favour of letting Go build tags dispatch between
platform-specific implementations directly, matching the
reviewer's suggestion.

- Delete detector/strategy{,_linux,_darwin}.go
- Rename Watch{GPG,U2F,HMAC}Linux/Darwin → Watch{GPG,U2F,HMAC};
  main.go calls detector.Watch* directly
- Revert cosmetic variable renames (printVersion→version, sz→size)
  and strip added comments to reduce diff noise

Replace the osascript notifier strategy shim with
build-tagged stub:
- Delete notifier/{macos,strategy_linux,strategy_d Jump to bottom
- Add notifier/libnotify_darwin.go: same SetupLibnotifyNotifier
  name as the Linux implementation; sends a macOS
  Center banner via osascript on *_ON events
- Revert main.go to call SetupLibnotifyNotifier di Jump to bottom
  drop the --notify flag and YUBIKEY_TOUCH_DETECTOR_NOTIFY env var

Fix notifier/socket_dir_darwin.go: remove the pointless
XDG_RUNTIME_DIR check since macOS does not have it
@AnkitChahar
Copy link
Copy Markdown
Author

Hi @max-baz, thanks for the detailed review, apologies for the delay. I've worked through most of the comments:

Removed the strategy pattern. Deleted detector/strategy{,_linux,_darwin}.go and renamed Watch{GPG,U2F,HMAC}Linux/DarwinWatch{GPG,U2F,HMAC}. Go build tags now handle dispatch directly and main.go calls detector.Watch* without any intermediary. Applied the same approach to the notifier — dropped the strategy_{linux,darwin}.go shims in favour of a plain build-tagged notifier/libnotify_darwin.go.

Reverted cosmetic changes. printVersionversion, szsize, stripped the added comments.

macOS notifier. Rather than dropping it, I kept it as a build-tagged libnotify_darwin.go that sends a Notification Center banner via osascript under the same --libnotify flag, avoiding the strategy shim. Happy to drop it if you'd prefer to keep this PR detection-only.

socket_dir_darwin.go. Removed the pointless XDG_RUNTIME_DIR check.

Two things still open:

karalabe/hid being archived. The two viable replacements I found are github.com/sstallion/go-hid (actively maintained, tracks upstream hidapi, BSD licensed, UsagePage/Usage populated on both Linux and macOS) and github.com/bearsh/hid (near drop-in API replacement, maintained fork, last release Sep 2024). sstallion/go-hid would also answer your question about unifying the Linux/Darwin U2F path — with it both platforms could share a single u2f.go. Happy to make that switch if you have a preference.

FIDO_USAGE_CTAPHIDFIDO_USAGE_U2F rename. The constant was moved to u2f_common.go so Darwin could share it, and renamed because U2F more accurately describes usage value 0x01 within the FIDO usage page per the HID spec. Happy to revert if you'd prefer to keep the original name.

@AnkitChahar AnkitChahar changed the title feat: add macOS support via strategy pattern feat: add macOS support Apr 15, 2026
@AnkitChahar AnkitChahar requested a review from max-baz April 16, 2026 06:22
@max-baz
Copy link
Copy Markdown
Owner

max-baz commented Apr 16, 2026

Thanks!

Yes I still prefer to drop the macOS notifier part and focus on detection for now.

Could we try to see if github.com/sstallion/go-hid can equally be used for detection on macOS? I think let's not merge the code with Linux just yet, focus only on macOS, I just think it would be great if we don't launch the macOS support by using an already archived and deprecated github.com/karalabe/hid ...

Regarding FIDO_USAGE_CTAPHID vs FIDO_USAGE_U2F yes I think claude has messed up here, I prefer we restore the block of constants to exactly how it was before, with exactly those constant names and comments.

@AnkitChahar
Copy link
Copy Markdown
Author

Well, I have a bad news. The U2F detection library actually blocks the FIDO device for detection and that results in the device not being available for regular use 🤦🏻‍♂️, so I think we will have to shift our logic to what yknotify does i.e. watching the macos log stream. Let me know if I should move ahead with that?

@max-baz
Copy link
Copy Markdown
Owner

max-baz commented Apr 16, 2026

Ooh really... So just to confirm, did it never work, or do you think some recent change broke it that we could revert? Have you seen the U2F events being detected by the code in this PR before? What about GPG and SSH events by the way?

@AnkitChahar
Copy link
Copy Markdown
Author

I hadn't tested the U2F flow previously because I generally didn't work with it. It was a genuine miss from my side. I tested it yesterday and it did not work 😞

The GPG and SSH flows are working as expected.

@max-baz
Copy link
Copy Markdown
Owner

max-baz commented Apr 17, 2026

I see, yeah I guess let's go with watching macos log stream in that case!

@yakimant
Copy link
Copy Markdown

Hi guys! Thank you both, looking forward for this PR.
I can help tesing if needed.

@max-baz
Copy link
Copy Markdown
Owner

max-baz commented Apr 17, 2026

Testing is most definitely appreciated, as I don't have a macOS device to test this myself, I will release based on community feedback! @AnkitChahar's original idea for FIDO2/U2F detection was potentially a much less fragile solution, I'm sad it didn't work out, but if you want to try to tinker with it as well, and see if you might find a tweak that can help making it work, it's also appreciated! Otherwise as you've seen above we will move to another approach based on monitoring log streams.

…ccess

Replace the IOKit/hidapi approach with watching kernel IOHIDFamily log
messages via log stream. The previous implementation held the FIDO HID
device open exclusively, blocking concurrent browser WebAuthn sessions.

The new approach tracks IOHIDLibUserClient handles associated with YubiKey
devices and emits U2F_ON/U2F_OFF on startQueue/stopQueue events — without
ever opening the device.

- u2f_common.go: add //go:build linux (constants and runU2FPacketWatcher
  are now Linux-only)
- go.mod: remove karalabe/hid dependency
- libnotify_darwin.go: revert temporary osascript to a no-op stub
@AnkitChahar
Copy link
Copy Markdown
Author

I have made the relevant changes. I have tested it on a macOS device and it seems to be working fine. I don't have a linux device so can't really test it on that. Would appreciate any help for linux.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants