Skip to main content

shadowforge_lib/domain/opsec/
mod.rs

1//! Amnesiac mode, geographic distribution manifests, forensic watermark tripwires.
2//!
3//! This module contains the pure domain logic for operational security.
4//! The amnesiac pipeline runs entirely in memory: no temp files, no logs,
5//! no filesystem writes.
6
7use 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
19/// Read all bytes from a reader into a `Vec<u8>`, zeroizing on error.
20fn 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
31/// Run the embed pipeline entirely in memory.
32///
33/// 1. Reads cover and payload from their respective readers.
34/// 2. Embeds payload into cover using the given technique.
35/// 3. Writes the stego output to `output`.
36/// 4. Zeroizes all intermediate buffers.
37///
38/// # Errors
39///
40/// Returns [`OpsecError::PipelineError`] if any step fails.
41pub 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    // Step 1: Read cover
48    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    // Step 2: Read payload
56    let mut payload_bytes = read_all_zeroizing(payload_input)?;
57    let payload = Payload::from_bytes(payload_bytes.clone());
58    payload_bytes.zeroize();
59
60    // Step 3: Embed
61    let stego = technique
62        .embed(cover, &payload)
63        .map_err(|e| OpsecError::PipelineError {
64            reason: format!("embed failed: {e}"),
65        })?;
66
67    // Step 4: Write output
68    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
77// ─── Geographic Distribution ──────────────────────────────────────────────────
78
79/// Validate a geographic manifest.
80///
81/// Ensures that the number of distinct jurisdictions meets the
82/// `minimum_jurisdictions` requirement.
83///
84/// # Errors
85///
86/// Returns [`OpsecError::ManifestError`] if validation fails.
87pub 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
108/// Build a geographic manifest from shard assignments.
109///
110/// # Errors
111///
112/// Returns [`OpsecError::ManifestError`] if the manifest is invalid.
113pub 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/// Produce a human-readable recovery complexity score for a manifest.
126///
127/// Returns a string summarising which jurisdictions must cooperate and an
128/// estimated coordination difficulty.
129#[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/// Render a geographic manifest as a Markdown document.
151#[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
180// ─── Forensic Watermark Tripwires ─────────────────────────────────────────────
181
182/// Fixed marker pattern embedded at seed-derived positions.
183const MARKER_PATTERN: [u8; 4] = [0xDE, 0xAD, 0xBE, 0xEF];
184/// Number of marker bits to embed in LSB positions.
185const MARKER_BITS: usize = MARKER_PATTERN.len() * 8;
186
187// LCG parameters (Numerical Recipes) for deterministic position derivation.
188const LCG_A: u64 = 6_364_136_223_846_793_005;
189const LCG_C: u64 = 1_442_695_040_888_963_407;
190
191/// Derive deterministic LSB positions from a watermark tag's embedding seed.
192///
193/// Produces `count` unique byte-offset positions within `cover_len` bytes
194/// using a simple seeded LCG. The positions are deterministic given the seed
195/// but appear random.
196fn 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    // Seed a simple LCG from the embedding_seed bytes
202    let mut state: u64 = 0;
203    for (i, &b) in seed_bytes.iter().enumerate() {
204        // i % 8 is always in 0..7 so the multiply can never exceed 56
205        #[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
224/// Embed a forensic watermark tripwire into cover data.
225///
226/// Modifies LSBs at seed-derived positions to encode the marker pattern.
227///
228/// # Errors
229///
230/// Returns [`OpsecError::WatermarkError`] if the cover is too small.
231pub 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        // bit_idx < MARKER_BITS = 32, so bit_idx/8 < 4 = MARKER_PATTERN.len()
249        #[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/// Try to identify which recipient's watermark is present in cover data.
265///
266/// Returns the index of the matching tag, or `None` if no match is found.
267#[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            // bit_idx < MARKER_BITS = 32, so bit_idx/8 < 4 = MARKER_PATTERN.len()
279            #[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    /// A mock embed technique that appends payload bytes to cover data.
310    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    /// A mock embed technique that always fails.
336    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        // Output should contain both cover and payload bytes (mock appends)
374        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        // With empty payload, output should match cover
397        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        // Verify that we can run multiple embeds without accumulating state
423        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    // ─── Geographic Distribution Tests ────────────────────────────────────
434
435    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    // ─── Forensic Watermark Tests ─────────────────────────────────────────
528
529    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        // All three should have different byte patterns
572        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)); // tag_b is at index 1
589        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); // unmodified
596
597        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); // Way too small for 32 bits
606
607        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}