← Blog

March 29, 2026 · security · 10 min read

Noise XX in ten minutes

Noise is a framework for building cryptographic handshakes. Noise XX is the specific handshake Hoppr uses between peers over Bluetooth. Three messages. Four key agreements. Mutual authentication. Forward secrecy. No certificates, no session tickets, no 90-page specs. This post walks through it.

If you have set up TLS before, you already know the rough shape: two parties meet, exchange some public keys, derive a shared secret, and start talking in a symmetric cipher. Noise does the same job, but the parts are smaller, named, and visible. You can read a Noise pattern end to end and see exactly which key shook hands with which, and in what order.

Terminology

Four lowercase tokens do all the work in a Noise pattern. Memorise these and the rest reads itself.

e    ephemeral key pair (fresh for this handshake, discarded after)
s    static key pair    (long-term identity, lives in the keychain)
ee   Diffie-Hellman between initiator's e and responder's e
es   Diffie-Hellman between initiator's e and responder's s
se   Diffie-Hellman between initiator's s and responder's e
ss   Diffie-Hellman between initiator's s and responder's s (unused in XX)

Every token either sends a key or mixes a DH result into the running handshake state. The state is a pair of values called the chaining key and the handshake hash, updated through HKDF on every step. When a key becomes available for symmetric encryption, later tokens in the same message are automatically encrypted under it.

The three messages

initiator                    responder
   |   e                         |
   | --------------------------> |
   |   e, ee, s, es              |
   | <-------------------------- |
   |   s, se                     |
   | --------------------------> |
   |                             |
   |   [transport keys shared]   |

Message 1 (initiator to responder): e. The initiator generates a fresh Curve25519 ephemeral key pair and sends the public half in the clear. There is nothing encrypted here because there is no shared secret yet. The responder now knows the initiator's ephemeral public key, and that is all.

Message 2 (responder to initiator): e, ee, s, es. The responder generates its own ephemeral and sends the public half. It then performs DH(responder_e, initiator_e), mixing the result into the chaining key. That DH output is the first shared secret, so the remaining tokens in this message can be encrypted. Next it appends its static public key, encrypted under the key derived from ee. Finally it performs DH(responder_s, initiator_e) and mixes that in too. The initiator can decrypt the static key (using its own ephemeral private and the responder's ephemeral public to reconstruct the same DH result), and now knows the responder's long-term identity.

Message 3 (initiator to responder): s, se. The initiator sends its static public key, encrypted under the current chaining key. It then performs DH(initiator_s, responder_e) and mixes that in. The responder decrypts the static key and now knows the initiator's long-term identity. Both sides have mixed the same four DH results into the same chain, in the same order. Both sides derive the same pair of transport cipher states, one for each direction, from the final chaining key.

Why XX specifically

Noise has a naming grid. The first letter describes how the initiator's static key is handled, the second letter describes the responder's. X means the static key is transmitted during the handshake, encrypted. So XX means both sides authenticate with a static key, both sides learn the other's identity during the handshake, and neither side needs to know the other's static key up front. That fits a mesh messenger where peers meet strangers over BLE and want mutual authentication without any central directory.

Other patterns make other trade-offs. IK expects you to already know the responder's static key and hides the initiator's from passive observers. NK is one-sided, like a TLS session to a server. XX is the one that is symmetric, zero-prior-knowledge, and mutually authenticated, which is the shape you want for phone-to-phone.

Forward secrecy, without the mystique

The transport keys that protect your actual messages are derived from the DHs involving the ephemeral keys. The ephemerals never leave memory. They are generated at the start of the handshake and thrown away at the end. So if an attacker later seizes the device and extracts the long-term static key, they still cannot decrypt yesterday's session, because yesterday's session was keyed in part by a random number that no longer exists anywhere.

That is all forward secrecy is. Use something disposable in the key derivation, and dispose of it.

Replay resistance

Once the handshake finishes, every transport message is encrypted with ChaCha20-Poly1305 and carries a per-message nonce that increments on each send. The recipient tracks the expected nonce. A replayed packet has an old nonce, so either the receiver rejects it outright or the authentication tag fails to verify against the current keying material. A tampered packet fails the Poly1305 check and is dropped. Old captured ciphertext does not replay into a live session.

What it does not do

Noise XX authenticates a key. It does not authenticate a person. At the end of the handshake you know with certainty that the peer on the other end holds the static private key matching the static public key you now have. You do not know whose key it is. The name next to it in the app is whatever the peer typed in. That is what the QR fingerprint verification is for: you verify, out of band, that the static public key you are looking at belongs to the human sitting across from you. Do that once, and Noise XX keeps the identity pinned from then on.

Hoppr's one twist: prologue binding

Noise lets each handshake mix an arbitrary byte string called the prologue into the handshake hash before the first message is sent. It is not transmitted. Both sides must use the same bytes or the handshake fails at the first authentication check. Hoppr uses this to partition its two stacks.

Hoppr-native sessions mix in "hoppr-mesh/1". The public interop variant mixes in the empty prologue, which is the upstream default. A peer speaking the wrong variant will derive a different handshake hash, will fail to decrypt the first encrypted token, and the handshake aborts. One byte string decides who you are talking to. No version negotiation message, no downgrade path, no ambiguity.

That is the whole thing

Three messages. Four Curve25519 operations. One HKDF chain. A transport state both sides trust, derived without a certificate, a directory, or any third party. Everything else Hoppr does, the mesh routing, the gift-wrapped Nostr fallback, the reactions and edits and zaps, rides on top of this one handshake. If you want to read the spec itself, it is at noiseprotocol.org and it is shorter than most RFCs' table of contents.

Published by the Hoppr team. Mirrored as a NIP-23 long-form event (kind 30023) on the Hoppr publication key. Questions: hello@hoppr.chat.