Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Yhide (Yung Hide) Technical Documentation

Version: 1.2.0

Language: Rust

Developer: Adekunle Abdulmujeeb (@sudoer0x0)

Contributor: Kemisola

License: MIT

Repository: https://github.com/sudoer0x0/yhide


Table of Contents

  1. About
  2. Project Overview
  3. Architecture
  4. Module-by-Module Reference
  5. Feature Reference (with examples)
  6. CLI Command Reference
  7. Security Model
  8. Wire Formats
  9. Data Flow Walkthroughs
  10. Supported Formats & Limitations
  11. Testing
  12. Build & Installation
  13. Roadmap

1. About

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

2. Project Overview

2.1 What problem does Yhide solve?

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 venv headaches, 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).

2.2 Design Philosophy

  1. 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.
  2. Secure by default — every payload is encrypted before it is ever embedded. There is no "insecure mode."
  3. 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.
  4. 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.
  5. 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.
  6. 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.

3. Architecture

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.

3.1 Source Tree

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.


4. Module-by-Module Reference

4.1 main.rs — Entry Point

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).

4.2 cli.rs — Command-Line Interface

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

4.2.1 argnorm.rs — Single-Dash Normalization

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.

4.2.2 banner.rs — ASCII-Art Logo

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.

4.2.3 Overwrite Confirmation

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:

  • -force given: proceed immediately, no matter what. This is the automation path — never touches stdin.
  • -json given, 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).

4.3 container.rs — Payload Container Format

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 left false and 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.

4.4 crypto.rs — Encryption Layer

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.

4.5 embed.rs — LSB Embedding Engine

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.

4.6 carrier/mod.rs — Format Detection, Reliability & Dispatch

This module is the single place that:

  1. Detects carrier format from magic bytes, never from file extension. A .png file that's actually a renamed .txt is 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); }
  1. 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.

  2. Classifies every supported format's reliability via Format::reliability() and Format::reliability_note() - see below.

  3. Dispatches inspect, hide, extract, hide_dual, and extract_flexible_raw calls to the correct per-format module (png.rs, wav.rs, jpeg.rs, pdf.rs, or append.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.rs tries 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 makes yhide -extract work 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.

4.7 carrier/png.rs — Raster Image Carrier

Handles both PNG and BMP (they share the same underlying pipeline via the image crate, since both are lossless raster formats).

Pipeline:

  1. 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).
  2. Every single byte of that buffer is one embeddable unit. A 200×200 image has 200 × 200 × 4 = 160,000 embeddable units.
  3. embed::embed() / embed::extract() operate directly on this raw byte buffer.
  4. 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.

4.8 carrier/wav.rs — Audio Carrier

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.

4.9 carrier/jpeg.rs — JPEG Carrier (Unreliable)

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.

4.10 carrier/pdf.rs — PDF Carrier

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.

4.11 carrier/append.rs — MKV/MP4 Carrier (Unreliable)

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.

4.12 analyze.rs — Steganalysis

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.

4.13 validate.rs — Tiered Validation

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() and inline_password_note() return an Option<String>None means 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(), and check_carrier_exists() return Result<()>, 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.

4.14 output.rs — Terminal & JSON Output

A thin, consistent layer over the console crate for colored terminal output (✅ green success, ⚠️ yellow warnings, ❌ red errors), plus a set of #[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.


5. Feature Reference (with examples)

5.1 Mandatory Encryption

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 yourpassword

Even if an attacker extracts the raw bits from hidden.png without the password, they get ciphertext, not the original PDF.

5.2 Any File Into Any Supported Carrier

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 pass

On 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.exe

5.3 Optional Compression

yhide -hide -c cover.png -p large_log.txt -o hidden.png -password pass -compress

Uses 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.

5.4 Randomized, Key-Seeded Embedding

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.

5.5 Plausible Deniability Mode

yhide -hide -c photo.png -p real_secret.pdf -o hidden.png \
  -password realpassword123 -duress-password duresspassword456 -decoy fake_receipt.pdf
yhide -extract -c hidden.png -o out/ -password realpassword123    # → real_secret.pdf
yhide -extract -c hidden.png -o out/ -password duresspassword456  # → fake_receipt.pdf

Both 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.

5.6 Format-Honest Rejections

$ 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.

5.7 Capacity Checking Before Embedding

$ 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.

5.8 Tiered, Non-Overbearing Warnings

$ 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.

5.9 Steganalysis (-analyze)

$ 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.

5.10 Scripting via -json

$ 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": []
}

5.11 Secure Interactive Password Prompt (the Default)

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.

5.12 Format Detection by Magic Bytes

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.

5.13 Single-Dash and Double-Dash Flags, Interchangeably

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 thing

5.14 Path-Traversal-Safe Extraction

If 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.)

5.15 Overwrite Confirmation

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 -json

In -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.

5.16 New Carrier Formats: JPEG, PDF, MKV, MP4

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: Unreliable

Hiding 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.

5.17 Deterministic Presence Checking for Discrete-Storage Formats

-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.

6. CLI Command Reference

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.

6.1 Actions (exactly one required)

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

6.2 Core Options

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

6.3 Security Options

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

6.4 Output Options

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

6.5 Informational

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.


7. Security Model

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()

7.1 Path Traversal Protection

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.

7.2 Overwrite Protection

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.


8. Wire Formats

8.1 Container Wire Format

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

8.2 Encrypted Blob Wire Format

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

8.3 Embedding Layout (per carrier)

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.

8.4 PDF Object Tagging

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).

8.5 MKV/MP4 Trailing-Append Block

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).


9. Data Flow Walkthroughs

9.1 -hide (normal, single-payload)

  1. argnorm::normalize() runs on raw argv first, so -hide/-p/-password and their double-dash equivalents resolve identically.
  2. validate::check_carrier_exists / check_payload_exists — Tier 3 checks.
  3. get_password() — from -password or interactive prompt (with confirmation), returned as zeroize::Zeroizing<String>.
  4. validate::check_password_not_empty() — Tier 3 check.
  5. Container::from_file(payload, compress) — reads payload, computes SHA-256, optionally compresses.
  6. Container::to_bytes() — serializes to the container wire format.
  7. 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).
  8. crypto::derive_embed_seed(password) — derives the ChaCha20 seed for bit placement, using the fixed embed-seed context salt.
  9. carrier::hide(cover, output, seed, blob) — detects format, dispatches to png::hide_raster or wav::hide.
  10. 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.
  11. Any Tier 2 warnings (near-capacity, inline password) are collected and printed after success.

9.2 -extract

  1. validate::check_carrier_exists.
  2. get_password(), then validate::check_password_not_empty().
  3. crypto::derive_embed_seed(password).
  4. carrier::extract_flexible_raw(cover, seed) — returns three read attempts: full-range, half A, half B.
  5. For each attempt, in order: try crypto::decrypt() (fails if wrong password/corrupted), then Container::parse_and_restore() (fails if checksum mismatch). First success wins.
  6. sanitize_filename() strips any directory components from the restored filename (§7.1) before it is used for anything.
  7. resolve_output_path() determines the final write location (joining the sanitized filename if -output is a directory).
  8. Write the recovered bytes to disk.

9.3 -hide with deniability mode

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.


10. Supported Formats & Limitations

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
PDF Carrier ✅ Reliable* Unreferenced stream object via lopdf; *survives normal use, not PDF "optimizer" tools
JPEG Carrier ⚠️ Unreliable Pixel-domain LSB, re-encoded at quality 100 — lossy compression can corrupt hidden bits
MKV / WebM Carrier ⚠️ Unreliable Trailing-append after existing content — won't survive re-muxing
MP4 Carrier ⚠️ Unreliable 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. -rv for -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.

11. Testing

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.


12. Build & Installation

12.1 Build from source

git clone https://github.com/sudoer0x0/yhide.git
cd yhide
cargo build --release
./target/release/yhide --version

Requires Rust 1.75+ (stable).

12.2 Install via script (recommended for end users)

curl -fsSL https://yhide.dev/install.sh | bash      # Linux / macOS
irm https://yhide.dev/install.ps1 | iex             # Windows

The 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.

12.3 CI/CD

.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.


13. Roadmap

  • 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 via indicatif, 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.

About

Yhide (Yung Hide) is a modern, secure, cross-platform steganography CLI written in Rust. It hides arbitrary files inside carrier files, images and audio in this release, using encrypted, randomized bit embedding, with support for plausible deniability under coercion.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages