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 data (stdin to stdout; point the shell at 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.
No install needed to try it: keytap.jul.sh is a terminal running this same CLI, compiled to WebAssembly; every command below works there.
Derive keys and encrypt files from a passkey.
Usage: keytap <COMMAND> [ARGS]
Commands
init Create the passkey (only needed once)
public [NAME] [--as VAL] Output the public key
reveal [NAME] [--as VAL] Reveal private key material
encrypt [NAME] [--to VAL] [-R VAL] Encrypt stdin to stdout with the derived age identity
decrypt [NAME] Decrypt age input from stdin to stdout with the derived age identity
remember NAME Remember a derived key on this machine (no more prompts for it)
forget [NAME] [--all] Forget a remembered key
remembered List keys remembered on this machine (never prints key material)
Arguments & options
NAME Key name for domain separation [default: default]
--as VAL Output format (hex | base64 | age | ssh) [default: hex]
--to VAL Additional age recipient (can be repeated)
-R VAL File containing age recipients (one per line)
--all Forget every remembered key, including ones from previous passkeys
Skip repeated prompts for a key: `keytap remember NAME` (see `keytap remember --help`).
Holds that expire instead (ssh-agent, TTLs): see `keytap reveal --help`.
CI (headless, $CI set): keys come from `$KEYTAP_KEY_<NAME>` — see `keytap reveal --help`.
Run `keytap <COMMAND> --help` for the full details of any command.
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
By default nothing is stored; run the same command on another machine and you
get the same key there. If a prompt per use is too much friction,
keytap remember NAME keeps that derived key on that machine
(see Skipping repeated prompts).
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
Because each command costs a scan, the page also offers an opt-in
"remember this key" checkbox: tick it before approving and the machine that
printed the QR code stores the derived key exactly as if you had run
keytap remember there — no second ceremony
(see Skipping repeated prompts).
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/keytapNames 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:
- By default keytap does not sync or cache derived keys. It derives on demand, writes to stdout, and exits. There are no local config files, and no state is stored implicitly.
- The one explicit exception is remembering —
keytap remember NAME, or the opt-in "remember this key" checkbox on the nearby-phone page: it stores that derived key on this machine, with no TTL, untilkeytap forget,keytap forget --all, or passkey replacement. The key lands in a plain file (not encrypted at rest), silently upgraded to the OS keychain when the machine has one (then encrypted at rest by it). Either way, any process running as your user may be able to invoke keytap and use the key without a ceremony. - Remembered keys are tied to a fingerprint of the registered passkey credential.
keytap initis a root boundary: it wipes all remembered entries, and lookups are scoped to the current root, so keys remembered under a replaced passkey are never used. - 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.
By default every command derives on demand; each use costs one passkey prompt. When that is too much friction, pick a holder for the key.
keytap remember runs one ceremony, then stores the derived raw key on that
machine. Later keytap commands for that name stop prompting. There is no TTL;
the key stays until you forget it or replace the passkey.
keytap remember deploy # one ceremony; 'deploy' stops prompting on this machine
keytap reveal deploy # instant, no prompt
keytap remembered # list remembered names (never key material)
keytap forget deploy # back to prompting; or: keytap forget --allRemembered keys are bound to the passkey that produced them. keytap init
replaces the root and wipes every remembered entry; keys remembered under an
old passkey are never used. Remembering is per machine.
When a command authenticates via the nearby-phone flow, the phone page offers
the same opt-in as a checkbox: tick "remember this key" while approving any
derive command and the machine remembers it — one ceremony instead of the two
that a separate keytap remember would cost over QR.
Where the key lives: a plain file, ~/.local/state/keytap/remembered.json
(0600, honors $XDG_STATE_HOME). On machines with an OS keychain (macOS
Keychain; Secret Service on desktop Linux), remember upgrades to it
automatically; entries are then auditable under service keytap, account
remember:<root>:<name>, encrypted at rest by the keychain. The success
message says which of the two was used, and lookups check the keychain first.
Be clear-eyed about the plain file (what you get on headless Linux, servers, and containers, where Secret Service needs a desktop session): it is not encrypted at rest, so anyone who can read your files (root, backups, disk images) can use the key. Treat it like an unencrypted SSH private key.
The trade-off: any process running as your user may be able to invoke keytap and use a remembered key without a ceremony. If you want a hold that expires instead, use an agent:
Load the derived key into ssh-agent once; every ssh afterward is silent
until the TTL runs out:
eval "$(ssh-agent -s)"
keytap reveal ha --as ssh | ssh-add -t 900 - # one auth, 15-minute holdThere is deliberately no keytap ssh command; ssh-agent already does that
job, with better hardening.
Bind it to a shell variable for the process lifetime:
KEY=$(keytap reveal deploy --as hex)
use "$KEY"; use "$KEY"
unset KEYYou can write revealed keys into the keychain yourself
(security add-generic-password, secret-tool store); keytap never touches
entries it didn't create. Usually simpler: remember the key and have the other
tool run keytap reveal, which no longer prompts.
Whatever holds the key must be trusted accordingly.
A CI job can hold secrets, but nobody is there to approve a passkey ceremony.
So under $CI (which every major CI platform sets) keytap refuses to start
one: a passkey prompt in a headless job is a hung runner, not a question.
A missing key fails the job immediately, with the fix in the message.
(--prompt overrides, for the rare run where someone really is watching the
log and wants to scan the QR code out of it.)
Instead, hand the job the derived key through the environment, one variable per key name:
# once, on a machine with the passkey
keytap reveal ci --as age | gh secret set KEYTAP_KEY_CI# in the job: the same commands as on your machine, no branching
env:
KEYTAP_KEY_CI: ${{ secrets.KEYTAP_KEY_CI }}
steps:
- run: keytap decrypt ci < secrets/api-token.ageThe contract is deliberately narrow:
- The variable holds exactly the output of
keytap reveal <name> --as age(AGE-SECRET-KEY-1…). One format, and a checksummed one: a mangled secret fails loudly at startup instead of quietly becoming a different key. The same 32 bytes still come out in every format —keytap reveal ci --as sshworks from the variable. - The variable name is
KEYTAP_KEY_plus the key name uppercased, with everything outsideA–Z0–9flattened to_(my-app→KEYTAP_KEY_MY_APP). Keep CI-bound key names to lowercase letters, digits, and dashes and this is invisible. - A set variable always wins over remembered keys and ceremonies, and a set-but-broken variable (empty, wrong encoding) is always a hard error naming the variable — never a silent fall-through to a prompt no one can answer.
- If a variable leaks, that name is burned. The value is the derived key for that one name — never the passkey root — and derivation is deterministic, so there is no rotating it: retire the name.
encrypt/decrypt are pure filters: stdin in, stdout out, streamed at any
size. Files are the shell's job, so pipelines never need plaintext temp files:
printf '%s' "$SECRET" | keytap encrypt backup > secret.age # stdin → stdout
keytap decrypt backup < secret.age | load-into-env # → a consumer, no temp file(Older keytaps had a multi-file batch mode to amortize one ceremony across
many files; keytap remember made that redundant, so v6 removed it.)
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 --as age)" > secret.age
age -d -i <(keytap reveal notes --as age) secret.age{
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