Skip to content

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:

go
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:

go
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:

go
// 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:

  1. 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.
  2. 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.