v3 Noise Handshake
HiveMind protocol v3 makes a Noise handshake the only transport key
exchange. There is no legacy fallback, no pre-shared crypto_key, and no
unencrypted path: a connection that cannot complete the handshake is closed
with WebSocket code 1008.
What you need
Section titled “What you need”A client authenticates with the password it was issued. v3 derives the Noise pre-shared key from that password, so no separate crypto key exists or is required.
Get the password from GET /v1/clients/{id}/identify, or from an
identity file.
Patterns and suites
Section titled “Patterns and suites”| Kind | Value | Support |
|---|---|---|
| Pattern | XXpsk2 |
Required — first contact, trust on first use |
| Pattern | KKpsk0 |
Optional — both static keys known in advance |
| Suite | 25519_ChaChaPoly_SHA256 |
Required |
| Suite | 25519_AESGCM_SHA256 |
Optional — for peers with hardware AES or Web Crypto |
The full Noise protocol name is Noise_{pattern}_{suite}, so a first
connection negotiates Noise_XXpsk2_25519_ChaChaPoly_SHA256. Implement the two
required entries first; a hub always offers them.
Selection. Walk your own preference-ordered suite list and take the first
one the server also advertises — this keeps the choice independent of the
server’s ordering. Then pick the pattern: KKpsk0 when you already hold a
pinned static key for this peer and the server offers it, otherwise
XXpsk2. If neither pattern is on offer, there is no mutual option and the
connection fails.
Deriving the pre-shared key
Section titled “Deriving the pre-shared key”psk = argon2id( secret = password (UTF-8), salt = SHA-256(server node_id), time_cost = 3, memory_cost = 65536, // 64 MiB parallelism = 1, hash_length = 32,)Both peers must derive from identical inputs. The salt is the SHA-256 of the server’s node id, not the client’s.
Canonical JSON
Section titled “Canonical JSON”The prologue binds negotiation bytes that both peers must produce identically. Serialize with sorted keys, no whitespace, and UTF-8 output without ASCII escaping:
{"a":[1,2],"b":1,"c":{"y":null,"z":true}}In Python that is json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8"). Match those three properties in your
language and the bytes agree.
The prologue
Section titled “The prologue”prologue = canonical_json(server HELLO payload) + canonical_json(server HANDSHAKE payload) + utf8(protocol_name)Concatenated in that order, with no separators. This is the downgrade and tampering protection: if either peer computed the negotiation differently, the handshake fails rather than silently proceeding on weaker terms.
Sequence
Section titled “Sequence”-
The server sends a cleartext
HELLOcarrying itsnode_id, then a cleartextHANDSHAKEcarryingmax_protocol_version,noise.patternsandnoise.suites. Keep both payloads: the prologue is built from them. -
The client checks
max_protocol_version >= 3on both sides, selects a pattern and suite, and builds the prologue. -
The client writes Noise message 1 with a payload of
canonical_json({"binarize": <bool>, "encodings": [...]})and sends it in aHANDSHAKEenvelope naming the selection:{"noise": {"pattern": "XXpsk2", "suite": "25519_ChaChaPoly_SHA256", "msg": "<hex>"}} -
The server replies with
{"noise": {"msg": "<hex>"}}. Read it. UnderXXpsk2the handshake is not finished yet, so write message 3 (an empty payload) and send it back in the same shape —patternandsuiteare only sent on message 1. UnderKKpsk0the handshake completes here. -
Split into the two transport cipher states. Everything after this point is a Noise transport message. The server’s handshake payload may carry
{"encoding": "..."}, which fixes the JSON encoding for the session. -
Send
HELLOas the first transport message, carryingpubkey, the serializedsession, andsite_id.
Handshake messages are hex-encoded inside a JSON envelope. Transport messages are raw binary WebSocket frames.
Transport framing
Section titled “Transport framing”A Noise transport message caps at 65535 bytes, which is smaller than some bus messages. So the plaintext inside each transport message begins with a one-byte marker that says both how to decode it and where it sits in a possibly-chunked message:
| Marker | Meaning |
|---|---|
0x00 |
A complete UTF-8 JSON message |
0x01 |
A complete binary frame |
0x02 |
First chunk of a chunked JSON message |
0x03 |
First chunk of a chunked binary message |
0x04 |
A middle chunk |
0x05 |
The final chunk |
Anything that fits goes out as a single 0x00 or 0x01 frame. A larger
message is split at 65000 bytes per chunk into 0x02/0x03, then 0x04
repeated, then 0x05. Each chunk is its own Noise transport message.
Three rules an implementation has to hold:
- Serialize sends per message. The cipher state nonce counter is strictly sequential, so all chunks of one message must be encrypted and put on the wire contiguously. Interleaving two messages breaks the receiver.
- Treat any decryption failure as fatal. A transport message that fails to decrypt at the current counter means tampering, replay, or reordering. Drop the session; do not skip the frame and continue.
- Cap reassembly. Stop buffering at 32 MiB and drop the message. A chunk sequence that arrives out of order — a middle chunk with nothing open, or a new message starting while one is still buffered — is also fatal.
Trust on first use
Section titled “Trust on first use”After an XXpsk2 handshake, pin the server’s static public key and
persist it with the client identity. Later connections then negotiate
KKpsk0 against the pinned key, which authenticates the server before any
payload is exchanged. Pin under the server’s node_id, so two hubs announcing the
same default id do not collide.
A pinned key that stops matching is a failure, not a prompt to re-pin: refuse
the connection and make the operator drop the pin deliberately. The one
exception is a failed KKpsk0 handshake. KK requires each side to hold the
other’s static key, but the client selects it knowing only that it pinned the
server’s — so the failure is just as likely to mean the server never had the
client’s key. Drop the stale pin there and let the next attempt fall back to
XXpsk2.
Your own static key must also be persisted. Regenerating it on each start makes every connection look like a new peer and defeats pinning in both directions.
Implementing in a new language
Section titled “Implementing in a new language”You need a Noise Protocol Framework implementation supporting XXpsk2 (and
KKpsk0 if you want pinned-key handshakes) with X25519, ChaCha20-Poly1305 and
SHA-256, plus argon2id. Mature libraries exist for every language Thalovant
ships an SDK for; do not write the primitives yourself.
Budget for argon2id before you start: 64 MiB of working memory is a hard requirement of the derivation, which rules out most microcontrollers unless the pre-shared key is derived off-device and provisioned.
Interoperability rests on four things being byte-exact: the canonical JSON, the prologue concatenation order, the argon2id parameters, and the transport frame markers. Test against a real hub before shipping — a handshake that fails closed is easy to spot, but a prologue mismatch fails in exactly the same way as a wrong password, so verify the success path rather than only the failure.