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:

  1. +The secret is split into one share per guardian with Shamir's Secret Sharing over GF(2^8).
  2. +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.
  3. +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-vault exports AES-256-GCM and HKDF helpers for that (see Crypto Utilities), but VaultClient does 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.

PathAPI VersionWho splits/combinesAuthFor
Client → GatewayV1GatewayPer-request Ed25519 ownership signatureApplications
Tooling → GuardiansV2ClientGuardian session token plus a per-request Ed25519 ownership signatureSoftware 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:

bash
npm install @debros/orama-vault
typescript
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,
});
FieldTypeRequiredDescription
guardiansGuardianEndpoint[]YesGuardian 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.
privateKeyUint8ArrayYes32-byte Ed25519 seed. The identity is the SHA-256 of its public key, and every request is signed with it.
identityHexstringNoIf set, it must equal the identity derived from privateKey, or the constructor throws. Leave it out.
timeoutMsnumberNoRequest 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:

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

Each 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

ConstraintLimit
Allowed charactersAlphanumeric, _, -
Max name length128 characters
Max secrets per identity1,000
Max secret size512 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:

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

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

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

typescript
const deleteResult = await vault.delete("api-key");
console.log(deleteResult.ackCount); // guardians that dropped their share

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

typescript
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();
StatusMeaning
200 okAt least quorum guardians stored the share
400Malformed JSON, identity not 64 hex characters, bad base64, or an empty envelope
401invalid ownership signature
409 version_conflictA quorum of guardians already has this version or newer; re-read and bump version before retrying
429Rate limited (see below)
503 insufficient_quorumFewer 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.

typescript
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

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

OperationPer source IPPer identity
Push (store)30 requests/minute30 requests/hour
Pull (retrieve)60 requests/minute120 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.

MethodPathAuthDescription
POST/v2/vault/auth/challengeNoneRequest authentication challenge
POST/v2/vault/auth/sessionNoneExchange challenge response for session token
PUT/v2/vault/secrets/{name}Session and ownership signatureStore a named secret share
GET/v2/vault/secrets/{name}Session and ownership signatureRetrieve a named secret share
DELETE/v2/vault/secrets/{name}Session and ownership signatureDelete a named secret share
GET/v2/vault/secretsSession and ownership signatureList 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:

HeaderValue
X-Vault-Pubkey64 hex characters, the Ed25519 public key; its SHA-256 must equal the identity in the session token
X-Vault-Signature128 hex characters, the Ed25519 signature over the message below
X-Vault-TimestampUnix seconds; required on GET, DELETE and list, accepted within 120 seconds of the guardian's clock
CallSigned ASCII message
PUTvault-secret-put-v1:<identity>:<name>:<version>
GETvault-secret-get-v1:<identity>:<name>:<timestamp>
DELETEvault-secret-delete-v1:<identity>:<name>:<timestamp>
listvault-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:

StatusCause
400Empty 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
401session token required, invalid session token, or invalid ownership signature
404secret not found
429More than 120 requests in 60 seconds from one source IP (every endpoint counts, health included)
500Disk 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).

MethodPathAuthDescription
POST/v1/vault/pushEd25519 ownership signatureStore secret (gateway splits into shares)
POST/v1/vault/pullEd25519 ownership signatureRetrieve secret (gateway combines shares)
GET/v1/vault/healthNoneAggregate guardian health
GET/v1/vault/statusNoneGuardian count, healthy count, threshold, write quorum

Guardian Health Endpoints

On each guardian, for operators. See Vault guardians for what the answers mean.

MethodPathAuthDescription
GET/v1/vault/healthNoneStatus, version, share count, peers, data_dir_ok
GET/v1/vault/statusNoneRuntime config (data_dir, ports)
GET/v1/vault/guardiansNoneGuardians this guardian knows (empty today)

Authentication

A guardian checks two things on every direct call.

A session token. The direct V2 path issues one:

  1. +Client requests a challenge from the guardian (/v2/vault/auth/challenge)
  2. +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
  3. +Client sends those values back with its identity (/v2/vault/auth/session)
  4. +Guardian recomputes the tag, requires the challenge to be under 60 seconds old, and returns identity, expiry_ns and a tag. The client joins them as <identity>:<expiry_ns>:<tag>, the token for X-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

PropertyValue
Expiry1 hour
CachingPer guardian in the SDK, until 30 seconds before expiry
InvalidationOn guardian restart
After a restartCalls 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)
323
524
725
936
1248

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:

typescript
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";
FunctionDescription
shamirSplit / shamirCombineSplit and reconstruct secrets using Shamir's Secret Sharing
encrypt / decryptAES-256-GCM with a random 96-bit nonce prepended and optional additional authenticated data
encryptString / decryptStringString convenience wrappers
encryptAndSerialize / deserializeAndDecryptEncrypt + serialize in one step
generateKey / generateNonceCryptographic key and nonce generation
clearKeyZero a key in memory
deriveKeyHKDFDerive keys using HKDF-SHA256
identityFromSeed / publicKeyFromSeedThe identity (SHA-256 of the public key) and the Ed25519 public key for a 32-byte seed
serializeEncrypted / deserializeEncryptedBinary serialization helpers
encryptedToHex / encryptedFromHex / encryptedToBase64 / encryptedFromBase64Encoding utilities
isValidEncryptedDataValidate encrypted data structure

Typical use is to encrypt first and store the result:

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