use std::f32::consts::PI;
use std::fmt;
use image::{imageops, imageops::FilterType, GrayImage, Luma};
use sha3::{Digest, Sha3_256};
use zeroize::{ZeroizeOnDrop, Zeroizing};
use crate::crypto::expand::{expand_master_key, DerivedKeys};
use crate::crypto::kdf::KeyDeriver;
use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
const PHASH_THUMBNAIL_SIZE: usize = 32;
const N_HASH_BITS: usize = 64;
const DELTA_MIN: f32 = 5.0;
const MAX_UNSTABLE_BITS: usize = 1;
const PHASH_SALT_DOMAIN: &[u8] = b"STENOXIDE-v1-phash-salt";
const PHASH_SALT_LEN: usize = 32;
const ZSTD_FRAME_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
const AEAD_KEYSTREAM_OFFSET: u64 = 64;
#[derive(ZeroizeOnDrop)]
pub struct PHashSalt([u8; PHASH_SALT_LEN]);
impl PHashSalt {
pub(crate) fn new(bytes: [u8; PHASH_SALT_LEN]) -> Self {
Self(bytes)
}
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
}
#[derive(Debug)]
pub enum PHashError {
InsufficientStability {
unstable_bits: usize,
threshold: f32,
},
RecoveryFailed,
KdfError(String),
}
impl fmt::Display for PHashError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PHashError::InsufficientStability {
unstable_bits,
threshold,
} => write!(
f,
"image is perceptually unstable: {unstable_bits} hash bits sit within {threshold} \
of the median; choose a container with more texture"
),
PHashError::RecoveryFailed => write!(
f,
"could not recover the perceptual hash salt from the stego image"
),
PHashError::KdfError(message) => {
write!(f, "key derivation failed during salt recovery: {message}")
}
}
}
}
impl std::error::Error for PHashError {}
struct HashBits {
bits: [bool; N_HASH_BITS],
margins: [f32; N_HASH_BITS],
}
impl HashBits {
fn unstable_indices(&self) -> Vec<usize> {
self.margins
.iter()
.enumerate()
.filter(|(_, &margin)| margin < DELTA_MIN)
.map(|(index, _)| index)
.collect()
}
}
pub(super) fn luminance(sample: &[u8], color_space: ColorSpace) -> u8 {
let (red, green, blue) = match color_space {
ColorSpace::Luma8 => return sample[0],
ColorSpace::Rgb8 | ColorSpace::Rgba8 => {
(sample[0] as f32, sample[1] as f32, sample[2] as f32)
}
ColorSpace::Rgb16 => {
const SCALE: f32 = 255.0 / 65535.0;
let red = u16::from_le_bytes([sample[0], sample[1]]) as f32 * SCALE;
let green = u16::from_le_bytes([sample[2], sample[3]]) as f32 * SCALE;
let blue = u16::from_le_bytes([sample[4], sample[5]]) as f32 * SCALE;
(red, green, blue)
}
};
let luma = 0.299 * red + 0.587 * green + 0.114 * blue;
luma.clamp(0.0, 255.0) as u8
}
fn luminance_thumbnail(img: &ImageBuffer) -> GrayImage {
let (width, height) = img.dimensions();
let color_space = img.color_space();
let bytes_per_pixel = color_space.bytes_per_pixel();
let pixels = img.pixels();
let full = GrayImage::from_fn(width, height, |x, y| {
let offset = img.pixel_offset(x, y);
let sample = pixels.get(offset..offset + bytes_per_pixel);
Luma([sample.map_or(0, |sample| luminance(sample, color_space))])
});
let side = PHASH_THUMBNAIL_SIZE as u32;
imageops::resize(&full, side, side, FilterType::Triangle)
}
fn dct_2d(thumbnail: &GrayImage) -> [f32; PHASH_THUMBNAIL_SIZE * PHASH_THUMBNAIL_SIZE] {
const N: usize = PHASH_THUMBNAIL_SIZE;
let mut basis = [[0.0f32; N]; N];
for (k, row) in basis.iter_mut().enumerate() {
for (n, value) in row.iter_mut().enumerate() {
*value = (PI / N as f32 * (n as f32 + 0.5) * k as f32).cos();
}
}
let mut rows = [0.0f32; N * N];
for y in 0..N {
for (k, basis_row) in basis.iter().enumerate() {
let mut acc = 0.0f32;
for (n, weight) in basis_row.iter().enumerate() {
acc += thumbnail.get_pixel(n as u32, y as u32).0[0] as f32 * weight;
}
rows[y * N + k] = acc;
}
}
let mut coefficients = [0.0f32; N * N];
for x in 0..N {
for (k, basis_row) in basis.iter().enumerate() {
let mut acc = 0.0f32;
for (n, weight) in basis_row.iter().enumerate() {
acc += rows[n * N + x] * weight;
}
coefficients[k * N + x] = acc;
}
}
coefficients
}
fn median(coefficients: &[f32; N_HASH_BITS]) -> f32 {
let mut sorted = *coefficients;
sorted.sort_by(f32::total_cmp);
(sorted[N_HASH_BITS / 2 - 1] + sorted[N_HASH_BITS / 2]) / 2.0
}
fn compute_hash_bits(img: &ImageBuffer) -> HashBits {
let thumbnail = luminance_thumbnail(img);
let coefficients = dct_2d(&thumbnail);
let mut ac = [0.0f32; N_HASH_BITS];
ac.copy_from_slice(&coefficients[1..=N_HASH_BITS]);
let median = median(&ac);
let mut bits = [false; N_HASH_BITS];
let mut margins = [0.0f32; N_HASH_BITS];
for index in 0..N_HASH_BITS {
bits[index] = ac[index] > median;
margins[index] = (ac[index] - median).abs();
}
HashBits { bits, margins }
}
fn salt_from_bits(bits: &[bool; N_HASH_BITS]) -> PHashSalt {
let mut packed = [0u8; N_HASH_BITS / 8];
for (index, &bit) in bits.iter().enumerate() {
if bit {
packed[index / 8] |= 1 << (7 - (index % 8));
}
}
let mut hasher = Sha3_256::new();
hasher.update(packed);
hasher.update(PHASH_SALT_DOMAIN);
let digest: [u8; PHASH_SALT_LEN] = hasher.finalize().into();
PHashSalt::new(digest)
}
pub(crate) struct PHashHypotheses {
pub(crate) primary: PHashSalt,
pub(crate) alternative: Option<PHashSalt>,
}
pub(crate) fn phash_salt_hypotheses(img: &ImageBuffer) -> Result<PHashHypotheses, PHashError> {
let hash = compute_hash_bits(img);
let unstable = hash.unstable_indices();
if unstable.len() > MAX_UNSTABLE_BITS {
return Err(PHashError::InsufficientStability {
unstable_bits: unstable.len(),
threshold: DELTA_MIN,
});
}
let alternative = unstable.first().map(|&index| {
let mut flipped = hash.bits;
if let Some(bit) = flipped.get_mut(index) {
*bit = !*bit;
}
salt_from_bits(&flipped)
});
Ok(PHashHypotheses {
primary: salt_from_bits(&hash.bits),
alternative,
})
}
pub fn compute_stable_phash(img: &ImageBuffer) -> Result<PHashSalt, PHashError> {
Ok(phash_salt_hypotheses(img)?.primary)
}
fn prefix_matches_key(keys: &DerivedKeys, ciphertext_prefix: &[u8]) -> bool {
use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
use chacha20::XChaCha20;
let Some(head) = ciphertext_prefix.get(..ZSTD_FRAME_MAGIC.len()) else {
return false;
};
let mut cipher = XChaCha20::new(keys.enc_key().into(), keys.nonce().into());
if cipher.try_seek(AEAD_KEYSTREAM_OFFSET).is_err() {
return false;
}
let mut plaintext = Zeroizing::new(head.to_vec());
if cipher
.try_apply_keystream_b2b(head, &mut plaintext)
.is_err()
{
return false;
}
plaintext.as_slice() == ZSTD_FRAME_MAGIC
}
fn try_hypothesis(
bits: &[bool; N_HASH_BITS],
password: &[u8],
kdf: &impl KeyDeriver,
ciphertext_prefix: &[u8],
) -> Result<Option<PHashSalt>, PHashError> {
let salt = salt_from_bits(bits);
let master_key = kdf
.derive(password, &salt)
.map_err(|err| PHashError::KdfError(err.to_string()))?;
let keys =
expand_master_key(&master_key).map_err(|err| PHashError::KdfError(err.to_string()))?;
drop(master_key);
let matched = prefix_matches_key(&keys, ciphertext_prefix);
drop(keys);
if matched {
Ok(Some(salt))
} else {
Ok(None)
}
}
pub(crate) fn recover_phash_salt(
stego_img: &ImageBuffer,
password: &[u8],
kdf: &impl KeyDeriver,
ciphertext_prefix: &[u8],
) -> Result<PHashSalt, PHashError> {
let hash = compute_hash_bits(stego_img);
let unstable = hash.unstable_indices();
let uncertain = match unstable.as_slice() {
[] => return Ok(salt_from_bits(&hash.bits)),
[index] => *index,
more => {
return Err(PHashError::InsufficientStability {
unstable_bits: more.len(),
threshold: DELTA_MIN,
})
}
};
let mut cleared = hash.bits;
cleared[uncertain] = false;
let mut set = hash.bits;
set[uncertain] = true;
let (cleared, set) = rayon::join(
|| try_hypothesis(&cleared, password, kdf, ciphertext_prefix),
|| try_hypothesis(&set, password, kdf, ciphertext_prefix),
);
match (cleared?, set?) {
(Some(salt), _) | (None, Some(salt)) => Ok(salt),
(None, None) => Err(PHashError::RecoveryFailed),
}
}