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?
asked Jan 29, 2023 at 15:25
\$\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!

answered Jan 29, 2023 at 18:36
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

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

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.