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)
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),
)
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}
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...
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
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": "..." }
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.
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());
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
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
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 }
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.
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" }),
});
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
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
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 }
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.
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"));
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
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.