> ## Documentation Index
> Fetch the complete documentation index at: https://chahine.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# E2E Encryption

> Encrypt CRDT values client-side — the server stores and forwards ciphertext and never sees plaintext.

Meridian supports **end-to-end encryption** for CRDT values. Encryption and decryption happen entirely in the browser using the Web Crypto API (AES-GCM-256). The server handles CRDT structure (HLC timestamps, node IDs, merge logic) but stores opaque ciphertext for encrypted fields.

## How it works

When you pass an `encryption` map to `MeridianClient.create()`, matching CRDT handles automatically:

1. Encrypt the value with AES-GCM-256 before sending any op
2. Decrypt incoming deltas before surfacing values to your app

The encrypted wire format is a JSON sentinel object stored in place of the plaintext value:

```json theme={null}
{ "$e": 1, "n": "<base64url nonce>", "d": "<base64url ciphertext+tag>" }
```

The server treats this as an opaque JSON value — it stores, merges, and broadcasts it without modification.

## Supported CRDT types

| CRDT | Encrypted |
| - | - |
| `LwwRegister` | ✅ value field |
| `Presence` | ✅ data field |
| `ORSet` | ✅ element values (deterministic AES-GCM) |
| `Tree` | ✅ node value field |
| `GCounter` | ❌ numeric merge requires plaintext |
| `PNCounter` | ❌ numeric merge requires plaintext |
| `RGA` | ❌ character-level merge requires plaintext |
| `CRDTMap` | ❌ delegates to inner CRDTs |

## Quick start

### 1. Generate or import a key

```ts theme={null}
import { generateAesGcmKey, importAesGcmKey } from "meridian-sdk";

// Generate a fresh key (distribute out-of-band to other clients)
const { key, rawBase64 } = await generateAesGcmKey();
console.log(rawBase64); // share this with peers

// Import an existing key from a base64url string
const key = await importAesGcmKey("your-base64url-key-here");
```

### 2. Pass keys to the client

The `encryption` config is a map from CRDT ID pattern (glob supported) to `CryptoKey`. Any handle whose `crdtId` matches a pattern uses that key.

```ts theme={null}
import { Effect } from "effect";
import { MeridianClient, importAesGcmKey } from "meridian-sdk";

const profileKey = await importAesGcmKey(process.env.PROFILE_KEY!);
const roomKey    = await importAesGcmKey(process.env.ROOM_KEY!);

const client = await Effect.runPromise(
  MeridianClient.create({
    url: "wss://your-server.com",
    namespace: "my-app",
    token: myToken,
    encryption: {
      "lw:profile": profileKey,   // exact match
      "pr:room-*":  roomKey,      // glob — matches pr:room-1, pr:room-lobby, …
    },
  })
);
```

### 3. Use handles normally

No API change — `set()`, `value()`, `heartbeat()`, `online()`, `add()`, `remove()`, `addNode()`, `updateNode()` all work identically. Encryption is transparent.

```ts theme={null}
// LwwRegister — value is encrypted on the wire
const profile = client.lwwregister("lw:profile");
profile.set({ name: "Alice", email: "alice@example.com" });
console.log(profile.value()); // { name: "Alice", email: "…" } — already decrypted

// Presence — data field is encrypted
const room = client.presence("pr:room-1");
room.heartbeat({ cursor: { x: 42, y: 100 }, status: "active" }, 30_000);
console.log(room.online()); // entries with decrypted data

// ORSet — element values are encrypted before being sent
const tags = client.orset("or:secret-tags");
tags.add("confidential");          // local state stays plaintext
tags.remove("confidential");       // works correctly — deterministic encryption matches the server key
console.log(tags.elements());      // ["confidential"] — decrypted

// Tree — node values are encrypted
const outline = client.tree("tr:private-doc");
const id = outline.addNode(null, "a0", "My secret note");
outline.updateNode(id, "Updated content");
console.log(outline.value().roots[0].value); // "Updated content" — decrypted
```

## Key distribution

Meridian does not manage keys. You are responsible for distributing them to clients that need access. Common patterns:

* **Symmetric per-user key** — derive a key from a password or passphrase using PBKDF2, store encrypted under the user's auth token
* **Per-room key** — generate once, share via your app server's invite flow (e.g. encrypted with the recipient's public key)
* **Key server** — a separate service that issues keys after verifying access rights

## Glob patterns

The `encryption` map keys support a single `*` wildcard (same syntax as [V2 permission patterns](https://chahine.mintlify.site/sdk/permissions.md)):

```ts theme={null}
encryption: {
  "lw:*":     secretKey,  // all LWW registers
  "pr:room-*": roomKey,   // presence channels for rooms
  "lw:public": undefined, // no entry = not encrypted (plaintext)
}
```

Pattern evaluation is first-match-wins in iteration order. Unmatched CRDT IDs are not encrypted.

## ORSet — deterministic encryption

Standard AES-GCM uses a random nonce for every encryption, which means encrypting the same element twice produces different ciphertexts. This would break ORSet: the server uses the element value as a map key, so two clients encrypting `"alice"` would create two unrelated server-side entries and `remove()` would only remove one of them.

Meridian solves this with **deterministic nonce derivation**: the nonce is the first 12 bytes of `SHA-256(plaintext)`. Same element → same nonce → same ciphertext → same server key. Remove operations work correctly across all clients.

Security properties are preserved: different plaintext values produce different SHA-256 digests and thus different nonces, so there is no cross-value nonce reuse. The only information leaked is element equality (two clients that add the same value produce the same ciphertext), which is already implied by ORSet semantics.

Use `encryptJsonDeterministic` directly if you need this primitive outside of ORSet:

```ts theme={null}
import { encryptJsonDeterministic, generateAesGcmKey } from "meridian-sdk";

const { key } = await generateAesGcmKey();

const enc1 = await encryptJsonDeterministic(key, "alice");
const enc2 = await encryptJsonDeterministic(key, "alice");
enc1.n === enc2.n; // true — same nonce, same ciphertext

const enc3 = await encryptJsonDeterministic(key, "bob");
enc1.n === enc3.n; // false — different plaintext → different nonce
```

## What the server can still see

Even with encryption enabled, the server observes:

* **CRDT structure** — timestamps (HLC), client IDs, op ordering, number of ops
* **Ciphertext size** — reveals approximate plaintext length
* **Access patterns** — which clients write to which CRDTs and when

For stronger privacy guarantees (hiding access patterns), combine with BFT signing and route traffic through an anonymizing proxy.

## API reference

```ts theme={null}
// Generate a new AES-256-GCM key
generateAesGcmKey(): Promise<{ key: CryptoKey; rawBase64: string }>

// Import a key from raw bytes or a base64url string
importAesGcmKey(raw: Uint8Array | string): Promise<CryptoKey>

// Low-level primitives (for custom integrations)
encryptJson(key: CryptoKey, value: unknown): Promise<EncryptedValue>
decryptJson(key: CryptoKey, enc: EncryptedValue): Promise<unknown>
isEncryptedValue(v: unknown): v is EncryptedValue

// Deterministic encryption — nonce = SHA-256(plaintext)[0:12]
// Required for ORSet; safe to use elsewhere when element equality must be preserved.
encryptJsonDeterministic(key: CryptoKey, value: unknown): Promise<EncryptedValue>
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.