diff --git a/crates/irtt-cli/src/cmd/client/args.rs b/crates/irtt-cli/src/cmd/client/args.rs index 70bceb3..6f04f08 100644 --- a/crates/irtt-cli/src/cmd/client/args.rs +++ b/crates/irtt-cli/src/cmd/client/args.rs @@ -296,6 +296,19 @@ mod tests { assert!(help.contains("--loose")); } + #[test] + fn an_empty_hmac_key_is_rejected() { + let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err(); + assert!(err.to_string().contains("HMAC key must not be empty")); + } + + #[test] + fn a_non_empty_hmac_key_is_accepted() { + let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap(); + let config = args.prepare().unwrap().client; + assert_eq!(config.hmac_key, Some(b"secret".to_vec())); + } + #[test] fn output_format_summary_policy_is_table_only() { assert!(OutputFormat::Table.prints_summary()); diff --git a/crates/irtt-cli/src/cmd/server/args.rs b/crates/irtt-cli/src/cmd/server/args.rs index 9110998..174715a 100644 --- a/crates/irtt-cli/src/cmd/server/args.rs +++ b/crates/irtt-cli/src/cmd/server/args.rs @@ -26,6 +26,7 @@ pub struct ServerArgs { #[arg( long, value_name = "KEY", + value_parser = parse_hmac_key, long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments." )] pub hmac: Option, @@ -160,6 +161,15 @@ impl ServerArgs { } } +/// Rejects an empty HMAC key so a blank shell expansion cannot silently +/// enable authentication with predictable, empty key material. +fn parse_hmac_key(input: &str) -> Result { + if input.is_empty() { + return Err("HMAC key must not be empty".to_owned()); + } + Ok(input.to_owned()) +} + /// Parses a duration whose zero is meaningful server policy. fn parse_duration_allow_zero(input: &str) -> Result { if input == "0" { @@ -404,6 +414,18 @@ mod tests { assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err()); } + #[test] + fn an_empty_hmac_key_is_rejected() { + let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err(); + assert!(err.to_string().contains("HMAC key must not be empty")); + } + + #[test] + fn a_whitespace_only_hmac_key_is_accepted_literally() { + let config = bound(&["--hmac", " "]).server_config(); + assert_eq!(config.hmac_key(), Some(b" ".as_slice())); + } + #[test] fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() { assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err()); diff --git a/crates/irtt-cli/src/cmd/tui/args.rs b/crates/irtt-cli/src/cmd/tui/args.rs index 31e640b..8e1320c 100644 --- a/crates/irtt-cli/src/cmd/tui/args.rs +++ b/crates/irtt-cli/src/cmd/tui/args.rs @@ -270,6 +270,14 @@ mod tests { assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl); } + #[test] + fn an_empty_hmac_key_is_rejected_via_the_shared_args() { + // CommonClientArgs is flattened into both the client and TUI parsers, + // so proving rejection once here covers both without duplicating it. + let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err(); + assert!(err.to_string().contains("HMAC key must not be empty")); + } + #[test] fn tui_help_lists_multi_target_options() { let help = TuiArgs::command().render_help().to_string(); diff --git a/crates/irtt-cli/src/shared/client/args.rs b/crates/irtt-cli/src/shared/client/args.rs index 6f0df09..b705dbc 100644 --- a/crates/irtt-cli/src/shared/client/args.rs +++ b/crates/irtt-cli/src/shared/client/args.rs @@ -15,7 +15,7 @@ pub struct CommonClientArgs { pub length: u32, /// HMAC key. - #[arg(long)] + #[arg(long, value_parser = parse_hmac_key)] pub hmac: Option, /// Clock mode to request. @@ -189,6 +189,13 @@ pub fn parse_length(input: &str) -> Result { Ok(length) } +pub fn parse_hmac_key(input: &str) -> Result { + if input.is_empty() { + return Err("HMAC key must not be empty".to_owned()); + } + Ok(input.to_owned()) +} + pub fn parse_server_fill(input: &str) -> Result { if input.is_empty() { return Err("server fill must not be empty".to_owned());