From 87528385c1f55d409fb864085fd1014eb51e2662 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Sun, 30 Aug 2026 15:19:56 -0600 Subject: [PATCH] Add Md5AuthString type Adds wrapper type for MD5 passwords used with TCP (RFC 2385). Md5AuthString enforces constraints around length (1-80 bytes, inclusive) as well as content (printable ASCII), with proper Errors and memory zeroization. Signed-off-by: Trey Aspelund --- CHANGELOG.md | 2 + Cargo.lock | 7 ++ Cargo.toml | 1 + all_schemas.json | 13 +++ src/lib.rs | 3 + src/md5.rs | 245 +++++++++++++++++++++++++++++++++++++++++++++ src/schema_util.rs | 1 + 7 files changed, 272 insertions(+) create mode 100644 src/md5.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6ef56..0ca2cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next +* Adds a validated MD5 authentication string type that zeroizes its key on drop + ## [0.1.7] - 2026-08-13 * Adds validated unicast link-local IPv4, IPv6, and dual-stack address types diff --git a/Cargo.lock b/Cargo.lock index 2f51d21..7726773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "zeroize", ] [[package]] @@ -622,6 +623,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 3a851c5..d610315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ ipnetwork = { version = "0.21.1", optional = true } macaddr = { version = "1.0.1", optional = true } sha1 = { version = "0.11.0", optional = true } rand = { version = "0.10.2", optional = true } +zeroize = "1.9.0" [dev-dependencies] expectorate = "1.2.0" diff --git a/all_schemas.json b/all_schemas.json index 638bbdf..163b019 100644 --- a/all_schemas.json +++ b/all_schemas.json @@ -88,6 +88,19 @@ "version": "0.1.0" } }, + "Md5AuthString": { + "title": "An MD5 authentication string", + "description": "A nonempty printable ASCII string of at most 80 bytes", + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[ -~]+$", + "x-rust-type": { + "crate": "oxnet", + "path": "oxnet::Md5AuthString", + "version": "0.1.8" + } + }, "UnicastLinkLocalIpAddr": { "oneOf": [ { diff --git a/src/lib.rs b/src/lib.rs index 3020301..5c3418d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod ipaddr; mod ipnet; +mod md5; mod multicast; #[cfg(feature = "schemars")] mod schema_util; @@ -24,6 +25,8 @@ pub use ipaddr::{ #[cfg(feature = "ula")] pub use ipnet::{UlaBuildError, UlaBuilder}; +pub use md5::{Md5AuthString, Md5AuthStringError}; + pub use multicast::MulticastMac; pub use sockaddr::{SocketAddrJson, SocketAddrV4Json, SocketAddrV6Json}; diff --git a/src/md5.rs b/src/md5.rs new file mode 100644 index 0000000..875438f --- /dev/null +++ b/src/md5.rs @@ -0,0 +1,245 @@ +// Copyright 2026 Oxide Computer Company + +use std::hash::{Hash, Hasher}; +use zeroize::{ZeroizeOnDrop, Zeroizing}; + +/// An MD5 authentication key represented as a printable ASCII string. +/// +/// The key contains between 1 and 80 bytes, inclusive, and every byte is in +/// the printable ASCII range (`0x20..=0x7e`). This follows the recommendation +/// for TCP MD5 keys in RFC 2385 section 4.5. +/// +/// The [`Debug`](std::fmt::Debug) implementation redacts the key, and its +/// allocation is zeroized when the value is dropped. Converting it into a +/// [`String`] transfers responsibility for zeroizing that allocation to the +/// caller. Its serialized representation contains the key as a plain string. +#[derive(Clone, Eq, PartialEq)] +pub struct Md5AuthString(Zeroizing); + +impl Md5AuthString { + /// Maximum key length in bytes. + pub const MAX_LEN: usize = 80; + + /// Creates an MD5 authentication string after validating its contents. + pub fn new(source: String) -> Result { + let source = Zeroizing::new(source); + + if source.is_empty() { + return Err(Md5AuthStringError::Empty); + } + + if source.len() > Self::MAX_LEN { + return Err(Md5AuthStringError::TooLong { len: source.len() }); + } + + if !source.chars().all(|c| c.is_ascii_graphic() || c == ' ') { + return Err(Md5AuthStringError::NotPrintableAscii); + } + + Ok(Self(source)) + } + + /// Returns the key as a byte slice. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + /// Returns the key as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the underlying string, transferring responsibility for + /// zeroizing it to the caller. + pub fn into_inner(mut self) -> String { + std::mem::take(&mut *self.0) + } +} + +impl Hash for Md5AuthString { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); + } +} + +impl ZeroizeOnDrop for Md5AuthString {} + +impl TryFrom for Md5AuthString { + type Error = Md5AuthStringError; + + fn try_from(source: String) -> Result { + Self::new(source) + } +} + +impl From for String { + fn from(source: Md5AuthString) -> Self { + source.into_inner() + } +} + +impl std::fmt::Debug for Md5AuthString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Md5AuthString()") + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Md5AuthString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let source = ::deserialize(deserializer)?; + Self::new(source).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Md5AuthString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for Md5AuthString { + fn schema_name() -> String { + "Md5AuthString".to_string() + } + + fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + metadata: Some(Box::new(schemars::schema::Metadata { + title: Some("An MD5 authentication string".to_string()), + description: Some( + "A nonempty printable ASCII string of at most 80 bytes".to_string(), + ), + ..Default::default() + })), + instance_type: Some(schemars::schema::InstanceType::String.into()), + string: Some(Box::new(schemars::schema::StringValidation { + max_length: Some(Self::MAX_LEN as u32), + min_length: Some(1), + pattern: Some(r"^[ -~]+$".to_string()), + })), + extensions: crate::schema_util::extension("Md5AuthString", "0.1.8"), + ..Default::default() + } + .into() + } +} + +impl std::error::Error for Md5AuthStringError {} + +/// An error returned when an MD5 authentication string violates its required +/// invariants. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Md5AuthStringError { + /// The string is empty. + Empty, + /// The string exceeds [`Md5AuthString::MAX_LEN`] bytes. + TooLong { + /// The actual string length in bytes. + len: usize, + }, + /// The string contains a byte outside the printable ASCII range. + NotPrintableAscii, +} + +impl std::fmt::Display for Md5AuthStringError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(f, "MD5 auth string must not be empty"), + Self::TooLong { len } => write!( + f, + "MD5 auth string length must be <= {}, found {len}", + Md5AuthString::MAX_LEN + ), + Self::NotPrintableAscii => write!( + f, + "MD5 auth string must be fully comprised of printable ASCII characters" + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_printable_ascii_within_length_limit() { + for byte in b' '..=b'~' { + let source = char::from(byte).to_string(); + assert_eq!(Md5AuthString::new(source.clone()).unwrap().as_str(), source); + } + + let source = "x".repeat(Md5AuthString::MAX_LEN); + let key = Md5AuthString::new(source.clone()).unwrap(); + assert_eq!(key.as_str(), source); + assert_eq!(key.as_bytes(), source.as_bytes()); + assert_eq!(String::from(key), source); + } + + #[test] + fn rejects_strings_outside_invariants() { + assert_eq!( + Md5AuthString::new(String::new()), + Err(Md5AuthStringError::Empty) + ); + + let len = Md5AuthString::MAX_LEN + 1; + assert_eq!( + Md5AuthString::new("x".repeat(len)), + Err(Md5AuthStringError::TooLong { len }) + ); + + for source in ["line\nfeed", "tab\tkey", "nul\0key", "non-ASCII-é"] { + assert_eq!( + Md5AuthString::new(source.to_string()), + Err(Md5AuthStringError::NotPrintableAscii) + ); + } + } + + #[test] + fn debug_redacts_inner_string() { + let key = Md5AuthString::new("super secret".to_string()).unwrap(); + assert_eq!(format!("{key:?}"), "Md5AuthString()"); + } + + #[cfg(all(feature = "serde", feature = "schemars"))] + #[test] + fn serde_round_trip_preserves_invariants() { + let key = Md5AuthString::new("secret key".to_string()).unwrap(); + let json = serde_json::to_string(&key).unwrap(); + assert_eq!(json, r#""secret key""#); + assert_eq!(serde_json::from_str::(&json).unwrap(), key); + + assert!(serde_json::from_str::(r#"""#).is_err()); + assert!(serde_json::from_str::(r#""line\nfeed""#).is_err()); + } + + #[cfg(feature = "schemars")] + #[test] + fn json_schema_matches_invariants() { + let schema = schemars::schema_for!(Md5AuthString); + let validation = schema.schema.string.expect("string validation"); + + assert_eq!(validation.min_length, Some(1)); + assert_eq!(validation.max_length, Some(Md5AuthString::MAX_LEN as u32)); + assert_eq!(validation.pattern.as_deref(), Some(r"^[ -~]+$")); + assert_eq!( + schema.schema.extensions.get("x-rust-type"), + Some(&serde_json::json!({ + "crate": "oxnet", + "version": "0.1.8", + "path": "oxnet::Md5AuthString", + })) + ); + } +} diff --git a/src/schema_util.rs b/src/schema_util.rs index 513e453..f375d88 100644 --- a/src/schema_util.rs +++ b/src/schema_util.rs @@ -80,6 +80,7 @@ mod tests { let _ = gen.subschema_for::(); let _ = gen.subschema_for::(); let _ = gen.subschema_for::(); + let _ = gen.subschema_for::(); /// Object to validate types with inlined schemas. #[derive(schemars::JsonSchema)]