Dev.to Security πŸ” Cybersecurity πŸ‘ 1 πŸ“– 6 min read

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

TL;DR: Halonyx is a self-hostable, end-to-end encrypted messenger built on the Signal Protocol (X3DH + Double Ratchet). The relay server architecturally cannot read your messages. Files transfer peer-to-peer via WebTorre

TL;DR: Halonyx is a self-hostable, end-to-end encrypted messenger built on the Signal Protocol (X3DH + Double Ratchet). The relay server architecturally cannot read your messages. Files transfer peer-to-peer via WebTorrent (BitTorrent over WebRTC). Identity is pseudonymous β€” no phone number, no email required.

πŸ”— 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)

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"]
);

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

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 βœ…

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)

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)

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 = ?

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

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.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.