feat(app): allow hexadecimal HMAC key specification and redact keys from debug output
This commit is contained in:
14 files changed
+72
-40
No files matched your search
Generated
+1
@@ -832,6 +832,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"hex",
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ clap = { version = "4.6.7", features = ["derive", "env"] }
|
|||||||
clap-num = "=1.2.0"
|
clap-num = "=1.2.0"
|
||||||
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
||||||
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
||||||
|
hex = "0.4.3"
|
||||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||||
irtt-client = { workspace = true, optional = true }
|
irtt-client = { workspace = true, optional = true }
|
||||||
irtt-proto = { workspace = true, optional = true }
|
irtt-proto = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub struct ClientArgs {
|
|||||||
value_name = "TARGET",
|
value_name = "TARGET",
|
||||||
num_args = 0..,
|
num_args = 0..,
|
||||||
value_parser = parse_target,
|
value_parser = parse_target,
|
||||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
||||||
)]
|
)]
|
||||||
pub targets: Vec<TargetArg>,
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const DEFAULT_PORT: u16 = 2112;
|
|||||||
// Every policy option is optional. An omitted option leaves the corresponding
|
// Every policy option is optional. An omitted option leaves the corresponding
|
||||||
// `ServerConfig` default in place rather than restating it here, so library and
|
// `ServerConfig` default in place rather than restating it here, so library and
|
||||||
// CLI defaults cannot drift apart.
|
// CLI defaults cannot drift apart.
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Clone, Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "irtt-server",
|
name = "irtt-server",
|
||||||
about = "Minimal IRTT-compatible UDP server",
|
about = "Minimal IRTT-compatible UDP server",
|
||||||
@@ -28,13 +28,14 @@ pub struct ServerArgs {
|
|||||||
)]
|
)]
|
||||||
pub bind: Vec<SocketAddr>,
|
pub bind: Vec<SocketAddr>,
|
||||||
|
|
||||||
|
// Decode during configuration preparation so Clap cannot echo rejected keys.
|
||||||
/// HMAC key; every request must then carry a valid MAC.
|
/// HMAC key; every request must then carry a valid MAC.
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "KEY",
|
value_name = "KEY",
|
||||||
value_parser = parse_hmac_key,
|
|
||||||
env = "IRTT_SERVER_HMAC",
|
env = "IRTT_SERVER_HMAC",
|
||||||
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, or in the environment as IRTT_SERVER_HMAC."
|
hide_env_values = true,
|
||||||
|
long_help = "HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes (digits may use either case). Empty keys and invalid hex are rejected.\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, or in the environment as IRTT_SERVER_HMAC."
|
||||||
)]
|
)]
|
||||||
pub hmac: Option<String>,
|
pub hmac: Option<String>,
|
||||||
|
|
||||||
@@ -126,6 +127,23 @@ pub struct ServerArgs {
|
|||||||
pub no_dscp: bool,
|
pub no_dscp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ServerArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ServerArgs")
|
||||||
|
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||||
|
.field("bind", &self.bind)
|
||||||
|
.field("max_sessions", &self.max_sessions)
|
||||||
|
.field("max_packet_length", &self.max_packet_length)
|
||||||
|
.field("min_interval", &self.min_interval)
|
||||||
|
.field("burst", &self.burst)
|
||||||
|
.field("idle_timeout", &self.idle_timeout)
|
||||||
|
.field("max_duration", &self.max_duration)
|
||||||
|
.field("timestamp_allowance", &self.timestamp_allowance)
|
||||||
|
.field("no_dscp", &self.no_dscp)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The command-line spelling of [`TimestampAllowance`].
|
/// The command-line spelling of [`TimestampAllowance`].
|
||||||
///
|
///
|
||||||
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
|
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
|
||||||
@@ -166,11 +184,10 @@ impl ServerArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the server configuration this invocation asks for.
|
/// Builds the server configuration this invocation asks for.
|
||||||
#[must_use]
|
pub fn server_config(&self) -> Result<ServerConfig, String> {
|
||||||
pub fn server_config(&self) -> ServerConfig {
|
|
||||||
let mut config = ServerConfig::default();
|
let mut config = ServerConfig::default();
|
||||||
if let Some(key) = self.hmac.as_ref() {
|
if let Some(key) = self.hmac.as_ref() {
|
||||||
config = config.with_hmac_key(key.as_bytes());
|
config = config.with_hmac_key(crate::shared::decode_hmac_key(key)?);
|
||||||
}
|
}
|
||||||
if let Some(value) = self.max_sessions {
|
if let Some(value) = self.max_sessions {
|
||||||
config = config.with_max_sessions(value);
|
config = config.with_max_sessions(value);
|
||||||
@@ -198,19 +215,10 @@ impl ServerArgs {
|
|||||||
if self.no_dscp {
|
if self.no_dscp {
|
||||||
config = config.with_dscp_allowed(false);
|
config = config.with_dscp_allowed(false);
|
||||||
}
|
}
|
||||||
config
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<String, String> {
|
|
||||||
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.
|
/// Parses a duration whose zero is meaningful server policy.
|
||||||
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
||||||
if input == "0" {
|
if input == "0" {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async fn serve(
|
|||||||
args: ServerArgs,
|
args: ServerArgs,
|
||||||
shutdown_requested: &AtomicBool,
|
shutdown_requested: &AtomicBool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = args.server_config();
|
let config = args.server_config()?;
|
||||||
let binds = args.resolve_binds();
|
let binds = args.resolve_binds();
|
||||||
let is_implicit_default = args.bind.is_empty();
|
let is_implicit_default = args.bind.is_empty();
|
||||||
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub struct TuiArgs {
|
|||||||
num_args = 1..,
|
num_args = 1..,
|
||||||
required = true,
|
required = true,
|
||||||
value_parser = parse_target,
|
value_parser = parse_target,
|
||||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
||||||
)]
|
)]
|
||||||
pub targets: Vec<TargetArg>,
|
pub targets: Vec<TargetArg>,
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ pub struct CommonClientArgs {
|
|||||||
#[arg(long, default_value_t = 0, value_parser = parse_length)]
|
#[arg(long, default_value_t = 0, value_parser = parse_length)]
|
||||||
pub length: u32,
|
pub length: u32,
|
||||||
|
|
||||||
/// HMAC key.
|
// Decode during configuration preparation so Clap cannot echo rejected keys.
|
||||||
#[arg(long, value_parser = parse_hmac_key)]
|
/// HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes.
|
||||||
|
#[arg(long)]
|
||||||
pub hmac: Option<String>,
|
pub hmac: Option<String>,
|
||||||
|
|
||||||
/// Clock mode to request.
|
/// Clock mode to request.
|
||||||
@@ -267,13 +268,6 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
|
|||||||
Ok(length)
|
Ok(length)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
|
|
||||||
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<String, String> {
|
pub fn parse_server_fill(input: &str) -> Result<String, String> {
|
||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
return Err("server fill must not be empty".to_owned());
|
return Err("server fill must not be empty".to_owned());
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ impl CommonClientArgs {
|
|||||||
/// Build the shared client configuration template for a managed run.
|
/// Build the shared client configuration template for a managed run.
|
||||||
///
|
///
|
||||||
/// The config is endpoint-independent; managed targets own their addresses.
|
/// The config is endpoint-independent; managed targets own their addresses.
|
||||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
pub fn to_client_config(&self, duration: Duration) -> Result<ClientConfig, String> {
|
||||||
ClientConfig {
|
Ok(ClientConfig {
|
||||||
address_family: self.address_family(),
|
address_family: self.address_family(),
|
||||||
open: OpenPolicy {
|
open: OpenPolicy {
|
||||||
negotiation: if self.loose {
|
negotiation: if self.loose {
|
||||||
@@ -45,9 +45,11 @@ impl CommonClientArgs {
|
|||||||
},
|
},
|
||||||
auth: self
|
auth: self
|
||||||
.hmac
|
.hmac
|
||||||
.as_ref()
|
.as_deref()
|
||||||
|
.map(crate::shared::decode_hmac_key)
|
||||||
|
.transpose()?
|
||||||
.map_or(Authentication::Unauthenticated, |key| {
|
.map_or(Authentication::Unauthenticated, |key| {
|
||||||
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
Authentication::Hmac(HmacKey::new(key))
|
||||||
}),
|
}),
|
||||||
socket: SocketConfig {
|
socket: SocketConfig {
|
||||||
bind_addr: self.bind_addr,
|
bind_addr: self.bind_addr,
|
||||||
@@ -61,7 +63,7 @@ impl CommonClientArgs {
|
|||||||
..SocketConfig::default()
|
..SocketConfig::default()
|
||||||
},
|
},
|
||||||
..ClientConfig::default()
|
..ClientConfig::default()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ pub async fn prepare_managed_run(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(ManagedRunSetup {
|
Ok(ManagedRunSetup {
|
||||||
targets,
|
targets,
|
||||||
client: common.to_client_config(duration),
|
client: common.to_client_config(duration)?,
|
||||||
pacing: selection.pacing.into(),
|
pacing: selection.pacing.into(),
|
||||||
stdin_controlled: selection.stdin_controlled,
|
stdin_controlled: selection.stdin_controlled,
|
||||||
preparation,
|
preparation,
|
||||||
|
|||||||
@@ -88,12 +88,13 @@ impl From<GroupPacingArg> for ManagedPacing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum TargetParseError {
|
enum TargetParseError {
|
||||||
EmptyLabel,
|
EmptyLabel,
|
||||||
EmptyAddress,
|
EmptyAddress,
|
||||||
InvalidEscape,
|
InvalidEscape,
|
||||||
EmptyTarget,
|
EmptyTarget,
|
||||||
|
InvalidHmac(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for TargetParseError {
|
impl fmt::Display for TargetParseError {
|
||||||
@@ -103,6 +104,7 @@ impl fmt::Display for TargetParseError {
|
|||||||
Self::EmptyAddress => "target address must not be empty",
|
Self::EmptyAddress => "target address must not be empty",
|
||||||
Self::InvalidEscape => "invalid target escape sequence",
|
Self::InvalidEscape => "invalid target escape sequence",
|
||||||
Self::EmptyTarget => "target must not be empty",
|
Self::EmptyTarget => "target must not be empty",
|
||||||
|
Self::InvalidHmac(message) => message,
|
||||||
};
|
};
|
||||||
f.write_str(message)
|
f.write_str(message)
|
||||||
}
|
}
|
||||||
@@ -197,7 +199,10 @@ fn parse_target_syntax(
|
|||||||
let auth = if value.is_empty() {
|
let auth = if value.is_empty() {
|
||||||
TargetAuth::Override(Authentication::Unauthenticated)
|
TargetAuth::Override(Authentication::Unauthenticated)
|
||||||
} else {
|
} else {
|
||||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
|
TargetAuth::Override(Authentication::Hmac(HmacKey::new(
|
||||||
|
crate::shared::decode_hmac_key(&value)
|
||||||
|
.map_err(TargetParseError::InvalidHmac)?,
|
||||||
|
)))
|
||||||
};
|
};
|
||||||
(&input[..index], auth)
|
(&input[..index], auth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/// Decode CLI key syntax without ever including key material in diagnostics.
|
||||||
|
pub(crate) fn decode_hmac_key(input: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let key = match input.strip_prefix("0x") {
|
||||||
|
Some(hex) => hex::decode(hex).map_err(|_| {
|
||||||
|
"HMAC hexadecimal key must contain an even number of valid hexadecimal digits"
|
||||||
|
.to_owned()
|
||||||
|
})?,
|
||||||
|
None => input.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("HMAC key must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
#[cfg(any(feature = "client", feature = "tui"))]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||||
|
mod hmac;
|
||||||
|
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||||
|
pub(crate) use hmac::decode_hmac_key;
|
||||||
+3
-2
@@ -37,12 +37,13 @@ overrides it for that target, and `@hmac=` explicitly disables HMAC for that
|
|||||||
target. The modifier uses `@`, not a shell command separator; quote an argument
|
target. The modifier uses `@`, not a shell command separator; quote an argument
|
||||||
if its key needs shell quoting.
|
if its key needs shell quoting.
|
||||||
|
|
||||||
|
Global `--hmac KEY` and per-target `@hmac=KEY` accept plain-text keys or hexadecimal keys prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent.
|
||||||
|
|
||||||
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
Target syntax supports these backslash escapes in labels, addresses, and HMAC
|
||||||
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
||||||
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
||||||
label or address when it would otherwise begin `@hmac=`; escaping the first
|
label or address when it would otherwise begin `@hmac=`; escaping the first
|
||||||
`=` is how an unlabeled target or a label can contain that character. An `=` in
|
`=` is how an unlabeled target or a label can contain that character. An `=` in an HMAC value does not need escaping. Positional
|
||||||
an HMAC value, including Base64 padding, does not need escaping. Positional
|
|
||||||
target arguments may contain a raw comma; stdin target sets have the additional
|
target arguments may contain a raw comma; stdin target sets have the additional
|
||||||
`\,` escape described below.
|
`\,` escape described below.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -74,8 +74,9 @@ IPv6 listener.
|
|||||||
|
|
||||||
### `--hmac KEY`
|
### `--hmac KEY`
|
||||||
|
|
||||||
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured,
|
Accepts a plain-text key or a hexadecimal key prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent. The `IRTT_SERVER_HMAC` environment variable uses the same syntax.
|
||||||
every request must carry a valid MAC and every reply is authenticated;
|
|
||||||
|
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
|
without one, authenticated requests are dropped. The key is visible in the
|
||||||
process arguments.
|
process arguments.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user