1use crate::domain::errors::OpsecError;
4use crate::domain::ports::{
5 AmnesiaPipeline, EmbedTechnique, ForensicWatermarker, GeographicDistributor, PanicWiper,
6};
7use crate::domain::types::{
8 CoverMedia, GeographicManifest, PanicWipeConfig, Payload, WatermarkReceipt,
9 WatermarkTripwireTag,
10};
11use rand::Rng;
12use std::fs::{self, OpenOptions};
13use std::io::{Read, Seek, SeekFrom, Write};
14use std::path::Path;
15
16pub struct SecurePanicWiper;
26
27impl SecurePanicWiper {
28 #[must_use]
30 pub const fn new() -> Self {
31 Self
32 }
33
34 fn wipe_file(path: &Path) -> Result<(), String> {
36 if !path.exists() {
38 return Err(format!("file does not exist: {}", path.display()));
39 }
40
41 let metadata = fs::metadata(path).map_err(|e| format!("failed to get metadata: {e}"))?;
43 let file_size = metadata.len();
44
45 if file_size == 0 {
46 fs::remove_file(path).map_err(|e| format!("failed to delete empty file: {e}"))?;
48 return Ok(());
49 }
50
51 let mut file = OpenOptions::new()
53 .write(true)
54 .open(path)
55 .map_err(|e| format!("failed to open file for wiping: {e}"))?;
56
57 file.seek(SeekFrom::Start(0))
59 .map_err(|e| format!("failed to seek (pass 1): {e}"))?;
60 let zeros = vec![0u8; 4096];
61 let mut remaining = file_size;
62 while remaining > 0 {
63 let chunk_size = remaining.min(4096) as usize;
64 file.write_all(
65 zeros
66 .get(..chunk_size)
67 .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
68 )
69 .map_err(|e| format!("failed to write zeros: {e}"))?;
70 remaining -= chunk_size as u64;
71 }
72 file.flush()
73 .map_err(|e| format!("failed to flush (pass 1): {e}"))?;
74
75 file.seek(SeekFrom::Start(0))
77 .map_err(|e| format!("failed to seek (pass 2): {e}"))?;
78 let ones = vec![0xFFu8; 4096];
79 let mut remaining = file_size;
80 while remaining > 0 {
81 let chunk_size = remaining.min(4096) as usize;
82 file.write_all(
83 ones.get(..chunk_size)
84 .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
85 )
86 .map_err(|e| format!("failed to write ones: {e}"))?;
87 remaining -= chunk_size as u64;
88 }
89 file.flush()
90 .map_err(|e| format!("failed to flush (pass 2): {e}"))?;
91
92 file.seek(SeekFrom::Start(0))
94 .map_err(|e| format!("failed to seek (pass 3): {e}"))?;
95 let mut rng = rand::rng();
96 let mut random_buf = vec![0u8; 4096];
97 let mut remaining = file_size;
98 while remaining > 0 {
99 let chunk_size = remaining.min(4096) as usize;
100 rng.fill_bytes(
101 random_buf
102 .get_mut(..chunk_size)
103 .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
104 );
105 file.write_all(
106 random_buf
107 .get(..chunk_size)
108 .ok_or_else(|| format!("chunk_size {chunk_size} exceeds buffer"))?,
109 )
110 .map_err(|e| format!("failed to write random data: {e}"))?;
111 remaining -= chunk_size as u64;
112 }
113 file.flush()
114 .map_err(|e| format!("failed to flush (pass 3): {e}"))?;
115
116 file.set_len(0)
118 .map_err(|e| format!("failed to truncate: {e}"))?;
119
120 drop(file);
122
123 fs::remove_file(path).map_err(|e| format!("failed to delete file: {e}"))?;
125
126 Ok(())
127 }
128
129 fn wipe_dir_recursive(path: &Path) -> Result<(), String> {
131 if !path.exists() {
132 return Err(format!("directory does not exist: {}", path.display()));
133 }
134
135 if !path.is_dir() {
136 return Err(format!("path is not a directory: {}", path.display()));
137 }
138
139 let entries = fs::read_dir(path)
141 .map_err(|e| format!("failed to read directory: {e}"))?
142 .collect::<Result<Vec<_>, _>>()
143 .map_err(|e| format!("failed to collect entries: {e}"))?;
144
145 for entry in entries {
147 let entry_path = entry.path();
148
149 if entry_path.is_dir() {
150 let _ = Self::wipe_dir_recursive(&entry_path);
153 } else {
154 let _ = Self::wipe_file(&entry_path);
155 }
156 }
157
158 fs::remove_dir(path).map_err(|e| format!("failed to remove directory: {e}"))?;
160
161 Ok(())
162 }
163}
164
165impl Default for SecurePanicWiper {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171impl PanicWiper for SecurePanicWiper {
172 fn wipe(&self, config: &PanicWipeConfig) -> Result<(), OpsecError> {
173 let mut failures = Vec::new();
174
175 for path in &config.key_paths {
177 if let Err(e) = Self::wipe_file(path) {
178 failures.push((path.display().to_string(), e));
179 }
180 }
181
182 for path in &config.config_paths {
184 if let Err(e) = Self::wipe_file(path) {
185 failures.push((path.display().to_string(), e));
186 }
187 }
188
189 for path in &config.temp_dirs {
191 if let Err(e) = Self::wipe_dir_recursive(path) {
192 failures.push((path.display().to_string(), e));
193 }
194 }
195
196 if let Some((path, reason)) = failures.first() {
199 return Err(OpsecError::WipeStepFailed {
200 path: path.clone(),
201 reason: reason.clone(),
202 });
203 }
204
205 Ok(())
206 }
207}
208
209pub struct AmnesiaPipelineImpl;
214
215impl AmnesiaPipelineImpl {
216 #[must_use]
218 pub const fn new() -> Self {
219 Self
220 }
221}
222
223impl Default for AmnesiaPipelineImpl {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229impl AmnesiaPipeline for AmnesiaPipelineImpl {
230 fn embed_in_memory(
231 &self,
232 payload_input: &mut dyn Read,
233 cover_input: &mut dyn Read,
234 output: &mut dyn Write,
235 technique: &dyn EmbedTechnique,
236 ) -> Result<(), OpsecError> {
237 crate::domain::opsec::embed_in_memory(payload_input, cover_input, output, technique)
238 }
239}
240
241pub struct GeographicDistributorImpl;
246
247impl GeographicDistributorImpl {
248 #[must_use]
250 pub const fn new() -> Self {
251 Self
252 }
253}
254
255impl Default for GeographicDistributorImpl {
256 fn default() -> Self {
257 Self::new()
258 }
259}
260
261impl GeographicDistributor for GeographicDistributorImpl {
262 fn distribute_with_manifest(
263 &self,
264 payload: &Payload,
265 covers: Vec<CoverMedia>,
266 manifest: &GeographicManifest,
267 embedder: &dyn EmbedTechnique,
268 ) -> Result<Vec<CoverMedia>, OpsecError> {
269 crate::domain::opsec::validate_manifest(manifest)?;
271
272 if covers.len() < manifest.shards.len() {
273 return Err(OpsecError::ManifestError {
274 reason: format!(
275 "not enough covers ({}) for manifest entries ({})",
276 covers.len(),
277 manifest.shards.len()
278 ),
279 });
280 }
281
282 let payload_bytes = payload.as_bytes();
284 let shard_count = manifest.shards.len();
285
286 let chunk_size = payload_bytes.len().div_ceil(shard_count);
288 let mut results = Vec::with_capacity(shard_count);
289
290 for (i, cover) in covers.into_iter().enumerate().take(shard_count) {
291 let start = i * chunk_size;
292 let end = (start + chunk_size).min(payload_bytes.len());
293 let chunk = payload_bytes.get(start..end).unwrap_or_default();
294
295 let shard_payload = Payload::from_bytes(chunk.to_vec());
296 let stego =
297 embedder
298 .embed(cover, &shard_payload)
299 .map_err(|e| OpsecError::ManifestError {
300 reason: format!("embed failed for shard {i}: {e}"),
301 })?;
302 results.push(stego);
303 }
304
305 Ok(results)
306 }
307}
308
309pub struct ForensicWatermarkerImpl;
314
315impl ForensicWatermarkerImpl {
316 #[must_use]
318 pub const fn new() -> Self {
319 Self
320 }
321}
322
323impl Default for ForensicWatermarkerImpl {
324 fn default() -> Self {
325 Self::new()
326 }
327}
328
329impl ForensicWatermarker for ForensicWatermarkerImpl {
330 fn embed_tripwire(
331 &self,
332 mut cover: CoverMedia,
333 tag: &WatermarkTripwireTag,
334 ) -> Result<CoverMedia, OpsecError> {
335 crate::domain::opsec::embed_watermark(&mut cover, tag)?;
336 Ok(cover)
337 }
338
339 fn identify_recipient(
340 &self,
341 stego: &CoverMedia,
342 tags: &[WatermarkTripwireTag],
343 ) -> Result<Option<WatermarkReceipt>, OpsecError> {
344 let result = crate::domain::opsec::identify_watermark(stego, tags);
345 Ok(result.and_then(|idx| {
346 let tag = tags.get(idx)?;
347 Some(WatermarkReceipt {
348 recipient: tag.recipient_id.to_string(),
349 algorithm: "lsb-tripwire-v1".into(),
350 shards: vec![],
351 created_at: chrono::Utc::now(),
352 })
353 }))
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::fs::File;
361 use std::io::Read;
362 use tempfile::TempDir;
363
364 type TestResult = Result<(), Box<dyn std::error::Error>>;
365
366 #[test]
367 fn test_wipe_single_file() -> TestResult {
368 let temp_dir = TempDir::new()?;
369 let file_path = temp_dir.path().join("test_key.txt");
370
371 fs::write(&file_path, b"secret key data")?;
373
374 assert!(file_path.exists());
375
376 SecurePanicWiper::wipe_file(&file_path)?;
378
379 assert!(!file_path.exists());
381 Ok(())
382 }
383
384 #[test]
385 fn test_wipe_empty_file() -> TestResult {
386 let temp_dir = TempDir::new()?;
387 let file_path = temp_dir.path().join("empty.txt");
388
389 File::create(&file_path)?;
391 assert!(file_path.exists());
392
393 SecurePanicWiper::wipe_file(&file_path)?;
395 assert!(!file_path.exists());
396 Ok(())
397 }
398
399 #[test]
400 fn test_wipe_nonexistent_file_returns_error() {
401 let result = SecurePanicWiper::wipe_file(Path::new("/nonexistent/file.txt"));
402 assert!(result.is_err());
403 if let Err(msg) = result {
404 assert!(msg.contains("does not exist"));
405 }
406 }
407
408 #[test]
409 fn test_wipe_config_with_multiple_files() -> TestResult {
410 let temp_dir = TempDir::new()?;
411
412 let key1 = temp_dir.path().join("key1.pem");
413 let key2 = temp_dir.path().join("key2.pem");
414 let config_file = temp_dir.path().join("config.toml");
415
416 fs::write(&key1, b"private key 1")?;
418 fs::write(&key2, b"private key 2")?;
419 fs::write(&config_file, b"sensitive config")?;
420
421 assert!(key1.exists());
422 assert!(key2.exists());
423 assert!(config_file.exists());
424
425 let wipe_config = PanicWipeConfig {
427 key_paths: vec![key1.clone(), key2.clone()],
428 config_paths: vec![config_file.clone()],
429 temp_dirs: vec![],
430 };
431
432 let wiper = SecurePanicWiper::new();
433 wiper.wipe(&wipe_config)?;
434
435 assert!(!key1.exists());
437 assert!(!key2.exists());
438 assert!(!config_file.exists());
439 Ok(())
440 }
441
442 #[test]
443 fn test_wipe_directory_recursive() -> TestResult {
444 let temp_dir = TempDir::new()?;
445 let wipe_dir = temp_dir.path().join("to_wipe");
446 let subdir = wipe_dir.join("subdir");
447
448 fs::create_dir_all(&subdir)?;
450
451 let file1 = wipe_dir.join("file1.txt");
452 let file2 = subdir.join("file2.txt");
453
454 fs::write(&file1, b"temp data 1")?;
455 fs::write(&file2, b"temp data 2")?;
456
457 assert!(wipe_dir.exists());
458 assert!(file1.exists());
459 assert!(file2.exists());
460
461 SecurePanicWiper::wipe_dir_recursive(&wipe_dir)?;
463
464 assert!(!wipe_dir.exists());
466 assert!(!file1.exists());
467 assert!(!file2.exists());
468 Ok(())
469 }
470
471 #[test]
472 fn test_wipe_continues_on_partial_failure() -> TestResult {
473 let temp_dir = TempDir::new()?;
474
475 let key1 = temp_dir.path().join("key1.pem");
476 let nonexistent = temp_dir.path().join("nonexistent.pem");
477 let key2 = temp_dir.path().join("key2.pem");
478
479 fs::write(&key1, b"key 1")?;
481 fs::write(&key2, b"key 2")?;
482
483 let wipe_config = PanicWipeConfig {
484 key_paths: vec![key1.clone(), nonexistent, key2.clone()],
485 config_paths: vec![],
486 temp_dirs: vec![],
487 };
488
489 let wiper = SecurePanicWiper::new();
490 let result = wiper.wipe(&wipe_config);
491
492 assert!(
494 matches!(&result, Err(OpsecError::WipeStepFailed { path, .. }) if path.contains("nonexistent.pem")),
495 );
496
497 assert!(!key1.exists());
499 assert!(!key2.exists());
500 Ok(())
501 }
502
503 #[test]
504 fn test_overwrite_actually_changes_file_content() -> TestResult {
505 let temp_dir = TempDir::new()?;
506 let file_path = temp_dir.path().join("test.dat");
507
508 let original_data = b"AAAABBBBCCCCDDDD";
510 fs::write(&file_path, original_data)?;
511
512 let mut file = File::open(&file_path)?;
514 let mut read_back = Vec::new();
515 file.read_to_end(&mut read_back)?;
516 assert_eq!(read_back, original_data);
517 drop(file);
518
519 let file_size = fs::metadata(&file_path)?.len();
521 assert_eq!(file_size, original_data.len() as u64);
522
523 SecurePanicWiper::wipe_file(&file_path)?;
524
525 assert!(!file_path.exists());
527 Ok(())
528 }
529
530 use crate::domain::errors::StegoError;
533 use crate::domain::types::{Capacity, CoverMedia, Payload, StegoTechnique};
534 use bytes::Bytes;
535 use std::io::Cursor;
536
537 struct MockEmbedder;
538
539 impl crate::domain::ports::EmbedTechnique for MockEmbedder {
540 fn technique(&self) -> StegoTechnique {
541 StegoTechnique::LsbImage
542 }
543
544 fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
545 Ok(Capacity {
546 bytes: cover.data.len() as u64,
547 technique: StegoTechnique::LsbImage,
548 })
549 }
550
551 fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
552 let mut combined = cover.data.to_vec();
553 combined.extend_from_slice(payload.as_bytes());
554 Ok(CoverMedia {
555 kind: cover.kind,
556 data: Bytes::from(combined),
557 metadata: cover.metadata,
558 })
559 }
560 }
561
562 #[test]
563 fn amnesiac_adapter_embed_roundtrip() -> TestResult {
564 let pipeline = AmnesiaPipelineImpl::new();
565 let mut cover = Cursor::new(b"img-data".to_vec());
566 let mut payload = Cursor::new(b"secret".to_vec());
567 let mut output = Vec::new();
568
569 pipeline.embed_in_memory(&mut payload, &mut cover, &mut output, &MockEmbedder)?;
570
571 assert!(output.starts_with(b"img-data"));
572 assert!(output.ends_with(b"secret"));
573 Ok(())
574 }
575
576 #[test]
577 fn amnesiac_adapter_default() -> TestResult {
578 let pipeline = AmnesiaPipelineImpl;
579 let mut cover = Cursor::new(b"c".to_vec());
580 let mut payload = Cursor::new(b"p".to_vec());
581 let mut output = Vec::new();
582
583 pipeline.embed_in_memory(&mut payload, &mut cover, &mut output, &MockEmbedder)?;
584
585 assert!(!output.is_empty());
586 Ok(())
587 }
588
589 use crate::domain::types::{CoverMediaKind, GeoShardEntry, GeographicManifest};
592
593 fn test_covers(n: usize) -> Vec<CoverMedia> {
594 (0..n)
595 .map(|i| CoverMedia {
596 kind: CoverMediaKind::PngImage,
597 data: Bytes::from(vec![0u8; 64]),
598 metadata: {
599 let mut m = std::collections::HashMap::new();
600 m.insert("idx".into(), i.to_string());
601 m
602 },
603 })
604 .collect()
605 }
606
607 fn test_geo_manifest() -> GeographicManifest {
608 GeographicManifest {
609 shards: vec![
610 GeoShardEntry {
611 shard_index: 0,
612 jurisdiction: "IS".into(),
613 holder_description: "Iceland contact".into(),
614 },
615 GeoShardEntry {
616 shard_index: 1,
617 jurisdiction: "CH".into(),
618 holder_description: "Swiss contact".into(),
619 },
620 GeoShardEntry {
621 shard_index: 2,
622 jurisdiction: "SG".into(),
623 holder_description: "Singapore contact".into(),
624 },
625 ],
626 minimum_jurisdictions: 2,
627 }
628 }
629
630 #[test]
631 fn geographic_distribute_succeeds() -> TestResult {
632 let distributor = GeographicDistributorImpl::new();
633 let payload = Payload::from_bytes(b"secret payload data here!".to_vec());
634 let covers = test_covers(3);
635 let manifest = test_geo_manifest();
636
637 let results =
638 distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder)?;
639
640 assert_eq!(results.len(), 3);
641 Ok(())
642 }
643
644 #[test]
645 fn geographic_distribute_fails_insufficient_covers() {
646 let distributor = GeographicDistributorImpl::new();
647 let payload = Payload::from_bytes(b"secret".to_vec());
648 let covers = test_covers(1); let manifest = test_geo_manifest();
650
651 let result =
652 distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder);
653 assert!(result.is_err());
654 }
655
656 #[test]
657 fn geographic_distribute_fails_invalid_manifest() {
658 let distributor = GeographicDistributorImpl::new();
659 let payload = Payload::from_bytes(b"secret".to_vec());
660 let covers = test_covers(1);
661 let manifest = GeographicManifest {
662 shards: vec![GeoShardEntry {
663 shard_index: 0,
664 jurisdiction: "IS".into(),
665 holder_description: "one".into(),
666 }],
667 minimum_jurisdictions: 3, };
669
670 let result =
671 distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder);
672 assert!(result.is_err());
673 }
674
675 #[test]
676 fn geographic_distributor_default() -> TestResult {
677 let distributor = GeographicDistributorImpl;
678 let payload = Payload::from_bytes(b"data".to_vec());
679 let covers = test_covers(3);
680 let manifest = test_geo_manifest();
681
682 let results =
683 distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder)?;
684 assert_eq!(results.len(), 3);
685 Ok(())
686 }
687
688 #[test]
691 fn forensic_watermarker_embed_roundtrip() -> TestResult {
692 let wm = ForensicWatermarkerImpl::new();
693 let cover = CoverMedia {
694 kind: CoverMediaKind::PngImage,
695 data: Bytes::from(vec![0u8; 1024]),
696 metadata: std::collections::HashMap::new(),
697 };
698 let tag = WatermarkTripwireTag {
699 recipient_id: uuid::Uuid::new_v4(),
700 embedding_seed: b"adapter-test-seed".to_vec(),
701 };
702
703 let stego = wm.embed_tripwire(cover, &tag)?;
704 let receipt = wm.identify_recipient(&stego, std::slice::from_ref(&tag))?;
705 assert!(receipt.is_some());
706 let receipt = receipt.ok_or("expected watermark receipt")?;
707 assert_eq!(receipt.recipient, tag.recipient_id.to_string());
708 assert_eq!(receipt.algorithm, "lsb-tripwire-v1");
709 Ok(())
710 }
711
712 #[test]
713 fn forensic_watermarker_no_match() -> TestResult {
714 let wm = ForensicWatermarkerImpl;
715 let cover = CoverMedia {
716 kind: CoverMediaKind::PngImage,
717 data: Bytes::from(vec![0u8; 1024]),
718 metadata: std::collections::HashMap::new(),
719 };
720 let unknown_tag = WatermarkTripwireTag {
721 recipient_id: uuid::Uuid::new_v4(),
722 embedding_seed: b"unknown".to_vec(),
723 };
724
725 let result = wm.identify_recipient(&cover, &[unknown_tag])?;
726 assert!(result.is_none());
727 Ok(())
728 }
729
730 #[test]
731 fn wipe_dir_recursive_nonexistent() {
732 let result = SecurePanicWiper::wipe_dir_recursive(Path::new("/nonexistent/dir"));
733 assert!(result.is_err());
734 if let Err(msg) = result {
735 assert!(msg.contains("does not exist"));
736 }
737 }
738
739 #[test]
740 fn wipe_dir_recursive_not_a_directory() -> TestResult {
741 let temp_dir = TempDir::new()?;
742 let file_path = temp_dir.path().join("regular_file.txt");
743 fs::write(&file_path, b"data")?;
744
745 let result = SecurePanicWiper::wipe_dir_recursive(&file_path);
746 assert!(result.is_err());
747 if let Err(msg) = result {
748 assert!(msg.contains("not a directory"));
749 }
750 Ok(())
751 }
752
753 #[test]
754 fn wipe_config_with_temp_dirs() -> TestResult {
755 let temp_dir = TempDir::new()?;
756 let temp_subdir = temp_dir.path().join("cache");
757 fs::create_dir_all(&temp_subdir)?;
758 let cache_file = temp_subdir.join("cached.dat");
759 fs::write(&cache_file, b"cached stuff")?;
760
761 let config_file = temp_dir.path().join("settings.toml");
762 fs::write(&config_file, b"[settings]\nkey = true")?;
763
764 let wipe_config = PanicWipeConfig {
765 key_paths: vec![],
766 config_paths: vec![config_file.clone()],
767 temp_dirs: vec![temp_subdir.clone()],
768 };
769
770 let wiper = SecurePanicWiper::new();
771 wiper.wipe(&wipe_config)?;
772
773 assert!(!config_file.exists());
774 assert!(!temp_subdir.exists());
775 Ok(())
776 }
777
778 #[test]
779 fn wipe_config_with_missing_temp_dir_returns_error() -> TestResult {
780 let temp_dir = TempDir::new()?;
781 let nonexistent_dir = temp_dir.path().join("no_such_dir");
782
783 let wipe_config = PanicWipeConfig {
784 key_paths: vec![],
785 config_paths: vec![],
786 temp_dirs: vec![nonexistent_dir],
787 };
788
789 let wiper = SecurePanicWiper::new();
790 let result = wiper.wipe(&wipe_config);
791 assert!(matches!(result, Err(OpsecError::WipeStepFailed { .. })));
792 Ok(())
793 }
794
795 #[test]
796 fn wipe_large_file() -> TestResult {
797 let temp_dir = TempDir::new()?;
798 let file_path = temp_dir.path().join("large.dat");
799 let data = vec![0x42u8; 8192];
801 fs::write(&file_path, &data)?;
802
803 SecurePanicWiper::wipe_file(&file_path)?;
804 assert!(!file_path.exists());
805 Ok(())
806 }
807
808 #[test]
809 fn geographic_distributor_extra_covers_ignored() -> TestResult {
810 let distributor = GeographicDistributorImpl::new();
811 let payload = Payload::from_bytes(b"data for geographic dist".to_vec());
812 let covers = test_covers(5);
814 let manifest = test_geo_manifest();
815
816 let results =
817 distributor.distribute_with_manifest(&payload, covers, &manifest, &MockEmbedder)?;
818 assert_eq!(results.len(), 3);
820 Ok(())
821 }
822}