stegdoc
v6.1.0
Published
Hide files inside Office documents (XLSX/DOCX) with AES-256 encryption and steganography
Maintainers
Readme
stegdoc
Hide a file inside a legitimate-looking Office document
stegdoc encodes any file into an ordinary-looking Excel or Word document. The payload is spread across realistic nginx access log entries or Hebrew incident reports, so the cover survives casual inspection. Optionally protected with AES-256-GCM.
The format engine is written in Rust. It ships as a prebuilt native addon for the CLI, and as WebAssembly for the single-file browser tool.
Features
- Log-based steganography — the data is the logs; there is no hidden sheet and nothing extra to notice
- AES-256-GCM encryption, password-derived keys
- v6 by default — Argon2id key derivation and tamper-evident metadata, with
--v5for older decoders - Brotli compression (quality 6), skipped for formats that are already compressed
- XLSX and DOCX covers — an access-log spreadsheet or a Hebrew RTL incident report
- Multi-part splitting for payloads larger than one document
- Integrity checking with SHA-256, verified on decode
- ZIP, directory, or single-file input on decode, discovered automatically
- v5 and v6 read support — the decoder reads both log-embed versions
Install
npm install -g stegdocThe install pulls a prebuilt engine for the host platform (win32-x64,
linux-x64, linux-arm64, darwin-x64, darwin-arm64). No compiler or Rust
toolchain is required. If a platform package is missing, log-embed files cannot
be decoded; the CLI says so instead of guessing.
Or run it without installing:
npx stegdoc encode myfile.pdf -p mypasswordQuick start
# Hide a file, encrypted
stegdoc encode secret.pdf -p mypassword
# Get it back
stegdoc decode access_log_20260315_1200_A1B2_part1.xlsx -p mypassword
# Inspect without decoding
stegdoc info access_log_20260315_1200_A1B2_part1.xlsx
# Check that it will decode
stegdoc verify access_log_20260315_1200_A1B2_part1.xlsx -p mypasswordCommands
encode <inputs...>
| Option | Description | Default |
|--------|-------------|---------|
| -o, --output-dir <dir> | Output directory | Current directory |
| --bundle-name <name> | Filename recorded when several inputs are bundled | bundle.zip |
| -s, --chunk-size <size> | Split size: 5MB, 25MB, 3 parts, or max | 5MB |
| -f, --format <format> | Cover format: xlsx or docx | xlsx |
| -p, --password <pass> | Encryption password | None (unencrypted) |
| --v5 | Emit the v5 format (PBKDF2) | Off (v6) |
| --v6 | Emit the v6 format (the default) | On |
| --no-limit | Bypass the DOCX 1 MB limit | Off |
| --force | Overwrite existing files | Prompt |
| -q, --quiet | Minimal output for scripting | Off |
| -y, --yes | Skip interactive prompts | Off |
A directory is zipped before encoding, and several inputs are bundled into one
zip first, so a decode hands that archive back. -s max produces a single part;
-s "3 parts" splits into roughly three. Bundling a folder or several inputs
uses the native engine to build the archive.
stegdoc encode document.pdf -p mysecret # access log spreadsheet
stegdoc encode config.json -p mysecret -f docx # Hebrew incident report
stegdoc encode large-file.zip -p mysecret -s "3 parts" # three parts
stegdoc encode ./my-folder -p mysecret # zipped first
stegdoc encode a.pdf b.pdf --bundle-name pair.zip -p mysecretdecode <file>
| Option | Description | Default |
|--------|-------------|---------|
| -o, --output <path> | Output file or directory | Original filename |
| -p, --password <pass> | Decryption password | Prompt if needed |
| --force | Overwrite existing files | Prompt |
| -q, --quiet | Minimal output | Off |
| -y, --yes | Skip prompts, fail if a password is needed | Off |
Point decode at any one part, a directory holding the parts, or a ZIP of
them; the rest are found automatically.
stegdoc decode access_log_20260315_1200_A1B2_part1.xlsx -p mysecret
stegdoc decode system_report_20260315_0800_CD42_part1.docx -p mysecret
stegdoc decode stegdoc-parts.zip -p mysecret -o ./restoredinfo <file>
Reads metadata without decoding: cover format and version, original filename and size, whether it is encrypted and compressed, the part count, and whether metadata is authenticated.
verify <file> [-p <pass>]
Runs the structural and password checks without writing any output. It exits non-zero if the file will not decode.
How it works
Pipeline
Input
|
[Brotli q6] skipped for already-compressed types
|
[AES-256-GCM] optional
|
[log-embed] payload spread across log line fields
|
[Office wrapper] XLSX access logs or a DOCX incident report
|
Output part(s)Each log row carries 114 raw bytes across six channels:
| Channel | Encoding | Bytes |
|---------|----------|-------|
| URL path segment | base64url | 21 |
| Query param token | base64url | 21 |
| Query param state | base64url | 21 |
| Referer ref param | base64url | 21 |
| X-Request-ID | UUID v4 | 14 |
| X-Trace-ID | 32-char hex | 16 |
Metadata and encryption parameters travel in header lines marked by the
/api/v1/health path, in a length-prefixed frame:
STGD06|<metaLen>|<encLen>|<kdfLen>|{metadataJson}{encryptionMeta}{kdfParams}v5 uses the shorter STGD05|<metaLen>|<encLen>|... frame. The decoder
dispatches on the marker.
Decoding uses payloadSize and dataLineCount from the metadata to truncate
the payload. Filler rows are appended for realism and ignored on decode.
Covers
XLSX — a single "Access Logs" sheet of realistic nginx entries with columns
for remote address, timestamp, method, request, status, bytes, referer,
user-agent, X-Request-ID, and X-Trace-ID. No hidden sheets.
DOCX — a Hebrew RTL incident report: title, executive summary, timeline
table, log excerpts in monospace blocks, root cause, and recommendations. The
prose is generated deterministically from the payload hash, so covers do not
repeat. DOCX refuses inputs over 1 MB unless --no-limit is given; use XLSX
for large payloads.
Multi-part output
Large payloads are split across parts. Parts are built in parallel, each with its own IV and tag but a shared session salt, and the CLI prints progress. Decode finds the sibling parts automatically.
Filenames are decoys:
access_log_YYYYMMDD_HH00_XXXX[_partN].xlsx
system_report_YYYYMMDD_HH00_XXXX[_partN].docxEncryption
Encryption is optional but recommended. Without -p, the payload is hidden but
not encrypted.
v6 (default)
v6 makes the metadata tamper-evident and uses a memory-hard KDF. The cover, channels, chunking, and filenames are identical to v5; only the header and crypto change.
| | |
|---|---|
| Cipher | AES-256-GCM |
| Key derivation | Argon2id, version 19, m=65536 KiB, t=3, p=1 |
| Key | 256-bit |
| IV | 96-bit, random per part |
| Salt | 128-bit, shared per session |
| Tag | 128-bit |
The KDF parameters are recorded in the file and reproduced on decode. When
encrypted, the AEAD associated data is the header frame minus the encryption
parameters, so changing the original filename, size, content hash, or KDF
parameters breaks decryption. The frame is
STGD06|<metaLen>|<encLen>|<kdfLen>|....
v5 (opt-in, --v5)
v5 uses PBKDF2 and leaves the metadata unauthenticated. It exists for decoders that predate v6, and its read compatibility is permanent.
| | | |---|---| | Cipher | AES-256-GCM | | Key derivation | PBKDF2-SHA256, 100,000 iterations | | Key | 256-bit | | IV | 96-bit, random per part | | Salt | 128-bit, shared per session | | Tag | 128-bit |
v6 requires the native engine to produce; --v5 is the only format the
JavaScript fallback can emit. The decoder reads both versions without a flag.
Decode inputs
decode accepts:
- a single encoded
.xlsx/.docx, - a directory containing the sibling parts,
- a ZIP holding the parts.
A ZIP is expanded, non-container entries are ignored, and the first complete part set is decoded. Only ZIP is supported; RAR and 7z would need extra decoders.
Browser tool
The same engine also runs as WebAssembly in a single self-contained HTML file, for offline use where installing anything is not an option.
npx @stegdoc/web # writes ./stegdoc.htmlOr build it from source:
pnpm install
pnpm build:web # -> dist/stegdoc.htmlOpen the file in a browser. Encoding and decoding run in a Web Worker
so the page stays responsive; nothing is uploaded and no network is used. The UI
offers XLSX or DOCX, v6 or v5, an optional password, an optional chunk size, and
Brotli compression. Several files can be selected at once and are bundled into
one zip before encoding. When the browser supports the File System Access API,
the page asks for an output folder and writes the documents straight into it;
otherwise a single part downloads directly and a multi-part set downloads as one
stegdoc-parts.zip. Input is held in memory, so the UI warns above 64 MiB and
refuses past 128 MiB with a pointer to the CLI.
Security model
- The native engine performs no filesystem writes. The CLI owns the output path, the overwrite prompt, and the write.
- The original filename in the metadata is untrusted. It is reduced to a bare filename and cannot escape the chosen output directory.
- Decompression output and ZIP expansion are bounded, so a crafted file fails on the cap instead of exhausting memory or disk.
contentHashis verified after decoding and the output is deleted on mismatch.- v6 binds the metadata to the ciphertext; v5 metadata is plaintext and its
integrity rests on
contentHashand the path rules.
The wire format is specified in spec/FORMAT.md.
Compatibility
- The decoder auto-detects the version, so no flag is needed to read old files.
- v6 is the default output.
--v5emits the older format; both cover XLSX and DOCX. - v3/v4 (base64 in a hidden sheet or paragraph, gzip) are no longer readable. The decoder reports that the format is unsupported; re-encode the file with a current version of stegdoc.
- Log-embed (v5/v6) decoding requires the native engine. Without it, the CLI fails loudly rather than falling back to an incomplete reader.
- Encoding and decoding are randomised, so two encodes of the same input are not byte-identical; conformance is defined on the decode direction.
Building from source
Requires Node.js >= 18 and a Rust toolchain.
pnpm install
pnpm build:native # build the napi binding -> native/stegdoc.nodeThen run the CLI directly with node src/index.js <command>.
| Command | Purpose |
|---------|---------|
| pnpm test | conformance against the frozen fixtures (builds native first) |
| pnpm test:rust | the Rust decoder against the same fixtures |
| pnpm test:interop | Rust binary encode -> CLI (napi) decode |
| pnpm test:safety | output-path traversal containment |
| pnpm test:contract | CLI flags, exit codes, filenames |
| pnpm build:wasm | the wasm binding -> web/pkg |
| pnpm build:web | the single-file browser tool -> dist/stegdoc.html |
| pnpm build:web-package | stage @stegdoc/web from dist/ |
| pnpm test:wasm | wasm round-trip, headless in Node |
| pnpm test:browser | the built page, round-trip from file:// |
| cargo test | Rust unit and property tests |
Requirements
- Node.js >= 18 for the CLI
- A supported platform for the prebuilt engine (see Install)
License
MIT — see LICENSE.
