<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mark Thayer</title>
    <description>The latest articles on DEV Community by Mark Thayer (@f0rest8).</description>
    <link>https://dev.to/f0rest8</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3180631%2F268029bc-d75c-42e6-9467-b9ecb04995bb.jpeg</url>
      <title>DEV Community: Mark Thayer</title>
      <link>https://dev.to/f0rest8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/f0rest8"/>
    <language>en</language>
    <item>
      <title>Three Questions Every Zero-Knowledge Builder Gets Asked (And How I Answer Them)</title>
      <dc:creator>Mark Thayer</dc:creator>
      <pubDate>Sun, 07 Jun 2026 02:14:59 +0000</pubDate>
      <link>https://dev.to/f0rest8/three-questions-every-zero-knowledge-builder-gets-asked-and-how-i-answer-them-cec</link>
      <guid>https://dev.to/f0rest8/three-questions-every-zero-knowledge-builder-gets-asked-and-how-i-answer-them-cec</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzbg5jie0jfmxqwfg4qh3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzbg5jie0jfmxqwfg4qh3.png" alt="Lattice ocean waves with the Moss Piglet logo" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Last week, a fellow builder working on a zero-knowledge financial tracker left a thoughtful comment on the &lt;a href="https://www.producthunt.com/p/metamorphic/zero-knowledge-habit-tracking-your-growth-belongs-to-you?comment=5432111" rel="noopener noreferrer"&gt;Metamorphic Product Hunt&lt;/a&gt; page. He hit his first 100% streak, said he liked the product, and then — because he's deep in the same architectural decisions I've been living with — asked me three questions.&lt;/p&gt;

&lt;p&gt;They're the same three questions I get asked everywhere: in DMs, in calls with potential customers, in conversations with other founders. So I'm pulling my reply out of the thread and putting it here, because if you're building anything zero-knowledge, you'll get asked all three eventually.&lt;/p&gt;

&lt;p&gt;The questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is end-to-end encryption worth keeping in the core, or should it be optional?&lt;/li&gt;
&lt;li&gt;Aren't you worried about users losing their data, with no way for you to help?&lt;/li&gt;
&lt;li&gt;Don't you feel like the odd one out for not bolting on AI?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here was my reply:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Is E2EE worth keeping in the core?
&lt;/h2&gt;

&lt;p&gt;Yes — and I'd encourage you to hold the line on financial data especially. Financial behavior is one of the most sensitive datasets imo. If you've already done the architectural work to support it, that's compounding value you don't want to throw away. Every time you re-litigate the decision, you pay the migration tax twice (I’ve been there too). Build it in, document the tradeoffs honestly, and move on to features. The customers who care will be the ones worth keeping. Also, the more we build with zero-knowledge, privacy-first architectures in mind — the more we’ll start to innovate there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Worried about users losing their data because we can't recover it?
&lt;/h2&gt;

&lt;p&gt;Yes, but it doesn’t keep me up at night, and I built around that constraint. A few things that I think help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Password reset flows preserve data.&lt;/strong&gt; When users reset their password, their data stays accessible — we use password-derived keys with a recovery mechanism that doesn't give us access to the cleartext.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data export, always.&lt;/strong&gt; Users can export everything at any time. The strongest user protection isn't us holding their data hostage — it's making sure they can walk out the door with it whenever they want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human readable recovery keys&lt;/strong&gt; so people can be proactive to have a second layer of protection against accidental data loss due to forgotten passwords. All while remaining zero-knowledge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear UX around the tradeoff.&lt;/strong&gt; We're explicit during signup: this is zero-knowledge, which means you hold the key. The customers who choose us choose us partly because of that constraint, not despite it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The worry never fully goes away, but I try to reframe it because what's the alternative? Being able to recover data because we hold the keys — that seems worse in terms of failure for the people who actually need privacy.&lt;/p&gt;

&lt;p&gt;In your case, I would think failure is even higher-risk than a service like Metamorphic. Or take our other product, Mosslet (a social network), you could have journalists, abuse survivors, people with controlling family members, people in regulated industries.&lt;/p&gt;

&lt;p&gt;The "we can recover anything" architecture is the architecture that gets subpoenaed, breached, or sold. Maybe you can protect it for a while, but it’s a hard thing to do the longer you stay in business. Being able to say, “Okay we got breached, but all they got was encrypted blobs that they can’t do anything with” — that seems better to me than “Okay — it happened to us too".&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Feeling like the odd one out for not bolting on AI?
&lt;/h2&gt;

&lt;p&gt;Sometimes it does seem like only AI-heavy products are getting attention. But, honestly? No. The opposite. And DuckDuckGo just saw huge adoption numbers after Google started force-feeding people AI, showing customers might still prefer choice and considerate design.&lt;/p&gt;

&lt;p&gt;And in terms of privacy, AI-in-the-product almost always means "your data goes to a third-party model provider," which collapses zero-knowledge the moment you ship it. There are emerging patterns for on-device / WASM-resident inference that could preserve E2EE, and I'm watching that space — but I'm trying not to ship AI features that quietly undo all the wins for people from our privacy-first zk architecture — so I have to be slower perhaps than others in that space.&lt;/p&gt;

&lt;p&gt;Although, I have started to use some WASM/on-device AI on &lt;a href="https://mosslet.com/" rel="noopener noreferrer"&gt;Mosslet&lt;/a&gt; to get some of the automation benefits while preserving people’s privacy. I think that’s an area you may want to explore because there is some real value to the speed and automation you could achieve with AI models and financial data, you’d just want to ensure they’re running locally on the client-side with no exposure of people’s decrypted data.&lt;/p&gt;

&lt;p&gt;I’ve heard people say the market will split to be either AI products or privacy products, but not both — I tend to push back a bit there. I think the more we develop privacy-first products then the more we will start to think about how we can make AI fit that model — and then we’ll have some real innovation. And hopefully also innovation around the production of AI (as it’s not a bright spot in my opinion).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The three questions above all share a shape. They're all variants of the same underlying anxiety: &lt;em&gt;am I making the product harder, slower, or less competitive by holding the line on privacy?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My honest answer, after building two zero-knowledge products and shipping a post-quantum migration to production: no. The constraint makes the product better. It forces clearer UX. It forces honest tradeoffs. It attracts the customers who actually care, and those customers stay.&lt;/p&gt;

&lt;p&gt;And to me, most importantly, it makes the product safer. &lt;/p&gt;

&lt;p&gt;Software products (therefore developers) have become so integral to our modern life that it becomes borderline negligent to ship a service that isn’t built to protect its customers. It would be like a doctor or surgeon or nurse treating a patient with no regard for the harm their position makes possible. &lt;/p&gt;

&lt;p&gt;Software is being used to decide whether your future self-driving car stops for a pedestrian or traffic light. It’s also being proposed to determine whether you should be arrested for a crime you didn’t commit. And currently being used to find, identify, and track you. These systems carry an immense amount of power and wield an immense potential to harm people — privacy and thoughtful, considerate design of software systems have never been more important.&lt;/p&gt;

&lt;p&gt;The world doesn't need fewer zero-knowledge builders. It needs more of us — building in public, comparing notes, and refusing to treat privacy as a feature flag.&lt;/p&gt;

&lt;p&gt;If any of this is useful in your own build, here are the tools we've open-sourced along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://crates.io/crates/metamorphic-crypto" rel="noopener noreferrer"&gt;metamorphic-crypto&lt;/a&gt; — our Rust encryption library, also packaged for WASM on &lt;a href="https://www.npmjs.com/package/@f0rest8/metamorphic-crypto" rel="noopener noreferrer"&gt;npm&lt;/a&gt;. Hybrid ML-KEM-1024 + X25519 post-quantum KEM, XSalsa20-Poly1305, Argon2id. Dual MIT/Apache-2.0.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hex.pm/packages/metamorphic_crypto" rel="noopener noreferrer"&gt;metamorphic_crypto&lt;/a&gt; — the Elixir NIF wrapper. Honest scoping: this is server-side, not zero-knowledge itself, but it's a useful drop-in enacl replacement for teams migrating toward a ZK architecture. There's a &lt;a href="https://github.com/moss-piglet/metamorphic_crypto/blob/main/docs/zero-knowledge-guide.md" rel="noopener noreferrer"&gt;zero-knowledge guide&lt;/a&gt; in the repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the deeper technical story of how we got Mosslet to post-quantum zero-knowledge in production, I wrote that up &lt;a href="https://dev.to/f0rest8/building-the-worlds-first-post-quantum-zero-knowledge-social-network-a-phased-migration-story-2i5d"&gt;here&lt;/a&gt; earlier this week.&lt;/p&gt;

&lt;p&gt;And if you'd ever like direct help thinking through a zero-knowledge architecture, my company &lt;a href="https://mosspiglet.dev/" rel="noopener noreferrer"&gt;Moss Piglet&lt;/a&gt; does consulting. 🦦&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>elixir</category>
    </item>
    <item>
      <title>Building the World's First Post-Quantum Zero-Knowledge Social Network: A Phased Migration Story</title>
      <dc:creator>Mark Thayer</dc:creator>
      <pubDate>Tue, 02 Jun 2026 17:53:38 +0000</pubDate>
      <link>https://dev.to/f0rest8/building-the-worlds-first-post-quantum-zero-knowledge-social-network-a-phased-migration-story-2i5d</link>
      <guid>https://dev.to/f0rest8/building-the-worlds-first-post-quantum-zero-knowledge-social-network-a-phased-migration-story-2i5d</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fehempidgfz1x9zskdp4d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fehempidgfz1x9zskdp4d.png" alt="Screenshot of Mosslet’s public timeline featuring the latest public post from co-founder/creator mark" width="800" height="523"&gt;&lt;/a&gt;&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;This post is the inside view of moving &lt;a href="https://mosslet.com" rel="noopener noreferrer"&gt;Mosslet&lt;/a&gt; — 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;push_event&lt;/code&gt; calls, and ~230 server-side decrypt call sites.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want the story, keep reading.&lt;/p&gt;
&lt;h2&gt;
  
  
  The starting position
&lt;/h2&gt;

&lt;p&gt;Mosslet was already encrypted before any of this. Content was encrypted with NaCl secret-box keys, those keys were sealed for each recipient with &lt;code&gt;crypto_box_seal&lt;/code&gt;, and the database stored ciphertext. That's better than most products.&lt;/p&gt;

&lt;p&gt;But the encryption happened on the server, before the row hit the database. Plaintext touched Phoenix memory on every write and most reads.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;crypto_box_seal&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So I committed to two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move all user-content encryption into the browser, before anything reaches the server.&lt;/li&gt;
&lt;li&gt;Add hybrid post-quantum sealing (ML-KEM + X25519) so today's captured ciphertext doesn't become tomorrow's plaintext.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  One Rust crate, three targets
&lt;/h2&gt;

&lt;p&gt;The first decision that paid for itself many times over: build the crypto as a Rust crate with three compile targets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dual-licensed MIT/Apache-2.0, published as a Hex NIF wrapper that's a NaCl-compatible drop-in replacement for &lt;code&gt;enacl&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: rip out libsodium
&lt;/h2&gt;

&lt;p&gt;Unglamorous, but it set up everything that followed. Replace &lt;code&gt;enacl&lt;/code&gt; (C NIF wrapper around libsodium) with &lt;code&gt;metamorphic_crypto&lt;/code&gt; (the Rust NIF). Same NaCl wire format. No data touched. &lt;code&gt;libsodium-dev&lt;/code&gt; came out of the Dockerfile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# mix.exs&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:enacl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 1.2”},
+ {:metamorphic_crypto, "&lt;/span&gt;&lt;span class="err"&gt;~&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="err"&gt;”&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting thing about Phase 1 is what it enabled: every &lt;code&gt;Encrypted.Utils.*&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: hybrid PQ sealing, server-side
&lt;/h2&gt;

&lt;p&gt;Phase 2 added ML-KEM-768 + X25519 hybrid sealing — still server-side, but now post-quantum. Two columns on the &lt;code&gt;users&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# priv/repo/migrations/20260514155214_add_pq_key_fields_to_users.exs&lt;/span&gt;
&lt;span class="n"&gt;add&lt;/span&gt; &lt;span class="ss"&gt;:pq_public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:binary&lt;/span&gt;
&lt;span class="n"&gt;add&lt;/span&gt; &lt;span class="ss"&gt;:encrypted_pq_private_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:binary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wire format carried a version tag so the unseal side could auto-detect which scheme to use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: move the crypto into the browser
&lt;/h2&gt;

&lt;p&gt;This was the hard one.&lt;/p&gt;

&lt;p&gt;Conceptually simple: compile &lt;code&gt;metamorphic-crypto&lt;/code&gt; to WASM, vendor it under &lt;code&gt;assets/vendor/&lt;/code&gt;, serve the &lt;code&gt;.wasm&lt;/code&gt; binary from Phoenix static, and rewrite the LiveView hooks to encrypt before &lt;code&gt;push_event&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the keys are born: registration
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/js/hooks/registration-hook.js (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;generateZKMaterial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Hybrid PQ keypair (Cat-5: ML-KEM-1024 + X25519)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pqGenerateKeypair&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;classicPub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;classicSec&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;classicGenerateKeypair&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Per-user attributes key (symmetric, used to encrypt profile fields)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userAttributesKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;randomSecretboxKey&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Derive KEK from password (Argon2id in WASM)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deriveKekFromPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Wrap private material with the password-derived KEK&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encryptedPqSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;secretboxSeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encryptedClassicSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;secretboxSeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;classicSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encryptedUserAttributesKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;secretboxSeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userAttributesKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kek&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Only these public bytes + sealed-with-KEK blobs go to the server&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;save_zk_keys&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;pq_public_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;encrypted_pq_private_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encryptedPqSecret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;classic_public_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;classicPub&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;encrypted_classic_private_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encryptedClassicSecret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;encrypted_user_attributes_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encryptedUserAttributesKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full hook on &lt;a href="https://gist.github.com/f0rest8/3f2bddc8508c18494614466bfb8092e1" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On the server side, the schema receives these as already-encrypted columns. The User schema uses &lt;code&gt;Cloak.Ecto.Binary&lt;/code&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/mosslet/accounts/user.ex (excerpt)&lt;/span&gt;
&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Encrypted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Binary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;redact:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:email_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Encrypted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;HMAC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;redact:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# blind index for lookup&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Encrypted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Binary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;redact:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:username_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Encrypted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;HMAC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;redact:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;

  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;virtual:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;redact:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;

  &lt;span class="c1"&gt;# Browser-generated, server-stored sealed material&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:pq_public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:binary&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:encrypted_pq_private_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:binary&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:key_pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:map&lt;/span&gt;           &lt;span class="c1"&gt;# classic keypair, private portion sealed&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:user_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:binary&lt;/span&gt;        &lt;span class="c1"&gt;# sealed user_attributes_key&lt;/span&gt;

  &lt;span class="c1"&gt;# Virtual: populated by pre_decrypt_user/2 at LiveView mount, never persisted&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ss"&gt;:decrypted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;virtual:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth pointing out here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;field :password, :string, virtual: true, redact: true&lt;/code&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;field :decrypted, :map, virtual: true&lt;/code&gt; — this is the &lt;code&gt;pre_decrypt_user&lt;/code&gt; pattern. At LiveView mount, the server unseals the user's profile fields (which are server-encrypted with Cloak at rest), attaches them as a &lt;code&gt;.decrypted&lt;/code&gt; map, and every template reads &lt;code&gt;user.decrypted[:username]&lt;/code&gt; instead of decrypting on every render. Cut decrypt churn by an order of magnitude.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full schema: &lt;a href="https://gist.github.com/f0rest8/9bee20166450b26ab2fd53ec41e6c060" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write path: a two-phase commit
&lt;/h2&gt;

&lt;p&gt;Saving a post is the most interesting write path. The form is bound to a normal Phoenix LiveView &lt;code&gt;phx-submit=“save_post"&lt;/code&gt;, but the hook intercepts the submit &lt;em&gt;before&lt;/em&gt; it reaches Phoenix, runs the ZK flow, and ends up calling a different event entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/js/hooks/post-form-hook.js (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isPublicPost&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// public posts stay server-side (SEO, federation)&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;randomSecretboxKey&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;secretboxSeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cwCiphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;secretboxSeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Phase 1: send the encrypted body — server replies with recipient pubkeys + metadata&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;save_post_encrypted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;content_warning_ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cwCiphertext&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cwCiphertext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;shared_user_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Hold the post_key in memory; we'll need it again for Phase 2&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pendingPostKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 2: server pushes recipient list + display-name metadata back to us&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;encrypt_post_fields&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pendingPostKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Seal post_key for each recipient with their (browser-fetched) hybrid PQ pubkey&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sealed_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;sealed_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sealForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pq_public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classic_public_key&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Encrypt everything else (usernames-as-displayed, etc.) with post_key&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encrypted_fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;encryptMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;finalize_post_encrypted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sealed_keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encrypted_fields&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pendingPostKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full hook: &lt;a href="https://gist.github.com/f0rest8/081587c5250dbefdfe372e540f2b348b" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;phx-submit=“save_post"&lt;/code&gt; handler in the parent &lt;code&gt;MossletWeb.PostLive.Index&lt;/code&gt; LiveView is the &lt;strong&gt;legacy / public-post&lt;/strong&gt; fallback. Private posts never reach it — they're intercepted by the form hook and routed through &lt;code&gt;save_post_encrypted&lt;/code&gt; / &lt;code&gt;finalize_post_encrypted&lt;/code&gt; on the &lt;code&gt;MossletWeb.PostLive.FormComponent&lt;/code&gt; LiveComponent instead. On the server side, those events accept pre-encrypted ciphertext through a &lt;code&gt;changeset_zk/3&lt;/code&gt; that skips all server-side encryption. The server stitches a post together out of parts it can't read.&lt;/p&gt;

&lt;p&gt;The "graceful fallback" line is more important than it looks. If WASM hasn't loaded yet, or &lt;code&gt;postKey&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The read path: sealed key → WASM unseal → DOM
&lt;/h3&gt;

&lt;p&gt;The read path is the mirror image. The server sends ciphertext + sealed &lt;code&gt;post_key&lt;/code&gt; as &lt;code&gt;data-*&lt;/code&gt; attributes on the post node. A LiveView hook (&lt;code&gt;DecryptPost&lt;/code&gt;) unseals the key in WASM and writes the plaintext into the target DOM node.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/js/hooks/decrypt-post.js (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sealedKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sealedPostKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;encryptedBody&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sealedKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Use cached conn_key if we've unsealed this connection's keys before (LRU, max 50)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unsealForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;sealedKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_myClassicSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_myPqSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;cachePostKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// cached for later fav/edit ops&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;secretboxOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// markdown rendered browser-side&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full hook: &lt;a href="https://gist.github.com/f0rest8/09c7957a67f3b12f0517f485486771fc" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two details worth flagging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Markdown rendering happens browser-side.&lt;/strong&gt; 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.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-instance &lt;code&gt;_connKeyCache&lt;/code&gt; LRU (max 50 entries).&lt;/strong&gt; Once a connection's &lt;code&gt;conn_key&lt;/code&gt; is 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Two-phase commit for group creation
&lt;/h3&gt;

&lt;p&gt;Groups are the hardest write path in any ZK social product. A &lt;code&gt;group_key&lt;/code&gt; has to be sealed for every member without the server ever holding the raw key.&lt;/p&gt;

&lt;p&gt;The browser handles it as a two-phase coordination:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1:&lt;/strong&gt; browser generates &lt;code&gt;group_key&lt;/code&gt;, encrypts name/description, seals the key for the creator, fetches each member's hybrid pubkeys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2:&lt;/strong&gt; browser seals &lt;code&gt;group_key&lt;/code&gt; for every member, encrypts member metadata (display names, monikers, avatars), and pushes everything in a single &lt;code&gt;create_group_zk&lt;/code&gt; call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On the Elixir side, this is one function that accepts pre-sealed material — the two-phase complexity lives entirely in the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/mosslet/groups.ex (excerpt)&lt;/span&gt;
&lt;span class="nv"&gt;@doc&lt;/span&gt; &lt;span class="sd"&gt;"""
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.
"""&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;create_group_zk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zk_attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sealed_members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;group_changeset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_changeset_zk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zk_attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;owner_changeset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UserGroup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner_changeset_zk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zk_attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;sealed_by_user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sealed_members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%{},&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;&amp;amp;1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;&amp;amp;1&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Multi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Multi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:insert_group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_changeset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Multi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:insert_user_group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;insert_group:&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;owner_changeset&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Changeset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Mosslet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_on_primary&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;insert_group:&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sealed_by_user_id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="no"&gt;UserGroup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_changeset_zk&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;
          &lt;span class="ss"&gt;sealed_key:&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sealed_key"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="ss"&gt;encrypted_name:&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"encrypted_name"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="ss"&gt;encrypted_moniker:&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"encrypted_moniker"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="ss"&gt;encrypted_avatar_img:&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"encrypted_avatar_img"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Changeset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Changeset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="no"&gt;Mosslet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction_on_primary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Mosslet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;&amp;amp;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_group!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:group_created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full module: &lt;a href="https://gist.github.com/f0rest8/a66313f4711ea1af0313d7ffc562a399" rel="noopener noreferrer"&gt;GitHub Gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: Cat-5 default
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The re-seal worker is the part I'm most quietly proud of, because of what it &lt;em&gt;isn’t&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/mosslet/workers/pq_reseal_worker.ex (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;Mosslet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Workers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;PqResealWorker&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nv"&gt;@moduledoc&lt;/span&gt; &lt;span class="sd"&gt;"""
  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.
  """&lt;/span&gt;

  &lt;span class="nv"&gt;@cat5_version_tag&lt;/span&gt; &lt;span class="mh"&gt;0x03&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;run_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Mosslet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;BackgroundTask&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Accounts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_user!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;user&lt;/span&gt;
      &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;list_context_keys_for_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;already_cat5?&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;sealed&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;unseal_with_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reseal_as_cat5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pq_public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;persist_resealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sealed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;already_cat5?&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;&lt;span class="ss"&gt;sealed_key:&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;@cat5_version_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_rest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;binary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;already_cat5?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;Task&lt;/code&gt; via &lt;code&gt;Mosslet.BackgroundTask.run/1&lt;/code&gt;, fire-and-forget on login.&lt;/p&gt;

&lt;p&gt;The version-tag check (&lt;code&gt;already_cat5?/1&lt;/code&gt;) 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit #4: the part where you check your work
&lt;/h2&gt;

&lt;p&gt;Architecture claims are cheap. The fourth audit is the part that turns the claim into evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope:&lt;/strong&gt; every server-side decrypt call site (~230), all 87 JS hooks, all 121 &lt;code&gt;push_event&lt;/code&gt; calls, every &lt;code&gt;sessionStorage&lt;/code&gt; access, every &lt;code&gt;data-*&lt;/code&gt; DOM attribute, and every &lt;code&gt;.toLowerCase()&lt;/code&gt; call that crosses the server boundary (because lowercasing is how blind-index pre-images get built, and a stray one is how plaintext leaks).&lt;/p&gt;

&lt;p&gt;Findings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq5141gh1091gcjxdt49n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq5141gh1091gcjxdt49n.png" alt="Table showing findings from our 4th audit" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All 270 tests pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  The remaining ~102 server-side decrypts
&lt;/h3&gt;

&lt;p&gt;Here's the part most "we built a zero-knowledge X" posts skip. There are still ~102 server-side decrypt call sites. &lt;strong&gt;None of them are write paths. None of them are high-traffic read paths.&lt;/strong&gt; They break down like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2hprk17w4ot4yuxtq3qd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2hprk17w4ot4yuxtq3qd.png" alt="Table showing the remaining server-side decrypt calls" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each one is documented with its call site in &lt;code&gt;docs/PQ_ENCRYPTION_MIGRATION.md&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;One pattern worth naming explicitly, because a careful reader of the codebase will spot it: there is a server-side helper called &lt;code&gt;pre_decrypt_post/3&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; decrypt the post body. It decrypts post &lt;em&gt;metadata&lt;/em&gt; (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 &lt;code&gt;decrypt-post.js&lt;/code&gt; as opaque ciphertext and unsealed in WASM. Same name pattern, different layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;p&gt;A few things I'd save you if you were about to do this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the crypto as one library with multiple targets, not two libraries that happen to agree.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version-tag your ciphertext from day one.&lt;/strong&gt; A single leading byte (&lt;code&gt;0x02&lt;/code&gt;, &lt;code&gt;0x03&lt;/code&gt;, …) 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-knowledge is a property of the data flow, not of a function.&lt;/strong&gt; Most of the work is not writing &lt;code&gt;seal()&lt;/code&gt; and &lt;code&gt;unseal()&lt;/code&gt;. It is auditing every &lt;code&gt;data-*&lt;/code&gt; attribute, every &lt;code&gt;push_event&lt;/code&gt; payload, every &lt;code&gt;sessionStorage.setItem&lt;/code&gt;, every &lt;code&gt;IO.inspect&lt;/code&gt; someone left in during debugging. The fourth audit was not redundant; it was the one that caught the last &lt;code&gt;{:ok, _} =&lt;/code&gt; pattern that would have crashed loudly on a malformed ciphertext instead of falling back gracefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan the graceful fallback before you plan the happy path.&lt;/strong&gt; 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 &lt;em&gt;default&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be honest about the edges.&lt;/strong&gt; "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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to look
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Crypto core (Rust, MIT/Apache-2.0): &lt;code&gt;github.com/moss-piglet/metamorphic-crypto&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Elixir NIF wrapper: &lt;code&gt;hex.pm/packages/metamorphic_crypto&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Mosslet (AGPL-3.0): &lt;code&gt;github.com/moss-piglet/MOSSLET&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Full migration doc &amp;amp; remaining-decrypt inventory: &lt;code&gt;docs/PQ_ENCRYPTION_MIGRATION.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;ZK architecture guide: &lt;code&gt;docs/zero-knowledge-guide.md&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mosslet is live at &lt;a href="https://mosslet.com/" rel="noopener noreferrer"&gt;mosslet.com&lt;/a&gt;. 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.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>phoenix</category>
      <category>webassembly</category>
      <category>privacy</category>
    </item>
    <item>
      <title>What Post-Quantum Encryption Means for Your Data</title>
      <dc:creator>Mark Thayer</dc:creator>
      <pubDate>Mon, 11 May 2026 14:23:08 +0000</pubDate>
      <link>https://dev.to/f0rest8/what-post-quantum-encryption-means-for-your-data-3lml</link>
      <guid>https://dev.to/f0rest8/what-post-quantum-encryption-means-for-your-data-3lml</guid>
      <description>&lt;p&gt;Your encrypted data might already be compromised. You just don't know it yet.&lt;/p&gt;

&lt;p&gt;The attack is called "harvest now, decrypt later." Intelligence agencies and well-funded attackers are collecting encrypted traffic today, banking on quantum computers being able to break it tomorrow. If your data is encrypted with classical algorithms alone — RSA, ECDH, X25519 — that bet might pay off.&lt;/p&gt;

&lt;p&gt;This isn't theoretical. NIST spent eight years evaluating post-quantum algorithms and finalized its first standards in 2024. The message was clear: start migrating now, not when quantum computers arrive.&lt;/p&gt;

&lt;p&gt;We took that seriously. &lt;a href="https://metamorphic.app" rel="noopener noreferrer"&gt;Metamorphic&lt;/a&gt; is a zero-knowledge encrypted habit tracker, and the data it protects — sobriety counters, mental health habits, fitness patterns — is the kind of data that stays sensitive for a lifetime. So we built &lt;a href="https://github.com/moss-piglet/metamorphic-crypto" rel="noopener noreferrer"&gt;metamorphic-crypto&lt;/a&gt;, an open-source Rust encryption library with hybrid post-quantum encryption, and it's running in production today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hybrid?
&lt;/h2&gt;

&lt;p&gt;A hybrid key encapsulation mechanism (KEM) combines a classical algorithm with a post-quantum one. In our case: X25519 + ML-KEM-768.&lt;/p&gt;

&lt;p&gt;The reasoning is simple. ML-KEM (formerly CRYSTALS-Kyber) is NIST's primary post-quantum KEM standard. It's well-studied and conservative. But post-quantum cryptography is young. If a flaw is found in ML-KEM, the classical X25519 layer still protects your data. If quantum computers break X25519, the ML-KEM layer still holds. You need to break &lt;em&gt;both&lt;/em&gt; to compromise the encryption.&lt;/p&gt;

&lt;p&gt;The combined shared secret feeds into the symmetric layer — XSalsa20-Poly1305 for authenticated encryption, with keys derived through Argon2id where passwords are involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version-tagged ciphertext
&lt;/h2&gt;

&lt;p&gt;Algorithms change. Migration is inevitable. So every ciphertext blob produced by metamorphic-crypto carries a version tag — a prefix byte that identifies the encryption scheme used.&lt;/p&gt;

&lt;p&gt;Version 1 is classical-only (X25519 + XSalsa20-Poly1305). Version 2 is hybrid post-quantum (X25519 + ML-KEM-768 + XSalsa20-Poly1305). When we decrypt, we read the tag first and select the right path automatically.&lt;/p&gt;

&lt;p&gt;This means we can migrate users progressively. When a user logs in, the client checks whether they have hybrid keys. If not, it generates a new ML-KEM-768 keypair, encrypts it with their session key, and sends the public key and encrypted private key to the server. Then it re-seals their existing data with the hybrid scheme. The server never sees any plaintext during this process — it just stores updated ciphertext blobs.&lt;/p&gt;

&lt;p&gt;If we ever need a version 3, the same mechanism works. No flag days, no forced migrations, no downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Rust, compiled to WASM
&lt;/h2&gt;

&lt;p&gt;Metamorphic is a web app. The zero-knowledge architecture means all encryption and decryption happens client-side, in the browser. That means the crypto library needs to run in JavaScript's environment.&lt;/p&gt;

&lt;p&gt;We originally used libsodium-wrappers-sumo (the JS build of libsodium). It worked, but it didn't support ML-KEM, and extending it would have meant wrapping C code in JavaScript bindings ourselves.&lt;/p&gt;

&lt;p&gt;Rust gave us several things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RustCrypto primitives&lt;/strong&gt; — pure Rust implementations of X25519, XSalsa20-Poly1305, Argon2id, and ML-KEM-768. Well-audited, actively maintained, no C dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;&lt;/strong&gt; — the entire library is safe Rust, enforced at the compiler level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wasm-pack compilation&lt;/strong&gt; — one build step produces a WASM binary and JS bindings that run in any modern browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UniFFI targets&lt;/strong&gt; — the same Rust core can compile to native iOS and Android libraries when we build native apps. One cryptographic implementation, every platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The WASM module loads asynchronously on first use. Every function calls &lt;code&gt;ensureReady()&lt;/code&gt; before executing, so the rest of the application doesn't need to care about initialization timing. The public API is identical to the old libsodium wrapper — same function names, same base64 string inputs and outputs. We swapped the implementation without changing a single call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-layer model
&lt;/h2&gt;

&lt;p&gt;In production, user data is protected by three independent layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Client-side zero-knowledge encryption&lt;/strong&gt; — data is encrypted in the browser with the user's keys before it ever leaves the device. The server receives and stores only ciphertext. This uses the hybrid post-quantum scheme for users who've migrated and classical for those who haven't yet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Server-side application encryption&lt;/strong&gt; — the Ecto fields are wrapped in AES-256-GCM via Elixir’s Cloak libraries, keyed to a server-side secret. This protects data at rest against database compromise, even though the server can't read the inner plaintext layer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Database-level encryption&lt;/strong&gt; — our infrastructure provider uses LUKS to implement full-disk encryption on the database volume, providing a third and final layer of encryption at rest.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An attacker who compromises the database gets AES-256-GCM ciphertext. An attacker who compromises the database &lt;em&gt;and&lt;/em&gt; the server's Cloak key gets zero-knowledge ciphertext. They still can't read it. They'd need the user's password-derived key, which never leaves the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the open-source library
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/moss-piglet/metamorphic-crypto" rel="noopener noreferrer"&gt;metamorphic-crypto&lt;/a&gt; contains the full cryptographic core:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hybrid KEM: X25519 + ML-KEM-768 key generation, seal, unseal&lt;/li&gt;
&lt;li&gt;Classical KEM: X25519 sealed boxes (NaCl-compatible)&lt;/li&gt;
&lt;li&gt;Symmetric encryption: XSalsa20-Poly1305 secret boxes&lt;/li&gt;
&lt;li&gt;Key derivation: Argon2id with configurable parameters&lt;/li&gt;
&lt;li&gt;Version-tagged ciphertext with automatic scheme detection&lt;/li&gt;
&lt;li&gt;Recovery key generation and encryption&lt;/li&gt;
&lt;li&gt;WASM build via wasm-pack, UniFFI scaffolding for future native targets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The library is MIT-licensed. If you're building something that needs post-quantum encryption in the browser, you can use it directly or use it as a reference for how to structure the hybrid KEM flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;Zero-knowledge encryption with post-quantum protection means we can't offer server-side features that require reading your data. No AI that analyzes your journal entries. No recommendation engine based on your behavior. No aggregate analytics across users.&lt;/p&gt;

&lt;p&gt;We think that's worth it. Your habits reveal who you were, who you are, and who you're trying to become. That data deserves encryption that will hold up not just today, but decades from now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://metamorphic.app" rel="noopener noreferrer"&gt;metamorphic.app&lt;/a&gt; · &lt;a href="https://github.com/moss-piglet/metamorphic-crypto" rel="noopener noreferrer"&gt;metamorphic-crypto on GitHub&lt;/a&gt; · &lt;a href="https://metamorphic.app/encryption" rel="noopener noreferrer"&gt;Encryption deep-dive&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>rust</category>
      <category>encryption</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>The Science Behind Your Habits (And Why Most Trackers Ignore It)</title>
      <dc:creator>Mark Thayer</dc:creator>
      <pubDate>Wed, 06 May 2026 13:10:31 +0000</pubDate>
      <link>https://dev.to/mosspigletcorp/the-science-behind-your-habits-and-why-most-trackers-ignore-it-2mdo</link>
      <guid>https://dev.to/mosspigletcorp/the-science-behind-your-habits-and-why-most-trackers-ignore-it-2mdo</guid>
      <description>&lt;p&gt;Most habit trackers are built on vibes. Streaks. Green checkmarks. A dopamine hit when the number goes up. A guilt trip when it resets to zero.&lt;/p&gt;

&lt;p&gt;It works — for about two weeks. Then you miss a day, the streak breaks, and the app that was supposed to help you change quietly becomes the thing you avoid opening.&lt;/p&gt;

&lt;p&gt;We built Metamorphic differently. Not because we think streaks are bad, but because the behavioral science says there's a lot more going on — and almost none of it shows up in the apps people actually use.&lt;/p&gt;

&lt;p&gt;Here's what the research says. And here's what we did about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Missed days don't destroy habits
&lt;/h2&gt;

&lt;p&gt;This is the big one. The popular belief is that habits are fragile — miss a day and you're back to square one. The research says otherwise.&lt;/p&gt;

&lt;p&gt;Lally et al. (2010) ran a study tracking how habits actually form in real life. The average time to automaticity was 66 days, not the 21-day myth that won't die. More importantly: missing a single day had no measurable effect on habit formation. The curve barely blinked.&lt;/p&gt;

&lt;p&gt;Most habit trackers punish you for missing a day. Your streak resets. Your progress visualization drops. The implicit message is: you failed.&lt;/p&gt;

&lt;p&gt;Metamorphic has streak forgiveness. A single missed day doesn't break your streak. Skip days are first-class — you set them when you create the habit, and they're respected, not penalized. Because the science says flexibility predicts long-term success better than rigidity does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strongest behavior change technique isn't tracking
&lt;/h2&gt;

&lt;p&gt;If you had to pick one intervention — one single thing that moves the needle on whether someone follows through — it's implementation intentions. "When X happens, I will do Y."&lt;/p&gt;

&lt;p&gt;Gollwitzer (1999) found an effect size of d=0.65. That's large. Larger than goal setting alone. Larger than motivation. Larger than tracking.&lt;/p&gt;

&lt;p&gt;The reason is simple: decisions are expensive. Every time you have to decide &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;where&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; to do a habit, you're spending willpower. Implementation intentions pre-load the decision. You've already decided. The cue fires, the behavior follows.&lt;/p&gt;

&lt;p&gt;Metamorphic has a first-class "When → Then" field on every goal. Not buried in a settings menu. Not a tooltip. It's right there when you create the goal, with a timed reminder attached. Because the most effective technique in behavior change deserves more than an afterthought.&lt;/p&gt;

&lt;p&gt;We also built contextual cue prompts directly onto habits — an "After I…" field that links your habit to the thing you already do before it. &lt;em&gt;After morning coffee → Meditate. After I sit down at my desk → Review my goals.&lt;/em&gt; Wood &amp;amp; Neal (2007) showed that contextual cues — preceding activities, locations — are stronger triggers than time-based reminders alone. Your life already has a rhythm. We want to hook into it, not override it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Habits and goals are not the same thing
&lt;/h2&gt;

&lt;p&gt;This sounds obvious. Most apps ignore it anyway.&lt;/p&gt;

&lt;p&gt;A habit is something you do repeatedly. A goal is something you're working toward. The connection between them — how daily actions compound into outcomes — is where behavior change actually happens. And it's where most people get stuck.&lt;/p&gt;

&lt;p&gt;Locke &amp;amp; Latham (2002) spent decades studying goal-setting. Their central finding: specific, challenging goals with clear feedback mechanisms outperform vague intentions by a wide margin. But the mechanism matters. You need the bridge between "I want to run a marathon" and "I ran 3 miles today."&lt;/p&gt;

&lt;p&gt;Metamorphic lets you link habits directly to goals. Not as a tag. Not as a folder. As an explicit connection: &lt;em&gt;this habit serves this goal&lt;/em&gt;. You can see how your daily actions feed into your larger aims. That bridge — from action to outcome — is the hardest thing to build into a product because it requires the system to understand that a check-in today is part of a trajectory that spans months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Emotions aren't decoration
&lt;/h2&gt;

&lt;p&gt;Most habit trackers either ignore mood entirely or give you a 3-point smiley face scale. Happy, neutral, sad. Done.&lt;/p&gt;

&lt;p&gt;Barrett et al. (2001) found that emotion differentiation — the ability to make fine-grained distinctions between emotional states — is directly linked to better emotion regulation. People who can distinguish between "frustrated" and "anxious" and "overwhelmed" handle those states more effectively than people who lump them all into "bad."&lt;/p&gt;

&lt;p&gt;Metamorphic has a 9-point mood scale and 46 distinct emotions. Not because complexity is a virtue, but because granularity is a skill. The act of choosing between "restless" and "anxious" is itself a form of self-awareness. The tracker becomes a mirror, not just a ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reflection without action is just venting
&lt;/h2&gt;

&lt;p&gt;Pennebaker (1997) showed that expressive writing — writing about your emotional experiences — has measurable psychological and even physical health benefits. Journaling works. This is well-established.&lt;/p&gt;

&lt;p&gt;What's less discussed is the gap between reflection and behavior change. You can journal every day and never change a thing. The insight stays on the page.&lt;/p&gt;

&lt;p&gt;Kolb's Experiential Learning Cycle describes the loop: experience → reflection → conceptualization → action → experience. Most apps give you the reflection step and stop there.&lt;/p&gt;

&lt;p&gt;Metamorphic has habits, goals, reflections, journal entries, and a daily schedule — and they're connected. A reflection can surface a pattern. A pattern can become a goal. A goal can spawn a habit. A habit gets a cue, a reminder, a link back to the goal it serves. The system is designed to close the loop, not just document it.&lt;/p&gt;

&lt;p&gt;We're building this further. After you write a reflection, we'll gently ask: &lt;em&gt;Want to carry something forward?&lt;/em&gt; You can create a habit, set an intention on a goal, or add something to tomorrow's priorities. Or dismiss it with a tap. The prompt only appears when the reflection suggests deeper processing — certain mood states, longer entries. It's never forced. Because autonomy matters more than engagement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gamification should celebrate, not control
&lt;/h2&gt;

&lt;p&gt;There's a fine line between motivation and manipulation. Variable ratio reinforcement — unpredictable positive feedback — is the most psychologically engaging reward schedule (Schultz, 1997). It's also the mechanism behind slot machines.&lt;/p&gt;

&lt;p&gt;Metamorphic has 18 tiered achievements across 6 categories. We use gamification deliberately. Celebrations are earned, not manufactured. They acknowledge what you've done without creating anxiety about what you haven't.&lt;/p&gt;

&lt;p&gt;The design principle we follow: whispered, not shouted. A subtle glow, not a modal. A brief animation, not a notification. Confetti for genuine milestones, silence for ordinary check-ins. The app should feel like a quiet ally, not a needy coach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy isn't a feature. It's the foundation.
&lt;/h2&gt;

&lt;p&gt;Becoming a parent inspired me to create &lt;a href="https://mosslet.com" rel="noopener noreferrer"&gt;Mosslet&lt;/a&gt;, our privacy-first space online for social and journaling, where people could be safe from the surveillance economy.&lt;/p&gt;

&lt;p&gt;The first version of Mosslet was actually called Metamorphic, but when Facebook rebranded to Meta, I felt we needed to change and my partner came up with Mosslet from our public benefit company's name Moss Piglet. Later, when Meta announced removing E2E encryption from Instagram DMs, I was inspired to implement zero-knowledge messaging in Mosslet. This would become the inspiration for our zero-knowledge, quantum resistant, architecture on Metamorphic today.&lt;/p&gt;

&lt;p&gt;With a privacy-first architecture in hand and the Metamorphic brand on the shelf, I was in need of a new idea. And that was when inspiration found me.&lt;/p&gt;

&lt;p&gt;My partner is passionate about psychology, particularly behavioral science, and self-improvement. Observing her, I was struck with a thought: a habit tracker built around behavioral science could be helpful for people, even people like me who traditionally forgo any kind of habit tracking and forming. And a habit tracker is something that &lt;em&gt;should be private&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And that was how Metamorphic transformed itself into a privacy-first app to help us transform ourselves.&lt;/p&gt;

&lt;p&gt;Every mechanism I've described — mood tracking, emotion differentiation, honest reflection, habit-goal linking, streak forgiveness — depends on one thing: honesty. The data is only useful if it's true. And the data is only true if you feel safe entering it.&lt;/p&gt;

&lt;p&gt;Your habits reveal what you're trying to change about yourself. Your mood logs reveal your emotional patterns. Your reflections reveal your inner life. Your goals reveal your vulnerabilities. This is some of the most intimate data a person can generate.&lt;/p&gt;

&lt;p&gt;Most habit trackers can read all of it. The company, its employees, its partners, its acquirers, and — depending on jurisdiction — law enforcement. Your behavioral data sits on their servers, readable, queryable, sellable.&lt;/p&gt;

&lt;p&gt;Metamorphic uses zero-knowledge encryption. Your data is encrypted on your device before it ever reaches our servers. We can't read it. Not because we promise not to — because we're technically unable to. The architecture doesn't allow it.&lt;/p&gt;

&lt;p&gt;We also use post-quantum encryption — designed to resist not just today's threats but the quantum computing attacks that researchers expect within the next decade. Your habits from 2026 should still be private in 2036.&lt;/p&gt;

&lt;p&gt;This isn't a premium feature. It's not an add-on. It's how the entire system works, free tier included. Because the behavioral science is clear: self-tracking only works when it's honest, and honesty requires safety.&lt;/p&gt;

&lt;h2&gt;
  
  
  We built what the research said to build
&lt;/h2&gt;

&lt;p&gt;We didn't want Metamorphic to be another app where marketing picked the features and science got a footnote. We wanted it to actually work — to do what the literature says matters, not what looks good in a screenshot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Habit strength indicators.&lt;/strong&gt; Lally's research shows habit formation follows a curve — rapid gains early, plateau around 66 days. Every habit shows a strength percentage on your dashboard. Not just "14-day streak" but "78% formed." Realistic expectations based on actual science instead of arbitrary streak counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contextual reflection prompts.&lt;/strong&gt; Reflection prompts respond to your current state — a different prompt after a streak break than after a milestone, a different tone after a tough mood than after a good one. All of this runs on metadata alone — dates, counts, scores — never on your encrypted content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WOOP-based goal setting.&lt;/strong&gt; Oettingen &amp;amp; Gollwitzer (2010) developed Mental Contrasting with Implementation Intentions — wish, outcome, obstacle, plan. It outperforms positive visualization alone by 2-3x for goal completion. Goals include obstacle identification and confidence calibration, so you're thinking about what might get in the way &lt;em&gt;before&lt;/em&gt; it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contextual cue prompts.&lt;/strong&gt; Every habit has an "After I…" field that links it to the thing you already do before it. &lt;em&gt;After morning coffee → Meditate.&lt;/em&gt; Your cue shows right on the habit card and in your reminders — anchoring new behaviors to your existing routine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Behavior change is hard. The research says it's also predictable — not perfectly, but enough to build better tools. Most apps don't bother. They give you a checkbox and a streak counter and call it done.&lt;/p&gt;

&lt;p&gt;We think you deserve more than that. And we think you deserve it without giving up the most personal data you have.&lt;/p&gt;

&lt;p&gt;Metamorphic is free to start. Your data is encrypted before it leaves your device. And the science is built into the product, not bolted onto the marketing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://metamorphic.app/users/register" rel="noopener noreferrer"&gt;Create your free account →&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Barrett, L. F., Gross, J., Christensen, T. C., &amp;amp; Benvenuto, M. (2001). Knowing what you're feeling and knowing what to do about it. &lt;em&gt;Cognition &amp;amp; Emotion&lt;/em&gt;, 15(6), 713–724.&lt;/p&gt;

&lt;p&gt;Fogg, B. J. (2009). A behavior model for persuasive design. &lt;em&gt;Proceedings of the 4th International Conference on Persuasive Technology&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Gollwitzer, P. M. (1999). Implementation intentions: Strong effects of simple plans. &lt;em&gt;American Psychologist&lt;/em&gt;, 54(7), 493–503.&lt;/p&gt;

&lt;p&gt;Lally, P., van Jaarsveld, C. H. M., Potts, H. W. W., &amp;amp; Wardle, J. (2010). How are habits formed: Modelling habit formation in the real world. &lt;em&gt;European Journal of Social Psychology&lt;/em&gt;, 40(6), 998–1009.&lt;/p&gt;

&lt;p&gt;Locke, E. A., &amp;amp; Latham, G. P. (2002). Building a practically useful theory of goal setting and task motivation. &lt;em&gt;American Psychologist&lt;/em&gt;, 57(9), 705–717.&lt;/p&gt;

&lt;p&gt;Oettingen, G., &amp;amp; Gollwitzer, P. M. (2010). Strategies of setting and implementing goals. In &lt;em&gt;Social psychological foundations of clinical psychology&lt;/em&gt; (pp. 114–135).&lt;/p&gt;

&lt;p&gt;Pennebaker, J. W. (1997). Writing about emotional experiences as a therapeutic process. &lt;em&gt;Psychological Science&lt;/em&gt;, 8(3), 162–166.&lt;/p&gt;

&lt;p&gt;Schultz, W. (1997). Dopamine neurons and their role in reward mechanisms. &lt;em&gt;Current Opinion in Neurobiology&lt;/em&gt;, 7(2), 191–197.&lt;/p&gt;

&lt;p&gt;Wood, W., &amp;amp; Neal, D. T. (2007). A new look at habits and the habit-goal interface. &lt;em&gt;Psychological Review&lt;/em&gt;, 114(4), 843–863.&lt;/p&gt;

</description>
      <category>behavioralscience</category>
      <category>habits</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Habit Tracker Knows More About You Than Your Therapist. Mine Can’t Read Any of It.</title>
      <dc:creator>Mark Thayer</dc:creator>
      <pubDate>Fri, 17 Apr 2026 16:10:58 +0000</pubDate>
      <link>https://dev.to/mosspigletcorp/your-habit-tracker-knows-more-about-you-than-your-therapist-mine-cant-read-any-of-it-3if0</link>
      <guid>https://dev.to/mosspigletcorp/your-habit-tracker-knows-more-about-you-than-your-therapist-mine-cant-read-any-of-it-3if0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpa9ciudh1hsm8f0tsqu0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpa9ciudh1hsm8f0tsqu0.png" alt="Screenshot of the landing page for Metamorphic, with its blue egg logo of geometric striations of color." width="800" height="629"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built a habit tracker where the server has no idea what you’re tracking.&lt;/p&gt;

&lt;p&gt;Every habit app I looked at stores your data in plaintext on their servers. Your daily check-ins, your goals, your mood journal, your streaks — sitting in a database, readable by anyone with access. That felt wrong to me.&lt;/p&gt;

&lt;p&gt;So I built Metamorphic: a habit and self-improvement tracker where all your data is encrypted in your browser before it ever reaches the server. The server only stores opaque blobs of ciphertext. Not even your email address is stored in plaintext.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why encrypt a habit tracker?
&lt;/h3&gt;

&lt;p&gt;Think about what a habit tracker actually contains. It’s not just “drink more water” and a checkbox. Over time, it becomes a detailed map of what you’re trying to change about yourself — your struggles, your patterns, the things you fail at repeatedly. That’s more intimate than most of what you’d share on social media.&lt;/p&gt;

&lt;p&gt;The idea came from watching my partner, who has a background in psychology and behavior science. She’s always thinking about how to break old habits and build better ones. It clicked: if your habits, goals, and self-reflections are this personal, they should be private to only you. And you shouldn’t have to worry about whether they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  The backstory
&lt;/h3&gt;

&lt;p&gt;I run &lt;a href="https://mosspiglet.dev" rel="noopener noreferrer"&gt;Moss Piglet&lt;/a&gt;, a bootstrapped public benefit company. Our other product, &lt;a href="https://mosslet.com" rel="noopener noreferrer"&gt;MOSSLET&lt;/a&gt;, is a privacy-first social platform built with Elixir. When I was becoming a new dad, I’d just finished reading &lt;em&gt;The Age of Surveillance Capitalism&lt;/em&gt; by Shoshana Zuboff — and I wanted a better digital world for my daughter. When Meta rolled back end-to-end encryption on its messaging, I was pushed to implement real E2EE in MOSSLET.&lt;/p&gt;

&lt;p&gt;Metamorphic takes that work further. Instead of encrypting just messages, the entire application is zero-knowledge. The server can’t read your habits, your goals, your reflections, your schedule, your group data — none of it. If our database were fully breached tomorrow, an attacker would get nothing useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Metamorphic actually does
&lt;/h3&gt;

&lt;p&gt;It’s a complete self-improvement platform, not just a checkbox app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Habit tracking&lt;/strong&gt;  — Daily and weekly check-ins, streaks, drag-and-drop reordering, categories&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-reflections&lt;/strong&gt;  — A journal with mood tracking and daily prompts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Goal setting&lt;/strong&gt;  — Milestones, progress bars, and the ability to link goals to habits so check-ins automatically advance your progress&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule and calendar&lt;/strong&gt;  — Recurring events, a day planner, and printable views for people who like paper&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Family and group accountability&lt;/strong&gt;  — Shared habits, shared goals, a group dashboard, and member spotlights&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress insights&lt;/strong&gt;  — Activity heatmaps and completion stats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data export&lt;/strong&gt;  — JSON and CSV, decrypted entirely in your browser. The server never sees the plaintext, even during export&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I feel strongly about: &lt;strong&gt;encryption is not a premium feature.&lt;/strong&gt; Every tier — including the free one — gets full end-to-end encryption. Paid tiers unlock convenience features like unlimited habits, reminders, data export, and groups. But privacy is not something you should have to pay extra for.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/fGA40p1Fqew"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  How the privacy works (without the jargon)
&lt;/h3&gt;

&lt;p&gt;When you create a habit, type a journal entry, or set a goal, your browser encrypts that data before sending it to the server. The server stores it, but has no way to read it. When you load the page later, the server sends the encrypted blobs back, and your browser decrypts them using keys that only exist on your device.&lt;/p&gt;

&lt;p&gt;Your password is never stored or transmitted in a usable form. Instead, it’s used to derive a cryptographic key locally, and that derived key unlocks everything else. If you lose your password and haven’t set up a recovery key, your data is gone — by design. That’s the real trade-off of zero-knowledge, and it’s the correct one.&lt;/p&gt;

&lt;p&gt;For the more technically inclined:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-side encryption&lt;/strong&gt; uses libsodium (XSalsa20-Poly1305)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key distribution&lt;/strong&gt; uses a hybrid post-quantum scheme: ML-KEM-768 combined with X25519 — the same approach Signal and Apple iMessage have adopted to protect against future quantum computers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three independent encryption layers at rest&lt;/strong&gt; : client-side E2E, AES-256-GCM in Postgres, and LUKS disk encryption on the hosting infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-knowledge email&lt;/strong&gt; : no plaintext email column in the database. Only a one-way hash for lookups and an encrypted blob&lt;/li&gt;
&lt;li&gt;A detailed architecture writeup is at &lt;a href="https://metamorphic.app/encryption" rel="noopener noreferrer"&gt;metamorphic.app/encryption&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The interesting tension
&lt;/h3&gt;

&lt;p&gt;Metamorphic is built with Phoenix LiveView, a framework where the server renders the page. But in a zero-knowledge system, the server can’t render the &lt;em&gt;content&lt;/em&gt; — it doesn’t know what it says. The result is a choreography: the server sends the page structure with placeholder skeletons, and JavaScript hooks decrypt and fill in the real content on arrival.&lt;/p&gt;

&lt;p&gt;It works. The skeletons flash in briefly, then the real data appears. UX-wise, it’s not dramatically different from any app with a loading state. Architecturally, it’s a very different beast — 15+ JavaScript hooks managing the encrypt/decrypt lifecycle across habits, goals, reflections, events, groups, and export.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real trade-offs
&lt;/h3&gt;

&lt;p&gt;I won’t pretend zero-knowledge doesn’t have costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No server-side search.&lt;/strong&gt; If you want to filter habits by name or search your reflections, that happens client-side after decryption. Fine at our current scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you lose your password and your recovery key, your data is unrecoverable.&lt;/strong&gt; This is a real UX concern. The recovery key flow mitigates it, but the fundamental constraint is by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing is harder.&lt;/strong&gt; You can’t assert on decrypted content in server-side tests because decryption only happens in JavaScript. Tests verify DOM structure and that encrypted fields are stored correctly — the decrypt-and-display pipeline is the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are trade-offs I’m willing to make. Your habit data being unreadable to everyone except you — including me — is worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;p&gt;I’m not very good at habit tracking myself, which is partly why I built this. I’ve been using Metamorphic to get back into yoga, meditation, and running. So far, so good.&lt;/p&gt;

&lt;p&gt;Check it out at &lt;a href="https://metamorphic.app/" rel="noopener noreferrer"&gt;metamorphic.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>security</category>
      <category>elixir</category>
      <category>privacy</category>
    </item>
    <item>
      <title>I Built a Zero-Knowledge Encrypted Habit Tracker with Elixir &amp; Phoenix LiveView</title>
      <dc:creator>Mark Thayer</dc:creator>
      <pubDate>Thu, 16 Apr 2026 23:16:47 +0000</pubDate>
      <link>https://dev.to/mosspigletcorp/i-built-a-zero-knowledge-encrypted-habit-tracker-with-elixir-phoenix-liveview-3jc9</link>
      <guid>https://dev.to/mosspigletcorp/i-built-a-zero-knowledge-encrypted-habit-tracker-with-elixir-phoenix-liveview-3jc9</guid>
      <description>&lt;p&gt;I'm the solo dev at &lt;a href="https://mosspiglet.dev" rel="noopener noreferrer"&gt;Moss Piglet&lt;/a&gt;, a bootstrapped public benefit company. I've been building &lt;a href="https://metamorphic.app" rel="noopener noreferrer"&gt;Metamorphic&lt;/a&gt; — a habit and self-improvement tracker where all personal data is encrypted client-side before it ever reaches the server. The server only stores opaque cipher-text blobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why encrypt a habit tracker?
&lt;/h2&gt;

&lt;p&gt;I was inspired by my partner's background in psych and behavior science. It clicked that something as personal as your habits, goals, and self-reflections — basically a map of what you're trying to change about yourself — should be private to only you, and you shouldn't have to worry about it being otherwise.&lt;br&gt;
Every other habit tracker I looked at stores your data in plaintext. Metamorphic doesn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it does
&lt;/h3&gt;

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

&lt;p&gt;&lt;strong&gt;Encryption is not a premium feature.&lt;/strong&gt; Every tier gets full E2E encryption. Paid tiers gate convenience (unlimited habits, reminders, export, groups), not privacy.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the crypto works
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Client-side encryption via &lt;code&gt;libsodium-wrappers-sumo&lt;/code&gt; — XSalsa20-Poly1305 for data, NaCl box/seal for key distribution&lt;/li&gt;
&lt;li&gt;Hybrid post-quantum key encapsulation (ML-KEM-768 + X25519 via &lt;code&gt;@noble/post-quantum&lt;/code&gt;) — the same approach as Signal and Apple iMessage&lt;/li&gt;
&lt;li&gt;Three independent encryption layers at rest: client-side E2E, Cloak AES-256-GCM in Postgres, and LUKS disk encryption on &lt;a href="https://fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Zero-knowledge email: no plaintext email column in the database — only an HMAC blind index for lookups and an E2E-encrypted blob&lt;/li&gt;
&lt;li&gt;Password never touches sessionStorage — only the Argon2id-derived session key&lt;/li&gt;
&lt;li&gt;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&lt;/li&gt;
&lt;li&gt;Recovery key flow for password reset without server access to private keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More detail on the architecture: &lt;a href="https://metamorphic.app/encryption" rel="noopener noreferrer"&gt;metamorphic.app/encryption&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The interesting tradeoff: LiveView + zero-knowledge
&lt;/h3&gt;

&lt;p&gt;This is the part I think other Elixir/Phoenix devs will find most relevant.&lt;/p&gt;

&lt;p&gt;LiveView is server-rendered by design. Zero-knowledge encryption means the server can't see the content it's rendering. These are fundamentally in tension.&lt;/p&gt;

&lt;p&gt;The result: brief placeholder skeletons that JS hooks fill in after client-side decryption, and a lot of &lt;code&gt;push_event/handleEvent&lt;/code&gt; choreography (15+ hooks). It's not too different UX-wise from a trust-the-server model — the skeletons flash in briefly — but architecturally it's a very different beast.&lt;/p&gt;

&lt;p&gt;Other tradeoffs:&lt;/p&gt;

&lt;p&gt;No server-side search on encrypted fields. Filtering by habit name or reflection text happens client-side after decryption. Fine at current scale.&lt;/p&gt;

&lt;p&gt;Testing is harder. You can't assert on decrypted content in LiveView tests since decryption is JS-only. Tests focus on DOM structure and data attributes. Context-level tests verify encrypted fields are stored and retrieved correctly — the decrypt-and-display pipeline is the gap.&lt;/p&gt;

&lt;p&gt;If you lose your password and haven't set up a recovery key, your data is gone. By design. Real UX tradeoff, but correct for zero-knowledge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Elixir/Phoenix LiveView&lt;/strong&gt; — full-stack web app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecto + Postgres&lt;/strong&gt; (&lt;a href="https://fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt; Managed Postgres)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;libsodium-wrappers-sumo + &lt;a href="https://github.com/paulmillr/noble-post-quantum" rel="noopener noreferrer"&gt;@noble/post-quantum&lt;/a&gt;&lt;/strong&gt; — client-side crypto&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloak/cloak_ecto&lt;/strong&gt; — application-level at-rest encryption&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oban&lt;/strong&gt; — background jobs (reminders)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS v4 + daisyUI&lt;/strong&gt; — UI/theming&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sortable.js&lt;/strong&gt; — drag-and-drop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSZip&lt;/strong&gt; — export packaging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tidewave&lt;/strong&gt; — AI-assisted development (runtime introspection, live SQL/eval, a11y diagnostics)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lot was shared from my work on &lt;a href="https://mosslet.com" rel="noopener noreferrer"&gt;MOSSLET&lt;/a&gt;, a privacy-first social platform with private journal and Bluesky interop, also built with Elixir.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;p&gt;I'm not very good at habit tracking myself, so I've been using Metamorphic to get back into yoga, meditation, and running. So far so good.&lt;/p&gt;

&lt;p&gt;Check it out at &lt;a href="https://metamorphic.app" rel="noopener noreferrer"&gt;metamorphic.app&lt;/a&gt;. Happy to answer questions about the architecture, the zero-knowledge approach, bootstrapping a public benefit company, or anything Elixir-related.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>showdev</category>
      <category>security</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
