Encryption & Transport
Trading requests to GoDark are end-to-end encrypted on the WebSocket channel. The bodies the rest of the docs describe — order.place, order.cancel, order.modify — are the logical requests. Actual wire bytes are AES-256-GCM ciphertext produced by an ephemeral session key negotiated with the gateway.
This page specifies that channel: the handshake, the key derivation, the framing, and the authenticated envelope. It exists so auditors and people porting the protocol to an unsupported language can verify the wire behaviour.
Use an SDK. Do not implement this by hand for production. The official SDKs (Python today, JavaScript in progress) perform the handshake, serialize protobuf, manage nonces, and encrypt/decrypt every frame. The spec below is reference material.
Scope
| Applies to | Does not apply to |
|---|---|
WebSocket trading ops (order.place, order.cancel, order.modify) | REST endpoints under /api/v1 (plain HTTPS + bearer) |
Authenticated stream channels (orders, positions) | Public WebSocket channels (instruments, transparency) |
| Market-data proxies (unauthenticated) |
REST is encrypted by TLS only. The extra layer described here is specific to authenticated WebSocket trading, where the gateway must not see plaintext orders in its logs or memory outside a well-defined decryption boundary.
Session lifecycle
A session is the unit of shared-key material. Every new WebSocket connection establishes a fresh session; the session ends when the socket closes (or a rekey is requested). The four steps, in order:
- Authenticate the socket with
op: "login"and a bearer token — see Authentication and WebSocket Trading.session.setupis rejected untilloginsucceeds. - Exchange ephemeral keys. The client generates a fresh X25519 keypair per session and sends the public half via
op: "session.setup". The gateway replies with its own fresh X25519 public key and an opaquesession_id. - Derive the session key locally. Both sides run X25519 ECDH on the pair and expand through HKDF-SHA256 into a 256-bit AES key (
K). The shared secret never traverses the wire. - Encrypt every trading op under
Kwith AES-256-GCM. Responses (ack,order_update,position_update) are returned encrypted under the sameKand decrypted client-side.
op: session.setup
Client → server, after op: "login" has succeeded on the same socket:
{
"id": "c-2",
"op": "session.setup",
"args": {
"client_ecdh_pubkey": "<base64(32 bytes)>"
}
}
Server response:
{
"id": "c-2",
"op": "session.setup",
"code": 0,
"data": {
"session_id": "9007199254740993",
"server_ecdh_pubkey": "<base64(32 bytes)>"
}
}
| Field | Type | Notes |
|---|---|---|
client_ecdh_pubkey | base64(32 bytes) | Raw X25519 public key per RFC 7748. Fresh per session. |
server_ecdh_pubkey | base64(32 bytes) | Gateway's ephemeral X25519 public key. Not a long-lived committee key; rotated per session. |
session_id | string (u64 decimal) | Opaque gateway-assigned identifier; must be treated as an integer for the nonce construction below. |
server_ecdh_pubkey is an ephemeral, session-scoped public key delivered in-band by the gateway. It is not a static committee identity key. The session key derived from it binds the channel to this specific (client_pk, server_pk) pair and provides forward secrecy: once the session ends and both sides discard their private halves, past traffic cannot be decrypted.
Rekey
The server may emit an unsolicited rekey_required event (for example before internal session_id exhaustion, or on a scheduled rotation). Clients MUST:
- Stop sending encrypted ops.
- Run
session.setupagain on the same socket with a fresh client keypair. - Reset the send-nonce counter to zero.
- Resume encrypted ops under the new key.
Subscriptions and login state are preserved across a rekey; only the symmetric key and nonce counter are rotated.
Key derivation
Given the two 32-byte X25519 public keys (client_pk, server_pk) and the client's private key:
ikm = X25519(client_private, server_pk) // 32-byte shared secret
salt = min(client_pk, server_pk) || max(client_pk, server_pk) // 64 bytes
info = "gdx-e2e-session-key-v1" // ASCII
K = HKDF-SHA256(ikm, salt=salt, info=info, length=32)
Rules:
min/maxis a byte-lexicographic comparison of the two raw 32-byte public keys. This makes the salt symmetric — both sides compute the same HKDF input without exchanging extra bytes.- If
ikm == 0x00…00(32 zero bytes), the peer sent a small-subgroup public key. Clients MUST abort the session withinvalid_public_key. infois a fixed ASCII constant. The-v1suffix is the protocol version — if the KDF ever changes,infochanges too, so old and new keys never collide.- Output length is fixed at 32 bytes → AES-256-GCM key. There is no separate MAC key; GCM provides authenticity.
Frame encryption
AES-256-GCM, 96-bit nonce, 128-bit authentication tag. Tag is appended to the ciphertext in standard GCM concatenated form:
wire_body = AES-GCM-Encrypt(K, nonce, plaintext, aad) // ciphertext || 16-byte tag
Nonce construction
nonce = session_id (8 bytes, big-endian)
|| counter (4 bytes, big-endian)
| Field | Size | Rules |
|---|---|---|
session_id | u64 big-endian | Same value returned by session.setup. Never repeats within a session's lifetime (it's the session itself). |
counter | u32 big-endian | Starts at 0 per session, incremented by exactly 1 per encrypted frame you send. Never reused. Receive-side is tracked separately. |
Counter overflow (> 2^32 - 1 frames in one session) is a hard error — the client MUST close the session and rekey. The SDK raises EncryptionError / nonce_overflow before sending an overflowed frame.
Associated data (AAD)
Every frame carries a protobuf-encoded header as AES-GCM AAD — authenticated but not encrypted. This binds the ciphertext to routing metadata the gateway needs to read before it can decrypt, and prevents a compromised intermediary from swapping headers between frames.
Requests — OrderHeader AAD (client → server):
| Field | Type | Notes |
|---|---|---|
user_uuid | bytes (16) | RFC 4122 UUID, big-endian. Must match the login-bound account. |
symbol_id | uint32 | Resolved instrument id. |
request_type | enum | place / cancel / modify. Must match the outer op. |
nonce | uint32 | Same counter used to build the GCM nonce. |
body_length | uint32 | len(plaintext) + 16 (ciphertext includes the auth tag). |
Responses — ResponseHeader AAD (server → client):
| Field | Type | Notes |
|---|---|---|
user_uuid | bytes (16) | Always the authenticated UUID — never trust the envelope copy, always re-derive from your session state. |
message_type | enum | ack / order_update / position_update / system_health. |
body_length | uint32 | len(ciphertext) including the 16-byte tag. |
nonce | uint32 | Server-side send counter for this direction. |
fencing_epoch | uint32 | Epoch for replay/fencing guards across reconnect. |
AAD bytes must be byte-identical to what the server produces for the same logical fields; if your protobuf encoding differs by even one byte, GCM tag verification fails with InvalidTag. In practice this means: use the generated protobuf classes from gdx-core/crates/gdx-wire/proto/, emit default-valued fields exactly as the canonical encoder does, and preserve field ordering.
Encrypted request envelope
After session.setup, trading ops carry their protobuf body encrypted. The logical args you would have sent are replaced with a single encrypted blob plus header metadata:
{
"id": "c-10",
"op": "order.place",
"args": {
"header": {
"symbol_id": 1,
"request_type": "place",
"nonce": 0,
"body_length": 158
},
"ciphertext": "<base64(AES-256-GCM ciphertext || 16-byte tag)>"
}
}
args.headeris the same struct the SDK serialized as AAD. It's echoed here in JSON (not protobuf) so the gateway can route without decrypting, and the client can sanity-check what the gateway saw.args.ciphertextis the AES-GCM output; plaintext is the protobuf-serializedEdgeSequencerRequest(the union ofPlaceOrderInput/CancelMessage/ModifyOrderInput). See each endpoint's page for the plaintext field list.user_uuidis intentionally omitted from the envelope. The gateway binds the session to thelogin-authenticated UUID; anyuser_uuida client might put on the wire is ignored.
Encrypted response envelope
Responses — both the synchronous ack for a trading op and streaming channel pushes — come back the same way:
{
"id": "c-10",
"op": "order.place",
"code": 0,
"data": {
"header": {
"message_type": "ack",
"body_length": 42,
"nonce": 0,
"fencing_epoch": 7
},
"ciphertext": "<base64 ciphertext || tag>"
}
}
Stream pushes (orders, positions) follow the same shape but with channel + type + seq at the top level instead of id + op + code — see WebSocket Trading for the un-encrypted envelope skeleton.
Plaintext of an ack is a protobuf NodeResponse; plaintext of a stream push is a protobuf SequencerToEdgeMessage. Parse, then map into the public types documented in Orders Channel and Positions Channel.
Security properties
| Property | How it's achieved |
|---|---|
| Confidentiality of order contents | AES-256-GCM under K. K derives from ECDH with ephemeral keys on both sides; the shared secret never traverses the wire. |
| Forward secrecy | X25519 private keys are ephemeral per session. Discarding them after session end makes recorded ciphertext undecryptable. |
| Integrity & authenticity | 128-bit GCM tag over ciphertext + AAD. Any mutation of the header, ciphertext, or tag fails verification with decrypt_failed. |
| Identity binding | AAD includes the user UUID; the gateway compares it to the login-authenticated UUID before accepting the frame. A stolen session cannot be replayed under a different account. |
| Replay resistance (within a session) | Receive-side nonce tracking rejects reused or out-of-order counters with nonce_out_of_order. |
| Replay resistance (across sessions) | session_id is part of the GCM nonce, so frames encrypted in one session can't decrypt in another even under the same key. Sessions are unique. |
| Reordering across reconnect | Response fencing_epoch increments on each server-side session rotation; a stale client rejects frames whose epoch has moved. |
What this channel does not give you
- Authenticity of the gateway's identity.
server_ecdh_pubkeyis an ephemeral key delivered in-band. Clients trust it transitively through TLS + bearer-token authentication to the gateway, not through a pinned long-lived public key. If an operator rotates the edge deployment, there is no key-pinning alarm. - Protection against a malicious gateway. A compromised gateway sees plaintext orders after decryption. The encryption layer protects orders from passive observers and from logs / traces; it does not make GoDark non-custodial with respect to the operator. Committee-held decryption keys and threshold schemes are a separate design (not this channel).
Error codes
All failures here surface through the standard error path — code non-zero on an op response, or a code-bearing event if the failure is asynchronous.
| Code | When |
|---|---|
session_required | Trading op sent before session.setup completed on this socket. |
session_expired | Gateway retired the session; client must rerun session.setup. |
invalid_public_key | Client or server public key was not 32 bytes, was the neutral element, or produced an all-zero shared secret. |
decrypt_failed | AES-GCM tag verification failed. Causes: wrong key, AAD mismatch (header bytes or identity), flipped bits, or mismatched session_id in the nonce. |
nonce_out_of_order | Receive-side nonce tracker saw a counter it won't accept (reuse, regression, or an unacceptable jump). |
nonce_overflow | Send counter would exceed 2^32 - 1 within a single session. Rekey. |
rekey_required | Gateway asked the client to rerun session.setup. Not an error per se; treated as a directive. |
See Error Codes for the cross-cutting list.
Reference implementation
The Python SDK under gdx-sdk/python/ is the reference. The interesting files:
| File | What to look at |
|---|---|
src/godark/_crypto.py | X25519 keypair generation, HKDF salt construction, build_gcm_nonce, encrypt / decrypt. |
src/godark/_session.py | CryptoSession lifecycle: generate_keypair → establish → encrypt_order / decrypt_push → reset. |
src/godark/_proto.py | build_order_header_aad, build_response_header_aad — the AAD byte layout the gateway expects. |
src/godark/client.py | _setup_ecdh_session, _send_encrypted_order, _handle_encrypted_push — the full request / response pipeline. |
The JavaScript SDK reference implementation is tracked on the feat/production-readiness branch and follows the same primitives.