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

DataEncrypted?
Passwords (password field)Yes
Passwords (username, notes)Yes
Vault secrets (value field)Yes
Vault secrets (notes)Yes
Email content at restYes
Email content in real-time eventsNo (plaintext via SSE)
Secret keys (names)No (needed for lookup)
Password domainsNo (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:

  1. Derives the keypair from PIN + salt
  2. Checks that the derived public key matches the server’s stored public key
  3. 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 CLIinternal/crypto/ package
  • OpenClaw pluginsrc/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

OperationWhat you needHow to get it
Write (create/update)Identity’s public keyGET /api/encryption/public_key field
Read (decrypt)Identity’s private key~/.ravi/auth.jsonprivate_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 serverAgent is already connected to the Ravi MCP server
Implement REST encryptionBuilding a new Ravi SDK, integration library, or UI that manages vault entries directly

Next steps