stenoxide-core 3.7.4

Adaptive LSB steganography engine with HILL cost functions, STC embedding and an Argon2id + XChaCha20-Poly1305 cryptographic pipeline
Documentation
//! Capacity sizer enforcing the `max_bpp` compile-time hard limit.
//!
//! The sizer answers one question before anything is encrypted or embedded: how
//! many payload bytes does this container actually admit? Asking it early is
//! what keeps the failure cheap and honest — a payload that does not fit is
//! rejected while it is still plaintext in the caller's hands, rather than after
//! a key derivation, a compression pass and a full cost analysis.
//!
//! # What the numbers mean
//!
//! Capacity is whittled down in three steps, and each one is a different kind of
//! constraint:
//!
//! 1. **The security ceiling.** Only [`MAX_BPP`] bits per usable position may be
//!    embedded. This is not a property of the image or of the code; it is the
//!    payload rate below which the modern rich-model detectors stay near chance.
//! 2. **The coding efficiency.** Syndrome-Trellis Codes do not reach the
//!    rate-distortion bound exactly, so a fraction of the gross bits is spent on
//!    the code itself. `STC_EFFICIENCY` is the conservative share that
//!    survives.
//! 3. **The cryptographic overhead.** The Poly1305 tag rides inside the embedded
//!    bits and is not payload, so it comes off the top.
//!
//! # Why the error says so little
//!
//! [`SizerError`] carries the exact figures for the caller that wants them, but
//! its message deliberately does not print them. A user-visible error is the one
//! artifact of this system an adversary is most likely to obtain — pasted into a
//! bug report, a chat, a screenshot — and a message quoting the exact available
//! byte count leaks the efficiency factor, the overhead and, through them, the
//! number of usable positions the container was found to have. The advice
//! "shorten the message or use a larger image" is everything the user needs and
//! nothing an attacker can key on.

use std::fmt;

use crate::cost::CostMap;
use crate::stego::stc::MAX_BPP;

/// Share of the gross capacity that survives Syndrome-Trellis coding.
///
/// The trellis spends part of the cover on the code itself, and the exact share
/// depends on the constraint height and on the shape of the cost distribution.
/// Eighty-five per cent is deliberately pessimistic: the sizer's promise is that
/// a payload it accepts will embed, so it must round against itself. Advertising
/// capacity the coder then refuses would turn a clean rejection into a failure
/// halfway through the pipeline.
const STC_EFFICIENCY: f32 = 0.85;

/// Bytes of Poly1305 tag carried inside the embedded bits.
const MAC_OVERHEAD_BYTES: usize = 16;

/// Bytes of ML-KEM-1024 ciphertext carried alongside an asymmetric payload.
///
/// The recipient decapsulates it to recover the message key, so it must travel
/// inside the container and comes out of the same budget as the payload.
#[cfg(feature = "pqc")]
const ML_KEM_1024_CIPHERTEXT_BYTES: usize = 1568;

/// Bits in a byte, named where the conversion happens.
const BITS_PER_BYTE: usize = 8;

/// The one way capacity planning can fail.
#[derive(Debug)]
pub enum SizerError {
    /// The payload is larger than the container admits.
    PayloadTooLarge {
        /// Size of the payload, in bytes.
        payload: usize,
        /// Bytes the container admits.
        available: usize,
        /// How many bytes over the limit the payload is.
        deficit: usize,
    },
}

impl fmt::Display for SizerError {
    /// Explains what to do, never what the limit is; see the module
    /// documentation.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SizerError::PayloadTooLarge { .. } => write!(
                f,
                "the message does not fit in this image; shorten the message or use an image of \
                 higher resolution"
            ),
        }
    }
}

impl std::error::Error for SizerError {}

/// How the message key reaches the recipient.
///
/// The choice changes what has to be embedded besides the payload, which is why
/// capacity planning needs to know about it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EmbeddingMode {
    /// Both sides derive the key from a shared password. Nothing but the
    /// ciphertext and its tag is embedded.
    #[default]
    Symmetric,
    /// The message key is encapsulated to the recipient's ML-KEM-1024 public
    /// key, and the resulting ciphertext is embedded with the payload.
    #[cfg(feature = "pqc")]
    AsymmetricPqc,
}

impl EmbeddingMode {
    /// Bytes this mode spends on getting the key to the recipient.
    ///
    /// Zero for [`EmbeddingMode::Symmetric`], where the key never travels: the
    /// recipient rederives it from the password and the container itself.
    pub fn key_transport_overhead_bytes(self) -> usize {
        match self {
            EmbeddingMode::Symmetric => 0,
            #[cfg(feature = "pqc")]
            EmbeddingMode::AsymmetricPqc => ML_KEM_1024_CIPHERTEXT_BYTES,
        }
    }
}

/// What a container can carry, broken down by the constraint that shaped it.
///
/// The fields are `pub(crate)` and mirrored by accessors: the report is a
/// measurement, and nothing outside this layer should be able to write one that
/// no cost map produced.
#[derive(Debug, Clone, Copy)]
pub struct CapacityReport {
    /// Pixels in the container.
    pub(crate) total_pixels: usize,
    /// Pixels the embedder may use, i.e. those with a strictly positive cost.
    pub(crate) textured_pixels: usize,
    /// Bits allowed over the usable pixels by the [`MAX_BPP`] ceiling.
    pub(crate) gross_capacity_bits: usize,
    /// Bits left once Syndrome-Trellis coding has taken its share.
    pub(crate) net_capacity_bits: usize,
    /// Bytes of fixed cryptographic overhead: the Poly1305 tag.
    pub(crate) mac_overhead_bytes: usize,
    /// Bytes of payload the container admits.
    pub(crate) available_bytes: usize,
}

impl CapacityReport {
    /// Pixels in the container.
    pub fn total_pixels(&self) -> usize {
        self.total_pixels
    }

    /// Pixels the embedder may use.
    pub fn textured_pixels(&self) -> usize {
        self.textured_pixels
    }

    /// Bits allowed by the [`MAX_BPP`] ceiling, before coding overhead.
    pub fn gross_capacity_bits(&self) -> usize {
        self.gross_capacity_bits
    }

    /// Bits left once Syndrome-Trellis coding has taken its share.
    pub fn net_capacity_bits(&self) -> usize {
        self.net_capacity_bits
    }

    /// Bytes of fixed cryptographic overhead.
    pub fn mac_overhead_bytes(&self) -> usize {
        self.mac_overhead_bytes
    }

    /// Bytes of payload the container admits.
    pub fn available_bytes(&self) -> usize {
        self.available_bytes
    }
}

/// Measures what `cost_map` can carry in the given mode.
///
/// Total: every input produces a report. A container with no usable pixel is not
/// an error here, it is a report whose `available_bytes` is zero — deciding what
/// to do about that is [`validate_payload_fits`]'s job, and it needs a payload
/// size to say anything useful.
///
/// A position counts as usable when its cost is strictly positive. Zero is the
/// reserved value for "no embedder may touch this"; the HILL model never emits
/// it, since its costs are reciprocals of a non-negative quantity, so on a HILL
/// map every pixel counts and the ceiling is what binds.
pub fn compute_capacity(cost_map: &CostMap<'_>, mode: EmbeddingMode) -> CapacityReport {
    let total_pixels = cost_map.pixel_count();
    let textured_pixels = cost_map.costs().iter().filter(|&&cost| cost > 0.0).count();

    // In `f32`, matching `StcConfig::capacity_bits` exactly: the coder rejects a
    // payload the sizer accepted if the two disagree by even one bit, so both
    // sides compute the ceiling the same way rather than the most precise way.
    let gross_capacity_bits = (textured_pixels as f32 * MAX_BPP) as usize;
    let net_capacity_bits = (gross_capacity_bits as f32 * STC_EFFICIENCY) as usize;

    // Saturating throughout: a small container can owe more overhead than it has
    // capacity, and that is a container with no room for a payload — not a
    // subtraction that should wrap into an enormous one.
    let available_bytes = (net_capacity_bits / BITS_PER_BYTE)
        .saturating_sub(MAC_OVERHEAD_BYTES)
        .saturating_sub(mode.key_transport_overhead_bytes());

    CapacityReport {
        total_pixels,
        textured_pixels,
        gross_capacity_bits,
        net_capacity_bits,
        mac_overhead_bytes: MAC_OVERHEAD_BYTES,
        available_bytes,
    }
}

/// Checks a payload of `payload_len` bytes against a measured container.
///
/// # Errors
///
/// Returns [`SizerError::PayloadTooLarge`] when the payload exceeds
/// `report.available_bytes`, carrying the payload size, the available size and
/// the difference for callers that need to report progress towards a fit.
pub fn validate_payload_fits(
    payload_len: usize,
    report: &CapacityReport,
) -> Result<(), SizerError> {
    if payload_len > report.available_bytes {
        return Err(SizerError::PayloadTooLarge {
            payload: payload_len,
            available: report.available_bytes,
            deficit: payload_len - report.available_bytes,
        });
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
    // well. A test that cannot panic cannot fail, so they are lifted here and
    // only here.
    #![allow(clippy::expect_used)]
    #![allow(clippy::panic)]

    use super::*;

    use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};

    /// Side length of the synthetic containers below, in pixels.
    const SIDE: u32 = 200;

    /// A container of [`SIDE`] squared pixels. Only its geometry matters here.
    fn image() -> ImageBuffer {
        ImageBuffer::new(
            vec![0u8; (SIDE * SIDE) as usize],
            SIDE,
            SIDE,
            ColorSpace::Luma8,
        )
    }

    /// A map in which `usable` positions carry a positive cost and the rest are
    /// marked untouchable.
    fn map(image: &ImageBuffer, usable: usize) -> CostMap<'_> {
        let costs = (0..image.pixel_count())
            .map(|index| if index < usable { 1.0 } else { 0.0 })
            .collect();

        CostMap::new(image, costs)
    }

    /// Capacity is whittled down by the ceiling, the coder and the tag, in that
    /// order, and the report says which step took what.
    #[test]
    fn capacity_is_reported_step_by_step() {
        let image = image();
        let map = map(&image, image.pixel_count());
        let report = compute_capacity(&map, EmbeddingMode::Symmetric);

        assert_eq!(report.total_pixels(), image.pixel_count());
        assert_eq!(report.textured_pixels(), image.pixel_count());
        assert_eq!(
            report.gross_capacity_bits(),
            (image.pixel_count() as f32 * MAX_BPP) as usize
        );
        assert_eq!(
            report.net_capacity_bits(),
            (report.gross_capacity_bits() as f32 * STC_EFFICIENCY) as usize
        );
        assert_eq!(report.mac_overhead_bytes(), MAC_OVERHEAD_BYTES);
        assert_eq!(
            report.available_bytes(),
            report.net_capacity_bits() / BITS_PER_BYTE - MAC_OVERHEAD_BYTES
        );
    }

    /// Only positions with a strictly positive cost count towards capacity.
    #[test]
    fn positions_of_zero_cost_carry_nothing() {
        let image = image();
        let half = image.pixel_count() / 2;

        let full = compute_capacity(&map(&image, image.pixel_count()), EmbeddingMode::Symmetric);
        let halved = compute_capacity(&map(&image, half), EmbeddingMode::Symmetric);

        assert_eq!(halved.textured_pixels(), half);
        assert_eq!(halved.total_pixels(), full.total_pixels());
        assert!(halved.available_bytes() < full.available_bytes());
    }

    /// A container too small to pay for its own tag has no room at all, and
    /// says so rather than wrapping into an enormous capacity.
    #[test]
    fn a_container_that_cannot_pay_the_tag_admits_nothing() {
        let tiny = ImageBuffer::new(vec![0u8; 64], 8, 8, ColorSpace::Luma8);
        let report = compute_capacity(&map(&tiny, tiny.pixel_count()), EmbeddingMode::Symmetric);

        assert_eq!(report.available_bytes(), 0);
    }

    /// Nothing is spent on key transport when both sides derive the key.
    #[test]
    fn the_symmetric_mode_transports_no_key() {
        assert_eq!(EmbeddingMode::default(), EmbeddingMode::Symmetric);
        assert_eq!(EmbeddingMode::Symmetric.key_transport_overhead_bytes(), 0);
    }

    /// The exact boundary: the last payload that fits, and the first that does
    /// not.
    #[test]
    fn the_last_byte_that_fits_is_accepted_and_the_next_is_not() {
        let image = image();
        let report = compute_capacity(&map(&image, image.pixel_count()), EmbeddingMode::Symmetric);
        let available = report.available_bytes();

        assert!(available > 0, "the fixture must have room to measure");
        assert!(validate_payload_fits(available, &report).is_ok());
        assert!(validate_payload_fits(available - 1, &report).is_ok());

        match validate_payload_fits(available + 1, &report) {
            Err(SizerError::PayloadTooLarge {
                payload,
                available: reported,
                deficit,
            }) => {
                assert_eq!(payload, available + 1);
                assert_eq!(reported, available);
                assert_eq!(deficit, 1);
            }
            Ok(()) => panic!("one byte over the limit must be refused"),
        }
    }

    /// The refusal tells the user what to do and nothing about the container.
    #[test]
    fn the_refusal_leaks_no_parameter() {
        let message = SizerError::PayloadTooLarge {
            payload: 4_242,
            available: 1_337,
            deficit: 2_905,
        }
        .to_string();

        assert!(message.contains("shorten the message"));
        for leak in [
            "4242", "1337", "2905", "bpp", "0.02", "byte", "capacity", "pixel",
        ] {
            assert!(
                !message.contains(leak),
                "the message must not expose {leak:?}: {message}"
            );
        }
    }
}