Skip to main content

lit/network/
airgap.rs

1use crate::crypto::encryption::restrict_to_owner;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7/// Global airgap mode flag
8static AIRGAP_MODE_ENABLED: AtomicBool = AtomicBool::new(false);
9
10/// Airgap configuration for isolated network environments
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(default)]
13pub struct AirgapConfig {
14    /// Enable airgap mode (blocks all network protocols)
15    pub enabled: bool,
16
17    /// Allowed transport types
18    pub allowed_transports: Vec<TransportType>,
19
20    /// Allowed removable media paths (USB drives, etc.)
21    pub allowed_media: Vec<String>,
22
23    /// Allowed network shares (SMB/CIFS paths)
24    pub allowed_shares: Vec<String>,
25
26    /// Enable strict mode (blocks even LAN protocols)
27    pub strict_mode: bool,
28
29    /// Audit logging for transport access
30    pub audit_log: bool,
31
32    /// Audit log path
33    pub audit_log_path: Option<String>,
34}
35
36/// Transport types for airgapped environments
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum TransportType {
39    /// Local filesystem (always allowed)
40    LocalFilesystem,
41
42    /// USB/removable media drives
43    RemovableMedia,
44
45    /// Network file shares (SMB/CIFS)
46    NetworkShare,
47
48    /// Direct file:// protocol
49    FileProtocol,
50
51    /// Blocked: HTTP/HTTPS
52    Http,
53
54    /// Blocked: SSH/SCP
55    Ssh,
56
57    /// Blocked: Custom lit:// network protocol
58    LitProtocol,
59
60    /// Blocked: FTP/FTPS
61    Ftp,
62
63    /// Blocked: Any other network protocol
64    Other,
65}
66
67impl Default for AirgapConfig {
68    fn default() -> Self {
69        AirgapConfig {
70            enabled: false,
71            allowed_transports: vec![
72                TransportType::LocalFilesystem,
73                TransportType::RemovableMedia,
74                TransportType::NetworkShare,
75                TransportType::FileProtocol,
76            ],
77            allowed_media: vec![],
78            allowed_shares: vec![],
79            strict_mode: false,
80            audit_log: true,
81            audit_log_path: Some("~/.lit/airgap_audit.log".to_string()),
82        }
83    }
84}
85
86impl AirgapConfig {
87    /// Load configuration from file
88    pub fn load() -> Result<Self, String> {
89        let config_path = Self::config_path()?;
90
91        if !config_path.exists() {
92            return Ok(AirgapConfig::default());
93        }
94
95        let content = fs::read_to_string(&config_path)
96            .map_err(|e| format!("Failed to read airgap config: {}", e))?;
97
98        toml::from_str(&content).map_err(|e| format!("Failed to parse airgap config: {}", e))
99    }
100
101    /// Get the config file path
102    fn config_path() -> Result<PathBuf, String> {
103        let home = dirs::home_dir().ok_or("Could not find home directory")?;
104        Ok(home.join(".lit").join("airgap.toml"))
105    }
106
107    /// Save configuration to file
108    pub fn save(&self) -> Result<(), String> {
109        let config_path = Self::config_path()?;
110
111        // Create parent directory if needed
112        if let Some(parent) = config_path.parent() {
113            fs::create_dir_all(parent)
114                .map_err(|e| format!("Failed to create config directory: {}", e))?;
115        }
116
117        let content = toml::to_string_pretty(self)
118            .map_err(|e| format!("Failed to serialize config: {}", e))?;
119
120        fs::write(&config_path, content).map_err(|e| format!("Failed to write config: {}", e))
121    }
122
123    /// Enable airgap mode globally
124    pub fn enable_airgap_mode() {
125        AIRGAP_MODE_ENABLED.store(true, Ordering::SeqCst);
126    }
127
128    /// Disable airgap mode globally
129    pub fn disable_airgap_mode() {
130        AIRGAP_MODE_ENABLED.store(false, Ordering::SeqCst);
131    }
132
133    /// Check if airgap mode is enabled
134    pub fn is_airgap_mode() -> bool {
135        AIRGAP_MODE_ENABLED.load(Ordering::SeqCst)
136    }
137}
138
139/// Airgap validator for transport restrictions
140pub struct AirgapValidator {
141    config: AirgapConfig,
142}
143
144impl AirgapValidator {
145    /// Create a new validator
146    pub fn new() -> Result<Self, String> {
147        let config = AirgapConfig::load()?;
148
149        // Apply global airgap mode if configured
150        if config.enabled {
151            AirgapConfig::enable_airgap_mode();
152        }
153
154        Ok(AirgapValidator { config })
155    }
156
157    /// Validate a path/URL for airgapped access
158    pub fn validate_transport(&self, path: &str) -> Result<TransportInfo, String> {
159        // If airgap mode is not enabled, allow everything
160        if !self.config.enabled && !AirgapConfig::is_airgap_mode() {
161            return Ok(TransportInfo {
162                transport_type: self.detect_transport_type(path)?,
163                normalized_path: path.to_string(),
164                is_allowed: true,
165            });
166        }
167
168        // Detect transport type
169        let transport_type = self.detect_transport_type(path)?;
170
171        // Check if transport type is allowed
172        if !self.config.allowed_transports.contains(&transport_type) {
173            return Err(format!(
174                "🚫 AIRGAP MODE: Transport type {:?} is blocked. \
175                 Only physical transports allowed (USB, network shares, local filesystem). \
176                 Use --airgapped=false to disable airgap mode.",
177                transport_type
178            ));
179        }
180
181        // Additional validation based on transport type
182        match &transport_type {
183            TransportType::RemovableMedia => {
184                self.validate_removable_media(path)?;
185            }
186            TransportType::NetworkShare => {
187                if self.config.strict_mode {
188                    return Err(
189                        "🚫 AIRGAP STRICT MODE: Network shares are blocked in strict mode. \
190                         Use USB/removable media only."
191                            .to_string(),
192                    );
193                }
194                self.validate_network_share(path)?;
195            }
196            TransportType::Http
197            | TransportType::Ssh
198            | TransportType::LitProtocol
199            | TransportType::Ftp
200            | TransportType::Other => {
201                return Err(format!(
202                    "🚫 AIRGAP MODE: Network protocol {:?} is blocked. \
203                     Use file://, USB drives, or network shares only.",
204                    transport_type
205                ));
206            }
207            _ => {}
208        }
209
210        // Log if enabled
211        if self.config.audit_log {
212            self.log_transport_access(path, &transport_type)?;
213        }
214
215        Ok(TransportInfo {
216            transport_type: transport_type.clone(),
217            normalized_path: self.normalize_path(path)?,
218            is_allowed: true,
219        })
220    }
221
222    /// Detect the transport type from a path/URL
223    fn detect_transport_type(&self, path: &str) -> Result<TransportType, String> {
224        // Protocol-based detection
225        if path.starts_with("http://") || path.starts_with("https://") {
226            return Ok(TransportType::Http);
227        }
228        if path.starts_with("ssh://") || path.starts_with("scp://") {
229            return Ok(TransportType::Ssh);
230        }
231        if path.starts_with("lit://") {
232            return Ok(TransportType::LitProtocol);
233        }
234        if path.starts_with("ftp://") || path.starts_with("ftps://") {
235            return Ok(TransportType::Ftp);
236        }
237        if path.starts_with("file://") {
238            return Ok(TransportType::FileProtocol);
239        }
240
241        // Windows network share detection (\\server\share or //server/share)
242        if path.starts_with(r"\\") || path.starts_with("//") {
243            return Ok(TransportType::NetworkShare);
244        }
245
246        // Windows drive letter detection. The parsed path is only consulted
247        // here, so it is bound inside the block rather than above it — on any
248        // other platform there is nothing to parse it for.
249        #[cfg(target_os = "windows")]
250        {
251            let path_obj = std::path::Path::new(path);
252            if let Some(first_component) = path_obj.components().next() {
253                use std::path::Component;
254                if let Component::Prefix(prefix) = first_component {
255                    use std::path::Prefix;
256                    match prefix.kind() {
257                        Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
258                            // Check if it's a removable drive
259                            if self.is_removable_drive(path)? {
260                                return Ok(TransportType::RemovableMedia);
261                            }
262                            return Ok(TransportType::LocalFilesystem);
263                        }
264                        Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
265                            return Ok(TransportType::NetworkShare);
266                        }
267                        _ => {}
268                    }
269                }
270            }
271        }
272
273        // Unix absolute path
274        if path.starts_with('/') {
275            // Check if it's a mount point for removable media
276            if self.is_removable_mount(path)? {
277                return Ok(TransportType::RemovableMedia);
278            }
279            return Ok(TransportType::LocalFilesystem);
280        }
281
282        // Relative path - treat as local filesystem
283        Ok(TransportType::LocalFilesystem)
284    }
285
286    /// Check if a Windows drive is removable (USB, etc.)
287    #[cfg(target_os = "windows")]
288    fn is_removable_drive(&self, path: &str) -> Result<bool, String> {
289        use std::ffi::OsStr;
290        use std::os::windows::ffi::OsStrExt;
291
292        // Extract drive letter
293        let path_obj = std::path::Path::new(path);
294        let drive = if let Some(first_component) = path_obj.components().next() {
295            first_component.as_os_str().to_string_lossy().to_string()
296        } else {
297            return Ok(false);
298        };
299
300        // Add backslash if not present
301        let drive_root = if drive.ends_with('\\') {
302            drive
303        } else {
304            format!("{}\\", drive)
305        };
306
307        // Convert to wide string for Windows API
308        let wide: Vec<u16> = OsStr::new(&drive_root)
309            .encode_wide()
310            .chain(std::iter::once(0))
311            .collect();
312
313        // SAFETY: `wide` is a valid null-terminated UTF-16 string from OsStr conversion.
314        #[cfg(target_os = "windows")]
315        unsafe {
316            use windows::core::PCWSTR;
317            use windows::Win32::Storage::FileSystem::GetDriveTypeW;
318
319            let drive_type = GetDriveTypeW(PCWSTR::from_raw(wide.as_ptr()));
320            // DRIVE_REMOVABLE = 2
321            Ok(drive_type == 2)
322        }
323
324        #[cfg(not(target_os = "windows"))]
325        Ok(false)
326    }
327
328    // There is deliberately no non-Windows `is_removable_drive`: drive letters
329    // only exist on Windows, and its sole caller sits inside a Windows-gated
330    // block. Other platforms detect removable media by mount point instead.
331
332    /// Check if a Unix path is a mount point for removable media
333    fn is_removable_mount(&self, path: &str) -> Result<bool, String> {
334        // Common removable media mount points
335        let removable_paths = vec![
336            "/media/",
337            "/mnt/",
338            "/Volumes/", // macOS
339            "/run/media/",
340        ];
341
342        for mount_prefix in removable_paths {
343            if path.starts_with(mount_prefix) {
344                return Ok(true);
345            }
346        }
347
348        Ok(false)
349    }
350
351    /// Validate removable media access
352    fn validate_removable_media(&self, path: &str) -> Result<(), String> {
353        // If no specific media paths are configured, allow all removable media
354        if self.config.allowed_media.is_empty() {
355            return Ok(());
356        }
357
358        // Check if path starts with any allowed media path
359        for allowed in &self.config.allowed_media {
360            if path.starts_with(allowed) {
361                return Ok(());
362            }
363        }
364
365        Err(format!(
366            "🚫 AIRGAP MODE: Removable media path '{}' is not in the allowed list. \
367             Configure allowed media in ~/.lit/airgap.toml",
368            path
369        ))
370    }
371
372    /// Validate network share access
373    fn validate_network_share(&self, path: &str) -> Result<(), String> {
374        // If no specific shares are configured, allow all network shares
375        if self.config.allowed_shares.is_empty() {
376            return Ok(());
377        }
378
379        // Check if path starts with any allowed share path
380        for allowed in &self.config.allowed_shares {
381            if path.starts_with(allowed) {
382                return Ok(());
383            }
384        }
385
386        Err(format!(
387            "🚫 AIRGAP MODE: Network share '{}' is not in the allowed list. \
388             Configure allowed shares in ~/.lit/airgap.toml",
389            path
390        ))
391    }
392
393    /// Normalize a path for consistent handling
394    fn normalize_path(&self, path: &str) -> Result<String, String> {
395        // Remove file:// prefix if present
396        let path = if let Some(stripped) = path.strip_prefix("file://") {
397            stripped
398        } else {
399            path
400        };
401
402        // SECURITY: Only expand tilde (~), not arbitrary environment variables,
403        // to prevent injection via crafted paths containing e.g. ${MALICIOUS}
404        let expanded = shellexpand::tilde(path);
405
406        Ok(expanded.to_string())
407    }
408
409    /// Log a transport access attempt
410    fn log_transport_access(
411        &self,
412        path: &str,
413        transport_type: &TransportType,
414    ) -> Result<(), String> {
415        if let Some(log_path) = &self.config.audit_log_path {
416            let expanded_path = shellexpand::tilde(log_path);
417            let log_path = PathBuf::from(expanded_path.as_ref());
418
419            // Create parent directory if needed
420            if let Some(parent) = log_path.parent() {
421                fs::create_dir_all(parent)
422                    .map_err(|e| format!("Failed to create log directory: {}", e))?;
423            }
424
425            let timestamp = chrono::Utc::now().to_rfc3339();
426            let log_entry = format!(
427                "{} | AIRGAP TRANSPORT | {:?} | {}\n",
428                timestamp, transport_type, path
429            );
430
431            use std::io::Write;
432            let mut file = fs::OpenOptions::new()
433                .create(true)
434                .append(true)
435                .open(&log_path)
436                .map_err(|e| format!("Failed to open log file: {}", e))?;
437
438            file.write_all(log_entry.as_bytes())
439                .map_err(|e| format!("Failed to write to log: {}", e))?;
440            drop(file);
441
442            // Every line here is a filesystem path the user moved data through,
443            // which is a record of what they have and where they keep it. The
444            // file was created at the process umask — 0644 on this machine —
445            // leaving that history readable by any other local account. Applied
446            // on each append rather than at creation so logs written by earlier
447            // versions are corrected too.
448            restrict_to_owner(&log_path)?;
449        }
450
451        Ok(())
452    }
453
454    /// Get current configuration
455    pub fn config(&self) -> &AirgapConfig {
456        &self.config
457    }
458}
459
460/// Information about a validated transport
461#[derive(Debug, Clone)]
462pub struct TransportInfo {
463    /// The detected transport type
464    pub transport_type: TransportType,
465
466    /// Normalized path (expanded variables, etc.)
467    pub normalized_path: String,
468
469    /// Whether this transport is allowed
470    pub is_allowed: bool,
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_transport_detection_http() {
479        let validator = AirgapValidator {
480            config: AirgapConfig::default(),
481        };
482
483        assert_eq!(
484            validator
485                .detect_transport_type("http://example.com")
486                .unwrap(),
487            TransportType::Http
488        );
489        assert_eq!(
490            validator
491                .detect_transport_type("https://example.com")
492                .unwrap(),
493            TransportType::Http
494        );
495    }
496
497    #[test]
498    fn test_transport_detection_ssh() {
499        let validator = AirgapValidator {
500            config: AirgapConfig::default(),
501        };
502
503        assert_eq!(
504            validator
505                .detect_transport_type("ssh://server/repo")
506                .unwrap(),
507            TransportType::Ssh
508        );
509        assert_eq!(
510            validator
511                .detect_transport_type("scp://server/repo")
512                .unwrap(),
513            TransportType::Ssh
514        );
515    }
516
517    #[test]
518    fn test_transport_detection_lit() {
519        let validator = AirgapValidator {
520            config: AirgapConfig::default(),
521        };
522
523        assert_eq!(
524            validator
525                .detect_transport_type("lit://192.168.1.100/repo")
526                .unwrap(),
527            TransportType::LitProtocol
528        );
529    }
530
531    #[test]
532    fn test_transport_detection_network_share() {
533        let validator = AirgapValidator {
534            config: AirgapConfig::default(),
535        };
536
537        assert_eq!(
538            validator
539                .detect_transport_type(r"\\server\share\repo")
540                .unwrap(),
541            TransportType::NetworkShare
542        );
543        assert_eq!(
544            validator
545                .detect_transport_type("//server/share/repo")
546                .unwrap(),
547            TransportType::NetworkShare
548        );
549    }
550
551    #[test]
552    fn test_transport_detection_file_protocol() {
553        let validator = AirgapValidator {
554            config: AirgapConfig::default(),
555        };
556
557        assert_eq!(
558            validator
559                .detect_transport_type("file:///path/to/repo")
560                .unwrap(),
561            TransportType::FileProtocol
562        );
563    }
564
565    #[test]
566    fn test_airgap_blocks_network_protocols() {
567        let config = AirgapConfig {
568            enabled: true,
569            ..Default::default()
570        };
571        let validator = AirgapValidator { config };
572
573        // Should block HTTP
574        assert!(validator.validate_transport("http://example.com").is_err());
575
576        // Should block SSH
577        assert!(validator.validate_transport("ssh://server/repo").is_err());
578
579        // Should block lit:// protocol
580        assert!(validator
581            .validate_transport("lit://192.168.1.100/repo")
582            .is_err());
583    }
584
585    #[test]
586    fn test_airgap_allows_local_filesystem() {
587        let config = AirgapConfig {
588            enabled: true,
589            ..Default::default()
590        };
591        let validator = AirgapValidator { config };
592
593        // Should allow local paths
594        assert!(validator.validate_transport("/path/to/repo").is_ok());
595        assert!(validator.validate_transport("./relative/path").is_ok());
596        assert!(validator.validate_transport("file:///path/to/repo").is_ok());
597    }
598
599    #[test]
600    fn test_airgap_strict_mode_blocks_shares() {
601        let config = AirgapConfig {
602            enabled: true,
603            strict_mode: true,
604            ..Default::default()
605        };
606        let validator = AirgapValidator { config };
607
608        // Should block network shares in strict mode
609        assert!(validator.validate_transport(r"\\server\share").is_err());
610    }
611
612    #[test]
613    fn test_path_normalization() {
614        let validator = AirgapValidator {
615            config: AirgapConfig::default(),
616        };
617
618        assert_eq!(
619            validator.normalize_path("file:///tmp/test").unwrap(),
620            "/tmp/test"
621        );
622    }
623}