Encryption
Keyhole encrypts all secrets at rest using AES-256-GCM. Encryption keys are derived differently for personal secrets and vault secrets.
Personal secrets
Each personal secret gets its own encryption key, derived from your SSH agent signature:
challenge = SHA-256(server_secret + ":" + "keyhole-v1:" + username + ":" + path)
signature = agent.Sign(your_ed25519_key, challenge)
key = HKDF-SHA256(signature, salt=server_secret, info="keyhole-key-v1")
on-disk = AES-256-GCM(key, nonce=random_12_bytes, plaintext)Decrypting requires both your SSH private key (via agent) and the server secret. Neither is sufficient alone.
Vault secrets
Vaults use a random 512-byte vault key shared among members. Each secret derives its own key from the vault key:
secret_key = HKDF-SHA256(vault_key, salt=server_secret, info="keyhole-vault-v1:<path>")
on-disk = AES-256-GCM(secret_key, nonce=random_12_bytes, plaintext)Vault key wrapping
Each member's copy of the vault key is wrapped with a key derived from their SSH agent signature:
// Member wrapping key
challenge = SHA-256(server_secret + ":" + "keyhole-v1:" + username + ":" + "__vault_key__/<vault_name>")
signature = agent.Sign(user_ed25519_key, challenge)
wrapping_key = HKDF-SHA256(signature, salt=server_secret, info="keyhole-vault-wrapping-v1")
wrapped_key = AES-256-GCM(wrapping_key, nonce=random_12_bytes, vault_key)
// Invite token key
token = random_32_bytes
token_key = HKDF-SHA256(token, salt=server_secret, info="keyhole-vault-invite-v1:<vault_name>:<username>")
pending = AES-256-GCM(token_key, nonce=random_12_bytes, vault_key)This two-phase invite design means:
- Invite: the vault key is wrapped with an HKDF-derived key from the invite token. The HKDF info parameter includes the vault name and target username for domain separation, preventing cross-vault token reuse.
- Accept: the user decrypts with the token, then re-wraps the vault key with their agent-derived key. Vault invites expire after 72 hours.
Revoking a member removes their wrapped key — no vault secrets need to be re-encrypted. Rotating the key afterwards (below) also retires the key the revoked member may have saved.
Vault key rotation
vault rotate generates a fresh 512-byte vault key, re-encrypts every vault secret under it, and re-wraps the new key for the rotator. Other members' agents are not available to the server while they are offline, so they cannot be silently re-keyed: each remaining member and pending invitee receives a fresh invite token (using the same token-wrapping scheme as an invite), and their roles are preserved when they re-accept.
Rotation is crash-safe. The new wrapped key and the re-encrypted secrets are staged first, then committed with a single atomic rename; an interrupted rotation is rolled back (before the commit) or rolled forward (after it) by the next vault rotate. At no intermediate point can a retired key decrypt post-rotation ciphertexts.
