runtime: derive secret keys with hkdf
This commit is contained in:
Generated
+1
@@ -485,6 +485,7 @@ dependencies = [
|
|||||||
"crank-mapping",
|
"crank-mapping",
|
||||||
"crank-schema",
|
"crank-schema",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hkdf",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ argon2 = "0.5"
|
|||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
hkdf = "0.12"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
prost-reflect = { version = "0.16", features = ["serde"] }
|
prost-reflect = { version = "0.16", features = ["serde"] }
|
||||||
prost-types = "0.14"
|
prost-types = "0.14"
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/secret-crypto-hkdf`
|
### `feat/postgres-pool-config`
|
||||||
|
|
||||||
Status: in_progress
|
Status: in_progress
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- master key derivation uses HKDF-SHA256 instead of raw SHA256
|
- PostgreSQL pool settings are explicit instead of implicit sqlx defaults
|
||||||
- secret encryption/decryption compatibility is explicit and tested
|
- pool sizing and timeouts come from runtime config/env
|
||||||
- admin-api and runtime continue to handle secret crypto through the shared path
|
- startup validates pool configuration and tests cover parsing/defaults
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/postgres-pool-config`
|
- `feat/postgres-registry-modularization`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -2386,9 +2386,10 @@ impl AdminService {
|
|||||||
secret_id: secret_id.as_str().to_owned(),
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
version: secret.secret.current_version,
|
version: secret.secret.current_version,
|
||||||
})?;
|
})?;
|
||||||
let plaintext = self
|
let plaintext = self.secret_crypto.decrypt(
|
||||||
.secret_crypto
|
&version.secret_version.key_version,
|
||||||
.decrypt(&version.secret_version.ciphertext)?;
|
&version.secret_version.ciphertext,
|
||||||
|
)?;
|
||||||
self.registry
|
self.registry
|
||||||
.touch_secret(workspace_id, secret_id, &used_at)
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
.await
|
.await
|
||||||
@@ -4667,7 +4668,10 @@ async fn resolve_runtime_auth_for_task(
|
|||||||
secret_id: secret_id.as_str().to_owned(),
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
version: secret.secret.current_version,
|
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
|
registry
|
||||||
.touch_secret(workspace_id, secret_id, &used_at)
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -582,7 +582,10 @@ async fn resolve_auth_profile(
|
|||||||
secret_id: secret_id.as_str().to_owned(),
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
version: secret.secret.current_version,
|
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
|
registry
|
||||||
.touch_secret(workspace_id, secret_id, &used_at)
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ crank-adapter-websocket = { path = "../crank-adapter-websocket" }
|
|||||||
crank-core = { path = "../crank-core" }
|
crank-core = { path = "../crank-core" }
|
||||||
crank-mapping = { path = "../crank-mapping" }
|
crank-mapping = { path = "../crank-mapping" }
|
||||||
crank-schema = { path = "../crank-schema" }
|
crank-schema = { path = "../crank-schema" }
|
||||||
|
hkdf.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ use aes_gcm::{
|
|||||||
aead::{Aead, OsRng, rand_core::RngCore},
|
aead::{Aead, OsRng, rand_core::RngCore},
|
||||||
};
|
};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use hkdf::Hkdf;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::RuntimeError;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct SecretCrypto {
|
pub struct SecretCrypto {
|
||||||
cipher: Aes256Gcm,
|
current_cipher: Aes256Gcm,
|
||||||
|
legacy_cipher: Aes256Gcm,
|
||||||
key_version: String,
|
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 {
|
Ok(Self {
|
||||||
cipher,
|
current_cipher: derive_hkdf_cipher(trimmed)?,
|
||||||
key_version: "v1".to_owned(),
|
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<String, RuntimeError> {
|
pub fn encrypt(&self, value: &Value) -> Result<String, RuntimeError> {
|
||||||
let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto {
|
encrypt_value_with_cipher(&self.current_cipher, value, "encrypt secret value")
|
||||||
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}"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, RuntimeError> {
|
pub fn decrypt(&self, key_version: &str, ciphertext: &str) -> Result<Value, RuntimeError> {
|
||||||
let envelope: CipherEnvelope =
|
let envelope: CipherEnvelope =
|
||||||
serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto {
|
serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
details: format!("failed to decode secret envelope: {error}"),
|
details: format!("failed to decode secret envelope: {error}"),
|
||||||
@@ -86,8 +67,8 @@ impl SecretCrypto {
|
|||||||
details: format!("failed to decode secret payload: {error}"),
|
details: format!("failed to decode secret payload: {error}"),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let plaintext = self
|
let cipher = self.cipher_for_version(key_version)?;
|
||||||
.cipher
|
let plaintext = cipher
|
||||||
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
details: format!("failed to decrypt secret value: {error}"),
|
details: format!("failed to decrypt secret value: {error}"),
|
||||||
@@ -97,16 +78,100 @@ impl SecretCrypto {
|
|||||||
details: format!("failed to deserialize secret value: {error}"),
|
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<Aes256Gcm, RuntimeError> {
|
||||||
|
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<Aes256Gcm, RuntimeError> {
|
||||||
|
let hkdf = Hkdf::<Sha256>::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<String, RuntimeError> {
|
||||||
|
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<String, RuntimeError> {
|
||||||
|
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::<Sha256>::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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn roundtrips_secret_payload() {
|
fn roundtrips_secret_payload_with_current_scheme() {
|
||||||
let crypto = SecretCrypto::new("test-master-key").unwrap();
|
let crypto = SecretCrypto::new("test-master-key").unwrap();
|
||||||
let plaintext = json!({
|
let plaintext = json!({
|
||||||
"token": "top-secret",
|
"token": "top-secret",
|
||||||
@@ -114,8 +179,73 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ciphertext = crypto.encrypt(&plaintext).unwrap();
|
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);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user