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
/resolveresponse 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:
- The client gets the static key authentically, never from the wire. It comes from
/resolveover TLS and is installed withlattice_secure_bind_server_key; the client uses the installed key and never anything carried inSEC_ACCEPT. - The key is PER-SERVER, never fleet-wide. This is the one that will quietly falsify
everything above if you get it wrong.
persistent_servermints 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. - The client is actually bound. A joiner that received no key gets the old
unauthenticated exchange.
lattice_secure_require_bindingcloses 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:
- 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"). - 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. - 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. - Servers →
required. Now safe: every client speaks secure. Plaintext is refused and counted inplaintext_dropped. - Clients →
requiredtoo. See the downgrade note below — this step is not optional if you care about the guarantee. - 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.
- Servers →
require_binding. The last door. Do it whenlattice_secure_binding_statsshows 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:
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¶
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.
| 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:
| 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_keydoes 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¶
- Peer identity & local player — the secure handshake is what tells a client its own peer id; that page covers what happens when it does not.
- Lobbies & matchmaking — where the session token the seam verifies comes from.
- Runner lifecycle & pump —
configure_securemust precedelisten/connect.