Skip to content

jul-sh/keytap

Repository files navigation

Passkeys that turn into real keys.

keytap icon

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 age identity
  • 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 keytap deterministically derives named child keys from it.

Same passkey + same name = same key. Different name = different key.

Why this exists

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.

How it works

At a high level, keytap does four things:

  1. You register a passkey for the relying party keytap.jul.sh.
  2. When you ask for a key name like default, backup, or deploy, keytap runs a WebAuthn authentication ceremony using the PRF extension.
  3. The passkey returns deterministic PRF output for that name.
  4. keytap turns 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:

  • default for your main identity
  • github for GitHub SSH auth
  • backup for 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

Platform model

macOS

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.

Linux and other non-native environments

On platforms where the CLI cannot do the passkey ceremony natively, keytap falls back to a nearby-phone flow.

The flow is:

  1. the CLI prints a QR code
  2. you scan it with your phone
  3. your phone opens the keytap page
  4. you approve with a passkey on the phone
  5. the PRF result is sent back to the CLI over an end-to-end encrypted relay channel

Install

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
     fi

Releases 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/keytap

Quick start

Create the passkey once

keytap init

This creates the passkey that keytap will use as the root. Then you can:

Derive & Format Keys

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 ssh
  • keytap reveal backup --format age

Encrypt & Decrypt

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.age
  • keytap 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.

Manage Recipients

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]

Choosing names

Names are cheap, so use them liberally. A good rule is: one name per purpose.

For example:

  • github
  • gitlab
  • backup
  • terraform
  • notes

This is cleaner than reusing one key everywhere, and easier to reason about than a pile of manually managed key files.

Security

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-keygen and 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.

Auth via phone over relay (fallback)

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.sh performs 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.

Tips

Use with the age CLI

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.age

Store a derived key in macOS Keychain

If 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.

Nix flake

{
  inputs.keytap.url = "github:jul-sh/keytap";

  outputs = { keytap, ... }: {
    # add keytap.packages.${system}.default to your buildInputs
  };
}

In one sentence

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.

License

MIT

About

CLI to derive a reproducible SSH key, age identity, or app secret anywhere you can unlock your passkey

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors