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.
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.
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).
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 Scream — 321 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.
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.
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…):
$ 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)
# 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 -
| 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.
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.
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
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.
Cargo.toml.serde_derive, thiserror, clap, or any proc-macro — compile-time arbitrary code is the exact attack surface we're shrinking.#![deny(unsafe_code)] workspace-wide, with documented per-module opt-outs only for SIMD hot paths.--no-default-features --features png.
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.