rename crates/ to engrams/, bindings/ to receptors/
- crates/ → engrams/ (Rust engrams live here) - bindings/ → receptors/ (cross-language access points into the graph) - Cargo.toml workspace paths updated
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "engram-crypto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Quantum-secure encryption at rest for Engram — AES-256-GCM with PQ upgrade path"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
thiserror = "1"
|
||||
# AES-256-GCM symmetric encryption (quantum-resistant at 256-bit key length)
|
||||
aes-gcm = "0.10"
|
||||
# BLAKE3 for key derivation (fast, cryptographically strong)
|
||||
blake3 = "1"
|
||||
# Random number generation
|
||||
rand = "0.8"
|
||||
# Base64 encoding for serialization (used in EncryptedContent serialization)
|
||||
base64 = "0.22"
|
||||
|
||||
# TODO: Upgrade to post-quantum KEM/signature once crates stabilize.
|
||||
# Target: ml-kem (CRYSTALS-Kyber / NIST ML-KEM) and ml-dsa (CRYSTALS-Dilithium / NIST ML-DSA).
|
||||
# As of 2025, the `ml-kem` and `ml-dsa` crates are available on crates.io but not yet
|
||||
# production-stable for all platforms. The algorithm registry structure below is designed
|
||||
# so that the upgrade is a drop-in: add the PQ crate, implement the KemAlgorithm variant,
|
||||
# and new writes use the new algorithm while old records continue to decrypt via the registry.
|
||||
#
|
||||
# Uncomment when ready:
|
||||
# ml-kem = "0.2" # CRYSTALS-Kyber (NIST ML-KEM 768/1024)
|
||||
# ml-dsa = "0.1" # CRYSTALS-Dilithium (NIST ML-DSA)
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,87 @@
|
||||
/// Algorithm registry types — the versioning layer for crypto algorithm rotation.
|
||||
///
|
||||
/// Each encrypted record carries an `algorithm_id`. The registry maps these IDs
|
||||
/// to the parameters needed to decrypt. When you rotate algorithms, old records
|
||||
/// keep their ID and decrypt using the historical version. New records use the
|
||||
/// new active algorithm.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Key Encapsulation Mechanism algorithms.
|
||||
///
|
||||
/// # Current
|
||||
/// - `Aes256GcmDirect`: AES-256-GCM with a directly-provided 256-bit key.
|
||||
/// Quantum-resistant at 256-bit (Grover halves to 128-bit effective security).
|
||||
///
|
||||
/// # Planned (post-quantum upgrade)
|
||||
/// - `MlKem768`: CRYSTALS-Kyber 768 (NIST ML-KEM Level 3 — 128-bit PQ security)
|
||||
/// - `MlKem1024`: CRYSTALS-Kyber 1024 (NIST ML-KEM Level 5 — 256-bit PQ security)
|
||||
/// - `ClassicRsa4096`: RSA-4096 OAEP fallback (NOT quantum-resistant — for compat only)
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum KemAlgorithm {
|
||||
/// AES-256-GCM with direct key (current default, quantum-resistant at 256-bit)
|
||||
Aes256GcmDirect,
|
||||
// TODO: uncomment when ml-kem crate stabilizes
|
||||
// MlKem768,
|
||||
// MlKem1024,
|
||||
// ClassicRsa4096,
|
||||
}
|
||||
|
||||
impl KemAlgorithm {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
KemAlgorithm::Aes256GcmDirect => "aes256gcm-direct-v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signature algorithms for authenticating ciphertext.
|
||||
///
|
||||
/// # Current
|
||||
/// - `Blake3Mac`: BLAKE3 keyed hash as a MAC (message authentication code).
|
||||
/// Not a signature in the asymmetric sense, but provides authenticity.
|
||||
///
|
||||
/// # Planned (post-quantum upgrade)
|
||||
/// - `MlDsa44` / `MlDsa65` / `MlDsa87`: CRYSTALS-Dilithium (NIST ML-DSA)
|
||||
/// - `SphincsSha256128f`: SPHINCS+ stateless hash-based signature
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SigAlgorithm {
|
||||
/// BLAKE3 keyed MAC (current default)
|
||||
Blake3Mac,
|
||||
// TODO: uncomment when ml-dsa crate stabilizes
|
||||
// MlDsa44, // NIST Security Level 2 (128-bit)
|
||||
// MlDsa65, // NIST Security Level 3 (192-bit)
|
||||
// MlDsa87, // NIST Security Level 5 (256-bit)
|
||||
// SphincsSha256128f, // Stateless hash-based, conservative security
|
||||
}
|
||||
|
||||
impl SigAlgorithm {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
SigAlgorithm::Blake3Mac => "blake3-mac-v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A versioned algorithm configuration entry.
|
||||
///
|
||||
/// Historical versions are kept in the registry so that old ciphertexts can
|
||||
/// always be decrypted even after algorithm rotation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlgorithmVersion {
|
||||
/// Unique string ID stored alongside every ciphertext.
|
||||
pub id: String,
|
||||
/// The KEM algorithm used for key derivation/encapsulation.
|
||||
pub kem: KemAlgorithm,
|
||||
/// The signature algorithm used for ciphertext authentication.
|
||||
pub sig: SigAlgorithm,
|
||||
/// Unix milliseconds when this version became active.
|
||||
pub activated_at: i64,
|
||||
/// Unix milliseconds when this version was superseded (None = still active).
|
||||
pub retired_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl AlgorithmVersion {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.retired_at.is_none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/// 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(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CryptoError {
|
||||
#[error("Encryption failed: {0}")]
|
||||
EncryptionFailed(String),
|
||||
|
||||
#[error("Decryption failed: {0}")]
|
||||
DecryptionFailed(String),
|
||||
|
||||
#[error("Signature verification failed")]
|
||||
SignatureInvalid,
|
||||
|
||||
#[error("Unknown algorithm ID: {0}")]
|
||||
UnknownAlgorithm(String),
|
||||
|
||||
#[error("Key derivation failed: {0}")]
|
||||
KeyDerivation(String),
|
||||
|
||||
#[error("Invalid key length: expected {expected}, got {got}")]
|
||||
InvalidKeyLength { expected: usize, got: usize },
|
||||
|
||||
#[error("Algorithm not available: {0}")]
|
||||
AlgorithmUnavailable(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
pub type CryptoResult<T> = Result<T, CryptoError>;
|
||||
@@ -0,0 +1,43 @@
|
||||
/// Engram Crypto — quantum-secure encryption at rest.
|
||||
///
|
||||
/// # Current Implementation
|
||||
///
|
||||
/// Uses AES-256-GCM for symmetric encryption with BLAKE3 for key derivation.
|
||||
/// AES-256 is already quantum-resistant (Grover's algorithm halves the key space
|
||||
/// from 2^256 to 2^128, which remains computationally infeasible).
|
||||
///
|
||||
/// # Post-Quantum Upgrade Path
|
||||
///
|
||||
/// The `AlgorithmRegistry` stores an `algorithm_id` alongside every ciphertext.
|
||||
/// When ML-KEM (CRYSTALS-Kyber) and ML-DSA (CRYSTALS-Dilithium) crates stabilize,
|
||||
/// the upgrade is:
|
||||
/// 1. Add `KemAlgorithm::MlKem768` / `MlKem1024` variants
|
||||
/// 2. Implement `CryptoEngine::encrypt()` for the new algorithm
|
||||
/// 3. Set it as the active algorithm in the registry
|
||||
/// 4. Old records continue to decrypt via their stored `algorithm_id`
|
||||
/// 5. Background re-encryption rotates old records to the new algorithm
|
||||
///
|
||||
/// No data migration required — the registry handles version negotiation.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use engram_crypto::{CryptoEngine, AlgorithmRegistry};
|
||||
///
|
||||
/// let key = b"an-example-32-byte-key!!12345678";
|
||||
/// let engine = CryptoEngine::from_key(key).unwrap();
|
||||
///
|
||||
/// let plaintext = b"sensitive memory content";
|
||||
/// let encrypted = engine.encrypt(plaintext).unwrap();
|
||||
/// let decrypted = engine.decrypt(&encrypted).unwrap();
|
||||
/// assert_eq!(plaintext, decrypted.as_slice());
|
||||
/// ```
|
||||
pub mod algorithm;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod registry;
|
||||
|
||||
pub use algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
|
||||
pub use engine::{CryptoEngine, EncryptedContent};
|
||||
pub use error::CryptoError;
|
||||
pub use registry::AlgorithmRegistry;
|
||||
@@ -0,0 +1,95 @@
|
||||
/// Algorithm registry — tracks active and historical algorithm versions.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
|
||||
use crate::error::{CryptoError, CryptoResult};
|
||||
|
||||
/// Maintains the set of algorithm versions known to this node.
|
||||
///
|
||||
/// The active version is used for all new encryptions.
|
||||
/// Historical versions remain so old ciphertexts can always be decrypted.
|
||||
pub struct AlgorithmRegistry {
|
||||
/// The currently active algorithm version.
|
||||
pub active_kem: KemAlgorithm,
|
||||
pub active_sig: SigAlgorithm,
|
||||
/// All known versions (active + historical), keyed by algorithm ID.
|
||||
pub versions: HashMap<String, AlgorithmVersion>,
|
||||
}
|
||||
|
||||
impl AlgorithmRegistry {
|
||||
/// Create a registry with the default algorithm (AES-256-GCM + BLAKE3 MAC).
|
||||
pub fn default_registry() -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
|
||||
let default_version = AlgorithmVersion {
|
||||
id: "aes256gcm-direct-v1".to_string(),
|
||||
kem: KemAlgorithm::Aes256GcmDirect,
|
||||
sig: SigAlgorithm::Blake3Mac,
|
||||
activated_at: now,
|
||||
retired_at: None,
|
||||
};
|
||||
|
||||
let mut versions = HashMap::new();
|
||||
versions.insert(default_version.id.clone(), default_version);
|
||||
|
||||
Self {
|
||||
active_kem: KemAlgorithm::Aes256GcmDirect,
|
||||
active_sig: SigAlgorithm::Blake3Mac,
|
||||
versions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the active algorithm ID (used as the `algorithm_id` in new ciphertexts).
|
||||
pub fn active_id(&self) -> &str {
|
||||
self.active_kem.id()
|
||||
}
|
||||
|
||||
/// Look up a version by its ID (for decryption of historical records).
|
||||
pub fn get_version(&self, id: &str) -> CryptoResult<&AlgorithmVersion> {
|
||||
self.versions
|
||||
.get(id)
|
||||
.ok_or_else(|| CryptoError::UnknownAlgorithm(id.to_string()))
|
||||
}
|
||||
|
||||
/// Rotate to a new KEM algorithm.
|
||||
///
|
||||
/// The current active version is marked as retired. A new version entry is
|
||||
/// added and becomes active. Old records retain their algorithm_id and can
|
||||
/// still be decrypted via `get_version()`.
|
||||
///
|
||||
/// Background re-encryption can then update old records at leisure.
|
||||
pub fn rotate_kem(&mut self, new_kem: KemAlgorithm) -> CryptoResult<()> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
|
||||
// Retire current active version
|
||||
if let Some(current) = self.versions.get_mut(self.active_kem.id()) {
|
||||
current.retired_at = Some(now);
|
||||
}
|
||||
|
||||
let new_id = new_kem.id().to_string();
|
||||
let new_version = AlgorithmVersion {
|
||||
id: new_id.clone(),
|
||||
kem: new_kem.clone(),
|
||||
sig: self.active_sig.clone(),
|
||||
activated_at: now,
|
||||
retired_at: None,
|
||||
};
|
||||
|
||||
self.versions.insert(new_id, new_version);
|
||||
self.active_kem = new_kem;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all versions, active and historical.
|
||||
pub fn list_versions(&self) -> Vec<&AlgorithmVersion> {
|
||||
let mut vs: Vec<&AlgorithmVersion> = self.versions.values().collect();
|
||||
vs.sort_by_key(|v| v.activated_at);
|
||||
vs
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user