Feel like a secret agent: Hidden messages in images with steganography 🖼️🕵️‍♀️
Tags: javascript, tutorial, cryptography, webdev
Published: 2021-06-19 18:22:38

James Bond, Ethan Hunt, Napoleon Solo - secret agents working in disguise, sending secret messages to their employer and other agents. Let's be honest, secret agents are cool. At least in the movies and books. They get awesome gadgets, hunt down villains, get to visit fancy clubs with fancy clothes. And at the end of they day, they save the world. When I was a kid, I would've loved to be a secret agent.

In this post, I'm going to show you a technique that might well be used by secret agents to hide images within other images: Steganography.

## But first: What's steganography anyways?

Steganography could be something invented by the famous engineer Q of MI6 in "James Bond" movies, but it's actually much older! Hiding messages or images from eyes that shouldn't see them was a thing since the ancient times already.

According to Wikipedia, in 440 BC, Herodotus, an ancient Greek writer, once shaved the head of one of his most loyal servants to write a message on their bald head and sent the servant to the recipient once their hair grew back.

We're not going to shave anyone today, let alone hide messages on each others heads. Instead, we're hiding an image in another image.

To do this, we get rid of insignificant parts of the colors of one image and replace it with the significant parts of the colors of another image.

## Wait, what? Significant, insignificant?

To understand what that means, we first need to know how colors work, for example, in PNG. Web devs might be familiar with the hex notations of colors, such as `#f60053`, or `#16ee8a`. A hex color consists of four different parts:

 * A `#` as a prefix
 * Two hex digits for red
 * Two hex digits for green
 * Two hex digits for blue

Since the values can go from `00` to `FF` for each color, this means it's going from `0` to `255` in decimal. In binary, it would go from `00000000` to `11111111`.

Binary works very similar to decimal: The further left a single digit is, the higher it's value. The "significance" of a bit therefore increases, the further left it is.

For example: `11111111` is almost twice as large as `01111111`, `11111110` on the other hand is only slightly smaller. A human eye most likely won't notice the difference betweeen `#FFFFFF` and `#FEFEFE`. It will notice the difference between `#FFFFFF` and `#7F7F7F`, though.

## Let's hide an image with JS

Let's hide this stock image:

![A stock image of a computer with some CLI output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0jot4yko32tc5o3ej8xu.png)

in this cat image:

![A fluffy orange cat](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kggvmlnuwti4w9pdot2o.png)

I'm going to write a little Node script to hide an image in another. This means my script needs to take three arguments:

 * The main image
 * The hidden image
 * The destination

Let's code this out first:

```javascript
const args = process.argv.slice(2)

const mainImagePath = args[0]
const hiddenImagePath = args[1]
const targetImagePath = args[2]

// Usage:
// node hide-image.js ./cat.png ./hidden.png ./target.png
```

So far so good. Now I'll install [image-size](https://www.npmjs.com/package/image-size) to get the size of the main image and [canvas for node](https://www.npmjs.com/package/canvas) to inspect the images  and generate a new image.

First, let's find out the dimensions of the main image and the secret image and create canvasses for both of them. I'll also create a canvas for the output image:

```javascript
const imageSize = require('image-size')
const { createCanvas, loadImage } = require('canvas')

const args = process.argv.slice(2)

const mainImagePath = args[0]
const hiddenImagePath = args[1]
const targetImagePath = args[2]

const sizeMain = imageSize(mainImagePath)
const sizeHidden = imageSize(hiddenImagePath)

const canvasMain = createCanvas(sizeMain.width, sizeMain.height)
const canvasHidden = createCanvas(sizeHidden.width, sizeHidden.height)
const canvasTarget = createCanvas(sizeMain.width, sizeMain.height)

const contextMain = canvasMain.getContext('2d')
const contextHidden = canvasHidden.getContext('2d')
const contextTarget = canvasTarget.getContext('2d')
```

Next, I need to load both images into their respective canvasses. Since these methods return promises, I put the rest of the code in an immediately invoked function expression that allows for async/await:

```javascript
;(async () => {
  const mainImage = await loadImage(mainImagePath)
  contextMain.drawImage(mainImage, 0, 0, sizeMain.width, sizeMain.height)
  
  const hiddenImage = await loadImage(hiddenImagePath)
  contextHidden.drawImage(hiddenImage, 0, 0, sizeHidden.width, sizeHidden.height)
})()
```

Next, I iterate over every single pixel of the images and get their color values:

```javascript
  for (let x = 0; x < sizeHidden.width; x++) {
    for (let y = 0; y < sizeHidden.height; y++) {
      const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data)
      const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data)
    }
  }
```

With these values, I can now calculate the "combined" color of every pixel that I'm going to draw into the target image.

## Calculating the new color

I said something about significant bits earlier. To actually calculate the color, let me illustrate this a bit further.

Let's say, I want to combine the red parts of colors A and B. I'll represent their bits (8bit) as follows:

```
A7 A6 A5 A4 A3 A2 A1 A0 (color A)
B7 B6 B5 B4 B3 B2 B1 B0 (color B)
```

To hide the color B in the color A, I replace the first (right most), lets say, 3 bits of A with the last (left most) bits of B. The resulting bit pattern would look like this:

```
A7 A6 A5 A4 A3 B7 B6 B5
```

This means, I lose some information of both colors, but the combined color will not look much different than the color B itself.

Let's code this:

```javascript
const combineColors = (a, b) => {
  const aBinary = a.toString(2).padStart(8, '0')
  const bBinary = b.toString(2).padStart(8, '0')

  return parseInt('' +
    aBinary[0] +
    aBinary[1] +
    aBinary[2] +
    aBinary[3] +
    aBinary[4] +
    bBinary[0] +
    bBinary[1] +
    bBinary[2], 
  2)
}
```

I can now use that function in the pixel loop:

```javascript
const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data)
const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data)

const combinedColor = [
  combineColors(colorMain[0], colorHidden[0]),
  combineColors(colorMain[1], colorHidden[1]),
  combineColors(colorMain[2], colorHidden[2]),
]

contextTarget.fillStyle = `rgb(${combinedColor[0]}, ${combinedColor[1]}, ${combinedColor[2]})`
contextTarget.fillRect(x, y, 1, 1)
```

Almost there, now I only need to save the resulting image:

```javascript
const buffer = canvasTarget.toBuffer('image/png')
fs.writeFileSync(targetImagePath, buffer)
```

And here's the result:

![An image hidden in the cat image from above](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c6i6fx9eposz68hiparc.png)

Depending on your screen settings, you might see the pattern of the hidden image in the top half of the image. Usually, you would use an image that obfuscates the hidden image more.

## And how do I restore the hidden image?

To extract the hidden image, all that's necessary is to read out the last 3 bits of each pixel and make them the most significant bits again:

```javascript
const extractColor = c => {
  const cBinary = c.toString(2).padStart(8, '0')
  
  return parseInt('' +
    cBinary[5] + 
    cBinary[6] + 
    cBinary[7] + 
    '00000',
  2)
}
```

If I do this for every single pixel, I get the original image again (plus a few artifacts):

![Original image in lower quality](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ijrlpe9vww27bpy11g3n.png)

Now you can feel like a real secret agent by hiding images and sending hidden messages to other secret agents!

---

*I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a* ❤️ *or a* 🦄*! I write tech articles in my free time and like to drink coffee every once in a while.*

*If you want to support my efforts,* [*buy me a coffee* ☕](http://buymeacoffee.com/pthormeier) *or* [*follow me on Twitter* 🐦](https://twitter.com/pthormeier)*!* *You can also support me directly via [Paypal](https://www.paypal.me/pthormeier)!*

[![Buy me a coffee button](https://dev-to-uploads.s3.amazonaws.com/i/x55hp6jopwyy161d8e2u.png)](http://buymeacoffee.com/pthormeier)