# AetherStream Protocol Specification v2 **Status**: v2 is the only supported wire protocol. v1 (plaintext, two ports) is retired and must be rejected. **Reference implementation**: `core/` (`aether-core` Rust crate). Every client links this crate (C ABI) instead of re-implementing the protocol. If this document and `core/` disagree, it is a bug; fix both. Byte-exact examples of every packet: `core/tests/vectors/v2_wire_vectors.json` (independently re-derived by `core/tests/vectors/verify_vectors.py`). **Supersedes**: v1.0 (see git history before 2026-11-03). Notation: `[a..b)` = byte offsets, half-open. All layouts below give **every** byte; the total length is normative. --- ## 1. Transport - **Single UDP port**: `47998` carries handshake, media, input and control. (The v1 input port `47999` is removed: it accepted unauthenticated `SendInput`.) - Max datagram size: **1400 bytes** (outer). No IP fragmentation. - Byte order: **big-endian for every multi-byte field**, including `f32` (IEEE-754 bits, BE) and signed integers (two's complement, BE). - Magic: every datagram starts with `0xAE 0x54`. - Reserved bytes are sent as 0 and ignored on receive. ## 2. Packet classes | Opcode | Name | Class | Direction | Length | | :--- | :--- | :--- | :--- | :--- | | `0x60` | CLIENT_HELLO | plaintext handshake | C→H | 160 | | `0x61` | COOKIE_CHALLENGE | plaintext handshake | H→C | 40 | | `0x62` | SERVER_HELLO | plaintext handshake | H→C | 80 | | `0x63` | HANDSHAKE_ERROR | plaintext handshake | H→C | 24 | | `0x6E` | PUNCH | plaintext NAT keep-alive (ignored by receiver) | both | 4 (`AE 54 6E 00`) | | `0xE0` | SECURE | encrypted envelope (§4) | both | 35..1400 | **Any other opcode received outside a SECURE envelope MUST be dropped.** Inner opcodes (§5) are only valid after decryption. ## 3. Handshake (SPAKE2 + return-routability cookie) ### 3.1 Authentication modes - **Mode 0 – PIN pairing**: the host displays a 6-digit PIN. SPAKE2 password = ASCII `"AetherStream-PIN:" + 6 digits`. - **Mode 1 – paired device**: client presents `pairing_id` (16 B) previously granted by the host (§5.9). SPAKE2 password = the 32-byte `pairing_secret`. SPAKE2: RustCrypto `spake2` crate, Ed25519 group, **symmetric** mode, identity `b"aetherstream-v2"`. Message = 33 bytes. A wrong password does not fail at SPAKE2 time; it yields a different key and the first SECURE packet fails authentication (implicit key confirmation, then an explicit `HANDSHAKE_ERROR 6`, §3.7). A passive or active network attacker gets at most **one online PIN guess per handshake**; offline dictionary attacks are not possible (this is the reason a PAKE is used instead of a PIN-derived key). ### 3.2 CLIENT_HELLO `0x60` — exactly 160 bytes ``` [0..2) magic AE 54 [2] 0x60 [3] protocol version = 2 [4] auth mode (0 = PIN, 1 = paired) [5..8) reserved = 0 [8..24) client_random (16 B, fresh per handshake attempt sequence) [24..57) client SPAKE2 message (33 B) [57..73) pairing_id (16 B; all zero in mode 0) [73..89) cookie (16 B; all zero on first attempt) [89..160) zero padding ``` The padding makes the request larger than any handshake response (anti-amplification: largest response is 80 B). Hosts MUST drop CLIENT_HELLO whose length ≠ 160. Clients retransmit the identical CLIENT_HELLO (same client_random and SPAKE2 message; cookie filled in once received) every 250 ms, max 20 transmissions. ### 3.3 COOKIE_CHALLENGE `0x61` — 40 bytes ``` [0..2) magic [2] 0x61 [3] version 2 [4..20) client_random echo (16 B, copied from the CLIENT_HELLO) [20..36) cookie (16 B) [36..40) reserved = 0 ``` `cookie = HMAC-SHA256(cookie_key, bucket_u64_be || ip_bytes(4|16) || port_u16_be || client_random)[0..16]`, `bucket = host_clock_seconds / 60` (reference implementation: `now_us / 60_000_000` of the clock the host application passes; Unix time is recommended). `cookie_key` is 32 random bytes per host process; only the issuing process verifies cookies. Host accepts the current or previous bucket. The host keeps **no per-client state** before a valid cookie is presented. ### 3.4 SERVER_HELLO `0x62` — 80 bytes ``` [0..2) magic [2] 0x62 [3] version 2 [4] auth mode (echo) [5..8) reserved = 0 [8..12) session_id (u32, random, non-zero) [12..28) host_random (16 B) [28..61) host SPAKE2 message (33 B) [61..64) reserved = 0 [64..80) client_random echo (16 B) ``` If the host receives the same `(addr, client_random)` again while the handshake is pending, it re-sends the identical SERVER_HELLO (idempotent). ### 3.5 HANDSHAKE_ERROR `0x63` — 24 bytes ``` [0..2) magic [2] 0x63 [3] version 2 [4] code [5..8) reserved = 0 [8..24) client_random echo (16 B) ``` | code | meaning | sent in response to | | :--- | :--- | :--- | | 1 | unsupported version | CLIENT_HELLO | | 2 | unknown pairing_id (revoked or never granted → re-pair with PIN) | CLIENT_HELLO | | 3 | locked out (too many failed attempts from this IP; retry after 60 s) | CLIENT_HELLO | | 4 | host busy (session/pending limit, or host RNG failure) | CLIENT_HELLO | | 5 | PIN pairing disabled | CLIENT_HELLO | | 6 | **authentication failed** (wrong PIN / pairing secret) | first SECURE packet of a pending handshake (§3.7) | ### 3.6 Reply binding (anti-forgery) COOKIE_CHALLENGE, SERVER_HELLO and HANDSHAKE_ERROR are unauthenticated, so each echoes the 16-byte `client_random`. **Clients MUST drop a reply whose echo differs from their current client_random** (counted as `unbound_drops`). An off-path attacker therefore cannot abort or derail a handshake without having observed the CLIENT_HELLO (2^-128 guess). *Residual (on-path)*: an attacker who can read the CLIENT_HELLO (same LAN segment, compromised router) can still forge a bound HANDSHAKE_ERROR or SERVER_HELLO and abort the handshake (denial of service). A forged SERVER_HELLO makes the client's HANDSHAKE_REQ undecryptable for the real host, which counts as one failure for the client's IP (§3.8). No key material or input injection is possible. ### 3.7 Key schedule ``` spake_key = SPAKE2.finish(peer_message) (32 B) th = SHA-256( CLIENT_HELLO[0..73) || SERVER_HELLO[0..61) ) prk = HKDF-SHA256-Extract(salt = th, ikm = spake_key) k_c2h = HKDF-Expand(prk, "aether v2 c2h", 32) k_h2c = HKDF-Expand(prk, "aether v2 h2c", 32) ``` The cookie, padding and the SERVER_HELLO echo field are excluded from `th` (the client_random is already covered via CLIENT_HELLO), so a cookie round-trip does not change the keys. ### 3.8 Completion, failure signal and lockout - The first SECURE packet from the client MUST carry an inner `HANDSHAKE_REQ` (§5.1). The client retransmits it (new counter each time) every 250 ms, max 20 transmissions, until HANDSHAKE_RESP arrives. - Only a SECURE packet from the address that completed the cookie exchange can complete or fail a pending handshake; packets from other addresses are dropped without effect. - If it fails to decrypt, the host: discards the pending handshake (one guess consumed), counts one failure for the client IP **and** globally, and sends **one `HANDSHAKE_ERROR 6`** bound to the handshake's client_random. Clients waiting for HANDSHAKE_RESP accept code 6 and report "PIN이 올바르지 않습니다" (mode 0) or "페어링 정보가 유효하지 않습니다" (mode 1) immediately. *Oracle analysis*: code 6 reveals exactly the bit the attacker already learns implicitly (no HANDSHAKE_RESP), only sooner; the handshake is gone, so no additional guess is possible without a new cookie-verified CLIENT_HELLO that is subject to the same counters. - Lockout: ≥5 failures from one IP within 5 min → that IP gets `HANDSHAKE_ERROR 3` for 60 s. ≥20 global failures in mode 0 → host rotates the PIN and shows the new one. - Pending handshakes expire after 10 s; the host keeps at most 64 pending handshakes. ## 4. SECURE envelope `0xE0` ``` [0..2) magic AE 54 [2] 0xE0 [3] reserved = 0 [4..8) session_id (u32) [8..16) counter (u64, per direction, starts at 0, +1 per datagram, never reused) [16..N-16) ChaCha20-Poly1305 ciphertext of the inner packet [N-16..N) Poly1305 tag (16 B) ``` - Key: `k_c2h` for client→host, `k_h2c` for host→client. - Nonce (12 B): `00 00 00 00 || counter_u64_be`. Direction separation comes from distinct keys. - AAD: bytes `[0..16)` of the envelope. - Replay protection: receiver keeps a 1024-entry sliding window on `counter`; duplicates and too-old counters are dropped silently (counted in stats). - Roaming: the host updates the session's peer address only after a packet from a new address authenticates and passes the replay check. - Max inner size: 1400 − 32 = **1368 bytes**. Minimum inner size 3. - Rekey: a session MUST be torn down before either counter reaches 2^60 (practically never). ## 5. Inner packets (only inside SECURE) All start with magic `AE 54` and an opcode. Fixed-size packets MUST have exactly the stated length; receivers drop anything else. ### 5.1 HANDSHAKE_REQ `0x01` (C→H) — 16 bytes ``` [0..2) magic [2] 0x01 [3] version = 2 [4..8) preferred width (u32) [8..12) preferred height (u32) [12..14) preferred fps (u16) [14] preferred codec (0 H264, 1 HEVC, 2 AV1) [15] flags: bit0 = request pairing credentials (mode 0 only), bit1 = enable clipboard sync ``` ### 5.2 HANDSHAKE_RESP `0x02` (H→C) — 18 bytes ``` [0..2) magic [2] 0x02 [3] status (0 OK, 1 error) [4..8) width [8..12) height [12..14) fps [14] codec [15] flags [16..18) host refresh rate ``` A client MUST NOT report "connected" before it receives HANDSHAKE_RESP with status 0. The host re-sends its HANDSHAKE_RESP if the client's HANDSHAKE_REQ arrives again. Values are the parameters the host actually streams. ### 5.3 VIDEO_FRAME_SLICE `0x10` (H→C) — 16-byte header + payload 1..1352 ``` [0..2) magic [2] 0x10 [3] flags (bit0 keyframe = this frame is an IDR, bit1 last slice) [4..8) frame_index (u32, wraps) [8..10) slice_index [10..12) total_slices (≥1, slice_index < total_slices) [12..16) timestamp_us (u32, host capture clock, wraps) [16..) payload ``` Receivers accept payloads up to 1352 B (1368 − 16). **Senders use at most 1348 B per slice** (1368 − 20), so the XOR parity of a group (as long as its longest slice) fits in a FEC_FRAME_SLICE; all slices of a frame except the last are exactly 1348 B in the reference packetizer. `keyframe` MUST reflect the actual bitstream (IDR NAL present), not whether one was requested. Frame indices are assigned sequentially per session (starting at 0). Frame index comparisons use serial-number arithmetic (RFC 1982); a jump of more than 1024 frames in either direction, or a new session, resets the reassembler. ### 5.4 FEC_FRAME_SLICE `0x15` (H→C) — 20-byte header + parity Single XOR parity per group. Group size is carried per packet (`slice_count`); v2 hosts use 4 (last group may be shorter). Send order: a group's data slices, then its parity. ``` [0..2) magic [2] 0x15 [3] flags (bit0 keyframe) [4..8) frame_index [8..10) start_slice_index [10..12) slice_count (≥1) [12..14) total_slices [14..16) xor of payload lengths [16..20) timestamp_us [20..) xor of payloads (zero-padded to the longest; 1..1348 B) ``` `start_slice_index + slice_count ≤ total_slices`. One lost data slice per group can be rebuilt. ### 5.5 AUDIO_FRAME `0x18` (H→C) — 16-byte header + payload 0..1352 ``` [0..2) magic [2] 0x18 [3] flags [4..8) sequence (u32) [8..12) timestamp_us (u32) [12..14) samples per channel (u16) [14] channels [15] format (1 = PCM16 LE interleaved — sample data is LE, header fields BE; 2 = float32; 3 = Opus) [16..) payload ``` ### 5.6 INPUT_EVENT `0x20` (C→H) — 16 bytes ``` [0..2) magic [2] 0x20 [3] type [4..8) p1 [8..12) p2 [12..16) sequence (u32) ``` | type | name | p1 | p2 | | :--- | :--- | :--- | :--- | | 1 | MOUSE_MOVE_ABS | f32 x ∈ [0,1] of the video frame (finite) | f32 y ∈ [0,1] | | 2 | MOUSE_BUTTON | button: 1 L, 2 R, 3 M, 4 X1, 5 X2 | 1 = down, 0 = up | | 3 | KEY | Windows virtual-key code (1..255) | 1 = down, 0 = up | | 4 | GAMEPAD_BUTTONS | full XInput `wButtons` bitmask (state, not edge; ≤ 0xFFFF) | reserved 0 | | 5 | GAMEPAD_LEFT_STICK | i32 x ∈ [−32768, 32767] | i32 y (positive = up) | | 6 | GAMEPAD_RIGHT_STICK | i32 x | i32 y | | 7 | GAMEPAD_TRIGGERS | left 0..255 | right 0..255 | | 8 | MOUSE_MOVE_REL | i32 dx | i32 dy | | 9 | MOUSE_WHEEL | i32 vertical (120 = one notch away from user) | i32 horizontal | Values outside these domains are rejected by sender and receiver. XInput bitmask: DPAD_UP 0x0001, DPAD_DOWN 0x0002, DPAD_LEFT 0x0004, DPAD_RIGHT 0x0008, START 0x0010, BACK 0x0020, LEFT_THUMB 0x0040, RIGHT_THUMB 0x0080, LEFT_SHOULDER 0x0100, RIGHT_SHOULDER 0x0200, GUIDE 0x0400, A 0x1000, B 0x2000, X 0x4000, Y 0x8000. Gamepad types carry **state**, so a lost packet cannot leave a button stuck: clients resend the full gamepad state (types 4–7) every 250 ms while any control is non-neutral, and keep resending the neutral state for 1 s after returning to neutral. Absolute coordinates are relative to the decoded video frame (clients remove letterboxing before normalising). Input is accepted only after the host has sent HANDSHAKE_RESP status 0. ### 5.7 PING `0x30` / PONG `0x31` — 16 bytes ``` [0..2) magic [2] 0x30|0x31 [3] reserved 0 [4..12) timestamp_us (u64, sender clock) [12..16) sequence (u32) ``` Receiver of PING echoes the timestamp and sequence in PONG. Clients PING every 1 s; this doubles as keep-alive. ### 5.8 KEYFRAME_REQUEST `0x36` (C→H) — 16 bytes ``` [0..2) magic [2] 0x36 [3] reason (1 decoder error, 2 frame loss, 3 startup) [4..8) last_complete_frame_index (u32) [8..12) sequence (u32) [12..16) reserved 0 ``` Host answers with an IDR, rate-limited to one per 100 ms per session. Clients also rate-limit to one per 100 ms and retry while still waiting for a keyframe (interval max(100 ms, 2·SRTT)). ### 5.9 PAIRING_GRANT `0x64` (H→C) — 52 bytes ``` [0..2) magic [2] 0x64 [3] version 2 [4..20) pairing_id [20..52) pairing_secret ``` Sent after a successful mode-0 handshake (after HANDSHAKE_RESP status 0) when HANDSHAKE_REQ flag bit0 is set. The host **retransmits it every 250 ms (max 20 transmissions) until it receives PAIRING_GRANT_ACK** or the session ends. Clients store the credentials in platform secure storage (DPAPI / Keystore / Keychain), keyed by host. **Host storage**: SPAKE2 is a balanced PAKE, so the host must keep the **pairing secret itself** (not a hash) to run mode 1. The host application stores `pairing_id → pairing_secret` encrypted at rest (Windows DPAPI) and allows revocation (revoked id → `HANDSHAKE_ERROR 2`). Compromise of the host's store exposes all pairing secrets; clients then re-pair with a PIN. ### 5.10 PAIRING_GRANT_ACK `0x65` (C→H) — 20 bytes ``` [0..2) magic [2] 0x65 [3] version 2 [4..20) pairing_id ``` Sent by the client for every PAIRING_GRANT copy it receives (duplicates are acknowledged but surfaced to the application once). ### 5.11 DISCONNECT `0x6F` — 4 bytes `[0..2)` magic, `[2]` 0x6F, `[3]` reason (0 normal, 1 timeout, 2 kicked, 3 shutting down). Sessions also time out after **5 s** without an authenticated packet. ### 5.12 NETWORK_FEEDBACK `0x35` (C→H) — 24 bytes ``` [0..2) magic [2] 0x35 [3] fraction_lost (0..255 = lost/expected of host→client datagrams in the interval) [4..8) sequence [8..12) highest (newest resolved) frame_index [12..16) smoothed RTT µs (0 = no sample) [16..20) jitter µs (RFC 3550 interarrival jitter of video frames) [20..24) datagrams lost in the interval ``` Loss is computed from gaps in the host's SECURE counter. Host logs it; rate control is a later milestone (see `docs/ROADMAP.md` M5). ### 5.13 SET_MONITOR_REQ `0x42` (C→H) / SET_MONITOR_RESP `0x43` (H→C) — 8 bytes each ``` REQ: [0..2) magic [2] 0x42 [3] reserved 0 [4..8) monitor_id (u32) RESP: [0..2) magic [2] 0x43 [3] status (0 OK, 1 error) [4..8) active monitor_id (u32) ``` ### 5.14 STREAM_CONFIG_UPDATE `0x45` (C→H) — 10 bytes ``` [0..2) magic [2] 0x45 [3] flags (0x01 4:4:4, 0x02 HDR10, 0x04 clipboard sync) [4..8) bitrate_bps [8..10) fps ``` Flag `0x04` MUST reflect the client's real clipboard setting; it updates the session's clipboard state on both ends. ### 5.15 CLIPBOARD_DATA `0x50` (both directions) — 8-byte header + text ``` [0..2) magic [2] 0x50 [3] format (1 = UTF-8) [4..8) length (u32) [8..8+length) text ``` Total length MUST equal 8 + length; text MUST be valid UTF-8. Max text 1360 bytes per packet; longer text is truncated at a UTF-8 character boundary (no multi-packet clipboard in v2). Only exchanged while the client's clipboard sync is enabled. ## 6. Rendezvous (WAN room codes) See [`docs/RENDEZVOUS.md`](RENDEZVOUS.md). Codes are 8 Crockford-base32 characters (`XXXX-XXXX`; input is normalised: uppercase, `-` stripped, `O`→`0`, `I`/`L`→`1`), resolved via the Cloudflare rendezvous Worker into host candidate addresses. Learning a code grants nothing: the PIN/pairing handshake above is still required. Legacy 6-character codes (reversible LAN IP:port encoding over 13 well-known ports) remain accepted for offline LAN use. Public-address discovery uses STUN `stun.cloudflare.com:3478` **from the stream socket itself**.