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.
Why Build Another Messenger?
Most "secure messenger" projects I've seen fall into one of two categories:
- They use TLS and call it "encrypted" β which protects the transport, not the content from the server
- 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_initpacket. It never derivesSKβ 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.
- Sender seeds the file using WebTorrent β BitTorrent running in the browser via WebRTC data channels
- A magnet URI is sent to the recipient through the encrypted message channel
- Recipient's browser leeches directly from the sender
- Public trackers handle peer discovery only β they never see file contents
- 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.
Top comments (11)
Implementing X3DH from first principles rather than treating a crypto library as a black box takes real discipline, and this article makes the four DH operations genuinely readable. A few things stood out: the safety number design using lexicographic sorting so both parties derive an identical result regardless of initiator order, the dual SQLite database isolation where identity and operational data are linked only by a one-way hash, and the offline mailbox with at-most-once delivery. All three reflect architectural thinking that goes beyond "add encryption and call it secure."
I have been building something called the Opportunity Skill, which includes agent-to-agent communication where your AI agent discovers, evaluates, and initiates contact with other people's agents on your behalf. Its security at the message layer is still incomplete. I would be interested in exploring whether Halonyx's model of architecturally excluding the server from reading message content could strengthen that channel in a future version.
Thanks for the detailed read π genuinely appreciate it. The
lexicographic sort for safety numbers was one of those details
that took way longer to get right than I expected. Sounds trivial
on paper, but both parties have to land on the exact same result
independently, with zero coordination, so even small ordering
mistakes break the whole thing silently.
On the agent-to-agent angle: that's a really interesting
application actually. X3DH was more or less built for this exact
scenario. The protocol assumes Bob might be completely offline
when Alice reaches out, which is the whole reason pre-key bundles
exist. Alice just fetches Bob's signed pre-key and a one-time
pre-key ahead of time, runs the four DH operations on her end, and
gets a shared secret without needing Bob present at all. If your
agents are finding and contacting each other asynchronously, that
"set up a secure channel with someone who isn't even there right
now" property is basically the core problem X3DH was designed to
solve.
Where it gets trickier for agents specifically is identity
verification. Safety Numbers work because two humans can just read
60 digits to each other on a call and trust their own ears. Agents
don't have that you'd need some other out-of-band trust anchor
instead, like signed identity assertions or a registry of some
kind, to get an equivalent guard against MITM.
Anyway, curious to see where you take the Opportunity Skill if you
keep building on it.
The curve mix you flagged as the thing you got wrong is imo actually a forced trade-off between two of your own guarantees, not really a mistake. The whole reason the P-256 identity keys are interesting is that you kept them non-exportable in WebCrypto, so the raw material never touches JS. But SubtleCrypto only gave you the NIST curves for ECDH for years, X25519 and Ed25519 support is recent and still uneven across engines. So the moment you want a real non-exportable CryptoKey you're basically pushed onto P-256, because doing X25519 identity keys means pulling in noble or libsodium, and now the key lives in JS memory and you've lost the exact isolation property that made the design worth writing about. Your two goals, Signal's curves everywhere vs keys the runtime physically can't export, are quietly in tension, and WebCrypto is what breaks the tie. Worth saying the NIST distrust is more "we can't prove the seed constants are clean" than "it's known broken", so a P-256 identity key sitting next to X25519 for the actual X3DH exchange isn't a hole, it's mostly extra surface and cognitive load from running more primitives than you strictly need.
The nice part is this is aging out. X25519/Ed25519 in WebCrypto is shipping across most engines now, so at some point you can move identity onto X25519 and still keep the non-exportable guarantee, which closes the inconsistency w/o giving anything up. fwiw the OPK exhaustion and the WebRTC STUN leak being in the "what we got wrong" section instead of buried is the part that made me trust the rest of it.
This is exactly the framing I was missing when I wrote that section. I knew something felt off about calling it a mistake, just couldn't say why. Now I can: non-exportable CryptoKey and Signal's curve consistency are two separate goals, and it's WebCrypto's historical ECDH support that forces you to pick between them. Pulling in noble or libsodium for X25519 identity keys does give up the isolation property that made the design worth having. Keys the runtime physically can't export is just a stronger guarantee than "we pinky-swear not to serialize them."
The NIST framing is also more precise than what I had in my head. "Can't prove the seed constants are clean" isn't "known broken," different claim, and the gap matters when you're reasoning about risk in a non-production context like this.
Also good to know about the X25519/Ed25519 WebCrypto update, I'll track that. Moving identity onto X25519 once engines agree closes the inconsistency without losing the non-exportable guarantee. Cleaner than either of the trade-offs I was stuck between.
And appreciate the callout on OPK exhaustion and the STUN leak being visible rather than buried, that was a deliberate call, good to know it reads as intended.
Yeah exactly, and honestly the non-production framing is the right place to be. Most projects at this stage either overclaim production-ready or quietly hide the sharp edges, so shipping the STRIDE model and the benchmarks right next to the honest caveats is what makes it land. One heads-up for whenever you do the X25519 move: gate it behind runtime feature detection rather than assuming it, probe crypto.subtle for X25519 and keep the P-256 path as a fallback, otherwise you quietly break identity for anyone on an engine that hasn't shipped it yet. The long-lived identity key is what makes the backwards-compat fiddly, but you clearly already think this way. Good read, nice work on it.
Good catch on the feature detection point. Probing crypto.subtle for X25519 and keeping P-256 as a fallback rather than assuming engine parity makes sense, especially for identity keys. A silent breakage there isn't a per-session problem, it breaks identity, which is a lot worse. Will build it that way when the migration happens. Thanks for the read.
The "server hands you its own key" attack vector is the one most devs never think about. TLS protecting transport vs. actually verifying who you're encrypting to β that's the gap most E2EE apps quietly fall through.
Exactly, and the frustrating part is most apps market themselves as E2EE without ever addressing it. TLS is table stakes, not a guarantee. The key server is the attack surface and Safety Numbers are basically the only practical answer that doesn't require trusting the infrastructure.
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?
Good question! Short answer, X3DH was specifically built for
asynchronous first contact, and that's something simpler DH
approaches just aren't great at.
With plain ECDH, both parties basically need to be online at the
same time to exchange keys. X3DH gets around that by having Bob
pre-upload a bundle (identity key, signed pre-key, a few one-time
pre-keys) to the server ahead of time. So, when Alice wants to
start talking to him, she doesn't need him present at all, that is she
just grabs his bundle, runs four DH operations on her side, and
ends up with the same shared secret Bob will derive once he comes
online and processes her message.
The other big reason is forward secrecy right from message one.
Since ephemeral keys get mixed into those DH operations alongside
the long-term identity keys, even that very first message is
protected, so if a long-term key gets compromised down the line,
it doesn't unravel that initial exchange.
For something like an anonymous Q&A app where both people are
probably online at the same time anyway, the offline initiation
part might matter less day-to-day. But honestly the forward
secrecy piece still feels worth having, especially given how much
trust users are putting into "this session is actually private."
This is awesome!