Skip to main content

Hide any file inside PNG/BMP/TIFF/WebP — compress, encrypt, embed. Rust-powered steganography engine.

Project description

png-parser

License: MIT Rust WASM Python npm PyPI

Hide any file inside an image — compress, encrypt, embed.

A high-performance steganography engine written in Rust, available for Python and JavaScript (WebAssembly). Hide any file — PDF, video, ZIP, binary — inside a PNG, BMP, TIFF, or WebP image. The carrier image looks identical to the original. No pixels are changed in chunk mode.


Table of Contents


How it works

hide:
  secret file
    → flate2 compress      (rust_backend, pure Rust, WASM-safe)
    → AES-256-GCM encrypt
    → embed into image
    → save image

reveal:
  stego image
    → extract payload
    → AES-256-GCM decrypt
    → flate2 decompress
    → restore original file

The original filename, file size, expiry, and embedding mode are stored inside the ciphertext — not in plaintext. An observer with the stego image but without the password sees none of this metadata.


Packages

Platform Install Status
Python pip install png-parser ✅ Published
JS / WASM npm i @pranjalpanging/png-parser ✅ Published

What's new in v0.3.0

Feature v0.2 v0.3
Payload type Text strings only Any file (PDF, ZIP, video, …)
Key derivation PBKDF2-SHA256 (100k iter) Argon2id (memory-hard, GPU-resistant)
Salt size 16 bytes 32 bytes
Compression None flate2 rust_backend (pure Rust, WASM-safe)
Image formats PNG only PNG, BMP, TIFF, WebP
Embed modes Chunk only Chunk + Pixel (adaptive LSB)
Expiry granularity Hours only Days + hours + minutes + seconds
Delete protection Unprotected Password-required for encrypted payloads
Secure erase No Payload zeroed before removal
New functions info, verify, reencrypt, capacity, fingerprint, split, merge
Filename preservation No Original filename stored in header
Sharding No Split file across multiple images

Python

Install

pip install png-parser>=0.3.0

Hide a file

import png_parser

# No password — plain compression only
png_parser.hide("photo.png", "out.png", "document.pdf")

# With encryption
png_parser.hide("photo.png", "out.png", "document.pdf",
    password="my-password")

# With encryption + flexible expiry (all units are additive)
png_parser.hide("photo.png", "out.png", "document.pdf",
    password="my-password",
    expires_days=1,
    expires_hours=6,
    expires_minutes=30,
    expires_seconds=0)

# Pixel mode — embeds in image pixels instead of a chunk
png_parser.hide("photo.png", "out.png", "document.pdf",
    password="my-password",
    mode="pixel")

Reveal a file

# output_path can be a directory — original filename is restored automatically
result = png_parser.reveal("out.png", "./extracted/",
    password="my-password")
print(result)
# ./extracted/document.pdf

Inspect without extracting

import json

raw  = png_parser.info("out.png", password="my-password")
meta = json.loads(raw)

print(meta["filename"])    # document.pdf
print(meta["file_size"])   # 204800
print(meta["encrypted"])   # True
print(meta["expires_at"])  # unix:1742000000 or permanent
print(meta["fingerprint"]) # a3f9c1d2...
print(meta["mode"])        # chunk or pixel

# Without password — only fingerprint and encrypted flag visible
raw = png_parser.info("out.png")
# {"encrypted": true, "fingerprint": "a3f9c1d2...", ...}

Verify a password

# Check without extracting anything
ok = png_parser.verify("out.png", "my-password")
print(ok)  # True or False

Delete a payload

# Password required if payload is encrypted
# Data is zeroed in place before removal — not just stripped
png_parser.delete("out.png", "clean.png", password="my-password")

Change password

# Re-encrypts without writing plaintext to disk at any point
png_parser.reencrypt("out.png", "new.png",
    old_password="old-password",
    new_password="new-password")

Check capacity

# How many bytes can this image hold?
bytes_available = png_parser.capacity("photo.png", mode="chunk")
print(f"{bytes_available:,} bytes available")

bytes_available = png_parser.capacity("photo.png", mode="pixel")
print(f"{bytes_available:,} bytes available in pixel mode")

Fingerprint

# SHA-256 of the raw envelope — no password needed
# Useful to verify two images carry the same payload
fp = png_parser.fingerprint("out.png")
print(fp)  # a3f9c1d2ee4b...

Split a large file across multiple images

# Each carrier image gets one shard
shards = png_parser.split(
    "bigfile.zip",
    ["photo1.png", "photo2.png", "photo3.png"],
    "./shards/",
    password="my-password",
    expires_days=7,
)
print(shards)
# ['./shards/shard_0_photo1.png',
#  './shards/shard_1_photo2.png',
#  './shards/shard_2_photo3.png']

Merge shards

# Shards can be passed in any order — sorted internally by index
# SHA-256 of reassembled file is verified against stored hash
output = png_parser.merge(
    ["./shards/shard_2_photo3.png",
     "./shards/shard_0_photo1.png",
     "./shards/shard_1_photo2.png"],
    "./output/",
    password="my-password",
)
print(output)  # ./output/bigfile.zip

JavaScript / WASM

Install

npm i @pranjalpanging/png-parser

Initialise

import init, {
    hide_js,
    reveal_js,
    info_js,
    verify_js,
    delete_js,
    reencrypt_js,
    fingerprint_js,
    capacity_js,
} from '@pranjalpanging/png-parser';

await init();

Hide a file

// imageBytes and fileBytes are Uint8Array
const stego = hide_js(
    imageBytes,    // carrier image bytes
    fileBytes,     // file to hide
    "secret.pdf",  // original filename (stored in header)
    "password",    // encryption password — pass null for no encryption
    "chunk",       // mode: "chunk" or "pixel"
    null,          // expires_days
    null,          // expires_hours
    null,          // expires_minutes
    null           // expires_seconds
);
// stego is Uint8Array — the modified image bytes

Reveal a file

// Returns Uint8Array of the hidden file bytes
const fileBytes = reveal_js(stegoBytes, "password");

// Download in browser
const blob = new Blob([fileBytes]);
const url  = URL.createObjectURL(blob);
const a    = document.createElement("a");
a.href     = url;
a.download = "secret.pdf";
a.click();
URL.revokeObjectURL(url);

Inspect without extracting

// Returns JSON string
const meta = JSON.parse(info_js(stegoBytes, "password"));
console.log(meta.filename);    // "secret.pdf"
console.log(meta.file_size);   // 204800
console.log(meta.encrypted);   // true
console.log(meta.expires_at);  // "unix:1742000000" or "permanent"
console.log(meta.fingerprint); // "a3f9c1d2..."

Other functions

// Verify password
const ok = verify_js(stegoBytes, "password"); // true / false

// Delete payload (password required if encrypted)
const cleanBytes = delete_js(stegoBytes, "password");

// Change password
const newBytes = reencrypt_js(stegoBytes, "old-pass", "new-pass");

// Fingerprint (no password needed)
const fp = fingerprint_js(stegoBytes);

// Capacity
const bytes = capacity_js(imageBytes, "chunk");

Full browser example

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>png-parser demo</title>
</head>
<body>
  <h2>Hide a file inside an image</h2>

  <label>Carrier image (PNG/BMP/TIFF/WebP)</label><br>
  <input type="file" id="carrier" accept="image/*"><br><br>

  <label>Secret file (any format)</label><br>
  <input type="file" id="secret"><br><br>

  <label>Password (optional)</label><br>
  <input type="password" id="password" placeholder="Leave blank for no encryption"><br><br>

  <label>Expiry (optional)</label><br>
  <input type="number" id="days"    placeholder="Days"   style="width:80px">
  <input type="number" id="hours"   placeholder="Hours"  style="width:80px">
  <input type="number" id="minutes" placeholder="Minutes" style="width:80px"><br><br>

  <button onclick="hideFile()">Hide and Download</button>
  <button onclick="revealFile()">Reveal from image</button>

  <p id="status"></p>

  <script type="module">
    import init, { hide_js, reveal_js } from './pkg/png_parser.js';
    await init();

    function status(msg) {
      document.getElementById("status").textContent = msg;
    }

    function download(bytes, name, type = "image/png") {
      const blob = new Blob([bytes], { type });
      const a    = document.createElement("a");
      a.href     = URL.createObjectURL(blob);
      a.download = name;
      a.click();
      URL.revokeObjectURL(a.href);
    }

    window.hideFile = async () => {
      const carrier  = document.getElementById("carrier").files[0];
      const secret   = document.getElementById("secret").files[0];
      const password = document.getElementById("password").value || null;
      const days     = parseInt(document.getElementById("days").value)    || null;
      const hours    = parseInt(document.getElementById("hours").value)   || null;
      const minutes  = parseInt(document.getElementById("minutes").value) || null;

      if (!carrier || !secret) {
        return status("Please select both files.");
      }

      const carrierBytes = new Uint8Array(await carrier.arrayBuffer());
      const secretBytes  = new Uint8Array(await secret.arrayBuffer());

      try {
        const result = hide_js(
            carrierBytes, secretBytes, secret.name,
            password, "chunk",
            days, hours, minutes, null
        );
        download(result, "stego_" + carrier.name);
        status("Done — file hidden successfully.");
      } catch (e) {
        status("Error: " + e);
      }
    };

    window.revealFile = async () => {
      const carrier  = document.getElementById("carrier").files[0];
      const password = document.getElementById("password").value || null;

      if (!carrier) return status("Please select a stego image.");

      const carrierBytes = new Uint8Array(await carrier.arrayBuffer());

      try {
        const fileBytes = reveal_js(carrierBytes, password);
        download(fileBytes, "revealed_file", "application/octet-stream");
        status("Done — file revealed successfully.");
      } catch (e) {
        status("Error: " + e);
      }
    };
  </script>
</body>
</html>

Supported formats

Format Chunk mode Pixel mode Method
PNG Custom stEg ancillary chunk / adaptive LSB
BMP Append after EOF / adaptive LSB
TIFF Append after EOF / adaptive LSB
WebP Unknown RIFF chunk
JPEG Rejected — lossy format destroys data

Security

Component Detail
Cipher AES-256-GCM — authenticated encryption
KDF Argon2id — memory-hard, resistant to GPU/ASIC attacks
Salt 32 bytes, randomly generated per operation
Nonce 12 bytes, randomly generated per operation
Compression flate2 rust_backend — pure Rust, zero C dependencies
Metadata Filename, expiry, mode stored inside ciphertext — tamper-proof
Integrity GCM authentication tag detects any modification
Secure delete Payload bytes zeroed in place before chunk removal
Password verify Decrypt attempted — plaintext never returned on failure

Two encryptions of the same file with the same password produce different ciphertext because salt and nonce are randomised every time.


Embedding modes

Chunk mode (default)

Inserts a custom ancillary PNG chunk named stEg just before IEND. Per the PNG specification, unknown ancillary chunks are silently skipped by all standard image viewers. The image pixels are never modified — the file is visually and statistically identical to the original.

For BMP and TIFF, data is appended after the image EOF with a magic marker. For WebP, a proper RIFF chunk is inserted.

Pixel mode

Embeds data into the least-significant bit of the R, G, B channels of high-texture pixels only. Flat, uniform regions are skipped entirely. This defeats simple statistical detectors such as chi-square analysis and RS analysis which look for LSB changes in smooth areas.

Use capacity() to check how many bytes are available before embedding.


Expiry system

Expiry is stored as a Unix timestamp inside the ciphertext. It is tamper-proof — an attacker cannot extend or remove the expiry without the password.

All time units are optional and additive:

# Expires in exactly 1 day, 6 hours, 30 minutes from now
png_parser.hide("photo.png", "out.png", "file.pdf",
    password="x",
    expires_days=1,
    expires_hours=6,
    expires_minutes=30)

When reveal() is called after expiry, a PermissionError is raised. The file bytes are never written to disk.


Sharding

Split a file too large for a single carrier across multiple images:

file.zip (300 MB)
  → shard 0 → photo1.png  (100 MB payload)
  → shard 1 → photo2.png  (100 MB payload)
  → shard 2 → photo3.png  (100 MB payload)

Each shard header stores the shard index, total count, and SHA-256 of the full original file. merge() sorts shards by index, assembles them, and verifies the SHA-256 before writing output. Any missing or mismatched shard causes an error.


CLI

# Hide a file
png-parser-cli hide -i photo.png -f secret.pdf -o out.png \
    -p password --days 1 --hours 6 --minutes 30 --mode chunk

# Reveal
png-parser-cli reveal -i out.png -o ./extracted/ -p password

# Inspect metadata
png-parser-cli info -i out.png -p password

# Verify password
png-parser-cli verify -i out.png -p password

# Delete payload (password required if encrypted)
png-parser-cli delete -i out.png -o clean.png -p password

# Change password
png-parser-cli reencrypt -i out.png -o new.png \
    --old-password old --new-password new

# Check capacity
png-parser-cli capacity -i photo.png --mode chunk
png-parser-cli capacity -i photo.png --mode pixel

# Fingerprint
png-parser-cli fingerprint -i out.png

# Inspect PNG chunk structure
png-parser-cli inspect -i photo.png

# Strip metadata chunks (tEXt, zTXt, iTXt, eXIf)
png-parser-cli strip -i photo.png -o clean.png

# Split a file across multiple carriers
png-parser-cli split -f bigfile.zip \
    -c photo1.png photo2.png photo3.png \
    -o ./shards/ -p password --days 7

# Merge shards (any order)
png-parser-cli merge \
    -i shards/shard_0_photo1.png \
       shards/shard_1_photo2.png \
       shards/shard_2_photo3.png \
    -o ./output/ -p password

Build from source

Requirements:

  • Rust stable (rustup install stable)
  • Python 3.8+ with pip install maturin (for Python build)
  • npm install -g wasm-pack (for WASM build)
# Clone
git clone https://github.com/PranjalPanging/png-parser
cd png-parser

# Python — installs into current virtualenv
maturin develop --release

# WASM — outputs to pkg/
wasm-pack build --target web --release \
    --no-default-features --features js

# CLI binary
cargo build --release --no-default-features

Architecture

src/
├── lib.rs          Python + WASM API surface
├── main.rs         CLI entry point
├── error.rs        All error variants + Python exception mapping
├── chunk_type.rs   4-byte PNG chunk name + spec rules
├── chunk.rs        PNG chunk: parse, CRC verify, secure erase
├── png.rs          Full PNG file: parse, build, insert, remove
├── header.rs       PayloadHeader — inside ciphertext, tamper-proof
├── crypto.rs       Argon2id KDF + AES-256-GCM + zlib
├── format.rs       Format detection + routing (PNG/BMP/TIFF/WebP)
├── pixel.rs        Adaptive LSB engine
└── commands/
    ├── mod.rs
    └── mode.rs     All operations: hide, reveal, info, verify,
                    delete, reencrypt, capacity, fingerprint,
                    split, merge, inspect, strip

Versioning

This project follows Semantic Versioning.

Package Current Registry
Python 0.3.0 PyPI
JavaScript 0.3.0 npm

Python and JavaScript versions are kept in sync from v0.3.0 onwards.

Version Notes
0.3.0 Full rewrite — any file type, Argon2id, multi-format, WASM, sharding
0.2.0 AES-256-GCM encryption, expiry system
0.1.0 Initial release

Versions below 1.0.0 may have breaking changes between minor releases.


License

MIT — see LICENSE


Built with ❤️ by Pranjal Panging

GitHub

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

png_parser-0.3.0-cp38-abi3-win_amd64.whl (569.2 kB view details)

Uploaded CPython 3.8+Windows x86-64

File details

Details for the file png_parser-0.3.0-cp38-abi3-win_amd64.whl.

File metadata

  • Download URL: png_parser-0.3.0-cp38-abi3-win_amd64.whl
  • Upload date:
  • Size: 569.2 kB
  • Tags: CPython 3.8+, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.0

File hashes

Hashes for png_parser-0.3.0-cp38-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 83a30e2fe08291df855f7588920d96ff76899f28556b4cb55cd7d8a612f872c3
MD5 65b8c72c330c92ae8873cfb5d0aedad3
BLAKE2b-256 4d41d2b5ee74dac0deaacf010cfe88438c4c6cb98430469ab44c747a2265c63a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page