- Go 99.1%
- Just 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
ci/woodpecker/push/build Pipeline was successful
.woodpecker/release.yml |
||
| .just | ||
| .woodpecker | ||
| .gitignore | ||
| agent.go | ||
| agent_darwin.go | ||
| agent_linux.go | ||
| agent_test.go | ||
| args.go | ||
| args_test.go | ||
| CHANGELOG.md | ||
| client.go | ||
| config.go | ||
| config_test.go | ||
| daemon.go | ||
| daemon_test.go | ||
| fingerprint.go | ||
| fingerprint_test.go | ||
| go.mod | ||
| go.sum | ||
| identity_agent.go | ||
| justfile | ||
| keyring.go | ||
| keyring_cmd.go | ||
| keyring_test.go | ||
| locker.go | ||
| locker_test.go | ||
| main.go | ||
| peer.go | ||
| peer_darwin.go | ||
| peer_linux.go | ||
| preload.go | ||
| preload_test.go | ||
| prompt.go | ||
| README.md | ||
| sshkeys.go | ||
| sshkeys_test.go | ||
| xdg_test.go | ||
sage
A per-identity SSH agent. Each identity gets an isolated agent of its own, instead of one global agent holding every key — all served by one sage process, which also keeps your key passphrases in a keyring unlocked with one passphrase.
Install
Requires Go 1.25+ and just.
just install # builds and copies to ~/.local/bin ($XDG_BIN_HOME)
Or build manually:
go build -o sage .
Usage
# Serve an identity's agent and point the shell at it
eval "$(sage load github)"
# Add a key to the running agent (standard ssh-add)
ssh-add ~/.ssh/github_ed25519
# Make an identity's configured SSH keys usable
sage preload github # specific identity
sage preload # the currently-loaded identity (from $SSH_AUTH_SOCK)
# Check what's running (also the bare default: `sage` on its own)
sage status
sage
# Check a specific identity
sage status github
# Remove all keys from an agent (it keeps being served)
sage unload github
# Remove only keys that don't belong to the identity (wrong-scope leaks)
sage clean github # specific identity
sage clean # sweep every running agent
# Stop serving an agent, dropping its keys
sage stop github
# Stop the sage agent: every agent, and lock the keyring
sage stop-all
Commands
| Command | Description |
|---|---|
load <identity> |
Serve the identity's agent, print export statements |
preload [identity] |
Make the identity's configured SSH keys usable |
keyring init |
Create the keyring, choosing its passphrase |
keyring add [identity] |
Store the passphrases of an identity's keys in the keyring |
keyring rm [identity] |
Remove an identity's key passphrases from the keyring |
keyring unlock |
Unlock the keyring for --hold (preload does this when it needs to) |
keyring lock |
Lock the keyring now, dropping the keys decrypted from it |
keyring |
Show whether the keyring is unlocked and which keys it holds |
keyring locker add [file] |
Store locker's identity in the keyring (its file by default, or stdin) |
keyring locker rm |
Remove locker's identity from the keyring |
unload <identity> |
Remove all keys from an agent |
clean [identity] |
Remove keys whose fingerprint isn't in the identity's ssh_keys |
status [identity] |
Show the agents and the keyring (the bare sage default) |
stop <identity> |
Stop serving the identity's agent and remove its socket |
stop-all |
Stop the sage agent |
version |
Print version |
Flags
| Flag | Default | Description |
|---|---|---|
--lifetime |
8h |
Lifetime of a key loaded with its own passphrase, in ssh's time format (8h, 1h30m, 2d); for load, of keys added without one |
--dir |
~/.ssh/agents |
Directory for the agent sockets |
--config |
~/.config/sage/config.toml |
Config file read by preload and status |
--hold |
12h |
keyring unlock: how long the keyring stays unlocked |
Preloading keys
sage preload reads a config file that maps each identity to its SSH keys.
With no argument it targets the currently-loaded identity (derived
from $SSH_AUTH_SOCK); with an argument it targets that identity, serving its
agent first.
The config lives at ~/.config/sage/config.toml (honoring $XDG_CONFIG_HOME):
[github]
ssh_keys = ["~/.ssh/github_ed25519"]
[work]
ssh_keys = ["~/.ssh/work_ed25519", "~/.ssh/work_rsa"]
ssh_keys— paths (~expanded) to private keys loaded into the identity's agent.
sage manages SSH keys only. It used to preset GPG passphrases too; that is gone
since git can sign commits with an SSH key (gpg.format = ssh), and a config
that still has gpg_keys is refused with that advice.
preload is idempotent: a key the agent already holds, or can unlock from the
keyring, is left alone. So it prompts only for what is actually cold and is a
quiet, cheap no-op when everything is warm —
which is what lets a tool run it on every context entry (e.g. moor's per-domain
preload) without re-asking for keys it already holds.
A key the keyring does not hold is asked for and decrypted by preload and
handed to that identity's isolated agent, so every passphrase prompt is
sage's and reads the same way:
⚠ unlock ssh github:
⚠ unlock ssh work/id_rsa:
A key is named by its identity, and by its file as well when the identity has
several keys — key files are often all called id_ed25519, which would not
say whose passphrase is wanted.
A wrong passphrase is asked for again, up to three tries. Prompts are read
from the terminal with echo off, and anything typed before the prompt appeared
is thrown away rather than taken as the passphrase. A key in a format sage
cannot decode itself is handed to ssh-add, which asks with its own prompt.
Keyring
Instead of a prompt per key, sage can keep the passphrases of your SSH keys in a keyring, encrypted under one keyring passphrase. Set it up once:
sage keyring init # choose the keyring passphrase
sage keyring add github # store github's key passphrases, each checked against its key
sage keyring add work
From then on, when preload finds keys cold, it asks one thing:
⚠ unlock keyring:
The keyring then stays unlocked for 12 hours (sage keyring unlock --hold 1d
for another length, sage keyring lock to end it early). While it is, every
key it holds is in its identity's agent — listed straight away, and decrypted
the first time ssh or git asks it to sign — so nothing needs loading. preload
asks for the keyring only when the keyring is locked and holds one of the keys
that are cold; a key it does not hold is asked for on its own, as without a
keyring. Pressing Enter at the keyring prompt skips the keyring for that run.
The keyring lives in $XDG_DATA_HOME/sage/keyring: the passphrases are sealed
with AES-256-GCM under a key derived from the keyring passphrase with Argon2id
(about a second to unlock, on purpose). The file lists in the clear which keys
it holds, by fingerprint, so preload can tell without
the passphrase whether unlocking it would help; that list is bound to the
encrypted part, so an edited one fails to open. After changing a key's
passphrase, sage keyring rm and sage keyring add it again; until then
preload reports that the stored one no longer works and asks for the key.
Lifetimes. A key decrypted from the keyring lasts as long as the keyring
stays unlocked, and locking the keyring — or its hold running out — drops it
at once, along with the passphrases. Keys added by hand (ssh-add,
AddKeysToAgent, or preload asking for a key the keyring lacks) keep their
own lifetimes.
Where the keys live. The keyring's passphrases and the keys decrypted from
it stay inside the sage agent: no key from the keyring is handed to another
process. It keeps them in memory, locked out of swap where the system allows,
and marks itself non-dumpable so other processes of yours cannot attach to it
or read its memory. The keyring passphrase reaches it over a control socket beside the agent
sockets, ~/.ssh/agents/sage.ctl, after sage checks the process on the
other end is your own sage; nothing it holds is handed back. Keeping it there
means whatever can reach your agents can reach what controls them, and nothing
else: a sandbox that keeps ~/.ssh out, as claudia's does, keeps both out.
What this does not stop is a process running as you while the keyring is unlocked: it can ask your agents to sign, as it could ask any agent, and it could replace the sage binary or wait for you to type a passphrase. The keyring's protection is for the passphrases at rest and in memory; the time it stays unlocked is the window in which anything running as you can use the keys.
locker's identity
locker seals its token stashes to an age identity that otherwise sits unencrypted on disk. The keyring can hold it instead, so the one keyring passphrase guards it too:
sage keyring locker add # from ~/.config/locker/identity.txt
locker status # identity: sage's keyring (once the file is gone)
rm ~/.config/locker/identity.txt
With its identity file absent, locker asks for it, and it is handed only to
the installed locker binary ($XDG_BIN_HOME/locker, or ~/.local/bin/locker).
At a terminal locker asks through the sage program, which checks that locker
ran it and can ask for the keyring passphrase when the keyring is locked.
locker env, which runs in a shell hook and never asks, goes to the sage
agent directly, which checks that the caller is the installed locker: no sage
process per switch, and no dependence on which sage is first on PATH.
Your shell, or any other program — another build of sage included — asking
is refused, and while the keyring is locked locker env gets nothing.
As with keys, this guards the identity at rest and from other programs, not from a process running as you that sets out to get it: it can run locker itself.
Wrong-scope keys
An agent accepts any key handed to its socket, so a key can land in the
wrong identity's agent without sage's involvement — most commonly via OpenSSH's
AddKeysToAgent yes depositing a key into whatever agent $SSH_AUTH_SOCK
points at when you connect. To keep this from happening, pin each host to its
identity's socket in ~/.ssh/config:
Host github.com
IdentityFile ~/.ssh/github_ed25519
IdentityAgent ~/.ssh/agents/github.sock
IdentitiesOnly yes
sage status flags any loaded key whose fingerprint isn't in that identity's
ssh_keys, and sage clean removes them (leaving the configured keys in
place):
$ sage status work
identities (1)
● work ~/.ssh/agents/work.sock
work@laptop ssh-ed25519 SHA256:abc… keyring
personal@laptop ssh-ed25519 SHA256:xyz…
⚠ holds a key not in ssh_keys for "work" (wrong scope): SHA256:xyz… personal@laptop
$ sage clean work
removed key from "work" (wrong scope): SHA256:xyz… personal (ssh-ed25519)
An identity with no config section is left untouched, and if a configured key's fingerprint can't be resolved sage stays silent rather than remove on a guess.
How it works
One sage process — the sage agent — serves every identity's agent, each on
its own socket, ~/.ssh/agents/<identity>.sock, for SSH_AUTH_SOCK or an
IdentityAgent line. It speaks the standard agent protocol, so ssh, git,
ssh-add and agent forwarding work with it as with ssh-agent.
sage load starts the sage agent when it is not running — detached, so it
outlives the shell — and it runs until sage stop-all or the session ends.
On start it serves every identity in the config, so a host pinned to its
identity's socket finds an agent there before any shell has loaded that
identity. sage load is idempotent, which makes it safe in a shell profile:
# ~/.bashrc or ~/.zshrc
eval "$(sage load default)"
sage status (or just sage) shows what the identities share — the sage
agent's pid and control socket, and whether the keyring is unlocked — and
then each identity's agent with its keys:
● sage
agent: pid 3044212 · ~/.ssh/agents/sage.ctl
keyring: unlocked · 11h42m left · 3 key(s)
identities (2)
● amedia ~/.ssh/agents/amedia.sock
varl@nikki ssh-ed25519 SHA256:bZmd… keyring
● vlv ~/.ssh/agents/vlv.sock
varl@nikki ssh-ed25519 SHA256:0Jlw… keyring, decrypted on first use
sage is controlled over a socket of its own in the same directory,
~/.ssh/agents/sage.ctl, which sage keeps owner-only. Run sage agent in a
terminal to see why it fails to start, if it does.
Coming from sage 1.x, which ran one ssh-agent per identity: the first
sage load stops each of those agents and serves its socket in its place, so
shells already pointing at it keep working. Their keys go with them; preload
or the keyring brings them back. Keys of the sk- (hardware security key)
kind, PKCS#11 tokens, ssh-add -c confirmation and ssh-add -h destination
constraints are not supported by the sage agent.
Shell integration examples
Multiple identities:
# Load a specific identity before a git operation
eval "$(sage load work)"
git push
# Switch to another
eval "$(sage load personal)"
git push
SSH config integration:
# ~/.ssh/config
Host github.com
IdentityAgent ~/.ssh/agents/github.sock
Platform support
- Linux
- macOS (built, not tested on a Mac)