Keymaster is a small binary written in Swift that allows scripts to access the Mac Keychain guarded by TouchID.
Macs come with the security command which can get and set secrets to the
Keychain:
# Save a key/value to the default "login" keychain, with key "MyKeyName",
# update if exists (-U), allow no app to access without a prompt (-T ""),
# and prompt for secret to store (-w)
security add-generic-password -a login -s "MyKeyName" -T "" -U -w
# Get the secret value from a key
security find-generic-password -s "MyKeyName" -wYou can use security in a script, but (AFAIK) you can't tell it to use
biometrics to guard secrets, you have to enter the password each time, or
"always allow" the security binary to access the secret.
Keymaster fixes this.
Compile keymaster.swift into a binary:
swiftc -O -o keymaster keymaster.swift -framework LocalAuthentication -framework SecurityPut the keymaster binary somewhere in your $PATH, or run it directly from
the project directory.
keymaster [options] get <key> # Retrieve a secret (printed to stdout)
echo <secret> | keymaster [options] set <key> # Store a secret (read from stdin)
keymaster [options] delete <key> # Delete a secret
Options:
-v Enable debug logging (stderr)
-s, --session <name> Override session scope (see Session Groups)
-g, --groups-file <path> Use a custom groups file path
On first use, macOS will show two Keychain Access prompts asking whether to
allow keymaster to access keychain items. Select Always Allow for both:
- HMAC session key (
keymaster_session_hmac_key) — used internally to validate sessions. This is read on every invocation, before TouchID, so it must be accessible without a prompt. - Your secret — the actual keychain item you're storing or retrieving.
Keymaster handles authentication itself via TouchID. The keychain is a passive store — "Always Allow" makes it transparent, leaving TouchID as the sole authentication gate. If you don't select "Always Allow", you'll get a keychain dialog on every invocation in addition to TouchID.
To change a secret, delete and re-set it, or edit it directly in
Keychain Access.app.
After a successful TouchID authentication, keymaster caches the session for
that specific key for 5 minutes (300 seconds) by default. Accessing a different
key within the TTL window still requires its own TouchID, unless the keys share
a session group (see below). Configure the TTL with the KEYMASTER_TTL
environment variable:
# Extend to 10 minutes
export KEYMASTER_TTL=600
# Require TouchID every time
export KEYMASTER_TTL=0The session file is stored in $TMPDIR (a per-user directory on macOS, e.g.,
/var/folders/xx/.../T/keymaster_session). It does not persist across reboots.
Expired entries are pruned automatically.
The session is HMAC-SHA256 signed using a key stored in the keychain. Key names are hashed before being written to the session file, so the file does not reveal which keychain entries have been accessed.
By default, each key maintains an independent session — authenticating for one key does not affect another. Session groups let multiple keys share a single authentication window, so a TouchID prompt for any key in the group satisfies all of them.
Define groups in ~/.config/keymaster/groups:
# Keys used for deployment
[deploy]
ssh_key_passphrase:id_deploy
ansible_vault
With this configuration, the first keymaster get for either key triggers
TouchID. Subsequent access to either key within the TTL window reuses the
session — one prompt covers both.
A key may belong to at most one group. If a key appears in multiple groups, keymaster exits with an error. Keys not listed in any group behave as before (per-key sessions).
The session scope can be set per-invocation, taking precedence over the groups file:
# Flag (highest priority)
keymaster --session deploy get my_other_key
# Environment variable (overrides groups file, overridden by flag)
export KEYMASTER_SESSION=deploy
keymaster get my_other_keyThis is useful for ad-hoc grouping or for temporarily including a key in an existing group's session without editing the config file.
The full resolution order for session scope is: -s/--session flag, then
KEYMASTER_SESSION environment variable, then the groups file, then the key
name itself (the default per-key behavior).
The groups file defaults to ~/.config/keymaster/groups. Override it with the
-g/--groups-file flag or the KEYMASTER_GROUPS_FILE environment variable:
# Flag (highest priority)
keymaster --groups-file /path/to/groups get my_key
# Environment variable (overridden by flag)
export KEYMASTER_GROUPS_FILE=/path/to/groupsKeymaster can act as an SSH_ASKPASS provider, supplying SSH key passphrases
via TouchID instead of typing them. Every SSH connection triggers a TouchID
prompt (or reuses the cached session within the TTL window).
This means you don't need ssh-add or a running ssh-agent — SSH itself
calls keymaster directly each time it needs a passphrase.
# 1. Import an existing SSH key's passphrase
bin/keymaster-ssh import ~/.ssh/id_ed25519
# 2. Configure your shell (add output to ~/.zshrc)
bin/keymaster-ssh setup >> ~/.zshrc
source ~/.zshrc
# 3. SSH now uses TouchID for passphrases
ssh user@hostGenerate a new SSH key with a random passphrase, automatically stored in keymaster.
keymaster-ssh generate [-t type] [-b bits] [-C comment] [-f file]
| Flag | Default | Description |
|---|---|---|
-t |
ed25519 |
Key type (ed25519, rsa, ecdsa) |
-b |
(none) | Key size in bits (only applies to rsa) |
-C |
$USER@$(hostname -s) |
Key comment |
-f |
~/.ssh/id_<type> |
Output file path |
The passphrase is a random 32-character string generated from /dev/urandom.
You never need to see or type it — keymaster handles retrieval via TouchID.
The command refuses to overwrite an existing key file. If keymaster fails to store the passphrase after key generation, the passphrase is printed to stderr so you can store it manually.
# Generate an ed25519 key with defaults
keymaster-ssh generate
# Generate an RSA-4096 key for a specific purpose
keymaster-ssh generate -t rsa -b 4096 -f ~/.ssh/id_work -C "work@example.com"Store an existing key's passphrase in keymaster.
keymaster-ssh import <key_path>
The command prompts you to enter the passphrase, then verifies it by attempting
to extract the public key with ssh-keygen -y. If the key has no passphrase,
it exits with a message and stores nothing.
keymaster-ssh import ~/.ssh/id_ed25519
keymaster-ssh import ~/.ssh/id_rsaPrint shell configuration lines to stdout. Pipe or copy these into your
~/.zshrc or ~/.bashrc.
# Preview what will be added
keymaster-ssh setup
# Apply directly
keymaster-ssh setup >> ~/.zshrc
source ~/.zshrcThis sets two environment variables:
SSH_ASKPASS— points to thekeymaster-askpassscriptSSH_ASKPASS_REQUIRE=force— tells SSH to always use the askpass program, even when running in a terminal (without this, SSH only uses askpass when there is no TTY)
If you're using a custom prefix, it also includes the KEYMASTER_SSH_PREFIX
export.
Show which SSH keys in ~/.ssh/ have passphrases stored in keymaster.
keymaster-ssh listOutput:
KEY STORED
id_ed25519 yes
id_rsa no
Scans ~/.ssh/id_* (excluding .pub files). The first invocation may trigger
a TouchID prompt; subsequent checks within the TTL window are cached.
Remove a stored passphrase from keymaster. Takes the key basename (not the full path).
keymaster-ssh remove id_ed25519This only removes the passphrase from keymaster's keychain store. It does not delete or modify the SSH key file itself.
By default, passphrases are stored under the keychain key
ssh_key_passphrase:<key_basename> (e.g., ssh_key_passphrase:id_ed25519).
You can change the prefix to namespace keys differently:
# Via environment variable (affects both keymaster-ssh and keymaster-askpass)
export KEYMASTER_SSH_PREFIX="work_ssh"
# Resulting keychain key: work_ssh:id_ed25519
# Via flag (keymaster-ssh only, overrides the env var)
keymaster-ssh --prefix work_ssh import ~/.ssh/id_ed25519If you use a custom prefix, make sure the same KEYMASTER_SSH_PREFIX is set in
your shell profile so that keymaster-askpass uses it when SSH invokes it. The
setup command includes this export automatically when a custom prefix is
active.
SSH supports an SSH_ASKPASS environment variable pointing to a program that
returns the passphrase on stdout. The bin/keymaster-askpass script:
- Receives the SSH prompt as
$1(e.g.,"Enter passphrase for /path/to/key: ") - Checks
SSH_ASKPASS_PROMPT— only handles passphrase prompts (ignores confirmation and notification prompts) - Parses the key path from the prompt and extracts its basename
- Calls
keymaster get <prefix>:<basename>which triggers TouchID - Returns the passphrase to SSH on stdout
Both keymaster-askpass and keymaster-ssh resolve the keymaster binary by
first checking relative to the script's location (the project root), then
falling back to $PATH.
- Passphrases are stored in the macOS Keychain, protected by TouchID via keymaster. They are never written to disk in plaintext.
- The session file is stored in
$TMPDIR(per-user, mode 700 on macOS) and HMAC-signed to prevent forgery. The HMAC key is stored in the keychain and generated automatically on first use. - Passphrases are never exposed in process arguments.
keymaster setreads from stdin, andkeymaster-sshpasses passphrases tossh-keygenviaSSH_ASKPASSrather than command-line flags. - The
KEYMASTER_TTLsetting controls how often TouchID is required. Set it to0for maximum security (TouchID on every SSH connection).
test-sessions.sh is an interactive test for session groups. It stores three
temporary secrets, then retrieves them under different session configurations
with debug output enabled. Because keymaster uses real TouchID, the script
cannot assert pass/fail automatically — instead it prints what to expect at
each step so you can verify the prompts you receive.
./test-sessions.shThe script creates three temporary keychain entries (cleaned up on exit) and a
temporary groups file that places two of the keys in a [deploy] group. It
then runs four retrieval steps:
| Step | What it does | Expected result |
|---|---|---|
| 1 | get key A (in [deploy] group) |
TouchID prompt |
| 2 | get key B (also in [deploy] group) |
No prompt (reuses session) |
| 3 | get key C (ungrouped) |
TouchID prompt |
| 4 | get key A with --session custom_session |
TouchID prompt (override) |