refactor(client): make authentication explicit and redact HMAC keys

This commit is contained in:
Nils Andreas Svee committed 2026-10-04 16:45:59 +07:00
1 parent 9c703ecff2
commit a2f70c4f06
18 files changed
+492 -119

No files matched your search

@@ -0,0 +1,96 @@
use std::{net::UdpSocket, thread, time::Duration};
use irtt_client::{Authentication, Client, ClientConfig, ClientEvent, HmacKey, SocketConfig};
use irtt_proto::{decode_request, verify_packet_hmac};
use irtt_server::{ServerConfig, ServerCore};
// Exercise the runtime-free API against the production server core, including
// the wire HMAC flag on Open, Echo and Close for an explicitly empty key.
#[test]
fn concrete_authentication_preserves_session_and_empty_key_behavior() {
for key in [None, Some(b"session-test-key".as_slice()), Some(&[])] {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let addr = socket.local_addr().unwrap();
let server = thread::spawn(move || {
let config = key.map_or_else(ServerConfig::default, |key| {
ServerConfig::default().with_hmac_key(key)
});
let mut core = ServerCore::new(config);
let mut buffer = [0; 2048];
for _ in 0..3 {
let (len, peer) = socket.recv_from(&mut buffer).unwrap();
let packet = &buffer[..len];
assert_eq!(decode_request(packet).unwrap().hmac_present, key.is_some());
if let Some(key) = key {
verify_packet_hmac(key, packet).unwrap();
}
if let Some(reply) = core.handle_datagram(peer, packet).unwrap() {
socket.send_to(reply.bytes(), peer).unwrap();
}
}
assert_eq!(core.session_count(), 0, "Close must release the session");
});
let mut client = Client::connect(ClientConfig {
server_addr: addr.to_string(),
auth: key.map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key))
}),
open_timeouts: vec![Duration::from_millis(200)],
socket_config: SocketConfig {
recv_timeout: Some(Duration::from_secs(1)),
..SocketConfig::default()
},
..ClientConfig::default()
})
.unwrap();
client.open().unwrap();
client.send_probe().unwrap();
assert!(client
.recv_once()
.unwrap()
.iter()
.any(|event| { matches!(event, ClientEvent::EchoReply { .. }) }));
client.close().unwrap();
server.join().unwrap();
}
}
#[test]
fn authentication_debug_redacts_keys_in_enclosing_configurations() {
let sentinel = b"CONSPICUOUS_HMAC_SECRET_DO_NOT_PRINT";
let key = HmacKey::new(sentinel.as_slice());
let auth = Authentication::Hmac(key.clone());
let config = ClientConfig {
auth: auth.clone(),
..ClientConfig::default()
};
let mut outputs = vec![
format!("{key:?}"),
format!("{auth:?}"),
format!("{config:?}"),
];
#[cfg(feature = "tokio")]
{
use irtt_client::managed::{ManagedClientConfig, ManagedTargetConfig, TargetAuth};
let mut target = ManagedTargetConfig::new("one", "127.0.0.1:2112");
target.auth = TargetAuth::Override(auth);
outputs.push(format!("{target:?}"));
outputs.push(format!(
"{:?}",
ManagedClientConfig {
client: config,
..ManagedClientConfig::default()
}
));
}
// Check both textual and ordinary byte-slice Debug encodings so replacing
// the opaque formatter with a derived formatter cannot slip through.
for output in outputs.drain(..) {
assert!(!output.contains(std::str::from_utf8(sentinel).unwrap()));
assert!(!output.contains(&format!("{sentinel:?}")));
assert!(output.contains("[REDACTED]"));
}
}
+4 -3
View File
@@ -1,10 +1,11 @@
use super::*;
use irtt_client::{Authentication, HmacKey};
#[test]
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
let key = b"secret".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key.clone());
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
@@ -26,7 +27,7 @@ fn hmac_open_ignores_bad_hmac_before_valid_reply() {
let key = b"secret".to_vec();
let wrong_key = b"wrong".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key.clone());
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
@@ -47,7 +48,7 @@ fn hmac_open_ignores_bad_hmac_before_valid_reply() {
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
let key = b"secret".to_vec();
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.hmac_key = Some(key.clone());
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
let mut returned = default_params();
returned.interval_ns += 1;
let server_key = key.clone();
@@ -0,0 +1,212 @@
#![cfg(feature = "tokio")]
use std::{collections::HashSet, time::Duration};
use irtt_client::{managed::*, Authentication, ClientConfig, ClientEvent, HmacKey};
use irtt_server::{Server, ServerConfig};
fn config(auth: Authentication) -> ManagedClientConfig {
ManagedClientConfig {
client: ClientConfig {
auth,
duration: None,
interval: Duration::from_millis(20),
probe_timeout: Duration::from_millis(100),
open_timeouts: vec![Duration::from_millis(200)],
..ClientConfig::default()
},
completion: ManagedCompletionPolicy::ExplicitStop,
final_drain: Duration::ZERO,
..ManagedClientConfig::default()
}
}
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
#[test]
fn managed_authentication_inherits_disables_and_overrides_on_creation_and_update() {
runtime().block_on(async {
tokio::time::timeout(Duration::from_secs(5), async {
let shared = Authentication::Hmac(HmacKey::new(b"shared-test-key".as_slice()));
let replacement =
Authentication::Hmac(HmacKey::new(b"replacement-test-key".as_slice()));
let empty = Authentication::Hmac(HmacKey::new(Vec::new()));
let mut targets = Vec::new();
let mut servers = Vec::new();
for (id, key, auth) in [
(
"inherit",
Some(b"shared-test-key".as_slice()),
TargetAuth::Inherit,
),
(
"disable",
None,
TargetAuth::Override(Authentication::Unauthenticated),
),
(
"replace",
Some(b"replacement-test-key".as_slice()),
TargetAuth::Override(replacement),
),
("empty", Some(&[]), TargetAuth::Override(empty)),
] {
let server_config = key.map_or_else(ServerConfig::default, |key| {
ServerConfig::default().with_hmac_key(key)
});
let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), server_config)
.await
.unwrap();
let mut target =
ManagedTargetConfig::new(id, server.local_addr().unwrap().to_string());
target.auth = auth;
targets.push(target);
servers.push(tokio::spawn(async move {
server.run(std::future::pending()).await
}));
}
for initial in [true, false] {
let (task, handle) = ManagedClient::task(
config(shared.clone()),
if initial { targets.clone() } else { vec![] },
)
.unwrap();
let mut events = handle.subscribe().unwrap();
let run = tokio::spawn(task);
if !initial {
handle
.update_targets(targets.clone())
.unwrap()
.await
.unwrap();
}
let mut replied = HashSet::new();
while replied.len() < targets.len() {
if let ManagedEvent::Client {
target,
event: ClientEvent::EchoReply { .. },
} = events.recv().await.unwrap()
{
replied.insert(target.id);
}
}
handle.stop().await;
let outcome = run.await.unwrap();
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
assert_eq!(outcome.successful_target_outcomes, targets.len() as u64);
assert!(outcome.recent_target_outcomes.iter().all(|target| {
target.replies_received > 0 && target.cleanup_failure.is_none()
}));
}
for server in servers {
server.abort();
assert!(server.await.unwrap_err().is_cancelled());
}
})
.await
.expect("every managed authentication mode must receive replies");
});
}
#[test]
fn changing_auth_specification_replaces_generation_even_when_resolved_auth_is_equal() {
runtime().block_on(async {
tokio::time::timeout(Duration::from_secs(5), async {
for auth in [
Authentication::Unauthenticated,
Authentication::Hmac(HmacKey::new(b"shared-test-key".as_slice())),
] {
let server_config = match &auth {
Authentication::Unauthenticated => ServerConfig::default(),
Authentication::Hmac(key) => {
ServerConfig::default().with_hmac_key(key.as_bytes())
}
};
let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), server_config)
.await
.unwrap();
let mut configured =
ManagedTargetConfig::new("one", server.local_addr().unwrap().to_string());
let service = tokio::spawn(async move { server.run(std::future::pending()).await });
let (task, handle) =
ManagedClient::task(config(auth.clone()), vec![configured.clone()]).unwrap();
let mut events = handle.subscribe().unwrap();
let run = tokio::spawn(task);
let mut current = handle.status().targets[0].target.clone();
// Wait for a real reply before changing the running session.
loop {
if matches!(events.recv().await.unwrap(), ManagedEvent::Client {
target, event: ClientEvent::EchoReply { .. }
} if target == current)
{
break;
}
}
for specification in [TargetAuth::Override(auth.clone()), TargetAuth::Inherit] {
configured.auth = specification;
let applied = handle
.update_targets(vec![configured.clone()])
.unwrap()
.await
.unwrap();
let next = applied
.status
.targets
.iter()
.find(|target| target.desired)
.unwrap()
.target
.clone();
assert!(next.generation > current.generation);
let mut retired = false;
let mut replied = false;
while !retired || !replied {
match events.recv().await.unwrap() {
ManagedEvent::TargetFinished { outcome }
if outcome.target == current =>
{
assert_eq!(outcome.end_reason, ManagedTargetEndReason::Replaced);
assert!(outcome.cleanup_failure.is_none());
retired = true;
}
ManagedEvent::Client {
target,
event: ClientEvent::EchoReply { .. },
} if target == next => {
replied = true;
}
_ => {}
}
}
let identical = handle
.update_targets(vec![configured.clone()])
.unwrap()
.await
.unwrap();
assert_eq!(
identical
.status
.targets
.iter()
.find(|target| target.desired)
.unwrap()
.target,
next
);
current = next;
}
handle.stop().await;
assert_eq!(run.await.unwrap().successful_target_outcomes, 3);
service.abort();
assert!(service.await.unwrap_err().is_cancelled());
}
})
.await
.expect("auth specification changes must replace and reopen target generations");
});
}