# [[How to use a YubiKey for GitHub with AI coding agents]] ![[How to use a YubiKey for GitHub with AI coding agents.svg]] This is how I set up my Mac so that AI coding agents (like [[Claude Code]]) can commit, push and merge on [[GitHub]] for me, while I still use my [[YubiKey]] for GitHub over [[SSH]] and don't leave any unencrypted keys on disk. It's written so I can redo it on a new computer. ## The problem Coding agents run commands in a shell that has no terminal (no TTY) attached. So whenever [[Git]] or SSH needs something from me, the agent can't ask: - Typing the passphrase for a key file only works in a real terminal. - Typing the YubiKey PIN only works in a real terminal. - Nothing tells me when the YubiKey is waiting to be touched, so the agent's push just times out. The fix is to have an **SSH agent** hold the unlocked keys, and a small **askpass script** that shows any remaining prompts as macOS dialogs. ## How it works in the end | Action | What I have to do | |---|---| | Log in to the Mac | Run `sshload` once in a terminal and type each key's passphrase | | Commit (signed) | Nothing | | Push or pull | Touch the YubiKey when the "Touch your YubiKey" dialog appears | | Merge a PR | Nothing locally: `gh pr merge --squash` or `--merge` | Two keys are involved: - **`~/.ssh/id_ecdsa_sk`**: the [[FIDO2]] key on the YubiKey, used to log in to GitHub. The file is only a pointer to the YubiKey, but it's still passphrase-protected so device-compliance tools don't flag it as an unencrypted key. - **`~/.ssh/id_ed25519`**: a normal key file with a passphrase, used to **sign commits**. Signing with the YubiKey too would mean a touch for every commit (and every commit in a rebase), which is miserable. ## Prerequisites - [[Homebrew]]: `brew install openssh libfido2 ykman gh`. Use Homebrew's OpenSSH for everything, because it has YubiKey (FIDO2) support built in. - A FIDO2 SSH key on the YubiKey, added to GitHub as an **Authentication key**: [[How to generate an SSH key on the YubiKey for GitHub]]. Give the `id_ecdsa_sk` file a passphrase. - Optionally a PIN on the YubiKey: [[Setting a PIN on YubiKey]]. - An ed25519 signing key with a passphrase (`ssh-keygen -t ed25519`), added to GitHub as a **Signing key** (GitHub → Settings → SSH and GPG keys → New SSH key → Key type: Signing Key). Adding it through the CLI needs the `admin:ssh_signing_key` scope: `gh auth refresh -s admin:ssh_signing_key`, then `gh ssh-key add ~/.ssh/id_ed25519.pub --type signing`. ## Step 1: The askpass script OpenSSH runs the program named in `SSH_ASKPASS` whenever it needs to ask me something and there's no terminal. It tells the program what kind of prompt it is through `SSH_ASKPASS_PROMPT`: - empty: a secret, like the YubiKey PIN. Show a hidden-input dialog and print only the answer. - `confirm`: a yes/no question. Show OK/Cancel, print nothing, and exit 1 on Cancel. - `none`: a notice, like "touch your key". Show something, and expect to be killed when it's done. Save this as `~/.local/bin/ssh-askpass-mac` and run `chmod 700 ~/.local/bin/ssh-askpass-mac`: ```zsh #!/bin/zsh # SSH_ASKPASS helper: shows OpenSSH prompts (FIDO PIN, confirmations, touch # notices) as macOS dialogs so agent-driven shells without a TTY can still ask. # The prompt is passed via argv, never interpolated into AppleScript source. prompt="${1:-SSH}" case "${SSH_ASKPASS_PROMPT:-}" in confirm) /usr/bin/osascript - "$prompt" >/dev/null <<'AS' || exit 1 on run argv display dialog (item 1 of argv) buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with title "SSH" end run AS exit 0 ;; none) # Touch notice. macOS drops `display notification` from background processes # like ssh-agent, so use a dialog. exec makes osascript the process ssh # kills once the key is touched, which closes the dialog; it gives up after 30s. exec /usr/bin/osascript - "Touch your YubiKey to continue. $prompt" >/dev/null 2>&1 <<'AS' on run argv display dialog (item 1 of argv) buttons {"OK"} default button "OK" with title "SSH" with icon caution giving up after 30 end run AS ;; *) /usr/bin/osascript - "$prompt" 2>/dev/null <<'AS' || exit 1 on run argv set r to display dialog (item 1 of argv) default answer "" with hidden answer with title "SSH" return text returned of r end run AS ;; esac ``` ## Step 2: Run the SSH agent at login Create `~/Library/LaunchAgents/com.YOURNAME.ssh-agent.plist`. launchd doesn't expand `~` or `$HOME`, so use full paths (replace `YOURUSER`): ```xml <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.YOURNAME.ssh-agent</string> <key>ProgramArguments</key> <array> <string>/bin/sh</string> <string>-c</string> <string>rm -f /Users/YOURUSER/.ssh/agent.sock; exec /opt/homebrew/bin/ssh-agent -D -a /Users/YOURUSER/.ssh/agent.sock</string> </array> <key>EnvironmentVariables</key> <dict> <key>SSH_ASKPASS</key> <string>/Users/YOURUSER/.local/bin/ssh-askpass-mac</string> <key>SSH_ASKPASS_REQUIRE</key> <string>force</string> </dict> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> </dict> </plist> ``` Notes: - The `rm -f` is there because `ssh-agent -a` refuses to start if an old socket file is still lying around. - `SSH_ASKPASS` and `SSH_ASKPASS_REQUIRE=force` have to be in the **agent's** environment, because the agent is what asks for the touch and the PIN. Setting them in `.zshrc` doesn't reach an agent that's already running. - `~/.ssh` should be `drwx------` (owner only), since anything that can reach the socket can use the loaded keys. Load it and check it's running: ```bash plutil -lint ~/Library/LaunchAgents/com.YOURNAME.ssh-agent.plist launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.YOURNAME.ssh-agent.plist launchctl print gui/$(id -u)/com.YOURNAME.ssh-agent | grep state SSH_AUTH_SOCK=~/.ssh/agent.sock /opt/homebrew/bin/ssh-add -l # "The agent has no identities." = working ``` ## Step 3: Point everything at the agent In `~/.zshrc`, make sure this is the **only** line that sets `SSH_AUTH_SOCK`. Delete any old ones, for example from yubikey-agent or gpg-agent: ```zsh export SSH_AUTH_SOCK="/Users/YOURUSER/.ssh/agent.sock" alias sshload='/opt/homebrew/bin/ssh-add ~/.ssh/id_ecdsa_sk ~/.ssh/id_ed25519' ``` In `~/.ssh/config`: ``` Host github.com HostName github.com User git IdentityFile ~/.ssh/id_ecdsa_sk IdentitiesOnly yes IdentityAgent /Users/YOURUSER/.ssh/agent.sock ``` Changing `SSH_AUTH_SOCK` globally also changes which agent every *other* SSH host uses. For hosts that should keep working the old way, add `IdentityAgent none` (no agent), or point them at the agent they need. Tell Git to use SSH signing with Homebrew's tools: ```bash git config --global core.sshCommand /opt/homebrew/bin/ssh git config --global gpg.format ssh git config --global user.signingkey ~/.ssh/id_ed25519.pub git config --global commit.gpgsign true git config --global gpg.ssh.program /opt/homebrew/bin/ssh-keygen ``` `core.sshCommand` doesn't affect signing, which is why `gpg.ssh.program` is set separately. ## Step 4: GitHub CLI login, with no plaintext tokens `gh` is what merges PRs (`gh pr merge`), so it needs a login. Git itself goes over SSH. ```bash gh auth login --web --git-protocol ssh --skip-ssh-key gh auth status # should say "(keyring)" and "Git operations protocol: ssh" ``` Make sure no plaintext GitHub tokens are lying around: - Nothing like `credential.password` in `~/.gitconfig`. - No `credential.helper store` (it saves tokens unencrypted in `~/.git-credentials`). - No `GH_TOKEN` or `GITHUB_TOKEN` in shell profiles. Any token found in those places should be revoked on GitHub: Settings → Developer settings → Personal access tokens, and Settings → Applications → Authorized OAuth Apps for app tokens. Tokens that are 40 hex characters with no prefix are old, pre-2021 format. ## Step 5: Every time I log in 1. Open a terminal and run `sshload`, then type both passphrases. 2. Start the coding agent **from a terminal opened after this setup**, so it inherits the new `SSH_AUTH_SOCK`. The keys stay loaded until I log out, restart, or the agent restarts. ## Step 6: Check it works ```bash /opt/homebrew/bin/ssh-add -l # should list ECDSA-SK and ED25519 /opt/homebrew/bin/ssh -T [email protected] # dialog appears → touch key → "Hi <username>! You've successfully authenticated..." ``` For signing, make a test commit on a throwaway branch and check the signature locally: ```bash git switch -c signing-test && git commit --allow-empty -m "Test signed commit" echo "$(git config user.email) $(cat ~/.ssh/id_ed25519.pub)" > /tmp/allowed_signers git -c gpg.ssh.allowedSignersFile=/tmp/allowed_signers log -1 --format='%G? %GS' # "G" = good git switch - && git branch -D signing-test ``` The commit email has to be a verified email on the GitHub account for GitHub to show "Verified". ## Getting the "touch your YubiKey" dialog to show This took the most fiddling. 1. **A sound isn't enough.** I tried playing a sound; it's easy to miss if the volume is low. 2. **Notifications don't work.** `display notification` from `osascript` shows up as coming from **Script Editor**, which had notifications switched off (System Settings → Notifications → Script Editor). But even after I allowed them, and with no Focus mode on, nothing appeared when the *agent* triggered it. It only worked when I ran the script from my own terminal. macOS silently drops notifications from background processes like a launchd agent. 3. **Dialogs do work.** `display dialog` from the same background process *does* show up. So the `none` (touch) branch shows a dialog instead. 4. **Make the dialog close itself when I touch the key.** When the touch happens, ssh-agent kills the askpass process. Because the script uses `exec osascript`, the process being killed *is* the dialog, so it disappears. Without `exec`, the agent would only kill the zsh wrapper and the dialog would stay on screen. `giving up after 30` closes it anyway if I never touch. 5. **Clicking OK doesn't cancel anything.** It just hides the dialog; the key keeps blinking until touched or timed out. How I worked out that the agent was calling the script but macOS was hiding the notification: I temporarily added this line near the top of the script: ```zsh print -r -- "$(date "+%F %T") pid=$ ppid=$PPID mode=${SSH_ASKPASS_PROMPT:-secret} prompt=$prompt" >> "$HOME/Library/Logs/ssh-askpass.log" ``` If the log shows `mode=none` with the ssh-agent's PID as `ppid`, the agent is doing its job and the problem is on the display side. (Never log the answer from the secret branch.) To test a dialog in the same context as the agent without touching any keys: ```bash launchctl submit -l askpass-test -- /usr/bin/env SSH_ASKPASS_PROMPT=none ~/.local/bin/ssh-askpass-mac "Background test" launchctl remove askpass-test ``` Other script details that matter: - The prompt is passed as an **argument** (`osascript - "$prompt"` with `item 1 of argv`), never pasted into the AppleScript text. Quotes or backslashes in a prompt could otherwise break the script or run code. - The `confirm` branch must print **nothing**. OpenSSH treats an empty, successful answer as "yes", so any leftover output from the dialog would be taken as an answer. ## Troubleshooting | Symptom | Cause | |---|---| | `Error connecting to agent: Connection refused` | `SSH_AUTH_SOCK` points at a dead socket, usually an old line in `.zshrc` | | Passphrase asked on every commit, or on every commit during a rebase | Signing key isn't loaded in the agent. Run `sshload` first | | `agent refused operation` / `Permission denied (publickey)` after ~30s | The YubiKey was never touched, usually because the dialog didn't show | | `ssh-add --apple-use-keychain` fails | That flag exists only in Apple's `/usr/bin/ssh-add`, not Homebrew's | | `--show-signature` says `gpg.ssh.allowedSignersFile needs to be configured` | Only local verification is missing. The commits are still signed | | Push rejected: `Commits must have verified signatures` | Some commits were made unsigned. Re-sign them with the agent loaded: `git rebase --exec 'git commit --amend --no-edit -n -S' main` | | Merged commits lost "Verified" | GitHub's **rebase** merge re-creates the commits without signatures. Use squash or merge | ## Things I decided against - **Resident key** (`ssh-keygen -O resident`, loaded with `ssh-add -K`): no key file on disk, but it needs a new GitHub key and only saves typing one passphrase. - **SSH connection sharing** (`ControlMaster`/`ControlPersist`): one touch could cover several pushes, but I couldn't confirm GitHub supports it, and it means pushes without a fresh touch. - **1Password's SSH agent**: approves keys with Touch ID, but it doesn't support YubiKey FIDO2 keys, so it would only replace the signing half. %% # Excalidraw Data ## Text Elements ## Drawing ```compressed-json N4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebR44gAYaOiCEfQQOKGZuAG0AXX4IXDg4AGUoqHFUUDBIdXTqiCJlaRS6hkIECgAhXGwAa2VSYQ5iAGE2fDZSbggAYgAzZZX2 yGwRQKyASSr9CpGBhEnp2Yl5gEYEK6u1iA3SLahd9L7B4dGJqZm5qHIOZhwXBPO4PJ4vfQAMUI+HwFRgwTmgg8oM2mWeewObCOAHUSOpuHxwOs0TtMX9sQh4YiJMiSKjHuiIQAlYStDjhHJoC78EmMsnpADyQOwahg3AuiUSvPupIx6UhnCgkNw+hh4rQAFYZWCmXtFVkyoQjNUeNLibL+fL9AAVLBQACCLS4EmCiygDPB5OBjsebAokhCxG4HCE sJ1cohAFExg6/QGg3NgSMqBGrRC4ymbfAmiMhGNPXqFeQMqyaahQ+GLcxsCNYQANbgAZgALHEeC3EgBOLsAdibFxbLc1A5lNbr+AAmtwAGxdxLaAAcTZnbc1vcXFx42otRjYBm4tQ69AIQmqF2JAF80170qz88QOcwueg8wWZcMSEaTYTzR1P8QFQIHA3A7v+pAkAAsmwxAIDGuCaMEwZoIsBBhB+EFfKch7EpAPRTMhr7KJouAABQ8BcvbULwlH URRVGoAumoAJRrJAzIIMoYbAnMpDEWRPBNtKvBCXRomMdoLEQFeN7oliRzClA7AAiGYb4DKiwlggHFjEwhAcMoOF1JAmQIUh3B/GevLrEQIFoJZCDWRAHCqtUDlOcIUBEByFmkFZuEQPowJHKQAByrm+f5xmBcFTDwYhCCEQ5MkWnYABWCDYNkZQuXA0GwfF5koWhjkWv0SmMDa+74EZHSwIgSJpFlylsbKzBQAY2YNWglbqQF0yDIl3Cofg6EBZ pBhlE1SmcMNJVOfgoSOs1lXVapsIpR0jjMGZxxKvakGZEIc2jaVHSaPpjgGQACoEixMJkYhzKZCXBjKhDMD0l36cohVDfZflnZAH2QSQcBsPpUA5SUcA/X9SWAzKmhsJgU3BM1nD5U0ehZLg+nSWA150IsMLhIel4gJeQA== ``` %%