1
\$\begingroup\$

This simple implementation hides text secrets (S) in images by manipulating the least significant bits (lsb) on Pixels.

How it works

When encrypting I take a three pixel "chunk" for each byte. Each bit of the byte is hidden in the RGB (lsb) values of the pixels. The last blue value in the last pixel of each chunk is an exception, it stores a termination flag, more characters need to be encrypted sets this flag to 1 otherwise to 0.

Decryption reverts the above method, by constructing a byte from each three pixel chunk and putting it back to a string.

Implementation

The encrypt function:

/// Hides a secret string in the target image.
///
/// # Arguments
///
/// * `src_img` - Target image the secret will be written to.
/// * `secret`  - Secret string which will be hidden in the target image.
///
pub fn encode_secret(src_img: &mut DynamicImage, secret: String) {
    let secret_bytes = secret.into_bytes();

    // Iterate over each byte in the secret
    for (byte_idx, byte) in secret_bytes.iter().enumerate() {
        // Index of the first (of three) Pixel
        let n_rgb = byte_idx as u32 * 3;

        // Get bits for current byte
        let bit_buffer = byte.to_bit_buffer();

        // Iterate over chunks of three bits
        for (bit_chunk_idx, bit_chunk) in bit_buffer.chunks(3).enumerate() {
            // Calculate (image) row and column of the current pixel
            let n: u32 = n_rgb + bit_chunk_idx as u32;
            let col_idx = n / src_img.width();
            let row_idx = n % src_img.width();

            // Replace the least signbificant bits for R and G values of the Pixel.
            let o_pixel = src_img.get_pixel(row_idx, col_idx);
            let r_out = o_pixel[0].set_lsb(bit_chunk[0]);
            let g_out = o_pixel[1].set_lsb(bit_chunk[1]);

            let b_out: u8 = if bit_chunk.len() > 2 {
                // More than 2 bits in chunk, not the last chunk. We expect more chunks to come
                o_pixel[2].set_lsb(bit_chunk[2])
            } else if byte_idx + 1 == secret_bytes.len() {
                // This is the last secret char, we set the last byte to be even.
                o_pixel[2].set_lsb(false)
            } else {
                o_pixel[2].set_lsb(true) // Last chunk but not the last secret char, we set the last byte to be odd.
            };

            // Write pixel back to image
            src_img.put_pixel(
                row_idx,
                col_idx,
                image::Rgba([r_out, g_out, b_out, o_pixel[3]]),
            );
        }
    }
}

The decrypt function:

/// Returns a secret string retrieved from the provided image if it exists.
///
/// # Arguments
///
/// * `img` - Image from which a secret will be retrieved.
///
pub fn decode_secret(img: &DynamicImage) -> Option<String> {
    let mut result: String = String::new();
    let mut byte_array = vec![];

    let pixels: Vec<_> = img.pixels().collect();

    // Iterate chunks of three pixels
    for pixel_chunk in pixels.chunks(3) {
        let mut byte: u8 = 0;

        // Construct a byte from three pixels
        byte = byte.set_bit(0, pixel_chunk[0].2 .0[0].get_lsb());
        byte = byte.set_bit(1, pixel_chunk[0].2 .0[1].get_lsb());
        byte = byte.set_bit(2, pixel_chunk[0].2 .0[2].get_lsb());
        byte = byte.set_bit(3, pixel_chunk[1].2 .0[0].get_lsb());
        byte = byte.set_bit(4, pixel_chunk[1].2 .0[1].get_lsb());
        byte = byte.set_bit(5, pixel_chunk[1].2 .0[2].get_lsb());
        byte = byte.set_bit(6, pixel_chunk[2].2 .0[0].get_lsb());
        byte = byte.set_bit(7, pixel_chunk[2].2 .0[1].get_lsb());

        byte_array.push(byte);

        // Last byte (blue) value of the last pixel in the chunk is even. This is the termination flag.
        if pixel_chunk[2].2 .0[2] % 2 == 0 {
            break;
        }
    }

    // Try convert the byte array to (secret) string
    match String::from_utf8(byte_array) {
        Ok(res) => result = format!("{}{}", result, res),
        Err(_err) => return None,
    }

    if result.is_empty() {
        return None;
    }

    Some(result)
}

For the bit operations I use some helper functions:

pub trait BitBuffer {
    fn to_bit_buffer(&self) -> Vec<bool>;
}

impl BitBuffer for u8 {
    /// Returns a vector of bools representing the single bits of self.
    /// 
    fn to_bit_buffer(&self) -> Vec<bool> {
        let mut result = vec![];

        for i in 0..=7 {
            result.push(self.get_bit(i).unwrap());
        }

        result
    }
}

pub trait BitOps {
    /// Sets the least significant bit of a number according to the passed value.
    ///
    fn set_lsb(&self, value: bool) -> Self;

    /// Returns the bit on the specified position.
    /// 
    fn get_bit(&self, n: u8) -> Option<bool>;

    /// Sets a bit on the specified position.
    /// 
    fn set_bit(&self, n: usize, value: bool) -> u8;

    /// Returns the least significant bit.
    ///
    fn get_lsb(&self) -> bool;
}

impl BitOps for u8 {
    fn get_bit(&self, n: u8) -> Option<bool> {
        if n > 7 {
            return None;
        }
        let result = *self >> n & 1;
        if result == 1 {
            return Some(true);
        } else if result == 0 {
            return Some(false);
        }
        panic!();
    }

    fn set_bit(&self, n: usize, value: bool) -> u8 {
        let mut result = *self;

        if n > 7 {
            panic!();
        }

        if value {
            result |= 1 << n;
        } else {
            result &= !(1 << n);
        }
        result
    }

    fn set_lsb(&self, value: bool) -> u8 {
        let mut result = *self;
        if value {
            result |= 0b0000_0001;
            return result;
        }
        result &= 0b1111_1110;
        result
    }

    fn get_lsb(&self) -> bool {
        self % 2 != 0
    }
}

In order to run the above implementation you will need the image crate in your project. Load an image and pass the required parameters to the functions above. Because of the nature of the implementation, your image will need to have a pixel amount which is at least three times the character amount.

What am I (not) looking for in a review

  1. It is not my goal to implement a highly sophisticated algorithm, just a simple one.
  2. Am I doing things the rustacean way? Especially in the encode_secret function I have a feeling, that I should/could have used more iter magic. Still after browsing the docs I ended up using nested loops.
  3. How is the overall code style? Is there any room for performance optimization?
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

There might be more crustacean ways to express this cast:

        let n_rgb = byte_idx as u32 * 3;

Giving an explicit type with n_rgb: u32 = ... , or even just writing the factor 3 first, may improve the readability a bit.


Thank you for the valuable comment about indexing the first (of three) pixels.

Consider deleting redundant comments such as

    // Iterate over each byte in the secret
    ...
        // Get bits for current byte

Your code is wonderfully clear already. We use comments to explain why, and code to explain what is happening.


I feel the code that assigns r_out and g_out is fine the way it is. But a purist might ask for MANIFEST_CONSTANTS indicating that we're indexing the red and green pixels, and then the very helpful comment would become redundant.


Consider replacing the outer for loop with a zip that also iterates through secret bits.

The function is not yet "too" long, but nonetheless you might push some of its logic into a write_lsb(secret_byte, bit_chunk) helper.


Suppose we hid a short message in a very large image. When recovering the message, does img.pixels().collect() eagerly do a lot more work than necessary? We would prefer to lazily evaluate, so pixels at the end are never even touched.

The decode for loop offers the perfect opportunity to introduce a construct_byte(pixel_chunk) helper. And if the helper could produce the sequence of indexes being visited, a single loop could keep assigning byte * 2 + ... lsb.

This is a slightly unusual expression:

if pixel_chunk[2].2 .0[2] % 2 == 0 {

There's many ways to phrase it. But having adopted an idiom, stick with it. Here, .get_lsb() is how you've been recovering those bits, so it would be appropriate to also use it here, showing parallel structure.


I am reading the get_bit helper. Sorry, I don't understand why it's useful for the return value to be optional. Shouldn't we throw fatal error if caller offers n too large, as we see in set_bit ?

Also, given *self >> n, I am sad that we can't just mask against 1 and that's the end of it.


I am reading set_bit.

Consider renaming it to assign_bit, as "set" / "clear" in this context suggests assigning 1 / 0. Similarly for assign_lsb.

The logic is perfectly clear. It does frustrate Intel's branch prediction. If you ever profile this code and find that you want it to go faster, consider using a branchless expression. Simplest approach is to unconditionally clear, then merge in the bit:

        let mask = !(1 << n);
        result &= mask;
        result |= value << n;

Similarly for the lsb helper:

        result &= 0b1111_1110;
        result |= value;

You might instead obtain these helpers from a crate.


If you ever want to shoot for a more sophisticated algorithm:

  1. Consider pre-processing secret with checksum or compression, so you can verify that it round tripped correctly.
  2. Consider searching for a "good" image location to alter, as adding low-bit noise to a wash or constant background is more apparent than adding it to images of vegetation or people.

Overall?

This code is maintainable by others, and it achieves its design goals. LGTM. Ship it!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.