Skip to content

Repository files navigation

Matrishka

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.

License: MIT .NET 10 Platforms

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.

Screenshots

Pick an action Hide videos Extract
Landing screen Hide videos Extract

The same executable is also a full CLI (see below) — the GUI just wraps it.

Status

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.

Download / Build

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 target

The 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.sh is a bash script; on Windows run it under Git Bash / WSL, or invoke dotnet publish directly 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.

Supported containers

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.

GUI

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)

CLI

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).

pack

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.mp4

The 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.

play / extract / info

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 + size

play 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.

add / forget / repack

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-master

recover-cover

matrishka recover-cover trojan.mp4              # writes <name>_cover.<ext> next to the source
matrishka recover-cover trojan.mp4 cover.mp4    # …or an explicit path

Reconstructs 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.

scan

matrishka scan ~/Movies -p mypass

Walks 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).

File format (v0.3)

[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:

  1. Argon2id runs once per secret over (password, global_salt) → per-secret master. If --master is set, Argon2id runs one more time over (master_pw, global_salt)master_master. Strength is selectable via --kdf-strength:

    preset t m p ≈ time
    fast 2 32 MB 2 ~0.5 s
    normal 3 64 MB 2 ~1 s
    paranoid 4 256 MB 4 ~4 s

    normal is 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.

  2. HKDF-Expand (SHA-256) derives the rest of the keys with length-prefixed domain-separation labels (byte-stable, -v3 suffix):

    label used for
    matrishka-toc-enc-v3 AES key for the master TOC region
    matrishka-toc-mac-v3 HMAC key for the master TOC region
    matrishka-tail-mac-v3 HMAC key for the 42-byte tail (master-only)
    matrishka-slot-test-v3 constant-time slot-match test
    matrishka-slot-enc-v3 AES key for the 48-byte slot payload
    matrishka-slot-payload-mask-v3 MAC/verify key for the slot payload
    matrishka-body-enc-v3 AES-256-CTR key for a secret's body region
    matrishka-body-mac-v3 HMAC-SHA256 key for the per-chunk body MACs
    matrishka-body-nonce-v3 AES-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_master still get distinct keystreams.

  3. 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").

Threat model

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 matrishka binary breaks deniability)
  • Known-plaintext attacks if the cover is publicly known
  • Re-encoding / re-muxing of the cover container (always destroys the payload)

Warnings

  • 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 binwalk will flag the trailing data.
  • Do not use publicly known films as covers for critical secrets — known-plaintext size-diff attacks become possible.

Known limitations

  • Passwords linger in process memory. Both --password (System.CommandLine string) and the GUI password input (Avalonia TextBox.Text returns System.String) yield CLR strings that cannot be reliably overwritten — the GC may move, copy, or intern them. We copy the password into a self-zeroing SecureBuffer as early as possible, but the original string can survive until the process exits. Treat the running matrishka process 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 exposes char[] — see the Roadmap below.

Limits

  • Maximum secret size: 60 GB (AES-CTR counter limit).
  • Up to 16 384 secrets per cover (MaxSlotsPadded); slots_padded is 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).

Tests

dotnet test

79 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: TailHeader round-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.FromCharArray zeros input, MP4 pre-sniff filter, MpvLauncher doesn't leak temp config on failure

Roadmap

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.

Contributing

See CONTRIBUTING.md. Security issues: please follow SECURITY.md — do not open a public issue.

License

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.

About

Hide one video inside another — the cover plays normally, the secret streams on-the-fly with a password. Any files work too, not just video.

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages