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
20 changes: 18 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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") {
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion devolutions-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ workspace = true

[features]
default = []
mlock = ["dep:secrets"]
openapi = ["dep:utoipa"]

[dependencies]
Expand Down Expand Up @@ -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"
Expand Down
58 changes: 44 additions & 14 deletions devolutions-gateway/src/credential/crypto.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Mutex<MasterKeyManager>> = 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 {
Expand All @@ -51,22 +59,36 @@ impl MasterKeyManager {
///
/// Returns error if secure memory allocation fails or RNG fails.
fn new() -> anyhow::Result<Self> {
// 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 })
}

/// Encrypt a password using ChaCha20-Poly1305.
///
/// Returns the nonce and ciphertext (which includes the Poly1305 auth tag).
pub(super) fn encrypt(&self, plaintext: &str) -> anyhow::Result<EncryptedPassword> {
#[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);
Expand All @@ -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<DecryptedPassword> {
#[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())
Expand All @@ -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.
///
Expand Down
8 changes: 8 additions & 0 deletions devolutions-gateway/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down