signed_version settles the server's version inside the key exchange, so
an echo of it on the messages after has nothing left to confirm. With it
go RendezvousMessage.kx_advertised, the check the transport ran on each
decrypted frame, and the tests of both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
Version 1 of the key exchange splits the exchanged key into a subkey per
direction, the way Noise splits its cipher states after the handshake, and
leaves the nonce layout and the handshake messages as they are. Each subkey
is a keyed BLAKE2b over the handshake transcript: both ephemeral public keys,
the version the responder advertised and the one the initiator picked, so a
change to any of it in transit leaves the two sides with different keys. The
original scheme is version 0, which is also what an absent field means.
Negotiated through new fields old peers do not see: the side that speaks
first advertises the newest version it has (`KeyExchange.version` from the
server, `IdPk.kx_version` inside the signed identity from the controlled
side), the other side answers with the highest both have, and the pair runs
that. Any old peer on either end keeps today's stream byte for byte. The
controlled side's advertisement sits inside the signature; the server's is
repeated in `RendezvousMessage.kx_advertised` on its first message after the
exchange, inside the encrypted channel, and a client that was shown a
different value in the clear refuses the stream.
`Encrypt::new` is unchanged; `new_split`, `set_key_split` and
`check_kx_advertised` are added beside it, and `box_pk_of` for a responder
that kept only the secret half of its ephemeral pair. The tests pin the
version 0 output as well as the split and the echo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
Upstream reserves a frame's whole declared length the moment its header
is in, bounded only by max_frame_size, so a header alone buys the
allocation it names: 128 KiB before a session is authorized and 16 MiB
after. The rustdesk-org tungstenite-rs fork on rustdesk-patches-0.29,
upstream v0.29.0, grows the read buffer one read_buffer_size chunk at a
time as the bytes actually arrive instead, and tokio-tungstenite moves to
its rustdesk-patches-0.29 branch to match, still one commit for
set_config. 0.27's read chunking comes with the move, so a large frame no
longer clears its whole grown buffer on every read.
No source change: Bytes payloads and the non-exhaustive config arrived in
0.26 and 0.25 and were already in use here, and 0.28's boxing of
Error::Http derefs at its one use. The read buffer keeps tungstenite's
128 KiB default, this side reading video frames and file blocks over a
handful of connections rather than small messages over thousands. The
workspace root patches tungstenite to the fork, where cargo reads
[patch], so this crate's copy and tokio-tungstenite's resolve to one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
The two WebSocket tests both stopped at max_frame_size: a header over the cap
and a single large frame after the cap is lifted. Dropping max_message_size
from set_max_packet_length left both passing, and that line is what refuses
two frames each under the cap that reassemble to a message over it - the other
way a peer buys the allocation. A test now sends exactly that, 12 KiB and a
12 KiB continuation against a 16 KiB cap, and fails when the line is dropped.
The doc on Stream::set_max_packet_length claimed that lowering the bound never
discards a message already being reassembled. That is the TCP codec, which
checks only on the header; WebRTC and a fragmented WebSocket message re-check
the bound on every fragment and end the message once it passes. The doc now
promises only what holds everywhere: set it before the first read, and do not
lean on lowering it to constrain a message in progress.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
The cap's header-only refusal was tested on the TCP codec and its reassembly
bound on WebRTC, but not on WebSocket, which is the transport where a frame
header buys an allocation, and neither transport had a test that lifting the
cap lets a large message through again - the half of the lifecycle a server
runs at authorization, and the one a forgotten restore would break silently.
Two WebSocket tests build a loopback from a raw TcpStream with no handshake,
write server-to-client frames by hand, and check that a header declaring one
byte over the cap is refused before any payload exists, and that a message
refused under a low cap arrives once the cap is lifted. A WebRTC test does the
same lift. Each fails when the corresponding half of set_max_packet_length is
made a no-op.
The doc on Stream::set_max_packet_length now says the one thing the bound
does not do: lowering it applies from the next frame on and does not discard
one whose decoding or reassembly has already begun, so it has to be set before
the first read of untrusted data. That is how the server uses it; the note is
there so nothing later treats it as a switch that can be thrown at any time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
A frame header declares its own length, and until a connection authorizes nothing
bounded that length but the transport's ceiling: 1 GiB on TCP and on WebRTC, 64 MiB
on WebSocket. The decoder here already refuses to preallocate on a declared length,
so on TCP an attacker spends a byte sent per byte held; tungstenite does not, and
reserves a frame's whole declared payload the moment it passes max_frame_size, so a
fourteen-byte header buys 16 MiB. That is CVE-2021-32675's shape, and Redis answered
it the same way: a bound that holds only until the peer has authenticated.
`Stream::set_max_packet_length` carries such a bound to whichever transport is under
it. TCP has the knob already. WebRTC gains one: its reassembly ceiling moves from a
constant to a per-stream atomic, shared by every clone because the receive path runs
off one, and clamped to that constant so the send path's check still describes what a
peer will accept. WebSocket reaches tungstenite's own config, which needs a fork -
upstream exposes get_config and no set_config, at v0.26.2 and still at 0.30.0 - and
moves both bounds: max_frame_size is what stops a header from buying an allocation,
max_message_size is what bounds reassembly. Every transport clamps to its own
ceiling, so usize::MAX restores exactly what was there and no caller can raise one
above what it always had.
Nothing here calls it, so on its own this changes no behaviour.
Tests cover both new paths: the codec takes a header at the cap and dies on the one
a byte above it, before any payload is buffered; and a WebRTC loopback pair whose
receiver has lowered its bound refuses an oversize message, while the sender, whose
own bound is untouched, still puts it on the wire the way an unauthenticated peer
would.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab