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_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/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..34f562d0 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,47 @@ impl Client { out } + /// A cookie reply for the message 1 being sent: keep the cookie and + /// 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 { + 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; + }; + // 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, + }; + 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 +943,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 { @@ -1082,12 +1147,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(); @@ -1276,4 +1368,99 @@ 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" + ); + // 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!( + 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..71b89272 --- /dev/null +++ b/proto/src/v3/cookie.rs @@ -0,0 +1,456 @@ +//! 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, 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`]: +//! +//! ```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 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. +//! +//! [`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 `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]`. +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. +/// +/// 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) { + 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) + /// under `binding`'s cookie, from the current or the previous secret. + pub fn verify( + &mut self, + now: Duration, + binding: &[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, binding), msg1); + bool::from(expected.ct_eq(mac)) + }) + } + + /// A reply to `msg1` (without any cookie) carrying `binding`'s current + /// cookie, sealed under `key`. + pub fn reply( + &mut self, + now: Duration, + binding: &[u8], + key: &Key32, + tag: u16, + msg1: &[u8], + rng: &mut dyn Random, + ) -> Vec { + self.rotate(now, rng); + let cookie = cookie_value(&self.current, binding); + let nonce = self.next_nonce(rng); + seal(key, tag, nonce, &cookie, &msg1_digest(msg1)).encode() + } +} + +/// 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 load, 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..1de870ca --- /dev/null +++ b/proto/src/v3/cookie/tests.rs @@ -0,0 +1,389 @@ +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 k_dev = key32(&g["input.k_dev"]); + let dev_id: Id16 = arr(&g["input.dev_id"]); + let cookie: [u8; 16] = arr(&v["cookie.v4"]); + let binding = session_binding(&dev_id, &v["input.address_v4"]); + assert_eq!(cookie_value(&secret, &binding), cookie); + assert_eq!( + cookie_value(&secret, &session_binding(&dev_id, &v["input.address_v6"])).to_vec(), + v["cookie.v6"] + ); + 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"])); + 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()); +} + +/// 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 ad661e34..b1146e85 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::{ @@ -57,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 + /// 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 @@ -70,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 @@ -92,9 +92,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, @@ -104,6 +101,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 { @@ -116,18 +119,18 @@ 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), 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), max_age: SESSION_MAX_AGE, + cookies: CookieMode::UnderLoad, + cookie_load_msg1_per_second: 400, } } } @@ -140,6 +143,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 +163,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 +182,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 @@ -329,12 +349,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, @@ -461,22 +477,78 @@ 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 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. - cached: Vec::Bucket>>, + cached: Vec>, } -struct CachedAnswer { +struct CachedAnswer { key: Digest, msg2: Vec, - /// The source whose message 1 got this answer. - origin: B, + /// Where it may be resent, and when it last was. + resends: ResendSlots, until: Duration, } @@ -575,9 +647,63 @@ 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 +/// 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, + 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; @@ -587,13 +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 `stale_per_second + unanswered_per_second`. 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 per_window = u64::from(config.stale_per_second) + u64::from(config.unanswered_per_second); + let lanes = 2 + MAX_COOKIE_ADDRESSES as u64; + let per_window = + 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) @@ -608,7 +738,11 @@ pub struct HostSessions { refusal_contexts: Vec>, refusals: Vec>, msg1_rate: SourceLimiter, - stale_replay_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 @@ -619,11 +753,15 @@ 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, 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,10 +773,15 @@ 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), + verified_reads: HashMap::new(), config, rng, devices: HashMap::new(), @@ -653,10 +796,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, @@ -686,6 +825,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`. @@ -752,6 +912,7 @@ impl HostSessions { start: now, count: 0, }, + verified: AddressWindows::new(now), unanswered: RateWindow { start: now, count: 0, @@ -768,9 +929,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,71 +943,101 @@ impl HostSessions { { return; } + let verified = mac.is_some_and(|mac| { + 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 + // only for a known dev_id. That covers new message 1s, cached + // answers and the live address alike. + 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 let Some(answer) = self - .stale + if self + .devices .get(&dev_id) - .and_then(|b| b.cached.iter().find(|c| c.key == key && now < c.until)) + .and_then(|d| d.live.as_ref()) + .is_some_and(|l| l.msg1_key == key) { + return; + } + 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 per-source budgets. - 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)) - { + // 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; + } + 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; } 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 { @@ -868,8 +1061,25 @@ 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 limit = self.config.msg1_per_second; + let allowed = if verified { + self.verified_reads + .entry(dev_id) + .or_insert_with(|| AddressWindows::new(now)) + .allow(now, from, limit) + } 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 @@ -911,7 +1121,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()) { @@ -954,12 +1170,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, @@ -973,8 +1184,41 @@ 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, + &cookie::session_binding(dev_id, &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. + /// `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, @@ -984,27 +1228,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 allowed = if verified { + budget.verified.allow(now, from, limit) + } else { + budget.window.allow(now, limit) + }; + if !allowed { + 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.rate_bucket(); + 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); @@ -1012,7 +1266,7 @@ impl HostSessions { budget.cached.push(CachedAnswer { key, msg2: msg2.clone(), - origin, + resends, until, }); self.remember_answered(now, key); @@ -1020,6 +1274,7 @@ impl HostSessions { to: from.clone(), datagram: msg2, }); + true } /// Remembers an authenticated message 1 for 30 s, so its replays are @@ -1559,11 +1814,12 @@ impl HostSessions { } self.refusals.retain(|r| r.until > now); self.msg1_rate.prune(now); - self.stale_replay_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); @@ -1668,15 +1924,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, ) } @@ -1688,6 +1947,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 { @@ -2017,9 +2281,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); @@ -2043,9 +2308,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"); @@ -2140,17 +2403,21 @@ 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); 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"); } @@ -2318,13 +2585,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!( @@ -2367,25 +2639,32 @@ 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(), - 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(); @@ -2400,9 +2679,18 @@ 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 holder of DEV's key 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!( @@ -2418,7 +2706,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 +2726,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 +2747,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. @@ -2476,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) @@ -2491,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] @@ -2521,7 +2820,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 +2874,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 +2887,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 { @@ -2640,34 +2940,61 @@ 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); } + /// 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 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, 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(); + source = 1000 + (source + 1 - 1000) % 6; + 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()); @@ -2683,6 +3010,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); @@ -2690,17 +3018,19 @@ 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 × ((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 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, 5 * 31); + assert_eq!(a, 30 * 31); } // Windows aligned on whole seconds reach 30 of the 31 the bound - // allows for. - assert_eq!(largest, 1200); + // allows for, with every lane and the unanswered memory full. + assert_eq!(largest, 4 * 35 * 30); } #[test] @@ -2714,21 +3044,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 * 30 * 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); } @@ -2754,23 +3099,582 @@ 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); + 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 { + let out = host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); + only_cookie_reply(&out, source, ipad.msg1()); + } + // ...and never take the iPad's own allowance. + assert!(host + .handle_datagram(T0 + ms(30), &ADDR, &msg1, &creds) + .is_empty()); assert_eq!( - sends(&host.handle_datagram(T0 + ms(5), &ADDR, ipad.msg1(), &creds)).len(), + sends(&host.handle_datagram(T0 + ms(120), &ADDR, &msg1, &creds)).len(), 1 ); - // Replays from 200 more sources... - for source in 100..300u32 { - host.handle_datagram(T0 + ms(10), &source, ipad.msg1(), &creds); + } + + /// 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()), } - // ...never take the iPad's own allowance: its retry gets the resend. + } + + #[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 + ms(30), &ADDR, ipad.msg1(), &creds) + .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!( - sends(&host.handle_datagram(T0 + ms(120), &ADDR, ipad.msg1(), &creds)).len(), + 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"); + } + + /// 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); + } + + /// 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); + } + + /// 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/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..0ac9753e 100644 --- a/proto/src/v3/pairing.rs +++ b/proto/src/v3/pairing.rs @@ -33,8 +33,9 @@ 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::host::{PeerAddr, ResendSlots, SourceLimiter}; use super::record::{Opener, RecordHeader, Sealer}; use super::wire::{ self, PairAccept, PairHello, PairMessage, SlotStatus, V3Kind, MAX_NOISE_MESSAGE, @@ -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 { @@ -332,9 +333,18 @@ 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`]). + 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 +354,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, } } } @@ -377,7 +389,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, @@ -420,8 +433,17 @@ 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 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. + arrivals: LoadMeter, + cookie_replies: u64, } impl PairingHost { @@ -433,8 +455,13 @@ 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), + verified_rate: SourceLimiter::new(config.msg1_per_source_per_second), config, host_id, host_version: host_version.to_string(), @@ -512,6 +539,44 @@ 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), + } + } + + /// 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, binding, 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( @@ -542,20 +607,56 @@ 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; }; - if let Some(attempt) = self + 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, &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 + // included: only a cookie reply goes back, sealed under a key only + // the QR's holder can derive. + 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, &binding, &canon, &key, out); + return; + } + 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, &binding, &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(), @@ -583,8 +684,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, &binding, &canon, &key, out); + } return; } let ctx = pairing_ctx( @@ -643,7 +755,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, @@ -807,6 +919,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)); } @@ -816,7 +929,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 +998,35 @@ 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. 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()?; + 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); + if self.with_cookie.as_ref() == Some(&msg1) { + return None; + } + 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..57fbe489 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", @@ -394,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(); @@ -403,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), @@ -643,27 +657,57 @@ 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:?}" + ); + if cookies != CookieMode::Off { + assert!(host.cookie_replies() > 0); + } } /// Many senders, each inside its own limit, burst junk right before each of @@ -671,13 +715,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 +736,201 @@ 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()); + } + // 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); +} + +#[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!( + 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); + 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!(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); +} + +/// 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}"); + } +} + +/// 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); } - assert!(answered >= 1, "message 2 on {answered} of 8 retries"); + 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)); +} + +/// 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/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 { diff --git a/proto/testdata/v3/cookie_vectors.py b/proto/testdata/v3/cookie_vectors.py new file mode 100644 index 00000000..34dd9d52 --- /dev/null +++ b/proto/testdata/v3/cookie_vectors.py @@ -0,0 +1,201 @@ +"""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.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.", + "# 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..5d26da7b --- /dev/null +++ b/proto/testdata/v3/cookie_vectors.txt @@ -0,0 +1,29 @@ +# 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.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. +# 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_r 303132333435363738393a3b3c3d3e3f +input.nonce_session 0102030405060708090a0b0c +input.nonce_pair 1112131415161718191a1b1c +cookie.v4 abf64a22893536117a31a0a2469ad8d7 +cookie.v6 3a6c75a0ef865b80132edb133d48ad01 +cookie.pair_v4 36f72e9cd2c21cb9fe5f5d5a2168c969 +key.session 436c023aa1d5860964056150cbbd372a81b586a96917c5d4aaad3f0599ab118e +key.pairing ac86452d1cb196443b385343b085d2201686fd0e28ca67790cdba349a1d23bbb +msg1.digest 5db3e630cad3c7dbd4fa60b90442e5067e567f1162a1f78893a0dba5f31c6997 +msg1.mac2 954c010237f795fd3373458b335008e2 +msg1.with_cookie 454d02100000b50000000000010000000303d4c3b2a1cbc30000000000000000000000000000000000000000000000000000000000000000000000000000000000000001006a00d0d3d6d9dcdfe2e5e8ebeef1f4f7fafd93f0b6e180f3b43d8a422040f863ef12bfeeb14c03b1f304e73cca999426ab5d3a7ab33ae01cda93610712ce93e9fdfd37e921ea8471848b5f521643592de04f429ca0e88a8c1f17c5a2997270cee460a5eb6c237bc7c1afd52eff001000954c010237f795fd3373458b335008e2 +reply.session 454d024200003000000000000000000001002c000102030405060708090a0b0c093e5ad69439573de512f966d8f67f4e0a2009a28892706a2ff9c29822c17afb +reply.pairing 454d024200003000000000000000000002002c001112131415161718191a1b1cc89d2943a64af10de327fa3cd9082f1bbf79d76cec0886dce2f1060843ae7009 diff --git a/proto/tests/v3_session_sim.rs b/proto/tests/v3_session_sim.rs index cbba7826..fe1a5c91 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,24 @@ 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 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) { + if cookies != CookieMode::Off { + assert!(sim.host.cookie_replies() > 0, "{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 +1596,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 +1636,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,21 +1645,24 @@ 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 -/// 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); @@ -1649,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" + ); } 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 {