DEV Community

Cover image for N! Ways to Hide a Message: Multi-Carrier Encoding in Rust
Matías Denda
Matías Denda

Posted on

N! Ways to Hide a Message: Multi-Carrier Encoding in Rust

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
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
smith_luna_b9736d99368c89 profile image
Smith Luna

Did you have any other apps than here

Collapse
 
mdenda profile image
Matías Denda

you can check my github

Collapse
 
smith_luna_b9736d99368c89 profile image
Smith Luna

Did you have zangi app or telegram