- Add fk_entries_user_id, fk_entries_history_user_id, fk_audit_log_user_id (ON DELETE SET NULL) - Add scripts/cleanup-orphan-user-ids.sql for pre-deploy orphan user_id cleanup - Remove deprecated SERVER_MASTER_KEY / per-user key wrap helpers from secrets-core - secrets-mcp: require authenticated user for secrets_search; improve body-read failure response - Bump secrets-mcp to 0.2.1 Made-with: Cursor
134 lines
4.7 KiB
Rust
134 lines
4.7 KiB
Rust
use aes_gcm::{
|
|
Aes256Gcm, Key, Nonce,
|
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
|
};
|
|
use anyhow::{Context, Result, bail};
|
|
use serde_json::Value;
|
|
|
|
const NONCE_LEN: usize = 12;
|
|
|
|
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
|
|
|
|
/// Encrypt plaintext bytes with AES-256-GCM.
|
|
/// Returns `nonce (12 B) || ciphertext+tag`.
|
|
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
|
let key = Key::<Aes256Gcm>::from_slice(master_key);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
|
let ciphertext = cipher
|
|
.encrypt(&nonce, plaintext)
|
|
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
|
|
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
|
|
out.extend_from_slice(&nonce);
|
|
out.extend_from_slice(&ciphertext);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
|
|
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
|
if data.len() < NONCE_LEN {
|
|
bail!(
|
|
"encrypted data too short ({}B); possibly corrupted",
|
|
data.len()
|
|
);
|
|
}
|
|
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
|
|
let key = Key::<Aes256Gcm>::from_slice(master_key);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Nonce::from_slice(nonce_bytes);
|
|
cipher
|
|
.decrypt(nonce, ciphertext)
|
|
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
|
|
}
|
|
|
|
// ─── JSON helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
|
|
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
|
|
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
|
|
encrypt(master_key, &bytes)
|
|
}
|
|
|
|
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
|
|
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
|
|
let bytes = decrypt(master_key, data)?;
|
|
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
|
|
}
|
|
|
|
// ─── Client-supplied key extraction ──────────────────────────────────────────
|
|
|
|
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
|
|
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
|
|
let bytes = hex::decode_hex(hex_str.trim())?;
|
|
if bytes.len() != 32 {
|
|
bail!(
|
|
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
|
|
bytes.len()
|
|
);
|
|
}
|
|
let mut key = [0u8; 32];
|
|
key.copy_from_slice(&bytes);
|
|
Ok(key)
|
|
}
|
|
|
|
// ─── Public hex helpers ───────────────────────────────────────────────────────
|
|
|
|
pub mod hex {
|
|
use anyhow::{Result, bail};
|
|
|
|
pub fn encode_hex(bytes: &[u8]) -> String {
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
}
|
|
|
|
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
|
let s = s.trim();
|
|
if !s.len().is_multiple_of(2) {
|
|
bail!("hex string has odd length");
|
|
}
|
|
(0..s.len())
|
|
.step_by(2)
|
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn roundtrip_encrypt_decrypt() {
|
|
let key = [0x42u8; 32];
|
|
let plaintext = b"hello world";
|
|
let enc = encrypt(&key, plaintext).unwrap();
|
|
let dec = decrypt(&key, &enc).unwrap();
|
|
assert_eq!(dec, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_produces_different_ciphertexts() {
|
|
let key = [0x42u8; 32];
|
|
let plaintext = b"hello world";
|
|
let enc1 = encrypt(&key, plaintext).unwrap();
|
|
let enc2 = encrypt(&key, plaintext).unwrap();
|
|
assert_ne!(enc1, enc2);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_key_fails_decryption() {
|
|
let key1 = [0x42u8; 32];
|
|
let key2 = [0x43u8; 32];
|
|
let enc = encrypt(&key1, b"secret").unwrap();
|
|
assert!(decrypt(&key2, &enc).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn json_roundtrip() {
|
|
let key = [0x42u8; 32];
|
|
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
|
|
let enc = encrypt_json(&key, &value).unwrap();
|
|
let dec = decrypt_json(&key, &enc).unwrap();
|
|
assert_eq!(dec, value);
|
|
}
|
|
}
|