rsteg

A minimal-dependency steganography toolkit in Rust — library, CLI, and (soon) full format parity with steghide and stegano-rs. Jump to the live stego demo, benchmarks, feature tracker, or read the algorithm explainers.

What is steganography?

Cryptography hides what a message says. Steganography hides that there is a message at all. rsteg takes a carrier file (image or audio) and tweaks its least-significant bits so they spell out a hidden payload. A copy of the carrier that doesn't use rsteg is indistinguishable from a copy that does — unless you know the scheme.

How LSB embedding works

For each byte of the cover, we overwrite the density low bits with one bit of payload. At density 1, the top 7 bits of a pixel are untouched — a pixel value of 0xA3 = 10100011 becomes 0xA2 = 10100010 to carry a 0 bit. That's a 1/256 change, invisible to the eye. At density 4 you get four times the capacity and the change is ±7 (still perceptually tiny but statistically detectable).

See it in action — Munch hidden in Munch

The left is an ordinary PNG of Munch's Starry Night. The right is the same painting with 328 KB of arbitrary binary data — the full JPG of Munch's The Scream — woven into its least significant bits at density 1, sealed with XChaCha20-Poly1305. To the eye and to diff-style byte comparison of visible pixels, they are indistinguishable. Only the bottom bit of every colour channel has been rewritten.

Starry Night — original PNG cover, 2.0 MB
cover munch_starry.png · 2.0 MB
Starry Night — PNG with Scream embedded via rsteg, 2.2 MB
stego munch_starry_stego.png · 2.2 MB download
Reveal the payload that was extracted

Running rsteg extract --in munch_starry_stego.png --password - --out scream.jpg returns a JPG byte-for-byte identical to the one that was embedded (SHA-1 10570e48…):

Munch's The Scream — extracted from the stego PNG
$ rsteg embed \
    --in  munch_starry.png \
    --payload munch_scream.jpg \
    --out munch_starry_stego.png \
    --password -
embedded 328443B into 2058477B carrier → 2166395B stego (328533 bytes framed)

Benchmarks — rsteg vs steghide vs stegano-rs

Median of 11 iterations after 3 warmups, on macOS aarch64. Full methodology and caveats in bench/README.md.

Op / carrier / payloadrstegsteghidesteganospeedup (vs next best)
BMP 128×128 embed 1 KB1.6 ms20.6 ms13×
BMP 128×128 extract 1 KB1.5 ms6.5 ms4.4×
BMP 512×512 embed 1 KB3.8 ms41.2 ms11×
BMP 512×512 embed 10 KB3.5 ms440.1 ms125×
BMP 512×512 extract 10 KB2.7 ms28.1 ms10×
WAV 5 s stereo embed 1 KB2.4 mscrash¹69 ms29× (vs stegano)
WAV 5 s stereo embed 20 KB3.5 mscrash¹71 ms20× (vs stegano)
WAV 5 s stereo extract 20 KB2.9 mscrash¹68 ms23× (vs stegano)
PNG 256×256 embed 1 KB6.1 ms67 ms11×
PNG 256×256 extract 10 KB2.6 ms66 ms25×

¹ Native arm64 steghide 0.6.0 reliably SIGSEGVs when embedding payloads ≥ 1 KB into the synthetic wav-short cover. Raising ulimit -s to 64 MB did not help, so this is not a stack-size issue. Payloads ≤ 512 B embed successfully. rsteg is the only tool with complete wav-short coverage on this platform.

rsteg is faster than both reference tools on every case it shares with them. The gap versus steghide is 4–125× depending on carrier size and payload; against stegano-cli on PNG/WAV it's 10–25×.

Feature tracker & comparison

shipped
planned — phase 1
planned — phase 2
non-goals
yes in rsteg already (this PR) planned p1 next PR phase 2 after phase 1 ships yes supported partial no non-goal
Capability rsteg steghide 0.5.1 stegano-rs
Carrier formats
BMP 24-bit uncompressed yes yes no
BMP 32-bit (alpha-aware) planned p1 uniform-alpha skip no no
BMP 1/4/8-bit paletted non-goal partial (unsafe LSB on indices) no
WAV 16-bit PCM planned p1 yes yes
WAV 8-bit PCM planned p1 yes no
PNG (RGB / RGBA) planned p1 miniz_oxide no yes
JPEG phase 2 F5 yes graph-matching no
AU (Sun audio) non-goal yes no
Embedding schemes
Linear LSB (density 1) yes yes yes
Higher densities (2/3/4) yes opt-in ≥ 3 no no
Passphrase-seeded permuted positions planned p1 default when encrypted yes LCG no
JPEG F5 matrix encoding phase 2 no no
JPEG graph-matching phase 2 yes no
Compression before embed phase 3 yes zlib no
Confidentiality & integrity
Authenticated encryption (AEAD) planned p1 XChaCha20-Poly1305 no CRC32 only yes
Memory-hard KDF (Argon2id) planned p1 no MD5-based mhash yes
Rollback / tamper-resistant header planned p1 full header in AAD no partial
User experience
Capacity reporting yes library no fails on embed yes
Inspect / detect (no passphrase) planned p1 no no
JSON output for scripting planned p1 no partial
Stdin / stdout pipelining planned p1 limited yes
Multi-file payload in one carrier planned p1 no yes
Raw / headerless mode planned p1 --no-header no yes unveil-raw
Interoperability
Read steghide-produced files planned p1 BMP/WAV yes no
Write steghide-compatible files non-goal yes no
Engineering
Memory-safe implementation language yes Rust no C++ yes Rust
Minimal-dep supply chain yes 0 deps in core no system libs partial moderate
Actively maintained yes in development no last release 2003 yes
License MIT / Apache-2.0 GPL-2.0 MIT / Apache-2.0

Sources: steghide 0.5.1 source, stegano-rs README, rsteg spec set. Phase labels map to specs/09-roadmap.md: phase 1 is the next PR; phase 2 is after it merges.

Try it locally

The interactive embed / extract / inspect demo runs as a small axum server against the real rsteg-bmp crate — so the round-trip you see on localhost is the same code path the library ships. We don't host it on Cloudflare Pages because Pages is static-only and we'd rather you run the canonical Rust implementation than a JS reimplementation that could silently drift.

Clone the repo and run:

git clone https://github.com/OpenHackersClub/rsteg
cd rsteg
cargo run -p rsteg-web
# then open http://127.0.0.1:3456

You get a preset gallery (gradient / noise / stripes / checkerboard / solid / photo-like), an upload slot for your own 24-bit BMP, density selector (1–4), and a side-by-side cover/stego diff after embed. Extract and Inspect each take an uploaded BMP and return JSON.

How rsteg is built

Plugin architecture via Cargo features

rsteg-core has zero runtime deps. Each carrier format and each crypto scheme is a separate crate gated by a Cargo feature. If you only need PNG-LSB, cargo build --no-default-features --features png pulls exactly one transitive dep (miniz_oxide for DEFLATE).

rsteg-cli
  ├── rsteg-core                  (std only)
  ├── rsteg-png        feat: png  (miniz_oxide)
  ├── rsteg-bmp        feat: bmp  (zero deps)
  ├── rsteg-wav        feat: wav  (zero deps)
  ├── rsteg-jpeg       feat: jpeg (zune-jpeg, phase 2)
  └── rsteg-crypto     feat: crypto + compat-steghide

The 32-byte PayloadHeader

Every rsteg-written carrier starts its embedded bit-stream with a 32-byte frame:

magic "RSTG" │ version │ flags │ crypto_fourcc │ scheme_fourcc │ density │ body_len │ body_crc32 │ reserved
  4           │   1     │  1    │      4        │      4        │    1    │    4     │     4       │    8   = 32 bytes

When a payload is encrypted (XChaCha20-Poly1305 + Argon2id KDF — feature landing next), the full header is bound into the AEAD associated data. Tampering with any field — or a rollback to an older scheme — fails Poly1305 tag verification, not the plaintext CRC. The CRC is there only as an integrity hint for unencrypted payloads.

Supply-chain posture

Source: github.com/OpenHackersClub/rsteg. This page is the static deploy of crates/rsteg-web/src/index.html with interactive panels stripped — the live demo ships as a local axum server.