- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| cmd | ||
| docs | ||
| internal | ||
| .gitignore | ||
| .goreleaser.yaml | ||
| .markdownlint.yaml | ||
| .pre-commit-config.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| mise.toml | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
keyhole
An SSH-based secret storage server. Store and retrieve secrets using your existing SSH key — no new credentials to manage, no browser, no agent to install.
ssh -A alice@keys.example.com get account/github
How it works
Keyhole is an SSH server that encrypts secrets at rest using a key derived from your SSH private key. The private key never leaves your machine — instead, your local SSH agent signs a deterministic challenge, and the signature is fed through HKDF to produce an AES-256-GCM encryption key.
Decrypting any secret requires two independent factors:
| Factor | Held by |
|---|---|
| Your SSH private key | Your machine (via agent) |
| The server secret | The keyhole server |
Neither is sufficient on its own. A leaked backup or stolen disk exposes only ciphertext; a stolen SSH key is useless without the server secret. An actively compromised server is outside this protection — it handles plaintext during operations — so run keyhole on a host you trust (see Security notes).
Encryption scheme
Personal secrets
challenge = SHA-256(server_secret + ":" + "keyhole-v1:" + username + ":" + path)
signature = agent.Sign(your_ed25519_key, challenge)
key = HKDF-SHA256(signature, info="keyhole-key-v1")
on-disk = AES-256-GCM(key, nonce=random_12_bytes, plaintext)
Vault secrets
Vaults use a random 512-byte vault key shared among members:
secret_key = HKDF-SHA256(vault_key, info="keyhole-vault-v1:<path>")
on-disk = AES-256-GCM(secret_key, nonce=random_12_bytes, plaintext)
Each member's copy of the vault key is wrapped with a key derived from their SSH agent signature.
Ed25519 is required — its signatures are deterministic, which means the same challenge always produces the same key. RSA signatures are probabilistic and are rejected.
Installation
From source
go install go.olrik.dev/keyhole@latest
Or build manually:
git clone https://github.com/davidolrik/keyhole
cd keyhole
go build -o keyhole .
Docker
docker pull ghcr.io/davidolrik/keyhole:latest
Server setup
Binary
keyhole serve --listen :2222 --data ~/.keyhole --admin alice
Docker Compose
services:
keyhole:
image: ghcr.io/davidolrik/keyhole:latest
ports:
- "2222:2222"
volumes:
- ./data:/data
environment:
- KEYHOLE_LISTEN=:2222
- KEYHOLE_DATA_DIR=/data
- KEYHOLE_ADMINS=alice
First run
On first start keyhole generates and persists:
host_key— Ed25519 SSH host key (fingerprint logged on startup)server_secret— 64-character alphanumeric string (minimum 64 characters required; the server will refuse to start with a shorter secret)
Back up server_secret. Losing it makes all stored secrets permanently unrecoverable.
Configuration
Configuration is resolved in precedence order: defaults < config file < environment < CLI flags.
CLI flags
| Flag | Short | Default | Description |
|---|---|---|---|
--listen |
-L |
:2222 |
Address to listen on |
--data |
-D |
~/.keyhole |
Data directory |
--admin |
Comma-separated list of admin usernames | ||
--config |
-C |
Path to HCL config file |
Environment variables
| Variable | Description |
|---|---|
KEYHOLE_LISTEN |
Address to listen on |
KEYHOLE_DATA_DIR |
Data directory |
KEYHOLE_ADMINS |
Comma-separated list of admin usernames |
Deprecated:
KEYHOLE_SERVER_SECRETis deprecated and will be removed in a future release. Environment variables are visible through/proc,ps, and are inherited by child processes. Useserver_secretin your HCL config file or let keyhole auto-generate it.
Config file (HCL)
By default, keyhole looks for keyhole.hcl inside the data directory. Override with --config.
listen = ":2222"
data_dir = "/var/lib/keyhole"
admins = ["alice", "bob"]
server_secret = "your-alphanumeric-secret" # minimum 64 characters
Bootstrap the first admin
After starting the server, add your public key manually (one-time setup):
mkdir -p ~/.keyhole/alice/.ssh
cp ~/.ssh/id_ed25519.pub ~/.keyhole/alice/.ssh/authorized_keys
From then on, new users self-register with an invite code (see below).
Usage
Commands
ssh [-A] <user>@<host> [-p <port>] <command> [args]
Personal secrets
| Command | Description |
|---|---|
get <path> |
Decrypt and print a secret |
set <path> |
Encrypt and store a secret |
del <path> |
Delete a secret (alias: delete) |
list [prefix] |
List secrets (alias: ls) |
move <src> <dst> |
Move a secret between paths or vaults |
Vault secrets
Use colon syntax to target a vault: vault:path.
| Command | Description |
|---|---|
get <vault>:<path> |
Decrypt a vault secret |
set <vault>:<path> |
Encrypt a vault secret |
del <vault>:<path> |
Delete a vault secret |
list <vault>:[prefix] |
List vault secrets |
Vault management
| Command | Description |
|---|---|
vault create <name> |
Create a vault (you become owner) |
vault invite <name> <user> |
Invite a user to a vault (admin/owner) |
vault accept <name> <token> |
Accept a vault invitation |
vault promote <name> <user> |
Promote a member to admin (admin/owner) |
vault demote <name> <user> |
Demote an admin to member (admin/owner) |
vault revoke <name> <user> |
Remove a user from a vault (admin/owner) |
vault rotate <name> |
Rotate the vault key, re-invite members (admin/owner) |
vault members <name> |
List vault members and roles |
vault destroy <name> |
Permanently destroy a vault (owner only) |
Administration
| Command | Description |
|---|---|
invite |
Generate a single-use invite code (admin only) |
register <code> |
Register your SSH key |
help |
Show usage |
get, set, del, move, and the vault key operations (vault create, vault invite, vault accept, vault rotate) require SSH agent forwarding (-A).
Storing and retrieving secrets
# Store (interactive prompt, echo hidden)
ssh -A -t alice@keys.example.com set account/github
# Store from a pipe
echo "hunter2" | ssh -A alice@keys.example.com set account/github
# Retrieve
ssh -A alice@keys.example.com get account/github
# Use in a script
export GITHUB_TOKEN=$(ssh -A alice@keys.example.com get tokens/github)
Deleting secrets
# Delete a personal secret (prompts for confirmation)
ssh -A alice@keys.example.com del account/github
# Delete a vault secret
ssh -A alice@keys.example.com del team:deploy/old-key
delete is an alias for del.
Moving secrets
# Move a personal secret to a new path
ssh -A alice@keys.example.com move account/github account/github-old
# Move a personal secret into a vault (prompts for confirmation showing vault members)
ssh -A alice@keys.example.com move tokens/deploy team:deploy/token
# Move a vault secret to personal storage
ssh -A alice@keys.example.com move team:deploy/token tokens/deploy
The source secret is deleted after a successful move. If the move target is a vault, you will be shown the vault members and asked to confirm before proceeding.
Vault workflow
# Create a shared vault
ssh -A alice@keys.example.com vault create team
# Invite a colleague
ssh -A alice@keys.example.com vault invite team bob
# → 3f9ab2c1d4e5... (64-character invite token)
# Bob accepts the invitation
ssh -A bob@keys.example.com vault accept team 3f9ab2c1d4e5...
# Store a secret in the vault
ssh -A alice@keys.example.com set team:deploy/api-key
# Bob retrieves it
ssh -A bob@keys.example.com get team:deploy/api-key
# After revoking someone, rotate the vault key to lock them out cryptographically
ssh -A alice@keys.example.com vault revoke team bob
ssh -A alice@keys.example.com vault rotate team
# → prints a fresh invite token per remaining member; deliver them out-of-band
Listing
# List all personal secrets
ssh alice@keys.example.com list
# List under a prefix
ssh alice@keys.example.com list account
# Glob match (trailing * filters by prefix)
ssh alice@keys.example.com ls account/git*
ssh alice@keys.example.com ls account/*
# List vault secrets
ssh alice@keys.example.com list team:
List output shows directory components in blue when the terminal supports color. Color is on by default and can be disabled by forwarding NO_COLOR=1 or TERM=dumb.
Invites and registration
User accounts are created via single-use invite codes. An admin generates a code:
ssh -A alice@keys.example.com invite
# → kh_a3f9b2c1d4e567890abcdef...
The new user registers using the code. They will be shown their key fingerprint and asked to confirm before the account is created:
ssh -i ~/.ssh/id_ed25519 bob@keys.example.com register kh_a3f9b2c1d4e567890abcdef...
# Registering key: ssh-ed25519 AAAA...
# Fingerprint: SHA256:xxxx
# Accept? [y/N]: y
# Registration successful. You can now connect as bob.
Invite codes are single-use and expire after 72 hours. They are consumed atomically on successful registration. If registration is declined, the code remains valid for another attempt.
Colors
Keyhole colors list output by default — directory prefixes appear in blue. Colors are detected automatically:
| Condition | Result |
|---|---|
| Default | Colors on |
NO_COLOR=1 forwarded |
Colors off |
TERM=dumb forwarded |
Colors off |
ssh -t (PTY allocated) |
Colors on |
To disable colors without -t:
ssh -o SetEnv=NO_COLOR=1 alice@keys.example.com list
Or add to ~/.ssh/config:
Host keys.example.com
SetEnv NO_COLOR=1
Data layout
{data_dir}/
├── host_key # Ed25519 host key (PEM)
├── server_secret # Server-side encryption factor (min 64 alphanumeric chars)
├── keyhole.hcl # Config file (optional)
├── audit.log # Structured audit log
├── invites/
│ ├── kh_<random> # Pending invite codes (contain creation timestamp)
│ └── consumed/
│ └── kh_<random> # Used invite codes (moved here atomically)
├── vaults/
│ └── {name}/
│ ├── meta.json # {"owner","created"}
│ ├── members.json # {"user":"role",...}
│ ├── keys/
│ │ └── {user}.enc # Wrapped vault key per member
│ ├── pending/
│ │ └── {user}.invite # Invite-wrapped vault key (JSON: wrapped key + timestamp)
│ └── secrets/
│ └── {path}.enc # Encrypted vault secret
└── {username}/
├── .ssh/
│ └── authorized_keys # User's registered public key
└── account/
├── github.enc # Encrypted secret: nonce(12B) + ciphertext
└── tokens/
└── api.enc
File permissions: directories 0700, files 0600.
Audit log
All connections, authentication failures, and commands are logged to {data_dir}/audit.log as JSON (one object per line):
{"time":"2026-03-04T12:00:00Z","level":"INFO","msg":"connect","user":"alice","remote":"1.2.3.4:54321","key":"SHA256:abc..."}
{"time":"2026-03-04T12:00:01Z","level":"INFO","msg":"command","user":"alice","remote":"1.2.3.4:54321","op":"get","path":"account/github","result":"ok"}
{"time":"2026-03-04T12:00:02Z","level":"WARN","msg":"auth_denied","user":"mallory","remote":"5.6.7.8:9999","reason":"non-Ed25519 key type ssh-rsa"}
The log is append-only and survives server restarts.
Security notes
- The server is trusted. Encryption at rest protects against leaked backups and stolen disks, not against a malicious server: the server handles plaintext in memory during every
get/set, and agent forwarding lets it request signatures from your agent for the duration of a session. Run keyhole on a host you trust, and considerssh-add -cso your agent confirms each signature request. - Ed25519 only. RSA and ECDSA keys are rejected at authentication. Ed25519 signatures are deterministic, which is required for reproducible key derivation.
- Path isolation. Secrets are namespaced per user; one user cannot access another's secrets. Paths containing
..,., or any component starting with.are rejected, along with control characters and invalid UTF-8 (preventing terminal escape injection via shared vault listings). - Secret size limit. Secrets are capped at 64 KB.
- Agent required for get/set. If the SSH agent is not forwarded, the server returns an error immediately rather than hanging.
- No shell. The server accepts only structured commands; there is no shell access.
- Vault key wrapping. Vault keys are individually wrapped per member using their SSH agent signature, so revoking a member does not require re-encrypting all vault secrets.
- Revocation vs. rotation.
vault revokeremoves access immediately;vault rotateadditionally re-encrypts the vault under a fresh key, so a revoked member who saved the old key cannot decrypt ciphertexts they obtain later (e.g. from a leaked backup). - Two-phase vault invite. Invite tokens wrap the vault key with a temporary HKDF-derived key; on accept, the vault key is re-wrapped with the member's agent key. Vault invites expire after 72 hours.
- Server secret backup. Store
server_secretsomewhere safe and separate from the data directory. Without it, every stored secret is permanently inaccessible. The server secret must be at least 64 characters; the server will refuse to start with a shorter value.
Requirements
- Go 1.26+
- An Ed25519 SSH key (
ssh-keygen -t ed25519) ssh-agentrunning and loaded with your key (ssh-add)