Agent index (llms.txt) — fetch this first, then the page it names.
Vault
Orama Vault stores small secrets by splitting them with Shamir's Secret Sharing across guardian nodes, so that no guardian holds the secret. Status: partial; guardians do not yet discover each other and nothing re-shares or repairs shares, so do not use it as the only copy of anything important. Guardian nodes run the vault-guardian service, written in Zig. The vault is part of the network, not a separate product: operators run guardians as described in Vault guardians.
How It Works
Storing a secret works like this. With the vault SDK (from inside the mesh) the client does the steps itself; through the gateway's /v1/vault routes the gateway performs the same split in its own memory:
- +The secret is split into one share per guardian with Shamir's Secret Sharing over GF(2^8).
- +Each share is sent to a different guardian over an authenticated session. The guardian stores the share as bytes. Neither the SDK nor the guardian encrypts it.
- +The adaptive threshold (the minimum number of shares needed to reconstruct) and the write quorum are calculated from the guardian count.
To retrieve the secret, the SDK collects shares from guardians and reconstructs the original value.
This means:
- +No single guardian sees the secret. Fewer than K shares reveal nothing about it, whatever the attacker's computing power. K guardians that collude can reconstruct it (K is about a third of the fleet).
- +Splitting happens before storage. With the SDK the secret is split before it leaves the client; via the gateway it is split in the gateway's memory, which therefore holds the whole value for the length of a request.
- +Encrypt first. The vault stores exactly the bytes it is given. What you store should already be ciphertext;
@debros/orama-vaultexports AES-256-GCM and HKDF helpers for that (see Crypto Utilities), butVaultClientdoes not call them. - +Fault tolerant. A secret survives guardian failures as long as the read threshold is met.
- +Write quorum. A write is reported successful only when at least W guardians acknowledged it, and W is always larger than K.
- +Zeroed shares. The SDK and the gateway zero their share buffers after each operation. They do not zero everything: request bodies and base64 strings are left to the garbage collector.
Architecture
The vault has two access paths:
Gateway Proxy (Client → Gateway → Guardians)
This is the path for applications. The Orama gateway exposes V1 vault endpoints over HTTPS and performs the Shamir splitting and combining server-side. Each request proves ownership of the identity with a fresh Ed25519 signature.
Direct (Tooling → Guardians)
Guardians listen on the WireGuard overlay (10.0.0.x), so only software running
on the mesh can reach them: node agents, operator tooling and RootWallet. That
client is published separately as @debros/orama-vault. It used to ship inside
@debros/orama, which offered every application an API it had no route to.
| Path | API Version | Who splits/combines | Auth | For |
|---|---|---|---|---|
| Client → Gateway | V1 | Gateway | Per-request Ed25519 ownership signature | Applications |
| Tooling → Guardians | V2 | Client | Guardian session token plus a per-request Ed25519 ownership signature | Software on the WireGuard overlay |
An identity is the hex SHA-256 of an Ed25519 public key. There is no registration: any key pair is an identity, and the first successful write creates it.
Setup
The direct client is its own package (Node 20.19 or later). Install it only where the guardians are reachable:
npm install @debros/orama-vault
import { VaultClient } from "@debros/orama-vault";
const vault = new VaultClient({
guardians: [
{ address: "10.0.0.1", port: 10106 },
{ address: "10.0.0.2", port: 10106 },
{ address: "10.0.0.3", port: 10106 },
],
privateKey, // 32-byte Ed25519 seed (Uint8Array)
timeoutMs: 5000,
});| Field | Type | Required | Description |
|---|---|---|---|
guardians | GuardianEndpoint[] | Yes | Guardian nodes that hold shares. Each needs address and port (10106 on a node). This list is the unit of redundancy: a secret is split into one share per guardian listed, and the write quorum is computed from its size. |
privateKey | Uint8Array | Yes | 32-byte Ed25519 seed. The identity is the SHA-256 of its public key, and every request is signed with it. |
identityHex | string | No | If set, it must equal the identity derived from privateKey, or the constructor throws. Leave it out. |
timeoutMs | number | No | Request timeout in milliseconds per guardian (default 10000). |
The list needs at least three guardians to run Shamir. With one guardian the split fails; applications on a one-node eval cluster use the gateway, which stores the envelope whole (see Threshold Configuration).
Storing Secrets
Pass a name, the secret as a Uint8Array, and a version number:
const data = new TextEncoder().encode("my-secret-data");
const result = await vault.store("api-key", data, 1);
console.log(result.ackCount); // number of successful writes
console.log(result.totalContacted); // guardians the write was addressed to
console.log(result.failCount); // number of write failures
console.log(result.guardianResults); // per-guardian resultsEach guardian write is attempted up to three times, with 200 ms and 400 ms between attempts; authentication and not-found errors are not retried.
A write that does not reach quorum throws a QuorumError carrying ackCount,
required and the per-guardian results, so anything store returns is durable.
It used to resolve with quorumMet: false, which a caller who did not inspect
that field — the obvious way to use a promise that resolves — read as success.
Versions only go up: a guardian refuses a version that is not strictly greater than the one it holds. After a QuorumError, retry with a higher version, not the same one. Guardians that already took the failed write refuse a repeat, while the rest take a share of a new split, and a version that holds shares of two splits can reconstruct to garbage without an error.
Secret Name Constraints
| Constraint | Limit |
|---|---|
| Allowed characters | Alphanumeric, _, - |
| Max name length | 128 characters |
| Max secrets per identity | 1,000 |
| Max secret size | 512 KiB minus one byte (a share is the secret plus one byte for its x coordinate, and a guardian accepts 512 KiB) |
Retrieving Secrets
The SDK asks every guardian for its share and reconstructs the original value:
const retrieved = await vault.retrieve("api-key");
console.log(new TextDecoder().decode(retrieved.data)); // "my-secret-data"
console.log(retrieved.sharesCollected); // number of shares used
console.log(retrieved.version); // the version returnedReconstruction requires at least the read threshold number of shares, calculated automatically from the guardian count. Each guardian is given 10 seconds to answer.
Shares are grouped by the version they belong to and only shares of one version
are ever combined. A guardian that missed the last write still answers, with a
share of the previous split; mixing it with newer shares reconstructs neither
version and raises no error. If the newest version is short of the threshold —
a write that did not reach quorum — the newest version that is complete is
returned, and version says which. When no version has enough shares,
retrieve throws a QuorumError naming what it collected.
The threshold is computed from the guardian list in your configuration, not stored with the secret. Keep the list the same between store and retrieve: if it shrinks or grows so that the threshold changes, a combine of too few shares returns wrong bytes without an error.
Listing Secrets
List all secrets stored under your identity:
const secrets = await vault.list();
console.log(secrets.secrets); // [{ name, version, size }, ...]A secret is listed when at least the read threshold of guardians report holding a share of it, because that is exactly the condition under which it can be reconstructed. The version reported is the newest any guardian holds.
Deleting Secrets
Delete a secret from all guardians:
const deleteResult = await vault.delete("api-key");
console.log(deleteResult.ackCount); // guardians that dropped their shareLike a write, a delete that does not reach quorum throws a QuorumError: enough
guardians still hold a share to reconstruct the secret, so it is not gone.
Gateway Proxy API
For clients that cannot connect directly to guardians, the gateway exposes V1 vault endpoints. The gateway performs Shamir splitting/combining server-side. A client makes one HTTPS call and the gateway fans out to the guardians.
These endpoints are not protected by the namespace API key — the /v1/vault/ path is public in the gateway's routing. Instead, each push and pull request must prove ownership of the identity with a fresh Ed25519 signature, verified independently by the gateway and by every guardian it forwards to. There is no SDK helper for the gateway proxy path; the caller is responsible for holding the Ed25519 identity keypair and signing the canonical message itself.
Store via Gateway
The request body requires identity (64 hex chars, = SHA-256(pubkey)), the base64-encoded envelope (up to 512 KiB minus one byte once decoded, and it should already be ciphertext), a monotonically increasing version, the hex-encoded pubkey, and a hex-encoded Ed25519 signature over the ASCII message vault-push-v1:<identity>:<version>. The body is limited to 1 MiB.
const message = `vault-push-v1:${identityHex}:${version}`;
const signature = sign(message, privateKey); // your Ed25519 signer, hex-encoded
const res = await fetch("https://ns-myapp.cluster.example.com/v1/vault/push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: identityHex,
envelope: base64EncryptedData,
version,
pubkey: pubkeyHex,
signature,
}),
});
const { status, ack_count, total, quorum, threshold } = await res.json();| Status | Meaning |
|---|---|
200 ok | At least quorum guardians stored the share |
| 400 | Malformed JSON, identity not 64 hex characters, bad base64, or an empty envelope |
| 401 | invalid ownership signature |
409 version_conflict | A quorum of guardians already has this version or newer; re-read and bump version before retrying |
| 429 | Rate limited (see below) |
503 insufficient_quorum | Fewer than quorum guardians stored the share. Some may have stored it anyway, so use a higher version for the retry. A 503 no guardian nodes available means the gateway found no guardian |
Retrieve via Gateway
The request body requires identity, pubkey, a timestamp (unix seconds, must be within 120 seconds of the gateway's clock), and a hex-encoded Ed25519 signature over the ASCII message vault-pull-v1:<identity>:<timestamp>. The body is limited to 4 KiB.
const timestamp = Math.floor(Date.now() / 1000);
const message = `vault-pull-v1:${identityHex}:${timestamp}`;
const signature = sign(message, privateKey);
const res = await fetch("https://ns-myapp.cluster.example.com/v1/vault/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: identityHex,
pubkey: pubkeyHex,
signature,
timestamp,
}),
});
const { envelope, collected, threshold } = await res.json();The gateway groups the guardians' shares by version and combines the newest version that has at least its stored threshold. If no version has enough shares the answer is 503 not enough consistent shares; an identity that never pushed gets the same 503, not a 404.
Health Check
curl https://ns-myapp.cluster.example.com/v1/vault/health
No authentication required. Returns {"status": "healthy" | "degraded" | "unavailable"}: healthy when at least W guardians answer, degraded when at least K do, unavailable otherwise. GET /v1/vault/status returns {guardians, healthy, threshold, write_quorum}.
Rate Limits
The gateway proxy enforces limits per source IP and per identity. An IP is charged before the signature is checked, so guessing seeds across many identities from one address runs into a limit that the per-identity bucket cannot see:
| Operation | Per source IP | Per identity |
|---|---|---|
| Push (store) | 30 requests/minute | 30 requests/hour |
| Pull (retrieve) | 60 requests/minute | 120 requests/hour |
Limits are per gateway process and reset when it restarts.
REST API Reference
Guardian V2 Endpoints (Direct)
Used by the SDK when connecting directly to guardian nodes.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v2/vault/auth/challenge | None | Request authentication challenge |
| POST | /v2/vault/auth/session | None | Exchange challenge response for session token |
| PUT | /v2/vault/secrets/{name} | Session and ownership signature | Store a named secret share |
| GET | /v2/vault/secrets/{name} | Session and ownership signature | Retrieve a named secret share |
| DELETE | /v2/vault/secrets/{name} | Session and ownership signature | Delete a named secret share |
| GET | /v2/vault/secrets | Session and ownership signature | List all secrets for identity |
Session tokens are passed via the X-Session-Token header. Sessions expire after 1 hour and are invalidated on guardian restart.
Every secrets call also carries the ownership proof as headers:
| Header | Value |
|---|---|
X-Vault-Pubkey | 64 hex characters, the Ed25519 public key; its SHA-256 must equal the identity in the session token |
X-Vault-Signature | 128 hex characters, the Ed25519 signature over the message below |
X-Vault-Timestamp | Unix seconds; required on GET, DELETE and list, accepted within 120 seconds of the guardian's clock |
| Call | Signed ASCII message |
|---|---|
| PUT | vault-secret-put-v1:<identity>:<name>:<version> |
| GET | vault-secret-get-v1:<identity>:<name>:<timestamp> |
| DELETE | vault-secret-delete-v1:<identity>:<name>:<timestamp> |
| list | vault-secret-list-v1:<identity>:<timestamp> |
PUT takes a JSON body {"share": "<base64>", "version": <uint64>} and answers {"status":"stored","name":...,"version":...}. The share is [x][y...]: one x coordinate byte (1 to 255) followed by one y byte per secret byte, 512 KiB at most once decoded. GET answers {"share","name","version","created_ns","updated_ns"}, DELETE {"status":"deleted","name"}, list {"secrets":[{"name","version","size","created_ns","updated_ns"}]}.
Errors are {"error": "..."} with these statuses:
| Status | Cause |
|---|---|
| 400 | Empty or oversized body (1 MiB), invalid JSON or base64, empty or oversized share, invalid secret name, secret limit exceeded (1,000), or a version not greater than the stored one |
| 401 | session token required, invalid session token, or invalid ownership signature |
| 404 | secret not found |
| 429 | More than 120 requests in 60 seconds from one source IP (every endpoint counts, health included) |
| 500 | Disk failure, or a share that fails its integrity check |
Gateway V1 Endpoints (Proxy)
These paths are public in the gateway's routing — the namespace API key is not checked. Push and pull are instead authenticated per-request with an Ed25519 ownership signature (see Gateway Proxy API).
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/vault/push | Ed25519 ownership signature | Store secret (gateway splits into shares) |
| POST | /v1/vault/pull | Ed25519 ownership signature | Retrieve secret (gateway combines shares) |
| GET | /v1/vault/health | None | Aggregate guardian health |
| GET | /v1/vault/status | None | Guardian count, healthy count, threshold, write quorum |
Guardian Health Endpoints
On each guardian, for operators. See Vault guardians for what the answers mean.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/vault/health | None | Status, version, share count, peers, data_dir_ok |
| GET | /v1/vault/status | None | Runtime config (data_dir, ports) |
| GET | /v1/vault/guardians | None | Guardians this guardian knows (empty today) |
Authentication
A guardian checks two things on every direct call.
A session token. The direct V2 path issues one:
- +Client requests a challenge from the guardian (
/v2/vault/auth/challenge) - +Guardian returns a nonce, a timestamp (
created_ns), and an HMAC tag over both computed with the guardian's own server secret, which it draws at start and never persists - +Client sends those values back with its identity (
/v2/vault/auth/session) - +Guardian recomputes the tag, requires the challenge to be under 60 seconds
old, and returns
identity,expiry_nsand a tag. The client joins them as<identity>:<expiry_ns>:<tag>, the token forX-Session-Token
No client secret takes part in this exchange. The guardian verifies a tag it produced itself, so the session proves only that the caller completed a challenge on this guardian, not that it holds the identity's key.
An ownership proof. That is the layer that binds a request to an identity: the Ed25519 signature described above, over a message that names the operation, the secret and a version or timestamp. The guardian checks that the SHA-256 of the public key equals the identity in the session token, so a caller that does not hold the key cannot present a valid proof for that identity.
Guardians trust the WireGuard overlay for everything else: the transport between a client and a guardian is plain HTTP inside the tunnel, and the unauthenticated status and health endpoints answer anyone on the mesh. The technical reference's Vault chapter (docs/whitepaper/technical-reference/vol1/28-vault.md, "Trust and security" and "Known gaps") lists the weaknesses that remain.
Requests through the gateway (/v1/vault/push and /v1/vault/pull) carry the
same kind of Ed25519 proof, and the gateway runs the guardian handshake itself.
Session Lifecycle
| Property | Value |
|---|---|
| Expiry | 1 hour |
| Caching | Per guardian in the SDK, until 30 seconds before expiry |
| Invalidation | On guardian restart |
| After a restart | Calls fail with an AUTH error and are not retried; call vault.clearSessions() and repeat |
Threshold Configuration
The adaptive threshold is calculated automatically based on how many guardians you configure. You do not set it manually. The system determines:
- +Read threshold (K) —
max(2, floor(N/3)). The minimum number of shares needed to reconstruct the secret; any K−1 shares reveal nothing. - +Write quorum (W) —
min(N, max(K+1, ceil(2N/3))). The number of guardians that must acknowledge a write for it to succeed.
The invariant W > K is deliberate: a write is only reported successful once it has persisted strictly more shares than a read requires, so a successful write is always recoverable and survives losing at least one guardian.
| Guardians (N) | Read Threshold (K) | Write Quorum (W) |
|---|---|---|
| 3 | 2 | 3 |
| 5 | 2 | 4 |
| 7 | 2 | 5 |
| 9 | 3 | 6 |
| 12 | 4 | 8 |
More guardians means higher fault tolerance, and reads tolerate more failures than writes: reads need K guardians, writes need W. With three guardians, losing one still allows full reconstruction, but every write is refused until it is back, because W equals N.
The gateway, the SDK and the guardian's own code compute these formulas independently and must agree exactly. On a one-node eval cluster Shamir cannot run (it needs K of at least 2), so the gateway stores the envelope whole as a local key with K = 1 and W = 1. That is not secret sharing: one disk holds the ciphertext.
Crypto Utilities
@debros/orama-vault exports the low-level cryptographic primitives it uses
internally. They were exported from @debros/orama until they moved with the
rest of the vault client:
import {
// Shamir's Secret Sharing
shamirSplit,
shamirCombine,
// Symmetric encryption
encrypt,
decrypt,
encryptString,
decryptString,
encryptAndSerialize,
deserializeAndDecrypt,
// Key management
generateKey,
generateNonce,
clearKey,
deriveKeyHKDF,
// Identity
identityFromSeed,
publicKeyFromSeed,
// Serialization
serializeEncrypted,
deserializeEncrypted,
encryptedToHex,
encryptedFromHex,
encryptedToBase64,
encryptedFromBase64,
// Validation
isValidEncryptedData,
} from "@debros/orama-vault";| Function | Description |
|---|---|
shamirSplit / shamirCombine | Split and reconstruct secrets using Shamir's Secret Sharing |
encrypt / decrypt | AES-256-GCM with a random 96-bit nonce prepended and optional additional authenticated data |
encryptString / decryptString | String convenience wrappers |
encryptAndSerialize / deserializeAndDecrypt | Encrypt + serialize in one step |
generateKey / generateNonce | Cryptographic key and nonce generation |
clearKey | Zero a key in memory |
deriveKeyHKDF | Derive keys using HKDF-SHA256 |
identityFromSeed / publicKeyFromSeed | The identity (SHA-256 of the public key) and the Ed25519 public key for a 32-byte seed |
serializeEncrypted / deserializeEncrypted | Binary serialization helpers |
encryptedToHex / encryptedFromHex / encryptedToBase64 / encryptedFromBase64 | Encoding utilities |
isValidEncryptedData | Validate encrypted data structure |
Typical use is to encrypt first and store the result:
const key = deriveKeyHKDF(masterKey, "my-app", "api-key");
const sealed = encryptAndSerialize(plaintext, key);
await vault.store("api-key", sealed.data, 1);Access Control
Secrets are scoped to your identity. Each guardian checks the ownership signature of every call, so only the holder of the key behind an identity can present a valid proof for it, and different identities on the same guardians have separate secret directories. One known bug weakens this on the direct path: a guardian's V2 GET and DELETE handlers do not validate the secret name the way PUT does, so a caller on the overlay with any valid identity can address another identity's secret on that guardian. It is recorded under "Known gaps" in the technical reference's Vault chapter. Applications on the gateway path are not exposed to it, because the gateway does not serve V2.