Archived
rename crates/ to engrams/; add el-compiler el package with bootstrap artifact
- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
- src/{compiler,lexer,parser,codegen}.el
- bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
//! Core seal/unseal operations.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::artifact::{DeploymentBinding, SealAlgorithm, SealConfig, SealedArtifact};
|
||||
use crate::error::{SealError, SealResult};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Seal `bytecode` into a [`SealedArtifact`] using the given [`SealConfig`].
|
||||
///
|
||||
/// # Sealing steps
|
||||
///
|
||||
/// 1. Resolve the deployment binding material.
|
||||
/// 2. Generate a random 256-bit symmetric key.
|
||||
/// 3. Encrypt bytecode with AES-256-GCM.
|
||||
/// 4. XOR the symmetric key with `BLAKE3(binding_material)` to produce
|
||||
/// the `encapsulated_key` field. Possession of the binding secret is
|
||||
/// required to recover the symmetric key.
|
||||
/// 5. MAC the header + ciphertext with the symmetric key.
|
||||
/// 6. Serialize into [`SealedArtifact`].
|
||||
pub fn seal(bytecode: &[u8], config: &SealConfig) -> SealResult<SealedArtifact> {
|
||||
match &config.algorithm {
|
||||
SealAlgorithm::Aes256Gcm => seal_aes256gcm(bytecode, config),
|
||||
SealAlgorithm::MlKem768 | SealAlgorithm::MlKem1024 => {
|
||||
Err(SealError::UnsupportedAlgorithm(config.algorithm.id().to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unseal a [`SealedArtifact`], recovering the original bytecode.
|
||||
///
|
||||
/// The `binding_key` must match the key that was used during sealing.
|
||||
/// For [`DeploymentBinding::EnvironmentKey`], this is the raw env var bytes.
|
||||
/// For [`DeploymentBinding::MachineFingerprint`], this is the fingerprint bytes.
|
||||
/// For [`DeploymentBinding::None`], pass `&[]`.
|
||||
pub fn unseal(artifact: &SealedArtifact, binding_key: &[u8]) -> SealResult<Vec<u8>> {
|
||||
match artifact.algorithm_id.as_str() {
|
||||
"aes256gcm-v1" => unseal_aes256gcm(artifact, binding_key),
|
||||
other => Err(SealError::UnsupportedAlgorithm(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the MAC/signature on a [`SealedArtifact`] without decrypting.
|
||||
///
|
||||
/// Returns `true` if the artifact is intact. This only proves the artifact
|
||||
/// has not been tampered with — it does not prove the deployment key is
|
||||
/// correct.
|
||||
///
|
||||
/// Note: verification requires the symmetric key, which requires the
|
||||
/// binding material. For a lightweight integrity check, use the GCM auth
|
||||
/// tag (which is verified implicitly by [`unseal`]).
|
||||
pub fn verify(artifact: &SealedArtifact) -> SealResult<bool> {
|
||||
// Without the binding key we can't recover the symmetric key to verify
|
||||
// the MAC. What we *can* do is check structural integrity:
|
||||
// - Magic and version are checked in from_bytes().
|
||||
// - Nonce must be 12 bytes (AES-GCM).
|
||||
// - Ciphertext must be non-empty.
|
||||
let structural_ok = artifact.nonce.len() == 12
|
||||
&& !artifact.ciphertext.is_empty()
|
||||
&& !artifact.encapsulated_key.is_empty()
|
||||
&& !artifact.signature.is_empty();
|
||||
Ok(structural_ok)
|
||||
}
|
||||
|
||||
// ── AES-256-GCM sealing ───────────────────────────────────────────────────────
|
||||
|
||||
fn seal_aes256gcm(bytecode: &[u8], config: &SealConfig) -> SealResult<SealedArtifact> {
|
||||
let algorithm_id = SealAlgorithm::Aes256Gcm.id().to_string();
|
||||
|
||||
// 1. Resolve the binding material
|
||||
let (binding_material, fingerprint) = resolve_binding(&config.deployment_binding)?;
|
||||
|
||||
// 2. Generate a random 256-bit symmetric key
|
||||
let mut sym_key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut sym_key);
|
||||
|
||||
// 3. Encrypt bytecode
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(&sym_key);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, bytecode)
|
||||
.map_err(|e| SealError::EncryptionFailed(e.to_string()))?;
|
||||
|
||||
// 4. Encapsulate the symmetric key: XOR with BLAKE3(binding_material)
|
||||
let binding_hash = blake3_32(&binding_material);
|
||||
let encapsulated_key: Vec<u8> = sym_key.iter().zip(binding_hash.iter()).map(|(a, b)| a ^ b).collect();
|
||||
|
||||
// 5. MAC: BLAKE3 keyed over (algorithm_id || nonce || ciphertext)
|
||||
let signature = compute_mac(&sym_key, &algorithm_id, nonce.as_slice(), &ciphertext);
|
||||
|
||||
Ok(SealedArtifact {
|
||||
algorithm_id,
|
||||
signature,
|
||||
encapsulated_key,
|
||||
nonce: nonce.to_vec(),
|
||||
ciphertext,
|
||||
deployment_fingerprint: fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
fn unseal_aes256gcm(artifact: &SealedArtifact, binding_key: &[u8]) -> SealResult<Vec<u8>> {
|
||||
// 1. Derive binding hash from the provided key.
|
||||
// If binding_key is empty, use the zero vector (matches DeploymentBinding::None).
|
||||
let effective_key = if binding_key.is_empty() {
|
||||
vec![0u8; 32]
|
||||
} else {
|
||||
binding_key.to_vec()
|
||||
};
|
||||
let binding_hash = blake3_32(&effective_key);
|
||||
|
||||
// 1b. If a fingerprint was embedded, verify the binding key matches
|
||||
if let Some(ref fp) = artifact.deployment_fingerprint {
|
||||
let expected_fp = blake3_hash(&effective_key);
|
||||
if expected_fp.as_slice() != fp.as_slice() {
|
||||
return Err(SealError::BindingMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Recover the symmetric key: XOR encapsulated_key with binding_hash
|
||||
if artifact.encapsulated_key.len() != 32 {
|
||||
return Err(SealError::DecryptionFailed("encapsulated key wrong length".into()));
|
||||
}
|
||||
let sym_key: Vec<u8> = artifact.encapsulated_key.iter().zip(binding_hash.iter()).map(|(a, b)| a ^ b).collect();
|
||||
let sym_key_arr: [u8; 32] = sym_key.try_into().unwrap();
|
||||
|
||||
// 3. Verify MAC before decrypting
|
||||
let expected_mac = compute_mac(&sym_key_arr, &artifact.algorithm_id, &artifact.nonce, &artifact.ciphertext);
|
||||
if expected_mac != artifact.signature {
|
||||
return Err(SealError::SignatureInvalid);
|
||||
}
|
||||
|
||||
// 4. Decrypt
|
||||
if artifact.nonce.len() != 12 {
|
||||
return Err(SealError::DecryptionFailed("invalid nonce length".into()));
|
||||
}
|
||||
let nonce = Nonce::from_slice(&artifact.nonce);
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(&sym_key_arr);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, artifact.ciphertext.as_slice())
|
||||
.map_err(|e| SealError::DecryptionFailed(e.to_string()))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
// ── Binding resolution ────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a deployment binding to raw bytes and an optional fingerprint.
|
||||
///
|
||||
/// Returns `(binding_material, deployment_fingerprint)`.
|
||||
/// The fingerprint is stored in the artifact; the binding material is never stored.
|
||||
fn resolve_binding(binding: &DeploymentBinding) -> SealResult<(Vec<u8>, Option<Vec<u8>>)> {
|
||||
match binding {
|
||||
DeploymentBinding::EnvironmentKey(var_name) => {
|
||||
let val = std::env::var(var_name)
|
||||
.map_err(|_| SealError::MissingEnvKey(var_name.clone()))?;
|
||||
let material = val.into_bytes();
|
||||
let fingerprint = blake3_hash(&material);
|
||||
Ok((material, Some(fingerprint)))
|
||||
}
|
||||
DeploymentBinding::MachineFingerprint => {
|
||||
// Derive from hostname + OS
|
||||
let hostname = get_hostname();
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
let raw = format!("{hostname}::{os}::{arch}");
|
||||
let material = raw.into_bytes();
|
||||
let fingerprint = blake3_hash(&material);
|
||||
Ok((material, Some(fingerprint)))
|
||||
}
|
||||
DeploymentBinding::None => {
|
||||
// Zero vector — trivially recoverable, testing only
|
||||
Ok((vec![0u8; 32], None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_hostname() -> String {
|
||||
std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-host".into())
|
||||
}
|
||||
|
||||
// ── Crypto helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn blake3_32(data: &[u8]) -> [u8; 32] {
|
||||
*blake3::hash(data).as_bytes()
|
||||
}
|
||||
|
||||
fn blake3_hash(data: &[u8]) -> Vec<u8> {
|
||||
blake3::hash(data).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn compute_mac(key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = blake3::Hasher::new_keyed(key);
|
||||
hasher.update(algorithm_id.as_bytes());
|
||||
hasher.update(nonce);
|
||||
hasher.update(ciphertext);
|
||||
hasher.finalize().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::artifact::{DeploymentBinding, SealAlgorithm, SealConfig};
|
||||
|
||||
fn no_binding_config() -> SealConfig {
|
||||
SealConfig {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: DeploymentBinding::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn env_binding_config(var: &str) -> SealConfig {
|
||||
SealConfig {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: DeploymentBinding::EnvironmentKey(var.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seal_unseal_roundtrip_no_binding() {
|
||||
let bytecode = b"PUSH 42\nCALL print\nRETURN";
|
||||
let config = no_binding_config();
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
let recovered = unseal(&artifact, &[]).unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seal_unseal_roundtrip_env_key() {
|
||||
std::env::set_var("_EL_TEST_SEAL_KEY", "super-secret-deployment-key");
|
||||
let bytecode = b"sealed bytecode payload";
|
||||
let config = env_binding_config("_EL_TEST_SEAL_KEY");
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
let recovered = unseal(&artifact, b"super-secret-deployment-key").unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
std::env::remove_var("_EL_TEST_SEAL_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_binding_key_rejected() {
|
||||
let bytecode = b"secret bytecode";
|
||||
let config = no_binding_config();
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
// Use wrong key — MAC should fail
|
||||
let mut bad_artifact = artifact.clone();
|
||||
bad_artifact.encapsulated_key = vec![0xAA; 32]; // wrong key
|
||||
let result = unseal(&bad_artifact, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_ciphertext_rejected() {
|
||||
let bytecode = b"important bytecode";
|
||||
let config = no_binding_config();
|
||||
let mut artifact = seal(bytecode, &config).unwrap();
|
||||
// Flip a byte in the ciphertext
|
||||
if let Some(b) = artifact.ciphertext.first_mut() {
|
||||
*b ^= 0xFF;
|
||||
}
|
||||
let result = unseal(&artifact, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_mac_rejected() {
|
||||
let bytecode = b"important bytecode";
|
||||
let config = no_binding_config();
|
||||
let mut artifact = seal(bytecode, &config).unwrap();
|
||||
// Flip the first byte of the MAC
|
||||
if let Some(b) = artifact.signature.first_mut() {
|
||||
*b ^= 0xFF;
|
||||
}
|
||||
let result = unseal(&artifact, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_roundtrip() {
|
||||
let bytecode = b"fn main() { return 42 }";
|
||||
let config = no_binding_config();
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
let bytes = artifact.to_bytes().unwrap();
|
||||
let restored = SealedArtifact::from_bytes(&bytes).unwrap();
|
||||
let recovered = unseal(&restored, &[]).unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_magic_header_present() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
let bytes = artifact.to_bytes().unwrap();
|
||||
assert_eq!(&bytes[..8], b"ENGRAM01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_magic_rejected() {
|
||||
let mut bytes = seal(b"test", &no_binding_config()).unwrap().to_bytes().unwrap();
|
||||
bytes[0] = 0xFF; // corrupt magic
|
||||
let result = SealedArtifact::from_bytes(&bytes);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_structural_ok() {
|
||||
let artifact = seal(b"bytecode", &no_binding_config()).unwrap();
|
||||
assert!(verify(&artifact).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_bytecode_sealable() {
|
||||
let artifact = seal(b"", &no_binding_config()).unwrap();
|
||||
let recovered = unseal(&artifact, &[]).unwrap();
|
||||
assert_eq!(recovered, b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_bytecode_sealable() {
|
||||
let bytecode = vec![0x42u8; 100_000];
|
||||
let artifact = seal(&bytecode, &no_binding_config()).unwrap();
|
||||
let recovered = unseal(&artifact, &[]).unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_algorithm_id_stored() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
assert_eq!(artifact.algorithm_id, "aes256gcm-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_is_12_bytes() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
assert_eq!(artifact.nonce.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encapsulated_key_is_32_bytes() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
assert_eq!(artifact.encapsulated_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_seals_produce_different_ciphertexts() {
|
||||
let bytecode = b"same input";
|
||||
let config = no_binding_config();
|
||||
let a1 = seal(bytecode, &config).unwrap();
|
||||
let a2 = seal(bytecode, &config).unwrap();
|
||||
// Random nonce means ciphertexts differ
|
||||
assert_ne!(a1.ciphertext, a2.ciphertext);
|
||||
assert_ne!(a1.nonce, a2.nonce);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_env_key_returns_error() {
|
||||
std::env::remove_var("_EL_NONEXISTENT_KEY");
|
||||
let result = seal(b"test", &env_binding_config("_EL_NONEXISTENT_KEY"));
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), SealError::MissingEnvKey(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user