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

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

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.

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

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.

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_SESSIONS 4096) nor the pre-existing UdpTransport::peers_ (kMaxPeers 4096) 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