Skip to content

Commit c76d7fe

Browse files
committed
fix(freedesktop-rs#508): correct passphrase handling and secret serialization
1 parent 41542cf commit c76d7fe

10 files changed

Lines changed: 137 additions & 120 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ nmrs = { path = "nmrs", version = "3.0" }
3333
async-trait = "0.1.89"
3434
bitflags = "2.13.0"
3535
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "process"] }
36+
zeroize = { version = "1.9.0", features = ["aarch64", "derive", "std"] }

nmrs/CHANGELOG.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to the `nmrs` crate will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `Passphrase` keeps Wi-Fi, OpenVPN, and WireGuard secrets redacted from debug
10+
output and zeroizes their owned buffers when dropped. ([#508](https://github.com/freedesktop-rs/nmrs/pull/508))
11+
12+
### Changed
13+
14+
- **Breaking:** Wi-Fi, OpenVPN, and WireGuard secret fields now use
15+
`Passphrase` instead of `String`. ([#508](https://github.com/freedesktop-rs/nmrs/pull/508))
16+
717
### Fixed
818

919
- Resolve Bluetooth devices through the BlueZ adapter that owns their address
@@ -12,11 +22,6 @@ All notable changes to the `nmrs` crate will be documented in this file.
1222
## [3.4.1] - 2026-07-19
1323
### Added
1424

15-
- `AccessPoint::is_hidden()` and `NetworkSnapshot::hidden_access_points()`
16-
expose access points for which NetworkManager did not report an SSID. ([#496](https://github.com/freedesktop-rs/nmrs/pull/496))
17-
- An isolated Docker-based NetworkManager integration harness, including
18-
virtual WPA Wi-Fi coverage with `mac80211_hwsim`, keeps integration tests
19-
separate from developer network profiles. ([#504](https://github.com/freedesktop-rs/nmrs/pull/504))
2025
- Isolated NetworkManager integration contracts now cover saved-profile events,
2126
secret-agent registration, wired DHCP activation, and virtual WPA Wi-Fi
2227
discovery/authentication/reconnection without touching developer profiles. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505))
@@ -26,8 +31,6 @@ All notable changes to the `nmrs` crate will be documented in this file.
2631

2732
### Changed
2833

29-
- `NetworkSnapshot::wifi_groups()` now omits hidden access points; use
30-
`hidden_access_points()` when those individually reported APs are needed. ([#494](https://github.com/freedesktop-rs/nmrs/pull/494), [#496](https://github.com/freedesktop-rs/nmrs/pull/496))
3134
- Network, device, and settings monitors now return only after their initial
3235
D-Bus subscriptions are installed, so a mutation immediately after startup
3336
cannot race the subscription task. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505))
@@ -37,8 +40,6 @@ All notable changes to the `nmrs` crate will be documented in this file.
3740

3841
### Fixed
3942

40-
- Automatic Wi-Fi scans and readiness checks now skip unmanaged or unavailable
41-
radios, selecting a usable managed device when one is present. ([#504](https://github.com/freedesktop-rs/nmrs/pull/504))
4243
- Preserve complete OpenVPN, VLAN, WireGuard, Bluetooth, Wi-Fi, and access-point
4344
settings when constructing or decoding NetworkManager payloads. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505))
4445
- Preserve saved Wi-Fi profiles when stored-secret activation fails, while

nmrs/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ base64.workspace = true
2525
tokio.workspace = true
2626
async-trait.workspace = true
2727
bitflags.workspace = true
28-
zeroize = { version = "1.9.0", features = ["aarch64", "derive", "std"] }
28+
zeroize.workspace = true
2929

3030
[package.metadata.docs.rs]
3131
all-features = true

nmrs/src/api/builders/vpn.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ use zvariant::{Dict, Value, signature};
7171
use super::wireguard_builder::WireGuardBuilder;
7272
use crate::api::models::{
7373
ConnectionError, ConnectionOptions, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig,
74-
OpenVpnProxy, VpnCredentials,
74+
OpenVpnProxy, Passphrase, VpnCredentials,
7575
};
7676

7777
/// Builds WireGuard VPN connection settings.
@@ -141,6 +141,16 @@ fn push_opt_str(out: &mut Vec<(String, String)>, key: &str, value: Option<&Strin
141141
}
142142
}
143143

144+
/// Pushes a plaintext copy of a secret onto `out` if `value` is `Some`.
145+
///
146+
/// NetworkManager's VPN settings dictionary owns its strings, so the copy
147+
/// cannot retain the zeroization behavior of [`Passphrase`].
148+
fn push_opt_secret(out: &mut Vec<(String, String)>, key: &str, value: Option<&Passphrase>) {
149+
if let Some(value) = value {
150+
out.push((key.to_string(), value.expose_secret().to_owned()));
151+
}
152+
}
153+
144154
/// Pushes `(key, value.to_string())` onto `out` if `value` is `Some`.
145155
///
146156
/// Used for numeric/boolean OpenVPN options that NetworkManager stores as
@@ -301,7 +311,10 @@ pub fn build_openvpn_connection(
301311
vpn_data.push(("http-proxy-username".into(), u.clone()));
302312
}
303313
if let Some(p) = password {
304-
vpn_data.push(("http-proxy-password".into(), p.clone()));
314+
vpn_data.push((
315+
"http-proxy-password".into(),
316+
p.expose_secret().to_owned(),
317+
));
305318
}
306319
}
307320
OpenVpnProxy::Socks {
@@ -328,15 +341,11 @@ pub fn build_openvpn_connection(
328341
let data_dict = string_pairs_to_dict(vpn_data)?;
329342

330343
let mut vpn_secrets: Vec<(String, String)> = Vec::new();
331-
push_opt_display(
332-
&mut vpn_secrets,
333-
"password",
334-
config.password.clone().map(|p| p.reveal()),
335-
);
336-
push_opt_display(
344+
push_opt_secret(&mut vpn_secrets, "password", config.password.as_ref());
345+
push_opt_secret(
337346
&mut vpn_secrets,
338347
"cert-pass",
339-
config.key_password.clone().map(|p| p.reveal()),
348+
config.key_password.as_ref(),
340349
);
341350

342351
let mut vpn: HashMap<&'static str, Value<'static>> = HashMap::new();

nmrs/src/api/builders/wifi_builder.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,10 @@ impl WifiConnectionBuilder {
162162
/// routers that advertise both WPA and WPA2.
163163
#[must_use]
164164
pub fn wpa_psk(mut self, psk: impl Into<Passphrase>) -> Self {
165+
let psk = psk.into();
165166
let mut security = HashMap::new();
166167
security.insert("key-mgmt", Value::from("wpa-psk"));
167-
security.insert("psk", Value::from(psk.into().reveal()));
168+
security.insert("psk", Value::from(psk.expose_secret().to_owned()));
168169
security.insert("psk-flags", Value::from(0u32));
169170
security.insert("auth-alg", Value::from("open"));
170171

@@ -215,7 +216,10 @@ impl WifiConnectionBuilder {
215216

216217
match opts.method {
217218
EapMethod::Peap | EapMethod::Ttls => {
218-
e1x.insert("password", Value::from(opts.password.reveal()));
219+
e1x.insert(
220+
"password",
221+
Value::from(opts.password.expose_secret().to_owned()),
222+
);
219223

220224
if let Some(ai) = opts.anonymous_identity {
221225
e1x.insert("anonymous-identity", Value::from(ai));
@@ -235,7 +239,10 @@ impl WifiConnectionBuilder {
235239
}
236240

237241
if let Some(password) = opts.private_key_password {
238-
e1x.insert("private-key-password", Value::from(password.reveal()));
242+
e1x.insert(
243+
"private-key-password",
244+
Value::from(password.expose_secret().to_owned()),
245+
);
239246
}
240247

241248
if let Some(cert) =

nmrs/src/api/builders/wireguard_builder.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ impl WireGuardBuilder {
205205
}
206206

207207
// Validate private key
208-
validate_wireguard_key(private_key.reveal_ref(), "Private key")?;
208+
validate_wireguard_key(private_key.expose_secret(), "Private key")?;
209209

210210
// Validate address
211211
let (ip, prefix) = validate_address(&address)?;
@@ -246,7 +246,10 @@ impl WireGuardBuilder {
246246

247247
// Build wireguard section
248248
let mut wireguard = HashMap::new();
249-
wireguard.insert("private-key", Value::from(private_key.reveal()));
249+
wireguard.insert(
250+
"private-key",
251+
Value::from(private_key.expose_secret().to_owned()),
252+
);
250253

251254
// Build peers array
252255
let mut peers_array: Vec<HashMap<String, zvariant::Value<'static>>> = Vec::new();
@@ -259,7 +262,10 @@ impl WireGuardBuilder {
259262
peer_dict.insert("allowed-ips".into(), Value::from(peer.allowed_ips));
260263

261264
if let Some(psk) = peer.preshared_key {
262-
peer_dict.insert("preshared-key".into(), Value::from(psk.reveal()));
265+
peer_dict.insert(
266+
"preshared-key".into(),
267+
Value::from(psk.expose_secret().to_owned()),
268+
);
263269
}
264270

265271
if let Some(ka) = peer.persistent_keepalive {

nmrs/src/api/models/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod error;
99
mod monitor;
1010
mod network_event;
1111
mod openvpn;
12+
mod passphrase;
1213
mod radio;
1314
mod saved_connection;
1415
pub(crate) mod snapshot;
@@ -33,6 +34,7 @@ pub use error::*;
3334
pub use monitor::*;
3435
pub use network_event::*;
3536
pub use openvpn::*;
37+
pub use passphrase::*;
3638
pub use radio::*;
3739
pub use saved_connection::*;
3840
pub use snapshot::{AppletNetworkSummary, NetworkSnapshot};

nmrs/src/api/models/passphrase.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use std::fmt;
2+
3+
use zeroize::ZeroizeOnDrop;
4+
5+
/// An owned secret string whose buffer is zeroized when dropped.
6+
///
7+
/// `Passphrase` redacts its [`Debug`](fmt::Debug) output and keeps the secret
8+
/// wrapper attached when values are moved or destructured. Use
9+
/// [`expose_secret`](Self::expose_secret) only where plaintext access is
10+
/// required.
11+
///
12+
/// Zeroization reduces the lifetime of stale secret data in memory, but it
13+
/// cannot protect a live value from a debugger, core dump, or copies made for
14+
/// transport to NetworkManager. Cloning creates another independently
15+
/// zeroized secret allocation. Equality comparisons are not constant-time.
16+
#[non_exhaustive]
17+
#[derive(Clone, Default, Eq, PartialEq, ZeroizeOnDrop)]
18+
pub struct Passphrase(String);
19+
20+
impl Passphrase {
21+
/// Wraps an owned secret string.
22+
#[must_use]
23+
pub fn new(passphrase: String) -> Self {
24+
Self(passphrase)
25+
}
26+
27+
/// Returns the secret length in bytes.
28+
#[must_use]
29+
pub fn len(&self) -> usize {
30+
self.0.len()
31+
}
32+
33+
/// Returns `true` when the secret is empty.
34+
#[must_use]
35+
pub fn is_empty(&self) -> bool {
36+
self.0.is_empty()
37+
}
38+
39+
/// Borrows the plaintext secret.
40+
///
41+
/// Keep the borrow short-lived and do not log or persist it. Any copy made
42+
/// from this value must be cleared separately.
43+
#[must_use]
44+
pub fn expose_secret(&self) -> &str {
45+
&self.0
46+
}
47+
}
48+
49+
impl fmt::Debug for Passphrase {
50+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51+
formatter.write_str("Passphrase([REDACTED])")
52+
}
53+
}
54+
55+
impl From<String> for Passphrase {
56+
fn from(passphrase: String) -> Self {
57+
Self::new(passphrase)
58+
}
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
#[test]
66+
fn debug_output_redacts_secret() {
67+
let passphrase = Passphrase::new("correct horse battery staple".to_string());
68+
69+
let output = format!("{passphrase:?}");
70+
71+
assert_eq!(output, "Passphrase([REDACTED])");
72+
assert!(!output.contains("correct horse battery staple"));
73+
}
74+
75+
#[test]
76+
fn exposes_secret_only_when_requested() {
77+
let passphrase = Passphrase::new("secret".to_string());
78+
79+
assert_eq!(passphrase.expose_secret(), "secret");
80+
assert_eq!(passphrase.len(), 6);
81+
assert!(!passphrase.is_empty());
82+
}
83+
}

nmrs/src/api/models/wifi.rs

Lines changed: 1 addition & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
use std::fmt::Debug;
22

33
use serde::{Deserialize, Serialize};
4-
use zeroize::ZeroizeOnDrop;
5-
64
use super::access_point::{AccessPoint, SecurityFeatures};
75
use super::error::ConnectionError;
6+
use super::passphrase::Passphrase;
87
use super::saved_connection::SavedConnectionBrief;
98

109
/// Visible Wi-Fi access points grouped by interface and SSID for applet UIs.
@@ -860,97 +859,6 @@ impl EapOptionsBuilder {
860859
}
861860
}
862861

863-
/// A memory-safe wrapper around [`String`] to protect secret passphrases.
864-
///
865-
/// Guarantees that the underlying memory is zeroized on [`Drop`], preventing the passphrase from
866-
/// leaking. Also hides the passphrase from [`Debug`].
867-
///
868-
/// # Usage
869-
/// Passphrase data should always be held within a [`Passphrase`] for as long as possible within
870-
/// its lifetime.
871-
///
872-
/// [`Passphrase::reveal`] exists for flexibility and returns the inner [`String`], but it forfeits
873-
/// the protection which this type provides - use with care.
874-
///
875-
/// # Examples
876-
/// ```
877-
/// use nmrs::Passphrase;
878-
/// use zeroize::Zeroize;
879-
///
880-
/// fn main() {
881-
/// let s: String = "password".to_string();
882-
/// let mut pass = Passphrase::from(s);
883-
///
884-
/// // Get the String back if needed.
885-
/// let mut revealed = pass.reveal();
886-
///
887-
/// // ...
888-
///
889-
/// // Revealed passphrases must be zeroized manually.
890-
/// revealed.zeroize();
891-
/// }
892-
/// ```
893-
#[derive(Clone, Default, Eq, PartialEq, ZeroizeOnDrop)]
894-
pub struct Passphrase(String);
895-
896-
impl Passphrase {
897-
pub fn new(passphrase: String) -> Self {
898-
Passphrase(passphrase)
899-
}
900-
901-
pub fn len(&self) -> usize {
902-
self.0.len()
903-
}
904-
905-
pub fn is_empty(&self) -> bool {
906-
self.0.is_empty()
907-
}
908-
909-
/// Moves the inner [`String`] outside of [`Passphrase`].
910-
///
911-
/// # Security
912-
/// * [`Debug`] is no longer protected.
913-
/// * [`ZeroizeOnDrop`] will no longer apply since the inner [`String`] is returned so
914-
/// `zeroize()` *must* be called manually before [`Drop`] occurs:
915-
/// ```
916-
/// use nmrs::Passphrase;
917-
/// use zeroize::Zeroize;
918-
/// {
919-
/// let mut passphrase: Passphrase = Passphrase::new("password".to_string());
920-
/// let mut revealed = passphrase.reveal();
921-
///
922-
/// // ...
923-
///
924-
/// revealed.zeroize();
925-
/// } // Dropped here
926-
/// ```
927-
pub fn reveal(mut self) -> String {
928-
std::mem::take(&mut self.0)
929-
}
930-
931-
/// Returns a borrowed reference to the inner [`String`]. See [`Passphrase::reveal`] for moving
932-
/// the inner value.
933-
///
934-
/// # Security
935-
/// The returned reference is **not** protected by zeroization or from being logged and should not be
936-
/// cloned.
937-
pub fn reveal_ref(&self) -> &str {
938-
&self.0
939-
}
940-
}
941-
942-
impl Debug for Passphrase {
943-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
944-
f.debug_tuple("Passphrase").field(&"[REDACTED]").finish()
945-
}
946-
}
947-
948-
impl From<String> for Passphrase {
949-
fn from(s: String) -> Self {
950-
Passphrase(s)
951-
}
952-
}
953-
954862
/// Wi-Fi connection security types.
955863
///
956864
/// Represents the authentication method for connecting to a WiFi network.

0 commit comments

Comments
 (0)