mist
Asymmetric-key audio steganography library that hides encrypted messages inside compressed audio using X25519 and ChaCha20-Poly1305
About mist
This large README is shown as a plain-text preview. Read the full README at the source
<p align="center">
<img src="mist_logo.png" alt="mist logo" width="360">
</p>
# mist
[](https://github.com/iSerganov/mist/actions/workflows/ci.yml)
[](https://github.com/iSerganov/mist/actions/workflows/ci.yml)
[](https://golang.org/)
[](LICENSE)
**Asymmetric-key audio steganography for Go — a library and a command-line tool.**
A sender hides a message using nothing but the recipient's public key. Only the
matching private key can read it back. The payload lives inside the audio
itself — not in tags, comments, or container metadata — so a plain digital
recording of a stream carries the message with it.
Output is Ogg Vorbis or any lossless format your FFmpeg can write: FLAC, WAV,
ALAC, WavPack, TTA, AIFF, CAF and the rest. The extension of `--output` picks
it, exactly as it does for `ffmpeg`.
Mist is built for learning: a place to explore steganography, hybrid
cryptography, and bitstream-level audio embedding in a codebase small enough to
read in an afternoon. Contributions and design discussion are very welcome.
---
## Contents
- [mist](#mist)
- [Contents](#contents)
- [How it works](#how-it-works)
- [Requirements](#requirements)
- [Install](#install)
- [Command line](#command-line)
- [embed](#embed)
- [catch](#catch)
- [formats](#formats)
- [estimate](#estimate)
- [analyze](#analyze)
- [Interrupting a run](#interrupting-a-run)
- [Library](#library)
- [Emitter](#emitter)
- [Catcher](#catcher)
- [Options](#options)
- [Errors](#errors)
- [Choosing a carrier](#choosing-a-carrier)
- [Audio quality](#audio-quality)
- [Supported formats](#supported-formats)
- [Status and limitations](#status-and-limitations)
- [Security notes](#security-notes)
- [Development](#development)
- [Ethics](#ethics)
- [Contributing](#contributing)
---
## How it works
**The message is encrypted before it is hidden.** Mist uses a hybrid box in the
shape of `age` or NaCl's `box`: a freshly generated ephemeral X25519 key agrees a
secret with the recipient's public key, HKDF-SHA256 splits that secret into an
AEAD key, a position-selection key and a length mask, and ChaCha20-Poly1305 seals
the payload. The sender key travels as an Elligator 2 representative, which is
uniformly random bytes, because a bare X25519 key has a structure anyone can
test for. Because that sender key is ephemeral and generated per message, a
sender needs no long-term identity at all. An optional Ed25519 signature can be
added when you *do* want the recipient to know who sent it.
**The hiding place is the audio, and where exactly depends on the format.**
Mist always decodes the carrier to PCM and re-encodes it. For Ogg Vorbis it
then modifies the quantized residue values inside the resulting packets;
extraction is the mirror image and is far cheaper, Huffman-decoding the
bitstream and stopping before the inverse MDCT, so it never reanalyses PCM.
For a lossless format there is no bitstream surgery at all: a lossless encoder
returns its input samples bit for bit, so the bits go straight into the PCM
before encoding and are read back out of the decoded samples.
Everything else is identical either way. The values that carry bits are taken
in a keyed order, never sequentially, and a syndrome-trellis code chooses which
of them to change: about one value for every seven bits, preferring the changes
that disturb the audio least. Every encode carries the same number of bits per
value whether or not there is a real message — short payloads are padded with
CSPRNG filler. Presence and absence are meant to leave the same statistical
footprint. A lossless output also leaves digital silence alone, exactly as a
plain encoder does.
**Audio is divided into frames, and the message goes into the first one with
room for it.** The carrier is split into self-contained 8-second frames. A
message that fits in one frame goes there and stops; a longer one spreads
across as many *consecutive* frames as it needs, each independently sealed
with its own ephemeral key. Every frame not carrying a piece of the message is
filled with CSPRNG bytes at exactly the same density, so a carrying frame and
an empty one are statistically indistinguishable — the padding is not
decoration, it is what stops the payload's location being obvious. There is no
sync marker either: both sides derive frame boundaries from the timestamps
already present in the stream.
One consequence is worth knowing: a listener who joins a live broadcast after
the message's frame (or, for a longer message, after its first frame) has
passed recovers nothing, and a recording that loses that frame — or any one
frame of a longer message's span — loses the message.
## Requirements
- **Go 1.27+**
- **A C compiler and system FFmpeg**, discoverable via `pkg-config`. All codec
I/O goes through libav; Mist deliberately adds no other Vorbis or Ogg library.
```bash
# macOS
brew install ffmpeg pkg-config
# Debian / Ubuntu
sudo apt-get install pkg-config libavformat-dev libavcodec-dev libavutil-dev libswresample-dev libvorbis-dev
```
Building with `CGO_ENABLED=0` still type-checks against a pure-Go stub, which
keeps editors and vet happy, but every encode and decode then reports
unimplemented. Real use needs cgo.
## Install
```bash
# library
go get github.com/iSerganov/mist
# command-line tool
go install github.com/iSerganov/mist/cmd/mist@latest
```
On macOS the repository is also a Homebrew tap. The formula builds from source
against Homebrew's FFmpeg:
```bash
brew tap iSerganov/mist https://github.com/iSerganov/mist
brew install mist
```
The explicit URL is needed because the tap lives in this repository rather than
in a `homebrew-mist` one. Publishing a GitHub release runs
[bump-formula.yml](.github/workflows/bump-formula.yml), which points `url` and
`sha256` in [Formula/mist.rb](Formula/mist.rb) at the new tag and commits to `master`.
## Command line
`mist --version` prints the version. `make build` stamps it from `git describe`;
a `go install` reports the module version, and a plain `go build` reports `dev`.
The `mist` binary has several commands. Colour is enabled for terminals and turned
off automatically for pipes, `TERM=dumb`, and `NO_COLOR`; `--no-color` forces it
off.
### embed
```bash
mist embed --input song.ogg --data "the eagle lands at dawn"
```
```
mist · embed
carrier song.ogg
payload 23 bytes text
output song.stego.ogg vorbis · lossy, bits in the residues
recipient new keypair no --key given
→ re-encoding carrier and embedding…
✓ embedded into song.stego.ogg (129.8 KiB in 273ms)
public key song.stego.pub share with senders
private key song.stego.key keep secret — required to read the message
! without the private key the message cannot be recovered
```
| Flag | Short | Required | Description |
|---|---|---|---|
| `--input` | `-i` | yes | Carrier audio: path, `file://` or `http(s)://` URL |
| `--data` | `-d` | one of | Text to hide |
| `--file` | `-f` | one of | Image, audio or other file to hide; give this or `--data`, not both |
| `--key` | `-k` | no | Recipient **public** key file; a keypair is generated when omitted |
| `--output` | `-o` | no | Destination file; its extension picks the format (default `<input>.stego.ogg`) |
| `--out-codec` | | no | Encoder to use when the container holds more than one, e.g. `alac` for `.m4a` |
**The output format follows the `--output` extension**, the same way `ffmpeg`'s
does:
```bash
mist embed -i song.mp3 -d "the eagle lands at dawn" -o song.flac
mist embed -i song.mp3 -d "the eagle lands at dawn" -o song.m4a --out-codec alac
```
A lossless output is worth preferring when you have the disk space: it is far
roomier and very nearly transparent. Measured against the carrier, a FLAC stego
file scores about 85 dB SDR where the Vorbis path scores about 26 dB — a
lossless encode adds no loss of its own, and the only change is one step in the
last bit of one percent of the samples. Run `mist formats` to see every target
your FFmpeg build can write. An unwritable one — a lossy format other than
Vorbis, or an extension FFmpeg does not know — fails immediately, before the
carrier is even read.
`--file` hides a file instead of text. The payload type follows the file's
content, not its extension: an image or audio file travels as such and comes
back under a generated name (`recovered-<frame>.jpg`), anything else keeps its
base name. A payload larger than one frame is spread across as many as it
needs, so a photo fits when the carrier is long enough; `mist estimate` reports
how much. Lossless output holds far more than Vorbis.
```bash
mist embed -i song.flac -f photo.jpg -o song.stego.flac
```
When `--key` is omitted, Mist generates a keypair and writes it beside the
output, named after it: `song.stego.pub` and `song.stego.key`. Keys are
hex-encoded so they can be inspected and pasted, and the private key is written
with mode `0600`. They are written only after the embed succeeds, so a failed
run leaves nothing behind. Give the public key to anyone who should be able to
send you messages; keep the private key — without it the message is gone.
### catch
```bash
mist catch --input song.stego.ogg --key song.stego.key
```
```
mist · catch
source song.stego.ogg
key song.stego.key
timeout none, read to end of stream
→ listening…
▸ frame 0 23 B the eagle lands at dawn
✓ 1 frame(s) recovered · end of stream
```
| Flag | Short | Required | Description |
|---|---|---|---|
| `--input` | `-i` | yes | Source to read: path, `file://` or `http(s)://` URL |
| `--key` | `-k` | yes | Private key file |
| `--timeout` | `-t` | no | Go duration; `0` (the default) reads until the stream ends |
| `--out-dir` | | no | Directory for recovered images, audio and files (default `.`) |
| `--partial` | | no | Keep what arrived of a spanned message that was cut short |
Text is printed. Any other payload is written to `--out-dir` and its path is
printed. Only the last element of a stored name is used, and an existing file is
never replaced: a taken name becomes `name-1.ext`, `name-2.ext`.
A message spread over several frames is lost when one of its frames is. With
`--partial`, `catch` keeps the part that arrived before the gap instead: text is
printed, anything else is saved with a `.partial` suffix, and a warning says how
much of the message that is. On a terminal, `embed` and `catch` show which frame
they are on and how much of a spanned message has arrived.
A successful scan reports one message, whether it rode in a single frame or
spread across several — `FrameIdx` names the frame where it started. `catch`
keeps reading to the end regardless, because that frame may be anywhere in the
file. A file ends by itself; a live stream runs until `--timeout` elapses or
you interrupt it. The command exits non-zero when nothing was recovered.
Frames that carry no message and frames sealed for somebody else's key are
indistinguishable, so both are skipped in silence. `catch` will never tell you
"something was here but I could not read it" — that distinction would itself be
a signal worth detecting.
`catch` takes no format flag. The stream itself says whether the bits are in
Vorbis residues or in PCM samples, so a FLAC and an Ogg are read by the same
command.
### formats
```bash
mist formats
```
```
mist · formats
.aif aiff pcm_s16be · lossless, bits in the samples
.caf caf alac · lossless, bits in the samples
.flac flac flac · lossless, bits in the samples
.ogg ogg vorbis · lossy, bits in the residues
.wav wav pcm_s16le · lossless, bits in the samples
.wv wv wavpack · lossless, bits in the samples
…
```
The list is not kept inside Mist — it is what the libraries on your machine
report. Give `embed --output` one of those extensions, and name the encoder
with `--out-codec` where a container holds more than one. `--out-codec` accepts
either name libav knows a codec by, the encoder's or the codec's, so both `dca`
and `dts` work.
### estimate
```bash
mist estimate --input song.ogg
```
```
mist · estimate
carrier song.ogg
target ogg/vorbis vorbis · lossy, bits in the residues
→ decoding and probing capacity…
source vorbis · lossy 44100 Hz · 2ch · 128 kbps · 24s
frames ████████████████████████ 3/3 usable 3 total
frame cap 77 B largest message that fits in a single frame
total cap 241 B largest message spanning every usable frame
✓ this carrier can be used with `mist embed`
```
| Flag | Short | Required | Description |
|---|---|---|---|
| `--input` | `-i` | yes | Carrier audio: path, `file://` or `http(s)://` URL |
| `--output` | `-o` | no | Target container, or an output path whose extension names one (default `ogg`) |
| `--out-codec` | | no | Encoder to use when the container holds more than one, e.g. `alac` for `.m4a` |
`estimate` decodes the carrier and re-encodes it for the same target `embed`
would write — `--output`/`--out-codec` pick that target the same way, and no
`--data` or `--key` is needed, since capacity does not depend on the message or
the recipient. `source` is read straight from libav: the carrier's own codec,
container, sample rate, channel count, bitrate (when the source reports one)
and duration.
It reports the real limit `embed` enforces: for Vorbis it groups the
encoder's actual packets into stego frames and counts each one's genuinely
eligible residues, and for a lossless target it computes each frame's sample
capacity directly. `frame cap` is the largest message a single, unspanned
frame can hold — what `embed` uses when a message fits in one frame outright.
`total cap` is the largest message `embed` could carry overall by spreading it
across every usable frame in turn, for one that doesn't. A carrier that
cannot fit even the protocol envelope in any frame exits non-zero with the
same hint `embed` would give:
```bash
mist estimate --input tiny.wav
```
```
mist · estimate
carrier tiny.wav
target ogg/vorbis vorbis · lossy, bits in the residues
→ decoding and probing capacity…
source pcm_s16le · lossless 44100 Hz · 2ch · 1411 kbps · 0s
frames ░░░░░░░░░░░░░░░░░░░░░░░░ 0/1 usable 1 total
frame cap 0 B largest message that fits in a single frame
total cap 0 B largest message spanning every usable frame
✗ mist: carrier has no frame with room for the payload — quiet or tonal audio
yields too few usable residues; try a longer or richer carrier, or a
lossless --output format, which holds far more
```
A lossless target is cheap to check — capacity there is arithmetic over the
sample count, so no full encode is needed — while a Vorbis target costs what
`embed` costs, since only the encoder's real residues say how many are
eligible.
### analyze
```bash
mist analyze ~/Music/incoming # default: the current folder
mist analyze . --reference originals/carrier.wav
```
`analyze` is interactive. It browses a folder: a `..` row for its parent, its
subfolders, then the audio files in it. Each file is shown with what libav
reports about it (the fields `ffprobe` shows: codec, container, sample rate,
channels, bitrate, duration) and its size. Files libav cannot decode as audio
are left out.
| Key | Does |
|---|---|
| `↑`/`↓` (`k`/`j`) | move |
| `enter` (`→`/`l`) | open the folder, or analyze the file |
| `←`/`backspace` (`h`) | go up to the parent folder |
| `/` | filter by name |
| `r` | mark the file as the original to compare against (again to clear) |
| `pgup`/`pgdown` | page through a long folder |
| `b`/`esc` | back to the list from a report; `esc` also cancels a running analysis |
| `q` | quit |
Every audio format is analyzed, and the report asks two separate questions.
**Does it carry a Mist message?** This part runs every stage of
[`make harness`](#development) that one file supports, in the harness's order,
and gives a calibrated probability.
| Stage | What it asks |
|---|---|
| format | Could Mist have written this at all? Mist writes only Ogg Vorbis and lossless audio, so any other lossy file (MP3, AAC, Opus) is 0% for Mist. Its other-tool checks still run |
| chi-square, spa, rs, hcf-com | The classical LSB detectors, on the values Mist embeds into: grid samples for lossless audio, residue indices for Vorbis |
| classifier, markov, rich | The trained wardens, scoring the file with the models the calibration run fitted |
| dead tail | Is the end constant while the start is not, as embedding without filler would leave it? |
| fingerprint | Re-encodes the file with the `ffmpeg` command line at its own settings and compares container and metadata field by field. Context only: any encoder other than ffmpeg differs too. Needs `ffmpeg` and `ffprobe` on `PATH` |
| known cover, sdr | Only with a reference (below) |
| selection, key-aware | Not run: they need the recipient's public key, which `analyze` neither needs nor accepts |
| invariance, perceptual | Not run: they need two embeddings of the original, or ViSQOL/PEAQ |
**Did any other tool hide data in it?** These checks look where steganography
tools and file-joining tricks put data outside the audio itself. None of them is
calibrated, since there is no corpus of other tools' output to calibrate
against, so each reports a level (none, low, medium, high) with the reasons for
it, never a percentage.
| Check | What it looks for |
|---|---|
| container | Bytes after the end of the audio that no tag format accounts for (MP3, AAC, Ogg, WAV, AIFF, MP4), bytes between frames, unknown WAV/AIFF chunks and MP4 atoms, padding that is not empty, files appended to cover art (ID3 `APIC`, FLAC `PICTURE`), large binary `PRIV`/`GEOB` frames, and tag text that reads like base64 or hex. ID3v1, APEv2, Lyrics3 and MusicMatch tags, XMP packets, Content Credentials (`C2PA`), Pro Tools and broadcast chunks, and runs of `0x00`/`0xFF` fill are recognised as ordinary |
| mp3 header bits | The private, copyright, original and emphasis bits of every frame. An encoder sets them once per file; changing from frame to frame they carry one bit per frame |
| mp3 ancillary | The bits between one frame's audio and the next. Encoders leave them zero or write their own name and padding (LAME writes `LAME3.100UUUU…`); anything else, at the entropy of encrypted data, is a payload |
| mp3 granules | Context only: the parity of each granule's length, where MP3Stego hides its bits. An honest encoder's lengths have random parity too, so this cannot be scored without the encoder that made the file |
| silence | Lossless files only: isolated ±1 samples dropped into digital silence, the trace LSB-replacement tools leave. Dither fills silence densely and a fade reaches ±1 in runs, so neither counts |
A tool that changes audio samples as carefully as Mist does leaves nothing
these checks can see; they catch the far more common tricks.
```
mist · analysis · 02 - Ride the Lightning.mp3
mp3 · lossy · 44100 Hz · 2ch · 320 kbps · 6m57s · 15.9 MiB
Mist's own embedding calibrated
✓ format mp3 is lossy and not Vorbis: Mist cannot write it, so it carries no Mist message
Any steganography tool structure and format checks, not calibrated
✓ container nothing hidden found
✓ mp3 header bits nothing hidden found
✓ mp3 ancillary nothing hidden found; 214484 bytes of ancillary data, 0 bytes of it neither zero…
i mp3 granules context: 63910 granules, 49.7% of lengths odd; no Xing/Info header, …Frequently Asked Questions
What is mist?
mist is a Security library for the Go programming language. Asymmetric-key audio steganography library that hides encrypted messages inside compressed audio using X25519 and ChaCha20-Poly1305
How do I install mist?
Install mist with the Go module system using `go get iSerganov/mist`. Check the repository for the current installation instructions.
What category does mist belong to?
mist is listed under Security, specifically Security.