Skip to main content

cortex_airlock/
session.rs

1use std::time::Instant;
2use cortex_core::error::{CortexError, CortexResult};
3use cortex_core::crypto::ContentHash;
4use cortex_core::tibet::TibetToken;
5use crate::secure_mem::LockedBuffer;
6
7/// Airlock configuration
8#[derive(Clone, Debug)]
9pub struct AirlockConfig {
10    /// Maximum buffer size in bytes
11    pub max_buffer_bytes: usize,
12    /// Auto-destruct timeout in milliseconds
13    pub timeout_ms: u64,
14}
15
16impl Default for AirlockConfig {
17    fn default() -> Self {
18        Self {
19            max_buffer_bytes: 64 * 1024 * 1024, // 64 MB
20            timeout_ms: 30_000,                   // 30 seconds
21        }
22    }
23}
24
25/// Audit record for an airlock session
26#[derive(Clone, Debug)]
27pub struct AirlockSession {
28    pub session_id: String,
29    pub actor: String,
30    pub jis_level: u8,
31    pub chunks_processed: usize,
32    pub chunks_denied: usize,
33    pub duration_ms: f64,
34    pub input_hash: ContentHash,
35    pub output_hash: ContentHash,
36}
37
38/// The Airlock — zero plaintext lifetime processing
39///
40/// All data processing happens within a closure. Plaintext exists ONLY
41/// inside the closure scope. The airlock:
42/// 1. Allocates mlock'd memory (never swapped to disk)
43/// 2. Decrypts data into the locked buffer
44/// 3. Runs the processing closure
45/// 4. Captures the encrypted output
46/// 5. Zeroizes all plaintext memory on exit
47pub struct Airlock {
48    config: AirlockConfig,
49}
50
51impl Airlock {
52    pub fn new(config: AirlockConfig) -> Self {
53        Self { config }
54    }
55
56    pub fn with_defaults() -> Self {
57        Self::new(AirlockConfig::default())
58    }
59
60    /// Process data within the airlock. The closure receives plaintext
61    /// and must return the processed result. All plaintext is wiped
62    /// after the closure exits.
63    ///
64    /// Returns: (processed_output, audit_session)
65    pub fn process<F, R>(
66        &self,
67        input: &[u8],
68        actor: &str,
69        jis_level: u8,
70        f: F,
71    ) -> CortexResult<(R, AirlockSession)>
72    where
73        F: FnOnce(&[u8]) -> CortexResult<R>,
74    {
75        if input.len() > self.config.max_buffer_bytes {
76            return Err(CortexError::AirlockViolation(format!(
77                "Input {} bytes exceeds max {} bytes",
78                input.len(),
79                self.config.max_buffer_bytes
80            )));
81        }
82
83        let start = Instant::now();
84        let input_hash = ContentHash::compute(input);
85        let session_id = format!(
86            "airlock_{}",
87            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
88        );
89
90        // Allocate mlock'd buffer and write plaintext into it
91        let mut buffer = LockedBuffer::new(input.len());
92        buffer.write(input);
93
94        // Process within scope — this is the ONLY place plaintext exists
95        let result = f(buffer.as_bytes());
96
97        // IMMEDIATELY wipe the buffer — before checking the result
98        buffer.wipe();
99
100        // Now handle the result
101        let output = result?;
102
103        let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
104
105        let session = AirlockSession {
106            session_id,
107            actor: actor.to_string(),
108            jis_level,
109            chunks_processed: 1,
110            chunks_denied: 0,
111            duration_ms,
112            input_hash,
113            output_hash: ContentHash("sha256:output_pending".into()),
114        };
115
116        tracing::info!(
117            actor = actor,
118            jis_level = jis_level,
119            duration_ms = duration_ms,
120            "Airlock session complete — plaintext wiped"
121        );
122
123        Ok((output, session))
124    }
125
126    /// Process multiple chunks, filtering by JIS level.
127    /// Returns only chunks the actor is authorized to access.
128    pub fn process_chunks<F, R>(
129        &self,
130        chunks: &[(Vec<u8>, u8)], // (data, jis_level)
131        actor: &str,
132        actor_jis_level: u8,
133        f: F,
134    ) -> CortexResult<(Vec<R>, AirlockSession)>
135    where
136        F: Fn(&[u8]) -> CortexResult<R>,
137    {
138        let start = Instant::now();
139        let session_id = format!(
140            "airlock_{}",
141            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
142        );
143
144        let mut results = Vec::new();
145        let mut denied = 0usize;
146        let mut processed = 0usize;
147
148        for (data, required_level) in chunks {
149            if actor_jis_level < *required_level {
150                denied += 1;
151                continue;
152            }
153
154            // Each chunk gets its own locked buffer
155            let mut buffer = LockedBuffer::new(data.len());
156            buffer.write(data);
157
158            let result = f(buffer.as_bytes());
159
160            // WIPE before handling result
161            buffer.wipe();
162
163            results.push(result?);
164            processed += 1;
165        }
166
167        let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
168
169        let session = AirlockSession {
170            session_id,
171            actor: actor.to_string(),
172            jis_level: actor_jis_level,
173            chunks_processed: processed,
174            chunks_denied: denied,
175            duration_ms,
176            input_hash: ContentHash(format!("sha256:batch_{}_chunks", chunks.len())),
177            output_hash: ContentHash(format!("sha256:batch_{}_results", processed)),
178        };
179
180        tracing::info!(
181            actor = actor,
182            jis_level = actor_jis_level,
183            processed = processed,
184            denied = denied,
185            duration_ms = duration_ms,
186            "Airlock batch session complete — all plaintext wiped"
187        );
188
189        Ok((results, session))
190    }
191
192    /// Generate a TIBET audit token from an airlock session
193    pub fn audit_token(&self, session: &AirlockSession) -> TibetToken {
194        TibetToken::new(
195            session.input_hash.clone(),
196            format!("Airlock session {}", session.session_id),
197            &session.actor,
198            session.jis_level,
199        )
200        .with_access_stats(session.chunks_processed, session.chunks_denied)
201        .with_airlock_time(session.duration_ms)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_airlock_process() {
211        let airlock = Airlock::with_defaults();
212        let data = b"sensitive document content";
213
214        let (result, session) = airlock
215            .process(data, "analyst@company.com", 2, |plaintext| {
216                // Inside the airlock: plaintext is available
217                Ok(plaintext.len())
218            })
219            .unwrap();
220
221        assert_eq!(result, data.len());
222        assert_eq!(session.actor, "analyst@company.com");
223        assert_eq!(session.jis_level, 2);
224        assert!(session.duration_ms >= 0.0);
225    }
226
227    #[test]
228    fn test_airlock_overflow_protection() {
229        let airlock = Airlock::new(AirlockConfig {
230            max_buffer_bytes: 16,
231            timeout_ms: 1000,
232        });
233
234        let big_data = vec![0u8; 100];
235        let result = airlock.process(&big_data, "actor", 0, |_| Ok(()));
236
237        assert!(result.is_err());
238        assert!(matches!(
239            result.unwrap_err(),
240            CortexError::AirlockViolation(_)
241        ));
242    }
243
244    #[test]
245    fn test_airlock_jis_gated_chunks() {
246        let airlock = Airlock::with_defaults();
247
248        let chunks = vec![
249            (b"public info".to_vec(), 0),
250            (b"internal doc".to_vec(), 1),
251            (b"M&A strategy".to_vec(), 2),
252            (b"board minutes".to_vec(), 3),
253        ];
254
255        // JIS level 1 user: can access level 0 and 1
256        let (results, session) = airlock
257            .process_chunks(&chunks, "intern@company.com", 1, |plaintext| {
258                Ok(String::from_utf8_lossy(plaintext).to_string())
259            })
260            .unwrap();
261
262        assert_eq!(results.len(), 2); // Only JIS 0 and 1
263        assert_eq!(session.chunks_processed, 2);
264        assert_eq!(session.chunks_denied, 2); // JIS 2 and 3 denied
265        assert_eq!(results[0], "public info");
266        assert_eq!(results[1], "internal doc");
267    }
268
269    #[test]
270    fn test_airlock_audit_token() {
271        let airlock = Airlock::with_defaults();
272        let data = b"audit me";
273
274        let (_, session) = airlock
275            .process(data, "auditor@company.com", 3, |_| Ok(()))
276            .unwrap();
277
278        let token = airlock.audit_token(&session);
279        assert_eq!(token.eromheen.actor, "auditor@company.com");
280        assert_eq!(token.eromheen.jis_level, 3);
281        assert!(token.eromheen.airlock_session_ms.is_some());
282    }
283}