steghide in the browser. Hide a file or a message inside an image, a PCM WAV or an MP3 and reveal it again with a passphrase — entirely client side, with no upload and no server.
Live demo — 73hi.com/image-steg.html — the hosted build of this project, so you can try it before cloning anything.
ImageSteg is a TypeScript port of steghide 0.5.1 that keeps both of its embedding methods and adds two audio carriers:
| carrier | where the data lives | one payload byte needs |
|---|---|---|
| BMP, PNG (and anything the browser can decode) | least significant bits of pixel values, swapped by graph matching | ~8 pixels |
| JPEG (baseline, progressive, arithmetic coded) | parity of the non-zero quantised DCT coefficients | 3 coefficients |
| PCM WAV (8/16/24/32 bit int) | least significant bit of the samples, swapped the same way | 16 samples |
| MP3 | the unused tail of the audio frames | 1 spare frame byte |
Everything is byte compatible with the steghide command line tool on the BMP and JPEG
paths: the bit layout, the passphrase seeded permutation of the sample positions and the
Rijndael/CBC encryption all follow 0.5.1, so files produced by either side can be read by
the other.
- No upload. The cover image, the secret and the passphrase never leave the tab.
- Real steghide, not naive LSB. Samples are grouped into vertices and a minimum weight matching decides what to change, so only about one sample per hidden value is touched and a swap keeps the value histogram of the carrier intact.
- Real JPEG DCT steganography. Browsers expose no API for quantised DCT coefficients, so the project ships its own JPEG entropy codec (baseline, progressive and arithmetic coded input, Huffman output) instead of falling back to pixels.
Want to see it first? Open the hosted demo — 73hi.com/image-steg.html — no install needed. To run it yourself:
git clone <this repo>
cd imagesteg
npm install # only TypeScript, for the build step
npm run build # src/*.ts -> dist/*.js
npm start # http://localhost:8080Then open http://localhost:8080/. ES modules cannot be loaded from file://, so the page
has to be served — npm start runs a small zero dependency static server
(scripts/serve.mjs).
npm test builds and runs the regression gate (test/roundtrip.mjs), which round-trips
every carrier that works without a canvas under Node.
dist/index.js is a plain ES module with no bundler involved. In the browser:
import { embed, capacityBytes, decodeImageBlob, encodeBitmap } from './dist/index.js';
const cover = await decodeImageBlob(file); // File or Blob
const payload = new TextEncoder().encode('meet me at noon');
const result = await embed(cover, payload, {
passphrase: 'correct horse battery staple',
fileName: 'note.txt',
compression: 9, // zlib level, 0 = off
checksum: true, // CRC32
encrypt: true // AES-256 / Rijndael-CBC, libmcrypt compatible
});
const bytes = encodeBitmap(result.image); // 24-bit BMP…and to read it back:
import { extract, decodeImageBlob } from './dist/index.js';
const stego = await decodeImageBlob(file);
const data = await extract(stego, 'correct horse battery staple');
console.log(data.fileName, data.checksumOk, new TextDecoder().decode(data.data));The same calls work under Node for the JPEG, WAV and MP3 carriers — they are pure
computation. Only imageio.ts (pixel decode/encode) needs a browser canvas:
import { parseJpeg, writeJpeg, JpegCoverFile } from 'imagesteg';
const jpeg = parseJpeg(bytes); // Uint8Array
const cover = new JpegCoverFile(jpeg);
await embedIn(cover, payload, options);
fs.writeFileSync('stego.jpg', writeJpeg(jpeg));Extraction from an unknown container dispatches on the file itself:
import { extractAny, coverFromBlob, extractFrom } from 'imagesteg';
const data = await extractAny(blob, passphrase); // JPEG / BMP / PNG
const file = await coverFromBlob(blob); // any CoverFileThe audio carriers have their own entry points:
import { parseWav, encodeWav, embedAudio, extractAudio, audioCapacityBytes } from 'imagesteg';
import { parseMp3, embedMp3, extractMp3, mp3CapacityBytes } from 'imagesteg';onProgress?: (ratio: number, label: string) => void is accepted by every embed and
extract call, which is how the demo page drives its progress bar.
index.html the demo page (all element ids live here)
styles/ base.css (design tokens) + page.css + steg.css
src/
app.ts the page controller: DOM wiring only, no algorithm
index.ts the public API of the library (re-exports)
core.ts embed / extract on any cover, capacity estimates
embdata.ts steghide's EmbData header: compression, encryption, CRC32
bitstring.ts the bit oriented buffer the header is streamed through
crypto.ts MD5, CRC32, libmcrypt key derivation, Rijndael/CBC, zlib
graph.ts the sample graph and the RGB adjacency lists
matching.ts the minimum weight matching heuristic
selector.ts the passphrase seeded permutation of the sample positions
sampledomain.ts the two sample domains (RGB pixel, DCT coefficient)
imageio.ts 24-bit BMP I/O plus browser image conversion (needs canvas)
jpegdct.ts baseline / progressive / arithmetic JPEG entropy codec
jpegcover.ts the JPEG sample access layer
jpegarith.ts the QM (binary arithmetic) decoder
audioio.ts the WAV (RIFF) container
audiocover.ts the PCM sample domain and its carrier
mp3ancillary.ts post-encoding hiding in the unused tail of MP3 frames
test/roundtrip.mjs regression gate
scripts/serve.mjs zero dependency static server for the demo
src/ is the source of truth; dist/ is generated. Never edit dist/ by hand.
- The payload is compressed (zlib), encrypted (Rijndael-256 in CBC mode with libmcrypt's
key derivation) and wrapped in steghide's
EmbDataheader, optionally with a CRC32. - An MD5 seeded permutation (
selector.ts) spreads the resulting bits over the whole carrier, so the payload is not stored in a predictable order. - Samples are grouped into vertices and a minimum weight matching (
matching.ts) pairs them. For each pair the two sample values are swapped, which is what leaves the distribution of the carrier alone. Vertices that find no partner nudge one sample to the nearest value carrying the wanted embedded value. - The result is written back into the same container: pixels for BMP/PNG, re-encoded DCT coefficients for JPEG, PCM samples for WAV, frame padding for MP3.
steghide extract -sf image.bmp -p passphraseand-sf image.jpgread the BMP and JPEG outputs of this tool, and this tool reads what the CLI produces.- The CLI does not read PNG — the PNG download exists because it is much smaller, and it is only readable here.
- JPEG output is always Huffman coded (annex K tables), exactly like
jpeg_write_coefficients(), even when the source was arithmetic coded. - Any re-compression destroys the payload: re-saving a JPEG, resizing, filtering, a screenshot round-trip, converting a WAV to MP3 or letting a tag editor rewrite an MP3.
npm test
test/roundtrip.mjs builds a synthetic baseline JPEG, a raw pixel array, a 16 bit PCM WAV
and a chain of MPEG-1 Layer III frames and checks that:
- the JPEG entropy codec reproduces the coefficients it wrote,
- a payload survives
embed → write → parse → extracton all four carriers, - the recovered file name, CRC32 and encryption flag come back intact,
- a wrong passphrase is rejected instead of returning garbage,
- the number of touched samples stays inside the graph matching bound.
- Browser: any current browser with ES modules, canvas and WebCrypto. WebCrypto needs a
secure context —
https://orhttp://localhost. - Node (library and tests): 18 or newer, for
crypto.subtle,CompressionStreamandBlob.
GNU General Public License v2 or later — see LICENSE. This project is a port of steghide 0.5.1 by Stefan Hetzl and inherits its licence.
The demo page loads Font Awesome from a CDN for its icons. Nothing else is requested from the network, and no file, passphrase or payload is ever sent anywhere.