
There's a specific kind of claim I'm suspicious of when I read it about other products: "zero-knowledge encrypted." It can mean a lot of things — from "we ran AES on it before saving" to "the server architecturally cannot read user content." Most of the time, when you dig in, it lands somewhere in between.
This post is the inside view of moving Mosslet — an open-source social network built in Elixir/Phoenix — from the in-between version to the architectural version, with hybrid post-quantum encryption as part of the move. It's the version with the honest edges intact.
If you just want the result: as of June 2026, Mosslet is a fully zero-knowledge social network. All user-generated content paths use hybrid ML-KEM-1024 + X25519 sealing performed in the browser via WebAssembly. The Rust crypto core is open source. The migration shipped in four phases, audited four times, with Audit #4 confirming zero key-material leaks across 87 JS hooks, 121 push_event calls, and ~230 server-side decrypt call sites.
There’s still more work to be done, but we’ve reached a point where people on the network can post and share in a fully zero-knowledge, post-quantum encrypted architecture.
If you want the story, keep reading.
The starting position
Mosslet was already encrypted before any of this. Content was encrypted with NaCl secret-box keys, those keys were sealed for each recipient with crypto_box_seal, and the database stored ciphertext. That's better than most products.
But the encryption happened on the server, before the row hit the database. Plaintext touched Phoenix memory on every write and most reads.
That's a real property — a database breach exposes nothing legible. But it's not what most people mean when they say "zero-knowledge." It's also not post-quantum. A patient adversary could harvest sealed crypto_box_seal ciphertext today and decrypt it once a cryptographically relevant quantum computer exists. "Harvest now, decrypt later" is a real threat model for a product whose pitch is protecting an inner life — journals, conversations, group chats.
So I committed to two things:
- Move all user-content encryption into the browser, before anything reaches the server.
- Add hybrid post-quantum sealing (ML-KEM + X25519) so today's captured ciphertext doesn't become tomorrow's plaintext.
What I didn't appreciate at the start was how much of that work would be neither cryptography nor Elixir. It would be discipline about which bytes get to cross which boundaries.
One Rust crate, three targets
The first decision that paid for itself many times over: build the crypto as a Rust crate with three compile targets.
metamorphic-crypto (Rust crate, #![forbid(unsafe_code)])
├── Compiles to WASM → browser (JS hooks encrypt/decrypt)
├── Compiles to NIF → metamorphic_crypto Hex package (server-side)
└── Compiles to UniFFI → iOS/Android (future native apps)
Dual-licensed MIT/Apache-2.0, published as a Hex NIF wrapper that's a NaCl-compatible drop-in replacement for enacl. Same Rust code runs in the browser and on the server. Wire format is guaranteed compatible because it's literally the same function compiled to two targets.
That unlocked a property I'd undervalued: migration without data conversion. The browser can encrypt a thing, the server can decrypt that exact thing if it has to (e.g. for public/federated content), and the on-disk format is identical whether the encryption happened browser-side or server-side. The phased migration could move call sites one at a time without a "now we re-encrypt everything" step.
Phase 1: rip out libsodium
Unglamorous, but it set up everything that followed. Replace enacl (C NIF wrapper around libsodium) with metamorphic_crypto (the Rust NIF). Same NaCl wire format. No data touched. libsodium-dev came out of the Dockerfile.
# mix.exs
- {:enacl, "~> 1.2”},
+ {:metamorphic_crypto, "~> 0.2”},
The interesting thing about Phase 1 is what it enabled: every Encrypted.Utils.* call in the codebase was now funneled through one Rust crate I controlled. Adding post-quantum primitives no longer meant coordinating with someone else's release cycle.
Phase 2: hybrid PQ sealing, server-side
Phase 2 added ML-KEM-768 + X25519 hybrid sealing — still server-side, but now post-quantum. Two columns on the users table:
# priv/repo/migrations/20260514155214_add_pq_key_fields_to_users.exs
add :pq_public_key, :binary
add :encrypted_pq_private_key, :binary
The wire format carried a version tag so the unseal side could auto-detect which scheme to use:
v1 (legacy): raw crypto_box_seal output — no prefix
v2 (Cat-3): 0x02 || ML-KEM-768 + X25519 ct || nonce || secretbox
v3 (Cat-5): 0x03 || ML-KEM-1024 + X25519 ct || nonce || secretbox
Existing ciphertext kept decrypting via the v1 path; new ciphertext used v2; the unseal function checked the first byte and dispatched. No flag day, no downtime.
Phase 3: move the crypto into the browser
This was the hard one.
Conceptually simple: compile metamorphic-crypto to WASM, vendor it under assets/vendor/, serve the .wasm binary from Phoenix static, and rewrite the LiveView hooks to encrypt before push_event.
In practice, this is where 90% of the actual work lived, because zero-knowledge isn't a property of any single function — it's a property of the entire data flow. Every place where the server reads a sealed key, decrypts content, and renders it to HTML is a place where ZK breaks.
Where the keys are born: registration
User keypairs are generated in the browser at signup, not on the server. The password-derived KEK wraps the private keys before they're sent up. The server never sees a plaintext private key — only sealed ones.
// assets/js/hooks/registration-hook.js (excerpt)
async generateZKMaterial(password) {
// Hybrid PQ keypair (Cat-5: ML-KEM-1024 + X25519)
const { publicKey, secretKey } = await pqGenerateKeypair();
const { publicKey: classicPub, secretKey: classicSec } =
await classicGenerateKeypair();
// Per-user attributes key (symmetric, used to encrypt profile fields)
const userAttributesKey = await randomSecretboxKey();
// Derive KEK from password (Argon2id in WASM)
const kek = await deriveKekFromPassword(password);
// Wrap private material with the password-derived KEK
const encryptedPqSecret = await secretboxSeal(secretKey, kek);
const encryptedClassicSecret = await secretboxSeal(classicSec, kek);
const encryptedUserAttributesKey = await secretboxSeal(userAttributesKey, kek);
// Only these public bytes + sealed-with-KEK blobs go to the server
this.pushEvent("save_zk_keys", {
pq_public_key: b64(publicKey),
encrypted_pq_private_key: b64(encryptedPqSecret),
classic_public_key: b64(classicPub),
encrypted_classic_private_key: b64(encryptedClassicSecret),
encrypted_user_attributes_key: b64(encryptedUserAttributesKey),
});
}
Full hook on GitHub Gist.
On the server side, the schema receives these as already-encrypted columns. The User schema uses Cloak.Ecto.Binary for an additional server-side at-rest layer over what the browser already did (defense in depth — even if a future bug ever wrote plaintext, it would still be encrypted at the database layer):
# lib/mosslet/accounts/user.ex (excerpt)
schema "users" do
field :email, Encrypted.Binary, redact: true
field :email_hash, Encrypted.HMAC, redact: true # blind index for lookup
field :username, Encrypted.Binary, redact: true
field :username_hash, Encrypted.HMAC, redact: true
field :password, :string, virtual: true, redact: true
# Browser-generated, server-stored sealed material
field :pq_public_key, :binary
field :encrypted_pq_private_key, :binary
field :key_pair, :map # classic keypair, private portion sealed
field :user_key, :binary # sealed user_attributes_key
# Virtual: populated by pre_decrypt_user/2 at LiveView mount, never persisted
field :decrypted, :map, virtual: true
...
end
Two things worth pointing out here:
-
field :password, :string, virtual: true, redact: true— the password is virtual and redacted from logs. The server never persists it; only the password-derived KEK (computed browser-side) is ever derivable from it. -
field :decrypted, :map, virtual: true— this is thepre_decrypt_userpattern. At LiveView mount, the server unseals the user's profile fields (which are server-encrypted with Cloak at rest), attaches them as a.decryptedmap, and every template readsuser.decrypted[:username]instead of decrypting on every render. Cut decrypt churn by an order of magnitude.
Full schema: GitHub Gist.
The write path: a two-phase commit
Saving a post is the most interesting write path. The form is bound to a normal Phoenix LiveView phx-submit=“save_post", but the hook intercepts the submit before it reaches Phoenix, runs the ZK flow, and ends up calling a different event entirely.
// assets/js/hooks/post-form-hook.js (excerpt)
this.el.addEventListener("submit", async (e) => {
if (this.isPublicPost()) return; // public posts stay server-side (SEO, federation)
e.preventDefault();
const postKey = await randomSecretboxKey();
const ciphertext = await secretboxSeal(body, postKey);
const cwCiphertext = cw ? await secretboxSeal(cw, postKey) : null;
// Phase 1: send the encrypted body — server replies with recipient pubkeys + metadata
this.pushEvent("save_post_encrypted", {
ciphertext: b64(ciphertext),
content_warning_ciphertext: cwCiphertext && b64(cwCiphertext),
visibility,
shared_user_ids,
});
// Hold the post_key in memory; we'll need it again for Phase 2
this._pendingPostKey = postKey;
});
// Phase 2: server pushes recipient list + display-name metadata back to us
this.handleEvent("encrypt_post_fields", async ({ recipients, metadata }) => {
const postKey = this._pendingPostKey;
// Seal post_key for each recipient with their (browser-fetched) hybrid PQ pubkey
const sealed_keys = await Promise.all(
recipients.map(async (r) => ({
user_id: r.user_id,
sealed_key: b64(await sealForUser(postKey, r.pq_public_key, r.classic_public_key)),
}))
);
// Encrypt everything else (usernames-as-displayed, etc.) with post_key
const encrypted_fields = await encryptMetadata(metadata, postKey);
this.pushEvent("finalize_post_encrypted", { sealed_keys, encrypted_fields });
this._pendingPostKey = null;
});
Full hook: GitHub Gist.
The phx-submit=“save_post" handler in the parent MossletWeb.PostLive.Index LiveView is the legacy / public-post fallback. Private posts never reach it — they're intercepted by the form hook and routed through save_post_encrypted / finalize_post_encrypted on the MossletWeb.PostLive.FormComponent LiveComponent instead. On the server side, those events accept pre-encrypted ciphertext through a changeset_zk/3 that skips all server-side encryption. The server stitches a post together out of parts it can't read.
The "graceful fallback" line is more important than it looks. If WASM hasn't loaded yet, or postKey derivation fails for any reason, the form falls through to the server-side path. The product never breaks. Over time, the server-side path runs less often, but it remains correct.
The read path: sealed key → WASM unseal → DOM
The read path is the mirror image. The server sends ciphertext + sealed post_key as data-* attributes on the post node. A LiveView hook (DecryptPost) unseals the key in WASM and writes the plaintext into the target DOM node.
// assets/js/hooks/decrypt-post.js (excerpt)
async mounted() {
const sealedKey = this.el.dataset.sealedPostKey;
const ciphertext = this.el.dataset.encryptedBody;
if (!sealedKey || !ciphertext) return;
// Use cached conn_key if we've unsealed this connection's keys before (LRU, max 50)
const postKey = await unsealForUser(
sealedKey,
this._myClassicSecret,
this._myPqSecret,
);
cachePostKey(this.el.dataset.postId, postKey); // cached for later fav/edit ops
const plaintext = await secretboxOpen(ciphertext, postKey);
this.target.innerHTML = renderMarkdown(plaintext); // markdown rendered browser-side
}
Full hook: GitHub Gist.
Two details worth flagging:
- Markdown rendering happens browser-side. For private posts, the server never sees the rendered HTML — only the encrypted markdown source. (For public posts, markdown renders server-side, because federation and SEO need it. Different code path, same data flow direction.)
-
Cross-instance
_connKeyCacheLRU (max 50 entries). Once a connection'sconn_keyis unsealed, it's cached in a module-level Map keyed by connection_id. Subsequent posts from the same connection skip the unseal step. Bounded so an attacker who pops a single session can't pull every key the user has ever touched.
Two-phase commit for group creation
Groups are the hardest write path in any ZK social product. A group_key has to be sealed for every member without the server ever holding the raw key.
The browser handles it as a two-phase coordination:
-
Phase 1: browser generates
group_key, encrypts name/description, seals the key for the creator, fetches each member's hybrid pubkeys. -
Phase 2: browser seals
group_keyfor every member, encrypts member metadata (display names, monikers, avatars), and pushes everything in a singlecreate_group_zkcall.
On the Elixir side, this is one function that accepts pre-sealed material — the two-phase complexity lives entirely in the browser:
# lib/mosslet/groups.ex (excerpt)
@doc """
Creates a group from browser-encrypted fields (ZK two-phase commit).
The browser generated the group_key, encrypted name/description, sealed the key
for the creator and all members, and encrypted member display names. The raw
group_key NEVER exists in server memory.
"""
def create_group_zk(zk_attrs, owner, users, sealed_members) do
group_changeset = Group.create_changeset_zk(zk_attrs)
owner_changeset = UserGroup.owner_changeset_zk(zk_attrs)
sealed_by_user_id = Enum.into(sealed_members, %{}, &{&1["user_id"], &1})
Ecto.Multi.new()
|> Ecto.Multi.insert(:insert_group, group_changeset)
|> Ecto.Multi.insert(:insert_user_group, fn %{insert_group: g} ->
owner_changeset |> Ecto.Changeset.put_change(:group_id, g.id)
end)
|> Mosslet.Repo.transaction_on_primary()
|> case do
{:ok, %{insert_group: group}} ->
for u <- users, sealed = sealed_by_user_id[u.id], sealed do
UserGroup.member_changeset_zk(%{
sealed_key: sealed["sealed_key"],
encrypted_name: sealed["encrypted_name"],
encrypted_moniker: sealed["encrypted_moniker"],
encrypted_avatar_img: sealed["encrypted_avatar_img"]
})
|> Ecto.Changeset.put_change(:group_id, group.id)
|> Ecto.Changeset.put_change(:user_id, u.id)
|> then(&Mosslet.Repo.transaction_on_primary(fn -> Mosslet.Repo.insert(&1) end))
end
{:ok, adapter().get_group!(group.id)} |> broadcast(:group_created)
error -> error
end
end
Full module: GitHub Gist.
Phase 4: Cat-5 default
Phase 4 swapped the default from Cat-3 (ML-KEM-768) to Cat-5 (ML-KEM-1024). The version-tag design paid off again: existing v2 ciphertext kept decrypting via auto-detection, new operations sealed as v3, and a background worker progressively re-sealed users' context keys on login.
The re-seal worker is the part I'm most quietly proud of, because of what it isn’t:
# lib/mosslet/workers/pq_reseal_worker.ex (excerpt)
defmodule Mosslet.Workers.PqResealWorker do
@moduledoc """
Progressively re-seals a user's context keys to Cat-5 hybrid.
Re-seals v1 legacy and v2 Cat-3 to v3 Cat-5.
Runs as an in-memory background task via `Mosslet.BackgroundTask` so the
session key never touches persistent storage (DB, logs, etc.). If the BEAM
restarts mid-reseal, remaining keys are picked up on the next login —
the version-tag check makes this idempotent.
"""
@cat5_version_tag 0x03
def run_async(user, session_key) do
Mosslet.BackgroundTask.run(fn ->
user = Accounts.get_user!(user.id)
user
|> list_context_keys_for_user()
|> Enum.reject(&already_cat5?/1)
|> Enum.each(fn sealed ->
sealed
|> unseal_with_session(session_key)
|> reseal_as_cat5(user.pq_public_key, user.key_pair["public"])
|> persist_resealed(sealed.id)
end)
end)
end
defp already_cat5?(%{sealed_key: <<@cat5_version_tag, _rest::binary>>}), do: true
defp already_cat5?(_), do: false
end
The thing this isn't is an Oban worker. Oban persists job args to the database. The session key — the thing required to unseal v1/v2 ciphertext so it can be re-sealed as v3 — is exactly the bit of material that must never touch disk. So this runs as an in-memory Task via Mosslet.BackgroundTask.run/1, fire-and-forget on login.
The version-tag check (already_cat5?/1) makes the whole thing idempotent. A BEAM restart mid-reseal isn't a problem: next login enqueues another pass, and keys already at v3 are skipped on the first byte. No flag day, no stop-the-world, no risk of writing a session key to a job-args column.
Audit #4: the part where you check your work
Architecture claims are cheap. The fourth audit is the part that turns the claim into evidence.
Scope: every server-side decrypt call site (~230), all 87 JS hooks, all 121 push_event calls, every sessionStorage access, every data-* DOM attribute, and every .toLowerCase() call that crosses the server boundary (because lowercasing is how blind-index pre-images get built, and a stray one is how plaintext leaks).
Findings:
All 270 tests pass.
The remaining ~102 server-side decrypts
Here's the part most "we built a zero-knowledge X" posts skip. There are still ~102 server-side decrypt call sites. None of them are write paths. None of them are high-traffic read paths. They break down like this:
Each one is documented with its call site in docs/PQ_ENCRYPTION_MIGRATION.md. Some of them — the edit-form pre-fill in particular — are queued for a Phase 5 migration to client-side hooks. Some of them (federation, S3 URLs, SMTP) are architecturally required: if a feature is "show this to an unauthenticated viewer" or "hand this to a third-party protocol," there is no version of that feature that's also zero-knowledge. That's not a flaw to paper over. It's the trade-off being honest about itself.
One pattern worth naming explicitly, because a careful reader of the codebase will spot it: there is a server-side helper called pre_decrypt_post/3. It does not decrypt the post body. It decrypts post metadata (the owner's display name as it should appear to the current viewer, for instance) — fields that are server-encrypted at rest with Cloak. The body itself is handed to decrypt-post.js as opaque ciphertext and unsealed in WASM. Same name pattern, different layer.
What I'd tell someone starting this
A few things I'd save you if you were about to do this:
Build the crypto as one library with multiple targets, not two libraries that happen to agree. The single biggest source of bugs in cross-environment crypto is the wire format drifting between the browser and the server implementation. Compiling the same Rust to WASM and to a NIF made that class of bug impossible.
Version-tag your ciphertext from day one. A single leading byte (0x02, 0x03, …) lets you migrate forward forever without data conversion. The cost is one byte. The payoff is every future migration being a default-flip plus a background reseal instead of a downtime window.
Zero-knowledge is a property of the data flow, not of a function. Most of the work is not writing seal() and unseal(). It is auditing every data-* attribute, every push_event payload, every sessionStorage.setItem, every IO.inspect someone left in during debugging. The fourth audit was not redundant; it was the one that caught the last {:ok, _} = pattern that would have crashed loudly on a malformed ciphertext instead of falling back gracefully.
Plan the graceful fallback before you plan the happy path. WASM doesn't always load in time. Keys aren't always derived on first render. Caches get evicted. If your ZK path is the only path, your product breaks the first time the network hiccups. If your ZK path is the default path with a correct server-side fallback, you get to ship one call site at a time and let the server-side path quietly retire as the browser side proves itself.
Be honest about the edges. "World's first post-quantum zero-knowledge social network" is a defensible claim because every write path and every high-traffic read path is browser-encrypted. It would not be defensible if I tried to also claim the SEO crawler page or the ATProto export is zero-knowledge. Naming what's in scope and what isn't is what makes the in-scope part believable.
Where to look
- Crypto core (Rust, MIT/Apache-2.0):
github.com/moss-piglet/metamorphic-crypto - Elixir NIF wrapper:
hex.pm/packages/metamorphic_crypto - Mosslet (AGPL-3.0):
github.com/moss-piglet/MOSSLET - Full migration doc & remaining-decrypt inventory:
docs/PQ_ENCRYPTION_MIGRATION.md - ZK architecture guide:
docs/zero-knowledge-guide.md
Mosslet is live at mosslet.com. It's the first one. It probably won't be the last, and that's the point — the crypto core is open source specifically so it doesn't have to be.


Top comments (0)