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.
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.
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.
| Role | Primitive |
|---|---|
| Authenticated encryption | XChaCha20-Poly1305 as the primary AEAD. AES-256-GCM is acceptable as an alternate. |
| Passphrase / key stretch | Argon2id for the passphrase and for hashing the machine auth secret at rest. |
| Labelled subkeys | HKDF-SHA256 for deriving labelled keys from a root secret. |
| Asymmetric / wrapping | X25519 sealed-box style, for the account keypair, vault-key wrapping and sharing. |
| Blind index | HMAC-SHA256 over a normalized key name, so plaintext key names never reach us. |
| Human authentication | SRP-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.
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.
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.
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.
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.
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.
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.
What each attacker gets.
| Threat | What 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 see | All of them. The server is zero-knowledge by construction. |
| Malicious or compelled operator | Cannot 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.
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.
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.