No description
Find a file
Yuguo Cao 884564b466 chore: remove transient review/report artifacts (MR-REVIEW.md, FIX-REPORT.md)
These are generated scratch outputs (multipass-review report, prior fix report),
not project source. Removed from tracking and gitignored so they don't return.
2026-06-10 15:10:53 +02:00
launchd fix: resolve multipass code-review findings 2026-05-30 13:52:15 +02:00
Resources feat: yubilock — YubiKey-removal anti-tamper daemon for macOS 2026-05-29 00:05:58 +02:00
scripts build: stable self-signed code-signing identity for persistent TCC grants 2026-05-29 19:24:41 +02:00
Sources fix: handle sleep/wake — no lock-screen capture, reconcile key presence on wake 2026-06-10 14:39:01 +02:00
Tests/YubilockCoreTests fix: handle sleep/wake — no lock-screen capture, reconcile key presence on wake 2026-06-10 14:39:01 +02:00
.gitignore chore: remove transient review/report artifacts (MR-REVIEW.md, FIX-REPORT.md) 2026-06-10 15:10:53 +02:00
Package.swift feat: yubilock — YubiKey-removal anti-tamper daemon for macOS 2026-05-29 00:05:58 +02:00
README.md fix: handle sleep/wake — no lock-screen capture, reconcile key presence on wake 2026-06-10 14:39:01 +02:00

yubilock

yubilock is a macOS anti-tamper daemon for a YubiKey-protected workstation. It watches for the configured YubiKey USB device to disappear, arms itself when the key is gone, and on the first subsequent input attempt it captures a webcam photo, optionally turns that photo into a prank desktop wallpaper with a caption, shows the image full-screen, and then locks the Mac. Most settings can be changed from the menu-bar Settings window and are persisted to settings.json.

Requirements

  • macOS 13+
  • Xcode Command Line Tools
  • A YubiKey

Build

swift build -c release

Packaging & Installation

YubiLock needs to be packaged as a proper .app bundle so AVFoundation can read NSCameraUsageDescription from an Info.plist, and so TCC permissions like Input Monitoring, Accessibility, and Camera attach to a stable code-signed identity instead of a bare SPM binary launched by launchd.

Package it:

chmod +x scripts/package.sh && ./scripts/package.sh

Install it with this flow:

  1. Build the bundle with ./scripts/package.sh
  2. Copy dist/YubiLock.app into /Applications/
  3. Launch once manually to trigger permission prompts: open /Applications/YubiLock.app or run /Applications/YubiLock.app/Contents/MacOS/yubilock in Terminal
  4. Grant Input Monitoring, Accessibility, and Camera in System Settings → Privacy & Security
  5. Copy launchd/com.user.yubilock.plist to ~/Library/LaunchAgents/
  6. Bootstrap the agent:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.yubilock.plist

To uninstall:

launchctl bootout gui/$(id -u)/com.user.yubilock
rm ~/Library/LaunchAgents/com.user.yubilock.plist
rm -rf /Applications/YubiLock.app

Notes:

  • On first launch, the camera prompt appears immediately. Input Monitoring and Accessibility must be toggled on manually in System Settings, then the app relaunched.
  • Ad-hoc signing is used here for personal use, so the signature hash changes on every rebuild and macOS may re-prompt for permissions after each repackage.

Ad-hoc signatures change on every rebuild, which makes macOS treat the app as a different binary and drop Input Monitoring / Accessibility grants. A stable self-signed identity fixes that: the designated requirement stays the same across rebuilds, so TCC permissions persist.

One-time setup:

chmod +x scripts/create-signing-cert.sh && ./scripts/create-signing-cert.sh

After that, ./scripts/package.sh detects YubiLock Self-Signed automatically and signs with it. If you switch from ad-hoc signing to the stable identity, reset TCC once and re-grant permissions:

tccutil reset Accessibility com.user.yubilock
tccutil reset ListenEvent com.user.yubilock
tccutil reset PostEvent com.user.yubilock

If the OpenSSL workflow fails on your macOS version, use the Keychain Access GUI fallback instead:

  • Keychain Access → Certificate Assistant → Create a Certificate
  • Name: YubiLock Self-Signed
  • Identity Type: Self Signed Root
  • Certificate Type: Code Signing

Then rerun ./scripts/package.sh.

This is a personal-use self-signed identity, not a Developer ID certificate, and it is not intended for distribution.

Install

See Packaging & Installation. The bundled .app is the supported install path.

Permissions

Grant these in System Settings → Privacy & Security for the yubilock binary:

  • Input Monitoring
  • Accessibility
  • Camera
  • Screen Recording, only if you enable the optional screenshot-at-trigger feature

If a permission is missing, the daemon prints an actionable error telling you where to enable it.

Cryptographic key authentication (opt-in)

YubiLock can optionally require YubiKey challenge-response before it trusts that a USB device is the real key.

Setup:

  1. Install ykman:
brew install ykman
  1. Enroll the YubiKey:
yubilock enroll

The enrollment flow warns that it overwrites slot 2, programs HMAC-SHA1 with touch disabled, and stores the 20-byte challenge secret in the macOS Keychain with afterFirstUnlockThisDeviceOnly accessibility.

The assumed ykman 5.x commands are:

ykman otp chalresp --force 2 <key-hex>
ykman otp calculate 2 <challenge-hex>

We accept the small TOCTOU window between the enroll-time ykman safety check and exec in this personal-use tool.

This has been tested against ykman 5.9.1. HMAC-SHA1 and no-touch are the defaults for ykman otp chalresp on 5.x, and YubiLock supplies the generated secret as a one-time command-line argument during the owner-run, interactive yubilock enroll command. That means the secret can be briefly visible to another same-user process via ps, but the threat model already concedes that same-user code execution can read the stored Keychain secret directly, so this is an accepted tradeoff rather than a protected channel.

If your installed ykman uses different flags or slot handling, adjust the single runner seam in the source and re-enroll.

  1. Enable Require key challenge-response in the Settings window or pass --require-key-challenge on launch. This setting is live and does not require a relaunch.

When enabled, a USB device that cannot answer the challenge will not disarm the daemon. If an unverifiable key is plugged in while the daemon is armed, YubiLock treats it as spoofed and fires the prank.

How It Works

The camera is only powered on for the warmup plus capture window, roughly 1-2 seconds total by default. The green LED will briefly light up when the photo is taken, then immediately turn off. YubiLock arms when the YubiKey is removed and triggers the capture on the first input of any kind: a key press, a click, or simply moving the cursor/trackpad. YubiLock lets the first interaction through (so it feels responsive), then blocks all further keyboard and mouse input from the moment the camera starts until the screen locks - the desktop stays visible but the machine is no longer actually usable, while the photo is captured and the prank is applied. After wake or screen unlock, it re-reads YubiKey presence from IORegistry so any USB events missed during sleep are repaired before the next arm/disarm decision.

Telegram alerts

Telegram alerts are optional and best-effort. If you enable them, YubiLock sends the captured evidence photos only after the screen has already locked, and it never waits for that network request on the critical path.

To set it up:

  1. Create a bot with @BotFather and copy the bot token it gives you.
  2. Find your numeric Telegram chat ID. Direct chat IDs are positive numbers; group and supergroup IDs are usually negative numbers.
  3. Enter the bot token in the Preferences window's Telegram section. YubiLock stores it in the macOS Keychain, or you can provide it at launch with YUBILOCK_TELEGRAM_BOT_TOKEN; the environment variable overrides the Keychain.
  4. Enter the chat ID and enable Telegram alerts.

Delivery is fire-and-forget after the screen lock. There is no retry, so a capture can be lost if the Mac is offline, the request fails, or the machine sleeps immediately after locking. When burst capture or screenshot evidence is enabled, YubiLock sends every evidence photo in order after the lock completes.

Keychain persistence depends on the stable self-signed signing identity described above. If you use ad-hoc signing, macOS may treat each rebuild as a different app and the Keychain item may stop being visible.

Menu Bar

YubiLock shows a menu-bar status icon while it is running unless you pass --no-menubar.

  • key.fill: idle, the YubiKey is present and the machine is safe
  • eye.fill: armed, the key is removed and YubiLock is watching for input
  • camera.fill: triggered, the webcam capture is in progress
  • lock.fill: fired, the prank has run and the daemon is waiting for the YubiKey to be reinserted
  • exclamationmark.triangle.fill: permissions required — Accessibility and/or Input Monitoring are not granted, so YubiLock cannot arm
  • lock.slash.fill: lock failed — the prank ran but every screen-lock method failed, so the Mac is NOT locked (check logs)

The menu also includes:

  • Open Photos Folder, which opens the configured photos directory in Finder
  • Quit YubiLock, which shuts down the daemon, restores the wallpaper (except under --dry-run, where restore-on-exit is suppressed — reinsert the YubiKey to restore), and removes the event tap cleanly

Configuration Flags

Command-line flags override the persisted settings for that launch only.

  • --vendor-id <hex>: YubiKey vendor ID, default 0x1050
  • --product-id <hex>: optional product ID filter
  • --display-seconds <int>: full-screen display duration, default 3
  • --warmup-seconds <float>: camera warmup before capture, default 0.3
  • --burst-count <int>: webcam frames captured per trigger, default 1
  • --screenshot: capture a screenshot of the screen at trigger
  • --photos-dir <path>: photo and wallpaper output directory, default ~/Library/Application Support/yubilock/photos
  • --caption "<text>": caption text, default NO CROISSANT FOR YOU! 🥐🚫😤
  • --no-wallpaper: disable wallpaper replacement
  • --no-menubar: run headless without a visible menu-bar item
  • --dry-run: skip the actual screen-lock call and wallpaper restore-on-exit, useful during development
  • --no-prevent-sleep: allow the Mac to idle-sleep while armed (by default YubiLock keeps the Mac awake while armed so the trap stays live)
  • --telegram-chat-id <id>: set the Telegram chat ID for this launch
  • --no-telegram: disable Telegram alerts for this launch
  • --help: print usage

Settings & Preferences

Open the preferences window from the menu-bar icon with Settings… or ⌘,. The menu bar must be enabled for this entry to be available.

Settings are stored in ~/Library/Application Support/yubilock/settings.json. The directory is created with 0700 permissions and the file is written with 0600 permissions. Settings persist across relaunches and launchd restarts. The file is written atomically and sanitized on load; a corrupt file falls back to defaults and is logged.

Option Default Live Relaunch
Warmup seconds 0.3 Yes No
Display seconds 3 Yes No
Burst count 1 Yes No
Screenshot Off Yes No
Caption NO CROISSANT FOR YOU! 🥐🚫😤 Yes No
Replace wallpaper On Yes No
Vendor ID (hex) 0x1050 No Yes
Product ID (hex, blank = any) Blank No Yes
Photos directory ~/Library/Application Support/yubilock/photos No Yes
Dry run Off Yes No
Keep Mac awake while armed On Yes No
Show menu bar icon On No Yes
Telegram enabled Off Yes No
Telegram chat ID Blank Yes No

Configuration is resolved as defaults < settings.json < command-line flags. Under launchd with no CLI flags, settings.json is authoritative. CLI flags override stored settings for that launch and are not persisted.

The Settings window validates input inline and disables Save until everything is valid. Vendor and product IDs must be hexadecimal in 0x0000-0xFFFF, caption length must be 1-500 characters, display seconds must be 0-60, burst count must be 1-5, warmup seconds must be 0-10, and the photos directory must resolve inside your home folder. If saving fails, the window shows an error instead of confirming success.

Saving applies live fields immediately. Vendor ID, product ID, photos directory, and the menu bar setting require relaunch; after Save the window shows a Relaunch required to apply: ... note when those values change.

Turning off Show menu bar icon removes the only UI path to open Settings or Quit. To undo it, relaunch with the menu bar enabled or edit/delete settings.json.

Test Mode

swift run yubilock --dry-run --display-seconds 2

This still swaps the wallpaper unless --no-wallpaper is also set. --dry-run suppresses the actual screen-lock call and the wallpaper-restore-on-exit path. The event tap still comes down and the prank wallpaper still applies.

After triggering, the event tap is always uninstalled so you regain control of the machine; the only way to restore the original wallpaper is to reinsert the YubiKey, which transitions the daemon back to idle.

Lower --warmup-seconds values produce darker photos in dim environments. Raising it to 1.0 or 1.5 can help in very dark rooms.

Caveats

  • SACLockScreenImmediate is private API. This is a personal-use tool, not App Store distributable.
  • "Keep Mac awake while armed" holds an IOKit display-sleep power assertion while armed and the screen is unlocked, keeping the display and system awake so macOS doesn't sleep the display and auto-lock the screen before the prank fires. Once the OS lock screen is up, the event tap no longer sees input and the trap is bypassed, and YubiLock will not fire the prank while the screen is already locked. The assertion is released the moment the screen locks — whether the prank fired or you locked it yourself — so a locked Mac sleeps normally. It CANNOT prevent closing the lid (clamshell sleep is hardware-enforced), explicit sleep (Apple menu, power button, pmset sleepnow), or low-battery emergency sleep — on wake from any of those, macOS's own lock screen takes over.
  • CGEventTap is not a real security boundary. Admin users and other taps can bypass it, and the tap here blocks keyboard and mouse input from the first interaction until the screen locks (only the single triggering event passes through), so an intruder cannot meaningfully use the machine during the capture window.
  • The wallpaper change affects the user-session lock-screen background. The login-window background is a separate system setting that requires root to change.
  • On macOS 14+ (Sonoma and later), YubiLock snapshots the system wallpaper store before pranking and restores it verbatim on reinsert/quit, so dynamic and moving/aerial wallpapers come back exactly (it reloads WallpaperAgent to apply the restore). On older macOS without that store, restore falls back to the desktop-image URL API: if the pre-prank desktop was a solid color with no image file, or the recorded original was moved or deleted, restore uses a plain black wallpaper instead, because macOS cannot reconstruct a solid-color desktop through that API.
  • Enabling Telegram introduces network egress and sends a photo of whoever triggered the device. The bot token is a secret stored in the Keychain, and delivery is best-effort with no retry.
  • HMAC-SHA1 challenge-response does not protect against an attacker who already has user-level code execution. They can read the Keychain secret and answer the challenge in software; that same threat model is why the one-time enroll-time argv visibility is an accepted tradeoff here. An asymmetric mode would be needed for a stronger model.
  • Slot 2 is programmed with touch disabled, so any process that can reach the YubiKey while it is plugged in can exercise the challenge-response. Enrollment overwrites any existing slot-2 credential.
  • A spoof device plugged in while the real key is still present can suppress arming because attachment tracking still uses first-device semantics. That behavior is unrelated to challenge-response.
  • SIGKILL leaves a brief restart gap while launchd restarts the daemon under KeepAlive.
  • If ykman breaks or the key is lost, edit settings.json and set requireKeyChallengeResponse to false, then relaunch from your unlocked session. The file is 0600, so only your logged-in user can recover it.

Troubleshooting

If the event tap does not fire, verify that both Input Monitoring and Accessibility are enabled for the binary in System Settings → Privacy & Security.

  • Logs: structured logs are available via log show --predicate 'subsystem == "com.user.yubilock"' --last 1h. To also capture stdout/stderr to files, add StandardOutPath / StandardErrorPath keys to the plist pointing at absolute paths under your own ~/Library/Logs/ (launchd does not expand ~).