Post 3 of 6 on building Anyhide, a Rust steganography tool. This post is about threat models where the adversary isn't a server or a middleman — it's a person with leverage.
Most cryptographic tools are designed against adversaries who intercept things. They sit on the wire and try to decrypt what they see. They mine keys out of RAM. They exploit implementation bugs.
But there's another adversary that most crypto doesn't help you with: the one who has you in a room and wants you to type the passphrase. This is called "rubber-hose cryptanalysis" in the literature, or sometimes "the $5 wrench attack" after an old xkcd. Neither phrase really captures it. The point is simple: if someone can compel you to unlock the ciphertext, the math doesn't save you.
What can save you — partially, imperfectly, but usefully — is plausible deniability. The idea: design the tool so that the passphrase you give under coercion reveals something, but not the real thing. And make sure the revealed thing is indistinguishable from what you'd get with the real passphrase.
In Anyhide this is called the duress password. This post is about how it works and how I implemented it.
The design goal
When you encode a message, you can optionally supply a second message and a second passphrase:
anyhide encode \
-c carrier.mp4 \
-m "the drop is at 0400 on wednesday" \
-p "real-passphrase" \
--decoy "nothing important here" \
--decoy-pass "decoy-passphrase" \
--their-key bob.pub
The encoder produces one ciphertext that contains both messages. When Bob decodes:
- With
real-passphrase→ "the drop is at 0400 on wednesday" - With
decoy-passphrase→ "nothing important here" - With any other passphrase → random-looking bytes that decode cleanly but say nothing meaningful
The critical security property: an adversary who has the ciphertext can't tell which of the three cases a given output belongs to. The decoy is not marked as "decoy" anywhere. The real message is not marked as "real". They're both just plaintexts that fell out of a decoder which always returns something.
The never-fail decoder
This is the philosophy that makes duress passwords actually work. Most cryptographic libraries throw an InvalidTag or AuthenticationError when you give them a bad passphrase. This is great for debugging and terrible for deniability, because the error is itself a signal.
An adversary watching your screen sees "DECRYPTION FAILED" and now knows the passphrase you gave was wrong. They twist harder.
Anyhide's decoder has an explicit invariant: it never returns an error for any input. Any passphrase, any code, any carrier — the decoder produces bytes. If those bytes are a valid message, you see the message. If they aren't, you see what looks like random garbage, but is actually deterministic output of the same shape as a real message.
This means the attack surface for "figure out which passphrase is the real one" is reduced to: does this output look like natural language? And even that gets fuzzy when the real messages are short, encrypted, or structured.
The configuration
The duress feature is wired in through a tiny optional field on EncoderConfig:
// src/encoder.rs
/// Configuration for a decoy message (duress password feature).
#[derive(Debug, Clone)]
pub struct DecoyConfig<'a> {
/// The decoy message to encode.
pub message: &'a str,
/// The passphrase for the decoy message.
pub passphrase: &'a str,
}
/// Configuration for the encoder.
#[derive(Debug, Clone)]
pub struct EncoderConfig<'a> {
// ... other fields ...
/// Optional decoy message configuration (duress password).
/// If provided, a second message is encoded with a different passphrase.
/// Using the decoy passphrase reveals the decoy message instead of the real one.
pub decoy: Option<DecoyConfig<'a>>,
}
The whole feature is opt-in. If you don't set decoy, encoding behaves exactly as before. Backwards-compatible by construction.
Under the hood
The encoding runs twice, once with each passphrase. The real message and decoy message are each converted into their own position sequences into the carrier. Both sequences are packed into the same output structure, and the decoder uses the passphrase you provide to select which sequence to extract.
The elegant part — the one that took me the longest to get right — is that an observer looking at the ciphertext cannot tell whether the decoy is present. There's no "decoy flag" or "has_decoy" field. The output size depends on message length, not on whether a decoy was used.
The bug I want you to learn from
Version 0.9.0 had a subtle flaw that I only spotted after shipping. Here's the scenario:
Anyhide supports signing messages with Ed25519. If Alice always signs her messages, Bob knows to trust only signed messages from her. Unsigned messages from her are probably forgeries or garbage.
Now: in v0.9.0, the real message was signed, but the decoy message was not.
Why? Because the decoy was meant to look like a normal message, and signing it felt like extra work. But consider what this hands an attacker:
- Attacker seizes the laptop and the ciphertext.
- Attacker forces Alice to reveal a passphrase.
- Alice reveals the decoy passphrase. Ciphertext decodes to "nothing important here". No signature on that output.
- Attacker knows Alice always signs her real messages.
- Attacker: "The message you showed me wasn't signed. There must be another passphrase."
v0.9.1 fixed this by signing both the real and decoy messages with the same key. Now the signature attaches to whichever message the decoder produces, and an attacker sees a valid signature on whatever comes out — real or decoy — so the signature can't be used to distinguish them.
The lesson: when you're building a deniability feature, every observable property of the output must be identical across the real and decoy code paths. Not "mostly identical". Not "identical unless you squint". Identical. Anything else is a channel the adversary can use to distinguish real from decoy.
I wrote this up in the CHANGELOG at the time because I thought it was a nice case study:
v0.9.1 — Fix: sign decoy message with same key as real message. Previously only the real message was signed, decoy had no signature. An attacker who knows the sender always signs could distinguish real from decoy. Now both are signed with the same key.
If you're building security software, keep a public log of these. It's good for you (forces you to reason explicitly about what you broke), and it's good for your users (teaches them what the threat model looks like under stress).
The carrier helps too
There's a second layer that compounds with duress passwords, which I mentioned in Post 2: multi-carrier encoding. If you use three carrier files, the attacker now needs the right files, the right order, and the right passphrase. Any one of those being wrong produces garbage. Multiple of them being wrong still produces garbage. The adversary has no way to tell which axis they're off on.
The combined space looks like this:
- Wrong passphrase + right order → garbage
- Right decoy passphrase + right order → decoy message
- Right real passphrase + wrong order → garbage
- Right real passphrase + right order → real message
- Wrong passphrase + wrong order → garbage
Five cases. Four of them produce garbage that looks alike. One produces the decoy. One produces the real thing. The attacker's job is to find the one that gives them the real thing, and nothing in the output helps them triangulate.
Does this actually work?
In a literal cryptanalysis sense: this does not replace strong encryption. The math is the math. What plausible deniability adds is a human-layer defense: a story you can tell that is internally consistent with the ciphertext you're holding.
Under coercion, you want a story that:
- Is plausible (you can tell it without looking nervous).
- Is consistent with the cryptographic artifacts the adversary has.
- Leaves the adversary unable to prove you're lying without more evidence.
Duress passwords give you (2). You have to supply (1) yourself — the decoy has to be the kind of thing you'd actually say under normal circumstances. "Nothing important here" is weak. "Bring eggs on the way home, love you" is better. The contents matter as much as the crypto.
What I'd do differently
If I were starting over, I'd probably support multiple decoys instead of just one, with a shared-secret mechanism for choosing which one to reveal under which circumstances. This is more complex and I'm not sure it's worth the cognitive load. Single decoy covers most scenarios I can construct.
The other thing I'd consider: an explicit "panic mode" where one of the passphrases not only reveals a decoy but also zeroizes the real message from the code entirely. Right now both messages are present in the ciphertext forever; if the adversary finds the real passphrase later, they get the real message. Panic mode would destroy the real-message data on first decoy-passphrase use. I haven't implemented it because the UX is tricky — you don't want to accidentally wipe your real messages — but it's on the list.
Next up: Post 4 — Forward Secrecy and the Double Ratchet. How Anyhide rotates keys per message so that even if your long-term keys are later compromised, past messages stay unreadable. And why I ended up supporting three different storage formats for ephemeral keys.
Repo: github.com/matutetandil/anyhide. Issues and discussion welcome.
Top comments (0)