DEV Community

Cover image for I Built a Signal Protocol Messenger from Scratch with X3DH, Double Ratchet, Safety Numbers, P2P File Transfer
Abhiram
Abhiram

Posted on

I Built a Signal Protocol Messenger from Scratch with X3DH, Double Ratchet, Safety Numbers, P2P File Transfer

Most developers think TLS means secure. It doesn't.

TLS protects the transport. The server still decides whose
public key you encrypt to. A malicious server hands you its
own key, reads everything you send, re-encrypts it, and
forwards it along. Neither party notices.

This is the attack most "end-to-end encrypted" apps are
silently vulnerable to. We built Halonyx to close it.

πŸ”— GitHub Β· 🌐 Live Demo


Why Build Another Messenger?

Most "secure messenger" projects I've seen fall into one of two categories:

  1. They use TLS and call it "encrypted" β€” which protects the transport, not the content from the server
  2. They use a crypto library as a black box without understanding what's happening inside I wanted to actually implement the Signal Protocol from first principles: understand every DH operation in X3DH, trace how the Double Ratchet chains evolve, and design a system where the relay server is architecturally excluded from reading anything β€” not just by policy, but by cryptographic design.

This is Halonyx. Here's what we built and what we learned.


The Core Problem: Trust No Server

The typical E2EE claim is "only you and the recipient can read messages." But there's a hidden assumption: that the server gives you the real public key of your contact.

A malicious server can silently substitute its own key during the X3DH handshake. You encrypt to the server's key, it decrypts, re-encrypts to your contact, and neither party knows. This is a MITM attack at the key distribution layer.

Signal solves this with Safety Numbers. We implemented the same mechanism β€” more on that later.


Signal Protocol: What's Actually Happening

X3DH Key Exchange

X3DH (Extended Triple Diffie-Hellman) lets two parties establish a shared secret even if one of them is offline. It uses four DH operations:

DH1 = DH(IKa,  SPKb)   β€” Alice's identity key    Γ— Bob's signed pre-key
DH2 = DH(EKa,  IKb)    β€” Alice's ephemeral key   Γ— Bob's identity key
DH3 = DH(EKa,  SPKb)   β€” Alice's ephemeral key   Γ— Bob's signed pre-key
DH4 = DH(EKa,  OPKb)   β€” Alice's ephemeral key   Γ— Bob's one-time pre-key

SK  = HKDF(DH1 β€– DH2 β€– DH3 β€– DH4)
Enter fullscreen mode Exit fullscreen mode

Why four operations instead of one? Each adds a layer:

  • DH1 binds both parties' long-term identities
  • DH2 + DH3 add ephemeral randomness β€” even if Alice's long-term key is later compromised, past sessions are safe
  • DH4 uses a one-time pre-key (OPK) β€” once consumed, it's gone. Provides deniability and replay protection. The server stores Bob's pre-key bundle and relays Alice's x3dh_init packet. It never derives SK β€” it never has enough information to.

Double Ratchet

After X3DH establishes the root key, every message advances the Double Ratchet:

Symmetric ratchet: Each message derives a unique message key from the current chain key via HKDF. The chain key advances. Message keys are used once and discarded.

DH ratchet: Every time the conversation direction changes (reply), a new DH exchange runs. This derives fresh root and chain keys, "healing" the session even if a message key was previously compromised.

The result:

  • Forward secrecy β€” compromising key N reveals nothing about keys 1…N-1
  • Post-compromise security β€” after a breach, the DH ratchet step re-randomises the session ### Key Persistence: The Hard Part

WebCrypto's CryptoKey objects can be marked non-exportable β€” the raw key material is never accessible to JavaScript. We store these directly in IndexedDB.

// Keys never leave as raw bytes
const keyPair = await crypto.subtle.generateKey(
  { name: "ECDH", namedCurve: "P-256" },
  false, // non-exportable
  ["deriveKey", "deriveBits"]
);
Enter fullscreen mode Exit fullscreen mode

Session state (root key, chain keys, ratchet DH keys, message counters) is serialised and persisted across page reloads. Each USID maps 1:1 to a stable cryptographic identity.


Safety Numbers: Closing the MITM Gap

Even with perfect E2EE, a malicious key server breaks everything. Safety Numbers are the solution.

Each user generates a P-256 ECDH identity key pair at registration. Both parties independently compute:

safetyNumber = SHA-256(
    sort_lex([
        SHA256(aliceUsid) + alicePubKey,
        SHA256(bobUsid)   + bobPubKey
    ])
)
// β†’ 12 groups of 5 digits, 60 digits total
Enter fullscreen mode Exit fullscreen mode

Lexicographic sorting ensures both parties derive an identical result regardless of who initiates. They compare the number out-of-band (voice call, in person). If they match β€” no MITM. If they differ β€” a key was substituted.

We also implement key change detection: the last-seen safety number is stored in localStorage. On every subsequent session, if the number changes, a prominent warning is shown before proceeding.

Without Safety Numbers (vulnerable):

  Alice                  Server (malicious)              Bob
    │── GET /public-key ──→│                               β”‚
    │←─ Mallory's key ─────│  ← server substitutes        β”‚
    β”‚  encrypts to Mallory  β”‚                               β”‚
    │── ciphertext ────────→│── re-encrypts to Bob ────────→│

With Safety Numbers (protected):

  Alice sees:  12345 67890 11111
  Bob   sees:  72891 23456 78901   ← mismatch β†’ attack caught βœ…
Enter fullscreen mode Exit fullscreen mode

P2P File Transfer via WebTorrent

Server involvement in file transfer is a massive privacy leak. Our solution: the server never touches files.

  1. Sender seeds the file using WebTorrent β€” BitTorrent running in the browser via WebRTC data channels
  2. A magnet URI is sent to the recipient through the encrypted message channel
  3. Recipient's browser leeches directly from the sender
  4. Public trackers handle peer discovery only β€” they never see file contents
  5. STUN/TURN handles NAT traversal for users behind symmetric NATs
Sender Browser                    Recipient Browser
  └── WebTorrent.seed(file)
        └── magnet URI (via encrypted WS)  ──→
                                              WebTorrent.download()
              WebRTC DataChannel ────────────────────────────→
              (direct P2P, server not involved)
Enter fullscreen mode Exit fullscreen mode

Live upload speed, download speed, progress, and seeding ratio are displayed in real time via WebTorrent's event API.


Database Architecture: Dual Isolation

Identity metadata and operational data live in separate SQLite databases, linked only by SHA-256(USID). Plaintext identity is never stored anywhere.

identity.db    β†’   name Β· email Β· hashed_usid
app.db         β†’   users Β· contacts Β· mailbox (hashed_usid only)
keys.db        β†’   X3DH public key bundles (hashed_usid only)
Enter fullscreen mode Exit fullscreen mode

Even if app.db is fully compromised, an attacker gets hashed USIDs and encrypted message payloads β€” no names, no emails, no identity. The databases are only correlated by SHA-256(USID), which is a one-way mapping.


Offline Mailbox: At-Most-Once Delivery

When a recipient is offline, the server queues the encrypted payload. On their next WebSocket reconnect, all queued messages are flushed and immediately deleted:

Sender β†’ Server (recipient offline)
  └── INSERT INTO mailbox (encrypted_payload)
  └── { type: "queued" }   ← sender sees clock icon

Recipient reconnects
  └── SELECT * FROM mailbox WHERE recipient = ?
  └── forward each message via WebSocket
  └── DELETE FROM mailbox WHERE recipient = ?
Enter fullscreen mode Exit fullscreen mode

The server stores only the already-encrypted payload β€” it cannot read the content. Deletion on flush ensures no permanent retention.


Cryptographic Primitives Summary

Primitive Algorithm Key Size
Symmetric Encryption AES-256-GCM 256 bits
Key Derivation HKDF-SHA256 256 bits
Hashing SHA-256 256 bits
Key Exchange X25519 (ECDH) 256 bits
Identity / Safety Numbers P-256 (ECDH) 256 bits
Message Authentication HMAC-SHA256 256 bits
Pre-Key Signing Ed25519 256 bits

Guarantees: forward secrecy Β· post-compromise security Β· HMAC authentication Β· deniability Β· pseudonymity Β· MITM detection


What We Got Wrong (and Fixed)

Curve inconsistency. Signal uses Curve25519 throughout. We used P-256 for identity keys because WebCrypto's ECDH is more consistent across browsers for that curve. The trade-off: P-256 is NIST-standardised (some distrust NIST curves post-Snowden). X25519 is used for X3DH key exchange where performance matters more.

OPK exhaustion. One-time pre-keys are consumed per session. If a user goes offline for a long time, their OPK supply can be exhausted. We added a /keys/replenish endpoint and OPK monitoring, but automatic client-side replenishment is on the roadmap.

WebRTC IP leaks. WebTorrent uses WebRTC, which can leak local and public IPs via STUN. Documented in our STRIDE threat model β€” mitigation requires a VPN or disabling WebRTC at the browser level.


Roadmap

  • [ ] Safety Number QR code scan
  • [ ] Post-quantum cryptography (CRYSTALS-Dilithium / SPHINCS+)
  • [ ] Multi-device session sync
  • [ ] Group messaging via Sender Keys
  • [ ] Voice & video (WebRTC)
  • [ ] Push notifications (Web Push / VAPID)

Try It / Contribute

πŸ”— GitHub: https://github.com/ABHIRAM-CREATOR06/Halonyx
🌐 Live: https://halonyx.onrender.com
πŸ“„ Threat Model: datathreat/datathreat.md β€” STRIDE analysis, 17 attack surfaces
πŸ“Š Benchmarks: benchmark/benchmark.md β€” X3DH, Double Ratchet, WebSocket, SQLite latencies

Self-host in three commands:

git clone https://github.com/ABHIRAM-CREATOR06/Halonyx.git
cd halonyx && npm install
npm start
Enter fullscreen mode Exit fullscreen mode

Happy to discuss any of the protocol decisions in the comments β€” especially the curve choice, the safety number design, or the dual-database isolation model.


Top comments (1)

Collapse
 
evans_owusu_6801c8d54ae89 profile image
Evans Owusu

Impressive build! Encryption at this level is something
I think about a lot with Yhuu (yhuu.life) β€” an anonymous
Q&A app where session privacy is everything.

We're not at Double Ratchet level but the core challenge
is similar: how do you make people genuinely trust that
their anonymity is protected?

What made you choose X3DH over simpler key exchange
approaches for this project?