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#[derive(Clone, Debug)]
9pub struct AirlockConfig {
10 pub max_buffer_bytes: usize,
12 pub timeout_ms: u64,
14}
15
16impl Default for AirlockConfig {
17 fn default() -> Self {
18 Self {
19 max_buffer_bytes: 64 * 1024 * 1024, timeout_ms: 30_000, }
22 }
23}
24
25#[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
38pub 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 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 let mut buffer = LockedBuffer::new(input.len());
92 buffer.write(input);
93
94 let result = f(buffer.as_bytes());
96
97 buffer.wipe();
99
100 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 pub fn process_chunks<F, R>(
129 &self,
130 chunks: &[(Vec<u8>, u8)], 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 let mut buffer = LockedBuffer::new(data.len());
156 buffer.write(data);
157
158 let result = f(buffer.as_bytes());
159
160 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 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 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 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); assert_eq!(session.chunks_processed, 2);
264 assert_eq!(session.chunks_denied, 2); 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}