Developer docs

Build against a store
that can't read you.

The concepts, the full API reference and how to prove the zero-knowledge property yourself with a round-trip test. The server is an authenticated blob store. Your client library does all the crypto. For the cryptographic detail behind every term here, see the security model.

Status: approved design, pre-implementation. Request and response shapes follow the committed design. A few details marked open are still being settled.

Concepts

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.

Where the crypto lives

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.

Concepts

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.

Concepts

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.

Concepts

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.

Concepts

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.

API reference

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.

POST/v1/enrollno auth

Register an access request with a contact identifier. Creates an application with status=pending for an operator to review.

Request body
{ "contact": "you@example.com" }
Response
{ "application_id": "app_...", "status": "pending" }
GET/v1/enroll/{id}no auth

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.

Response
{
  "application_id": "app_...",
  "status": "pending | approved",
  "enrollment_claim_token": "..."   // present once approved; one-time, consumed by init
}
API reference

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.

POST/v1/accounts/initclaim token

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.

Request body
{
  "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
}
Response
{ "account_id": "acct_...", "vault_id": "vlt_...", "status": "active" }
API reference

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.

POST/v1/auth/srp/startSRP step 1

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.

Request body
{ "account_id": "acct_...", "A": "<hex>" }
Response
{ "srp_salt": "<base64>", "B": "<hex>" }
POST/v1/auth/srp/finishSRP step 2

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.

Request body
{ "account_id": "acct_...", "M1": "<hex>" }
Response
{ "M2": "<hex>", "session": "..." }
POST/v1/auth/machinemachine

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.

Request body
{ "credential_id": "mac_...", "auth_secret": "<base64>" }
Response
{ "session": "...", "vault_id": "vlt_..." }
API reference

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.

PUT/v1/secrets/{key_ref}session

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.

Request body
{
  "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
}
Response
{ "key_ref": "<hex>", "status": "stored" }
GET/v1/secrets/{key_ref}session

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.

Response
{
  "key_ref":    "<hex>",
  "ciphertext": "<base64>",
  "nonce":      "<base64>",
  "alg":        "xchacha20poly1305"
}
DELETE/v1/secrets/{key_ref}session

Soft-delete the secret at key_ref. Leaves a tombstone rather than a hard removal.

Response
{ "key_ref": "<hex>", "status": "deleted" }
GET/v1/secretssession

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.

Response
{
  "secrets": [
    { "key_ref": "<hex>", "enc_key_name": "<base64>" }
  ]
}
API reference

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.

POST/v1/vaultshuman

Create a vault. The client generates a new vault key, wraps it to the account public key and uploads the wrapped form.

Request body
{ "wrapped_vk": "<base64>" }   // vault key wrapped to account_pubkey
Response
{ "vault_id": "vlt_..." }
GET/v1/vaultshuman

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.

Response
{
  "vaults": [
    { "vault_id": "vlt_...", "wrapped_vk": "<base64>" }
  ]
}
POST/v1/credentials/machinehuman

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.

Request body
{
  "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")
}
Response
{ "credential_id": "mac_...", "vault_id": "vlt_...", "label": "ci-prod" }
DELETE/v1/credentials/machine/{id}human

Revoke a machine slot. The credential stops authenticating immediately. No other access is affected, and no data is lost.

Response
{ "credential_id": "mac_...", "status": "revoked" }
POST/v1/vaults/{id}/sharehuman

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.

Request body
{
  "grantee_account_id": "acct_...",
  "wrapped_vk":         "<base64>"   // VK wrapped to grantee account_pubkey
}
Response
{ "vault_id": "vlt_...", "grantee_account_id": "acct_...", "status": "shared" }
DELETE/v1/vaults/{id}/share/{id}human

Revoke a share. The grantee's access record is marked revoked. Their wrapped copy of the vault key stops being served.

Response
{ "vault_id": "vlt_...", "status": "revoked" }
API reference

Account.

POST/v1/account/rotate-passphrasehuman

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.

Request body
{
  "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>"
}
Response
{ "account_id": "acct_...", "status": "rotated" }
Operations

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.

Getting help

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.

Operations

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
What you are proving

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.