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.
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 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.
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)
Median of 11 iterations after 3 warmups, on macOS aarch64. Full methodology and caveats in bench/README.md.
| Op / carrier / payload | rsteg | steghide | stegano | speedup (vs next best) |
|---|---|---|---|---|
| BMP 128×128 embed 1 KB | 1.6 ms | 20.6 ms | — | 13× |
| BMP 128×128 extract 1 KB | 1.5 ms | 6.5 ms | — | 4.4× |
| BMP 512×512 embed 1 KB | 3.8 ms | 41.2 ms | — | 11× |
| BMP 512×512 embed 10 KB | 3.5 ms | 440.1 ms | — | 125× |
| BMP 512×512 extract 10 KB | 2.7 ms | 28.1 ms | — | 10× |
| WAV 5 s stereo embed 1 KB | 2.4 ms | crash¹ | 69 ms | 29× (vs stegano) |
| WAV 5 s stereo embed 20 KB | 3.5 ms | crash¹ | 71 ms | 20× (vs stegano) |
| WAV 5 s stereo extract 20 KB | 2.9 ms | crash¹ | 68 ms | 23× (vs stegano) |
| PNG 256×256 embed 1 KB | 6.1 ms | — | 67 ms | 11× |
| PNG 256×256 extract 10 KB | 2.6 ms | — | 66 ms | 25× |
¹ 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×.
| 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.
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.
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 is the static deploy of crates/rsteg-web/src/index.html
with interactive panels stripped — the live demo ships as a local axum server.