feat: add secret store foundation
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
aead::{Aead, OsRng, rand_core::RngCore},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretCrypto {
|
||||
cipher: Aes256Gcm,
|
||||
key_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CipherEnvelope {
|
||||
nonce_b64: String,
|
||||
ciphertext_b64: String,
|
||||
}
|
||||
|
||||
impl SecretCrypto {
|
||||
pub fn new(master_key: &str) -> Result<Self, ApiError> {
|
||||
let trimmed = master_key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ApiError::internal("CRANK_MASTER_KEY must not be empty"));
|
||||
}
|
||||
|
||||
let digest = Sha256::digest(trimmed.as_bytes());
|
||||
let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| {
|
||||
ApiError::internal(format!("failed to initialize secret crypto: {error}"))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
cipher,
|
||||
key_version: "v1".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn key_version(&self) -> &str {
|
||||
&self.key_version
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, value: &Value) -> Result<String, ApiError> {
|
||||
let plaintext = serde_json::to_vec(value).map_err(|error| {
|
||||
ApiError::internal(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| {
|
||||
ApiError::internal(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| {
|
||||
ApiError::internal(format!("failed to encode secret ciphertext: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, ApiError> {
|
||||
let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| {
|
||||
ApiError::internal(format!("failed to decode secret envelope: {error}"))
|
||||
})?;
|
||||
let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| {
|
||||
ApiError::internal(format!("failed to decode secret nonce: {error}"))
|
||||
})?;
|
||||
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
||||
ApiError::internal(format!("failed to decode secret payload: {error}"))
|
||||
})?;
|
||||
let plaintext = self
|
||||
.cipher
|
||||
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
||||
.map_err(|error| {
|
||||
ApiError::internal(format!("failed to decrypt secret value: {error}"))
|
||||
})?;
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|error| {
|
||||
ApiError::internal(format!("failed to deserialize secret value: {error}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::SecretCrypto;
|
||||
|
||||
#[test]
|
||||
fn roundtrips_secret_payload() {
|
||||
let crypto = SecretCrypto::new("test-master-key").unwrap();
|
||||
let plaintext = json!({
|
||||
"token": "top-secret",
|
||||
"username": "demo"
|
||||
});
|
||||
|
||||
let ciphertext = crypto.encrypt(&plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&ciphertext).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user