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 toDoes 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:

  1. Authenticate the socket with op: "login" and a bearer token — see Authentication and WebSocket Trading. session.setup is rejected until login succeeds.
  2. 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 opaque session_id.
  3. 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.
  4. Encrypt every trading op under K with AES-256-GCM. Responses (ack, order_update, position_update) are returned encrypted under the same K and 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)>"
  }
}
FieldTypeNotes
client_ecdh_pubkeybase64(32 bytes)Raw X25519 public key per RFC 7748. Fresh per session.
server_ecdh_pubkeybase64(32 bytes)Gateway's ephemeral X25519 public key. Not a long-lived committee key; rotated per session.
session_idstring (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:

  1. Stop sending encrypted ops.
  2. Run session.setup again on the same socket with a fresh client keypair.
  3. Reset the send-nonce counter to zero.
  4. 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:

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)
FieldSizeRules
session_idu64 big-endianSame value returned by session.setup. Never repeats within a session's lifetime (it's the session itself).
counteru32 big-endianStarts 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):

FieldTypeNotes
user_uuidbytes (16)RFC 4122 UUID, big-endian. Must match the login-bound account.
symbol_iduint32Resolved instrument id.
request_typeenumplace / cancel / modify. Must match the outer op.
nonceuint32Same counter used to build the GCM nonce.
body_lengthuint32len(plaintext) + 16 (ciphertext includes the auth tag).

Responses — ResponseHeader AAD (server → client):

FieldTypeNotes
user_uuidbytes (16)Always the authenticated UUID — never trust the envelope copy, always re-derive from your session state.
message_typeenumack / order_update / position_update / system_health.
body_lengthuint32len(ciphertext) including the 16-byte tag.
nonceuint32Server-side send counter for this direction.
fencing_epochuint32Epoch 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)>"
  }
}

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

PropertyHow it's achieved
Confidentiality of order contentsAES-256-GCM under K. K derives from ECDH with ephemeral keys on both sides; the shared secret never traverses the wire.
Forward secrecyX25519 private keys are ephemeral per session. Discarding them after session end makes recorded ciphertext undecryptable.
Integrity & authenticity128-bit GCM tag over ciphertext + AAD. Any mutation of the header, ciphertext, or tag fails verification with decrypt_failed.
Identity bindingAAD 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 reconnectResponse 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

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.

CodeWhen
session_requiredTrading op sent before session.setup completed on this socket.
session_expiredGateway retired the session; client must rerun session.setup.
invalid_public_keyClient or server public key was not 32 bytes, was the neutral element, or produced an all-zero shared secret.
decrypt_failedAES-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_orderReceive-side nonce tracker saw a counter it won't accept (reuse, regression, or an unacceptable jump).
nonce_overflowSend counter would exceed 2^32 - 1 within a single session. Rekey.
rekey_requiredGateway 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:

FileWhat to look at
src/godark/_crypto.pyX25519 keypair generation, HKDF salt construction, build_gcm_nonce, encrypt / decrypt.
src/godark/_session.pyCryptoSession lifecycle: generate_keypairestablishencrypt_order / decrypt_pushreset.
src/godark/_proto.pybuild_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.