This is the first post of a 6-part series where I unpack the design of Anyhide — a steganography tool I built in Rust. Each post tackles one feature, one decision, and the code behind it.
Classical steganography is simple: you take an image, flip the least-significant bits of some pixels, and smuggle your message inside. The image now carries a payload. You send the image. The recipient extracts the payload.
It works. It's also, in 2026, kind of a disaster.
LSB steganography is detectable by statistical analysis. Any carrier you modify is evidence. Any file you transmit is a file someone can analyze. If your threat model includes an adversary who can examine the files you send, then hiding data inside those files is exactly the wrong move.
So I spent the last few months building a steganography tool that works the other way around. The carrier is never modified. The carrier is never transmitted. What goes over the wire is a short encrypted code — a set of positions into a file both parties already have.
It's called Anyhide. It's written in Rust. The repo is here. This post is a walkthrough of the core idea.
The inversion
The mental model of classical steganography is "hide the payload in the carrier." The mental model of Anyhide is "hide the map to the payload in a shared reference."
SENDER RECEIVER
carrier.mp4 ──┐ ┌── carrier.mp4
│ │ (same file, unchanged)
secret.zip ───┼──► ANYHIDE CODE ─────┼──► secret.zip
│ (only this │
passphrase ───┘ is sent) └── passphrase
Both parties hold the same file — any file. A video, a PDF, an MP3, a PNG, a Linux kernel tarball. It doesn't matter. The file is never touched and never transmitted.
The sender encrypts their message and encodes it as a sequence of byte positions that reconstruct the ciphertext when read out of the shared carrier. Those positions, compressed and encrypted again, become the "Anyhide code" — a base64 string that fits in a tweet.
That string is all that travels.
Two things this gets you for free
Plausible deniability. The string on the wire looks like any other base64 blob. An adversary can't even tell there's a payload in the carrier, because the carrier isn't involved. There's nothing to analyze.
No forensic artifacts on the carrier. Traditional stego tools leave telltale statistical anomalies in the carrier — histograms that don't match natural images, file sizes that are off by exactly the right number of bits. Anyhide touches nothing. If law enforcement seizes your laptop and finds the MP4, they find an ordinary MP4.
The demo
Here's what the actual flow looks like on the command line.
# Both parties generate keypairs once
alice$ anyhide keygen -o alice
bob$ anyhide keygen -o bob
# They exchange public keys somehow — this is standard PKI
# Alice has bob.pub, Bob has alice.pub
# Alice hides a message using a shared carrier both of them have
alice$ anyhide encode \
-c shared_video.mp4 \
-m "meet at the usual place, 8pm" \
-p "correcthorse" \
--their-key bob.pub
# Output:
# AwNhYmMxMjM0NTY3ODkwYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXox... (~200 chars)
# Alice sends that string to Bob. Any channel. SMS, email, a napkin.
# Bob decodes using the same carrier + his private key + passphrase
bob$ anyhide decode \
--code "AwNhYmMxMjM0..." \
-c shared_video.mp4 \
-p "correcthorse" \
--my-key bob.key
# Output: meet at the usual place, 8pm
The shared_video.mp4 never moved. The passphrase never moved. Only that one base64 string moved.
If an adversary intercepts the string and doesn't have the carrier, they have nothing. If they have the carrier but not the passphrase, they have nothing. If they have everything but the wrong passphrase, they get deterministic garbage — not an error message. (More on that in Post 3.)
The Rust that makes it possible
At the heart of Anyhide is a small, clean abstraction: the Carrier enum. It's the kind of thing Rust lets you write that would be awkward in most languages.
// src/text/carrier.rs
pub enum Carrier {
/// Text carrier - uses substring matching (case-insensitive)
Text(CarrierSearch),
/// Binary carrier - uses byte-sequence matching
Binary(BinaryCarrierSearch),
}
impl Carrier {
pub fn from_text(text: &str) -> Self {
Carrier::Text(CarrierSearch::new(text))
}
pub fn from_bytes(data: Vec<u8>) -> Self {
Carrier::Binary(BinaryCarrierSearch::new(data))
}
/// Detects carrier type from file extension and loads appropriately.
pub fn from_file(path: &std::path::Path) -> std::io::Result<Self> {
let extension = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match extension.as_str() {
"txt" | "md" | "text" | "csv" | "json" | "xml" | "html" | "htm" => {
let content = std::fs::read_to_string(path)?;
Ok(Carrier::from_text(&content))
}
_ => {
let data = std::fs::read(path)?;
Ok(Carrier::from_bytes(data))
}
}
}
}
Two variants, one trait-like surface. Text carriers use substring matching (so you can hide a message by pointing at character positions in Shakespeare). Binary carriers use byte-sequence matching (so you can hide a message inside a raw MP4). The encoder and decoder don't care which one they got — they pattern-match on the enum and dispatch.
This is where Rust earns its keep. In a dynamic language I'd be doing runtime type checks and praying. Here the compiler forces every code path to handle both variants, and I can extend the enum later (a third Chunked variant for very large files is on my list) without touching any calling code.
What's under the hood
Internally, encoding a message goes through this pipeline:
message → compress (DEFLATE)
→ sign (Ed25519, optional)
→ symmetric encrypt (ChaCha20-Poly1305, key from HKDF(passphrase))
→ asymmetric encrypt (X25519 ECDH + ChaCha20-Poly1305)
→ find byte positions in carrier
→ distribute positions via passphrase-seeded PRNG
→ base64 encode
→ ANYHIDE CODE
Every stage is there for a reason. The symmetric layer protects the positions even if you leak the long-term keys (the passphrase is a second factor). The asymmetric layer makes it end-to-end so only the intended recipient can decode. The position distribution means the same message encoded twice with the same keys and passphrase still produces different outputs.
The whole thing is ~16,300 lines of Rust. There are 319 tests and they all pass. I know because I just ran them:
$ cargo test
test result: ok. 260 passed; 0 failed (unit)
test result: ok. 15 passed; 0 failed (chat)
test result: ok. 43 passed; 0 failed (integration)
test result: ok. 1 passed; 0 failed (doctest)
What's coming in this series
This is Post 1 of 6. The roadmap:
- This post — What Anyhide is and why the carrier-is-never-sent model matters.
- Multi-carrier encoding — Using multiple carrier files together, where the order is itself a secret. N carriers → N! additional combinations for an adversary to brute-force.
- Plausible deniability with duress passwords — How to encode two messages under two different passphrases. Under coercion, reveal the decoy. The real message stays hidden and is cryptographically indistinguishable from a wrong guess.
- Forward secrecy and the Double Ratchet — Per-message key rotation, ephemeral keypairs, and the three storage formats I ended up supporting because chat has different needs than file transfer.
-
P2P chat over Tor — Building a chat client on top of
arti-client(Rust's native Tor implementation), with hidden services as the transport and the Double Ratchet doing end-to-end encryption. - A multi-contact TUI with ratatui — The terminal UI: sidebar, tabs, a Doom-style command console, request/accept for incoming connections, and the UX work that goes into making cryptography usable.
Each post stands alone, and each one is grounded in the actual code — no hand-waving. If you want to skim the repo first, it lives at github.com/matutetandil/anyhide.
Post 2 drops in two weeks.
If you build privacy or security tooling in Rust, I'd love to hear what you're working on. If you think the "carrier is never sent" model is broken somewhere, I'd love to hear that too — drop a comment or open an issue on the repo.
Top comments (0)