<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nulkratos</title>
    <description>The latest articles on DEV Community by Nulkratos (@nulkratos).</description>
    <link>https://dev.to/nulkratos</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%2F3911520%2Fcd054afe-8964-43b1-9d6f-93ae2bc4adcf.png</url>
      <title>DEV Community: Nulkratos</title>
      <link>https://dev.to/nulkratos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nulkratos"/>
    <language>en</language>
    <item>
      <title>I Built a Zero-Knowledge Encrypted Messenger That Runs Entirely in Your Browser — No Account, No Phone, No Install</title>
      <dc:creator>Nulkratos</dc:creator>
      <pubDate>Mon, 04 May 2026 07:21:29 +0000</pubDate>
      <link>https://dev.to/nulkratos/i-built-a-zero-knowledge-encrypted-messenger-that-runs-entirely-in-your-browser-no-account-no-16eh</link>
      <guid>https://dev.to/nulkratos/i-built-a-zero-knowledge-encrypted-messenger-that-runs-entirely-in-your-browser-no-account-no-16eh</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkq9vbu3psfj66pc1ssl6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkq9vbu3psfj66pc1ssl6.png" alt=" " width="800" height="511"&gt;&lt;/a&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://nulkratos-core.web.app" rel="noopener noreferrer"&gt;https://nulkratos-core.web.app&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/nulkratos/nulkratos-core" rel="noopener noreferrer"&gt;https://github.com/nulkratos/nulkratos-core&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The problem I kept running into
&lt;/h2&gt;

&lt;p&gt;Every "private" messenger I found had the same tradeoffs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signal → needs your phone number. That's an identity anchor.&lt;/li&gt;
&lt;li&gt;Telegram → not E2E by default. Server-side messages.&lt;/li&gt;
&lt;li&gt;WhatsApp → Meta. Enough said.&lt;/li&gt;
&lt;li&gt;Matrix/Element → self-host complexity, still needs an account.&lt;/li&gt;
&lt;li&gt;Briar → great, but mobile-only and requires install.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted something where &lt;strong&gt;neither party has to prove who they are to anyone&lt;/strong&gt; — not to a server, not to a phone carrier, not to an app store. Just open a browser, agree on a channel ID and a PIN, and talk. That's it.&lt;/p&gt;

&lt;p&gt;So I built it.&lt;/p&gt;


&lt;h2&gt;
  
  
  What Nulkratos-Core actually does
&lt;/h2&gt;

&lt;p&gt;It's a single HTML file. No backend logic. No user database. No plaintext ever touches the server.&lt;/p&gt;

&lt;p&gt;Here's exactly what happens when you send a message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You type a message
    ↓
AES-256-GCM encrypts it in your browser
    ↓
Encrypted blob is written to Firestore
    ↓
Contact's browser reads the blob
    ↓
AES-256-GCM decrypts it locally
    ↓
Contact reads your message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server (Firestore) sees only ciphertext. &lt;strong&gt;It has no key. It cannot decrypt.&lt;/strong&gt; Not even with a court order, because the key is derived from your PIN using Argon2id and never transmitted.&lt;/p&gt;




&lt;h2&gt;
  
  
  The crypto stack — in detail
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Key derivation — Argon2id
&lt;/h3&gt;

&lt;p&gt;When you enter your PIN, it doesn't go to any server. Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Argon2id(
  password: yourPIN,
  salt: channelID,   ← shared between both users, not secret
  memory: 65536,     ← 64 MB — intentionally memory-hard
  iterations: 3,
  parallelism: 1,
  output: 32 bytes
)
→ masterKey
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why Argon2id specifically?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Memory-hard: a brute-force attacker needs 64 MB &lt;em&gt;per guess&lt;/em&gt;. GPU farms become economically impractical.&lt;/li&gt;
&lt;li&gt;It's the winner of the Password Hashing Competition (2015).&lt;/li&gt;
&lt;li&gt;It combines Argon2i (side-channel resistance) and Argon2d (GPU resistance).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 6-digit PIN with Argon2id at these parameters takes ~2 seconds on a modern laptop. An attacker trying all 1,000,000 combinations needs 23 days on a single GPU — and that's before renting the hardware.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Message encryption — AES-256-GCM
&lt;/h3&gt;

&lt;p&gt;Each message gets its own derived key via HKDF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HKDF(
  inputKey: masterKey,
  salt: ratchetIndex,   ← increments per message
  info: "nulkratos-msg-key",
  length: 32
)
→ messageKey

AES-256-GCM(
  key: messageKey,
  iv: crypto.getRandomValues(12 bytes),
  plaintext: yourMessage
)
→ { ciphertext, authTag, iv }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why GCM mode?&lt;/strong&gt; It's authenticated encryption. The auth tag means any tampering with the ciphertext is detected before decryption. You can't flip bits and get a different plaintext — you get a hard authentication failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Forward secrecy — HKDF ratchet
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ratchetIndex&lt;/code&gt; increments with every message. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each message uses a different derived key&lt;/li&gt;
&lt;li&gt;Compromising one message key doesn't help decrypt past or future messages&lt;/li&gt;
&lt;li&gt;There's no "master decrypt" — you can't go back&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a simplified version of the Signal Double Ratchet concept, adapted for a PIN-based symmetric model.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Traffic analysis resistance — chaff injection
&lt;/h3&gt;

&lt;p&gt;Even with encrypted content, an observer watching Firestore can do traffic analysis: &lt;em&gt;"They sent a message at 14:32:07, then again at 14:32:45 — that's a natural conversation rhythm."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nulkratos-Core injects random chaff messages at random intervals. These are indistinguishable from real messages at the transport layer. The recipient's browser recognises and discards them silently. An external observer cannot tell which Firestore writes are real conversations and which are noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Timestamp blinding
&lt;/h3&gt;

&lt;p&gt;Real timestamps leak conversation patterns. Every message timestamp is blurred by ±90 seconds of random offset before being stored. This breaks timing correlation attacks — you can't reconstruct when a conversation actually happened even with full Firestore read access.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the server actually stores
&lt;/h2&gt;

&lt;p&gt;Here's what a Firestore document looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gx9kP2mN...(AES-256-GCM ciphertext, base64)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rT7wQs3j...(12-byte random IV, base64)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"t"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1714823947&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_chaff"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No sender ID. No recipient ID. No username. No IP. No read receipts stored server-side. The channel ID is the document path — it's a shared secret between both users, never stored in plaintext inside the document.&lt;/p&gt;




&lt;h2&gt;
  
  
  Zero-knowledge architecture in practice
&lt;/h2&gt;

&lt;p&gt;"Zero knowledge" gets thrown around loosely. Here's what it concretely means here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the server knows&lt;/th&gt;
&lt;th&gt;What the server does NOT know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;That messages exist&lt;/td&gt;
&lt;td&gt;Who sent them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When (blurred ±90s)&lt;/td&gt;
&lt;td&gt;What they say&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How many (+ chaff)&lt;/td&gt;
&lt;td&gt;The PIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The channel ID&lt;/td&gt;
&lt;td&gt;The derived key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even if someone had full Firestore admin access, they'd have a list of encrypted blobs with blurred timestamps. Nothing more.&lt;/p&gt;




&lt;h2&gt;
  
  
  The WebCrypto API — why this is possible in a browser at all
&lt;/h2&gt;

&lt;p&gt;All of this runs via the browser's native &lt;code&gt;window.crypto.subtle&lt;/code&gt; API. No cryptography library. No native module. No &lt;code&gt;npm install crypto&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Key derivation in pure browser JS&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pinBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HKDF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deriveKey&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aesKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deriveKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HKDF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;channelSalt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;infoBuffer&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;decrypt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Encrypt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;aesKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;encodedMessage&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebCrypto is implemented in native code by the browser engine (BoringSSL in Chrome, NSS in Firefox). It's faster than any JS library and runs in a separate thread — it won't block your UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I deliberately didn't build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No accounts.&lt;/strong&gt; The moment you have accounts, you have a user database that can be subpoenaed, breached, or sold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No phone numbers.&lt;/strong&gt; Phone numbers are government-issued identity anchors. Using one to "verify" you ties your communications to your real identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No message history.&lt;/strong&gt; Messages are deleted from Firestore after 24 hours. There's nothing to subpoena because there's nothing to store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No read receipts.&lt;/strong&gt; These leak presence and timing data even in encrypted systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No typing indicators.&lt;/strong&gt; Same reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  Device requirements
&lt;/h2&gt;

&lt;p&gt;This runs on anything with a modern browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome 90+, Firefox 88+, Safari 15+, Edge 90+&lt;/li&gt;
&lt;li&gt;Needs WebCrypto API (all modern browsers have it)&lt;/li&gt;
&lt;li&gt;256 MB RAM minimum (Argon2id needs 64 MB per key derivation)&lt;/li&gt;
&lt;li&gt;Any internet connection for Firestore sync&lt;/li&gt;
&lt;li&gt;No install, no app store, no permissions&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd love feedback on
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The ratchet implementation&lt;/strong&gt; — I'm using HKDF with an incrementing index as the salt. Is this cryptographically sound as a simplified forward-secrecy mechanism, or is there a better approach that doesn't require Diffie-Hellman key exchange?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The chaff timing&lt;/strong&gt; — Currently random intervals between 8–45 seconds. Is this realistic enough to defeat traffic analysis, or would a more structured approach (e.g. constant-rate padding) be better?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PIN entropy&lt;/strong&gt; — I'm relying on Argon2id to compensate for low-entropy PINs. Should I be enforcing minimum PIN complexity on the client side, or does that defeat the UX simplicity that makes this usable?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The channel ID as Argon2id salt&lt;/strong&gt; — The channel ID is shared between both users and not secret. Using it as the salt is intentional (the salt doesn't need to be secret in Argon2). But it does mean that if two pairs of users somehow chose the same channel ID AND the same PIN, they'd derive the same key. The probability is astronomically low, but am I thinking about this correctly?&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://nulkratos-core.web.app" rel="noopener noreferrer"&gt;https://nulkratos-core.web.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open it in two browser tabs. Create a channel in one. Join with the same ID and PIN in the other. Send a message. Open Firestore (public read for demo) and look at what's stored. You'll see why "zero knowledge" isn't marketing — it's just what the ciphertext looks like.&lt;/p&gt;

&lt;p&gt;Source is on GitHub for auditing. I'd genuinely welcome any cryptographer picking holes in the implementation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with: Web Crypto API, Argon2id (argon2-browser), Firebase Firestore, vanilla JS — no frameworks, no build step, no npm.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags to add on Dev.to when posting:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;security&lt;/code&gt; &lt;code&gt;webdev&lt;/code&gt; &lt;code&gt;javascript&lt;/code&gt; &lt;code&gt;privacy&lt;/code&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
