DEV Community

Nulkratos
Nulkratos

Posted on

I Built a Zero-Knowledge Encrypted Messenger That Runs Entirely in Your Browser — No Account, No Phone, No Install

 Live: https://nulkratos-core.web.app

GitHub: https://github.com/nulkratos/nulkratos-core


The problem I kept running into

Every "private" messenger I found had the same tradeoffs:

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

I wanted something where neither party has to prove who they are to anyone — 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.

So I built it.


What Nulkratos-Core actually does

It's a single HTML file. No backend logic. No user database. No plaintext ever touches the server.

Here's exactly what happens when you send a message:

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
Enter fullscreen mode Exit fullscreen mode

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


The crypto stack — in detail

1. Key derivation — Argon2id

When you enter your PIN, it doesn't go to any server. Instead:

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
Enter fullscreen mode Exit fullscreen mode

Why Argon2id specifically?

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

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.

2. Message encryption — AES-256-GCM

Each message gets its own derived key via HKDF:

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 }
Enter fullscreen mode Exit fullscreen mode

Why GCM mode? 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.

3. Forward secrecy — HKDF ratchet

The ratchetIndex increments with every message. This means:

  • Each message uses a different derived key
  • Compromising one message key doesn't help decrypt past or future messages
  • There's no "master decrypt" — you can't go back

This is a simplified version of the Signal Double Ratchet concept, adapted for a PIN-based symmetric model.

4. Traffic analysis resistance — chaff injection

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

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.

5. Timestamp blinding

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.


What the server actually stores

Here's what a Firestore document looks like:

{
  "c": "Gx9kP2mN...(AES-256-GCM ciphertext, base64)",
  "iv": "rT7wQs3j...(12-byte random IV, base64)",
  "t": 1714823947,
  "ri": 7,
  "_chaff": false
}
Enter fullscreen mode Exit fullscreen mode

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.


Zero-knowledge architecture in practice

"Zero knowledge" gets thrown around loosely. Here's what it concretely means here:

What the server knows What the server does NOT know
That messages exist Who sent them
When (blurred ±90s) What they say
How many (+ chaff) The PIN
The channel ID The derived key

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


The WebCrypto API — why this is possible in a browser at all

All of this runs via the browser's native window.crypto.subtle API. No cryptography library. No native module. No npm install crypto.

// Key derivation in pure browser JS
const rawKey = await window.crypto.subtle.importKey(
  "raw", pinBuffer, { name: "HKDF" }, false, ["deriveKey"]
);

const aesKey = await window.crypto.subtle.deriveKey(
  { name: "HKDF", hash: "SHA-256", salt: channelSalt, info: infoBuffer },
  rawKey,
  { name: "AES-GCM", length: 256 },
  false,
  ["encrypt", "decrypt"]
);

// Encrypt
const ciphertext = await window.crypto.subtle.encrypt(
  { name: "AES-GCM", iv: iv },
  aesKey,
  encodedMessage
);
Enter fullscreen mode Exit fullscreen mode

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.


What I deliberately didn't build

No accounts. The moment you have accounts, you have a user database that can be subpoenaed, breached, or sold.

No phone numbers. Phone numbers are government-issued identity anchors. Using one to "verify" you ties your communications to your real identity.

No message history. Messages are deleted from Firestore after 24 hours. There's nothing to subpoena because there's nothing to store.

No read receipts. These leak presence and timing data even in encrypted systems.

No typing indicators. Same reason.


Device requirements

This runs on anything with a modern browser:

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

What I'd love feedback on

  1. The ratchet implementation — 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?

  2. The chaff timing — 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?

  3. PIN entropy — 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?

  4. The channel ID as Argon2id salt — 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?


Try it

Live: https://nulkratos-core.web.app

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.

Source is on GitHub for auditing. I'd genuinely welcome any cryptographer picking holes in the implementation.


Built with: Web Crypto API, Argon2id (argon2-browser), Firebase Firestore, vanilla JS — no frameworks, no build step, no npm.


Tags to add on Dev.to when posting:

security webdev javascript privacy

Top comments (0)