E2E Encryption
Overview
Ravi uses end-to-end encryption for all sensitive data in the credential vault. Credentials are encrypted client-side before being sent to the server. The server stores only ciphertext and can never read your passwords or secrets.
How it works
Key derivation
When you first log in, you choose a 6-digit PIN. This PIN is used to derive your encryption keys:
PIN + server-stored salt
→ Argon2id (time=3, mem=64MB, threads=1)
→ 32-byte seed
→ X25519 keypair (public + private key)
- The PIN never leaves your machine
- The salt is stored server-side (one per account)
- The derived public key is uploaded to the server
- The derived private key stays local in
~/.ravi/auth.json
Encryption
Data is encrypted using NaCl SealedBox:
plaintext
→ SealedBox seal (ephemeral X25519 + Poly1305 MAC)
→ ciphertext stored as "e2e::<base64>"
SealedBox provides anonymous encryption — anyone can encrypt to your public key, but only the holder of the private key can decrypt.
What’s encrypted
| Data | Encrypted? |
|---|---|
| Passwords (password field) | Yes |
| Passwords (username, notes) | Yes |
| Vault secrets (value field) | Yes |
| Vault secrets (notes) | Yes |
| Email content at rest | Yes |
| Email content in real-time events | No (plaintext via SSE) |
| Secret keys (names) | No (needed for lookup) |
| Password domains | No (needed for lookup) |
Zero-knowledge guarantee
The server never sees:
- Your PIN
- Your private key
- Plaintext passwords or secret values
A server-side breach exposes only ciphertext. Without your PIN, the data is unreadable.
PIN verification
To prevent wrong-PIN errors from silently corrupting data, the server stores a known ciphertext (the encrypted string "ravi-e2e-verify"). On login, the client:
- Derives the keypair from PIN + salt
- Checks that the derived public key matches the server’s stored public key
- Decrypts the verifier to confirm the PIN is correct
You get 3 PIN attempts per login. There is no server-side lockout — the verification is purely cryptographic.
Recovery key
During first-time setup, a recovery key is saved to ~/.ravi/recovery-key.txt. Back this up. If you forget your PIN, the recovery key is the only way to regain access to your encrypted data.
Cross-platform compatibility
The same encryption format is used across all Ravi clients:
- Go CLI —
internal/crypto/package - OpenClaw plugin —
src/crypto.ts(TypeScript) - API — ciphertext format
"e2e::<base64>"
Data encrypted by the CLI can be decrypted by the OpenClaw plugin and vice versa, as long as the same PIN is used.
Transparent operation
As a user, you never need to think about encryption. The CLI and plugins handle it automatically:
# This auto-encrypts before sending to the server
ravi passwords create example.com --password "S3cret!" --json
# This auto-decrypts when reading from the server
ravi passwords get <uuid> --json
# → {"password": "S3cret!"}
For REST API callers
Most developers should use the CLI subprocess pattern or the Ravi MCP server for vault operations — they handle encryption transparently. If you are building a client that calls the REST API directly (e.g., a new SDK or integration layer), this section covers what you need.
How writing and reading differ
| Operation | What you need | How to get it |
|---|---|---|
| Write (create/update) | Identity’s public key | GET /api/encryption/ → public_key field |
| Read (decrypt) | Identity’s private key | ~/.ravi/auth.json → private_key field (local only) |
Writing does not require the user’s PIN — the public key is stored server-side and can be fetched with a normal Bearer token. Reading requires the private key, which is derived from the user’s PIN and stored locally. The server never holds the private key.
GET /api/encryption/ response
{
"public_key": "<base64-encoded 32-byte X25519 public key>",
"salt": "<base64-encoded 16-byte Argon2id salt>",
"verifier": "<base64-encoded SealedBox ciphertext of 'ravi-e2e-verify'>"
}
public_key is null if the user has not yet set up E2E encryption. In that case vault writes are not possible via REST — the user must run ravi login first.
Encrypting a value (Python)
Uses PyNaCl (libsodium binding).
import base64
import httpx
from nacl.public import PublicKey, SealedBox
def ravi_encrypt(plaintext: str, access_token: str, identity_uuid: str) -> str:
"""Encrypt a string for storage in Ravi's credential vault.
Returns a value in the 'e2e::<base64>' format expected by the API.
"""
# 1. Fetch the identity's public key
resp = httpx.get(
"https://ravi.app/api/encryption/",
headers={
"Authorization": f"Bearer {access_token}",
"X-Ravi-Identity": identity_uuid,
},
)
resp.raise_for_status()
pub_key_b64 = resp.json()["public_key"]
if not pub_key_b64:
raise ValueError("E2E encryption not set up for this identity. Run `ravi login` first.")
# 2. Decode the public key
pub_key_bytes = base64.b64decode(pub_key_b64)
recipient_key = PublicKey(pub_key_bytes)
# 3. SealedBox-encrypt the plaintext
box = SealedBox(recipient_key)
ciphertext = box.encrypt(plaintext.encode("utf-8"))
# 4. Return in Ravi's wire format
return "e2e::" + base64.b64encode(ciphertext).decode("ascii")
# Example: create a password entry with encrypted fields
encrypted_username = ravi_encrypt("alice@example.com", ACCESS_TOKEN, IDENTITY_UUID)
encrypted_password = ravi_encrypt("hunter2", ACCESS_TOKEN, IDENTITY_UUID)
httpx.post(
"https://ravi.app/api/passwords/",
headers={
"Authorization": f"Bearer {ACCESS_TOKEN}",
"X-Ravi-Identity": IDENTITY_UUID,
},
json={
"domain": "example.com",
"username": encrypted_username,
"password": encrypted_password,
},
).raise_for_status()
Encrypting a value (TypeScript)
Uses tweetnacl and tweetnacl-util.
import nacl from "tweetnacl";
import { encodeBase64, decodeBase64, encodeUTF8 } from "tweetnacl-util";
import { blake2b } from "hash-wasm";
/** Compute the 24-byte SealedBox nonce matching libsodium's crypto_box_seal. */
async function sealedBoxNonce(ephemeralPK: Uint8Array, recipientPK: Uint8Array): Promise<Uint8Array> {
const input = new Uint8Array(64);
input.set(ephemeralPK, 0);
input.set(recipientPK, 32);
const nonceHex = await blake2b(input, 192); // 192 bits = 24 bytes
return Uint8Array.from(Buffer.from(nonceHex, "hex"));
}
/** Encrypt plaintext with the recipient's X25519 public key (NaCl SealedBox). */
async function sealedBoxSeal(plaintext: Uint8Array, recipientPK: Uint8Array): Promise<Uint8Array> {
const ephemeral = nacl.box.keyPair();
const nonce = await sealedBoxNonce(ephemeral.publicKey, recipientPK);
const encrypted = nacl.box(plaintext, nonce, recipientPK, ephemeral.secretKey);
if (!encrypted) throw new Error("nacl.box encryption failed");
const sealed = new Uint8Array(32 + encrypted.length);
sealed.set(ephemeral.publicKey, 0);
sealed.set(encrypted, 32);
return sealed;
}
async function raviEncrypt(
plaintext: string,
accessToken: string,
identityUuid: string,
): Promise<string> {
// 1. Fetch the identity's public key
const resp = await fetch("https://ravi.app/api/encryption/", {
headers: {
Authorization: `Bearer ${accessToken}`,
"X-Ravi-Identity": identityUuid,
},
});
if (!resp.ok) throw new Error(`GET /api/encryption/ failed: ${resp.status}`);
const { public_key } = await resp.json() as { public_key: string | null };
if (!public_key) throw new Error("E2E encryption not set up. Run `ravi login` first.");
// 2. Encrypt
const recipientPK = decodeBase64(public_key);
const sealed = await sealedBoxSeal(encodeUTF8(plaintext), recipientPK);
// 3. Return in Ravi's wire format
return "e2e::" + encodeBase64(sealed);
}
Reading encrypted values
Decryption requires the private key, which is stored locally in ~/.ravi/auth.json as private_key (base64). It is never sent to the server. REST API responses return ciphertext as-is — only a client holding the private key can decrypt it.
For headless readers: mount ~/.ravi/auth.json at startup and read private_key directly. The decrypt operation is the inverse of sealedBoxSeal above (use nacl.box.open with the ephemeral public key extracted from the first 32 bytes of the sealed ciphertext).
In practice, most automated readers should use the CLI (ravi passwords get <uuid> --json) or the MCP server rather than implementing decryption from scratch — the CLI handles key loading and decryption transparently.
When to implement this vs. use the CLI
| You should… | When… |
|---|---|
Use CLI subprocess (ravi passwords create) | Writing from any scripted context — simplest path |
| Use MCP server | Agent is already connected to the Ravi MCP server |
| Implement REST encryption | Building a new Ravi SDK, integration library, or UI that manages vault entries directly |
Next steps
- Credential Vault — passwords and secrets
- Security Model — full security architecture
- TypeScript REST API — direct REST API usage patterns
- Production Patterns — CLI subprocess and vault patterns for production