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¶
The key exchange is UNAUTHENTICATED — this is not TLS
Nothing in the handshake proves the peer's identity. An active on-path attacker can still machine-in-the-middle the connection by substituting its own public key, and everything below will look perfectly healthy while it does.
What the channel does stop: passive eavesdropping, off-path forgery, replay, and spoofed-source floods.
What it does not stop: an active MITM.
Closing that gap needs a signed server identity, which this core deliberately does not vendor. What it gives you instead is the token validation seam: your host application already holds the director's session tokens, so it gets to approve or refuse each peer during the handshake. Wiring a real signature check into that seam is what makes the channel authenticated. Until you do, treat it as confidentiality and integrity against a network observer, not as proof of who you are talking to.
Verified against: lattice.h:508-516 ("WHAT YOU DO NOT GET (be precise about this)").
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.
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").
The token validation seam¶
This is the hook that turns an unauthenticated channel into an authenticated one. 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.
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. |
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¶
Beyond the unauthenticated key exchange above, these are open and worth knowing before you deploy:
- No idle-session expiry. Neither the secure layer's session table (
MAX_SESSIONS4096) nor the pre-existingUdpTransport::peers_(kMaxPeers4096) expires idle peers. - 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.
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.