Post 2 of 6 in the series on building Anyhide, a Rust steganography tool. This post is about a small feature that multiplies your adversary's work by a factorial.
I like features that give you an outsized security improvement for very little code. Multi-carrier encoding is one of those. The implementation is maybe twelve lines. The effect is that the set of carriers you use becomes an ordered secret — and getting that order wrong produces deterministic garbage, which an attacker can't distinguish from using the wrong files entirely.
Let me explain.
The single-carrier recap
In a normal Anyhide flow, both parties share one file. They use it as a reference, and the sender encodes a message by computing byte positions into that file. Single file, single shared secret.
But "the carrier is the file we both have" is a constraint I wanted to relax. What if the carrier is a set of files? What if the set is ordered, and the order itself is a secret nobody but the two parties knows?
That's multi-carrier encoding.
The API
From the command line, you just pass -c multiple times:
# Sender
anyhide encode \
-c song.mp3 \
-c photo.jpg \
-c document.pdf \
-m "meeting moved to 9pm" \
-p "passphrase" \
--their-key bob.pub
# Receiver needs the SAME files in the SAME order
anyhide decode \
--code "..." \
-c song.mp3 \
-c photo.jpg \
-c document.pdf \
-p "passphrase" \
--my-key bob.key
If Bob swaps photo.jpg and document.pdf, he does not get an error. He gets garbage — random-looking bytes that happen to decode cleanly from the wrong carrier. He won't know whether the message was the wrong passphrase, the wrong key, the wrong files, or the wrong order of the right files.
The math
The number of ways to order N distinct files is N! (N factorial). That means if an adversary has all four of your carrier files and the passphrase and the private key, they still have to try up to 24 orderings to recover a 4-carrier message.
| N carriers | Orderings to try |
|---|---|
| 2 | 2 |
| 3 | 6 |
| 4 | 24 |
| 5 | 120 |
| 7 | 5,040 |
| 10 | 3,628,800 |
This isn't cryptographic security — factorial growth is polynomial next to exponential keyspaces. But it's not meant to be. It's an additional layer of ambiguity, and more importantly, it's ambiguity that an attacker can't distinguish from other failures. They can't test "is this the wrong order?" without a ciphertext oracle, which they don't have.
The implementation
Here's the actual code. It lives in src/text/carrier.rs and is 17 lines:
/// Creates a carrier from multiple files concatenated in order.
///
/// *Order matters!* Different order = different carrier = different decoding result.
/// This provides N! additional security combinations for N carriers.
///
/// - Single file: Delegates to `from_file()` (preserves text vs binary detection)
/// - Multiple files: All read as bytes and concatenated (always binary carrier)
pub fn from_files(paths: &[std::path::PathBuf]) -> std::io::Result<Self> {
if paths.is_empty() {
return Ok(Carrier::from_bytes(vec![]));
}
if paths.len() == 1 {
return Self::from_file(&paths[0]);
}
// Multiple files: read all as bytes and concatenate in order
let mut combined = Vec::new();
for path in paths {
let data = std::fs::read(path)?;
combined.extend(data);
}
Ok(Carrier::from_bytes(combined))
}
That's it. Read each file as bytes, concatenate them in the provided order, hand the resulting buffer to the binary carrier machinery, and move on. The rest of the encoder and decoder doesn't even know it's dealing with multiple files — to them, it's just a longer buffer.
Why the single-file branch matters
Notice the early return at paths.len() == 1. Without it, a single-carrier call would lose the text/binary autodetection that from_file gives you. A .txt with one carrier would be treated as binary, and suddenly substring matching becomes byte matching. The search still works, but you lose the case-insensitive lookup that text carriers give you for free.
Keeping the single-file case routed through from_file means the multi-carrier feature is a pure extension — existing callers and existing encoded codes are untouched. This is the kind of thing I care about when adding features to a tool people might already depend on.
Why ordered concatenation instead of something cleverer
I considered a few alternatives while designing this:
- Hashing the carrier set and using the hash as a seed. Too indirect. The whole appeal of Anyhide is that the carrier is the file, not a digest of it. Hashing would also make the "why wrong order gives garbage" property harder to reason about.
- Interleaving bytes from each file in a passphrase-derived pattern. More complex, marginally better, and it made the error surface much bigger. Every bug would be a silent decoder failure, which is exactly what Anyhide is designed to never surface.
- Each carrier gets its own search, and fragments are distributed across carriers. Elegant on paper, but it changes the security model: now partial knowledge of one carrier compromises the whole message instead of just its fraction. Worse.
Concatenation in caller-provided order is the simplest model that works and the easiest to reason about. Boring. Correct.
Plausible deniability across the factorial
Here's where this gets fun. Imagine you encode a message with [A, B, C]. Six orderings are possible:
[A, B, C] → "meeting at 9pm" (real)
[A, C, B] → garbage
[B, A, C] → garbage
[B, C, A] → garbage
[C, A, B] → garbage
[C, B, A] → garbage
All five "wrong" orderings produce outputs indistinguishable from random. An attacker who recovers your files, your key, and your passphrase — but not the ordering — sees six plausible-looking plaintexts. None of them says "ERROR". One says "meeting at 9pm". The other five say things like \x8f\x3aq#w....
Now pair this with the duress-password feature from Post 3 and the adversary's problem gets harder still: they can't tell whether a given output is "the real message in the right order", "the decoy message in the right order", or "the wrong ordering producing noise".
A note on testing
The integration test that verifies this is one line of logic:
#[test]
fn test_multi_carrier_wrong_order_produces_garbage() {
let msg = "top secret";
let carriers = vec!["a.txt", "b.txt", "c.txt"];
let code = encode_with(carriers.clone(), msg);
// Wrong order
let wrong = vec!["b.txt", "a.txt", "c.txt"];
let decoded = decode_with(wrong, &code);
// Does not return Err(); returns Ok with garbage
assert_ne!(decoded, msg);
// And it's "garbage" in the sense that re-encoding it
// wouldn't round-trip either
}
The interesting assertion isn't assert_ne!. It's that the function returns Ok with bytes, not an error. That's the "never-fail decoder" invariant Anyhide is built around, and multi-carrier inherits it for free because it's just a longer buffer going through the same pipeline.
What you get for 17 lines of Rust
To recap:
- N! additional orderings an adversary must exhaustively search through.
- Zero impact on single-carrier code paths (backwards compatible by design).
- Order is a new secret, composable with the passphrase, key, and (see Post 3) duress password.
- Wrong order produces garbage indistinguishable from any other failure mode.
The lesson for me, writing this: the best additions to a security tool are the ones you can explain in two sentences and implement without touching the core. If your new feature reshapes the pipeline, you've probably made a mistake.
Next up in the series: Post 3 — Plausible Deniability and Duress Passwords. How to encode two messages under two different passphrases, so that under coercion you can reveal a decoy that's cryptographically indistinguishable from the real one. Dropping in two weeks.
Repo: github.com/matutetandil/anyhide. If you spot a way multi-carrier breaks the security properties I claimed above, open an issue — I want to know.
Top comments (3)
Did you have any other apps than here
you can check my github
Did you have zangi app or telegram