Skip to content

Secure datagram channel new

The runner's UDP plane is plaintext and unauthenticated by default. The secure channel turns it into an AEAD-sealed channel with a stateless connect cookie in front of it — opt-in, additive, and off unless you turn it on.

While it is off, the wire, the packet path, and the ABI are unchanged: a build with the feature present but disabled is byte-for-byte the build without it, and interoperates with peers that have never heard of it. That property is load-bearing — it is what makes the rollout below safe, and the conformance suite asserts it directly rather than taking it on trust:

W.18a with the feature off, a session connects and replicates exactly as before
W.18b ... with every secure counter still zero (the decorator was never built)
W.18c ... and neither end claims a sealed session

Verified against: reference/src/transport_secure.{h,cpp}, reference/include/lattice/lattice.h:487-599, reference/README.md §"Secure channel (Task Core-H2)".

Read this before you rely on it

Authenticated only when you BIND it — and the binding is a separate step

On its own the key exchange proves nothing about who you reached. An active on-path attacker can complete the handshake twice — once with each side — and both ends see a perfectly healthy sealed channel.

Channel binding closes that, and it is opt-in in the same way the channel itself is:

  • The server holds a static X25519 key and publishes its public half through the director (lattice_secure_set_server_key).
  • The joiner receives that key over TLS in its /resolve response and installs it (lattice_secure_bind_server_key).
  • Both ends mix the resulting shared value into the session key, so an attacker who does not hold the static secret derives a different key and fails the key confirmation that already happens. No new detection step was added; the existing one stopped being forgeable.

What you get with binding on: confidentiality, integrity, replay and flood resistance — and the assurance that the endpoint is the one the director published for this session.

What you get without it: everything except that last clause. A session where the joiner never received a server key is still relayable, which is why the migration below ends with lattice_secure_require_binding.

AC.1 a bound handshake completes and both ends count it as bound
AC.2 an on-path relay — an endpoint that does not hold the expected static key — cannot complete a bound handshake, and fails at key confirmation
AC.6 a bound client never falls back to plaintext, however long the silence

Verified against: reference/src/transport_secure.h ("CHANNEL BINDING"), reference/tests/conformance.cpp PART AC.

The claim is exactly as true as these three conditions

An independent review could not construct a relay that escapes the binding, and found the reduction tighter than first stated: ES = X25519(client_ephemeral, server_static) is the only handshake input a relay cannot obtain, and it is mandatory in the derivation. But that conclusion rests on three things being true of your deployment, and one of them is not something the core can enforce:

  1. The client gets the static key authentically, never from the wire. It comes from /resolve over TLS and is installed with lattice_secure_bind_server_key; the client uses the installed key and never anything carried in SEC_ACCEPT.
  2. The key is PER-SERVER, never fleet-wide. This is the one that will quietly falsify everything above if you get it wrong. persistent_server mints a fresh key per boot, per instance, and the director stores it per instance. If you ever publish one key for a whole fleet, any member of that fleet can take the relay position against any other — and the sentence "an on-path attacker cannot complete the handshake" simply stops being true, with nothing failing to tell you.
  3. The client is actually bound. A joiner that received no key gets the old unauthenticated exchange. lattice_secure_require_binding closes that from the server side, and is the last step of the rollout for exactly this reason.

Tokens authenticate the client. They cannot authenticate the channel.

It is tempting to think verifying the joiner's session token is enough, or that mixing the token into the key confirmation would bind the channel. It is not, and the reason is worth stating plainly because the intuition is so reasonable:

The token is a bearer credential the client sends over the channel. In a relayed handshake the client's "server" is the attacker, so the attacker decrypts the token, reads every byte of it, and forwards it to the real server — which finds it genuine, because it is. Any value derived from the token is therefore computable by the attacker on both legs.

So the two mechanisms answer different questions and neither substitutes for the other: the token validation seam answers "is this player allowed in?", and channel binding answers "am I talking to the server I was sent to?". Turn on both.

What you get when it is on

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: SEC_HELLO (client pubkey)
  Note over S: NO state, NO scalar mult yet
  S-->>C: SEC_COOKIE (keyed MAC over source addr + pubkey + epoch + salt)
  C->>S: SEC_HELLO + echoed cookie
  Note over S: cookie verified → now commit state,<br/>X25519 key agreement
  S-->>C: SEC_ACCEPT (key confirmation, assigned peer id)
  C->>S: SEC_SEALED … (ChaCha20-Poly1305, per-direction nonces, replay window)
  • A DTLS-style stateless cookie exchange before any key agreement. The server answers a first-contact hello with a keyed-MAC challenge bound to the client's source address, and commits no memory and no scalar multiplication until the client echoes it. A spoofed-source flood therefore cannot exhaust server state or make it do public-key work.
  • X25519 key agreement bound to that cookie exchange, with explicit key confirmation.
  • Every subsequent datagram sealed with ChaCha20-Poly1305 under per-direction nonces and checked against a sliding replay window. A tampered, replayed, or too-old datagram is dropped silently and counted — it never disconnects the session, because one injected packet must not become a denial of service against the real peer.

It is implemented as an ITransport decorator wrapping whichever transport the runner selected, so udp, nat, and loopback all get it with no per-transport work.

Verified against: lattice.h:497-506; reference/src/transport_secure.cpp (the SEC_HELLO 0xC1 / SEC_COOKIE 0xC2 / SEC_ACCEPT 0xC3 / SEC_SEALED 0xC4 codes, chosen outside the runner's MsgType range so optional mode tells secure from plaintext by the first byte with no negotiation).

The three modes

Mode Server behaviour Client behaviour
off (default) The legacy plaintext plane, unchanged. The decorator is never constructed. Same.
optional Accepts both planes, per connection. A legacy peer still gets through. Attempts the secure handshake, then falls back to plaintext after fallback_ms.
required Refuses plaintext in both directions. Refuses plaintext in both directions.

Verified against: lattice.h:521-525 (lattice_secure_mode).

Turning it on

Two seams, and a programmatic call always beats the environment — so a host that sets its policy in code cannot have it weakened by whatever environment it happens to be launched in.

Environment

Variable Values Meaning
LATTICE_SECURE off (default) / optional / required The mode. Unset or unrecognised ⇒ off.
LATTICE_SECURE_FALLBACK_MS milliseconds (default 1500) How long an optional client waits for the secure handshake before connecting in plaintext.
LATTICE_SECURE_REQUIRE_TOKEN 1 In required mode, deliver a peer's data only once the token seam has accepted it.

C ABI

lattice_secure_config cfg = {0};
cfg.mode          = LATTICE_SECURE_REQUIRED;
cfg.require_token = 1;
cfg.validate      = my_validate_session_token;   /* see the seam below */
cfg.validate_user = my_state;

/* MUST be called BEFORE listen()/connect() — that is where the transport is built. */
lattice_runner_configure_secure(runner, &cfg);

Passing mode == LATTICE_SECURE_OFF explicitly pins the feature off, overriding the environment.

Verified against: lattice.h:540-565.

Unity (C#)

runner.ConfigureSecure(new SecureOptions(LatticeSecureMode.Required)
{
    RequireToken  = true,
    ValidateToken = (token, peer) => MySessionTokens.Verify(token, peer),
});

// Shorthand when you have no token seam yet:
runner.ConfigureSecure(LatticeSecureMode.Optional);

Call it before the transport is built — that is, before Listen/Connect, and so before a StartGame that passes a port, since that listens. A client may configure between StartGame and Connect; only the listen/connect call closes the window. Calling it afterwards throws LatticeException (ErrInvalidArg) rather than silently leaving you believing the channel is sealed.

NetworkRunner.IsSecureActive is an honest "is my traffic actually sealed right now" — it is false when the feature is off, still handshaking, or the peer fell back to plaintext — not "did I ask for encryption".

Read the counters with runner.GetSecureStats(), which returns the LatticeSecureStats struct described under Observability.

Stats read Off until the transport exists

The counters live on the transport decorator, which is not built until Listen/Connect. A runner that has been configured but has not yet listened or connected reports mode Off and all-zero counters. That is not your ConfigureSecure being ignored — verify the channel with IsSecureActive once the session is up, not with the stats beforehand.

The interop types — LatticeSecureMode, LatticeSecureConfig, LatticeSecureStats, and the LatticeSecureTokenValidateFn delegate — live in Runtime/Interop/LatticeSecure.cs; you normally touch only SecureOptions and LatticeSecureMode.

Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/NetworkRunner.Secure.cs (ConfigureSecure, GetSecureStats, IsSecureActive, SecureOverheadBytes, SecureOptions), Runtime/Interop/LatticeSecure.cs (the marshalled types), bindings/lattice-unity/README.md:87-95.

Godot (GDScript)

runner.configure_secure(
    LatticeRunner.SECURE_REQUIRED,
    0,                                  # fallback_ms: 0 => the core default (1500)
    true,                               # require_token
    _validate_session_token,            # a Callable, server-side
)
runner.start_game(LatticeRunner.MODE_HOST, "9100")

func _validate_session_token(token: PackedByteArray, peer: int) -> bool:
    return MySessionTokens.verify(token, peer)

Call it before listen()/connect_to(). Calling it before start_game() also works and is the recommended shape — the config is stashed and applied the moment the runner exists, which is the only way to secure a start_game(mode, "host:port") that opens the link itself. Configuring after the transport is built returns false with an explicit error rather than silently doing nothing.

runner.is_secure_active() is the honest "is my traffic sealed right now"; runner.get_secure_stats() returns the counter table below as a Dictionary (keyed by the same field names, plus mode_str); runner.get_secure_overhead_bytes() is the constant 26.

The validator is fail-closed, and must not re-enter the runner

The Callable runs on the server, inside lattice_runner_tick() — the same main thread as _physics_process, which is what makes calling into GDScript from there safe. Two rules:

  • Return a real bool. An invalid Callable, a script error, or any non-boolean return refuses the peer. A validator that did not answer has not approved anyone.
  • Do not call back into the runner from inside it. The ABI forbids it, and the binding cannot enforce that on your behalf.

Verified against: bindings/lattice-godot/src/lattice_runner.cpp (_on_secure_validate), lattice_glue.h (glue_secure_admit, the fail-closed policy, asserted in compile_check/glue_test_main.cpp §4h).

Rolling it out: off → optional → required

Do not flip a live fleet straight to required. Servers in required mode refuse plaintext, so every client that has not shipped the upgrade is disconnected at once. The staged order exists because optional is deliberately bidirectionally compatible:

  1. Servers → optional. They now accept both planes. No client notices; legacy clients keep connecting in plaintext — asserted by conformance W.22a ("an OPTIONAL server serves a plaintext (pre-Core-H2) client unchanged") and W.13 ("OPTIONAL mode passes a legacy plaintext peer straight through").
  2. Ship the SDKs, then clients → optional. Upgraded clients negotiate secure against upgraded servers and fall back to plaintext against anything else — conformance W.23a ("an OPTIONAL client falls back to plaintext when the server ignores the handshake") and W.14a ("... while a secure peer on the same server still gets the sealed handshake"). Still no forced break.
  3. Wait, and watch the counters. Roll until plaintext_passed (below) has gone to zero across the fleet. That is your evidence that no client still needs the legacy plane — not a guess, a measurement.
  4. Servers → required. Now safe: every client speaks secure. Plaintext is refused and counted in plaintext_dropped.
  5. Clients → required too. See the downgrade note below — this step is not optional if you care about the guarantee.
  6. Servers publish a key; clients bind to it. Now the channel knows who it reached. See binding the handshake — this step is independent of 1–5 and can start as soon as your servers are on step 1.
  7. Servers → require_binding. The last door. Do it when lattice_secure_binding_stats shows every session arriving bound; until then it refuses the clients that have not caught up.

A client left on optional can be downgraded

optional clients fall back to plaintext after fallback_ms. An active attacker who simply blocks the secure handshake until that deadline expires gets the client to connect in plaintext of its own accord — no crypto broken, just a stalled packet. Step 5 is what closes this: a required client refuses to fall back, so a suppressed handshake becomes a failed connection instead of a silent downgrade.
Verified against: conformance W.24 ("REQUIRED refuses to fall back: no plaintext session is ever formed"), W.14b ("plaintext forged as that sealed peer is refused, not downgraded").

require_token with no validator refuses every peer

Setting require_token while validate is NULL means there is nothing to check tokens with, so every peer is refused. That fail-closed choice is the core's and it is deliberate — but it will look like a total connectivity outage if you enable the flag before wiring the seam.
Verified against: lattice.h:544-546; conformance W.17a ("require_token with no validator refuses every peer (fail-closed)") and W.17b ("... and says so in the counters").

Binding the handshake to the real server

Steps 1–5 give you a sealed channel. They do not tell the joiner which server sealed it. These two calls do, and they are independent of the mode ladder above — you can add them at any point after step 1.

/* SERVER, at boot — before listen(). */
uint8_t server_pub[32];
lattice_secure_set_server_key(r, NULL, server_pub);   /* NULL secret => generate one */
/* Publish server_pub (base64url) to the director as `server_key`, via /fleet/register
 * for a dedicated instance or /matchmake for a listen-server host. */

/* JOINER, at connect — before connect(). */
uint8_t key[32];                       /* the `server_key` from the /resolve response */
lattice_secure_bind_server_key(r, key);

From the moment a client installs a server key it requires the server to prove possession, and it will not fall back to plaintext — silence from a bound server is indistinguishable from an attacker suppressing the handshake, so the fallback is simply not available.

Why tampering with the handshake's bound flag is a denial of service, not a downgrade

SEC_HELLO carries a flag telling the server to mix, and it is not covered by the connect cookie's MAC. All four tamper directions were checked on the wire and every one fails closed — but the reason is worth stating precisely, because "the flag is not authenticated" invites the wrong conclusion:

  • The mixing decision is local. A client mixes because it holds a key, never because of the flag, so clearing the flag in flight cannot make a client skip the binding — it only makes the two sides derive different keys, and the handshake fails.
  • A client that asks for binding we cannot do is REFUSED, not quietly served unbound. That check is what does the real work here; without it, stripping the flag would get an attacker a session.

If either property ever changes, the flag needs MAC coverage. It is safe today because of those two rules, not because it does not matter.

The director stores and forwards the public half only. It holds no secret, derives nothing from the value, and a leak of its storage reveals public keys.

uint64_t bound = 0, refused = 0;
lattice_secure_binding_stats(r, &bound, &refused);   /* bound > 0 => this session is bound */

Once enough clients are getting the key, close the door on the rest:

lattice_secure_require_binding(r, 1);   /* server: refuse clients that do not bind */

Let the evidence choose the policy

A good client rule is: bind and require when the director gave you a server_key, stay optional when it did not. The presence of a key is proof the server runs a build that speaks this protocol, so you get the strong guarantee wherever it is available without turning a security improvement into an outage for everyone still on the old build. That is exactly what the Battleship sample does — see the case study.

Verified against: reference/tests/conformance.cpp PART AC — AC.3 (a server with no static key refuses a binding client rather than quietly serving it unbound), AC.4 (require-binding refuses an unbound client), AC.5 (a server offering binding still serves unbound clients, which is what makes the migration possible).

The token validation seam

This answers "is this player allowed in?" — a different question from "am I talking to the right server?", which is channel binding. Wire both. It runs on the server, inside the handshake, with the token bytes the peer passed to lattice_runner_connect — already decrypted and authenticated, so they are exactly what that peer sent.

You probably want the built-in validator, not this callback

Verifying an Ed25519-signed JWT is code you do not want every project reinventing, so the core ships it. Hand it the director's JWKS and it does the rest:

/* `jwks` is the body of https://<director>/.well-known/jwks.json, fetched at boot.
 * lattice_fetch_* will fetch it for you without needing a runner. */
uint32_t installed = 0, refused = 0;
lattice_secure_set_token_key_jwks(r, jwks, jwks_len,
                                  "lattice-director", "lattice-gameserver",
                                  &installed, &refused);

It checks, in order: three base64url segments, alg exactly EdDSA (so none is refused like any other value), a crit header refused outright, the Ed25519 signature over header.payload, and only then the claims — exp in the future, nbf (if present) in the past, iss and aud as configured. No clock leeway, on either bound. If leeway is ever added it belongs on nbf and never on exp: one forgives a client whose clock runs ahead and would otherwise be refused at the start of its token's life, the other silently extends the life of every token you ever issued. With several keys in the JWKS a token is accepted if any of them verifies; the header's kid only decides which is tried first, so the answer does not depend on the order keys appear in the document.

Log refused. It counts entries that claimed to be Ed25519 signing keys and were not usable — trusting fewer keys than you think you do is how a rotation fails quietly.

Verified against: conformance PART AB — 74 JWT and 41 JWKS cases written from RFC 7515 / 7519 / 8037 before the implementation existed.

If you need something else — a token format that is not a JWT, an allow-list, a call into your own account service — the raw seam is still there:

typedef int (*lattice_secure_token_validate_fn)(void* user, const uint8_t* token,
                                                uint32_t token_len, uint32_t peer);

Return non-zero to admit the peer; zero drops its session and increments tokens_rejected. peer is the connection id the authority knows this client by — the same value that appears in owner fields, and the id the client itself will read back as its local player.

This is where the director's session token gets verified. The matchmaking flow hands a client an Ed25519-signed session token bound to {session, handle, endpoint, player}; verifying that signature here — offline, against the director's JWKS — is what proves the peer is who it claims to be.

Callback discipline

It runs on the tick thread inside lattice_runner_tick(). It must not throw, and must not call back into the runner.
Verified against: lattice.h:527-538.

MTU: sealed datagrams are bigger

uint32_t overhead = lattice_secure_overhead_bytes();   /* 26 */

A sealed datagram costs 26 bytes more than the plaintext datagram it carries — outer type, sequence, Poly1305 tag, inner type. Anything that fragments to a safe MTU must subtract this from its payload budget when the channel is on, or you will start fragmenting one byte past where you meant to.

The value is constant and safe to read at any time, with or without a runner (Unity: NetworkRunner.SecureOverheadBytes).

Verified against: lattice.h:596-599; conformance W.0 ("sealed-datagram overhead is the documented 26 bytes (outer+seq+tag+inner)") and W.5 ("an application datagram round-trips sealed (and is exactly +26 bytes)").

Observability

Every rejection path lands in exactly one counter, because a channel that silently drops everything and a channel that is simply idle look identical without them.

lattice_secure_stats_t s;
lattice_runner_secure_stats(runner, &s);
Counter What it tells you
handshakes_ok Sessions that reached the sealed state.
cookies_issued / cookies_rejected Stateless challenges sent; echoed cookies that failed the MAC or epoch window.
hello_no_cookie First-contact hellos answered statelessly.
decrypt_failures AEAD tag mismatch — tampered, truncated, or wrong key.
replays_rejected Valid tag, but a duplicate or too-old sequence.
plaintext_passed optional mode letting a legacy peer through. Watch this during rollout — it must reach zero before step 4.
plaintext_dropped required mode refusing an unencrypted datagram.
tokens_rejected The validation seam said no (or was required and absent).
sealed_tx / sealed_rx Sealed datagrams sent / received.
malformed_dropped Wrong version/magic/length — rejected before any crypto ran.
mode / active The live mode; active is 1 once at least one sealed session exists.

Channel binding has its own two counters, read through a separate call because lattice_secure_stats_t is size-frozen:

uint64_t bound = 0, refused = 0;
lattice_secure_binding_stats(runner, &bound, &refused);
Counter What it tells you
bound Handshakes completed with the binding. On a client this is the "I reached the server the director published" signal.
refused Peers turned away over binding policy — a client asked us to bind and we hold no key, or we require binding and it did not.

lattice_runner_secure_active() is the same signal as active, as a plain int.

mode reads Off until the transport is built

The counters describe the live secure layer, and that layer is not constructed until listen()/connect(). Between configuring and connecting, the snapshot is still all-zero and mode reads Off — that is "not built yet", not "your policy was ignored". If the policy had genuinely been rejected you would have got an error from the configure call itself.
Verified against: reference/src/runner.cpp:393-397 (secure_stats returns early when the decorator does not exist); asserted in compile_check/glue_test_main.cpp §4h.

Verified against: lattice.h:567-594.

Binding support

Surface Configure Stats / active Token seam
C ABI ✅ lattice_runner_configure_secure ✅ ✅
Unity (C#) ✅ ConfigureSecure ✅ GetSecureStats / IsSecureActive ✅ SecureOptions.ValidateToken
Godot (GDScript) ✅ configure_secure ✅ get_secure_stats / is_secure_active ✅ a GDScript Callable

Known gaps

  • A session with no server key is still relayable. Binding is what closes the active-MITM gap, and a joiner that never received a server_key does not get it. That is the reason step 7 exists rather than being optional.
  • No in-band rekey. Not needed for nonce hygiene — the per-direction sequence is 64-bit — but it means a session key lives as long as the session does.
  • The static key identifies the SERVER, not its operator. It says "this is the endpoint the director published for this session", which is the claim the director is in a position to make. It is not an attestation about the machine or the person running it.

Next steps