Security model

How we built a vault
we can't open.

This is the published cryptography behind secret-lab, named down to the primitive. Every secret is encrypted on the client before it reaches us. The server stores ciphertext and authentication verifiers and nothing that decrypts a single value. That property holds against a stolen database, a fully owned server and the operator under a court order.

Status: approved design, pre-implementation. Where a parameter is still open we say so.

01 / The claim

Zero knowledge, by construction.

All decryption happens on the client. The key that opens a secret is derived on your device, lives in memory for as long as you are using it and is never transmitted. We run an authenticated blob store. It takes ciphertext in and hands it back to whoever proves they are allowed to ask. It never sees a value.

So a customer secret is an opaque document. You serialize whatever you want, JSON for a structured record, plain text or raw bytes, then encrypt it locally. The server stores the result as bytes with a nonce and an algorithm tag. It does not parse the value or know its shape. That is what "the operator cannot read your vault" means here. We hold no key, so there is nothing to seal and nothing a breach can turn into plaintext. A subpoena gets the same ciphertext everyone else would.

The honest cost

A vault we genuinely cannot open is a vault we cannot recover for you. There is no master key on our side and no back door. Lose both of your secrets and the data is gone. The loss model below is explicit about this because it is the price of the guarantee, not a footnote to it.

02 / Primitives

What we build with.

The architecture is a faithful clone of 1Password's account cryptography, Two-Secret Key Derivation over a keypair hierarchy, brought onto current primitives. Where the published 1Password stack used PBKDF2 and RSA-2048 we use Argon2id and X25519. The shape is identical. The pieces are newer.

RolePrimitive
Authenticated encryptionXChaCha20-Poly1305 as the primary AEAD. AES-256-GCM is acceptable as an alternate.
Passphrase / key stretchArgon2id for the passphrase and for hashing the machine auth secret at rest.
Labelled subkeysHKDF-SHA256 for deriving labelled keys from a root secret.
Asymmetric / wrappingX25519 sealed-box style, for the account keypair, vault-key wrapping and sharing.
Blind indexHMAC-SHA256 over a normalized key name, so plaintext key names never reach us.
Human authenticationSRP-6a, 4096-bit group, SHA-256. The passphrase and Secret Key never cross the wire.

Every AEAD nonce is random per encryption. The additional authenticated data binds account_id ∥ vault_id ∥ key_ref, so a ciphertext blob cannot be lifted out of one slot and replayed into another. The exact X25519 sealed-box construction, NaCl box or HPKE, is being settled during implementation planning. OPAQUE is the future upgrade path for the human authentication exchange.

03 / Two-Secret Key Derivation

Two secrets. Neither reaches us.

Account unlock rests on two secrets that both stay on the client. One you remember. One you keep.

  • Secret Key (SK) is 256 bits, generated on your device when the account is created and shown once as an Emergency Kit for you to store. We never receive it.
  • Passphrase (P) is human-chosen and memorized. It is never sent either.

From those two the client derives the Account Unlock Key. The passphrase is stretched with Argon2id against a per-account salt. The Secret Key is run through HKDF-SHA256. The two are combined with XOR:

# client-side, every unlock, never stored
AUK = Argon2id(P, salt_account)  HKDF-SHA256(SK, salt=account_id, info="auk")

The AUK is never written to disk anywhere, on your side or ours. It is re-derived each time you unlock and discarded when you are done. Splitting unlock across a memorized secret and a stored one is the reason a stolen Emergency Kit alone is useless, and a guessed passphrase alone is useless. An attacker needs both, and we hold neither.

04 / Key hierarchy

One keypair. Wrapped many ways.

The AUK does not encrypt your secrets directly. It wraps an account private key, and that indirection is what makes the rest of the system tractable.

AUK
 └─ wraps  account private key (X25519)          ← human slot
      └─ unwraps  vault keys (XChaCha20-Poly1305 symmetric)
           └─ encrypt  items (opaque documents)

The account keypair is the one thing that gets wrapped multiple ways. Its private half sits encrypted under the AUK. Its public half wraps vault keys. Each vault carries its own symmetric vault key, the VK, and the owner's copy of that VK is wrapped to the account public key. Each secret is encrypted under its vault's VK. Nothing more.

Why the indirection earns its keep

Changing a passphrase only re-wraps the account private key under a freshly derived AUK. The items stay put. Every other slot stays put. You are not re-encrypting a vault full of secrets to rotate a passphrase, you are re-wrapping a single key. Sharing works the same way from the other direction: granting another account access to a vault means wrapping that vault's VK to the grantee's account public key. The grantee then unlocks it through their own account, with their own two secrets. We move wrapped keys around. We never see an unwrapped one.

05 / Key slots

Humans and machines on one spine.

A person and a headless service reach the same vault through different front doors. We call these key slots. Everything below the account and vault key is identical no matter which slot did the unlocking. The slot only decides how you prove who you are and how your copy of the key is wrapped.

                       account / vault keys
                         │ wrapped per slot ↓
   human slot                 machine slot "ci-prod"        share slot
   wrap(acct_priv, AUK)        wrap(VK, unwrap_ci)           wrap(VK, grantee_pub)
   auth: SRP-6a                auth: HKDF(MK,"auth")         (grantee unlocks via
   (P + SK, split custody)     scoped to one vault            their own account)

The human slot authenticates with SRP-6a and unlocks the whole account keypair. A machine slot authenticates with a key derived from a single machine secret and is scoped to exactly one vault. A share slot is just a vault key wrapped to someone else's public key. Three doors, one set of secrets behind them, and not one of them leaves a usable key on our servers.

06 / Machine credentials

One secret in the config.

A build pipeline or a running service has no passphrase to remember and no human in the loop. Forcing a two-secret split on a machine would be theatre, both secrets would end up sitting in the same config file anyway. So a machine credential carries a single client-generated secret, the machine key, and derives everything else from it.

# client-side, from one machine_key (MK)
auth_secret = HKDF(MK, "auth")     # sent to server; server stores Argon2id(auth_secret)
unwrap_key  = HKDF(MK, "unwrap")   # NEVER sent; unwraps this slot's wrapped VK

The server only ever sees auth_secret, and it stores even that as an Argon2id hash. It cannot run the derivation backwards to recover the machine key, and it cannot reach unwrap_key, which never leaves the machine. So the zero-knowledge property holds on the machine path too. The unwrap key opens this slot's wrapped VK and nothing else.

Machine slots are scoped to a single vault, which is least privilege from the moment you mint one, and each is independently revocable. Delete the slot and that credential is dead, with no effect on any other access. Losing a machine key is a non-event. The owner issues a new machine credential and no data is lost, because the machine key never held anything the owner could not re-wrap.

07 / Data at rest

What the database actually holds.

A secret is stored as key → one ciphertext blob. The server never parses the value, so there is zero structure leak from the stored bytes. Key names get the same treatment. Instead of a plaintext name we store a blind index:

key_ref = HMAC-SHA256(index_key, normalize(key_name))

The per-vault index_key is itself wrapped under the vault key, so only a holder of the VK can compute a ref. The client computes the ref locally to address a secret, and the plaintext name never reaches us. A client that wants to list and decrypt its own key names can store the human-readable name encrypted per record. Writes are latest-only: a PUT overwrites in place, there is no version history, and deletes are soft-delete tombstones rather than hard removals.

The audit log records actions, never content. It holds no plaintext, no passphrases, no keys and no auth secrets. There are no root-key, Shamir or unseal tables in the schema, because the server holds no decryption keys and so has nothing to seal.

08 / Loss & recovery

The price of a vault we can't open.

Recovery is where a zero-knowledge design has to be honest, because the same property that locks us out locks us out of helping you.

  • Lose your passphrase or Secret Key and you are locked out. There is no vendor recovery, by design. The Emergency Kit is the safety net for the Secret Key, so store it somewhere durable. Passphrase loss is unrecoverable.
  • Lose a machine key and you lose nothing. The owner issues a new machine credential and the old slot gets deleted.

Admin-assisted account recovery is out of scope. Adding it would mean putting some recovery key somewhere on our side, which would break the individual zero-knowledge guarantee for everyone. We would only revisit that tradeoff if a shared Teams or Business tier were ever on the table, and even then it would be an explicit, separate model rather than a quiet back door bolted onto this one.

09 / Threat model

What each attacker gets.

ThreatWhat they get
Stolen DB, disk or backup. Snooping DBA.Ciphertext only. No decryption key is ever on disk.
Full app-server compromise (RCE)Ciphertext only. The server holds no decryption keys at runtime either.
Secrets the server must never seeAll of them. The server is zero-knowledge by construction.
Malicious or compelled operatorCannot read vaults. Holds no keys to hand over.

The common thread is that none of these attackers touch a decryption key, because none exists on our side to touch. Transport is TLS. The product does not use mTLS or client certificates, it carries SRP and bearer sessions over TLS instead. That is a deliberate scope choice, not an oversight, and it is listed below alongside the rest of what we left out.

10 / Out of scope

What we deliberately left out.

A few things you might expect are absent on purpose. Each is a tradeoff we made in the open.

  • mTLS / client certificates. Bearer and SRP over TLS instead.
  • Any server-readable tier. Server-side decryption would defeat the entire model.
  • Shamir operator unseal or a server-held root key. There are no server keys to seal.
  • Secret version history. Latest-only writes.
  • Field-level encryption. Opaque documents only, the client decides the structure.
  • Admin-assisted account recovery. It would break individual zero-knowledge.
  • CRL / OCSP. Credential rotation and revocation handle this directly.
Read next

The developer docs map these primitives onto the API surface, and the quickstart walks one round trip end to end. Both link back here for the cryptography.