@pranjalpanging/png-parser
v0.3.0
Published
Hide any file inside PNG/BMP/TIFF/WebP — compress, encrypt, embed. Rust-powered steganography engine.
Maintainers
Readme
png-parser
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
- Packages
- What's new in v0.3.0
- Python
- JavaScript / WASM
- Supported formats
- Security
- Embedding modes
- Expiry system
- Sharding
- CLI
- Build from source
- Architecture
- Versioning
- License
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 fileThe 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.0Hide 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.pdfInspect 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 FalseDelete 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.zipJavaScript / WASM
Install
npm i @pranjalpanging/png-parserInitialise
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 bytesReveal 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 passwordBuild 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-featuresArchitecture
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, stripVersioning
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
