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 does all the crypto. For the cryptographic detail behind every term here, see the security model.

Status: live at secrets.lab.stratus5.net. Every request and response shape below is taken from the running server. Base URL for all paths: https://secrets.lab.stratus5.net.

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. Your client 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 your client 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. Enrollment is self-serve: a single POST /v1/enroll mints an empty account shell and returns a one-time claim, with no operator approval in the loop. Your client then runs a one-time init that consumes the claim and 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 its salt, the passphrase KDF salt, the account public key and the account private key wrapped under your Account Unlock Key (the AUK). None of it decrypts anything on its own. An account moves through three states: pending after enrollment and before setup, active once init consumes the claim, and suspended if an operator suspends it.

The claim is a bootstrap token

The claim is shaped <account_id>:<token>. The server keeps only its SHA-256 hash and returns the clear value once. It is consumed at init and cannot be replayed. Until init runs, the account is an inert shell holding nothing but that hash, so an unconsumed claim is worth nothing on its own.

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.

Getting started

Bootstrap in two calls.

Going from nothing to an active account is two HTTP calls. POST /v1/enroll hands back an account id and a one-time claim. POST /v1/accounts/init consumes that claim and uploads the public and wrapped material your client generated locally. After that you authenticate and start reading and writing secrets. The client does the crypto for both; the curl below shows only the wire so you can see what crosses it.

# 1. self-serve enrollment: mint an account shell + one-time claim
curl -s -X POST https://secrets.lab.stratus5.net/v1/enroll
# → { "account_id": "acct_9f3...", "claim": "acct_9f3...:tok_b71..." }

# 2. init: the client derives the AUK from passphrase + Secret Key, wraps the
#    account keypair and first vault key, and uploads only ciphertext + verifiers.
curl -s -X POST https://secrets.lab.stratus5.net/v1/accounts/init \
  -H 'content-type: application/json' -d @init.json
# → { "account_id": "acct_9f3..." }   the account is now active
Two calls, no admin key

Both steps are public: POST /v1/enroll then POST /v1/accounts/init against the same base URL, with no admin key anywhere. The hand-built JSON for step 2 is laid out under account setup.

Getting started

Conventions.

A few rules hold across every endpoint, so they are stated once here rather than repeated on each card.

  • Transport. HTTPS only, terminated at the edge. All paths hang off https://secrets.lab.stratus5.net.
  • Encoding. Request and response bodies are JSON. Binary fields (keys, salts, ciphertext, nonces, wrapped blobs) are standard base64 inside the JSON. The one exception is the {key_ref} path segment, which is base64url without padding because it sits in the URL.
  • Auth headers. Admin endpoints take X-Admin-Key. Authenticated endpoints take Authorization: Bearer <token> where the token came from an auth call. Enrollment, init, auth-params and the SRP and machine auth calls are public.
  • IDs are client-chosen where it matters. The account id is fixed by the claim, but vault_id, the secret's key_ref and a machine credential_id are minted by the client so the server never needs to hand back an identifier you then have to map to a local secret.
  • Errors. Every error is { "error": "<reason>" } with a matching HTTP status. The full catalogue is under errors.
API reference

Enrollment.

One public endpoint, no authentication, no admin key. It mints an empty account shell and returns a one-time claim immediately. There is no approval step and no application to poll. The only gate is a per-IP rate limit, which is safe because the shell is inert until init consumes the claim.

POST/v1/enrollno auth

Provision a new account shell and return the one-time claim. The request takes no body (send {} or nothing). The claim is shaped <account_id>:<token>; the server stores only its SHA-256 hash and returns the clear value this one time. Pass it straight to init.

Response · 201
{
  "account_id": "acct_...",
  "claim":      "acct_...:tok_..."   // one-time, consumed by init, never returned again
}
Errors

429 too_many when the per-IP window is exceeded (see rate limits).

Operator-minted alternative

Operators can also mint a shell with POST /v1/admin/accounts under an X-Admin-Key, which returns the same { account_id, claim }. It exists for back-office provisioning and account-management tooling; ordinary clients should use /v1/enroll. See admin.

API reference

Account setup.

One-time setup authorised by the claim. By the time you call this, the client has generated the Secret Key locally as the Emergency Kit, taken a passphrase, derived the Account Unlock Key, generated the account keypair, and minted the first vault and its key. This call uploads the public and wrapped material, all of it ciphertext or opaque, consumes the claim and flips the account to active. The first vault is created in the same call so the account is usable the moment init returns.

POST/v1/accounts/initclaim

Upload the SRP verifier and its salt, the passphrase KDF salt, the account public key, the AUK-wrapped account private key and the first vault with its wrapped key. The account_id must be the one embedded in the claim, because it is the AUK domain separator and the human-slot AAD; minting your own would make the slot fail to unwrap on later unlock. The claim is consumed on success and cannot be replayed.

Request body
{
  "claim":               "acct_...:tok_...",
  "account_id":          "acct_...",       // must match the id inside the claim
  "account_pubkey":      "<base64>",
  "enc_account_privkey": "<base64>",       // account private key wrapped under the AUK
  "kdf_salt":            "<base64>",       // passphrase KDF salt (Argon2id)
  "auth_verifier_salt":  "<base64>",       // SRP verifier salt
  "auth_verifier":       "<base64>",       // SRP-6a verifier v = g^x, computed client-side
  "first_vault": {
    "vault_id":   "vault_...",            // client-chosen
    "wrapped_vk": "<base64>"             // first vault key wrapped to account_pubkey
  }
}
Response · 201
{ "account_id": "acct_..." }
GET/v1/accounts/{id}/auth-paramsno auth

Return the passphrase KDF salt for an account. The client needs it to derive the AUK, and the AUK is needed before it can authenticate, so gating this behind auth would be circular. Salts are not secret. Only active accounts answer; a pending or suspended account returns 403.

Response · 200
{ "kdf_salt": "<base64>" }
API reference

Authentication.

Two paths, both ending in a bearer token. Humans run SRP-6a over the passphrase and Secret Key, neither of which crosses the wire. Machines present a one-way auth secret derived from their machine key. Both return a token and an expires_at; attach the token as Authorization: Bearer <token> on every authenticated call after that. See sessions for lifetime and scope.

POST/v1/auth/srp/startSRP step 1

Begin the SRP-6a exchange. The client sends the account id and its ephemeral public value a. The server replies with the verifier salt, its own ephemeral public value b, and a one-time handle that ties this exchange to its finish. The handle expires after two minutes.

Request body
{ "account_id": "acct_...", "a": "<base64>" }   // a = g^a mod N, client public ephemeral
Response · 200
{
  "handle": "srp_...",      // pass back to finish; one-shot, 2-minute TTL
  "salt":   "<base64>",     // SRP verifier salt
  "b":      "<base64>"      // server public ephemeral
}
POST/v1/auth/srp/finishSRP step 2

Complete the exchange. The client sends the handle and its proof m1. The server verifies it against the stored verifier, replies with its own proof m2 so the client knows the server also holds the verifier, issues a token, and returns the wrapped account material the client needs to unlock its human slot. Neither passphrase nor Secret Key was transmitted at any point.

Request body
{ "handle": "srp_...", "m1": "<base64>" }
Response · 200
{
  "m2":                  "<base64>",   // server proof
  "token":               "...",        // bearer token for subsequent calls
  "expires_at":          "<RFC3339>",
  "account_pubkey":      "<base64>",
  "enc_account_privkey": "<base64>"    // AUK-wrapped, the client unwraps it locally
}
POST/v1/auth/machinemachine

Authenticate a machine credential. The client sends the credential id and the auth secret HKDF(MK, "auth"). The server checks it against the stored Argon2id verifier and issues a token scoped to the credential's single vault, alongside that slot's wrapped vault key so the client can unwrap it under HKDF(MK, "unwrap") without a human keypair.

Request body
{ "credential_id": "mach_...", "auth_secret": "<base64>" }
Response · 200
{
  "token":      "...",
  "expires_at": "<RFC3339>",
  "vault_id":   "vault_...",    // the one vault this credential reaches
  "wrapped_vk": "<base64>"      // VK wrapped under HKDF(MK,"unwrap")
}
API reference

Vaults.

Human-session operations. A vault is a container with its own symmetric key, the VK. The client generates the VK, wraps it to the account public key and uploads only the wrapped form; the server never holds an unwrapped key. Listing returns each vault you can reach with its wrapped VK so the client can unwrap it under the account private key.

POST/v1/vaultshuman

Create a vault. The client picks the vault_id, generates a new VK and wraps it to its own account public key.

Request body
{
  "vault_id":   "vault_...",     // client-chosen
  "wrapped_vk": "<base64>"      // VK wrapped to account_pubkey
}
Response · 201
{ "vault_id": "vault_..." }
GET/v1/vaultshuman

List every vault the account can reach, each with its wrapped VK. The response is a JSON array.

Response · 200
[
  { "vault_id": "vault_...", "wrapped_vk": "<base64>" }
]
API reference

Secrets.

Session calls, ciphertext both directions, all scoped to a vault id in the path. Each secret is addressed by its key_ref, a blind index the client computes as HMAC-SHA256(index_key, normalize(key_name)) and encodes base64url (no padding) for the URL. The server stores and serves the ciphertext, nonce, algorithm tag, a protection marker and an optional encrypted key name. It never sees a plaintext key or value. A machine session may touch only the one vault it is scoped to; a human session may touch any vault it has access to.

PUT/v1/vaults/{vault_id}/secrets/{key_ref}session

Create or overwrite the secret at key_ref. Writes are latest-only, so this overwrites in place with no version history. The body is the encrypted item the client produced.

Request body
{
  "ciphertext":   "<base64>",    // the opaque encrypted document
  "nonce":        "<base64>",    // random per encryption
  "alg":          "xchacha20poly1305",
  "protection":   "vault",            // opaque marker describing how the item was wrapped
  "enc_key_name": "<base64>"     // optional: key name encrypted under VK, for list-and-decrypt
}
Response · 200
{ "status": "stored" }
GET/v1/vaults/{vault_id}/secrets/{key_ref}session

Fetch the encrypted item. The client decrypts it under the vault key. The AAD bound at encryption time (vault_id ∥ key_ref) means a blob lifted to a different vault or ref fails authentication on decrypt rather than yielding wrong plaintext. account_id is deliberately not bound, so any vault-key holder (the human owner or a scoped machine credential) can open the item.

Response · 200
{
  "ciphertext":   "<base64>",
  "nonce":        "<base64>",
  "alg":          "xchacha20poly1305",
  "protection":   "vault",
  "enc_key_name": "<base64>"
}
DELETE/v1/vaults/{vault_id}/secrets/{key_ref}session

Soft-delete the secret. Leaves a tombstone rather than a hard removal, so a later GET at that ref returns 404.

Response · 200
{ "status": "deleted" }
GET/v1/vaults/{vault_id}/secretssession

List the live refs in the vault, each with its encrypted key name (where one was stored) and last-updated time. The client decrypts the names locally to show a readable listing. The response is a JSON array.

Response · 200
[
  {
    "key_ref":      "<base64>",
    "enc_key_name": "<base64>",
    "updated_at":   "<RFC3339>"
  }
]
API reference

Machine credentials.

A machine credential is a slot scoped to a single vault that a headless consumer authenticates with, no human keypair involved. Minting one is a human-session call on the vault. The client generates the machine key locally, derives the auth secret and the unwrap key, wraps the VK under the unwrap key, and uploads the verifier material and the wrapped VK. The machine key itself never reaches the server; you hand it to the consumer out of band.

POST/v1/vaults/{vault_id}/credentials/machinehuman

Create a machine slot on the vault in the path. The client picks the credential_id. The verifier is stored as Argon2id(HKDF(MK,"auth")) so the server can check the auth secret later without holding it.

Request body
{
  "credential_id":      "mach_...",   // client-chosen
  "label":             "ci-prod",
  "auth_verifier_salt": "<base64>",
  "auth_verifier":      "<base64>",    // Argon2id verifier over HKDF(MK,"auth")
  "wrapped_vk":         "<base64>"     // VK wrapped under HKDF(MK,"unwrap")
}
Response · 201
{ "credential_id": "mach_..." }
DELETE/v1/credentials/machine/{id}human

Revoke a machine slot. The credential stops authenticating immediately. A human may revoke only a credential scoped to a vault they can reach. No other access is affected and no data is lost; mint a new credential and the old machine key is worth nothing.

Response · 200
{ "status": "revoked" }
API reference

Admin.

Operator endpoints, gated by a static X-Admin-Key header rather than a session. They never touch plaintext or any unwrapping key; they provision shells, suspend accounts and report metadata sizes. Stats are sizes and counts only, never values. The admin key is held server-side by the operator console and is not something an ordinary client ever needs.

POST/v1/admin/accountsX-Admin-Key

Pre-provision an empty account shell and return the same one-time claim that /v1/enroll returns. Use this for back-office provisioning; self-serve clients should use enrollment instead. Takes no body.

Response · 201
{ "account_id": "acct_...", "claim": "acct_...:tok_..." }
POST/v1/admin/accounts/{id}/suspendX-Admin-Key

Suspend an account. A suspended account stops answering auth-params and fails SRP and machine auth, so no new session can be opened against it. Stored ciphertext is untouched.

Response · 200
{ "status": "suspended" }
GET/v1/admin/accounts/{id}/statsX-Admin-Key

Report usage for an account: the number of live secrets and the total ciphertext size in bytes across its vaults. Sizes only, never plaintext, never key names.

Response · 200
{ "account_id": "acct_...", "secrets": 12, "bytes": 40960 }
Not yet live

Vault sharing to another account and passphrase rotation are part of the key model but are not exposed as endpoints yet. The cryptography supports both (a share is a VK re-wrapped to a grantee public key; a rotation is the account private key re-wrapped under a new AUK), so they are additive when they ship. Do not build against them until they appear here.

Operations

Sessions.

Both auth paths return a token and an expires_at. Attach the token on every authenticated call as an HTTP header:

Authorization: Bearer <token>

The server stores only a hash of the token, never the token itself, and checks expiry on each call. A request with a missing, unknown or expired token gets 401. A human session can reach any vault the account has access to. A machine session is pinned to the single vault its credential is scoped to, and reaching past it returns 403. Some endpoints additionally require a human session: creating a vault, minting or revoking a machine credential. A machine token on those returns 403.

Operations

Rate limits.

Limits are per-IP and, on the auth paths, also per-account or per-credential. Hitting one returns 429 with { "error": "too many attempts" } (or too_many on enrollment). Back off and retry.

  • Enrollment. A fixed window per IP caps how many account shells one source can mint. This is the only gate on the unauthenticated mint path.
  • SRP start. A fixed window per IP caps the expensive modular-exponentiation work, on top of the failure limiter.
  • Auth failures. Repeated failed SRP or machine auth trips a failure limiter keyed by IP and by account or credential. A clean success resets it, so normal use never sees it.

The edge sits behind nginx, which forwards the real client address. Per-IP limiting is defence in depth; the per-account and per-credential limits are the controls that cannot be spoofed by rotating source addresses.

Operations

Errors & help.

Every API error is a JSON object with one field and a matching HTTP status:

{ "error": "<reason>" }
StatusWhen
400Malformed JSON, a bad field, or an undecodable base64/base64url value (bad json, bad fields, bad key_ref, bad ciphertext).
401Auth did not verify, or the bearer token is missing, unknown or expired (auth failed, invalid or expired session).
403Out of scope or wrong session kind: a machine token reaching past its vault, a machine token on a human-only call, no access to the vault, an account that is not active, a bad or disabled admin key.
404No live record at this id or key_ref, including one behind a soft-delete tombstone (not found).
409A conflicting write, such as reusing an id that already exists (conflict).
429A rate limit tripped. See rate limits.
500An internal error. Nothing leaked; the call simply did not complete.

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 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

For access questions or an operational problem, 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 your client 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 → token
2.  plaintext = random bytes
3.  ref = base64url(HMAC(index_key, normalize("test/roundtrip")))
4.  PUT  /v1/vaults/{vault}/secrets/{ref} # client encrypts under VK, uploads ciphertext+nonce
5.  GET  /v1/vaults/{vault}/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/vaults/{vault}/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/vaults/{vault}/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.