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),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use crate::crypto::aead::{compress_and_encrypt, XChaCha20Poly1305Cipher};
use crate::crypto::kdf::{Argon2Kdf, MasterKey};
const SIDE: u32 = PHASH_THUMBNAIL_SIZE as u32;
const PASSWORD: &[u8] = b"a-container-passphrase";
fn noise_image(seed: u64) -> ImageBuffer {
let mut rng = StdRng::seed_from_u64(seed);
let pixel_count = (SIDE * SIDE) as usize;
let pixels = (0..pixel_count * 3).map(|_| rng.random()).collect();
ImageBuffer::new(pixels, SIDE, SIDE, ColorSpace::Rgb8)
}
fn stable_noise_image(seed: u64) -> ImageBuffer {
match (seed..seed + 64)
.map(noise_image)
.find(|image| compute_hash_bits(image).unstable_indices().is_empty())
{
Some(image) => image,
None => panic!("no stable container in sixty-four candidates from {seed}"),
}
}
fn flat_image() -> ImageBuffer {
let pixel_count = (SIDE * SIDE) as usize;
ImageBuffer::new(vec![128u8; pixel_count * 3], SIDE, SIDE, ColorSpace::Rgb8)
}
fn ciphertext_prefix_for(bits: &[bool; N_HASH_BITS]) -> Vec<u8> {
let salt = salt_from_bits(bits);
let master_key = Argon2Kdf::low_cost_for_tests()
.derive(PASSWORD, &salt)
.expect("a non-empty password must stretch");
let keys = expand_master_key(&master_key).expect("expansion must succeed");
let ciphertext = compress_and_encrypt(
&b"a payload long enough to fill a whole keystream block and then some".repeat(4),
keys.enc_key(),
keys.nonce(),
&XChaCha20Poly1305Cipher::new(),
)
.expect("encryption must succeed");
ciphertext.iter().copied().take(64).collect()
}
#[test]
fn luminance_reads_each_layout_on_the_same_scale() {
assert_eq!(luminance(&[110], ColorSpace::Luma8), 110);
assert_eq!(luminance(&[255, 0, 0], ColorSpace::Rgb8), 76);
assert_eq!(luminance(&[0, 255, 0], ColorSpace::Rgb8), 149);
assert_eq!(luminance(&[0, 0, 255], ColorSpace::Rgb8), 29);
assert_eq!(
luminance(&[255, 0, 0, 17], ColorSpace::Rgba8),
luminance(&[255, 0, 0], ColorSpace::Rgb8)
);
assert_eq!(
luminance(&[0xFF, 0xFF, 0, 0, 0, 0], ColorSpace::Rgb16),
luminance(&[255, 0, 0], ColorSpace::Rgb8)
);
}
#[test]
fn a_textured_container_has_a_single_hypothesis() {
let image = stable_noise_image(1);
let hypotheses = match phash_salt_hypotheses(&image) {
Ok(hypotheses) => hypotheses,
Err(error) => panic!("colour noise must hash stably: {error}"),
};
assert!(
hypotheses.alternative.is_none(),
"a fully determined hash has nothing to disambiguate"
);
let salt = compute_stable_phash(&image).expect("the same image must hash again");
assert_eq!(salt.as_bytes(), hypotheses.primary.as_bytes());
}
#[test]
fn a_flat_container_is_refused_as_unstable() {
let error = phash_salt_hypotheses(&flat_image())
.map(|_| ())
.expect_err("a uniform image cannot hash reproducibly");
match error {
PHashError::InsufficientStability {
unstable_bits,
threshold,
} => {
assert!(unstable_bits > MAX_UNSTABLE_BITS);
assert_eq!(threshold, DELTA_MIN);
}
other => panic!("expected an instability verdict, got: {other:?}"),
}
}
#[test]
fn uncertain_bits_never_appear_alone() {
for seed in 0..64u64 {
let unstable = compute_hash_bits(&noise_image(seed))
.unstable_indices()
.len();
assert_ne!(unstable, 1, "seed {seed} produced a lone uncertain bit");
}
assert_ne!(compute_hash_bits(&flat_image()).unstable_indices().len(), 1);
}
#[test]
fn the_salt_is_deterministic_and_image_dependent() {
let image = stable_noise_image(100);
let other_image = stable_noise_image(200);
let first = compute_stable_phash(&image).expect("noise must hash");
let again = compute_stable_phash(&image).expect("noise must hash");
let other = compute_stable_phash(&other_image).expect("noise must hash");
assert_eq!(first.as_bytes(), again.as_bytes());
assert_ne!(first.as_bytes(), other.as_bytes());
}
#[test]
fn one_flipped_bit_changes_the_whole_salt() {
let mut bits = [false; N_HASH_BITS];
let base = salt_from_bits(&bits);
bits[17] = true;
let flipped = salt_from_bits(&bits);
assert_ne!(base.as_bytes(), flipped.as_bytes());
}
#[test]
fn the_median_is_the_midpoint_of_the_two_central_coefficients() {
let mut coefficients = [0.0f32; N_HASH_BITS];
for (index, value) in coefficients.iter_mut().enumerate() {
*value = index as f32;
}
assert_eq!(median(&coefficients), 31.5);
}
#[test]
fn the_zstd_magic_number_tells_the_keys_apart() {
let bits = [true; N_HASH_BITS];
let prefix = ciphertext_prefix_for(&bits);
let salt = salt_from_bits(&bits);
let master_key = Argon2Kdf::low_cost_for_tests()
.derive(PASSWORD, &salt)
.expect("a non-empty password must stretch");
let keys = expand_master_key(&master_key).expect("expansion must succeed");
assert!(prefix_matches_key(&keys, &prefix));
let other = expand_master_key(&MasterKey::new([0x5Au8; 32])).expect("expansion");
assert!(!prefix_matches_key(&other, &prefix));
assert!(!prefix_matches_key(&keys, &prefix[..3]));
}
#[test]
fn a_hypothesis_is_judged_by_the_payload_it_explains() {
let kdf = Argon2Kdf::low_cost_for_tests();
let bits = [true; N_HASH_BITS];
let prefix = ciphertext_prefix_for(&bits);
match try_hypothesis(&bits, PASSWORD, &kdf, &prefix) {
Ok(Some(salt)) => assert_eq!(salt.as_bytes(), salt_from_bits(&bits).as_bytes()),
Ok(None) => panic!("the hypothesis the payload was made under must match"),
Err(error) => panic!("derivation must succeed: {error}"),
}
let mut wrong = bits;
wrong[0] = false;
match try_hypothesis(&wrong, PASSWORD, &kdf, &prefix) {
Ok(None) => {}
Ok(Some(_)) => panic!("a hypothesis that explains nothing must be rejected"),
Err(error) => panic!("derivation must succeed: {error}"),
}
match try_hypothesis(&bits, &[], &kdf, &prefix) {
Err(PHashError::KdfError(_)) => {}
Err(error) => panic!("expected a derivation failure, got: {error}"),
Ok(_) => panic!("an empty password must fail derivation"),
}
}
#[test]
fn recovery_short_circuits_on_a_certain_hash() {
let image = stable_noise_image(4);
let expected = compute_stable_phash(&image).expect("noise must hash");
let recovered = recover_phash_salt(&image, PASSWORD, &Argon2Kdf::low_cost_for_tests(), &[])
.expect("a fully determined hash needs no payload to be recovered");
assert_eq!(recovered.as_bytes(), expected.as_bytes());
}
#[test]
fn recovery_refuses_an_unstable_image() {
let error = recover_phash_salt(
&flat_image(),
PASSWORD,
&Argon2Kdf::low_cost_for_tests(),
&[],
)
.map(|_| ())
.expect_err("a uniform image has no hash to recover");
assert!(
matches!(error, PHashError::InsufficientStability { .. }),
"got: {error:?}"
);
}
#[test]
fn every_failure_explains_itself() {
let unstable = PHashError::InsufficientStability {
unstable_bits: 7,
threshold: DELTA_MIN,
}
.to_string();
assert!(unstable.contains('7') && unstable.contains("texture"));
assert!(!PHashError::RecoveryFailed.to_string().is_empty());
assert!(PHashError::KdfError("empty password".to_owned())
.to_string()
.contains("empty password"));
}
}