From 56b560418c4d4187af3c5b5d0f61a43a3a9ac8d8 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sun, 3 May 2026 23:06:26 +0700 Subject: [PATCH] test(client): add comprehensive integration tests for HMAC authentication Verify HMAC negotiation, session closure, and error handling for authenticated probes to ensure protocol security and reliability. --- crates/irtt-client/tests/hmac.rs | 296 ++++++++++++++++++++++++ crates/irtt-client/tests/support/mod.rs | 162 ++++++++++++- 2 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 crates/irtt-client/tests/hmac.rs diff --git a/crates/irtt-client/tests/hmac.rs b/crates/irtt-client/tests/hmac.rs new file mode 100644 index 0000000..4215be4 --- /dev/null +++ b/crates/irtt-client/tests/hmac.rs @@ -0,0 +1,296 @@ +mod support; + +use std::time::Duration; + +use irtt_client::{ + Client, ClientConfig, ClientError, ClientEvent, ClientTimestamp, OpenOutcome, SocketConfig, + WarningKind, +}; +use irtt_proto::{echo_packet_len, ProtoError, TimestampFields}; + +use support::{ + config_for_params, default_params, params_for_modes, run_one_probe_with_config, + standard_timestamps, start_bad_hmac_echo_reply_server, start_hmac_close_server, + start_hmac_required_open_drop_server, ServerObservation, TOKEN, +}; + +#[test] +fn hmac_open_success_negotiates_without_warnings() { + let key = b"compat-secret".to_vec(); + let params = default_params(); + let config_params = params.clone(); + let config_key = key.clone(); + + let run = run_one_probe_with_config( + params.clone(), + TimestampFields::default(), + Some(key), + |addr| ClientConfig { + hmac_key: Some(config_key), + ..config_for_params(addr, &config_params) + }, + ); + + assert_eq!(run.negotiated.params, params); + match &run.observations[0] { + ServerObservation::Open { params: got, hmac } => { + assert_eq!(got, ¶ms); + assert!(*hmac); + } + other => panic!("expected open observation, got {other:?}"), + } + assert_no_warning_or_error(&[run.sent, run.reply]); +} + +#[test] +fn hmac_echo_success_verifies_request_and_accepts_reply() { + let key = b"compat-secret".to_vec(); + let params = default_params(); + let config_params = params.clone(); + let config_key = key.clone(); + + let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), |addr| { + ClientConfig { + hmac_key: Some(config_key), + ..config_for_params(addr, &config_params) + } + }); + + match &run.observations[1] { + ServerObservation::Echo { + len, + hmac, + token, + sequence, + } => { + assert_eq!(*len, echo_packet_len(true, ¶ms)); + assert!(*hmac); + assert_eq!(*token, TOKEN); + assert_eq!(*sequence, 0); + } + other => panic!("expected echo observation, got {other:?}"), + } + + match &run.reply { + ClientEvent::EchoReply { + seq, + logical_seq, + bytes, + server_timing, + .. + } => { + assert_eq!(*seq, 0); + assert_eq!(*logical_seq, 0); + assert_eq!(*bytes, echo_packet_len(true, ¶ms)); + assert!(server_timing.is_some()); + } + other => panic!("expected EchoReply, got {other:?}"), + } + assert_no_duplicate_late_or_warning(&[run.reply]); +} + +#[test] +fn hmac_close_success_sends_authenticated_close_and_closes_session() { + let key = b"compat-secret".to_vec(); + let params = default_params(); + let server = start_hmac_close_server(params.clone(), key.clone()); + let mut config = config_for_params(server.addr, ¶ms); + config.hmac_key = Some(key); + + let mut client = Client::connect(config).unwrap(); + let outcome = client.open(ClientTimestamp::now()).unwrap(); + assert_started(outcome, ¶ms); + + let events = client.close(ClientTimestamp::now()).unwrap(); + assert!(matches!( + events.as_slice(), + [ClientEvent::SessionClosed { token: TOKEN, .. }] + )); + assert!(matches!( + client.send_probe(), + Err(ClientError::AlreadyClosed) + )); + + let observations = server.observations(2); + match &observations[1] { + ServerObservation::Close { hmac, token } => { + assert!(*hmac); + assert_eq!(*token, TOKEN); + } + other => panic!("expected close observation, got {other:?}"), + } + server.join(); +} + +#[test] +fn missing_client_key_against_hmac_required_server_times_out() { + let key = b"compat-secret".to_vec(); + let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250)); + let mut config = config_for_params(server.addr, &default_params()); + config.open_timeouts = vec![Duration::from_millis(200)]; + config.hmac_key = None; + + let mut client = Client::connect(config).unwrap(); + assert!(matches!( + client.open(ClientTimestamp::now()), + Err(ClientError::OpenTimeout) + )); + + let observations = server.observations(1); + assert!(matches!( + observations.as_slice(), + [ServerObservation::RejectedHmac { + hmac: false, + bad_hmac: true + }] + )); + server.join(); +} + +#[test] +fn wrong_client_key_against_hmac_required_server_times_out() { + let server_key = b"compat-secret".to_vec(); + let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250)); + let mut config = config_for_params(server.addr, &default_params()); + config.open_timeouts = vec![Duration::from_millis(200)]; + config.hmac_key = Some(b"wrong-secret".to_vec()); + + let mut client = Client::connect(config).unwrap(); + assert!(matches!( + client.open(ClientTimestamp::now()), + Err(ClientError::OpenTimeout) + )); + + let observations = server.observations(1); + assert!(matches!( + observations.as_slice(), + [ServerObservation::RejectedHmac { + hmac: true, + bad_hmac: true + }] + )); + server.join(); +} + +#[test] +fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() { + let key = b"compat-secret".to_vec(); + let params = default_params(); + let server = start_bad_hmac_echo_reply_server(params.clone(), key.clone()); + let mut config = config_for_params(server.addr, ¶ms); + config.hmac_key = Some(key); + config.socket_config = SocketConfig { + recv_timeout: Some(Duration::from_millis(500)), + ..Default::default() + }; + + let mut client = Client::connect(config).unwrap(); + let outcome = client.open(ClientTimestamp::now()).unwrap(); + assert_started(outcome, ¶ms); + + let sent = client.send_probe().unwrap(); + assert_eq!(sent.len(), 1); + let events = client.recv_once().unwrap(); + assert!(matches!( + events.as_slice(), + [ClientEvent::Warning { + kind: WarningKind::MalformedOrUnrelatedPacket, + .. + }] + )); + assert!(!events + .iter() + .any(|event| matches!(event, ClientEvent::EchoReply { .. }))); + + let observations = server.observations(2); + assert!(matches!( + observations.as_slice(), + [ + ServerObservation::Open { hmac: true, .. }, + ServerObservation::Echo { + hmac: true, + token: TOKEN, + sequence: 0, + .. + } + ] + )); + server.join(); +} + +#[test] +fn hmac_open_reply_with_bad_hmac_fails_with_protocol_error() { + let key = b"compat-secret".to_vec(); + let wrong_key = b"wrong-secret".to_vec(); + let params = default_params(); + let server = support::start_bad_hmac_open_reply_server(params.clone(), key.clone(), wrong_key); + let mut config = config_for_params(server.addr, ¶ms); + config.hmac_key = Some(key); + + let mut client = Client::connect(config).unwrap(); + assert!(matches!( + client.open(ClientTimestamp::now()), + Err(ClientError::Protocol(ProtoError::BadHmac)) + )); + server.join(); +} + +#[test] +fn non_hmac_client_open_does_not_set_hmac_flag() { + let params = params_for_modes( + irtt_proto::ReceivedStats::None, + irtt_proto::StampAt::None, + irtt_proto::Clock::Both, + ); + let server = support::start_open_server(params.clone(), None); + let mut config = config_for_params(server.addr, ¶ms); + config.hmac_key = None; + + let mut client = Client::connect(config).unwrap(); + let outcome = client.open(ClientTimestamp::now()).unwrap(); + assert_started(outcome, ¶ms); + + let observations = server.observations(1); + assert!(matches!( + observations.as_slice(), + [ServerObservation::Open { hmac: false, .. }] + )); + server.join(); +} + +fn assert_started(outcome: OpenOutcome, expected: &irtt_proto::Params) { + match outcome { + OpenOutcome::Started { + token, + negotiated, + event: + ClientEvent::SessionStarted { + token: event_token, + negotiated: event_negotiated, + .. + }, + .. + } => { + assert_eq!(token, TOKEN); + assert_eq!(event_token, TOKEN); + assert_eq!(negotiated.params, *expected); + assert_eq!(event_negotiated.params, *expected); + } + other => panic!("expected started open outcome, got {other:?}"), + } +} + +fn assert_no_warning_or_error(events: &[ClientEvent]) { + assert!(!events.iter().any(|event| { + matches!( + event, + ClientEvent::Warning { .. } + | ClientEvent::DuplicateReply { .. } + | ClientEvent::LateReply { .. } + ) + })); +} + +fn assert_no_duplicate_late_or_warning(events: &[ClientEvent]) { + assert_no_warning_or_error(events); +} diff --git a/crates/irtt-client/tests/support/mod.rs b/crates/irtt-client/tests/support/mod.rs index e87349a..c1a36b8 100644 --- a/crates/irtt-client/tests/support/mod.rs +++ b/crates/irtt-client/tests/support/mod.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::{ net::{SocketAddr, UdpSocket}, sync::mpsc, @@ -9,8 +11,8 @@ use irtt_client::{ Client, ClientConfig, ClientEvent, ClientTimestamp, NegotiatedParams, OpenOutcome, SocketConfig, }; use irtt_proto::{ - compute_hmac_in_place, echo_packet_len, flags, verify_hmac, Clock, Params, ReceivedStats, - ServerFill, StampAt, TimestampFields, HMAC_SIZE, MAGIC, PROTOCOL_VERSION, + compute_hmac_in_place, echo_packet_len, flags, verify_hmac, Clock, Params, ProtoError, + ReceivedStats, ServerFill, StampAt, TimestampFields, HMAC_SIZE, MAGIC, PROTOCOL_VERSION, }; const HMAC_OFFSET: usize = 4; @@ -40,12 +42,20 @@ pub enum ServerObservation { params: Params, hmac: bool, }, + RejectedHmac { + hmac: bool, + bad_hmac: bool, + }, Echo { len: usize, hmac: bool, token: u64, sequence: u32, }, + Close { + hmac: bool, + token: u64, + }, } pub struct OneProbeRun { @@ -168,6 +178,122 @@ pub fn start_open_server(params: Params, hmac_key: Option>) -> FakeServe }) } +pub fn start_hmac_required_open_drop_server(key: Vec, wait: Duration) -> FakeServer { + start_fake_server(move |socket, tx| { + socket.set_read_timeout(Some(wait)).unwrap(); + while let Some(request) = recv_request_timeout(&socket) { + let hmac = request + .get(3) + .is_some_and(|flags| flags & flags::FLAG_HMAC != 0); + match verify_hmac(&key, &request, HMAC_OFFSET) { + Ok(()) => {} + Err(error) => { + tx.send(ServerObservation::RejectedHmac { + hmac, + bad_hmac: error == ProtoError::BadHmac, + }) + .unwrap(); + } + } + } + }) +} + +pub fn start_hmac_close_server(params: Params, key: Vec) -> FakeServer { + start_fake_server(move |socket, tx| { + let (request, peer) = recv_request(&socket); + let (open_params, hmac) = decode_open_request_params(&request, Some(&key)); + tx.send(ServerObservation::Open { + params: open_params, + hmac, + }) + .unwrap(); + + let reply = open_reply( + flags::FLAG_OPEN | flags::FLAG_REPLY, + TOKEN, + ¶ms, + Some(&key), + ); + socket.send_to(&reply, peer).unwrap(); + + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (request, peer) = recv_request(&socket); + let close = observe_close_request(&request, &key); + tx.send(close).unwrap(); + + let reply = close_reply(TOKEN, &key); + socket.send_to(&reply, peer).unwrap(); + }) +} + +pub fn start_bad_hmac_echo_reply_server(params: Params, key: Vec) -> FakeServer { + start_fake_server(move |socket, tx| { + let (request, peer) = recv_request(&socket); + let (open_params, hmac) = decode_open_request_params(&request, Some(&key)); + tx.send(ServerObservation::Open { + params: open_params, + hmac, + }) + .unwrap(); + + let reply = open_reply( + flags::FLAG_OPEN | flags::FLAG_REPLY, + TOKEN, + ¶ms, + Some(&key), + ); + socket.send_to(&reply, peer).unwrap(); + + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (request, peer) = recv_request(&socket); + let echo = observe_echo_request(&request, Some(&key)); + let sequence = match echo { + ServerObservation::Echo { sequence, .. } => sequence, + _ => unreachable!(), + }; + tx.send(echo).unwrap(); + + let mut reply_packet = echo_reply_packet( + TOKEN, + sequence, + ¶ms, + &TimestampFields::default(), + Some(&key), + ); + reply_packet[HMAC_OFFSET] ^= 0xff; + socket.send_to(&reply_packet, peer).unwrap(); + }) +} + +pub fn start_bad_hmac_open_reply_server( + params: Params, + request_key: Vec, + reply_key: Vec, +) -> FakeServer { + start_fake_server(move |socket, tx| { + let (request, peer) = recv_request(&socket); + let (open_params, hmac) = decode_open_request_params(&request, Some(&request_key)); + tx.send(ServerObservation::Open { + params: open_params, + hmac, + }) + .unwrap(); + + let reply = open_reply( + flags::FLAG_OPEN | flags::FLAG_REPLY, + TOKEN, + ¶ms, + Some(&reply_key), + ); + socket.send_to(&reply, peer).unwrap(); + }) +} + pub fn server_fill(value: &str) -> Option { Some(ServerFill { value: value.to_owned(), @@ -203,7 +329,7 @@ fn start_one_probe_server( let echo = observe_echo_request(&request, hmac_key.as_deref()); let sequence = match echo { ServerObservation::Echo { sequence, .. } => sequence, - ServerObservation::Open { .. } => unreachable!(), + _ => unreachable!(), }; tx.send(echo).unwrap(); @@ -231,6 +357,14 @@ fn recv_request(socket: &UdpSocket) -> (Vec, SocketAddr) { (buf[..size].to_vec(), peer) } +fn recv_request_timeout(socket: &UdpSocket) -> Option> { + let mut buf = [0_u8; 2048]; + match socket.recv_from(&mut buf) { + Ok((size, _)) => Some(buf[..size].to_vec()), + Err(_) => None, + } +} + fn assert_started(outcome: OpenOutcome) -> NegotiatedParams { match outcome { OpenOutcome::Started { negotiated, .. } => negotiated, @@ -274,6 +408,28 @@ fn observe_echo_request(packet: &[u8], hmac_key: Option<&[u8]>) -> ServerObserva } } +fn observe_close_request(packet: &[u8], hmac_key: &[u8]) -> ServerObservation { + assert_eq!(&packet[..3], &MAGIC); + assert_eq!(packet[3] & flags::FLAG_CLOSE, flags::FLAG_CLOSE); + assert_eq!(packet[3] & flags::FLAG_HMAC, flags::FLAG_HMAC); + verify_hmac(hmac_key, packet, HMAC_OFFSET).unwrap(); + + let token_offset = 4 + HMAC_SIZE; + let token = u64::from_le_bytes(packet[token_offset..token_offset + 8].try_into().unwrap()); + + ServerObservation::Close { hmac: true, token } +} + +fn close_reply(token: u64, hmac_key: &[u8]) -> Vec { + let mut packet = Vec::new(); + packet.extend_from_slice(&MAGIC); + packet.push(flags::FLAG_REPLY | flags::FLAG_CLOSE | flags::FLAG_HMAC); + packet.extend_from_slice(&[0_u8; HMAC_SIZE]); + packet.extend_from_slice(&token.to_le_bytes()); + compute_hmac_in_place(hmac_key, &mut packet, HMAC_OFFSET).unwrap(); + packet +} + fn open_reply(flags: u8, token: u64, params: &Params, hmac_key: Option<&[u8]>) -> Vec { let mut packet = Vec::new(); packet.extend_from_slice(&MAGIC);