Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
iw dev | awk '$1 == "Interface" { print $2 }' | sort -u
)

sudo -n modprobe mac80211_hwsim radios=2
sudo -n modprobe mac80211_hwsim radios=3
udevadm settle --timeout=10

mapfile -t interfaces_after < <(
Expand All @@ -126,8 +126,8 @@ jobs:
<(printf '%s\n' "${interfaces_before[@]}") \
<(printf '%s\n' "${interfaces_after[@]}")
)
if (( ${#hwsim_interfaces[@]} != 2 )); then
echo "Expected the hwsim module to create two interfaces, found ${#hwsim_interfaces[@]}" >&2
if (( ${#hwsim_interfaces[@]} != 3 )); then
echo "Expected the hwsim module to create three interfaces, found ${#hwsim_interfaces[@]}" >&2
iw dev >&2 || true
exit 1
fi
Expand All @@ -149,8 +149,8 @@ jobs:
trap diagnose ERR

read -r -a hwsim_interfaces <<< "${NMRS_HOST_HWSIM_INTERFACES:?missing hwsim interface list}"
if (( ${#hwsim_interfaces[@]} != 2 )); then
echo "Expected exactly two recorded hwsim interfaces, found ${#hwsim_interfaces[@]}" >&2
if (( ${#hwsim_interfaces[@]} != 3 )); then
echo "Expected exactly three recorded hwsim interfaces, found ${#hwsim_interfaces[@]}" >&2
exit 1
fi

Expand All @@ -169,8 +169,8 @@ jobs:
test-integration \
-euo pipefail -c '
read -r -a hwsim_interfaces <<< "${NMRS_HOST_HWSIM_INTERFACES:?missing hwsim interface list}"
if (( ${#hwsim_interfaces[@]} != 2 )); then
echo "Expected exactly two hwsim interfaces in the helper, found ${#hwsim_interfaces[@]}" >&2
if (( ${#hwsim_interfaces[@]} != 3 )); then
echo "Expected exactly three hwsim interfaces in the helper, found ${#hwsim_interfaces[@]}" >&2
exit 1
fi

Expand Down
25 changes: 17 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,30 @@ declared, missing services and unexpected D-Bus errors fail the test.

### Deterministic WiFi integration

The WiFi contract requires two `mac80211_hwsim` radios. The container configures
one as a WPA2 access point, supplies DHCP with dnsmasq, and gives only the other
radio to its private NetworkManager. It asserts discovery, WPA authentication,
network and device callback delivery, DHCP, disconnect, saved-credential
reconnect, forget, and the missing-password error after cleanup.
The WiFi contract requires three `mac80211_hwsim` radios. The container
configures the first as a WPA2 access point and the second as an SAE-only
access point, each with its own hostapd, subnet, and dnsmasq, and gives only the
third radio to its private NetworkManager. It asserts discovery, WPA
authentication, network and device callback delivery, DHCP, disconnect,
saved-credential reconnect, forget, and the missing-password error after
cleanup.

WPA3-Personal access points reject `key-mgmt=wpa-psk`, so the SAE-only radio is
what proves `WifiSecurity::WpaPsk` upgrades to SAE instead of failing, and that
`WifiSecurity::Sae` authenticates. It requires a `hostapd` built with SAE
support.

```bash
sudo modprobe mac80211_hwsim radios=2
sudo modprobe mac80211_hwsim radios=3
docker compose run --build --rm test-wifi-integration
sudo modprobe -r mac80211_hwsim
```

The WiFi runner sets `NMRS_REQUIRE_WIFI=1`, `NMRS_WIFI_INTERFACE`,
`NMRS_EXPECT_WIFI_SSID`, and `NMRS_WIFI_PASSWORD`. If a declared facility is
missing, the test fails rather than being reported as a pass.
`NMRS_EXPECT_WIFI_SSID`, and `NMRS_WIFI_PASSWORD`, plus
`NMRS_REQUIRE_WIFI_SAE=1`, `NMRS_EXPECT_WIFI_SAE_SSID`, and
`NMRS_WIFI_SAE_PASSWORD` for the SAE BSS. If a declared facility is missing, the
test fails rather than being reported as a pass.

To run the NM-only contracts against a deliberately selected local daemon, opt
in explicitly:
Expand Down
8 changes: 7 additions & 1 deletion docs/src/api/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,17 @@ pub struct NetworkInfo {
pub enum WifiSecurity {
Open,
WpaPsk { psk: String },
Sae { psk: String },
WpaEap { opts: EapOptions },
Wpa3Eap192bit { opts: EapOptions },
}
```

Methods: `secured()`, `is_psk()`, `is_eap()`
Methods: `secured()`, `is_psk()`, `is_sae()`, `is_eap()`

`WpaPsk` emits `key-mgmt=wpa-psk` and `Sae` emits `key-mgmt=sae`. SAE-only
WPA3-Personal access points reject the former; `connect()` upgrades `WpaPsk` to
`Sae` automatically when the target AP advertises SAE without PSK.

### EapOptions

Expand Down
2 changes: 1 addition & 1 deletion docs/src/development/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ The WiFi contract uses one hwsim radio for a WPA2 access point with DHCP and a
second radio as NetworkManager's station:

```bash
sudo modprobe mac80211_hwsim radios=2
sudo modprobe mac80211_hwsim radios=3
docker compose run --build --rm test-wifi-integration
sudo modprobe -r mac80211_hwsim
```
Expand Down
7 changes: 4 additions & 3 deletions docs/src/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ with `mac80211_hwsim`. One radio advertises a WPA2-PSK test network using
other radio.

```bash
sudo modprobe mac80211_hwsim radios=2
sudo modprobe mac80211_hwsim radios=3
docker compose run --build --rm test-wifi-integration
sudo modprobe -r mac80211_hwsim
```
Expand All @@ -160,8 +160,9 @@ It also mounts the host's `/run/udev` read-only so NetworkManager can manage
the newly created hwsim links.

The self-hosted runner service account needs passwordless `sudo` permission for
`modprobe mac80211_hwsim radios=2` and `modprobe -r mac80211_hwsim`; CI invokes
both commands with `sudo -n`.
`modprobe mac80211_hwsim radios=3` and `modprobe -r mac80211_hwsim`; CI invokes
both commands with `sudo -n`. The sudoers rule matches the exact argument list,
so changing the radio count means updating the rule on the runner as well.

### Approving Wi-Fi CI Runs

Expand Down
20 changes: 20 additions & 0 deletions docs/src/guide/wifi-wpa-psk.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ async fn main() -> nmrs::Result<()> {

The `WifiSecurity::WpaPsk` variant works with WPA, WPA2, and WPA3 Personal networks. NetworkManager negotiates the strongest supported protocol automatically.

## WPA3-Personal (SAE)

`WpaPsk` emits `key-mgmt=wpa-psk`, which WPA3-Personal access points reject when
they advertise SAE without PSK. `connect()` detects that case from the access
point's own security flags and switches to SAE for you, so the snippet above
works unchanged on an SAE-only network.

Transition-mode access points advertise both SAE and PSK and accept `WpaPsk`, so
those are left as-is. To request SAE explicitly — when building settings
yourself, or to pin key management regardless of what the AP advertises — use
the variant directly:

```rust
nm.connect("Wpa3Only", None, WifiSecurity::Sae {
psk: "my_secure_password".into(),
}).await?;
```

Unlike WPA-PSK, SAE has no 8-character minimum passphrase length.

## Password Requirements

- A new profile requires a non-empty password. An empty PSK requests the stored
Expand Down
10 changes: 10 additions & 0 deletions nmrs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ All notable changes to the `nmrs` crate will be documented in this file.
## [Unreleased]
### Added

- `WifiSecurity::Sae` and `WifiConnectionBuilder::sae()` emit
`key-mgmt=sae` for WPA3-Personal access points, plus `WifiSecurity::is_sae()`
to distinguish them from WPA-PSK. ([#486](https://github.com/freedesktop-rs/nmrs/issues/486))
- `connect_by_uuid()` and `disconnect_by_uuid()` activate and deactivate any
saved connection by UUID, not just VPN profiles. `connect_by_uuid()` takes a
`ConnectByUuidConfig` so the interface stored in the profile can be
Expand All @@ -19,6 +22,13 @@ All notable changes to the `nmrs` crate will be documented in this file.

### Fixed

- Connecting with `WifiSecurity::WpaPsk` to a WPA3-Personal access point that
advertises SAE without PSK no longer fails with
`802-11-wireless-security.key-mgmt: Access point does not support PSK but
setting requires it`. `connect()` reads the target access point's security
flags and emits `key-mgmt=sae` for SAE-only APs; transition-mode APs that
still advertise PSK are unchanged.
([#486](https://github.com/freedesktop-rs/nmrs/issues/486))
- `disconnect_vpn_by_uuid()` again returns `Ok(())` when no saved connection
matches the UUID. Delegating to `disconnect_by_uuid()` leaked that case out
as `ConnectionError::SavedConnectionNotFound`, which callers of the
Expand Down
19 changes: 19 additions & 0 deletions nmrs/src/api/builders/wifi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub fn build_wifi_connection(
let builder = match security {
models::WifiSecurity::Open => base_wifi_builder(ssid, opts).open(),
models::WifiSecurity::WpaPsk { psk } => base_wifi_builder(ssid, opts).wpa_psk(psk),
models::WifiSecurity::Sae { psk } => base_wifi_builder(ssid, opts).sae(psk),
models::WifiSecurity::WpaEap { opts: eap } => {
base_wifi_builder(ssid, opts).wpa_eap(eap.clone())
}
Expand Down Expand Up @@ -120,6 +121,7 @@ pub fn try_build_wifi_connection(
let builder = match security {
models::WifiSecurity::Open => base.open(),
models::WifiSecurity::WpaPsk { psk } => base.wpa_psk(psk),
models::WifiSecurity::Sae { psk } => base.sae(psk),
models::WifiSecurity::WpaEap { opts: eap } => base.try_wpa_eap(eap.clone())?,
models::WifiSecurity::Wpa3Eap192bit { opts: eap } => {
base.try_wpa3_eap_192_bit(eap.clone())?
Expand Down Expand Up @@ -227,6 +229,23 @@ mod tests {
}
}

#[test]
fn sae_security_maps_to_the_sae_builder() {
let conn = build_wifi_connection(
"wpa3net",
&WifiSecurity::Sae {
psk: "password123".into(),
},
&default_opts(),
);

let security = conn
.get("802-11-wireless-security")
.expect("SAE must produce a security section");
assert_eq!(security.get("key-mgmt"), Some(&Value::from("sae")));
assert!(security.get("auth-alg").is_none());
}

#[test]
fn open_connection_has_correct_type() {
let conn = build_wifi_connection("open_net", &WifiSecurity::Open, &default_opts());
Expand Down
72 changes: 72 additions & 0 deletions nmrs/src/api/builders/wifi_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,31 @@ impl WifiConnectionBuilder {
self
}

/// Configures SAE (WPA3-Personal) security with the given passphrase.
///
/// Emits `key-mgmt=sae`, which is what SAE-only access points require.
/// Transition-mode APs that advertise both SAE and PSK also accept
/// [`wpa_psk`](Self::wpa_psk).
///
/// Unlike [`wpa_psk`](Self::wpa_psk) this sets no `auth-alg`;
/// NetworkManager rejects `auth-alg=open` combined with SAE. Protected
/// management frames are left unset so NetworkManager applies its own
/// SAE default.
#[must_use]
pub fn sae(mut self, psk: impl Into<String>) -> Self {
let mut security = HashMap::new();
security.insert("key-mgmt", Value::from("sae"));
security.insert("psk", Value::from(psk.into()));
security.insert("psk-flags", Value::from(0u32));

self.inner = self
.inner
.without_section("802-1x")
.with_section("802-11-wireless-security", security);
self.security_configured = true;
self
}

/// Configures WPA-EAP (Enterprise) security with 802.1X authentication.
///
/// Supports PEAP, TTLS, and TLS methods with various inner authentication protocols.
Expand Down Expand Up @@ -610,6 +635,53 @@ mod tests {
);
}

#[test]
fn builds_sae_wifi() {
let settings = WifiConnectionBuilder::new("Wpa3Net")
.sae("password123")
.ipv4_auto()
.ipv6_auto()
.build();

let security = settings
.get("802-11-wireless-security")
.expect("SAE must produce a security section");
assert_eq!(security.get("key-mgmt"), Some(&Value::from("sae")));
assert_eq!(
security.get("psk"),
Some(&Value::from("password123".to_string()))
);
assert_eq!(security.get("psk-flags"), Some(&Value::from(0u32)));

// NetworkManager rejects a profile that pairs SAE with an auth-alg.
assert!(
security.get("auth-alg").is_none(),
"SAE must not set auth-alg"
);
assert!(!settings.contains_key("802-1x"));

let wireless = settings.get("802-11-wireless").unwrap();
assert_eq!(
wireless.get("security"),
Some(&Value::from("802-11-wireless-security"))
);
}

#[test]
fn sae_replaces_previously_configured_psk_security() {
let settings = WifiConnectionBuilder::new("Wpa3Net")
.wpa_psk("password123")
.sae("password123")
.build();

let security = settings.get("802-11-wireless-security").unwrap();
assert_eq!(security.get("key-mgmt"), Some(&Value::from("sae")));
assert!(
security.get("auth-alg").is_none(),
"the earlier wpa_psk auth-alg must not survive"
);
}

#[test]
fn builds_wpa_eap_wifi() {
let eap_opts = EapOptions {
Expand Down
39 changes: 34 additions & 5 deletions nmrs/src/api/models/wifi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub struct WifiNetworkGroup {
/// println!("SSID: {}", net.ssid);
/// println!(" Signal: {}%", net.strength.unwrap_or(0));
/// println!(" Secured: {}", net.secured);
///
///
/// if let Some(freq) = net.frequency {
/// let band = if freq > 5000 { "5GHz" } else { "2.4GHz" };
/// println!(" Band: {}", band);
Expand Down Expand Up @@ -118,12 +118,12 @@ pub struct Network {
///
/// if let Some(network) = networks.first() {
/// let info = nm.show_details(network).await?;
///
///
/// println!("Network: {}", info.ssid);
/// println!("Signal: {} {}", info.strength, info.bars);
/// println!("Security: {}", info.security);
/// println!("Status: {}", info.status);
///
///
/// if let Some(rate) = info.rate_mbps {
/// println!("Speed: {} Mbps", rate);
/// }
Expand Down Expand Up @@ -917,7 +917,9 @@ impl EapOptionsBuilder {
/// # Variants
///
/// - [`Open`](WifiSecurity::Open) - No authentication required (open network)
/// - [`WpaPsk`](WifiSecurity::WpaPsk) - WPA/WPA2/WPA3 Personal (password-based)
/// - [`WpaPsk`](WifiSecurity::WpaPsk) - WPA/WPA2 Personal, and WPA3 Personal on
/// transition-mode APs that still advertise PSK (password-based)
/// - [`Sae`](WifiSecurity::Sae) - WPA3 Personal on SAE-only APs (password-based)
/// - [`WpaEap`](WifiSecurity::WpaEap) - WPA/WPA2 Enterprise (802.1X authentication)
///
/// # Examples
Expand Down Expand Up @@ -970,7 +972,12 @@ impl EapOptionsBuilder {
pub enum WifiSecurity {
/// Open network (no authentication)
Open,
/// WPA-PSK (password-based authentication)
/// WPA-PSK (password-based authentication).
///
/// Emits `key-mgmt=wpa-psk`, which SAE-only WPA3-Personal access points
/// reject. [`connect`](crate::NetworkManager::connect) upgrades this to
/// [`Sae`](Self::Sae) automatically when the target AP advertises SAE
/// without PSK.
WpaPsk {
/// Pre-shared key (password)
psk: String,
Expand All @@ -986,6 +993,15 @@ pub enum WifiSecurity {
/// EAP configuration options
opts: EapOptions,
},
/// SAE (WPA3-Personal, password-based authentication).
///
/// Emits `key-mgmt=sae`. Use this for access points that advertise SAE
/// without PSK; transition-mode APs that advertise both accept
/// [`WpaPsk`](Self::WpaPsk).
Sae {
/// Pre-shared key (password)
psk: String,
},
}

impl fmt::Debug for WifiSecurity {
Expand All @@ -1004,6 +1020,10 @@ impl fmt::Debug for WifiSecurity {
.debug_struct("Wpa3Eap192bit")
.field("opts", opts)
.finish(),
Self::Sae { .. } => formatter
.debug_struct("Sae")
.field("psk", &Redacted)
.finish(),
}
}
}
Expand All @@ -1021,6 +1041,15 @@ impl WifiSecurity {
matches!(self, WifiSecurity::WpaPsk { .. })
}

/// Returns `true` if this is an SAE (WPA3-Personal) security type.
///
/// [`is_psk`](Self::is_psk) covers only `key-mgmt=wpa-psk`; callers that
/// treat both as "needs a passphrase" should check both.
#[must_use]
pub fn is_sae(&self) -> bool {
matches!(self, WifiSecurity::Sae { .. })
}

/// Returns `true` if this is a WPA-EAP (Enterprise/802.1X) security type.
#[must_use]
pub fn is_eap(&self) -> bool {
Expand Down
Loading