Skip to content
Console

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.

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.

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.

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.

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.

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.

  1. The server sends a cleartext HELLO carrying its node_id, then a cleartext HANDSHAKE carrying max_protocol_version, noise.patterns and noise.suites. Keep both payloads: the prologue is built from them.

  2. The client checks max_protocol_version >= 3 on both sides, selects a pattern and suite, and builds the prologue.

  3. The client writes Noise message 1 with a payload of canonical_json({"binarize": <bool>, "encodings": [...]}) and sends it in a HANDSHAKE envelope naming the selection:

    {"noise": {"pattern": "XXpsk2", "suite": "25519_ChaChaPoly_SHA256", "msg": "<hex>"}}
  4. The server replies with {"noise": {"msg": "<hex>"}}. Read it. Under XXpsk2 the handshake is not finished yet, so write message 3 (an empty payload) and send it back in the same shape — pattern and suite are only sent on message 1. Under KKpsk0 the handshake completes here.

  5. 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.

  6. Send HELLO as the first transport message, carrying pubkey, the serialized session, and site_id.

Handshake messages are hex-encoded inside a JSON envelope. Transport messages are raw binary WebSocket frames.

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.

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.

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.