Skip to content

feat: F5 JPEG steganography support#235

Merged
sassman merged 42 commits intomainfrom
feat/f5-jpeg
Feb 11, 2026
Merged

feat: F5 JPEG steganography support#235
sassman merged 42 commits intomainfrom
feat/f5-jpeg

Conversation

@sassman
Copy link
Member

@sassman sassman commented Jan 24, 2026

Summary

Adds JPEG steganography support using the F5 algorithm, enabling users to hide and extract secret data in JPEG images alongside the existing PNG and WAV support.

  • JPEG hide/unveil works transparently via file extension detection
  • Password serves dual purpose: encryption (ChaCha20-Poly1305) AND F5 permutation seed
  • No public API changes — same builder pattern as PNG/WAV

What is F5?

F5 is a steganographic algorithm designed specifically for JPEG images. Unlike naive LSB embedding in spatial domain:

  • Frequency domain embedding: Data is hidden in quantized DCT coefficients, surviving JPEG's lossy compression
  • Matrix encoding: Minimizes embedding changes using (1, n, k) codes — on average only 1 coefficient change per k bits embedded
  • Permutation-based shuffling: When a password is provided, coefficients are accessed in a pseudorandom order derived from the password, making extraction without the password computationally infeasible

This makes F5 more statistically secure than naive approaches — changes to the coefficient histogram are minimal and spread across the image.

Usage Examples

CLI

# Hide a message in a JPEG (without password)
stegano hide --in photo.jpg --out secret.jpg --message "Hello World"

# Hide with password (encrypted + shuffled)
stegano hide --in photo.jpg --out secret.jpg --message "Secret!" --password "MyPass123"

# Unveil
stegano unveil --in secret.jpg --out ./output/ --password "MyPass123"
cat ./output/secret-message.txt

Rust API

use stegano_core::api::{hide, unveil};

// Hide with password
hide::prepare()
    .with_message("Secret message")
    .with_image("photo.jpg")
    .using_password("MyPassword")
    .with_output("stego.jpg")
    .execute()?;

// Unveil
unveil::prepare()
    .from_secret_file("stego.jpg")
    .using_password("MyPassword")
    .into_output_folder("./output/")
    .execute()?;

Cross-format (PNG → JPEG)

hide::prepare()
    .with_message("From PNG to JPEG")
    .with_image("source.png")       // PNG input
    .with_output("output.jpg")      // JPEG output
    .execute()?;

Security Model

Mode Encryption Coefficient Access Security
No password None Sequential Low — extraction is straightforward
With password ChaCha20-Poly1305 Permuted (password-seeded) High — attacker needs password for both extraction order and decryption

Test Plan

  • Unit tests for JPEG hide/unveil with and without password
  • Wrong password fails extraction
  • Binary file roundtrip test
  • PNG→JPEG cross-format test
  • All existing PNG/WAV tests still pass (60 tests)
  • CLI smoke tests pass

@codecov
Copy link

codecov bot commented Jan 25, 2026

Codecov Report

❌ Patch coverage is 80.03686% with 325 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.38%. Comparing base (ded19f0) to head (b1e3d60).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/stegano-inspect/src/main.rs 0.00% 147 Missing ⚠️
crates/stegano-core/src/media/types.rs 67.47% 40 Missing ⚠️
crates/stegano-f5/src/encoder.rs 89.65% 33 Missing ⚠️
crates/stegano-core/src/media/codec_options.rs 35.48% 20 Missing ⚠️
crates/stegano-f5/src/decoder.rs 93.30% 15 Missing ⚠️
crates/stegano-core/src/api/unveil_raw.rs 52.17% 11 Missing ⚠️
crates/stegano-core/src/message.rs 0.00% 9 Missing ⚠️
crates/stegano-cli/src/commands/hide.rs 0.00% 8 Missing ⚠️
crates/stegano-f5/src/jpeg_ops.rs 95.97% 8 Missing ⚠️
crates/stegano-core/src/lib.rs 83.33% 6 Missing ⚠️
... and 8 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #235      +/-   ##
==========================================
+ Coverage   82.44%   86.38%   +3.93%     
==========================================
  Files          29       38       +9     
  Lines        1954     5023    +3069     
  Branches     1954        0    -1954     
==========================================
+ Hits         1611     4339    +2728     
- Misses        226      684     +458     
+ Partials      117        0     -117     
Files with missing lines Coverage Δ
crates/stegano-core/src/media/image/decoder.rs 92.59% <100.00%> (+3.70%) ⬆️
crates/stegano-core/src/media/image/encoder.rs 96.33% <100.00%> (+1.83%) ⬆️
crates/stegano-core/src/media/image/f5_decoder.rs 100.00% <100.00%> (ø)
crates/stegano-core/src/media/image/lsb_codec.rs 96.96% <100.00%> (+3.21%) ⬆️
crates/stegano-core/src/media/payload/factory.rs 100.00% <100.00%> (ø)
crates/stegano-f5/src/error.rs 100.00% <100.00%> (ø)
crates/stegano-f5/src/permutation.rs 100.00% <100.00%> (ø)
crates/stegano-cli/src/commands/unveil.rs 0.00% <0.00%> (ø)
crates/stegano-cli/src/commands/unveil_raw.rs 0.00% <0.00%> (ø)
crates/stegano-core/src/api/unveil.rs 91.48% <96.38%> (+13.40%) ⬆️
... and 15 more

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sassman sassman marked this pull request as ready for review February 3, 2026 20:56
sassman and others added 27 commits February 11, 2026 21:46
- stack overflow silently truncated the 70% quality JPEG
- unify to single heap-based `prepare_chess_image` generator
- extract `prepare_fixture_image` helper to reduce duplication

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
- matrix encoding with check matrix H_w for (1, n, k) scheme
- permutative straddling via Fisher-Yates shuffle (fastrand)
- encoder/decoder with shrinkage handling on synthetic DCT coefficients
- roundtrip tests pass for various message sizes

Phase 2 (JPEG codec integration) not yet started - requires
forked jpeg-decoder/encoder crates to access DCT coefficients.

Includes architecture doc and research paper for reference.

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
- add coefficient-level JPEG transcode pipeline (parse → huffman decode → F5 → huffman encode → write)
- preserve image quality by operating on quantized DCT coefficients without requantization
- split scan module into baseline.rs for future progressive JPEG support

Key components:
- huffman.rs: BitReader/BitWriter with byte stuffing, Huffman encode/decode
- parser.rs: JPEG segment parsing, quantization and Huffman tables
- scan/baseline.rs: baseline JPEG coefficient encode/decode
- writer.rs: JPEG reassembly with modified scan data

Includes:
- hide/unveil CLI examples for quick testing
- architecture docs with progressive JPEG extension plan
- 73 tests covering roundtrip encode/decode and F5 embedding
…bmodules

- custom huffman/marker/scan parser was incomplete and hard to extend;
  forked jpeg-encoder and jpeg-decoder provide full, tested JPEG codecs
  with surgical hooks for F5 coefficient access
- encoder fork: `set_coefficient_hook()` intercepts after DCT+quantization
- decoder fork: `decode_raw_coefficients()` returns quantized DCT coefficients
- `jpeg_ops.rs` orchestrates both forks, normalizing coefficient ordering
  (encoder=zigzag, decoder=natural) for consistent F5 embedding/extraction
- stegano-inspect `quantization` subcommand rewired to use decoder fork
- stegano-inspect `summary` subcommand added: dimensions, coding process,
  baseline flag, color model, sampling factors, metadata presence, F5 capacity
- adds original F5 paper (Westfeld) as research reference

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Adds execute_jpeg() method that:
- Serializes Message to bytes with optional encryption via FabS
- Uses password as F5 permutation seed for shuffled DCT access
- Supports JPEG→JPEG transcoding and PNG→JPEG conversion
Transparent JPEG support in execute():
- Detects JPEG input by file extension internally
- Extracts F5 data using password as permutation seed
- Wrong password fails both coefficient order and decryption
- Fix typo in workspace Cargo.toml (versoin -> version)
- Apply rustfmt formatting to stegano-core API files
- Move test module to end of file in types.rs
- Replace manual clamp/contains patterns with idiomatic methods
- Replace range loops with iterator patterns
- Remove useless vec! when array suffices
- Replace assert_eq! with bool literals with assert!/assert!
- Update submodules with clippy fixes
The F5 JPEG steganography feature uses git submodules for the
modified JPEG encoder and decoder crates. This updates the CI
workflow to initialize submodules recursively during checkout.

Previously the workflow used reusable workflows that didn't
support submodules, so the jobs are now inlined with the
submodules: recursive option added to each checkout step.
…nested errors

- Remove string fields from error variants for cleaner API
- Wrap JPEG codec errors in Box<dyn Error> to preserve error chain
- Custom Debug impl delegates to Display for user-friendly unwrap()
- Add e2e tests verifying error messages and source chain integrity
Eliminates JPEG code duplication by moving F5 logic into `Media.hide_data()`:

- Codec choice now determines output format: `CodecOptions::Lsb` → PNG, `F5` → JPEG, `AudioLsb` → WAV
- `Media::ImageJpeg` variant stores source bytes for JPEG→JPEG transcoding
- `EncodedMedia` enum carries encoded data ready for saving
- `SteganoEncoder` derives codec from target path extension
- `HideApi.execute_jpeg()` removed; unified path via `SteganoEncoder`
- `PayloadCodecFactory.password()` method added for F5 seed derivation

Step 2 will apply same pattern to decoding/unveil.

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
- Remove is_jpeg_extension() check in execute()
- Rename unveil_standard() to unveil()
- ImageJpeg now uses F5JpegDecoder instead of LSB
- Delete duplicate unveil_jpeg() method
- Export LsbCodecOptions from stegano-core
- Update main.rs to return LsbCodecOptions from get_options()
- Update hide.rs to remove with_options() (codec now determined by target extension)
- Update unveil.rs and unveil_raw.rs to use LsbCodecOptions
Instead of exposing LsbCodecOptions to CLI consumers, provide specific
builder methods for each configurable parameter:

- Add with_color_step_increment() to UnveilApi and UnveilRawApi
- Remove with_options() that took LsbCodecOptions
- CLI now passes individual values, not codec option structs
- Core library internally decides which codec to use based on media type

This keeps codec implementation details internal while still allowing
configuration of relevant parameters.
- Add color_channel_step_increment field to SteganoEncoder
- Add with_color_step_increment() to SteganoEncoder and HideApi
- Use the value when building LsbCodecOptions for PNG output
- CLI now properly passes the color step increment for hide operations
- make encoder coefficient hook fallible (returns Result)
- add EmbeddingFailed variant to F5Error for hook errors
- F5 capacity exceeded now fails at hide time, not unveil time
- rename F5JpegDecoder::new() to decode() for clarity

Previously, embedding errors (e.g. message too large) were silently
swallowed in the hook, only surfacing later during extraction.

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
…ding

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
- add DEFAULT_JPEG_QUALITY const (90) to codec_options
- add jpeg_quality field and with_jpeg_quality() to SteganoEncoder
- add jpeg_quality field and with_jpeg_quality() to HideApi
- add --jpeg-quality CLI arg with 1-100 range validation
- add integration test for custom quality roundtrip

Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Allows consumers to predict encoded payload size before encoding,
enabling accurate capacity checking against carrier limits.

- Add encoded_size() method to PayloadEncoder trait
- Implement for PayloadEncoderWithLengthHeader (content + 6 bytes overhead)
- Implement for PayloadFlexCodec (delegates to inner encoder)
- Implement for CryptedPayloadCodec (encryption overhead + inner encoding)
- Add test verifying predicted size matches actual encoding
Exposes F5 header overhead (4 bytes) so consumers can accurately
calculate total capacity requirements for embedding.
The test now correctly uses F5Encoder::HEADER_BYTES to calculate
usable capacity, ensuring the right-sized message actually fits.
- Add #[allow(dead_code)] to encoded_size trait method (API for future use)
- Add #[allow(dead_code)] to ENCRYPTION_OVERHEAD constant (used internally)
- Apply cargo fmt formatting to test assertions
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
@sassman sassman added this pull request to the merge queue Feb 11, 2026
Merged via the queue into main with commit 6f3ab74 Feb 11, 2026
19 checks passed
@sassman sassman deleted the feat/f5-jpeg branch February 11, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant