Archived
d1ec384b27
- crates/ → engrams/ (Rust engrams live here) - bindings/ → receptors/ (cross-language access points into the graph) - Cargo.toml workspace paths updated
341 lines
13 KiB
Rust
341 lines
13 KiB
Rust
/// CryptoEngine — encrypt and decrypt node content with algorithm versioning.
|
|
///
|
|
/// # Security Model
|
|
///
|
|
/// - **Symmetric encryption**: AES-256-GCM (authenticated encryption, quantum-resistant)
|
|
/// - **Key derivation**: BLAKE3 KDF — stretches the master key into per-operation keys
|
|
/// - **Authentication**: BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext)
|
|
/// - **Nonces**: 96-bit random nonce per encryption (from OS CSPRNG via `rand`)
|
|
///
|
|
/// # AES-256-GCM and Quantum Resistance
|
|
///
|
|
/// AES-256 is considered quantum-resistant: Grover's algorithm provides at most
|
|
/// a quadratic speedup, reducing 256-bit security to ~128-bit effective security
|
|
/// against quantum adversaries. 128-bit quantum security is currently considered
|
|
/// sufficient. The algorithm_id in EncryptedContent ensures an upgrade to
|
|
/// ML-KEM/Kyber is a transparent drop-in when the crates stabilize.
|
|
use aes_gcm::{
|
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
|
Aes256Gcm, Key, Nonce,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::{CryptoError, CryptoResult};
|
|
use crate::registry::AlgorithmRegistry;
|
|
|
|
/// An encrypted content blob, self-describing with its algorithm version.
|
|
///
|
|
/// The `algorithm_id` field allows any version to decrypt any record,
|
|
/// even after algorithm rotation. This is the key to migration-free upgrades.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EncryptedContent {
|
|
/// Which algorithm version encrypted this record.
|
|
/// Maps to an entry in `AlgorithmRegistry::versions`.
|
|
pub algorithm_id: String,
|
|
/// The AES-256-GCM ciphertext (includes the GCM auth tag).
|
|
pub ciphertext: Vec<u8>,
|
|
/// In a PQ scheme: the KEM-encapsulated symmetric key.
|
|
/// In the current AES-direct scheme: empty (key is derived from master key + context).
|
|
pub encapsulated_key: Vec<u8>,
|
|
/// 96-bit random AES-GCM nonce.
|
|
pub nonce: Vec<u8>,
|
|
/// BLAKE3 MAC over (algorithm_id || nonce || ciphertext).
|
|
/// In a PQ scheme: this would be a Dilithium/ML-DSA signature.
|
|
pub signature: Vec<u8>,
|
|
}
|
|
|
|
impl EncryptedContent {
|
|
/// Serialize to a compact JSON string for storage.
|
|
pub fn to_bytes(&self) -> CryptoResult<Vec<u8>> {
|
|
serde_json::to_vec(self).map_err(|e| CryptoError::Serialization(e.to_string()))
|
|
}
|
|
|
|
/// Deserialize from bytes (JSON).
|
|
pub fn from_bytes(bytes: &[u8]) -> CryptoResult<Self> {
|
|
serde_json::from_slice(bytes).map_err(|e| CryptoError::DecryptionFailed(e.to_string()))
|
|
}
|
|
}
|
|
|
|
/// The encryption engine.
|
|
///
|
|
/// One engine instance per process (or per-request for stateless usage).
|
|
/// The engine holds the master key and the algorithm registry.
|
|
pub struct CryptoEngine {
|
|
/// Master key bytes — 32 bytes for AES-256.
|
|
/// In a PQ scheme: this would be a keypair (public/private).
|
|
master_key: [u8; 32],
|
|
/// Algorithm registry — tracks active and historical versions.
|
|
pub registry: AlgorithmRegistry,
|
|
}
|
|
|
|
impl CryptoEngine {
|
|
/// Create an engine from a 32-byte master key (AES-256 requires 256-bit key).
|
|
pub fn from_key(key: &[u8]) -> CryptoResult<Self> {
|
|
if key.len() < 32 {
|
|
return Err(CryptoError::InvalidKeyLength {
|
|
expected: 32,
|
|
got: key.len(),
|
|
});
|
|
}
|
|
let mut master_key = [0u8; 32];
|
|
master_key.copy_from_slice(&key[..32]);
|
|
Ok(Self {
|
|
master_key,
|
|
registry: AlgorithmRegistry::default_registry(),
|
|
})
|
|
}
|
|
|
|
/// Create an engine from an environment variable `ENGRAM_ENCRYPTION_KEY`.
|
|
///
|
|
/// Returns `None` if the variable is not set (dev mode — plaintext storage).
|
|
/// Returns an error if the variable is set but the key is too short.
|
|
pub fn from_env() -> CryptoResult<Option<Self>> {
|
|
match std::env::var("ENGRAM_ENCRYPTION_KEY") {
|
|
Ok(key_str) => {
|
|
let key_bytes = key_str.as_bytes();
|
|
// Derive a 32-byte key from whatever the user provided
|
|
let derived = derive_key(key_bytes, b"engram-master-key");
|
|
Ok(Some(Self::from_key(&derived)?))
|
|
}
|
|
Err(std::env::VarError::NotPresent) => Ok(None),
|
|
Err(e) => Err(CryptoError::KeyDerivation(e.to_string())),
|
|
}
|
|
}
|
|
|
|
// ── Encrypt ───────────────────────────────────────────────────────────────
|
|
|
|
/// Encrypt `plaintext` using the active algorithm.
|
|
///
|
|
/// Returns an `EncryptedContent` that is self-describing: it carries its
|
|
/// `algorithm_id`, so decryption never needs out-of-band version tracking.
|
|
pub fn encrypt(&self, plaintext: &[u8]) -> CryptoResult<EncryptedContent> {
|
|
let algorithm_id = self.registry.active_id().to_string();
|
|
|
|
// Derive a per-operation encryption key from the master key
|
|
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
|
|
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
|
|
// Random 96-bit nonce
|
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
|
|
|
// Encrypt
|
|
let ciphertext = cipher
|
|
.encrypt(&nonce, plaintext)
|
|
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
|
|
|
// MAC: BLAKE3 keyed hash over (algorithm_id || nonce || ciphertext)
|
|
let mac_key = derive_key(&self.master_key, b"mac");
|
|
let signature = compute_mac(&mac_key, &algorithm_id, nonce.as_slice(), &ciphertext);
|
|
|
|
Ok(EncryptedContent {
|
|
algorithm_id,
|
|
ciphertext,
|
|
encapsulated_key: vec![], // unused in AES-direct mode
|
|
nonce: nonce.to_vec(),
|
|
signature,
|
|
})
|
|
}
|
|
|
|
// ── Decrypt ───────────────────────────────────────────────────────────────
|
|
|
|
/// Decrypt an `EncryptedContent`, dispatching to the correct algorithm version.
|
|
pub fn decrypt(&self, content: &EncryptedContent) -> CryptoResult<Vec<u8>> {
|
|
// Look up the algorithm version that produced this ciphertext
|
|
let _version = self.registry.get_version(&content.algorithm_id)?;
|
|
|
|
// Verify MAC before decrypting (fail-fast on tampering)
|
|
let mac_key = derive_key(&self.master_key, b"mac");
|
|
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
|
|
if expected != content.signature {
|
|
return Err(CryptoError::SignatureInvalid);
|
|
}
|
|
|
|
// Decrypt
|
|
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
|
|
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
|
|
if content.nonce.len() != 12 {
|
|
return Err(CryptoError::DecryptionFailed(format!(
|
|
"invalid nonce length: {}",
|
|
content.nonce.len()
|
|
)));
|
|
}
|
|
let nonce = Nonce::from_slice(&content.nonce);
|
|
|
|
let plaintext = cipher
|
|
.decrypt(nonce, content.ciphertext.as_slice())
|
|
.map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?;
|
|
|
|
Ok(plaintext)
|
|
}
|
|
|
|
// ── Signature verification ─────────────────────────────────────────────────
|
|
|
|
/// Verify the MAC/signature on an encrypted content blob.
|
|
pub fn verify_signature(&self, content: &EncryptedContent) -> CryptoResult<bool> {
|
|
let mac_key = derive_key(&self.master_key, b"mac");
|
|
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
|
|
Ok(expected == content.signature)
|
|
}
|
|
|
|
// ── Algorithm rotation ────────────────────────────────────────────────────
|
|
|
|
/// Rotate to a new KEM algorithm.
|
|
///
|
|
/// After rotation, new encryptions use the new algorithm.
|
|
/// Old records retain their `algorithm_id` and decrypt via the historical registry.
|
|
pub fn rotate_algorithm(&mut self, new_kem: crate::algorithm::KemAlgorithm) -> CryptoResult<()> {
|
|
self.registry.rotate_kem(new_kem)
|
|
}
|
|
}
|
|
|
|
// ── Key derivation ────────────────────────────────────────────────────────────
|
|
|
|
/// Derive a 32-byte sub-key from the master key and a context string.
|
|
/// Uses BLAKE3's keyed hash for domain separation.
|
|
fn derive_key(master: &[u8], context: &[u8]) -> [u8; 32] {
|
|
// BLAKE3 derive_key: master is the key material, context is the domain
|
|
let mut hasher = blake3::Hasher::new_keyed(
|
|
&padded_32(master),
|
|
);
|
|
hasher.update(context);
|
|
let hash = hasher.finalize();
|
|
*hash.as_bytes()
|
|
}
|
|
|
|
/// Pad or truncate bytes to exactly 32 bytes.
|
|
fn padded_32(bytes: &[u8]) -> [u8; 32] {
|
|
let mut out = [0u8; 32];
|
|
let len = bytes.len().min(32);
|
|
out[..len].copy_from_slice(&bytes[..len]);
|
|
out
|
|
}
|
|
|
|
/// Compute a BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext).
|
|
fn compute_mac(mac_key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
|
|
let mut hasher = blake3::Hasher::new_keyed(mac_key);
|
|
hasher.update(algorithm_id.as_bytes());
|
|
hasher.update(nonce);
|
|
hasher.update(ciphertext);
|
|
hasher.finalize().as_bytes().to_vec()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_engine() -> CryptoEngine {
|
|
let key = b"test-master-key-must-be-32-bytes";
|
|
CryptoEngine::from_key(key).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_encrypt_decrypt_roundtrip() {
|
|
let engine = make_engine();
|
|
let plaintext = b"sensitive memory content - do not store unencrypted";
|
|
let enc = engine.encrypt(plaintext).unwrap();
|
|
let dec = engine.decrypt(&enc).unwrap();
|
|
assert_eq!(dec, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn test_nonce_is_random() {
|
|
let engine = make_engine();
|
|
let enc1 = engine.encrypt(b"same plaintext").unwrap();
|
|
let enc2 = engine.encrypt(b"same plaintext").unwrap();
|
|
// Nonces must differ (probabilistic — collision probability 1/2^96)
|
|
assert_ne!(enc1.nonce, enc2.nonce);
|
|
// Ciphertexts must differ (different nonces → different ciphertexts)
|
|
assert_ne!(enc1.ciphertext, enc2.ciphertext);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tampered_ciphertext_rejected() {
|
|
let engine = make_engine();
|
|
let mut enc = engine.encrypt(b"original").unwrap();
|
|
// Flip a byte in the ciphertext
|
|
if let Some(b) = enc.ciphertext.first_mut() {
|
|
*b ^= 0xFF;
|
|
}
|
|
// Decryption should fail due to GCM auth tag verification
|
|
let result = engine.decrypt(&enc);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tampered_mac_rejected() {
|
|
let engine = make_engine();
|
|
let mut enc = engine.encrypt(b"original").unwrap();
|
|
// Flip a byte in the signature
|
|
if let Some(b) = enc.signature.first_mut() {
|
|
*b ^= 0xFF;
|
|
}
|
|
let result = engine.decrypt(&enc);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_signature() {
|
|
let engine = make_engine();
|
|
let enc = engine.encrypt(b"content").unwrap();
|
|
assert!(engine.verify_signature(&enc).unwrap());
|
|
|
|
let mut tampered = enc.clone();
|
|
tampered.signature[0] ^= 0x01;
|
|
assert!(!engine.verify_signature(&tampered).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_algorithm_id_stored() {
|
|
let engine = make_engine();
|
|
let enc = engine.encrypt(b"data").unwrap();
|
|
assert_eq!(enc.algorithm_id, "aes256gcm-direct-v1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_serialization_roundtrip() {
|
|
let engine = make_engine();
|
|
let enc = engine.encrypt(b"serialize me").unwrap();
|
|
let bytes = enc.to_bytes().unwrap();
|
|
let restored = EncryptedContent::from_bytes(&bytes).unwrap();
|
|
let dec = engine.decrypt(&restored).unwrap();
|
|
assert_eq!(dec, b"serialize me");
|
|
}
|
|
|
|
#[test]
|
|
fn test_short_key_rejected() {
|
|
let short_key = b"too-short";
|
|
let result = CryptoEngine::from_key(short_key);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_plaintext() {
|
|
let engine = make_engine();
|
|
let enc = engine.encrypt(b"").unwrap();
|
|
let dec = engine.decrypt(&enc).unwrap();
|
|
assert_eq!(dec, b"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_large_plaintext() {
|
|
let engine = make_engine();
|
|
let plaintext = vec![0xABu8; 1_000_000]; // 1 MB
|
|
let enc = engine.encrypt(&plaintext).unwrap();
|
|
let dec = engine.decrypt(&enc).unwrap();
|
|
assert_eq!(dec, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_algorithm_rejected() {
|
|
let engine = make_engine();
|
|
let mut enc = engine.encrypt(b"data").unwrap();
|
|
enc.algorithm_id = "unknown-algo-v99".to_string();
|
|
let result = engine.decrypt(&enc);
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), CryptoError::UnknownAlgorithm(_)));
|
|
}
|
|
}
|