keytap is a CLI that turns one passkey into unique keys you can reproduce anywhere.
If your passkey already syncs across your devices, keytap lets you use that passkey as a stable root secret.
From that root, it can deterministically derive:
- an
ageidentity - an SSH keypair
- a 32-byte app secret
It can also use the derived age identity directly to encrypt and decrypt files.
The mental model is simple:
your passkey is the root secret, and
keytapdeterministically derives named child keys from it.
Same passkey + same name = same key. Different name = different key.
Passkey providers are good at syncing passkeys.
They are not designed to sync arbitrary private keys like your SSH key for GitHub, your age identity for encrypted files, or an app secret used by a script or service.
So people fall back to awkward alternatives: manually copying plaintext private keys between machines, storing long-lived secrets in more places than they want, or generating different keys per device and dealing with the sprawl.
At a high level, keytap does four things:
- You register a passkey for the relying party
keytap.jul.sh. - When you ask for a key name like
default,backup, ordeploy,keytapruns a WebAuthn authentication ceremony using the PRF extension. - The passkey returns deterministic PRF output for that name.
keytapturns that output into 32 bytes of key material and formats it as SSH,age, hex, base64, or raw bytes.
The name is just domain separation. It lets one passkey produce many independent keys.
Examples:
defaultfor your main identitygithubfor GitHub SSH authbackupfor encrypted backups
The important property is predictability, across installs:
- same passkey, same name → same derived key
- same passkey, different name → different derived key
- different passkey → completely different keys
On macOS, keytap uses the native passkey flow.
In the normal case, that means the CLI triggers a local WebAuthn ceremony and you approve it with Touch ID or your system passkey UI.
On platforms where the CLI cannot do the passkey ceremony natively, keytap falls back to a nearby-phone flow.
The flow is:
- the CLI prints a QR code
- you scan it with your phone
- your phone opens the
keytappage - you approve with a passkey on the phone
- the PRF result is sent back to the CLI over an end-to-end encrypted relay channel
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 a downloaded release was built from this repository:
gh attestation verify keytap-*.zip -R jul-sh/keytapCreate the passkey once
keytap initThis creates the passkey that keytap will use as the root. Then you can:
Use keytap public for public keys and keytap reveal for private keys. Both default to the name default unless a specific name is provided.
| Action | Command Example | Supported Formats |
|---|---|---|
| Public Key | keytap public [name] --format [type] |
ssh, age, hex, base64 |
| Private Key | keytap reveal [name] --format [type] |
ssh, age, hex, base64, raw |
Examples:
keytap public github --format sshkeytap reveal backup --format age
Encryption and decryption use the age identity derived from your passkey. You can specify a custom key name using the optional --key argument (defaults to default).
| Action | Command Example |
|---|---|
| Encrypt | keytap encrypt [file] [--key name] |
| Decrypt | keytap decrypt [file] [--key name] |
Examples:
keytap encrypt .env > .env.agekeytap decrypt .env.age > .env
The same passkey and key name reproduce the same identity, so the file can be decrypted on any machine where you can unlock that passkey.
You can encrypt files for yourself, specific recipients, or groups using the --to and -R flags. By default, your own identity is always included unless --no-self is specified.
| Scenario | Command Example |
|---|---|
| Add Recipients | keytap encrypt [file] --to [age-pubkey] > [output] |
| Recipients File | keytap encrypt [file] -R [recipients.txt] > [output] |
| Exclude Self | keytap encrypt [file] --to [age-pubkey] --no-self > [output] |
Names are cheap, so use them liberally. A good rule is: one name per purpose.
For example:
githubgitlabbackupterraformnotes
This is cleaner than reusing one key everywhere, and easier to reason about than a pile of manually managed key files.
keytap is a convenience utility, not a high-assurance security tool. It is designed to make passkey-derived keys easy to use across machines. If your threat model involves nation-state adversaries, targeted attacks, or secrets where compromise has severe consequences, use purpose-built tools instead:
- SSH keys: Generate directly with
ssh-keygenand manage per-device keys. Use FIDO2 resident keys on a hardware token for phishing-resistant SSH without syncing private material at all. - age encryption: Generate standalone identities with
age-keygen. See age and age-plugin-yubikey for hardware-bound identities.
keytap ties all derived keys to a single passkey registered under the keytap.jul.sh relying party. That means you trust your passkey provider, the WebAuthn PRF extension, and the keytap.jul.sh domain. This is a meaningful trust surface that the tools above avoid entirely.
With that said, here is how keytap works within those constraints:
- keytap does not sync or cache derived keys. 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.
keytap has built-in encrypt and decrypt, but you can also use derived keys with the regular age CLI:
echo "secret" | age -r "$(keytap public notes --format age)" > secret.age
age -d -i <(keytap reveal notes --format age) secret.ageIf you want fewer auth prompts, you can store a derived secret in Keychain yourself:
security add-generic-password -s keytap -a AGE_SECRET_KEY -w "$(keytap reveal myKey --format age)"This trades convenience for a larger persistence footprint.
{
inputs.keytap.url = "github:jul-sh/keytap";
outputs = { keytap, ... }: {
# add keytap.packages.${system}.default to your buildInputs
};
}keytap is for people who want their passkey to behave like a portable root of identity, from which they can deterministically regenerate the keys their tools actually need.
MIT