Hide one video inside another. A cover video plays normally in any player; the hidden secret is decrypted and streamed on-the-fly with a password — and the secret can be any file, not just video.
Matrishka's flagship trick is video-in-video: it embeds one encrypted video inside another. The cover plays normally in any media player, while the hidden video is decrypted on-the-fly through a local HTTP loopback and streamed to a player — nothing is ever written to disk in the clear. But the secret isn't limited to video: you can stash any files — documents, archives, photos, anything — inside a cover video, and recover them later with a password (files-mode packs several under one master password). See the Threat model section below for what this does and does not protect against.
| Pick an action | Hide videos | Extract |
|---|---|---|
![]() |
![]() |
![]() |
The same executable is also a full CLI (see below) — the GUI just wraps it.
v0.3.0 — the current file format (cover + slot area + 74-byte tail + encrypted TOC,
no magic bytes by design). Clean Release build, 79/79 tests passing on Linux,
Windows, and macOS via the CI matrix in .github/workflows/ci.yml. CLI and Avalonia
GUI both ship from a single binary.
There are no prebuilt release artifacts yet — to run matrishka, build a binary from
source. The supported way to produce a self-contained single-file executable for any
platform is build.sh:
./build.sh # builds for all platforms: linux-x64, linux-arm64, win-x64, osx-x64, osx-arm64
./build.sh linux-x64 # …or just one targetThe script just wraps dotnet publish -r <rid> --self-contained true -p:PublishSingleFile=true
on src/Matrishka.App/Matrishka.App.csproj and trims the output down to the bare executable.
Result: dist/<rid>/matrishka (or matrishka.exe on Windows), ~58 MB, no .NET runtime
needed on the target machine. Drop it anywhere in PATH and run.
build.shis a bash script; on Windows run it under Git Bash / WSL, or invokedotnet publishdirectly with the same flags.
Development build (requires .NET 10 SDK):
dotnet build -c Release
# binary at src/Matrishka.App/bin/Release/net10.0/matrishka(.exe)See Tests below for running the test suite.
| Format | Extensions | Wrapper | Stealth |
|---|---|---|---|
| MP4 family (ISO BMFF) | .mp4 .mov .m4v .3gp .3g2 |
16-byte free box |
High (valid MP4 padding) |
| Matroska / WebM (EBML) | .mkv .webm |
9 B EBML Void | Medium (most players ignore) |
| AVI (RIFF) | .avi |
8 B RIFF JUNK | Medium (most players ignore) |
Format is detected by content sniffing the first 16 bytes (file extension is informational). The secret can be any binary — it does not have to match the cover format.
Run matrishka with no arguments to open the graphical interface (Avalonia, cross-platform). The window has four big cards — Hide, Open, Save, Find — and supports drag-and-drop from the file manager.
The same binary serves both UI and CLI; the dispatch is:
matrishka → opens the GUI window
matrishka pack ... → runs CLI (any non-zero args)
matrishka pack <cover> --secret <file> --password <pw> [--secret <file> --password <pw> ...]
[--label <lbl>] ... [--master <pw>] [-o <output>] [--kdf-strength fast|normal|paranoid]
matrishka play <file> [-p <pw>] [--index <n>] [--player <path>]
matrishka extract <file> <out|dir> [-p <pw>] [--index <n>] [--all]
matrishka info <file> [-p <pw>]
matrishka scan <folder> [-p <pw>]
matrishka add <file> --secret <new> --new-password <pw>
--open-password <pw> [--open-password <pw> ...]
[--label <lbl>] [--kdf-strength fast|normal|paranoid]
matrishka forget <file> --forget-password <pw>
--keep-password <pw> [--keep-password <pw> ...]
[--kdf-strength fast|normal|paranoid]
matrishka repack <file> <output> [-p <pw> ...]
[--add-secret <file> --add-password <pw> ...] [--add-label <lbl> ...]
[--master <pw>] [--kdf-strength fast|normal|paranoid]
matrishka recover-cover <file> [output]
If -p / --password is omitted where it is required, the password is prompted with
masking (Backspace and Escape supported).
Secrets are passed via the repeatable --secret option, each paired with a positional-repeatable
--password (counts must match). An optional --master encrypts a TOC that lets the master
password list / extract / play every secret in the file.
# one secret, no master — the simplest case
matrishka pack ozero.mp4 --secret family.mkv --password mypass -o trojan.mp4
# three secrets under a master password
matrishka pack ozero.mp4 \
--secret a.mkv --password pa --label "alice" \
--secret b.mkv --password pb --label "bob" \
--secret c.mkv --password pc --label "carol" \
--master secret-master -o trojan.mp4
# weakest KDF for fast iteration on tiny test files
matrishka pack cover.mp4 --secret s.bin --password p --kdf-strength fast -o out.mp4The cover container is auto-detected (MP4 / MOV / MKV / WEBM / AVI), the secret can be any binary (the round-trip recovers the exact bytes). The output file is marked read-only to discourage accidental modification by media servers.
matrishka play trojan.mp4 # decrypts on-the-fly via 127.0.0.1 → mpv
matrishka play trojan.mp4 -p master --index 2 # master mode: play the 2nd secret
matrishka extract trojan.mp4 recovered.mkv # decrypts to a real file (not read-only)
matrishka extract trojan.mp4 outdir/ -p master --all # master mode: extract every secret into a dir
matrishka info trojan.mp4 # structural info (no password)
matrishka info trojan.mp4 -p master # master mode: list every secret + label + sizeplay decrypts through a 127.0.0.1 HTTP loopback server with Bearer-token auth and
launches the player. Requires mpv in PATH (override with --player /path/to/mpv).
The token is passed via HTTP header so it never appears in process command-line,
history, or watch_later files. 24-hour safety timeout.
info reads top-level container structure plus the v3 tail (cover size, KDF params,
slots_padded, presence of a master TOC). For MP4 it walks all boxes; for MKV/AVI it
only confirms the format. With a master password it additionally lists every secret with
its label and size.
These commands rebuild the file (the on-disk format is write-once). add appends one
secret, forget removes one, and repack is the general-purpose "open with these
passwords, optionally add / remove / re-master, write a fresh file" path. All three
take --kdf-strength.
# add a new secret to an existing file (supply every password you want to keep)
matrishka add trojan.mp4 \
--secret new.mkv --new-password pnew \
--open-password pa --open-password pb --open-password pc
# remove the secret that opens with "pb"
matrishka forget trojan.mp4 --forget-password pb \
--keep-password pa --keep-password pc
# full rebuild: keep two secrets, add two more, set a brand-new master
matrishka repack trojan.mp4 fresh.mp4 \
-p pa -p pc \
--add-secret d.mkv --add-password pd --add-label "dave" \
--add-secret e.mkv --add-password pe --add-label "eve" \
--master new-mastermatrishka recover-cover trojan.mp4 # writes <name>_cover.<ext> next to the source
matrishka recover-cover trojan.mp4 cover.mp4 # …or an explicit pathReconstructs the original cover bytes from a matrishka. No password required — the cover is plaintext by design (it has to play in any media player). MKV/AVI are byte-identical to the source; MP4 may differ in ~8 bytes if the original had a size=0 last top-level box (we rewrote it to explicit size at pack time) — the recovered MP4 still plays identically.
matrishka scan ~/Movies -p mypassWalks a folder recursively and tries the supplied password against every video file. The format has no magic byte or fast-fail signature, so every candidate pays the full ~1 s Argon2id cost — this is the deniability tradeoff. Each hit is reported as individual (single secret) or master (count of secrets).
[0 .. cover_size) — original cover (unmodified)
[cover_size .. cover_size + W) — container wrapper:
W = 16 B `free` (MP4), 9 B Void (MKV), 8 B JUNK (AVI)
[ body ]
[ secret regions, tightly packed ] — one AEAD region per secret (no gaps between them)
[ encrypted TOC region (only if --master)] — nonce(12) | ciphertext(N) | mac(32)
[ random padding ] — until (body + tail) is a multiple of 1 MiB
[ tail (read from EOF backward) ]
[ slot_area ] — slots_padded × 108 B (always a power of 2, ≥ 256)
[ global_salt : 16 B ] — single salt used by every Argon2id run
[ tail_mac : 32 B ] — HMAC-SHA256 over the 42 B below (master-only)
[ toc_offset : 8 B BE ] — body-local offset to the TOC region
[ toc_size : 4 B ]
[ slots_padded : 2 B ] — power of 2 in [256, 16384]
[ flags : 1 B ] — bit0 = has_master_toc
[ cover_size : 7 B BE ] — 56-bit, caps cover at ~72 PB
[ kdf_params : 4 B ] — [ver=3, argon2_t, m_log2_MB, argon2_p] ← EOF
Fixed tail is 74 bytes; total tail size is 74 + slots_padded × 108. The body +
tail is padded up to a 1 MiB boundary so the total file size never leaks the secret size
to within a megabyte.
No magic, no CRC. By design: any structural marker is a forensic signature that breaks the deniability promise. The cost is that "is this a matrishka?" can only be answered by trying a password (~1 s of Argon2id).
Slot internals (108 B each): salt(16) || nonce(12) || enc_payload(48) || mac(32).
Encrypted payload is body_master(32) || body_offset(8) || body_length(8). Populated
slots hide among slots_padded − populated random decoy slots; the scan always walks
all slots and always performs exactly one decrypt, so it leaks slots_padded (already
public in the tail) but not the populated count or the matched index.
TOC region (only written when --master is set): encrypted under the master's
master key with a TLV attribute payload per secret (Label, Mime, MtimeMs).
Without a master the format still reserves a TOC offset/size, filled with random bytes
so the tail looks identical.
Crypto pipeline:
-
Argon2id runs once per secret over
(password, global_salt)→ per-secretmaster. If--masteris set, Argon2id runs one more time over(master_pw, global_salt)→master_master. Strength is selectable via--kdf-strength:preset t m p ≈ time fast2 32 MB 2 ~0.5 s normal3 64 MB 2 ~1 s paranoid4 256 MB 4 ~4 s normalis the default. The four plaintext bytes at the very end of the file (kdf_params) record the chosen preset, so the reader always uses the params the packer wrote — there is no separate negotiation. -
HKDF-Expand (SHA-256) derives the rest of the keys with length-prefixed domain-separation labels (byte-stable,
-v3suffix):label used for matrishka-toc-enc-v3AES key for the master TOC region matrishka-toc-mac-v3HMAC key for the master TOC region matrishka-tail-mac-v3HMAC key for the 42-byte tail (master-only) matrishka-slot-test-v3constant-time slot-match test matrishka-slot-enc-v3AES key for the 48-byte slot payload matrishka-slot-payload-mask-v3MAC/verify key for the slot payload matrishka-body-enc-v3AES-256-CTR key for a secret's body region matrishka-body-mac-v3HMAC-SHA256 key for the per-chunk body MACs matrishka-body-nonce-v3AES-CTR nonce for a secret's body region All three body keys are bound to the secret's slot index, so two secrets that accidentally share a
body_masterstill get distinct keystreams. -
Body AEAD: 1 MiB AES-256-CTR chunks, each followed by a 32-byte
HMAC-SHA256(chunk_index_be64 || ciphertext)tag. The chunk index in the MAC input prevents reordering / swapping attacks.
Per-file overhead: wrapper (0/8/9/16) + 74 B fixed tail + slots_padded × 108 B + 32 B per 1 MiB chunk per secret + padding up to the next 1 MiB. For a single small
secret the constant overhead is around 28 KB (dominated by the minimum 256-slot table);
for a 1 GB secret it is ≈ 0.003 % of the secret size.
Tamper resistance: every chunk, every slot, the TOC region, and (with a master) the
fixed tail header carry HMAC-SHA256 tags verified with constant-time compare. Bit-flips,
swaps, or substitutions are detected immediately — extract fails with a clear
"HMAC verification failed" rather than producing garbled output. On the master-open
path, a tampered tail header raises TailTamperedException (distinct from "wrong
password").
Defends against:
- Casual observers (your friend looking through movies on your NAS)
- Simple forensic tools (
file,binwalk) - Automated media servers — with caveats below
- Bit-flipping / tampering attacks on the encrypted region (per-chunk HMAC detects any modification)
Does NOT defend against:
- Forensics-grade entropy analysis of file tail
- Government coercion (presence of
matrishkabinary breaks deniability) - Known-plaintext attacks if the cover is publicly known
- Re-encoding / re-muxing of the cover container (always destroys the payload)
- ⚠ Do NOT store matrishka files in media-server libraries (Plex, Jellyfin, Emby, Kodi auto-organize, Handbrake batch transcoding). These will repack the container and destroy the hidden payload.
- ⚠ Forgetting the password = data loss. No recovery.
- ⚠ MP4 covers are the most stealthy. MKV/AVI wrap the tail in a small native element (EBML Void / RIFF JUNK), but the bulk of the payload still follows as trailing data. Lenient players still play them, but
binwalkwill flag the trailing data. - ⚠ Do not use publicly known films as covers for critical secrets — known-plaintext size-diff attacks become possible.
- Passwords linger in process memory. Both
--password(System.CommandLine string) and the GUI password input (AvaloniaTextBox.TextreturnsSystem.String) yield CLR strings that cannot be reliably overwritten — the GC may move, copy, or intern them. We copy the password into a self-zeroingSecureBufferas early as possible, but the original string can survive until the process exits. Treat the runningmatrishkaprocess as holding key material for its lifetime; do not run unattended on a multi-user machine and do not produce core dumps. Fully fixing this needs a custom Avalonia password control that exposeschar[]— see the Roadmap below.
- Maximum secret size: 60 GB (AES-CTR counter limit).
- Up to 16 384 secrets per cover (
MaxSlotsPadded);slots_paddedis always a power of two ≥ 256. - Cover sniffed by content, so renamed files still work; but extension matters for
scan(only known video extensions are scanned).
dotnet test79 tests cover:
- Crypto: NIST SP 800-38A AES-CTR vector, RFC 5869 HKDF vector, Argon2id determinism, chunked AEAD round-trip, bit-flip detection, MAC tampering detection (chunk + tail), chunk-index reordering rejection
- Parsers: MP4 box walker (multi-mdat, extended size, size=0 fix-up), EBML / RIFF sniffing
- Tail / format:
TailHeaderround-trip read/write, field-bounds validation (TOC offset/size, cover size,slots_padded), slot-count spec checks, tail-MAC tampering rejection - Integration: pack/extract round-trip on all container formats, wrong-password rejection, Range header parsing (3 forms), HTTP loopback server (auth, ranges, cross-chunk reads)
- Regressions:
SecureBuffer.FromCharArrayzeros input, MP4 pre-sniff filter,MpvLauncherdoesn't leak temp config on failure
Matrishka is pre-1.0 and evolving. Rough direction:
- Now — CLI + Avalonia GUI, chunked AEAD, multi-container (MP4/MKV/AVI), multi-secret packing.
- Next — a custom Avalonia password control exposing
char[](see Known limitations), more container formats, hardening around large-file streaming. - Later — server mode (decrypted streams exposed to set-top boxes over the local network), companion mobile viewer.
Ideas and PRs welcome — see CONTRIBUTING.md.
See CONTRIBUTING.md. Security issues: please follow SECURITY.md — do not open a public issue.
MIT © 2026 universeissilent42.
The application icon is a derivative of the OpenMoji "Nesting Dolls" emoji (CC BY-SA 4.0);
see src/Matrishka.App/Assets/icon/NOTICE.md.
Third-party dependency licenses are listed in THIRD-PARTY-NOTICES.md.


