Most developers think TLS means secure. It doesn't.
TLS protects the transport. The server still decides whose
public key you encrypt to. A malic...
For further actions, you may consider blocking this person and/or reporting abuse
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!