mist

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 17 Imported by: 0

README

mist

CI Coverage Go Version 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 compressed Ogg Vorbis audio itself — not in tags, comments, or container metadata — so a plain digital recording of a stream carries the message with it.

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


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. 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 compressed audio. Mist decodes the carrier to PCM, re-encodes it to Vorbis, and then modifies the quantized residue values inside the resulting packets. Extraction is the mirror image and is far cheaper: it Huffman-decodes the bitstream and stops before the inverse MDCT, so it never reanalyses PCM. Which residues get touched is chosen by a keyed PRNG, never sequentially, and every encode perturbs the same fraction of eligible residues 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.

Audio is divided into frames, and the message goes into one of them. The carrier is split into self-contained 8-second frames, and the sealed message is written into the first frame with room for it. Every other frame is filled with CSPRNG bytes at exactly the same density, so the frame carrying the message is statistically indistinguishable from the ones carrying nothing — 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 packet timestamps already present in the stream.

One consequence is worth knowing: because the message lives in a single frame, a listener who joins a live broadcast after that frame has passed recovers nothing, and a recording that loses that frame 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.
# macOS
brew install ffmpeg pkg-config

# Debian / Ubuntu
sudo apt-get install pkg-config libavformat-dev libavcodec-dev libavutil-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

# library
go get github.com/iSerganov/mist

# command-line tool
go install github.com/iSerganov/mist/cmd/mist@latest

Command line

The mist binary has two commands. Colour is enabled for terminals and turned off automatically for pipes, TERM=dumb, and NO_COLOR; --no-color forces it off.

embed
mist embed --input song.ogg --data "the eagle lands at dawn"
  mist · embed

  carrier     song.ogg
  payload     23 bytes  text
  output      song.stego.ogg
  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 yes Text to hide
--key -k no Recipient public key file; a keypair is generated when omitted
--output -o no Destination file (default <input>.stego.ogg)

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

A successful scan reports one frame, since the message is embedded once. catch keeps reading to the end regardless, because the carrying 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.

Interrupting a run

Both commands install a signal handler for SIGINT, SIGTERM and SIGHUP, which cancels the context they run under. catch stops listening and reports whatever it recovered up to that point; embed abandons the run without writing an output file or any keys, since a half-embedded carrier is of no use to anybody. SIGKILL cannot be trapped by any process, so it terminates the tool immediately as it would anything else.

Library

Runnable Godoc examples live in the example package (go test ./example).

Emitter
pub, priv, err := mist.GenerateKeyPair()

emitter, err := mist.NewEmitter(pub)
stego, err := emitter.EmbedReader(ctx, carrier, mist.Text("hello"))
defer stego.Close()
io.Copy(dst, stego)

Embed(ctx, source, payload) takes a path or URL, EmbedReader an open io.Reader, and EmbedFile an *os.File. All three return an io.ReadCloser producing a new Ogg Vorbis stream, which the caller must close.

Catcher
catcher, err := mist.NewCatcher(priv)
results, err := catcher.Listen(ctx, "https://radio.example.com/live.ogg")
for r := range results {
    fmt.Println(r.FrameIdx, string(r.Payload.Data))
}
if ctx.Err() != nil {
    // stopped by the caller rather than by end of stream
}

Listen accepts a path or URL and streams results as they are recovered. The channel closes when the source ends or when the context is cancelled; check ctx.Err() afterwards to tell the two apart. ListenReader does the same over an open reader, and Extract scans an *os.File synchronously and returns a slice. Prefer Listen for live sources: ListenReader buffers a non-seekable reader in full before it starts.

Options
Option Applies to Purpose
WithSenderAuth(priv) Emitter Add an Ed25519 signature over the plaintext
WithMaxRetries(n) Catcher Tolerate transient read failures on a live source
WithBackoff(d) Catcher Pause between those retries
WithLogger(*slog.Logger) Catcher Receive diagnostics such as joining mid-frame

The logger is discarded by default and never receives failed decrypts.

Errors
Sentinel Meaning
ErrInvalidKey Key missing or the wrong size
ErrInvalidPayload Payload malformed or too large for a frame
ErrInvalidSource Empty, nil or unusable source argument
ErrUnsupportedCodec Carrier codec Mist cannot decode, or a non-Vorbis extraction source
ErrCarrier Source could not be opened, demuxed or decoded
ErrNoCapacity No frame had room for the message, so nothing was embedded

ErrNoCapacity is deliberately loud. Everything on the reading side fails silently, but an Embed that quietly produced a file containing nothing would be a much worse outcome than an error.

Choosing a carrier

This matters more than it might seem. The payload rides in quantized residues, and quiet or purely tonal audio simply does not produce many usable ones. A pure 440 Hz sine wave yields so few that an 8-second frame cannot hold even the 64-byte envelope, and embed fails with ErrNoCapacity rather than writing a file that silently carries nothing.

Music, speech, and anything with broadband content are all comfortable carriers. As a rough sense of scale, an 8-second frame of ordinary stereo music holds a little over a hundred bytes — a sentence or two, not a document. Capacity is deliberately modest: embedding is confined to the frequencies where it cannot be heard, and every extra byte is extra distortion.

Since the message occupies a single frame, a longer carrier does not buy more room; a richer one does. If your message does not fit, shorten it or pick a carrier with more high-frequency content.

FrameCapacity() reports a rough upper bound and over-estimates, because it does not account for how many residues are actually usable. Treat it as a planning hint; the real limit is enforced when you call Embed.

Audio quality

Mist always decodes and re-encodes, so some loss is unavoidable for a lossy source — but the embedding itself should be inaudible. The encode bitrate is derived from the carrier's own rate with headroom above it, rather than fixed, and the payload is written only above 6 kHz at a low density.

Measured on a 128 kbps MP3, against a plain FFmpeg transcode of the same decoded audio (signal-to-distortion, higher is better):

SDR
plain transcode, no embedding 22.75 dB
mist re-encode, no embedding 22.76 dB
mist with a message embedded 22.58 dB

So the message costs under 0.2 dB — the re-encode itself dominates, and that is the price of the format, not of the steganography.

Supported formats

Output is always Ogg Vorbis. The payload lives in Vorbis residues, so this is fixed by the technique rather than by a temporary limitation.

Input is whatever your FFmpeg can decode. The carrier is decoded to PCM and re-encoded, so it need not already be Ogg, and Mist keeps no list of acceptable formats: it carries libav's own codec id through and asks libav whether a decoder exists. MP3, FLAC, WAV, Opus, AAC/M4A, ALAC and the rest all work if your build supports them, and a format yours cannot decode is reported as ErrUnsupportedCodec naming the codec. Sample format does not matter either — FLAC's integer samples and Vorbis's floats are normalised alike.

Note that an Ogg Vorbis input is still decoded and re-encoded, with the generation loss that implies. This is deliberate: Mist owns the whole encode path and does not patch somebody else's existing bitstream. Extraction, by contrast, requires Ogg Vorbis and never re-encodes anything.

Status and limitations

Phase 1 is complete end to end. Crypto, wire framing, the libav cgo layer, Vorbis residue parse and rewrite, keyed position selection, constant-density embedding, the Emitter, the Catcher, and the CLI are all implemented and tested, including multi-frame carriers and live streams.

Known gaps, all recorded in CLAUDE.md:

  • FrameCapacity() over-estimates, as described above.
  • Embed over http(s) buffers a finite file rather than streaming a live source.
  • A listener that joins mid-frame cannot align to that frame; it says so once and resumes cleanly from the next one.
  • A scan goroutine parked in a blocking libav read outlives its context until the read returns. Listen's channel still closes immediately on cancellation, so callers are unaffected.

Security notes

The design target is a passive warden who knows exactly how Mist works and has the file, but not the private key — Kerckhoffs's principle, with all security resting on the key. Encrypting the payload stops it being read; the keyed position selection and constant embedding density are what aim to stop it being noticed.

One consequence is worth spelling out: position selection derives from the recipient's public key, which is not a secret. Anyone holding it can work out which residues to look at, so everything they would find there must be indistinguishable from noise — which is why the ciphertext length is masked with a subkey derived from the ECDH shared secret rather than stored in the clear.

Explicitly not goals in Phase 1: surviving a digital-to-analog-to-digital round trip, surviving re-encoding by a different encoder, embedding into pre-existing files produced elsewhere, and deniability under coercion (undetectability and deniability are different guarantees). "Undetectable" also remains a design target rather than a proven property: a benchmark suite running real steganalysis detectors against Mist's output does not exist yet, and building one is an open task.

Development

make build                       # -> bin/mist
make keys                        # X25519 keypair via OpenSSL -> keys/mist.{pub,key}
make embed INPUT=song.mp3 DATA="hello" OUTPUT=out.ogg KEY=keys/mist.pub
make catch INPUT=out.ogg KEY=keys/mist.key TIMEOUT=30s
make test                        # go test -race -count=1 ./...
make lint                        # golangci-lint run --timeout=5m

make keys writes the raw key bytes as hex, which is the format the CLI reads. OpenSSL stores X25519 keys as PKCS#8/SPKI DER whose final 32 bytes are the key itself, so the pair it produces is interchangeable with one mist embed generates for itself.

CLAUDE.md is the design reference: package layout, protocol constants, the invariants the stego layer must preserve, and the conventions used throughout. Read it before changing anything under internal/.

CI runs golangci-lint v2.11.4 and race tests with a coverage badge, matching gocue.

Ethics

This project exists for legitimate confidential communication, digital watermarking, and steganography/steganalysis research. It is not designed for, and should not be used for, concealing illegal content or evading its detection.

Contributing

Issues, design notes, and pull requests are very welcome — this project is meant to be learned from and built with. If you are adding to internal/, please read the invariants in CLAUDE.md first; several of them are load-bearing in non-obvious ways, and the tests exist to catch exactly those cases.

Documentation

Overview

Package mist is an asymmetric-key audio steganography library.

A sender embeds a payload using only the recipient's X25519 public key. Only the matching private key can recover it. Phase 1 carries text inside Ogg Vorbis audio packets — not container metadata — so a digital recording of a live stream is enough to extract the message.

Hybrid encryption follows the age / NaCl box shape: an ephemeral X25519 key agreement derives a ChaCha20-Poly1305 key. Each stego frame is sealed independently so repeating a payload across a live stream does not produce a correlatable ciphertext. Unused capacity is filled with CSPRNG bytes at the same embedding density as a real message, so presence and absence have the same statistical footprint.

Codec I/O goes through libav (libavformat, libavcodec, libavutil) via cgo. Coefficient-domain embedding is intentionally behind a Codec interface so WAV/PCM and MP3 can be added later without touching crypto or framing.

Index

Constants

View Source
const (
	PublicKeySize  = 32
	PrivateKeySize = 32
)

X25519 key sizes in bytes.

View Source
const (
	SigningPublicKeySize  = 32
	SigningPrivateKeySize = 64
	SignatureSize         = 64
)

Ed25519 sizes for optional sender authentication.

View Source
const (
	NonceSize = 12
	TagSize   = 16
)

ChaCha20-Poly1305 sizes in bytes.

View Source
const EnvelopeOverhead = PublicKeySize + LengthSize + NonceSize + TagSize

EnvelopeOverhead is the number of bytes added by hybrid encryption before embedding: ephemeral public key + masked length + nonce + Poly1305 tag.

View Source
const FrameDuration = 8 * time.Second

FrameDuration is the fixed stego-frame length. Embed loops the payload across consecutive frames of this duration; Listen uses the same value to recover frame phase. It is a protocol constant, not a per-call option.

View Source
const LengthSize = 4

LengthSize is the masked ciphertext-length field that lets a receiver read exactly the right number of bytes instead of searching for the end.

View Source
const Version byte = 1

Version is the payload framing version written inside the ciphertext.

Variables

View Source
var (
	ErrInvalidKey       = errors.New("mist: invalid key")
	ErrInvalidPayload   = errors.New("mist: invalid payload")
	ErrInvalidSource    = errors.New("mist: invalid source")
	ErrUnsupportedCodec = errors.New("mist: unsupported codec")
	ErrCarrier          = errors.New("mist: unreadable carrier")
	ErrClosed           = errors.New("mist: already closed")
	// ErrNoCapacity means no frame in the carrier had room for the
	// envelope, so nothing was embedded. Quiet or tonal audio yields too
	// few usable residues; a richer or longer carrier fixes it.
	ErrNoCapacity = errors.New("mist: carrier has no frame with room for the payload")
)

Sentinel errors returned by the public API. Per-frame decrypt failures inside Listen are not surfaced — they are indistinguishable from frames that carry no payload and must stay silent.

Functions

func FrameCapacity

func FrameCapacity() int

FrameCapacity returns the maximum payload bytes embeddable per stego frame at the library's fixed embedding density, before encryption overhead. Useful for callers who need to know the maximum message size supported without multi-frame spanning (Phase 2).

func GenerateKeyPair

func GenerateKeyPair() (pub, priv []byte, err error)

GenerateKeyPair creates a new X25519 keypair for a recipient. pub is shared freely with senders; priv is kept secret by the recipient.

func GenerateSigningKeyPair

func GenerateSigningKeyPair() (pub, priv []byte, err error)

GenerateSigningKeyPair creates an Ed25519 keypair for optional sender authentication. Pass the private key to NewEmitter via WithSenderAuth.

Types

type Catcher

type Catcher struct {
	// contains filtered or unexported fields
}

Catcher extracts payloads with a recipient private key. Construct it once with NewCatcher and share it; methods do not mutate it.

func NewCatcher

func NewCatcher(privKey []byte, opts ...CatcherOption) (*Catcher, error)

NewCatcher returns an immutable Catcher for privKey. The key and any option-held secrets are copied; later writes to the caller's slices are not observed.

func (*Catcher) Extract

func (c *Catcher) Extract(ctx context.Context, source *os.File) ([]Result, error)

Extract scans source synchronously and returns every frame that decrypts and authenticates. It stops at EOF or when ctx is cancelled. source stays owned by the caller.

func (*Catcher) Listen

func (c *Catcher) Listen(ctx context.Context, source string) (<-chan Result, error)

Listen opens source (a local file path, file:// URL, or http(s):// URL), continuously scans for stego frames, and sends each successfully decrypted payload to the returned channel.

The returned channel is closed when either:

  • the source reaches EOF (finite file or stream ended by the server), or
  • ctx is cancelled.

The caller distinguishes the two after the channel closes:

  • ctx.Err() == nil → source reached EOF naturally
  • ctx.Err() != nil → context was cancelled by the caller

The error return covers only immediate setup failures (invalid source, unreadable file, malformed key). Per-frame decrypt failures are silently skipped — they are indistinguishable from frames carrying no payload and must not surface as errors.

func (*Catcher) ListenReader

func (c *Catcher) ListenReader(ctx context.Context, r io.Reader) (<-chan Result, error)

ListenReader is Listen over an already-open bitstream (pipe, HTTP body, in-memory buffer). The caller retains ownership of r and must close it if it is an io.Closer.

type CatcherOption

type CatcherOption func(*Catcher)

CatcherOption configures NewCatcher.

func WithBackoff

func WithBackoff(backoff time.Duration) CatcherOption

WithBackoff configures the pause between read retries.

func WithLogger

func WithLogger(log *slog.Logger) CatcherOption

WithLogger directs diagnostics — such as joining a stream mid-frame — to log. Failed decrypts are never logged: they are indistinguishable from frames carrying no payload. Discarded by default.

func WithMaxRetries

func WithMaxRetries(retries int) CatcherOption

WithMaxRetries configures how many consecutive read failures a live source may produce before Listen gives up and closes the channel.

type Emitter

type Emitter struct {
	// contains filtered or unexported fields
}

Emitter encrypts a payload for a recipient public key and embeds it into an Ogg Vorbis carrier. Construct it once with NewEmitter and share it. Methods do not mutate the Emitter.

func NewEmitter

func NewEmitter(recipientPubKey []byte, opts ...EmitterOption) (*Emitter, error)

NewEmitter returns an Emitter for recipientPubKey. The key and any option-held secrets are copied; later writes to the caller's slices are not observed.

func (*Emitter) Embed

func (e *Emitter) Embed(ctx context.Context, source string, payload Payload) (io.ReadCloser, error)

Embed opens source (a local file path, file:// URL, or http(s):// URL) as the carrier, encrypts payload for the recipient, and returns a new stego Ogg stream.

The payload is looped across fixed-duration stego frames, each re-encrypted with a fresh ephemeral X25519 key, for the full duration of the carrier. Suitable for both finite files and wrapping a live encoder output — the returned reader produces output as fast as the carrier does.

func (*Emitter) EmbedFile

func (e *Emitter) EmbedFile(ctx context.Context, carrier *os.File, payload Payload) (io.ReadCloser, error)

EmbedFile is Embed over an *os.File carrier. The file stays owned by the caller.

func (*Emitter) EmbedReader

func (e *Emitter) EmbedReader(ctx context.Context, carrier io.Reader, payload Payload) (io.ReadCloser, error)

EmbedReader is Embed over an already-open carrier (pipe, HTTP body, in-memory buffer). The caller retains ownership of carrier and must close it if it is an io.Closer.

type EmitterOption

type EmitterOption func(*Emitter)

EmitterOption configures NewEmitter.

func WithSenderAuth

func WithSenderAuth(senderPrivKey []byte) EmitterOption

WithSenderAuth attaches an Ed25519 signature over the plaintext, itself encrypted before embedding. Off by default — an unauthenticated ciphertext is shorter and one step closer to looking like nothing.

type Payload

type Payload struct {
	Type PayloadType
	Data []byte
}

Payload is the unit that Emitter encrypts and Catcher emits. Phase 1 only uses PayloadText; the wire format is already type-agnostic.

func Text

func Text(s string) Payload

Text returns a text payload.

type PayloadType

type PayloadType byte

PayloadType identifies the kind of data inside a Payload. The type byte is encrypted with the payload, so a warden who isolates the embedded bytes still cannot read it without the private key.

const (
	PayloadText  PayloadType = 0x01
	PayloadImage PayloadType = 0x02
	PayloadAudio PayloadType = 0x03
	PayloadFile  PayloadType = 0x04
)

type Result

type Result struct {
	Payload  Payload
	FrameIdx int64 // ordinal of the frame within this Listen session (0-based)
}

Result is emitted by Catcher whenever a stego frame decrypts and authenticates successfully.

Directories

Path Synopsis
cmd
mist command
Command mist hides a text message inside an audio file and recovers it again with the matching private key.
Command mist hides a text message inside an audio file and recovers it again with the matching private key.
Package example contains Godoc examples for github.com/iSerganov/mist.
Package example contains Godoc examples for github.com/iSerganov/mist.
internal
av
Package av is the cgo boundary to libavformat, libavcodec, and libavutil.
Package av is the cgo boundary to libavformat, libavcodec, and libavutil.
codec
Package codec is the boundary between PCM and a compressed bitstream.
Package codec is the boundary between PCM and a compressed bitstream.
codec/vorbis
Package vorbis is the Phase 1 codec: PCM ↔ Ogg Vorbis via package av, plus a bitstream residue parser used by stego.
Package vorbis is the Phase 1 codec: PCM ↔ Ogg Vorbis via package av, plus a bitstream residue parser used by stego.
crypto
Package crypto is the hybrid box: X25519, HKDF, ChaCha20-Poly1305, Ed25519.
Package crypto is the hybrid box: X25519, HKDF, ChaCha20-Poly1305, Ed25519.
frame
Package frame models fixed-duration stego frames.
Package frame models fixed-duration stego frames.
stego
Package stego embeds and extracts bit strings in quantized Vorbis residues.
Package stego embeds and extracts bit strings in quantized Vorbis residues.
wire
Package wire is the inner payload framing and the outer hybrid-box envelope.
Package wire is the inner payload framing and the outer hybrid-box envelope.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL