The zero-knowledge model.
secret-lab is an API-only secrets store. You keep key → value entries in vaults, where each value is an opaque document the server never parses. It can be JSON for a multivalue record, plain text or raw binary. The server stores only ciphertext and the verifiers it needs to authenticate you. It holds no key that decrypts any secret.
That single fact shapes the whole API. The client library generates keys, derives them, encrypts and decrypts. The server takes ciphertext in and hands ciphertext back. A full compromise of the database or the running process yields ciphertext only, for every secret, with no exceptions. The cryptography that makes this true, Two-Secret Key Derivation over an X25519 keypair hierarchy, is documented in the security model.
Anywhere these docs say "the client encrypts", "wrap", "unwrap", "derive the AUK" or "compute the blind index", that work happens in the client library on your side. The endpoints below move opaque blobs. They never see a plaintext value, key name or unlock key.
Accounts.
An account is the unit of identity. It is created through a manual approval flow, not self-serve signup. You request access, an operator approves it, and then your client runs a one-time init that uploads your public material. The two secrets that unlock the account, a memorized passphrase and a 256-bit Secret Key generated on your device, never reach the server.
What the server stores for an account is the SRP verifier and salt, the passphrase KDF salt, the account public key and the account private key wrapped under your Account Unlock Key. None of it decrypts anything on its own. An account moves through three states: pending after approval and before setup, active once init completes, and suspended if an operator suspends it.
Vaults and items.
A vault is a container of secrets with its own symmetric vault key, the VK. Items inside it are encrypted under that VK. The owner's copy of the VK is wrapped to their account public key, so unlocking the account keypair gives access to the vault. You can share a vault by wrapping its VK to another account's public key.
An item is a single key → ciphertext pair. The server addresses it by a blind index called the key_ref, computed client-side as HMAC-SHA256(index_key, normalize(key_name)). Plaintext key names never reach the server. Writes are latest-only, so a PUT overwrites in place with no version history, and deletes leave a soft-delete tombstone.
Key slots.
The same vault is reachable through different front doors called key slots. A human slot wraps the account private key under the Account Unlock Key and authenticates with SRP-6a. A machine slot wraps a vault key under a key derived from a single machine secret and is scoped to one vault. A share slot is a vault key wrapped to another account's public key. Everything below the account and vault key is identical regardless of which slot did the unlocking. The slots differ only in how you authenticate and how your copy of the key is wrapped.
Machine credentials.
A machine credential lets a headless consumer, a CI job or a running service, reach one vault with no human in the loop. The client generates a single machine key (MK) and from it derives two values. HKDF(MK, "auth") is the auth secret sent to the server, which stores it as an Argon2id hash. HKDF(MK, "unwrap") never leaves the machine and unwraps that slot's wrapped vault key.
Because the server only ever sees the auth secret, and stores even that hashed, it cannot recover the machine key or the unwrap key. Machine slots are scoped to a single vault and independently revocable. Losing a machine key costs nothing. Mint a new credential and delete the old slot.
Enrollment.
Public endpoints over plain TLS, no authentication. Accounts are approved by hand, so enrollment registers interest and returns a tracking id. Nothing secret is delivered server to client here.
Register an access request with a contact identifier. Creates an application with status=pending for an operator to review.
{ "contact": "you@example.com" }
{ "application_id": "app_...", "status": "pending" }
Check the status of an application by its id. Returns the current state. After an operator approves it, this is where the one-time enrollment claim token is delivered for the client to run init.
{
"application_id": "app_...",
"status": "pending | approved",
"enrollment_claim_token": "..." // present once approved; one-time, consumed by init
}
Account setup.
One-time setup authorized by the enrollment claim token. The client has already generated the Secret Key locally as an Emergency Kit, chosen a passphrase, derived the Account Unlock Key, generated the account keypair and created the first vault key. This call uploads the public and wrapped material, all ciphertext or opaque, and flips the account to active.
Upload the SRP verifier and salt, the account public key, the AUK-wrapped account private key and the first wrapped vault key. The claim token is consumed on success.
{
"enrollment_claim_token": "...",
"srp_verifier": "<base64>",
"srp_salt": "<base64>",
"kdf_salt": "<base64>", // per-account Argon2id salt for the passphrase
"account_pubkey": "<base64>",
"enc_account_privkey": "<base64>", // account private key wrapped under AUK
"wrapped_vk": "<base64>" // first vault key wrapped to account_pubkey
}
{ "account_id": "acct_...", "vault_id": "vlt_...", "status": "active" }
Authentication.
Two paths, both ending in a session. Humans authenticate with SRP-6a using the passphrase and Secret Key, neither of which crosses the wire. Machines authenticate with the auth secret derived from their machine key. The session token carriage (a header) and lifetime are being settled during implementation planning, so treat the session value below as the credential you attach to subsequent authenticated calls.
Begin the SRP-6a exchange. The client sends its identity and ephemeral public value A. The server returns the account salt and its ephemeral public value B.
{ "account_id": "acct_...", "A": "<hex>" }
{ "srp_salt": "<base64>", "B": "<hex>" }
Complete the exchange. The client proves it derived the shared session key by sending M1. The server verifies it, replies with its own proof M2 and issues a session. Neither passphrase nor Secret Key was ever transmitted.
{ "account_id": "acct_...", "M1": "<hex>" }
{ "M2": "<hex>", "session": "..." }
Authenticate a machine credential. The client sends the auth secret HKDF(MK, "auth"). The server checks it against the stored Argon2id verifier and issues a session scoped to the slot's single vault.
{ "credential_id": "mac_...", "auth_secret": "<base64>" }
{ "session": "...", "vault_id": "vlt_..." }
Secrets.
Authenticated session, ciphertext both directions. Each secret is addressed by its key_ref blind index, which the client computes locally. The server stores and serves the ciphertext, nonce, algorithm tag and optional encrypted key name. It never sees a plaintext key or value.
Create or overwrite a secret at key_ref in the session's vault. Latest-only, so this overwrites any existing value in place. The body is the encrypted item produced by the client.
{
"ciphertext": "<base64>", // the opaque encrypted document
"nonce": "<base64>", // random per encryption
"alg": "xchacha20poly1305",
"enc_key_name": "<base64>" // optional: human key name encrypted under VK, for list-and-decrypt
}
{ "key_ref": "<hex>", "status": "stored" }
Fetch the encrypted item at key_ref. The client decrypts it under the vault key. The AAD bound at encryption time (account_id ∥ vault_id ∥ key_ref) means a blob moved to a different ref fails authentication on decrypt.
{
"key_ref": "<hex>",
"ciphertext": "<base64>",
"nonce": "<base64>",
"alg": "xchacha20poly1305"
}
Soft-delete the secret at key_ref. Leaves a tombstone rather than a hard removal.
{ "key_ref": "<hex>", "status": "deleted" }
List the refs in the session's vault, with the encrypted key name per record where one was stored. The client decrypts the names locally to show a human-readable listing. Whether key-name listing ships as encrypted-name-per-record or a client-held manifest is an open implementation detail.
{
"secrets": [
{ "key_ref": "<hex>", "enc_key_name": "<base64>" }
]
}
Vaults & credentials.
Human-authenticated operations on vaults, machine credentials and shares. Every upload here is a wrapped key or an opaque slot blob produced by the client. The server stores them without ever holding an unwrapped key.
Create a vault. The client generates a new vault key, wraps it to the account public key and uploads the wrapped form.
{ "wrapped_vk": "<base64>" } // vault key wrapped to account_pubkey
{ "vault_id": "vlt_..." }
List the vaults the account can reach, owned and shared, with the wrapped vault key for each so the client can unwrap it under the account private key.
{
"vaults": [
{ "vault_id": "vlt_...", "wrapped_vk": "<base64>" }
]
}
Create a machine slot scoped to one vault. The client has generated the machine key, derived the auth secret and the unwrap key, and wrapped the vault key under the unwrap key. It uploads the slot blob, the auth verifier material and the scope. The machine key itself is handed to the operator out of band and never sent here.
{
"vault_id": "vlt_...", // scope: one vault only
"label": "ci-prod",
"auth_verifier": "<base64>", // Argon2id(HKDF(MK,"auth")), stored as the verifier
"wrapped_vk": "<base64>" // VK wrapped under HKDF(MK,"unwrap")
}
{ "credential_id": "mac_...", "vault_id": "vlt_...", "label": "ci-prod" }
Revoke a machine slot. The credential stops authenticating immediately. No other access is affected, and no data is lost.
{ "credential_id": "mac_...", "status": "revoked" }
Share a vault with another account. The client unwraps the vault key, re-wraps it to the grantee's account public key and uploads the result. The grantee then unlocks it through their own account.
{
"grantee_account_id": "acct_...",
"wrapped_vk": "<base64>" // VK wrapped to grantee account_pubkey
}
{ "vault_id": "vlt_...", "grantee_account_id": "acct_...", "status": "shared" }
Revoke a share. The grantee's access record is marked revoked. Their wrapped copy of the vault key stops being served.
{ "vault_id": "vlt_...", "status": "revoked" }
Account.
Change the passphrase. The client derives a new Account Unlock Key from the new passphrase and the existing Secret Key, re-wraps the account private key under it and uploads the new wrapped key together with the new SRP verifier and salts. Items and every other slot are untouched, because only the account key wrapping changes.
{
"enc_account_privkey": "<base64>", // account private key re-wrapped under the new AUK
"srp_verifier": "<base64>", // new verifier for the new passphrase
"srp_salt": "<base64>",
"kdf_salt": "<base64>"
}
{ "account_id": "acct_...", "status": "rotated" }
Errors & help.
The wire format for errors is being settled during implementation planning, so the catalog below describes the conditions you will hit rather than fixed codes. Two kinds matter, and they fail in different places.
Server-side failures
These come back from the API. Authentication that does not verify, an SRP M1 the server rejects or a machine auth secret that does not match its Argon2id verifier. A key_ref that has no live record, including one behind a soft-delete tombstone. A revoked machine slot or a revoked share, which stops authenticating or stops serving its wrapped key. An operation outside the session's scope, such as a machine session reaching past its one vault.
Client-side crypto failures
These never reach the server, because the server never decrypts. The one that matters most is an AEAD authentication failure on decrypt. If the ciphertext, nonce or bound AAD has been altered, Poly1305 rejects the tag and decryption fails closed. The client gets an error and no plaintext. The same happens if a blob is lifted to a different key_ref, since the AAD binds account_id ∥ vault_id ∥ key_ref and no longer matches. A wrong passphrase or Secret Key produces the wrong Account Unlock Key, so the account private key fails to unwrap. None of these are server errors. They are the zero-knowledge design failing safe on your side.
Accounts are approved by hand and every request is read in person. For access, a stuck enrollment or an operational question, write to chka@stratus5.com. We cannot help with a lost passphrase or Secret Key. That is the design, see the loss model.
Testing.
The property worth testing is the round trip: a value you encrypt, store, fetch and decrypt comes back byte-for-byte equal, and a value that was tampered with on the way fails to decrypt rather than returning wrong plaintext. You can prove both against a local instance.
Round-trip check
Point the client library at a local server, authenticate, then run one cycle through the API. Assert equality at the end.
# against a local instance
1. authenticate # SRP (P + SK) or machine auth → session
2. plaintext = random bytes
3. ref = HMAC(index_key, normalize("test/roundtrip"))
4. PUT /v1/secrets/{ref} # client encrypts plaintext under VK, uploads ciphertext+nonce
5. GET /v1/secrets/{ref} # pull the stored blob back
6. decrypted = client decrypts the returned blob
7. assert decrypted == plaintext ✓ round trip holds
Tamper check
Now flip a bit and confirm the failure is a hard one. Decryption must fail closed, never hand back altered plaintext.
1. GET /v1/secrets/{ref} # fetch the stored ciphertext
2. flip one byte of ciphertext (or nonce)
3. attempt client decrypt
4. assert decrypt FAILS ✓ Poly1305 rejects the tag
5. assert no plaintext is returned ✓ fails closed
# replay check: move a valid blob to a different key_ref
6. PUT the unmodified blob at a different {ref2}
7. GET /v1/secrets/{ref2}; attempt decrypt
8. assert decrypt FAILS ✓ AAD binds the original key_ref
The round-trip assert shows the client crypto is correct end to end. The tamper and replay asserts show the AEAD and its bound AAD do their job: altered or misplaced ciphertext fails authentication and yields nothing. Run these against the operator's own server and the result is the same, because the server holds no key to turn ciphertext into plaintext in the first place.
The full walk-through, in Go, Java, Node and .NET, is on the quickstart page.