From f20aa0380c81d917729a5d11c0d2b7cb79cdf38c Mon Sep 17 00:00:00 2001 From: jrb0001 Date: Sat, 30 May 2026 12:20:02 +0200 Subject: [PATCH 1/2] feat(#433): EAP-TLS and WPA3-EAP 192bit mode --- nmrs/src/api/builders/wifi.rs | 116 +++++++++- nmrs/src/api/builders/wifi_builder.rs | 93 ++++++-- nmrs/src/api/models/tests.rs | 171 ++++++++++++-- nmrs/src/api/models/wifi.rs | 306 ++++++++++++++++++++++++-- nmrs/src/util/validation.rs | 296 +++++++++++++++++++++++-- 5 files changed, 915 insertions(+), 67 deletions(-) diff --git a/nmrs/src/api/builders/wifi.rs b/nmrs/src/api/builders/wifi.rs index 1b44a2ff..b6f0f760 100644 --- a/nmrs/src/api/builders/wifi.rs +++ b/nmrs/src/api/builders/wifi.rs @@ -72,6 +72,7 @@ pub fn build_wifi_connection( models::WifiSecurity::Open => builder.open(), models::WifiSecurity::WpaPsk { psk } => builder.wpa_psk(psk), models::WifiSecurity::WpaEap { opts } => builder.wpa_eap(opts.clone()), + models::WifiSecurity::Wpa3Eap192bit { opts } => builder.wpa3_eap_192_bit(opts.clone()), }; builder.build() @@ -190,9 +191,15 @@ mod tests { anonymous_identity: Some("anonymous@example.com".into()), domain_suffix_match: Some("example.com".into()), ca_cert_path: None, + ca_cert_blob: None, system_ca_certs: true, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, }; let conn = build_wifi_connection( "enterprise", @@ -227,9 +234,15 @@ mod tests { anonymous_identity: None, domain_suffix_match: None, ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".into()), + ca_cert_blob: None, system_ca_certs: false, method: EapMethod::Ttls, phase2: Phase2::Pap, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, }; let conn = build_wifi_connection( "eduroam", @@ -241,7 +254,108 @@ mod tests { assert_eq!(e1x.get("phase2-auth"), Some(&Value::from("pap"))); assert_eq!( e1x.get("ca-cert"), - Some(&Value::from("file:///etc/ssl/certs/ca.pem".to_string())) + Some(&Value::from("file:///etc/ssl/certs/ca.pem")) + ); + // system-ca-certs should NOT be present when false + assert!(e1x.get("system-ca-certs").is_none()); + } + + #[test] + fn builds_eap_tls_connection() { + let eap_opts = EapOptions { + identity: "student@uni.edu".into(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: None, + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".into()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("file:///etc/ssl/private/client.key".into()), + private_key_blob: None, + private_key_password: Some("password".into()), + client_cert_path: Some("file:///etc/ssl/certs/client.crt".into()), + client_cert_blob: None, + }; + let conn = build_wifi_connection( + "eduroam", + &WifiSecurity::WpaEap { opts: eap_opts }, + &default_opts(), + ); + + let security = conn.get("802-11-wireless-security").unwrap(); + assert_eq!(security.get("key-mgmt"), Some(&Value::from("wpa-eap"))); + + let e1x = conn.get("802-1x").unwrap(); + assert_eq!(e1x.get("phase2-auth"), None); + assert_eq!( + e1x.get("private-key"), + Some(&Value::from("file:///etc/ssl/private/client.key")) + ); + assert_eq!( + e1x.get("private-key-password"), + Some(&Value::from("password")) + ); + assert_eq!( + e1x.get("client-cert"), + Some(&Value::from("file:///etc/ssl/certs/client.crt")) + ); + assert_eq!( + e1x.get("ca-cert"), + Some(&Value::from("file:///etc/ssl/certs/ca.pem")) + ); + // system-ca-certs should NOT be present when false + assert!(e1x.get("system-ca-certs").is_none()); + } + + #[test] + fn builds_eap_192bit_connection() { + let eap_opts = EapOptions { + identity: "student@uni.edu".into(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: None, + ca_cert_path: None, + ca_cert_blob: Some(b"ca_cert_blob".into()), + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: Some(b"private_key_blob".into()), + private_key_password: Some("password".into()), + client_cert_path: None, + client_cert_blob: Some(b"client_cert_blob".into()), + }; + let conn = build_wifi_connection( + "eduroam", + &WifiSecurity::Wpa3Eap192bit { opts: eap_opts }, + &default_opts(), + ); + + let security = conn.get("802-11-wireless-security").unwrap(); + assert_eq!( + security.get("key-mgmt"), + Some(&Value::from("wpa-eap-suite-b-192")) + ); + + let e1x = conn.get("802-1x").unwrap(); + assert_eq!(e1x.get("phase2-auth"), None); + assert_eq!( + e1x.get("private-key"), + Some(&Value::from(b"private_key_blob".to_vec())) + ); + assert_eq!( + e1x.get("private-key-password"), + Some(&Value::from("password")) + ); + assert_eq!( + e1x.get("client-cert"), + Some(&Value::from(b"client_cert_blob".to_vec())) + ); + assert_eq!( + e1x.get("ca-cert"), + Some(&Value::from(b"ca_cert_blob".to_vec())) ); // system-ca-certs should NOT be present when false assert!(e1x.get("system-ca-certs").is_none()); diff --git a/nmrs/src/api/builders/wifi_builder.rs b/nmrs/src/api/builders/wifi_builder.rs index 4851bb58..b7878c77 100644 --- a/nmrs/src/api/builders/wifi_builder.rs +++ b/nmrs/src/api/builders/wifi_builder.rs @@ -173,11 +173,24 @@ impl WifiConnectionBuilder { /// Configures WPA-EAP (Enterprise) security with 802.1X authentication. /// - /// Supports PEAP and TTLS methods with various inner authentication protocols. + /// Supports PEAP, TTLS, and TLS methods with various inner authentication protocols. #[must_use] - pub fn wpa_eap(mut self, opts: models::EapOptions) -> Self { + pub fn wpa_eap(self, opts: models::EapOptions) -> Self { + self.wpa_eap_shared("wpa-eap", opts) + } + + /// Configures WPA3-EAP (Enterprise) with 192bit security with 802.1X authentication. + /// + /// Supports only EAP-TLS. + #[must_use] + pub fn wpa3_eap_192_bit(self, opts: models::EapOptions) -> Self { + self.wpa_eap_shared("wpa-eap-suite-b-192", opts) + } + + #[must_use] + fn wpa_eap_shared(mut self, key_mgmt: &'static str, opts: models::EapOptions) -> Self { let mut security = HashMap::new(); - security.insert("key-mgmt", Value::from("wpa-eap")); + security.insert("key-mgmt", Value::from(key_mgmt)); security.insert("auth-alg", Value::from("open")); self.inner = self @@ -190,26 +203,49 @@ impl WifiConnectionBuilder { let eap_str = match opts.method { EapMethod::Peap => "peap", EapMethod::Ttls => "ttls", + EapMethod::Tls => "tls", }; e1x.insert("eap", Self::string_array(&[eap_str])); e1x.insert("identity", Value::from(opts.identity)); - e1x.insert("password", Value::from(opts.password)); - if let Some(ai) = opts.anonymous_identity { - e1x.insert("anonymous-identity", Value::from(ai)); + match opts.method { + EapMethod::Peap | EapMethod::Ttls => { + e1x.insert("password", Value::from(opts.password)); + + if let Some(ai) = opts.anonymous_identity { + e1x.insert("anonymous-identity", Value::from(ai)); + } + + let p2 = match opts.phase2 { + models::Phase2::Mschapv2 => "mschapv2", + models::Phase2::Pap => "pap", + }; + e1x.insert("phase2-auth", Value::from(p2)); + } + EapMethod::Tls => { + if let Some(cert) = + Self::path_or_blob("private_key", opts.private_key_path, opts.private_key_blob) + { + e1x.insert("private-key", cert); + } + + if let Some(password) = opts.private_key_password { + e1x.insert("private-key-password", Value::from(password)); + } + + if let Some(cert) = + Self::path_or_blob("client_cert", opts.client_cert_path, opts.client_cert_blob) + { + e1x.insert("client-cert", cert); + } + } } - let p2 = match opts.phase2 { - models::Phase2::Mschapv2 => "mschapv2", - models::Phase2::Pap => "pap", - }; - e1x.insert("phase2-auth", Value::from(p2)); - if opts.system_ca_certs { e1x.insert("system-ca-certs", Value::from(true)); } - if let Some(cert) = opts.ca_cert_path { - e1x.insert("ca-cert", Value::from(cert)); + if let Some(cert) = Self::path_or_blob("ca_cert", opts.ca_cert_path, opts.ca_cert_blob) { + e1x.insert("ca-cert", cert); } if let Some(dom) = opts.domain_suffix_match { e1x.insert("domain-suffix-match", Value::from(dom)); @@ -372,6 +408,29 @@ impl WifiConnectionBuilder { let vals: Vec = xs.iter().map(|s| s.to_string()).collect(); Value::from(vals) } + + fn path_or_blob( + attribute: &str, + path: Option, + blob: Option>, + ) -> Option> { + match (path, blob) { + (None, None) => None, + (Some(path), None) => Some(Self::path(path)), + (None, Some(blob)) => Some(Self::blob(blob)), + (Some(_), Some(_)) => { + panic!("Cannot specify both {attribute}_path and {attribute}_blob."); + } + } + } + + fn path(value: String) -> Value<'static> { + Value::from(value) + } + + fn blob(value: Vec) -> Value<'static> { + Value::from(value) + } } #[cfg(test)] @@ -439,9 +498,15 @@ mod tests { anonymous_identity: Some("anon@example.com".into()), domain_suffix_match: Some("example.com".into()), ca_cert_path: None, + ca_cert_blob: None, system_ca_certs: true, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, }; let settings = WifiConnectionBuilder::new("Enterprise") diff --git a/nmrs/src/api/models/tests.rs b/nmrs/src/api/models/tests.rs index 0ab0d368..bbd8b7b7 100644 --- a/nmrs/src/api/models/tests.rs +++ b/nmrs/src/api/models/tests.rs @@ -188,9 +188,40 @@ fn wifi_security_eap() { anonymous_identity: None, domain_suffix_match: None, ca_cert_path: None, + ca_cert_blob: None, system_ca_certs: false, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, + }, + }; + assert!(eap.secured()); + assert!(!eap.is_psk()); + assert!(eap.is_eap()); +} + +#[test] +fn wifi_security_eap_192bit() { + let eap = WifiSecurity::Wpa3Eap192bit { + opts: EapOptions { + identity: "user@example.com".into(), + password: "".into(), + anonymous_identity: None, + domain_suffix_match: None, + ca_cert_path: Some("file:///etc/ssl/certs/ca.crt".into()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("file:///etc/ssl/private/client.key".into()), + private_key_blob: None, + private_key_password: Some("password".into()), + client_cert_path: Some("file:///etc/ssl/certs/client.crt".into()), + client_cert_blob: None, }, }; assert!(eap.secured()); @@ -910,6 +941,106 @@ fn test_eap_options_builder_ttls_pap() { ); } +#[test] +fn test_eap_options_builder_tls() { + let opts = EapOptions::builder() + .identity("student@university.edu") + .method(EapMethod::Tls) + .ca_cert_path("file:///etc/ssl/certs/ca.pem") + .private_key_path("file:///etc/ssl/private/client.key") + .private_key_password("password") + .client_cert_path("file:///etc/ssl/certs/client.pem") + .build() + .unwrap(); + + assert_eq!(opts.method, EapMethod::Tls); + assert_eq!( + opts.ca_cert_path, + Some("file:///etc/ssl/certs/ca.pem".into()) + ); + assert_eq!( + opts.private_key_path, + Some("file:///etc/ssl/private/client.key".into()) + ); + assert_eq!(opts.private_key_password, Some("password".into())); + assert_eq!( + opts.client_cert_path, + Some("file:///etc/ssl/certs/client.pem".into()) + ); +} + +#[test] +fn test_eap_options_builder_path_blob_ca_cert_path() { + let opts = EapOptions::builder() + .identity("student@university.edu") + .method(EapMethod::Tls) + .ca_cert_path("file:///etc/ssl/certs/ca.pem") + .ca_cert_blob(vec![1]) + .private_key_path("file:///etc/ssl/private/client.key") + .private_key_password("password") + .client_cert_path("file:///etc/ssl/certs/client.pem") + .build() + .unwrap(); + + assert_eq!(opts.method, EapMethod::Tls); + assert_eq!(opts.ca_cert_path, None); + assert_eq!(opts.ca_cert_blob, Some(vec![1])); +} + +#[test] +fn test_eap_options_builder_path_blob_ca_cert() { + let opts = EapOptions::builder() + .identity("student@university.edu") + .method(EapMethod::Tls) + .ca_cert_path("file:///etc/ssl/certs/ca.pem") + .ca_cert_blob(vec![1]) + .private_key_path("file:///etc/ssl/private/client.key") + .private_key_password("password") + .client_cert_path("file:///etc/ssl/certs/client.pem") + .build() + .unwrap(); + + assert_eq!(opts.method, EapMethod::Tls); + assert_eq!(opts.ca_cert_path, None); + assert_eq!(opts.ca_cert_blob, Some(vec![1])); +} + +#[test] +fn test_eap_options_builder_path_blob_private_key() { + let opts = EapOptions::builder() + .identity("student@university.edu") + .method(EapMethod::Tls) + .ca_cert_path("file:///etc/ssl/certs/ca.pem") + .private_key_path("file:///etc/ssl/private/client.key") + .private_key_blob(vec![1]) + .private_key_password("password") + .client_cert_path("file:///etc/ssl/certs/client.pem") + .build() + .unwrap(); + + assert_eq!(opts.method, EapMethod::Tls); + assert_eq!(opts.private_key_path, None); + assert_eq!(opts.private_key_blob, Some(vec![1])); +} + +#[test] +fn test_eap_options_builder_path_blob_client_cert() { + let opts = EapOptions::builder() + .identity("student@university.edu") + .method(EapMethod::Tls) + .ca_cert_path("file:///etc/ssl/certs/ca.pem") + .private_key_path("file:///etc/ssl/private/client.key") + .private_key_password("password") + .client_cert_path("file:///etc/ssl/certs/client.pem") + .client_cert_blob(vec![1]) + .build() + .unwrap(); + + assert_eq!(opts.method, EapMethod::Tls); + assert_eq!(opts.client_cert_path, None); + assert_eq!(opts.client_cert_blob, Some(vec![1])); +} + #[test] fn test_eap_options_builder_missing_identity() { let err = EapOptions::builder() @@ -954,6 +1085,26 @@ fn test_eap_options_builder_missing_phase2() { assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } +#[test] +fn test_eap_options_builder_equivalence_to_new() { + let opts_new = EapOptions::new("user@example.com", "password") + .with_method(EapMethod::Peap) + .with_phase2(Phase2::Mschapv2); + + let opts_builder = EapOptions::builder() + .identity("user@example.com") + .password("password") + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .build() + .unwrap(); + + assert_eq!(opts_new.identity, opts_builder.identity); + assert_eq!(opts_new.password, opts_builder.password); + assert_eq!(opts_new.method, opts_builder.method); + assert_eq!(opts_new.phase2, opts_builder.phase2); +} + #[test] fn test_vpn_credentials_builder_equivalence_to_new() { let peer = WireGuardPeer::new( @@ -989,26 +1140,6 @@ fn test_vpn_credentials_builder_equivalence_to_new() { assert_eq!(creds_new.peers.len(), creds_builder.peers.len()); } -#[test] -fn test_eap_options_builder_equivalence_to_new() { - let opts_new = EapOptions::new("user@example.com", "password") - .with_method(EapMethod::Peap) - .with_phase2(Phase2::Mschapv2); - - let opts_builder = EapOptions::builder() - .identity("user@example.com") - .password("password") - .method(EapMethod::Peap) - .phase2(Phase2::Mschapv2) - .build() - .unwrap(); - - assert_eq!(opts_new.identity, opts_builder.identity); - assert_eq!(opts_new.password, opts_builder.password); - assert_eq!(opts_new.method, opts_builder.method); - assert_eq!(opts_new.phase2, opts_builder.phase2); -} - #[test] fn test_timeout_config_default() { let config = TimeoutConfig::default(); diff --git a/nmrs/src/api/models/wifi.rs b/nmrs/src/api/models/wifi.rs index 8646a5e9..89b9fe7e 100644 --- a/nmrs/src/api/models/wifi.rs +++ b/nmrs/src/api/models/wifi.rs @@ -145,6 +145,8 @@ pub enum EapMethod { /// Tunneled TLS (EAP-TTLS) - similar to PEAP but more flexible. /// Can use various inner authentication methods like PAP or MSCHAPv2. Ttls, + /// TLS (EAP-TLS) - uses certificates for client authentication. + Tls, } /// Phase 2 (inner) authentication methods for EAP connections. @@ -198,20 +200,32 @@ pub enum Phase2 { pub struct EapOptions { /// User identity (usually email or username) pub identity: String, - /// Password for authentication + /// PEAP/TTLS: Password for authentication pub password: String, - /// Anonymous outer identity (for privacy) + /// PEAP/TTLS: Anonymous outer identity (for privacy) pub anonymous_identity: Option, /// Domain to match against server certificate pub domain_suffix_match: Option, - /// Path to CA certificate file (file:// URL) + /// Path to CA certificate file (file:// URL), mutually exclusive with `ca_cert_blob` pub ca_cert_path: Option, + /// CA certificate encoded as DER, mutually exclusive with `ca_cert_path` + pub ca_cert_blob: Option>, /// Use system CA certificate store pub system_ca_certs: bool, /// EAP method (PEAP or TTLS) pub method: EapMethod, - /// Phase 2 inner authentication method + /// PEAP/TTLS: Phase 2 inner authentication method pub phase2: Phase2, + /// TLS: Path to the private key file of the client certificate (file:// URL), mutually exclusive with `private_key_blob` + pub private_key_path: Option, + /// TLS: Private key of the client certificate encoded as PEM or PKCS#12, mutually exclusive with `private_key_path` + pub private_key_blob: Option>, + /// TLS: Password for the private key file + pub private_key_password: Option, + /// TLS: Path to the client certificate file (file:// URL), mutually exclusive with `client_cert_blob` + pub client_cert_path: Option, + /// TLS: Client certificate encoded as DER or PKCS#12, mutually exclusive with `client_cert_path` + pub client_cert_blob: Option>, } impl Default for EapOptions { @@ -222,9 +236,15 @@ impl Default for EapOptions { anonymous_identity: None, domain_suffix_match: None, ca_cert_path: None, + ca_cert_blob: None, system_ca_certs: false, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, } } } @@ -249,6 +269,60 @@ impl EapOptions { } } + /// Creates a new `EapOptions` with the minimum required fields for EAP-TLS. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, EapMethod}; + /// + /// let opts = EapOptions::new_tls_path("user@example.com", "file:///etc/ssl/private/client.key", "file:///etc/ssl/certs/client.crt") + /// .with_private_key_password("password") + /// .with_ca_cert_path("file:///etc/ssl/certs/ca.pem"); + /// ``` + pub fn new_tls_path( + identity: impl Into, + private_key_path: impl Into, + client_cert_path: impl Into, + ) -> Self { + Self { + identity: identity.into(), + method: EapMethod::Tls, + private_key_path: Some(private_key_path.into()), + client_cert_path: Some(client_cert_path.into()), + ..Default::default() + } + } + + /// Creates a new `EapOptions` with the minimum required fields for EAP-TLS. + /// + /// Private key must be in PEM or PKCS#12 format. + /// Certificate must be in DER or PKCS#12 format. + /// CA certificate must be in DER format. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, EapMethod}; + /// + /// let opts = EapOptions::new_tls_blob("user@example.com", vec![], vec![]) + /// .with_private_key_password("password") + /// .with_ca_cert_blob(vec![]); + /// ``` + pub fn new_tls_blob( + identity: impl Into, + private_key_blob: impl Into>, + client_cert_blob: impl Into>, + ) -> Self { + Self { + identity: identity.into(), + method: EapMethod::Tls, + private_key_blob: Some(private_key_blob.into()), + client_cert_blob: Some(client_cert_blob.into()), + ..Default::default() + } + } + /// Creates a new `EapOptions` builder. /// /// This provides an alternative way to construct EAP options with a fluent API, @@ -289,12 +363,25 @@ impl EapOptions { } /// Sets the path to the CA certificate file (must start with `file://`). + /// + /// Clears `ca_cert_blob` because they are mutually exclusive. #[must_use] pub fn with_ca_cert_path(mut self, path: impl Into) -> Self { + self.ca_cert_blob = None; self.ca_cert_path = Some(path.into()); self } + /// Sets the CA certificate encoded as DER. + /// + /// Clears `ca_cert_path` because they are mutually exclusive. + #[must_use] + pub fn with_ca_cert_blob(mut self, data: impl Into>) -> Self { + self.ca_cert_path = None; + self.ca_cert_blob = Some(data.into()); + self + } + /// Sets whether to use the system CA certificate store. #[must_use] pub fn with_system_ca_certs(mut self, use_system: bool) -> Self { @@ -315,6 +402,13 @@ impl EapOptions { self.phase2 = phase2; self } + + /// Sets the password for the private key file. + #[must_use] + pub fn with_private_key_password(mut self, password: impl Into) -> Self { + self.private_key_password = Some(password.into()); + self + } } /// Builder for constructing `EapOptions` with a fluent API. @@ -355,6 +449,22 @@ impl EapOptions { /// .build() /// .expect("all required fields set"); /// ``` +/// +/// ## TLS +/// +/// ```rust +/// use nmrs::{EapOptions, EapMethod}; +/// +/// let opts = EapOptions::builder() +/// .identity("student@university.edu") +/// .method(EapMethod::Tls) +/// .private_key_path("file:///etc/ssl/private/student.key") +/// .private_key_password("password") +/// .client_cert_path("file:///etc/ssl/certs/student.crt") +/// .ca_cert_path("file:///etc/ssl/certs/university-ca.pem") +/// .build() +/// .expect("all required fields set"); +/// ``` #[derive(Debug, Default)] pub struct EapOptionsBuilder { identity: Option, @@ -362,9 +472,15 @@ pub struct EapOptionsBuilder { anonymous_identity: Option, domain_suffix_match: Option, ca_cert_path: Option, + ca_cert_blob: Option>, system_ca_certs: bool, method: Option, phase2: Option, + private_key_path: Option, + private_key_blob: Option>, + private_key_password: Option, + client_cert_path: Option, + client_cert_blob: Option>, } impl EapOptionsBuilder { @@ -428,6 +544,8 @@ impl EapOptionsBuilder { /// /// The path must start with `file://` (e.g., "file:///etc/ssl/certs/ca.pem"). /// + /// Clears `ca_cert_blob` because they are mutually exclusive. + /// /// # Examples /// /// ```rust @@ -438,10 +556,30 @@ impl EapOptionsBuilder { /// ``` #[must_use] pub fn ca_cert_path(mut self, path: impl Into) -> Self { + self.ca_cert_blob = None; self.ca_cert_path = Some(path.into()); self } + /// Sets the CA certificate encoded as DER. + /// + /// Clears `ca_cert_path` because they are mutually exclusive. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .ca_cert_blob(vec![]); + /// ``` + #[must_use] + pub fn ca_cert_blob(mut self, data: impl Into>) -> Self { + self.ca_cert_path = None; + self.ca_cert_blob = Some(data.into()); + self + } + /// Sets whether to use the system CA certificate store. /// /// When enabled, the system's trusted CA certificates will be used @@ -499,6 +637,102 @@ impl EapOptionsBuilder { self } + /// Sets the path to the private key file of the client certificate. + /// + /// The path must start with `file://` (e.g., "file:///etc/ssl/private/client.key"). + /// + /// Clears `private_key_blob` because they are mutually exclusive. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .private_key_path("file:///etc/ssl/private/client.key"); + /// ``` + #[must_use] + pub fn private_key_path(mut self, path: impl Into) -> Self { + self.private_key_blob = None; + self.private_key_path = Some(path.into()); + self + } + + /// Sets the private key of the client certificate encoded as PEM or PKCS#12. + /// + /// Clears `private_key_path` because they are mutually exclusive. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .private_key_blob(vec![]); + /// ``` + #[must_use] + pub fn private_key_blob(mut self, data: impl Into>) -> Self { + self.private_key_path = None; + self.private_key_blob = Some(data.into()); + self + } + + /// Sets the password for the private key file. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .private_key_password("password"); + /// ``` + #[must_use] + pub fn private_key_password(mut self, password: impl Into) -> Self { + self.private_key_password = Some(password.into()); + self + } + + /// Sets the path to the client certificate file. + /// + /// The path must start with `file://` (e.g., "file:///etc/ssl/certs/client.crt"). + /// + /// Clears `client_cert_blob` because they are mutually exclusive. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .client_cert_path("file:///etc/ssl/certs/client.crt"); + /// ``` + #[must_use] + pub fn client_cert_path(mut self, path: impl Into) -> Self { + self.client_cert_blob = None; + self.client_cert_path = Some(path.into()); + self + } + + /// Sets the client certificate encoded as DER or PKCS#12. + /// + /// Clears `client_cert_path` because they are mutually exclusive. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .client_cert_blob(vec![]); + /// ``` + #[must_use] + pub fn client_cert_blob(mut self, data: impl Into>) -> Self { + self.client_cert_path = None; + self.client_cert_blob = Some(data.into()); + self + } + /// Builds the `EapOptions` from the configured values. /// /// # Errors @@ -521,29 +755,62 @@ impl EapOptionsBuilder { /// ``` #[must_use = "use the EAP options with WifiSecurity::WpaEap or handle the error"] pub fn build(self) -> Result { + let is_peap_or_ttls = + self.method == Some(EapMethod::Peap) || self.method == Some(EapMethod::Ttls); + + if self.ca_cert_path.is_some() && self.ca_cert_blob.is_some() { + return Err(ConnectionError::IncompleteBuilder( + "EAP CA certificate cannot be specified both as a path and blob".into(), + )); + } + if self.private_key_path.is_some() && self.private_key_blob.is_some() { + return Err(ConnectionError::IncompleteBuilder( + "EAP private key cannot be specified both as a path and blob".into(), + )); + } + if self.client_cert_path.is_some() && self.client_cert_blob.is_some() { + return Err(ConnectionError::IncompleteBuilder( + "EAP client certificate cannot be specified both as a path and blob".into(), + )); + } + Ok(EapOptions { identity: self.identity.ok_or_else(|| { ConnectionError::IncompleteBuilder( "EAP identity is required (use .identity())".into(), ) })?, - password: self.password.ok_or_else(|| { - ConnectionError::IncompleteBuilder( - "EAP password is required (use .password())".into(), - ) - })?, + password: if is_peap_or_ttls { + self.password.ok_or_else(|| { + ConnectionError::IncompleteBuilder( + "EAP password is required (use .password())".into(), + ) + })? + } else { + String::new() + }, anonymous_identity: self.anonymous_identity, domain_suffix_match: self.domain_suffix_match, ca_cert_path: self.ca_cert_path, + ca_cert_blob: self.ca_cert_blob, system_ca_certs: self.system_ca_certs, method: self.method.ok_or_else(|| { ConnectionError::IncompleteBuilder("EAP method is required (use .method())".into()) })?, - phase2: self.phase2.ok_or_else(|| { - ConnectionError::IncompleteBuilder( - "EAP phase 2 method is required (use .phase2())".into(), - ) - })?, + phase2: if is_peap_or_ttls { + self.phase2.ok_or_else(|| { + ConnectionError::IncompleteBuilder( + "EAP phase 2 method is required (use .phase2())".into(), + ) + })? + } else { + Phase2::Mschapv2 + }, + private_key_path: self.private_key_path, + private_key_blob: self.private_key_blob, + private_key_password: self.private_key_password, + client_cert_path: self.client_cert_path, + client_cert_blob: self.client_cert_blob, }) } } @@ -618,6 +885,12 @@ pub enum WifiSecurity { /// EAP configuration options opts: EapOptions, }, + /// WPA3-EAP 192-bit mode (Enterprise authentication via 802.1X) + /// Only EAP-TLS is allowed as authentication method. + Wpa3Eap192bit { + /// EAP configuration options + opts: EapOptions, + }, } impl WifiSecurity { @@ -636,7 +909,10 @@ impl WifiSecurity { /// Returns `true` if this is a WPA-EAP (Enterprise/802.1X) security type. #[must_use] pub fn is_eap(&self) -> bool { - matches!(self, WifiSecurity::WpaEap { .. }) + matches!( + self, + WifiSecurity::WpaEap { .. } | WifiSecurity::Wpa3Eap192bit { .. } + ) } } diff --git a/nmrs/src/util/validation.rs b/nmrs/src/util/validation.rs index f10244a7..328b8718 100644 --- a/nmrs/src/util/validation.rs +++ b/nmrs/src/util/validation.rs @@ -9,6 +9,7 @@ use crate::api::models::{ ConnectionError, OpenVpnAuthType, OpenVpnConfig, OpenVpnProxy, VpnCredentials, WifiSecurity, WireGuardPeer, }; +use crate::{EapMethod, EapOptions}; /// Maximum SSID length in bytes (802.11 standard). const MAX_SSID_BYTES: usize = 32; @@ -138,13 +139,35 @@ pub fn validate_wifi_security(security: &WifiSecurity) -> Result<(), ConnectionE } WifiSecurity::WpaEap { opts } => { - // Validate identity - if opts.identity.trim().is_empty() { + validate_wifi_eap(opts)?; + + Ok(()) + } + + WifiSecurity::Wpa3Eap192bit { opts } => { + if opts.method != EapMethod::Tls { return Err(ConnectionError::InvalidAddress( - "EAP identity cannot be empty".to_string(), + "WPA3-EAP 192bit requires authentication method TLS".to_string(), )); } + validate_wifi_eap(opts)?; + + Ok(()) + } + } +} + +fn validate_wifi_eap(opts: &EapOptions) -> Result<(), ConnectionError> { + // Validate identity + if opts.identity.trim().is_empty() { + return Err(ConnectionError::InvalidAddress( + "EAP identity cannot be empty".to_string(), + )); + } + + match opts.method { + EapMethod::Peap | EapMethod::Ttls => { // Validate password if opts.password.is_empty() { return Err(ConnectionError::InvalidAddress( @@ -169,24 +192,61 @@ pub fn validate_wifi_security(security: &WifiSecurity) -> Result<(), ConnectionE "EAP domain suffix match cannot be empty if provided".to_string(), )); } + } + EapMethod::Tls => { + if !validate_path_or_blob( + "EAP private key", + &opts.private_key_path, + &opts.private_key_blob, + )? { + return Err(ConnectionError::InvalidAddress( + "EAP private key must be provided".to_string(), + )); + } - // Validate CA cert path if provided - if let Some(ref ca_cert) = opts.ca_cert_path { - if ca_cert.trim().is_empty() { - return Err(ConnectionError::InvalidAddress( - "EAP CA certificate path cannot be empty if provided".to_string(), - )); - } - // Check if it starts with file:// as required by NetworkManager - if !ca_cert.starts_with("file://") { - return Err(ConnectionError::InvalidAddress( - "EAP CA certificate path must start with 'file://'".to_string(), - )); - } + if !validate_path_or_blob( + "EAP client certificate", + &opts.client_cert_path, + &opts.client_cert_blob, + )? { + return Err(ConnectionError::InvalidAddress( + "EAP client certificate must be provided".to_string(), + )); } + } + } - Ok(()) + validate_path_or_blob("EAP CA certificate", &opts.ca_cert_path, &opts.ca_cert_blob)?; + + Ok(()) +} + +fn validate_path_or_blob( + field: &str, + path: &Option, + blob: &Option>, +) -> Result { + // Validate CA cert path if provided + match (path, blob) { + (None, None) => Ok(false), + (Some(path), None) => { + if path.trim().is_empty() { + return Err(ConnectionError::InvalidAddress(format!( + "{field} path cannot be empty if provided" + ))); + } + // Check if it starts with file:// as required by NetworkManager + if !path.starts_with("file://") { + return Err(ConnectionError::InvalidAddress(format!( + "{field} path must start with 'file://'" + ))); + } + Ok(true) } + (None, Some(_)) => Ok(true), + (Some(_), Some(_)) => Err(ConnectionError::InvalidAddress(format!( + "{field} path and blob cannot be provided at the same time" + ))), } } @@ -826,9 +886,15 @@ mod tests { anonymous_identity: None, domain_suffix_match: Some("example.com".to_string()), ca_cert_path: Some("file:///etc/ssl/cert.pem".to_string()), + ca_cert_blob: None, system_ca_certs: false, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, }, }; assert!(validate_wifi_security(&eap).is_ok()); @@ -843,9 +909,15 @@ mod tests { anonymous_identity: None, domain_suffix_match: None, ca_cert_path: None, + ca_cert_blob: None, system_ca_certs: true, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, }, }; assert!(validate_wifi_security(&eap).is_err()); @@ -860,9 +932,199 @@ mod tests { anonymous_identity: None, domain_suffix_match: None, ca_cert_path: Some("/etc/ssl/cert.pem".to_string()), // Missing file:// + ca_cert_blob: None, system_ca_certs: false, method: EapMethod::Peap, phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, + }, + }; + assert!(validate_wifi_security(&eap).is_err()); + } + + #[test] + fn test_validate_wifi_security_eap_192bit_not_tls() { + let eap = WifiSecurity::Wpa3Eap192bit { + opts: EapOptions { + identity: "".to_string(), + password: "password".to_string(), + anonymous_identity: None, + domain_suffix_match: None, + ca_cert_path: None, + ca_cert_blob: None, + system_ca_certs: true, + method: EapMethod::Peap, + phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, + }, + }; + assert!(validate_wifi_security(&eap).is_err()); + } + + #[test] + fn test_validate_wifi_security_eap_192bit_valid_path() { + let eap = WifiSecurity::Wpa3Eap192bit { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".to_string()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("file:///etc/ssl/private/client.pem".to_string()), + private_key_blob: None, + private_key_password: None, + client_cert_path: Some("file:///etc/ssl/certs/client.pem".to_string()), + client_cert_blob: None, + }, + }; + assert!(validate_wifi_security(&eap).is_ok()); + } + + #[test] + fn test_validate_wifi_security_eap_192bit_valid_blob() { + let eap = WifiSecurity::Wpa3Eap192bit { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: None, + ca_cert_blob: Some(b"ca_cert_blob".to_vec()), + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: Some(b"private_key_blob".to_vec()), + private_key_password: None, + client_cert_path: None, + client_cert_blob: Some(b"client_cert_blob".to_vec()), + }, + }; + assert!(validate_wifi_security(&eap).is_ok()); + } + + #[test] + fn test_validate_wifi_security_eap_192bit_path_blob() { + let eap = WifiSecurity::Wpa3Eap192bit { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".to_string()), + ca_cert_blob: Some(b"ca_cert_blob".to_vec()), + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("file:///etc/ssl/private/client.pem".to_string()), + private_key_blob: Some(b"private_key_blob".to_vec()), + private_key_password: None, + client_cert_path: Some("file:///etc/ssl/certs/client.pem".to_string()), + client_cert_blob: Some(b"client_cert_blob".to_vec()), + }, + }; + assert!(validate_wifi_security(&eap).is_err()); + } + + #[test] + fn test_validate_wifi_security_eap_tls_invalid_private_key() { + let eap = WifiSecurity::WpaEap { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".to_string()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("/etc/ssl/private/client.pem".to_string()), + private_key_blob: None, + private_key_password: None, + client_cert_path: Some("file:///etc/ssl/certs/client.pem".to_string()), + client_cert_blob: None, + }, + }; + assert!(validate_wifi_security(&eap).is_err()); + } + + #[test] + fn test_validate_wifi_security_eap_tls_invalid_client_cert() { + let eap = WifiSecurity::WpaEap { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".to_string()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("file:///etc/ssl/private/client.pem".to_string()), + private_key_blob: None, + private_key_password: None, + client_cert_path: Some("/etc/ssl/certs/client.pem".to_string()), + client_cert_blob: None, + }, + }; + assert!(validate_wifi_security(&eap).is_err()); + } + + #[test] + fn test_validate_wifi_security_eap_tls_missing_private_key() { + let eap = WifiSecurity::WpaEap { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".to_string()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: None, + private_key_blob: None, + private_key_password: None, + client_cert_path: Some("file:///etc/ssl/certs/client.pem".to_string()), + client_cert_blob: None, + }, + }; + assert!(validate_wifi_security(&eap).is_err()); + } + + #[test] + fn test_validate_wifi_security_eap_tls_missing_client_cert() { + let eap = WifiSecurity::WpaEap { + opts: EapOptions { + identity: "user@example.com".to_string(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: Some("example.com".to_string()), + ca_cert_path: Some("file:///etc/ssl/certs/ca.pem".to_string()), + ca_cert_blob: None, + system_ca_certs: false, + method: EapMethod::Tls, + phase2: Phase2::Mschapv2, + private_key_path: Some("file:///etc/ssl/private/client.pem".to_string()), + private_key_blob: None, + private_key_password: None, + client_cert_path: None, + client_cert_blob: None, }, }; assert!(validate_wifi_security(&eap).is_err()); From e5d29b853517dd45e3669c3d19cc96da2e0c1d55 Mon Sep 17 00:00:00 2001 From: jrb0001 Date: Sat, 30 May 2026 12:46:54 +0200 Subject: [PATCH 2/2] fix: send private-key, client-cert and ca-cert paths as byte array instead of string Otherwise connecting fails with: Dbus(MethodError(OwnedErrorName("org.freedesktop.NetworkManager.Settings.Connection.Failed"), Some("Das Ermitteln der AP-Sicherheitsinformationen scheiterte"), Msg { type: Error, serial: 26358, sender: UniqueName(":1.1563"), reply-serial: 193, body: Str, fds: [] })) --- nmrs/src/api/builders/wifi.rs | 10 ++++++---- nmrs/src/api/builders/wifi_builder.rs | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/nmrs/src/api/builders/wifi.rs b/nmrs/src/api/builders/wifi.rs index b6f0f760..d0e958e1 100644 --- a/nmrs/src/api/builders/wifi.rs +++ b/nmrs/src/api/builders/wifi.rs @@ -254,7 +254,7 @@ mod tests { assert_eq!(e1x.get("phase2-auth"), Some(&Value::from("pap"))); assert_eq!( e1x.get("ca-cert"), - Some(&Value::from("file:///etc/ssl/certs/ca.pem")) + Some(&Value::from(b"file:///etc/ssl/certs/ca.pem\0".to_vec())) ); // system-ca-certs should NOT be present when false assert!(e1x.get("system-ca-certs").is_none()); @@ -291,7 +291,9 @@ mod tests { assert_eq!(e1x.get("phase2-auth"), None); assert_eq!( e1x.get("private-key"), - Some(&Value::from("file:///etc/ssl/private/client.key")) + Some(&Value::from( + b"file:///etc/ssl/private/client.key\0".to_vec() + )) ); assert_eq!( e1x.get("private-key-password"), @@ -299,11 +301,11 @@ mod tests { ); assert_eq!( e1x.get("client-cert"), - Some(&Value::from("file:///etc/ssl/certs/client.crt")) + Some(&Value::from(b"file:///etc/ssl/certs/client.crt\0".to_vec())) ); assert_eq!( e1x.get("ca-cert"), - Some(&Value::from("file:///etc/ssl/certs/ca.pem")) + Some(&Value::from(b"file:///etc/ssl/certs/ca.pem\0".to_vec())) ); // system-ca-certs should NOT be present when false assert!(e1x.get("system-ca-certs").is_none()); diff --git a/nmrs/src/api/builders/wifi_builder.rs b/nmrs/src/api/builders/wifi_builder.rs index b7878c77..c9520ec7 100644 --- a/nmrs/src/api/builders/wifi_builder.rs +++ b/nmrs/src/api/builders/wifi_builder.rs @@ -424,8 +424,9 @@ impl WifiConnectionBuilder { } } - fn path(value: String) -> Value<'static> { - Value::from(value) + fn path(mut value: String) -> Value<'static> { + value.push('\0'); + Value::from(value.into_bytes()) } fn blob(value: Vec) -> Value<'static> {