keytap is a smol CLI that lets you recover the same SSH key, age identity, or app secret on any machine where you can unlock the same passkey.
Passkey providers sync passkeys. They usually do not sync arbitrary private keys such as SSH keys. keytap bridges that gap by deriving the key locally after passkey authentication, without manually copying private key files between machines.
URL=$(curl -fsSL https://api.github.com/repos/jul-sh/keytap/releases/latest \
| grep -o '"browser_download_url": *"[^"]*"' | cut -d '"' -f 4 \
| grep "$([ "$(uname -s)" = Darwin ] && echo arm64 || echo linux)") \
&& curl -fLO "$URL" && mkdir -p ~/.local/bin \
&& if [ "$(uname -s)" = Darwin ]; then
mkdir -p ~/.local/share/keytap && unzip -o keytap-*-arm64.zip -d ~/.local/share/keytap \
&& ln -sf ~/.local/share/keytap/Keytap.app/Contents/MacOS/keytap ~/.local/bin/keytap
else
unzip -o keytap-*-linux*.zip keytap -d ~/.local/bin
fiReleases are built in CI with build attestation. To verify the binary was built from this repo's source (requires GitHub CLI):
gh attestation verify keytap-*.zip -R jul-sh/keytapCreate the passkey once:
keytap --initThen derive key material:
keytap [name]Use a name to derive different keys from the same passkey:
keytap backup
keytap deploy
keytap # The default name is `default`.Derive key material in different formats:
keytap myBase64Key --format base64
keytap myRawKey --format raw
keytap smolSecrets --format age
keytap smolSshKey --format sshGet the public key for a derived key:
keytap smolSshKey --format ssh --publicEncrypt a file with your derived age identity:
keytap --encrypt secrets.env > secrets.env.ageDecrypt it:
keytap --decrypt secrets.env.age > secrets.envEncrypt to yourself and others:
keytap --encrypt secrets.env --to age1abc... > secrets.env.ageOr use a recipients file (one age public key per line):
keytap --encrypt secrets.env -R age-recipients.txt > secrets.env.ageEncrypt to others only, without including yourself:
keytap --encrypt secrets.env --to age1abc... --no-self > secrets.env.ageThe key name works the same way as with key derivation:
keytap backup --encrypt secrets.env > secrets.env.age
keytap backup --decrypt secrets.env.age > secrets.env- macOS 15.0 or later
- Apple Silicon (
arm64) - A passkey provider with PRF support (like Apple's built-in Password Manager)
- A phone with a passkey provider that supports the PRF extension
keytap --initcreates a passkey for the relying partykeytap.jul.sh. The passkey lives in your chosen passkey provider.keytap [name]performs a WebAuthn assertion using the PRF extension. The PRF input isSHA256("keytap:prf:<name>"), so each name requests a different PRF output directly from the passkey.- The PRF output is expanded with HKDF-SHA256 using a fixed keytap info string to produce 32 bytes of key material.
- The result is formatted as raw bytes, hex, base64, an
agesecret key, or an OpenSSH Ed25519 key. With--encrypt/--decrypt, the derived age identity is used to encrypt or decrypt files directly.
Same passkey, same name, same derived key. Different names derive different keys.
On macOS passkey support is native, authentication is as simple as touching Touch ID.
On systems without native passkey support (like Linux), keytap authenticates via your phone over an encrypted relay. It prints a QR code to stderr, you scan it on your phone, approve with your passkey, and the result is sent back over an end-to-end encrypted channel (X25519 + AES-256-GCM). The relay never sees plaintext key material.
keytap's security model is simple: the passkey is the root secret.
- keytap depends on your passkey provider, WebAuthn PRF, and local device authentication. It does not create a stronger trust boundary than the provider already gives you.
- keytap does not sync or cache derived keys itself. It derives on demand, writes to stdout, and exits. There are no local config files or cached state.
- If you save the output, pipe it into another tool, or import it into an agent, that destination now holds the key and must be trusted accordingly.
- The PRF inputs are public and derived from the key name. They provide stable derivation and domain separation, not secrecy.
- Replacing the registered passkey changes every key derived from it. Treat the passkey as the root of your derived identities.
When keytap authenticates via your phone, additional trust considerations apply:
- You trust the web page served to your phone. The website served by
keytap.jul.shperforms the WebAuthn ceremony, receives the PRF output, encrypts it, and posts back to the host, via the relay. You trust its functionality and integrity. The web page is served inspectable, but in practice you are unlikely to review it each time. - The Cloudflare relay (
keytap-relay.julsh.workers.dev) forwards opaque encrypted blobs. It never sees plaintext key material. The channel is end-to-end encrypted with X25519 ECDH + HKDF-SHA256 + AES-256-GCM. An attacker who controls the relay can deny service but cannot decrypt the payload. - On macOS hosts, none of this applies. The native passkey flow uses the hosts passkeys, or alternatively a native QR code with that opens a native direct device-to-device channel.
Add keytap to a Nix shell using the attested, signed release:
{
inputs.keytap.url = "github:jul-sh/keytap";
outputs = { keytap, ... }: {
# add keytap.packages.${system}.default to your buildInputs
};
}If you want to avoid re-authenticating every time, you can store a derived key in the macOS Keychain:
security add-generic-password -s keytap -a AGE_SECRET_KEY -w "$(keytap myKey --format age)"keytap has built-in encryption via --encrypt and --decrypt, but you can also use the age CLI directly with derived keys:
echo "secret" | age -r "$(keytap smolSecrets --format age --public)" > secret.age
age -d -i <(keytap smolSecrets --format age) secret.ageMIT