Quickstart

One round trip,
start to finish.

Request access, unlock with your passphrase and Secret Key, put a secret, get it back and issue a scoped machine credential. Same flow in every language. The crypto runs on your side, so the server only ever handles ciphertext.

1 request access 2 set up & unlock 3 put a secret 4 get it back 5 issue a machine credential
SDK in development. Import path secretstore and method shapes are committed. The package is not published yet.
1Request access

Enrollment is public and manual. You post a contact, an operator approves it by hand, then your client claims the account with a one-time token. There is no self-serve signup.

import "secretstore"

app, _ := secretstore.Enroll(ctx, "https://secrets.lab.stratus5.net", "you@example.com")
// app.Status == "pending". Wait for an operator to approve.
// Once approved, GET /v1/enroll/{id} returns a one-time claim token.
claim, _ := secretstore.PollClaim(ctx, app.ID)
2Set up and unlock

First run does a one-time Init. The client generates your 256-bit Secret Key as an Emergency Kit, takes your passphrase, derives the Account Unlock Key, builds the account keypair and a first vault, then uploads only public and wrapped material. After that you unlock with the same two secrets over SRP-6a.

// one-time: generates SK (store the Emergency Kit), derives AUK, uploads public material
kit, _ := secretstore.Init(ctx, claim, secretstore.Passphrase("correct horse battery staple"))
fmt.Println(kit.SecretKey) // shown once. Store it. We never see it.

// every session after: unlock with passphrase + Secret Key (SRP-6a, neither leaves the device)
client, _ := secretstore.Unlock(ctx,
    secretstore.Passphrase("correct horse battery staple"),
    secretstore.SecretKey(kit.SecretKey),
)
3Put a secret

The client computes the blind index, encrypts the value under the vault key and uploads ciphertext. Pass any bytes you like, the value is an opaque document the server never parses.

vault := client.Vault(kit.VaultID)
_ = vault.Put(ctx, "stripe/secret-key", []byte("sk_live_9c2f..."))
// under the hood: key_ref = HMAC(index_key, normalize(name));
// ciphertext = XChaCha20-Poly1305(VK, value); PUT /v1/secrets/{key_ref}
4Get it back

Fetch and decrypt in one call. If the stored blob was altered, decryption fails closed and you get an error, never wrong plaintext.

val, err := vault.Get(ctx, "stripe/secret-key")
// GET /v1/secrets/{key_ref}; client decrypts under VK
if err != nil { // tampered ciphertext or wrong key → fails closed }
fmt.Printf("%s\n", val) // sk_live_9c2f...
5Issue a scoped machine credential

Mint a credential scoped to one vault for a CI job or a service. The client generates the machine key, derives the auth secret and unwrap key, wraps the vault key and uploads the slot. The machine key is the single secret you place in the consumer's config.

cred, _ := vault.IssueMachineCredential(ctx, "ci-prod")
fmt.Println(cred.MachineKey) // the one secret for the machine's config. Scoped to this vault only.

// the consumer, headless, unlocks with just that key:
m, _ := secretstore.UnlockMachine(ctx, secretstore.MachineKey(cred.MachineKey))
v, _ := m.Get(ctx, "stripe/secret-key") // POST /v1/auth/machine → session → GET
Real HTTP with java.net.http.HttpClient. The crypto steps are described in prose and will ship as a helper library. Do not treat the crypto as a callable method yet.
1Request access

Post a contact to the public enrollment endpoint. You get an application_id with status=pending. An operator approves it by hand, after which GET /v1/enroll/{id} returns a one-time claim token.

var http = HttpClient.newHttpClient();
var base = "https://secrets.lab.stratus5.net";

var enroll = HttpRequest.newBuilder(URI.create(base + "/v1/enroll"))
    .header("content-type", "application/json")
    .POST(BodyPublishers.ofString("{\"contact\":\"you@example.com\"}"))
    .build();
var res = http.send(enroll, BodyHandlers.ofString());
// { "application_id": "app_...", "status": "pending" }
// later, once approved:
// GET /v1/enroll/{id} → { "status": "approved", "enrollment_claim_token": "..." }
2Set up and unlock

Client-side crypto, helper library in development. Generate a 256-bit Secret Key and keep it as your Emergency Kit. Derive the Account Unlock Key as Argon2id(passphrase, salt) XOR HKDF-SHA256(SK, account_id, "auk"). Generate an X25519 account keypair, wrap the private half under the AUK, generate a first vault key and wrap it to the account public key. See the 2SKD section. Then post the public and wrapped material to init.

// after deriving verifier/pubkey/enc_privkey/wrapped_vk client-side:
var body = """
  { "enrollment_claim_token":"...", "srp_verifier":"<b64>", "srp_salt":"<b64>",
    "kdf_salt":"<b64>", "account_pubkey":"<b64>",
    "enc_account_privkey":"<b64>", "wrapped_vk":"<b64>" }""";
http.send(HttpRequest.newBuilder(URI.create(base + "/v1/accounts/init"))
    .header("content-type", "application/json")
    .POST(BodyPublishers.ofString(body)).build(), BodyHandlers.ofString());
// → { "account_id":"acct_...", "vault_id":"vlt_...", "status":"active" }

To unlock a session, run the SRP-6a exchange: POST /v1/auth/srp/start with your identity and ephemeral A, derive the shared key from the returned salt and B, then POST /v1/auth/srp/finish with M1. The passphrase and Secret Key never leave the client. The response carries the session you attach to later calls.

3Put a secret

Client-side crypto. Compute key_ref = HMAC-SHA256(index_key, normalize(name)) and encrypt the value with XChaCha20-Poly1305 under the vault key, with the AAD bound to account_id ∥ vault_id ∥ key_ref. Then PUT the ciphertext.

http.send(HttpRequest.newBuilder(URI.create(base + "/v1/secrets/" + keyRef))
    .header("content-type", "application/json")
    .header("authorization", session)
    .PUT(BodyPublishers.ofString(
       "{\"ciphertext\":\"<b64>\",\"nonce\":\"<b64>\",\"alg\":\"xchacha20poly1305\"}"))
    .build(), BodyHandlers.ofString());
4Get it back

GET the same ref, then decrypt the returned blob client-side. A tampered ciphertext or wrong key fails the Poly1305 tag and decryption fails closed.

var got = http.send(HttpRequest.newBuilder(URI.create(base + "/v1/secrets/" + keyRef))
    .header("authorization", session)
    .GET().build(), BodyHandlers.ofString());
// { "ciphertext":"<b64>", "nonce":"<b64>", "alg":"xchacha20poly1305" }
// then: plaintext = XChaCha20-Poly1305.open(VK, nonce, ciphertext, aad)  // client-side
5Issue a scoped machine credential

Client-side crypto. Generate a machine key, derive auth_secret = HKDF(MK,"auth") and unwrap_key = HKDF(MK,"unwrap"), wrap the vault key under the unwrap key and compute Argon2id(auth_secret) as the verifier. The machine key is the one secret you give the consumer. See the machine credentials section.

http.send(HttpRequest.newBuilder(URI.create(base + "/v1/credentials/machine"))
    .header("content-type", "application/json")
    .header("authorization", session)
    .POST(BodyPublishers.ofString(
       "{\"vault_id\":\"vlt_...\",\"label\":\"ci-prod\",\"auth_verifier\":\"<b64>\",\"wrapped_vk\":\"<b64>\"}"))
    .build(), BodyHandlers.ofString());
// the consumer then POSTs auth_secret to /v1/auth/machine to get a vault-scoped session
Real HTTP with the built-in fetch. The crypto steps are described in prose and will ship as a helper library. Do not treat the crypto as a callable method yet.
1Request access

Post a contact to the public enrollment endpoint, then poll for approval. Approval is manual.

const base = "https://secrets.lab.stratus5.net";

const r = await fetch(`${base}/v1/enroll`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ contact: "you@example.com" }),
});
const { application_id } = await r.json(); // status: "pending"
// once approved: GET /v1/enroll/{id} → { enrollment_claim_token }
2Set up and unlock

Client-side crypto, helper library in development. Generate a 256-bit Secret Key as your Emergency Kit. Derive the AUK as Argon2id(passphrase, salt) XOR HKDF-SHA256(SK, account_id, "auk"). Build the X25519 account keypair, wrap its private half under the AUK, make a first vault key wrapped to the account public key. See the 2SKD section.

await fetch(`${base}/v1/accounts/init`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    enrollment_claim_token: claim,
    srp_verifier: vB64, srp_salt: sB64, kdf_salt: kB64,
    account_pubkey: pubB64, enc_account_privkey: encPrivB64, wrapped_vk: wvkB64,
  }),
});
// → { account_id, vault_id, status: "active" }

For a session, run the SRP-6a exchange: POST /v1/auth/srp/start with A, derive the shared key from the returned salt and B, then POST /v1/auth/srp/finish with M1. The response carries the session. Neither secret leaves the process.

3Put a secret

Client-side crypto. Compute key_ref = HMAC-SHA256(index_key, normalize(name)) and encrypt with XChaCha20-Poly1305 under the vault key, AAD bound to account_id ∥ vault_id ∥ key_ref. Then PUT the ciphertext.

await fetch(`${base}/v1/secrets/${keyRef}`, {
  method: "PUT",
  headers: { "content-type": "application/json", authorization: session },
  body: JSON.stringify({ ciphertext: ctB64, nonce: nonceB64, alg: "xchacha20poly1305" }),
});
4Get it back

GET the ref, then decrypt client-side. Altered ciphertext fails the tag and decryption fails closed.

const g = await fetch(`${base}/v1/secrets/${keyRef}`, {
  headers: { authorization: session },
});
const blob = await g.json(); // { ciphertext, nonce, alg }
// plaintext = xchacha20poly1305_open(VK, blob.nonce, blob.ciphertext, aad)  // client-side
5Issue a scoped machine credential

Client-side crypto. Generate the machine key, derive HKDF(MK,"auth") and HKDF(MK,"unwrap"), wrap the vault key under the unwrap key and store Argon2id(auth_secret) as the verifier. The machine key is the only secret the consumer needs. See the machine credentials section.

await fetch(`${base}/v1/credentials/machine`, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: session },
  body: JSON.stringify({
    vault_id: "vlt_...", label: "ci-prod",
    auth_verifier: avB64, wrapped_vk: mwvkB64,
  }),
});
// consumer posts auth_secret to /v1/auth/machine for a vault-scoped session
Real HTTP with System.Net.Http.HttpClient. The crypto steps are described in prose and will ship as a helper library. Do not treat the crypto as a callable method yet.
1Request access

Post a contact to enrollment and wait for manual approval, then read the one-time claim token from the application status.

var http = new HttpClient { BaseAddress = new Uri("https://secrets.lab.stratus5.net") };

var res = await http.PostAsync("/v1/enroll",
    new StringContent("{\"contact\":\"you@example.com\"}",
        Encoding.UTF8, "application/json"));
// { "application_id": "app_...", "status": "pending" }
// once approved: GET /v1/enroll/{id} → { enrollment_claim_token }
2Set up and unlock

Client-side crypto, helper library in development. Generate a 256-bit Secret Key as your Emergency Kit. Derive the AUK as Argon2id(passphrase, salt) XOR HKDF-SHA256(SK, account_id, "auk"). Build the X25519 account keypair, wrap the private half under the AUK, make a first vault key wrapped to the account public key. See the 2SKD section.

var body = """
  { "enrollment_claim_token":"...", "srp_verifier":"<b64>", "srp_salt":"<b64>",
    "kdf_salt":"<b64>", "account_pubkey":"<b64>",
    "enc_account_privkey":"<b64>", "wrapped_vk":"<b64>" }""";
await http.PostAsync("/v1/accounts/init",
    new StringContent(body, Encoding.UTF8, "application/json"));
// → { "account_id":"acct_...", "vault_id":"vlt_...", "status":"active" }

For a session, run the SRP-6a exchange against /v1/auth/srp/start and /v1/auth/srp/finish: send A, derive the shared key from the returned salt and B, then send M1. The passphrase and Secret Key never leave the client.

3Put a secret

Client-side crypto. Compute key_ref = HMAC-SHA256(index_key, normalize(name)) and encrypt with XChaCha20-Poly1305 under the vault key, AAD bound to account_id ∥ vault_id ∥ key_ref. Then PUT the ciphertext.

http.DefaultRequestHeaders.Add("authorization", session);
await http.PutAsync($"/v1/secrets/{keyRef}",
    new StringContent(
        "{\"ciphertext\":\"<b64>\",\"nonce\":\"<b64>\",\"alg\":\"xchacha20poly1305\"}",
        Encoding.UTF8, "application/json"));
4Get it back

GET the ref and decrypt client-side. Tampered ciphertext fails the Poly1305 tag and decryption fails closed.

var g = await http.GetStringAsync($"/v1/secrets/{keyRef}");
// { "ciphertext":"<b64>", "nonce":"<b64>", "alg":"xchacha20poly1305" }
// plaintext = XChaCha20Poly1305.Open(VK, nonce, ciphertext, aad);  // client-side
5Issue a scoped machine credential

Client-side crypto. Generate the machine key, derive HKDF(MK,"auth") and HKDF(MK,"unwrap"), wrap the vault key under the unwrap key and store Argon2id(auth_secret) as the verifier. Hand the machine key to the consumer. See the machine credentials section.

await http.PostAsync("/v1/credentials/machine",
    new StringContent(
        "{\"vault_id\":\"vlt_...\",\"label\":\"ci-prod\",\"auth_verifier\":\"<b64>\",\"wrapped_vk\":\"<b64>\"}",
        Encoding.UTF8, "application/json"));
// consumer posts auth_secret to /v1/auth/machine for a vault-scoped session

Once you can put and get a secret, prove the guarantee for yourself. The testing section walks a round-trip assert and a tamper assert against a local instance. The full endpoint reference is in the docs.