seekrit
Docs/Encryption model

Encryption model

seekrit uses envelope encryption. There are three layers of keys, and the plaintext of your secrets — along with private keys and passphrases — never reaches the server.

The key hierarchy

                     environment DEK (AES-256)          one per environment
                     ├── encrypts every secret in that environment
                     └── wrapped separately for each principal:
                         ├── user A  (ECDH to A's public key)
                         ├── user B  (ECDH to B's public key)
                         └── token T (ECDH to T's public key)

Data encryption key (DEK)

Every environment has its own random 256-bit data encryption key. Secrets in that environment are encrypted with it using AES-256-GCM. The ciphertext is bound to its location with additional authenticated data (AAD) of environmentId/SECRET_NAME, so a blob cannot be silently moved to a different secret or environment.

Principal keypairs

Every principal — a user or a service token — has a P-256 (ECDH) keypair. The public key is stored by the server; the private key is not (for tokens it lives inside the token string; for users it is passphrase-encrypted, see below).

Key wrapping

To give a principal access to an environment, its DEK is wrapped to that principal's public key. Wrapping uses an ephemeral ECDH exchange plus HKDF-SHA256 to derive a one-time AES-256-GCM key — an ECIES-style construction. Only the holder of the matching private key can unwrap the DEK. Each grant is an independent wrapped copy of the same DEK.

note

Possession of a wrapped-DEK grant and the matching private key is exactly what "having access" means. There is no server-side switch that grants plaintext — access is cryptographic.

Protecting user private keys

A user needs their private key on every device they sign in from, but the server must never see it. So the private key is encrypted client-side with a key derived from the user's passphrase using PBKDF2-HMAC-SHA256 (600,000 iterations), and the encrypted blob is stored server-side.

  • On any device, the client fetches the encrypted blob and decrypts it locally with the passphrase.
  • The passphrase and the plaintext private key never leave the client.
  • A wrong passphrase surfaces as an authentication failure — the blob simply won't decrypt.
The unlock keyring dialog in the dashboard, over a secrets table whose values are still redacted
What that looks like in the dashboard: values stay redacted until the passphrase unwraps the private key in the tab. Behind the dialog the page holds only ciphertext — that is all the server ever sent.

This passphrase is deliberately separate from how you sign in (OAuth, or an email/password credential verified by Stytch). Sign-in proves who you are and starts a session; the passphrase decrypts your secrets and is never sent to the server. That separation is why an email/password reset can restore account access but can never recover your encrypted data.

Unlocking with a passkey

Typing the passphrase on every reload is the friction that separation costs you, so the private key can carry additional wraps — one per enrolled passkey. Each uses the WebAuthn PRF extension, which turns an authenticator into a keyed hash: for a fixed input, a given credential returns 32 bytes that are stable across evaluations, require a user-verified touch (Touch ID, Windows Hello, a security key), and cannot be derived from anything the server holds. That output is run through HKDF-SHA256 to key AES-256-GCM, and the resulting blob (pk2.) is stored server-side beside the passphrase blob.

  • Enrolling re-wraps the key you already have — it needs the keyring unlocked, so you enter your passphrase once to add a passkey.
  • Unlocking with a passkey is a touch instead of a passphrase. Nothing else changes: it is the same private key, so every environment grant works exactly as before.
  • The server stores a credential id, a PRF input (public), and ciphertext. None of it yields the key without the authenticator.
  • Removing a passkey removes a way in, nothing more. The key and its grants are untouched.
note

A passkey here unlocks your data, not your account — it is not a sign-in method, and it is not a second factor. It also never replaces the passphrase: the passphrase is the only thing that works on a device with no enrolled authenticator, and PRF is still missing from some browser and authenticator combinations (notably external security keys under Safari). Anyone who can satisfy your authenticator can open your keyring on that device, so enroll only devices you control.

There is intentionally no passphrase reset: losing it means the encrypted private key is unrecoverable. That is the cost of the server never holding it. To recover environment access after a lost passphrase or a departed key-holder — without seekrit ever holding a key — configure customer-controlled recovery in advance.

Service tokens

Service tokens are self-contained principals for machines. The token string itself carries the private key. The server stores only:

  • a SHA-256 hash of the full token (to authenticate requests), and
  • the token's public key (to wrap DEK grants to it).

So a machine holding the token can unwrap any environment DEK granted to it, entirely offline, without the server ever having its private key. See Service tokens.

What a secret write looks like

  1. The client fetches its wrapped DEK for the environment and unwraps it with its private key.
  2. It encrypts the new value with the DEK (AES-256-GCM, AAD = envId/NAME).
  3. It uploads only the resulting ciphertext blob.

The reverse — fetch ciphertext, unwrap DEK, decrypt — happens on read. The API is never a party to any of the cryptography.

Algorithms at a glance

PurposeAlgorithm
Secret encryptionAES-256-GCM (per-secret AAD)
DEK wrappingEphemeral ECDH P-256 + HKDF-SHA256 → AES-256-GCM
Passphrase key derivationPBKDF2-HMAC-SHA256, 600k iterations
Passkey key derivationWebAuthn PRF output + HKDF-SHA256 → AES-256-GCM
Service token hashingSHA-256

All of it runs on the Web Crypto API, so the exact same code runs in the browser, the Workers runtime, and Node for the CLI. Every ciphertext blob is versioned (e.g. sc1., wd1., pk1., pk2.) so algorithms can be migrated without breaking existing data.