shadowforge_lib/domain/ports.rs
1//! Port trait definitions for all bounded contexts.
2//!
3//! Each trait defines a capability boundary between the domain and its
4//! adapters. All traits are **object-safe** — verified by compile-time
5//! assertions below. No I/O, no `async`, no concrete types from external
6//! crates appear in return positions.
7
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::path::Path;
11
12use bytes::Bytes;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::domain::errors::{
17 AdaptiveError, AnalysisError, ArchiveError, CanaryError, CorrectionError, CryptoError,
18 DeadDropError, DeniableError, DistributionError, MediaError, OpsecError, PdfError,
19 ReconstructionError, ScrubberError, StegoError, TimeLockError,
20};
21use crate::domain::types::{
22 AnalysisReport, ArchiveFormat, CanaryShard, Capacity, CoverMedia, DeniableKeySet,
23 DeniablePayloadPair, EmbeddingProfile, GeographicManifest, KeyPair, Payload, PlatformProfile,
24 Shard, Signature, StegoTechnique, StyloProfile, TimeLockPuzzle, WatermarkReceipt,
25 WatermarkTripwireTag,
26};
27
28// ─── Crypto ───────────────────────────────────────────────────────────────────
29
30/// Key encapsulation mechanism (KEM) port — ML-KEM-1024.
31///
32/// All operations are constant-time with respect to secret key material.
33pub trait Encryptor {
34 /// Generate a fresh key pair.
35 ///
36 /// # Errors
37 /// Returns [`CryptoError::KeyGenFailed`] if the RNG or parameter
38 /// validation fails.
39 fn generate_keypair(&self) -> Result<KeyPair, CryptoError>;
40
41 /// Encapsulate a shared secret using the recipient's public key.
42 ///
43 /// Returns `(ciphertext, shared_secret)`.
44 ///
45 /// # Errors
46 /// Returns [`CryptoError::EncapsulationFailed`] on invalid key material.
47 fn encapsulate(&self, public_key: &[u8]) -> Result<(Bytes, Bytes), CryptoError>;
48
49 /// Decapsulate a shared secret using the holder's secret key.
50 ///
51 /// # Errors
52 /// Returns [`CryptoError::DecapsulationFailed`] on invalid key or
53 /// ciphertext.
54 fn decapsulate(&self, secret_key: &[u8], ciphertext: &[u8]) -> Result<Bytes, CryptoError>;
55}
56
57/// Digital signature port — ML-DSA-87.
58///
59/// All comparisons over signature bytes use constant-time equality.
60pub trait Signer {
61 /// Generate a fresh signing key pair.
62 ///
63 /// # Errors
64 /// Returns [`CryptoError::KeyGenFailed`] if key generation fails.
65 fn generate_keypair(&self) -> Result<KeyPair, CryptoError>;
66
67 /// Sign a message with the secret signing key.
68 ///
69 /// # Errors
70 /// Returns [`CryptoError::SigningFailed`] on invalid key material.
71 fn sign(&self, secret_key: &[u8], message: &[u8]) -> Result<Signature, CryptoError>;
72
73 /// Verify a signature against the public key and message.
74 ///
75 /// Returns `true` when the signature is valid.
76 ///
77 /// # Errors
78 /// Returns [`CryptoError::VerificationFailed`] only on implementation
79 /// errors; an invalid signature returns `Ok(false)`.
80 fn verify(
81 &self,
82 public_key: &[u8],
83 message: &[u8],
84 signature: &Signature,
85 ) -> Result<bool, CryptoError>;
86}
87
88/// Symmetric cipher port — AES-256-GCM.
89pub trait SymmetricCipher {
90 /// Encrypt `plaintext` with `key` and `nonce`.
91 ///
92 /// # Errors
93 /// Returns [`CryptoError::InvalidKeyLength`] or
94 /// [`CryptoError::EncryptionFailed`].
95 fn encrypt(&self, key: &[u8], nonce: &[u8], plaintext: &[u8]) -> Result<Bytes, CryptoError>;
96
97 /// Decrypt and authenticate `ciphertext` with `key` and `nonce`.
98 ///
99 /// # Errors
100 /// Returns [`CryptoError::DecryptionFailed`] if authentication fails.
101 fn decrypt(&self, key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> Result<Bytes, CryptoError>;
102}
103
104// ─── Error Correction ─────────────────────────────────────────────────────────
105
106/// Reed-Solomon K-of-N erasure coding port.
107pub trait ErrorCorrector {
108 /// Encode `data` into `data_shards + parity_shards` [`Shard`]s.
109 ///
110 /// Each shard carries an HMAC-SHA-256 tag over `index || total || data`.
111 ///
112 /// # Errors
113 /// Returns [`CorrectionError::InvalidParameters`] or
114 /// [`CorrectionError::ReedSolomonError`].
115 fn encode(
116 &self,
117 data: &[u8],
118 data_shards: u8,
119 parity_shards: u8,
120 ) -> Result<Vec<Shard>, CorrectionError>;
121
122 /// Decode `shards` back to the original bytes.
123 ///
124 /// Accepts partial shard sets (some may be `None`); requires at least
125 /// `data_shards` valid shards with passing HMAC tags.
126 ///
127 /// # Errors
128 /// Returns [`CorrectionError::InsufficientShards`],
129 /// [`CorrectionError::HmacMismatch`], or
130 /// [`CorrectionError::ReedSolomonError`].
131 fn decode(
132 &self,
133 shards: &[Option<Shard>],
134 data_shards: u8,
135 parity_shards: u8,
136 ) -> Result<Bytes, CorrectionError>;
137}
138
139// ─── Steganography ────────────────────────────────────────────────────────────
140
141/// Embedding half of a steganographic technique.
142pub trait EmbedTechnique {
143 /// The technique identifier for this implementation.
144 fn technique(&self) -> StegoTechnique;
145
146 /// Estimate how many payload bytes `cover` can hold.
147 ///
148 /// # Errors
149 /// Returns [`StegoError::UnsupportedCoverType`] if the cover kind is
150 /// incompatible with this technique.
151 fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError>;
152
153 /// Embed `payload` into `cover`, returning the stego cover.
154 ///
155 /// # Errors
156 /// Returns [`StegoError::PayloadTooLarge`] or
157 /// [`StegoError::MalformedCoverData`].
158 fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError>;
159}
160
161/// Extraction half of a steganographic technique.
162pub trait ExtractTechnique {
163 /// The technique identifier for this implementation.
164 fn technique(&self) -> StegoTechnique;
165
166 /// Extract a hidden payload from `stego`.
167 ///
168 /// # Errors
169 /// Returns [`StegoError::NoPayloadFound`] or
170 /// [`StegoError::IntegrityCheckFailed`].
171 fn extract(&self, stego: &CoverMedia) -> Result<Payload, StegoError>;
172}
173
174// ─── Media ────────────────────────────────────────────────────────────────────
175
176/// Codec adapter port for loading and saving cover media files.
177///
178/// I/O is performed by the adapter; the domain receives decoded pixels/samples.
179pub trait MediaLoader {
180 /// Load a media file from `path` and return decoded [`CoverMedia`].
181 ///
182 /// # Errors
183 /// Returns [`MediaError::UnsupportedFormat`], [`MediaError::DecodeFailed`],
184 /// or [`MediaError::IoError`].
185 fn load(&self, path: &Path) -> Result<CoverMedia, MediaError>;
186
187 /// Encode `media` and write it to `path`.
188 ///
189 /// # Errors
190 /// Returns [`MediaError::EncodeFailed`] or [`MediaError::IoError`].
191 fn save(&self, media: &CoverMedia, path: &Path) -> Result<(), MediaError>;
192}
193
194// ─── PDF ──────────────────────────────────────────────────────────────────────
195
196/// First-class PDF bounded context port.
197///
198/// Covers parsing, page rendering, content-stream LSB, and metadata embedding.
199pub trait PdfProcessor {
200 /// Parse a PDF file from `path` into a [`CoverMedia`].
201 ///
202 /// # Errors
203 /// Returns [`PdfError::ParseFailed`] or [`PdfError::IoError`].
204 fn load_pdf(&self, path: &Path) -> Result<CoverMedia, PdfError>;
205
206 /// Serialise `media` back to a PDF file at `path`.
207 ///
208 /// # Errors
209 /// Returns [`PdfError::RebuildFailed`] or [`PdfError::IoError`].
210 fn save_pdf(&self, media: &CoverMedia, path: &Path) -> Result<(), PdfError>;
211
212 /// Rasterise every page of `pdf` to a PNG [`CoverMedia`].
213 ///
214 /// # Errors
215 /// Returns [`PdfError::RenderFailed`] on any page failure.
216 fn render_pages_to_images(&self, pdf: &CoverMedia) -> Result<Vec<CoverMedia>, PdfError>;
217
218 /// Reconstruct a PDF from rasterised `images`, retaining `original`
219 /// metadata where possible.
220 ///
221 /// # Errors
222 /// Returns [`PdfError::RebuildFailed`].
223 fn rebuild_pdf_from_images(
224 &self,
225 images: Vec<CoverMedia>,
226 original: &CoverMedia,
227 ) -> Result<CoverMedia, PdfError>;
228
229 /// Embed `payload` via content-stream LSB coefficient modification.
230 ///
231 /// # Errors
232 /// Returns [`PdfError::EmbedFailed`].
233 fn embed_in_content_stream(
234 &self,
235 pdf: CoverMedia,
236 payload: &Payload,
237 ) -> Result<CoverMedia, PdfError>;
238
239 /// Extract a payload previously embedded in the content stream.
240 ///
241 /// # Errors
242 /// Returns [`PdfError::ExtractFailed`].
243 fn extract_from_content_stream(&self, pdf: &CoverMedia) -> Result<Payload, PdfError>;
244
245 /// Embed `payload` into XMP / document-level metadata fields.
246 ///
247 /// # Errors
248 /// Returns [`PdfError::EmbedFailed`].
249 fn embed_in_metadata(&self, pdf: CoverMedia, payload: &Payload)
250 -> Result<CoverMedia, PdfError>;
251
252 /// Extract a payload previously embedded in XMP / document-level metadata.
253 ///
254 /// # Errors
255 /// Returns [`PdfError::ExtractFailed`].
256 fn extract_from_metadata(&self, pdf: &CoverMedia) -> Result<Payload, PdfError>;
257}
258
259// ─── Distribution ─────────────────────────────────────────────────────────────
260
261/// Payload distribution port.
262///
263/// Accepts an embedder trait-object so the distribution pattern is decoupled
264/// from any specific steganographic technique.
265pub trait Distributor {
266 /// Distribute `payload` across `covers` according to `profile`.
267 ///
268 /// # Errors
269 /// Returns [`DistributionError::InsufficientCovers`] or
270 /// [`DistributionError::EmbedFailed`].
271 fn distribute(
272 &self,
273 payload: &Payload,
274 profile: &EmbeddingProfile,
275 covers: Vec<CoverMedia>,
276 embedder: &dyn EmbedTechnique,
277 ) -> Result<Vec<CoverMedia>, DistributionError>;
278}
279
280// ─── Reconstruction ───────────────────────────────────────────────────────────
281
282/// K-of-N shard reconstruction port.
283pub trait Reconstructor {
284 /// Reconstruct the original payload from stego `covers`.
285 ///
286 /// `progress_cb` is called with `(completed, total)` after each
287 /// extraction step so callers can display progress.
288 ///
289 /// # Errors
290 /// Returns [`ReconstructionError::InsufficientCovers`],
291 /// [`ReconstructionError::ExtractionFailed`], or
292 /// [`ReconstructionError::CorrectionFailed`].
293 fn reconstruct(
294 &self,
295 covers: Vec<CoverMedia>,
296 extractor: &dyn ExtractTechnique,
297 progress_cb: &dyn Fn(usize, usize),
298 ) -> Result<Payload, ReconstructionError>;
299}
300
301// ─── Analysis ─────────────────────────────────────────────────────────────────
302
303/// Steganalysis and capacity estimation port.
304pub trait CapacityAnalyser {
305 /// Analyse `cover` with `technique` and return an [`AnalysisReport`].
306 ///
307 /// # Errors
308 /// Returns [`AnalysisError::UnsupportedCoverType`] or
309 /// [`AnalysisError::ComputationFailed`].
310 fn analyse(
311 &self,
312 cover: &CoverMedia,
313 technique: StegoTechnique,
314 ) -> Result<AnalysisReport, AnalysisError>;
315}
316
317// ─── Archive ──────────────────────────────────────────────────────────────────
318
319/// Multi-carrier archive port (ZIP / TAR / TAR.GZ).
320pub trait ArchiveHandler {
321 /// Pack `files` (name, bytes) into an archive of `format`.
322 ///
323 /// # Errors
324 /// Returns [`ArchiveError::PackFailed`] or
325 /// [`ArchiveError::UnsupportedFormat`].
326 fn pack(&self, files: &[(&str, &[u8])], format: ArchiveFormat) -> Result<Bytes, ArchiveError>;
327
328 /// Unpack `archive` of `format` into `(name, bytes)` pairs.
329 ///
330 /// # Errors
331 /// Returns [`ArchiveError::UnpackFailed`] or
332 /// [`ArchiveError::UnsupportedFormat`].
333 fn unpack(
334 &self,
335 archive: &[u8],
336 format: ArchiveFormat,
337 ) -> Result<Vec<(String, Bytes)>, ArchiveError>;
338}
339
340// ─── Adaptive Embedding ───────────────────────────────────────────────────────
341
342/// Serde helper for `[u16; 64]` — serde only auto-derives array impls up to
343/// `[T; 32]`, so we provide a thin wrapper using `Vec<u16>` as the wire format.
344mod serde_quant_table {
345 use serde::{Deserialize, Deserializer, Serialize, Serializer};
346
347 pub fn serialize<S: Serializer>(arr: &[u16; 64], s: S) -> Result<S::Ok, S::Error> {
348 arr.as_slice().serialize(s)
349 }
350
351 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u16; 64], D::Error> {
352 let v = Vec::<u16>::deserialize(d)?;
353 v.as_slice().try_into().map_err(|_| {
354 serde::de::Error::invalid_length(v.len(), &"exactly 64 JPEG quantisation coefficients")
355 })
356 }
357}
358
359/// Camera-model statistical fingerprint — used to defeat model-based
360/// steganalysis by matching the cover's noise floor.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct CameraProfile {
363 /// JPEG quantisation table (64 coefficients in zig-zag order).
364 #[serde(with = "serde_quant_table")]
365 pub quantisation_table: [u16; 64],
366 /// Estimated noise floor of the camera sensor (in decibels).
367 pub noise_floor_db: f64,
368 /// Human-readable camera model identifier.
369 pub model_id: String,
370}
371
372/// Spectral fingerprint for a single AI image generator model.
373///
374/// Stores per-resolution carrier frequency maps extracted from reference
375/// images (pure-black / pure-white outputs dominated by the watermark
376/// signal). The `carrier_map` key is `"WIDTHxHEIGHT"` (e.g. `"1024x1024"`).
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct AiGenProfile {
379 /// Generator identifier, e.g. `"gemini"`, `"midjourney-v7"`.
380 pub model_id: String,
381 /// Per-channel embedding weights `[R, G, B]` (G is reference=1.0).
382 pub channel_weights: [f64; 3],
383 /// Map from `"WxH"` resolution string to carrier bin list.
384 pub carrier_map: HashMap<String, Vec<CarrierBin>>,
385}
386
387impl AiGenProfile {
388 /// Return carrier bins for the given resolution, or `None` if unknown.
389 #[must_use]
390 pub fn carrier_bins_for(&self, width: u32, height: u32) -> Option<&[CarrierBin]> {
391 let key = format!("{width}x{height}");
392 self.carrier_map.get(&key).map(Vec::as_slice)
393 }
394}
395
396/// A single frequency-domain carrier bin occupied by an AI generator's
397/// watermark.
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct CarrierBin {
400 /// `(row_bin, col_bin)` in the 2-D FFT of the green channel.
401 pub freq: (u32, u32),
402 /// Expected phase angle of the carrier (radians).
403 pub phase: f64,
404 /// Measured phase coherence across reference images, clamped to
405 /// `0.0..=1.0` by the constructor. The custom deserializer enforces
406 /// the same clamp so untrusted profiles cannot bypass it.
407 #[serde(deserialize_with = "de_clamp_coherence")]
408 coherence: f64,
409}
410
411/// Serde deserializer that clamps a `f64` to `0.0..=1.0`.
412fn de_clamp_coherence<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
413 let v = f64::deserialize(d)?;
414 Ok(v.clamp(0.0, 1.0))
415}
416
417impl CarrierBin {
418 /// Construct a `CarrierBin`, clamping `coherence` to `0.0..=1.0`.
419 #[must_use]
420 pub const fn new(freq: (u32, u32), phase: f64, coherence: f64) -> Self {
421 Self {
422 freq,
423 phase,
424 coherence: coherence.clamp(0.0, 1.0),
425 }
426 }
427
428 /// Return the (clamped) phase coherence value.
429 #[must_use]
430 pub const fn coherence(&self) -> f64 {
431 self.coherence
432 }
433
434 /// Return `true` when coherence ≥ 0.90 — a reliable carrier.
435 #[must_use]
436 pub fn is_strong(&self) -> bool {
437 self.coherence >= 0.90
438 }
439}
440
441/// Discriminated union of cover-source fingerprint profiles.
442///
443/// Use `CoverProfile::Camera` for traditional camera-sourced images and
444/// `CoverProfile::AiGenerator` for AI-generated covers (Gemini, Midjourney,
445/// etc.).
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(tag = "kind", content = "data")]
448pub enum CoverProfile {
449 /// Traditional camera-sourced image (JPEG quant table + sensor noise).
450 Camera(CameraProfile),
451 /// AI-generated image with per-resolution spectral carrier bins.
452 AiGenerator(AiGenProfile),
453}
454
455impl CoverProfile {
456 /// Return the human-readable generator / camera model identifier.
457 #[must_use]
458 pub fn model_id(&self) -> &str {
459 match self {
460 Self::Camera(p) => &p.model_id,
461 Self::AiGenerator(p) => &p.model_id,
462 }
463 }
464}
465
466/// Adversarial embedding optimiser port (STC-inspired).
467///
468/// Reorders bit assignments after embedding to minimise chi-square distance
469/// from the unmodified cover's statistical distribution.
470pub trait AdaptiveOptimiser {
471 /// Optimise `stego` to stay within `target_db` of the original's
472 /// statistical distribution.
473 ///
474 /// # Errors
475 /// Returns [`AdaptiveError::BudgetNotMet`] if no permutation achieves
476 /// the target.
477 fn optimise(
478 &self,
479 stego: CoverMedia,
480 original: &CoverMedia,
481 target_db: f64,
482 ) -> Result<CoverMedia, AdaptiveError>;
483}
484
485/// Cover profile matching port — detects whether a cover is AI-generated or
486/// camera-sourced.
487pub trait CoverProfileMatcher {
488 /// Return the best-matching [`CoverProfile`] for `cover`, or `None`
489 /// if no profile is close enough.
490 fn profile_for(&self, cover: &CoverMedia) -> Option<CoverProfile>;
491
492 /// Apply `profile` to `cover` (e.g. adjust JPEG quant tables for a
493 /// camera profile; no-op for AI profiles — we avoid their bins instead).
494 ///
495 /// # Errors
496 /// Returns [`AdaptiveError::ProfileMatchFailed`].
497 fn apply_profile(
498 &self,
499 cover: CoverMedia,
500 profile: &CoverProfile,
501 ) -> Result<CoverMedia, AdaptiveError>;
502}
503
504/// Compression survivability port — social media platform recompression.
505pub trait CompressionSimulator {
506 /// Simulate a target platform's recompression pipeline on `cover`.
507 ///
508 /// # Errors
509 /// Returns [`AdaptiveError::CompressionSimFailed`].
510 fn simulate(
511 &self,
512 cover: CoverMedia,
513 platform: &PlatformProfile,
514 ) -> Result<CoverMedia, AdaptiveError>;
515
516 /// Estimate the embedding capacity that survives `platform`'s pipeline.
517 ///
518 /// # Errors
519 /// Returns [`AdaptiveError::CompressionSimFailed`].
520 fn survivable_capacity(
521 &self,
522 cover: &CoverMedia,
523 platform: &PlatformProfile,
524 ) -> Result<Capacity, AdaptiveError>;
525}
526
527// ─── Deniable Steganography ───────────────────────────────────────────────────
528
529/// Dual-payload deniable steganography port.
530///
531/// The stego cover is indistinguishable regardless of which key is presented.
532pub trait DeniableEmbedder {
533 /// Embed both the real and decoy payload in `cover`.
534 ///
535 /// The resulting cover decrypts to `pair.real_payload` under
536 /// `keys.primary_key` and to `pair.decoy_payload` under `keys.decoy_key`.
537 ///
538 /// # Errors
539 /// Returns [`DeniableError::InsufficientCapacity`] or
540 /// [`DeniableError::EmbedFailed`].
541 fn embed_dual(
542 &self,
543 cover: CoverMedia,
544 pair: &DeniablePayloadPair,
545 keys: &DeniableKeySet,
546 embedder: &dyn EmbedTechnique,
547 ) -> Result<CoverMedia, DeniableError>;
548
549 /// Extract a payload from `stego` using the provided `key`.
550 ///
551 /// Returns the decoy payload when given the decoy key, and the real
552 /// payload when given the primary key — neither party can prove which.
553 ///
554 /// # Errors
555 /// Returns [`DeniableError::ExtractionFailed`].
556 fn extract_with_key(
557 &self,
558 stego: &CoverMedia,
559 key: &[u8],
560 extractor: &dyn ExtractTechnique,
561 ) -> Result<Payload, DeniableError>;
562}
563
564// ─── Operational Security ─────────────────────────────────────────────────────
565
566/// Emergency panic-wipe port.
567///
568/// Synchronous and best-effort: logs failures internally but completes all
569/// wipe steps regardless. Must never propagate an error to the caller under
570/// duress.
571pub trait PanicWiper {
572 /// Securely erase all paths described in `config`.
573 ///
574 /// # Errors
575 /// Returns [`OpsecError::WipeStepFailed`] only when *all* steps have
576 /// been attempted and logging is safe. Under duress this should be
577 /// treated as non-fatal by the caller.
578 fn wipe(&self, config: &crate::domain::types::PanicWipeConfig) -> Result<(), OpsecError>;
579}
580
581/// Forensic watermark tripwire port.
582///
583/// Unique per-recipient watermarks allow identifying which copy of a
584/// distributed set was leaked.
585pub trait ForensicWatermarker {
586 /// Embed a per-recipient tripwire watermark into `cover`.
587 ///
588 /// # Errors
589 /// Returns [`OpsecError::WatermarkError`].
590 fn embed_tripwire(
591 &self,
592 cover: CoverMedia,
593 tag: &WatermarkTripwireTag,
594 ) -> Result<CoverMedia, OpsecError>;
595
596 /// Identify which recipient's watermark is present in `stego`.
597 ///
598 /// Returns the matching [`WatermarkTripwireTag`] from `tags`, or `None`
599 /// if no match is found.
600 ///
601 /// # Errors
602 /// Returns [`OpsecError::WatermarkError`] on implementation error.
603 fn identify_recipient(
604 &self,
605 stego: &CoverMedia,
606 tags: &[WatermarkTripwireTag],
607 ) -> Result<Option<WatermarkReceipt>, OpsecError>;
608}
609
610/// Amnesiac in-memory pipeline port.
611///
612/// The entire embed/extract cycle runs without touching the filesystem.
613/// Uses [`std::io::pipe`] (stable 1.87) internally.
614pub trait AmnesiaPipeline {
615 /// Embed a payload read from `cover_input` and `payload_input` using
616 /// `technique`, writing the stego output to `output`.
617 ///
618 /// No temporary files, logs of sensitive data, or crash dumps are created.
619 ///
620 /// # Errors
621 /// Returns [`OpsecError::PipelineError`].
622 fn embed_in_memory(
623 &self,
624 payload_input: &mut dyn Read,
625 cover_input: &mut dyn Read,
626 output: &mut dyn Write,
627 technique: &dyn EmbedTechnique,
628 ) -> Result<(), OpsecError>;
629}
630
631/// Geographic threshold distribution port.
632///
633/// Annotates shards with jurisdictional metadata, producing a
634/// [`GeographicManifest`] that makes legal compulsion across jurisdictions
635/// impractical.
636pub trait GeographicDistributor {
637 /// Distribute `payload` across `covers` and annotate each shard with
638 /// jurisdictional metadata from `manifest`.
639 ///
640 /// # Errors
641 /// Returns [`OpsecError::ManifestError`].
642 fn distribute_with_manifest(
643 &self,
644 payload: &Payload,
645 covers: Vec<CoverMedia>,
646 manifest: &GeographicManifest,
647 embedder: &dyn EmbedTechnique,
648 ) -> Result<Vec<CoverMedia>, OpsecError>;
649}
650
651// ─── Canary Shards ────────────────────────────────────────────────────────────
652
653/// Canary shard tripwire port.
654pub trait CanaryService {
655 /// Embed an additional canary shard in `covers` alongside the regular
656 /// distribution.
657 ///
658 /// Returns the modified covers and the [`CanaryShard`] to be planted in
659 /// a honeypot location.
660 ///
661 /// # Errors
662 /// Returns [`CanaryError::NoCovers`] or [`CanaryError::EmbedFailed`].
663 fn embed_canary(
664 &self,
665 covers: Vec<CoverMedia>,
666 embedder: &dyn EmbedTechnique,
667 ) -> Result<(Vec<CoverMedia>, CanaryShard), CanaryError>;
668
669 /// Return `true` if the `shard`'s notify URL is reachable, indicating
670 /// the canary has been accessed.
671 ///
672 /// Non-blocking check; returns `false` on network error.
673 fn check_canary(&self, shard: &CanaryShard) -> bool;
674}
675
676// ─── Dead Drop ────────────────────────────────────────────────────────────────
677
678/// Platform-aware dead drop encoder port.
679///
680/// Produces a stego cover optimised for posting publicly on a target
681/// platform. No direct file transfer between parties.
682pub trait DeadDropEncoder {
683 /// Encode `payload` into `cover` for posting on `platform`.
684 ///
685 /// The resulting cover survives the platform's recompression pipeline
686 /// and can be retrieved by the recipient via public URL.
687 ///
688 /// # Errors
689 /// Returns [`DeadDropError::UnsupportedPlatform`] or
690 /// [`DeadDropError::EncodeFailed`].
691 fn encode_for_platform(
692 &self,
693 cover: CoverMedia,
694 payload: &Payload,
695 platform: &PlatformProfile,
696 embedder: &dyn EmbedTechnique,
697 ) -> Result<CoverMedia, DeadDropError>;
698}
699
700// ─── Time-Lock ────────────────────────────────────────────────────────────────
701
702/// Rivest sequential-squaring time-lock puzzle port.
703///
704/// A payload cannot be decrypted before a specified time, even under
705/// compulsion.
706pub trait TimeLockService {
707 /// Wrap `payload` in a time-lock puzzle that cannot be solved before
708 /// `unlock_at`.
709 ///
710 /// # Errors
711 /// Returns [`TimeLockError::ComputationFailed`].
712 fn lock(
713 &self,
714 payload: &Payload,
715 unlock_at: DateTime<Utc>,
716 ) -> Result<TimeLockPuzzle, TimeLockError>;
717
718 /// Solve the `puzzle` by sequential squaring and decrypt the payload.
719 ///
720 /// Blocks until the puzzle is solved; may take significant time.
721 ///
722 /// # Errors
723 /// Returns [`TimeLockError::ComputationFailed`] or
724 /// [`TimeLockError::DecryptFailed`].
725 fn unlock(&self, puzzle: &TimeLockPuzzle) -> Result<Payload, TimeLockError>;
726
727 /// Non-blocking puzzle check. Returns `Ok(Some(payload))` if the puzzle
728 /// is already solved, `Ok(None)` if it cannot yet be solved, or an error
729 /// if the computation itself fails.
730 ///
731 /// # Errors
732 /// Returns [`TimeLockError::ComputationFailed`] or
733 /// [`TimeLockError::DecryptFailed`].
734 fn try_unlock(&self, puzzle: &TimeLockPuzzle) -> Result<Option<Payload>, TimeLockError>;
735}
736
737// ─── Stylometric Scrubber ─────────────────────────────────────────────────────
738
739/// Linguistic stylometric fingerprint scrubbing port.
740///
741/// Normalises text to destroy authorship attribution fingerprints without
742/// changing the semantic content.
743pub trait StyloScrubber {
744 /// Scrub `text` to match `profile`.
745 ///
746 /// # Errors
747 /// Returns [`ScrubberError::InvalidUtf8`] or
748 /// [`ScrubberError::ProfileNotSatisfied`].
749 fn scrub(&self, text: &str, profile: &StyloProfile) -> Result<String, ScrubberError>;
750}
751
752// ─── Corpus Steganography ─────────────────────────────────────────────────────
753
754/// Corpus index and zero-modification cover selection port.
755pub trait CorpusIndex {
756 /// Search the index for covers whose natural bit pattern already encodes
757 /// (or closely encodes) `payload` using `technique`.
758 ///
759 /// Returns up to `max_results` entries sorted by match quality.
760 ///
761 /// # Errors
762 /// Returns [`crate::domain::errors::CorpusError::NoSuitableCover`] or
763 /// [`crate::domain::errors::CorpusError::IndexError`].
764 fn search(
765 &self,
766 payload: &Payload,
767 technique: StegoTechnique,
768 max_results: usize,
769 ) -> Result<Vec<crate::domain::types::CorpusEntry>, crate::domain::errors::CorpusError>;
770
771 /// Add the file at `path` to the index, computing its bit-pattern
772 /// fingerprint.
773 ///
774 /// # Errors
775 /// Returns [`crate::domain::errors::CorpusError::AddFailed`].
776 fn add_to_index(
777 &self,
778 path: &Path,
779 ) -> Result<crate::domain::types::CorpusEntry, crate::domain::errors::CorpusError>;
780
781 /// Scan `corpus_dir` recursively and build the full index.
782 ///
783 /// Returns the number of entries indexed.
784 ///
785 /// # Errors
786 /// Returns [`crate::domain::errors::CorpusError::IndexError`].
787 fn build_index(&self, corpus_dir: &Path) -> Result<usize, crate::domain::errors::CorpusError>;
788
789 /// Search the index for covers that match `payload` and have a
790 /// `spectral_key` whose `model_id` and `resolution` match the supplied
791 /// values.
792 ///
793 /// Returns up to `max_results` entries sorted by match quality.
794 ///
795 /// # Errors
796 /// Returns [`crate::domain::errors::CorpusError::NoSuitableCover`] or
797 /// [`crate::domain::errors::CorpusError::IndexError`].
798 fn search_for_model(
799 &self,
800 payload: &Payload,
801 model_id: &str,
802 resolution: (u32, u32),
803 max_results: usize,
804 ) -> Result<Vec<crate::domain::types::CorpusEntry>, crate::domain::errors::CorpusError>;
805
806 /// Return a sorted list of `(SpectralKey, count)` pairs describing how
807 /// many corpus entries are indexed per model/resolution combination.
808 ///
809 /// Using `Vec` instead of `HashMap` to keep the trait object-safe.
810 fn model_stats(&self) -> Vec<(crate::domain::types::SpectralKey, usize)>;
811}
812
813// ─── Object-Safety Assertions ─────────────────────────────────────────────────
814//
815// These compile-time checks verify that every port trait is object-safe.
816// If any trait is accidentally made non-object-safe (e.g. by adding a
817// method with a generic parameter), this module will fail to compile with
818// a clear error pointing at the offending trait.
819
820#[cfg(test)]
821mod object_safety_tests {
822 use super::*;
823
824 /// Verifies object safety of all port traits.
825 #[test]
826 fn all_port_traits_are_object_safe() {
827 fn assert_object_safe<T: ?Sized>() {}
828
829 assert_object_safe::<dyn Encryptor>();
830 assert_object_safe::<dyn Signer>();
831 assert_object_safe::<dyn SymmetricCipher>();
832 assert_object_safe::<dyn ErrorCorrector>();
833 assert_object_safe::<dyn EmbedTechnique>();
834 assert_object_safe::<dyn ExtractTechnique>();
835 assert_object_safe::<dyn MediaLoader>();
836 assert_object_safe::<dyn PdfProcessor>();
837 assert_object_safe::<dyn Distributor>();
838 assert_object_safe::<dyn Reconstructor>();
839 assert_object_safe::<dyn CapacityAnalyser>();
840 assert_object_safe::<dyn ArchiveHandler>();
841 assert_object_safe::<dyn AdaptiveOptimiser>();
842 assert_object_safe::<dyn CoverProfileMatcher>();
843 assert_object_safe::<dyn CompressionSimulator>();
844 assert_object_safe::<dyn DeniableEmbedder>();
845 assert_object_safe::<dyn PanicWiper>();
846 assert_object_safe::<dyn ForensicWatermarker>();
847 assert_object_safe::<dyn AmnesiaPipeline>();
848 assert_object_safe::<dyn GeographicDistributor>();
849 assert_object_safe::<dyn CanaryService>();
850 assert_object_safe::<dyn DeadDropEncoder>();
851 assert_object_safe::<dyn TimeLockService>();
852 assert_object_safe::<dyn StyloScrubber>();
853 assert_object_safe::<dyn CorpusIndex>();
854 }
855}
856
857#[cfg(test)]
858mod cover_profile_tests {
859 use super::*;
860
861 type TestResult = Result<(), Box<dyn std::error::Error>>;
862
863 #[test]
864 fn camera_profile_model_id_via_cover_profile() {
865 let profile = CoverProfile::Camera(CameraProfile {
866 quantisation_table: [0u16; 64],
867 noise_floor_db: -80.0,
868 model_id: "canon-eos-r6".to_string(),
869 });
870 assert_eq!(profile.model_id(), "canon-eos-r6");
871 }
872
873 #[test]
874 fn ai_gen_profile_model_id_via_cover_profile() {
875 let profile = CoverProfile::AiGenerator(AiGenProfile {
876 model_id: "gemini".to_string(),
877 channel_weights: [0.85, 1.0, 0.70],
878 carrier_map: HashMap::new(),
879 });
880 assert_eq!(profile.model_id(), "gemini");
881 }
882
883 #[test]
884 fn carrier_bin_coherence_clamped_above_one() {
885 let bin = CarrierBin::new((9, 9), 0.0, 1.5);
886 assert!((bin.coherence() - 1.0).abs() < f64::EPSILON);
887 }
888
889 #[test]
890 fn carrier_bin_coherence_clamped_below_zero() {
891 let bin = CarrierBin::new((9, 9), 0.0, -0.5);
892 assert!((bin.coherence() - 0.0).abs() < f64::EPSILON);
893 }
894
895 #[test]
896 fn carrier_bin_is_strong_at_exactly_0_90() {
897 let strong = CarrierBin::new((9, 9), 0.0, 0.90);
898 assert!(strong.is_strong());
899 }
900
901 #[test]
902 fn carrier_bin_not_strong_below_0_90() {
903 let weak = CarrierBin::new((9, 9), 0.0, 0.899_999);
904 assert!(!weak.is_strong());
905 }
906
907 #[test]
908 fn ai_gen_profile_carrier_bins_for_known_resolution() {
909 let bins = vec![CarrierBin::new((9, 9), 0.0, 1.0)];
910 let mut carrier_map = HashMap::new();
911 carrier_map.insert("1024x1024".to_string(), bins);
912 let profile = AiGenProfile {
913 model_id: "gemini".to_string(),
914 channel_weights: [0.85, 1.0, 0.70],
915 carrier_map,
916 };
917 assert!(profile.carrier_bins_for(1024, 1024).is_some());
918 assert!(profile.carrier_bins_for(512, 512).is_none());
919 }
920
921 #[test]
922 fn ai_gen_profile_carrier_bins_count() {
923 let bins = vec![
924 CarrierBin::new((9, 9), 0.0, 1.0),
925 CarrierBin::new((5, 5), 0.0, 1.0),
926 ];
927 let mut carrier_map = HashMap::new();
928 carrier_map.insert("1024x1024".to_string(), bins);
929 let profile = AiGenProfile {
930 model_id: "gemini".to_string(),
931 channel_weights: [0.85, 1.0, 0.70],
932 carrier_map,
933 };
934 assert_eq!(
935 profile.carrier_bins_for(1024, 1024).map(<[_]>::len),
936 Some(2)
937 );
938 }
939
940 #[test]
941 fn cover_profile_round_trips_through_serde_json() -> TestResult {
942 let profile = CoverProfile::AiGenerator(AiGenProfile {
943 model_id: "gemini".to_string(),
944 channel_weights: [0.85, 1.0, 0.70],
945 carrier_map: HashMap::new(),
946 });
947 let json = serde_json::to_string(&profile)?;
948 let decoded: CoverProfile = serde_json::from_str(&json)?;
949 assert_eq!(decoded.model_id(), "gemini");
950 Ok(())
951 }
952
953 #[test]
954 fn camera_cover_profile_round_trips_through_serde_json() -> TestResult {
955 let profile = CoverProfile::Camera(CameraProfile {
956 quantisation_table: [2u16; 64],
957 noise_floor_db: -75.0,
958 model_id: "nikon-z9".to_string(),
959 });
960 let json = serde_json::to_string(&profile)?;
961 let decoded: CoverProfile = serde_json::from_str(&json)?;
962 assert_eq!(decoded.model_id(), "nikon-z9");
963 Ok(())
964 }
965}