<?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>Forem: Martin Kambla</title>
    <description>The latest articles on Forem by Martin Kambla (@xmkx).</description>
    <link>https://forem.com/xmkx</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3855918%2Fa334d629-dda6-444b-a4cc-87f6826ae68e.png</url>
      <title>Forem: Martin Kambla</title>
      <link>https://forem.com/xmkx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/xmkx"/>
    <language>en</language>
    <item>
      <title>Delivering E2EE media without blowing up Postgres</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Tue, 05 May 2026 03:54:24 +0000</pubDate>
      <link>https://forem.com/xmkx/delivering-e2ee-media-without-blowing-up-postgres-6ea</link>
      <guid>https://forem.com/xmkx/delivering-e2ee-media-without-blowing-up-postgres-6ea</guid>
      <description>&lt;p&gt;When I launched my messenger, the media upload path looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client → encrypt → POST /media/upload → INSERT INTO media (ciphertext BYTEA)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functionality was there, 2MB, 25MB (sometimes times out) and at 100MB you get blowups. Here's a brief "lessons-learned" about the road of Postgres BYTEA at scal, and the architectuer I ended up with shipping 200MB encrypte video without the server ever seeing plaintext.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why BYTEA was good at first?
&lt;/h2&gt;

&lt;p&gt;I've always preferred Postgres to any other DB system. I'm sure others have their benefits in different scenrios but I usually when dealing with serious applications I use Postgres. &lt;/p&gt;

&lt;p&gt;And for messaging, the attraction was specific. If the ciphertext lives next to the row pointing at it, there's one write, one transaction, one thing to fail. No dangling references between the DB and a blob store if one side of the write fails. Simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks?
&lt;/h2&gt;

&lt;p&gt;Then I tried to ship a 50 MB video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WAL explosion.&lt;/strong&gt; Every write to a &lt;code&gt;BYTEA&lt;/code&gt; column goes through the Write-Ahead Log. A 50 MB attachment is 50 MB of WAL. Multiply by the replication lag on my read replica and I was seeing replication fall 60+ seconds behind on a single upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup time.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; with BYTEA attachments turns into a slow march. At 100 GB of media my nightly dump was taking over an hour. And every byte of that was stuff the database didn't need to be parsing — it's opaque ciphertext.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory pressure.&lt;/strong&gt; The server was reading the full ciphertext into JVM heap to stream it back to the client on download. One concurrent 100 MB download = 100 MB of heap. Three concurrent downloads on a 1 GB instance and you're in swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection hold time.&lt;/strong&gt; An upload that takes 90 seconds holds a DB connection for 90 seconds. With a default pool of 20 connections, ten concurrent uploaders and everything else blocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TOAST limitations.&lt;/strong&gt; Postgres stores large column values in a separate "TOAST" table with its own indexing and compression. TOAST has a hard 1 GB limit per value. I wasn't near that, but I was building a path that led there.&lt;/p&gt;

&lt;p&gt;The pattern was clear: Postgres is a great metadata store, a bad blob store. I needed to split them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture I moved to
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client
  │
  │  1. POST /media/reserve   → server issues upload URL + media_id
  │                              + AES-GCM random key to encrypt with
  │                              (key is stored by the client, not the server)
  │
  │  2. encrypt(file, key)    → ciphertext bytes
  │
  │  3. PUT {presigned S3 URL} → DO Spaces / S3
  │
  │  4. POST /media/finalize  → server marks media_id as ready,
  │                              records size + sha256(ciphertext) only
  │
  │  5. send message with media_id + encrypted key (wrapped for recipient)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key property: &lt;strong&gt;the server never sees plaintext, and never sees the media key&lt;/strong&gt;. The server knows a ciphertext blob exists at &lt;code&gt;spaces://quldra-media/{media_id}&lt;/code&gt;. It knows the blob's size and hash. That's it.&lt;/p&gt;

&lt;p&gt;On the recipient side, the message carries the media key wrapped for the recipient's device key. The recipient unwraps it, downloads the ciphertext directly from DO Spaces via a short-lived presigned URL, and decrypts locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had to fix in the client?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Timeout discipline
&lt;/h3&gt;

&lt;p&gt;Default HTTP client timeouts are a lie for large uploads. The Ktor client defaults worked fine for API calls and quietly retried uploads that crossed 120 seconds, which turned into silent data loss — the client thought it had retried, the server had no idea anything had happened.&lt;/p&gt;

&lt;p&gt;I split the timeout regime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;mediaUploadClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CIO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpTimeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;requestTimeoutMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;  &lt;span class="c1"&gt;// 10 minutes&lt;/span&gt;
        &lt;span class="n"&gt;connectTimeoutMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30_000&lt;/span&gt;
        &lt;span class="n"&gt;socketTimeoutMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// CRITICAL: retry disabled on /media/upload&lt;/span&gt;
    &lt;span class="c1"&gt;// The upload is not idempotent from the client's perspective —&lt;/span&gt;
    &lt;span class="c1"&gt;// a retry means re-encrypting with a potentially different random nonce,&lt;/span&gt;
    &lt;span class="c1"&gt;// which creates an orphan blob in Spaces.&lt;/span&gt;
    &lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpRequestRetry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;retryOnExceptionIf&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="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;false&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;h3&gt;
  
  
  Streaming encryption
&lt;/h3&gt;

&lt;p&gt;You can't hold 200 MB of plaintext in memory on a mid-range Android device. Encryption has to stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;streamEncryptToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cipher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChaCha20Poly1305&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AEADParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;buffer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&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="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;chunk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOutputSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;written&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOutputSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;tagWritten&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doFinal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tagWritten&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;64 KB chunks are the sweet spot on Android. Smaller and the per-chunk syscall overhead dominates; larger and you start allocating in a way that pressures GC.&lt;/p&gt;

&lt;h3&gt;
  
  
  Progress reporting
&lt;/h3&gt;

&lt;p&gt;Users expect a progress bar for a 200 MB upload. Presigned S3 PUTs don't give you one by default — the network layer doesn't know anything about your application-level progress. I wrapped the &lt;code&gt;Source&lt;/code&gt; that streams the ciphertext file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProgressTrackingSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;totalBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytesRead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;bytesRead&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&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="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;bytesRead&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
            &lt;span class="nf"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytesRead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totalBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&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;And threaded the &lt;code&gt;onProgress&lt;/code&gt; callback into a &lt;code&gt;StateFlow&lt;/code&gt; the UI could render.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had to fix on the server
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Presigned URLs, not streaming through the server
&lt;/h3&gt;

&lt;p&gt;The wrong way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client → POST /media/upload (200 MB body) → server → write to S3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the server a bottleneck on every upload. The correct way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client → POST /media/reserve (metadata only) → server
server → presign a PUT URL against S3 → return to client
client → PUT to S3 directly (server not involved)
client → POST /media/finalize (metadata only) → server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server-side, presigning with the AWS SDK is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;presigner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;S3Presigner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;presignedUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;presigner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presignPutObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signatureDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ofMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putObjectRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"quldra-media"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mediaId&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;The server does no I/O during the upload. No connection held. No heap pressure. It issues a URL, then hears back 90 seconds later when the finalize request comes in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying what landed
&lt;/h3&gt;

&lt;p&gt;The finalize step is where the server confirms the upload actually happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/media/finalize"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;req&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FinalizeRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;head&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"quldra-media"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mediaId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentLength&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expectedSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"uploaded size mismatch"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// We can't verify sha256 without reading the object — we trust the client&lt;/span&gt;
    &lt;span class="c1"&gt;// for the hash value it reports. The hash is only used for dedup + UX,&lt;/span&gt;
    &lt;span class="c1"&gt;// not security (the ciphertext is already AEAD-authenticated).&lt;/span&gt;

    &lt;span class="n"&gt;mediaRepo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mediaId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expectedSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OK&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;The HEAD request is cheap and confirms the blob exists with the expected size. The client's reported hash goes into the DB for deduplication and UI purposes. The ciphertext itself is already AEAD-authenticated — if someone tampers with the blob in storage, the recipient's decryption will fail with a tag mismatch, so I don't need the server to guarantee integrity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like now
&lt;/h2&gt;

&lt;p&gt;After the migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database size dropped from 180 GB to 11 GB overnight. Backups are fast again.&lt;/li&gt;
&lt;li&gt;WAL rate dropped by a factor of ~60.&lt;/li&gt;
&lt;li&gt;Memory per concurrent upload on the server: effectively zero.&lt;/li&gt;
&lt;li&gt;Maximum upload size went from "25 MB before things get flaky" to 200 MB reliably.&lt;/li&gt;
&lt;li&gt;Replication lag on the read replica went from 60s+ during upload spikes to under 1s sustained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The simplicity argument for BYTEA was real — and wrong at this scale. One extra system to operate (DO Spaces) is the right trade for keeping the database focused on what it's good at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The piece I'd do differently
&lt;/h2&gt;

&lt;p&gt;I'd have set a size threshold from day one: under 1 MB goes into Postgres, over 1 MB goes to object storage, metadata identical in both cases. That way the architecture supports both paths and you pick per-blob based on actual size, not "we decided in advance." It's more code but it's also the path I'd eventually want anyway.&lt;/p&gt;




&lt;p&gt;This is post 3 of a short series on the tech behind &lt;a href="https://quldra.com" rel="noopener noreferrer"&gt;Quldra&lt;/a&gt;, a post-quantum single-device messenger built in Kotlin Multiplatform. Previous posts covered &lt;a href="https://dev.to/xmkx/my-road-to-ml-kem-768-over-x25519-for-my-messaging-app-50op"&gt;My road to ML-KEM-768 over X25519 for my messaging app&lt;/a&gt; and &lt;a href="https://dev.to/xmkx/device-distinct-messaging-why-i-killed-multi-device-and-how-fingerprint-hashing-enforces-it-333j"&gt;Device distinct messaging: why I killed multi-device and how fingerprint hashing enforces it.&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>cryptography</category>
      <category>backend</category>
    </item>
    <item>
      <title>Device distinct messaging: why I killed multi-device and how fingerprint hashing enforces it.</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Fri, 01 May 2026 21:18:18 +0000</pubDate>
      <link>https://forem.com/xmkx/device-distinct-messaging-why-i-killed-multi-device-and-how-fingerprint-hashing-enforces-it-333j</link>
      <guid>https://forem.com/xmkx/device-distinct-messaging-why-i-killed-multi-device-and-how-fingerprint-hashing-enforces-it-333j</guid>
      <description>&lt;p&gt;Most messaging apps let you log in on your phone, laptop, iDevice, and browser, with all of your messages synced. It's framed as convenience. It's also an attack surface.&lt;/p&gt;

&lt;p&gt;When I was designing my messenger, I made a deliberately unpopular call: &lt;strong&gt;one device per account, enforced at the server.&lt;/strong&gt; This post is about how I implement that, why the enforcement matters more than the policy, and what the recovery story looks like when a user's device dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one device?
&lt;/h2&gt;

&lt;p&gt;The pitch for multi-device is: "I want my chats on every screen I own." The cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key distribution problem.&lt;/strong&gt; Every new device needs the session keys. Either you re-derive them from a central secret, losing per-device forward secrecy, or you distribute keys between devices, which creates an extra sync protocol to audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compromise blast radius.&lt;/strong&gt; A stolen laptop with your Signal desktop logged in is a full compromise of your chat history. In a single-device model, physical access to the one device is the attack, not access to any of N devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account-recovery social engineering.&lt;/strong&gt; "Hi, this is Bob, I got a new iPad, can you add it to my account?" is the oldest trick in the book. If the account can only ever have one device, the answer is always: "No. Do recovery."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an end-to-end encrypted app where I can't see the content, multi-device means I'm maintaining a protocol whose failure modes I can't observe. Single-device means the server's job is simple: &lt;strong&gt;track which install is the canonical one, and refuse anyone else.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint hash
&lt;/h2&gt;

&lt;p&gt;Every install of the app generates a random 32-byte install ID on first launch and persists it in secure storage: Keychain on iOS, EncryptedSharedPreferences on Android. The server never sees this raw ID. What it sees is a hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;computeFingerprintHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;installId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;digest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;installId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x00&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toByteArray&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toHexString&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;On registration, the client sends this hash. The server writes it into the &lt;code&gt;installs&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;installs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&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;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;active_fingerprint_hash&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;recovery_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&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;Note the &lt;code&gt;UNIQUE (user_id)&lt;/code&gt; constraint. One row per user. No multi-device possible at the schema level. If you wanted to add it later, you'd have to change the table shape, which is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 8-second poll
&lt;/h2&gt;

&lt;p&gt;Every authenticated request the client makes includes its fingerprint hash in the auth envelope. The server checks it against the stored &lt;code&gt;active_fingerprint_hash&lt;/code&gt;. If they match, the request goes through. If they don't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/users/me"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;get&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QuldraPrincipal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;install&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;installRepo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;NotFoundException&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="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activeFingerprintHash&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fingerprintHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"error"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="s"&gt;"device_deactivated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"recoveredAt"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recoveredAt&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@get&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDto&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;The client has a background poll loop that hits &lt;code&gt;/users/me&lt;/code&gt; every 8 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startDeviceCheckLoop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CoroutineScope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;apiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMe&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"device_deactivated"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;handleDeactivation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recoveredAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;break&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why 8 seconds? Because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short enough that a deactivated device doesn't stay alive long.&lt;/strong&gt; If someone steals your phone and does recovery on their new one, your old device knows within 8 seconds and locks itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long enough not to hammer the server.&lt;/strong&gt; At 8-second intervals, a single active user makes 10,800 requests per day. That's acceptable for now, but it is still a cost worth watching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feels instant to the user triggering the recovery.&lt;/strong&gt; They tap "I've got a new phone," hit their recovery path, and 8 seconds later the old device visibly dies. That feedback loop matters for the mental model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recovery = clean slate
&lt;/h2&gt;

&lt;p&gt;When a user recovers onto a new device:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The new device generates a fresh install ID and computes a new fingerprint hash.&lt;/li&gt;
&lt;li&gt;It hits the recovery endpoint with the user's recovery code.&lt;/li&gt;
&lt;li&gt;The server updates &lt;code&gt;active_fingerprint_hash&lt;/code&gt; to the new hash, increments &lt;code&gt;recovery_count&lt;/code&gt;, and sets &lt;code&gt;recovered_at = NOW()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The old device's next poll returns &lt;code&gt;device_deactivated&lt;/code&gt;, and the client hard-resets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The clean-slate part matters: &lt;strong&gt;messages sent before &lt;code&gt;recovered_at&lt;/code&gt; are not synchronized to the new device&lt;/strong&gt;. This is a deliberate E2E choice. The new device doesn't have the old room secrets. They're gone. Even if the server served the ciphertext, the new device couldn't decrypt it. Showing "undecryptable message" rows would be a worse UX than just pretending they aren't there.&lt;/p&gt;

&lt;p&gt;The conversation starts fresh from the recovery timestamp, which fits the security model: whoever stole the old device can still see the old messages on it until it dies on poll, but everything from the recovery moment onward is only visible on the new device.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs you as a builder
&lt;/h2&gt;

&lt;p&gt;Single-device enforcement isn't free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You have to explain it.&lt;/strong&gt; Users coming from WhatsApp or Telegram expect multi-device. The onboarding flow has to sell "you get one device and here's why" without sounding defensive. I learned to frame it as a feature, "your account is protected by physical possession," rather than a limitation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop/web is harder.&lt;/strong&gt; You can't have a persistent web session that survives the phone dying. I ended up building a separate ephemeral web chat that uses temporary keys and doesn't count as "the device." It's a different product surface with its own trust story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing is awkward.&lt;/strong&gt; Every manual QA pass burns through install IDs because each recovery is a real recovery. I wrote a server-side dev endpoint that resets fingerprint state for a test user. Gated behind &lt;code&gt;APP_ENV=dev&lt;/code&gt;, obviously.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What could be different?
&lt;/h2&gt;

&lt;p&gt;I think there's always room to improve, and it's a constant battle to settle on a solution. That's where LLMs help the most, in my opinion. Still, it would be useful to hear what people in the field think could be better here.&lt;/p&gt;

&lt;p&gt;If you have thoughts, let me know personally or in the comments.&lt;/p&gt;




&lt;p&gt;This is post 2 of a short series on the tech behind &lt;a href="https://www.quldra.com" rel="noopener noreferrer"&gt;Quldra&lt;/a&gt;, a post-quantum single-device messenger built in Kotlin Multiplatform. Previous post was on &lt;a href="https://dev.to/xmkx/my-road-to-ml-kem-768-over-x25519-for-my-messaging-app-50op"&gt;My road to ML-KEM-768 over X25519 for my messaging app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>security</category>
      <category>kotlin</category>
      <category>pqc</category>
    </item>
    <item>
      <title>My road to ML-KEM-768 over X25519 for my messaging app</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Tue, 28 Apr 2026 19:18:04 +0000</pubDate>
      <link>https://forem.com/xmkx/my-road-to-ml-kem-768-over-x25519-for-my-messaging-app-m3f</link>
      <guid>https://forem.com/xmkx/my-road-to-ml-kem-768-over-x25519-for-my-messaging-app-m3f</guid>
      <description>&lt;p&gt;Eight months ago I started working on a messaging app as an hobby to see how difficult it is. One thing led to another and then I was obsessed with the idea of having it Post-quantum ready. It is well known that Signal works in that regard but from my perspective it isn't full E2EE. Boiling it down to small stuff - why I chose ML-KEM-768 instead of X25519.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "harvest now, decrypt later" problem
&lt;/h2&gt;

&lt;p&gt;X25519 is an elliptic curve Diffie-Hellman on Curve25519. Its security rests on the discrete log problem being hard. It is, today, on classical hardware.&lt;/p&gt;

&lt;p&gt;A sufficiently large quantum computer running Shor's algorithm breaks it. Nobody has one yet but the bells are ringing. An adversary who can capture and store your encrypted traffic today can decrypt it the day they do. This is not a theoretical problem for a messaging app - messages sent today are expected to stay private for years, sometimes decades.&lt;/p&gt;

&lt;p&gt;Post-quantum key exchange is the hedge. And as of August 2024, NIST has a standard for it: &lt;strong&gt;FIPS 203&lt;/strong&gt;, which specifies &lt;strong&gt;ML-KEM&lt;/strong&gt; (Module Lattice-based Key encapsulation Mechanism), the renamed CRYSTALS-Kyber.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 768, not 512 or 1024?
&lt;/h2&gt;

&lt;p&gt;ML-KEM ships in three parameter sets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter set&lt;/th&gt;
&lt;th&gt;Claimed security category&lt;/th&gt;
&lt;th&gt;Rough classical equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ML-KEM-512&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;AES-128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML-KEM-768&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;AES-192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML-KEM-1024&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;AES-256&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;768 is the sweet spot most deployed post-quantum systems have converged on. Cloudflare, Chromes hybrid X25519Kyber768, Signal's PQXDH all use the 768 tier. It's the default "safe modern choice" - strong enough that nobody serious argues 512 is sufficient, and light enough that 1024's extra bytes aren't worth the hit unless you're protecting state secrets. In any of the cases you can still leave room for it to be more secure if need be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that actually matter
&lt;/h2&gt;

&lt;p&gt;Here's the honest size comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;X25519&lt;/th&gt;
&lt;th&gt;ML-KEM-768&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public key&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;1,184 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ciphertext / encapsulation&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;1,088 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared secret&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keygen (ms, M1)&lt;/td&gt;
&lt;td&gt;~0.02&lt;/td&gt;
&lt;td&gt;~0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encap (ms, M1)&lt;/td&gt;
&lt;td&gt;~0.04&lt;/td&gt;
&lt;td&gt;~0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decap (ms, M1)&lt;/td&gt;
&lt;td&gt;~0.04&lt;/td&gt;
&lt;td&gt;~0.07&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The speed penalty is basically noise on a modern device. The size penalty is real - every session key exchange ships &lt;strong&gt;~2 KB more on the wire&lt;/strong&gt; than it used to. For a messaging app where most messages are smaller than that, it's a meaningful bump in bandwidth for the first message in the conversation. &lt;/p&gt;

&lt;p&gt;I decided I could eat it. A 2KB one-time handshake cost per conversation is fine. A protocol that breaks in ten years is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual flow
&lt;/h2&gt;

&lt;p&gt;I'm not using ML-KEM to encrypt messages directly - it's a KEM, not a cipher. It gives you a shared secret, and you feed that shared secret into something that can actually encrypt bulk data. In my case, ChaCha20-Poly1305.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sender                                   recipient
  |                                          |
  |  fetch recipient's ML-KEM public key     |
  |-----------------------------------------&amp;gt;|
  |                                          |
  |  (encapsulate)                           |
  |    ciphertext, sharedSecret = Encap(pk)  |
  |                                          |
  |  HKDF(sharedSecret, salt=nonce)          |
  |     → 32-byte room key                   |
  |                                          |
  |  ChaCha20-Poly1305(key, nonce, plaintext)|
  |                                          |
  |  send {ciphertext, nonce, AEAD payload}  |
  |-----------------------------------------&amp;gt;|
  |                                          |
  |                       sharedSecret = Decap(sk, ciphertext)
  |                       HKDF(...) → same 32-byte key
  |                       ChaCha20-Poly1305 decrypt → plaintext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kem output is 32 bytes of raw shared secret. Feeding it straight into ChaCha20 as a key would work but is brittle . you'd be binding the key to the KEM output directly, with no domain separation, no per-message salt, HKDF with a per-message nonce as salt gives you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Domain separation (same KEM shared secret can produce different keys for different purposes.&lt;/li&gt;
&lt;li&gt;A key that rotates every message, even when the underlying KEM secret is reused for a session.&lt;/li&gt;
&lt;li&gt;A clean audit story - the cipher sees a fresh 32-byte key each time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what that looks like in Kotlin, using Bouncy Castle's ML-KEM provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;encryptMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;recipientPublicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MLKEMPublicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;associatedData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;EncryptedEnvelope&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Encapsulate to get a ciphertext + shared secret&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;encap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MLKEMEncapsulator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipientPublicKey&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encapsulate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Derive a per-message key via HKDF&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SecureRandom&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nextBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;messageKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hkdfSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ikm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;salt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"quldra/v3/msg"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toByteArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Encrypt with ChaCha20-Poly1305&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cipher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChaCha20Poly1305&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AEADParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;associatedData&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;output&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOutputSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;written&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doFinal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EncryptedEnvelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;kemCiphertext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 1088 bytes, sent once per session&lt;/span&gt;
        &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;// 12 bytes&lt;/span&gt;
        &lt;span class="n"&gt;aead&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;                       &lt;span class="c1"&gt;// plaintext.size + 16 bytes&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;A few notes on the code above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;'encap_ciphertext' is the big one - 1088 bytes. In my protocol I only send it to on session establishment, not every message. Within a session I use a cached room secret derived from that initial exchange. &lt;/li&gt;
&lt;li&gt;'info = "quldra/v3/msg" is the HKDF domain separator. The 'v3' bit matters - when i eventually rotate this (post-quantum standards will evolve), old messages stay decryptable under 'v2'/'v3' code paths and new ones use 'v4'.&lt;/li&gt;
&lt;li&gt;Don't reuse nonces. ChaCha20-Poly1305 breaks catastrophically on nonce reuse. I use a 12-byte random nonce, which is safe for around 2^32 messages per key befiore birthday-bound becomes relevant - I rotate the key before that anyway. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The migration gotchas I hit
&lt;/h2&gt;

&lt;p&gt;A few things I didn't expect when I did this swap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BouncyCastle's ML-KEM API changed between &lt;code&gt;bcprov-jdk18on&lt;/code&gt; 1.78 and 1.79.&lt;/strong&gt; If you pin one, pin both. I lost an afternoon to a deserialisation mismatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys don't round-trip through &lt;code&gt;toByteArray()&lt;/code&gt; on iOS Kotlin/Native.&lt;/strong&gt; I had to go through the raw encoded format manually. If you're using Kotlin Multiplatform, test serialisation on both platforms early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't hybridise unless you have to.&lt;/strong&gt; Chrome and Cloudflare use hybrid X25519+ML-KEM-768 as a belt-and-braces move while the post-quantum algorithms are still young. For a new app with no legacy decryption path to maintain, pure ML-KEM-768 is simpler and I'd rather have one thing to audit than two.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Honestly — ask me in five years. The whole point of post-quantum is that you can't know today whether the hedge paid off. The question is whether the cost today is acceptable for the protection tomorrow.&lt;/p&gt;

&lt;p&gt;For my app, a 2 KB handshake bump and a slightly larger public key registry was acceptable. For yours, it might not be. But if you're starting fresh and "messages I send today should still be private when my daughter is forty" sounds reasonable, ML-KEM-768 is the move.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://quldra.com" rel="noopener noreferrer"&gt;Quldra&lt;/a&gt;, a post-quantum, single-device messaging app in Kotlin Multiplatform. This is post 1 of a short series on the tech behind it.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>security</category>
      <category>kotlin</category>
      <category>pqc</category>
    </item>
    <item>
      <title>My road to ML-KEM-768 over X25519 for my messaging app</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Tue, 28 Apr 2026 19:18:04 +0000</pubDate>
      <link>https://forem.com/xmkx/my-road-to-ml-kem-768-over-x25519-for-my-messaging-app-50op</link>
      <guid>https://forem.com/xmkx/my-road-to-ml-kem-768-over-x25519-for-my-messaging-app-50op</guid>
      <description>&lt;p&gt;Eight months ago I started working on a messaging app as an hobby to see how difficult it is. One thing led to another and then I was obsessed with the idea of having it Post-quantum ready. It is well known that Signal works in that regard but from my perspective it isn't full E2EE. Boiling it down to small stuff - why I chose ML-KEM-768 instead of X25519.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "harvest now, decrypt later" problem
&lt;/h2&gt;

&lt;p&gt;X25519 is an elliptic curve Diffie-Hellman on Curve25519. Its security rests on the discrete log problem being hard. It is, today, on classical hardware.&lt;/p&gt;

&lt;p&gt;A sufficiently large quantum computer running Shor's algorithm breaks it. Nobody has one yet but the bells are ringing. An adversary who can capture and store your encrypted traffic today can decrypt it the day they do. This is not a theoretical problem for a messaging app - messages sent today are expected to stay private for years, sometimes decades.&lt;/p&gt;

&lt;p&gt;Post-quantum key exchange is the hedge. And as of August 2024, NIST has a standard for it: &lt;strong&gt;FIPS 203&lt;/strong&gt;, which specifies &lt;strong&gt;ML-KEM&lt;/strong&gt; (Module Lattice-based Key encapsulation Mechanism), the renamed CRYSTALS-Kyber.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 768, not 512 or 1024?
&lt;/h2&gt;

&lt;p&gt;ML-KEM ships in three parameter sets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter set&lt;/th&gt;
&lt;th&gt;Claimed security category&lt;/th&gt;
&lt;th&gt;Rough classical equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ML-KEM-512&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;AES-128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML-KEM-768&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;AES-192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML-KEM-1024&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;AES-256&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;768 is the sweet spot most deployed post-quantum systems have converged on. Cloudflare, Chromes hybrid X25519Kyber768, Signal's PQXDH all use the 768 tier. It's the default "safe modern choice" - strong enough that nobody serious argues 512 is sufficient, and light enough that 1024's extra bytes aren't worth the hit unless you're protecting state secrets. In any of the cases you can still leave room for it to be more secure if need be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that actually matter
&lt;/h2&gt;

&lt;p&gt;Here's the honest size comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;X25519&lt;/th&gt;
&lt;th&gt;ML-KEM-768&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public key&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;1,184 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ciphertext / encapsulation&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;1,088 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared secret&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;td&gt;32 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keygen (ms, M1)&lt;/td&gt;
&lt;td&gt;~0.02&lt;/td&gt;
&lt;td&gt;~0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encap (ms, M1)&lt;/td&gt;
&lt;td&gt;~0.04&lt;/td&gt;
&lt;td&gt;~0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decap (ms, M1)&lt;/td&gt;
&lt;td&gt;~0.04&lt;/td&gt;
&lt;td&gt;~0.07&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The speed penalty is basically noise on a modern device. The size penalty is real - every session key exchange ships &lt;strong&gt;~2 KB more on the wire&lt;/strong&gt; than it used to. For a messaging app where most messages are smaller than that, it's a meaningful bump in bandwidth for the first message in the conversation. &lt;/p&gt;

&lt;p&gt;I decided I could eat it. A 2KB one-time handshake cost per conversation is fine. A protocol that breaks in ten years is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual flow
&lt;/h2&gt;

&lt;p&gt;I'm not using ML-KEM to encrypt messages directly - it's a KEM, not a cipher. It gives you a shared secret, and you feed that shared secret into something that can actually encrypt bulk data. In my case, ChaCha20-Poly1305.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sender                                   recipient
  |                                          |
  |  fetch recipient's ML-KEM public key     |
  |-----------------------------------------&amp;gt;|
  |                                          |
  |  (encapsulate)                           |
  |    ciphertext, sharedSecret = Encap(pk)  |
  |                                          |
  |  HKDF(sharedSecret, salt=nonce)          |
  |     → 32-byte room key                   |
  |                                          |
  |  ChaCha20-Poly1305(key, nonce, plaintext)|
  |                                          |
  |  send {ciphertext, nonce, AEAD payload}  |
  |-----------------------------------------&amp;gt;|
  |                                          |
  |                       sharedSecret = Decap(sk, ciphertext)
  |                       HKDF(...) → same 32-byte key
  |                       ChaCha20-Poly1305 decrypt → plaintext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kem output is 32 bytes of raw shared secret. Feeding it straight into ChaCha20 as a key would work but is brittle . you'd be binding the key to the KEM output directly, with no domain separation, no per-message salt, HKDF with a per-message nonce as salt gives you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Domain separation (same KEM shared secret can produce different keys for different purposes.&lt;/li&gt;
&lt;li&gt;A key that rotates every message, even when the underlying KEM secret is reused for a session.&lt;/li&gt;
&lt;li&gt;A clean audit story - the cipher sees a fresh 32-byte key each time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what that looks like in Kotlin, using Bouncy Castle's ML-KEM provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;encryptMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;recipientPublicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MLKEMPublicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;associatedData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;EncryptedEnvelope&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Encapsulate to get a ciphertext + shared secret&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;encap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MLKEMEncapsulator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipientPublicKey&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encapsulate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Derive a per-message key via HKDF&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SecureRandom&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nextBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;messageKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hkdfSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ikm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;salt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"quldra/v3/msg"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toByteArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Encrypt with ChaCha20-Poly1305&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cipher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChaCha20Poly1305&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AEADParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;associatedData&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;output&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOutputSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;written&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doFinal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EncryptedEnvelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;kemCiphertext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 1088 bytes, sent once per session&lt;/span&gt;
        &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;// 12 bytes&lt;/span&gt;
        &lt;span class="n"&gt;aead&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;                       &lt;span class="c1"&gt;// plaintext.size + 16 bytes&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;A few notes on the code above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;'encap_ciphertext' is the big one - 1088 bytes. In my protocol I only send it to on session establishment, not every message. Within a session I use a cached room secret derived from that initial exchange. &lt;/li&gt;
&lt;li&gt;'info = "quldra/v3/msg" is the HKDF domain separator. The 'v3' bit matters - when i eventually rotate this (post-quantum standards will evolve), old messages stay decryptable under 'v2'/'v3' code paths and new ones use 'v4'.&lt;/li&gt;
&lt;li&gt;Don't reuse nonces. ChaCha20-Poly1305 breaks catastrophically on nonce reuse. I use a 12-byte random nonce, which is safe for around 2^32 messages per key befiore birthday-bound becomes relevant - I rotate the key before that anyway. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The migration gotchas I hit
&lt;/h2&gt;

&lt;p&gt;A few things I didn't expect when I did this swap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BouncyCastle's ML-KEM API changed between &lt;code&gt;bcprov-jdk18on&lt;/code&gt; 1.78 and 1.79.&lt;/strong&gt; If you pin one, pin both. I lost an afternoon to a deserialisation mismatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys don't round-trip through &lt;code&gt;toByteArray()&lt;/code&gt; on iOS Kotlin/Native.&lt;/strong&gt; I had to go through the raw encoded format manually. If you're using Kotlin Multiplatform, test serialisation on both platforms early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't hybridise unless you have to.&lt;/strong&gt; Chrome and Cloudflare use hybrid X25519+ML-KEM-768 as a belt-and-braces move while the post-quantum algorithms are still young. For a new app with no legacy decryption path to maintain, pure ML-KEM-768 is simpler and I'd rather have one thing to audit than two.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Honestly — ask me in five years. The whole point of post-quantum is that you can't know today whether the hedge paid off. The question is whether the cost today is acceptable for the protection tomorrow.&lt;/p&gt;

&lt;p&gt;For my app, a 2 KB handshake bump and a slightly larger public key registry was acceptable. For yours, it might not be. But if you're starting fresh and "messages I send today should still be private when my daughter is forty" sounds reasonable, ML-KEM-768 is the move.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://quldra.com" rel="noopener noreferrer"&gt;Quldra&lt;/a&gt;, a post-quantum, single-device messaging app in Kotlin Multiplatform. This is post 1 of a short series on the tech behind it.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>security</category>
      <category>kotlin</category>
      <category>pqc</category>
    </item>
    <item>
      <title>The Vercel leak is a warning shot for agentic coders</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Mon, 20 Apr 2026 00:04:58 +0000</pubDate>
      <link>https://forem.com/xmkx/the-vercel-leak-is-a-warning-shot-for-agentic-coders-1ni0</link>
      <guid>https://forem.com/xmkx/the-vercel-leak-is-a-warning-shot-for-agentic-coders-1ni0</guid>
      <description>&lt;p&gt;If you haven't heard, Vercel confirmed they had &lt;a href="https://vercel.com/kb/bulletin/vercel-april-2026-security-incident" rel="noopener noreferrer"&gt;a security incident&lt;/a&gt; on the 19th of April. That hit matters more than the average vendor's bad week, because a great proportion of "LLM whisperers" and vibe coders run their frontend through Vercel.&lt;/p&gt;

&lt;p&gt;Vercel state that &lt;strong&gt;more than 30% of deployments on its platform are now initiated by coding agents, up 1000% in six months&lt;/strong&gt;. They also say that projects deployed by coding agents are &lt;strong&gt;20x more likely to call AI inference providers&lt;/strong&gt; than human-deployed ones. To rephrase: agent-built apps are no longer edge cases, they're a meaningful chunk of modern web shipping.&lt;/p&gt;

&lt;p&gt;That scales the leak well past a regular vendor incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vercel actually said
&lt;/h2&gt;

&lt;p&gt;Per the bulletin, the incident originated from a small third-party AI tool whose Google Workspace OAuth app was the subject of a broader compromise. Vercel advised customers to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Review account activity logs for suspicious behavior.&lt;/li&gt;
&lt;li&gt;Audit and rotate any environment variables that may contain secrets — &lt;em&gt;especially&lt;/em&gt; ones not marked as &lt;strong&gt;sensitive&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enable the sensitive environment variables feature going forward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Variables explicitly flagged as &lt;code&gt;sensitive&lt;/code&gt; are stored so they can't be read back, and Vercel says it has no evidence those values were accessed. Everything else is fair game for rotation.&lt;/p&gt;

&lt;p&gt;That's the actual lesson — the risk is no longer just buggy code. The risk is &lt;strong&gt;delegated trust&lt;/strong&gt; across OAuth apps, agents, CI, hosting, logs, and environment-variable handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem was already visible
&lt;/h2&gt;

&lt;p&gt;This isn't a surprise if you'd been reading Vercel's own data. In their &lt;a href="https://vercel.com/blog/v0-vibe-coding-securely" rel="noopener noreferrer"&gt;earlier post on vibe coding securely&lt;/a&gt;, they noted that v0 blocked &lt;strong&gt;over 17,000 insecure deployments in July 2025 alone&lt;/strong&gt;. The recurring themes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exposed API keys&lt;/strong&gt; — Supabase, OpenAI, Gemini, Claude, xAI credentials nearly leaked by the thousand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; misuse&lt;/strong&gt; — LLMs confidently shoving database credentials into a prefix that ships straight to the browser by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unauthenticated API routes&lt;/strong&gt; — generated and deployed without anyone checking who can call them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same story, scaled up: fast AI-assisted shipping creates a larger security blast radius unless guardrails are enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should change
&lt;/h2&gt;

&lt;p&gt;From my perspective, anyone shipping with agents should actually read and follow the &lt;a href="https://genai.owasp.org/llm-top-10/" rel="noopener noreferrer"&gt;OWASP Top 10 for LLM Applications&lt;/a&gt;. Not skim. Read.&lt;/p&gt;

&lt;p&gt;A few specific shifts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mark and isolate sensitive parameters.&lt;/strong&gt; Credentials, tokens, signing keys — explicitly flag them. We don't need to get overparanoid about agents touching prototype env vars, but the moment a project goes outward-facing, rotate so there isn't a trace of those secrets in development pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat connected third-party tools as production threats.&lt;/strong&gt; Easier said than done, but human approval for connecting a new OAuth app to your hosting/source/CI should sit higher up than usual. The Vercel incident didn't start with Vercel — it started with a tool someone connected to Vercel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep audit trails for machine actions.&lt;/strong&gt; Agent sessions, deployments, permission grants — attributable and reviewable. If you can't answer "which agent did this, when, with whose credentials," you don't have a security posture, you have a story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;A lot of the new frontend AI world already routes through Vercel-style deployment patterns, and Vercel's own numbers show how fast that's growing. The fix isn't &lt;em&gt;"stop agentic coding."&lt;/em&gt; It's to be more mindful about every connected tool in the project — sooner or later one of them will be compromised, and the question is only whether you find out from your own audit log or from a vendor's bulletin.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>vercel</category>
      <category>security</category>
    </item>
    <item>
      <title>Project Glasswing: What Software Companies Should Actually Do in the Next 12 Months</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Tue, 14 Apr 2026 04:14:56 +0000</pubDate>
      <link>https://forem.com/xmkx/project-glasswing-what-software-companies-should-actually-do-in-the-next-12-months-1oe8</link>
      <guid>https://forem.com/xmkx/project-glasswing-what-software-companies-should-actually-do-in-the-next-12-months-1oe8</guid>
      <description>&lt;p&gt;Anthropic's Project Glasswing gave limited access to Claude Mythos Preview — a model reportedly able to identify and exploit zero-day vulnerabilities across major operating systems and browsers, including multi-stage exploit chains. Anthropic says more than 99% of the vulnerabilities it found remain unpatched.&lt;/p&gt;

&lt;p&gt;That is the data point that matters.&lt;/p&gt;

&lt;p&gt;The usual reaction to announcements like this is a philosophical debate about how many of those bugs were already known to someone and just unreported. That is a rabbit hole not worth digging. Assume the less flattering answer - act on that.&lt;/p&gt;

&lt;p&gt;Software will not suddenly stop working. Codebases are not worthless. Nobody has to rewrite everything next quarter.&lt;/p&gt;

&lt;p&gt;What changed is the &lt;strong&gt;economics of offense&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually got cheaper
&lt;/h2&gt;

&lt;p&gt;A model like Mythos does not need to invent new classes of vulnerabilities to cause disruption. It only needs to reduce the skill, time, and cost required to find real bugs, weaponize them, and chain them together.&lt;/p&gt;

&lt;p&gt;Anthropic explicitly warns that defenses whose main value is friction rather than a hard barrier will erode against model-assisted attackers, because models can grind through tedious exploit-development work at scale.&lt;/p&gt;

&lt;p&gt;An example of what "friction" means in practice: a stripped closed-source binary that used to cost a specialist a weekend of reverse engineering to map. A heap-grooming sequence only one in ten offensive researchers could reliably land. A logic bug buried three layers deep in a state machine nobody wanted to audit by hand.&lt;/p&gt;

&lt;p&gt;That kind of work compresses.&lt;/p&gt;

&lt;p&gt;Mythos reportedly found memory-corruption bugs, logic bugs, cryptographic implementation weaknesses, browser exploit chains, and vulnerabilities inside closed-source binaries via reverse engineering. Five bug classes. One pipeline.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exploit development gets faster&lt;/li&gt;
&lt;li&gt;exploit chaining gets easier&lt;/li&gt;
&lt;li&gt;reverse engineering gets cheaper&lt;/li&gt;
&lt;li&gt;patch windows get more dangerous&lt;/li&gt;
&lt;li&gt;"annoying but probably fine" exposures get less fine&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What software companies should actually do
&lt;/h2&gt;

&lt;p&gt;Most writing about AI risk becomes either philosophy or panic. Neither is operationally useful. Here is the triage.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Build an honest asset inventory
&lt;/h3&gt;

&lt;p&gt;Not a compliance spreadsheet. An actual map: what is internet-facing, what runs with elevated privilege, what is written in memory-unsafe languages, what parses untrusted input, what sits before authentication, what cannot be patched quickly, what third-party components quietly expand the attack surface.&lt;/p&gt;

&lt;p&gt;Without this, you are not doing prioritization. You are doing vibes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Split the estate into rewrite, isolate, monitor
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Rewrite&lt;/strong&gt;: high-risk parsers, security-critical low-level services, old C/C++ components exposed to hostile input, brittle auth or crypto-adjacent implementations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolate&lt;/strong&gt;: legacy services you cannot replace in 12 months, components with known rough edges and clear business dependency, anything that can be boxed into stricter privilege boundaries, seccomp profiles, sandboxes, or service segmentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitor&lt;/strong&gt;: lower-risk business logic, standard SaaS code in managed runtimes, internal tooling with constrained exposure.&lt;/p&gt;

&lt;p&gt;The point most teams miss: the right response to Mythos is not "rewrite everything." It is "be much more honest about what deserves rewriting first."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Make patch latency an executive KPI
&lt;/h3&gt;

&lt;p&gt;If 99% of Mythos-found vulnerabilities remain unpatched, the bottleneck is not discovery. It is organizational response speed.&lt;/p&gt;

&lt;p&gt;The question is no longer &lt;em&gt;could someone find this bug?&lt;/em&gt; It is &lt;em&gt;could we fix, validate, and deploy the patch before someone industrializes it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Patch velocity is now a business capability. Not just a security metric.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Discount "attacker effort" in your severity model
&lt;/h3&gt;

&lt;p&gt;A vulnerability that used to be dismissed as "the exploit path is too tedious" is more dangerous than it looked six months ago. A chain that used to require an unusually strong offensive researcher may increasingly be available to a competent operator with a good model and enough compute.&lt;/p&gt;

&lt;p&gt;That does not mean every bug is suddenly critical. It does mean your severity rubric should stop leaning on attacker inconvenience as a mitigation.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Default new security-critical code to memory-safe stacks
&lt;/h3&gt;

&lt;p&gt;Not ideology. Risk concentration. You do not need to rewrite mature systems tomorrow. You should probably stop creating fresh long-term exposure in exactly the places where a language choice removes entire bug classes.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Add model-assisted workflows to defense now
&lt;/h3&gt;

&lt;p&gt;Anthropic's own framing is that the long-run equilibrium may still favor defenders — the way fuzzers eventually did — but the transitional period favors attackers if defenders are slower to adapt.&lt;/p&gt;

&lt;p&gt;That means AI-assisted code review, exploitability analysis, dependency triage, fuzzing and testcase generation, and regression verification after patches.&lt;/p&gt;

&lt;p&gt;If offense becomes automated while defense stays ticket-driven and manual, the gap widens fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Stop treating architecture as separate from security
&lt;/h3&gt;

&lt;p&gt;The companies that look smartest in 18 months will not be the ones that wrote the most urgent memos.&lt;/p&gt;

&lt;p&gt;They will be the ones that quietly reduced blast radius.&lt;/p&gt;

&lt;p&gt;Least privilege, segmentation, sandboxing, narrow interfaces, ephemeral credentials, isolated parsers, fast rollback paths. All of those become more valuable when exploit development gets cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real question
&lt;/h2&gt;

&lt;p&gt;Mythos does not mean software companies need to rewrite everything.&lt;/p&gt;

&lt;p&gt;It means they have to stop pretending their backlog is neutral.&lt;/p&gt;

&lt;p&gt;A lot of organizations have accumulated technical debt under the quiet assumption that the exploit-development bottleneck was mostly on the attacker side. That assumption is weakening.&lt;/p&gt;

&lt;p&gt;What used to be &lt;em&gt;"we should clean this up eventually"&lt;/em&gt; is increasingly &lt;em&gt;"this is a compounding liability."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The next 12 months are not about panic. They are about sorting your projects into three buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;things you can still safely defer&lt;/li&gt;
&lt;li&gt;things you must isolate now&lt;/li&gt;
&lt;li&gt;things you should never have built that way again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not "do we rewrite everything?"&lt;/p&gt;

&lt;p&gt;But "which parts of our system can no longer rely on attacker friction to stay safe?"&lt;/p&gt;

&lt;p&gt;That is a much better executive question.&lt;/p&gt;

&lt;p&gt;And a much more expensive one to answer late.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>cybersecurity</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Commoditization Thesis: What Actually Happens When Software Gets Easy</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Wed, 08 Apr 2026 01:03:48 +0000</pubDate>
      <link>https://forem.com/xmkx/the-commoditization-thesis-what-actually-happens-when-software-gets-easy-1lm7</link>
      <guid>https://forem.com/xmkx/the-commoditization-thesis-what-actually-happens-when-software-gets-easy-1lm7</guid>
      <description>&lt;p&gt;The data is clear on what’s already happening.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Labor’s share of GDP fell to &lt;strong&gt;53.8% in Q3 2025&lt;/strong&gt; — the lowest in the modern BLS series back to 1947. Capital is eating labor’s lunch, and AI is accelerating it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Entry-level pressure is already visible: Stanford’s Digital Economy Lab found declines concentrated among &lt;strong&gt;22–25 year-old workers in AI-exposed jobs&lt;/strong&gt; such as software development, customer service, and clerical work.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The narrower BLS category of &lt;strong&gt;computer programmers&lt;/strong&gt; is projected to &lt;strong&gt;decline 6% through 2034&lt;/strong&gt;, even while broader software development roles still grow. That distinction matters: implementation-heavy work gets pressured first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Meanwhile, workers with &lt;strong&gt;AI skills command meaningful wage premiums&lt;/strong&gt;. PwC found an average &lt;strong&gt;56% wage premium&lt;/strong&gt; for workers with AI skills in 2024, and other labor-market reporting showed premiums up to &lt;strong&gt;43%&lt;/strong&gt; for jobs listing multiple AI skills.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tech salary growth slowed to &lt;strong&gt;1.6% in 2025&lt;/strong&gt;, down from &lt;strong&gt;3.5% in 2023&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not speculation. The compression is measurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Historical pattern — this has happened before, twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Web development (late 90s → 2008):&lt;/strong&gt; Developers charged premium rates to technically naive clients. WordPress, Squarespace, and frameworks killed the implementation premium. Value migrated to architecture, product strategy, integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile apps (2008 → mid-2010s):&lt;/strong&gt; App Store gold rush → React Native / Flutter commoditized a large chunk of standalone app building. Same outcome: implementation became cheaper, judgment became more expensive.&lt;/p&gt;

&lt;p&gt;The arc is usually the same: scarcity premium → tool-driven commoditization → value migrates upward to domain expertise, architecture, integration, and judgment.&lt;/p&gt;

&lt;p&gt;Historically, that took &lt;strong&gt;5–8 years&lt;/strong&gt;. AI appears to be compressing the cycle closer to &lt;strong&gt;2–3 years&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the capital side of this looks like
&lt;/h2&gt;

&lt;p&gt;Piketty’s &lt;strong&gt;r &amp;gt; g&lt;/strong&gt; framework feels increasingly visible in current data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;S&amp;amp;P 500&lt;/strong&gt; is dramatically above its January 2023 level.&lt;/li&gt;
&lt;li&gt;AI captured &lt;strong&gt;close to 50% of all global venture funding in 2025&lt;/strong&gt;, with roughly &lt;strong&gt;$202.3B&lt;/strong&gt; invested across infrastructure, foundation labs, and applications.&lt;/li&gt;
&lt;li&gt;McKinsey has explicitly warned that when &lt;strong&gt;real estate and equity values rise faster than GDP&lt;/strong&gt;, capital can get pulled toward asset inflation and repurchases rather than the kinds of investment that generate broad long-run growth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Labor income alone is becoming a weaker wealth-building vehicle. The gap between returns on capital and returns on labor is widening, not narrowing. For someone starting from near-zero capital, that is the fundamental challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The critical prediction for 2026–2030
&lt;/h2&gt;

&lt;p&gt;Middle-class software developer income will likely bifurcate.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bottom 60% of current developers:&lt;/strong&gt; real income stagnation or decline. Implementation work commoditizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top 20%:&lt;/strong&gt; premium widens. Architecture, security, compliance, domain-specific systems, AI integration, and judgment-heavy work become more valuable.&lt;/li&gt;
&lt;li&gt;The gap between these groups likely widens through at least &lt;strong&gt;2030&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not because software disappears.&lt;/p&gt;

&lt;p&gt;Because &lt;strong&gt;undifferentiated software work gets repriced downward&lt;/strong&gt;, while the value of high-context, high-trust, high-complexity work rises by contrast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable truth
&lt;/h2&gt;

&lt;p&gt;Software commoditization means the &lt;strong&gt;floor rises&lt;/strong&gt; — more people can build — but the &lt;strong&gt;ceiling also rises&lt;/strong&gt;. Complex integration, security, compliance, architecture, and domain-specific work become more valuable precisely because raw implementation gets cheaper.&lt;/p&gt;

&lt;p&gt;That usually hollows out the middle.&lt;/p&gt;

&lt;p&gt;The real risk is getting distracted by opportunities that look easier, when in practice they are just more crowded.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwaredevelopment</category>
      <category>career</category>
      <category>programming</category>
    </item>
    <item>
      <title>I scanned 8 popular npm projects for quantum-vulnerable cryptography. Here's what I found.</title>
      <dc:creator>Martin Kambla</dc:creator>
      <pubDate>Wed, 01 Apr 2026 15:25:03 +0000</pubDate>
      <link>https://forem.com/xmkx/i-scanned-8-popular-npm-projects-for-quantum-vulnerable-cryptography-heres-what-i-found-48</link>
      <guid>https://forem.com/xmkx/i-scanned-8-popular-npm-projects-for-quantum-vulnerable-cryptography-heres-what-i-found-48</guid>
      <description>&lt;p&gt;This week Google published a paper that changed the post-quantum timeline. Breaking ECDSA-256 — the signature scheme protecting Bitcoin, Ethereum, and most of the web — now requires &lt;a href="https://research.google/blog/safeguarding-cryptocurrency-by-disclosing-quantum-vulnerabilities-responsibly/" rel="noopener noreferrer"&gt;roughly 1,200 logical qubits and under 500,000 physical qubits&lt;/a&gt;. That's a 20x reduction from previous estimates.&lt;/p&gt;

&lt;p&gt;I wanted to answer a simple question: &lt;strong&gt;how exposed are the projects we all depend on?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/PQCWorld/pqaudit" rel="noopener noreferrer"&gt;pqaudit&lt;/a&gt;, an open-source CLI that scans source code and npm dependencies for quantum-vulnerable cryptography — algorithms broken by Shor's algorithm (RSA, ECDSA, Ed25519, ECDH, Diffie-Hellman) and weakened by Grover's algorithm (AES-128) — and flags the NIST-approved replacement for each one.&lt;/p&gt;

&lt;p&gt;Then I pointed it at 8 popular projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Critical&lt;/th&gt;
&lt;th&gt;High&lt;/th&gt;
&lt;th&gt;PQC Ready&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastify&lt;/td&gt;
&lt;td&gt;295&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;22,478&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma&lt;/td&gt;
&lt;td&gt;3,291&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jsonwebtoken&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solana web3.js&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ethereum web3.js&lt;/td&gt;
&lt;td&gt;1,194&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal Desktop&lt;/td&gt;
&lt;td&gt;2,854&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;30,423 files scanned. 6 of 8 are not quantum-ready.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let me walk through the interesting ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  jsonwebtoken: 21 critical findings in 65 files
&lt;/h2&gt;

&lt;p&gt;This one hit hardest. &lt;a href="https://github.com/auth0/node-jsonwebtoken" rel="noopener noreferrer"&gt;node-jsonwebtoken&lt;/a&gt; is the most popular JWT library on npm — and it's fundamentally built on quantum-vulnerable algorithms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[!!] RSA — RS256, RS384, RS512, PS256, PS384, PS512
     sign.js, verify.js, lib/validateAsymmetricKey.js
     Fix: ML-DSA-65 (FIPS 204)

[!!] ECDSA — ES256, ES384, ES512
     lib/validateAsymmetricKey.js
     Fix: ML-DSA-65 (FIPS 204)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every signing algorithm the library supports — RS256, ES256, and their variants — is broken by Shor's algorithm. If your app uses JWTs for authentication (and most Node.js apps do), your auth tokens are signed with quantum-vulnerable cryptography.&lt;/p&gt;

&lt;p&gt;There's no PQC JWT standard yet. The IETF is working on it, but it doesn't exist today. This is a systemic gap in the entire web ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solana web3.js: Ed25519 everywhere
&lt;/h2&gt;

&lt;p&gt;Solana's entire identity and transaction model is built on Ed25519:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[!!] Ed25519 — src/account.ts, src/keypair.ts, src/transaction/legacy.ts
     Fix: ML-DSA-65 (FIPS 204) — blocked by Solana protocol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;17 critical findings across 104 files. Every account, every keypair, every transaction signature depends on an algorithm that Shor's breaks. The secp256k1 program (for Ethereum compatibility) adds more.&lt;/p&gt;

&lt;p&gt;The migration path exists — ML-DSA-65 is the NIST replacement — but it requires a protocol-level upgrade across the entire Solana network. This isn't something you can fix in your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js: 17 critical findings (but it's not what you think)
&lt;/h2&gt;

&lt;p&gt;Next.js has 17 critical findings across 22,478 files. Sounds bad, but the nuance matters — &lt;strong&gt;every single finding is in vendored bundles&lt;/strong&gt;, not in Next.js source code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;crypto-browserify&lt;/code&gt; bundled in &lt;code&gt;packages/next/src/compiled/&lt;/code&gt; contains RSA, ECDSA, Ed25519, ECDH, and Diffie-Hellman polyfills&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jsonwebtoken&lt;/code&gt; compiled into the same directory&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;constants-browserify&lt;/code&gt; exposes RSA padding and ECDSA engine constants&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next.js doesn't use quantum-vulnerable crypto itself. But it ships it to every application that depends on it, through vendored dependencies. This is a supply-chain problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal Desktop: the only one migrating
&lt;/h2&gt;

&lt;p&gt;Signal Desktop had 12 critical findings (X25519 key exchange, ECDSA), but it's also &lt;strong&gt;the only project in this scan that has active PQC adoption&lt;/strong&gt;. pqaudit detected ML-KEM (Kyber) usage through &lt;code&gt;@signalapp/libsignal-client&lt;/code&gt; — Signal's implementation of the &lt;a href="https://signal.org/docs/specifications/pqxdh/" rel="noopener noreferrer"&gt;PQXDH protocol&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Signal is ahead. Everyone else is at zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Express and Prisma: PQC ready
&lt;/h2&gt;

&lt;p&gt;Express and Prisma both passed with zero critical findings. The pattern is clear — frameworks that delegate cryptography to the runtime or database layer don't have this problem. The vulnerability lives in libraries that implement or wrap cryptographic primitives directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks and what doesn't
&lt;/h2&gt;

&lt;p&gt;Not all cryptography is quantum-vulnerable. Here's the split:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Broken by Shor's algorithm (must migrate):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RSA (any key size) — key exchange and signatures&lt;/li&gt;
&lt;li&gt;ECDSA, Ed25519, EdDSA — signatures&lt;/li&gt;
&lt;li&gt;ECDH, X25519, Diffie-Hellman — key exchange&lt;/li&gt;
&lt;li&gt;DSA — signatures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Weakened by Grover's algorithm:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AES-128 — reduced to 64-bit effective security. Fix: use AES-256.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Already quantum-safe (no action needed):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AES-256, ChaCha20-Poly1305 — symmetric encryption&lt;/li&gt;
&lt;li&gt;SHA-256, SHA-3 — hashing&lt;/li&gt;
&lt;li&gt;ML-KEM (Kyber), ML-DSA (Dilithium), SLH-DSA (SPHINCS+) — the NIST PQC standards&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You might think quantum computers are far away. Google's paper says otherwise. And even before a quantum computer exists, "harvest now, decrypt later" attacks mean adversaries are collecting your encrypted traffic today for future decryption.&lt;/p&gt;

&lt;p&gt;The NSA's &lt;a href="https://media.defense.gov/2022/Sep/07/2003071836/-1/-1/0/CSI_CNSA_2.0_FAQ_.PDF" rel="noopener noreferrer"&gt;CNSA 2.0&lt;/a&gt; mandates PQC for new national security systems by &lt;strong&gt;January 2027&lt;/strong&gt;. Google has set a &lt;a href="https://blog.google/innovation-and-ai/technology/safety-security/cryptography-migration-timeline/" rel="noopener noreferrer"&gt;2029 deadline&lt;/a&gt; for its own products. NIST finalized the standards (FIPS 203, 204, 205) in August 2024.&lt;/p&gt;

&lt;p&gt;The migration window is open. The first step is visibility — knowing where quantum-vulnerable cryptography lives in your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx pqaudit &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command, no signup, no config. It scans your source code and npm dependencies, classifies findings by severity, and tells you the NIST-approved replacement for each one.&lt;/p&gt;

&lt;p&gt;Outputs: human-readable text, JSON, &lt;a href="https://cyclonedx.org/capabilities/cbom/" rel="noopener noreferrer"&gt;CycloneDX CBOM&lt;/a&gt;, or &lt;a href="https://sarifweb.azurewebsites.net/" rel="noopener noreferrer"&gt;SARIF&lt;/a&gt; for GitHub Code Scanning.&lt;/p&gt;

&lt;p&gt;For CI/CD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx pqaudit &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--ci&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; sarif &lt;span class="nt"&gt;--output&lt;/span&gt; pqaudit.sarif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's MIT licensed and on &lt;a href="https://github.com/PQCWorld/pqaudit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Issues and contributions welcome — especially detection rules for languages beyond JavaScript/TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full results
&lt;/h2&gt;

&lt;p&gt;The detailed scan data for all 8 projects is published at &lt;a href="https://pqcworld.com/scan-results.html" rel="noopener noreferrer"&gt;pqcworld.com/scan-results.html&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>node</category>
    </item>
  </channel>
</rss>
