Skip to content

Commit beffc9d

Browse files
committed
refactor(security): split keystore module and enforce key-file security checks
Extracts `KeyProvider` trait and `KeyDerivation` enum into dedicated files (provider.rs, derivation.rs) so mod.rs is reduced to re-exports. Introduces key_file_security.rs with `check_key_file`, which rejects symlinks, non-regular files, group/world-readable modes, and files owned by a different UID before any key material is read. All key-loading paths — FileKeyProvider, VaultKeyProvider, AwsKmsProvider, and VolumeEncryption — now call the check. Return types for `unwrap_key` and `rotate` are changed to `Zeroizing<[u8; 32]>` so plaintext key bytes are wiped from the stack when the caller drops the value. Tests that write key files are updated to use 0o600 permissions and are gated behind `#[cfg(unix)]`.
1 parent 20e5e50 commit beffc9d

9 files changed

Lines changed: 593 additions & 179 deletions

File tree

nodedb/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ subtle = { workspace = true }
9393
base64 = { workspace = true }
9494
getrandom = { workspace = true }
9595
aes-gcm = { workspace = true }
96+
zeroize = { workspace = true }
9697
aws-sdk-kms = { workspace = true }
9798
aws-config = { workspace = true }
9899

nodedb/src/control/security/encryption.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ use std::path::{Path, PathBuf};
1919
use aes_gcm::aead::{Aead, KeyInit};
2020
use aes_gcm::{Aes256Gcm, Nonce};
2121
use tracing::info;
22+
use zeroize::Zeroizing;
23+
24+
use super::keystore::key_file_security::check_key_file;
2225

2326
/// Volume encryption configuration.
2427
#[derive(Debug, Clone)]
@@ -137,6 +140,8 @@ impl VolumeEncryption {
137140
detail: "no master key path configured".into(),
138141
})?;
139142

143+
check_key_file(path)?;
144+
140145
let key_bytes = std::fs::read(path).map_err(|e| crate::Error::Encryption {
141146
detail: format!("failed to read master key from {}: {e}", path.display()),
142147
})?;
@@ -147,9 +152,9 @@ impl VolumeEncryption {
147152
});
148153
}
149154

150-
let mut key = [0u8; 32];
155+
let mut key = Zeroizing::new([0u8; 32]);
151156
key.copy_from_slice(&key_bytes[..32]);
152-
self.master_key = Some(key);
157+
self.master_key = Some(*key);
153158

154159
info!(path = %path.display(), "master encryption key loaded");
155160
Ok(())
@@ -266,6 +271,8 @@ impl VolumeEncryption {
266271
/// Each file's DEK is decrypted with the old key and re-encrypted
267272
/// with the new key, then the header is updated in place.
268273
pub fn rotate_master_key(&mut self, new_key_path: &Path) -> crate::Result<()> {
274+
check_key_file(new_key_path)?;
275+
269276
let new_bytes = std::fs::read(new_key_path).map_err(|e| crate::Error::Encryption {
270277
detail: format!("failed to read new key: {e}"),
271278
})?;
@@ -275,11 +282,11 @@ impl VolumeEncryption {
275282
});
276283
}
277284

278-
let mut new_key = [0u8; 32];
285+
let mut new_key = Zeroizing::new([0u8; 32]);
279286
new_key.copy_from_slice(&new_bytes[..32]);
280287

281288
// Store old key temporarily for DEK re-encryption.
282-
let _old_key = self.master_key.replace(new_key);
289+
let _old_key = self.master_key.replace(*new_key);
283290

284291
info!(
285292
new_key_path = %new_key_path.display(),
@@ -308,11 +315,13 @@ mod tests {
308315

309316
#[test]
310317
fn dek_roundtrip() {
318+
use std::os::unix::fs::PermissionsExt as _;
311319
let dir = tempfile::tempdir().unwrap();
312320
let key_path = dir.path().join("master.key");
313321
let mut key_data = [0u8; 32];
314322
getrandom::fill(&mut key_data).unwrap();
315323
std::fs::write(&key_path, key_data).unwrap();
324+
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
316325

317326
let mut enc = VolumeEncryption::new(VolumeEncryptionConfig {
318327
master_key_path: Some(key_path),
@@ -338,6 +347,9 @@ mod tests {
338347
getrandom::fill(&mut k2).unwrap();
339348
std::fs::write(&key1_path, k1).unwrap();
340349
std::fs::write(&key2_path, k2).unwrap();
350+
use std::os::unix::fs::PermissionsExt as _;
351+
std::fs::set_permissions(&key1_path, std::fs::Permissions::from_mode(0o600)).unwrap();
352+
std::fs::set_permissions(&key2_path, std::fs::Permissions::from_mode(0o600)).unwrap();
341353

342354
let mut enc = VolumeEncryption::new(VolumeEncryptionConfig {
343355
master_key_path: Some(key1_path),

nodedb/src/control/security/keystore/aws_kms.rs

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,24 @@
1919
//!
2020
//! Key rotation: generate a new random 32-byte DEK, call KMS `Encrypt` to
2121
//! produce a new ciphertext blob, persist it to disk, then return the new key.
22+
//!
23+
//! # Security checks
24+
//!
25+
//! `ciphertext_blob_path` is passed through [`check_key_file`] before being
26+
//! read. Symlinks, world/group-readable files, and files owned by a different
27+
//! UID are rejected.
2228
2329
use std::path::PathBuf;
2430

2531
use aws_sdk_kms::Client as KmsClient;
2632
use aws_sdk_kms::primitives::Blob;
2733
use tracing::info;
34+
use zeroize::Zeroizing;
2835

2936
use crate::Result;
3037

3138
use super::KeyProvider;
39+
use super::key_file_security::check_key_file;
3240

3341
/// AWS KMS key provider.
3442
pub struct AwsKmsProvider {
@@ -57,6 +65,7 @@ impl AwsKmsProvider {
5765
}
5866

5967
fn read_ciphertext_blob(&self) -> Result<Vec<u8>> {
68+
check_key_file(&self.ciphertext_blob_path)?;
6069
std::fs::read(&self.ciphertext_blob_path).map_err(|e| crate::Error::Encryption {
6170
detail: format!(
6271
"failed to read KMS ciphertext blob from {}: {e}",
@@ -77,7 +86,7 @@ impl AwsKmsProvider {
7786

7887
#[async_trait::async_trait]
7988
impl KeyProvider for AwsKmsProvider {
80-
async fn unwrap_key(&self) -> Result<[u8; 32]> {
89+
async fn unwrap_key(&self) -> Result<Zeroizing<[u8; 32]>> {
8190
let ciphertext = self.read_ciphertext_blob()?;
8291

8392
let resp = self
@@ -105,7 +114,7 @@ impl KeyProvider for AwsKmsProvider {
105114
});
106115
}
107116

108-
let mut key = [0u8; 32];
117+
let mut key = Zeroizing::new([0u8; 32]);
109118
key.copy_from_slice(&bytes);
110119
info!(
111120
key_id = %self.key_id,
@@ -114,10 +123,10 @@ impl KeyProvider for AwsKmsProvider {
114123
Ok(key)
115124
}
116125

117-
async fn rotate(&self) -> Result<[u8; 32]> {
126+
async fn rotate(&self) -> Result<Zeroizing<[u8; 32]>> {
118127
// Generate a new random 32-byte DEK.
119-
let mut new_dek = [0u8; 32];
120-
getrandom::fill(&mut new_dek).map_err(|e| crate::Error::Encryption {
128+
let mut new_dek = Zeroizing::new([0u8; 32]);
129+
getrandom::fill(new_dek.as_mut()).map_err(|e| crate::Error::Encryption {
121130
detail: format!("failed to generate new DEK for KMS rotation: {e}"),
122131
})?;
123132

@@ -154,14 +163,14 @@ mod tests {
154163
// AWS KMS tests use a mock HTTP server since the real SDK requires credentials.
155164
// We verify the happy path and auth error path by intercepting the AWS
156165
// endpoint via an HTTP mock that speaks the KMS JSON protocol shape.
157-
//
158-
// The mock responds to KMS `Decrypt` with a synthetic 32-byte plaintext
159-
// and to `Encrypt` with a fake ciphertext blob.
160166

161167
use super::*;
162168
use axum::{Router, routing::post};
163169
use base64::Engine as _;
164170

171+
#[cfg(unix)]
172+
use std::os::unix::fs::PermissionsExt as _;
173+
165174
fn spawn_mock_kms(port: u16, auth_ok: bool) -> tokio::task::JoinHandle<()> {
166175
tokio::spawn(async move {
167176
let app = Router::new().route(
@@ -240,30 +249,42 @@ mod tests {
240249
}
241250
}
242251

252+
/// Create a file with secure Unix permissions (0o600).
253+
#[cfg(unix)]
254+
fn write_secure(path: &std::path::Path, content: &[u8]) {
255+
use std::io::Write as _;
256+
let mut f = std::fs::File::create(path).unwrap();
257+
f.write_all(content).unwrap();
258+
drop(f);
259+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
260+
}
261+
243262
#[tokio::test]
263+
#[cfg(unix)]
244264
async fn kms_decrypt_happy_path() {
245265
let port = 18301u16;
246266
let _srv = spawn_mock_kms(port, true);
247267
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
248268

249269
let dir = tempfile::tempdir().unwrap();
250270
let blob_path = dir.path().join("ct.bin");
251-
std::fs::write(&blob_path, [0xFFu8; 64]).unwrap();
271+
write_secure(&blob_path, &[0xFFu8; 64]);
252272

253273
let provider = make_provider_with_endpoint(port, blob_path).await;
254274
let key = provider.unwrap_key().await.unwrap();
255-
assert_eq!(key, [0x42u8; 32]);
275+
assert_eq!(*key, [0x42u8; 32]);
256276
}
257277

258278
#[tokio::test]
279+
#[cfg(unix)]
259280
async fn kms_auth_error_path() {
260281
let port = 18302u16;
261282
let _srv = spawn_mock_kms(port, false);
262283
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
263284

264285
let dir = tempfile::tempdir().unwrap();
265286
let blob_path = dir.path().join("ct.bin");
266-
std::fs::write(&blob_path, [0xFFu8; 64]).unwrap();
287+
write_secure(&blob_path, &[0xFFu8; 64]);
267288

268289
let provider = make_provider_with_endpoint(port, blob_path).await;
269290
let err = provider.unwrap_key().await.unwrap_err();
@@ -273,4 +294,27 @@ mod tests {
273294
"expected KMS error, got: {detail}"
274295
);
275296
}
297+
298+
#[tokio::test]
299+
#[cfg(unix)]
300+
async fn kms_insecure_ciphertext_blob_rejected() {
301+
let dir = tempfile::tempdir().unwrap();
302+
let blob_path = dir.path().join("ct.bin");
303+
304+
use std::io::Write as _;
305+
// Insecure permissions (0o644).
306+
let mut f = std::fs::File::create(&blob_path).unwrap();
307+
f.write_all(&[0xFFu8; 64]).unwrap();
308+
drop(f);
309+
std::fs::set_permissions(&blob_path, std::fs::Permissions::from_mode(0o644)).unwrap();
310+
311+
// Use a dummy port — the check fires before any network call.
312+
let provider = make_provider_with_endpoint(19999, blob_path).await;
313+
let err = provider.unwrap_key().await.unwrap_err();
314+
let detail = format!("{err:?}");
315+
assert!(
316+
detail.contains("insecure") || detail.contains("644"),
317+
"expected insecure-permissions error, got: {detail}"
318+
);
319+
}
276320
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//! `KeyDerivation` config enum and its mapping to a concrete `KeyProvider`.
2+
3+
use std::path::PathBuf;
4+
5+
use super::aws_kms::AwsKmsProvider;
6+
use super::file::FileKeyProvider;
7+
use super::provider::KeyProvider;
8+
use super::vault::VaultKeyProvider;
9+
use crate::Result;
10+
11+
/// How the server obtains its 32-byte AES-256 encryption key.
12+
///
13+
/// All variants implement `KeyProvider` and load the plaintext key into
14+
/// mlocked memory via `nodedb_wal::secure_mem`. No variant returns a stub.
15+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16+
#[serde(tag = "type", rename_all = "snake_case")]
17+
pub enum KeyDerivation {
18+
/// Load a raw 32-byte key from a local file.
19+
///
20+
/// The file must contain exactly 32 bytes. This is the simplest mode,
21+
/// suitable for development or environments with an external secret manager
22+
/// that delivers the key file.
23+
File {
24+
/// Path to the 32-byte key file.
25+
key_path: PathBuf,
26+
},
27+
28+
/// Unwrap the encryption key via HashiCorp Vault transit engine.
29+
///
30+
/// ## Chosen flow
31+
///
32+
/// On first init the operator calls `vault write transit/datakey/plaintext/<key_name>`
33+
/// and stores the returned `ciphertext` blob in `<data_dir>/.wal-key.wrapped`.
34+
///
35+
/// At startup NodeDB reads the wrapped blob from disk, sends it to
36+
/// `POST {addr}/v1/{mount}/decrypt/{key_name}` with the Vault token,
37+
/// and extracts the `plaintext` field (base64 of 32 bytes) as the DEK.
38+
///
39+
/// Key rotation: call `POST {addr}/v1/{mount}/rewrap/{key_name}` with
40+
/// the old ciphertext blob and write the new ciphertext to `.wal-key.wrapped`.
41+
Vault {
42+
/// Vault server address, e.g. `https://vault.example.com:8200`.
43+
addr: String,
44+
/// Path to a file containing the Vault token.
45+
token_path: PathBuf,
46+
/// Name of the transit key in Vault.
47+
key_name: String,
48+
/// Mount path of the transit engine, e.g. `transit`.
49+
mount: String,
50+
/// Path to the wrapped DEK ciphertext blob stored on disk.
51+
/// Default: `<data_dir>/.wal-key.wrapped`
52+
ciphertext_blob_path: PathBuf,
53+
},
54+
55+
/// Unwrap the encryption key via AWS KMS.
56+
///
57+
/// ## Chosen flow
58+
///
59+
/// On first init the operator encrypts the plaintext DEK with
60+
/// `aws kms encrypt --key-id <key_id> --plaintext fileb://<32-byte-file>`
61+
/// and stores the ciphertext blob at `ciphertext_blob_path`.
62+
///
63+
/// At startup NodeDB reads the ciphertext blob, calls KMS `Decrypt`,
64+
/// and holds the plaintext 32-byte key in mlocked memory.
65+
///
66+
/// Key rotation: generate a new random DEK, call KMS `Encrypt`, write
67+
/// the new ciphertext blob, then call `rotate()` on the provider to
68+
/// get the new key.
69+
AwsKms {
70+
/// AWS KMS key ID or ARN.
71+
key_id: String,
72+
/// AWS region, e.g. `us-east-1`.
73+
region: String,
74+
/// Path to the KMS-encrypted DEK ciphertext blob.
75+
ciphertext_blob_path: PathBuf,
76+
},
77+
}
78+
79+
impl KeyDerivation {
80+
/// Build a boxed `KeyProvider` for this derivation config.
81+
///
82+
/// This is called once at startup. The returned provider's `unwrap_key()`
83+
/// is then called to obtain the 32-byte plaintext key.
84+
pub async fn into_provider(self) -> Result<Box<dyn KeyProvider + Send + Sync>> {
85+
match self {
86+
KeyDerivation::File { key_path } => Ok(Box::new(FileKeyProvider { key_path })),
87+
KeyDerivation::Vault {
88+
addr,
89+
token_path,
90+
key_name,
91+
mount,
92+
ciphertext_blob_path,
93+
} => Ok(Box::new(VaultKeyProvider::new(
94+
addr,
95+
token_path,
96+
key_name,
97+
mount,
98+
ciphertext_blob_path,
99+
))),
100+
KeyDerivation::AwsKms {
101+
key_id,
102+
region,
103+
ciphertext_blob_path,
104+
} => Ok(Box::new(
105+
AwsKmsProvider::new(key_id, region, ciphertext_blob_path).await?,
106+
)),
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)