shadowforge_lib/domain/opsec/
mod.rs1use std::collections::HashSet;
8use std::io::{Read, Write};
9
10use bytes::Bytes;
11use zeroize::Zeroize;
12
13use crate::domain::errors::OpsecError;
14use crate::domain::ports::EmbedTechnique;
15use crate::domain::types::{
16 CoverMedia, CoverMediaKind, GeoShardEntry, GeographicManifest, Payload, WatermarkTripwireTag,
17};
18
19fn read_all_zeroizing(reader: &mut dyn Read) -> Result<Vec<u8>, OpsecError> {
21 let mut buf = Vec::new();
22 reader.read_to_end(&mut buf).map_err(|e| {
23 buf.zeroize();
24 OpsecError::PipelineError {
25 reason: format!("failed to read input: {e}"),
26 }
27 })?;
28 Ok(buf)
29}
30
31pub fn embed_in_memory(
42 payload_input: &mut dyn Read,
43 cover_input: &mut dyn Read,
44 output: &mut dyn Write,
45 technique: &dyn EmbedTechnique,
46) -> Result<(), OpsecError> {
47 let cover_bytes = read_all_zeroizing(cover_input)?;
49 let cover = CoverMedia {
50 kind: CoverMediaKind::PngImage,
51 data: Bytes::from(cover_bytes),
52 metadata: std::collections::HashMap::new(),
53 };
54
55 let mut payload_bytes = read_all_zeroizing(payload_input)?;
57 let payload = Payload::from_bytes(payload_bytes.clone());
58 payload_bytes.zeroize();
59
60 let stego = technique
62 .embed(cover, &payload)
63 .map_err(|e| OpsecError::PipelineError {
64 reason: format!("embed failed: {e}"),
65 })?;
66
67 output
69 .write_all(&stego.data)
70 .map_err(|e| OpsecError::PipelineError {
71 reason: format!("failed to write output: {e}"),
72 })?;
73
74 Ok(())
75}
76
77pub fn validate_manifest(manifest: &GeographicManifest) -> Result<(), OpsecError> {
88 let jurisdictions: HashSet<&str> = manifest
89 .shards
90 .iter()
91 .map(|e| e.jurisdiction.as_str())
92 .collect();
93
94 let distinct = jurisdictions.len();
95
96 if distinct < manifest.minimum_jurisdictions as usize {
97 return Err(OpsecError::ManifestError {
98 reason: format!(
99 "manifest requires {} distinct jurisdictions but only {} are assigned",
100 manifest.minimum_jurisdictions, distinct
101 ),
102 });
103 }
104
105 Ok(())
106}
107
108pub fn build_manifest(
114 entries: Vec<GeoShardEntry>,
115 minimum_jurisdictions: u8,
116) -> Result<GeographicManifest, OpsecError> {
117 let manifest = GeographicManifest {
118 shards: entries,
119 minimum_jurisdictions,
120 };
121 validate_manifest(&manifest)?;
122 Ok(manifest)
123}
124
125#[must_use]
130pub fn recovery_complexity_score(manifest: &GeographicManifest) -> String {
131 let jurisdictions: HashSet<&str> = manifest
132 .shards
133 .iter()
134 .map(|e| e.jurisdiction.as_str())
135 .collect();
136
137 let mut sorted: Vec<&str> = jurisdictions.into_iter().collect();
138 sorted.sort_unstable();
139
140 let country_list = sorted.join(", ");
141
142 format!(
143 "Recovery requires cooperation across {} jurisdictions: [{}]. \
144 Estimated legal coordination time: > 6 months under MLAT.",
145 sorted.len(),
146 country_list
147 )
148}
149
150#[must_use]
152pub fn manifest_to_markdown(manifest: &GeographicManifest) -> String {
153 use std::fmt::Write as _;
154
155 let mut md = String::from("# Geographic Distribution Manifest\n\n");
156
157 let _ = write!(
158 md,
159 "**Minimum jurisdictions for reconstruction:** {}\n\n",
160 manifest.minimum_jurisdictions
161 );
162
163 md.push_str("| Shard | Jurisdiction | Holder |\n");
164 md.push_str("|-------|-------------|--------|\n");
165
166 for entry in &manifest.shards {
167 let _ = writeln!(
168 md,
169 "| {} | {} | {} |",
170 entry.shard_index, entry.jurisdiction, entry.holder_description
171 );
172 }
173
174 md.push('\n');
175 let _ = writeln!(md, "**{}**", recovery_complexity_score(manifest));
176
177 md
178}
179
180const MARKER_PATTERN: [u8; 4] = [0xDE, 0xAD, 0xBE, 0xEF];
184const MARKER_BITS: usize = MARKER_PATTERN.len() * 8;
186
187const LCG_A: u64 = 6_364_136_223_846_793_005;
189const LCG_C: u64 = 1_442_695_040_888_963_407;
190
191fn derive_positions(seed_bytes: &[u8], cover_len: usize, count: usize) -> Vec<usize> {
197 if cover_len == 0 || count == 0 {
198 return Vec::new();
199 }
200
201 let mut state: u64 = 0;
203 for (i, &b) in seed_bytes.iter().enumerate() {
204 #[expect(clippy::cast_possible_truncation, reason = "i % 8 always fits in u32")]
206 let shift = (i % 8) as u32 * 8;
207 state ^= u64::from(b).wrapping_shl(shift);
208 }
209
210 let mut positions = Vec::with_capacity(count);
211 let mut used = HashSet::with_capacity(count);
212
213 while positions.len() < count {
214 state = state.wrapping_mul(LCG_A).wrapping_add(LCG_C);
215 let pos = (state >> 16) as usize % cover_len;
216 if used.insert(pos) {
217 positions.push(pos);
218 }
219 }
220
221 positions
222}
223
224pub fn embed_watermark(
232 cover: &mut CoverMedia,
233 tag: &WatermarkTripwireTag,
234) -> Result<(), OpsecError> {
235 if cover.data.len() < MARKER_BITS {
236 return Err(OpsecError::WatermarkError {
237 reason: format!(
238 "cover too small ({} bytes) for watermark ({MARKER_BITS} bits)",
239 cover.data.len(),
240 ),
241 });
242 }
243
244 let positions = derive_positions(&tag.embedding_seed, cover.data.len(), MARKER_BITS);
245 let mut data = cover.data.to_vec();
246
247 for (bit_idx, &pos) in positions.iter().enumerate() {
248 #[expect(
250 clippy::indexing_slicing,
251 reason = "bit_idx < MARKER_BITS; pos validated by derive_positions"
252 )]
253 let marker_byte = MARKER_PATTERN[bit_idx / 8];
254 let bit = (marker_byte >> (7 - (bit_idx % 8))) & 1;
255 if let Some(byte) = data.get_mut(pos) {
256 *byte = (*byte & 0xFE) | bit;
257 }
258 }
259
260 cover.data = Bytes::from(data);
261 Ok(())
262}
263
264#[must_use]
268pub fn identify_watermark(cover: &CoverMedia, tags: &[WatermarkTripwireTag]) -> Option<usize> {
269 if cover.data.len() < MARKER_BITS {
270 return None;
271 }
272
273 for (tag_idx, tag) in tags.iter().enumerate() {
274 let positions = derive_positions(&tag.embedding_seed, cover.data.len(), MARKER_BITS);
275
276 let mut all_match = true;
277 for (bit_idx, &pos) in positions.iter().enumerate() {
278 #[expect(
280 clippy::indexing_slicing,
281 reason = "bit_idx < MARKER_BITS; pos validated by derive_positions"
282 )]
283 let marker_byte = MARKER_PATTERN[bit_idx / 8];
284 let expected_bit = (marker_byte >> (7 - (bit_idx % 8))) & 1;
285 let actual_bit = cover.data.get(pos).map_or(0xFF, |b| b & 1);
286 if actual_bit != expected_bit {
287 all_match = false;
288 break;
289 }
290 }
291
292 if all_match {
293 return Some(tag_idx);
294 }
295 }
296
297 None
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::domain::errors::StegoError;
304 use crate::domain::types::{Capacity, StegoTechnique};
305 use std::io::Cursor;
306
307 type TestResult = Result<(), Box<dyn std::error::Error>>;
308
309 struct MockEmbedder;
311
312 impl EmbedTechnique for MockEmbedder {
313 fn technique(&self) -> StegoTechnique {
314 StegoTechnique::LsbImage
315 }
316
317 fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
318 Ok(Capacity {
319 bytes: cover.data.len() as u64,
320 technique: StegoTechnique::LsbImage,
321 })
322 }
323
324 fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
325 let mut combined = cover.data.to_vec();
326 combined.extend_from_slice(payload.as_bytes());
327 Ok(CoverMedia {
328 kind: cover.kind,
329 data: Bytes::from(combined),
330 metadata: cover.metadata,
331 })
332 }
333 }
334
335 struct FailingEmbedder;
337
338 impl EmbedTechnique for FailingEmbedder {
339 fn technique(&self) -> StegoTechnique {
340 StegoTechnique::LsbImage
341 }
342
343 fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
344 Ok(Capacity {
345 bytes: 0,
346 technique: StegoTechnique::LsbImage,
347 })
348 }
349
350 fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
351 Err(StegoError::MalformedCoverData {
352 reason: "forced failure".into(),
353 })
354 }
355 }
356
357 #[test]
358 fn amnesiac_embed_roundtrip() -> TestResult {
359 let cover_data = b"cover-image-bytes";
360 let payload_data = b"secret-message";
361
362 let mut cover_reader = Cursor::new(cover_data.to_vec());
363 let mut payload_reader = Cursor::new(payload_data.to_vec());
364 let mut output = Vec::new();
365
366 embed_in_memory(
367 &mut payload_reader,
368 &mut cover_reader,
369 &mut output,
370 &MockEmbedder,
371 )?;
372
373 assert!(output.len() > cover_data.len());
375 assert!(output.starts_with(cover_data));
376 assert!(output.ends_with(payload_data));
377 Ok(())
378 }
379
380 #[test]
381 fn amnesiac_embed_empty_payload() -> TestResult {
382 let cover_data = b"cover";
383 let payload_data: &[u8] = b"";
384
385 let mut cover_reader = Cursor::new(cover_data.to_vec());
386 let mut payload_reader = Cursor::new(payload_data.to_vec());
387 let mut output = Vec::new();
388
389 embed_in_memory(
390 &mut payload_reader,
391 &mut cover_reader,
392 &mut output,
393 &MockEmbedder,
394 )?;
395
396 assert_eq!(output.as_slice(), cover_data);
398 Ok(())
399 }
400
401 #[test]
402 fn amnesiac_embed_fails_on_bad_technique() {
403 let cover_data = b"cover";
404 let payload_data = b"secret";
405
406 let mut cover_reader = Cursor::new(cover_data.to_vec());
407 let mut payload_reader = Cursor::new(payload_data.to_vec());
408 let mut output = Vec::new();
409
410 let result = embed_in_memory(
411 &mut payload_reader,
412 &mut cover_reader,
413 &mut output,
414 &FailingEmbedder,
415 );
416
417 assert!(result.is_err());
418 }
419
420 #[test]
421 fn amnesiac_no_heap_leak_on_success() -> TestResult {
422 for _ in 0..10 {
424 let mut cover = Cursor::new(b"cover".to_vec());
425 let mut payload = Cursor::new(b"secret".to_vec());
426 let mut output = Vec::new();
427
428 embed_in_memory(&mut payload, &mut cover, &mut output, &MockEmbedder)?;
429 }
430 Ok(())
431 }
432
433 fn sample_manifest() -> GeographicManifest {
436 GeographicManifest {
437 shards: vec![
438 GeoShardEntry {
439 shard_index: 0,
440 jurisdiction: "IS".into(),
441 holder_description: "Trusted contact in Iceland".into(),
442 },
443 GeoShardEntry {
444 shard_index: 1,
445 jurisdiction: "CH".into(),
446 holder_description: "Secure facility in Switzerland".into(),
447 },
448 GeoShardEntry {
449 shard_index: 2,
450 jurisdiction: "SG".into(),
451 holder_description: "Data centre in Singapore".into(),
452 },
453 ],
454 minimum_jurisdictions: 2,
455 }
456 }
457
458 #[test]
459 fn validate_manifest_passes_sufficient_jurisdictions() -> TestResult {
460 let manifest = sample_manifest();
461 validate_manifest(&manifest)?;
462 Ok(())
463 }
464
465 #[test]
466 fn validate_manifest_fails_insufficient_jurisdictions() {
467 let manifest = GeographicManifest {
468 shards: vec![GeoShardEntry {
469 shard_index: 0,
470 jurisdiction: "IS".into(),
471 holder_description: "contact".into(),
472 }],
473 minimum_jurisdictions: 3,
474 };
475 assert!(validate_manifest(&manifest).is_err());
476 }
477
478 #[test]
479 fn build_manifest_returns_valid() -> TestResult {
480 let entries = vec![
481 GeoShardEntry {
482 shard_index: 0,
483 jurisdiction: "IS".into(),
484 holder_description: "Iceland".into(),
485 },
486 GeoShardEntry {
487 shard_index: 1,
488 jurisdiction: "CH".into(),
489 holder_description: "Switzerland".into(),
490 },
491 ];
492 let manifest = build_manifest(entries, 2)?;
493 assert_eq!(manifest.shards.len(), 2);
494 Ok(())
495 }
496
497 #[test]
498 fn recovery_complexity_score_mentions_jurisdictions() {
499 let manifest = sample_manifest();
500 let score = recovery_complexity_score(&manifest);
501 assert!(score.contains("3 jurisdictions"));
502 assert!(score.contains("IS"));
503 assert!(score.contains("CH"));
504 assert!(score.contains("SG"));
505 assert!(score.contains("MLAT"));
506 }
507
508 #[test]
509 fn manifest_to_markdown_contains_heading() {
510 let manifest = sample_manifest();
511 let md = manifest_to_markdown(&manifest);
512 assert!(md.contains("# Geographic Distribution Manifest"));
513 assert!(md.contains("Iceland"));
514 assert!(md.contains("IS"));
515 }
516
517 #[test]
518 fn build_manifest_fails_insufficient() {
519 let entries = vec![GeoShardEntry {
520 shard_index: 0,
521 jurisdiction: "IS".into(),
522 holder_description: "contact".into(),
523 }];
524 assert!(build_manifest(entries, 2).is_err());
525 }
526
527 fn make_cover(size: usize) -> CoverMedia {
530 CoverMedia {
531 kind: CoverMediaKind::PngImage,
532 data: Bytes::from(vec![0u8; size]),
533 metadata: std::collections::HashMap::new(),
534 }
535 }
536
537 fn make_tag(seed: &[u8]) -> WatermarkTripwireTag {
538 WatermarkTripwireTag {
539 recipient_id: uuid::Uuid::new_v4(),
540 embedding_seed: seed.to_vec(),
541 }
542 }
543
544 #[test]
545 fn embed_then_identify_roundtrip() -> TestResult {
546 let tag_a = make_tag(b"recipient-a-seed");
547 let mut cover = make_cover(1024);
548
549 embed_watermark(&mut cover, &tag_a)?;
550
551 let tags = [tag_a.clone()];
552 let result = identify_watermark(&cover, &tags);
553 assert_eq!(result, Some(0));
554 Ok(())
555 }
556
557 #[test]
558 fn different_tags_produce_different_covers() -> TestResult {
559 let tag_a = make_tag(b"seed-alpha");
560 let tag_b = make_tag(b"seed-beta");
561 let tag_c = make_tag(b"seed-gamma");
562
563 let mut cover_a = make_cover(1024);
564 let mut cover_b = make_cover(1024);
565 let mut cover_c = make_cover(1024);
566
567 embed_watermark(&mut cover_a, &tag_a)?;
568 embed_watermark(&mut cover_b, &tag_b)?;
569 embed_watermark(&mut cover_c, &tag_c)?;
570
571 assert_ne!(cover_a.data, cover_b.data);
573 assert_ne!(cover_a.data, cover_c.data);
574 assert_ne!(cover_b.data, cover_c.data);
575 Ok(())
576 }
577
578 #[test]
579 fn identify_picks_correct_tag() -> TestResult {
580 let tag_a = make_tag(b"aaaa");
581 let tag_b = make_tag(b"bbbb");
582
583 let mut cover = make_cover(1024);
584 embed_watermark(&mut cover, &tag_b)?;
585
586 let tags = [tag_a, tag_b];
587 let result = identify_watermark(&cover, &tags);
588 assert_eq!(result, Some(1)); Ok(())
590 }
591
592 #[test]
593 fn identify_returns_none_when_no_match() {
594 let tag_a = make_tag(b"unknown-seed");
595 let cover = make_cover(1024); let tags = [tag_a];
598 let result = identify_watermark(&cover, &tags);
599 assert_eq!(result, None);
600 }
601
602 #[test]
603 fn embed_fails_on_small_cover() {
604 let tag = make_tag(b"seed");
605 let mut cover = make_cover(2); let result = embed_watermark(&mut cover, &tag);
608 assert!(result.is_err());
609 }
610
611 #[test]
612 fn derive_positions_deterministic() {
613 let seed = b"test-seed";
614 let p1 = derive_positions(seed, 1000, 32);
615 let p2 = derive_positions(seed, 1000, 32);
616 assert_eq!(p1, p2);
617 }
618
619 #[test]
620 fn derive_positions_unique() {
621 let seed = b"unique-seed";
622 let positions = derive_positions(seed, 10_000, 100);
623 let unique: HashSet<usize> = positions.iter().copied().collect();
624 assert_eq!(unique.len(), positions.len());
625 }
626}