Skip to content

Commit b0ddbdc

Browse files
3x3xX3N0Nclaude
andcommitted
Interop L0: the captured session
bench capture: a UDP relay between a real client and server logs every datagram both directions - wire bytes as a network sees them, no internal seams. Alongside: the session key (public API, the root SPEC derives every packet key from), and ground truth - 20 messages up (sizes 1..1500 B, so single- and multi-fragment are both on the wire), 20 echoed back, and a clean close. 214 datagrams in interop/vectors/session-1. Handshake datagrams are marked parse-only: their decryption needs ephemeral keys no passive observer holds. Everything after is decryptable from the session key - if SPEC.md documents the derivation well enough, which is exactly what rung L1 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8d6fd62 commit b0ddbdc

7 files changed

Lines changed: 660 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package tessera.bench
2+
3+
import tessera.core.Handshake
4+
import tessera.transport.ConnConfig
5+
import tessera.transport.TesseraClient
6+
import tessera.transport.TesseraServer
7+
import java.io.File
8+
import java.net.DatagramPacket
9+
import java.net.DatagramSocket
10+
import java.net.InetSocketAddress
11+
import java.util.Locale
12+
import java.util.concurrent.ConcurrentLinkedQueue
13+
14+
/**
15+
* INTEROP L0 (docs/INTEROP.md): capture a complete session for the clean-room decoder.
16+
*
17+
* A UDP relay sits between a real client and a real server and logs every datagram in both directions —
18+
* wire bytes exactly as a network would see them, no internal seams. Alongside, the run writes the one secret
19+
* the decoder is entitled to (`TesseraConnection.sessionKey` — public API, the root from which SPEC derives
20+
* every packet key) and the ground truth (the application messages each side sent).
21+
*
22+
* What the clean-room implementer gets: `datagrams.jsonl`, `meta.json`, `ground-truth.json`, and SPEC.md.
23+
* What they must produce: the messages, byte-exact, from the datagrams — which requires varints, headers,
24+
* header-protection removal, key derivation from the session key, AEAD open, frame parsing and reassembly to
25+
* all be implementable from the document. Handshake datagrams (flagged F_INITIAL) are parse-only: their
26+
* decryption needs ephemeral keys no passive observer has, and the capture marks them so.
27+
*
28+
* The workload is deliberately small and varied: 20 messages client->server (sizes 1..1500 B, so single- and
29+
* multi-fragment paths are both on the wire), 20 echoes back, then a clean close (the CLOSE frame and linger
30+
* behaviour are part of the capture).
31+
*
32+
* usage: bench capture [--out interop/vectors/session-1]
33+
*/
34+
fun captureMain(args: Array<String>) {
35+
fun opt(k: String, d: String) = args.indexOf("--$k").let { if (it >= 0) args[it + 1] else d }
36+
val outDir = File(opt("out", "interop/vectors/session-1")).apply { mkdirs() }
37+
38+
val keys = Handshake.generate()
39+
val cfg = ConnConfig(pingIntervalMs = 0, idleTimeoutMs = 60_000)
40+
val log = ConcurrentLinkedQueue<String>()
41+
val t0 = System.nanoTime()
42+
fun hex(b: ByteArray, len: Int) = buildString(len * 2) { for (i in 0 until len) append("%02x".format(b[i])) }
43+
44+
TesseraServer(InetSocketAddress("127.0.0.1", 0), keys, ByteArray(32) { it.toByte() }, cfg).use { server ->
45+
// The relay: client talks to relayPort, relay forwards to the server, learns the client's address from
46+
// the first datagram, and logs everything with direction and arrival order.
47+
val relay = DatagramSocket(0, java.net.InetAddress.getLoopbackAddress())
48+
val serverAddr = server.localAddress
49+
var clientAddr: InetSocketAddress? = null
50+
val relayThread = Thread {
51+
val buf = ByteArray(65535)
52+
try {
53+
while (true) {
54+
val p = DatagramPacket(buf, buf.size)
55+
relay.receive(p)
56+
val from = p.socketAddress as InetSocketAddress
57+
val dir: String
58+
val to: InetSocketAddress
59+
if (from.port == serverAddr.port) { dir = "s2c"; to = clientAddr ?: continue }
60+
else { if (clientAddr == null) clientAddr = from; dir = "c2s"; to = serverAddr }
61+
log.add(String.format(Locale.ROOT, "{\"seq\":%d,\"tUs\":%d,\"dir\":\"%s\",\"len\":%d,\"hex\":\"%s\"}",
62+
log.size, (System.nanoTime() - t0) / 1000, dir, p.length, hex(buf, p.length)))
63+
relay.send(DatagramPacket(buf, p.length, to))
64+
}
65+
} catch (e: Exception) { /* relay closed */ }
66+
}.apply { isDaemon = true; start() }
67+
68+
TesseraClient(cfg = cfg).use { client ->
69+
val conn = client.connect(
70+
InetSocketAddress("127.0.0.1", relay.localPort), keys.x25519Pub, keys.kemPub,
71+
"interop-l0".toByteArray(), timeoutMs = 10_000)
72+
val sconn = server.accept(5_000) ?: error("no accept")
73+
val zeroRtt = sconn.receive(2_000) ?: error("no 0-rtt payload")
74+
75+
// deterministic, size-varied payloads; every byte position is predictable from (index, position)
76+
fun payload(i: Int, size: Int) = ByteArray(size) { p -> ((i * 31 + p) and 0xFF).toByte() }
77+
val sizes = listOf(1, 8, 64, 200, 500, 1200, 1201, 1350, 1500, 32, 1024, 900, 700, 300, 150, 77, 1499, 2, 128, 1300)
78+
val c2s = ArrayList<ByteArray>(); val s2c = ArrayList<ByteArray>()
79+
for ((i, sz) in sizes.withIndex()) {
80+
val m = payload(i, sz); c2s.add(m); conn.send(m)
81+
val got = sconn.receive(5_000) ?: error("server did not get msg $i")
82+
check(got.contentEquals(m)) { "relay corrupted msg $i" }
83+
val echo = payload(100 + i, sz); s2c.add(echo); sconn.send(echo)
84+
check(conn.receive(5_000)?.contentEquals(echo) == true) { "echo $i not received" }
85+
}
86+
val sessionKey = conn.sessionKey.copyOf()
87+
conn.close(); sconn.close()
88+
Thread.sleep(500) // let close linger / CLOSE frames traverse the relay
89+
relay.close()
90+
91+
File(outDir, "datagrams.jsonl").writeText(log.joinToString("\n") + "\n")
92+
File(outDir, "meta.json").writeText(String.format(Locale.ROOT,
93+
"{\n \"sessionKeyHex\": \"%s\",\n \"clientIsInitiator\": true,\n \"tagLen\": %d,\n" +
94+
" \"note\": \"datagrams with the top flag bit set (F_INITIAL) are handshake packets: parse-only, their decryption needs ephemeral keys a passive observer does not have. Everything else is decryptable from sessionKey per SPEC.\"\n}\n",
95+
hex(sessionKey, sessionKey.size), cfg.tagLen))
96+
fun arr(l: List<ByteArray>) = l.joinToString(",\n ", "[\n ", "\n ]") { "\"" + hex(it, it.size) + "\"" }
97+
File(outDir, "ground-truth.json").writeText(
98+
"{\n \"zeroRttPayloadHex\": \"" + hex(zeroRtt, zeroRtt.size) + "\",\n" +
99+
" \"clientToServer\": " + arr(c2s) + ",\n \"serverToClient\": " + arr(s2c) + "\n}\n")
100+
println("captured ${log.size} datagrams -> $outDir (sessionKey ${sessionKey.size} B, ${c2s.size}+${s2c.size} messages)")
101+
}
102+
}
103+
}

bench/src/main/kotlin/tessera/bench/Main.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ fun main(args: Array<String>) {
135135
"idle" -> { idleMain(args.drop(1).toTypedArray()); return }
136136
"amp" -> { ampMain(args.drop(1).toTypedArray()); return }
137137
"vs" -> { vsMain(args.drop(1).toTypedArray()); return }
138+
"capture" -> { captureMain(args.drop(1).toTypedArray()); return }
138139
"vsbulk" -> { vsBulkMain(args.drop(1).toTypedArray()); return }
139140
"connect" -> { connectBench(netem = netem); return }
140141
"coldstart" -> { coldStartMain(args.drop(1).toTypedArray()); return }

docs/THREAT-MODEL.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Threat model
2+
3+
Phase 0 of `docs/AUDIT-PLAN.md`. One page, adversary first. Tessera is a single-implementation research
4+
prototype, wire format v0, unaudited; nothing below has been independently reviewed. Claims about mechanisms
5+
are made against the code in `core/src/main/kotlin/tessera/core/`, not the prose docs; where the two disagree,
6+
the code is cited and the disagreement noted.
7+
8+
## 1. Assets protected
9+
10+
- **Payload confidentiality**, including against a recorder who stores traffic today and decrypts with a
11+
quantum computer later. The handshake secret is hybrid X25519 + ML-KEM-768; breaking confidentiality of a
12+
recorded session requires breaking both.
13+
- **Payload and header integrity.** Every post-handshake packet is ChaCha20-Poly1305 AEAD with the header as
14+
AAD; an on-path modification fails authentication and the packet is dropped.
15+
- **Connection identity.** Packets are bound to a connection by keys, not by address; only a holder of the
16+
connection's keys (or of the server's stateless-reset secret) can terminate or speak on a connection.
17+
- **Bounded reflection.** A spoofed source cannot use the server as a >3x amplifier, and cannot (beyond fixed
18+
rate budgets) use it as a CPU oracle for ML-KEM decapsulations (`AddressValidation.kt`).
19+
20+
**Not protected:**
21+
22+
- **Traffic analysis.** Packet sizes, timing, rates, and direction are visible. No padding beyond the minimum
23+
needed for the header-protection sample; no cover traffic.
24+
- **Connection linkability at the network layer.** The 4-byte short connection id rides in the clear on every
25+
short packet (deliberately, per `PacketCrypto.kt`: header protection masks only the packet number and two
26+
flag bits; the pathId bits and connId stay readable for load balancers). An observer can trivially track a
27+
connection across its lifetime, and across a client rebind if the connId is kept.
28+
- **Peer anonymity.** IP addresses are not hidden; that is not this layer's job.
29+
- **Full forward secrecy of the first flight.** The session key is DH(client-ephemeral, server-static) plus
30+
KEM against the server-static ML-KEM key (`Handshake.kt`). There is no server ephemeral in the exchange —
31+
the code is "Noise-IK-shaped", not full IK with an `ee` token as SPEC's shorthand might suggest — so an
32+
adversary who later obtains **both** server static private keys and recorded the traffic recovers the
33+
session key. Key rotation (`secret_{n+1} = HKDF(secret_n)`) chains forward from that same secret and does
34+
not repair this. Resumed sessions inherit the same bound via the ticket (below).
35+
36+
## 2. Adversaries
37+
38+
**Passive recorder (incl. harvest-now-decrypt-later).** Records everything, forever. Stopped by the hybrid
39+
key exchange: recovering plaintext requires the X25519 shared secret *and* the ML-KEM-768 shared secret
40+
(`Handshake.kt` concatenates both into HKDF). A future quantum attacker breaks X25519 but not ML-KEM; a
41+
classical attacker with a lattice break gets the KEM but not X25519. Caveat: a recorder who later steals the
42+
server's static keys wins (see forward-secrecy note above); a recorder who later steals a ticket key reads
43+
that ticket's resumed 0-RTT flights.
44+
45+
**On-path active attacker.** Can drop, delay, reorder, modify, inject. Modification and injection fail the
46+
AEAD (per-attempt forgery 2^-128 at tag 16, 2^-64 at tag 8). Flipping the key-phase bit to provoke a spurious
47+
rotation is specifically handled: `KeyPhaseState.open` follows an update only after a packet authenticates
48+
under the pre-derived next-generation keys. Dropping and delaying are availability attacks and are not
49+
prevented — an on-path attacker can always kill a connection.
50+
51+
**Off-path spoofer.** Sends packets with forged sources. Cannot forge data packets (no keys), cannot forge a
52+
stateless reset (the token is delivered inside the encrypted handshake reply and is an HMAC under a
53+
server-held secret, `StatelessReset.kt`), cannot amplify (3x bound before path validation, `PathValidation`;
54+
a Retry packet at 31 B against a >=1.2 KB initial is a deamplifier), and cannot burn KEM CPU past the fixed
55+
per-source and global token buckets — under pressure an unvalidated source gets a Retry token it never
56+
receives at a spoofed address (`AddressValidation.kt`). Known residue, documented in SPEC "Address
57+
validation": the Retry itself is unauthenticated, so an off-path guesser can inject one; the client accepts
58+
at most one per connect, from the addressed server only, and its retransmit train continues — cost is one
59+
extra initial, not a failed connect.
60+
61+
**Malicious peer.** Holds valid keys; the crypto does not constrain it. It can lie in every frame: ACK
62+
inflation, credit/grant manipulation, ECN lies, bogus PMTUD, junk frames. Defences are parsers hardened by
63+
fuzzing (findings recorded in-code, e.g. the empty-packet check in `PacketCrypto.checkShort`), bounded
64+
decoder state, and rate/credit sanity limits — but a peer-driven resource-exhaustion review is exactly what
65+
an audit is for. Assume a malicious peer can waste this endpoint's memory and CPU up to the audited bounds
66+
and no confidentiality beyond its own connection.
67+
68+
**Restarted / keyless server.** A server that crashed and lost all connection state can still emit a valid
69+
stateless reset: the reset secret derives from the operator-provided ticket key, which survives restarts, and
70+
the token is HMAC(secret, shortConnId) where the connId is readable on the client's retransmits
71+
(`StatelessReset.kt`). Reset emission is itself bounded (min-size packet requirement plus a 2000/s global
72+
bucket, SPEC "stateless reset") so the mechanism is not a reflector. The failure mode without it — client
73+
retransmits into a black hole until the 10 s idle timeout — is availability, not secrecy.
74+
75+
## 3. Out of scope
76+
77+
- **Key distribution.** Noise IK premise: the client already holds the server's static X25519 and ML-KEM
78+
public keys, obtained out of band (pinning, TOFU, delegated credential). There is no PKI, no certificates,
79+
no in-band identity. A client that accepts the wrong static key has lost before the first packet.
80+
- **DoS beyond the stated bounds.** The 3x amplification bound, the KEM admission budgets, and the reset/Retry
81+
rate caps are the whole DoS story. Volumetric flooding of the link, state exhaustion by a peer with valid
82+
keys past audited limits, and everything an on-path attacker can do by dropping are out of scope.
83+
- **Side channels beyond constant-time primitives.** Tag and token comparisons are constant-time
84+
(`Arrays.constantTimeAreEqual`, the local `eq` in `AddressValidation.kt`); the crypto is BouncyCastle's.
85+
Cache-timing of the JVM, the Rust SIMD datapath, and ML-KEM implementation side channels are not analyzed.
86+
- **Traffic analysis** of sizes, timing, and connection linkage (Section 1).
87+
88+
## 4. Known accepted weaknesses
89+
90+
- **`tagLen = 8` truncated-tag mode.** Negotiated in `ConnParams` (`Params.kt`): forgery ~2^-64 per attempt,
91+
documented in-code as acceptable for media/game state, **not for transactions**. Truncation happens on the
92+
wire only; the keystream and confidentiality argument are unchanged (SPEC v0.9). The truncated-tag open
93+
path recomputes the full Poly1305 tag and compares a prefix in constant time (`PacketCrypto.openTruncated`).
94+
- **±10 s 0-RTT replay window with a seen-set.** `ZeroRtt.Server` and `Resumption.Server` reject timestamps
95+
outside ±10 s and any fingerprint already seen inside the window. The fingerprint is 8 bytes (first 8 of
96+
the random ePub; ticket-nonce xor client-nonce for resumption) — collision, not forgery, resistance; the
97+
seen-set is pruned only above 100k entries. Within-window replay from a clock-skewed pair, and the fact
98+
that 0-RTT data must be idempotent at the application layer regardless, are accepted and documented in the
99+
code. A resumed connection additionally binds ts+nonce into the session key so a replayed ticket under a
100+
fresh nonce yields a distinct key (`Resumption.sessionKey`).
101+
- **Single implementation, no interop evidence.** The v0 wire format has never been parsed by an independent
102+
decoder (AUDIT-PLAN Phase 2 / TODO item 7). Every compatibility claim is self-referential.
103+
- **No audit.** No external cryptographic or implementation review has occurred. The handshake deviates from
104+
proven Noise IK (hybrid KEM injection, no server ephemeral, 0-RTT layer) and those deltas are undocumented
105+
formally — that is Phase 1.
106+
107+
## 5. Mechanism inventory
108+
109+
| Mechanism | Where | Notes |
110+
|---|---|---|
111+
| Hybrid handshake: X25519 + ML-KEM-768 into HKDF-SHA256 | `core/Handshake.kt` | Client ephemeral only; server static keys pinned out of band |
112+
| Packet AEAD (ChaCha20-Poly1305), header protection, truncated-tag open | `core/PacketCrypto.kt` (`PacketKeys`, `PacketProtection`) | RFC 9001 §5.4-shape HP; flags mask 0x63 leaves connId/pathId in clear by design |
113+
| Key update via phase bit; one retained previous generation; follow only after next-key auth | `core/PacketCrypto.kt` (`KeyPhaseState`) | HP key fixed at generation 0 for the connection's lifetime |
114+
| Automatic rotation policy: 2^20 packets or 1 GiB sealed per tx generation | SPEC v0.9; trigger in `transport` `transmit`, machinery `KeyPhaseState` | Policy, not a derived AEAD limit; counters freeze while an update is pending |
115+
| 0-RTT replay defence: ±10 s window + seen-set | `core/ZeroRtt.kt`, `core/Resumption.kt` | App-layer idempotence still required |
116+
| Stateless resumption tickets (server-encrypted, 7-day lifetime) | `core/Resumption.kt` | Ticket-key compromise reads resumed 0-RTT flights |
117+
| Stateless reset tokens: HMAC(derived secret, connId), constant-time match | `core/StatelessReset.kt` | Token delivered inside encrypted handshake reply; survives restart via ticket key |
118+
| Amplification bound (3x before path validation) + path challenge | `core/PathValidation.kt` | Bounds reflected bytes, not CPU |
119+
| KEM admission control: per-source/global buckets, pressure-triggered Retry tokens | `core/AddressValidation.kt` | Bounds CPU; fixed 8192-slot keyed table, no growth |

0 commit comments

Comments
 (0)