Metamorphic - Zero-knowledge, E2E encrypted habit tracker built with Phoenix LiveView

Hi everyone :waving_hand:,

I’d like to share Metamorphic, a zk habit and self-improvement tracker I’ve been building with Phoenix LiveView.

The core idea: all personal data is encrypted client-side before it ever reaches the server. The server only stores opaque ciphertext blobs.

Some backstory: I’ve had the brand assets for Metamorphic ever since earlier versions of Mosslet, and I was trying to think of what my next application project could be for it. I was inspired by observing my partner’s passion for psych, behavior, and habit forming. That started to give me the idea and then it made sense to me that something as personal as your habits and goals should be private to only you, and you shouldn’t have to worry about it being otherwise (I was also motivated to apply the recent zk-messaging updates from Mosslet to an entire app).

What it does:

  • Habit tracking with daily/weekly check-ins, streaks, and drag-and-drop reordering
  • Self-reflections with mood tracking and daily prompts
  • Goal setting with milestones, progress bars, and habit linking
  • Schedule/calendar with recurring events, day planner, and printable views
  • Family/group accountability with shared habits, shared goals, and a group dashboard
  • Progress insights with activity heatmaps and completion stats
  • Data export (JSON/CSV) — decrypted entirely client-side, server never sees plaintext

Encryption is not a premium feature. Every tier gets full E2E encryption. Paid tiers gate convenience (unlimited habits, reminders, export, groups), not privacy.

How the crypto works:

  • Client-side encryption via libsodium-wrappers-sumo(XSalsa20-Poly1305 for data, NaCl box/seal for key distribution)
  • Hybrid post-quantum key encapsulation (ML-KEM-768 + X25519 via @noble/post-quantum) — same approach as Signal and Apple iMessage
  • Three independent encryption layers at rest: client-side E2E, Cloak AES-256-GCM in Postgres, and LUKS disk encryption on Fly.io (fly’s managed postgres)
  • Zero-knowledge email: no plaintext email column in the database, only an HMAC blind index for lookups and an E2E-encrypted blob
  • Password never touches sessionStorage — only the Argon2id-derived session key
  • Persistent key cache using Web Crypto API (non-extractable AES-256-GCM wrapping key in IndexedDB) so browser restarts don’t require re-entering your password
  • Recovery key flow for password reset without server access to private keys

Tradeoffs worth mentioning:

  • LiveView and zk encryption are in tension — the server renders the page but can’t render the actual content. You end up with brief placeholder skeletons that JS hooks fill in after decryption, and a lot of push_event/handleEventchoreography (15+ hooks)… (although not too different UX-wise from my experience with Mosslet’s mostly trust-the-server model).
  • No server-side search on encrypted fields. Filtering by habit name or reflection text has to happen client-side after decryption. Fine so far for now.
  • Testing perhaps is harder than usual — you can’t assert on decrypted content in LiveView tests since decryption is JS-only, so tests focus on DOM structure and data attributes rather than visible text. The context-level tests (Habits, Goals, Reflections, etc.) do verify that encrypted fields are stored and retrieved correctly (assert habit.encrypted_name != nil ), so the data pipeline is tested — it’s just the decrypt-and-display pipeline that seems untestable server-side.
  • If a user loses their password and hasn’t set up a recovery key, their data is gone by design. Correct for our zk design, but a real UX tradeoff.

Tech stack:

  • Elixir/Phoenix LiveView for the full-stack web app
  • Ecto + Postgres (Fly.io Managed Postgres)
  • libsodium-wrappers-sumo + @noble/post-quantum on the client
  • Cloak/cloak_ecto for application-level at-rest encryption
  • Oban for background jobs (reminders)
  • Tailwind CSS v4 + daisyUI (theming only) for the UI
  • Sortable.js for drag-and-drop, JSZip for export packaging
  • Tidewave for AI-assisted development — runtime introspection, live SQL/eval, a11y diagnostics, and browser interaction from the editor

A lot was taken/shared from my work with MOSSLET - a privacy-focused alternative to the social networking landscape (built with Elixir).

Happy to answer any questions. You can also check out a more detailed overview of the encryption architecture at our encryption page. As before, 20% forever discount for using code ELIXIRFORUM20.

I’m not very good at habit/goal tracking and so I’ve been using this myself to encourage getting back into yoga, meditation, and running. So far so good. Let me know if it’s helpful for you. Cheers :blush:

4 Likes

Update: Open-sourced our crypto core — now Rust/WASM :blush:

Wanted to share a significant update since the original post. I’ve migrated all client-side cryptography from JavaScript (libsodium-wrappers-sumo + custom hybrid.js) to a unified Rust core compiled to WebAssembly.

Why: As a solo developer, I was trying to figure out how to reduce the maintenance burden while keeping the same security guarantees across platforms. Maintaining separate crypto implementations in JS, Swift, and Kotlin — all producing byte-identical ciphertext — felt like the one place I really couldn’t afford to juggle multiple codebases. A single Rust library that compiles to WASM for the web and can generate native bindings via UniFFI for iOS and Android down the road lets me stay focused on building in Elixir and Phoenix LiveView while knowing the crypto layer is consistent everywhere — and now auditable. At least, that’s my thinking.

What changed:

  • All crypto operations now run through metamorphic-crypto , a Rust library built onRustCrypto primitives with #![forbid(unsafe_code)]
  • Same algorithms: XSalsa20-Poly1305, hybrid ML-KEM-768 + X25519 post-quantum KEM,Argon2id key derivation
  • Version-tagged ciphertext (v1 legacy ↔ v2 hybrid) with auto-detection — all existingdata decrypts seamlessly
  • LiveView hooks call into the WASM module instead of JS crypto libraries. Thepush_event/handleEvent choreography is unchanged — just the crypto layerunderneath swapped out.
  • Migration is complete and running in production :backhand_index_pointing_right: metamorphic on Fly.io

The repo: github.com/moss-piglet/metamorphic-crypto Updated encryption architecture: metamorphic.app/encryption

The integration story might be interesting to anyone doing Rust/WASM interop in a LiveView app — happy to go deeper on any of it. It’s also interesting, to me at least, because my other product, Mosslet, intially took the :enacl and trust-the-server encryption approach. And again, I think for me, the biggest bottleneck arises when thinking about how to bring the encryption architectures I’ve gone with, and the subsequent ux, into native in a way that I can maintain (happily).

Also: since the original post my founder story was featured on the We Are Founders site and ranked #1 on their best habit tracking apps for 2026. You can also read some helpful articles on our metamorphic.app/blog.The ELIXIRFORUM20 discount code still works.

2 Likes

For anyone new and/or wanting to hear more about the LiveView integration side, I thought I’d share more about how the zero-knowledge encryption actually works in our Phoenix LiveView app.

I also wrote about the post-quantum encryption architecture and why we chose Rust/WASM on dev.to (in addition to my previous post above): What Post-Quantum Encryption Means for Your Data. This is more about the Elixir side — how LiveView made this pattern surprisingly elegant and I got to keep working in my favorite language :blush:.

The core challenge

Zero-knowledge for us is about the server never seeing plaintext of sensitive information (not a Web3 proof), but LiveView’s whole model is server-rendered. So you end up with this interesting inversion: the server renders the structure of the page, but the content of any sensitive field has to be decrypted and injected client-side.

How the key lifecycle works

When a person logs in, their password derives a session key (Argon2id, client-side in WASM). That session key decrypts their private key. The derived keys go into sessionStorage. The server’s job is just getting the encrypted key material to the client so this can happen.

Here’s the on_mount that makes it work:

def on_mount(:ensure_authenticated, _params, session, socket) do
  socket = mount_current_scope(socket, session)

  if socket.assigns.current_scope && socket.assigns.current_scope.user do
    socket =
      socket
      |> Phoenix.Component.assign(
        :crypto_data,
        build_crypto_data(socket.assigns.current_scope.user)
      )
      |> Phoenix.Component.assign(:crypto_ready, false)
      |> Phoenix.LiveView.attach_hook(:reauth_redirect, :handle_event, fn
        "session_keys_derived", _params, socket ->
          {:cont, Phoenix.Component.assign(socket, :crypto_ready, true)}

        "needs_reauth", %{"return_to" => return_to}, socket ->
          {:halt,
           socket
           |> Phoenix.LiveView.put_flash(:info, "Please re-enter your password to unlock your encrypted data.")
           |> Phoenix.LiveView.redirect(to: ~p"/users/reauthenticate?#{[return_to: return_to]}")}
        # ...
      end)

    {:cont, socket}
  else
    socket =
      socket
      |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
      |> Phoenix.LiveView.redirect(to: ~p"/users/log-in")

    {:halt, socket}
  end
end

# ...

defp build_crypto_data(user) do
  %{
    public_key: user.public_key || "",
    encrypted_private_key: user.encrypted_private_key || "",
    encrypted_user_key: user.encrypted_user_key || "",
    key_hash: user.key_hash,
    pq_public_key: user.pq_public_key,
    encrypted_pq_private_key: user.encrypted_pq_private_key
  }
end

crypto_data pushes the encrypted key material into assigns for the client hook. crypto_ready starts false and flips when the client pushes "session_keys_derived" back. If keys aren’t in sessionStorage (refresh, expiry), the client pushes "needs_reauth" and the attach_hook redirects to re-enter the password.

This lives at the on_mount level, so every LiveView in the authenticated session gets it for free. No per-view boilerplate.

Why LiveView was actually great for this

In LiveView the key derivation, reauth, and key exchange all collapse simply into on_mount + attach_hook. The server controls navigation, the socket is always there, and the client hook only needs to decrypt and inject — not manage state or rendering. It’s elegant and performant and I really don’t have to think about it.

We had initially created our zk architecture without a post-quantum key encapsulation, so there were already people using our service in production when I decided to add it. This meant that we needed a post-quantum migration flow that was backward compatible, and this is where LiveView, I feel, really shines.

When a person without hybrid keys logs in:

  1. Client generates ML-KEM-768 keypair in WASM
  2. Encrypts the private key with the session key
  3. Pushes "pq_key_migration" to the server
  4. Server stores the keys, responds with existing context keys that need re-sealing
  5. Client re-seals each one with the hybrid scheme, pushes "pq_reseal_keys_done”
  # :ensure_authenticated...
  "pq_key_migration", params, socket ->
     user = socket.assigns.current_scope.user

     with :ok <- validate_pq_key_params(params),
          {:ok, updated_user} <-
            Metamorphic.Accounts.update_user_pq_keys(user, %{
              pq_public_key: params["pq_public_key"],
              encrypted_pq_private_key: params["encrypted_pq_private_key"],
              encrypted_user_key: params["encrypted_user_key"]
            }) do
       # Send all v1-sealed context keys to the client for re-sealing
       context_keys = Metamorphic.Accounts.get_context_keys_for_reseal(updated_user)

       {:halt,
         socket
         |> Phoenix.Component.assign(:crypto_data, build_crypto_data(updated_user))
         |> Phoenix.LiveView.push_event("pq_reseal_context_keys", %{
           keys: context_keys
         })}
      else
        _ -> {:halt, socket}
      end

  "pq_reseal_keys_done", params, socket ->
     user = socket.assigns.current_scope.user

     case Metamorphic.Accounts.reseal_context_keys(user, params["keys"] || []) do
       {:ok, _} -> {:halt, socket}
       {:error, _} -> {:halt, socket}
     end
  # ...

Multi-step cryptographic migration over a single web socket connection. The server orchestrates but never sees plaintext. It’s just push_event / handle_event back and forth.

The Ecto side: Cloak + HMAC blind indexes

Every sensitive field on the server uses Cloak/cloak_ecto (AES-256-GCM):

schema "users" do
  field :email, :string, virtual: true, redact: true
  field :email_hash, Metamorphic.Encrypted.HMAC
  field :encrypted_email, Metamorphic.Encrypted.Binary
  field :public_key, Metamorphic.Encrypted.Binary
  field :encrypted_private_key, Metamorphic.Encrypted.Binary
  field :encrypted_user_key, Metamorphic.Encrypted.Binary
  field :key_hash, :string

  # Post-quantum hybrid key fields
  field :pq_public_key, Metamorphic.Encrypted.Binary
  field :encrypted_pq_private_key, Metamorphic.Encrypted.Binary
  # ...
end

This is the second encryption layer on top of the zero-knowledge layer. If someone dumps the database they get triple-encrypted blobs (Fly’s LUKS → Cloak → Metamorphic’s ZK-layer).

For lookups — finding someone by email during login — we use HMAC blind indexes. Query WHERE email_hash = ? without storing or indexing plaintext.

Other things Elixir/Phoenix/OTP gave us for free

  • PubSub + DNSCluster — when encrypted data changes, broadcast to other sessions for the same person. Client decrypts on arrival. No polling. And when we scale to multiple nodes, DNSCluster + Fly.io’s private network means PubSub messages route across instances automatically. We don’t have to think about it.
  • Oban — reminders, email delivery, subscription lifecycle, all supervised in the same app. No separate worker to deploy.
  • Supervision tree — Vault, Repo, PubSub, Oban, rate limiter, endpoint all start in order with restart guarantees. The app boots knowing crypto infrastructure is ready.
  • Verified routes~p"/users/reauthenticate?#{[return_to: return_to]}” catching typos at compile time. Small thing, but in auth flows a wrong redirect could be a security issue.

The trade-offs

There’s a brief moment after mount where encrypted fields aren’t visible while the WASM module initializes and decrypts. We handle it with loading states that resolve once crypto_ready flips. In practice it’s fast enough, but it’s real.

And obviously — no server-side full-text search, no server-rendered previews, no aggregate analytics. The server is a storage and routing layer for encrypted blobs. That’s the deal.

For the full post-quantum encryption breakdown (hybrid KEM, version-tagged ciphertext, three-layer encryption model): What Post-Quantum Encryption Means for Your Data

As I previously shared, the crypto library is open source and MIT-licensed: metamorphic-crypto.

1 Like