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, install, 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 the full JPG of Munch's The Scream321 KB of arbitrary binary data — 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, 1.96 MB
1.96 MB
cover munch_starry.png
Starry Night — PNG with Scream embedded via rsteg, 2.07 MB
2.07 MB +105 KB
stego munch_starry_stego.png · download

The stego PNG carries a 321 KB hidden JPG payload inside a 1.96 MB cover and ends up only +105 KB larger on disk — the extra bytes are PNG recompression overhead on the noisier bottom bits, not the payload itself.

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)

Install

# Build the CLI with default features (png + bmp + wav + crypto + compat-steghide).
cargo build --workspace --release

# Embed a payload into a BMP cover with a password-derived AEAD key.
cargo run -p rsteg-cli --release -- embed \
  --in cover.bmp --payload secret.txt --out stego.bmp --password -

# Extract it again.
cargo run -p rsteg-cli --release -- extract \
  --in stego.bmp --out recovered.txt --password -

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

Clone the repo and use the CLI — that's the real supported path and the same code this page calls the canonical Rust implementation:

git clone https://github.com/OpenHackersClub/rsteg
cd rsteg
cargo run -p rsteg-cli --release -- embed \
    --in  cover.bmp --payload secret.txt --out stego.bmp \
    --password -
cargo run -p rsteg-cli --release -- extract \
    --in  stego.bmp --out recovered.txt --password -

An in-browser embed / extract widget is coming via rsteg-wasm (see spec 10) — no server required, all crypto client-side. Until then, the CLI is the canonical path.

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 lives at public/index.html and deploys to Cloudflare Pages. Intro / LSB basics / bench headline sections are spliced in from source READMEs by rsteg-site-build.