Building Zero-Knowledge Phoenix Apps

Copy Markdown View Source

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 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

LayerRoleTechnology
BrowserEncrypts/decrypts user dataWASM (metamorphic-crypto in JS hook)
BrowserDerives session key from passwordWASM Argon2id
BrowserSeals context keys for sharingWASM (client-side key distribution)
BrowserCaches derived keysIndexedDB + Web Crypto API
ServerGenerates keypairs during provisioningmetamorphic_crypto NIF
ServerOrchestrates key distribution eventsLiveView push_event
ServerBackground re-keying (PQ migration)metamorphic_crypto NIF (with client-provided keys)
ServerStores opaque ciphertextEcto schema
ServerDefense-in-depth at-rest encryptionCloak (AES-256-GCM)
ServerBlind indexes for lookupsHMAC-SHA512

Prerequisites

Add these to your mix.exs:

def deps do
  [
    {:metamorphic_crypto, "~> 0.2"},
    {: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 section below.

Client Setup

1. Add the WASM Module

Build the metamorphic-crypto Rust crate to WASM:

# Clone the crate and build with wasm-pack
git clone --depth 1 https://github.com/moss-piglet/metamorphic-crypto.git
wasm-pack build metamorphic-crypto --target web --out-dir pkg --no-typescript

Copy the generated files to your Phoenix app:

mkdir -p assets/vendor/metamorphic-crypto
cp metamorphic-crypto/pkg/metamorphic_crypto.js assets/vendor/metamorphic-crypto/
cp metamorphic-crypto/pkg/metamorphic_crypto_bg.wasm assets/vendor/metamorphic-crypto/

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

// 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:

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

2. Create the Crypto Module

// 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:

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

loadCrypto();

3. The Key Cache Module

// 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

<%!-- 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.

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:

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

<%!-- 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
<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.
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:

# 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:

LayerScopePurpose
sessionStorageCurrent tab onlyActive decryption keys — cleared on tab close
localStorage + IndexedDBPersistentEncrypted 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:

<%!-- 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

<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.

<%!-- 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.

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

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

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).

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

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:

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:

<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.

<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

<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:

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':

    # 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:

    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