Skip to main content

docs/zero-knowledge-guide.md

# Building Zero-Knowledge Phoenix Apps

A practical guide to implementing full client-side zero-knowledge encryption in a
Phoenix LiveView application using the `metamorphic-crypto` Rust core compiled to
WASM for the browser.

This guide walks through how [Metamorphic](https://metamorphic.app) implements its
encryption architecture — the same pattern you can use in your own app.

**Note on the `metamorphic_crypto` Elixir library:** This guide primarily uses
the WASM build of the Rust crate for client-side crypto. The Elixir NIF library
is a server-side companion — useful for replacing `enacl`, generating test
fixtures, or during a gradual transition from server-side to client-side crypto.
In a full ZK architecture, the server doesn't need crypto on user data at all.

## What You'll Build

By the end of this guide you'll have:

- **Client-side encryption** — user data encrypted in the browser before it reaches
  your server
- **A key hierarchy** — each resource gets its own symmetric key, sealed to the
  user's public key
- **Searchable encrypted fields** — HMAC blind indexes for email lookups and
  uniqueness checks
- **Key distribution** — share access to encrypted resources between users without
  the server seeing plaintext
- **Defense in depth** — Cloak wrapping the already-encrypted blobs at rest

## How the Two Libraries Fit Together

```
                      metamorphic-crypto (Rust crate)
                      ┌──────────────────────────────┐
                      │  XSalsa20-Poly1305           │
                      │  X25519 box_seal             │
                      │  ML-KEM-768 + X25519 hybrid  │
                      │  Argon2id KDF                │
                      │  Recovery keys               │
                      └──────────┬───────────────────┘
                ┌────────────────┼────────────────┐
                │                │                │
         Compiles to        Compiles to       Compiles to
           WASM                NIF               UniFFI
        (browser)          (Elixir/OTP)      (iOS/Android)
                │                │
                ▼                ▼
    Colocated JS hook     metamorphic_crypto
    (encrypts/decrypts    Hex package
     user data)           (key distribution,
                          re-keying, provisioning)
```

The same Rust code produces identical ciphertext regardless of which target it's
compiled to. Data sealed by the server NIF can be unsealed by the browser WASM
module, and vice versa.

## Key Architecture

```
Password (entered by user, never stored)
  ├── Argon2id KDF ──► session_key (derived in browser at login)
  │                       │
  │                       └── decrypts private_key (secretbox)
  ├── crypto_box_keypair ──► keypair
  │                            │
  │                            ├── public_key → stored on server
  │                            │
  │                            └── private_key → encrypted with session_key
  │                                                stored on server
  └── ml_kem768_x25519.keygen ──► hybrid PQ keypair (optional)
                                     ├── pq_public_key → stored on server
                                     └── pq_private_key → encrypted with session_key
                                                           stored on server
```

### Context Keys

Each resource gets its own random symmetric key, called a **context key**:

```
user_key (random 32 bytes)
  ├── Encrypts: personal data (email, preferences)
  └── Distributed: seal_for_user(user_key, public_key)
     stored as user.encrypted_user_key

habit_key (random 32 bytes, per habit)
  ├── Encrypts: habit name, description, check-ins
  └── Distributed: seal_for_user(habit_key, public_key)
     stored as user_habits.encrypted_key

group_key (random 32 bytes, per group)
  ├── Encrypts: shared goals, check-ins
  └── Distributed to each member: seal_for_user(group_key, member.public_key)
```

### What Each Layer Handles

| Layer | Role | Technology |
|-------|------|------------|
| **Browser** | Encrypts/decrypts user data | WASM (`metamorphic-crypto` in JS hook) |
| **Browser** | Derives session key from password | WASM Argon2id |
| **Browser** | Seals context keys for sharing | WASM (client-side key distribution) |
| **Browser** | Caches derived keys | IndexedDB + Web Crypto API |
| **Server** | Generates keypairs during provisioning | `metamorphic_crypto` NIF |
| **Server** | Orchestrates key distribution events | LiveView `push_event` |
| **Server** | Background re-keying (PQ migration) | `metamorphic_crypto` NIF (with client-provided keys) |
| **Server** | Stores opaque ciphertext | Ecto schema |
| **Server** | Defense-in-depth at-rest encryption | Cloak (AES-256-GCM) |
| **Server** | Blind indexes for lookups | HMAC-SHA512 |

## Prerequisites

Add these to your `mix.exs`:

```elixir
def deps do
  [
    {:metamorphic_crypto, "~> 0.1"},
    {:cloak_ecto, "~> 1.3"},
    {:argon2_elixir, "~> 4.0"}   # for password hashing
  ]
end
```

The `metamorphic-crypto` WASM module needs to be in your `assets/vendor/` directory.
See the [Client Setup](#client-setup) section below.

## Client Setup

### 1. Add the WASM Module

Build the `metamorphic-crypto` Rust crate to WASM, or download the prebuilt module:

```bash
# From the metamorphic-crypto repo
wasm-pack build --target web --out-dir pkg
```

Copy the generated files to your Phoenix app:

```bash
cp pkg/metamorphic_crypto.js assets/vendor/metamorphic-crypto/
cp pkg/metamorphic_crypto_bg.wasm assets/vendor/metamorphic-crypto/
cp pkg/metamorphic_crypto.d.ts assets/vendor/metamorphic-crypto/
```

Then in `assets/js/app.js`, import and configure esbuild to serve the WASM:

```javascript
// assets/js/app.js
import { wasmInit } from "../vendor/metamorphic-crypto/metamorphic_crypto";
```

And ensure your WASM binary is served from the `/wasm/` path in your endpoint:

```elixir
# lib/my_app_web/endpoint.ex
plug Plug.Static,
  at: "/wasm",
  from: {:my_app, "priv/static/wasm"},
  gzip: false
```

### 2. Create the Crypto Module

```javascript
// assets/js/crypto/nacl.js

import { wasmInit } from "../../vendor/metamorphic-crypto/metamorphic_crypto";

let ready = false;
const readyQueue = [];

export function ensureReady() {
  if (ready) return Promise.resolve();
  return new Promise((resolve) => readyQueue.push(resolve));
}

export async function loadCrypto() {
  if (ready) return;
  const mod = await wasmInit("/wasm/metamorphic_crypto_bg.wasm");
  window.__metamorphic_crypto = mod;
  ready = true;
  readyQueue.forEach((fn) => fn());
}

export function generateKey() {
  return window.__metamorphic_crypto.generateKey();
}

export function generateKeypair() {
  return window.__metamorphic_crypto.generateKeypair();
}

export function encrypt(plaintext, key) {
  return window.__metamorphic_crypto.encrypt(plaintext, key);
}

export function decrypt(ciphertext, key) {
  return window.__metamorphic_crypto.decrypt(ciphertext, key);
}

export function seal(plaintext, publicKey) {
  return window.__metamorphic_crypto.seal(plaintext, publicKey);
}

export function unseal(ciphertext, publicKey, privateKey) {
  return window.__metamorphic_crypto.unseal(ciphertext, publicKey, privateKey);
}

export function deriveSessionKey(password, salt) {
  return window.__metamorphic_crypto.deriveSessionKey(password, salt);
}
```

Call `loadCrypto()` when your page loads:

```javascript
// assets/js/app.js
import { loadCrypto } from "./crypto/nacl";

loadCrypto();
```

### 3. The Key Cache Module

```javascript
// assets/js/crypto/key_cache.js

const DB_NAME = "_my_app_crypto";
const STORE_NAME = "keys";
const LS_CACHE_KEY = "_my_app_key_cache";

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => {
      request.result.createObjectStore(STORE_NAME);
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

export async function cacheKeys(sessionKey, privateKey, userKey) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const key = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
  tx.objectStore(STORE_NAME).put(key, "wrapping_key");

  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(
    JSON.stringify({ sessionKey, privateKey, userKey, cachedAt: Date.now() })
  );
  const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
  localStorage.setItem(LS_CACHE_KEY, JSON.stringify({
    iv: Array.from(iv),
    ct: Array.from(new Uint8Array(encrypted))
  }));
}

export async function getCachedKeys() {
  const raw = localStorage.getItem(LS_CACHE_KEY);
  if (!raw) return null;

  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readonly");
  const key = await new Promise((resolve) => {
    const req = tx.objectStore(STORE_NAME).get("wrapping_key");
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => resolve(null);
  });
  if (!key) return null;

  const { iv, ct } = JSON.parse(raw);
  try {
    const decrypted = await crypto.subtle.decrypt(
      { name: "AES-GCM", iv: new Uint8Array(iv) },
      key,
      new Uint8Array(ct)
    );
    return JSON.parse(new TextDecoder().decode(decrypted));
  } catch {
    return null;
  }
}

export function clearKeyCache() {
  localStorage.removeItem(LS_CACHE_KEY);
  const req = indexedDB.deleteDatabase(DB_NAME);
}
```

## Step 1: Registration Flow

The registration flow generates keys client-side before the form is submitted,
then sends the encrypted blobs to the server.

### Colocated Hook

```heex
<%!-- lib/my_app_web/live/registration_live.html.heex --%>
<.form for={@form} id="registration-form" phx-hook=".RegistrationHook">
  <.input field={@form[:email]} type="email" />
  <.input field={@form[:password]} type="password" />
  <.input field={@form[:encrypted_email]} type="hidden" name="user[encrypted_email]" />
  <.input field={@form[:public_key]} type="hidden" name="user[public_key]" />
  <.input field={@form[:encrypted_private_key]} type="hidden" name="user[encrypted_private_key]" />
  <.input field={@form[:encrypted_user_key]} type="hidden" name="user[encrypted_user_key]" />
  <.input field={@form[:key_hash]} type="hidden" name="user[key_hash]" />
  <button type="submit">
    Create Account
  </button>
</.form>

<script :type={Phoenix.LiveView.ColocatedHook} name=".RegistrationHook">
  import { ensureReady, generateKey, generateKeypair, seal, deriveSessionKey, encrypt } from "../../js/crypto/nacl";

  export default {
    mounted() {
      this.el.addEventListener("submit", async (e) => {
        e.preventDefault();
        await ensureReady();

        const password = this.el.querySelector('input[name="user[password]"]').value;
        const email = this.el.querySelector('input[name="user[email]"]').value;

        // 1. Derive session key
        const salt = window.__metamorphic_crypto.generateSalt();
        const sessionKey = await deriveSessionKey(password, salt);

        // 2. Generate keypair
        const keypair = generateKeypair();
        const encryptedPrivateKey = encrypt(keypair.secretKey, sessionKey);

        // 3. Generate user_key (top-level context key)
        const userKey = generateKey();
        const encryptedUserKey = seal(userKey, keypair.publicKey);

        // 4. Encrypt email
        const encryptedEmail = encrypt(email, userKey);

        // 5. Store session key temporarily — SessionKeyDeriver will pick
        //    this up on the next page load to derive all keys
        sessionStorage.setItem("_session_key_temp", sessionKey);

        // 6. Inject hidden fields (salt is already Base64 from the WASM module)
        this.el.querySelector('input[name="user[key_hash]"]').value = salt + "$argon2id";
        this.el.querySelector('input[name="user[public_key]"]').value = keypair.publicKey;
        this.el.querySelector('input[name="user[encrypted_private_key]"]').value = encryptedPrivateKey;
        this.el.querySelector('input[name="user[encrypted_user_key]"]').value = encryptedUserKey;
        this.el.querySelector('input[name="user[encrypted_email]"]').value = encryptedEmail;

        // 7. Submit the form — server stores blobs, redirects to dashboard,
        //    where SessionKeyDeriver uses the temp key to derive everything
        this.el.submit();
      });
    }
  }
</script>
```

### Server-Side Controller

On the server, receive the encrypted blobs and store them.

**Important trade-off:** The server sees the plaintext email and password
transiently during registration. The password is needed for server-side Argon2
session auth. The email is needed to send the confirmation email and compute the
HMAC blind index. After the request completes, neither is persisted — only the
`email_hash` (HMAC) and `encrypted_email` (ciphertext) remain in the database.
Declare the `email` field as virtual with `redact: true` to prevent accidental
persistence or logging.

```elixir
defmodule MyAppWeb.UserRegistrationController do
  use MyAppWeb, :controller

  alias MyApp.Accounts
  alias MyApp.Accounts.User

  def create(conn, %{"user" => user_params}) do
    # Extract transient plaintext email for confirmation email and blind index
    plain_email = user_params["email"]

    # Compute HMAC blind index for lookups
    email_hash = :crypto.mac(:hmac, :sha512, Application.fetch_env!(:my_app, :email_hmac_key), String.downcase(plain_email))

    # Hash password for server-side auth (independent of client-side KDF).
    # The client derives session_key via Argon2id with its own salt for key
    # derivation. The server hashes the password with a separate Argon2 salt
    # for session authentication. These are two separate operations serving
    # different purposes — they never share a salt.
    hashed_password = Argon2.hash_pwd_salt(user_params["password"])

    attrs = %{
      email_hash: email_hash,
      encrypted_email: user_params["encrypted_email"],
      public_key: user_params["public_key"],
      encrypted_private_key: user_params["encrypted_private_key"],
      encrypted_user_key: user_params["encrypted_user_key"],
      key_hash: user_params["key_hash"],
      hashed_password: hashed_password
    }

    case Accounts.create_user(attrs) do
      {:ok, user} ->
        # Send confirmation email using transient plaintext
        MyApp.Email.confirm_email(plain_email, user)
          |> MyApp.Mailer.deliver_later()

        conn
        |> put_flash(:info, "Account created")
        |> redirect(to: ~p"/dashboard")

      {:error, changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end
```

### Ecto Schema

Store the encrypted blobs and use Cloak encrypted types for at-rest protection:

```elixir
defmodule MyApp.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :email_hash, :binary                       # HMAC-SHA512 blind index
    field :encrypted_email, MyApp.Encrypted.Binary   # E2E encrypted email (Cloak-wrapped)
    field :public_key, MyApp.Encrypted.Binary        # X25519 public key (Cloak-wrapped)
    field :encrypted_private_key, MyApp.Encrypted.Binary  # X25519 private key (Cloak-wrapped)
    field :encrypted_user_key, MyApp.Encrypted.Binary     # user_key sealed for this user
    field :key_hash, :string                              # Argon2id salt + params
    field :hashed_password, :string                       # Argon2 password hash (server auth)

    # Optional: post-quantum hybrid keypair
    field :pq_public_key, MyApp.Encrypted.Binary
    field :encrypted_pq_private_key, MyApp.Encrypted.Binary

    timestamps()
  end
end
```

**Note:** The `encrypted_private_key` field is encrypted twice — once by the
client (secretbox with session key) and once by Cloak (AES-256-GCM at rest).
The server cannot read the inner layer. This is defense-in-depth.

## Step 2: Login Flow

### Hook to Derive Keys Before Auth

```heex
<%!-- lib/my_app_web/live/login_live.html.heex --%>
<.form for={@form} id="login-form" phx-hook=".LoginHook">
  <.input field={@form[:email]} type="email" />
  <.input field={@form[:password]} type="password" />
  <button type="submit">Sign In</button>
</.form>
```

On login, the hook intercepts the form to:
1. Request the user's `key_hash` (containing the Argon2id salt)
2. Derive the session key
3. Store the session key temporarily
4. Submit the form normally for server auth

```javascript
<script :type={Phoenix.LiveView.ColocatedHook} name=".LoginHook">
  import { ensureReady, deriveSessionKey } from "../../js/crypto/nacl";

  export default {
    mounted() {
      this.el.addEventListener("submit", async (e) => {
        e.preventDefault();
        await ensureReady();

        const email = this.el.querySelector('input[name="user[email]"]').value;
        const password = this.el.querySelector('input[name="user[password]"]').value;

        // 1. Fetch key_hash (contains salt)
        const resp = await fetch("/api/auth/salt", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email })
        });
        const { key_hash } = await resp.json();

        // 2. Derive session key from salt + password
        const salt = key_hash.split("$")[0];
        const sessionKey = await deriveSessionKey(password, salt);

        // 3. Store temporarily in sessionStorage
        sessionStorage.setItem("_session_key_temp", sessionKey);

        // 4. Submit original form for password verification
        this.el.submit();
      });
    }
  }
</script>
```

### The Salt Endpoint (Server)

This endpoint returns the Argon2id salt for a given email. It must be hardened
against user enumeration:

- **Timing normalization** — enforce a minimum response time so an attacker
  can't distinguish "user exists" (DB hit) from "user not found" (fake hash).
- **Rate limiting** — 10 requests/minute per IP.
- **Deterministic fake salt** — for unknown emails, derive a fake salt from a
  server secret + the email (HMAC). This makes repeated requests for the same
  unknown email return the same hash, preventing timing-based enumeration.

```elixir
defmodule MyAppWeb.AuthSaltController do
  use MyAppWeb, :controller

  alias MyApp.Accounts

  @min_response_ms 100

  def show(conn, %{"email" => email}) when is_binary(email) and email != "" do
    start = System.monotonic_time(:millisecond)

    email_hash =
      :crypto.mac(:hmac, :sha512,
        Application.fetch_env!(:my_app, :email_hmac_key),
        String.downcase(email))

    key_hash =
      case Repo.get_by(User, email_hash: email_hash) do
        %User{key_hash: kh} -> kh
        nil -> generate_fake_key_hash(email)
      end

    enforce_min_duration(start)
    json(conn, %{key_hash: key_hash})
  end

  def show(conn, _params) do
    conn |> put_status(:bad_request) |> json(%{error: "email is required"})
  end

  # Deterministic fake salt — same email always produces the same fake,
  # so timing is consistent regardless of how many times it's queried
  defp generate_fake_key_hash(email) do
    fake_salt =
      :crypto.mac(:hmac, :sha512,
        Application.fetch_env!(:my_app, :fake_salt_secret),
        String.downcase(email))
      |> binary_part(0, 16)
      |> Base.encode64()

    fake_salt <> "$argon2id"
  end

  defp enforce_min_duration(start) do
    elapsed = System.monotonic_time(:millisecond) - start
    remaining = @min_response_ms - elapsed
    if remaining > 0, do: Process.sleep(remaining)
  end
end
```

In the router, apply rate limiting:

```elixir
# lib/my_app_web/router.ex
pipeline :api_rate_limited do
  plug :accepts, ["json"]
  plug MyAppWeb.Plugs.RateLimiter, scale: :timer.minutes(1), limit: 10, key_prefix: "api"
end

scope "/api", MyAppWeb do
  pipe_through :api_rate_limited
  post "/auth/salt", AuthSaltController, :show
end
```

## Step 3: Key Derivation on Dashboard Mount

After login or registration, the user is redirected to an authenticated page.
The `SessionKeyDeriver` hook picks up the temporary session key (stored in
sessionStorage by the Login/Registration hook) and derives the full key set.

### Storage Model

Keys live in two layers:

| Layer | Scope | Purpose |
|-------|-------|---------|
| **sessionStorage** | Current tab only | Active decryption keys — cleared on tab close |
| **localStorage + IndexedDB** | Persistent | Encrypted key cache — survives browser restarts |

This is a UX vs. security trade-off:

- **sessionStorage only** — most secure. User must re-enter password on every
  tab close. Appropriate for high-security contexts.
- **Persistent cache** — better UX. Derived keys are encrypted with a
  non-extractable AES-256-GCM wrapping key (IndexedDB) and stored as ciphertext
  (localStorage). Survives browser restarts without password re-entry.

In Metamorphic, we use **both**: sessionStorage for the active tab, and an
encrypted persistent cache so users don't have to re-enter their password on
browser restart. The persistent cache is cleared on logout and password change.
Users can opt out of the persistent cache in Settings ("Always require password")
— this disables caching entirely and clears existing cached keys, falling back
to sessionStorage-only behavior.

### Data Attributes

Pass the encrypted keys from the server to the client via data attributes:

```heex
<%!-- In your Layouts.app template --%>
<div
  id="session-key-deriver"
  phx-hook=".SessionKeyDeriver"
  phx-update="ignore"
  data-key-hash={@current_scope.user.key_hash}
  data-public-key={@current_scope.user.public_key}
  data-encrypted-private-key={@current_scope.user.encrypted_private_key}
  data-encrypted-user-key={@current_scope.user.encrypted_user_key}
>
</div>
```

### The SessionKeyDeriver Hook

```javascript
<script :type={Phoenix.LiveView.ColocatedHook} name=".SessionKeyDeriver">
  import { ensureReady, decrypt, unseal } from "../../js/crypto/nacl";
  import { cacheKeys, getCachedKeys } from "../../js/crypto/key_cache";

  export default {
    async mounted() {
      await ensureReady();

      const el = this.el;
      const publicKey = el.dataset.publicKey;
      const encryptedPrivateKey = el.dataset.encryptedPrivateKey;
      const encryptedUserKey = el.dataset.encryptedUserKey;

      // Always store public key — it's not secret and other hooks need it
      sessionStorage.setItem("_public_key", publicKey);

      // 1. Check sessionStorage — already derived this session?
      const existingKey = sessionStorage.getItem("_session_key");
      if (existingKey) {
        // Validate by trial-decrypting the private key
        try {
          decrypt(encryptedPrivateKey, existingKey);
          return; // Keys are valid
        } catch {
          // Stale keys (password changed) — fall through
        }
      }

      // 2. Check persistent cache (encrypted localStorage + IndexedDB)
      const cached = await getCachedKeys();
      if (cached) {
        try {
          decrypt(encryptedPrivateKey, cached.sessionKey);
          sessionStorage.setItem("_session_key", cached.sessionKey);
          sessionStorage.setItem("_private_key", cached.privateKey);
          sessionStorage.setItem("_user_key", cached.userKey);
          return;
        } catch {
          // Cache is stale — fall through
        }
      }

      // 3. Derive from temp key (just logged in or registered)
      const tempKey = sessionStorage.getItem("_session_key_temp");
      if (!tempKey) {
        window.location = "/users/reauthenticate";
        return;
      }

      // Decrypt private key with session key
      const privateKey = decrypt(encryptedPrivateKey, tempKey);

      // Unseal user_key with keypair
      const userKey = unseal(encryptedUserKey, publicKey, privateKey);

      // Store in sessionStorage for this tab
      sessionStorage.setItem("_session_key", tempKey);
      sessionStorage.setItem("_private_key", privateKey);
      sessionStorage.setItem("_user_key", userKey);
      sessionStorage.removeItem("_session_key_temp");

      // Cache for browser restart (encrypted with Web Crypto)
      await cacheKeys(tempKey, privateKey, userKey);
    }
  }
</script>
```

## Step 4: Encrypting Resources

Here's how to encrypt a habit (or any resource) before it reaches the server.
The pattern: intercept the form, encrypt client-side, push encrypted params via
LiveView event.

### Colocated Hook for Creating a Resource

The form uses a `type="button"` submit trigger (not `type="submit"`) so that
if the JS hook fails to load, the form **cannot submit unencrypted data** to
the server. Data is only sent via `pushEvent` after successful encryption.

```heex
<%!-- lib/my_app_web/live/habit_live/index.html.heex --%>
<div id="habit-form-wrapper" phx-hook=".HabitFormHook">
  <.input type="text" id="habit-name-input" name="name" label="Name" />
  <.input type="textarea" id="habit-desc-input" name="description" label="Description" />
  <button type="button" data-action="submit-habit"
    class="btn bg-primary text-primary-content">
    Create Habit
  </button>
</div>

<script :type={Phoenix.LiveView.ColocatedHook} name=".HabitFormHook">
  import { ensureReady, generateKey, seal, encrypt } from "../../js/crypto/nacl";

  export default {
    mounted() {
      const btn = this.el.querySelector('[data-action="submit-habit"]');

      btn.addEventListener("click", async () => {
        await ensureReady();

        const privateKey = sessionStorage.getItem("_private_key");
        const publicKey = sessionStorage.getItem("_public_key");
        if (!privateKey || !publicKey) {
          window.location = "/users/reauthenticate";
          return;
        }

        const name = this.el.querySelector("#habit-name-input").value;
        const description = this.el.querySelector("#habit-desc-input").value;

        try {
          // Generate a per-habit context key
          const habitKey = generateKey();

          // Encrypt the fields with the context key
          const encryptedName = encrypt(name, habitKey);
          const encryptedDescription = encrypt(description, habitKey);

          // Seal the context key to the user's public key
          const encryptedKey = seal(habitKey, publicKey);

          // Push encrypted params to the server via LiveView
          this.pushEvent("create_habit", {
            encrypted_name: encryptedName,
            encrypted_description: encryptedDescription,
            encrypted_key: encryptedKey,
          });
        } catch (err) {
          console.error("Encryption failed:", err);
        }
      });
    }
  }
</script>
```

### Server Handler

The server receives only encrypted blobs. It validates structure and stores them.

```elixir
defmodule MyAppWeb.HabitLive do
  use MyAppWeb, :live_view

  def handle_event("create_habit", params, socket) do
    %{"encrypted_name" => enc_name, "encrypted_description" => enc_desc,
      "encrypted_key" => enc_key} = params

    user = socket.assigns.current_scope.user

    case MyApp.Habits.create_habit(user, %{
      encrypted_name: enc_name,
      encrypted_description: enc_desc,
      encrypted_key: enc_key
    }) do
      {:ok, habit} ->
        {:noreply,
         socket
         |> put_flash(:info, "Habit created")
         |> stream_insert(:habits, habit)}

      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
end
```

### Context Module

```elixir
defmodule MyApp.Habits do
  alias MyApp.Repo
  alias MyApp.Habits.{Habit, UserHabit}

  def create_habit(user, attrs) do
    Ecto.Multi.new()
    |> Ecto.Multi.insert(:habit, %Habit{
      user_id: user.id,
      encrypted_name: attrs.encrypted_name,
      encrypted_description: attrs.encrypted_description
    })
    |> Ecto.Multi.insert(:user_habit, fn %{habit: habit} ->
      %UserHabit{
        user_id: user.id,
        habit_id: habit.id,
        encrypted_key: attrs.encrypted_key,
        role: "owner"
      }
    end)
    |> Repo.transaction()
    |> case do
      {:ok, %{habit: habit}} -> {:ok, habit}
      {:error, _, changeset, _} -> {:error, changeset}
    end
  end
end
```

### Ecto Schema for Encrypted Resources

```elixir
defmodule MyApp.Habits.Habit do
  use Ecto.Schema

  schema "habits" do
    field :encrypted_name, MyApp.Encrypted.Binary
    field :encrypted_description, MyApp.Encrypted.Binary

    belongs_to :user, MyApp.Accounts.User
    has_many :user_habits, MyApp.Habits.UserHabit
    timestamps()
  end
end

defmodule MyApp.Habits.UserHabit do
  use Ecto.Schema

  schema "user_habits" do
    field :encrypted_key, MyApp.Encrypted.Binary  # context key sealed for this user
    field :role, :string, default: "owner"

    belongs_to :user, MyApp.Accounts.User
    belongs_to :habit, MyApp.Habits.Habit
    timestamps()
  end
end
```

Where `MyApp.Encrypted.Binary` is your Cloak encrypted type (see [Cloak docs](https://hexdocs.pm/cloak_ecto/install.html)).

## Step 5: Key Distribution (Sharing Access)

When User A shares a resource with User B, someone needs to seal the context key
to User B's public key. In a true zero-knowledge architecture, **the server never
unseals or seals context keys** — it doesn't have anyone's private key.

Instead, key distribution is **event-driven and client-side**:

1. Server detects that a member needs a key (invited, pending access)
2. Server pushes a `push_event` to an online admin/owner's browser
3. The admin's client **unseals** the context key with their own private key
4. The admin's client **re-seals** it for the target user's public key
5. The admin's client pushes the sealed blob back to the server
6. Server stores it on the target user's join record

This means key distribution happens when an authorized user is online. The server
orchestrates — it knows *who* needs a key and *whose* public key to seal to — but
it never touches plaintext key material.

### Server: Detecting Pending Keys

```elixir
defmodule MyApp.Groups do
  import Ecto.Query

  def pending_key_requests(%Scope{user: user}) do
    # Find group members who've been invited but don't have a key yet
    from(gm in GroupMember,
      join: g in assoc(gm, :group),
      join: u in assoc(gm, :user),
      join: admin_gm in GroupMember,
        on: admin_gm.group_id == g.id and admin_gm.user_id == ^user.id,
      where: is_nil(gm.encrypted_key),
      where: admin_gm.role in ["owner", "admin"],
      where: not is_nil(u.public_key),
      select: %{
        group_id: g.id,
        member_user_id: u.id,
        member_public_key: u.public_key,
        member_pq_public_key: u.pq_public_key,
        admin_encrypted_key: admin_gm.encrypted_key
      }
    )
    |> Repo.all()
  end
end
```

### Server: Pushing to the Client

In your LiveView, on mount or when a new member is added:

```elixir
defmodule MyAppWeb.GroupsLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    pending = MyApp.Groups.pending_key_requests(socket.assigns.current_scope)

    socket =
      if pending != [] do
        push_event(socket, "distribute_keys", %{requests: pending})
      else
        socket
      end

    {:ok, socket}
  end

  # Client sends back the sealed key
  def handle_event("key_distributed", params, socket) do
    %{"group_id" => group_id, "member_user_id" => member_user_id,
      "encrypted_key" => encrypted_key} = params

    # Validate: only admins can distribute, and blob must be well-formed base64
    with :ok <- validate_admin(socket, group_id),
         :ok <- validate_encrypted_key(encrypted_key) do
      MyApp.Groups.store_distributed_key(group_id, member_user_id, encrypted_key)
    end

    {:noreply, socket}
  end

  defp validate_encrypted_key(key) when is_binary(key) and byte_size(key) > 0 do
    # Minimum 80 bytes: crypto_box_SEALBYTES (48) + 32-byte key = 80 for legacy box_seal.
    # Maximum 2048 bytes: covers hybrid PQ sealed keys (ML-KEM-768 ciphertext is ~1100 bytes).
    case Base.decode64(key) do
      {:ok, decoded} when byte_size(decoded) >= 80 and byte_size(decoded) <= 2048 -> :ok
      _ -> {:error, :invalid_encrypted_key}
    end
  end
end
```

### Client: The KeyDistributor Hook

This hook listens for `distribute_keys` events and does the cryptographic work:

```heex
<div id="key-distributor" phx-hook=".KeyDistributor" phx-update="ignore"></div>

<script :type={Phoenix.LiveView.ColocatedHook} name=".KeyDistributor">
  import { ensureReady, unseal, seal } from "../../js/crypto/nacl";

  export default {
    mounted() {
      this.handleEvent("distribute_keys", async ({ requests }) => {
        await ensureReady();

        const privateKey = sessionStorage.getItem("_private_key");
        const publicKey = sessionStorage.getItem("_public_key");
        if (!privateKey || !publicKey) return;

        for (const req of requests) {
          try {
            // 1. Unseal the context key using MY private key
            const contextKey = unseal(
              req.admin_encrypted_key, publicKey, privateKey
            );

            // 2. Re-seal it for the TARGET user's public key
            const sealedForMember = seal(contextKey, req.member_public_key);

            // 3. Send the sealed blob back to the server
            this.pushEvent("key_distributed", {
              group_id: req.group_id,
              member_user_id: req.member_user_id,
              encrypted_key: sealedForMember,
            });
          } catch (err) {
            console.error("Key distribution failed:", err);
          }
        }
      });
    }
  }
</script>
```

### When Can the Server Use MetamorphicCrypto for Key Distribution?

The server-side NIF is useful for key distribution in specific scenarios where
you intentionally grant the server temporary access to a context key:

- **Account provisioning** — generating keypairs and the initial `user_key` seal
  during registration (before the user has a client session)
- **Oban background jobs** — re-sealing context keys during a PQ migration where
  the server has been explicitly given a batch of plaintext context keys by the
  client for re-sealing
- **Admin-sealed resources** — if your app has a concept of "server-managed"
  resources that aren't zero-knowledge (e.g., system announcements encrypted for
  all users), the server can seal to each user's public key

For true zero-knowledge, the client always does the seal/unseal. The server
stores and routes opaque blobs.

## Step 6: Displaying Encrypted Data

When sending encrypted data to the client, pass the encrypted blobs and keys
as data attributes on the LiveView elements. The hook decrypts and updates the
DOM client-side.

```heex
<div id="habits" phx-update="stream">
  <div
    :for={{id, habit} <- @streams.habits}
    id={id}
    phx-hook=".HabitCard"
    data-encrypted-name={habit.encrypted_name}
    data-encrypted-description={habit.encrypted_description}
    data-encrypted-key={habit.user_habit.encrypted_key}
  >
    <h3 data-decrypt-name class="font-semibold text-base-content animate-pulse">···</h3>
    <p data-decrypt-description class="text-base-content/70 animate-pulse">···</p>
    <p data-decrypt-error class="text-error hidden"></p>
  </div>
</div>
```

The hook manages its own DOM after decryption, so content is only updated
client-side.

### The Decryption Hook

```heex
<script :type={Phoenix.LiveView.ColocatedHook} name=".HabitCard">
  import { ensureReady, decrypt, unseal } from "../../js/crypto/nacl";

  export default {
    async mounted() {
      await ensureReady();

      const privateKey = sessionStorage.getItem("_private_key");
      const publicKey = sessionStorage.getItem("_public_key");

      if (!privateKey || !publicKey) {
        window.location = "/users/reauthenticate";
        return;
      }

      const el = this.el;

      try {
        // Unseal the per-resource context key
        const habitKey = unseal(el.dataset.encryptedKey, publicKey, privateKey);

        // Decrypt the fields
        const name = decrypt(el.dataset.encryptedName, habitKey);
        const description = decrypt(el.dataset.encryptedDescription, habitKey);

        // Update the DOM — use textContent (NOT innerHTML) to prevent XSS.
        // Decrypted user content could contain <script> tags or event handlers.
        const nameEl = el.querySelector("[data-decrypt-name]");
        const descEl = el.querySelector("[data-decrypt-description]");
        if (nameEl) nameEl.textContent = name;
        if (descEl) descEl.textContent = description;
      } catch (err) {
        const errEl = el.querySelector("[data-decrypt-error]");
        if (errEl) errEl.textContent = "Failed to decrypt";
        console.error("Decryption failed:", err);
      }
    }
  }
</script>
```

## Schema Design Rules

1. **Encrypted fields are `:binary`** — always. The ciphertext is non-printable
   binary data.
2. **Context keys in join tables** — store the sealed context key on the join
   table (`user_habits.encrypted_key`), not the resource table.
3. **Blind indexes for lookups** — use HMAC-SHA512 for case-insensitive
   uniqueness checks (email, username).
4. **Metadata stays plaintext** — dates, positions, colors, and other non-sensitive
   data can remain in plaintext. Be deliberate about what's metadata vs. content.
5. **Cloak wraps everything** — every `:binary` encrypted field should be
   Cloak-encrypted at rest as a defense-in-depth layer.

## Security Considerations

### Password Never Reaches sessionStorage

Only the Argon2id-derived session key is stored. The raw password is used once
during KDF derivation and immediately discarded.

### Key Cache is Encrypted at Rest

The persistent key cache uses the Web Crypto API with a non-extractable AES-256-GCM
wrapping key stored in IndexedDB. An adversary who copies localStorage from disk
gets only encrypted ciphertext. The wrapping key cannot be extracted by JavaScript
— it can only be used for encrypt/decrypt operations via the Web Crypto API.

Clear the cache on logout and password change:

```javascript
function onLogout() {
  sessionStorage.clear();
  localStorage.removeItem("_my_app_key_cache");
  indexedDB.deleteDatabase("_my_app_crypto");
}
```

### Salt Endpoint is a User Enumeration Vector

The `/api/auth/salt` endpoint necessarily reveals whether an email exists (different
response for known vs. unknown users). Mitigate this with:

1. **Timing normalization** — enforce minimum response time (100ms+)
2. **Deterministic fake salts** — HMAC-derived, so repeated queries for the same
   unknown email return the same fake (no timing delta on cache hits)
3. **Rate limiting** — 10 requests/minute per IP minimum. Use IP hashing for
   privacy in logs.

### XSS is the Primary Threat to Key Material

During an active session, derived keys live in `sessionStorage`. Any XSS
vulnerability can read them. This is the weakest link in the architecture — not
the crypto, but the browser execution environment.

**Mitigations (all required for production):**

1. **Strict Content-Security-Policy** — per-request nonce for scripts, no
   `unsafe-inline` for script-src, `frame-ancestors 'none'`:

   ```elixir
   # lib/my_app_web/plugs/content_security_policy.ex
   defmodule MyAppWeb.Plugs.ContentSecurityPolicy do
     import Plug.Conn

     def init(opts), do: opts

     def call(conn, _opts) do
       nonce = Base.encode64(:crypto.strong_rand_bytes(16))

       conn
       |> assign(:csp_nonce, nonce)
       |> put_resp_header("content-security-policy", """
       default-src 'self'; \
       script-src 'self' 'nonce-#{nonce}' 'wasm-unsafe-eval'; \
       style-src 'self' 'unsafe-inline'; \
       img-src 'self' data:; \
       connect-src 'self'; \
       frame-ancestors 'none'; \
       object-src 'none'; \
       base-uri 'self'\
       """)
     end
   end
   ```

   Wire it into your browser pipeline:

   ```elixir
   pipeline :browser do
     # ...
     plug MyAppWeb.Plugs.ContentSecurityPolicy
   end
   ```

2. **No inline scripts** — use colocated hooks (which are bundled by esbuild)
   and nonce-tagged script tags only. Never use `<script>` without a nonce.

3. **Sanitize all user-generated content** — Phoenix's HEEx templates
   auto-escape by default, but be careful with `raw/1` and `Phoenix.HTML.raw/1`.

4. **HttpOnly session cookies** — Phoenix does this by default. The session
   token is never accessible to JavaScript.

The persistent key cache (IndexedDB + Web Crypto) is more resilient — the
wrapping key is non-extractable, so even XSS can't read raw key bytes from it.
But during an active session, `sessionStorage` is the attack surface.

### Plaintext Email Touches the Server at Registration

The server transiently sees the plaintext email during registration (for
confirmation email delivery and HMAC blind index computation). After the request
completes, only `email_hash` and `encrypted_email` persist. This is an
intentional trade-off — alternatives (like client-side HMAC) would require
shipping the HMAC secret to the browser, which is worse.

If you need stronger email privacy, you can skip email confirmation entirely
and only store the HMAC + encrypted blob. The user's email is then only ever
readable by the user themselves (via client-side decryption).

### Cloak is Defense-in-Depth, Not the Primary Protection

The primary encryption is client-side XSalsa20-Poly1305 / ML-KEM-768. The Cloak
layer protects against DB-level compromise but is not the user's E2E encryption.
If you rotate your Cloak key, user data is still protected by the E2E layer.

### Key Rotation is Not Built In

This library uses a single-key design per context. If you need key rotation
(multiple active keys with version-tagged ciphertext), use Cloak for the Ecto
layer. MetamorphicCrypto's server-side role is key distribution and generation,
not rotation.

### Key Distribution Requires an Online Admin

In the event-driven model, key distribution only happens when an authorized user
(admin/owner) is online. If no admin is online when a new member is invited, the
member gets their key the next time any admin loads the page. Design your UI to
show a "pending access" state for members awaiting keys.

## Reading

- [Metamorphic encryption architecture](https://github.com/moss-piglet/metamorphic/blob/main/docs/ENCRYPTION_ARCHITECTURE.md)
— the production reference implementation
- [What Post-Quantum Encryption Means for Your Data](https://dev.to/mosspiglet/what-post-quantum-encryption-means-for-your-data)
- [Cloak](https://hex.pm/packages/cloak) — Ecto encrypted types and key rotation
- [metamorphic-crypto](https://github.com/moss-piglet/metamorphic-crypto)
- [libsodium documentation](https://doc.libsodium.org/) — wire format reference