moonquic quic transport

guide · six steps

From nothing to a packet

An empty project, a frame, a protected header, a second path, a resumed connection, and the clock that holds them together. Every block below is code this module compiles as it stands, except the one marked as a sketch — that one needs a certificate store and a socket loop around it.

Add it

moon add moonbitstack/moonquic, then import what you need. Six packages, each with one job: frame and packet are codecs, crypto is protection, recovery is the clock's business, stream is the application's, and conn is the transport that ties them together.

Nothing here opens a socket or reads a clock. The peer's address is a type parameter — a string, an enum, whatever your UDP loop keys connections by — and every entry point takes now in microseconds. That is what makes this library testable against a loss pattern, and what lets the page you are reading print a real packet: it is the same code, compiled to JavaScript.

Frames, both ways

A packet's payload is a run of frames. @frame.decode answers the frame and how many bytes it took, so reading a payload is a loop; encoding is one call per frame.

let frames : Array[@frame.Frame] = [
  @frame.Crypto(offset=0UL, data=b"hello"),
  @frame.MaxData(1_000_000UL),
  @frame.PathStatus(path_id=1UL, seq=1UL, available=true),
]
let mut wire = b""
for frame in frames {
  wire = wire + @frame.encode(frame)
}
let mut at = 0
let back : Array[@frame.Frame] = []
while at < wire.length() {
  let (frame, used) = @frame.decode(wire[at:]) catch {
    _ => abort("a frame did not decode")
  }
  back.push(frame)
  at = at + used
}
assert_eq(back, frames)

The set is the whole of RFC 9000 §19, RFC 9221's DATAGRAM, and the multipath frames. A frame the codec does not know is refused rather than skipped: RFC 9000 §12.4 makes an unknown type a connection error, and a silent skip would hide it from both ends.

Protect a packet

The Initial keys come from the destination connection id alone, so the first packet can be built before anything has been negotiated. Header first, then seal, which authenticates the header as associated data and masks it.

let dcid : Bytes = b"\x83\x94\xc8\xf0\x3e\x51\x57\x08"
let scid : Bytes = b"\xaa\xbb\xcc\xdd"
let (rx, tx) = @crypto.initial(dcid[:])
let wire = @conn.seal_long(
  @packet.Kind::Initial, 1U, dcid, scid, 0L, 4,
  @packet.payload([@frame.Ping]), tx,
)
let opened = @conn.recv_long(wire, rx, largest=-1L) catch {
  _ => abort("the packet did not open")
}
guard opened is Some((read, number)) else { abort("the packet did not open") }
assert_eq(number, 0L)
assert_eq(read, [@frame.Ping])

The last sixteen bytes are the tag. A receiver that gets a different number, or a byte changed anywhere before the tag, gets None — one answer for every way of being unreadable, because dropping the packet is the only thing to do about any of them.

A second path

Multipath starts with a transport parameter and a connection id: initial_max_path_id says how many paths each end will maintain, and a path is identified by the id its packets name, never by the address they came from. Each path then keeps its own packet numbers, its own round-trip estimate and congestion window, and its own acknowledgement.

let paths = @conn.Paths::new(local_max=2UL)   // what this end will maintain
paths.on_max_path_id(1UL)                     // what the peer said it would
assert_true(paths.openable(1UL))
assert_false(paths.openable(2UL))             // the peer offered one path, not two
let spare : Bytes = b"\xaa\xbb\xcc\xdd"
paths.bind(1UL, spare)                        // the peer's id for the path
ignore(paths.bind_local(1UL, b"\x01\x02"))    // this end's
assert_eq(paths.path_of(b"\x01\x02"), 1UL)
assert_false(paths.validated(1UL))
ignore(paths.start(1UL, b"\x10\x20\x30\x40\x50\x60\x70\x80"))
assert_true(paths.prove(1UL, b"\x10\x20\x30\x40\x50\x60\x70\x80"))
assert_true(paths.validated(1UL))

Two paths may use the same packet number, because the nonce names the path. Path 0 gives exactly the nonce RFC 9001 describes, so a connection that never opens a second path computes what it always did — and the first packet on a new path is number 0 there, whatever path 0 has used.

let (_, keys) = @crypto.initial(b"\x83\x94\xc8\xf0\x3e\x51\x57\x08")
assert_eq(keys.nonce_at(0UL, 54321L), keys.nonce(54321L))
assert_true(keys.nonce_at(1UL, 54321L) != keys.nonce(54321L))

On a server, Session::send_on puts frames on a path and Session::poll_lane hands back the datagrams that path has ready with the address they belong on; Endpoint::add_path makes the connection id the path answers to. On a client the caller owns the sockets, so it says which path a datagram arrived on (recv_on) and gets the path id back with what to send (poll_paths).

A path is opened on the connection id the peer gives for it, and that id arrives before anything has been sent on the path — it is what the path is for. A PATH_NEW_CONNECTION_ID therefore creates the path it names, which is why paths().peer_cid(1) answers with something to address before add_path has been called. A caller that puts a stream's frames on a path itself says the stream exists first: RFC 9000 §19.8 lets the peer answer only on a bidirectional stream this end opened, and Client::open_stream is that record without any bytes queued for the library's own send loop.

The shape of it — not compileable on its own

ignore(client.open_stream(4UL))      // a stream this end initiates, nothing on it yet
ignore(                              // the request, placed on path 1 by the caller
  client.send_on(
    1UL,
    [@frame.Stream(id=4UL, offset=0UL, fin=true, data=b"GET /\r\n")],
    now,
  ),
)

Resume a connection

0-RTT is only safe if the client knows what the last connection left it: RFC 9000 §7.4.1 forbids guessing the flow-control credit. Client::remembered is the ticket together with the parameter block it was issued under, and passing it back is all the next connection needs.

The shape of it — not compileable on its own

let kept = client.remembered()             // a ticket and the parameters it came with
let next = @conn.Client::new(              // the sketch: needs a store and a socket loop
  @tls.Client::new(roots, alpn=["h3"], sessions=store, early=true),
  "a.example",
  @conn.encode(params),
  rand=entropy,
  dcid=Bytes::make(8, 3),
  scid=Bytes::make(8, 4),
  now,
  resumption=kept,
)

Config's zero_rtt_data and zero_rtt_stream_data overrule what was remembered rather than being the only source: zero, the default, means the remembered value governs. A client with a ticket and no remembered block still has nothing to spend, so its early data waits for the handshake instead of risking a flow-control error the server would answer with a close.

A server allows early data with @tls.Tickets::new(keys, early=n) and judges tickets at wall, seconds since the epoch — a different clock from now, which only drives timers. An endpoint given no wall neither issues nor takes a ticket, because 0-RTT data can be replayed by whoever recorded it.

A second version

Version 2 is the same protocol under different constants, so the version a packet carries decides how it is protected and what its first byte means: the salt, the four labels, the Retry key and the four type codes all change. Config.versions names what an end speaks, and @crypto.Version::of turns the number a long header carries into those constants.

let v2 = @crypto.Version::of(@packet.version2).unwrap()
assert_eq(v2.key, b"quicv2 key")
assert_eq(@packet.Kind::Initial.code(@packet.version2), 1)
let dcid : Bytes = b"ƒ”Èð>QW"
let (rx, tx) = @crypto.initial(dcid[:], version=v2)
let wire = @conn.seal_long(
  @packet.Kind::Initial, @packet.version2, dcid, b"ª»ÌÝ", 0L, 4,
  @packet.payload([@frame.Ping]), tx,
)
guard @conn.recv_long(wire, rx, largest=-1L) is Some((read, _)) else {
  abort("the packet did not open")
}
assert_eq(read, [@frame.Ping])

The same call under version 1 produces different bytes, and neither opens under the other's keys — which is the whole point of a second version, and why the first flight says which one it is. A first flight also carries version_information: the version it chose and the ones beside it, both under the handshake's own authentication.

let param = @conn.version_information(
  @packet.version2,
  [@packet.version1, @packet.version2][:],
)
assert_eq(param.kind.code(), 0x11UL)
assert_eq(
  @conn.read_version_information(param.value[:]),
  Some((@packet.version2, [@packet.version1, @packet.version2])),
)

A server that does not speak the version a client chose answers with a Version Negotiation packet naming the ones it does; the client restarts from a new first flight in a version both ends speak, keeping its connection id and its packet numbers. When the peer instead answers in a version the client also offered, the client adopts it without a round trip. Either way the version the handshake settled on is checked against the peer's own list, and a mismatch ends the connection with VERSION_NEGOTIATION_ERROR (0x11).

A server does not have to leave the client's choice alone. Put [@packet.version2, @packet.version1] in Config.versions and it reads the first flight's list before the handshake starts, selects version 2, re-derives both directions' Initial keys and answers in version 2. The client learns the negotiated version from the long headers and adopts it; there is no Version Negotiation packet and no extra round trip (RFC 9368 §2.3). A client that offers only its Chosen Version keeps the handshake in that version, and a version_information that does not parse closes the connection.

Time and loss

Nothing moves without tick. It runs the probe timeout on each handshake level, the send loop's own, the idle timeout and the closing period, and it is where a lost handshake flight is put back on the wire. A client also owes a server an occasional padded Initial with a PING, because a server whose amplification budget is spent cannot answer anything else.

The same timer is what recovers a lost client Finished: the packet nobody can do without. Without it the server never confirms the handshake, and both ends wait out their idle timers holding a connection that is finished but not confirmed.

let policy = @recovery.Policy::new(
  datagram=1200L,
  ack_delay=25_000L,
  granularity=1_000L,
)
let state = @recovery.State::new(policy~, control=@recovery.Cubic::new())
assert_true(state.window() > 0L)

CUBIC follows a cubic function of the time since the last reduction and falls to 0.7 rather than to half; BBR sizes the window from the bandwidth-delay product and does not treat a loss as its congestion signal. Both implement Control, so a controller of your own is a trait implementation and nothing else.