Skip to main content

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}