Version: 1.2.0
Language: Rust
Developer: Adekunle Abdulmujeeb (@sudoer0x0)
Contributor: Kemisola
License: MIT
Repository: https://github.com/sudoer0x0/yhide
- About
- Project Overview
- Architecture
- Module-by-Module Reference
- 4.1
main.rs— Entry Point - 4.2
cli.rs— Command-Line Interface - 4.3
container.rs— Payload Container Format - 4.4
crypto.rs— Encryption Layer - 4.5
embed.rs— LSB Embedding Engine - 4.6
carrier/mod.rs— Format Detection, Reliability & Dispatch - 4.7
carrier/png.rs— Raster Image Carrier - 4.8
carrier/wav.rs— Audio Carrier - 4.9
carrier/jpeg.rs— JPEG Carrier (Unreliable) - 4.10
carrier/pdf.rs— PDF Carrier - 4.11
carrier/append.rs— MKV/MP4 Carrier (Unreliable) - 4.12
analyze.rs— Steganalysis - 4.13
validate.rs— Tiered Validation - 4.14
output.rs— Terminal & JSON Output
- 4.1
- Feature Reference (with examples)
- CLI Command Reference
- Security Model
- Wire Formats
- Data Flow Walkthroughs
- Supported Formats & Limitations
- Testing
- Build & Installation
- Roadmap
Yhide (Yung Hide) is a modern, secure, cross-platform steganography CLI written in Rust. It hides arbitrary files inside carrier files, images, audio, video, documents & so on, using encrypted, randomized bit embedding, with support for plausible deniability under coercion.
Yhide is part of the "Y-suite" of command-line tools, alongside Ymap (Yung Mapper, a network scanner) and Yhash (a hashing utility), all following the same design philosophy: memorable commands, zero-friction installation (single static binary, no runtime dependencies), and professional terminal output.
| Role | Name |
|---|---|
| Developer | Adekunle Abdulmujeeb (@sudoer0x0) |
| Contributor | Kemisola |
Steganography — hiding data inside other, innocuous-looking data — is a decades-old technique, but most publicly available tools have not evolved alongside modern cryptographic and security practice:
- Encryption is often optional, or uses outdated primitives.
- Embedding is naive sequential least-significant-bit (LSB) writing, which is trivially detected by basic statistical tools.
- Installation typically requires a Java runtime (OpenStego), a Python
environment with
venvheadaches, or manual dependency management. - Tools rarely tell you why something won't work until after you've already produced a broken output file.
Yhide addresses all four points directly (see Section 5).
- Format-honest — every carrier format uses the embedding technique that actually survives that format's constraints. Formats that can't reliably hold hidden data (e.g. MP3) are rejected outright with a clear explanation, rather than silently producing broken output.
- Secure by default — every payload is encrypted before it is ever embedded. There is no "insecure mode."
- Approachable, not overprotective — validation follows a tiered response system (see 4.10): operations are blocked only when they genuinely cannot succeed. Anything merely suboptimal proceeds, accompanied by a clear explanation.
- Deniable — a carrier can hold two independent payloads behind two different passwords, so a person under duress can reveal something plausible without exposing the real content.
- Any file, any carrier — the payload is treated as an opaque byte stream. A PDF can hide inside a PNG; a ZIP can hide inside a WAV file. There is no restriction on payload type.
- Zero-friction distribution — Yhide compiles to a single static
binary per platform. Running it requires nothing beyond the operating
system itself — no Rust, no Python, no interpreter, no
venv.
Yhide is organized as a set of small, single-responsibility modules that
compose together through the hide and extract command flows. The
diagram below shows how data moves through the system during a hide
operation:
┌─────────────────────┐
payload file ────────► │ container.rs │ wraps payload with
│ (filename, size, │ filename + checksum
│ SHA-256 checksum) │ metadata
└──────────┬───────────┘
│ serialized bytes
▼
┌─────────────────────┐
password ─────────────► │ crypto.rs │ Argon2id → AES-256-GCM
│ (encrypt + derive │
│ embed-position seed) │
└──────────┬───────────┘
│ encrypted blob + seed
▼
┌─────────────────────┐
carrier file ────────► │ carrier/mod.rs │ detects format
│ → png.rs / wav.rs │ (magic bytes),
└──────────┬───────────┘ dispatches
│
▼
┌─────────────────────┐
│ embed.rs │ ChaCha20-seeded
│ (randomized LSB │ permutation, writes
│ placement) │ bits into carrier
└──────────┬───────────┘
│
▼
output carrier file
extract runs this pipeline in reverse: carrier reads bits back out via
embed::extract, crypto::decrypt verifies the AES-GCM authentication tag
and recovers the plaintext, and container::parse_and_restore validates the
checksum and restores the original filename and bytes.
cli.rs is the orchestration layer that ties these modules together per
subcommand, and validate.rs / output.rs provide the cross-cutting
concerns (pre-flight checks, warnings, and formatted/JSON output) used
throughout.
yhide/
├── Cargo.toml, Cargo.lock
├── src/
│ ├── main.rs (17 lines) — entry point
│ ├── cli.rs (773 lines) — flag-based CLI, orchestration, overwrite handling
│ ├── argnorm.rs (130 lines) — single-dash/double-dash normalization
│ ├── banner.rs (102 lines) — ASCII-art logo generator
│ ├── container.rs (209 lines) — payload envelope format
│ ├── crypto.rs (161 lines) — AES-256-GCM + Argon2id
│ ├── embed.rs (186 lines) — randomized LSB engine
│ ├── analyze.rs (134 lines) — steganalysis (statistical + deterministic)
│ ├── validate.rs (111 lines) — tiered validation checks
│ ├── output.rs (116 lines) — terminal/JSON output
│ └── carrier/
│ ├── mod.rs (341 lines) — format detection, reliability, dispatch
│ ├── png.rs (175 lines) — PNG/BMP raster carrier
│ ├── wav.rs (196 lines) — WAV audio carrier
│ ├── jpeg.rs (131 lines) — JPEG carrier (Unreliable)
│ ├── pdf.rs (166 lines) — PDF carrier (lopdf-based)
│ └── append.rs (173 lines) — shared MKV/MP4 trailing-append carrier
├── install.sh, install.ps1 — cross-platform installers
├── .github/workflows/release.yml — CI cross-compilation matrix
Total: ~3,120 lines of Rust across 16 source files.
The entire binary entry point is 15 lines. It declares the module tree and
delegates everything to cli::run(), printing any returned error through
output::error and exiting with status code 1 on failure.
fn main() {
if let Err(e) = cli::run() {
output::error(&format!("{e}"));
std::process::exit(1);
}
}This keeps error handling uniform: every failure path in the codebase
returns anyhow::Result, and exactly one place converts an Err into a
user-facing message and a non-zero exit code (important for scripting —
yhide -hide ... || echo "failed" works correctly).
Built with clap (derive API), but deliberately flag-based rather than
subcommand-based: -hide, -extract, -info, and -analyze are boolean
flags on a single flat argument struct, not clap subcommands. This matches
the style of the rest of the Y-suite (Yhash, Ymap), where every action and
option is a flag rather than a positional word. Exactly one action flag
must be present per invocation, which run() checks manually rather than
delegating to clap's subcommand system — this keeps every shared flag
(-cover, -payload, -output, -password, ...) available uniformly
across all four actions instead of being redeclared once per subcommand.
Single-dash support for every long flag. Before clap ever sees the raw
argv, main.rs runs it through argnorm::normalize() (see
4.2.1), which rewrites any
multi-character single-dash argument (-help, -about, -compress,
-duress-password) into its double-dash equivalent (--help, --about,
...). This means -hide and --hide are always interchangeable, matching
how Yhash's own flags (-sha256, -chain, -check) are written with a
single dash throughout. Genuine one-letter short flags (-c, -p, -o,
-r, -d, -v, -h, -V) are left untouched by this normalization,
since they were never ambiguous in the first place.
Payload vs. password flags. -p / --payload is the file being
hidden; --password (alias --pass) is the encryption password. These are
deliberately separate letters/words specifically so that -h is free to
mean --help, as it does in virtually every other CLI tool — an earlier
revision used -h for the payload path, which collided with clap's default
help flag and required a workaround. Freeing -h back up for help removed
that workaround entirely.
Custom, example-rich help and about panels. Rather than relying on
clap's terse auto-generated --help output, cli.rs implements its own
print_help() and print_about() functions, each opening with a small
ASCII-art "YHIDE" banner (see 4.2.2).
print_help() groups flags into categories (Core, Security, Output, Other)
and ends with a set of copy-pasteable example invocations covering every
action and feature; print_about() renders a bordered info box with
version, license, supported carriers, install command, repository, and
credits. Both are also shown for a bare yhide invocation with no
arguments at all, and for -help/--help/-h explicitly — a first-time
user typing just the program name sees the full guide immediately rather
than an error.
Key functions:
| Function | Responsibility |
|---|---|
run() |
Normalizes argv, parses flags, dispatches to the matching action handler |
print_help() |
Renders the categorized flag reference + examples panel |
print_about() |
Renders the ASCII banner + bordered version/credits box |
run_hide() |
Orchestrates the full hide pipeline: validation → container → encryption → embedding, including the dual-payload deniability path |
run_extract() |
Tries full-range, then half-A, then half-B extraction (to support both normal and deniability-mode carriers transparently), then sanitizes the restored filename |
run_info() |
Calls carrier::inspect() and prints capacity/format details |
run_analyze() |
Calls analyze::analyze() and prints a confidence estimate |
get_password() |
Returns a password either from -password (with a Tier 2 shell-history warning) or via a masked interactive prompt (rpassword), with confirmation on hide, wrapped in zeroize::Zeroizing so it's wiped from memory when dropped |
sanitize_filename() |
Security-critical: strips any directory components from a filename recovered from a carrier before it's ever used to construct an output path — see 7.1 |
resolve_output_path() |
If -output points at a directory, restores the (sanitized) original filename inside it; otherwise treats -output as the exact output file path |
A small, pure, heavily-unit-tested module that rewrites argv before clap
parses it. The rule: any argument starting with exactly one dash and having
two or more characters after it is promoted to double-dash form (so
-help becomes --help); anything already double-dash, a genuine
one-character short flag, a bare -/--, or a plain value (path, password,
filename) is left completely untouched.
pub fn normalize(args: Vec<String>) -> Vec<String> {
let mut past_separator = false;
args.into_iter().enumerate().map(|(i, arg)| {
if i == 0 { return arg; }
if past_separator { return arg; } // after "--", nothing is a flag
if arg == "--" { past_separator = true; return arg; }
if arg.starts_with("--") { return arg; }
if let Some(rest) = arg.strip_prefix('-') {
if rest.is_empty() { return arg; }
if rest.chars().count() == 1 { return arg; } // real short flag
return format!("-{arg}"); // "-help" -> "--help"
}
arg
}).collect()
}One deliberate edge case worth calling out: everything after a literal
-- separator is left untouched no matter what it looks like, so a
filename like -weird.png passed after -- is never mistaken for a flag.
This was caught by the module's own test suite during development (a test
initially failed because the first version of this function didn't track
the separator), which is exactly the kind of subtle argument-parsing bug
that's cheap to catch with a unit test and expensive to catch in the field.
Known limitation: bundled single-character short flags (e.g. -rv
meaning -r -v) are not supported — any two-or-more-character single-dash
argument is always treated as a long-flag name. This trade-off keeps the
rule simple and unambiguous rather than trying to infer intent.
Generates the "YHIDE" banner shown by -about, -help, and bare yhide.
Rather than hand-typing one large pre-assembled block of art (easy to
misalign a single character and never notice), each letter is defined as
its own small [&str; 6] glyph, and the banner is built by joining the
matching row of each letter's glyph, left to right:
fn glyph(ch: char) -> [&'static str; 6] {
match ch {
'Y' => ["█ █", "█ █", " █ █ ", " ██ ", " █ ", " █ "],
'H' => ["█ █", "█ █", "█ █", "██████", "█ █", "█ █"],
// ...
}
}A unit test asserts every generated row has identical width, which would immediately catch a glyph typo — a small but genuine example of testing a purely cosmetic feature specifically because visual bugs in hand-built ASCII art are otherwise easy to ship unnoticed.
Both -hide and -extract check whether their final output path already
exists before writing anything, via resolve_overwrite():
fn resolve_overwrite(initial: &Path, force: bool, json: bool) -> Result<Option<PathBuf>> {
let mut path = initial.to_path_buf();
loop {
if force || !path.exists() {
return Ok(Some(path));
}
if json {
bail!("'{}' already exists. Use -force to overwrite, or choose a different -output path.", path.display());
}
// ...prompt: overwrite (o) / rename (r) / cancel (c)...
}
}Three distinct behaviors, deliberately kept separate rather than collapsed into one:
-forcegiven: proceed immediately, no matter what. This is the automation path — never touches stdin.-jsongiven, no-force, file exists: fail immediately with a specific, scriptable error. Scripted callers should never be blocked waiting on a stdin prompt that a human isn't there to answer.- Interactive, no
-force, file exists: prompt with three options — overwrite, rename (loops back to re-check the new path, so a second conflict is caught too), or cancel. Cancelling (or just pressing Enter) aborts the whole operation cleanly with no changes made and no error - the user consciously chose not to proceed, which isn't a failure.
For -hide, the check happens right after validating that the cover and
payload exist, before any password prompt or encryption work - no point
asking for a password if the user is about to cancel anyway. For
-extract, the check can only happen after decryption succeeds when
-output is a directory, since the actual filename isn't known until the
container has been decrypted and restored (see §9.2).
This module is what makes "hide any file inside any file" actually work end-to-end, rather than just at the bit-embedding layer. Before a payload is encrypted, it's wrapped in a small binary envelope recording its original filename, size, and a SHA-256 checksum.
Key struct:
pub struct Container {
pub filename: String,
pub original_size: u64,
pub checksum: [u8; 32],
pub compressed: bool,
pub body: Vec<u8>,
}Key functions:
Container::from_file(path, compress)— reads a payload file, computes its SHA-256 checksum, optionally compresses it with zstd. If compression doesn't actually make the data smaller (e.g. the payload is already a ZIP), the compressed flag is leftfalseand the original bytes are kept — no point paying decompression cost on extract for zero benefit.Container::to_bytes()— serializes the container to its wire format (see Section 8.1).Container::parse_and_restore(data)— parses a decrypted byte stream back into(filename, original_bytes), decompressing if needed and verifying the SHA-256 checksum before returning. A mismatch produces a clear error rather than silently returning corrupted data.
Why this design matters: the container's checksum is computed over the original, uncompressed payload, so integrity is verified independent of whether compression was used. And because the entire serialized container (not just the raw payload bytes) is what gets AES-256-GCM encrypted, an attacker cannot tamper with the claimed filename or size independently of the content — the same authentication tag covers all of it.
Implements the mandatory encryption every payload goes through.
Key derivation — Argon2id. Argon2id is a memory-hard password hashing algorithm, meaning it deliberately requires a significant amount of RAM to compute, which makes brute-forcing passwords via GPUs/ASICs (which have lots of compute but comparatively little fast memory) far more expensive than with a fast hash like SHA-256.
fn argon2_params() -> Params {
Params::new(19_456, 2, 1, Some(KEY_LEN)).expect("valid argon2 params")
}This uses ~19 MiB of memory and 2 iterations — tuned to be meaningfully slow without making the CLI feel broken on modest hardware.
Encryption — AES-256-GCM. An AEAD (Authenticated Encryption with Associated Data) cipher, meaning a single operation provides both:
- Confidentiality — the ciphertext reveals nothing about the plaintext.
- Integrity/authenticity — a 16-byte authentication tag detects any tampering with the ciphertext. Decryption fails cleanly if the tag doesn't verify, rather than returning corrupted plaintext.
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>>
pub fn decrypt(blob: &[u8], password: &str) -> Result<Vec<u8>>Each call to encrypt generates a fresh random 16-byte salt (for
Argon2id) and a fresh random 12-byte nonce (for AES-GCM), both
prepended to the returned blob. Reusing a nonce with the same key would
catastrophically break AES-GCM's security guarantees, so a new one is
always generated per encryption.
The embed-position seed — and a bug caught during design. Yhide also
needs a separate pseudo-random seed to decide where in the carrier to
place bits (see 4.5). The original
design derived this seed from password + the same per-file salt used for the AES key. That's circular: the salt lives inside the encrypted blob,
and the encrypted blob is exactly what the embed-seed is needed to locate
inside the carrier in the first place — you can't read the salt before
knowing where to look, and you can't know where to look without the salt.
The fix: derive_embed_seed(password) uses a fixed, non-secret,
tool-wide context salt (b"yhide-embed-seed"), domain-separated from the
real per-file AES salt:
const EMBED_SEED_SALT: &[u8; SALT_LEN] = b"yhide-embed-seed";
pub fn derive_embed_seed(password: &str) -> Result<[u8; 32]> {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
let mut seed = [0u8; 32];
argon2.hash_password_into(password.as_bytes(), EMBED_SEED_SALT, &mut seed)?;
Ok(seed)
}This is safe because the embed-seed's only job is to decide bit placement — it has no bearing on confidentiality, which is entirely handled by AES-256-GCM with its own properly-randomized per-file salt and nonce.
The shared bit-placement engine used by every carrier format. Rather than
each format (PNG, WAV, future JPEG/video) reimplementing "hide bits in the
least-significant bit of some bytes," this module implements it once,
generically, over any &mut [u8] of embeddable units.
Why randomized, not sequential? Naive steganography tools write hidden bits into the LSBs of pixels/samples starting from the first one and moving sequentially. This is exactly the pattern that basic steganalysis tools (e.g. StegExpose) look for — a payload occupying a contiguous run at the start of the data is a dead giveaway. Yhide instead uses a password-derived pseudo-random permutation to decide the order in which carrier units are touched, scattering the hidden bits across the entire carrier rather than clustering them.
How the permutation works:
fn derive_permutation(seed: &[u8; 32], total_units: usize) -> Vec<usize> {
let mut rng = ChaCha20Rng::from_seed(*seed);
let mut order: Vec<usize> = (0..total_units).collect();
order.shuffle(&mut rng);
order
}A full permutation of every unit index is generated deterministically from the seed using the ChaCha20 CSPRNG. Because it's fully deterministic, the extraction side regenerates the identical order from the same seed — no extra state needs to travel with the carrier file.
Avoiding the "bootstrap" problem. How do you know how many bits to read
during extraction, if that length is itself hidden data? Yhide's answer:
the first 64 positions in the permutation always carry a 64-bit
big-endian length header. The next payload_len * 8 positions carry the
actual payload bits. Both sides—embed and extract—compute the same
permutation and can therefore always find the header first, learn the
length, and then read exactly that many payload bits.
permutation: [ p0, p1, ..., p63 | p64, p65, ..., p64+len*8-1 | ...unused... ]
└── 64-bit header ──┘└──── payload bits ─────┘
Capacity calculation:
pub fn capacity_bytes(total_units: usize) -> usize {
total_units.saturating_sub(HEADER_BITS) / 8
}One embeddable unit (one byte, e.g. one color channel of one pixel, or the
low byte of one audio sample) stores exactly one bit. So the usable payload
capacity is (total_units - 64) / 8 bytes — 64 bits are permanently
reserved for the length header.
Example: a 200×200 pixel PNG has 200 × 200 × 4 (RGBA) = 160,000
embeddable units, giving a capacity of (160,000 - 64) / 8 = 19,992 bytes
— matching exactly what yhide -info reports for such an image.
This module is the single place that:
- Detects carrier format from magic bytes, never from file extension.
A
.pngfile that's actually a renamed.txtis caught here rather than causing a confusing failure several steps later.
if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" { return Ok(Format::Png); }
if data.len() >= 2 && &data[0..2] == b"BM" { return Ok(Format::Bmp); }
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WAVE" { return Ok(Format::Wav); }
if data.len() >= 3 && &data[0..3] == b"\xFF\xD8\xFF" { return Ok(Format::Jpeg); }
if data.len() >= 4 && &data[0..4] == b"%PDF" { return Ok(Format::Pdf); }
if data.len() >= 4 && &data[0..4] == b"\x1A\x45\xDF\xA3" { return Ok(Format::Mkv); } // EBML header (MKV/WebM)
if data.len() >= 8 && &data[4..8] == b"ftyp" { return Ok(Format::Mp4); }-
Recognizes but rejects formats not implemented at all (FLAC) with a specific, actionable message, and rejects fundamentally unsuitable formats (MP3 — lossy compression destroys any embedded bits) with an explanation of why, not just that it failed.
-
Classifies every supported format's reliability via
Format::reliability()andFormat::reliability_note()- see below. -
Dispatches
inspect,hide,extract,hide_dual, andextract_flexible_rawcalls to the correct per-format module (png.rs,wav.rs,jpeg.rs,pdf.rs, orappend.rs) based on the detected format.
Reliability classification. Not every carrier format can guarantee that hidden data survives normal use of the file the way PNG/BMP/WAV can. Rather than either (a) blocking those formats outright, or (b) silently supporting them and letting users find out the hard way, Yhide classifies each format explicitly:
pub enum Reliability { Reliable, Unreliable }
impl Format {
pub fn reliability(&self) -> Option<Reliability> {
match self {
Format::Png | Format::Bmp | Format::Wav | Format::Pdf => Some(Reliability::Reliable),
Format::Jpeg | Format::Mkv | Format::Mp4 => Some(Reliability::Unreliable),
Format::Unsupported(_) => None,
}
}
pub fn reliability_note(&self) -> Option<&'static str> { /* short, format-specific caveat */ }
}reliability_note() is what actually drives the warning shown at hide
time (see §4.2's run_hide) and in -info output - it's None for
PNG/BMP/WAV (nothing to caveat) and a short, specific explanation for
everything else, always paired with a recommendation to use a Reliable
carrier instead wherever it matters. This is the direct implementation of
the "if a format can't reliably hold a payload, show a short warning and
recommend a good one" requirement - Yhide never silently pretends every
format is equally trustworthy.
The deniability extraction problem, and how it's solved generically.
When extracting, Yhide doesn't know in advance whether a carrier used
normal single-payload embedding or deniability-mode splitting - and, since
v1.2.0, it also doesn't know in advance which kind of embedding scheme
the format even uses (bit-scattered vs. discrete-object vs.
appended-block). extract_flexible_raw() abstracts over all of this: each
carrier module returns a Vec<Result<Vec<u8>>> of every plausible raw-blob
candidate for its own embedding scheme (three attempts for PNG/BMP/WAV/JPEG
- full range, half A, half B; every tagged stream object found for PDF;
every marker occurrence found for MKV/MP4), and the caller in
cli.rstries each candidate against the provided password in turn, stopping at the first one that both decrypts and passes the container's checksum check. This is what makesyhide -extractwork identically regardless of carrier format or whether deniability mode was used - the complexity of how each format stores multiple payloads is fully contained within that format's own module.
The deniability extraction problem, and how it's solved. When
extracting, Yhide doesn't know in advance whether a carrier was hidden
using normal single-payload mode (full carrier range) or deniability mode
(split into two halves). extract_flexible_raw() returns all three
possible read attempts — full range, half A, half B — as a Vec<Result<...>>,
and the caller (in cli.rs) tries each one against the provided password
until one successfully decrypts and validates. This makes extraction
transparent to the user: the same yhide -extract command works regardless
of which mode was used to hide the file.
Handles both PNG and BMP (they share the same underlying pipeline via the
image crate, since both are lossless raster formats).
Pipeline:
image::open()decodes the file and.to_rgba8()converts it to a flat RGBA8 pixel buffer — every pixel becomes 4 consecutive bytes (Red, Green, Blue, Alpha).- Every single byte of that buffer is one embeddable unit. A 200×200 image has 200 × 200 × 4 = 160,000 embeddable units.
embed::embed()/embed::extract()operate directly on this raw byte buffer.- The modified buffer is re-encoded losslessly via
image::save_buffer_with_format(), using PNG or BMP encoding based on the output file's extension — losslessness is essential here, since any lossy re-compression would destroy the embedded LSBs.
Deniability mode (hide_raster_dual): splits the pixel buffer into two
disjoint halves by index parity — even-indexed bytes form "half A," odd-
indexed bytes form "half B." The real payload is embedded into half A using
a seed derived from the real password; the decoy is embedded into half B
using a seed derived from the duress password. Because the two halves never
overlap, both payloads can coexist in the same carrier without corrupting
each other.
Full pixel byte array: [b0, b1, b2, b3, b4, b5, b6, b7, ...]
Half A (even indices): b0, b2, b4, b6, ... ← real payload
Half B (odd indices) : b1, b3, b5, b7, ... ← decoy payload
Each half naturally has roughly half the total capacity, so deniability mode costs you half your usual capacity per payload — a direct and transparent trade-off communicated to the user via a Tier 2 warning when usage is high.
Supports 16-bit PCM WAV files via the hound crate.
Why only the low byte of each sample? A 16-bit PCM sample is stored as two bytes. If Yhide embedded bits into the LSB of both bytes, flipping the LSB of the high byte would change the sample's value by up to 128 — audible and potentially detectable. By restricting embedding to only the low byte's LSB, the maximum possible amplitude change per sample is ±1 — inaudible and statistically minimal.
let low_bytes: Vec<u8> = wav.samples.iter()
.map(|&s| (s as u16 & 0x00FF) as u8)
.collect();After embedding, samples are reconstructed by combining the original high byte with the new low byte:
let high = (*sample as u16) & 0xFF00;
*sample = (high | new_low as u16) as i16;Bit depth restriction: Yhide checks that the input WAV is specifically 16-bit integer PCM, and produces a clear, actionable error for any other format (8-bit, 24-bit, 32-bit float, etc.) rather than attempting to process it incorrectly:
if spec.sample_format != SampleFormat::Int || spec.bits_per_sample != 16 {
bail!("this WAV is {:?} {}-bit, but Yhide currently only supports \
16-bit PCM WAV files. Re-export as 16-bit PCM WAV to use it \
as a carrier.", spec.sample_format, spec.bits_per_sample);
}Deniability mode (hide_dual) works identically to the raster case, but
splits the low-byte array by sample index parity instead of pixel-byte
index parity.
Reuses exactly the same LSB engine as png.rs, operating on the decoded
pixel buffer - the only real difference is the output codec. JPEG decodes
to an RgbImage (3 channels, no alpha - JPEG has no alpha channel) instead
of RgbaImage, and the modified buffer is re-encoded via
image::codecs::jpeg::JpegEncoder::new_with_quality(file, 100) - quality
fixed at the maximum to minimize (never eliminate) the chance that JPEG's
own DCT quantization step flips one of the LSBs Yhide just wrote.
let mut encoder = JpegEncoder::new_with_quality(file, 100);
encoder.encode(img.as_raw(), w, h, ColorType::Rgb8)?;This is a real, working implementation, and it is genuinely unreliable in practice - not a hedge. During development, hiding even a small text payload in a 300×300 quality-100 JPEG and immediately extracting it failed with a checksum mismatch, exactly as the reliability warning predicts. That's the correct, safe outcome: the AES-GCM/checksum layer caught the corruption and reported it clearly rather than returning garbage. Yhide ships this deliberately anyway, because a user who explicitly wants to try JPEG despite the caveat should be able to, and because failure here is always safe, never silent.
Deniability mode (hide_dual/extract_half_a/extract_half_b) mirrors
png.rs exactly - same even/odd pixel-byte split, same half-capacity
trade-off.
PDF has no flat array of "embeddable bytes" the way raster/audio carriers
do - it's a structured object graph, not a sample buffer - so this module
doesn't use embed.rs at all. Instead, the entire encrypted blob becomes
the content of a new PDF stream object, added to the document via
lopdf but never referenced from any page:
fn make_payload_stream(blob: &[u8]) -> Stream {
let mut dict = Dictionary::new();
dict.set("Type", "YHide");
dict.set("Subtype", "YHidePayload");
Stream::new(dict, blob.to_vec()).with_compression(false)
}
pub fn hide(cover: &Path, output: &Path, blob: &[u8]) -> Result<()> {
let mut doc = Document::load(cover)?;
doc.add_object(make_payload_stream(blob));
doc.save(output)?;
Ok(())
}Because nothing in the page tree points at this object, PDF viewers never
render it - but it is syntactically part of the file, so it round-trips
through normal open/save cycles in most readers. .with_compression(false)
deliberately disables lopdf's optional Flate compression on this stream:
Yhide's own AES-256-GCM + checksum layer already guarantees integrity, so
there's no reason to also depend on a compression round-trip being
lossless.
Finding the payload again on extract doesn't require remembering an
object ID - extract_candidates() just iterates every object in the
document and collects the content of every stream tagged
/Subtype /YHidePayload:
for object in doc.objects.values() {
if let Object::Stream(stream) = object {
if stream.dict.get(b"Subtype").and_then(Object::as_name_str) == Ok("YHidePayload") {
found.push(Ok(stream.content.clone()));
}
}
}Deniability mode (hide_dual) simply adds two such tagged stream
objects instead of one - real and decoy - and extract_candidates()
naturally returns both for the caller to try decrypting in turn, exactly
matching the pattern used by every other carrier format.
Capacity has no fixed limit the way a pixel/sample count does -
inspect() just confirms the PDF parses correctly and reports "no fixed
capacity limit" rather than a byte count.
Shared by both MKV (and WebM, which uses the same EBML container format) and MP4, since both are handled with the exact same technique: most container-format parsers read strictly up to the last element/box they recognize and simply stop, ignoring anything after it. Yhide exploits that tolerance by appending the encrypted blob behind a small, distinctive marker:
const MARKER: &[u8; 7] = b"YHDEBIN";
// [7 bytes marker][8 bytes little-endian length][length bytes of blob]hide() reads the whole cover file, appends one such block, and writes
the result - no parsing of the container's actual internal structure is
needed or attempted, since the technique works identically regardless of
what's inside. extract_candidates() scans the file for every occurrence
of the marker (there can be more than one in deniability mode) and returns
each following blob as a candidate, stopping cleanly - not panicking - if
a length field would read past the end of the file (covered by a dedicated
test using deliberately malformed trailing data).
This is explicitly not part of either container format's real
structure. It survives being copied, moved, and played by many (not all)
players, but it will not survive re-encoding, re-muxing, or being
re-uploaded to any platform that transcodes the file - which is exactly
why both formats are classified Reliability::Unreliable.
Implements the -analyze action: a best-effort, honestly-scoped attempt to
determine whether a file already contains a hidden payload, without
needing a password. Two different techniques are used depending on how the
carrier's own format actually stores data - using one uniform technique
for all formats would be less accurate for at least one of them, so
analyze() dispatches per format instead:
LSB-scattered formats (PNG, BMP, WAV, JPEG) — statistical estimate. Encrypted data is, by design, statistically indistinguishable from random noise (that's what "encrypted" means at a statistical level). So if a carrier's LSBs have an embedded encrypted Yhide payload, those LSBs — at least across the embedded region — should sit very close to a 50/50 split of 0s and 1s. Untouched natural image or audio LSBs typically show a slight bias, since they're correlated with real signal content rather than pure noise.
let ones = sample.iter().filter(|&&b| b & 1 == 1).count();
let p = ones as f64 / sample.len() as f64;
let deviation = (p - 0.5).abs();
let confidence = ((1.0 - (deviation / 0.05).min(1.0)) * 100.0).clamp(0.0, 99.0);The deviation from perfect 0.5 balance is converted into a rough confidence
percentage. This is explicitly a heuristic, not a full implementation of
sample-pairs analysis or RS (Regular/Singular groups) analysis — real
steganalysis research uses more sophisticated statistical tests. Yhide
presents its result as an estimate, never a certainty, matching how any
honest steganalysis tool should communicate uncertainty. -deep runs the
same test over the entire carrier instead of a bounded 200,000-unit
subsample, trading speed for a tighter estimate on large files.
Discrete-storage formats (PDF, MKV, MP4) — deterministic presence
check. These formats don't scatter bits across a sample buffer at all -
Yhide either stored a recognizable tagged object (PDF) or marker-prefixed
block (MKV/MP4), or it didn't. Running a statistical LSB-bias test on
these would be meaningless (there's no bit-scattering to measure), so
analyze() instead calls the same extract_candidates() function each
carrier module already exposes for -extract, and simply checks whether
it found anything:
Format::Pdf => {
let found = !matches!(carrier::pdf::extract_candidates(path).as_slice(), [Err(_)]);
Ok(deterministic_result(found, &format))
}This reports 99% confidence if a signature was found, 0% if not — no statistical uncertainty involved, since presence here really is a yes/no fact rather than an estimate. The result is intentionally still capped at 99% rather than 100%, keeping the same "estimate, not proof" framing consistent across every format, even where the underlying check happens to be exact.
Implements the philosophy described in Section 2.2: Yhide never uses a binary allow/block model.
- Tier 1 — Proceed silently. No function needed; this is just the absence of a warning.
- Tier 2 — Proceed, with a warning. Functions like
near_capacity_warning()andinline_password_note()return anOption<String>—Nonemeans nothing worth saying,Some(msg)is printed after the operation completes successfully. - Tier 3 — Block, with a specific reason. Functions like
check_duress_args(),check_passwords_distinct(),check_payload_exists(), andcheck_carrier_exists()returnResult<()>, and a hard error aborts the operation before any work is done.
Example — near-capacity warning:
pub fn near_capacity_warning(used_bytes: usize, capacity_bytes: usize) -> Option<Warning> {
let ratio = used_bytes as f64 / capacity_bytes as f64;
if ratio > 0.8 {
Some(format!("using {:.0}% of this carrier's safe capacity ({} / {} bytes).",
ratio * 100.0, used_bytes, capacity_bytes))
} else {
None
}
}Below 80% capacity usage: silence (Tier 1). Above 80%: a Tier 2 warning that still lets the operation succeed. Only an outright capacity overflow (payload literally doesn't fit) is a Tier 3 block.
A thin, consistent layer over the console crate for colored terminal
output (✅ green success, #[derive(Serialize)] structs (HideResult, ExtractResult, InfoResult,
AnalyzeResult) that back the -json flag available on every action.
Keeping both output modes defined in one file prevents them from drifting
out of sync as features are added.
Capacity-related fields (capacity_bytes, capacity_used_percent on
HideResult; total_embeddable_units, max_safe_capacity_bytes on
InfoResult) are Option<usize>/Option<f64> rather than plain numbers,
since PDF/MKV/MP4 carriers don't have a fixed capacity - they serialize to
null in JSON output for those formats rather than a fabricated number
like 0 or -1, which would be misleading (0 capacity would look like an
error, not "unlimited"). InfoResult also carries reliability and
reliability_note fields so scripts can programmatically check a
carrier's reliability classification without parsing human-readable text.
Every -hide operation encrypts the payload with AES-256-GCM before
embedding — there is no flag to skip this.
yhide -hide -c photo.png -p secret.pdf -o hidden.png -password yourpasswordEven if an attacker extracts the raw bits from hidden.png without the
password, they get ciphertext, not the original PDF.
The payload can be any file type — Yhide doesn't inspect or restrict it.
yhide -hide -c cover.png -p notes.txt -o hidden.png -password pass
yhide -hide -c cover.png -p archive.zip -o hidden.png -password pass
yhide -hide -c cover.wav -p installer.exe -o hidden.wav -password passOn extraction, the original filename and extension are restored automatically:
yhide -extract -c hidden.png -o recovered/ -password pass
# → recovered/notes.txt, recovered/archive.zip, recovered/installer.exeyhide -hide -c cover.png -p large_log.txt -o hidden.png -password pass -compressUses zstd. If the payload doesn't actually shrink (e.g. it's already a ZIP or JPEG), Yhide silently keeps the uncompressed version instead — no wasted decompression step on extract for zero benefit.
Not user-facing as a flag — it's the default and only embedding mode. Two hides of the same payload into the same carrier with different passwords will scatter bits into completely different positions, since the permutation is derived from the password.
yhide -hide -c photo.png -p real_secret.pdf -o hidden.png \
-password realpassword123 -duress-password duresspassword456 -decoy fake_receipt.pdfyhide -extract -c hidden.png -o out/ -password realpassword123 # → real_secret.pdf
yhide -extract -c hidden.png -o out/ -password duresspassword456 # → fake_receipt.pdfBoth extractions look identical to run — there is no way to tell from the command or its output which "mode" was used, which is the point.
$ yhide -info -c track.mp3
Carrier: track.mp3
⚠️ MP3 is a lossy-compressed format. Yhide does not support hiding directly
in MP3 - embedded bits would be destroyed by the codec's own compression.
Convert to WAV or FLAC first if you want to hide data in this audio.$ yhide -hide -c small.png -p huge_file.bin -o out.png -password pass
❌ payload (5000108 bytes to embed, including encryption overhead) is too
large for 'small.png' (max safe capacity: 19992 bytes for this image).
Try a larger image, or enable -compress if the payload is compressible.$ yhide -hide -c cover.png -p big_payload.bin -o hidden.png -password pass
✅ payload hidden in 'cover.png' -> 'hidden.png'
⚠️ using 92% of this carrier's safe capacity (18400 / 19992 bytes).The operation still succeeds — the warning informs without blocking.
$ yhide -analyze -c suspicious.png -deep
Analyzing: suspicious.png
Format: PNG
Estimated confidence of a hidden payload: 87%
Consistent with: LSB embedding, randomized pixel-channel order
deep scan over all 480000 embeddable units (LSB balance: 49.94% ones).
Note: this is a statistical estimate, not a certainty.$ yhide -hide -c cover.png -p secret.pdf -o hidden.png -password pass -json
{
"status": "success",
"cover": "cover.png",
"output": "hidden.png",
"carrier_format": "PNG",
"payload_file": "secret.pdf",
"embedded_bytes": 4213,
"capacity_bytes": 19992,
"capacity_used_percent": 21.08,
"compressed": false,
"resilient": false,
"deniability_enabled": false,
"warnings": []
}The default, recommended flow omits -password entirely and lets Yhide
prompt for it — masked, with confirmation on -hide to catch typos before
anything is written to disk. The entered password is held in a
zeroize::Zeroizing<String> for its entire lifetime in the program, so it
is overwritten in memory the moment it goes out of scope rather than
lingering in a freed heap allocation:
$ yhide -hide -c cover.png -p secret.pdf -o hidden.png
Password: ****************
Confirm password: ****************
✅ payload hidden in 'cover.png' -> 'hidden.png'
An inline -password is still accepted for scripts/automation, but it's
no longer shown as the default in examples throughout -help or this
document — using it prints an explicit warning that it's recommended for
automation only:
$ yhide -hide -c cover.png -p secret.pdf -o hidden.png -password mypass
✅ payload hidden in 'cover.png' -> 'hidden.png'
⚠️ password was passed inline (-password) - it may be visible in your shell
history or process list. This is recommended only for automation/scripts;
for everyday, interactive use, omit -password and Yhide will prompt for
it securely instead.
Renaming a .txt file to .png doesn't fool Yhide — the actual file
signature is checked, not the extension:
$ yhide -info -c fake.png
Carrier: fake.png
⚠️ unrecognized file format. Run `yhide -help` to see supported carrier formats.
Every long flag accepts either one dash or two — -hide and --hide are
identical, as are -cover/--cover, -compress/--compress, and so on.
This matches the single-dash convention used throughout the rest of the
Y-suite (Yhash's -sha256, -chain, -check), so switching between tools
doesn't require remembering different dash conventions:
yhide -hide -c cover.png -p secret.pdf -o hidden.png -password pass
yhide --hide --cover cover.png --payload secret.pdf --output hidden.png --password pass
# both lines above do exactly the same thingIf a carrier file's embedded container claims a filename containing directory components — whether by accident or by a maliciously crafted carrier — Yhide never uses that path directly. It's sanitized down to a bare filename before anything is written to disk:
$ yhide -extract -c suspicious_carrier.png -o out/ -password pass
✅ recovered 'passwd' (312 bytes) -> 'out/passwd'
⚠️ the carrier claimed a filename containing path separators or traversal
sequences - it was sanitized to a plain filename for your safety.
(In this example the carrier's container internally claimed a filename of
../../../../etc/passwd; Yhide wrote the recovered bytes to out/passwd
instead of attempting to escape the output directory. See
7.1 for the implementation.)
Neither -hide nor -extract silently overwrites an existing file:
$ yhide -hide -c photo.png -p secret.pdf -o hidden.png
⚠️ a file already exists at 'hidden.png'.
Overwrite (o), choose a different name (r), or cancel (c)? [o/r/c]: r
Enter a new output filename or path: hidden_v2.png
✅ payload hidden in 'photo.png' -> 'hidden_v2.png'
For automation, -force (alias -y) skips the prompt entirely:
yhide -hide -c photo.png -p secret.pdf -o hidden.png -password pass -force -jsonIn -json mode without -force, an existing file is treated as a hard
error rather than a prompt, since a script has no one to answer it:
$ yhide -hide -c photo.png -p secret.pdf -o hidden.png -json
❌ 'hidden.png' already exists. Use -force to overwrite, or choose a different -output path.
Beyond PNG/BMP/WAV, Yhide now supports four more carrier formats, each labeled with its actual reliability rather than treated as uniformly safe:
yhide -hide -c report.pdf -p secret.txt -o hidden.pdf # PDF: Reliable
yhide -hide -c photo.jpg -p secret.txt -o hidden.jpg # JPEG: Unreliable
yhide -hide -c video.mp4 -p secret.txt -o hidden.mp4 # MP4: Unreliable
yhide -hide -c video.mkv -p secret.txt -o hidden.mkv # MKV/WebM: UnreliableHiding into an Unreliable carrier always shows the specific reason and a recommendation, but still completes the operation — Yhide doesn't block a format just because it isn't the most reliable choice:
$ yhide -hide -c photo.jpg -p secret.txt -o hidden.jpg
✅ payload hidden in 'photo.jpg' -> 'hidden.jpg'
⚠️ JPEG re-encoding is lossy - hidden data has a real chance of not surviving
extraction, especially for larger payloads. PNG, BMP, or WAV are
recommended wherever guaranteed reliability matters.
-analyze automatically uses the right technique for the format: a
statistical LSB-bias estimate for PNG/BMP/WAV/JPEG, or a deterministic
signature check for PDF/MKV/MP4 (see 4.12):
$ yhide -analyze -c hidden.pdf
Analyzing: hidden.pdf
Format: PDF
Estimated confidence of a hidden payload: 99%
Consistent with: stored as an unreferenced object inside the PDF's object table
deterministic check: a Yhide-tagged payload signature was found in this file.
Note: this is a statistical estimate, not a certainty.
Yhide is flag-based: exactly one action flag is required per invocation,
plus whatever shared flags that action needs. Every long flag works with
either one dash or two (-hide / --hide); short one-letter flags only
ever take one dash.
| Flag | Description |
|---|---|
-hide |
Hide a payload file inside a carrier file |
-extract |
Recover a hidden payload from a carrier file |
-info |
Show a carrier's format, capacity, and embedding method |
-analyze |
Estimate whether a file already contains a hidden payload |
| Short | Long | Required for | Description |
|---|---|---|---|
-c |
-cover |
every action | Carrier file path |
-p |
-payload |
-hide |
Payload file to embed (any type) |
-o |
-output |
-hide, -extract |
Output path |
-password (alias -pass) |
Password for automation only - default is an interactive secure prompt |
| Short | Long | Description |
|---|---|---|
-compress |
zstd-compress the payload before encrypting (-hide) |
|
-r |
-resilient |
Reed-Solomon error correction (planned, not yet wired up) |
-d |
-duress-password |
Enables deniability mode; requires -decoy |
-decoy |
Decoy payload file for deniability mode |
| Short | Long | Description |
|---|---|---|
-deep |
Full-carrier scan instead of a bounded subsample (-analyze) |
|
-json |
Print a machine-readable JSON result | |
-v |
-verbose |
Show full step-by-step detail |
-y |
-force (alias -yes) |
Overwrite an existing output file without asking - for scripts/automation |
| Short | Long | Description |
|---|---|---|
-about |
Prints version, author, contributor, license, and repository info | |
-h |
-help |
Full usage guide with categorized flags and examples |
-V |
-version |
Tool version |
Running yhide with no arguments at all shows the same output as
-help/--help/-h.
| Threat | Mitigation | Implemented in |
|---|---|---|
| Passive detection (statistical steganalysis) | Randomized, key-seeded embedding positions instead of sequential LSB | embed.rs |
| Payload theft if carrier is intercepted | AES-256-GCM encryption, Argon2id key derivation | crypto.rs |
| Tampering / corrupted carrier | AES-GCM authentication tag — extraction fails cleanly, never returns garbage | crypto.rs, container.rs |
| Coercion to reveal password | Plausible deniability mode with a duress password revealing a decoy, supported on every carrier format | carrier/*.rs |
| Silent capacity overflow / corruption | Explicit capacity calculation before every hide, Tier 3 block if exceeded | embed.rs, carrier/*.rs |
| False confidence on unreliable carriers | Format-honesty: every format is classified Reliable/Unreliable, with a specific warning + recommendation shown at hide time for anything less than fully reliable (JPEG, PDF, MKV, MP4) | carrier/mod.rs::Format::reliability_note() |
| Silent extraction corruption on lossy carriers | If a lossy re-encode (JPEG) or trailing-data loss (MKV/MP4) does corrupt the payload, the AES-GCM tag / container checksum catches it and fails cleanly rather than returning garbage | crypto.rs, container.rs |
| Renamed/spoofed file extensions | Format detection via magic bytes, never extension | carrier/mod.rs::detect() |
| Path traversal / arbitrary file write on extract | Restored filenames are sanitized to a bare filename before use | cli.rs::sanitize_filename() |
| Accidental overwrite of existing files | Interactive confirmation (overwrite/rename/cancel) before any write; scripted (-json) mode fails instead of prompting unless -force is given |
cli.rs::resolve_overwrite() |
| Password lingering in memory after use | Passwords held in zeroize::Zeroizing<String>, wiped on drop |
cli.rs::get_password() |
| Empty / no-op password | Explicit Tier 3 rejection of empty passwords on both hide and extract | validate.rs::check_password_not_empty() |
| Ambiguous or misparsed CLI flags | Pure, unit-tested argument normalization run before any parsing | argnorm.rs |
| Inline password exposure via shell history/process list | Discouraged by default (interactive prompt is the recommended path); explicit warning shown whenever -password is used inline, framed as automation-only |
cli.rs::get_password(), validate.rs::inline_password_note() |
The filename restored during -extract comes from inside the container
format (§8.1) — data that travels inside the carrier file. If that
carrier came from an untrusted source, an attacker could craft a container
claiming a filename like ../../../../etc/cron.d/evil or an absolute path,
hoping that a naive extractor would write the recovered bytes wherever that
path points, rather than into the directory the user actually asked for.
Yhide never uses the claimed filename directly. sanitize_filename()
passes it through Path::file_name(), which discards any directory
components and keeps only the final path segment — so ../../etc/passwd
becomes passwd, /tmp/x.bin becomes x.bin, and a degenerate value like
"", ".", or ".." falls back to a generic recovered_payload.bin
rather than failing the whole extraction. If sanitization actually changed
anything, the user is shown a warning explaining exactly why, rather than
the substitution happening silently:
fn sanitize_filename(raw: &str) -> String {
let candidate = Path::new(raw)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let trimmed = candidate.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
"recovered_payload.bin".to_string()
} else {
trimmed.to_string()
}
}This is covered by four dedicated unit tests (cli::tests::sanitize_filename_*)
exercising traversal sequences, absolute paths, and degenerate input.
Covered in detail in 4.2.3. Summary: an
existing output file is never silently replaced. Interactive use gets a
prompt (overwrite/rename/cancel); scripted use (-json) fails immediately
with a specific error unless -force is explicitly given. Three unit
tests (cli::tests::resolve_overwrite_*) cover the no-conflict,
-force, and JSON-mode-without--force paths.
Produced by Container::to_bytes(), consumed by Container::parse_and_restore().
This is what gets AES-256-GCM encrypted as a single unit.
Offset Size Field Notes
0 4 bytes magic b"YHDE"
4 1 byte version currently 1
5 1 byte flags bit0 = zstd-compressed
6 2 bytes filename_len u16, little-endian
8 N bytes filename UTF-8, length = filename_len
8+N 8 bytes original_size u64, little-endian — size BEFORE compression
16+N 32 bytes sha256 checksum of the ORIGINAL, uncompressed payload
48+N 8 bytes body_len u64, little-endian — length of body that follows
56+N body_len body raw bytes, possibly zstd-compressed
Produced by crypto::encrypt(), consumed by crypto::decrypt(). This blob
is what actually gets embedded into the carrier.
Offset Size Field Notes
0 16 bytes salt random, used for Argon2id key derivation
16 12 bytes nonce random, used for AES-GCM
28 N bytes ciphertext includes the 16-byte GCM authentication tag
Within a carrier's embeddable-unit array (see embed.rs):
Permutation order: [ pos_0 ... pos_63 | pos_64 ... pos_(64+len*8-1) | unused... ]
└── 64-bit header ──┘└──────── payload bits ────────┘
Header: the payload's byte-length, as 64 bits, MSB-first
Payload: the encrypted blob (§8.2), which itself contains the container (§8.1)
This layout applies to PNG, BMP, WAV, and JPEG — every carrier that scatters bits across a flat array of embeddable units. PDF and MKV/MP4 use the different, discrete-storage layouts below instead, since they don't have such an array.
PDF doesn't use embed.rs at all - the entire encrypted blob (§8.2) is
stored verbatim as the content of a new PDF stream object, uncompressed,
identified by a dictionary tag rather than a byte offset:
/Type /YHide
/Subtype /YHidePayload
<< stream content: the raw encrypted blob (§8.2), no compression filter >>
Deniability mode simply adds two such objects instead of one; both carry
identical /Type//Subtype tags, and the caller tries decrypting each
with the given password in turn (see §4.10).
MKV, WebM, and MP4 all use the same appended-block format, written directly after the carrier's existing bytes with no modification to the original content:
Offset Size Field Notes
0 7 bytes marker b"YHDEBIN"
7 8 bytes length u64, little-endian - length of blob that follows
15 length blob the encrypted blob (§8.2)
Deniability mode appends two such blocks back to back; extract_candidates()
scans the whole file for every marker occurrence and returns each blob, in
file order, as a candidate (see §4.11).
argnorm::normalize()runs on rawargvfirst, so-hide/-p/-passwordand their double-dash equivalents resolve identically.validate::check_carrier_exists/check_payload_exists— Tier 3 checks.get_password()— from-passwordor interactive prompt (with confirmation), returned aszeroize::Zeroizing<String>.validate::check_password_not_empty()— Tier 3 check.Container::from_file(payload, compress)— reads payload, computes SHA-256, optionally compresses.Container::to_bytes()— serializes to the container wire format.crypto::encrypt(container_bytes, password)— Argon2id derives an AES key from a fresh random salt; AES-256-GCM encrypts with a fresh random nonce. Result: the encrypted blob (§8.2).crypto::derive_embed_seed(password)— derives the ChaCha20 seed for bit placement, using the fixed embed-seed context salt.carrier::hide(cover, output, seed, blob)— detects format, dispatches topng::hide_rasterorwav::hide.- Inside the format module: load the carrier into a raw byte buffer,
compute capacity, check the blob fits (Tier 3 if not), call
embed::embed()to write bits at permutation-derived positions, save losslessly. - Any Tier 2 warnings (near-capacity, inline password) are collected and printed after success.
validate::check_carrier_exists.get_password(), thenvalidate::check_password_not_empty().crypto::derive_embed_seed(password).carrier::extract_flexible_raw(cover, seed)— returns three read attempts: full-range, half A, half B.- For each attempt, in order: try
crypto::decrypt()(fails if wrong password/corrupted), thenContainer::parse_and_restore()(fails if checksum mismatch). First success wins. sanitize_filename()strips any directory components from the restored filename (§7.1) before it is used for anything.resolve_output_path()determines the final write location (joining the sanitized filename if-outputis a directory).- Write the recovered bytes to disk.
Steps 1–7 run twice — once for the real payload with the real password,
once for the decoy payload with the duress password — producing two
independent encrypted blobs and two independent embed seeds. Then
carrier::hide_dual() splits the carrier's embeddable units into two
disjoint halves (by index parity) and embeds each blob into its own half
using its own seed, so the two embeddings can never collide.
| Format | Role | Reliability | Notes |
|---|---|---|---|
| PNG | Carrier | ✅ Reliable | LSB across RGBA channels |
| BMP | Carrier | ✅ Reliable | Same pipeline as PNG |
| WAV (16-bit PCM) | Carrier | ✅ Reliable | LSB on low byte of each sample only |
| Carrier | ✅ Reliable* | Unreferenced stream object via lopdf; *survives normal use, not PDF "optimizer" tools |
|
| JPEG | Carrier | Pixel-domain LSB, re-encoded at quality 100 — lossy compression can corrupt hidden bits | |
| MKV / WebM | Carrier | Trailing-append after existing content — won't survive re-muxing | |
| MP4 | Carrier | Same trailing-append technique as MKV | |
| FLAC | Carrier | 🚧 Planned | Needs a mature pure-Rust lossless encoder |
| MP3 | Carrier | ❌ Not supported | Lossy compression destroys embedded bits unconditionally — rejected outright, no Unreliable option offered |
| Any file type | Payload | ✅ Supported | No restriction whatsoever |
Known limitations in this release:
-resilient(Reed-Solomon error correction) is accepted as a flag but not yet functionally wired up — it prints a notice and proceeds without it.-analyze's statistical mode (PNG/BMP/WAV/JPEG) is a real, working heuristic, but not a full implementation of academic steganalysis techniques (sample-pairs, RS analysis). Its deterministic mode (PDF/MKV/MP4) is exact, since presence there is a yes/no fact.- Deniability mode halves available capacity per payload for the bit-scattered formats (PNG/BMP/WAV/JPEG), since the carrier is split into two disjoint halves. PDF/MKV/MP4 don't have this cost, since they simply store two tagged objects/blocks instead of splitting a byte array.
- Bundled single-character short flags (e.g.
-rvfor-r -v) are not supported by the argument normalizer — see §4.2.1. - JPEG steganography is genuinely unreliable in practice, not just in theory — see §4.9 for an example of it failing (safely) during development testing.
- MKV/MP4/PDF's "unlimited" capacity is only bounded by practical file size and available disk space; there is no explicit hard limit enforced, so an extremely large payload would still be capped by normal filesystem constraints rather than a Yhide-specific check.
cargo test runs 30 unit tests covering the cryptographic, data-integrity,
argument-parsing, security-hardening, and new-carrier-format logic of the
system:
| Test | Verifies |
|---|---|
container::tests::round_trip_uncompressed |
Container serialize → parse round-trips exactly |
container::tests::round_trip_compressed |
Compression is applied and reversed correctly |
crypto::tests::encrypt_decrypt_round_trip |
AES-256-GCM round-trips exactly |
crypto::tests::wrong_password_fails_cleanly |
Wrong password produces an error, not garbage |
crypto::tests::tampered_blob_fails |
A single flipped bit in ciphertext is detected and rejected |
embed::tests::embed_extract_round_trip |
LSB embed/extract round-trips exactly |
embed::tests::wrong_seed_does_not_panic |
Wrong seed never causes a crash, only a clean failure or garbage (never UB) |
embed::tests::capacity_exceeded_errors |
Oversized payloads are rejected, not silently truncated |
argnorm::tests::* (6 tests) |
Single-dash/double-dash normalization is correct on short flags, long flags, -- separators, and values |
banner::tests::* (3 tests) |
The ASCII-art banner has consistent row count and width |
cli::tests::sanitize_filename_* (4 tests) |
Path traversal, absolute paths, and degenerate filenames are all neutralized before use |
cli::tests::resolve_overwrite_* (3 tests) |
No-conflict, -force, and JSON-mode-without--force overwrite paths all behave correctly |
carrier::pdf::tests::* (2 tests) |
PDF hide/extract round-trips exactly; deniability mode produces two independently-recoverable objects |
carrier::append::tests::* (4 tests) |
MKV/MP4 hide/extract round-trips exactly; deniability mode preserves block order; missing/malformed markers never panic |
Beyond unit tests, the following were manually verified end-to-end during
development: PNG/BMP/WAV/JPEG/PDF/MKV/MP4 hide/extract, binary payload with
-compress, capacity-overflow rejection with the correct Tier 3 message,
full plausible-deniability round-trip on every carrier format (real
password → real file, duress password → decoy file), every flag in both
single-dash and double-dash form, the empty-password rejection, and the
full interactive overwrite flow (overwrite / rename / cancel) alongside
-force and the -json-mode failure path. Notably, JPEG's dedicated test
actually failed to round-trip during development — exactly as its
"Unreliable" classification predicts — which was treated as confirmation
the warning is accurate rather than as a bug to fix.
git clone https://github.com/sudoer0x0/yhide.git
cd yhide
cargo build --release
./target/release/yhide --versionRequires Rust 1.75+ (stable).
curl -fsSL https://yhide.dev/install.sh | bash # Linux / macOSirm https://yhide.dev/install.ps1 | iex # WindowsThe installer detects OS/architecture, downloads the matching prebuilt binary from GitHub Releases, verifies its SHA-256 checksum, installs it, and adds it to PATH automatically — no manual configuration required.
.github/workflows/release.yml cross-compiles Yhide for five targets
(Linux x86_64/ARM64, macOS Intel/Apple Silicon, Windows x86_64) whenever a
version tag is pushed, and attaches each binary plus its checksum to a
GitHub Release automatically.
- Now shipped (v1.2.0): JPEG, PDF, MKV/WebM, and MP4 carrier support (with honest reliability labeling), overwrite confirmation, and a safer default password workflow.
- Phase 2: True DCT-coefficient JPEG embedding (to make JPEG
genuinely reliable rather than merely supported), functional
Reed-Solomon resilience (
-resilient), progress bars for large files viaindicatif, FLAC carrier support. - Phase 3: Frame-aware video embedding (rather than trailing-append) for true MP4/MKV reliability, package manager distribution (Homebrew, Scoop, winget), zero-width Unicode text carrier mode for plaintext/markdown/code files.
Yhide is developed by Adekunle Abdulmujeeb, with contributions from Kemisola, as part of the Y-suite of command-line security tools alongside Ymap and Yhash.