Skip to content
botanica-consultingPublic

About

SSH keys in Secure Enclave. A minimal, ssh-idiomatic and zero-conf Touch ID based authentication agent.

Topics

Resources

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Repository files navigation

Botanica Software Labs

sod logo — a patch of grass on a soil block

sod

SSH native keys, sealed in the Secure Enclave. Use Touch ID to login.

CI License: MIT macOS 13+ Swift 6 Touch ID

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 in action: sd install, wire up the socket, then ssh into GitHub — authenticated with Touch ID

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.

Why sod

  • 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.

Requirements

  • 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).

Install

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).

Homebrew

brew install botanica-consulting/tap/sod

Builds from source, so it needs a Swift toolchain — the Xcode Command Line Tools (xcode-select --install), not the full Xcode app.

Prebuilt installer — no toolchain

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.

From source

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)

Shell completions

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.fish

Quickstart

These 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 connect

Advanced usage

Generate 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 handle

Run 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 LaunchAgent

Prefer 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 it

Point 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 connect

Interop 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 — works

Agent forwarding (ssh -A)

sod 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 to host itself 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  # restore

Or, 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 done

2. 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.plist

Note: 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.

How it works

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.

Security model

  • 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) or log in to <host> (<address>) over SSH (the host from the requesting ssh command line, and the address of the TCP connection that ssh actually 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 with sd 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.md to report a vulnerability.

Development & testing (no Touch ID)

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 taps

The 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.

Project layout

  • Sources/SSHWire/ — pure SSH wire format (uint32/string/mpint, ecdsa blobs, ssh-agent framing). No SE imports; fully unit-tested.
  • Sources/SEKeyStore/ — KeyBackend, SecureEnclaveBackend, gated MockP256Backend, the handle-file format, and provider resolution.
  • Sources/SodKit/ — the keygen/agent/add command logic + argument parsing.
  • Sources/sod/ — the thin @main entry point.
  • Tests/SodTests/, scripts/, packaging/, man/, docs/ — tests, build & packaging, the man page, and design notes (docs/PLAN.md, docs/M0-RESULT.md).

License

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.

Botanica Software Labs

About

SSH keys in Secure Enclave. A minimal, ssh-idiomatic and zero-conf Touch ID based authentication agent.

Topics

Resources

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages