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);