From 91c90aabb3f0ff720eec66ff294a14402b5d6fe5 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 13:41:48 -0500 Subject: [PATCH 1/5] Transport v3, phase 2b-iii: the return-routability cookie Under load, a session or pairing message 1 must carry a valid cookie for its exact source address (IP and port) before the host reads it, answers it from a cache, resends message 2 or uses the live address's reserve. Without one the host sends only a 64-byte cookie reply, for a known dev_id or while a QR window is open, and does no Noise read and no Diffie-Hellman. - Cookie: HMAC-SHA-256(secret, address)[..16]; the 32-byte secret rotates every 120 s and the previous one is still accepted. - Cookie reply: packet type 0x42, sealed with ChaCha20-Poly1305 under a key from K_dev or the QR PSK, with SHA-256 of the message 1 as AAD. - Message 1 with a cookie: a trailing EXT_COOKIE extension carrying HMAC-SHA-256(cookie, message 1)[..16]. - Load: 400 message 1 arrivals in this second or the one before, or half the host-wide Diffie-Hellman budget in use. - The reference session and pairing clients attach the cookie and resend at once when a reply arrives. Clients without cookie support connect whenever the host isn't under load. Adds vectors written by an independent Python script, the v3_cookie fuzz target, cookie parsers in v3_parsers, flood and rotation tests, and runs the host's crash-at-every-step tests with cookies forced on. --- .github/workflows/proto-fuzz.yml | 2 +- Cargo.lock | 1 + host/src/transport/v3.rs | 44 ++- host/src/transport/v3/tests.rs | 57 +++- proto/Cargo.toml | 2 + proto/fuzz/Cargo.lock | 1 + proto/fuzz/Cargo.toml | 7 + proto/fuzz/fuzz_targets/v3_cookie.rs | 311 +++++++++++++++++++ proto/fuzz/fuzz_targets/v3_parsers.rs | 25 ++ proto/src/v3/client.rs | 150 +++++++++- proto/src/v3/cookie.rs | 413 ++++++++++++++++++++++++++ proto/src/v3/cookie/tests.rs | 314 ++++++++++++++++++++ proto/src/v3/host.rs | 386 +++++++++++++++++++++++- proto/src/v3/mod.rs | 1 + proto/src/v3/pairing.rs | 98 +++++- proto/src/v3/pairing/tests.rs | 141 ++++++++- proto/testdata/v3/cookie_vectors.py | 194 ++++++++++++ proto/testdata/v3/cookie_vectors.txt | 25 ++ proto/tests/v3_session_sim.rs | 62 +++- proto/tests/v3_sim/mod.rs | 3 + 20 files changed, 2189 insertions(+), 48 deletions(-) create mode 100644 proto/fuzz/fuzz_targets/v3_cookie.rs create mode 100644 proto/src/v3/cookie.rs create mode 100644 proto/src/v3/cookie/tests.rs create mode 100644 proto/testdata/v3/cookie_vectors.py create mode 100644 proto/testdata/v3/cookie_vectors.txt diff --git a/.github/workflows/proto-fuzz.yml b/.github/workflows/proto-fuzz.yml index 35ab3c5b..57e811f7 100644 --- a/.github/workflows/proto-fuzz.yml +++ b/.github/workflows/proto-fuzz.yml @@ -50,7 +50,7 @@ jobs: # uploaded. A crash is reported by target name only. run: | set -uo pipefail - targets="credstore_formats credstore_ops v3_handshake v3_pairing v3_parsers v3_record_open v3_replay_window v3_session_sim" + targets="credstore_formats credstore_ops v3_cookie v3_handshake v3_pairing v3_parsers v3_record_open v3_replay_window v3_session_sim" listed=$(cargo "+$FUZZ_TOOLCHAIN" fuzz list | sort | tr '\n' ' ' | sed 's/ $//') if [ "$listed" != "$targets" ]; then echo "::error::fuzz targets are '$listed', expected '$targets'" diff --git a/Cargo.lock b/Cargo.lock index e6c7f9e7..82b30654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1354,6 +1354,7 @@ version = "0.3.4" dependencies = [ "ring", "snow", + "subtle", "tracing", "zeroize", ] diff --git a/host/src/transport/v3.rs b/host/src/transport/v3.rs index ca974d4d..b8bad57d 100644 --- a/host/src/transport/v3.rs +++ b/host/src/transport/v3.rs @@ -38,6 +38,7 @@ use std::time::{Duration, Instant}; use eternal_credstore::{CredentialSnapshot, Finalize, StoreError}; use eternal_wire::v2::control::{ByeReason, ControlMessage, CONTROL_HEADER_SIZE}; use eternal_wire::v2::{CommonPrefix, PacketType}; +use eternal_wire::v3::cookie::CookieMode; use eternal_wire::v3::host::{ CloseReason, HostConfig, HostCredentials, HostOutput, HostSessions, PeerAddr, }; @@ -61,6 +62,18 @@ impl PeerAddr for PeerId { fn rate_bucket(&self) -> Self::Bucket { (self.link, self.addr.map(|addr| addr.rate_bucket())) } + + /// The link, then the source's IP and port if it has one. + fn cookie_input(&self) -> Vec { + let mut out = match self.link { + LinkId::Udp => vec![0], + LinkId::Usb { device_id } => [&[1][..], &device_id.to_le_bytes()].concat(), + }; + if let Some(addr) = self.addr { + out.extend(addr.cookie_input()); + } + out + } } /// How often pending records are checked for expiry. @@ -383,8 +396,32 @@ impl V3Host { now: Instant, wall_us: u64, ) -> Self { + Self::with_cookies( + host_id, + snapshot, + pairings, + rng, + now, + wall_us, + CookieMode::UnderLoad, + ) + } + + /// [`new`](Self::new), with message 1's cookies required as `cookies` + /// says on both handshakes: tests run the cookie path with `Always`. + pub(super) fn with_cookies( + host_id: Id16, + snapshot: Option>, + pairings: Pairings, + rng: Box, + now: Instant, + wall_us: u64, + cookies: CookieMode, + ) -> Self { + let mut config = host_config(host_id); + config.cookies = cookies; Self { - sessions: HostSessions::new(host_config(host_id), rng, Duration::ZERO, wall_us), + sessions: HostSessions::new(config, rng, Duration::ZERO, wall_us), creds: Credentials { snapshot: snapshot.unwrap_or_default(), accept: Accept::default(), @@ -395,7 +432,10 @@ impl V3Host { streaming: None, outbox: VecDeque::new(), pairing: PairingHost::new( - PairingConfig::default(), + PairingConfig { + cookies, + ..PairingConfig::default() + }, host_id, env!("CARGO_PKG_VERSION"), "", diff --git a/host/src/transport/v3/tests.rs b/host/src/transport/v3/tests.rs index 49f0cdeb..d057bd23 100644 --- a/host/src/transport/v3/tests.rs +++ b/host/src/transport/v3/tests.rs @@ -103,16 +103,21 @@ impl Rig { } fn with(store: Store, live: Live) -> Self { + Self::with_cookies(store, live, CookieMode::UnderLoad) + } + + fn with_cookies(store: Store, live: Live, cookies: CookieMode) -> Self { let start = Instant::now(); let mut session = Session::new(100); session.require_v3(); - let host = V3Host::new( + let host = V3Host::with_cookies( HOST_ID, Some(store.snapshot()), Pairings::Ready, Box::new(SeededRandom::new(9)), start, WALL_S * 1_000_000, + cookies, ); Self { host, @@ -320,6 +325,19 @@ fn to_pad(rig: &mut Rig, pad: &mut Pad, sends: Vec<(PeerId, Vec)>) { settle(rig, pad, outputs); } +/// Sends a pairing message 1 and gives back what answers it, after the +/// cookie round trip if the host asks for one. +fn pair_msg1(rig: &mut Rig, from: SocketAddr, ipad: &mut PairingClient) -> Vec<(PeerId, Vec)> { + let first = rig.receive(from, ipad.msg1()); + match first.as_slice() { + [(_, reply)] => match ipad.handle_cookie(reply) { + Some(msg1) => rig.receive(from, &msg1), + None => first, + }, + _ => first, + } +} + /// Moves time on in steps, ticking both sides like the real loops do. fn advance(rig: &mut Rig, pad: &mut Pad, by: Duration) { let end = rig.now + by; @@ -1071,17 +1089,31 @@ fn liveness_ends_the_v3_session_with_a_sealed_goodbye() { /// /// With `in_commit` the crash points are those of the commit itself; /// otherwise the session commits first and `job` is killed. +/// Each crash point runs with and without message 1's cookie round trip. fn crash_each_step( records: &[(u8, u8, Option, bool, u64)], dev: u8, in_commit: bool, job: fn(&mut Rig, &mut Pad), check: fn(&Store, bool), +) { + for cookies in [CookieMode::UnderLoad, CookieMode::Always] { + crash_each_step_with(records, dev, in_commit, job, check, cookies); + } +} + +fn crash_each_step_with( + records: &[(u8, u8, Option, bool, u64)], + dev: u8, + in_commit: bool, + job: fn(&mut Rig, &mut Pad), + check: fn(&Store, bool), + cookies: CookieMode, ) { let world = world(records); for k in 0.. { let (store, _, live) = world.open(); - let mut rig = Rig::with(store, live); + let mut rig = Rig::with_cookies(store, live, cookies); let mut pad = Pad::new(50_000); if !in_commit { connect(&mut rig, &mut pad, dev); @@ -1094,6 +1126,11 @@ fn crash_each_step( job(&mut rig, &mut pad); let crashed = rig.live.steps.count.get() > base + k; rig.live.steps.crash_at.set(None); + assert_eq!( + rig.host.table().cookie_replies() > 0, + cookies == CookieMode::Always, + "the cookie path ran only when required" + ); check_invariants(rig.store()); check(rig.store(), crashed); assert_eq!(rig.host.held_invalid(), 0, "every job released its holds"); @@ -1617,7 +1654,7 @@ fn pair( &mut SeededRandom::new(u64::from(slot) + 40), ) .unwrap(); - let msg2 = rig.receive(from, ipad.msg1()); + let msg2 = pair_msg1(rig, from, &mut ipad); assert_eq!(msg2.len(), 1, "message 2"); let accept = ipad.read_msg2(&msg2[0].1).unwrap(); let key = ClientKey { @@ -1770,10 +1807,16 @@ fn pairings_that_are_unavailable_open_no_window() { /// went out only if it is, and the iPad's next confirm finishes the job. #[test] fn a_crash_in_the_pairing_write_never_answers_what_isnt_stored() { + for cookies in [CookieMode::UnderLoad, CookieMode::Always] { + crash_in_the_pairing_write(cookies); + } +} + +fn crash_in_the_pairing_write(cookies: CookieMode) { for k in 0.. { let world = world(&[]); let (store, _, live) = world.open(); - let mut rig = Rig::with(store, live); + let mut rig = Rig::with_cookies(store, live, cookies); let pad = Pad::new(50_000); let at = rig.at(); let qr = rig.host.open_pairing(at, host_addr(), "PC").unwrap(); @@ -1786,9 +1829,13 @@ fn a_crash_in_the_pairing_write_never_answers_what_isnt_stored() { &mut SeededRandom::new(3), ) .unwrap(); - let msg2 = rig.receive(pad.addr, ipad.msg1()); + let msg2 = pair_msg1(&mut rig, pad.addr, &mut ipad); let accept = ipad.read_msg2(&msg2[0].1).unwrap(); let dev_id = accept.dev_id; + assert_eq!( + rig.host.pairing.cookie_replies() > 0, + cookies == CookieMode::Always + ); let base = rig.live.steps.count.get(); rig.live.steps.crash_at.set(Some(base + k)); let confirm = ipad.confirm(None).unwrap(); diff --git a/proto/Cargo.toml b/proto/Cargo.toml index 6eb48f10..807303fe 100644 --- a/proto/Cargo.toml +++ b/proto/Cargo.toml @@ -23,6 +23,8 @@ snow = { version = "0.10", default-features = false, features = [ "risky-raw-split", ] } ring = "0.17" +# Constant-time comparison of the cookie MAC (already in the tree via snow). +subtle = "2.6" zeroize = "1" [[example]] diff --git a/proto/fuzz/Cargo.lock b/proto/fuzz/Cargo.lock index 82e2c329..48fb06f7 100644 --- a/proto/fuzz/Cargo.lock +++ b/proto/fuzz/Cargo.lock @@ -180,6 +180,7 @@ version = "0.3.4" dependencies = [ "ring", "snow", + "subtle", "tracing", "zeroize", ] diff --git a/proto/fuzz/Cargo.toml b/proto/fuzz/Cargo.toml index 551babd7..dc179aec 100644 --- a/proto/fuzz/Cargo.toml +++ b/proto/fuzz/Cargo.toml @@ -51,6 +51,13 @@ test = false doc = false bench = false +[[bin]] +name = "v3_cookie" +path = "fuzz_targets/v3_cookie.rs" +test = false +doc = false +bench = false + [[bin]] name = "v3_session_sim" path = "fuzz_targets/v3_session_sim.rs" diff --git a/proto/fuzz/fuzz_targets/v3_cookie.rs b/proto/fuzz/fuzz_targets/v3_cookie.rs new file mode 100644 index 00000000..9fa6b897 --- /dev/null +++ b/proto/fuzz/fuzz_targets/v3_cookie.rs @@ -0,0 +1,311 @@ +//! The return-routability cookie on both handshakes, with cookies required: +//! a genuine iPad at one address, and fuzzer-chosen replays, bit flips and +//! forgeries of what it and the host sent, from its address or others, +//! across the cookie secret's rotations. +//! +//! What must hold: +//! - no message 1 from an address other than the iPad's is read or +//! answered with anything but a cookie reply, and a reply is smaller than +//! what it answers; +//! - message 1 for an unknown `dev_id` gets nothing at all; +//! - the iPad takes a cookie (and resends) only from a reply the host +//! produced, byte for byte, for the message 1 it's sending; +//! - nothing panics. +#![no_main] + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::time::Duration; + +use eternal_wire::v3::client::{Client, ClientConfig, ClientKey, ClientOutput}; +use eternal_wire::v3::cookie::{self, CookieMode}; +use eternal_wire::v3::handshake::Initiator; +use eternal_wire::v3::host::{HostConfig, HostCredentials, HostOutput, HostSessions}; +use eternal_wire::v3::pairing::{ + PairCredentials, PairOutput, PairingClient, PairingConfig, PairingHost, +}; +use eternal_wire::v3::wire::{self, Accept, HelloPayload, SlotStatus, V3Kind}; +use eternal_wire::v3::{Id16, Key32, SeededRandom, Zeroizing}; +use libfuzzer_sys::fuzz_target; + +const HOST: Id16 = [0xB0; 16]; +const DEV: Id16 = [0xD0; 16]; +const K: Key32 = [0x42; 32]; +const IPAD: u32 = 1; +const WALL: u64 = 1_762_000_000_000_000; + +struct Creds; + +impl HostCredentials for Creds { + fn key(&self, dev_id: &Id16) -> Option> { + (*dev_id == DEV).then(|| Zeroizing::new(K)) + } + fn last_committed_timestamp(&self, _: &Id16) -> u64 { + 0 + } + fn accept_template(&self, _: &Id16) -> Accept { + Accept::default() + } + fn slot_status(&self, _: &Id16) -> SlotStatus { + SlotStatus::default() + } +} + +impl PairCredentials for Creds { + fn active_in_slot(&self, _: &Id16) -> Option<(Id16, Zeroizing)> { + None + } + fn slot_status(&self, _: &Id16) -> SlotStatus { + SlotStatus::default() + } +} + +fn source(b: u8) -> u32 { + if b & 0x80 == 0 { + IPAD + } else { + 2 + u32::from(b & 0x7F) + } +} + +fn flip(datagram: &mut [u8], b: u8) { + if !datagram.is_empty() { + let at = usize::from(b) * 7 % datagram.len(); + datagram[at] ^= 1 << (b % 8); + } +} + +fuzz_target!(|data: &[u8]| { + let Some((&mode, data)) = data.split_first() else { + return; + }; + if mode & 1 == 0 { + session(mode, data); + } else { + pairing(mode, data); + } +}); + +fn session(mode: u8, data: &[u8]) { + let config = HostConfig { + cookies: CookieMode::Always, + ..HostConfig::new(HOST) + }; + let mut now = Duration::from_secs(100); + let mut host: HostSessions = HostSessions::new( + config, + Box::new(SeededRandom::new(u64::from(mode))), + now, + WALL, + ); + let creds = Creds; + let mut ipad = Client::new( + ClientConfig::new(HOST), + HelloPayload::default(), + Box::new(SeededRandom::new(7)), + ); + let key = ClientKey { + dev_id: DEV, + k_dev: K, + }; + let clock = |now: Duration| WALL + u64::try_from(now.as_micros()).unwrap(); + // What the iPad sent, and what the host sent it (with how often). + let mut from_ipad: Vec> = Vec::new(); + let mut to_ipad: HashMap, u32> = HashMap::new(); + let mut inbox: Vec> = Vec::new(); + + let to_host = |host: &mut HostSessions, + now: Duration, + from: u32, + datagram: &[u8], + to_ipad: &mut HashMap, u32>, + inbox: &mut Vec>| { + let (reads, dh) = (host.msg1_reads(), host.handshakes_performed()); + let out = host.handle_datagram(now, &from, datagram, &creds); + let foreign_msg1 = from != IPAD && wire::classify(datagram) == V3Kind::Handshake1; + if from != IPAD { + assert_eq!(host.msg1_reads(), reads, "a read for another address"); + assert_eq!(host.handshakes_performed(), dh); + } + for o in out { + if let HostOutput::Send { to, datagram: d } = o { + if foreign_msg1 || cookie::is_reply(&d) { + assert!(cookie::is_reply(&d), "only a cookie reply"); + assert!(d.len() < datagram.len(), "smaller than its message 1"); + } + if to == IPAD { + *to_ipad.entry(d.clone()).or_default() += 1; + inbox.push(d); + } + } + } + }; + + for chunk in data.chunks_exact(3).take(96) { + let (op, a, b) = (chunk[0], chunk[1], chunk[2]); + now += Duration::from_millis(u64::from(a) * 3); + let mut client_out = Vec::new(); + match op % 8 { + 0 => client_out = ipad.connect(now, key.clone(), clock(now)), + 1 => client_out = ipad.poll(now, clock(now)), + 2 => { + // The host's datagrams reach the iPad, maybe flipped. + for mut d in std::mem::take(&mut inbox) { + if b & 1 == 1 { + flip(&mut d, a); + } + let genuine = to_ipad.contains_key(&d); + let out = ipad.handle_datagram(now, &d, clock(now)); + if cookie::is_reply(&d) && !out.is_empty() { + assert!(genuine, "a cookie from a reply the host never sent"); + } + client_out.extend(out); + } + } + 3 | 4 => { + // A replay or flip of the iPad's datagrams from some address. + if !from_ipad.is_empty() { + let mut d = from_ipad[usize::from(a) % from_ipad.len()].clone(); + if op % 8 == 4 { + flip(&mut d, b); + } + to_host(&mut host, now, source(b), &d, &mut to_ipad, &mut inbox); + } + } + 5 => { + // A forged message 1, for the iPad's dev_id or an unknown one. + let dev_id = if b & 1 == 0 { DEV } else { [b; 16] }; + let hello = HelloPayload { + timestamp_us: u64::from(a) + 1, + ..Default::default() + }; + let forged = Initiator::start(&[a; 32], &dev_id, &HOST, 1, &hello).unwrap(); + let before = host.cookie_replies(); + to_host( + &mut host, + now, + source(b), + forged.msg1(), + &mut to_ipad, + &mut inbox, + ); + if dev_id != DEV { + assert_eq!( + host.cookie_replies(), + before, + "a reply for an unknown dev_id" + ); + } + } + 6 => { + // Long enough to rotate the secret once or twice. + now += Duration::from_secs(u64::from(b)); + host.poll(now); + } + _ => { + // A host reply, maybe flipped, back at the host. + if let Some(d) = to_ipad.keys().nth(usize::from(a) % to_ipad.len().max(1)) { + let mut d = d.clone(); + flip(&mut d, b); + to_host(&mut host, now, source(b), &d, &mut to_ipad, &mut inbox); + } + } + } + for output in client_out { + if let ClientOutput::Send(d) = output { + from_ipad.push(d.clone()); + to_host(&mut host, now, IPAD, &d, &mut to_ipad, &mut inbox); + } + } + } +} + +fn pairing(mode: u8, data: &[u8]) { + let config = PairingConfig { + cookies: CookieMode::Always, + ..PairingConfig::default() + }; + let mut host: PairingHost = PairingHost::new( + config, + HOST, + "0.4.0", + "PC", + Box::new(SeededRandom::new(u64::from(mode))), + ); + let creds = Creds; + let mut now = Duration::from_secs(100); + let qr = host.open(now, SocketAddr::from(([10, 0, 0, 1], 47_700))); + let mut rng = SeededRandom::new(9); + let mut ipad = + PairingClient::start(&qr, &[0x5A; 16], "0.4.0", "iPad", 50_000, &mut rng).unwrap(); + let mut seen: Vec> = vec![ipad.msg1().to_vec()]; + let mut replies: Vec> = Vec::new(); + + for chunk in data.chunks_exact(3).take(96) { + let (op, a, b) = (chunk[0], chunk[1], chunk[2]); + now += Duration::from_millis(u64::from(a) * 3); + let (from, mut datagram) = match op % 5 { + 0 => (IPAD, ipad.msg1().to_vec()), + 1 | 2 => { + let mut d = seen[usize::from(a) % seen.len()].clone(); + if op % 5 == 2 { + flip(&mut d, b); + } + (source(b), d) + } + 3 => { + // A cookie reply, maybe flipped, to the iPad. + if let Some(reply) = replies.get(usize::from(a) % replies.len().max(1)) { + let mut d = reply.clone(); + if b & 1 == 1 { + flip(&mut d, a); + } + let genuine = replies.contains(&d); + if let Some(msg1) = ipad.handle_cookie(&d) { + assert!(genuine, "a cookie from a reply the host never sent"); + seen.push(msg1.clone()); + (IPAD, msg1) + } else { + continue; + } + } else { + continue; + } + } + _ => { + now += Duration::from_secs(u64::from(b)); + host.poll(now); + continue; + } + }; + if op % 5 == 0 && b & 1 == 1 { + flip(&mut datagram, a); + } + let in_flight = host.in_flight(now); + for output in host.handle_datagram(now, &from, &datagram, &creds) { + match output { + PairOutput::Send { to, datagram: d } => { + if from != IPAD && wire::classify(&datagram) == V3Kind::Handshake1 { + assert!(cookie::is_reply(&d), "only a cookie reply"); + } + if cookie::is_reply(&d) { + assert!(d.len() < datagram.len()); + if to == IPAD { + replies.push(d); + } + } + } + PairOutput::Store(_) | PairOutput::Paired { .. } => { + panic!("no confirm is ever sent here") + } + } + } + if from != IPAD { + assert_eq!( + host.in_flight(now), + in_flight, + "another address started a pairing" + ); + } + } +} diff --git a/proto/fuzz/fuzz_targets/v3_parsers.rs b/proto/fuzz/fuzz_targets/v3_parsers.rs index 7b9a4d47..46db7905 100644 --- a/proto/fuzz/fuzz_targets/v3_parsers.rs +++ b/proto/fuzz/fuzz_targets/v3_parsers.rs @@ -2,6 +2,7 @@ //! re-encodes to something that parses back to the same value. #![no_main] +use eternal_wire::v3::cookie::{self, CookieReply}; use eternal_wire::v3::pairing::QrPayload; use eternal_wire::v3::record::RecordHeader; use eternal_wire::v3::wire::{ @@ -13,6 +14,30 @@ fuzz_target!(|data: &[u8]| { let _ = wire::classify(data); let _ = RecordHeader::parse(data); + if let Ok(reply) = CookieReply::parse(data) { + assert_eq!( + reply.encode(), + data, + "a cookie reply has exactly one encoding" + ); + assert!(cookie::is_reply(data)); + assert_eq!(wire::classify(data), wire::V3Kind::Other); + } + match cookie::split(data) { + (canon, Some(mac)) => { + // Exactly one cookie after the handshake extension, stripped, + // with only payload_len rewritten. + let end = canon.len(); + assert_eq!(data.len(), end + 20); + assert_eq!(canon[..6], data[..6]); + assert_eq!(canon[8..], data[8..end]); + assert_eq!(data[end..end + 4], [0xFF, 0x00, 16, 0]); + assert_eq!(data[end + 4..], mac); + assert_eq!(cookie::split(&canon).1, None); + } + (canon, None) => assert_eq!(&*canon, data), + } + if let Ok(msg1) = wire::parse_msg1(data) { let port = u16::from_le_bytes([data[22], data[23]]); let again = wire::encode_msg1(msg1.client_nonce, port, &msg1.dev_id, msg1.noise); diff --git a/proto/src/v3/client.rs b/proto/src/v3/client.rs index 273ed178..e8945b80 100644 --- a/proto/src/v3/client.rs +++ b/proto/src/v3/client.rs @@ -30,6 +30,7 @@ use std::time::Duration; +use super::cookie::{self, CookieReply, HeldCookie}; use super::handshake::Initiator; use super::record::{Opener, RecordHeader, Sealer}; use super::wire::{self, Accept, HelloPayload, Msg2Payload, SessionMessage, SlotStatus, V3Kind}; @@ -286,6 +287,8 @@ pub struct Client { /// them. Their live session, and an abandoned attempt the host may have /// committed (the commit that reached the ceiling), stay usable. no_new_attempts: Vec, + /// The host's last cookie: attached to every message 1 while fresh. + cookie: Option, } /// A confirming attempt the iPad stopped retrying. Nothing is sealed under @@ -316,6 +319,15 @@ impl Client { intent: None, clock_limit: None, no_new_attempts: Vec::new(), + cookie: None, + } + } + + /// Message 1 as sent now: with the host's cookie while it's fresh. + fn msg1_datagram(cookie: Option, now: Duration, initiator: &Initiator) -> Vec { + match cookie.filter(|c| c.fresh(now)) { + Some(held) => cookie::attach(initiator.msg1(), &held.cookie), + None => initiator.msg1().to_vec(), } } @@ -482,7 +494,11 @@ impl Client { &hello, ) .expect("message 1 always fits"); - out.push(ClientOutput::Send(initiator.msg1().to_vec())); + out.push(ClientOutput::Send(Self::msg1_datagram( + self.cookie, + now, + &initiator, + ))); self.attempt = Attempt::Connecting { initiator: Box::new(initiator), key, @@ -499,6 +515,10 @@ impl Client { clock_us: u64, ) -> Vec { let mut out = Vec::new(); + if cookie::is_reply(datagram) { + self.handle_cookie(now, datagram, &mut out); + return out; + } match wire::classify(datagram) { V3Kind::Handshake2 => self.handle_msg2(now, datagram, clock_us, &mut out), V3Kind::Sealed => self.handle_record(now, datagram, &mut out), @@ -507,6 +527,38 @@ impl Client { out } + /// A cookie reply for the message 1 being sent: keep the cookie and + /// send message 1 again with it at once. Anything else is dropped: a + /// reply opens only under this attempt's key and its exact message 1. + fn handle_cookie(&mut self, now: Duration, datagram: &[u8], out: &mut Vec) { + let Attempt::Connecting { + initiator, + key, + last_sent, + .. + } = &mut self.attempt + else { + return; + }; + let Ok(reply) = CookieReply::parse(datagram) else { + return; + }; + if reply.tag != super::EXT_SESSION { + return; + } + let k = cookie::session_key(&key.k_dev, &key.dev_id); + let Some(value) = cookie::open(&k, &reply, &cookie::msg1_digest(initiator.msg1())) else { + return; + }; + let held = HeldCookie { + cookie: value, + received: now, + }; + self.cookie = Some(held); + *last_sent = now; + out.push(ClientOutput::Send(cookie::attach(initiator.msg1(), &value))); + } + fn handle_msg2( &mut self, now: Duration, @@ -882,7 +934,11 @@ impl Client { self.fail_attempt(dev_id, &mut out); } else if now.saturating_sub(*last_sent) >= self.config.msg1_retry_interval { *last_sent = now; - out.push(ClientOutput::Send(initiator.msg1().to_vec())); + out.push(ClientOutput::Send(Self::msg1_datagram( + self.cookie, + now, + initiator, + ))); } } Attempt::Confirming { @@ -1276,4 +1332,94 @@ mod tests { assert!(text.contains("body_len: 11"), "{text}"); assert!(!text.contains("102, 114"), "{text}"); } + + /// The host's cookie reply to `msg1` from the iPad's address. + fn cookie_reply(msg1: &[u8], value: &[u8; 16]) -> Vec { + let k = cookie::session_key(&key().k_dev, &key().dev_id); + cookie::seal( + &k, + super::super::EXT_SESSION, + [1; 12], + value, + &cookie::msg1_digest(msg1), + ) + .encode() + } + + #[test] + fn a_cookie_reply_resends_message_1_with_the_cookie_at_once() { + let mut ipad = client(); + let msg1 = sent(&ipad.connect(T0, key(), 1_000)).remove(0); + let value = [0x77; 16]; + let out = sent(&ipad.handle_datagram( + T0 + Duration::from_millis(3), + &cookie_reply(&msg1, &value), + 1_000, + )); + assert_eq!( + out, + vec![cookie::attach(&msg1, &value)], + "at once, with the cookie" + ); + // Retries carry it too, on the usual schedule from the resend. + let at = T0 + Duration::from_millis(3) + ClientConfig::new(HOST).msg1_retry_interval; + assert_eq!( + sent(&ipad.poll(at, 1_000)), + vec![cookie::attach(&msg1, &value)] + ); + // The host reads it as the same message 1. + let (canon, mac) = cookie::split(&out[0]); + assert_eq!(&*canon, &msg1[..]); + assert_eq!(mac, Some(cookie::mac2(&value, &msg1))); + } + + #[test] + fn a_cookie_reply_that_isnt_for_this_attempt_is_dropped() { + let mut ipad = client(); + let msg1 = sent(&ipad.connect(T0, key(), 1_000)).remove(0); + let value = [0x77; 16]; + // For another message 1, under another key, or flipped. + let mut other = msg1.clone(); + other[20] ^= 1; + let wrong_key = cookie::seal( + &[9; 32], + super::super::EXT_SESSION, + [1; 12], + &value, + &cookie::msg1_digest(&msg1), + ) + .encode(); + let mut flipped = cookie_reply(&msg1, &value); + flipped[40] ^= 1; + let mut pairing = cookie_reply(&msg1, &value); + pairing[16] = 2; + for reply in [cookie_reply(&other, &value), wrong_key, flipped, pairing] { + assert!(ipad.handle_datagram(T0, &reply, 1_000).is_empty()); + } + // Nothing was kept: the next retry has no cookie. + let at = T0 + ClientConfig::new(HOST).msg1_retry_interval; + assert_eq!(sent(&ipad.poll(at, 1_000)), vec![msg1.clone()]); + // And with no attempt, a genuine reply is dropped too. + let (mut live, _) = live_client(); + assert!(live + .handle_datagram(T0, &cookie_reply(&msg1, &value), 1_000) + .is_empty()); + } + + #[test] + fn a_held_cookie_goes_on_later_attempts_until_it_gets_old() { + let mut ipad = client(); + let msg1 = sent(&ipad.connect(T0, key(), 1_000)).remove(0); + let value = [0x78; 16]; + ipad.handle_datagram(T0, &cookie_reply(&msg1, &value), 1_000); + // A new attempt within the cookie's lifetime starts with it. + let at = T0 + Duration::from_secs(10); + let next = sent(&ipad.connect(at, key(), 2_000)).remove(0); + let (canon, mac) = cookie::split(&next); + assert_eq!(mac, Some(cookie::mac2(&value, &canon))); + // Past it, without. + let late = T0 + cookie::CLIENT_COOKIE_LIFETIME; + let last = sent(&ipad.connect(late, key(), 3_000)).remove(0); + assert_eq!(cookie::split(&last).1, None); + } } diff --git a/proto/src/v3/cookie.rs b/proto/src/v3/cookie.rs new file mode 100644 index 00000000..83507684 --- /dev/null +++ b/proto/src/v3/cookie.rs @@ -0,0 +1,413 @@ +//! The return-routability cookie for message 1 (both the session and the +//! pairing handshake), in the style of WireGuard's cookie reply. +//! +//! While a host is under load it answers a message 1 that carries no valid +//! cookie for its source with a cookie reply, and does nothing else: no +//! Noise read, no Diffie-Hellman, no per-source window. The iPad sends its +//! message 1 again with the cookie, which proves it receives at that +//! address, so spoofed sources can never reach the host's crypto or its +//! shared budgets. +//! +//! - **The cookie** is `HMAC-SHA-256(secret, address)[..16]`, where the +//! address is the source's IP and port ([`PeerAddr::cookie_input`]) and +//! the secret is 32 random bytes rotated every [`SECRET_LIFETIME`]. The +//! previous secret is still accepted, so a cookie is good for at least +//! that long. +//! - **The cookie reply**, host → iPad, packet type [`COOKIE_TYPE`]: +//! +//! ```text +//! [0..16] v2 control header type 0x42, session_id 0, msg_seq 0 +//! [16..18] extension tag u16 the handshake's (which key opens it) +//! [18..20] length u16 44 +//! [20..32] nonce 12 random bytes +//! [32..64] ChaCha20-Poly1305 seal(cookie), 16-byte tag +//! ``` +//! +//! The AAD is `SHA-256` of the message 1 it answers, without any cookie. +//! The key ([`session_key`], [`pairing_key`]) comes from the handshake's +//! own secret, so only the iPad that sent the message 1 can read it. +//! - **Message 1 with a cookie** ends in one more extension, +//! [`EXT_COOKIE`], length 16: `mac2 = HMAC-SHA-256(cookie, message 1 +//! without it)[..16]`. The authenticated part of message 1 is unchanged. +//! +//! [`PeerAddr::cookie_input`]: super::host::PeerAddr::cookie_input + +use std::time::Duration; + +use super::pairing::hkdf32; +use super::wire::{self, V3Kind, EXTENSION_HEADER_SIZE, MSG1_EXTENSION_OFFSET}; +use super::{Id16, Key32, Random, Zeroizing, EXT_PAIR_QR, EXT_SESSION}; +use crate::v2::control::CONTROL_HEADER_SIZE; +use crate::v2::{WireError, MAGIC, VERSION}; + +/// Packet type of a cookie reply. Not a v2 type, so v2 receivers drop it. +pub const COOKIE_TYPE: u8 = 0x42; +/// Extension tag of a message 1's cookie MAC. +pub const EXT_COOKIE: u16 = 0x00FF; +pub const COOKIE_LEN: usize = 16; +pub const MAC2_LEN: usize = 16; +/// Bytes of a cookie reply: the header, the extension header, nonce, sealed +/// cookie and tag. +pub const COOKIE_REPLY_SIZE: usize = CONTROL_HEADER_SIZE + EXTENSION_HEADER_SIZE + 12 + 32; +/// How long a cookie secret is used for new cookies; the previous one is +/// still accepted for as long again. +pub const SECRET_LIFETIME: Duration = Duration::from_secs(120); +/// How long an iPad attaches a cookie it got. Below [`SECRET_LIFETIME`], so +/// the host still accepts it. +pub const CLIENT_COOKIE_LIFETIME: Duration = Duration::from_secs(110); +/// HKDF salt of the cookie keys. +pub const COOKIE_KEY_SALT: &[u8] = b"EM v3 cookie"; + +fn hmac16(key: &[u8], data: &[u8]) -> [u8; 16] { + let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, key); + let tag = ring::hmac::sign(&key, data); + tag.as_ref()[..16].try_into().expect("16 of 32 bytes") +} + +/// The key a session's cookie reply is sealed under: +/// HKDF-SHA-256(salt `"EM v3 cookie"`, IKM `K_dev`, info `dev_id`). +pub fn session_key(k_dev: &Key32, dev_id: &Id16) -> Zeroizing { + hkdf32(COOKIE_KEY_SALT, k_dev, dev_id) +} + +/// The key a pairing's cookie reply is sealed under: HKDF-SHA-256(salt +/// `"EM v3 cookie"`, IKM the QR pairing PSK, info `"pair"`). +pub fn pairing_key(psk: &Key32) -> Zeroizing { + hkdf32(COOKIE_KEY_SALT, psk, b"pair") +} + +/// The cookie for `address` under `secret`. +pub fn cookie_value(secret: &Key32, address: &[u8]) -> [u8; COOKIE_LEN] { + hmac16(secret, address) +} + +/// `HMAC-SHA-256(cookie, message 1 without a cookie)[..16]`. +pub fn mac2(cookie: &[u8; COOKIE_LEN], msg1: &[u8]) -> [u8; MAC2_LEN] { + hmac16(cookie, msg1) +} + +/// The end of a message 1's handshake extension, or `None` if `datagram` +/// isn't a v3 message 1 whose extension fits. +fn handshake_end(datagram: &[u8]) -> Option { + if wire::classify(datagram) != V3Kind::Handshake1 { + return None; + } + let at = MSG1_EXTENSION_OFFSET; + let len = datagram.get(at + 2..at + 4)?; + let end = at + EXTENSION_HEADER_SIZE + usize::from(u16::from_le_bytes([len[0], len[1]])); + (end <= datagram.len()).then_some(end) +} + +/// Writes `payload_len` for a datagram of `len` bytes. +fn set_payload_len(datagram: &mut [u8]) { + let len = u16::try_from(datagram.len() - CONTROL_HEADER_SIZE).expect("fits a datagram"); + datagram[6..8].copy_from_slice(&len.to_le_bytes()); +} + +/// Splits a message 1 into the message 1 without its cookie and the cookie +/// MAC it carries, if any. A message 1 with no cookie comes back as it is. +/// Anything after the handshake extension other than exactly one +/// [`EXT_COOKIE`] of length 16 is left in place, so the handshake's own +/// parser rejects it. +pub fn split(datagram: &[u8]) -> (std::borrow::Cow<'_, [u8]>, Option<[u8; MAC2_LEN]>) { + let Some(end) = handshake_end(datagram) else { + return (datagram.into(), None); + }; + let tail = &datagram[end..]; + if tail.len() != EXTENSION_HEADER_SIZE + MAC2_LEN + || tail[0..2] != EXT_COOKIE.to_le_bytes() + || tail[2..4] != (MAC2_LEN as u16).to_le_bytes() + { + return (datagram.into(), None); + } + let mut msg1 = datagram[..end].to_vec(); + set_payload_len(&mut msg1); + let mac = tail[4..].try_into().expect("16 bytes"); + (msg1.into(), Some(mac)) +} + +/// `msg1` (without a cookie) with `cookie`'s MAC attached. +pub fn attach(msg1: &[u8], cookie: &[u8; COOKIE_LEN]) -> Vec { + let mut out = Vec::with_capacity(msg1.len() + EXTENSION_HEADER_SIZE + MAC2_LEN); + out.extend_from_slice(msg1); + out.extend_from_slice(&EXT_COOKIE.to_le_bytes()); + out.extend_from_slice(&(MAC2_LEN as u16).to_le_bytes()); + out.extend_from_slice(&mac2(cookie, msg1)); + set_payload_len(&mut out); + out +} + +/// A cookie reply as it arrives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CookieReply { + /// The handshake it answers: [`EXT_SESSION`] or [`EXT_PAIR_QR`]. + pub tag: u16, + pub nonce: [u8; 12], + /// The sealed cookie and its tag. + pub sealed: [u8; 32], +} + +impl CookieReply { + pub fn encode(&self) -> Vec { + let mut out = vec![0u8; CONTROL_HEADER_SIZE]; + out[0..2].copy_from_slice(&MAGIC.to_le_bytes()); + out[2] = VERSION; + out[3] = COOKIE_TYPE; + out.extend_from_slice(&self.tag.to_le_bytes()); + out.extend_from_slice(&44u16.to_le_bytes()); + out.extend_from_slice(&self.nonce); + out.extend_from_slice(&self.sealed); + set_payload_len(&mut out); + out + } + + /// Parses exactly what [`encode`](Self::encode) writes. + pub fn parse(datagram: &[u8]) -> Result { + if datagram.len() != COOKIE_REPLY_SIZE { + return Err(WireError::LengthMismatch); + } + if datagram[0..2] != MAGIC.to_le_bytes() || datagram[2] != VERSION { + return Err(WireError::BadMagic); + } + if datagram[3] != COOKIE_TYPE { + return Err(WireError::UnknownType(datagram[3])); + } + let mut expected = [0u8; 16]; + expected[0..2].copy_from_slice(&MAGIC.to_le_bytes()); + expected[2] = VERSION; + expected[3] = COOKIE_TYPE; + expected[6..8].copy_from_slice(&48u16.to_le_bytes()); + if datagram[..16] != expected { + return Err(WireError::InvalidField("header")); + } + let tag = u16::from_le_bytes([datagram[16], datagram[17]]); + if tag != EXT_SESSION && tag != EXT_PAIR_QR { + return Err(WireError::InvalidField("extension_tag")); + } + if datagram[18..20] != 44u16.to_le_bytes() { + return Err(WireError::LengthMismatch); + } + Ok(Self { + tag, + nonce: datagram[20..32].try_into().expect("12 bytes"), + sealed: datagram[32..64].try_into().expect("32 bytes"), + }) + } +} + +fn aead_key(key: &Key32) -> ring::aead::LessSafeKey { + let unbound = ring::aead::UnboundKey::new(&ring::aead::CHACHA20_POLY1305, key) + .expect("32-byte ChaCha20-Poly1305 key"); + ring::aead::LessSafeKey::new(unbound) +} + +/// `SHA-256` of a message 1 without its cookie: the AAD of its reply. +pub fn msg1_digest(msg1: &[u8]) -> [u8; 32] { + ring::digest::digest(&ring::digest::SHA256, msg1) + .as_ref() + .try_into() + .expect("32 bytes") +} + +/// Seals `cookie` into a reply for the message 1 with `digest`. +pub fn seal( + key: &Key32, + tag: u16, + nonce: [u8; 12], + cookie: &[u8; COOKIE_LEN], + digest: &[u8; 32], +) -> CookieReply { + let mut in_out = cookie.to_vec(); + aead_key(key) + .seal_in_place_append_tag( + ring::aead::Nonce::assume_unique_for_key(nonce), + ring::aead::Aad::from(digest), + &mut in_out, + ) + .expect("a 16-byte cookie seals"); + CookieReply { + tag, + nonce, + sealed: in_out.try_into().expect("16 + 16 bytes"), + } +} + +/// Opens a reply to the message 1 with `digest`: the cookie, or `None` for a +/// reply under another key or for another message 1. +pub fn open(key: &Key32, reply: &CookieReply, digest: &[u8; 32]) -> Option<[u8; COOKIE_LEN]> { + let mut in_out = reply.sealed; + let plain = aead_key(key) + .open_in_place( + ring::aead::Nonce::assume_unique_for_key(reply.nonce), + ring::aead::Aad::from(digest), + &mut in_out, + ) + .ok()?; + plain.try_into().ok() +} + +/// The host's cookie secrets. +/// +/// Cookie replies have no budget of their own. A reply is a fixed amount of +/// symmetric work, it's smaller than the message 1 it answers, and a budget +/// would be the one thing a flood of spoofed message 1s could use up to +/// keep the genuine iPad from its cookie. +pub struct CookieJar { + current: Zeroizing, + previous: Option>, + rotated: Duration, +} + +impl CookieJar { + pub fn new(now: Duration, rng: &mut dyn Random) -> Self { + let mut current = Zeroizing::new([0u8; 32]); + rng.fill(&mut *current); + Self { + current, + previous: None, + rotated: now, + } + } + + /// Moves to a fresh secret once the current one is [`SECRET_LIFETIME`] + /// old; the one before is forgotten. + fn rotate(&mut self, now: Duration, rng: &mut dyn Random) { + if now.saturating_sub(self.rotated) < SECRET_LIFETIME { + return; + } + let mut next = Zeroizing::new([0u8; 32]); + rng.fill(&mut *next); + // Two lifetimes or more since the last rotation: the old current is + // too old to keep as well. + let stale = now.saturating_sub(self.rotated) >= 2 * SECRET_LIFETIME; + let old = std::mem::replace(&mut self.current, next); + self.previous = (!stale).then_some(old); + self.rotated = now; + } + + /// Whether `mac` is a valid cookie MAC for `msg1` (without its cookie) + /// from `address`, under the current or the previous secret. + pub fn verify( + &mut self, + now: Duration, + address: &[u8], + msg1: &[u8], + mac: &[u8; MAC2_LEN], + rng: &mut dyn Random, + ) -> bool { + self.rotate(now, rng); + std::iter::once(&self.current) + .chain(self.previous.as_ref()) + .any(|secret| { + use subtle::ConstantTimeEq; + let expected = mac2(&cookie_value(secret, address), msg1); + bool::from(expected.ct_eq(mac)) + }) + } + + /// A reply to `msg1` (without any cookie) carrying `address`'s current + /// cookie, sealed under `key`. + pub fn reply( + &mut self, + now: Duration, + address: &[u8], + key: &Key32, + tag: u16, + msg1: &[u8], + rng: &mut dyn Random, + ) -> Vec { + self.rotate(now, rng); + let cookie = cookie_value(&self.current, address); + let mut nonce = [0u8; 12]; + rng.fill(&mut nonce); + seal(key, tag, nonce, &cookie, &msg1_digest(msg1)).encode() + } +} + +/// When a host requires a cookie on message 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CookieMode { + /// While the handshake is under load: the default. + UnderLoad, + /// Always, for tests of the cookie path. + Always, + /// Never, for tests of the budgets beneath it. Never a host's setting. + Off, +} + +/// Message 1 arrivals per second, before any check, for the load test: +/// this second's and the one before. Counting arrivals rather than what the +/// cookie lets through keeps the load on for as long as the flood lasts. +#[derive(Debug, Clone, Copy)] +pub(super) struct LoadMeter { + start: Duration, + count: u32, + last: u32, +} + +impl LoadMeter { + pub(super) fn new(now: Duration) -> Self { + Self { + start: now, + count: 0, + last: 0, + } + } + + pub(super) fn record(&mut self, now: Duration) { + let elapsed = now.saturating_sub(self.start); + if elapsed >= Duration::from_secs(1) { + self.last = if elapsed < Duration::from_secs(2) { + self.count + } else { + 0 + }; + self.start = now; + self.count = 0; + } + self.count = self.count.saturating_add(1); + } + + /// Whether this second or the one before reached `per_second`. + pub(super) fn busy(&self, now: Duration, per_second: u32) -> bool { + let elapsed = now.saturating_sub(self.start); + if elapsed < Duration::from_secs(1) { + self.count >= per_second || self.last >= per_second + } else { + elapsed < Duration::from_secs(2) && self.count >= per_second + } + } +} + +/// Whether a datagram is a cookie reply, from its first bytes. +pub fn is_reply(datagram: &[u8]) -> bool { + datagram.len() >= 4 + && datagram[0..2] == MAGIC.to_le_bytes() + && datagram[2] == VERSION + && datagram[3] == COOKIE_TYPE +} + +/// What an iPad keeps of a cookie it got: the cookie and when, per host. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct HeldCookie { + pub cookie: [u8; COOKIE_LEN], + pub received: Duration, +} + +impl std::fmt::Debug for HeldCookie { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HeldCookie") + .field("received", &self.received) + .finish_non_exhaustive() + } +} + +impl HeldCookie { + /// Whether it's still young enough to attach. + pub fn fresh(&self, now: Duration) -> bool { + now.saturating_sub(self.received) < CLIENT_COOKIE_LIFETIME + } +} + +#[cfg(test)] +mod tests; diff --git a/proto/src/v3/cookie/tests.rs b/proto/src/v3/cookie/tests.rs new file mode 100644 index 00000000..9cfadd1e --- /dev/null +++ b/proto/src/v3/cookie/tests.rs @@ -0,0 +1,314 @@ +use super::*; +use crate::v3::host::PeerAddr; +use crate::v3::vectors::load; +use crate::v3::{wire, SeededRandom}; +use std::net::SocketAddr; + +fn key32(bytes: &[u8]) -> Key32 { + bytes.try_into().expect("32 bytes") +} + +fn arr(bytes: &[u8]) -> [u8; N] { + bytes.try_into().expect("length") +} + +fn golden_msg1() -> Vec { + load("session_golden.txt")["msg1"].clone() +} + +#[test] +fn vectors() { + let v = load("cookie_vectors.txt"); + let g = load("session_golden.txt"); + let secret = key32(&v["input.secret"]); + let msg1 = &g["msg1"]; + + // Addresses as the host's peers give them. + let v4: SocketAddr = "192.0.2.10:50000".parse().unwrap(); + let v6: SocketAddr = "[2001:db8::1]:50000".parse().unwrap(); + let mapped: SocketAddr = "[::ffff:192.0.2.10]:50000".parse().unwrap(); + assert_eq!(v4.cookie_input(), v["input.address_v4"]); + assert_eq!(v6.cookie_input(), v["input.address_v6"]); + assert_eq!(mapped.cookie_input(), v["input.address_v4"]); + + let cookie: [u8; 16] = arr(&v["cookie.v4"]); + assert_eq!(cookie_value(&secret, &v["input.address_v4"]), cookie); + assert_eq!( + cookie_value(&secret, &v["input.address_v6"]).to_vec(), + v["cookie.v6"] + ); + let k_dev = key32(&g["input.k_dev"]); + let dev_id: Id16 = arr(&g["input.dev_id"]); + let session = session_key(&k_dev, &dev_id); + assert_eq!(session.to_vec(), v["key.session"]); + let pairing = pairing_key(&key32(&v["input.pair_psk"])); + assert_eq!(pairing.to_vec(), v["key.pairing"]); + + let digest = msg1_digest(msg1); + assert_eq!(digest.to_vec(), v["msg1.digest"]); + assert_eq!(mac2(&cookie, msg1).to_vec(), v["msg1.mac2"]); + let with = attach(msg1, &cookie); + assert_eq!(with, v["msg1.with_cookie"]); + let (canon, mac) = split(&with); + assert_eq!(&*canon, &msg1[..]); + assert_eq!(mac.map(|m| m.to_vec()), Some(v["msg1.mac2"].clone())); + // The message 1 with the cookie still reads as the same message 1. + assert_eq!(wire::classify(&with), V3Kind::Handshake1); + assert!(wire::parse_msg1(&canon).is_ok()); + + for (name, key, tag, nonce) in [ + ( + "reply.session", + &session, + EXT_SESSION, + "input.nonce_session", + ), + ("reply.pairing", &pairing, EXT_PAIR_QR, "input.nonce_pair"), + ] { + let sealed = seal(key, tag, arr(&v[nonce]), &cookie, &digest).encode(); + assert_eq!(sealed, v[name], "{name}"); + assert_eq!(sealed.len(), COOKIE_REPLY_SIZE); + assert!(is_reply(&sealed)); + let parsed = CookieReply::parse(&v[name]).unwrap(); + assert_eq!(parsed.tag, tag); + assert_eq!(open(key, &parsed, &digest), Some(cookie)); + } +} + +#[test] +fn a_reply_opens_only_under_its_key_and_for_its_own_message_1() { + let v = load("cookie_vectors.txt"); + let reply = CookieReply::parse(&v["reply.session"]).unwrap(); + let key = key32(&v["key.session"]); + let digest: [u8; 32] = arr(&v["msg1.digest"]); + let mut other = digest; + other[0] ^= 1; + assert!(open(&key, &reply, &other).is_none(), "another message 1"); + let mut wrong = key; + wrong[31] ^= 1; + assert!(open(&wrong, &reply, &digest).is_none(), "another key"); + let pairing = key32(&v["key.pairing"]); + assert!(open(&pairing, &reply, &digest).is_none(), "the pairing key"); + for at in 0..32 { + let mut flipped = reply.clone(); + flipped.sealed[at] ^= 0x80; + assert!(open(&key, &flipped, &digest).is_none(), "sealed byte {at}"); + } + for at in 0..12 { + let mut flipped = reply.clone(); + flipped.nonce[at] ^= 1; + assert!(open(&key, &flipped, &digest).is_none(), "nonce byte {at}"); + } +} + +#[test] +fn the_reply_parser_takes_exactly_what_the_encoder_writes() { + let v = load("cookie_vectors.txt"); + let good = v["reply.session"].clone(); + assert!(CookieReply::parse(&good).is_ok()); + assert!(CookieReply::parse(&good[..63]).is_err(), "short"); + let mut long = good.clone(); + long.push(0); + assert!(CookieReply::parse(&long).is_err(), "long"); + // Every header byte is fixed: magic, version, type, flags, payload_len, + // session_id 0 and msg_seq 0. + for at in 0..16 { + let mut bad = good.clone(); + bad[at] ^= 1; + assert!(CookieReply::parse(&bad).is_err(), "header byte {at}"); + } + for tag in [0u16, 3, EXT_COOKIE, 0x0100] { + let mut bad = good.clone(); + bad[16..18].copy_from_slice(&tag.to_le_bytes()); + assert!(CookieReply::parse(&bad).is_err(), "tag {tag:#x}"); + } + let mut bad = good.clone(); + bad[18] = 43; + assert!(CookieReply::parse(&bad).is_err(), "extension length"); + // Not a message any v2 or v3 parser takes. + assert_eq!(wire::classify(&good), V3Kind::Other); + assert!(crate::v2::control::parse_control(&good).is_err()); +} + +#[test] +fn split_takes_one_exact_cookie_and_leaves_anything_else_for_the_parser() { + let msg1 = golden_msg1(); + let cookie = [7u8; 16]; + // No cookie: the same bytes, borrowed. + let (canon, mac) = split(&msg1); + assert!(matches!(canon, std::borrow::Cow::Borrowed(_))); + assert_eq!((&*canon, mac), (&msg1[..], None)); + let with = attach(&msg1, &cookie); + // Two cookies, a cookie of another length, another tag, or a byte more: + // left as they are, so message 1's parser refuses them. + let twice = attach(&with, &cookie); + let mut long = with.clone(); + long.push(0); + let mut short = with[..with.len() - 1].to_vec(); + let len = u16::try_from(short.len() - 16).unwrap(); + short[6..8].copy_from_slice(&len.to_le_bytes()); + let mut tag = with.clone(); + let at = msg1.len(); + tag[at] = 0xFE; + let mut length = with.clone(); + length[at + 2] = 15; + for (name, datagram) in [ + ("twice", twice), + ("long", long), + ("short", short), + ("tag", tag), + ("length", length), + ] { + let (canon, mac) = split(&datagram); + assert_eq!(mac, None, "{name}"); + assert_eq!(&*canon, &datagram[..], "{name}"); + assert!(wire::parse_msg1(&canon).is_err(), "{name}"); + } + // Not a message 1 at all: untouched. + let reply = load("cookie_vectors.txt")["reply.session"].clone(); + assert_eq!( + split(&reply), + (std::borrow::Cow::Borrowed(&reply[..]), None) + ); + assert_eq!(split(&[]), (std::borrow::Cow::Borrowed(&[][..]), None)); +} + +/// The jar against the address and message 1 it was made for. +fn check(jar: &mut CookieJar, now: Duration, cookie: &[u8; 16], rng: &mut SeededRandom) -> bool { + let msg1 = golden_msg1(); + jar.verify(now, b"addr", &msg1, &mac2(cookie, &msg1), rng) +} + +/// What the jar's reply to `b"addr"` carries at `now`. +fn issue(jar: &mut CookieJar, now: Duration, rng: &mut SeededRandom) -> [u8; 16] { + let msg1 = golden_msg1(); + let key = [3u8; 32]; + let reply = jar.reply(now, b"addr", &key, EXT_SESSION, &msg1, rng); + let parsed = CookieReply::parse(&reply).unwrap(); + open(&key, &parsed, &msg1_digest(&msg1)).unwrap() +} + +#[test] +fn a_cookie_binds_its_address_and_its_message_1() { + let mut rng = SeededRandom::new(1); + let mut jar = CookieJar::new(Duration::ZERO, &mut rng); + let now = Duration::from_secs(1); + let cookie = issue(&mut jar, now, &mut rng); + let msg1 = golden_msg1(); + let mac = mac2(&cookie, &msg1); + assert!(jar.verify(now, b"addr", &msg1, &mac, &mut rng)); + assert!( + !jar.verify(now, b"adds", &msg1, &mac, &mut rng), + "another address" + ); + let mut other = msg1.clone(); + other[20] ^= 1; + assert!( + !jar.verify(now, b"addr", &other, &mac, &mut rng), + "another message 1" + ); + let mut bad = mac; + bad[15] ^= 1; + assert!( + !jar.verify(now, b"addr", &msg1, &bad, &mut rng), + "a flipped MAC" + ); + // Another port is another address. + let a: SocketAddr = "192.0.2.10:50000".parse().unwrap(); + let b: SocketAddr = "192.0.2.10:50001".parse().unwrap(); + assert_ne!(a.cookie_input(), b.cookie_input()); +} + +#[test] +fn rotation_keeps_the_previous_secret_for_one_lifetime() { + let mut rng = SeededRandom::new(2); + let t0 = Duration::from_secs(1000); + let mut jar = CookieJar::new(t0, &mut rng); + let first = issue(&mut jar, t0, &mut rng); + // Just before the lifetime: the same secret, the same cookie. + let before = t0 + SECRET_LIFETIME - Duration::from_millis(1); + assert_eq!(issue(&mut jar, before, &mut rng), first); + // At it: a new secret for new cookies; the old one is still accepted. + let at = t0 + SECRET_LIFETIME; + let second = issue(&mut jar, at, &mut rng); + assert_ne!(second, first); + assert!(check(&mut jar, at, &first, &mut rng)); + assert!(check(&mut jar, at, &second, &mut rng)); + // A second rotation forgets the first secret. + let next = at + SECRET_LIFETIME; + assert!(check(&mut jar, next, &second, &mut rng)); + assert!(!check(&mut jar, next, &first, &mut rng)); +} + +#[test] +fn a_jar_idle_for_two_lifetimes_forgets_both_secrets() { + let mut rng = SeededRandom::new(3); + let t0 = Duration::from_secs(1000); + let mut jar = CookieJar::new(t0, &mut rng); + let first = issue(&mut jar, t0, &mut rng); + let later = t0 + 2 * SECRET_LIFETIME; + assert!(!check(&mut jar, later, &first, &mut rng)); + // Just under two lifetimes, it's the previous secret and still good. + let mut jar = CookieJar::new(t0, &mut rng); + let first = issue(&mut jar, t0, &mut rng); + let almost = t0 + 2 * SECRET_LIFETIME - Duration::from_millis(1); + assert!(check(&mut jar, almost, &first, &mut rng)); +} + +/// Wherever rotations fall, a cookie is accepted for at least +/// [`SECRET_LIFETIME`] after it was issued, so the iPad's +/// [`CLIENT_COOKIE_LIFETIME`] is always inside it. +#[test] +fn every_cookie_is_accepted_for_as_long_as_the_ipad_keeps_it() { + const { assert!(CLIENT_COOKIE_LIFETIME.as_millis() < SECRET_LIFETIME.as_millis()) }; + let mut rng = SeededRandom::new(4); + let t0 = Duration::from_secs(1000); + for seed in 0..200u64 { + let mut jar = CookieJar::new(t0, &mut rng); + // Calls at pseudo-random gaps of up to 130 s rotate it lazily. + let mut now = t0; + let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + let mut gap = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + Duration::from_millis(state % 130_000) + }; + for _ in 0..6 { + now += gap(); + issue(&mut jar, now, &mut rng); + } + now += gap(); + let issued = now; + let cookie = issue(&mut jar, issued, &mut rng); + let mut at = issued; + while at < issued + CLIENT_COOKIE_LIFETIME { + assert!(check(&mut jar, at, &cookie, &mut rng), "seed {seed}"); + at += gap().min(Duration::from_secs(20)) + Duration::from_millis(1); + } + let last = issued + CLIENT_COOKIE_LIFETIME - Duration::from_millis(1); + assert!(check(&mut jar, last, &cookie, &mut rng), "seed {seed}"); + } +} + +#[test] +fn a_held_cookie_is_fresh_for_its_lifetime() { + let held = HeldCookie { + cookie: [9; 16], + received: Duration::from_secs(5), + }; + assert!(held.fresh(Duration::from_secs(5) + CLIENT_COOKIE_LIFETIME - Duration::from_millis(1))); + assert!(!held.fresh(Duration::from_secs(5) + CLIENT_COOKIE_LIFETIME)); + assert!( + !format!("{held:?}").contains("9, 9"), + "Debug hides the cookie" + ); +} + +/// A cookie reply is never larger than what it answers: no message 1 the +/// host would answer is shorter than its extension's header. +#[test] +fn a_reply_is_smaller_than_any_message_1() { + const { assert!(COOKIE_REPLY_SIZE < wire::MSG1_EXTENSION_OFFSET + EXTENSION_HEADER_SIZE) }; + assert!(COOKIE_REPLY_SIZE < golden_msg1().len()); +} diff --git a/proto/src/v3/host.rs b/proto/src/v3/host.rs index ad661e34..371c2663 100644 --- a/proto/src/v3/host.rs +++ b/proto/src/v3/host.rs @@ -38,6 +38,7 @@ use std::hash::Hash; use std::net::{IpAddr, SocketAddr}; use std::time::Duration; +use super::cookie::{self, CookieJar, CookieMode, LoadMeter}; use super::handshake::Responder; use super::record::{Opener, RecordHeader, Sealer}; use super::wire::{ @@ -104,6 +105,12 @@ pub struct HostConfig { pub liveness_timeout: Duration, /// A session's maximum age. pub max_age: Duration, + /// When message 1 must carry a valid cookie for its source. + pub cookies: CookieMode, + /// Under load while this many message 1s arrived this second or the one + /// before, whatever they carry, or half the host-wide Diffie-Hellman + /// budget is used this second. + pub cookie_load_msg1_per_second: u32, } impl HostConfig { @@ -128,6 +135,8 @@ impl HostConfig { max_pending: 16, liveness_timeout: Duration::from_secs(3), max_age: SESSION_MAX_AGE, + cookies: CookieMode::UnderLoad, + cookie_load_msg1_per_second: 400, } } } @@ -140,6 +149,9 @@ impl HostConfig { pub trait PeerAddr: Clone + Eq + Hash { type Bucket: Clone + Eq + Hash; fn rate_bucket(&self) -> Self::Bucket; + /// What a return-routability cookie binds: the exact source, IP and + /// port, so a cookie proves the iPad receives at that address. + fn cookie_input(&self) -> Vec; } impl PeerAddr for SocketAddr { @@ -157,6 +169,17 @@ impl PeerAddr for SocketAddr { v4 => v4, } } + + /// The IP as [`rate_bucket`](Self::rate_bucket) normalizes it (4 or 16 + /// bytes), then the port, little-endian. + fn cookie_input(&self) -> Vec { + let mut out = match self.rate_bucket() { + IpAddr::V4(v4) => v4.octets().to_vec(), + IpAddr::V6(v6) => v6.octets().to_vec(), + }; + out.extend_from_slice(&self.port().to_le_bytes()); + out + } } #[cfg(any(test, feature = "test-hooks"))] @@ -165,6 +188,9 @@ impl PeerAddr for u32 { fn rate_bucket(&self) -> u32 { *self } + fn cookie_input(&self) -> Vec { + self.to_le_bytes().to_vec() + } } /// What the host's credential store answers. Phase 2 backs this with the @@ -624,6 +650,11 @@ pub struct HostSessions { wall_clock: (Duration, u64), msg1_reads: u64, dh_performed: u64, + /// The cookie secrets and the reply budget. + cookies: CookieJar, + /// Message 1 arrivals, for the load test. + arrivals: LoadMeter, + cookie_replies: u64, } impl HostSessions { @@ -635,7 +666,12 @@ impl HostSessions { now: Duration, wall_us: u64, ) -> Self { + let mut rng = rng; + let cookies = CookieJar::new(now, rng.as_mut()); Self { + cookies, + arrivals: LoadMeter::new(now), + cookie_replies: 0, answered_capacity: answered_capacity(&config), msg1_rate: SourceLimiter::new(config.msg1_per_second), stale_replay_rate: SourceLimiter::new(config.stale_replays_per_second), @@ -686,6 +722,27 @@ impl HostSessions { self.msg1_reads } + /// Cookie replies sent so far. + pub fn cookie_replies(&self) -> u64 { + self.cookie_replies + } + + /// Whether message 1 needs a cookie now: always or never as configured, + /// otherwise while many message 1s are arriving, or half the host-wide + /// Diffie-Hellman budget is used this second. + pub fn under_load(&self, now: Duration) -> bool { + match self.config.cookies { + CookieMode::Always => true, + CookieMode::Off => false, + CookieMode::UnderLoad => { + self.arrivals + .busy(now, self.config.cookie_load_msg1_per_second) + || (now.saturating_sub(self.dh_budget.start) < Duration::from_secs(1) + && self.dh_budget.count.saturating_mul(2) >= self.config.dh_per_second) + } + } + } + /// Diffie-Hellman operations performed so far: one per message 2 /// written, whether it accepts or answers `Stale`, `TooNew` or /// `PairAgain`. @@ -768,9 +825,11 @@ impl HostSessions { creds: &dyn HostCredentials, out: &mut Vec>, ) { - let Ok(msg1) = wire::parse_msg1(datagram) else { + let (canon, mac) = cookie::split(datagram); + let Ok(msg1) = wire::parse_msg1(&canon) else { return; }; + self.arrivals.record(now); let bucket = from.rate_bucket(); let key = msg1_key(&msg1); if self @@ -780,6 +839,36 @@ impl HostSessions { { return; } + // Under load, nothing answers or reads a message 1 without a valid + // cookie for its exact source: only a cookie reply goes back, and + // only for a known dev_id. That covers new message 1s, cached + // answers and the live address alike. + if self.under_load(now) { + let address = from.cookie_input(); + let verified = mac.is_some_and(|mac| { + self.cookies + .verify(now, &address, &canon, &mac, self.rng.as_mut()) + }); + if !verified { + if let Some(k_dev) = creds.key(&msg1.dev_id) { + let key = cookie::session_key(&k_dev, &msg1.dev_id); + let reply = self.cookies.reply( + now, + &address, + &key, + super::EXT_SESSION, + &canon, + self.rng.as_mut(), + ); + self.cookie_replies += 1; + out.push(HostOutput::Send { + to: from.clone(), + datagram: reply, + }); + } + return; + } + } let dev_id = msg1.dev_id; let resend_interval = self.config.msg2_resend_interval; let unverified_limit = self.config.unverified_replies_per_second; @@ -1668,15 +1757,18 @@ mod tests { } fn setup() -> (HostSessions, Creds) { + setup_with(CookieMode::UnderLoad) + } + + /// With cookies `Off`, for the budgets beneath the cookie: what a flood + /// from real addresses (which can answer a cookie) reaches. + fn setup_with(cookies: CookieMode) -> (HostSessions, Creds) { let mut creds = Creds::default(); creds.keys.insert(DEV, K); + let mut config = HostConfig::new(HOST); + config.cookies = cookies; ( - HostSessions::new( - HostConfig::new(HOST), - Box::new(SeededRandom::new(1)), - T0, - WALL, - ), + HostSessions::new(config, Box::new(SeededRandom::new(1)), T0, WALL), creds, ) } @@ -2418,7 +2510,7 @@ mod tests { #[test] fn unknown_dev_ids_are_never_charged() { - let (mut host, creds) = setup(); + let (mut host, creds) = setup_with(CookieMode::Off); for source in 0..2000u32 { let junk = Initiator::start(&K, &[0xEE; 16], &HOST, 1, &hello(1)).unwrap(); host.handle_datagram(T0, &(10_000 + source), junk.msg1(), &creds); @@ -2438,6 +2530,7 @@ mod tests { // Only the per-source table decides here. let mut config = HostConfig::new(HOST); config.dh_per_second = u32::MAX; + config.cookies = CookieMode::Off; let mut host: HostSessions = HostSessions::new(config, Box::new(SeededRandom::new(5)), T0, WALL); let forged = |i: u64| Initiator::start(&[7; 32], &DEV, &HOST, 1, &hello(i)).unwrap(); @@ -2458,7 +2551,7 @@ mod tests { #[test] fn the_live_sessions_own_address_has_a_reserved_budget() { - let (mut host, creds) = setup(); + let (mut host, creds) = setup_with(CookieMode::Off); live(&mut host, &creds, 10, T0); // Flood: forged message 1s for this dev_id from 2,000 sources use up // the shared budgets. @@ -2521,7 +2614,7 @@ mod tests { #[test] fn forged_message_1s_never_use_the_host_wide_handshake_budget() { - let (mut host, creds) = setup(); + let (mut host, creds) = setup_with(CookieMode::Off); // Forged message 1s for a known dev_id from 100 sources at their // full per-source rate, for 10 s. let forged: Vec> = (0..2000u64) @@ -2575,6 +2668,7 @@ mod tests { } let mut config = HostConfig::new(HOST); config.max_pending = 1000; + config.cookies = CookieMode::Off; let mut host2: HostSessions = HostSessions::new(config, Box::new(SeededRandom::new(9)), T0, WALL); for (i, id) in ids.iter().enumerate() { @@ -2587,7 +2681,7 @@ mod tests { #[test] fn spoofing_the_live_address_below_its_cap_never_drains_its_reserved_budget() { - let (mut host, creds) = setup(); + let (mut host, creds) = setup_with(CookieMode::Off); live(&mut host, &creds, 10, T0); // Forged message 1s for this dev_id from the live address itself. for i in 0..1500u64 { @@ -2683,6 +2777,7 @@ mod tests { fn the_remembered_table_fits_every_credential_at_full_rate() { let mut config = HostConfig::new(HOST); config.max_credentials = 4; + config.cookies = CookieMode::Off; let mut host = HostSessions::new(config, Box::new(SeededRandom::new(1)), T0, WALL); let mut creds = Creds::default(); *creds.timestamp.borrow_mut() = u64::from(u32::MAX); @@ -2773,4 +2868,273 @@ mod tests { 1 ); } + + /// Message 1 with the cookie the host's reply to it carries, from the + /// iPad's side: what [`crate::v3::client::Client`] does. + fn with_cookie(reply: &[u8], msg1: &[u8]) -> Vec { + let parsed = cookie::CookieReply::parse(reply).expect("a cookie reply"); + let key = cookie::session_key(&K, &DEV); + let value = cookie::open(&key, &parsed, &cookie::msg1_digest(msg1)).expect("opens"); + cookie::attach(msg1, &value) + } + + fn only_cookie_reply(out: &[HostOutput], to: u32, msg1: &[u8]) -> Vec { + match out { + [HostOutput::Send { to: at, datagram }] => { + assert_eq!(*at, to); + assert!(cookie::is_reply(datagram), "a cookie reply"); + assert!(datagram.len() < msg1.len(), "smaller than message 1"); + datagram.clone() + } + other => panic!("expected one cookie reply, got {} outputs", other.len()), + } + } + + #[test] + fn under_load_a_message_1_without_a_cookie_gets_only_a_cookie_reply() { + let (mut host, creds) = setup_with(CookieMode::Always); + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); + let out = host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds); + let reply = only_cookie_reply(&out, ADDR, ipad.msg1()); + assert_eq!(host.msg1_reads(), 0, "no Noise read"); + assert_eq!(host.handshakes_performed(), 0, "no Diffie-Hellman"); + assert_eq!(host.pending_count(), 0); + assert_eq!(host.cookie_replies(), 1); + // With the cookie: an ordinary handshake. + let msg1 = with_cookie(&reply, ipad.msg1()); + let out = sends(&host.handle_datagram(T0 + ms(1), &ADDR, &msg1, &creds)); + assert_eq!(out.len(), 1); + assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); + assert_eq!(host.msg1_reads(), 1); + assert!(host.pending_session(&DEV).is_some()); + } + + #[test] + fn unknown_dev_ids_get_no_cookie_reply() { + let (mut host, creds) = setup_with(CookieMode::Always); + let stranger = Initiator::start(&K, &[0xEE; 16], &HOST, 1, &hello(10)).unwrap(); + assert!(host + .handle_datagram(T0, &ADDR, stranger.msg1(), &creds) + .is_empty()); + assert_eq!(host.cookie_replies(), 0); + } + + #[test] + fn a_cookie_is_good_only_from_the_address_it_was_sent_to() { + let (mut host, creds) = setup_with(CookieMode::Always); + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); + let reply = only_cookie_reply( + &host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds), + ADDR, + ipad.msg1(), + ); + let msg1 = with_cookie(&reply, ipad.msg1()); + // The same bytes from anywhere else: a new cookie reply, nothing more. + only_cookie_reply(&host.handle_datagram(T0, &2, &msg1, &creds), 2, ipad.msg1()); + assert_eq!(host.msg1_reads(), 0); + // A forged MAC from the right address is no better. + let mut forged = msg1.clone(); + let last = forged.len() - 1; + forged[last] ^= 1; + only_cookie_reply( + &host.handle_datagram(T0, &ADDR, &forged, &creds), + ADDR, + ipad.msg1(), + ); + assert_eq!(host.msg1_reads(), 0); + } + + /// Under load the cookie comes before everything that answers: cached + /// Stale answers, message 2 resends and the live address's reserve. + #[test] + fn under_load_cached_answers_and_resends_need_a_cookie_too() { + let (mut host, creds) = setup_with(CookieMode::Always); + let round_trip = |host: &mut HostSessions, from: u32, msg1: &[u8]| { + let out = host.handle_datagram(T0, &from, msg1, &creds); + let reply = only_cookie_reply(&out, from, msg1); + sends(&host.handle_datagram(T0, &from, &with_cookie(&reply, msg1), &creds)) + }; + // A pending attempt: its message 1 again, without a cookie. + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); + assert_eq!(round_trip(&mut host, ADDR, ipad.msg1()).len(), 1); + let out = host.handle_datagram(T0 + ms(200), &ADDR, ipad.msg1(), &creds); + only_cookie_reply(&out, ADDR, ipad.msg1()); + // A cached Stale answer, replayed from elsewhere without a cookie. + *creds.timestamp.borrow_mut() = 1000; + let stale = Initiator::start(&K, &DEV, &HOST, 2, &hello(900)).unwrap(); + assert_eq!(round_trip(&mut host, 5, stale.msg1()).len(), 1, "Stale"); + let out = host.handle_datagram(T0 + ms(300), &6, stale.msg1(), &creds); + only_cookie_reply(&out, 6, stale.msg1()); + // The live address, spoofed: only cookie replies, to the iPad. + let (mut host, creds) = setup_with(CookieMode::Off); + live(&mut host, &creds, 10, T0); + host.config.cookies = CookieMode::Always; + let before = host.msg1_reads(); + for i in 0..50u64 { + let forged = Initiator::start(&[7; 32], &DEV, &HOST, 1, &hello(100 + i)).unwrap(); + let out = host.handle_datagram(T0 + ms(5), &ADDR, forged.msg1(), &creds); + only_cookie_reply(&out, ADDR, forged.msg1()); + } + assert_eq!(host.msg1_reads(), before); + } + + /// Phase-1 iPads send no cookie: they connect whenever the host isn't + /// under load, get only cookie replies (which they drop) while it is, + /// and connect again once the load has passed. + #[test] + fn a_client_without_cookies_connects_whenever_the_host_is_not_under_load() { + let (mut host, creds) = setup(); + let plain = |host: &mut HostSessions, at: Duration, ts: u64| { + let ipad = Initiator::start(&K, &DEV, &HOST, ts as u32, &hello(ts)).unwrap(); + let out = sends(&host.handle_datagram(at, &ADDR, ipad.msg1(), &creds)); + out.len() == 1 && wire::classify(&out[0]) == V3Kind::Handshake2 + }; + assert!(!host.under_load(T0)); + assert!(plain(&mut host, T0, 10)); + // A flood: message 1s for an unknown dev_id from many sources. They + // cost nothing, but they're arrivals. + let junk = Initiator::start(&K, &[0xEE; 16], &HOST, 1, &hello(1)).unwrap(); + let start = T0 + Duration::from_secs(1); + for i in 0..400u32 { + host.handle_datagram(start, &(10_000 + i), junk.msg1(), &creds); + } + assert!(host.under_load(start)); + assert!(!plain(&mut host, start + ms(10), 20), "only a cookie reply"); + // The second after still counts: the load doesn't flap at the edge. + let next = start + ms(1500); + assert!(host.under_load(next)); + // Two quiet seconds later it's over. + let after = start + Duration::from_secs(2); + assert!(!host.under_load(after)); + assert!(plain(&mut host, after, 30)); + } + + #[test] + fn half_the_handshake_budget_in_a_second_is_load() { + let mut creds = Creds::default(); + let ids: Vec = (0..100u16) + .map(|i| { + let mut id = [0u8; 16]; + id[..2].copy_from_slice(&i.to_le_bytes()); + id + }) + .collect(); + for id in &ids { + creds.keys.insert(*id, K); + } + let mut config = HostConfig::new(HOST); + config.max_pending = 1000; + let mut host: HostSessions = + HostSessions::new(config, Box::new(SeededRandom::new(9)), T0, WALL); + for (i, id) in ids.iter().enumerate() { + assert!(!host.under_load(T0), "{i} handshakes"); + let ipad = Initiator::start(&K, id, &HOST, 1, &hello(10)).unwrap(); + host.handle_datagram(T0, &(i as u32), ipad.msg1(), &creds); + } + assert_eq!(host.handshakes_performed(), 100); + assert!(host.under_load(T0)); + assert!(!host.under_load(T0 + Duration::from_secs(1))); + } + + /// 10,000 spoofed message 1s a second for the iPad's own dev_id, from + /// 1,000,000 sources, for 5 s, half of them replays of its genuine old + /// message 1s. Once the load is on, none is read, nothing reaches + /// Diffie-Hellman, each gets at most one smaller reply, and the iPad + /// reconnects through the cookie in the middle of it. + #[test] + fn a_spoofed_flood_never_reaches_the_crypto_and_never_starves_the_ipad() { + let (mut host, creds) = setup(); + *creds.timestamp.borrow_mut() = 1000; + let old: Vec> = (0..20u64) + .map(|i| { + let ipad = Initiator::start(&K, &DEV, &HOST, i as u32, &hello(500 + i)).unwrap(); + ipad.msg1().to_vec() + }) + .collect(); + let forged: Vec> = (0..20u64) + .map(|i| { + let ipad = Initiator::start(&[7; 32], &DEV, &HOST, 1, &hello(2000 + i)).unwrap(); + ipad.msg1().to_vec() + }) + .collect(); + let mut rng = SeededRandom::new(77); + let mut reads_once_loaded = None; + let mut reconnected = false; + let mut replies = 0u64; + for tick in 0..500u64 { + let at = T0 + ms(tick * 10); + for i in 0..100usize { + let mut source = [0u8; 4]; + rng.fill(&mut source); + let source = 10_000 + u32::from_le_bytes(source) % 1_000_000; + let msg1 = if i % 2 == 0 { + &old[i / 2 % 20] + } else { + &forged[i / 2 % 20] + }; + let out = host.handle_datagram(at, &source, msg1, &creds); + assert!(out.len() <= 1); + for o in &out { + let HostOutput::Send { datagram, .. } = o else { + panic!("only sends") + }; + assert!(datagram.len() < msg1.len()); + if reads_once_loaded.is_some() { + assert!(cookie::is_reply(datagram), "only cookie replies"); + } + replies += 1; + } + } + if reads_once_loaded.is_none() && host.under_load(at) { + reads_once_loaded = Some((host.msg1_reads(), host.handshakes_performed())); + } + if tick == 300 { + let ipad = Initiator::start(&K, &DEV, &HOST, 9, &hello(5000)).unwrap(); + let out = host.handle_datagram(at, &ADDR, ipad.msg1(), &creds); + let reply = only_cookie_reply(&out, ADDR, ipad.msg1()); + let msg1 = with_cookie(&reply, ipad.msg1()); + let out = sends(&host.handle_datagram(at, &ADDR, &msg1, &creds)); + reconnected = out.len() == 1 && wire::classify(&out[0]) == V3Kind::Handshake2; + } + } + // Before it, the phase-1 budgets: reads up to the load threshold, and + // Stale answers within the dev_id's 5 a second. + let (reads, handshakes) = reads_once_loaded.expect("the flood is load"); + assert!(reads <= 400, "{reads} reads before the load was on"); + assert!(handshakes <= 5); + assert_eq!(host.msg1_reads(), reads + 1, "only the iPad's, after"); + assert_eq!( + host.handshakes_performed(), + handshakes + 1, + "only the iPad's" + ); + assert!(reconnected); + assert!(replies <= 50_000); + assert!(host.pending_session(&DEV).is_some()); + } + + /// A real address that got a cookie and floods with it is still held to + /// its per-source limit. + #[test] + fn a_valid_cookie_never_lifts_the_per_source_limit() { + let (mut host, creds) = setup_with(CookieMode::Always); + let mut reads = Vec::new(); + for i in 0..100u64 { + let forged = Initiator::start(&[7; 32], &DEV, &HOST, 1, &hello(100 + i)).unwrap(); + let out = host.handle_datagram(T0, &9, forged.msg1(), &creds); + let reply = only_cookie_reply(&out, 9, forged.msg1()); + // The attacker can't open a reply sealed under K, but say it + // could: the cookie only proves the address. + let parsed = cookie::CookieReply::parse(&reply).unwrap(); + let value = cookie::open( + &cookie::session_key(&K, &DEV), + &parsed, + &cookie::msg1_digest(forged.msg1()), + ) + .unwrap(); + host.handle_datagram(T0, &9, &cookie::attach(forged.msg1(), &value), &creds); + reads.push(host.msg1_reads()); + } + assert_eq!(*reads.last().unwrap(), 20, "the per-source limit"); + } } diff --git a/proto/src/v3/mod.rs b/proto/src/v3/mod.rs index d0248cb3..370d397f 100644 --- a/proto/src/v3/mod.rs +++ b/proto/src/v3/mod.rs @@ -57,6 +57,7 @@ //! Integers are little-endian throughout, like v2. pub mod client; +pub mod cookie; pub mod handshake; pub mod host; pub mod pairing; diff --git a/proto/src/v3/pairing.rs b/proto/src/v3/pairing.rs index 2e2cc987..b94b924d 100644 --- a/proto/src/v3/pairing.rs +++ b/proto/src/v3/pairing.rs @@ -33,6 +33,7 @@ use std::time::Duration; use snow::HandshakeState; use super::client::ClientKey; +use super::cookie::{self, CookieJar, CookieMode, CookieReply, LoadMeter}; use super::handshake::{builder, map_snow, split}; use super::host::{PeerAddr, SourceLimiter}; use super::record::{Opener, RecordHeader, Sealer}; @@ -213,7 +214,7 @@ pub fn qr_psk(secret: &Key32, ctx: &[u8]) -> Zeroizing { } /// HKDF-SHA-256 (RFC 5869) with a 32-byte output. -fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> Zeroizing { +pub(super) fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> Zeroizing { struct Len; impl ring::hkdf::KeyType for Len { fn len(&self) -> usize { @@ -335,6 +336,12 @@ pub struct PairingConfig { /// 10 µs), so the most the table admits, (1,024 + 1) × 20 a second, is /// about a fifth of one core. pub msg1_per_source_per_second: u32, + /// When message 1 must carry a valid cookie for its source, as on the + /// session path ([`super::host::HostConfig::cookies`]). + pub cookies: CookieMode, + /// Under load while this many pairing message 1s arrived during an open + /// window this second or the one before, whatever they carry. + pub cookie_load_msg1_per_second: u32, } impl Default for PairingConfig { @@ -344,6 +351,8 @@ impl Default for PairingConfig { answered_lifetime: Duration::from_secs(30), reanswer_interval: Duration::from_millis(50), msg1_per_source_per_second: 20, + cookies: CookieMode::UnderLoad, + cookie_load_msg1_per_second: 400, } } } @@ -422,6 +431,10 @@ pub struct PairingHost { attempt: Option>, /// Message 1 reads per source. per_source: SourceLimiter, + cookies: CookieJar, + /// Message 1 arrivals, for the load test. + arrivals: LoadMeter, + cookie_replies: u64, } impl PairingHost { @@ -433,7 +446,11 @@ impl PairingHost { rng: Box, ) -> Self { assert!(wire::valid_version(host_version)); + let mut rng = rng; Self { + cookies: CookieJar::new(Duration::ZERO, rng.as_mut()), + arrivals: LoadMeter::new(Duration::ZERO), + cookie_replies: 0, per_source: SourceLimiter::new(config.msg1_per_source_per_second), config, host_id, @@ -512,6 +529,23 @@ impl PairingHost { self.attempt.as_ref().map(|a| a.session_id) } + /// Cookie replies sent so far. + pub fn cookie_replies(&self) -> u64 { + self.cookie_replies + } + + /// Whether message 1 needs a cookie now: always or never as configured, + /// otherwise while many message 1s are arriving. + pub fn under_load(&self, now: Duration) -> bool { + match self.config.cookies { + CookieMode::Always => true, + CookieMode::Off => false, + CookieMode::UnderLoad => self + .arrivals + .busy(now, self.config.cookie_load_msg1_per_second), + } + } + /// Handles a pairing message 1, or a record of the pairing in flight. /// Anything else is ignored. pub fn handle_datagram( @@ -542,9 +576,42 @@ impl PairingHost { let Some(window) = self.window.as_ref().filter(|w| now < w.expires) else { return; }; - let Ok(pair1) = wire::parse_pair1(datagram) else { + let (canon, mac) = cookie::split(datagram); + let Ok(pair1) = wire::parse_pair1(&canon) else { return; }; + self.arrivals.record(now); + // Under load, nothing answers or reads a message 1 without a valid + // cookie for its exact source, retries of the pairing in flight + // included: only a cookie reply goes back, sealed under a key only + // the QR's holder can derive. + if self.under_load(now) { + let address = from.cookie_input(); + let verified = mac.is_some_and(|mac| { + self.cookies + .verify(now, &address, &canon, &mac, self.rng.as_mut()) + }); + if !verified { + let ctx = pairing_ctx( + pair1.ipad_version, + &self.host_version, + &pair1.slot_id, + &self.host_id, + &pair1.nonce_i, + &window.nonce_r, + ); + let key = cookie::pairing_key(&qr_psk(&window.secret, &ctx)); + let reply = + self.cookies + .reply(now, &address, &key, EXT_PAIR_QR, &canon, self.rng.as_mut()); + self.cookie_replies += 1; + out.push(PairOutput::Send { + to: from.clone(), + datagram: reply, + }); + return; + } + } if let Some(attempt) = self .attempt .as_mut() @@ -816,7 +883,12 @@ impl PairingHost { /// counterpart in tests. pub struct PairingClient { state: Option, + /// Message 1 without a cookie. msg1: Vec, + /// What a cookie reply to it is sealed under. + cookie_key: Zeroizing, + /// Message 1 with the host's newest cookie, once one came. + with_cookie: Option>, session: Option<(Sealer, Opener, [u8; 32])>, accept: Option, } @@ -880,14 +952,32 @@ impl PairingClient { Ok(Self { state: Some(state), msg1, + cookie_key: cookie::pairing_key(&psk), + with_cookie: None, session: None, accept: None, }) } - /// Message 1; retries resend these bytes. + /// Message 1 as sent now: retries resend these bytes, with the host's + /// cookie once one came. pub fn msg1(&self) -> &[u8] { - &self.msg1 + self.with_cookie.as_deref().unwrap_or(&self.msg1) + } + + /// A cookie reply to this message 1, before message 2: keeps the cookie + /// and gives back message 1 to send again at once. Anything else is + /// `None` and changes nothing. + pub fn handle_cookie(&mut self, datagram: &[u8]) -> Option> { + self.state.as_ref()?; + let reply = CookieReply::parse(datagram).ok()?; + if reply.tag != EXT_PAIR_QR { + return None; + } + let value = cookie::open(&self.cookie_key, &reply, &cookie::msg1_digest(&self.msg1))?; + let msg1 = cookie::attach(&self.msg1, &value); + self.with_cookie = Some(msg1.clone()); + Some(msg1) } /// Reads message 2. On failure nothing changes. On success the iPad diff --git a/proto/src/v3/pairing/tests.rs b/proto/src/v3/pairing/tests.rs index 007f3399..ead3113c 100644 --- a/proto/src/v3/pairing/tests.rs +++ b/proto/src/v3/pairing/tests.rs @@ -37,8 +37,16 @@ impl PairCredentials for Creds { } fn host() -> PairingHost { + host_with(CookieMode::UnderLoad) +} + +/// With cookies `Off`, for the budgets beneath the cookie. +fn host_with(cookies: CookieMode) -> PairingHost { PairingHost::new( - PairingConfig::default(), + PairingConfig { + cookies, + ..PairingConfig::default() + }, HOST, "0.4.0", "Test PC", @@ -643,27 +651,55 @@ fn hex(bytes: &[u8]) -> String { out } +/// What answers the iPad's message 1 once any cookie round trip is done: a +/// message 2, or nothing. +fn pair_msg2( + host: &mut PairingHost, + at: Duration, + ipad: &mut PairingClient, + creds: &Creds, +) -> Option> { + let mut out = sends(&host.handle_datagram(at, &ADDR, ipad.msg1(), creds)); + if let [reply] = &out[..] { + if let Some(msg1) = ipad.handle_cookie(reply) { + out = sends(&host.handle_datagram(at, &ADDR, &msg1, creds)); + } + } + out.into_iter() + .find(|d| wire::classify(d) == V3Kind::Handshake2) +} + #[test] fn one_sender_flooding_junk_never_blocks_a_pairing_from_another() { + for cookies in [CookieMode::Off, CookieMode::UnderLoad] { + one_sender_flooding(cookies); + } +} + +fn one_sender_flooding(cookies: CookieMode) { let creds = Creds::default(); - let mut host = host(); + let mut host = host_with(cookies); let qr = host.open(T0, addr()); let mut forged = qr.clone(); forged.secret = Zeroizing::new([0x03; 32]); let junk = client(&forged, 40).msg1().to_vec(); // A thousand a second from one LAN address, with no QR, for two seconds. let mut answered = 0; - let ipad = client(&qr, 41); + let mut ipad = client(&qr, 41); for step in 0..2_000u64 { let at = T0 + Duration::from_millis(step); host.handle_datagram(at, &2, &junk, &creds); // The real iPad retries every 250 ms, well into each second's // flood. if step % 250 == 125 { - answered += sends(&host.handle_datagram(at, &ADDR, ipad.msg1(), &creds)).len(); + answered += usize::from(pair_msg2(&mut host, at, &mut ipad, &creds).is_some()); } } - assert!(answered >= 1, "the genuine iPad got its message 2"); + assert!( + answered >= 1, + "the genuine iPad got its message 2: {cookies:?}" + ); + assert_eq!(host.cookie_replies() > 0, cookies != CookieMode::Off); } /// Many senders, each inside its own limit, burst junk right before each of @@ -671,13 +707,19 @@ fn one_sender_flooding_junk_never_blocks_a_pairing_from_another() { /// limit before authentication can be used up.) #[test] fn a_genuine_pairing_gets_message_2_behind_many_senders() { + for cookies in [CookieMode::Off, CookieMode::UnderLoad] { + behind_many_senders(cookies); + } +} + +fn behind_many_senders(cookies: CookieMode) { let creds = Creds::default(); - let mut host = host(); + let mut host = host_with(cookies); let qr = host.open(T0, addr()); let mut forged = qr.clone(); forged.secret = Zeroizing::new([0x04; 32]); let junk = client(&forged, 42).msg1().to_vec(); - let ipad = client(&qr, 43); + let mut ipad = client(&qr, 43); let mut answered = 0; for retry in 0..8u64 { let at = T0 + Duration::from_millis(250 * retry); @@ -686,7 +728,88 @@ fn a_genuine_pairing_gets_message_2_behind_many_senders() { host.handle_datagram(at, &(100 + source), &junk, &creds); } } - answered += sends(&host.handle_datagram(at, &ADDR, ipad.msg1(), &creds)).len(); + answered += usize::from(pair_msg2(&mut host, at, &mut ipad, &creds).is_some()); + } + assert!( + answered >= 1, + "message 2 on {answered} of 8 retries: {cookies:?}" + ); + assert_eq!(host.cookie_replies() > 0, cookies != CookieMode::Off); +} + +#[test] +fn under_load_a_pairing_message_1_gets_only_a_cookie_reply_and_pairs_through_it() { + let creds = Creds::default(); + let mut host = host_with(CookieMode::Always); + let qr = host.open(T0, addr()); + let mut ipad = client(&qr, 50); + let out = sends(&host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds)); + let [reply] = &out[..] else { + panic!("one reply") + }; + assert!(cookie::is_reply(reply)); + assert!(reply.len() < ipad.msg1().len()); + assert!(!host.in_flight(T0), "no pairing started"); + // It reads nothing and remembers nothing: the same message 1 gets the + // same treatment, and with the cookie it pairs. + let msg1 = ipad.handle_cookie(reply).expect("the iPad opens it"); + assert_eq!(ipad.msg1(), &msg1[..], "retries carry the cookie"); + let msg2 = sends(&host.handle_datagram(T0 + ms(1), &ADDR, &msg1, &creds)); + assert_eq!(msg2.len(), 1); + ipad.read_msg2(&msg2[0]).unwrap(); + let confirm = ipad.confirm(None).unwrap(); + let pending = store_of(host.handle_datagram(T0 + ms(2), &ADDR, &confirm, &creds)); + assert!(pending.is_some(), "stored after the confirm"); + // A retry of message 1 without the cookie, from anywhere: a cookie + // reply, never message 2 again. + let plain = client(&qr, 50); + let out = sends(&host.handle_datagram(T0 + ms(300), &9, plain.msg1(), &creds)); + assert!(out.len() == 1 && cookie::is_reply(&out[0])); + assert_eq!(host.cookie_replies(), 2); +} + +#[test] +fn only_the_qr_holder_can_open_a_pairing_cookie_reply() { + let creds = Creds::default(); + let mut host = host_with(CookieMode::Always); + let qr = host.open(T0, addr()); + let mut forged = qr.clone(); + forged.secret = Zeroizing::new([0x05; 32]); + let mut guess = client(&forged, 51); + let out = sends(&host.handle_datagram(T0, &ADDR, guess.msg1(), &creds)); + assert_eq!(out.len(), 1, "a reply: the host can't tell before the PSK"); + assert!(guess.handle_cookie(&out[0]).is_none()); + // Nor can another iPad that scanned the same QR (its own nonce_i). + let mut ipad = client(&qr, 52); + let mine = sends(&host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds)); + let mut other = client(&qr, 53); + assert!(other.handle_cookie(&mine[0]).is_none()); + assert!(ipad.handle_cookie(&out[0]).is_none(), "not its message 1"); + assert!(ipad.handle_cookie(&mine[0]).is_some()); + // And nothing pairs without a window. + host.close(); + assert!(host + .handle_datagram(T0, &ADDR, ipad.msg1(), &creds) + .is_empty()); +} + +#[test] +fn pairing_load_turns_on_with_arrivals_and_off_after_them() { + let creds = Creds::default(); + let mut host = host(); + let qr = host.open(T0, addr()); + let mut forged = qr.clone(); + forged.secret = Zeroizing::new([0x06; 32]); + let junk = client(&forged, 54).msg1().to_vec(); + assert!(!host.under_load(T0)); + for i in 0..400u32 { + host.handle_datagram(T0, &(1000 + i), &junk, &creds); } - assert!(answered >= 1, "message 2 on {answered} of 8 retries"); + assert!(host.under_load(T0)); + assert!(host.under_load(T0 + ms(1500))); + assert!(!host.under_load(T0 + Duration::from_secs(2))); + // A phase-1 style iPad that ignores cookies pairs once it's over. + let ipad = client(&qr, 55); + let out = sends(&host.handle_datagram(T0 + Duration::from_secs(2), &ADDR, ipad.msg1(), &creds)); + assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); } diff --git a/proto/testdata/v3/cookie_vectors.py b/proto/testdata/v3/cookie_vectors.py new file mode 100644 index 00000000..67bcb7e2 --- /dev/null +++ b/proto/testdata/v3/cookie_vectors.py @@ -0,0 +1,194 @@ +"""Writes cookie_vectors.txt: the return-routability cookie's vectors. + +Independent of the Rust code: HMAC and HKDF come from Python's hashlib and +hmac, and ChaCha20-Poly1305 is the RFC 8439 construction written out below, +checked against RFC 8439 section 2.8.2 before anything is written. Message 1 +is the golden transcript's (session_golden.txt), under its k_dev and dev_id. + + python3 -I proto/testdata/v3/cookie_vectors.py +""" + +import hashlib +import hmac +import ipaddress +import pathlib +import struct + +HERE = pathlib.Path(__file__).resolve().parent + +MAGIC = 0x4D45 +VERSION = 2 +COOKIE_TYPE = 0x42 +EXT_SESSION = 0x0001 +EXT_PAIR_QR = 0x0002 +EXT_COOKIE = 0x00FF +SALT = b"EM v3 cookie" + + +def rotl(v, n): + return ((v << n) & 0xFFFFFFFF) | (v >> (32 - n)) + + +def quarter(s, a, b, c, d): + s[a] = (s[a] + s[b]) & 0xFFFFFFFF + s[d] = rotl(s[d] ^ s[a], 16) + s[c] = (s[c] + s[d]) & 0xFFFFFFFF + s[b] = rotl(s[b] ^ s[c], 12) + s[a] = (s[a] + s[b]) & 0xFFFFFFFF + s[d] = rotl(s[d] ^ s[a], 8) + s[c] = (s[c] + s[d]) & 0xFFFFFFFF + s[b] = rotl(s[b] ^ s[c], 7) + + +def chacha_block(key, counter, nonce): + state = list(struct.unpack("<4I", b"expand 32-byte k")) + state += list(struct.unpack("<8I", key)) + state += [counter] + list(struct.unpack("<3I", nonce)) + s = state[:] + for _ in range(10): + quarter(s, 0, 4, 8, 12) + quarter(s, 1, 5, 9, 13) + quarter(s, 2, 6, 10, 14) + quarter(s, 3, 7, 11, 15) + quarter(s, 0, 5, 10, 15) + quarter(s, 1, 6, 11, 12) + quarter(s, 2, 7, 8, 13) + quarter(s, 3, 4, 9, 14) + return struct.pack("<16I", *((x + y) & 0xFFFFFFFF for x, y in zip(s, state))) + + +def chacha_xor(key, counter, nonce, data): + out = bytearray() + for i in range(0, len(data), 64): + block = chacha_block(key, counter + i // 64, nonce) + out += bytes(a ^ b for a, b in zip(data[i : i + 64], block)) + return bytes(out) + + +def poly1305(key, msg): + r = int.from_bytes(key[:16], "little") & 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF + s = int.from_bytes(key[16:], "little") + p = (1 << 130) - 5 + acc = 0 + for i in range(0, len(msg), 16): + n = int.from_bytes(msg[i : i + 16] + b"\x01", "little") + acc = (acc + n) * r % p + return ((acc + s) & ((1 << 128) - 1)).to_bytes(16, "little") + + +def pad16(b): + return b"\x00" * (-len(b) % 16) + + +def seal(key, nonce, plaintext, aad): + otk = chacha_block(key, 0, nonce)[:32] + ct = chacha_xor(key, 1, nonce, plaintext) + mac_data = aad + pad16(aad) + ct + pad16(ct) + mac_data += struct.pack("lowercase-hex` per line.", + "# Written by cookie_vectors.py (independent of the Rust code); message 1, k_dev", + "# and dev_id are session_golden.txt's. Addresses are the IP (4 or 16 bytes,", + "# v4-mapped IPv6 as IPv4) then the port, little-endian. cookie.* are", + "# HMAC-SHA-256(secret, address)[..16]; key.session is HKDF(salt \"EM v3 cookie\",", + "# k_dev, info dev_id), key.pairing HKDF(salt, pair_psk, info \"pair\");", + "# msg1.with_cookie is message 1 with EXT_COOKIE carrying msg1.mac2 under", + "# cookie.v4; reply.* seal cookie.v4 with AAD msg1.digest.", + "# Consumed by `cargo test -p eternal-wire --lib v3::cookie` and, from phase 3,", + "# by the iOS tests. Regenerate only on a deliberate wire change.", + ] + text = "\n".join(header + [f"{name} {value.hex()}" for name, value in rows]) + "\n" + (HERE / "cookie_vectors.txt").write_text(text) + + +if __name__ == "__main__": + main() diff --git a/proto/testdata/v3/cookie_vectors.txt b/proto/testdata/v3/cookie_vectors.txt new file mode 100644 index 00000000..9400e8ba --- /dev/null +++ b/proto/testdata/v3/cookie_vectors.txt @@ -0,0 +1,25 @@ +# Return-routability cookie vectors. One `namelowercase-hex` per line. +# Written by cookie_vectors.py (independent of the Rust code); message 1, k_dev +# and dev_id are session_golden.txt's. Addresses are the IP (4 or 16 bytes, +# v4-mapped IPv6 as IPv4) then the port, little-endian. cookie.* are +# HMAC-SHA-256(secret, address)[..16]; key.session is HKDF(salt "EM v3 cookie", +# k_dev, info dev_id), key.pairing HKDF(salt, pair_psk, info "pair"); +# msg1.with_cookie is message 1 with EXT_COOKIE carrying msg1.mac2 under +# cookie.v4; reply.* seal cookie.v4 with AAD msg1.digest. +# Consumed by `cargo test -p eternal-wire --lib v3::cookie` and, from phase 3, +# by the iOS tests. Regenerate only on a deliberate wire change. +input.secret c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf +input.address_v4 c000020a50c3 +input.address_v6 20010db800000000000000000000000150c3 +input.pair_psk 505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f +input.nonce_session 0102030405060708090a0b0c +input.nonce_pair 1112131415161718191a1b1c +cookie.v4 5ab8c96047809bf691b5e43bd2788b6c +cookie.v6 012b42ad3511e2d4caa08a1716169841 +key.session 436c023aa1d5860964056150cbbd372a81b586a96917c5d4aaad3f0599ab118e +key.pairing ac86452d1cb196443b385343b085d2201686fd0e28ca67790cdba349a1d23bbb +msg1.digest 5db3e630cad3c7dbd4fa60b90442e5067e567f1162a1f78893a0dba5f31c6997 +msg1.mac2 1a44d5908ed63b5bc75eadd8ffa594ce +msg1.with_cookie 454d02100000b50000000000010000000303d4c3b2a1cbc30000000000000000000000000000000000000000000000000000000000000000000000000000000000000001006a00d0d3d6d9dcdfe2e5e8ebeef1f4f7fafd93f0b6e180f3b43d8a422040f863ef12bfeeb14c03b1f304e73cca999426ab5d3a7ab33ae01cda93610712ce93e9fdfd37e921ea8471848b5f521643592de04f429ca0e88a8c1f17c5a2997270cee460a5eb6c237bc7c1afd52eff0010001a44d5908ed63b5bc75eadd8ffa594ce +reply.session 454d024200003000000000000000000001002c000102030405060708090a0b0cf870d9945a8cfada0e96bdff4c142cf585aa8f0378cda289329f32a6b31f794b +reply.pairing 454d024200003000000000000000000002002c001112131415161718191a1b1c39d3aa0168ff5cea08a3bea54dea7ca07506214e5985e48645d4f9166b5f0ae7 diff --git a/proto/tests/v3_session_sim.rs b/proto/tests/v3_session_sim.rs index cbba7826..e1d9485b 100644 --- a/proto/tests/v3_session_sim.rs +++ b/proto/tests/v3_session_sim.rs @@ -8,6 +8,7 @@ use std::time::Duration; use eternal_wire::v2::PacketType; use eternal_wire::v3::client::{ClientClose, ClientConfig, ClientKey}; +use eternal_wire::v3::cookie::CookieMode; use eternal_wire::v3::host::{CloseReason, HostConfig}; use v3_sim::*; @@ -1170,8 +1171,11 @@ fn a_clock_far_ahead_of_the_host_still_connects() { fn floods_never_block_a_reconnect_from_the_live_address() { use eternal_wire::v3::handshake::Initiator; use eternal_wire::v3::wire::HelloPayload; - for known in [false, true] { - let mut sim = Sim::new(116 + u64::from(known)); + for (known, cookies) in [false, true] + .into_iter() + .flat_map(|k| [(k, CookieMode::Off), (k, CookieMode::UnderLoad)]) + { + let mut sim = sim_with_cookies(116 + u64::from(known), cookies); if known { connect_live(&mut sim); } @@ -1211,7 +1215,8 @@ fn floods_never_block_a_reconnect_from_the_live_address() { break; } } - assert!(done, "known dev_id: {known}"); + assert!(done, "known dev_id: {known}, {cookies:?}"); + check_cookie_path(&sim, cookies); } } @@ -1413,6 +1418,25 @@ fn connect_behind_bursts(sim: &mut Sim, mut burst: impl FnMut(&mut Sim)) -> bool false } +/// A sim whose host requires cookies as `cookies` says: `Off` tests the +/// budgets beneath the cookie, the default tests the cookie round trip. +fn sim_with_cookies(seed: u64, cookies: CookieMode) -> Sim { + let host = HostConfig { + cookies, + ..HostConfig::new(HOST_ID) + }; + Sim::with_configs(seed, host, ClientConfig::new(HOST_ID)) +} + +/// The flood was load exactly when cookies were on. +fn check_cookie_path(sim: &Sim, cookies: CookieMode) { + assert_eq!( + sim.host.cookie_replies() > 0, + cookies != CookieMode::Off, + "{cookies:?}" + ); +} + fn forged_for(dev_id: &eternal_wire::v3::Id16, count: u64) -> Vec> { use eternal_wire::v3::handshake::Initiator; use eternal_wire::v3::wire::HelloPayload; @@ -1573,7 +1597,13 @@ fn a_disconnected_client_switching_keys_keeps_the_intent_on_failure() { /// get no answer; those are remembered, and their replays cost no crypto. #[test] fn genuine_replayed_message_1s_never_starve_a_fresh_connect() { - let mut sim = Sim::new(141); + for cookies in [CookieMode::Off, CookieMode::UnderLoad] { + genuine_replays_behind(cookies); + } +} + +fn genuine_replays_behind(cookies: CookieMode) { + let mut sim = sim_with_cookies(141, cookies); for _ in 0..31 { connect_live(&mut sim); } @@ -1607,7 +1637,8 @@ fn genuine_replayed_message_1s_never_starve_a_fresh_connect() { } round += 1; }); - assert!(ok); + assert!(ok, "{cookies:?}"); + check_cookie_path(&sim, cookies); } /// Forged message 1s from the live address above its 2,000/s pre-crypto @@ -1615,15 +1646,18 @@ fn genuine_replayed_message_1s_never_starve_a_fresh_connect() { /// instead of being dropped. #[test] fn past_the_live_address_cap_the_reconnect_falls_back_to_the_per_source_path() { - let mut sim = Sim::new(142); - connect_live(&mut sim); - let forged = forged_for(&sim.key.dev_id, 100); - let ok = connect_behind_bursts(&mut sim, |sim| { - for i in 0..2010 { - sim.inject_from(IPAD, &forged[i % forged.len()]); - } - }); - assert!(ok); + for cookies in [CookieMode::Off, CookieMode::UnderLoad] { + let mut sim = sim_with_cookies(142, cookies); + connect_live(&mut sim); + let forged = forged_for(&sim.key.dev_id, 100); + let ok = connect_behind_bursts(&mut sim, |sim| { + for i in 0..2010 { + sim.inject_from(IPAD, &forged[i % forged.len()]); + } + }); + assert!(ok, "{cookies:?}"); + check_cookie_path(&sim, cookies); + } } /// The iPad's first message 1 is lost on its way, and an attacker who saw diff --git a/proto/tests/v3_sim/mod.rs b/proto/tests/v3_sim/mod.rs index 8f164ac8..50969b98 100644 --- a/proto/tests/v3_sim/mod.rs +++ b/proto/tests/v3_sim/mod.rs @@ -44,6 +44,9 @@ impl PeerAddr for SimAddr { fn rate_bucket(&self) -> u16 { self.ip } + fn cookie_input(&self) -> Vec { + [self.ip.to_le_bytes(), self.port.to_le_bytes()].concat() + } } pub const IPAD: SimAddr = SimAddr { From 69d74524173dfc4f8dc8daaa03dd57d7699c986e Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 14:26:24 -0500 Subject: [PATCH 2/5] Transport v3 cookie: cached answers to a new address need a cookie A cached message 2, or a cached Stale, TooNew or PairAgain answer, now goes without a cookie only to the exact address (IP and port) its message 1 came from. Any other address gets a cookie reply until it comes back with a valid cookie, whether or not the host is under load, and then has its own resend allowance. This applies to both the session and the pairing handshake. A cookie opens only under the handshake's own key, so copies of a message 1 replayed from other addresses, at any rate, no longer use any allowance the iPad's retries need. The shared budget for cached answers to other sources is removed, because only the key holder can reach it now. --- proto/src/v3/cookie.rs | 7 +- proto/src/v3/host.rs | 351 ++++++++++++++++++++++------------ proto/src/v3/pairing.rs | 101 ++++++---- proto/src/v3/pairing/tests.rs | 45 +++++ proto/tests/v3_session_sim.rs | 21 +- 5 files changed, 359 insertions(+), 166 deletions(-) diff --git a/proto/src/v3/cookie.rs b/proto/src/v3/cookie.rs index 83507684..39b5202c 100644 --- a/proto/src/v3/cookie.rs +++ b/proto/src/v3/cookie.rs @@ -324,14 +324,17 @@ impl CookieJar { } } -/// When a host requires a cookie on message 1. +/// When a host requires a cookie on every message 1. In every mode, a +/// cached answer (message 2, or Stale, TooNew and PairAgain) goes to an +/// address other than the one its message 1 came from only with a cookie. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CookieMode { /// While the handshake is under load: the default. UnderLoad, /// Always, for tests of the cookie path. Always, - /// Never, for tests of the budgets beneath it. Never a host's setting. + /// Never for load, for tests of the budgets beneath it. Never a + /// host's setting. Off, } diff --git a/proto/src/v3/host.rs b/proto/src/v3/host.rs index 371c2663..610d5521 100644 --- a/proto/src/v3/host.rs +++ b/proto/src/v3/host.rs @@ -93,9 +93,6 @@ pub struct HostConfig { /// before authentication. Its reserved budget of `msg1_per_second` is /// charged only after authentication. pub reserved_precrypto_per_second: u32, - /// Cached `Stale`, `TooNew` and `PairAgain` replays per second, - /// host-wide, to sources other than the one whose message 1 got them. - pub unverified_replies_per_second: u32, /// How far ahead of the later of the host's wall clock and the /// committed timestamp a message 1 timestamp may be. pub max_timestamp_ahead: Duration, @@ -130,7 +127,6 @@ impl HostConfig { msg1_per_second: 20, dh_per_second: 200, reserved_precrypto_per_second: 2000, - unverified_replies_per_second: 50, max_timestamp_ahead: Duration::from_secs(24 * 60 * 60), max_pending: 16, liveness_timeout: Duration::from_secs(3), @@ -355,12 +351,8 @@ struct PendingSession { opener: Opener, msg1_key: Digest, msg2: Vec, - /// When the cached message 2 last went to each source. Each source has - /// its own allowance; sources beyond the capacity share one. - msg2_sent: BoundedTimes<::Bucket>, - /// When the shared allowance for sources beyond the capacity was last - /// used. - msg2_overflow_sent: Option, + /// Where the cached message 2 may go again, and when it last did. + resends: ResendSlots, deadline: Duration, timestamp_us: u64, hello: HelloPayload, @@ -495,14 +487,14 @@ struct StaleBudget { /// Message 1s remembered without an answer. unanswered: RateWindow, /// Newest last. - cached: Vec::Bucket>>, + cached: Vec>, } -struct CachedAnswer { +struct CachedAnswer { key: Digest, msg2: Vec, - /// The source whose message 1 got this answer. - origin: B, + /// The exact address whose message 1 got this answer. + origin: A, until: Duration, } @@ -601,9 +593,62 @@ impl BoundedTimes { } } +/// Where a cached message 2 may be resent, at most once per interval each: +/// the exact address its message 1 came from, and addresses that came back +/// with a valid cookie. A cookie opens only under the handshake's own key, +/// so only the iPad can admit an address: spoofed copies from elsewhere get +/// a cookie reply and use none of this. Addresses beyond the capacity share +/// one more allowance. +pub(super) struct ResendSlots { + sent: BoundedTimes, + overflow: Option, +} + +impl ResendSlots { + /// Message 2 just went to `origin`. + pub(super) fn new(origin: A, now: Duration) -> Self { + let mut sent = BoundedTimes::new(MAX_MSG2_SOURCES); + sent.insert(origin, now); + Self { + sent, + overflow: None, + } + } + + /// Whether `to` may have resends without showing a cookie. + pub(super) fn admitted(&self, to: &A) -> bool { + self.sent.get(to).is_some() + } + + /// Whether a resend to `to` (admitted, or with a valid cookie) is due, + /// and if so records it. + pub(super) fn due(&mut self, to: &A, now: Duration, interval: Duration) -> bool { + match self.sent.get(to) { + Some(t) => { + let due = now.saturating_sub(t) >= interval; + if due { + self.sent.insert(to.clone(), now); + } + due + } + None if !self.sent.is_full() => self.sent.insert(to.clone(), now), + None => { + let due = self + .overflow + .is_none_or(|t| now.saturating_sub(t) >= interval); + if due { + self.overflow = Some(now); + } + due + } + } + } +} + /// Sources tracked by each per-source limit. const MAX_RATE_ENTRIES: usize = 1024; -/// Sources a pending session remembers message 2 resends for. +/// Addresses a pending message 2 (or a pairing's) may be resent to, each +/// with its own allowance. const MAX_MSG2_SOURCES: usize = 16; /// Remembered timed-out message 1s. const MAX_EXPIRED_MSG1: usize = 4096; @@ -645,7 +690,6 @@ pub struct HostSessions { /// from the config. answered_capacity: usize, dh_budget: RateWindow, - unverified_replies: RateWindow, /// The host's wall clock: µs since the Unix epoch at a given `now`. wall_clock: (Duration, u64), msg1_reads: u64, @@ -689,10 +733,6 @@ impl HostSessions { start: Duration::ZERO, count: 0, }, - unverified_replies: RateWindow { - start: Duration::ZERO, - count: 0, - }, wall_clock: (now, wall_us), msg1_reads: 0, dh_performed: 0, @@ -839,77 +879,56 @@ impl HostSessions { { return; } + let verified = mac.is_some_and(|mac| { + self.cookies + .verify(now, &from.cookie_input(), &canon, &mac, self.rng.as_mut()) + }); // Under load, nothing answers or reads a message 1 without a valid // cookie for its exact source: only a cookie reply goes back, and // only for a known dev_id. That covers new message 1s, cached // answers and the live address alike. - if self.under_load(now) { - let address = from.cookie_input(); - let verified = mac.is_some_and(|mac| { - self.cookies - .verify(now, &address, &canon, &mac, self.rng.as_mut()) - }); - if !verified { - if let Some(k_dev) = creds.key(&msg1.dev_id) { - let key = cookie::session_key(&k_dev, &msg1.dev_id); - let reply = self.cookies.reply( - now, - &address, - &key, - super::EXT_SESSION, - &canon, - self.rng.as_mut(), - ); - self.cookie_replies += 1; - out.push(HostOutput::Send { - to: from.clone(), - datagram: reply, - }); - } - return; - } + if self.under_load(now) && !verified { + self.send_cookie(now, from, &canon, &msg1.dev_id, creds, out); + return; } let dev_id = msg1.dev_id; let resend_interval = self.config.msg2_resend_interval; - let unverified_limit = self.config.unverified_replies_per_second; - if let Some(device) = self.devices.get_mut(&dev_id) { - if let Some(p) = device.pending.as_mut().filter(|p| p.msg1_key == key) { - // A retry of the message 1 that made this pending session: - // the cached message 2 again. Each source has its own 100 ms - // allowance, whichever sent first; sources beyond - // MAX_MSG2_SOURCES share one more. - let send = match p.msg2_sent.get(&bucket) { - Some(t) => { - let due = now.saturating_sub(t) >= resend_interval; - if due { - p.msg2_sent.insert(bucket.clone(), now); - } - due - } - None if !p.msg2_sent.is_full() => p.msg2_sent.insert(bucket.clone(), now), - None => { - // The table is full: new sources share one allowance, - // so 16 spoofed copies can't lock the iPad out. - let due = p - .msg2_overflow_sent - .is_none_or(|t| now.saturating_sub(t) >= resend_interval); - if due { - p.msg2_overflow_sent = Some(now); - } - due - } - }; - if send { - out.push(HostOutput::Send { - to: from.clone(), - datagram: p.msg2.clone(), - }); - } + let resend = self + .devices + .get(&dev_id) + .and_then(|d| d.pending.as_ref()) + .filter(|p| p.msg1_key == key) + .map(|p| p.resends.admitted(from)); + if let Some(admitted) = resend { + // A retry of the message 1 that made this pending session: the + // cached message 2 again, at most every 100 ms per address. An + // address other than the one it came from must show a cookie + // first, load or not, so spoofed copies can't take the + // allowances the iPad's retries need. + if !admitted && !verified { + self.send_cookie(now, from, &canon, &dev_id, creds, out); return; } - if device.live.as_ref().is_some_and(|l| l.msg1_key == key) { - return; + let p = self + .devices + .get_mut(&dev_id) + .and_then(|d| d.pending.as_mut()) + .expect("checked above"); + if p.resends.due(from, now, resend_interval) { + out.push(HostOutput::Send { + to: from.clone(), + datagram: p.msg2.clone(), + }); } + return; + } + if self + .devices + .get(&dev_id) + .and_then(|d| d.live.as_ref()) + .is_some_and(|l| l.msg1_key == key) + { + return; } if let Some(answer) = self .stale @@ -917,12 +936,14 @@ impl HostSessions { .and_then(|b| b.cached.iter().find(|c| c.key == key && now < c.until)) { // A replay of a message 1 that got an answer without a pending - // session: the cached answer, within per-source budgets. + // session: the cached answer, within its per-source budget. As + // for message 2, another address must show a cookie first. let msg2 = answer.msg2.clone(); - let from_origin = answer.origin == bucket; - if self.stale_replay_rate.allow(now, &bucket) - && (from_origin || self.unverified_replies.allow(now, unverified_limit)) - { + if answer.origin != *from && !verified { + self.send_cookie(now, from, &canon, &dev_id, creds, out); + return; + } + if self.stale_replay_rate.allow(now, &bucket) { out.push(HostOutput::Send { to: from.clone(), datagram: msg2, @@ -1043,12 +1064,7 @@ impl HostSessions { opener, msg1_key: key, msg2: msg2.clone(), - msg2_sent: { - let mut sent = BoundedTimes::new(MAX_MSG2_SOURCES); - sent.insert(bucket, now); - sent - }, - msg2_overflow_sent: None, + resends: ResendSlots::new(from.clone(), now), deadline: now + self.config.pending_lifetime, timestamp_us, hello, @@ -1062,6 +1078,36 @@ impl HostSessions { }); } + /// A cookie reply to `msg1` (without its cookie) from `from`, if + /// `dev_id` is known. + fn send_cookie( + &mut self, + now: Duration, + from: &A, + msg1: &[u8], + dev_id: &Id16, + creds: &dyn HostCredentials, + out: &mut Vec>, + ) { + let Some(k_dev) = creds.key(dev_id) else { + return; + }; + let key = cookie::session_key(&k_dev, dev_id); + let reply = self.cookies.reply( + now, + &from.cookie_input(), + &key, + super::EXT_SESSION, + msg1, + self.rng.as_mut(), + ); + self.cookie_replies += 1; + out.push(HostOutput::Send { + to: from.clone(), + datagram: reply, + }); + } + /// Answers a message 1 with `Stale`, `TooNew` or `PairAgain`, within the /// `dev_id`'s budget, caches the answer, and remembers the message 1. #[allow(clippy::too_many_arguments)] @@ -1093,7 +1139,7 @@ impl HostSessions { return; }; let until = now + self.config.expired_msg1_lifetime; - let origin = from.rate_bucket(); + let origin = from.clone(); let budget = self.stale_budget(dev_id, now); if budget.cached.len() >= MAX_CACHED_STALE { budget.cached.remove(0); @@ -2242,7 +2288,12 @@ mod tests { let replayed = Initiator::start(&K, &DEV, &HOST, 1, &hello(900)).unwrap(); let later = T0 + Duration::from_secs(2); let first = sends(&host.handle_datagram(later, &7, replayed.msg1(), &creds)); - let again = sends(&host.handle_datagram(later, &8, replayed.msg1(), &creds)); + let out = host.handle_datagram(later, &8, replayed.msg1(), &creds); + let msg1 = with_cookie( + &only_cookie_reply(&out, 8, replayed.msg1()), + replayed.msg1(), + ); + let again = sends(&host.handle_datagram(later, &8, &msg1, &creds)); assert_eq!(first.len(), 1); assert_eq!(first, again, "the cached answer, not a new handshake"); } @@ -2410,13 +2461,18 @@ mod tests { sends(&host.handle_datagram(T0, &1, ipad.msg1(), &creds)).len(), 1 ); - // A spoofed replay of that message 1 from elsewhere... + // A spoofed replay of that message 1 from elsewhere gets only a + // cookie reply... + let out = host.handle_datagram(T0 + ms(120), &9, ipad.msg1(), &creds); + let reply = only_cookie_reply(&out, 9, ipad.msg1()); + // ...and with the cookie (which needs K), its own allowance. + let msg1 = with_cookie(&reply, ipad.msg1()); assert_eq!( - sends_to(&host.handle_datagram(T0 + ms(120), &9, ipad.msg1(), &creds)), + sends_to(&host.handle_datagram(T0 + ms(120), &9, &msg1, &creds)), vec![9] ); assert!(host - .handle_datagram(T0 + ms(130), &9, ipad.msg1(), &creds) + .handle_datagram(T0 + ms(130), &9, &msg1, &creds) .is_empty()); // ...never uses up the iPad's own resend. assert_eq!( @@ -2459,21 +2515,28 @@ mod tests { } #[test] - fn cached_answers_to_other_sources_share_a_host_wide_budget() { + fn cached_answers_go_to_other_addresses_only_with_a_cookie() { let (mut host, creds) = setup(); *creds.timestamp.borrow_mut() = 1000; let old = Initiator::start(&K, &DEV, &HOST, 1, &hello(500)).unwrap(); + let answer = sends(&host.handle_datagram(T0, &1, old.msg1(), &creds)); + assert_eq!(answer.len(), 1); + // Replays of it from 200 other sources within a second: cookie + // replies only, so none of them uses an answer budget. + for source in 100..300u32 { + let out = host.handle_datagram(T0 + ms(10), &source, old.msg1(), &creds); + only_cookie_reply(&out, source, old.msg1()); + } + // Another address with the cookie (the iPad, moved) gets the cached + // answer, without another handshake. + let dh = host.handshakes_performed(); + let out = host.handle_datagram(T0 + ms(15), &2, old.msg1(), &creds); + let msg1 = with_cookie(&only_cookie_reply(&out, 2, old.msg1()), old.msg1()); assert_eq!( - sends(&host.handle_datagram(T0, &1, old.msg1(), &creds)).len(), - 1 + sends(&host.handle_datagram(T0 + ms(15), &2, &msg1, &creds)), + answer ); - // Replays of it from 200 other sources within a second. - let replies: usize = (100..300u32) - .map(|source| { - sends(&host.handle_datagram(T0 + ms(10), &source, old.msg1(), &creds)).len() - }) - .sum(); - assert_eq!(replies, 50, "unverified replies are capped host-wide"); + assert_eq!(host.handshakes_performed(), dh); // The source that sent it first keeps its own allowance. assert_eq!( sends(&host.handle_datagram(T0 + ms(20), &1, old.msg1(), &creds)).len(), @@ -2492,9 +2555,17 @@ mod tests { let (mut host, creds) = setup(); let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); host.handle_datagram(T0, &1, ipad.msg1(), &creds); + // Without a cookie, other sources get no message 2 at all. + for source in 100..300u32 { + let out = host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); + only_cookie_reply(&out, source, ipad.msg1()); + } + // With one (only the iPad can have it), at most 16 addresses. let replies: usize = (100..300u32) .map(|source| { - sends(&host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds)).len() + let out = host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); + let msg1 = with_cookie(&only_cookie_reply(&out, source, ipad.msg1()), ipad.msg1()); + sends(&host.handle_datagram(T0 + ms(10), &source, &msg1, &creds)).len() }) .sum(); assert_eq!( @@ -2849,22 +2920,24 @@ mod tests { let (mut host, creds) = setup(); let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); // A copy raced in from another source creates the pending session; - // the iPad's own copy follows. + // the iPad's own copy follows, and gets message 2 through a cookie. host.handle_datagram(T0, &77, ipad.msg1(), &creds); - assert_eq!( - sends(&host.handle_datagram(T0 + ms(5), &ADDR, ipad.msg1(), &creds)).len(), - 1 - ); - // Replays from 200 more sources... + let out = host.handle_datagram(T0 + ms(5), &ADDR, ipad.msg1(), &creds); + let msg1 = with_cookie(&only_cookie_reply(&out, ADDR, ipad.msg1()), ipad.msg1()); + let out = sends(&host.handle_datagram(T0 + ms(5), &ADDR, &msg1, &creds)); + assert_eq!(out.len(), 1); + assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); + // Replays from 200 more sources get only cookie replies... for source in 100..300u32 { - host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); + let out = host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); + only_cookie_reply(&out, source, ipad.msg1()); } - // ...never take the iPad's own allowance: its retry gets the resend. + // ...and never take the iPad's own allowance. assert!(host - .handle_datagram(T0 + ms(30), &ADDR, ipad.msg1(), &creds) + .handle_datagram(T0 + ms(30), &ADDR, &msg1, &creds) .is_empty()); assert_eq!( - sends(&host.handle_datagram(T0 + ms(120), &ADDR, ipad.msg1(), &creds)).len(), + sends(&host.handle_datagram(T0 + ms(120), &ADDR, &msg1, &creds)).len(), 1 ); } @@ -3137,4 +3210,46 @@ mod tests { } assert_eq!(*reads.last().unwrap(), 20, "the per-source limit"); } + + /// The iPad's message 1 was captured and its message 2 lost; spoofed + /// copies arrive first from 16 sources, then one more every 100 ms, + /// far below the load threshold. Every copy from elsewhere gets only a + /// cookie reply, and every retry of the iPad gets message 2. + #[test] + fn low_rate_spoofed_copies_never_take_the_ipads_message_2_resends() { + let (mut host, creds) = setup(); + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); + // The first copy makes the pending session; the rest are refused + // admission without a cookie. + assert_eq!( + sends(&host.handle_datagram(T0, &100, ipad.msg1(), &creds)).len(), + 1 + ); + for source in 101..116u32 { + let out = host.handle_datagram(T0, &source, ipad.msg1(), &creds); + only_cookie_reply(&out, source, ipad.msg1()); + } + let mut msg1 = ipad.msg1().to_vec(); + let mut received = 0; + for tick in 0..40u64 { + let at = T0 + ms(tick * 50); + if tick % 2 == 0 { + let out = host.handle_datagram(at, &999, ipad.msg1(), &creds); + only_cookie_reply(&out, 999, ipad.msg1()); + } + if tick % 5 == 0 { + // The iPad's retry every 250 ms, as the client sends it: with + // the cookie once it has one. + let out = sends(&host.handle_datagram(at + ms(1), &ADDR, &msg1, &creds)); + match &out[..] { + [d] if cookie::is_reply(d) => msg1 = with_cookie(d, ipad.msg1()), + [d] if wire::classify(d) == V3Kind::Handshake2 => received += 1, + other => panic!("{} outputs", other.len()), + } + } + assert!(!host.under_load(at)); + } + assert_eq!(received, 7, "every retry after the cookie round trip"); + assert_eq!(host.pending_count(), 1); + } } diff --git a/proto/src/v3/pairing.rs b/proto/src/v3/pairing.rs index b94b924d..cde6a291 100644 --- a/proto/src/v3/pairing.rs +++ b/proto/src/v3/pairing.rs @@ -35,7 +35,7 @@ use snow::HandshakeState; use super::client::ClientKey; use super::cookie::{self, CookieJar, CookieMode, CookieReply, LoadMeter}; use super::handshake::{builder, map_snow, split}; -use super::host::{PeerAddr, SourceLimiter}; +use super::host::{PeerAddr, ResendSlots, SourceLimiter}; use super::record::{Opener, RecordHeader, Sealer}; use super::wire::{ self, PairAccept, PairHello, PairMessage, SlotStatus, V3Kind, MAX_NOISE_MESSAGE, @@ -386,7 +386,8 @@ struct Attempt { /// The authenticated part of the message 1 it answered. msg1_noise: Vec, msg2: Vec, - last_msg2: Duration, + /// Where message 2 may be resent, and when it last was. + resends: ResendSlots, session_id: u32, sealer: Sealer, opener: Opener, @@ -546,6 +547,30 @@ impl PairingHost { } } + /// A cookie reply to `msg1` (without its cookie) from `from`. + fn send_cookie( + &mut self, + now: Duration, + from: &A, + msg1: &[u8], + key: &Key32, + out: &mut Vec>, + ) { + let reply = self.cookies.reply( + now, + &from.cookie_input(), + key, + EXT_PAIR_QR, + msg1, + self.rng.as_mut(), + ); + self.cookie_replies += 1; + out.push(PairOutput::Send { + to: from.clone(), + datagram: reply, + }); + } + /// Handles a pairing message 1, or a record of the pairing in flight. /// Anything else is ignored. pub fn handle_datagram( @@ -581,48 +606,50 @@ impl PairingHost { return; }; self.arrivals.record(now); + let verified = mac.is_some_and(|mac| { + self.cookies + .verify(now, &from.cookie_input(), &canon, &mac, self.rng.as_mut()) + }); // Under load, nothing answers or reads a message 1 without a valid // cookie for its exact source, retries of the pairing in flight // included: only a cookie reply goes back, sealed under a key only // the QR's holder can derive. - if self.under_load(now) { - let address = from.cookie_input(); - let verified = mac.is_some_and(|mac| { - self.cookies - .verify(now, &address, &canon, &mac, self.rng.as_mut()) - }); - if !verified { - let ctx = pairing_ctx( - pair1.ipad_version, - &self.host_version, - &pair1.slot_id, - &self.host_id, - &pair1.nonce_i, - &window.nonce_r, - ); - let key = cookie::pairing_key(&qr_psk(&window.secret, &ctx)); - let reply = - self.cookies - .reply(now, &address, &key, EXT_PAIR_QR, &canon, self.rng.as_mut()); - self.cookie_replies += 1; - out.push(PairOutput::Send { - to: from.clone(), - datagram: reply, - }); - return; - } + let cookie_key = || { + let ctx = pairing_ctx( + pair1.ipad_version, + &self.host_version, + &pair1.slot_id, + &self.host_id, + &pair1.nonce_i, + &window.nonce_r, + ); + cookie::pairing_key(&qr_psk(&window.secret, &ctx)) + }; + if self.under_load(now) && !verified { + let key = cookie_key(); + self.send_cookie(now, from, &canon, &key, out); + return; } - if let Some(attempt) = self + let resend = self .attempt - .as_mut() + .as_ref() .filter(|a| a.msg1_noise == pair1.noise) - { + .map(|a| (a.step, a.resends.admitted(from))); + if let Some((step, admitted)) = resend { // The same message 1 again: the same message 2, at most every - // 100 ms. - if attempt.step == Step::AwaitingConfirm - && now.saturating_sub(attempt.last_msg2) >= Duration::from_millis(100) - { - attempt.last_msg2 = now; + // 100 ms per address. An address other than the one it came + // from must show a cookie first, load or not, so spoofed copies + // can't take the iPad's resends. + if step != Step::AwaitingConfirm { + return; + } + if !admitted && !verified { + let key = cookie_key(); + self.send_cookie(now, from, &canon, &key, out); + return; + } + let attempt = self.attempt.as_mut().expect("checked above"); + if attempt.resends.due(from, now, Duration::from_millis(100)) { out.push(PairOutput::Send { to: from.clone(), datagram: attempt.msg2.clone(), @@ -710,7 +737,7 @@ impl PairingHost { self.attempt = Some(Attempt { msg1_noise: pair1.noise.to_vec(), msg2, - last_msg2: now, + resends: ResendSlots::new(from.clone(), now), session_id, sealer, opener, diff --git a/proto/src/v3/pairing/tests.rs b/proto/src/v3/pairing/tests.rs index ead3113c..cded66e1 100644 --- a/proto/src/v3/pairing/tests.rs +++ b/proto/src/v3/pairing/tests.rs @@ -813,3 +813,48 @@ fn pairing_load_turns_on_with_arrivals_and_off_after_them() { let out = sends(&host.handle_datagram(T0 + Duration::from_secs(2), &ADDR, ipad.msg1(), &creds)); assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); } + +/// Spoofed replays of the iPad's pairing message 1, ten a second, far below +/// the load threshold: they get only cookie replies, and every retry of the +/// iPad gets message 2, whether its own copy or a spoofed one came first. +#[test] +fn low_rate_spoofed_replays_never_take_the_ipads_message_2_resends() { + for spoofer_first in [false, true] { + let creds = Creds::default(); + let mut host = host(); + let qr = host.open(T0, addr()); + let mut ipad = client(&qr, 77); + let first = if spoofer_first { 999 } else { ADDR }; + // The first message 2 is lost. + assert_eq!( + sends(&host.handle_datagram(T0, &first, ipad.msg1(), &creds)).len(), + 1 + ); + let mut received = 0; + for tick in 1..41u64 { + let at = T0 + ms(tick * 50); + if tick % 2 == 0 { + let out = sends(&host.handle_datagram(at, &998, ipad.msg1(), &creds)); + assert!( + out.len() == 1 && cookie::is_reply(&out[0]), + "a cookie reply only" + ); + } + if tick % 5 == 0 { + let msg1 = ipad.msg1().to_vec(); + for d in sends(&host.handle_datagram(at + ms(1), &ADDR, &msg1, &creds)) { + if let Some(again) = ipad.handle_cookie(&d) { + // At once, with the cookie. + let out = sends(&host.handle_datagram(at + ms(1), &ADDR, &again, &creds)); + received += out.len(); + } else { + assert_eq!(wire::classify(&d), V3Kind::Handshake2); + received += 1; + } + } + } + assert!(!host.under_load(at)); + } + assert_eq!(received, 8, "every retry; spoofer first: {spoofer_first}"); + } +} diff --git a/proto/tests/v3_session_sim.rs b/proto/tests/v3_session_sim.rs index e1d9485b..fe1a5c91 100644 --- a/proto/tests/v3_session_sim.rs +++ b/proto/tests/v3_session_sim.rs @@ -1428,13 +1428,12 @@ fn sim_with_cookies(seed: u64, cookies: CookieMode) -> Sim { Sim::with_configs(seed, host, ClientConfig::new(HOST_ID)) } -/// The flood was load exactly when cookies were on. +/// The flood was load when cookies were on. (With them off, cached answers +/// to other addresses still ask for a cookie.) fn check_cookie_path(sim: &Sim, cookies: CookieMode) { - assert_eq!( - sim.host.cookie_replies() > 0, - cookies != CookieMode::Off, - "{cookies:?}" - ); + if cookies != CookieMode::Off { + assert!(sim.host.cookie_replies() > 0, "{cookies:?}"); + } } fn forged_for(dev_id: &eternal_wire::v3::Id16, count: u64) -> Vec> { @@ -1661,9 +1660,9 @@ fn past_the_live_address_cap_the_reconnect_falls_back_to_the_per_source_path() { } /// The iPad's first message 1 is lost on its way, and an attacker who saw -/// it replays it from 20 spoofed sources first, taking every per-source -/// message 2 slot. The iPad's own retries still get message 2 through the -/// shared overflow allowance. +/// it replays it from 20 spoofed sources first. The first copy makes the +/// pending session; the others get only cookie replies, and the iPad's own +/// retries get message 2 through the cookie. #[test] fn spoofed_copies_taking_every_message_2_slot_never_lock_the_ipad_out() { let mut sim = Sim::new(143); @@ -1683,4 +1682,8 @@ fn spoofed_copies_taking_every_message_2_slot_never_lock_the_ipad_out() { ); } assert!(sim.run_until(Duration::from_secs(3), |s| s.both_live_on_same_session())); + assert!( + sim.host.cookie_replies() >= 19, + "the other copies got cookie replies" + ); } From 4387e536dc6501d4bef460ef4d4964942dce0a40 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 15:25:48 -0500 Subject: [PATCH 3/5] Transport v3 cookie: no answer limit shared across ports or replays Cached Stale, TooNew and PairAgain answers now have exact-address resend allowances, the same as message 2: the address their message 1 came from, or an address that came back with a valid cookie, each with its own 100 ms allowance. The per-IP replay budget for them is removed. Every other limit a replay or spoofed source could use up before the iPad's own message 1 now falls back to the cookie: - Message 1 reads (session and pairing): a message 1 that the IP's limit refuses gets a cookie reply. One with a valid cookie is limited per exact address instead, so another port on the iPad's IP never uses up its reads. - The dev_id's Stale/TooNew/PairAgain budget: an unverified message 1 over it gets a cookie reply instead of being remembered unanswered. Message 1s with a valid cookie have a budget of their own, and the table of remembered message 1s is sized for it. --- proto/fuzz/fuzz_targets/v3_pairing.rs | 17 +- proto/src/v3/host.rs | 342 ++++++++++++++++++++------ proto/src/v3/pairing.rs | 23 +- proto/src/v3/pairing/tests.rs | 52 +++- 4 files changed, 353 insertions(+), 81 deletions(-) diff --git a/proto/fuzz/fuzz_targets/v3_pairing.rs b/proto/fuzz/fuzz_targets/v3_pairing.rs index 2408b9d9..c60b6005 100644 --- a/proto/fuzz/fuzz_targets/v3_pairing.rs +++ b/proto/fuzz/fuzz_targets/v3_pairing.rs @@ -14,6 +14,7 @@ //! - the iPad hears `PairDone` only after its own pairing's write //! succeeded, and `PairRefused` only after its own write was refused; //! - each written record is reported paired once; +//! - a forged message 1 gets at most a cookie reply its sender can't open; //! - nothing panics. #![no_main] @@ -21,6 +22,7 @@ use std::net::SocketAddr; use std::time::Duration; use eternal_wire::v3::client::ClientKey; +use eternal_wire::v3::cookie; use eternal_wire::v3::pairing::{ PairCredentials, PairOutput, PairResult, PairingClient, PairingConfig, PairingHost, PendingPairing, QrPayload, StoreResult, QR_LIFETIME, @@ -318,13 +320,22 @@ fuzz_target!(|data: &[u8]| { // A forged message 1 under another secret. let mut forged = qr.clone(); forged.secret = Zeroizing::new([b; 32]); - let client = + let mut client = PairingClient::start(&forged, &SLOT, "0.4.0", "x", 1, &mut rng).unwrap(); let msg1 = client.msg1().to_vec(); for output in host.handle_datagram(now, &2, &msg1, &creds) { assert!(!matches!(output, PairOutput::Store(_))); - if let PairOutput::Send { .. } = output { - assert_eq!(forged.secret, qr.secret, "an answer to a forged message 1"); + if let PairOutput::Send { datagram, .. } = output { + if cookie::is_reply(&datagram) { + // Sealed under the real QR's key: no use to it. + assert!( + forged.secret == qr.secret + || client.handle_cookie(&datagram).is_none(), + "a forger opened a cookie reply" + ); + } else { + assert_eq!(forged.secret, qr.secret, "an answer to a forged message 1"); + } } } } diff --git a/proto/src/v3/host.rs b/proto/src/v3/host.rs index 610d5521..94270598 100644 --- a/proto/src/v3/host.rs +++ b/proto/src/v3/host.rs @@ -58,10 +58,11 @@ pub struct HostConfig { pub pending_lifetime: Duration, /// How long a timed-out message 1 is remembered and dropped. pub expired_msg1_lifetime: Duration, - /// Shortest gap between two resends of a cached message 2 to one - /// source. + /// Shortest gap between two resends of a cached answer (message 2, or + /// `Stale`, `TooNew` and `PairAgain`) to one address. pub msg2_resend_interval: Duration, - /// New `Stale` answers per `dev_id` per second. + /// New `Stale` answers per `dev_id` per second, and as many again for + /// message 1s that carry a valid cookie. pub stale_per_second: u32, /// Authenticated message 1s per `dev_id` per second that get no answer /// (over its budget, a late copy, a timestamp past the ceiling) and are @@ -71,8 +72,6 @@ pub struct HostConfig { /// Credentials the host holds at most. With the two budgets above it /// sizes the table of remembered message 1s. pub max_credentials: usize, - /// Resends of a cached `Stale` answer per source per second. - pub stale_replays_per_second: u32, /// Shortest gap between two re-sealed `SessionConfirmAck`s. pub confirm_ack_interval: Duration, /// How long a sealed `SessionRefused` is kept: resent on the @@ -120,7 +119,6 @@ impl HostConfig { stale_per_second: 5, unanswered_per_second: 5, max_credentials: MAX_CREDENTIALS, - stale_replays_per_second: 5, confirm_ack_interval: Duration::from_millis(50), refusal_lifetime: Duration::from_secs(5), refusal_resend_interval: Duration::from_millis(100), @@ -484,6 +482,9 @@ impl RateWindow { /// is answered without another Diffie-Hellman. struct StaleBudget { window: RateWindow, + /// The same budget again, for message 1s with a valid cookie: only the + /// iPad can send those, so replays from elsewhere never use it up. + verified: RateWindow, /// Message 1s remembered without an answer. unanswered: RateWindow, /// Newest last. @@ -493,8 +494,8 @@ struct StaleBudget { struct CachedAnswer { key: Digest, msg2: Vec, - /// The exact address whose message 1 got this answer. - origin: A, + /// Where it may be resent, and when it last was. + resends: ResendSlots, until: Duration, } @@ -658,13 +659,15 @@ pub const MAX_CREDENTIALS: usize = 64; /// The size of the remembered message 1 table. Each budget window lasts at /// least 1 s, so the entries still remembered after `expired_msg1_lifetime` /// come from at most `ceil(lifetime) + 1` windows per `dev_id`, each adding -/// at most `stale_per_second + unanswered_per_second`. When more `dev_id`s +/// at most `2 × stale_per_second + unanswered_per_second` (the Stale budget +/// and its lane for message 1s with a cookie). When more `dev_id`s /// than `max_credentials` fill it, no new answer is given and nothing /// unexpired is evicted. fn answered_capacity(config: &HostConfig) -> usize { let lifetime = config.expired_msg1_lifetime; let windows = lifetime.as_secs() + u64::from(lifetime.subsec_nanos() > 0) + 1; - let per_window = u64::from(config.stale_per_second) + u64::from(config.unanswered_per_second); + let per_window = + 2 * u64::from(config.stale_per_second) + u64::from(config.unanswered_per_second); usize::try_from(windows.saturating_mul(per_window)) .unwrap_or(usize::MAX) .saturating_mul(config.max_credentials) @@ -679,7 +682,10 @@ pub struct HostSessions { refusal_contexts: Vec>, refusals: Vec>, msg1_rate: SourceLimiter, - stale_replay_rate: SourceLimiter, + /// Message 1 reads per exact address, for those with a valid cookie: + /// only the iPad can send them, so spoofing its IP from another port + /// never uses up its reads. + verified_rate: SourceLimiter, stale: HashMap>, /// Message 1s that already got a `Stale`, `TooNew` or `PairAgain` /// answer, for 30 s, with expiry order. A recognised replay whose cached @@ -718,7 +724,7 @@ impl HostSessions { cookie_replies: 0, answered_capacity: answered_capacity(&config), msg1_rate: SourceLimiter::new(config.msg1_per_second), - stale_replay_rate: SourceLimiter::new(config.stale_replays_per_second), + verified_rate: SourceLimiter::new(config.msg1_per_second), config, rng, devices: HashMap::new(), @@ -849,6 +855,10 @@ impl HostSessions { start: now, count: 0, }, + verified: RateWindow { + start: now, + count: 0, + }, unanswered: RateWindow { start: now, count: 0, @@ -930,23 +940,30 @@ impl HostSessions { { return; } - if let Some(answer) = self - .stale - .get(&dev_id) - .and_then(|b| b.cached.iter().find(|c| c.key == key && now < c.until)) - { + let cached = self.stale.get(&dev_id).and_then(|b| { + b.cached + .iter() + .find(|c| c.key == key && now < c.until) + .map(|c| c.resends.admitted(from)) + }); + if let Some(admitted) = cached { // A replay of a message 1 that got an answer without a pending - // session: the cached answer, within its per-source budget. As - // for message 2, another address must show a cookie first. - let msg2 = answer.msg2.clone(); - if answer.origin != *from && !verified { + // session: the cached answer, as for message 2. At most every + // 100 ms per address, and another address must show a cookie + // first. + if !admitted && !verified { self.send_cookie(now, from, &canon, &dev_id, creds, out); return; } - if self.stale_replay_rate.allow(now, &bucket) { + let answer = self + .stale + .get_mut(&dev_id) + .and_then(|b| b.cached.iter_mut().find(|c| c.key == key && now < c.until)) + .expect("checked above"); + if answer.resends.due(from, now, resend_interval) { out.push(HostOutput::Send { to: from.clone(), - datagram: msg2, + datagram: answer.msg2.clone(), }); } return; @@ -978,8 +995,21 @@ impl HostSessions { reserved = false; } } - if !reserved && !self.msg1_rate.allow(now, &bucket) { - return; + if !reserved { + // A message 1 with a valid cookie is limited per exact address; + // without one, per IP. One the IP's limit refuses gets a cookie + // reply, so spoofing the iPad's IP never keeps it from a read. + let allowed = if verified { + self.verified_rate.allow(now, from) + } else { + self.msg1_rate.allow(now, &bucket) + }; + if !allowed { + if !verified { + self.send_cookie(now, from, &canon, &dev_id, creds, out); + } + return; + } } let has_pending = self .devices @@ -1021,7 +1051,13 @@ impl HostSessions { None }; if let Some(reply) = no_pending { - self.answer_without_pending(now, from, &dev_id, key, responder, &reply, out); + let answered = self + .answer_without_pending(now, from, &dev_id, key, responder, &reply, verified, out); + if !answered && !verified { + // Over the shared budget: a cookie reply, so the iPad's own + // retry comes back on the lane only it can use. + self.send_cookie(now, from, &canon, &dev_id, creds, out); + } return; } if let Some(p) = self.devices.get(&dev_id).and_then(|d| d.pending.as_ref()) { @@ -1109,7 +1145,10 @@ impl HostSessions { } /// Answers a message 1 with `Stale`, `TooNew` or `PairAgain`, within the - /// `dev_id`'s budget, caches the answer, and remembers the message 1. + /// `dev_id`'s budget (its own lane when `verified`), caches the answer, + /// and remembers the message 1. `false` when an unverified message 1 is + /// over the budget: it isn't remembered, and the caller asks for a + /// cookie. #[allow(clippy::too_many_arguments)] fn answer_without_pending( &mut self, @@ -1119,27 +1158,37 @@ impl HostSessions { key: Digest, responder: Responder, reply: &Msg2Payload, + verified: bool, out: &mut Vec>, - ) { + ) -> bool { self.expire_answered(now); if self.answered.len() >= self.answered_capacity && !self.answered.contains_key(&key) { // Full of unexpired entries: refuse new work rather than forget // a message 1 that was already answered. - return; + return true; } let limit = self.config.stale_per_second; - if !self.stale_budget(dev_id, now).window.allow(now, limit) { - // Over the dev_id's budget: no answer, but remembered within - // its own budget, so its replays cost no crypto. + let budget = self.stale_budget(dev_id, now); + let window = if verified { + &mut budget.verified + } else { + &mut budget.window + }; + if !window.allow(now, limit) { + if !verified { + return false; + } + // Over its own lane: no answer, but remembered within its own + // budget, so its replays cost no crypto. self.remember_unanswered(now, dev_id, key); - return; + return true; } self.dh_performed += 1; let Ok((msg2, _)) = responder.write_msg2(reply) else { - return; + return true; }; let until = now + self.config.expired_msg1_lifetime; - let origin = from.clone(); + let resends = ResendSlots::new(from.clone(), now); let budget = self.stale_budget(dev_id, now); if budget.cached.len() >= MAX_CACHED_STALE { budget.cached.remove(0); @@ -1147,7 +1196,7 @@ impl HostSessions { budget.cached.push(CachedAnswer { key, msg2: msg2.clone(), - origin, + resends, until, }); self.remember_answered(now, key); @@ -1155,6 +1204,7 @@ impl HostSessions { to: from.clone(), datagram: msg2, }); + true } /// Remembers an authenticated message 1 for 30 s, so its replays are @@ -1694,7 +1744,7 @@ impl HostSessions { } self.refusals.retain(|r| r.until > now); self.msg1_rate.prune(now); - self.stale_replay_rate.prune(now); + self.verified_rate.prune(now); self.stale.retain(|_, b| { b.cached.retain(|c| now < c.until); !b.cached.is_empty() @@ -1826,6 +1876,11 @@ mod tests { } } + /// Sends other than cookie replies. + fn real_answers(out: &[HostOutput]) -> usize { + sends(out).iter().filter(|d| !cookie::is_reply(d)).count() + } + fn sends(out: &[HostOutput]) -> Vec> { out.iter() .filter_map(|o| match o { @@ -2155,9 +2210,10 @@ mod tests { .collect(); let answered = attempts .iter() - .filter(|a| !host.handle_datagram(T0, &ADDR, a.msg1(), &creds).is_empty()) + .filter(|a| real_answers(&host.handle_datagram(T0, &ADDR, a.msg1(), &creds)) > 0) .count(); assert_eq!(answered, 20); + assert_eq!(host.cookie_replies(), 5, "the rest get a cookie reply"); let other_addr = attempts[24].msg1(); assert!(!host.handle_datagram(T0, &2, other_addr, &creds).is_empty()); let next_second = T0 + Duration::from_secs(1); @@ -2181,9 +2237,7 @@ mod tests { .filter(|&i| { let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10 + u64::from(i))).unwrap(); let from = SocketAddr::from(([10, 0, 0, 7], 40_000 + i)); - !host - .handle_datagram(T0, &from, ipad.msg1(), &creds) - .is_empty() + real_answers(&host.handle_datagram(T0, &from, ipad.msg1(), &creds)) > 0 }) .count(); assert_eq!(answered, 20, "25 ports, one IP, one bucket"); @@ -2278,12 +2332,11 @@ mod tests { .filter(|&i| { let ipad = Initiator::start(&K, &DEV, &HOST, i, &hello(500 + u64::from(i))).unwrap(); - !host - .handle_datagram(T0, &(100 + i), ipad.msg1(), &creds) - .is_empty() + real_answers(&host.handle_datagram(T0, &(100 + i), ipad.msg1(), &creds)) > 0 }) .count(); assert_eq!(answered, 5); + assert_eq!(host.cookie_replies(), 5, "the rest get a cookie reply"); let replayed = Initiator::start(&K, &DEV, &HOST, 1, &hello(900)).unwrap(); let later = T0 + Duration::from_secs(2); @@ -2539,8 +2592,8 @@ mod tests { assert_eq!(host.handshakes_performed(), dh); // The source that sent it first keeps its own allowance. assert_eq!( - sends(&host.handle_datagram(T0 + ms(20), &1, old.msg1(), &creds)).len(), - 1 + sends(&host.handle_datagram(T0 + ms(150), &1, old.msg1(), &creds)), + answer ); // And the iPad's own new stale attempt is still answered. let fresh = Initiator::start(&K, &DEV, &HOST, 2, &hello(600)).unwrap(); @@ -2809,30 +2862,57 @@ mod tests { assert_eq!(host.msg1_reads(), reads); } + /// A new authenticated `Stale` message 1 for `id` from `source`, through + /// the cookie if the host asks for one, as the iPad does. The answers it + /// got, and the message 1. + fn stale_attempt( + host: &mut HostSessions, + creds: &Creds, + at: Duration, + source: u32, + id: &Id16, + ts: u64, + ) -> (usize, Vec) { + let ipad = Initiator::start(&K, id, &HOST, 1, &hello(ts)).unwrap(); + let mut out = host.handle_datagram(at, &source, ipad.msg1(), creds); + if let [HostOutput::Send { datagram, .. }] = &out[..] { + if cookie::is_reply(datagram) { + let reply = cookie::CookieReply::parse(datagram).unwrap(); + let digest = cookie::msg1_digest(ipad.msg1()); + let value = cookie::open(&cookie::session_key(&K, id), &reply, &digest).unwrap(); + let msg1 = cookie::attach(ipad.msg1(), &value); + out = host.handle_datagram(at, &source, &msg1, creds); + } + } + (real_answers(&out), ipad.msg1().to_vec()) + } + /// Floods `devices` distinct authenticated `Stale` message 1s per - /// device, 15 every 200 ms (75/s, past both budgets) from fresh - /// sources, for 31 s. Returns the answers per device, the largest - /// table size seen and the last message 1 that got an answer. + /// device, 15 every 200 ms (75/s, past every budget) from fresh + /// sources, for `ticks` × 200 ms. One the shared budget refuses comes back with its + /// cookie, as the iPad's would, so the cookie lane and the unanswered + /// memory fill too. Returns the answers per device, the largest table + /// size seen and the last message 1 that got an answer. fn flood_remembered( host: &mut HostSessions, creds: &Creds, ids: &[Id16], + ticks: u64, ) -> (Vec, usize, Vec) { let mut answers = vec![0; ids.len()]; let mut largest = 0; let mut last = Vec::new(); let mut source = 1000u32; let mut ts = 1u64; - for tick in 0..155u64 { + for tick in 0..ticks { let at = T0 + ms(tick * 200); for (n, id) in ids.iter().enumerate() { for _ in 0..15 { ts += 1; source += 1; - let ipad = Initiator::start(&K, id, &HOST, 1, &hello(ts)).unwrap(); - let sent = sends(&host.handle_datagram(at, &source, ipad.msg1(), creds)).len(); + let (sent, msg1) = stale_attempt(host, creds, at, source, id, ts); if sent > 0 { - last = ipad.msg1().to_vec(); + last = msg1; } answers[n] += sent; largest = largest.max(host.answered.len()); @@ -2856,17 +2936,17 @@ mod tests { for id in &ids { creds.keys.insert(*id, K); } - // 4 × (5 + 5) × (30 + 1). - assert_eq!(host.answered_capacity, 1240); - let (answers, largest, _) = flood_remembered(&mut host, &creds, &ids); - // The table never filled: every device got its full answer budget - // in every second, 5 × 31. + // 4 × (5 + 5 + 5) × (30 + 1). + assert_eq!(host.answered_capacity, 1860); + let (answers, largest, _) = flood_remembered(&mut host, &creds, &ids, 155); + // The table never filled: every device got both its answer budgets + // in every second, (5 + 5) × 31. for a in answers { - assert_eq!(a, 5 * 31); + assert_eq!(a, 10 * 31); } // Windows aligned on whole seconds reach 30 of the 31 the bound // allows for. - assert_eq!(largest, 1200); + assert_eq!(largest, 1800); } #[test] @@ -2880,21 +2960,36 @@ mod tests { for id in &ids { creds.keys.insert(*id, K); } - let (answers, largest, last) = flood_remembered(&mut host, &creds, &ids); + let (answers, largest, last) = flood_remembered(&mut host, &creds, &ids, 100); assert_eq!(largest, host.answered_capacity); assert!( - answers.iter().sum::() < 3 * 5 * 31, + answers.iter().sum::() < 3 * 10 * 20, "some answers refused" ); - // Full, and a new message 1 gets no answer. - let at = T0 + ms(154 * 200 + 100); - let new = Initiator::start(&K, &ids[2], &HOST, 1, &hello(2)).unwrap(); - assert!(host.answered.len() == host.answered_capacity); - assert!(sends(&host.handle_datagram(at, &7, new.msg1(), &creds)).is_empty()); - // Nothing unexpired was evicted to make room: the last answered one - // is still recognised, with no read. + // Full (it fills well before anything expires), and a new message 1, + // even through its cookie, gets no answer. + let at = T0 + ms(100 * 200); + assert_eq!(host.answered.len(), host.answered_capacity); + let (sent, _) = stale_attempt(&mut host, &creds, at, 7, &ids[2], 2); + assert_eq!(sent, 0); + // Nothing unexpired was evicted to make room: the last answered one, + // with a valid cookie, is still recognised (its cached answer, if + // any), with no read. + let out = host.handle_datagram(at, &8, &last, &creds); + let msg1 = match &out[..] { + [HostOutput::Send { datagram, .. }] if cookie::is_reply(datagram) => { + let reply = cookie::CookieReply::parse(datagram).unwrap(); + let digest = cookie::msg1_digest(&last); + let id = ids + .iter() + .find_map(|id| cookie::open(&cookie::session_key(&K, id), &reply, &digest)) + .unwrap(); + cookie::attach(&last, &id) + } + _ => last.clone(), + }; let reads = host.msg1_reads(); - host.handle_datagram(at, &8, &last, &creds); + host.handle_datagram(at, &8, &msg1, &creds); assert_eq!(host.msg1_reads(), reads); } @@ -3252,4 +3347,111 @@ mod tests { assert_eq!(received, 7, "every retry after the cookie round trip"); assert_eq!(host.pending_count(), 1); } + + /// The iPad's address and another port on its IP, for the tests below. + fn two_ports() -> (HostSessions, SocketAddr, SocketAddr) { + let host = HostSessions::new( + HostConfig::new(HOST), + Box::new(SeededRandom::new(1)), + T0, + WALL, + ); + let old_port: SocketAddr = "192.0.2.1:40000".parse().unwrap(); + let ipad: SocketAddr = "192.0.2.1:40001".parse().unwrap(); + (host, old_port, ipad) + } + + /// After a NAT rebind, cookie-less replays of a cached Stale answer's + /// message 1 to its first port never take the resends of the iPad on + /// its new port, which came back with a cookie. + #[test] + fn replays_to_the_old_port_never_take_a_rebound_ipads_cached_answer() { + let (_, creds) = setup(); + *creds.timestamp.borrow_mut() = 1000; + let (mut host, old_port, ipad) = two_ports(); + let attempt = Initiator::start(&K, &DEV, &HOST, 1, &hello(500)).unwrap(); + let first = sends(&host.handle_datagram(T0, &old_port, attempt.msg1(), &creds)); + assert_eq!(first.len(), 1); + let reply = sends(&host.handle_datagram(T0 + ms(1), &ipad, attempt.msg1(), &creds)); + assert!(reply.len() == 1 && cookie::is_reply(&reply[0])); + let with = with_cookie(&reply[0], attempt.msg1()); + let mut received = 0; + for tick in 0..8u64 { + let at = T0 + ms(2 + tick * 250); + for _ in 0..5 { + host.handle_datagram(at, &old_port, attempt.msg1(), &creds); + } + let out = sends(&host.handle_datagram(at + ms(1), &ipad, &with, &creds)); + if out == first { + received += 1; + } + assert!(!host.under_load(at)); + } + assert_eq!(received, 8, "every retry from the new port"); + } + + /// Forged message 1s from another port on the iPad's IP, past the IP's + /// read limit, never keep the iPad's own message 1 from being read: it + /// gets a cookie reply and is read on its address's own lane. + #[test] + fn another_port_on_the_ipads_ip_never_uses_up_its_reads() { + let (_, creds) = setup(); + let (mut host, old_port, ipad) = two_ports(); + let mut connected = 0; + for second in 0..4u64 { + let at = T0 + Duration::from_secs(second); + for i in 0..25u64 { + let forged = + Initiator::start(&[7; 32], &DEV, &HOST, 1, &hello(100 * second + i)).unwrap(); + host.handle_datagram(at, &old_port, forged.msg1(), &creds); + } + let attempt = Initiator::start(&K, &DEV, &HOST, 2, &hello(10 + second)).unwrap(); + let out = sends(&host.handle_datagram(at, &ipad, attempt.msg1(), &creds)); + assert!( + out.len() == 1 && cookie::is_reply(&out[0]), + "the IP's limit: a cookie" + ); + let msg1 = with_cookie(&out[0], attempt.msg1()); + let out = sends(&host.handle_datagram(at, &ipad, &msg1, &creds)); + if out.len() == 1 && wire::classify(&out[0]) == V3Kind::Handshake2 { + connected += 1; + } + assert!(!host.under_load(at)); + } + assert_eq!(connected, 4); + } + + /// Captured old genuine message 1s replayed from spoofed sources, past + /// the dev_id's Stale budget, never keep the iPad's own Stale answer + /// from it: it gets a cookie reply and is answered on the lane only a + /// message 1 with a cookie reaches. + #[test] + fn replayed_old_message_1s_never_use_up_the_ipads_stale_answer() { + let (mut host, creds) = setup(); + *creds.timestamp.borrow_mut() = 1_000_000; + let mut answered = 0; + for second in 0..4u64 { + let at = T0 + Duration::from_secs(second); + for i in 0..6u64 { + let old = + Initiator::start(&K, &DEV, &HOST, 1, &hello(1 + 10 * second + i)).unwrap(); + host.handle_datagram(at, &(500 + (10 * second + i) as u32), old.msg1(), &creds); + } + let mut ipad = Initiator::start(&K, &DEV, &HOST, 2, &hello(900 + second)).unwrap(); + let out = sends(&host.handle_datagram(at, &ADDR, ipad.msg1(), &creds)); + assert!( + out.len() == 1 && cookie::is_reply(&out[0]), + "over the shared budget" + ); + let msg1 = with_cookie(&out[0], ipad.msg1()); + let out = sends(&host.handle_datagram(at, &ADDR, &msg1, &creds)); + if out.len() == 1 + && matches!(ipad.read_msg2(&out[0]), Ok((Msg2Payload::Stale { .. }, _))) + { + answered += 1; + } + assert!(!host.under_load(at)); + } + assert_eq!(answered, 4); + } } diff --git a/proto/src/v3/pairing.rs b/proto/src/v3/pairing.rs index cde6a291..16f399a5 100644 --- a/proto/src/v3/pairing.rs +++ b/proto/src/v3/pairing.rs @@ -430,8 +430,12 @@ pub struct PairingHost { /// The generation the next window gets. next_generation: u64, attempt: Option>, - /// Message 1 reads per source. + /// Message 1 reads per source (IP). per_source: SourceLimiter, + /// Message 1 reads per exact address, for those with a valid cookie: + /// only the QR's holder can send them, so spoofing the iPad's IP from + /// another port never uses up its reads. + verified_rate: SourceLimiter, cookies: CookieJar, /// Message 1 arrivals, for the load test. arrivals: LoadMeter, @@ -453,6 +457,7 @@ impl PairingHost { arrivals: LoadMeter::new(Duration::ZERO), cookie_replies: 0, per_source: SourceLimiter::new(config.msg1_per_source_per_second), + verified_rate: SourceLimiter::new(config.msg1_per_source_per_second), config, host_id, host_version: host_version.to_string(), @@ -677,8 +682,19 @@ impl PairingHost { if window.handled.contains(&digest) || window.handled.len() >= MAX_HANDLED_MSG1 { return; } - // One source can't use up everyone's reads. - if !self.per_source.allow(now, &from.rate_bucket()) { + // One source can't use up everyone's reads. With a valid cookie the + // limit is per exact address; without, per IP, and a message 1 the + // IP's limit refuses gets a cookie reply instead. + let allowed = if verified { + self.verified_rate.allow(now, from) + } else { + self.per_source.allow(now, &from.rate_bucket()) + }; + if !allowed { + if !verified { + let key = cookie_key(); + self.send_cookie(now, from, &canon, &key, out); + } return; } let ctx = pairing_ctx( @@ -901,6 +917,7 @@ impl PairingHost { self.window = None; } self.per_source.prune(now); + self.verified_rate.prune(now); self.attempt .take_if(|a| a.expired(now) || (a.step != Step::Storing && now >= a.deadline)); } diff --git a/proto/src/v3/pairing/tests.rs b/proto/src/v3/pairing/tests.rs index cded66e1..ff7afc5a 100644 --- a/proto/src/v3/pairing/tests.rs +++ b/proto/src/v3/pairing/tests.rs @@ -402,7 +402,8 @@ fn message_1_retries_get_the_same_message_2_and_reads_are_budgeted() { assert_eq!(again, first, "the same message 2"); // Forged message 1s: 20 reads a second from one source, then nothing - // more from it is read. + // more from it is read without a cookie, only a cookie reply. With the + // cookie (only the QR's holder can open it), the iPad is read. let mut host = self::host(); let qr = host.open(T0, addr()); let mut forged = qr.clone(); @@ -411,9 +412,14 @@ fn message_1_retries_get_the_same_message_2_and_reads_are_budgeted() { for _ in 0..60 { host.handle_datagram(T0, &ADDR, forged.msg1(), &creds); } - assert!(host - .handle_datagram(T0, &ADDR, client(&qr, 9).msg1(), &creds) - .is_empty()); + let mut ipad = client(&qr, 10); + let out = sends(&host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds)); + assert!(out.len() == 1 && cookie::is_reply(&out[0])); + let msg1 = ipad.handle_cookie(&out[0]).unwrap(); + let out = sends(&host.handle_datagram(T0, &ADDR, &msg1, &creds)); + assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); + host.close(); + let qr = host.open(T0, addr()); assert_eq!( sends(&host.handle_datagram( T0 + Duration::from_secs(1), @@ -699,7 +705,9 @@ fn one_sender_flooding(cookies: CookieMode) { answered >= 1, "the genuine iPad got its message 2: {cookies:?}" ); - assert_eq!(host.cookie_replies() > 0, cookies != CookieMode::Off); + if cookies != CookieMode::Off { + assert!(host.cookie_replies() > 0); + } } /// Many senders, each inside its own limit, burst junk right before each of @@ -858,3 +866,37 @@ fn low_rate_spoofed_replays_never_take_the_ipads_message_2_resends() { assert_eq!(received, 8, "every retry; spoofer first: {spoofer_first}"); } } + +/// Junk pairing message 1s from another port on the iPad's IP, past the +/// IP's read limit, never keep the iPad's own from being read: it gets a +/// cookie reply and is read on its address's own lane. +#[test] +fn another_port_on_the_ipads_ip_never_uses_up_its_pairing_reads() { + let creds = Creds::default(); + let mut host: PairingHost = PairingHost::new( + PairingConfig::default(), + HOST, + "0.4.0", + "Test PC", + Box::new(SeededRandom::new(1)), + ); + let qr = host.open(T0, addr()); + let mut forged = qr.clone(); + forged.secret = Zeroizing::new([0x07; 32]); + let junk = client(&forged, 60).msg1().to_vec(); + let old_port: SocketAddr = "192.0.2.1:40000".parse().unwrap(); + let ipad_addr: SocketAddr = "192.0.2.1:40001".parse().unwrap(); + for _ in 0..25 { + host.handle_datagram(T0, &old_port, &junk, &creds); + } + let mut ipad = client(&qr, 61); + let out = sends(&host.handle_datagram(T0, &ipad_addr, ipad.msg1(), &creds)); + assert!( + out.len() == 1 && cookie::is_reply(&out[0]), + "the IP's limit: a cookie" + ); + let msg1 = ipad.handle_cookie(&out[0]).unwrap(); + let out = sends(&host.handle_datagram(T0, &ipad_addr, &msg1, &creds)); + assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); + assert!(!host.under_load(T0)); +} From 5e2834d2738053c6918499c862a97bae44f980a9 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 16:32:44 -0500 Subject: [PATCH 4/5] Transport v3: test and document the application-type rule - The reference client's test now covers a draining session: an unknown type isn't delivered and a Ping is, with draining set. It also checks that an unknown type on the live session still counts as liveness. - SessionMessage::parse says callers deliver Ok(None) only for application inner types, and the inner-type constants are documented as a group (0x50-0x53, and v2's Bye at 0x13). - The pairing read budget states its rate on average (up to twice that across a window boundary) and where the read cost was measured. - The many-senders pairing test expects message 2 on every retry, and notes that more senders than the source table holds is the cookie's case. --- proto/src/v3/client.rs | 31 +++++++++++++++++++++++++++++-- proto/src/v3/pairing.rs | 7 +++++-- proto/src/v3/pairing/tests.rs | 6 ++++-- proto/src/v3/wire.rs | 12 +++++++----- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/proto/src/v3/client.rs b/proto/src/v3/client.rs index e8945b80..3f11beee 100644 --- a/proto/src/v3/client.rs +++ b/proto/src/v3/client.rs @@ -1138,12 +1138,39 @@ mod tests { .iter() .any(|o| matches!(o, ClientOutput::Deliver { inner_type, .. } if *inner_type == PacketType::Ping as u8))); - // Live: an unknown type isn't delivered. + // Live: an unknown type isn't delivered, but it's still liveness. + let at = T0 + Duration::from_secs(5); let record = host.seal(0x60, &[9]).unwrap(); assert!(!ipad - .handle_datagram(T0, &record, 1_000) + .handle_datagram(at, &record, 1_000) .iter() .any(|o| matches!(o, ClientOutput::Deliver { .. }))); + assert_eq!(ipad.live.as_ref().unwrap().last_rx, at); + + // Draining: once a newer session takes over, the old one delivers + // application types only. + let mut newer = confirming_client(&mut ipad, at, 6_000, 83); + let record = newer.seal(PacketType::Ping as u8, &[0; 8]).unwrap(); + ipad.handle_datagram(at, &record, 6_000); + assert_eq!(ipad.live_session(), Some(83)); + assert_eq!(ipad.draining_session(), Some(81)); + let record = host.seal(0x5F, &[]).unwrap(); + assert!(!ipad + .handle_datagram(at, &record, 6_000) + .iter() + .any(|o| matches!(o, ClientOutput::Deliver { .. }))); + let record = host.seal(PacketType::Ping as u8, &[0; 8]).unwrap(); + assert!(ipad + .handle_datagram(at, &record, 6_000) + .iter() + .any(|o| matches!( + o, + ClientOutput::Deliver { + session_id: 81, + draining: true, + .. + } + ))); // Abandoned: an unknown type doesn't promote it either. let mut ipad = client(); diff --git a/proto/src/v3/pairing.rs b/proto/src/v3/pairing.rs index 16f399a5..88fefba8 100644 --- a/proto/src/v3/pairing.rs +++ b/proto/src/v3/pairing.rs @@ -333,8 +333,11 @@ pub struct PairingConfig { /// no host-wide limit before authentication, as on the session path: one /// would let many senders inside their own limits starve a genuine iPad. /// A read that fails is one AEAD attempt and no Diffie-Hellman (about - /// 10 µs), so the most the table admits, (1,024 + 1) × 20 a second, is - /// about a fifth of one core. + /// 10 µs, measured on an M1), so the most the table admits, (1,024 + 1) + /// × 20 a second on average (up to twice that across a window + /// boundary), is about a fifth of one core. Message 1s with a valid + /// cookie are read on a lane of their own, per exact address, that only + /// the QR's holder can reach. pub msg1_per_source_per_second: u32, /// When message 1 must carry a valid cookie for its source, as on the /// session path ([`super::host::HostConfig::cookies`]). diff --git a/proto/src/v3/pairing/tests.rs b/proto/src/v3/pairing/tests.rs index ff7afc5a..2494742a 100644 --- a/proto/src/v3/pairing/tests.rs +++ b/proto/src/v3/pairing/tests.rs @@ -738,8 +738,10 @@ fn behind_many_senders(cookies: CookieMode) { } answered += usize::from(pair_msg2(&mut host, at, &mut ipad, &creds).is_some()); } - assert!( - answered >= 1, + // Every retry, the first included. (More senders than the source + // table holds is left to the cookie: they're load.) + assert_eq!( + answered, 8, "message 2 on {answered} of 8 retries: {cookies:?}" ); assert_eq!(host.cookie_replies() > 0, cookies != CookieMode::Off); diff --git a/proto/src/v3/wire.rs b/proto/src/v3/wire.rs index 56b80752..6545b639 100644 --- a/proto/src/v3/wire.rs +++ b/proto/src/v3/wire.rs @@ -657,10 +657,8 @@ impl Msg2Payload { } } -/// Inner types of the session messages sealed in records. Values below 0x40 -/// are the v2 [`PacketType`] of the message carried (its v2 body follows). -/// Values at 0x40 and above are the protocol's own; one either side doesn't -/// know is dropped, and never counts as application data. +/// The first inner type of the protocol's own messages; one either side +/// doesn't know is dropped, and never counts as application data. pub const FIRST_PROTOCOL_INNER_TYPE: u8 = 0x40; /// Whether `inner_type` carries an application message (a v2 control @@ -669,6 +667,9 @@ pub fn is_application(inner_type: u8) -> bool { inner_type < FIRST_PROTOCOL_INNER_TYPE } +/// Inner types of the session messages sealed in records: 0x50-0x53, and +/// v2's Bye (0x13). Values below 0x40 are the v2 [`PacketType`] of the +/// message carried (its v2 body follows). pub const INNER_SESSION_CONFIRM: u8 = 0x50; pub const INNER_SESSION_CONFIRM_ACK: u8 = 0x51; pub const INNER_SESSION_REFUSED: u8 = 0x52; @@ -713,7 +714,8 @@ impl SessionMessage { } /// Parses a session message, or returns `Ok(None)` for any other inner - /// type, which the caller passes on to the application. + /// type. Callers deliver one of those to the application only when + /// [`is_application`] is true for it, and drop it otherwise. pub fn parse(inner_type: u8, body: &[u8]) -> Result, WireError> { let mut r = Reader::new(body); Ok(Some(match inner_type { From f20f232d9bc7c8e75524b5961e1eb88b36c4e6bd Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 17:36:52 -0500 Subject: [PATCH 5/5] Transport v3 cookie: bind cookies, and lanes per device and address - A session cookie is HMAC(secret, dev_id || address) and a pairing cookie HMAC(secret, nonce_r || address): one device's cookie is no use with another's message 1, nor one from an earlier QR under a later one. The vectors are regenerated. - Cookie lanes are per dev_id and exact address (4 addresses, then one shared): message 1 reads with a valid cookie, and the Stale/TooNew/ PairAgain budget with a cookie. Recorded copies replayed from an address the iPad used before use up only that address's lane, and another device's traffic never reaches this one's. The table of remembered message 1s is sized for the lanes. - A remembered message 1 whose cached answer has gone (pushed out of the 8-entry cache) gets a cookie reply instead of being dropped silently. With a valid cookie, it's read and answered again on its address's own lane. Replays without one still cost no crypto. - Cookie reply nonces are a random 64-bit prefix per jar and a 32-bit counter, with a new prefix whenever the counter wraps. - The reference clients don't resend on a copy of a cookie they already hold. --- proto/src/v3/client.rs | 16 +- proto/src/v3/cookie.rs | 70 ++++-- proto/src/v3/cookie/tests.rs | 83 ++++++- proto/src/v3/host.rs | 323 ++++++++++++++++++++++----- proto/src/v3/pairing.rs | 36 +-- proto/src/v3/pairing/tests.rs | 32 +++ proto/testdata/v3/cookie_vectors.py | 15 +- proto/testdata/v3/cookie_vectors.txt | 20 +- 8 files changed, 496 insertions(+), 99 deletions(-) diff --git a/proto/src/v3/client.rs b/proto/src/v3/client.rs index 3f11beee..34f562d0 100644 --- a/proto/src/v3/client.rs +++ b/proto/src/v3/client.rs @@ -528,7 +528,8 @@ impl Client { } /// A cookie reply for the message 1 being sent: keep the cookie and - /// send message 1 again with it at once. Anything else is dropped: a + /// send message 1 again with it at once, unless it's the cookie already + /// held. Anything else is dropped: a /// reply opens only under this attempt's key and its exact message 1. fn handle_cookie(&mut self, now: Duration, datagram: &[u8], out: &mut Vec) { let Attempt::Connecting { @@ -550,6 +551,14 @@ impl Client { let Some(value) = cookie::open(&k, &reply, &cookie::msg1_digest(initiator.msg1())) else { return; }; + // The cookie it already holds: its next retry carries it. Copies of + // a reply never make the iPad send more. + if self + .cookie + .is_some_and(|c| c.cookie == value && c.fresh(now)) + { + return; + } let held = HeldCookie { cookie: value, received: now, @@ -1388,6 +1397,11 @@ mod tests { vec![cookie::attach(&msg1, &value)], "at once, with the cookie" ); + // A copy of the same reply sends nothing more. + let copy = cookie_reply(&msg1, &value); + assert!(ipad + .handle_datagram(T0 + Duration::from_millis(4), ©, 1_000) + .is_empty()); // Retries carry it too, on the usual schedule from the resend. let at = T0 + Duration::from_millis(3) + ClientConfig::new(HOST).msg1_retry_interval; assert_eq!( diff --git a/proto/src/v3/cookie.rs b/proto/src/v3/cookie.rs index 39b5202c..71b89272 100644 --- a/proto/src/v3/cookie.rs +++ b/proto/src/v3/cookie.rs @@ -8,9 +8,11 @@ //! address, so spoofed sources can never reach the host's crypto or its //! shared budgets. //! -//! - **The cookie** is `HMAC-SHA-256(secret, address)[..16]`, where the -//! address is the source's IP and port ([`PeerAddr::cookie_input`]) and -//! the secret is 32 random bytes rotated every [`SECRET_LIFETIME`]. The +//! - **The cookie** is `HMAC-SHA-256(secret, binding)[..16]`. For a session +//! the binding is the `dev_id` and the source's IP and port +//! ([`PeerAddr::cookie_input`]); for pairing, the QR window's `nonce_r` +//! and the source. The secret is 32 random bytes rotated every +//! [`SECRET_LIFETIME`]. The //! previous secret is still accepted, so a cookie is good for at least //! that long. //! - **The cookie reply**, host → iPad, packet type [`COOKIE_TYPE`]: @@ -25,7 +27,8 @@ //! //! The AAD is `SHA-256` of the message 1 it answers, without any cookie. //! The key ([`session_key`], [`pairing_key`]) comes from the handshake's -//! own secret, so only the iPad that sent the message 1 can read it. +//! own secret, so only the holder of that secret (the `dev_id`'s key, or +//! the QR) can read it. //! - **Message 1 with a cookie** ends in one more extension, //! [`EXT_COOKIE`], length 16: `mac2 = HMAC-SHA-256(cookie, message 1 //! without it)[..16]`. The authenticated part of message 1 is unchanged. @@ -76,9 +79,23 @@ pub fn pairing_key(psk: &Key32) -> Zeroizing { hkdf32(COOKIE_KEY_SALT, psk, b"pair") } -/// The cookie for `address` under `secret`. -pub fn cookie_value(secret: &Key32, address: &[u8]) -> [u8; COOKIE_LEN] { - hmac16(secret, address) +/// The cookie for `binding` under `secret`: [`session_binding`] or +/// [`pairing_binding`]. The iPad treats a cookie as opaque. +pub fn cookie_value(secret: &Key32, binding: &[u8]) -> [u8; COOKIE_LEN] { + hmac16(secret, binding) +} + +/// What a session cookie binds: the `dev_id`, then the address +/// ([`PeerAddr::cookie_input`]). A cookie one device got is no use with +/// another's message 1, even from the same address. +pub fn session_binding(dev_id: &Id16, address: &[u8]) -> Vec { + [&dev_id[..], address].concat() +} + +/// What a pairing cookie binds: the QR window's `nonce_r`, then the +/// address. A cookie from an earlier QR is no use under a later one. +pub fn pairing_binding(nonce_r: &Id16, address: &[u8]) -> Vec { + [&nonce_r[..], address].concat() } /// `HMAC-SHA-256(cookie, message 1 without a cookie)[..16]`. @@ -252,23 +269,47 @@ pub fn open(key: &Key32, reply: &CookieReply, digest: &[u8; 32]) -> Option<[u8; /// symmetric work, it's smaller than the message 1 it answers, and a budget /// would be the one thing a flood of spoofed message 1s could use up to /// keep the genuine iPad from its cookie. +/// +/// A reply's nonce is a random 64-bit prefix and a 32-bit counter, with a +/// fresh prefix whenever the counter wraps: never repeated under one jar, +/// and prefixes drawn at random for every jar (a host start) collide with +/// a chance of about one in 2^64 per pair, although the reply keys stay the +/// same for a device across starts. pub struct CookieJar { current: Zeroizing, previous: Option>, rotated: Duration, + nonce_prefix: [u8; 8], + nonce_counter: u32, } impl CookieJar { pub fn new(now: Duration, rng: &mut dyn Random) -> Self { let mut current = Zeroizing::new([0u8; 32]); rng.fill(&mut *current); + let mut nonce_prefix = [0u8; 8]; + rng.fill(&mut nonce_prefix); Self { current, previous: None, rotated: now, + nonce_prefix, + nonce_counter: 0, } } + /// The next reply nonce. + fn next_nonce(&mut self, rng: &mut dyn Random) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&self.nonce_prefix); + nonce[8..].copy_from_slice(&self.nonce_counter.to_le_bytes()); + self.nonce_counter = self.nonce_counter.wrapping_add(1); + if self.nonce_counter == 0 { + rng.fill(&mut self.nonce_prefix); + } + nonce + } + /// Moves to a fresh secret once the current one is [`SECRET_LIFETIME`] /// old; the one before is forgotten. fn rotate(&mut self, now: Duration, rng: &mut dyn Random) { @@ -286,11 +327,11 @@ impl CookieJar { } /// Whether `mac` is a valid cookie MAC for `msg1` (without its cookie) - /// from `address`, under the current or the previous secret. + /// under `binding`'s cookie, from the current or the previous secret. pub fn verify( &mut self, now: Duration, - address: &[u8], + binding: &[u8], msg1: &[u8], mac: &[u8; MAC2_LEN], rng: &mut dyn Random, @@ -300,26 +341,25 @@ impl CookieJar { .chain(self.previous.as_ref()) .any(|secret| { use subtle::ConstantTimeEq; - let expected = mac2(&cookie_value(secret, address), msg1); + let expected = mac2(&cookie_value(secret, binding), msg1); bool::from(expected.ct_eq(mac)) }) } - /// A reply to `msg1` (without any cookie) carrying `address`'s current + /// A reply to `msg1` (without any cookie) carrying `binding`'s current /// cookie, sealed under `key`. pub fn reply( &mut self, now: Duration, - address: &[u8], + binding: &[u8], key: &Key32, tag: u16, msg1: &[u8], rng: &mut dyn Random, ) -> Vec { self.rotate(now, rng); - let cookie = cookie_value(&self.current, address); - let mut nonce = [0u8; 12]; - rng.fill(&mut nonce); + let cookie = cookie_value(&self.current, binding); + let nonce = self.next_nonce(rng); seal(key, tag, nonce, &cookie, &msg1_digest(msg1)).encode() } } diff --git a/proto/src/v3/cookie/tests.rs b/proto/src/v3/cookie/tests.rs index 9cfadd1e..1de870ca 100644 --- a/proto/src/v3/cookie/tests.rs +++ b/proto/src/v3/cookie/tests.rs @@ -31,14 +31,20 @@ fn vectors() { assert_eq!(v6.cookie_input(), v["input.address_v6"]); assert_eq!(mapped.cookie_input(), v["input.address_v4"]); + let k_dev = key32(&g["input.k_dev"]); + let dev_id: Id16 = arr(&g["input.dev_id"]); let cookie: [u8; 16] = arr(&v["cookie.v4"]); - assert_eq!(cookie_value(&secret, &v["input.address_v4"]), cookie); + let binding = session_binding(&dev_id, &v["input.address_v4"]); + assert_eq!(cookie_value(&secret, &binding), cookie); assert_eq!( - cookie_value(&secret, &v["input.address_v6"]).to_vec(), + cookie_value(&secret, &session_binding(&dev_id, &v["input.address_v6"])).to_vec(), v["cookie.v6"] ); - let k_dev = key32(&g["input.k_dev"]); - let dev_id: Id16 = arr(&g["input.dev_id"]); + let nonce_r: Id16 = arr(&v["input.nonce_r"]); + assert_eq!( + cookie_value(&secret, &pairing_binding(&nonce_r, &v["input.address_v4"])).to_vec(), + v["cookie.pair_v4"] + ); let session = session_key(&k_dev, &dev_id); assert_eq!(session.to_vec(), v["key.session"]); let pairing = pairing_key(&key32(&v["input.pair_psk"])); @@ -312,3 +318,72 @@ fn a_reply_is_smaller_than_any_message_1() { const { assert!(COOKIE_REPLY_SIZE < wire::MSG1_EXTENSION_OFFSET + EXTENSION_HEADER_SIZE) }; assert!(COOKIE_REPLY_SIZE < golden_msg1().len()); } + +/// Reply nonces are a per-jar random prefix and a counter: they never +/// repeat under one jar, and the prefix is drawn again when the counter +/// wraps. +#[test] +fn reply_nonces_count_up_and_take_a_new_prefix_on_wrap() { + let mut rng = SeededRandom::new(6); + let mut jar = CookieJar::new(Duration::ZERO, &mut rng); + let msg1 = golden_msg1(); + let nonce = |jar: &mut CookieJar, rng: &mut SeededRandom| { + let reply = jar.reply( + Duration::from_secs(1), + b"b", + &[3; 32], + EXT_SESSION, + &msg1, + rng, + ); + CookieReply::parse(&reply).unwrap().nonce + }; + let first = nonce(&mut jar, &mut rng); + let second = nonce(&mut jar, &mut rng); + assert_eq!(first[..8], second[..8], "one prefix"); + assert_eq!(first[8..], 0u32.to_le_bytes()); + assert_eq!(second[8..], 1u32.to_le_bytes()); + jar.nonce_counter = u32::MAX; + let last = nonce(&mut jar, &mut rng); + assert_eq!(last[..8], first[..8]); + assert_eq!(last[8..], u32::MAX.to_le_bytes()); + let wrapped = nonce(&mut jar, &mut rng); + assert_ne!(wrapped[..8], first[..8], "a new prefix"); + assert_eq!(wrapped[8..], 0u32.to_le_bytes()); + // Another jar (a host start) draws its own prefix. + let other = nonce(&mut CookieJar::new(Duration::ZERO, &mut rng), &mut rng); + assert_ne!(other[..8], first[..8]); +} + +#[test] +fn a_session_cookie_binds_its_dev_id() { + let address = [1u8, 2, 3, 4, 5, 6]; + let a = session_binding(&[0xA0; 16], &address); + let b = session_binding(&[0xB0; 16], &address); + let secret = [9u8; 32]; + assert_ne!(cookie_value(&secret, &a), cookie_value(&secret, &b)); + let mut rng = SeededRandom::new(7); + let mut jar = CookieJar::new(Duration::ZERO, &mut rng); + let msg1 = golden_msg1(); + let key = [3u8; 32]; + let reply = jar.reply( + Duration::from_secs(1), + &b, + &key, + EXT_SESSION, + &msg1, + &mut rng, + ); + let cookie = open( + &key, + &CookieReply::parse(&reply).unwrap(), + &msg1_digest(&msg1), + ) + .unwrap(); + let mac = mac2(&cookie, &msg1); + assert!(jar.verify(Duration::from_secs(1), &b, &msg1, &mac, &mut rng)); + assert!( + !jar.verify(Duration::from_secs(1), &a, &msg1, &mac, &mut rng), + "another dev_id" + ); +} diff --git a/proto/src/v3/host.rs b/proto/src/v3/host.rs index 94270598..b1146e85 100644 --- a/proto/src/v3/host.rs +++ b/proto/src/v3/host.rs @@ -62,7 +62,7 @@ pub struct HostConfig { /// `Stale`, `TooNew` and `PairAgain`) to one address. pub msg2_resend_interval: Duration, /// New `Stale` answers per `dev_id` per second, and as many again for - /// message 1s that carry a valid cookie. + /// each address that sends message 1s with a valid cookie. pub stale_per_second: u32, /// Authenticated message 1s per `dev_id` per second that get no answer /// (over its budget, a late copy, a timestamp past the ceiling) and are @@ -477,14 +477,67 @@ impl RateWindow { } } +/// One `dev_id`'s rate windows for message 1s with a valid cookie, per +/// exact address. Only the holder of that `dev_id`'s key can reach them, +/// and each of its addresses has its own window, so recorded copies +/// replayed from an address it used before never use up the one it's on +/// now. Addresses beyond [`MAX_COOKIE_ADDRESSES`] share one more. +struct AddressWindows { + windows: Vec<(A, RateWindow)>, + overflow: RateWindow, +} + +/// Addresses with a window of their own in each [`AddressWindows`]: a +/// phone may move between Wi-Fi, cellular and a rebound NAT port within a +/// second. +const MAX_COOKIE_ADDRESSES: usize = 4; + +impl AddressWindows { + fn new(now: Duration) -> Self { + Self { + windows: Vec::new(), + overflow: RateWindow { + start: now, + count: 0, + }, + } + } + + fn allow(&mut self, now: Duration, address: &A, limit: u32) -> bool { + // A window a second old is spent; it frees its place. + self.windows + .retain(|(_, w)| now.saturating_sub(w.start) < Duration::from_secs(1)); + if let Some((_, window)) = self.windows.iter_mut().find(|(a, _)| a == address) { + return window.allow(now, limit); + } + if self.windows.len() < MAX_COOKIE_ADDRESSES { + let mut window = RateWindow { + start: now, + count: 0, + }; + let allowed = window.allow(now, limit); + self.windows.push((address.clone(), window)); + return allowed; + } + self.overflow.allow(now, limit) + } + + /// Nothing in it is in use any more. + fn idle(&self, now: Duration) -> bool { + let spent = |w: &RateWindow| now.saturating_sub(w.start) >= Duration::from_secs(1); + self.windows.iter().all(|(_, w)| spent(w)) && spent(&self.overflow) + } +} + /// Per-`dev_id` budget for answers that make no pending session (`Stale`, /// `TooNew`, `PairAgain`), with recent answers cached so a replayed message 1 /// is answered without another Diffie-Hellman. struct StaleBudget { window: RateWindow, - /// The same budget again, for message 1s with a valid cookie: only the - /// iPad can send those, so replays from elsewhere never use it up. - verified: RateWindow, + /// The same budget again per exact address, for message 1s with a + /// valid cookie: only the holder of this `dev_id`'s key can send those, + /// so replays from elsewhere never use it up. + verified: AddressWindows, /// Message 1s remembered without an answer. unanswered: RateWindow, /// Newest last. @@ -596,9 +649,10 @@ impl BoundedTimes { /// Where a cached message 2 may be resent, at most once per interval each: /// the exact address its message 1 came from, and addresses that came back -/// with a valid cookie. A cookie opens only under the handshake's own key, -/// so only the iPad can admit an address: spoofed copies from elsewhere get -/// a cookie reply and use none of this. Addresses beyond the capacity share +/// with a valid cookie. A cookie opens only under the handshake's own key +/// and is bound to its `dev_id` (or QR window), so only the holder of that +/// key can admit an address: spoofed copies from elsewhere get a cookie +/// reply and use none of this. Addresses beyond the capacity share /// one more allowance. pub(super) struct ResendSlots { sent: BoundedTimes, @@ -659,15 +713,17 @@ pub const MAX_CREDENTIALS: usize = 64; /// The size of the remembered message 1 table. Each budget window lasts at /// least 1 s, so the entries still remembered after `expired_msg1_lifetime` /// come from at most `ceil(lifetime) + 1` windows per `dev_id`, each adding -/// at most `2 × stale_per_second + unanswered_per_second` (the Stale budget -/// and its lane for message 1s with a cookie). When more `dev_id`s +/// at most `(2 + MAX_COOKIE_ADDRESSES) × stale_per_second + +/// unanswered_per_second` (the Stale budget, and the per-address lanes for +/// message 1s with a cookie with their shared one). When more `dev_id`s /// than `max_credentials` fill it, no new answer is given and nothing /// unexpired is evicted. fn answered_capacity(config: &HostConfig) -> usize { let lifetime = config.expired_msg1_lifetime; let windows = lifetime.as_secs() + u64::from(lifetime.subsec_nanos() > 0) + 1; + let lanes = 2 + MAX_COOKIE_ADDRESSES as u64; let per_window = - 2 * u64::from(config.stale_per_second) + u64::from(config.unanswered_per_second); + lanes * u64::from(config.stale_per_second) + u64::from(config.unanswered_per_second); usize::try_from(windows.saturating_mul(per_window)) .unwrap_or(usize::MAX) .saturating_mul(config.max_credentials) @@ -682,10 +738,11 @@ pub struct HostSessions { refusal_contexts: Vec>, refusals: Vec>, msg1_rate: SourceLimiter, - /// Message 1 reads per exact address, for those with a valid cookie: - /// only the iPad can send them, so spoofing its IP from another port - /// never uses up its reads. - verified_rate: SourceLimiter, + /// Message 1 reads per `dev_id` and exact address, for those with a + /// valid cookie: only the holder of that `dev_id`'s key can send them, + /// so spoofing the iPad's IP from another port, or another device's own + /// traffic, never uses up its reads. Only for credentials the host has. + verified_reads: HashMap>, stale: HashMap>, /// Message 1s that already got a `Stale`, `TooNew` or `PairAgain` /// answer, for 30 s, with expiry order. A recognised replay whose cached @@ -724,7 +781,7 @@ impl HostSessions { cookie_replies: 0, answered_capacity: answered_capacity(&config), msg1_rate: SourceLimiter::new(config.msg1_per_second), - verified_rate: SourceLimiter::new(config.msg1_per_second), + verified_reads: HashMap::new(), config, rng, devices: HashMap::new(), @@ -855,10 +912,7 @@ impl HostSessions { start: now, count: 0, }, - verified: RateWindow { - start: now, - count: 0, - }, + verified: AddressWindows::new(now), unanswered: RateWindow { start: now, count: 0, @@ -890,8 +944,13 @@ impl HostSessions { return; } let verified = mac.is_some_and(|mac| { - self.cookies - .verify(now, &from.cookie_input(), &canon, &mac, self.rng.as_mut()) + self.cookies.verify( + now, + &cookie::session_binding(&msg1.dev_id, &from.cookie_input()), + &canon, + &mac, + self.rng.as_mut(), + ) }); // Under load, nothing answers or reads a message 1 without a valid // cookie for its exact source: only a cookie reply goes back, and @@ -969,9 +1028,16 @@ impl HostSessions { return; } if self.answered.get(&key).is_some_and(|&until| now < until) { - // Answered before, and its cached answer is gone: drop it, with - // no Diffie-Hellman. - return; + // Answered (or deliberately left unanswered) before, and no + // cached answer is left: replays from anyone cost no crypto, only + // a cookie reply. Spoofed replays of other message 1s can push an + // answer out of the cache, so a retry with a valid cookie, + // which only the holder of that dev_id's key can send, is read + // and answered again on its address's own lane. + if !verified { + self.send_cookie(now, from, &canon, &dev_id, creds, out); + return; + } } // Unknown dev_ids cost nothing and are never charged to a source. let Some(k_dev) = creds.key(&dev_id) else { @@ -999,8 +1065,12 @@ impl HostSessions { // A message 1 with a valid cookie is limited per exact address; // without one, per IP. One the IP's limit refuses gets a cookie // reply, so spoofing the iPad's IP never keeps it from a read. + let limit = self.config.msg1_per_second; let allowed = if verified { - self.verified_rate.allow(now, from) + self.verified_reads + .entry(dev_id) + .or_insert_with(|| AddressWindows::new(now)) + .allow(now, from, limit) } else { self.msg1_rate.allow(now, &bucket) }; @@ -1131,7 +1201,7 @@ impl HostSessions { let key = cookie::session_key(&k_dev, dev_id); let reply = self.cookies.reply( now, - &from.cookie_input(), + &cookie::session_binding(dev_id, &from.cookie_input()), &key, super::EXT_SESSION, msg1, @@ -1169,12 +1239,12 @@ impl HostSessions { } let limit = self.config.stale_per_second; let budget = self.stale_budget(dev_id, now); - let window = if verified { - &mut budget.verified + let allowed = if verified { + budget.verified.allow(now, from, limit) } else { - &mut budget.window + budget.window.allow(now, limit) }; - if !window.allow(now, limit) { + if !allowed { if !verified { return false; } @@ -1744,11 +1814,12 @@ impl HostSessions { } self.refusals.retain(|r| r.until > now); self.msg1_rate.prune(now); - self.verified_rate.prune(now); + self.verified_reads.retain(|_, w| !w.idle(now)); self.stale.retain(|_, b| { b.cached.retain(|c| now < c.until); !b.cached.is_empty() || now.saturating_sub(b.window.start) < Duration::from_secs(1) + || !b.verified.idle(now) || now.saturating_sub(b.unanswered.start) < Duration::from_secs(1) }); self.expire_answered(now); @@ -2613,7 +2684,8 @@ mod tests { let out = host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); only_cookie_reply(&out, source, ipad.msg1()); } - // With one (only the iPad can have it), at most 16 addresses. + // With one (only the holder of DEV's key can have it), at most 16 + // addresses. let replies: usize = (100..300u32) .map(|source| { let out = host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); @@ -2693,7 +2765,7 @@ mod tests { } #[test] - fn a_replay_whose_cached_answer_was_evicted_is_dropped_without_crypto() { + fn a_replay_whose_cached_answer_was_evicted_costs_no_crypto_without_a_cookie() { let (mut host, creds) = setup(); *creds.timestamp.borrow_mut() = 1000; let old: Vec<_> = (0..12u64) @@ -2708,10 +2780,20 @@ mod tests { 1 ); } - let before = host.handshakes_performed(); - let replay = host.handle_datagram(T0 + Duration::from_secs(3), &1, old[0].msg1(), &creds); - assert!(replay.is_empty()); + let (before, reads) = (host.handshakes_performed(), host.msg1_reads()); + let at = T0 + Duration::from_secs(3); + let replay = host.handle_datagram(at, &1, old[0].msg1(), &creds); + let reply = only_cookie_reply(&replay, 1, old[0].msg1()); assert_eq!(host.handshakes_performed(), before, "no Diffie-Hellman"); + assert_eq!(host.msg1_reads(), reads, "no read"); + // With its cookie (only the key holder can have it), it's read and + // answered again on its address's own lane. + let msg1 = with_cookie(&reply, old[0].msg1()); + assert_eq!( + real_answers(&host.handle_datagram(at, &1, &msg1, &creds)), + 1 + ); + assert_eq!(host.handshakes_performed(), before + 1); } #[test] @@ -2858,7 +2940,7 @@ mod tests { // The very first one, replayed just before 30 s, is still recognised. let reads = host.msg1_reads(); let out = host.handle_datagram(T0 + ms(29_900), &999, &first.unwrap(), &creds); - assert!(out.is_empty()); + assert_eq!(real_answers(&out), 0, "at most a cookie reply"); assert_eq!(host.msg1_reads(), reads); } @@ -2888,10 +2970,10 @@ mod tests { } /// Floods `devices` distinct authenticated `Stale` message 1s per - /// device, 15 every 200 ms (75/s, past every budget) from fresh - /// sources, for `ticks` × 200 ms. One the shared budget refuses comes back with its - /// cookie, as the iPad's would, so the cookie lane and the unanswered - /// memory fill too. Returns the answers per device, the largest table + /// device, 15 every 200 ms (75/s, past every budget), from 6 addresses + /// in turn (more than the per-address cookie lanes), for `ticks` × + /// 200 ms. One the host asks a cookie for comes back with it, as the + /// iPad's would, so the cookie lanes and the unanswered memory fill too. Returns the answers per device, the largest table /// size seen and the last message 1 that got an answer. fn flood_remembered( host: &mut HostSessions, @@ -2909,7 +2991,7 @@ mod tests { for (n, id) in ids.iter().enumerate() { for _ in 0..15 { ts += 1; - source += 1; + source = 1000 + (source + 1 - 1000) % 6; let (sent, msg1) = stale_attempt(host, creds, at, source, id, ts); if sent > 0 { last = msg1; @@ -2936,17 +3018,19 @@ mod tests { for id in &ids { creds.keys.insert(*id, K); } - // 4 × (5 + 5 + 5) × (30 + 1). - assert_eq!(host.answered_capacity, 1860); + // 4 × ((2 + 4) × 5 + 5) × (30 + 1): the Stale budget, four address + // lanes and their shared one, and the unanswered memory. + assert_eq!(host.answered_capacity, 4340); let (answers, largest, _) = flood_remembered(&mut host, &creds, &ids, 155); - // The table never filled: every device got both its answer budgets - // in every second, (5 + 5) × 31. + // The table never filled: every device got all its answer lanes in + // every second, (5 + 4 × 5 + 5) × 31. (Under this load every message + // 1 comes with its cookie, so the shared budget's 5 go unused.) for a in answers { - assert_eq!(a, 10 * 31); + assert_eq!(a, 30 * 31); } // Windows aligned on whole seconds reach 30 of the 31 the bound - // allows for. - assert_eq!(largest, 1800); + // allows for, with every lane and the unanswered memory full. + assert_eq!(largest, 4 * 35 * 30); } #[test] @@ -2963,7 +3047,7 @@ mod tests { let (answers, largest, last) = flood_remembered(&mut host, &creds, &ids, 100); assert_eq!(largest, host.answered_capacity); assert!( - answers.iter().sum::() < 3 * 10 * 20, + answers.iter().sum::() < 3 * 30 * 20, "some answers refused" ); // Full (it fills well before anything expires), and a new message 1, @@ -3454,4 +3538,143 @@ mod tests { } assert_eq!(answered, 4); } + + /// Message 1 with the cookie the reply carries, for `id`'s key. + fn with_cookie_for(reply: &[u8], msg1: &[u8], id: &Id16, k: &Key32) -> Vec { + let parsed = cookie::CookieReply::parse(reply).expect("a cookie reply"); + let key = cookie::session_key(k, id); + let value = cookie::open(&key, &parsed, &cookie::msg1_digest(msg1)).expect("opens"); + cookie::attach(msg1, &value) + } + + /// Spoofed replays of captured old message 1s push the iPad's cached + /// Stale answer out of the 8-entry cache, while its message 1 is still + /// remembered. Its retry gets a cookie reply instead of silence, and + /// with the cookie is answered again. + #[test] + fn an_evicted_cached_answer_is_answered_again_through_the_cookie() { + let (mut host, creds) = setup(); + *creds.timestamp.borrow_mut() = 1_000_000; + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(900)).unwrap(); + let answer = sends(&host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds)); + assert_eq!(answer.len(), 1); + // Its answer is lost; 9 captured old ones, replayed from elsewhere + // within the budget, each get a fresh answer and evict it. + for i in 0..9u64 { + let at = T0 + Duration::from_secs(1 + i / 4); + let old = Initiator::start(&K, &DEV, &HOST, 1, &hello(100 + i)).unwrap(); + assert_eq!( + real_answers(&host.handle_datagram(at, &(500 + i as u32), old.msg1(), &creds)), + 1 + ); + } + let at = T0 + Duration::from_secs(4); + assert!(!host.under_load(at)); + let out = host.handle_datagram(at, &ADDR, ipad.msg1(), &creds); + let msg1 = with_cookie(&only_cookie_reply(&out, ADDR, ipad.msg1()), ipad.msg1()); + let out = sends(&host.handle_datagram(at, &ADDR, &msg1, &creds)); + assert_eq!(out.len(), 1); + assert!(!cookie::is_reply(&out[0]), "answered again"); + } + + /// Another paired device's cookie, from its own address, is no use with + /// the iPad's captured message 1: it binds that device's dev_id. + #[test] + fn another_devices_cookie_never_verifies_the_ipads_message_1() { + let (mut host, mut creds) = setup_with(CookieMode::Always); + let other: Id16 = [0xD1; 16]; + let k_other: Key32 = [0x43; 32]; + creds.keys.insert(other, k_other); + let theirs = Initiator::start(&k_other, &other, &HOST, 1, &hello(10)).unwrap(); + let out = host.handle_datagram(T0, &9, theirs.msg1(), &creds); + let reply = only_cookie_reply(&out, 9, theirs.msg1()); + let parsed = cookie::CookieReply::parse(&reply).unwrap(); + let value = cookie::open( + &cookie::session_key(&k_other, &other), + &parsed, + &cookie::msg1_digest(theirs.msg1()), + ) + .unwrap(); + // The iPad's message 1 with that cookie, from that device's address. + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); + let forged = cookie::attach(ipad.msg1(), &value); + let reads = host.msg1_reads(); + let out = host.handle_datagram(T0, &9, &forged, &creds); + only_cookie_reply(&out, 9, ipad.msg1()); + assert_eq!(host.msg1_reads(), reads, "not read"); + assert_eq!(host.pending_count(), 0); + } + + /// Recorded cookie-bearing message 1s, replayed from the address they + /// came from once the 30 s replay memory has passed, use up only that + /// address's lane: the iPad, rebound to a new address, still gets its + /// Stale answer. + #[test] + fn recorded_cookie_message_1s_never_take_a_rebound_ipads_stale_lane() { + let (_, creds) = setup(); + *creds.timestamp.borrow_mut() = 1_000_000; + let mut host: HostSessions = { + let mut config = HostConfig::new(HOST); + config.cookies = CookieMode::Always; + HostSessions::new(config, Box::new(SeededRandom::new(1)), T0, WALL) + }; + let (old, new) = (40u32, 41u32); + let recorded: Vec> = (0..12u64) + .map(|i| { + let attempt = Initiator::start(&K, &DEV, &HOST, 1, &hello(100 + i)).unwrap(); + let at = T0 + Duration::from_secs(i / 4); + let out = host.handle_datagram(at, &old, attempt.msg1(), &creds); + let msg1 = with_cookie( + &only_cookie_reply(&out, old, attempt.msg1()), + attempt.msg1(), + ); + host.handle_datagram(at, &old, &msg1, &creds); + msg1 + }) + .collect(); + // 31 s later the cookies are still good, and the replay memory is + // gone: all 12 replayed from the old address within one second. + let at = T0 + Duration::from_secs(34); + host.poll(at); + for msg1 in &recorded { + host.handle_datagram(at, &old, msg1, &creds); + } + let ipad = Initiator::start(&K, &DEV, &HOST, 2, &hello(900)).unwrap(); + let out = host.handle_datagram(at, &new, ipad.msg1(), &creds); + let msg1 = with_cookie(&only_cookie_reply(&out, new, ipad.msg1()), ipad.msg1()); + let out = sends(&host.handle_datagram(at, &new, &msg1, &creds)); + assert_eq!(out.len(), 1); + assert!(!cookie::is_reply(&out[0]), "its Stale answer"); + } + + /// Another paired device with its own valid cookies at more addresses + /// than any table holds never uses up the iPad's cookie read lane. + #[test] + fn another_device_at_many_addresses_never_takes_the_ipads_reads() { + let (_, mut creds) = setup(); + let other: Id16 = [0xD1; 16]; + let k_other: Key32 = [0x43; 32]; + creds.keys.insert(other, k_other); + let mut config = HostConfig::new(HOST); + config.cookies = CookieMode::Always; + config.max_pending = 10_000; + config.dh_per_second = u32::MAX; + let mut host: HostSessions = + HostSessions::new(config, Box::new(SeededRandom::new(3)), T0, WALL); + for addr in 0..1100u32 { + for i in 0..20u64 { + let theirs = Initiator::start(&k_other, &other, &HOST, 1, &hello(1 + i)).unwrap(); + let out = host.handle_datagram(T0, &(10_000 + addr), theirs.msg1(), &creds); + let msg1 = with_cookie_for(&sends(&out)[0], theirs.msg1(), &other, &k_other); + host.handle_datagram(T0, &(10_000 + addr), &msg1, &creds); + } + } + let ipad = Initiator::start(&K, &DEV, &HOST, 1, &hello(10)).unwrap(); + let out = host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds); + let msg1 = with_cookie(&only_cookie_reply(&out, ADDR, ipad.msg1()), ipad.msg1()); + let reads = host.msg1_reads(); + let out = sends(&host.handle_datagram(T0, &ADDR, &msg1, &creds)); + assert_eq!(host.msg1_reads(), reads + 1, "read"); + assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); + } } diff --git a/proto/src/v3/pairing.rs b/proto/src/v3/pairing.rs index 88fefba8..0ac9753e 100644 --- a/proto/src/v3/pairing.rs +++ b/proto/src/v3/pairing.rs @@ -436,8 +436,9 @@ pub struct PairingHost { /// Message 1 reads per source (IP). per_source: SourceLimiter, /// Message 1 reads per exact address, for those with a valid cookie: - /// only the QR's holder can send them, so spoofing the iPad's IP from - /// another port never uses up its reads. + /// only the holder of the current QR can send them (the cookie binds + /// the window), so spoofing the iPad's IP from another port never uses + /// up its reads. verified_rate: SourceLimiter, cookies: CookieJar, /// Message 1 arrivals, for the load test. @@ -555,23 +556,20 @@ impl PairingHost { } } - /// A cookie reply to `msg1` (without its cookie) from `from`. + /// A cookie reply to `msg1` (without its cookie) from `from`, for + /// `binding` ([`cookie::pairing_binding`]). fn send_cookie( &mut self, now: Duration, from: &A, + binding: &[u8], msg1: &[u8], key: &Key32, out: &mut Vec>, ) { - let reply = self.cookies.reply( - now, - &from.cookie_input(), - key, - EXT_PAIR_QR, - msg1, - self.rng.as_mut(), - ); + let reply = self + .cookies + .reply(now, binding, key, EXT_PAIR_QR, msg1, self.rng.as_mut()); self.cookie_replies += 1; out.push(PairOutput::Send { to: from.clone(), @@ -614,9 +612,10 @@ impl PairingHost { return; }; self.arrivals.record(now); + let binding = cookie::pairing_binding(&window.nonce_r, &from.cookie_input()); let verified = mac.is_some_and(|mac| { self.cookies - .verify(now, &from.cookie_input(), &canon, &mac, self.rng.as_mut()) + .verify(now, &binding, &canon, &mac, self.rng.as_mut()) }); // Under load, nothing answers or reads a message 1 without a valid // cookie for its exact source, retries of the pairing in flight @@ -635,7 +634,7 @@ impl PairingHost { }; if self.under_load(now) && !verified { let key = cookie_key(); - self.send_cookie(now, from, &canon, &key, out); + self.send_cookie(now, from, &binding, &canon, &key, out); return; } let resend = self @@ -653,7 +652,7 @@ impl PairingHost { } if !admitted && !verified { let key = cookie_key(); - self.send_cookie(now, from, &canon, &key, out); + self.send_cookie(now, from, &binding, &canon, &key, out); return; } let attempt = self.attempt.as_mut().expect("checked above"); @@ -696,7 +695,7 @@ impl PairingHost { if !allowed { if !verified { let key = cookie_key(); - self.send_cookie(now, from, &canon, &key, out); + self.send_cookie(now, from, &binding, &canon, &key, out); } return; } @@ -1013,8 +1012,8 @@ impl PairingClient { } /// A cookie reply to this message 1, before message 2: keeps the cookie - /// and gives back message 1 to send again at once. Anything else is - /// `None` and changes nothing. + /// and gives back message 1 to send again at once. A copy of a cookie + /// already held, and anything else, is `None` and changes nothing. pub fn handle_cookie(&mut self, datagram: &[u8]) -> Option> { self.state.as_ref()?; let reply = CookieReply::parse(datagram).ok()?; @@ -1023,6 +1022,9 @@ impl PairingClient { } let value = cookie::open(&self.cookie_key, &reply, &cookie::msg1_digest(&self.msg1))?; let msg1 = cookie::attach(&self.msg1, &value); + if self.with_cookie.as_ref() == Some(&msg1) { + return None; + } self.with_cookie = Some(msg1.clone()); Some(msg1) } diff --git a/proto/src/v3/pairing/tests.rs b/proto/src/v3/pairing/tests.rs index 2494742a..57fbe489 100644 --- a/proto/src/v3/pairing/tests.rs +++ b/proto/src/v3/pairing/tests.rs @@ -763,6 +763,10 @@ fn under_load_a_pairing_message_1_gets_only_a_cookie_reply_and_pairs_through_it( // It reads nothing and remembers nothing: the same message 1 gets the // same treatment, and with the cookie it pairs. let msg1 = ipad.handle_cookie(reply).expect("the iPad opens it"); + assert!( + ipad.handle_cookie(reply).is_none(), + "a copy sends nothing more" + ); assert_eq!(ipad.msg1(), &msg1[..], "retries carry the cookie"); let msg2 = sends(&host.handle_datagram(T0 + ms(1), &ADDR, &msg1, &creds)); assert_eq!(msg2.len(), 1); @@ -902,3 +906,31 @@ fn another_port_on_the_ipads_ip_never_uses_up_its_pairing_reads() { assert_eq!(wire::classify(&out[0]), V3Kind::Handshake2); assert!(!host.under_load(T0)); } + +/// A cookie from an earlier QR window is no use under a later one, even +/// with a correct MAC over the new message 1 from the same address. +#[test] +fn a_pairing_cookie_binds_its_qr_window() { + let creds = Creds::default(); + let mut host = host_with(CookieMode::Always); + let qr = host.open(T0, addr()); + let ipad = client(&qr, 70); + let out = sends(&host.handle_datagram(T0, &ADDR, ipad.msg1(), &creds)); + let reply = cookie::CookieReply::parse(&out[0]).unwrap(); + let value = cookie::open(&ipad.cookie_key, &reply, &cookie::msg1_digest(ipad.msg1())).unwrap(); + // Under the same window it verifies... + let again = cookie::attach(ipad.msg1(), &value); + let msg2 = sends(&host.handle_datagram(T0, &ADDR, &again, &creds)); + assert_eq!(wire::classify(&msg2[0]), V3Kind::Handshake2); + // ...under the next one it doesn't. + host.close(); + let next = host.open(T0 + ms(10), addr()); + let fresh = client(&next, 71); + let reused = cookie::attach(fresh.msg1(), &value); + let out = sends(&host.handle_datagram(T0 + ms(10), &ADDR, &reused, &creds)); + assert!( + out.len() == 1 && cookie::is_reply(&out[0]), + "a new cookie, not a read" + ); + assert!(!host.in_flight(T0 + ms(10))); +} diff --git a/proto/testdata/v3/cookie_vectors.py b/proto/testdata/v3/cookie_vectors.py index 67bcb7e2..34dd9d52 100644 --- a/proto/testdata/v3/cookie_vectors.py +++ b/proto/testdata/v3/cookie_vectors.py @@ -150,11 +150,14 @@ def main(): psk = bytes(range(0x50, 0x70)) nonce_session = bytes(range(0x01, 0x0D)) nonce_pair = bytes(range(0x11, 0x1D)) + nonce_r = bytes(range(0x30, 0x40)) v4 = address("192.0.2.10", 50000) v6 = address("2001:db8::1", 50000) mapped = address("::ffff:192.0.2.10", 50000) assert mapped == v4 - cookie_v4 = hmac16(secret, v4) + # A session cookie binds the dev_id and the address; a pairing cookie + # binds the QR window's nonce_r and the address. + cookie_v4 = hmac16(secret, dev_id + v4) session_key = hkdf32(SALT, k_dev, dev_id) pairing_key = hkdf32(SALT, psk, b"pair") rows = [ @@ -162,10 +165,12 @@ def main(): ("input.address_v4", v4), ("input.address_v6", v6), ("input.pair_psk", psk), + ("input.nonce_r", nonce_r), ("input.nonce_session", nonce_session), ("input.nonce_pair", nonce_pair), ("cookie.v4", cookie_v4), - ("cookie.v6", hmac16(secret, v6)), + ("cookie.v6", hmac16(secret, dev_id + v6)), + ("cookie.pair_v4", hmac16(secret, nonce_r + v4)), ("key.session", session_key), ("key.pairing", pairing_key), ("msg1.digest", hashlib.sha256(msg1).digest()), @@ -178,8 +183,10 @@ def main(): "# Return-routability cookie vectors. One `namelowercase-hex` per line.", "# Written by cookie_vectors.py (independent of the Rust code); message 1, k_dev", "# and dev_id are session_golden.txt's. Addresses are the IP (4 or 16 bytes,", - "# v4-mapped IPv6 as IPv4) then the port, little-endian. cookie.* are", - "# HMAC-SHA-256(secret, address)[..16]; key.session is HKDF(salt \"EM v3 cookie\",", + "# v4-mapped IPv6 as IPv4) then the port, little-endian. cookie.v4/v6 are", + "# HMAC-SHA-256(secret, dev_id || address)[..16] (a session cookie);", + "# cookie.pair_v4 is HMAC-SHA-256(secret, nonce_r || address)[..16] (pairing).", + "# The iPad treats a cookie as opaque. key.session is HKDF(salt \"EM v3 cookie\",", "# k_dev, info dev_id), key.pairing HKDF(salt, pair_psk, info \"pair\");", "# msg1.with_cookie is message 1 with EXT_COOKIE carrying msg1.mac2 under", "# cookie.v4; reply.* seal cookie.v4 with AAD msg1.digest.", diff --git a/proto/testdata/v3/cookie_vectors.txt b/proto/testdata/v3/cookie_vectors.txt index 9400e8ba..5d26da7b 100644 --- a/proto/testdata/v3/cookie_vectors.txt +++ b/proto/testdata/v3/cookie_vectors.txt @@ -1,8 +1,10 @@ # Return-routability cookie vectors. One `namelowercase-hex` per line. # Written by cookie_vectors.py (independent of the Rust code); message 1, k_dev # and dev_id are session_golden.txt's. Addresses are the IP (4 or 16 bytes, -# v4-mapped IPv6 as IPv4) then the port, little-endian. cookie.* are -# HMAC-SHA-256(secret, address)[..16]; key.session is HKDF(salt "EM v3 cookie", +# v4-mapped IPv6 as IPv4) then the port, little-endian. cookie.v4/v6 are +# HMAC-SHA-256(secret, dev_id || address)[..16] (a session cookie); +# cookie.pair_v4 is HMAC-SHA-256(secret, nonce_r || address)[..16] (pairing). +# The iPad treats a cookie as opaque. key.session is HKDF(salt "EM v3 cookie", # k_dev, info dev_id), key.pairing HKDF(salt, pair_psk, info "pair"); # msg1.with_cookie is message 1 with EXT_COOKIE carrying msg1.mac2 under # cookie.v4; reply.* seal cookie.v4 with AAD msg1.digest. @@ -12,14 +14,16 @@ input.secret c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf input.address_v4 c000020a50c3 input.address_v6 20010db800000000000000000000000150c3 input.pair_psk 505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f +input.nonce_r 303132333435363738393a3b3c3d3e3f input.nonce_session 0102030405060708090a0b0c input.nonce_pair 1112131415161718191a1b1c -cookie.v4 5ab8c96047809bf691b5e43bd2788b6c -cookie.v6 012b42ad3511e2d4caa08a1716169841 +cookie.v4 abf64a22893536117a31a0a2469ad8d7 +cookie.v6 3a6c75a0ef865b80132edb133d48ad01 +cookie.pair_v4 36f72e9cd2c21cb9fe5f5d5a2168c969 key.session 436c023aa1d5860964056150cbbd372a81b586a96917c5d4aaad3f0599ab118e key.pairing ac86452d1cb196443b385343b085d2201686fd0e28ca67790cdba349a1d23bbb msg1.digest 5db3e630cad3c7dbd4fa60b90442e5067e567f1162a1f78893a0dba5f31c6997 -msg1.mac2 1a44d5908ed63b5bc75eadd8ffa594ce -msg1.with_cookie 454d02100000b50000000000010000000303d4c3b2a1cbc30000000000000000000000000000000000000000000000000000000000000000000000000000000000000001006a00d0d3d6d9dcdfe2e5e8ebeef1f4f7fafd93f0b6e180f3b43d8a422040f863ef12bfeeb14c03b1f304e73cca999426ab5d3a7ab33ae01cda93610712ce93e9fdfd37e921ea8471848b5f521643592de04f429ca0e88a8c1f17c5a2997270cee460a5eb6c237bc7c1afd52eff0010001a44d5908ed63b5bc75eadd8ffa594ce -reply.session 454d024200003000000000000000000001002c000102030405060708090a0b0cf870d9945a8cfada0e96bdff4c142cf585aa8f0378cda289329f32a6b31f794b -reply.pairing 454d024200003000000000000000000002002c001112131415161718191a1b1c39d3aa0168ff5cea08a3bea54dea7ca07506214e5985e48645d4f9166b5f0ae7 +msg1.mac2 954c010237f795fd3373458b335008e2 +msg1.with_cookie 454d02100000b50000000000010000000303d4c3b2a1cbc30000000000000000000000000000000000000000000000000000000000000000000000000000000000000001006a00d0d3d6d9dcdfe2e5e8ebeef1f4f7fafd93f0b6e180f3b43d8a422040f863ef12bfeeb14c03b1f304e73cca999426ab5d3a7ab33ae01cda93610712ce93e9fdfd37e921ea8471848b5f521643592de04f429ca0e88a8c1f17c5a2997270cee460a5eb6c237bc7c1afd52eff001000954c010237f795fd3373458b335008e2 +reply.session 454d024200003000000000000000000001002c000102030405060708090a0b0c093e5ad69439573de512f966d8f67f4e0a2009a28892706a2ff9c29822c17afb +reply.pairing 454d024200003000000000000000000002002c001112131415161718191a1b1cc89d2943a64af10de327fa3cd9082f1bbf79d76cec0886dce2f1060843ae7009