sod keeps an SSH authentication key inside the Secure Enclave — the private key
is generated there and never leaves it — and serves it to stock OpenSSH over the
ssh-agent protocol. Touch ID gates every signature. The key is a plain
ecdsa-sha2-nistp256, accepted by any SSH server; no FIDO/sk-
support required on the other end.
sod has the same usage as the bog-standard OpenSSH tooling - just prefix with sd and the rest takes care of itself.
| Command | Like | Does |
|---|---|---|
sd ssh-keygen |
ssh-keygen |
create a Secure-Enclave P-256 key (an opaque handle + a standard .pub) |
sd ssh-agent |
ssh-agent |
run the agent on a unix socket; print SSH_AUTH_SOCK to use it |
sd ssh-add |
ssh-add |
load / unload / list keys in the agent — no PIN prompt |
sd ssh-copy-id |
ssh-copy-id |
authorize your key on a server — defaults -i ~/.ssh/id_sod.pub |
Plus sd install — the one-step login setup: it runs the agent at every login and
prints the single line to add to your shell startup file (sd uninstall reverses it).
And sd doctor — a read-only health check of your whole setup (Secure Enclave, the
default key, the login agent, the live socket, and your shell wiring) that tells you
exactly what to fix.
And sd setup-git-signing — configures the current repo to sign git commits and tags with
your sod key over SSH (Touch ID per signature). By default it auto-signs both (opt out with
--no-auto-sign-commits / --no-auto-sign-tags). It prints a plan and asks before changing
anything, and won't overwrite an existing GPG setup without --force.
- Minimal. CLI-only, idiomatic, minimal surface interoping Secure Enclave to OpenSSH utilities. Barebones, no-fluff.
- Presence on every signature. Touch ID with passcode fallback, durable across fingerprint re-enrollment.
- Non-exportable. The handle file is an opaque, device-bound blob with no usable secret. Only this Mac's Secure Enclave can use the key, and only through the agent.
- Stock OpenSSH. Speaks the ssh-agent protocol; no patched
ssh, no kernel extensions, no daemons running as root. - Zero conf. Runs as an independent ssh agent, does not meddle with your other SSH key flows.
- Lean. A single notarized or self-built binary, dependant purely on Apple code - zero third-party dependencies.
- A Mac with a Secure Enclave (Apple Silicon, or Intel with a T2 chip) and Touch ID, macOS 13+.
- OpenSSH (
ssh,ssh-add) — macOS ships it. - To build from source: a Swift 6 toolchain (Command Line Tools is enough — no Xcode app required).
Two ways to get the sd binary — pick whichever suits you. Either way, the one-time
setup afterward is the same: sd install (see Quickstart).
brew install botanica-consulting/tap/sodBuilds from source, so it needs a Swift toolchain — the Xcode Command Line Tools
(xcode-select --install), not the full Xcode app.
Download sod-<version>.pkg from Releases
and open it. It's a notarized, universal binary: no Homebrew, no compiler, nothing to
build. It copies sd to /usr/local/bin (plus its man page and shell completions) and
nothing else — it does not touch your shell startup files or any agent. You still run
sd install once afterward.
git clone https://github.com/botanica-consulting/sod && cd sod
make install # builds a universal binary, installs to /usr/local (sudo)
# or just: swift build -c release (binary at .build/release/sd)Homebrew and the .pkg install zsh / bash / fish completions automatically, and so does
make install. After a bare swift build, generate them yourself:
sd --generate-completion-script zsh > /usr/local/share/zsh/site-functions/_sd
sd --generate-completion-script bash > /usr/local/etc/bash_completion.d/sd
sd --generate-completion-script fish > /usr/local/share/fish/vendor_completions.d/sd.fishThese steps are the same no matter how you installed sd — Homebrew and the .pkg both
land you here, and sd install is the shared setup step.
sd install # offers to create ~/.ssh/id_sod, runs the login agent, prints your shell line
sd doctor # verify it's all wired up (key, agent, login item, shell)sd install does the whole setup: it offers to create ~/.ssh/id_sod if you don't have one,
then installs a per-user LaunchAgent that runs at every login on the fixed socket
~/.ssh/sod-agent.sock. The agent serves ~/.ssh/id_sod automatically — no sd ssh-add
needed; it shows up in sd ssh-add -L and you can drop it with sd ssh-add -d/-D. Finally,
sd install prints an echo … >> <rcfile> command plus exec $SHELL to point your shell
at the agent — it never edits your startup file for you; you run the printed echo. After that,
ssh uses sod with Touch ID on every connection. (sd uninstall reverses it.)
Authorize the key on your server first:
sd ssh-copy-id user@host # appends ~/.ssh/id_sod.pub to the server
ssh user@host # Touch ID on connectGenerate a key. The default ~/.ssh/id_sod never collides with your normal
id_ecdsa/id_ed25519. Or put it anywhere:
sd ssh-keygen # -> ~/.ssh/id_sod (+ id_sod.pub)
sd ssh-keygen -f ~/keys/work -C "me@work" # -> ~/keys/work (+ work.pub)
sd ssh-keygen -y -f ~/keys/work # reprint the .pub line from a handleRun the agent. sd install is the recommended path: it installs a per-user
LaunchAgent that keeps the agent on the fixed socket ~/.ssh/sod-agent.sock across
logins (serving ~/.ssh/id_sod automatically), then prints the commands to point your
shell at it. sd uninstall reverses it.
sd install # agent at login + the SSH_AUTH_SOCK line for your shell
sd uninstall # remove the LaunchAgentPrefer a throwaway agent in the current shell instead (no LaunchAgent)? Use the faithful
ssh-agent form:
eval "$(sd ssh-agent)" # sh/zsh/bash/csh/fish auto-detected (-s / -c to force)
sd ssh-agent -k # stop itPoint ssh at the agent. SSH_AUTH_SOCK names a single agent, so you choose
whether sod is your only agent or coexists with another (1Password, Secretive, …):
Use sod everywhere — add the line sd install printed to your shell startup file:
| shell | file | line |
|---|---|---|
| zsh | ~/.zshrc |
export SSH_AUTH_SOCK="$HOME/.ssh/sod-agent.sock" |
| bash | ~/.bash_profile |
export SSH_AUTH_SOCK="$HOME/.ssh/sod-agent.sock" |
| fish | ~/.config/fish/config.fish |
set -gx SSH_AUTH_SOCK "$HOME/.ssh/sod-agent.sock" |
Use sod for some hosts only — leave SSH_AUTH_SOCK alone and route per-host in
~/.ssh/config, so your other agent keeps serving everything else:
Host git.example.com
IdentityAgent ~/.ssh/sod-agent.sock
Host prod
HostName prod.example.com
IdentityAgent ~/.ssh/sod-agent.sock
IdentityFile ~/.ssh/id_sod.pub # pin this key…
IdentitiesOnly yes # …and offer only it
Load / list / unload keys (the agent already serves ~/.ssh/id_sod; these manage
extra keys or drop the default — no PIN prompt, unlike stock ssh-add -s):
sd ssh-add ~/keys/work # load an additional handle
sd ssh-add -l # list fingerprints (-L for full public keys)
sd ssh-add -d ~/keys/work # unload one (-D to unload all, incl. id_sod)Authorize and connect. Put the .pub on the server, then connect:
sd ssh-copy-id user@host # appends ~/.ssh/id_sod.pub to the server
ssh user@host # Touch ID on connectInterop with stock tooling. sd ssh-add is just a convenience client;
the agent also speaks to stock ssh-add, which loads Secure-Enclave handles via its
smartcard messages:
ssh-add -s ~/.ssh/id_sod # press Enter at the PKCS#11 PIN prompt — the SE ignores it
ssh-add -e ~/.ssh/id_sod # unload (ssh-add -l / -L to list)Constrained adds are refused, not faked. sod does not enforce ssh-add key
constraints — destination (-h), lifetime (-t), or confirm (-c) — so it rejects
an add that carries them rather than silently serving an unconstrained key under a
constraint it won't honor. (ssh-add -h is the agent-side defense against
agent-forwarding abuse; honoring it for real isn't implemented, so we fail loudly instead
of giving a false sense of it.) Touch ID still gates every signature regardless.
ssh-add -h relay.example.com -s ~/.ssh/id_sod # refused: "agent refused operation"
ssh-add -s ~/.ssh/id_sod # add without constraints — workssod refuses to be a forwarded agent by default, and that's deliberate. Agent forwarding exports your agent to a remote host: anything on that host (or anyone who has compromised it) can quietly ask your agent to sign — including your presence-gated Secure-Enclave key. The Touch ID prompt is some protection, but a mistimed tap on a request you didn't initiate still hands a remote a signature. It's the classic ssh-agent footgun, and a hardware-backed key is exactly the key you least want reachable from a box you don't control.
So when a connection is forwarded (ssh -A / ForwardAgent yes), sod presents an empty
agent and refuses to sign — a remote can neither use nor even enumerate your key. It tells
forwarding apart precisely, using the standard session-bind@openssh.com flag (OpenSSH ≥ 8.9):
ssh -A host— the forwarded agent is inert (logging in tohostitself still works).ssh -J jump host(ProxyJump) and direct connections — unaffected; they authenticate from your machine, not over a forwarded agent.
Prefer -J (ProxyJump) over -A. For "reach host B through bastion A", -J keeps signing
on your laptop and never exposes the agent to the bastion — it's both safer and works with sod
out of the box. Reach for forwarding only when you genuinely need the agent itself present on
the remote (e.g. git push from a remote shell).
If you do need it, there are two ways to allow it — neither is a one-flag sd command, on
purpose: a persistent allow silently re-opens this hole for every future ssh -A, so we keep
it a deliberate act.
1. Temporarily, for one session. The login agent has KeepAlive — a plain sd ssh-agent -k
just gets relaunched by launchd — so stop it with bootout, run an allowing agent on the same
socket, then restore it (or just open a new login session):
launchctl bootout gui/$(id -u)/consulting.botanica.sod.agent # stop the refusing login agent
eval "$(sd ssh-agent --allow-agent-forwarding)" # allowing agent on the same socket
ssh -A host # forwarding works for this shell
sd ssh-agent -k # stop the allowing agent (frees the socket)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/consulting.botanica.sod.agent.plist # restoreOr, without touching the login agent at all, run a throwaway allowing agent on its own socket and point just this shell at it:
eval "$(sd ssh-agent --allow-agent-forwarding -a /tmp/sod-fwd.sock)" # this shell only
ssh -A host
sd ssh-agent -k -a /tmp/sod-fwd.sock # stop it when done2. Persistently, by editing the LaunchAgent. Add --allow-agent-forwarding to the
ProgramArguments in ~/Library/LaunchAgents/consulting.botanica.sod.agent.plist:
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/sd</string>
<string>ssh-agent</string>
<string>-d</string>
<string>--allow-agent-forwarding</string> <!-- add this line -->
<string>-a</string>
<string>/Users/you/.ssh/sod-agent.sock</string>
</array>then reload it:
launchctl bootout gui/$(id -u)/consulting.botanica.sod.agent
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/consulting.botanica.sod.agent.plistNote: sd install and sd uninstall regenerate this plist, so running either resets the
agent to the refusing default (fail-closed) — re-apply the edit afterward if you still want it.
Git commit/tag signing. sd setup-git-signing (run inside a repo) points git at your sod
key for SSH signing — it sets gpg.format=ssh, user.signingkey=~/.ssh/id_sod.pub, and
gpg.ssh.allowedSignersFile, and appends your key to allowed_signers. By default it sets
commit.gpgsign and tag.gpgsign, so every commit and annotated tag is signed automatically
(each one a Touch ID) — opt out with --no-auto-sign-commits / --no-auto-sign-tags and sign
deliberately with git commit -S / git tag -s instead. Local verification (git tag -v,
git log --show-signature) reads allowed_signers. For GitHub's
green Verified badge the tag's user.email must be a verified GitHub email (that's the
tagger line GitHub checks) — so if you have none configured, setup-git-signing asks for your
GitHub username and sets a repo-local user.email of <user>@users.noreply.github.com, i.e.
--github-user alice → user.email=alice@users.noreply.github.com (a verified no-reply address;
an existing user.email is left untouched). Under -y it can't prompt, so pass --github-user
or --email (don't know your username? gh api user --jq .login). You also need the same key
registered as a Signing key on GitHub (gh ssh-key add ~/.ssh/id_sod.pub --type signing).
To confirm it's registered: gh ssh-key list, your SSH keys settings,
or the public API — curl -s https://api.github.com/users/<you>/ssh_signing_keys. The definitive
check is the Verified badge on your first signed tag.
sd ssh-keygen ──► Secure Enclave generates a P-256 key
└─► ~/.ssh/id_sod (opaque handle, no usable secret)
~/.ssh/id_sod.pub (ecdsa-sha2-nistp256 ...)
ssh ──unix socket──► sd ssh-agent ──► Secure Enclave signs ──► Touch ID
(ssh-agent proto) (holds handles) (private key never leaves the SE)
The agent reconstructs the public key and lists identities without prompting; only a signature triggers Touch ID. Deleting the handle file orphans the key — there is no keychain item to clean up, because the blob is the only reference to it.
- The private key is generated in and never leaves the Secure Enclave. The handle is
the CryptoKit
dataRepresentation— a device-bound, SEP-wrapped blob with no usable secret; only this Mac's SE can use it. .userPresence= Touch ID with passcode fallback, durable across re-enrollment.- Listing identities / reading the public key never prompts; only signing does.
- The Touch ID sheet names the request. The reason line is derived from what is being
signed and who asked:
sign a git commit or tag in <repo>(the repo found from the requesting process's working directory) orlog in to <host> (<address>) over SSH(the host from the requestingsshcommand line, and the address of the TCP connection thatsshactually holds — read from its one established socket while it authenticates; a ProxyJump'd destination has none of its own, so it shows the name alone). All of that context is informational — it comes from the requesting process, which anything running as you could fake — so treat it as a guard against a mistimed tap, not as a security control. Forwarded connections get the generic wording. - Agent forwarding (
ssh -A) is refused by default — a forwarded connection sees an empty agent and gets no signatures, so a remote host can't use the key.ProxyJump(-J) and direct connections are unaffected. Opt in withsd ssh-agent --allow-agent-forwarding. - No keychain item, no keychain access group, no entitlements — which is also why a
plain Developer-ID signature notarizes cleanly. See
SECURITY.mdto report a vulnerability.
All SE operations sit behind a KeyBackend seam. A build-time mock (a plain in-process
P-256 key — real signatures, no SE, no Touch ID) runs the whole flow without a finger:
SE_SSH_MOCK=1 swift run sod-tests # wire + keystore + agent unit suites
SE_SSH_MOCK=1 bash scripts/selftest.sh /tmp/k # full generate → agent → ssh-add → ssh, no tapsThe mock is compiled only when SE_SSH_MOCK is set, so it is physically absent
from any release build (which prints a loud warning if you somehow build one). See
CONTRIBUTING.md for the build, test, and lint commands.
Sources/SSHWire/— pure SSH wire format (uint32/string/mpint, ecdsa blobs, ssh-agent framing). No SE imports; fully unit-tested.Sources/SEKeyStore/—KeyBackend,SecureEnclaveBackend, gatedMockP256Backend, the handle-file format, and provider resolution.Sources/SodKit/— the keygen/agent/add command logic + argument parsing.Sources/sod/— the thin@mainentry point.Tests/SodTests/,scripts/,packaging/,man/,docs/— tests, build & packaging, the man page, and design notes (docs/PLAN.md,docs/M0-RESULT.md).
MIT © Botanica Software Labs. A Botanica Software Labs product.
sod is provided free of charge and as-is — no warranty, and no liability on our part. See the MIT License for the full terms.


