stenoxide_core/image_io/phash.rs
1//! Perceptual hash margin filter with a hard stability limit of `k <= 1`.
2//!
3//! The Argon2id salt is not stored anywhere. It is recomputed from the image
4//! itself on both sides, which is what lets extraction work without any
5//! metadata travelling next to the payload. That only holds if the hash the
6//! receiver computes over the *stego* image is bit-for-bit the one the sender
7//! computed over the *cover* image.
8//!
9//! Embedding perturbs the least significant bits of a few thousand pixels. A
10//! 32x32 DCT coefficient is an average over the whole image, so those flips
11//! move it by a vanishing amount — unless the coefficient happened to sit right
12//! on the median, in which case an arbitrarily small perturbation flips its
13//! bit and the salt, the master key and the whole payload are lost.
14//!
15//! This module therefore refuses to hash images whose coefficients sit too
16//! close to the median. Each bit carries a margin, and:
17//!
18//! - `k == 0` unstable bits: the hash is reproducible, embedding proceeds.
19//! - `k == 1`: the image is still usable, but the receiver has to try both
20//! values of the uncertain bit; `recover_phash_salt` does exactly that.
21//! - `k >= 2`: rejected. Two uncertain bits would mean four hypotheses, each
22//! costing a full Argon2id derivation, and the number doubles from there.
23//!
24//! The margin filter also absorbs a second, subtler source of divergence: the
25//! DCT is built on `cos`, which is not specified bit-exactly by IEEE 754 and
26//! may differ in the last place between platforms. A bit whose margin exceeds
27//! `DELTA_MIN` cannot be flipped by an error of that size.
28
29use std::f32::consts::PI;
30use std::fmt;
31
32use image::{imageops, imageops::FilterType, GrayImage, Luma};
33use sha3::{Digest, Sha3_256};
34use zeroize::{ZeroizeOnDrop, Zeroizing};
35
36use crate::crypto::expand::{expand_master_key, DerivedKeys};
37use crate::crypto::kdf::KeyDeriver;
38use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
39
40/// Side length of the square thumbnail the DCT is computed over.
41const PHASH_THUMBNAIL_SIZE: usize = 32;
42
43/// Number of AC coefficients turned into hash bits.
44const N_HASH_BITS: usize = 64;
45
46/// Smallest distance from the median a coefficient may have and still be
47/// considered stable, in coefficient units.
48///
49/// The number is only meaningful against a fixed transform scale; see
50/// [`dct_2d`], which is deliberately left unnormalised for that reason.
51///
52/// At that scale the threshold is a texture requirement in disguise. A smooth
53/// container concentrates almost all of its energy in a handful of low
54/// coefficients and leaves the rest piled up around a near-zero median, so
55/// dozens of bits fall inside the margin and the image is rejected. A textured
56/// one spreads its energy across the spectrum and clears the threshold on
57/// every bit. That is the same property the cost layer wants, arrived at
58/// independently.
59const DELTA_MIN: f32 = 5.0;
60
61/// Largest number of unstable bits an image may have and still be accepted.
62///
63/// One uncertain bit means two hypotheses on the receiving side. Two would
64/// mean four Argon2id derivations, which at 128 MiB and four passes each is
65/// already an unreasonable price to pay for a container the user can simply
66/// replace.
67const MAX_UNSTABLE_BITS: usize = 1;
68
69/// Domain separator mixed into the salt so that the 64 hash bits cannot be
70/// reused as an input to any other construction in this crate.
71const PHASH_SALT_DOMAIN: &[u8] = b"STENOXIDE-v1-phash-salt";
72
73/// Length of the derived salt, in bytes.
74const PHASH_SALT_LEN: usize = 32;
75
76/// Magic number of a Zstandard frame, little-endian, as it appears on the wire.
77///
78/// See [`prefix_matches_key`] for why a compression magic number, of all
79/// things, is what distinguishes the two hypotheses.
80const ZSTD_FRAME_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
81
82/// Number of keystream bytes XChaCha20-Poly1305 spends on the Poly1305 key
83/// before it starts encrypting the message.
84///
85/// The AEAD construction of RFC 8439 reserves block zero for the one-time MAC
86/// key and encrypts the plaintext from block one onwards, so a raw XChaCha20
87/// instance has to be seeked past those 64 bytes to line its keystream up with
88/// the ciphertext.
89const AEAD_KEYSTREAM_OFFSET: u64 = 64;
90
91/// Perceptual hash of the container image, used as the Argon2id salt.
92///
93/// The bytes are wiped when the value is dropped. Like [`MasterKey`], the type
94/// implements neither [`Clone`], [`Copy`] nor [`Debug`]: the salt is not a
95/// secret in the sense a key is, but it is a deterministic function of the
96/// container, and leaking it into a log would let an attacker confirm which
97/// image a given payload belongs to.
98///
99/// # Why it compares and hashes, but still does not print
100///
101/// Two images that produce one salt produce one key and one nonce under any
102/// given password, so a caller holding several containers has a question worth
103/// asking about them: do any two of these collide? [`PartialEq`], [`Eq`] and
104/// [`Hash`] are derived to answer exactly that, and to answer it in linear time
105/// — grouping by salt needs a hash-map key, and comparing every pair instead
106/// would be quadratic in the size of a photo library.
107///
108/// Equality is the whole of what a caller gets. [`as_bytes`] stays
109/// crate-private and there is no public path to the 64 hash bits, so an outside
110/// program can learn that two of its own files collide and cannot learn the
111/// value that made them collide, nor compute a fingerprint it could compare
112/// against an image it does not hold.
113///
114/// The comparison is not constant time and does not need to be. Both operands
115/// are derived in-process from files the caller already has in front of them;
116/// there is no remote party feeding one side of it, and nothing timeable that
117/// the caller could not obtain by looking at the images.
118///
119/// [`MasterKey`]: crate::crypto::kdf::MasterKey
120/// [`as_bytes`]: PHashSalt::as_bytes
121#[derive(ZeroizeOnDrop, PartialEq, Eq, Hash)]
122pub struct PHashSalt([u8; PHASH_SALT_LEN]);
123
124impl PHashSalt {
125 /// Wraps the 32 bytes of a perceptual hash as a salt.
126 ///
127 /// Restricted to the crate: outside code must obtain a salt through
128 /// [`compute_stable_phash`], the only path that applies the stability
129 /// filter.
130 pub(crate) fn new(bytes: [u8; PHASH_SALT_LEN]) -> Self {
131 Self(bytes)
132 }
133
134 /// Borrows the salt bytes, for use as the Argon2id salt.
135 pub(crate) fn as_bytes(&self) -> &[u8] {
136 &self.0
137 }
138}
139
140/// Every way perceptual hashing can fail.
141#[derive(Debug)]
142pub enum PHashError {
143 /// Too many coefficients sit within `DELTA_MIN` of the median for the
144 /// hash to be reproducible after embedding.
145 InsufficientStability {
146 /// How many of the 64 bits are uncertain.
147 unstable_bits: usize,
148 /// The margin threshold that was applied.
149 threshold: f32,
150 },
151 /// Neither hypothesis for the uncertain bit produced a key that matches the
152 /// extracted payload. The image, the password or the payload is wrong.
153 RecoveryFailed,
154 /// Key derivation failed while testing a hypothesis.
155 KdfError(String),
156}
157
158impl fmt::Display for PHashError {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 match self {
161 PHashError::InsufficientStability {
162 unstable_bits,
163 threshold,
164 } => write!(
165 f,
166 "image is perceptually unstable: {unstable_bits} hash bits sit within {threshold} \
167 of the median; choose a container with more texture"
168 ),
169 PHashError::RecoveryFailed => write!(
170 f,
171 "could not recover the perceptual hash salt from the stego image"
172 ),
173 PHashError::KdfError(message) => {
174 write!(f, "key derivation failed during salt recovery: {message}")
175 }
176 }
177 }
178}
179
180impl std::error::Error for PHashError {}
181
182/// The 64 hash bits together with how far each one is from the decision
183/// boundary.
184///
185/// Kept as one value because the two arrays are always produced and consumed
186/// together: a bit without its margin cannot be judged reproducible, and a
187/// margin without its bit says nothing about the hash.
188struct HashBits {
189 /// `true` where the AC coefficient is above the median.
190 bits: [bool; N_HASH_BITS],
191 /// `|coefficient - median|` for each bit.
192 margins: [f32; N_HASH_BITS],
193}
194
195impl HashBits {
196 /// Indices of the bits whose margin is below [`DELTA_MIN`].
197 fn unstable_indices(&self) -> Vec<usize> {
198 self.margins
199 .iter()
200 .enumerate()
201 .filter(|(_, &margin)| margin < DELTA_MIN)
202 .map(|(index, _)| index)
203 .collect()
204 }
205}
206
207/// BT.601 luma of one pixel, from the raw sample bytes of that pixel.
208///
209/// `sample` is exactly one pixel as laid out by `color_space`, so every index
210/// below is in bounds by construction: the callers slice the buffer with
211/// [`ColorSpace::bytes_per_pixel`].
212///
213/// Shared with [`crate::image_io::jpeg_detect`], which measures its block
214/// energies on the same luma plane. The two analyses must agree on what "the
215/// brightness of a pixel" means, so there is exactly one implementation of it.
216pub(super) fn luminance(sample: &[u8], color_space: ColorSpace) -> u8 {
217 let (red, green, blue) = match color_space {
218 // Grayscale is already luma; running it through the coefficients would
219 // only add a rounding error.
220 ColorSpace::Luma8 => return sample[0],
221 ColorSpace::Rgb8 | ColorSpace::Rgba8 => {
222 (sample[0] as f32, sample[1] as f32, sample[2] as f32)
223 }
224 // Samples were stored as explicit little-endian pairs by the validation
225 // stage, so they are re-read the same way here rather than as native
226 // `u16`. Normalised to [0, 255] before the coefficients are applied, so
227 // that DELTA_MIN means the same thing at both bit depths.
228 ColorSpace::Rgb16 => {
229 const SCALE: f32 = 255.0 / 65535.0;
230 let red = u16::from_le_bytes([sample[0], sample[1]]) as f32 * SCALE;
231 let green = u16::from_le_bytes([sample[2], sample[3]]) as f32 * SCALE;
232 let blue = u16::from_le_bytes([sample[4], sample[5]]) as f32 * SCALE;
233 (red, green, blue)
234 }
235 };
236
237 let luma = 0.299 * red + 0.587 * green + 0.114 * blue;
238 luma.clamp(0.0, 255.0) as u8
239}
240
241/// Step 1 and 2 — converts the image to luma and resizes it to 32x32.
242///
243/// The resize is bilinear ([`FilterType::Triangle`]): the filter kernel is
244/// scaled to the source resolution, so every input pixel contributes to the
245/// thumbnail. That is what makes the result insensitive to the handful of
246/// least significant bits embedding will flip.
247fn luminance_thumbnail(img: &ImageBuffer) -> GrayImage {
248 let (width, height) = img.dimensions();
249 let color_space = img.color_space();
250 let bytes_per_pixel = color_space.bytes_per_pixel();
251 let pixels = img.pixels();
252
253 let full = GrayImage::from_fn(width, height, |x, y| {
254 let offset = img.pixel_offset(x, y);
255 // In range for every coordinate the closure is called with, by the
256 // `CoverSource` length contract. Falling back to black instead of
257 // indexing keeps the function total.
258 let sample = pixels.get(offset..offset + bytes_per_pixel);
259 Luma([sample.map_or(0, |sample| luminance(sample, color_space))])
260 });
261
262 let side = PHASH_THUMBNAIL_SIZE as u32;
263 imageops::resize(&full, side, side, FilterType::Triangle)
264}
265
266/// Step 3 — separable DCT-II of the 32x32 thumbnail.
267///
268/// Rows first, then columns; the output is row-major, and the receiver
269/// reproduces exactly this order.
270///
271/// # Why the transform is left unnormalised
272///
273/// No orthonormal scale factor is applied, following the convention every
274/// mainstream perceptual hash uses. The choice is not cosmetic: it fixes the
275/// scale that [`DELTA_MIN`] is measured against. Orthonormalising would divide
276/// every AC coefficient by about sixteen, and a threshold of `5.0` would then
277/// reject even densely textured containers, which is the opposite of what the
278/// filter is for. Only the ratio between the threshold and the transform scale
279/// has any meaning; this function pins one half of it.
280fn dct_2d(thumbnail: &GrayImage) -> [f32; PHASH_THUMBNAIL_SIZE * PHASH_THUMBNAIL_SIZE] {
281 const N: usize = PHASH_THUMBNAIL_SIZE;
282
283 // basis[k][n] = cos(pi/N * (n + 1/2) * k). Constant for a fixed N, but
284 // rebuilt per call because `cos` is not available in a const context on
285 // stable Rust.
286 let mut basis = [[0.0f32; N]; N];
287 for (k, row) in basis.iter_mut().enumerate() {
288 for (n, value) in row.iter_mut().enumerate() {
289 *value = (PI / N as f32 * (n as f32 + 0.5) * k as f32).cos();
290 }
291 }
292
293 let mut rows = [0.0f32; N * N];
294 for y in 0..N {
295 for (k, basis_row) in basis.iter().enumerate() {
296 let mut acc = 0.0f32;
297 for (n, weight) in basis_row.iter().enumerate() {
298 acc += thumbnail.get_pixel(n as u32, y as u32).0[0] as f32 * weight;
299 }
300 rows[y * N + k] = acc;
301 }
302 }
303
304 let mut coefficients = [0.0f32; N * N];
305 for x in 0..N {
306 for (k, basis_row) in basis.iter().enumerate() {
307 let mut acc = 0.0f32;
308 for (n, weight) in basis_row.iter().enumerate() {
309 acc += rows[n * N + x] * weight;
310 }
311 coefficients[k * N + x] = acc;
312 }
313 }
314
315 coefficients
316}
317
318/// Median of the 64 AC coefficients.
319///
320/// An even number of samples, so the two central values are averaged. Sorted
321/// with [`f32::total_cmp`] rather than `partial_cmp`: the latter is fallible on
322/// NaN, and a comparator that has to decide what to do about NaN is a
323/// comparator that can panic.
324fn median(coefficients: &[f32; N_HASH_BITS]) -> f32 {
325 let mut sorted = *coefficients;
326 sorted.sort_by(f32::total_cmp);
327 (sorted[N_HASH_BITS / 2 - 1] + sorted[N_HASH_BITS / 2]) / 2.0
328}
329
330/// Steps 1 to 4 — everything up to, but not including, the stability verdict.
331///
332/// Split out because [`recover_phash_salt`] needs the margins as well as the
333/// bits, and recomputing a DCT over a multi-megapixel image to get them back
334/// would be wasteful.
335fn compute_hash_bits(img: &ImageBuffer) -> HashBits {
336 let thumbnail = luminance_thumbnail(img);
337 let coefficients = dct_2d(&thumbnail);
338
339 // The DC term at [0, 0] is the mean brightness of the whole image. It
340 // carries no structure, dwarfs every other coefficient and would drag the
341 // median with it, so it is dropped and the next 64 coefficients are taken
342 // in row-major order.
343 let mut ac = [0.0f32; N_HASH_BITS];
344 ac.copy_from_slice(&coefficients[1..=N_HASH_BITS]);
345
346 let median = median(&ac);
347
348 let mut bits = [false; N_HASH_BITS];
349 let mut margins = [0.0f32; N_HASH_BITS];
350 for index in 0..N_HASH_BITS {
351 bits[index] = ac[index] > median;
352 margins[index] = (ac[index] - median).abs();
353 }
354
355 HashBits { bits, margins }
356}
357
358/// Step 6 — packs the 64 bits and hashes them into a salt.
359///
360/// Bits are packed most-significant-first inside each byte: bit `i` of the hash
361/// lands in bit `7 - (i % 8)` of byte `i / 8`. The order is arbitrary but it is
362/// part of the wire format, so both sides must agree on it.
363fn salt_from_bits(bits: &[bool; N_HASH_BITS]) -> PHashSalt {
364 let mut packed = [0u8; N_HASH_BITS / 8];
365 for (index, &bit) in bits.iter().enumerate() {
366 if bit {
367 packed[index / 8] |= 1 << (7 - (index % 8));
368 }
369 }
370
371 let mut hasher = Sha3_256::new();
372 hasher.update(packed);
373 hasher.update(PHASH_SALT_DOMAIN);
374 let digest: [u8; PHASH_SALT_LEN] = hasher.finalize().into();
375
376 PHashSalt::new(digest)
377}
378
379/// Every salt a container's perceptual hash could have produced.
380///
381/// One when the hash is fully determined; two when a single coefficient sits
382/// inside the margin, in which case the sender used one of the pair and nothing
383/// in the image says which.
384pub(crate) struct PHashHypotheses {
385 /// Salt of the bits exactly as they measure on this image.
386 ///
387 /// The one the sender used whenever the image is the unmodified cover, and
388 /// the overwhelmingly likely one even for a stego image: the uncertain
389 /// coefficient only moves if embedding happened to push it across the
390 /// median.
391 pub(crate) primary: PHashSalt,
392 /// Salt of the same bits with the uncertain one flipped, when there is one.
393 pub(crate) alternative: Option<PHashSalt>,
394}
395
396/// Enumerates the salts a container's hash can take, applying the same
397/// stability limit as [`compute_stable_phash`].
398///
399/// The extraction path needs this rather than a single salt. Its problem is
400/// circular: the payload is what tells the two hypotheses apart, and reading the
401/// payload needs the permutation seed, which is derived from the salt. It can
402/// only be broken by trying a hypothesis, extracting under it and letting
403/// [`recover_phash_salt`] judge the result — which requires being able to name
404/// the other hypothesis, and that is what this function provides.
405///
406/// # Errors
407///
408/// Returns [`PHashError::InsufficientStability`] when more than
409/// [`MAX_UNSTABLE_BITS`] coefficients sit within [`DELTA_MIN`] of the median.
410pub(crate) fn phash_salt_hypotheses(img: &ImageBuffer) -> Result<PHashHypotheses, PHashError> {
411 let hash = compute_hash_bits(img);
412 let unstable = hash.unstable_indices();
413
414 if unstable.len() > MAX_UNSTABLE_BITS {
415 return Err(PHashError::InsufficientStability {
416 unstable_bits: unstable.len(),
417 threshold: DELTA_MIN,
418 });
419 }
420
421 let alternative = unstable.first().map(|&index| {
422 let mut flipped = hash.bits;
423 // In bounds: the indices come from enumerating the margin array, which
424 // has exactly as many entries as the bit array. The fallback keeps the
425 // function total without an index panic.
426 if let Some(bit) = flipped.get_mut(index) {
427 *bit = !*bit;
428 }
429
430 salt_from_bits(&flipped)
431 });
432
433 Ok(PHashHypotheses {
434 primary: salt_from_bits(&hash.bits),
435 alternative,
436 })
437}
438
439/// Computes the perceptual hash salt of a container image, refusing images
440/// whose hash would not survive embedding.
441///
442/// Public so that the stability of a container can be asked about on its own,
443/// without running an embedding. The returned salt is opaque outside this
444/// crate — it has no public accessor — so exposing this function grants the
445/// verdict and not the value.
446///
447/// # Errors
448///
449/// Returns [`PHashError::InsufficientStability`] when more than
450/// `MAX_UNSTABLE_BITS` coefficients sit within `DELTA_MIN` of the median.
451/// Note that a single unstable bit is *accepted* here: the sender does not
452/// care which value it takes, because the receiver resolves the ambiguity
453/// during extraction.
454pub fn compute_stable_phash(img: &ImageBuffer) -> Result<PHashSalt, PHashError> {
455 Ok(phash_salt_hypotheses(img)?.primary)
456}
457
458/// Tests whether `ciphertext_prefix` was produced under the keys derived from a
459/// candidate salt.
460///
461/// # Why this is not a MAC check
462///
463/// Poly1305 authenticates the ciphertext as a whole and its tag lives at the
464/// very end of it, so a 64-byte prefix carries nothing that can be verified.
465/// Extracting the entire payload before the salt is known is not an option
466/// either: the STC decoder needs the permutation seed, which is derived from
467/// the very key we are trying to pin down.
468///
469/// What a prefix *does* allow is decrypting it with the raw XChaCha20
470/// keystream and looking at the plaintext. The payload is Zstandard-compressed
471/// before it is encrypted, so the correct key uncovers a Zstandard frame magic
472/// number and a wrong key uncovers uniformly random bytes — a discriminator
473/// that is wrong with probability `2^-32`.
474///
475/// That is sound because this function decides nothing security-relevant. It
476/// only picks which of two hypotheses to try first; the actual authentication
477/// still happens when the pipeline decrypts the full payload under the chosen
478/// salt, and a mistake here surfaces there as an ordinary authentication
479/// failure.
480fn prefix_matches_key(keys: &DerivedKeys, ciphertext_prefix: &[u8]) -> bool {
481 use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
482 use chacha20::XChaCha20;
483
484 let Some(head) = ciphertext_prefix.get(..ZSTD_FRAME_MAGIC.len()) else {
485 return false;
486 };
487
488 let mut cipher = XChaCha20::new(keys.enc_key().into(), keys.nonce().into());
489 if cipher.try_seek(AEAD_KEYSTREAM_OFFSET).is_err() {
490 return false;
491 }
492
493 let mut plaintext = Zeroizing::new(head.to_vec());
494
495 // The fallible form: as of `cipher` 0.5 the plain `apply_keystream_b2b`
496 // panics when the two buffers differ in length, and a panic is not
497 // something this crate is allowed to reach for. The lengths do match here —
498 // `plaintext` was built from `head` — so the branch is unreachable, and it
499 // stays as a branch rather than an assertion for exactly that reason.
500 if cipher
501 .try_apply_keystream_b2b(head, &mut plaintext)
502 .is_err()
503 {
504 return false;
505 }
506
507 plaintext.as_slice() == ZSTD_FRAME_MAGIC
508}
509
510/// Derives the keys for one hypothesis and checks them against the payload.
511///
512/// Returns `Ok(Some(salt))` when the hypothesis matches, `Ok(None)` when it
513/// does not, and `Err` only when key derivation itself failed — a condition
514/// that says nothing about which hypothesis was right and must not be confused
515/// with a rejection.
516fn try_hypothesis(
517 bits: &[bool; N_HASH_BITS],
518 password: &[u8],
519 kdf: &impl KeyDeriver,
520 ciphertext_prefix: &[u8],
521) -> Result<Option<PHashSalt>, PHashError> {
522 let salt = salt_from_bits(bits);
523
524 let master_key = kdf
525 .derive(password, &salt)
526 .map_err(|err| PHashError::KdfError(err.to_string()))?;
527 let keys =
528 expand_master_key(&master_key).map_err(|err| PHashError::KdfError(err.to_string()))?;
529 // Wiped here rather than at the end of the scope: nothing below needs it,
530 // and the two hypotheses run concurrently, so one of the two master keys is
531 // always live material that no longer has any use.
532 drop(master_key);
533
534 let matched = prefix_matches_key(&keys, ciphertext_prefix);
535 drop(keys);
536
537 if matched {
538 Ok(Some(salt))
539 } else {
540 Ok(None)
541 }
542}
543
544/// Recovers the salt of a stego image whose hash has one uncertain bit.
545///
546/// `ciphertext_prefix` is the first 64 bytes of the provisionally extracted
547/// payload. It is used to tell the two hypotheses apart; see
548/// [`prefix_matches_key`] for what "tell apart" means here and why it is not,
549/// and does not need to be, an authentication step.
550///
551/// # Errors
552///
553/// Returns [`PHashError::InsufficientStability`] if the stego image has more
554/// than [`MAX_UNSTABLE_BITS`] uncertain bits, [`PHashError::RecoveryFailed`] if
555/// neither hypothesis matches the payload, and [`PHashError::KdfError`] if the
556/// key derivation used to test a hypothesis failed.
557pub(crate) fn recover_phash_salt(
558 stego_img: &ImageBuffer,
559 password: &[u8],
560 kdf: &impl KeyDeriver,
561 ciphertext_prefix: &[u8],
562) -> Result<PHashSalt, PHashError> {
563 let hash = compute_hash_bits(stego_img);
564 let unstable = hash.unstable_indices();
565
566 let uncertain = match unstable.as_slice() {
567 // k = 0: every coefficient is far enough from the median that
568 // embedding cannot have moved it across. The hash the receiver just
569 // computed is the one the sender used, and no search is needed. This
570 // is exactly what `compute_stable_phash` would return, minus a second
571 // pass over the image.
572 [] => return Ok(salt_from_bits(&hash.bits)),
573 [index] => *index,
574 more => {
575 return Err(PHashError::InsufficientStability {
576 unstable_bits: more.len(),
577 threshold: DELTA_MIN,
578 })
579 }
580 };
581
582 let mut cleared = hash.bits;
583 cleared[uncertain] = false;
584 let mut set = hash.bits;
585 set[uncertain] = true;
586
587 // Argon2id at the production parameters costs 128 MiB and several hundred
588 // milliseconds. Running the two hypotheses on separate threads makes the
589 // recovery cost the same wall time as an ordinary derivation, at the price
590 // of holding two memory blocks at once.
591 let (cleared, set) = rayon::join(
592 || try_hypothesis(&cleared, password, kdf, ciphertext_prefix),
593 || try_hypothesis(&set, password, kdf, ciphertext_prefix),
594 );
595
596 // Both hypotheses can only match if the discriminator collided, which is a
597 // `2^-32` event; picking either one is then as good as picking the other,
598 // and the full-payload authentication downstream catches the mistake.
599 match (cleared?, set?) {
600 (Some(salt), _) | (None, Some(salt)) => Ok(salt),
601 (None, None) => Err(PHashError::RecoveryFailed),
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
608 // well. A test that cannot panic cannot fail, so they are lifted here and
609 // only here.
610 #![allow(clippy::expect_used)]
611 #![allow(clippy::panic)]
612
613 use super::*;
614
615 use rand::rngs::StdRng;
616 use rand::{RngExt, SeedableRng};
617
618 use crate::crypto::aead::{compress_and_encrypt, XChaCha20Poly1305Cipher};
619 use crate::crypto::kdf::{Argon2Kdf, MasterKey};
620
621 /// Side length of the synthetic containers below.
622 ///
623 /// Exactly the thumbnail size, so the resize step is the identity and the
624 /// DCT reads the samples these tests wrote. Building the hash out of a
625 /// multi-megapixel image instead would test the resampler, not the filter.
626 const SIDE: u32 = PHASH_THUMBNAIL_SIZE as u32;
627
628 /// The password the recovery tests stretch.
629 const PASSWORD: &[u8] = b"a-container-passphrase";
630
631 /// A square of uncorrelated colour noise.
632 ///
633 /// White noise spreads the 64 AC coefficients over a range of some
634 /// thousands of units, which is three orders of magnitude above the `5.0`
635 /// stability margin — so the hash of such an image is reproducible for
636 /// almost any seed.
637 fn noise_image(seed: u64) -> ImageBuffer {
638 let mut rng = StdRng::seed_from_u64(seed);
639 let pixel_count = (SIDE * SIDE) as usize;
640 let pixels = (0..pixel_count * 3).map(|_| rng.random()).collect();
641
642 ImageBuffer::new(pixels, SIDE, SIDE, ColorSpace::Rgb8)
643 }
644
645 /// The first noise square at or after `seed` whose hash is reproducible.
646 ///
647 /// Almost every seed produces one, but not quite every seed: the two
648 /// central AC coefficients are what the verdict turns on, and they land
649 /// within `2 * DELTA_MIN` of each other often enough that pinning a
650 /// literal seed would make these tests brittle for no reason.
651 fn stable_noise_image(seed: u64) -> ImageBuffer {
652 match (seed..seed + 64)
653 .map(noise_image)
654 .find(|image| compute_hash_bits(image).unstable_indices().is_empty())
655 {
656 Some(image) => image,
657 None => panic!("no stable container in sixty-four candidates from {seed}"),
658 }
659 }
660
661 /// A container with no structure at all, and therefore no usable hash.
662 fn flat_image() -> ImageBuffer {
663 let pixel_count = (SIDE * SIDE) as usize;
664
665 ImageBuffer::new(vec![128u8; pixel_count * 3], SIDE, SIDE, ColorSpace::Rgb8)
666 }
667
668 /// Sixty-four bytes of ciphertext produced under the keys of `bits`.
669 ///
670 /// The head of a real payload: compressed with Zstandard and then
671 /// encrypted, which is exactly what [`prefix_matches_key`] is written to
672 /// recognise.
673 fn ciphertext_prefix_for(bits: &[bool; N_HASH_BITS]) -> Vec<u8> {
674 let salt = salt_from_bits(bits);
675 let master_key = Argon2Kdf::low_cost_for_tests()
676 .derive(PASSWORD, &salt)
677 .expect("a non-empty password must stretch");
678 let keys = expand_master_key(&master_key).expect("expansion must succeed");
679
680 let ciphertext = compress_and_encrypt(
681 &b"a payload long enough to fill a whole keystream block and then some".repeat(4),
682 keys.enc_key(),
683 keys.nonce(),
684 &XChaCha20Poly1305Cipher::new(),
685 )
686 .expect("encryption must succeed");
687
688 ciphertext.iter().copied().take(64).collect()
689 }
690
691 /// Every layout reduces to the same notion of brightness.
692 #[test]
693 fn luminance_reads_each_layout_on_the_same_scale() {
694 // Grayscale is passed through untouched.
695 assert_eq!(luminance(&[110], ColorSpace::Luma8), 110);
696
697 // BT.601 weights the green channel most and the blue least.
698 assert_eq!(luminance(&[255, 0, 0], ColorSpace::Rgb8), 76);
699 assert_eq!(luminance(&[0, 255, 0], ColorSpace::Rgb8), 149);
700 assert_eq!(luminance(&[0, 0, 255], ColorSpace::Rgb8), 29);
701
702 // The alpha channel is not part of the brightness of a pixel.
703 assert_eq!(
704 luminance(&[255, 0, 0, 17], ColorSpace::Rgba8),
705 luminance(&[255, 0, 0], ColorSpace::Rgb8)
706 );
707
708 // Sixteen-bit samples are read as little-endian pairs and normalised to
709 // the same 0..=255 range, so `DELTA_MIN` means one thing at both depths.
710 assert_eq!(
711 luminance(&[0xFF, 0xFF, 0, 0, 0, 0], ColorSpace::Rgb16),
712 luminance(&[255, 0, 0], ColorSpace::Rgb8)
713 );
714 }
715
716 /// A textured container hashes reproducibly and leaves nothing to guess.
717 #[test]
718 fn a_textured_container_has_a_single_hypothesis() {
719 let image = stable_noise_image(1);
720
721 let hypotheses = match phash_salt_hypotheses(&image) {
722 Ok(hypotheses) => hypotheses,
723 Err(error) => panic!("colour noise must hash stably: {error}"),
724 };
725
726 assert!(
727 hypotheses.alternative.is_none(),
728 "a fully determined hash has nothing to disambiguate"
729 );
730
731 let salt = compute_stable_phash(&image).expect("the same image must hash again");
732 assert_eq!(salt.as_bytes(), hypotheses.primary.as_bytes());
733 }
734
735 /// A container with no structure is refused, and the refusal counts the
736 /// bits it could not pin down.
737 #[test]
738 fn a_flat_container_is_refused_as_unstable() {
739 let error = phash_salt_hypotheses(&flat_image())
740 .map(|_| ())
741 .expect_err("a uniform image cannot hash reproducibly");
742
743 match error {
744 PHashError::InsufficientStability {
745 unstable_bits,
746 threshold,
747 } => {
748 assert!(unstable_bits > MAX_UNSTABLE_BITS);
749 assert_eq!(threshold, DELTA_MIN);
750 }
751 other => panic!("expected an instability verdict, got: {other:?}"),
752 }
753 }
754
755 /// Uncertain bits always come in pairs, so `k` is never exactly one.
756 ///
757 /// A structural property of the filter rather than an accident of the
758 /// images below. The median of an even-sized sample is the midpoint of its
759 /// two central values, so those two are equidistant from it and no other
760 /// coefficient can be closer: whenever the smallest margin falls under
761 /// [`DELTA_MIN`], two bits do at once.
762 ///
763 /// The consequence is worth stating where it can be checked: the branch of
764 /// [`recover_phash_salt`] that resolves a single uncertain bit describes a
765 /// case this filter cannot produce, and every container it accepts has a
766 /// hash that is fully determined.
767 #[test]
768 fn uncertain_bits_never_appear_alone() {
769 for seed in 0..64u64 {
770 let unstable = compute_hash_bits(&noise_image(seed))
771 .unstable_indices()
772 .len();
773
774 assert_ne!(unstable, 1, "seed {seed} produced a lone uncertain bit");
775 }
776
777 assert_ne!(compute_hash_bits(&flat_image()).unstable_indices().len(), 1);
778 }
779
780 /// The hash is a function of the image and of nothing else.
781 #[test]
782 fn the_salt_is_deterministic_and_image_dependent() {
783 let image = stable_noise_image(100);
784 let other_image = stable_noise_image(200);
785
786 let first = compute_stable_phash(&image).expect("noise must hash");
787 let again = compute_stable_phash(&image).expect("noise must hash");
788 let other = compute_stable_phash(&other_image).expect("noise must hash");
789
790 assert_eq!(first.as_bytes(), again.as_bytes());
791 assert_ne!(first.as_bytes(), other.as_bytes());
792 }
793
794 /// Two containers that derive one salt compare equal and land in one
795 /// bucket; an unrelated one does not.
796 ///
797 /// The property the equality surface exists for, and the reason it is not
798 /// hypothetical: the hash is *built* to survive a change of this size —
799 /// extraction could not recompute the salt otherwise — so a stego image and
800 /// the cover it was made from are one container as far as the key
801 /// derivation is concerned, and so are two frames of one burst.
802 ///
803 /// # Why the perturbation is this small
804 ///
805 /// The variant flips the least significant bit of three of the 3072 samples.
806 /// Each flip moves the luma of its pixel by at most `0.587`, and every DCT
807 /// coefficient is a weighted sum of the samples with weights in `[-1, 1]`,
808 /// so no coefficient and no median can move by more than `3 * 0.587`. A bit
809 /// therefore needs a margin below `3.52` to be at risk, and
810 /// [`stable_noise_image`] returns a container whose every margin clears
811 /// [`DELTA_MIN`] — the equality below is arithmetic, not luck.
812 #[test]
813 fn containers_that_derive_one_salt_group_together() {
814 use std::collections::HashMap;
815
816 let image = stable_noise_image(7);
817
818 let mut samples = image.pixels().to_vec();
819 for (index, sample) in samples.iter_mut().enumerate() {
820 if index % 1024 == 0 {
821 *sample ^= 1;
822 }
823 }
824 assert_ne!(samples, image.pixels(), "the variant must be another file");
825
826 let variant = ImageBuffer::new(samples, SIDE, SIDE, ColorSpace::Rgb8);
827 let unrelated = stable_noise_image(4_000);
828
829 let salt = compute_stable_phash(&image).expect("noise must hash");
830 let variant_salt = compute_stable_phash(&variant).expect("noise must hash");
831 let unrelated_salt = compute_stable_phash(&unrelated).expect("noise must hash");
832
833 assert!(
834 salt == variant_salt,
835 "a change embedding could have made must not move the salt"
836 );
837 assert!(
838 salt != unrelated_salt,
839 "two unrelated containers must not share a salt"
840 );
841
842 // And the equality is usable as a hash-map key, which is what lets a
843 // caller group a folder of containers in one pass instead of comparing
844 // every pair of them.
845 let mut buckets: HashMap<PHashSalt, usize> = HashMap::new();
846 for salt in [salt, variant_salt, unrelated_salt] {
847 *buckets.entry(salt).or_default() += 1;
848 }
849
850 assert_eq!(buckets.len(), 2, "the variant must join the image it came from");
851 assert!(buckets.values().any(|&count| count == 2));
852 }
853
854 /// Flipping one hash bit changes the whole salt.
855 ///
856 /// The bits are packed and then hashed, so the salt is not a rearrangement
857 /// of them and a neighbouring hypothesis shares nothing with its partner.
858 #[test]
859 fn one_flipped_bit_changes_the_whole_salt() {
860 let mut bits = [false; N_HASH_BITS];
861 let base = salt_from_bits(&bits);
862
863 bits[17] = true;
864 let flipped = salt_from_bits(&bits);
865
866 assert_ne!(base.as_bytes(), flipped.as_bytes());
867 }
868
869 /// The median of an even sample is the midpoint of its two central values.
870 #[test]
871 fn the_median_is_the_midpoint_of_the_two_central_coefficients() {
872 let mut coefficients = [0.0f32; N_HASH_BITS];
873 for (index, value) in coefficients.iter_mut().enumerate() {
874 *value = index as f32;
875 }
876
877 assert_eq!(median(&coefficients), 31.5);
878 }
879
880 /// A correct key uncovers a Zstandard frame; a wrong one uncovers noise.
881 #[test]
882 fn the_zstd_magic_number_tells_the_keys_apart() {
883 let bits = [true; N_HASH_BITS];
884 let prefix = ciphertext_prefix_for(&bits);
885
886 let salt = salt_from_bits(&bits);
887 let master_key = Argon2Kdf::low_cost_for_tests()
888 .derive(PASSWORD, &salt)
889 .expect("a non-empty password must stretch");
890 let keys = expand_master_key(&master_key).expect("expansion must succeed");
891
892 assert!(prefix_matches_key(&keys, &prefix));
893
894 // Any other key decrypts the same prefix to bytes that are not a frame
895 // header, which is the whole discriminator.
896 let other = expand_master_key(&MasterKey::new([0x5Au8; 32])).expect("expansion");
897 assert!(!prefix_matches_key(&other, &prefix));
898
899 // A prefix too short to hold the magic number decides nothing.
900 assert!(!prefix_matches_key(&keys, &prefix[..3]));
901 }
902
903 /// One hypothesis is confirmed by the payload, the other is not.
904 #[test]
905 fn a_hypothesis_is_judged_by_the_payload_it_explains() {
906 let kdf = Argon2Kdf::low_cost_for_tests();
907 let bits = [true; N_HASH_BITS];
908 let prefix = ciphertext_prefix_for(&bits);
909
910 // `PHashSalt` implements no `Debug` — it is derived from the container
911 // and must not reach a log — so the arms below are spelled out rather
912 // than compared with a macro that would want to print the value.
913 match try_hypothesis(&bits, PASSWORD, &kdf, &prefix) {
914 Ok(Some(salt)) => assert_eq!(salt.as_bytes(), salt_from_bits(&bits).as_bytes()),
915 Ok(None) => panic!("the hypothesis the payload was made under must match"),
916 Err(error) => panic!("derivation must succeed: {error}"),
917 }
918
919 let mut wrong = bits;
920 wrong[0] = false;
921 match try_hypothesis(&wrong, PASSWORD, &kdf, &prefix) {
922 Ok(None) => {}
923 Ok(Some(_)) => panic!("a hypothesis that explains nothing must be rejected"),
924 Err(error) => panic!("derivation must succeed: {error}"),
925 }
926
927 // A derivation that fails is not a rejection: it says nothing about
928 // which hypothesis was right and must not be reported as one.
929 match try_hypothesis(&bits, &[], &kdf, &prefix) {
930 Err(PHashError::KdfError(_)) => {}
931 Err(error) => panic!("expected a derivation failure, got: {error}"),
932 Ok(_) => panic!("an empty password must fail derivation"),
933 }
934 }
935
936 /// A hash with no uncertain bit needs no search.
937 #[test]
938 fn recovery_short_circuits_on_a_certain_hash() {
939 let image = stable_noise_image(4);
940 let expected = compute_stable_phash(&image).expect("noise must hash");
941
942 let recovered = recover_phash_salt(&image, PASSWORD, &Argon2Kdf::low_cost_for_tests(), &[])
943 .expect("a fully determined hash needs no payload to be recovered");
944
945 assert_eq!(recovered.as_bytes(), expected.as_bytes());
946 }
947
948 /// An image whose hash is not reproducible is refused by the recovery path
949 /// on the same terms as by the sender's path.
950 #[test]
951 fn recovery_refuses_an_unstable_image() {
952 let error = recover_phash_salt(
953 &flat_image(),
954 PASSWORD,
955 &Argon2Kdf::low_cost_for_tests(),
956 &[],
957 )
958 .map(|_| ())
959 .expect_err("a uniform image has no hash to recover");
960
961 assert!(
962 matches!(error, PHashError::InsufficientStability { .. }),
963 "got: {error:?}"
964 );
965 }
966
967 /// Every failure of this layer explains itself.
968 #[test]
969 fn every_failure_explains_itself() {
970 let unstable = PHashError::InsufficientStability {
971 unstable_bits: 7,
972 threshold: DELTA_MIN,
973 }
974 .to_string();
975 assert!(unstable.contains('7') && unstable.contains("texture"));
976
977 assert!(!PHashError::RecoveryFailed.to_string().is_empty());
978 assert!(PHashError::KdfError("empty password".to_owned())
979 .to_string()
980 .contains("empty password"));
981 }
982}