mod carrier;
mod texture;
use std::fmt;
use std::path::Path;
use rand::rngs::{StdRng, SysRng};
use rand::{Rng, SeedableRng, TryRng};
use zeroize::Zeroizing;
use crate::cost::hill::HillCostProvider;
use crate::cost::CostProvider;
use crate::crypto::aead::{
compress, decompress, AEADCipher, AEADError, CryptoError, XChaCha20Poly1305Cipher,
STENOXIDE_AAD,
};
use crate::crypto::expand::{expand_master_key, DerivedKeys, ExpandError};
use crate::crypto::kdf::{Argon2Kdf, KdfError, KeyDeriver};
use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
use crate::image_io::jpeg_detect::detect_jpeg_artifacts;
use crate::image_io::phash::compute_stable_phash;
use crate::image_io::validate::{MAX_PIXELS, MIN_DIMENSION};
use crate::pipeline::error::OutputError;
use crate::pipeline::frame::write_png;
use self::carrier::{draw_free, draw_with_lsb};
use self::texture::Texture;
pub use self::carrier::RejectionExhausted;
pub const MIN_CONTAINER_SIDE: u32 = MIN_DIMENSION;
pub const MAX_CONTAINER_PIXELS: u64 = MAX_PIXELS;
pub const DEFAULT_CONTAINER_SIDE: u32 = MIN_CONTAINER_SIDE;
const CHANNELS: usize = 3;
const TAG_BYTES: usize = 16;
const LENGTH_HEADER_BYTES: usize = 4;
const MAX_CANDIDATES: u32 = 64;
const SEED_BYTES: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContainerDimensions {
width: u32,
height: u32,
}
impl ContainerDimensions {
pub fn new(width: u32, height: u32) -> Result<Self, GenerateError> {
let out_of_range = || GenerateError::DimensionsOutOfRange {
width,
height,
min_side: MIN_CONTAINER_SIDE,
max_pixels: MAX_CONTAINER_PIXELS,
};
if width < MIN_CONTAINER_SIDE || height < MIN_CONTAINER_SIDE {
return Err(out_of_range());
}
if u64::from(width) * u64::from(height) > MAX_CONTAINER_PIXELS {
return Err(out_of_range());
}
Ok(Self { width, height })
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
fn capacity(self) -> usize {
self.width as usize * self.height as usize * CHANNELS / 8
}
fn payload_capacity(self) -> usize {
self.capacity().saturating_sub(TAG_BYTES + LENGTH_HEADER_BYTES)
}
}
impl Default for ContainerDimensions {
fn default() -> Self {
Self {
width: DEFAULT_CONTAINER_SIDE,
height: DEFAULT_CONTAINER_SIDE,
}
}
}
#[derive(Debug)]
pub struct GenerateReport {
pub image_dimensions: (u32, u32),
pub payload_bytes: usize,
pub capacity_bytes: usize,
}
#[derive(Debug)]
pub enum GenerateError {
Entropy(String),
PayloadTooLarge {
payload: usize,
available: usize,
deficit: usize,
recommended_side: Option<u32>,
},
DimensionsOutOfRange {
width: u32,
height: u32,
min_side: u32,
max_pixels: u64,
},
NoUsableTexture {
candidates: u32,
},
Sampling(RejectionExhausted),
Kdf(KdfError),
Expand(ExpandError),
Crypto(CryptoError),
Output(OutputError),
}
impl fmt::Display for GenerateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GenerateError::Entropy(message) => write!(
f,
"could not read the system random number generator, and a container must not be \
generated without it: {message}"
),
GenerateError::PayloadTooLarge {
payload,
available,
deficit,
..
} => write!(
f,
"the payload does not fit in the requested container: {payload} bytes after \
compression against the {available} it admits, {deficit} bytes over"
),
GenerateError::DimensionsOutOfRange {
width,
height,
min_side,
max_pixels,
} => write!(
f,
"the requested container is {width}x{height}, which is outside the permitted \
range: each side must be at least {min_side} pixels and the two together at \
most {max_pixels} pixels"
),
GenerateError::NoUsableTexture { candidates } => write!(
f,
"no texture passed the container gates in {candidates} candidates"
),
GenerateError::Sampling(err) => write!(f, "{err}"),
GenerateError::Kdf(err) => write!(f, "{err}"),
GenerateError::Expand(err) => write!(f, "{err}"),
GenerateError::Crypto(err) => write!(f, "{err}"),
GenerateError::Output(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for GenerateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
GenerateError::Sampling(err) => Some(err),
GenerateError::Kdf(err) => Some(err),
GenerateError::Expand(err) => Some(err),
GenerateError::Crypto(err) => Some(err),
GenerateError::Output(err) => Some(err),
GenerateError::Entropy(_)
| GenerateError::PayloadTooLarge { .. }
| GenerateError::DimensionsOutOfRange { .. }
| GenerateError::NoUsableTexture { .. } => None,
}
}
}
impl From<RejectionExhausted> for GenerateError {
fn from(err: RejectionExhausted) -> Self {
GenerateError::Sampling(err)
}
}
impl From<KdfError> for GenerateError {
fn from(err: KdfError) -> Self {
GenerateError::Kdf(err)
}
}
impl From<ExpandError> for GenerateError {
fn from(err: ExpandError) -> Self {
GenerateError::Expand(err)
}
}
impl From<CryptoError> for GenerateError {
fn from(err: CryptoError) -> Self {
GenerateError::Crypto(err)
}
}
impl From<AEADError> for GenerateError {
fn from(err: AEADError) -> Self {
GenerateError::Crypto(CryptoError::AEADError(err))
}
}
impl From<OutputError> for GenerateError {
fn from(err: OutputError) -> Self {
GenerateError::Output(err)
}
}
fn recommended_square_side(payload: usize) -> Option<u32> {
let overhead = (TAG_BYTES + LENGTH_HEADER_BYTES) as u64;
let needed_bytes = (payload as u64).checked_add(overhead)?;
let needed_pixels = needed_bytes.checked_mul(8)?.div_ceil(CHANNELS as u64);
if needed_pixels > MAX_CONTAINER_PIXELS {
return None;
}
let exact_side = integer_sqrt_ceil(needed_pixels).max(MIN_CONTAINER_SIDE);
let rounded = exact_side.div_ceil(100).saturating_mul(100);
let side = if u64::from(rounded) * u64::from(rounded) <= MAX_CONTAINER_PIXELS {
rounded
} else {
exact_side
};
Some(side)
}
fn integer_sqrt_ceil(value: u64) -> u32 {
let mut root = (value as f64).sqrt() as u64;
while root.saturating_mul(root) < value {
root += 1;
}
while root > 0 && (root - 1).saturating_mul(root - 1) >= value {
root -= 1;
}
u32::try_from(root).unwrap_or(u32::MAX)
}
pub fn generate_container(
plaintext: Zeroizing<Vec<u8>>,
password: Zeroizing<Vec<u8>>,
dimensions: ContainerDimensions,
output_path: &Path,
) -> Result<GenerateReport, GenerateError> {
generate(
&Argon2Kdf::default_secure(),
plaintext,
password,
dimensions,
output_path,
)
}
#[cfg(any(test, feature = "test-utils"))]
pub fn generate_container_with_deriver(
kdf: &dyn KeyDeriver,
plaintext: Zeroizing<Vec<u8>>,
password: Zeroizing<Vec<u8>>,
dimensions: ContainerDimensions,
output_path: &Path,
) -> Result<GenerateReport, GenerateError> {
generate(kdf, plaintext, password, dimensions, output_path)
}
fn generate(
kdf: &dyn KeyDeriver,
plaintext: Zeroizing<Vec<u8>>,
password: Zeroizing<Vec<u8>>,
dimensions: ContainerDimensions,
output_path: &Path,
) -> Result<GenerateReport, GenerateError> {
let cipher = XChaCha20Poly1305Cipher::new();
let compressed = compress(plaintext.as_slice())?;
drop(plaintext);
let available = dimensions.payload_capacity();
if compressed.len() > available {
return Err(GenerateError::PayloadTooLarge {
payload: compressed.len(),
available,
deficit: compressed.len() - available,
recommended_side: recommended_square_side(compressed.len()),
});
}
let mut rng = seed_from_system()?;
for _ in 0..MAX_CANDIDATES {
let texture = Texture::new(rng.next_u64(), dimensions.width(), dimensions.height());
let draft = render(&texture, dimensions, &mut rng, None)?;
let Ok(draft_salt) = compute_stable_phash(&draft) else {
continue;
};
if !passes_container_gates(&draft) {
continue;
}
drop(draft);
let master_key = kdf.derive(password.as_slice(), &draft_salt)?;
let derived_keys = expand_master_key(&master_key)?;
drop(master_key);
let ciphertext = seal(&compressed, dimensions, &mut rng, &derived_keys, &cipher)?;
drop(derived_keys);
let container = render(&texture, dimensions, &mut rng, Some(&ciphertext))?;
drop(ciphertext);
let Ok(final_salt) = compute_stable_phash(&container) else {
continue;
};
if final_salt.as_bytes() != draft_salt.as_bytes() || shows_jpeg_grid(&container) {
continue;
}
write_png(&container, output_path)?;
return Ok(GenerateReport {
image_dimensions: container.dimensions(),
payload_bytes: compressed.len(),
capacity_bytes: available,
});
}
Err(GenerateError::NoUsableTexture {
candidates: MAX_CANDIDATES,
})
}
fn seed_from_system() -> Result<StdRng, GenerateError> {
let mut seed = Zeroizing::new([0u8; SEED_BYTES]);
SysRng
.try_fill_bytes(seed.as_mut_slice())
.map_err(|err| GenerateError::Entropy(err.to_string()))?;
let rng = StdRng::from_seed(*seed);
drop(seed);
Ok(rng)
}
fn passes_container_gates(image: &ImageBuffer) -> bool {
!shows_jpeg_grid(image) && HillCostProvider::new().compute(image).is_ok()
}
fn shows_jpeg_grid(image: &ImageBuffer) -> bool {
let (width, height) = image.dimensions();
detect_jpeg_artifacts(image.pixels(), width, height, image.color_space()).is_some()
}
fn render(
texture: &Texture,
dimensions: ContainerDimensions,
rng: &mut StdRng,
carrier: Option<&[u8]>,
) -> Result<ImageBuffer, GenerateError> {
let (width, height) = (dimensions.width(), dimensions.height());
let mut samples = vec![0u8; width as usize * height as usize * CHANNELS];
let carrier_bits = carrier.map_or(0, |bytes| bytes.len() * 8);
let mut position = 0usize;
for y in 0..height {
for x in 0..width {
let base_levels = texture.base_levels(x, y);
for &base in base_levels.iter() {
let value = match carrier {
Some(bytes) if position < carrier_bits => {
let byte = bytes.get(position / 8).copied().unwrap_or(0);
let bit = (byte >> (7 - position % 8)) & 1;
draw_with_lsb(rng, base, bit)?
}
_ => draw_free(rng, base),
};
if let Some(sample) = samples.get_mut(position) {
*sample = value;
}
position += 1;
}
}
}
Ok(ImageBuffer::new(samples, width, height, ColorSpace::Rgb8))
}
fn seal(
compressed: &[u8],
dimensions: ContainerDimensions,
rng: &mut StdRng,
keys: &DerivedKeys,
cipher: &dyn AEADCipher,
) -> Result<Zeroizing<Vec<u8>>, GenerateError> {
let plaintext_len = dimensions.capacity().saturating_sub(TAG_BYTES);
let mut buffer = Zeroizing::new(Vec::with_capacity(plaintext_len));
let announced = u32::try_from(compressed.len()).unwrap_or(u32::MAX);
buffer.extend_from_slice(&announced.to_be_bytes());
buffer.extend_from_slice(compressed);
let filled = buffer.len();
buffer.resize(plaintext_len, 0);
if let Some(padding) = buffer.get_mut(filled..) {
rng.fill_bytes(padding);
}
let ciphertext = cipher.encrypt(keys.enc_key(), keys.nonce(), &buffer, STENOXIDE_AAD)?;
drop(buffer);
Ok(ciphertext)
}
pub(crate) fn read_generated(
image: &ImageBuffer,
keys: &DerivedKeys,
cipher: &dyn AEADCipher,
) -> Result<(Zeroizing<Vec<u8>>, usize), CryptoError> {
let samples = image.pixels();
let capacity = samples.len() / 8;
if capacity <= TAG_BYTES + LENGTH_HEADER_BYTES {
return Err(CryptoError::AEADError(AEADError::AuthenticationFailed));
}
let ciphertext = Zeroizing::new(gather_carrier_bits(samples, capacity));
let buffer = cipher.decrypt(keys.enc_key(), keys.nonce(), &ciphertext, STENOXIDE_AAD)?;
let Some(header) = buffer.get(..LENGTH_HEADER_BYTES) else {
return Err(CryptoError::DecompressionError(
"the authenticated buffer is shorter than its own length header".to_owned(),
));
};
let announced = header
.try_into()
.map(|bytes: [u8; LENGTH_HEADER_BYTES]| u32::from_be_bytes(bytes) as usize)
.unwrap_or(0);
let Some(body) = buffer.get(LENGTH_HEADER_BYTES..LENGTH_HEADER_BYTES + announced) else {
return Err(CryptoError::DecompressionError(
"the authenticated buffer announces more payload than it holds".to_owned(),
));
};
let plaintext = decompress(body)?;
drop(buffer);
Ok((plaintext, capacity))
}
fn gather_carrier_bits(samples: &[u8], bytes: usize) -> Vec<u8> {
let mut out = vec![0u8; bytes];
for (position, sample) in samples.iter().enumerate().take(bytes * 8) {
if let Some(byte) = out.get_mut(position / 8) {
*byte |= (sample & 1) << (7 - position % 8);
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
use crate::crypto::kdf::MasterKey;
fn keys() -> DerivedKeys {
expand_master_key(&MasterKey::new([0x3Cu8; 32])).expect("expansion must succeed")
}
#[test]
fn the_ciphertext_is_sized_to_the_container() {
let default = ContainerDimensions::default();
let samples = default.width() as usize * default.height() as usize * CHANNELS;
assert_eq!(default.capacity(), samples / 8);
assert_eq!(default.capacity(), 1_500_000);
assert_eq!(
default.payload_capacity(),
1_500_000 - TAG_BYTES - LENGTH_HEADER_BYTES
);
}
#[test]
fn capacity_follows_the_pixel_count() {
let square = ContainerDimensions::new(4000, 4000).expect("within range");
let rectangle = ContainerDimensions::new(2000, 8000).expect("within range");
assert_eq!(square.capacity(), 4000 * 4000 * CHANNELS / 8);
assert_eq!(square.capacity(), rectangle.capacity());
assert!(square.capacity() > ContainerDimensions::default().capacity());
}
#[test]
fn dimensions_are_held_to_both_gates() {
assert!(ContainerDimensions::new(MIN_CONTAINER_SIDE, MIN_CONTAINER_SIDE).is_ok());
let too_short = ContainerDimensions::new(MIN_CONTAINER_SIDE - 1, MIN_CONTAINER_SIDE)
.map(|_| ())
.expect_err("a side below the floor must be refused");
assert!(matches!(
too_short,
GenerateError::DimensionsOutOfRange { .. }
));
let widest = (MAX_CONTAINER_PIXELS / u64::from(MIN_CONTAINER_SIDE)) as u32;
assert!(ContainerDimensions::new(widest, MIN_CONTAINER_SIDE).is_ok());
let over = ContainerDimensions::new(widest + 100, MIN_CONTAINER_SIDE)
.map(|_| ())
.expect_err("a product above the ceiling must be refused");
assert!(matches!(over, GenerateError::DimensionsOutOfRange { .. }));
}
#[test]
fn the_recommended_side_is_round_and_sufficient() {
let side = recommended_square_side(1_782_778).expect("a container this size exists");
assert_eq!(side % 100, 0, "the suggestion must be a round figure");
assert!(side >= MIN_CONTAINER_SIDE);
let admitted = ContainerDimensions::new(side, side)
.expect("the suggestion must be within range")
.payload_capacity();
assert!(
admitted >= 1_782_778,
"a container of the suggested side must actually hold the payload"
);
let admitted_below = ContainerDimensions::new(side - 100, side - 100)
.expect("within range")
.payload_capacity();
assert!(admitted_below < 1_782_778);
let unattainable = (MAX_CONTAINER_PIXELS as usize) * CHANNELS / 8;
assert!(recommended_square_side(unattainable).is_none());
}
#[test]
fn the_carrier_round_trips_through_the_samples() {
let payload = [0b1010_1010u8, 0b0000_1111, 0xFF, 0x00];
let samples: Vec<u8> = (0..payload.len() * 8)
.map(|position| {
let byte = payload[position / 8];
(byte >> (7 - position % 8)) & 1
})
.collect();
assert_eq!(gather_carrier_bits(&samples, payload.len()), payload);
let noisy: Vec<u8> = samples.iter().map(|bit| bit | 0xF0).collect();
assert_eq!(gather_carrier_bits(&noisy, payload.len()), payload);
}
#[test]
fn every_sealed_buffer_is_the_same_size() {
let mut rng = StdRng::seed_from_u64(5);
let cipher = XChaCha20Poly1305Cipher::new();
let keys = keys();
let dimensions = ContainerDimensions::default();
for length in [0usize, 1, 4_096, 100_000] {
let compressed = vec![0x5Au8; length];
let sealed = seal(&compressed, dimensions, &mut rng, &keys, &cipher)
.expect("a payload within capacity must seal");
assert_eq!(sealed.len(), dimensions.capacity(), "payload of {length}");
}
}
#[test]
fn a_sealed_payload_is_recovered_by_the_reader() {
let mut rng = StdRng::seed_from_u64(9);
let cipher = XChaCha20Poly1305Cipher::new();
let keys = keys();
let dimensions = ContainerDimensions::default();
let message = b"a message that is compressed, sealed and read back".repeat(4);
let compressed = compress(&message).expect("compression must succeed");
let sealed = seal(&compressed, dimensions, &mut rng, &keys, &cipher)
.expect("sealing must succeed");
let samples: Vec<u8> = (0..sealed.len() * 8)
.map(|position| {
let byte = sealed.get(position / 8).copied().unwrap_or(0);
0x80 | ((byte >> (7 - position % 8)) & 1)
})
.collect();
let image = ImageBuffer::new(
samples,
dimensions.width(),
dimensions.height(),
ColorSpace::Rgb8,
);
match read_generated(&image, &keys, &cipher) {
Ok((plaintext, bytes)) => {
assert_eq!(plaintext.as_slice(), message.as_slice());
assert_eq!(bytes, dimensions.capacity());
}
Err(error) => panic!("a sealed payload must be recovered: {error}"),
}
let other = expand_master_key(&MasterKey::new([0x11u8; 32])).expect("expansion");
let error = read_generated(&image, &other, &cipher)
.map(|_| ())
.expect_err("a wrong key must not authenticate");
assert!(
matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
"got: {error:?}"
);
}
#[test]
fn a_container_without_room_for_a_payload_is_refused() {
let image = ImageBuffer::new(vec![0u8; 64], 4, 4, ColorSpace::Rgb8);
let error = read_generated(&image, &keys(), &XChaCha20Poly1305Cipher::new())
.map(|_| ())
.expect_err("a container with no room must be refused");
assert!(
matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
"got: {error:?}"
);
}
#[test]
fn every_failure_explains_itself() {
let messages = [
GenerateError::Entropy("no device".to_owned()).to_string(),
GenerateError::PayloadTooLarge {
payload: 2_000_000,
available: 1_499_980,
deficit: 500_020,
recommended_side: recommended_square_side(2_000_000),
}
.to_string(),
GenerateError::DimensionsOutOfRange {
width: 1_000,
height: 3_000,
min_side: MIN_CONTAINER_SIDE,
max_pixels: MAX_CONTAINER_PIXELS,
}
.to_string(),
GenerateError::NoUsableTexture { candidates: 64 }.to_string(),
GenerateError::Sampling(RejectionExhausted).to_string(),
GenerateError::from(KdfError::EmptyPassword).to_string(),
GenerateError::from(ExpandError::HkdfError("too long".to_owned())).to_string(),
GenerateError::from(AEADError::AuthenticationFailed).to_string(),
GenerateError::from(OutputError::MalformedBuffer).to_string(),
];
for message in &messages {
assert!(!message.is_empty());
}
assert!(messages[0].contains("no device"));
assert!(messages[1].contains("2000000") && messages[1].contains("500020"));
assert!(messages[2].contains("1000x3000") && messages[2].contains("2000"));
assert!(messages[3].contains("64"));
assert!(std::error::Error::source(&GenerateError::from(KdfError::EmptyPassword)).is_some());
assert!(
std::error::Error::source(&GenerateError::NoUsableTexture { candidates: 1 }).is_none()
);
assert!(std::error::Error::source(&GenerateError::DimensionsOutOfRange {
width: 1_000,
height: 3_000,
min_side: MIN_CONTAINER_SIDE,
max_pixels: MAX_CONTAINER_PIXELS,
})
.is_none());
}
#[test]
fn the_generator_is_seeded_from_the_system() {
let mut first = seed_from_system().expect("the system generator must be readable");
let mut second = seed_from_system().expect("the system generator must be readable");
assert_ne!(first.next_u64(), second.next_u64());
}
}