enact-core 0.0.1

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! Encryption Processor
//!
//! Output processor that encrypts sensitive data for storage destinations.
//! Uses AES-256-GCM for symmetric encryption.
//!
//! @see docs/TECHNICAL/17-GUARDRAILS-PROTECTION.md

use super::context::ProtectionContext;
use super::processor::{OutputProcessor, ProcessedEvent};
use crate::streaming::StreamEvent;
use aes_gcm::{
    aead::{Aead, KeyInit},
    Aes256Gcm, Nonce,
};
use async_trait::async_trait;
use rand::RngCore;

/// 256-bit encryption key
pub type EncryptionKey = [u8; 32];

/// Encryption processor for storage destinations
///
/// Encrypts text content when the destination requires encryption (Storage).
/// Streaming destinations receive the original (unencrypted) content.
///
/// ## Design Notes
///
/// This is a placeholder implementation. In production:
/// - Use proper key derivation (HKDF)
/// - Integrate with KMS (AWS KMS, GCP KMS, etc.)
/// - Use envelope encryption for large payloads
/// - Store nonces with ciphertext
pub struct EncryptionProcessor {
    /// Whether encryption is enabled
    enabled: bool,
    /// Symmetric key material
    key: EncryptionKey,
}

impl EncryptionProcessor {
    /// Create a new encryption processor (disabled by default)
    pub fn new() -> Self {
        Self {
            enabled: false,
            key: [0u8; 32],
        }
    }

    /// Provide a specific encryption key (32 bytes)
    pub fn with_key(mut self, key: EncryptionKey) -> Self {
        self.key = key;
        self
    }

    /// Enable encryption
    pub fn enabled(mut self) -> Self {
        self.enabled = true;
        self
    }

    /// Check if encryption is enabled
    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// Encrypt a string using AES-256-GCM
    fn encrypt_text(&self, text: &str) -> anyhow::Result<String> {
        let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&self.key);
        let cipher = Aes256Gcm::new(key);

        let mut nonce_bytes = [0u8; 12];
        rand::thread_rng().fill_bytes(&mut nonce_bytes);
        let nonce = Nonce::from_slice(&nonce_bytes);

        let ciphertext = cipher
            .encrypt(nonce, text.as_bytes())
            .map_err(|e| anyhow::anyhow!("encryption failed: {:?}", e))?;

        // Store nonce + ciphertext as hex for transport
        let mut payload = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
        payload.extend_from_slice(&nonce_bytes);
        payload.extend_from_slice(&ciphertext);

        Ok(format!("ENC:{}", hex::encode(payload)))
    }

    /// Check if event has text content that should be encrypted
    fn should_encrypt_event(&self, event: &StreamEvent, ctx: &ProtectionContext) -> bool {
        // Only encrypt for storage destination
        if !ctx.destination.requires_encryption() {
            return false;
        }

        // Check if event has sensitive content
        matches!(
            event,
            StreamEvent::TextDelta { .. }
                | StreamEvent::StepEnd {
                    output: Some(_),
                    ..
                }
                | StreamEvent::ExecutionEnd {
                    final_output: Some(_),
                    ..
                }
        )
    }

    /// Encrypt event content
    fn encrypt_event(&self, event: StreamEvent) -> anyhow::Result<(StreamEvent, Option<String>)> {
        match event {
            StreamEvent::TextDelta { id, delta } => {
                let encrypted = self.encrypt_text(&delta)?;
                Ok((
                    StreamEvent::TextDelta {
                        id,
                        delta: "[ENCRYPTED]".to_string(),
                    },
                    Some(encrypted),
                ))
            }
            StreamEvent::StepEnd {
                execution_id,
                step_id,
                output: Some(text),
                duration_ms,
                timestamp,
            } => {
                let encrypted = self.encrypt_text(&text)?;
                Ok((
                    StreamEvent::StepEnd {
                        execution_id,
                        step_id,
                        output: Some("[ENCRYPTED]".to_string()),
                        duration_ms,
                        timestamp,
                    },
                    Some(encrypted),
                ))
            }
            StreamEvent::ExecutionEnd {
                execution_id,
                final_output: Some(text),
                duration_ms,
                timestamp,
            } => {
                let encrypted = self.encrypt_text(&text)?;
                Ok((
                    StreamEvent::ExecutionEnd {
                        execution_id,
                        final_output: Some("[ENCRYPTED]".to_string()),
                        duration_ms,
                        timestamp,
                    },
                    Some(encrypted),
                ))
            }
            _ => Ok((event, None)),
        }
    }
}

impl Default for EncryptionProcessor {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl OutputProcessor for EncryptionProcessor {
    fn name(&self) -> &str {
        "encryption"
    }

    async fn process(
        &self,
        event: StreamEvent,
        ctx: &ProtectionContext,
    ) -> anyhow::Result<ProcessedEvent> {
        // Skip if not enabled
        if !self.enabled {
            return Ok(ProcessedEvent::unchanged(event));
        }

        // Skip if not storage destination
        if !self.should_encrypt_event(&event, ctx) {
            return Ok(ProcessedEvent::unchanged(event));
        }

        // Encrypt the event
        let (encrypted_event, encrypted_payload) = self.encrypt_event(event)?;

        Ok(ProcessedEvent {
            event: encrypted_event,
            was_modified: true,
            encrypted_payload,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::kernel::ExecutionId;

    #[tokio::test]
    async fn test_encryption_processor_name() {
        let processor = EncryptionProcessor::new();
        assert_eq!(processor.name(), "encryption");
    }

    #[tokio::test]
    async fn test_encryption_processor_disabled_by_default() {
        let processor = EncryptionProcessor::new();
        assert!(!processor.is_enabled());
    }

    #[tokio::test]
    async fn test_encryption_processor_can_enable() {
        let processor = EncryptionProcessor::new().enabled();
        assert!(processor.is_enabled());
    }

    #[tokio::test]
    async fn test_encryption_skips_when_disabled() {
        let processor = EncryptionProcessor::new();
        let ctx = ProtectionContext::for_storage();
        let event = StreamEvent::text_delta("id", "secret data");

        let result = processor.process(event, &ctx).await.unwrap();

        // Should pass through unchanged
        assert!(!result.was_modified);
    }

    #[tokio::test]
    async fn test_encryption_skips_streaming_destination() {
        let processor = EncryptionProcessor::new().enabled();
        let ctx = ProtectionContext::for_stream(); // Not storage
        let event = StreamEvent::text_delta("id", "secret data");

        let result = processor.process(event, &ctx).await.unwrap();

        // Should pass through unchanged (streaming doesn't encrypt)
        assert!(!result.was_modified);
    }

    #[tokio::test]
    async fn test_encryption_encrypts_for_storage() {
        let processor = EncryptionProcessor::new()
            .with_key([1u8; 32])
            .enabled();
        let ctx = ProtectionContext::for_storage();
        let event = StreamEvent::text_delta("id", "secret data");

        let result = processor.process(event, &ctx).await.unwrap();

        // Should be modified
        assert!(result.was_modified);

        // Event should show [ENCRYPTED]
        if let StreamEvent::TextDelta { delta, .. } = result.event {
            assert_eq!(delta, "[ENCRYPTED]");
        } else {
            panic!("Expected TextDelta");
        }

        // Encrypted payload should exist
        let payload = result.encrypted_payload.expect("expected encrypted payload");
        assert!(payload.starts_with("ENC:"));
        assert!(
            !payload.contains("secret data"),
            "ciphertext should not contain plaintext"
        );
    }

    #[tokio::test]
    async fn test_encryption_control_events_pass_through() {
        let processor = EncryptionProcessor::new().enabled();
        let ctx = ProtectionContext::for_storage();
        let exec_id = ExecutionId::new();
        let event = StreamEvent::execution_start(&exec_id);

        let result = processor.process(event, &ctx).await.unwrap();

        // Control events should pass through unchanged
        assert!(!result.was_modified);
    }
}