From 55c81d242f7ba3ecde806f9532e397d5fc743799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 25 Feb 2026 12:41:53 +0900 Subject: [PATCH 1/8] feat(dgw): encrypt in-memory credentials Add ChaCha20-Poly1305 encryption for credentials stored in the credential store. Passwords are encrypted at rest with a master key protected via libsodium's mlock/mprotect facilities, preventing exposure in memory dumps or swap. Issue: DGW-326 --- Cargo.lock | 98 +++++++- devolutions-gateway/Cargo.toml | 4 + devolutions-gateway/src/api/preflight.rs | 17 +- devolutions-gateway/src/config.rs | 8 +- devolutions-gateway/src/credential/crypto.rs | 229 ++++++++++++++++++ .../src/{credential.rs => credential/mod.rs} | 151 ++++++------ devolutions-gateway/src/rdp_proxy.rs | 26 +- 7 files changed, 432 insertions(+), 101 deletions(-) create mode 100644 devolutions-gateway/src/credential/crypto.rs rename devolutions-gateway/src/{credential.rs => credential/mod.rs} (61%) diff --git a/Cargo.lock b/Cargo.lock index 760a0302d..41aff5206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + [[package]] name = "aead" version = "0.6.0-rc.2" @@ -46,7 +56,7 @@ version = "0.11.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0686ba04dc80c816104c96cd7782b748f6ad58c5dd4ee619ff3258cf68e83d54" dependencies = [ - "aead", + "aead 0.6.0-rc.2", "aes 0.9.0-rc.1", "cipher 0.5.0-rc.1", "ctr", @@ -867,6 +877,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.43" @@ -916,6 +950,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.6", "inout 0.1.4", + "zeroize", ] [[package]] @@ -1170,6 +1205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1215,7 +1251,7 @@ dependencies = [ "libloading", "log", "paste", - "secrecy", + "secrecy 0.8.0", ] [[package]] @@ -1448,6 +1484,7 @@ dependencies = [ "camino", "ceviche", "cfg-if", + "chacha20poly1305", "devolutions-agent-shared", "devolutions-gateway-generators", "devolutions-gateway-task", @@ -1487,10 +1524,13 @@ dependencies = [ "pin-project-lite 0.2.16", "portpicker", "proptest", + "rand 0.8.5", "reqwest", "rstest", "rustls-cng", "rustls-native-certs", + "secrecy 0.10.3", + "secrets", "serde", "serde-querystring", "serde_json", @@ -4486,6 +4526,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -4742,7 +4788,7 @@ version = "7.0.0-rc.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cdc52be663aebd70d7006ae305c87eb32a2b836d6c2f26f7e384f845d80b621" dependencies = [ - "aead", + "aead 0.6.0-rc.2", "aes 0.9.0-rc.1", "aes-gcm", "aes-kw", @@ -4801,7 +4847,7 @@ dependencies = [ "spki 0.8.0-rc.4", "thiserror 2.0.18", "time", - "universal-hash", + "universal-hash 0.6.0-rc.2", "x25519-dalek", "zeroize", ] @@ -5028,6 +5074,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "polyval" version = "0.7.0-rc.2" @@ -5036,7 +5093,7 @@ checksum = "1ffd40cc99d0fbb02b4b3771346b811df94194bc103983efa0203c8893755085" dependencies = [ "cfg-if", "cpufeatures", - "universal-hash", + "universal-hash 0.6.0-rc.2", ] [[package]] @@ -6035,6 +6092,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "secrets" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51745a213c4a2acabad80cd511e40376996bc83db6ceb4ebc7853d41c597988" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -7650,6 +7728,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.0-rc.2" diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index b8bcf4cb4..d48618cf7 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -74,6 +74,10 @@ bitflags = "2.9" # Security, crypto… picky = { version = "7.0.0-rc.15", default-features = false, features = ["jose", "x509", "pkcs12", "time_conversion"] } zeroize = { version = "1.8", features = ["derive"] } +chacha20poly1305 = "0.10" +secrets = "1.2" +secrecy = { version = "0.10", features = ["serde"] } +rand = "0.8" multibase = "0.9" argon2 = { version = "0.5", features = ["std"] } x509-cert = { version = "0.2", default-features = false, features = ["std"] } diff --git a/devolutions-gateway/src/api/preflight.rs b/devolutions-gateway/src/api/preflight.rs index 256fb80c1..377d2211a 100644 --- a/devolutions-gateway/src/api/preflight.rs +++ b/devolutions-gateway/src/api/preflight.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::DgwState; use crate::config::Conf; -use crate::credential::{AppCredentialMapping, CredentialStoreHandle}; +use crate::credential::CredentialStoreHandle; use crate::extract::PreflightScope; use crate::http::HttpError; use crate::session::SessionMessageSender; @@ -45,7 +45,7 @@ struct ProvisionTokenParams { struct ProvisionCredentialsParams { token: String, #[serde(flatten)] - mapping: AppCredentialMapping, + mapping: crate::credential::CleartextAppCredentialMapping, time_to_live: Option, } @@ -337,10 +337,19 @@ async fn handle_operation( }); } + // Encrypt passwords before storing. + let encrypted_mapping = mapping.map(|m| m.encrypt()).transpose().map_err(|e| { + error!(error = format!("{e:#}"), "Failed to encrypt credentials"); + PreflightError::new( + PreflightAlertStatus::InternalServerError, + "credential encryption failed", + ) + })?; + let previous_entry = credential_store - .insert(token, mapping, time_to_live) + .insert(token, encrypted_mapping, time_to_live) .inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials")) - .map_err(|e| PreflightError::new(PreflightAlertStatus::InvalidParams, format!("{e:#}")))?; + .map_err(|e| PreflightError::new(PreflightAlertStatus::InternalServerError, format!("{e:#}")))?; if previous_entry.is_some() { outputs.push(PreflightOutput { diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index 37f8eaef6..d96318fe8 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -9,6 +9,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use cfg_if::cfg_if; use picky::key::{PrivateKey, PublicKey}; use picky::pem::Pem; +use secrecy::SecretString; use tap::prelude::*; use tokio::sync::Notify; use tokio_rustls::rustls::pki_types; @@ -17,7 +18,6 @@ use url::Url; use uuid::Uuid; use crate::SYSTEM_LOGGER; -use crate::credential::Password; use crate::listener::ListenerUrls; use crate::target_addr::TargetAddr; use crate::token::Subkey; @@ -216,7 +216,7 @@ pub enum WebAppAuth { pub struct WebAppUser { pub name: String, /// Hash of the password, in the PHC string format - pub password_hash: Password, + pub password_hash: SecretString, } /// AI Router configuration (experimental) @@ -1243,7 +1243,7 @@ fn generate_self_signed_certificate( fn read_pfx_file( path: &Utf8Path, - password: Option<&Password>, + password: Option<&SecretString>, ) -> anyhow::Result<( Vec>, pki_types::PrivateKeyDer<'static>, @@ -1627,7 +1627,7 @@ pub mod dto { pub tls_private_key_file: Option, /// Password to use for decrypting the TLS private key #[serde(skip_serializing_if = "Option::is_none")] - pub tls_private_key_password: Option, + pub tls_private_key_password: Option, /// Subject name of the certificate to use for TLS #[serde(skip_serializing_if = "Option::is_none")] pub tls_certificate_subject_name: Option, diff --git a/devolutions-gateway/src/credential/crypto.rs b/devolutions-gateway/src/credential/crypto.rs new file mode 100644 index 000000000..9a22038ad --- /dev/null +++ b/devolutions-gateway/src/credential/crypto.rs @@ -0,0 +1,229 @@ +//! In-memory credential encryption using ChaCha20-Poly1305. +//! +//! This module provides encryption-at-rest for passwords stored in the credential store. +//! A randomly generated 256-bit master key is protected using libsodium's memory locking +//! facilities (mlock/mprotect), and passwords are encrypted using ChaCha20-Poly1305 AEAD. +//! +//! ## Security Properties +//! +//! - Master key stored in mlock'd memory (excluded from core dumps) +//! - Passwords encrypted at rest in regular heap memory +//! - Decryption on-demand into short-lived zeroized buffers +//! - ChaCha20-Poly1305 provides authenticated encryption +//! - Random 96-bit nonces prevent nonce reuse + +use core::fmt; +use std::sync::LazyLock; + +use anyhow::Context as _; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use parking_lot::Mutex; +use rand::RngCore as _; +use secrets::SecretBox; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Global master key for credential encryption. +/// +/// Initialized lazily on first access. The key material is stored in memory +/// protected by mlock/mprotect via libsodium's SecretBox, wrapped in a Mutex +/// for thread-safe access. +pub static MASTER_KEY: LazyLock> = LazyLock::new(|| { + Mutex::new(MasterKeyManager::new().expect("failed to initialize credential encryption master key")) +}); + +/// Manages the master encryption key using libsodium's secure memory facilities. +/// +/// The key is stored in memory that is: +/// - Locked (mlock) to prevent swapping to disk +/// - Protected (mprotect) with appropriate access controls +/// - Excluded from core dumps +/// - Zeroized on drop +pub struct MasterKeyManager { + // SecretBox provides mlock/mprotect for the key material. + key_material: SecretBox<[u8; 32]>, +} + +impl MasterKeyManager { + /// Generate a new random 256-bit master key. + /// + /// # Errors + /// + /// Returns error if secure memory allocation fails or RNG fails. + fn new() -> anyhow::Result { + // SecretBox allocates memory with mlock and mprotect. + let key_material = SecretBox::try_new(|key_bytes: &mut [u8; 32]| { + OsRng.fill_bytes(key_bytes); + Ok::<_, anyhow::Error>(()) + }) + .context("failed to allocate secure memory for master key")?; + + Ok(Self { key_material }) + } + + /// Encrypt a password using ChaCha20-Poly1305. + /// + /// Returns the nonce and ciphertext (which includes the Poly1305 auth tag). + pub fn encrypt(&self, plaintext: &str) -> anyhow::Result { + let key_ref = self.key_material.borrow(); + let cipher = ChaCha20Poly1305::new(Key::from_slice(key_ref.as_ref())); + + // Generate random 96-bit nonce (12 bytes for ChaCha20-Poly1305). + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt (ciphertext includes 16-byte Poly1305 tag). + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?; + + Ok(EncryptedPassword { + nonce: nonce_bytes, + ciphertext, + }) + } + + /// Decrypt a password, returning a zeroize-on-drop wrapper. + /// + /// The returned `DecryptedPassword` should have a short lifetime. + /// Use it immediately and let it drop to zeroize the plaintext. + pub fn decrypt(&self, encrypted: &EncryptedPassword) -> anyhow::Result { + let key_ref = self.key_material.borrow(); + let cipher = ChaCha20Poly1305::new(Key::from_slice(key_ref.as_ref())); + let nonce = Nonce::from_slice(&encrypted.nonce); + + let plaintext_bytes = cipher + .decrypt(nonce, encrypted.ciphertext.as_ref()) + .map_err(|e| anyhow::anyhow!("decryption failed: {e}"))?; + + // Convert bytes to String. + let plaintext = String::from_utf8(plaintext_bytes).context("decrypted password is not valid UTF-8")?; + + Ok(DecryptedPassword(plaintext)) + } +} + +// Note: SecretBox handles secure zeroization and munlock automatically on drop. + +/// Encrypted password stored in heap memory. +/// +/// Contains the nonce and ciphertext (including Poly1305 authentication tag). +/// This can be safely stored in regular memory as it's encrypted. +#[derive(Clone)] +pub struct EncryptedPassword { + /// 96-bit nonce (12 bytes) for ChaCha20-Poly1305. + nonce: [u8; 12], + + /// Ciphertext + 128-bit auth tag (plaintext_len + 16 bytes). + ciphertext: Vec, +} + +impl fmt::Debug for EncryptedPassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EncryptedPassword") + .field("ciphertext_len", &self.ciphertext.len()) + .finish_non_exhaustive() + } +} + +/// Temporarily decrypted password, zeroized on drop. +/// +/// IMPORTANT: This should have a SHORT lifetime. Decrypt immediately before use, +/// and let it drop as soon as possible to zeroize the plaintext. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct DecryptedPassword(String); + +impl DecryptedPassword { + /// Expose the plaintext password. + /// + /// IMPORTANT: Do not store the returned reference beyond the lifetime + /// of this `DecryptedPassword`. It will be zeroized when dropped. + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for DecryptedPassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DecryptedPassword").finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let key_manager = MasterKeyManager::new().unwrap(); + let plaintext = "my-secret-password"; + + let encrypted = key_manager.encrypt(plaintext).unwrap(); + let decrypted = key_manager.decrypt(&encrypted).unwrap(); + + assert_eq!(decrypted.expose_secret(), plaintext); + } + + #[test] + fn test_different_nonces() { + let key_manager = MasterKeyManager::new().unwrap(); + let plaintext = "password"; + + let encrypted1 = key_manager.encrypt(plaintext).unwrap(); + let encrypted2 = key_manager.encrypt(plaintext).unwrap(); + + // Same plaintext should produce different ciphertexts (different nonces). + assert_ne!(encrypted1.nonce, encrypted2.nonce); + assert_ne!(encrypted1.ciphertext, encrypted2.ciphertext); + } + + #[test] + fn test_wrong_key_fails_decryption() { + let key_manager1 = MasterKeyManager::new().unwrap(); + let key_manager2 = MasterKeyManager::new().unwrap(); + + let encrypted = key_manager1.encrypt("secret").unwrap(); + + // Decryption with different key should fail. + assert!(key_manager2.decrypt(&encrypted).is_err()); + } + + #[test] + fn test_corrupted_ciphertext_fails() { + let key_manager = MasterKeyManager::new().unwrap(); + let mut encrypted = key_manager.encrypt("secret").unwrap(); + + // Corrupt the ciphertext. + encrypted.ciphertext[0] ^= 0xFF; + + // Should fail authentication. + assert!(key_manager.decrypt(&encrypted).is_err()); + } + + #[test] + fn test_empty_password() { + let key_manager = MasterKeyManager::new().unwrap(); + let encrypted = key_manager.encrypt("").unwrap(); + let decrypted = key_manager.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted.expose_secret(), ""); + } + + #[test] + fn test_unicode_password() { + let key_manager = MasterKeyManager::new().unwrap(); + let plaintext = "пароль-密码-كلمة السر"; + let encrypted = key_manager.encrypt(plaintext).unwrap(); + let decrypted = key_manager.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted.expose_secret(), plaintext); + } + + #[test] + fn test_global_master_key() { + // Test that the global MASTER_KEY works. + let plaintext = "test-password"; + let encrypted = MASTER_KEY.lock().encrypt(plaintext).unwrap(); + let decrypted = MASTER_KEY.lock().decrypt(&encrypted).unwrap(); + assert_eq!(decrypted.expose_secret(), plaintext); + } +} diff --git a/devolutions-gateway/src/credential.rs b/devolutions-gateway/src/credential/mod.rs similarity index 61% rename from devolutions-gateway/src/credential.rs rename to devolutions-gateway/src/credential/mod.rs index 9779f33bb..f1e69c2b8 100644 --- a/devolutions-gateway/src/credential.rs +++ b/devolutions-gateway/src/credential/mod.rs @@ -1,3 +1,8 @@ +mod crypto; + +pub use crypto::{DecryptedPassword, EncryptedPassword, MASTER_KEY}; +use secrecy::ExposeSecret; + use core::fmt; use std::collections::HashMap; use std::sync::Arc; @@ -10,20 +15,82 @@ use serde::{de, ser}; use uuid::Uuid; /// Credential at the application protocol level +#[derive(Debug, Clone)] +pub enum AppCredential { + UsernamePassword { + username: String, + password: EncryptedPassword, + }, +} + +impl AppCredential { + /// Decrypt the password using the global master key. + /// + /// Returns the username and a short-lived decrypted password that zeroizes on drop. + pub fn decrypt_password(&self) -> anyhow::Result<(String, DecryptedPassword)> { + match self { + AppCredential::UsernamePassword { username, password } => { + let decrypted = MASTER_KEY.lock().decrypt(password)?; + Ok((username.clone(), decrypted)) + } + } + } +} + +/// Application protocol level credential mapping +#[derive(Debug, Clone)] +pub struct AppCredentialMapping { + pub proxy: AppCredential, + pub target: AppCredential, +} + +/// Cleartext credential wrapper used only for deserialization. +/// +/// This type is converted to `AppCredential` (with encrypted password) immediately after deserialization. #[derive(Debug, Deserialize)] #[serde(tag = "kind")] -pub enum AppCredential { +pub enum CleartextAppCredential { #[serde(rename = "username-password")] - UsernamePassword { username: String, password: Password }, + UsernamePassword { + username: String, + password: secrecy::SecretString, + }, +} + +impl CleartextAppCredential { + /// Encrypt the password and convert to storage-ready `AppCredential`. + pub fn encrypt(self) -> anyhow::Result { + match self { + CleartextAppCredential::UsernamePassword { username, password } => { + let encrypted = MASTER_KEY.lock().encrypt(password.expose_secret())?; + Ok(AppCredential::UsernamePassword { + username, + password: encrypted, + }) + } + } + } } -/// Application protocol level credential mapping +/// Cleartext credential mapping wrapper used only for deserialization. +/// +/// This type is converted to `AppCredentialMapping` (with encrypted passwords) immediately after deserialization. #[derive(Debug, Deserialize)] -pub struct AppCredentialMapping { +pub struct CleartextAppCredentialMapping { #[serde(rename = "proxy_credential")] - pub proxy: AppCredential, + pub proxy: CleartextAppCredential, #[serde(rename = "target_credential")] - pub target: AppCredential, + pub target: CleartextAppCredential, +} + +impl CleartextAppCredentialMapping { + /// Encrypt passwords and convert to storage-ready `AppCredentialMapping`. + pub fn encrypt(self) -> anyhow::Result { + Ok(AppCredentialMapping { + proxy: self.proxy.encrypt()?, + target: self.target.encrypt()?, + }) + } } #[derive(Debug, Clone)] @@ -99,78 +166,6 @@ impl CredentialStore { } } -#[derive(PartialEq, Eq, Clone, zeroize::Zeroize)] -pub struct Password(String); - -impl Password { - /// Do not copy the return value without wrapping into some "Zeroize"able structure - pub fn expose_secret(&self) -> &str { - &self.0 - } -} - -impl From<&str> for Password { - fn from(value: &str) -> Self { - Self(value.to_owned()) - } -} - -impl From for Password { - fn from(value: String) -> Self { - Self(value) - } -} - -impl fmt::Debug for Password { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Password").finish_non_exhaustive() - } -} - -impl<'de> de::Deserialize<'de> for Password { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct V; - - impl de::Visitor<'_> for V { - type Value = Password; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a string") - } - - fn visit_string(self, v: String) -> Result - where - E: de::Error, - { - Ok(Password(v)) - } - - fn visit_str(self, v: &str) -> Result - where - E: de::Error, - { - Ok(Password(v.to_owned())) - } - } - - let password = deserializer.deserialize_string(V)?; - - Ok(password) - } -} - -impl ser::Serialize for Password { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.0) - } -} - pub struct CleanupTask { pub handle: CredentialStoreHandle, } diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index 0da9569dd..429276285 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -402,14 +402,16 @@ where { use ironrdp_tokio::FramedWrite as _; - let credentials = match credentials { - crate::credential::AppCredential::UsernamePassword { username, password } => { - ironrdp_connector::Credentials::UsernamePassword { - username: username.clone(), - password: password.expose_secret().to_owned(), - } - } + // Decrypt password into short-lived buffer. + let (username, decrypted_password) = credentials + .decrypt_password() + .context("failed to decrypt credentials")?; + + let credentials = ironrdp_connector::Credentials::UsernamePassword { + username, + password: decrypted_password.expose_secret().to_owned(), }; + // decrypted_password drops here, zeroizing memory. let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( credentials, @@ -558,14 +560,18 @@ where where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, { - let crate::credential::AppCredential::UsernamePassword { username, password } = credentials; + // Decrypt password into short-lived buffer. + let (username, decrypted_password) = credentials + .decrypt_password() + .context("failed to decrypt credentials")?; - let username = sspi::Username::parse(username).context("invalid username")?; + let username = sspi::Username::parse(&username).context("invalid username")?; let identity = sspi::AuthIdentity { username, - password: password.expose_secret().to_owned().into(), + password: decrypted_password.expose_secret().to_owned().into(), }; + // decrypted_password drops here, zeroizing memory. let mut sequence = ironrdp_acceptor::credssp::CredsspSequence::init( &identity, From 162f6095cd10105ce308d1bfad033e8ab9bc5d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 26 Feb 2026 12:06:43 +0900 Subject: [PATCH 2/8] . --- devolutions-gateway/src/api/webapp.rs | 1 + devolutions-gateway/src/config.rs | 67 +++++++++++++++----- devolutions-gateway/src/credential/crypto.rs | 33 ++++------ devolutions-gateway/src/credential/mod.rs | 9 +-- devolutions-gateway/src/rd_clean_path.rs | 3 +- devolutions-gateway/src/rdp_proxy.rs | 5 +- 6 files changed, 76 insertions(+), 42 deletions(-) diff --git a/devolutions-gateway/src/api/webapp.rs b/devolutions-gateway/src/api/webapp.rs index 86ed8db60..2c6b99f89 100644 --- a/devolutions-gateway/src/api/webapp.rs +++ b/devolutions-gateway/src/api/webapp.rs @@ -10,6 +10,7 @@ use axum::{Json, Router, http}; use axum_extra::TypedHeader; use axum_extra::headers::{self, HeaderMapExt as _}; use picky::key::PrivateKey; +use secrecy::ExposeSecret as _; use tap::prelude::*; use tower_http::services::ServeFile; use uuid::Uuid; diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index d96318fe8..5ca9fc49f 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -9,7 +9,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use cfg_if::cfg_if; use picky::key::{PrivateKey, PublicKey}; use picky::pem::Pem; -use secrecy::SecretString; +use secrecy::{ExposeSecret as _, SecretString}; use tap::prelude::*; use tokio::sync::Notify; use tokio_rustls::rustls::pki_types; @@ -197,7 +197,7 @@ pub struct Conf { pub debug: dto::DebugConf, } -#[derive(PartialEq, Debug, Clone)] +#[derive(Debug, Clone)] pub struct WebAppConf { pub enabled: bool, pub authentication: WebAppAuth, @@ -206,13 +206,13 @@ pub struct WebAppConf { pub static_root_path: std::path::PathBuf, } -#[derive(PartialEq, Eq, Debug, Clone)] +#[derive(Debug, Clone)] pub enum WebAppAuth { Custom(HashMap), None, } -#[derive(PartialEq, Eq, Debug, Clone)] +#[derive(Debug, Clone)] pub struct WebAppUser { pub name: String, /// Hash of the password, in the PHC string format @@ -1256,7 +1256,8 @@ fn read_pfx_file( use picky::x509::certificate::CertType; let crypto_context = password - .map(|pwd| Pkcs12CryptoContext::new_with_password(pwd.expose_secret())) + .map(|secret| secret.expose_secret()) + .map(Pkcs12CryptoContext::new_with_password) .unwrap_or_else(Pkcs12CryptoContext::new_without_password); let parsing_params = Pkcs12ParsingParams::default(); @@ -1577,6 +1578,8 @@ fn to_listener_urls(conf: &dto::ListenerConf, hostname: &str, auto_ipv6: bool) - pub mod dto { use std::collections::HashMap; + use secrecy::ExposeSecret as _; + use super::*; /// Source of truth for Gateway configuration @@ -1585,7 +1588,7 @@ pub mod dto { /// and is not trying to be too smart. /// /// Unstable options are subject to change - #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct ConfFile { /// This Gateway unique ID (e.g.: 123e4567-e89b-12d3-a456-426614174000) @@ -1626,7 +1629,10 @@ pub mod dto { #[serde(alias = "PrivateKeyFile", skip_serializing_if = "Option::is_none")] pub tls_private_key_file: Option, /// Password to use for decrypting the TLS private key - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_opt_secret_string" + )] pub tls_private_key_password: Option, /// Subject name of the certificate to use for TLS #[serde(skip_serializing_if = "Option::is_none")] @@ -1661,8 +1667,11 @@ pub mod dto { pub credssp_private_key_file: Option, /// Password to use for decrypting the CredSSP private key - #[serde(skip_serializing_if = "Option::is_none")] - pub credssp_private_key_password: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_opt_secret_string" + )] + pub credssp_private_key_password: Option, /// Listeners to launch at startup #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1811,7 +1820,7 @@ pub mod dto { } /// Domain user credentials. - #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DomainUser { /// Username in FQDN format (e.g. "pw13@example.com"). /// @@ -1819,7 +1828,8 @@ pub mod dto { /// The KDC realm is derived from the gateway ID using the [KerberosServer::realm] method. pub fqdn: String, /// User password. - pub password: String, + #[serde(serialize_with = "serialize_secret_string")] + pub password: SecretString, /// Salt for generating the user's key. /// /// Usually, it is equal to `{REALM}{username}` (e.g. "EXAMPLEpw13"). @@ -1832,7 +1842,7 @@ pub mod dto { Self { username: fqdn, - password, + password: password.expose_secret().to_owned(), salt, } } @@ -1841,7 +1851,7 @@ pub mod dto { /// Kerberos server config /// /// This config is used to configure the Kerberos server during RDP proxying. - #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KerberosServer { /// Users credentials inside fake KDC. pub users: Vec, @@ -1896,7 +1906,7 @@ pub mod dto { } /// The Kerberos credentials-injection configuration. - #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KerberosConfig { /// Kerberos server and KDC configuration. pub kerberos_server: KerberosServer, @@ -1910,7 +1920,7 @@ pub mod dto { /// /// Note to developers: all options should be safe by default, never add an option /// that needs to be overridden manually in order to be safe. - #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DebugConf { /// Dump received tokens using a `debug` statement #[serde(default)] @@ -1974,7 +1984,15 @@ pub mod dto { impl DebugConf { pub fn is_default(&self) -> bool { - Self::default().eq(self) + !self.dump_tokens + && !self.disable_token_validation + && self.override_kdc.is_none() + && self.log_directives.is_none() + && self.capture_path.is_none() + && self.lib_xmf_path.is_none() + && !self.enable_unstable + && self.kerberos.is_none() + && self.ws_keep_alive_interval == ws_keep_alive_interval_default_value() } } @@ -2355,6 +2373,23 @@ pub mod dto { } } + fn serialize_secret_string(value: &SecretString, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(value.expose_secret()) + } + + fn serialize_opt_secret_string(value: &Option, serializer: S) -> Result + where + S: serde::Serializer, + { + match value { + Some(s) => serializer.serialize_str(s.expose_secret()), + None => serializer.serialize_none(), + } + } + impl ProxyConf { /// Convert this DTO to the http-client-proxy ProxyConfig. pub fn to_proxy_config(&self) -> http_client_proxy::ProxyConfig { diff --git a/devolutions-gateway/src/credential/crypto.rs b/devolutions-gateway/src/credential/crypto.rs index 9a22038ad..aa22d6b37 100644 --- a/devolutions-gateway/src/credential/crypto.rs +++ b/devolutions-gateway/src/credential/crypto.rs @@ -16,8 +16,8 @@ use core::fmt; use std::sync::LazyLock; use anyhow::Context as _; -use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; -use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use parking_lot::Mutex; use rand::RngCore as _; use secrets::SecretBox; @@ -28,7 +28,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; /// Initialized lazily on first access. The key material is stored in memory /// protected by mlock/mprotect via libsodium's SecretBox, wrapped in a Mutex /// for thread-safe access. -pub static MASTER_KEY: LazyLock> = LazyLock::new(|| { +pub(super) static MASTER_KEY: LazyLock> = LazyLock::new(|| { Mutex::new(MasterKeyManager::new().expect("failed to initialize credential encryption master key")) }); @@ -39,7 +39,7 @@ pub static MASTER_KEY: LazyLock> = LazyLock::new(|| { /// - Protected (mprotect) with appropriate access controls /// - Excluded from core dumps /// - Zeroized on drop -pub struct MasterKeyManager { +pub(super) struct MasterKeyManager { // SecretBox provides mlock/mprotect for the key material. key_material: SecretBox<[u8; 32]>, } @@ -64,37 +64,31 @@ impl MasterKeyManager { /// Encrypt a password using ChaCha20-Poly1305. /// /// Returns the nonce and ciphertext (which includes the Poly1305 auth tag). - pub fn encrypt(&self, plaintext: &str) -> anyhow::Result { + pub(super) fn encrypt(&self, plaintext: &str) -> anyhow::Result { let key_ref = self.key_material.borrow(); - let cipher = ChaCha20Poly1305::new(Key::from_slice(key_ref.as_ref())); + let cipher = ChaCha20Poly1305::new_from_slice(key_ref.as_ref()).expect("key is exactly 32 bytes"); // Generate random 96-bit nonce (12 bytes for ChaCha20-Poly1305). - let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); + let nonce = ChaCha20Poly1305::generate_nonce(OsRng); // Encrypt (ciphertext includes 16-byte Poly1305 tag). let ciphertext = cipher - .encrypt(nonce, plaintext.as_bytes()) + .encrypt(&nonce, plaintext.as_bytes()) .map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?; - Ok(EncryptedPassword { - nonce: nonce_bytes, - ciphertext, - }) + Ok(EncryptedPassword { nonce, ciphertext }) } /// Decrypt a password, returning a zeroize-on-drop wrapper. /// /// The returned `DecryptedPassword` should have a short lifetime. /// Use it immediately and let it drop to zeroize the plaintext. - pub fn decrypt(&self, encrypted: &EncryptedPassword) -> anyhow::Result { + pub(super) fn decrypt(&self, encrypted: &EncryptedPassword) -> anyhow::Result { let key_ref = self.key_material.borrow(); - let cipher = ChaCha20Poly1305::new(Key::from_slice(key_ref.as_ref())); - let nonce = Nonce::from_slice(&encrypted.nonce); + let cipher = ChaCha20Poly1305::new_from_slice(key_ref.as_ref()).expect("key is exactly 32 bytes"); let plaintext_bytes = cipher - .decrypt(nonce, encrypted.ciphertext.as_ref()) + .decrypt(&encrypted.nonce, encrypted.ciphertext.as_ref()) .map_err(|e| anyhow::anyhow!("decryption failed: {e}"))?; // Convert bytes to String. @@ -113,7 +107,7 @@ impl MasterKeyManager { #[derive(Clone)] pub struct EncryptedPassword { /// 96-bit nonce (12 bytes) for ChaCha20-Poly1305. - nonce: [u8; 12], + nonce: Nonce, /// Ciphertext + 128-bit auth tag (plaintext_len + 16 bytes). ciphertext: Vec, @@ -151,6 +145,7 @@ impl fmt::Debug for DecryptedPassword { } #[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code, panics are expected")] mod tests { use super::*; diff --git a/devolutions-gateway/src/credential/mod.rs b/devolutions-gateway/src/credential/mod.rs index f1e69c2b8..3d12aabe8 100644 --- a/devolutions-gateway/src/credential/mod.rs +++ b/devolutions-gateway/src/credential/mod.rs @@ -1,9 +1,8 @@ mod crypto; -pub use crypto::{DecryptedPassword, EncryptedPassword, MASTER_KEY}; -use secrecy::ExposeSecret; +#[rustfmt::skip] +pub use crypto::{DecryptedPassword, EncryptedPassword}; -use core::fmt; use std::collections::HashMap; use std::sync::Arc; @@ -11,9 +10,11 @@ use anyhow::Context; use async_trait::async_trait; use devolutions_gateway_task::{ShutdownSignal, Task}; use parking_lot::Mutex; -use serde::{de, ser}; +use secrecy::ExposeSecret as _; use uuid::Uuid; +use self::crypto::MASTER_KEY; + /// Credential at the application protocol level #[derive(Debug, Clone)] pub enum AppCredential { diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 436b690c0..10ff4cd5b 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use anyhow::Context as _; use ironrdp_connector::sspi; +use secrecy::ExposeSecret as _; use ironrdp_pdu::nego; use ironrdp_rdcleanpath::RDCleanPathPdu; use tap::prelude::*; @@ -404,7 +405,7 @@ async fn handle_with_credential_injection( } = user; // The username is in the FQDN format. Thus, the domain field can be empty. - sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password)) + sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password.expose_secret())) }); Some(sspi::KerberosServerConfig { diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index 429276285..493a85187 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Context as _; use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; +use secrecy::ExposeSecret as _; use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; use ironrdp_connector::sspi; use ironrdp_connector::sspi::generator::{GeneratorState, NetworkRequest}; @@ -130,8 +131,8 @@ where salt: _, } = user; - // The username is in the FQDN format. Thus, the domain field can be empty. - sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password)) + // The username is an the FQDN format. Thus, the domain field can be empty. + sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password.expose_secret())) }); Some(sspi::KerberosServerConfig { From 5124a4cca831e51a3afc948116ae060c72f61c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 26 Feb 2026 12:15:37 +0900 Subject: [PATCH 3/8] . --- devolutions-gateway/src/api/preflight.rs | 11 +---------- devolutions-gateway/src/credential/mod.rs | 18 +++++++++--------- devolutions-gateway/src/rdp_proxy.rs | 6 ++++-- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/devolutions-gateway/src/api/preflight.rs b/devolutions-gateway/src/api/preflight.rs index 377d2211a..afa2e1eea 100644 --- a/devolutions-gateway/src/api/preflight.rs +++ b/devolutions-gateway/src/api/preflight.rs @@ -337,17 +337,8 @@ async fn handle_operation( }); } - // Encrypt passwords before storing. - let encrypted_mapping = mapping.map(|m| m.encrypt()).transpose().map_err(|e| { - error!(error = format!("{e:#}"), "Failed to encrypt credentials"); - PreflightError::new( - PreflightAlertStatus::InternalServerError, - "credential encryption failed", - ) - })?; - let previous_entry = credential_store - .insert(token, encrypted_mapping, time_to_live) + .insert(token, mapping, time_to_live) .inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials")) .map_err(|e| PreflightError::new(PreflightAlertStatus::InternalServerError, format!("{e:#}")))?; diff --git a/devolutions-gateway/src/credential/mod.rs b/devolutions-gateway/src/credential/mod.rs index 3d12aabe8..9bd1114da 100644 --- a/devolutions-gateway/src/credential/mod.rs +++ b/devolutions-gateway/src/credential/mod.rs @@ -45,9 +45,10 @@ pub struct AppCredentialMapping { pub target: AppCredential, } -/// Cleartext credential wrapper used only for deserialization. +/// Cleartext credential received from the API, used for deserialization only. /// -/// This type is converted to `AppCredential` (with encrypted password) immediately after deserialization. +/// Passwords are encrypted and stored as [`AppCredential`] inside the credential store. +/// This type is never stored directly — hand it to [`CredentialStoreHandle::insert`]. #[derive(Debug, Deserialize)] #[serde(tag = "kind")] pub enum CleartextAppCredential { @@ -59,8 +60,7 @@ pub enum CleartextAppCredential { } impl CleartextAppCredential { - /// Encrypt the password and convert to storage-ready `AppCredential`. - pub fn encrypt(self) -> anyhow::Result { + fn encrypt(self) -> anyhow::Result { match self { CleartextAppCredential::UsernamePassword { username, password } => { let encrypted = MASTER_KEY.lock().encrypt(password.expose_secret())?; @@ -73,9 +73,9 @@ impl CleartextAppCredential { } } -/// Cleartext credential mapping wrapper used only for deserialization. +/// Cleartext credential mapping received from the API, used for deserialization only. /// -/// This type is converted to `AppCredentialMapping` (with encrypted passwords) immediately after deserialization. +/// Passwords are encrypted on write. Hand this directly to [`CredentialStoreHandle::insert`]. #[derive(Debug, Deserialize)] pub struct CleartextAppCredentialMapping { #[serde(rename = "proxy_credential")] @@ -85,8 +85,7 @@ pub struct CleartextAppCredentialMapping { } impl CleartextAppCredentialMapping { - /// Encrypt passwords and convert to storage-ready `AppCredentialMapping`. - pub fn encrypt(self) -> anyhow::Result { + fn encrypt(self) -> anyhow::Result { Ok(AppCredentialMapping { proxy: self.proxy.encrypt()?, target: self.target.encrypt()?, @@ -111,9 +110,10 @@ impl CredentialStoreHandle { pub fn insert( &self, token: String, - mapping: Option, + mapping: Option, time_to_live: time::Duration, ) -> anyhow::Result> { + let mapping = mapping.map(CleartextAppCredentialMapping::encrypt).transpose()?; self.0.lock().insert(token, mapping, time_to_live) } diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index 493a85187..c8cfda6ea 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -412,7 +412,8 @@ where username, password: decrypted_password.expose_secret().to_owned(), }; - // decrypted_password drops here, zeroizing memory. + // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext + // remains in `credentials` above, which is a regular String (downstream API limitation). let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( credentials, @@ -572,7 +573,8 @@ where username, password: decrypted_password.expose_secret().to_owned().into(), }; - // decrypted_password drops here, zeroizing memory. + // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext + // remains in `identity` above (downstream API limitation). let mut sequence = ironrdp_acceptor::credssp::CredsspSequence::init( &identity, From c10ad7084f21a3f5f45c4ff8c899f32d0bbd485d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:00:54 +0900 Subject: [PATCH 4/8] fix(dgw): address reviewer feedback on in-memory credential encryption (#1690) --- README.md | 7 ++++- devolutions-gateway/src/api/preflight.rs | 11 +++++-- devolutions-gateway/src/credential/mod.rs | 36 ++++++++++++++++++++--- devolutions-gateway/src/rd_clean_path.rs | 8 +++-- devolutions-gateway/src/rdp_proxy.rs | 10 +++++-- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f61fefe19..6d53769d2 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,16 @@ immediately, without going through the acceptance testing process of our quality ### From sources -Ensure that you have [the Rust toolchain installed][install_rust], then clone this repository and run: +Ensure that you have [the Rust toolchain installed][install_rust] and [libsodium][libsodium] installed on your system, then clone this repository and run: ```shell cargo install --path ./devolutions-gateway ``` +> **Note:** `libsodium` is required as a native dependency for in-memory credential protection. +> On Windows, it is vendored automatically via vcpkg. +> On Linux and macOS, install it using your system package manager (e.g., `apt install libsodium-dev` or `brew install libsodium`). + ## Configuration Devolutions Gateway is configured using a JSON document. @@ -339,6 +343,7 @@ See the dedicated [README.md file](./.github/workflows/README.md) in the `workfl [official_website]: https://devolutions.net/gateway/download/ [github_release]: https://github.com/Devolutions/devolutions-gateway/releases [install_rust]: https://www.rust-lang.org/tools/install +[libsodium]: https://libsodium.org/ [psmodule]: https://www.powershellgallery.com/packages/DevolutionsGateway/ [rustls]: https://crates.io/crates/rustls [microsoft_tls]: https://learn.microsoft.com/en-us/windows-server/security/tls/tls-registry-settings diff --git a/devolutions-gateway/src/api/preflight.rs b/devolutions-gateway/src/api/preflight.rs index afa2e1eea..24929b5f1 100644 --- a/devolutions-gateway/src/api/preflight.rs +++ b/devolutions-gateway/src/api/preflight.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::DgwState; use crate::config::Conf; -use crate::credential::CredentialStoreHandle; +use crate::credential::{CredentialStoreHandle, InsertError}; use crate::extract::PreflightScope; use crate::http::HttpError; use crate::session::SessionMessageSender; @@ -340,7 +340,14 @@ async fn handle_operation( let previous_entry = credential_store .insert(token, mapping, time_to_live) .inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials")) - .map_err(|e| PreflightError::new(PreflightAlertStatus::InternalServerError, format!("{e:#}")))?; + .map_err(|e| match e { + InsertError::InvalidToken(_) => { + PreflightError::new(PreflightAlertStatus::InvalidParams, format!("{e:#}")) + } + InsertError::Internal(_) => { + PreflightError::new(PreflightAlertStatus::InternalServerError, format!("{e:#}")) + } + })?; if previous_entry.is_some() { outputs.push(PreflightOutput { diff --git a/devolutions-gateway/src/credential/mod.rs b/devolutions-gateway/src/credential/mod.rs index 9bd1114da..792e8bb1e 100644 --- a/devolutions-gateway/src/credential/mod.rs +++ b/devolutions-gateway/src/credential/mod.rs @@ -4,6 +4,7 @@ mod crypto; pub use crypto::{DecryptedPassword, EncryptedPassword}; use std::collections::HashMap; +use std::fmt; use std::sync::Arc; use anyhow::Context; @@ -15,6 +16,28 @@ use uuid::Uuid; use self::crypto::MASTER_KEY; +/// Error returned by [`CredentialStoreHandle::insert`]. +#[derive(Debug)] +pub enum InsertError { + /// The provided token is invalid (e.g., missing or malformed JTI). + /// + /// This is a client-side error: the caller supplied bad input. + InvalidToken(anyhow::Error), + /// An internal error occurred (e.g., encryption failure). + Internal(anyhow::Error), +} + +impl fmt::Display for InsertError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidToken(e) => e.fmt(f), + Self::Internal(e) => e.fmt(f), + } + } +} + +impl std::error::Error for InsertError {} + /// Credential at the application protocol level #[derive(Debug, Clone)] pub enum AppCredential { @@ -112,8 +135,11 @@ impl CredentialStoreHandle { token: String, mapping: Option, time_to_live: time::Duration, - ) -> anyhow::Result> { - let mapping = mapping.map(CleartextAppCredentialMapping::encrypt).transpose()?; + ) -> Result, InsertError> { + let mapping = mapping + .map(CleartextAppCredentialMapping::encrypt) + .transpose() + .map_err(InsertError::Internal)?; self.0.lock().insert(token, mapping, time_to_live) } @@ -148,8 +174,10 @@ impl CredentialStore { token: String, mapping: Option, time_to_live: time::Duration, - ) -> anyhow::Result> { - let jti = crate::token::extract_jti(&token).context("failed to extract token ID")?; + ) -> Result, InsertError> { + let jti = crate::token::extract_jti(&token) + .context("failed to extract token ID") + .map_err(InsertError::InvalidToken)?; let entry = CredentialEntry { token, diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 10ff4cd5b..6d4614b5e 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use anyhow::Context as _; use ironrdp_connector::sspi; -use secrecy::ExposeSecret as _; use ironrdp_pdu::nego; use ironrdp_rdcleanpath::RDCleanPathPdu; +use secrecy::ExposeSecret as _; use tap::prelude::*; use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; @@ -405,7 +405,11 @@ async fn handle_with_credential_injection( } = user; // The username is in the FQDN format. Thus, the domain field can be empty. - sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password.expose_secret())) + sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( + fqdn, + "", + password.expose_secret(), + )) }); Some(sspi::KerberosServerConfig { diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index c8cfda6ea..b3dc466a7 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -3,11 +3,11 @@ use std::sync::Arc; use anyhow::Context as _; use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; -use secrecy::ExposeSecret as _; use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; use ironrdp_connector::sspi; use ironrdp_connector::sspi::generator::{GeneratorState, NetworkRequest}; use ironrdp_pdu::{mcs, nego, x224}; +use secrecy::ExposeSecret as _; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use typed_builder::TypedBuilder; @@ -131,8 +131,12 @@ where salt: _, } = user; - // The username is an the FQDN format. Thus, the domain field can be empty. - sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password.expose_secret())) + // The username is in the FQDN format. Thus, the domain field can be empty. + sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( + fqdn, + "", + password.expose_secret(), + )) }); Some(sspi::KerberosServerConfig { From 8bd98359ec8ef43e766db3f26a625d1dada6a0cf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:41:00 +0900 Subject: [PATCH 5/8] feat(dgw): make libsodium (secrets) an optional mlock feature (#1691) --- .github/workflows/ci.yml | 20 ++++++- README.md | 14 ++++- devolutions-gateway/Cargo.toml | 3 +- devolutions-gateway/src/credential/crypto.rs | 58 +++++++++++++++----- devolutions-gateway/src/service.rs | 8 +++ 5 files changed, 83 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd38c72c..fe2fddbe0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -620,13 +620,13 @@ jobs: if: ${{ matrix.os == 'linux' }} run: | sudo apt-get update - sudo apt-get -o Acquire::Retries=3 install python3-wget python3-setuptools libsystemd-dev dh-make + sudo apt-get -o Acquire::Retries=3 install python3-wget python3-setuptools libsystemd-dev dh-make libsodium-dev - name: Configure Linux (arm) runner if: ${{ matrix.os == 'linux' && matrix.arch == 'arm64' }} run: | sudo dpkg --add-architecture arm64 - sudo apt-get -o Acquire::Retries=3 install -qy binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user + sudo apt-get -o Acquire::Retries=3 install -qy binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user libsodium-dev:arm64 rustup target add aarch64-unknown-linux-gnu echo "STRIP_EXECUTABLE=aarch64-linux-gnu-strip" >> $GITHUB_ENV @@ -663,6 +663,22 @@ jobs: Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 shell: pwsh + + - name: Enable mlock for production + # arm64 is excluded: libsodium cross-compilation is not supported in the cbake sysroot. + # arm64 production builds will emit a startup warning about missing mlock protection. + # On Linux, libsodium-dev is installed in the configure steps above (apt-get). + # On Windows, libsodium is installed here via vcpkg (deferred to production to avoid slow builds on PRs). + if: ${{ needs.preflight.outputs.rust-profile == 'production' && matrix.arch != 'arm64' }} + run: | + if ($Env:RUNNER_OS -eq "Windows") { + # Install libsodium via vcpkg for the mlock feature (requires static library) + vcpkg install libsodium:x64-windows-static + echo "VCPKG_ROOT=$Env:VCPKG_INSTALLATION_ROOT" >> $Env:GITHUB_ENV + } + echo "CARGO_FEATURES=mlock" >> $Env:GITHUB_ENV + shell: pwsh + - name: Build run: | if ($Env:RUNNER_OS -eq "Linux") { diff --git a/README.md b/README.md index 6d53769d2..31709e049 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,23 @@ immediately, without going through the acceptance testing process of our quality ### From sources -Ensure that you have [the Rust toolchain installed][install_rust] and [libsodium][libsodium] installed on your system, then clone this repository and run: +Ensure that you have [the Rust toolchain installed][install_rust] and then clone this repository and run: ```shell cargo install --path ./devolutions-gateway ``` -> **Note:** `libsodium` is required as a native dependency for in-memory credential protection. -> On Windows, it is vendored automatically via vcpkg. +To enable enhanced in-memory credential protection (mlock via libsodium), build with the `mlock` feature: + +```shell +cargo install --path ./devolutions-gateway --features mlock +``` + +> **Note:** The `mlock` feature requires [libsodium][libsodium] to be installed. +> On Windows, it is found automatically via vcpkg. > On Linux and macOS, install it using your system package manager (e.g., `apt install libsodium-dev` or `brew install libsodium`). +> Production builds should always include the `mlock` feature. +> Without it, a startup warning is emitted in release builds. ## Configuration diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index d48618cf7..3ec30064f 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [features] default = [] +mlock = ["dep:secrets"] openapi = ["dep:utoipa"] [dependencies] @@ -75,7 +76,7 @@ bitflags = "2.9" picky = { version = "7.0.0-rc.15", default-features = false, features = ["jose", "x509", "pkcs12", "time_conversion"] } zeroize = { version = "1.8", features = ["derive"] } chacha20poly1305 = "0.10" -secrets = "1.2" +secrets = { version = "1.2", optional = true } secrecy = { version = "0.10", features = ["serde"] } rand = "0.8" multibase = "0.9" diff --git a/devolutions-gateway/src/credential/crypto.rs b/devolutions-gateway/src/credential/crypto.rs index aa22d6b37..2dec522e3 100644 --- a/devolutions-gateway/src/credential/crypto.rs +++ b/devolutions-gateway/src/credential/crypto.rs @@ -1,16 +1,19 @@ //! In-memory credential encryption using ChaCha20-Poly1305. //! //! This module provides encryption-at-rest for passwords stored in the credential store. -//! A randomly generated 256-bit master key is protected using libsodium's memory locking -//! facilities (mlock/mprotect), and passwords are encrypted using ChaCha20-Poly1305 AEAD. +//! A randomly generated 256-bit master key is stored in a zeroize-on-drop wrapper. +//! When the `mlock` feature is enabled, libsodium's memory locking facilities +//! (mlock/mprotect) are additionally used to prevent the key from being swapped to +//! disk or appearing in core dumps. //! //! ## Security Properties //! -//! - Master key stored in mlock'd memory (excluded from core dumps) //! - Passwords encrypted at rest in regular heap memory //! - Decryption on-demand into short-lived zeroized buffers //! - ChaCha20-Poly1305 provides authenticated encryption //! - Random 96-bit nonces prevent nonce reuse +//! - Master key zeroized on drop +//! - With `mlock` feature: Master key stored in mlock'd memory (excluded from core dumps) use core::fmt; use std::sync::LazyLock; @@ -20,28 +23,33 @@ use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use parking_lot::Mutex; use rand::RngCore as _; +#[cfg(feature = "mlock")] use secrets::SecretBox; +#[cfg(not(feature = "mlock"))] +use zeroize::Zeroizing; use zeroize::{Zeroize, ZeroizeOnDrop}; /// Global master key for credential encryption. /// -/// Initialized lazily on first access. The key material is stored in memory -/// protected by mlock/mprotect via libsodium's SecretBox, wrapped in a Mutex -/// for thread-safe access. +/// Initialized lazily on first access. The key material is wrapped in a Mutex +/// for thread-safe access. With the `mlock` feature, key memory is additionally +/// protected by mlock/mprotect via libsodium's SecretBox. pub(super) static MASTER_KEY: LazyLock> = LazyLock::new(|| { Mutex::new(MasterKeyManager::new().expect("failed to initialize credential encryption master key")) }); -/// Manages the master encryption key using libsodium's secure memory facilities. +/// Manages the master encryption key. /// -/// The key is stored in memory that is: +/// The key is zeroized on drop. When the `mlock` feature is enabled, the key +/// memory is additionally: /// - Locked (mlock) to prevent swapping to disk /// - Protected (mprotect) with appropriate access controls /// - Excluded from core dumps -/// - Zeroized on drop pub(super) struct MasterKeyManager { - // SecretBox provides mlock/mprotect for the key material. + #[cfg(feature = "mlock")] key_material: SecretBox<[u8; 32]>, + #[cfg(not(feature = "mlock"))] + key_material: Zeroizing<[u8; 32]>, } impl MasterKeyManager { @@ -51,13 +59,20 @@ impl MasterKeyManager { /// /// Returns error if secure memory allocation fails or RNG fails. fn new() -> anyhow::Result { - // SecretBox allocates memory with mlock and mprotect. + #[cfg(feature = "mlock")] let key_material = SecretBox::try_new(|key_bytes: &mut [u8; 32]| { OsRng.fill_bytes(key_bytes); Ok::<_, anyhow::Error>(()) }) .context("failed to allocate secure memory for master key")?; + #[cfg(not(feature = "mlock"))] + let key_material = { + let mut key = Zeroizing::new([0u8; 32]); + OsRng.fill_bytes(key.as_mut()); + key + }; + Ok(Self { key_material }) } @@ -65,8 +80,15 @@ impl MasterKeyManager { /// /// Returns the nonce and ciphertext (which includes the Poly1305 auth tag). pub(super) fn encrypt(&self, plaintext: &str) -> anyhow::Result { + #[cfg(feature = "mlock")] let key_ref = self.key_material.borrow(); - let cipher = ChaCha20Poly1305::new_from_slice(key_ref.as_ref()).expect("key is exactly 32 bytes"); + #[cfg(feature = "mlock")] + let key_bytes: &[u8] = key_ref.as_ref(); + + #[cfg(not(feature = "mlock"))] + let key_bytes: &[u8] = self.key_material.as_ref(); + + let cipher = ChaCha20Poly1305::new_from_slice(key_bytes).expect("key is exactly 32 bytes"); // Generate random 96-bit nonce (12 bytes for ChaCha20-Poly1305). let nonce = ChaCha20Poly1305::generate_nonce(OsRng); @@ -84,8 +106,15 @@ impl MasterKeyManager { /// The returned `DecryptedPassword` should have a short lifetime. /// Use it immediately and let it drop to zeroize the plaintext. pub(super) fn decrypt(&self, encrypted: &EncryptedPassword) -> anyhow::Result { + #[cfg(feature = "mlock")] let key_ref = self.key_material.borrow(); - let cipher = ChaCha20Poly1305::new_from_slice(key_ref.as_ref()).expect("key is exactly 32 bytes"); + #[cfg(feature = "mlock")] + let key_bytes: &[u8] = key_ref.as_ref(); + + #[cfg(not(feature = "mlock"))] + let key_bytes: &[u8] = self.key_material.as_ref(); + + let cipher = ChaCha20Poly1305::new_from_slice(key_bytes).expect("key is exactly 32 bytes"); let plaintext_bytes = cipher .decrypt(&encrypted.nonce, encrypted.ciphertext.as_ref()) @@ -98,7 +127,8 @@ impl MasterKeyManager { } } -// Note: SecretBox handles secure zeroization and munlock automatically on drop. +// Note: With `mlock` feature, SecretBox handles secure zeroization and munlock automatically on drop. +// Without `mlock` feature, Zeroizing handles secure zeroization on drop (no mlock). /// Encrypted password stored in heap memory. /// diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 64dde91c4..4b28cbf42 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -49,6 +49,14 @@ impl GatewayService { info!(version = env!("CARGO_PKG_VERSION")); + // Warn in release builds if the mlock security feature is not compiled in. + #[cfg(all(not(feature = "mlock"), not(debug_assertions)))] + warn!( + "Credential encryption master key does not have mlock memory protection. \ + Rebuild with the `mlock` feature (requires libsodium) to prevent key exposure \ + in core dumps and swap." + ); + let conf_file = conf_handle.get_conf_file(); trace!(?conf_file); From bada7f56b79562898bc125266f20d6b4f17d6a55 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:04:13 +0900 Subject: [PATCH 6/8] refactor(dgw): use secrecy::SecretString for decrypted passwords (#1692) --- devolutions-gateway/src/credential/crypto.rs | 35 ++++---------------- devolutions-gateway/src/credential/mod.rs | 4 +-- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/devolutions-gateway/src/credential/crypto.rs b/devolutions-gateway/src/credential/crypto.rs index 2dec522e3..8f34a97d2 100644 --- a/devolutions-gateway/src/credential/crypto.rs +++ b/devolutions-gateway/src/credential/crypto.rs @@ -23,11 +23,11 @@ use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use parking_lot::Mutex; use rand::RngCore as _; +use secrecy::SecretString; #[cfg(feature = "mlock")] use secrets::SecretBox; #[cfg(not(feature = "mlock"))] use zeroize::Zeroizing; -use zeroize::{Zeroize, ZeroizeOnDrop}; /// Global master key for credential encryption. /// @@ -101,11 +101,11 @@ impl MasterKeyManager { Ok(EncryptedPassword { nonce, ciphertext }) } - /// Decrypt a password, returning a zeroize-on-drop wrapper. + /// Decrypt a password, returning a `SecretString` that zeroizes on drop. /// - /// The returned `DecryptedPassword` should have a short lifetime. + /// The returned `SecretString` should have a short lifetime. /// Use it immediately and let it drop to zeroize the plaintext. - pub(super) fn decrypt(&self, encrypted: &EncryptedPassword) -> anyhow::Result { + pub(super) fn decrypt(&self, encrypted: &EncryptedPassword) -> anyhow::Result { #[cfg(feature = "mlock")] let key_ref = self.key_material.borrow(); #[cfg(feature = "mlock")] @@ -123,7 +123,7 @@ impl MasterKeyManager { // Convert bytes to String. let plaintext = String::from_utf8(plaintext_bytes).context("decrypted password is not valid UTF-8")?; - Ok(DecryptedPassword(plaintext)) + Ok(SecretString::from(plaintext)) } } @@ -151,32 +151,11 @@ impl fmt::Debug for EncryptedPassword { } } -/// Temporarily decrypted password, zeroized on drop. -/// -/// IMPORTANT: This should have a SHORT lifetime. Decrypt immediately before use, -/// and let it drop as soon as possible to zeroize the plaintext. -#[derive(Zeroize, ZeroizeOnDrop)] -pub struct DecryptedPassword(String); - -impl DecryptedPassword { - /// Expose the plaintext password. - /// - /// IMPORTANT: Do not store the returned reference beyond the lifetime - /// of this `DecryptedPassword`. It will be zeroized when dropped. - pub fn expose_secret(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for DecryptedPassword { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("DecryptedPassword").finish_non_exhaustive() - } -} - #[cfg(test)] #[expect(clippy::unwrap_used, reason = "test code, panics are expected")] mod tests { + use secrecy::ExposeSecret as _; + use super::*; #[test] diff --git a/devolutions-gateway/src/credential/mod.rs b/devolutions-gateway/src/credential/mod.rs index 792e8bb1e..166be9412 100644 --- a/devolutions-gateway/src/credential/mod.rs +++ b/devolutions-gateway/src/credential/mod.rs @@ -1,7 +1,7 @@ mod crypto; #[rustfmt::skip] -pub use crypto::{DecryptedPassword, EncryptedPassword}; +pub use crypto::EncryptedPassword; use std::collections::HashMap; use std::fmt; @@ -51,7 +51,7 @@ impl AppCredential { /// Decrypt the password using the global master key. /// /// Returns the username and a short-lived decrypted password that zeroizes on drop. - pub fn decrypt_password(&self) -> anyhow::Result<(String, DecryptedPassword)> { + pub fn decrypt_password(&self) -> anyhow::Result<(String, secrecy::SecretString)> { match self { AppCredential::UsernamePassword { username, password } => { let decrypted = MASTER_KEY.lock().decrypt(password)?; From 664317b1f911e09299e12a810e9b62a7b3a0582c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:08:28 +0000 Subject: [PATCH 7/8] Initial plan From 49d64ca1434318562c340137b279d150b9f98903 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:18:45 +0000 Subject: [PATCH 8/8] refactor(dgw): simplify aead error handling in credential crypto Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- devolutions-gateway/src/credential/crypto.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devolutions-gateway/src/credential/crypto.rs b/devolutions-gateway/src/credential/crypto.rs index 8f34a97d2..d76be4949 100644 --- a/devolutions-gateway/src/credential/crypto.rs +++ b/devolutions-gateway/src/credential/crypto.rs @@ -96,7 +96,7 @@ impl MasterKeyManager { // Encrypt (ciphertext includes 16-byte Poly1305 tag). let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) - .map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?; + .map_err(|_| anyhow::anyhow!("encryption failed"))?; Ok(EncryptedPassword { nonce, ciphertext }) } @@ -118,7 +118,7 @@ impl MasterKeyManager { let plaintext_bytes = cipher .decrypt(&encrypted.nonce, encrypted.ciphertext.as_ref()) - .map_err(|e| anyhow::anyhow!("decryption failed: {e}"))?; + .map_err(|_| anyhow::anyhow!("decryption failed"))?; // Convert bytes to String. let plaintext = String::from_utf8(plaintext_bytes).context("decrypted password is not valid UTF-8")?;