[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky] [Facebook]
[Build Your Own Python Action Arcade!]

[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Hide an image inside another using secure steganography in Python

[This program provides cryptographically secure steganography in Python]

My post Decode images hidden inside other images by steganography in Python shows how to hide an image inside another one. It works well but it uses Python's random library, which is not cryptographically secure. I think it would be hard for an attacker to recover the hidden image (as long as you change the seed used to initialize the random library) because I don't think you can use the normal way you attack random, but it's not too hard to put the issue to rest by using a cryptographically secure random number generator.

To use the program to stegify an image, select the Encrypt tab and click the Load buttons in the Message and Cover columns. Enter a password in the textbox and click Encode. To save the encoded result, click Save.

[Load the message and cover images and click Encode to stegify them] To destegify an image, select the Decrypt tab and click the Load button in the Encoded column as shown in the picture on the right. Enter the same password as before in the textbox and click Decode. To save the recovered message image, click Save.

Adding Real Cryptography

Instead of using Python's random class, you can use a cryptographically secure pseudo-random number generator (PRNG) that's initialized with a password. Then an attacker couldn't destegify the image without performing a massive trial-and-error attack so the hidden image would be secure.

This example uses the ChaCha20 algorithm provided by Python's cryptography library. If you don't already have the library installed, use:

pip install cryptography

You can make sure you're running the latest version by using:

pip install --upgrade cryptography

Generating Nibbles

Having installed or upgraded the library, you can use it to generate pseudo-random bytes. You may recall from my previous posts that the encode_images and decode_images functions don't actually use whole bytes, they use 4-bit integers.

FUN FACT: The two halves of a byte are sometimes called nibbles. The four most significant bits are called the left nibble and the four least significant bits are called the right nibble.

The following nibble_generator function yields nibbles one at a time.

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms def nibble_generator(seed_bytes, chunk_size=64): '''Yield secure pseudorandom nibbles (0-15) from a seed.''' # Zero-pad the seed to 32 bytes for ChaCha20. key = (seed_bytes + b'\0' * 32)[:32] nonce = b'\0' * 16 # Use a fixed nonce for deterministic streams. alg = algorithms.ChaCha20(key, nonce) cipher = Cipher(alg, mode=None) encryptor = cipher.encryptor() # Generate nibbles. mask = 0b00001111 zero_block = b'\0' * chunk_size while True: keystream_chunk = encryptor.update(zero_block) for byte in keystream_chunk: yield byte & mask yield byte >> 4

ChaCha20 requires a 32-byte key, so the code ensures that seed_bytes is 32 bytes long. First it adds 32 bytes of zeros so it knows the seed is at least 32 bytes long. It then uses a slice to get the left 32 bytes.

Next, the code creates a nonce. A nonce is a pseudo-random number that's used only once in a particular communication. For example, if a website sends you an encrypted password reset link, the nonce guarantees that an attacker who intercepts the message can't reuse the message later because a new message will have a different nonce. (The word nonce comes from "number used once.")

The function uses the key and nonce to create a ChaCha20 object. It uses that object to make a Cipher and uses the Cipher to make an encryptor.

Next the code defines a mask that it will use to separate each byte's nibbles. It also creates a block of 64 zeros.

Finally, the code enters an infinite loop to generate nibbles. Each time through the loop it calls the encryptor's update method passing it the zero block. The update generates a pseudo-random stream of bytes and combines them with the input plaintext or ciphertext with XOR. Passing in the zero block makes update return the pseudo-random bytes.

Having obtained the bytes, the code loops through them. It first uses the mask to clear the MSBs and yield the byte's right nibble. It then shifts the byte four bits to the right to move the MSBs into the LSB positions and yields the left nibble.

Stegifying With Nibbles

The following encode_images function uses the nibble generator to stegify one image inside another. The places where this code uses the nibble generator are highlighted in blue.

def encode_images(image1, image2, password): '''Encode image1 in image2.''' # Seed the random nibble generator. seed = password.encode() byte_stream = nibble_generator(seed) # Make a copy so we don't mess up the original. image2 = image2.copy() # Load the images' pixels. pixels1 = image1.load() pixels2 = image2.load() # Find the bounds we need to process. wid = min(image1.width, image2.width) hgt = min(image1.height, image2.height) # Process. mask = 0b11110000 for y in range(hgt): for x in range(wid): r1, g1, b1 = pixels1[x, y] r2, g2, b2 = pixels2[x, y] r2 = (r2 & mask) | ((r1 >> 4) ^ next(byte_stream)) g2 = (g2 & mask) | ((g1 >> 4) ^ next(byte_stream)) b2 = (b2 & mask) | ((b1 >> 4) ^ next(byte_stream)) pixels2[x, y] = (r2, g2, b2) return image2

This code processes the images' pixels just as the program did in the earlier examples except it uses the nibble generator instead of random.randint.

Destegifying With Nibbles

The following decode_images function destegifies images. Again, the places where the code uses the nibble generator are highlighted in blue.

def decode_images(image, password): '''Separate the two images.''' # Seed the random nibble generator. seed = password.encode() byte_stream = nibble_generator(seed) # Make the result images. image1 = image.copy() image2 = image.copy() # Load the images' pixels. pixels = image.load() pixels1 = image1.load() pixels2 = image2.load() # Find the bounds we need to process. wid = image.width hgt = image.height # Process. mask1 = 0b00001111 mask2 = 0b11110000 for y in range(hgt): for x in range(wid): r, g, b = pixels[x, y] r1 = ((r & mask1) ^ next(byte_stream)) << 4 g1 = ((g & mask1) ^ next(byte_stream)) << 4 b1 = ((b & mask1) ^ next(byte_stream)) << 4 r2 = r & mask2 g2 = g & mask2 b2 = b & mask2 pixels1[x, y] = (r1, g1, b1) pixels2[x, y] = (r2, g2, b2) return image1, image2

Like the encode_images function, this one works just as the previous example did except it uses the nibble generator instead of random.randint.

Conclusion

[If the password isn't the one you used to stegify an image, the result is garbage] Download the example and give it a try. If the password you use to destegify the image doesn't match the password used to stegify the image, you'll get garbage as shown in the picture on the right.
© 2025 - 2026 Rocky Mountain Computer Consulting, Inc. All rights reserved.