Vault Concepts

Understanding how Vault works makes it easier to use correctly and builds justified trust in the security model.


The master seed

Everything in Vault derives from a single 32-byte master seed.

Master seed: a3f1d7c2...  (32 random bytes, generated by your agent)

The seed is:

  • Generated with crypto.getRandomValues() (CSPRNG)
  • Never sent to AgentLair — it stays inside your agent
  • The root from which all encryption keys are derived
  • The thing you must protect above all else — lose it, lose access
import { VaultCrypto } from '@agentlair/vault-crypto'

// Generate once per agent identity
const seed = VaultCrypto.generateSeed()
const vc = VaultCrypto.fromSeed(seed)

// Export for backup
const hexSeed = vc.seedHex()  // 64 hex chars

Per-key derivation

Vault doesn’t use the master seed directly for encryption. Instead, it derives a unique AES-256-GCM key for each vault key name using HKDF.

Master seed  +  key name  →  HKDF-SHA-256  →  unique AES-256-GCM key

Label format: agentlair:vault:v1:{keyName}

Why this matters:

  • Knowing the ciphertext for openai-key reveals nothing about stripe-secret
  • Different vault keys can’t be cross-decrypted, even with partial seed knowledge
  • The derivation is deterministic: same seed + same key name = same AES key (always)
// Each key name gets its own derived AES key
const ct1 = await vc.encrypt('sk-openai-...', 'openai-key')
const ct2 = await vc.encrypt('sk-stripe-...', 'stripe-key')

// Both ciphertexts are decryptable only with the correct key name
const val1 = await vc.decrypt(ct1, 'openai-key')   // ✓
const wrong = await vc.decrypt(ct1, 'stripe-key')   // ✗ throws

Ciphertext format

The ciphertext field stored in Vault is base64url-encoded and contains:

base64url( IV[12 bytes] || AES-256-GCM encrypted data + 16-byte auth tag )
  • IV: 12 bytes, cryptographically random per encryption call
  • Auth tag: 16 bytes, AES-GCM integrity check — tampered ciphertext throws on decrypt
  • Encoding: base64url (URL-safe, no padding)

Each encrypt() call produces a different ciphertext (different random IV) even for the same plaintext. This is correct behavior — it prevents traffic analysis.


Versioning

Every PUT /v1/vault/{key} creates a new version. The latest version is always returned by default.

PUT /v1/vault/openai-key  (first call)   → version 1
PUT /v1/vault/openai-key  (second call)  → version 2
GET /v1/vault/openai-key                 → version 2 (latest)
GET /v1/vault/openai-key?version=1       → version 1

Version limits by tier:

  • Free: 3 versions per key — oldest pruned when limit exceeded
  • Paid: 100 versions per key

Rotation pattern:

// Rotate an API key
const newApiKey = 'sk-new-...'
const ct = await vc.encrypt(newApiKey, 'openai-key')
await vault.put('openai-key', ct)
// Old versions still accessible until pruned
// Rollback: GET /v1/vault/openai-key?version=N-1

Metadata

Each vault key can carry arbitrary JSON metadata (max 4 KB). Metadata is not encrypted — it’s stored and returned in plaintext. Use it for non-sensitive operational data.

await vault.put('openai-key', ct, {
  metadata: {
    service: 'openai',
    environment: 'production',
    rotated_at: new Date().toISOString(),
  }
})

Metadata is returned in GET /v1/vault/{key} and GET /v1/vault/ responses. Use it for tagging, service attribution, or rotation tracking — not for secret data.


Zero-knowledge architecture

“Zero-knowledge” means AgentLair has zero knowledge of your secrets.

What the server stores:

Key name:   openai-key
Ciphertext: aeGx8kFZpQr...Wx9mN (opaque blob)
Metadata:   { "service": "openai" }
Version:    2

What the server does NOT have:

  • Your master seed
  • The derived AES keys
  • The plaintext values
  • Any way to decrypt the blobs

A full breach of AgentLair’s KV store would expose opaque ciphertexts. Without the seed (which never left your agent), they’re computationally indistinguishable from random noise.

The one honest caveat: If an attacker compromised the AgentLair worker code (not just the KV store), they could alter the API to return malicious ciphertext. This would fail decryption (AES-GCM auth tag mismatch) — you’d get an error, not silently wrong data. What it could not do is steal previously stored values, since those are encrypted with keys derived from seeds the server never had.


Passphrase-based access

For human operators or one-time recovery, you can derive a VaultCrypto instance from a passphrase instead of a raw seed:

// Deterministic: same passphrase always produces the same seed
const vc = await VaultCrypto.fromPassphrase('correct-horse-battery-staple')

Implementation: PBKDF2-SHA-256, 600,000 iterations (OWASP 2023 recommendation), fixed domain salt agentlair-vault-v1.

When to use:

  • Human-managed credentials where memorability matters
  • Recovery (see below)
  • Wrapping a randomly-generated seed for backup

When NOT to use:

  • Agent runtime credentials — use fromSeed() with a stored hex seed
  • Passphrase security depends entirely on passphrase entropy

Seed backup and recovery

Since AgentLair never has your seed, recovery requires that you store it somewhere retrievable.

Pattern: encrypted seed backup

// Encrypt your seed with a passphrase, store backup in Vault itself
const backup = await vc.encryptSeedBackup('correct-horse-battery-staple')

// Store under reserved key _master_seed_backup
await vault.put('_master_seed_backup', backup)

Recovery flow:

// 1. Register recovery email
await fetch('https://api.agentlair.dev/v1/vault/recovery-email', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'you@example.com', encrypted_seed: backup }),
})

// 2. Later, if you lose your API keys:
//    POST /v1/vault/recover → email with magic link
//    GET /v1/vault/recover/verify?token=... → returns encrypted seed blob

// 3. Decrypt with passphrase
const recovered = await VaultCrypto.decryptSeedBackup(encryptedSeedBlob, 'correct-horse-battery-staple')
// recovered is a full VaultCrypto instance — access all your secrets

Recovery chain:

Passphrase → PBKDF2 → wrapper VaultCrypto

                  Decrypt seed backup

                  fromSeed(recovered) → access all vault secrets

Key naming conventions

Key names must match [A-Za-z0-9_\-.]{1,128}. Suggested conventions:

PatternExampleUse case
Service nameopenaiSingle credential per service
Service + descriptoropenai-key, stripe-secretMultiple credentials per service
Namespacedprod.openai, dev.stripeEnvironment separation
Versionedopenai-key-v2Manual versioning in key name

The _master_seed_backup key is reserved for the passphrase-encrypted seed backup pattern. Avoid it for other uses.


Account model

A Vault account is identified by an API key pair, not an email or identity. This means:

  • One POST /v1/auth/keys call = one account with its own key namespace
  • Multiple agents = multiple API keys = isolated namespaces (keys don’t collide)
  • If you want agents to share secrets, use the same API key

Backup key behavior:

  • backup_key provides the same access as api_key
  • If primary is compromised, immediately use backup to rotate — create a new key pair and migrate

Metadata is not encrypted

Repeat for clarity: metadata is plaintext on the server. Don’t put sensitive data in metadata.

// ✓ Safe in metadata — operational, not sensitive
{ service: 'openai', environment: 'prod', rotated_at: '...' }

// ✗ Never in metadata — this is visible to AgentLair
{ key_hint: 'sk-proj-ab', owner: 'bob@company.com' }

If you need to store sensitive context alongside a secret, encrypt it in the ciphertext itself (e.g., JSON-stringify { value, context } before encrypting).