From 7a9248a0f37a3644caa544a805cbd90974437639 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 12 Apr 2026 11:36:28 +0300 Subject: [PATCH] runtime: derive secret keys with hkdf --- Cargo.lock | 1 + Cargo.toml | 1 + TASKS.md | 10 +- apps/admin-api/src/service.rs | 12 +- apps/mcp-server/src/app.rs | 5 +- crates/crank-runtime/Cargo.toml | 1 + crates/crank-runtime/src/secret_crypto.rs | 202 ++++++++++++++++++---- 7 files changed, 186 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 023753a..ed58ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,7 @@ dependencies = [ "crank-mapping", "crank-schema", "futures-util", + "hkdf", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 5ed234e..fd0488f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ argon2 = "0.5" axum = "0.8" axum-extra = { version = "0.10", features = ["cookie"] } base64 = "0.22" +hkdf = "0.12" prost = "0.14" prost-reflect = { version = "0.16", features = ["serde"] } prost-types = "0.14" diff --git a/TASKS.md b/TASKS.md index 641dc20..4bd13b8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,18 +2,18 @@ ## Current -### `feat/secret-crypto-hkdf` +### `feat/postgres-pool-config` Status: in_progress DoD: -- master key derivation uses HKDF-SHA256 instead of raw SHA256 -- secret encryption/decryption compatibility is explicit and tested -- admin-api and runtime continue to handle secret crypto through the shared path +- PostgreSQL pool settings are explicit instead of implicit sqlx defaults +- pool sizing and timeouts come from runtime config/env +- startup validates pool configuration and tests cover parsing/defaults ## Next -- `feat/postgres-pool-config` +- `feat/postgres-registry-modularization` ## Backlog diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c9ba37c..a771c3a 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -2386,9 +2386,10 @@ impl AdminService { secret_id: secret_id.as_str().to_owned(), version: secret.secret.current_version, })?; - let plaintext = self - .secret_crypto - .decrypt(&version.secret_version.ciphertext)?; + let plaintext = self.secret_crypto.decrypt( + &version.secret_version.key_version, + &version.secret_version.ciphertext, + )?; self.registry .touch_secret(workspace_id, secret_id, &used_at) .await @@ -4667,7 +4668,10 @@ async fn resolve_runtime_auth_for_task( secret_id: secret_id.as_str().to_owned(), version: secret.secret.current_version, })?; - let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?; + let plaintext = secret_crypto.decrypt( + &version.secret_version.key_version, + &version.secret_version.ciphertext, + )?; registry .touch_secret(workspace_id, secret_id, &used_at) .await diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 321004b..752e92e 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -582,7 +582,10 @@ async fn resolve_auth_profile( secret_id: secret_id.as_str().to_owned(), version: secret.secret.current_version, })?; - let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?; + let plaintext = secret_crypto.decrypt( + &version.secret_version.key_version, + &version.secret_version.ciphertext, + )?; registry .touch_secret(workspace_id, secret_id, &used_at) .await diff --git a/crates/crank-runtime/Cargo.toml b/crates/crank-runtime/Cargo.toml index 2a7374b..e140bf4 100644 --- a/crates/crank-runtime/Cargo.toml +++ b/crates/crank-runtime/Cargo.toml @@ -16,6 +16,7 @@ crank-adapter-websocket = { path = "../crank-adapter-websocket" } crank-core = { path = "../crank-core" } crank-mapping = { path = "../crank-mapping" } crank-schema = { path = "../crank-schema" } +hkdf.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/crank-runtime/src/secret_crypto.rs b/crates/crank-runtime/src/secret_crypto.rs index 9bb3a40..9314041 100644 --- a/crates/crank-runtime/src/secret_crypto.rs +++ b/crates/crank-runtime/src/secret_crypto.rs @@ -3,15 +3,21 @@ use aes_gcm::{ aead::{Aead, OsRng, rand_core::RngCore}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use hkdf::Hkdf; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use crate::RuntimeError; +const LEGACY_KEY_VERSION: &str = "v1"; +const CURRENT_KEY_VERSION: &str = "v2"; +const SECRET_ENVELOPE_INFO: &[u8] = b"crank.secret-envelope.v2"; + #[derive(Clone)] pub struct SecretCrypto { - cipher: Aes256Gcm, + current_cipher: Aes256Gcm, + legacy_cipher: Aes256Gcm, key_version: String, } @@ -30,16 +36,10 @@ impl SecretCrypto { }); } - let digest = Sha256::digest(trimmed.as_bytes()); - let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| { - RuntimeError::SecretCrypto { - details: format!("failed to initialize secret crypto: {error}"), - } - })?; - Ok(Self { - cipher, - key_version: "v1".to_owned(), + current_cipher: derive_hkdf_cipher(trimmed)?, + legacy_cipher: derive_legacy_cipher(trimmed)?, + key_version: CURRENT_KEY_VERSION.to_owned(), }) } @@ -48,29 +48,10 @@ impl SecretCrypto { } pub fn encrypt(&self, value: &Value) -> Result { - let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto { - details: format!("failed to serialize secret value: {error}"), - })?; - let mut nonce_bytes = [0_u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - let ciphertext = self - .cipher - .encrypt(nonce, plaintext.as_ref()) - .map_err(|error| RuntimeError::SecretCrypto { - details: format!("failed to encrypt secret value: {error}"), - })?; - let envelope = CipherEnvelope { - nonce_b64: STANDARD.encode(nonce_bytes), - ciphertext_b64: STANDARD.encode(ciphertext), - }; - - serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto { - details: format!("failed to encode secret ciphertext: {error}"), - }) + encrypt_value_with_cipher(&self.current_cipher, value, "encrypt secret value") } - pub fn decrypt(&self, ciphertext: &str) -> Result { + pub fn decrypt(&self, key_version: &str, ciphertext: &str) -> Result { let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto { details: format!("failed to decode secret envelope: {error}"), @@ -86,8 +67,8 @@ impl SecretCrypto { details: format!("failed to decode secret payload: {error}"), } })?; - let plaintext = self - .cipher + let cipher = self.cipher_for_version(key_version)?; + let plaintext = cipher .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref()) .map_err(|error| RuntimeError::SecretCrypto { details: format!("failed to decrypt secret value: {error}"), @@ -97,16 +78,100 @@ impl SecretCrypto { details: format!("failed to deserialize secret value: {error}"), }) } + + fn cipher_for_version(&self, key_version: &str) -> Result<&Aes256Gcm, RuntimeError> { + match key_version { + LEGACY_KEY_VERSION => Ok(&self.legacy_cipher), + CURRENT_KEY_VERSION => Ok(&self.current_cipher), + other => Err(RuntimeError::SecretCrypto { + details: format!("unsupported secret key version: {other}"), + }), + } + } +} + +fn derive_legacy_cipher(master_key: &str) -> Result { + let digest = Sha256::digest(master_key.as_bytes()); + Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to initialize legacy secret crypto: {error}"), + }) +} + +fn derive_hkdf_cipher(master_key: &str) -> Result { + let hkdf = Hkdf::::new(None, master_key.as_bytes()); + let mut key_bytes = [0_u8; 32]; + hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes) + .map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to derive secret key with HKDF: {error}"), + })?; + Aes256Gcm::new_from_slice(&key_bytes).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to initialize secret crypto: {error}"), + }) +} + +fn encrypt_value_with_cipher( + cipher: &Aes256Gcm, + value: &Value, + action: &str, +) -> Result { + let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to serialize secret value: {error}"), + })?; + let mut nonce_bytes = [0_u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = + cipher + .encrypt(nonce, plaintext.as_ref()) + .map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to {action}: {error}"), + })?; + let envelope = CipherEnvelope { + nonce_b64: STANDARD.encode(nonce_bytes), + ciphertext_b64: STANDARD.encode(ciphertext), + }; + + serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to encode secret ciphertext: {error}"), + }) +} + +#[cfg(test)] +fn encrypt_with_legacy_scheme(master_key: &str, value: &Value) -> Result { + let cipher = derive_legacy_cipher(master_key)?; + encrypt_value_with_cipher(&cipher, value, "encrypt secret value with legacy scheme") +} + +#[cfg(test)] +fn current_key_bytes(master_key: &str) -> Result<[u8; 32], RuntimeError> { + let hkdf = Hkdf::::new(None, master_key.as_bytes()); + let mut key_bytes = [0_u8; 32]; + hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes) + .map_err(|error| RuntimeError::SecretCrypto { + details: format!("failed to derive secret key with HKDF: {error}"), + })?; + Ok(key_bytes) +} + +#[cfg(test)] +fn legacy_key_bytes(master_key: &str) -> [u8; 32] { + let digest = Sha256::digest(master_key.as_bytes()); + let mut key_bytes = [0_u8; 32]; + key_bytes.copy_from_slice(digest.as_slice()); + key_bytes } #[cfg(test)] mod tests { use serde_json::json; - use super::SecretCrypto; + use super::{ + CURRENT_KEY_VERSION, LEGACY_KEY_VERSION, SecretCrypto, current_key_bytes, + encrypt_with_legacy_scheme, legacy_key_bytes, + }; #[test] - fn roundtrips_secret_payload() { + fn roundtrips_secret_payload_with_current_scheme() { let crypto = SecretCrypto::new("test-master-key").unwrap(); let plaintext = json!({ "token": "top-secret", @@ -114,8 +179,73 @@ mod tests { }); let ciphertext = crypto.encrypt(&plaintext).unwrap(); - let decrypted = crypto.decrypt(&ciphertext).unwrap(); + let decrypted = crypto.decrypt(CURRENT_KEY_VERSION, &ciphertext).unwrap(); + + assert_eq!(decrypted, plaintext); + assert_eq!(crypto.key_version(), CURRENT_KEY_VERSION); + } + + #[test] + fn decrypts_legacy_v1_payloads() { + let plaintext = json!({ + "token": "top-secret", + "username": "demo" + }); + let ciphertext = encrypt_with_legacy_scheme("test-master-key", &plaintext).unwrap(); + let crypto = SecretCrypto::new("test-master-key").unwrap(); + + let decrypted = crypto.decrypt(LEGACY_KEY_VERSION, &ciphertext).unwrap(); assert_eq!(decrypted, plaintext); } + + #[test] + fn rejects_empty_master_key() { + let error = SecretCrypto::new(" ").err().unwrap(); + assert!( + error + .to_string() + .contains("CRANK_MASTER_KEY must not be empty") + ); + } + + #[test] + fn same_master_key_derives_stable_hkdf_key() { + let lhs = current_key_bytes("test-master-key").unwrap(); + let rhs = current_key_bytes("test-master-key").unwrap(); + + assert_eq!(lhs, rhs); + } + + #[test] + fn current_scheme_key_differs_from_legacy_scheme() { + let current = current_key_bytes("test-master-key").unwrap(); + let legacy = legacy_key_bytes("test-master-key"); + + assert_ne!(current, legacy); + } + + #[test] + fn different_master_keys_produce_different_ciphertexts() { + let plaintext = json!({ + "token": "top-secret", + "username": "demo" + }); + let left = SecretCrypto::new("test-master-key-a").unwrap(); + let right = SecretCrypto::new("test-master-key-b").unwrap(); + + let left_ciphertext = left.encrypt(&plaintext).unwrap(); + let right_ciphertext = right.encrypt(&plaintext).unwrap(); + + assert_ne!(left_ciphertext, right_ciphertext); + } + + #[test] + fn rejects_unknown_key_version() { + let crypto = SecretCrypto::new("test-master-key").unwrap(); + let ciphertext = crypto.encrypt(&json!({"token": "top-secret"})).unwrap(); + let error = crypto.decrypt("v999", &ciphertext).unwrap_err(); + + assert!(error.to_string().contains("unsupported secret key version")); + } }