docs(crypha): add learn/ track and surface the built project (M9)

Add the five-part learn/ folder (overview, concepts, architecture,
implementation, challenges) grounded in docs/research, with the
QR-from-ISO/IEC-18004 Reed-Solomon injection as the showpiece and crypha's
real capacities cited from the binary. Update the root README row from a
Python synopsis to the built Go project with Source Code and Docs links,
matching the nadezhda row.
This commit is contained in:
CarterPerez-dev 2026-07-19 02:57:27 -04:00
parent 908728be50
commit 7c1fab2240
6 changed files with 728 additions and 2 deletions

View File

@ -0,0 +1,153 @@
<!-- ©AngelaMos | 2026 -->
<!-- 00-OVERVIEW.md -->
# crypha: Overview
## What This Is
A multi-format steganography tool written in Go. You hand it a message or a file, and it seals that payload in a passphrase-encrypted, compressed, integrity-checked envelope, then hides the envelope inside an ordinary-looking carrier: the low bits of an image or an audio file, the Reed-Solomon slack of a QR code, zero-width characters in a block of text, or the structure of a PDF. Point `reveal` at the result and it auto-detects the carrier and hands the message back. It ships as a single static, dependency-free binary and drives from either a scriptable CLI or a guided terminal wizard.
The point of the project is to understand, by building it, the honest exchange between two adversaries. Steganography hides that a message exists; steganalysis is the statistical hunt that finds it anyway. crypha implements five very different hiding channels, each with its own capacity, fragility, and detection story, and the `learn/` track walks the analysis that breaks each one. Nothing here is a stub. Every carrier round-trips text and random-binary payloads under test, and the QR carrier is differentially tested against reference encoders and decoders.
## Why This Matters
Cryptography and steganography answer two different questions, and people constantly conflate them. Encryption makes a message unreadable. Steganography makes it unnoticeable. Encryption on its own still announces that a secret exists, and an opaque high-entropy blob is itself a signal, the exact thing a data-loss-prevention scanner, an intrusion-detection rule, or a border inspection is trained to flag. Steganography removes the signal by making the carrier look like a holiday photo, a voice memo, a PDF invoice, or a QR code on a poster.
The technique is not academic. It shows up in real intrusions on both sides of the line.
- **Witchetty, 2022.** The espionage group concealed a backdoor inside a bitmap of an old Windows logo hosted on a public cloud service, so the payload arrived on the target looking like an ordinary image download rather than malware (Symantec). The image still rendered as a logo. The code rode in the bits underneath.
- **The Stegano exploit kit, 2016.** Malicious script was hidden in the alpha channel of PNG banner ads served to millions of visitors on mainstream sites (ESET). A tiny per-pixel change to transparency, invisible on the page, carried the redirect logic.
- **Invoke-PSImage, 2017.** An open-source tool that packs a full PowerShell script into the two least-significant bits of the RGB channels of a PNG. It turned image LSB steganography into a point-and-click red-team primitive, and it is the direct ancestor of crypha's `image` carrier.
- **Stegoloader / Gatak, 2015.** This malware family pulled its own components out of images fetched at runtime, keeping the obvious executable code off disk where a scanner would look for it (Dell SecureWorks).
Defenders answer with steganalysis. The chi-square attack, RS analysis, and sample-pair analysis each estimate how much of an image has been touched, without ever needing the key. crypha exists to teach both moves. It encrypts first, so a discovered payload is still unreadable, then hides the ciphertext, and then tells you honestly how each carrier gets caught.
**Real-world scenarios where this applies:**
- **Covert-channel research.** Understanding how a payload survives, or fails to survive, a copy, a re-encode, a normalization pass, or a PDF save-as.
- **Blue-team detection.** Building the intuition to spot LSB embedding, zero-width runes, and appended-after-EOF data before an exfiltration tool uses them against you.
- **Learning applied cryptography.** The envelope is a complete, correct AEAD construction: Argon2id key derivation, ChaCha20-Poly1305, associated-data binding, and fail-closed verification, in about two hundred readable lines.
## What You'll Learn
**Security concepts:**
- **Steganography versus cryptography, and why you want both.** Concealment and secrecy are independent properties. crypha layers them: encrypt for secrecy, hide for concealment.
- **Five hiding channels and their trade-offs.** LSB in pixels and PCM samples, Reed-Solomon error injection in QR codes, zero-width Unicode in text, and three structural techniques in PDF, each with a different capacity, robustness, and detection profile.
- **Steganalysis, honestly.** How a defender detects each carrier: the chi-square and RS attacks on LSB, a one-pass rune scan on zero-width text, `strings | tail` on an appended PDF payload.
- **AEAD done correctly.** Why the header is authenticated as associated data, why a flipped byte must fail to open rather than decrypt to garbage, and why compress-then-encrypt is safe for an offline file tool when it would be dangerous for a network protocol.
**Technical skills:**
- **A plugin architecture in Go.** One `Carrier` interface, a self-registering registry via blank imports, and an engine that dispatches through it without knowing any carrier's internals.
- **Reimplementing a spec.** The QR carrier rebuilds module placement, mask reversal, block de-interleaving, and Reed-Solomon decode from ISO/IEC 18004, because the available Go library exposes only a finished bitmap.
- **Bit-level I/O and format quirks.** MSB-first bit packing, the mandatory NRGBA conversion that stops Go's PNG encoder from corrupting your low bits, and the WAV encoder's mandatory seek-back on close.
- **One engine, two frontends.** A shared brain that a cobra CLI and a bubbletea wizard both drive as thin, interchangeable faces, so the terminal UI holds zero carrier logic.
**Tools and techniques:**
- **`skip2/go-qrcode`** to generate a clean QR matrix, used as a generator and a differential-test oracle, never at runtime for decode.
- **`golang.org/x/crypto`** for Argon2id and ChaCha20-Poly1305, plus `crypto/aes` for the AES-256-GCM alternate.
- **`go-audio/wav`** for PCM read and write, and **`mewkiz/flac`** to decode a FLAC cover to samples.
- **`pdfcpu`** for lossless attachment and metadata techniques, and plain `io` for the append-after-EOF technique.
## Prerequisites
You do not need prior steganography experience. You do need some comfort with the following.
**Required knowledge:**
- **Go basics.** Structs, interfaces, slices, and errors. If you can read a method on an interface value, you can read this code.
- **Bytes and bits.** What a byte is, what "the least-significant bit" means, and that a `uint32` is four big-endian bytes on the wire.
- **What encryption is, roughly.** That a key turns readable data into unreadable data and back. The envelope chapter in [01-CONCEPTS.md](./01-CONCEPTS.md) explains the rest.
**Tools you'll need:**
- **A Go toolchain**, 1.25 or newer, only if you build from source. The prebuilt binary needs nothing. The `install.sh` script fetches a toolchain for you if one is missing and you asked to build.
- **Nothing else to run it.** No API keys, no network, no services. crypha is a one-shot offline file tool.
**Helpful but not required:**
- **ImageMagick and ffmpeg**, to synthesize throwaway covers for experimenting. [DEMO.md](../DEMO.md) shows the exact commands.
- A skim of how QR codes are structured, if you want the showpiece in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) to land faster.
## Quick Start
```bash
# Install (grabs a prebuilt binary, no Go needed):
curl -fsSL https://angelamos.com/crypha/install.sh | bash
# or, with a Go toolchain:
go install github.com/CarterPerez-dev/crypha/cmd/crypha@latest
# Launch the guided wizard:
crypha
# Ask how much a cover can hold:
crypha capacity -i photo.png
# Hide a message in an image, then read it back:
crypha hide -i photo.png -o secret.png --format image -m "meet at noon"
crypha reveal secret.png
```
Expected output: `capacity` prints a per-carrier table with an exact envelope byte count for the cover you gave it. `hide` prints a short receipt (output file, format, payload bytes, envelope bytes, whether it was encrypted and compressed). `reveal` needs no `--format`; it detects the carrier itself, writes the recovered message to stdout, and prints a one-line status to stderr like `revealed 11 bytes via image -> (stdout)`.
Add a passphrase and `reveal` asks for it before it decrypts:
```bash
crypha hide -i photo.png -o secret.png --format image -m "coordinates inside" --encrypt --compress
crypha reveal secret.png # prompts for the passphrase, then decrypts
```
A passphrase from any source, the `-k` flag, the `CRYPHA_PASSPHRASE` environment variable, or the no-echo prompt, always means the payload is encrypted. crypha never silently writes plaintext when you asked for a key.
## Project Structure
```
steganography-multi-tool/
├── cmd/crypha/ # tiny main: it only calls cli.Execute()
├── internal/
│ ├── cli/ # cobra: hide reveal capacity formats version tui, plus secure passphrase input
│ ├── tui/ # the bubbletea wizard, a pure view over the engine
│ ├── engine/ # the shared brain both frontends call
│ ├── carrier/ # the Carrier interface + Register/Get/All/Detect registry
│ │ ├── all/ # blank-import aggregator that registers every carrier
│ │ └── image/ audio/ qr/ text/ pdf/
│ ├── payload/ # the encrypted envelope: Argon2id, AEAD, flate, CRC32, framing
│ ├── bitio/ # MSB-first BitReader / BitWriter
│ ├── config/ # every constant: magic bytes, KDF params, the format catalog
│ └── report/ # human tables and --json rendering
├── learn/ # this teaching track
├── install.sh # the one-shot curl-able installer
├── .goreleaser.yaml # cross-platform release binaries
└── justfile # every recipe
```
The single most important thing to understand first is the seam in `internal/engine/engine.go`. Both frontends call `engine.Hide`, `engine.Reveal`, and `engine.Capacity`; the engine wraps the payload in an envelope and dispatches to a carrier through the registry. Everything in `internal/carrier` exists to store and retrieve opaque bytes, and everything in `internal/payload` exists to build those bytes. Neither knows about the other.
## Next Steps
1. **Understand the ideas.** Read [01-CONCEPTS.md](./01-CONCEPTS.md) for steganography versus cryptography, each of the five hiding channels, the AEAD envelope, and the steganalysis that breaks each carrier, grounded in real incidents.
2. **See the design.** Read [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) for the one-engine-two-frontends design, the self-registering carrier registry, the exact envelope byte layout, and how auto-detect avoids mistaking a QR-PNG for an ordinary image.
3. **Walk the code.** Read [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) to trace a payload from message to hidden bytes, with the QR-from-ISO/IEC-18004 Reed-Solomon injection as the showpiece.
4. **Extend it.** Read [04-CHALLENGES.md](./04-CHALLENGES.md) for projects from a new carrier to variable-density LSB modes to stronger steganalysis resistance.
## Common Issues
**`hide --format image` refuses the cover**
```
crypha: image cover must be an 8-bit PNG or a 24-bit BMP
```
Solution: the image carrier refuses paletted and 16-bit PNGs on purpose, because editing palette indices or high bytes would visibly wreck the cover. ImageMagick defaults to a 16-bit PNG for synthetic gradients; force 8-bit truecolor with `-depth 8 PNG24:cover.png`. A normal photo saved as PNG or a 24-bit BMP just works.
**`reveal` says it found nothing**
```
crypha: no crypha payload detected; pass a format to force a carrier
```
Solution: the file either does not contain a crypha payload, or the carrier was destroyed. LSB carriers survive a byte-for-byte copy but not a re-encode; if you re-saved the stego PNG as JPEG, or the WAV as MP3, the payload is gone. If you know the format, pass `--format` to skip detection and get a specific error.
**`capacity` on a QR cover says "does not fit" for encryption**
```
qr 52 38 does not fit
```
Solution: this is correct, not a bug. A QR code holds only tens of bytes in its correctable-error budget, and an encrypted envelope adds 68 bytes of overhead. QR is a plaintext-only carrier by design, and `capacity` tells you so up front.
## Related Projects
If you found this interesting, look at:
- **metadata-scrubber-tool**: the inverse instinct, stripping the hidden metadata out of a file instead of adding to it.
- **binary-analysis-tool**: static analysis of a binary's structure, the same "read a file format precisely" muscle applied to executables.
- **nadezhda** (security-news-scraper): the same single-static-binary, one-engine-two-frontends shape, applied to security-news intelligence.

View File

@ -0,0 +1,140 @@
<!-- ©AngelaMos | 2026 -->
<!-- 01-CONCEPTS.md -->
# crypha: Concepts
This chapter is the theory crypha is built on. It explains why concealment and secrecy are different problems, how each of the five carriers hides bytes, what the encrypted envelope does, and how a defender catches each carrier anyway. Every claim here is exercised by the code you will read in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md).
## Steganography is not cryptography
These two words get used interchangeably and they should not be. They solve different problems and they compose.
- **Cryptography provides secrecy.** It transforms a message so that without the key, the content is unreadable. It does not hide that a message exists. A PGP block, an encrypted zip, a TLS session: all of them loudly announce "there is a secret here," they just deny you the content.
- **Steganography provides concealment.** It hides that a message exists at all, by embedding it in something that looks unremarkable. Classic steganography does not, by itself, protect the content: if you find the hiding scheme, you read the message.
The failure mode of each is the strength of the other. Encryption's opaque blob is a signal, and signals get flagged. A data-loss-prevention system does not need to break your encryption to stop an exfiltration; it just needs to notice a 40 KB high-entropy attachment leaving the network and quarantine it. Steganography defeats that by never producing a suspicious object in the first place. The carrier is a photo, a song, an invoice.
crypha layers both, in this order:
```
message ──> [ compress ] ──> [ encrypt ] ──> envelope bytes ──> [ hide in carrier ] ──> stego file
```
Encrypt for secrecy, then hide for concealment. If the concealment fails and someone extracts the envelope, they still hold ciphertext they cannot read. If the encryption were somehow broken, they would still have had to notice the payload was there at all. The two layers cover each other's weaknesses. This is the whole design thesis of the tool.
## The carrier idea
A **carrier** is a file format with room to store bytes that a casual observer, and ideally an automated one, will not notice. crypha treats a carrier as a black box with four operations: hide bytes in a cover, reveal bytes from a stego file, report how many bytes a given cover can hold, and sniff whether a file even looks like this kind of carrier. The bytes it stores are always the opaque envelope; the carrier never knows or cares what is inside.
There are two broad families of carrier, and crypha implements both:
- **Substitution carriers** overwrite low-importance bits of existing data. The image and audio carriers replace least-significant bits. The QR carrier substitutes whole codewords within an error-correction budget. These change the cover's data slightly and are detectable by statistics.
- **Additive / structural carriers** append data the format ignores, or use fields the format tolerates. The zero-width text carrier appends invisible runes. The PDF carriers attach a file, write custom metadata keys, or append bytes after the end-of-file marker. These do not alter the visible content at all, but they are trivially found by anyone who looks at the raw bytes.
Neither family is "better." They trade capacity, robustness, and stealth against each other differently, which is exactly why a teaching tool implements five.
## Channel one and two: least-significant-bit substitution
An 8-bit color channel stores a value from 0 to 255. Changing its lowest bit changes the value by at most 1, a difference no eye can see in a photograph. A 16-bit audio sample sits on a scale of roughly 98 dB of dynamic range; flipping its lowest bit moves it by about -96 dBFS, below the noise floor of any real listening environment. So both images and audio have a spare bit per sample that you can overwrite with payload, one payload bit at a time.
```
original R channel 1 0 1 1 0 0 1 [1] payload bit = 0
after embedding 1 0 1 1 0 0 1 [0] value changed 179 -> 178, invisible
^ least-significant bit carries one payload bit
```
Capacity is straightforward. An image holds `width x height x 3 / 8` bytes, three channels of RGB per pixel at one bit each, eight bits to a byte. The alpha channel is deliberately never touched, because a pixel at 254/255 alpha instead of 255 shows as a faint fringe on a non-white background and is a direct steganalysis tell. A 640x480 image gives `640 x 480 x 3 / 8 = 115,200` channel-bits worth of bytes; crypha reserves a four-byte length prefix and reports **115,196** usable envelope bytes. Audio holds `samples x channels / 8`; two seconds of 44.1 kHz mono holds **11,021**.
The catch, and it is a big one, is fragility. LSB steganography survives a byte-for-byte copy and nothing else. Re-encode the PNG as JPEG and the discrete-cosine quantization discards exactly the low-order spatial detail your payload lived in. Re-encode the WAV as MP3 and psychoacoustic compression throws away the inaudible bits, including yours. This is not a bug in crypha; it is the defining property of substitution in a lossless-only channel. crypha refuses JPEG covers outright for this reason.
## Channel three: Reed-Solomon error injection in QR codes
This is the showpiece, and it is the one people get wrong. A QR code is not just a bitmap of a URL. It is a systematic error-correcting code: the data is stored in **data codewords**, and alongside them the encoder computes **error-correction codewords** so a scanner can still read the code when part of it is dirty, torn, or obscured by a logo. At error-correction level H, a QR code can lose about 30% of its codewords and still decode.
The naive, wrong idea is "hide data in the error-correction bits." The correct idea inverts it. You leave the error-correction codewords untouched and you deliberately **corrupt the data codewords**, staying within the code's correction budget. Reed-Solomon over the field GF(2^8) can correct up to `t = floor((n - k) / 2)` unknown-location errors per block, where `n` is the total codewords in a block and `k` is the data codewords. So:
1. Encode the benign cover content (say, a URL) into a clean QR code.
2. Inject up to `t` codeword-errors per block into the data region. The pattern of errors is your payload.
3. An ordinary scanner's Reed-Solomon decoder corrects those errors, recovers the original URL, and shows the user a perfectly normal link. It never knows anything was there.
4. crypha reads the same code, re-derives what the clean data should have been by RS-decoding each block, diffs the corrected data against the received data, and reads the injected error pattern back as the payload.
```
clean data block D0 D1 D2 D3 ... EC codewords intact
inject payload D0 D1^p0 D2 D3^p1 ... up to t corrupted codewords per block
phone scanner RS-corrects -> D0 D1 D2 D3, reads the URL, sees nothing
crypha RS-corrects -> D0 D1 D2 D3, XOR-diffs -> p0 p1, reads the payload
```
The receiver needs only the code and the version, not a separate copy of the cover; the correction machinery reconstructs the clean data for it. This is "blind" extraction.
The honest cost is capacity. Each corrupted codeword carries one payload byte, and crypha stays at half the theoretical budget for decode margin, so a small QR holds tens of bytes. The 28-character cover `https://angelamos.com/crypha` gives an envelope capacity of **52 bytes**, which is **38 bytes** of plaintext after framing and **does not fit** an encrypted envelope at all. The widely-quoted 7/15/25/30% figures for L/M/Q/H are the error-correction fraction of total codewords, not payload size. Do not confuse them. QR is a plaintext-only carrier, and the tool says so.
## Channel four: zero-width Unicode text
Unicode contains characters that render to nothing. crypha uses exactly two:
```
U+200B ZERO WIDTH SPACE -> bit 0
U+2060 WORD JOINER -> bit 1
```
Both are format characters with the `Default_Ignorable` property, both take zero display width, and neither causes any shaping or joining behavior, which rules out characters like U+200D (the zero-width joiner) that visibly fuse emoji. Eight of these runes encode one payload byte. crypha appends a framed run of them after the visible cover text, so `The quick brown fox.` still reads as `The quick brown fox.` while carrying an invisible payload behind it.
A persistent myth says Unicode normalization strips these characters. It does not. None of the four normalization forms, NFC, NFD, NFKC, NFKD, has a decomposition mapping for U+200B or U+2060, so all four leave them exactly in place. The myth comes from misreading two unrelated algorithms: IDNA2003 maps zero-width joiners to nothing, but only inside domain-name labels, not message text; and the UTS #39 confusable-skeleton algorithm removes ignorable characters, but that is an identifier-security check, not a text pipeline. A normal copy-paste, a database round-trip, or a chat message preserves the payload.
The trade-off here is the opposite of QR. Capacity is effectively unbounded and the visible text is byte-for-byte identical, but detection is trivial. This is concealment from a human skim, not a covert channel against a machine.
## Channel five: PDF structure
A PDF is a container with slack in several places, and crypha uses three, offered as `--technique`:
- **attachment** (default): PDF supports embedded file attachments as a first-class feature. crypha attaches the envelope as a lossless embedded file. It is robust, it survives a normal save in Acrobat or Preview, and it is the correct default for a teaching carrier because the round-trip is clean and easy to verify. Its stealth is the weakest of the three: the attachment shows in the panel and in `pdfinfo`.
- **metadata**: the PDF Info dictionary tolerates custom keys, and the spec requires readers to ignore keys they do not recognize. crypha stores a base64 payload across custom keys. It survives controlled delivery but is fragile against an "optimize" or "linearize" pass that drops non-standard keys.
- **append**: the spec says a reader seeks back to the last cross-reference table, so anything after the final `%%EOF` marker with no valid xref is ignored. crypha appends the framed envelope there. It is the purest "zero change to the document structure" option and survives naive copies, but any full save-as rewrite discards it.
All three are structural, not statistical, so none of them changes a single rendered pixel of the document. All three are found instantly by anyone who looks at the raw bytes.
## The encrypted envelope
Whatever the carrier, every payload is packed into one versioned envelope first. The carrier only ever stores these opaque bytes; all cryptography, compression, integrity, and versioning live in one place. This is the layout, with the exact overhead:
```
plaintext magic(4) ver(1) flags(1) │ len(4) body(N) │ crc32(4) +14 bytes
encrypted magic(4) ver(1) flags(1) cipher(1) params(9) salt(16) nonce(12) │ len(4) body(N+16) │ crc32(4)
└───────────────── authenticated as AEAD associated data ───────┘ +68 bytes
```
Three ideas do the work here.
**Argon2id for the key.** A passphrase is not a key. Argon2id (RFC 9106) is a memory-hard key-derivation function: it turns a passphrase plus a random 16-byte salt into a 32-byte key while deliberately burning memory and time, so an attacker guessing passphrases pays that cost per guess. crypha's default is the RFC's laptop-safe profile (64 MiB, three passes); `--strength high` uses the 2 GiB profile. The exact parameters used are written into the envelope, so `reveal` reproduces the same key without you re-declaring the profile.
**Authenticated encryption for secrecy and integrity together.** crypha defaults to ChaCha20-Poly1305 (RFC 8439), with AES-256-GCM behind `--cipher aes256gcm`. ChaCha20 is the default deliberately: it is constant-time in software on any CPU, while Go's AES-GCM is only constant-time with AES-NI hardware, and a portable file tool cannot assume the target has it. Both are AEAD constructions, meaning they produce a 16-byte authentication tag alongside the ciphertext. If any ciphertext byte is flipped, `Open` returns an error, not garbage.
**The header is authenticated as associated data.** This is the subtle, important part. The entire header up to and including the nonce is passed to the AEAD as "additional authenticated data." It is not encrypted, but it is covered by the tag. So an attacker cannot flip the "encrypted" flag, downgrade the cipher, or tamper with the Argon2id parameters without breaking authentication. An unknown version is rejected loudly rather than guessed. A tampered byte or a wrong passphrase fails closed.
One design question worth naming: crypha compresses **before** it encrypts. Compressing after encryption is pointless (ciphertext does not compress), but compressing before encryption is what enabled the CRIME and BREACH attacks on TLS. Those attacks require an adaptive network attacker who can inject chosen plaintext and watch the compressed-then-encrypted length across many requests. A one-shot offline file tool has no such oracle: there is no attacker in the loop injecting guesses. So compress-then-encrypt is safe here, and it means the ciphertext, and therefore the required carrier capacity, is smaller.
## Steganalysis: how each carrier gets caught
A teaching tool that only showed you how to hide would be lying by omission. Here is how a defender breaks each carrier. crypha's threat model assumes the defender is competent, which is exactly why the payload is always encrypted first.
**Image and audio LSB are caught by statistics.** LSB embedding leaves a faint, measurable fingerprint even though it is invisible. The classic attacks:
- **The chi-square attack** (Westfeld and Pfitzmann, 1999) exploits that sequential LSB embedding drives pairs of values, like 178 and 179, toward equal frequency. It measures how close each such pair is to a 50/50 split and flags images where they are suspiciously balanced.
- **RS analysis** (Fridrich, Goljan, and Du, 2001) flips LSBs in groups and watches how a "smoothness" measure of the image responds. Clean and embedded images respond differently, and the method estimates the embedded payload fraction, not just its presence.
- **Sample-pair analysis** (Dumitrescu, Wu, and Wang, 2002) does something similar with a precise statistical model of sample pairs.
- **StegExpose** (Boehm, 2014) fuses these into one detector for PNG and BMP.
The practical defense against all of them is to embed sparsely and randomly rather than filling every low bit sequentially, which is why crypha defaults to a single bit per channel and treats a 2-bit mode as a documented extension, not a default.
**Zero-width text is caught in one pass.** There is no statistical subtlety. `unicode.Is(unicode.Cf, r)` over the runes, or `grep -P '\xe2\x80\x8b'` over the bytes, or simply noticing that the visible character count is far below the total rune count, finds every hidden byte. There is no deniability against an automated scan, which is again why the payload is encrypted.
**QR injection hides from a scanner, not from an analyst.** A phone scanner genuinely cannot see the payload; its Reed-Solomon decoder erases it as noise. But an analyst who suspects a QR code and re-encodes the visible content into a clean QR can diff it against the suspect image, the same operation crypha's `reveal` performs, and recover the injected pattern. The hiding is robust against the intended consumer and transparent to a motivated investigator.
**PDF structural tricks are caught by reading the file.** `pdfinfo` and the attachment panel expose an attachment. `exiftool` dumps custom metadata keys. `strings file.pdf | tail` reveals bytes appended after `%%EOF`. None of these techniques is statistically covert; they hide from a person who opens the document, not from a person who inspects it.
The honest summary: crypha's carriers are teaching-grade concealment, and the encryption is production-grade secrecy. Against a competent inspector the concealment will often fail, and when it does, the encryption is what still protects the message. That layering is the point.
## Where to go next
[02-ARCHITECTURE.md](./02-ARCHITECTURE.md) turns these ideas into structure: the `Carrier` interface, the self-registering registry, the exact envelope encoding, and the auto-detect logic that keeps a QR-PNG from being mistaken for an ordinary image.

View File

@ -0,0 +1,171 @@
<!-- ©AngelaMos | 2026 -->
<!-- 02-ARCHITECTURE.md -->
# crypha: Architecture
This chapter is the shape of the code: how the pieces are separated, why they are separated that way, and how a request flows from a keystroke to a hidden file. The guiding rule is a strict separation of concerns. Carriers know nothing about cryptography. The envelope knows nothing about carriers. The frontends know nothing about either. One engine in the middle wires them together.
## One engine, two frontends
crypha has two ways to drive it: a scriptable cobra CLI and a guided bubbletea wizard. A tempting but wrong design would let each frontend do its own hiding. That path duplicates the important logic, and the two copies drift. crypha instead puts every operation behind one package, `internal/engine`, and makes both frontends thin faces over it.
```
cobra CLI ───┐
├──> engine ──> carrier registry ──> image audio qr text pdf
bubbletea TUI ──┘ │
└──> payload envelope (Argon2id · AEAD · flate · CRC32) ──> bitio
```
The engine exposes exactly the verbs the tool has: `Hide`, `Reveal`, `Capacity`, `CapacityAll`, `Catalog`, plus two preflight helpers, `Overhead` and `EnvelopeSize`, that let the wizard show an exact capacity meter before it commits. The cobra commands in `internal/cli` parse flags and call these. The bubbletea wizard in `internal/tui` collects the same inputs through a series of steps and calls the same functions. The TUI holds zero carrier logic; when it needs the exact envelope size for its live meter, it asks `engine.EnvelopeSize`, it does not reach into a carrier. Both auditors of this project confirmed the wizard is a pure view. That is the design working.
The payoff is that a bug fixed in the engine is fixed in both frontends at once, and a new carrier appears in both the CLI and the wizard without either frontend changing.
## The Carrier interface
Every hiding channel implements one small interface, in `internal/carrier/carrier.go`:
```go
type Carrier interface {
Format() string
Hide(cover io.Reader, payload []byte, out io.Writer) error
Reveal(stego io.Reader) ([]byte, error)
Capacity(cover io.Reader) (int, error)
Sniff(stego io.ReadSeeker) bool
}
```
Four verbs and a name. `Hide` reads a cover and writes a stego file carrying the given bytes. `Reveal` reads a stego file and returns the bytes. `Capacity` reports how many bytes a cover can hold. `Sniff` is a cheap "does this file even look like my format" check used by auto-detect. Notice what is absent: the interface takes and returns `[]byte`, never a message, never a passphrase, never an `Options`. A carrier is handed the finished envelope and stores it. It does not know whether those bytes are encrypted, compressed, or plaintext, and it does not care. That ignorance is the separation of concerns made concrete.
Each carrier lives in its own package, `image`, `audio`, `qr`, `text`, `pdf`, and depends only on `internal/carrier` and standard or third-party libraries. No carrier imports another. You can read, test, or replace any one of them in isolation.
## The self-registering registry
Carriers register themselves. Each carrier package has an `init` function that calls `carrier.Register` with an instance of its type:
```go
func init() {
carrier.Register(qrCarrier{})
}
```
The registry is a plain map from format name to `Carrier`, with `Register`, `Get`, `All`, `Formats`, and `Detect` helpers. The trick that makes registration fire is the blank-import aggregator `internal/carrier/all/all.go`, which imports every carrier package purely for its side effect:
```go
import (
_ "github.com/CarterPerez-dev/crypha/internal/carrier/image"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/audio"
// ... qr, text, pdf
)
```
The engine imports `all` once, also with a blank import. That single import chain runs every carrier's `init`, populating the registry before `main` starts. To add a sixth carrier you write its package with an `init` that registers it, add one line to `all.go`, and the engine, both frontends, `formats`, `capacity`, and auto-detect all pick it up with no further wiring. This is the plugin pattern that Go's `image` and `database/sql` packages use, applied here.
`All` returns the carriers sorted by name, so any iteration over them, capacity tables, auto-detect order, format listings, is deterministic. Determinism matters for the tests and for reproducible output.
## The engine as dispatcher
`internal/engine/engine.go` is the seam. Its job on `Hide` is three steps:
```go
func Hide(req HideRequest) (HideResult, error) {
c, err := ResolveCarrier(req.Format, req.Technique) // pick the carrier
env, err := payload.Pack(req.Payload, req.Options) // build the envelope
err = c.Hide(req.Cover, env, req.Out) // store the opaque bytes
return HideResult{ /* receipt */ }, nil
}
```
`ResolveCarrier` is where the one special case lives. Four of the five carriers are resolved by name straight out of the registry. PDF is different because it has techniques: `hide --format pdf --technique attachment|metadata|append`. So `ResolveCarrier` constructs a PDF carrier via `pdf.New(technique)` when the format is `pdf`, and rejects a `--technique` flag on any non-PDF format. This keeps the technique concept contained to the one carrier that has it, rather than polluting the interface for the other four.
`Reveal` runs the reverse: locate the carrier and extract the envelope, check whether the envelope is encrypted, demand a passphrase if it is and none was given, then `payload.Unpack`. `Capacity` and `CapacityAll` just call the carriers' `Capacity` and format the results into rows. The engine never touches bits or ciphers directly; it orchestrates the two subsystems that do.
## The envelope encoding
The envelope is built and parsed in `internal/payload/envelope.go` by `Pack`, `parse`, and `Unpack`. This is the exact byte layout, and the overhead is exact, not approximate:
```
plaintext magic(4) ver(1) flags(1) │ len(4) body(N) │ crc32(4) 14 bytes overhead
encrypted magic(4) ver(1) flags(1) cipher(1) params(9) salt(16) nonce(12) │ len(4) body(N+16) │ crc32(4)
└───────────────── AAD: authenticated, not encrypted ───────────┘ 68 bytes overhead
```
- **magic (4)** is a fixed constant, so `reveal` and auto-detect can reject random bytes cheaply before doing real work.
- **ver (1)** starts at 1. `parse` rejects any other version loudly rather than guessing at a layout it does not understand.
- **flags (1)** carries two bits today: encrypted and compressed, with room to grow.
- When encrypted, the header continues with **cipher (1)** identifying ChaCha20-Poly1305 or AES-256-GCM, **params (9)** holding the Argon2id time, memory, and threads, **salt (16)** for the key derivation, and **nonce (12)** for the AEAD.
- **len (4)** is the big-endian length of the body that follows.
- **body (N)** is the payload after the optional compress and encrypt steps. When encrypted it includes the 16-byte authentication tag.
- **crc32 (4)** covers the body. On the plaintext path it is the only integrity check and is what lets auto-detect confirm "this is a real crypha payload." On the encrypted path the AEAD tag already guarantees integrity, so the CRC is a cheap post-decrypt sanity check.
You can verify these numbers yourself. `crypha capacity -i cover.png --format image` on a 640x480 cover reports an envelope capacity of 115196, a max plaintext of 115182 (14 less), and a max encrypted of 115128 (68 less). The framing overhead is not a rounded estimate; it is these two constants.
The order of operations is the crux of correctness. On hide: compress if asked, then encrypt if asked, then frame. On reveal: unframe, then decrypt, then decompress. `Pack` writes the header first specifically so it can pass the header bytes to `aead.Seal` as associated data before it appends the ciphertext. `parse` records that same header slice as `aad` so `Unpack` can pass it to `aead.Open`. That is how tampering with the version or cipher choice breaks authentication.
## Bit I/O and the carrier plumbing
The substitution carriers need to read and write individual bits, not bytes, and they must agree on bit order. `internal/bitio` provides an MSB-first `BitReader` and `BitWriter`: the high bit of a byte is emitted first. As long as hide and reveal use the same order, any consistent choice works; crypha fixes MSB-first everywhere so a carrier written today and read tomorrow agree.
`internal/config` holds every constant the tool uses: the magic bytes, the KDF parameter profiles, the format catalog and its descriptions, the cipher identifiers. Nothing magic is hard-coded at a call site. `internal/report` renders results two ways, as human-readable aligned tables and, under a global `--json` flag, as machine-readable JSON where `reveal` base64-encodes the payload so binary data survives a pipe.
## Auto-detect, and the shadowing trap
The most interesting piece of the architecture is how `reveal` works with no `--format`. Naively you would sniff each carrier and use the first that says yes. That is exactly what the registry's `carrier.Detect` does, and it is not good enough, because of a shadowing problem:
**a QR stego is a PNG.** If auto-detect sniffs the image carrier first and the image carrier says "yes, I can read LSBs out of this PNG," it will happily extract 115 KB of noise and hand it back, shadowing the QR carrier that actually owns the bytes. Sniffing alone cannot tell the two apart, because the image carrier genuinely can read bits from any PNG; they just are not a valid payload.
The engine solves this in `detect` by adding a validation step. For each carrier in deterministic order it sniffs, then actually reveals, then validates the extracted envelope against the payload format:
```go
func detect(stego []byte) (carrier.Carrier, []byte, error) {
for _, c := range carrier.All() {
if !c.Sniff(bytes.NewReader(stego)) {
continue
}
env, err := c.Reveal(bytes.NewReader(stego))
if err != nil {
continue
}
if payload.Validate(env) == nil { // does it have our magic + a good CRC?
return c, env, nil
}
}
return nil, nil, ErrUndetected
}
```
A carrier only wins if the bytes it extracts carry the crypha magic and pass the CRC check. When the image carrier reads noise out of a QR-PNG, `payload.Validate` fails on the magic, the loop moves on, and the QR carrier, which extracts a real framed payload, wins. The correctness auditor for this stage confirmed the behavior empirically: across hundreds of QR stegos, none was shadowed by the image carrier, and across hundreds of random files, none produced a false positive. The `Sniff` step stays fast and envelope-agnostic; the validation step is what makes detection correct.
This is why `reveal` can auto-detect safely and why the DEMO can say, of a QR stego, "it must not be mistaken for an ordinary image carrier. It is not."
## The full data flow
Putting it together, here is one message from `crypha hide` to a file and back:
```
hide -m "meet at noon" --format image --encrypt
├─ cli parses flags, resolves the passphrase (k / env / no-echo prompt)
├─ engine.Hide
│ ├─ ResolveCarrier("image", "") -> image carrier
│ ├─ payload.Pack(msg, {encrypt, compress})
│ │ compress > Argon2id(salt) > ChaCha20-Poly1305.Seal(header as AAD) > frame
│ └─ image.Hide(cover, envelope, out)
│ toNRGBA(cover) > write length prefix + envelope bits into RGB LSBs > png.Encode
└─ receipt: image, 12 bytes payload, 92 bytes envelope, encrypted, compressed
reveal secret.png
├─ engine.Reveal
│ ├─ detect: sniff each carrier, reveal, payload.Validate -> image wins
│ ├─ payload.IsEncrypted(env) -> true, so demand a passphrase
│ └─ payload.Unpack(env, passphrase)
│ unframe > Argon2id(stored salt+params) > AEAD.Open(header as AAD) > decompress
└─ "meet at noon" to stdout, status to stderr
```
Every arrow crosses exactly one seam, and each seam is a package boundary you can test on its own. The next chapter walks the code that lives inside these boxes, with the QR carrier as the showpiece.
## Where to go next
[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) is the code walkthrough: the NRGBA trap in the image carrier, the WAV encoder's seek-back, and the full QR-from-ISO/IEC-18004 injection and Reed-Solomon decode, function by function.

View File

@ -0,0 +1,218 @@
<!-- ©AngelaMos | 2026 -->
<!-- 03-IMPLEMENTATION.md -->
# crypha: Implementation
This chapter walks the code. It starts with the envelope every carrier shares, moves through the three simpler carriers to build intuition, and then spends most of its length on the showpiece: the QR carrier, which reimplements QR module geometry and Reed-Solomon decoding from ISO/IEC 18004 because no Go library exposes what the covert channel needs. Function names are given so you can find each piece; there are no line-number references, since those rot the moment the file changes.
## The envelope: `payload.Pack` and `payload.Unpack`
Everything hidden by any carrier is first built by `Pack` in `internal/payload/envelope.go`. `Pack` takes the raw payload and an `Options` struct and returns the framed envelope bytes. The body of the function is the order of operations from the concepts chapter, made literal:
```go
if opts.Compress {
body, _ = compress(body) // flate, sets flagCompressed
flags |= flagCompressed
}
header.Write(magic[:])
header.WriteByte(currentVersion)
if len(opts.Passphrase) > 0 {
flags |= flagEncrypted
header.WriteByte(flags)
// salt, params, cipher id, nonce all written into the header ...
body = aead.Seal(nil, nonce, body, header.Bytes()) // header IS the AAD
} else {
header.WriteByte(flags)
}
```
The single most important line is `aead.Seal(nil, nonce, body, header.Bytes())`. The fourth argument is the associated data. By passing the entire header, magic, version, flags, cipher id, Argon2id parameters, salt, and nonce, `Pack` binds all of that metadata to the ciphertext's authentication tag. Nothing in the header is secret, but nothing in the header can be changed without invalidating the tag.
`parse` reads the layout back and, crucially, records the exact header slice it consumed as `p.aad`. `Unpack` then calls `aead.Open(nil, p.nonce, body, p.aad)` with that same slice. If an attacker flipped the encrypted flag, downgraded the cipher, or edited the KDF parameters, the `aad` no longer matches what was sealed and `Open` returns an error. crypha maps that to `ErrDecrypt`, "decryption failed (wrong passphrase or tampered data)," and exits non-zero. It never returns partially-decrypted garbage.
The Argon2id parameters travel in the envelope precisely so `reveal` needs no flags. `parse` reads `params` into a `kdfParams` struct, `Unpack` calls `deriveKey(passphrase, p.salt, p.params)`, and the exact same key falls out. `parse` also validates the parameters before use (`p.params.valid()`), because `argon2.IDKey` panics rather than errors on a zero time cost or an impossible memory-to-threads ratio, and a hostile envelope must not be able to trigger that panic.
## The image carrier: the NRGBA trap
`internal/carrier/image/image.go` is short, and almost all of its subtlety is in one helper, `toNRGBA`. The naive version of an LSB image carrier decodes the PNG, edits the low bits, and re-encodes. It produces corrupt output, and the reason is a genuine Go gotcha.
A standard 24-bit truecolor PNG decodes to `*image.RGBA`, which stores color with **premultiplied** alpha. When you hand an `*image.RGBA` to `png.Encode`, the encoder runs an alpha-unmultiply pass that does arithmetic on your pixel values, and that arithmetic rewrites exactly the low bits you just carefully set. Your payload is destroyed on the way out. A color-type-6 PNG decodes to `*image.NRGBA` (non-premultiplied), and for that type `png.Encode` copies the pixel bytes directly with no color math, so the low bits survive.
The fix is to always convert the cover to `*image.NRGBA` before touching it:
```go
func toNRGBA(src stdimage.Image) *stdimage.NRGBA {
if n, ok := src.(*stdimage.NRGBA); ok && n.Rect.Min == (stdimage.Point{}) {
return n
}
dst := stdimage.NewNRGBA(stdimage.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
return dst
}
```
For an opaque source, every pixel at alpha 255, the RGBA-to-NRGBA conversion is exactly lossless, so this costs nothing but a copy. `Hide` then walks payload bits into the low bit of each channel with `pixOffset`, which is where the alpha channel gets protected:
```go
func pixOffset(slot int) int {
return bytesPerPixel*(slot/channelsPerPixel) + (slot % channelsPerPixel)
}
```
The NRGBA pixel buffer is laid out `[R G B A R G B A ...]`, four bytes per pixel. By mapping slot `n` to pixel `n/3` and channel `n%3`, `pixOffset` visits only R, G, and B and steps over every A. The alpha byte is never written, because a pixel at alpha 254 instead of 255 shows as a faint fringe on a composited background and is a direct steganalysis tell.
Before any of this, `rejectLossy` refuses covers that cannot round-trip: `*image.Paletted` (editing a palette index jumps to a whole different color) and the 16-bit types like `*image.RGBA64` (eight bytes per pixel, a different layout). crypha would rather refuse a cover than silently mangle it, which is why the DEMO forces `-depth 8 PNG24:` for its synthetic gradient. A `frame` helper prefixes the payload with a big-endian `uint32` length so `Reveal` reads the length first, then exactly that many payload bytes, and stops.
## The zero-width text carrier: exact-length framing
`internal/carrier/text/text.go` maps bits to the two invisible runes and back. `Hide` builds a frame of `[magic][length][payload]`, walks it bit by bit with the shared `bitio.NewReader`, appends `zeroRune` (U+200B) for a 0 and `oneRune` (U+2060) for a 1, and writes the visible cover followed by the invisible run. Reading is the mirror: `extractBits` ranges over the runes and collects a 0 or 1 for each of the two carrier characters, ignoring everything else.
The interesting defensive detail is `parseFrame`. Because this carrier can be stacked (a stego text can itself be used as a cover), and because a scanner walks every possible magic offset in `findFrame`, the parser must not accept a frame whose declared length disagrees with the bits actually present:
```go
remaining := len(bits) - payloadStart
if uint64(length)*bitsPerByte != uint64(remaining) {
return nil, false // declared length must consume the run exactly
}
```
Requiring the payload to consume the run to its exact end, rather than merely fit inside it, is what stops a spurious earlier magic match from shadowing the real frame. This exactness was a finding fixed during the carrier's audit, and it is the difference between a robust parser and one that occasionally reads the wrong bytes.
## The audio carrier: WAV, and the mandatory Close
`internal/carrier/audio` decodes 16-bit PCM samples with `go-audio/wav`, sets the low bit of each sample the same way the image carrier sets a channel LSB, and re-encodes. The one non-obvious requirement is that the WAV encoder's `Close` is mandatory, not optional cleanup: WAV writes a data-chunk size field in its header that is only known once all samples are written, so `Close` seeks back to the start and patches it. crypha writes to an in-memory `writeseeker` so the encoder can perform that seek even when the ultimate destination is a plain `io.Writer` that cannot seek. A FLAC cover is decoded to the same PCM samples with `mewkiz/flac` and then re-emitted as WAV; native FLAC output is deferred because the only Go FLAC encoder emits frames that strict parsers reject.
## The showpiece: QR Reed-Solomon error injection
Now the main event. The QR carrier hides a payload as deliberate, correctable errors in a QR code's data codewords, so an ordinary phone scanner reads the visible cover and silently repairs the injected errors away, while crypha reads them back as the secret. This is the highest-effort carrier in the project, because the Go ecosystem gives you almost nothing to work with.
### The problem: `skip2` only hands you a finished bitmap
`github.com/skip2/go-qrcode` can generate a QR code and, via `NewWithForcedVersion`, pin its version and error-correction level. But its public surface exposes only `Bitmap() [][]bool`: the final, masked, fully-assembled matrix with function patterns baked in. The codewords, the block structure, and the chosen data mask are all unexported. To inject errors into specific data codewords and later read them back, crypha has to take that finished bitmap apart and reconstruct everything the library hid, from the spec. That reconstruction lives across `qr.go`, `matrix.go`, `blocks.go`, `gf.go`, and `rs.go`. crypha fixes the error-correction level at H and supports versions 1 through 10.
Here is the whole `Hide` pipeline, then each stage.
```go
code, version, _ := selectVersion(string(coverText), len(payload)) // 1. clean QR
clean, _ := matrixFromBitmap(code.Bitmap(), version) // 2. bitmap -> matrix
maskID, level, ok := parseFormat(clean) // 3. read format info
isFunc := functionModules(version) // 4. function map
order := placementOrder(version, isFunc) // 5. zigzag order
serial := readSerial(clean, order, maskID, spec.totalCodewords()) // unmask + read codewords
dataBlocks, ecBlocks, _ := spec.deinterleave(serial) // 6. split into blocks
framed := frame(payload)
injectFramed(dataBlocks, spec, framed) // 7. inject errors
stego := clean.clone()
writeSerial(stego, order, maskID, spec.interleave(dataBlocks, ecBlocks)) // 8. re-lay + write
renderPNG(stego, out) // encode PNG
```
### Stage 1: a clean cover matrix
`selectVersion` asks `skip2` for the smallest supported version (1 to 10) whose H-level capacity holds the payload, and whose cover text fits. It disables the library's quiet-zone border so crypha controls the geometry. `matrixFromBitmap` copies the returned `[][]bool` into crypha's `matrix` type, checking that the dimensions match the version's expected `symbolSize` (21 modules for version 1, growing by 4 per version).
### Stage 3: reading the format information
The format information is a 15-bit field stored twice in the code, encoding the mask id and error-correction level under a BCH error-correcting code. `parseFormat` reads the 15 module cells listed in `formatBitCells`, then recovers the intended value the way a real decoder does, by nearest codeword:
```go
for d := 0; d < formatCodeCount; d++ {
dist := bits.OnesCount(uint(stored ^ formatCode(d)))
if dist < bestDist { bestDist, bestData = dist, d }
}
if bestData < 0 || bestDist > formatMaxDistance { return 0, 0, false }
```
`formatCode` recomputes the BCH encoding of each of the 32 possible format values (including the mandatory mask `0x5412`), and the loop picks whichever is closest in Hamming distance to what was read, rejecting anything more than the code's 3-bit correction radius away. crypha then insists the level is H; any other level means this is not a crypha QR.
### Stage 4: the function map
Payload can only ride in data modules, never in the fixed patterns a scanner uses to orient itself. `functionModules` builds a boolean grid marking every non-data module for the given version: the three finder patterns and their separators, the two timing lines, the alignment patterns (looked up per version in `alignmentCenters`, skipping any that collide with a finder block), and, for version 7 and up, the two version-information blocks. This mask is what the placement walk consults to know which cells to skip.
One subtlety worth calling out: the finder-plus-separator block is 8 modules across, not the 7 of the finder pattern alone. Getting that edge off by one shifts the entire codeword placement and produces garbage. That exact off-by-one, the top-right and bottom-left finder blocks being marked 8 wide rather than 9 or 7, was one of two real bugs the differential tests caught during development. The test that catches it is described at the end of this section.
### Stage 5: the zigzag placement and mask reversal
QR modules are filled in a serpentine order: two columns at a time, starting from the bottom-right, snaking up then down, skipping function modules and the vertical timing line. `placementOrder` reproduces that walk exactly, appending each data-module coordinate to an ordered slice until it has visited every data module the version has:
```go
x := size - 2; y := size - 1; dirUp := true; xOffset := 1
for i := 0; i < count; i++ {
order = append(order, point{x: x + xOffset, y: y})
// toggle between the two columns, step up or down, bounce at the edges,
// hop over the timing column, and skip any function module
}
```
With that ordering in hand, `readSerial` walks it one codeword (8 modules) at a time and reads the bits, but with a mask reversal baked in. A QR code XORs a checkerboard-like mask over its data region so no large blank areas confuse a scanner. To recover the true codeword bits you must XOR the same mask back off:
```go
v := m.grid[p.y][p.x]
if maskBit(maskID, p.y, p.x) {
v = !v // undo the data mask
}
```
`maskBit` implements all eight ISO mask formulas. Skipping this step is not a subtle error; it turns every codeword into noise, and an early version of the carrier that omitted it produced a meaningless module diff. `writeSerial` is the exact inverse, re-applying the mask as it lays codewords back down.
### Stage 6: de-interleaving into blocks
A QR code does not store its blocks end to end; it interleaves their codewords so a physical smudge damages a little of each block rather than destroying one entirely. `blocks.go` holds the per-version, level-H block tables (`versionTable`), and `deinterleave` reverses the interleave to recover the individual data and error-correction blocks, using `blockLayout` to know each block's data and EC lengths. `interleave` is its inverse for the write path. These tables are transcribed from the ISO block-structure tables; they are the least glamorous and most error-prone part of the whole carrier, which is why the differential test against `skip2`'s clean bitmap matters so much.
### Stage 7: injecting the payload
This is the actual hiding, and it is nine lines. `injectFramed` distributes the framed payload bytes across the blocks round-robin, XORing each payload byte into one data codeword:
```go
for t := 0; t < len(framed); t++ {
block := t % nb // spread across blocks round-robin
slot := t / nb // next free slot within the block
if slot >= inject || slot >= len(dataBlocks[block]) {
return ErrPayloadTooLarge
}
dataBlocks[block][slot] ^= framed[t]
}
```
The budget is `inject := spec.injectPerBlock()`, which is `correctable() / 2`, which is `(ecPerBlock / 2) / 2`. The first halving is the Reed-Solomon limit itself, `t = floor((n - k) / 2)` correctable errors per block. The second halving is a deliberate safety margin (`injectionSafetyRatio = 2`): crypha injects only half the errors the code can correct, so the visible content still decodes with comfortable margin on a real scanner. This is exactly why capacity is honest and small. A version-10 code has 8 blocks, 28 EC codewords per block, so `correctable` is 14 and `injectPerBlock` is 7; `8 x 7` is 56 codewords, minus the 4-byte frame prefix, gives the **52**-byte envelope capacity you see in `capacity`.
Because a corrupted data codeword differs from the clean one, and the clean one is recoverable by Reed-Solomon, XOR is the perfect channel: `stego = clean XOR payload`, so `payload = clean XOR stego`, and the receiver recovers `clean` for free by decoding.
### Stage 8: writing it back out
`interleave` re-serializes the now-corrupted data blocks with the untouched EC blocks, `writeSerial` lays them back into a clone of the clean matrix (re-applying the mask), and `renderPNG` scales each module to an 8x8 pixel block, adds a 4-module quiet zone, and encodes a grayscale PNG. The result scans as the cover on any phone.
### Reveal: correcting the errors to read them
`Reveal` reverses the geometry, exactly the same `parseFormat` to `deinterleave` chain, and then `extractFramed` does the cryptographically-satisfying part: it recovers each block's clean data by Reed-Solomon decoding, and XOR-diffs it against what was received.
```go
recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...)
corrected, err := rsDecode(recv, ec) // repair the injected errors
clean[b] = corrected[:len(dataBlocks[b])]
// ... then framed[t] = clean[block][slot] ^ dataBlocks[block][slot]
```
`rsDecode`, in `rs.go`, is a from-scratch systematic Reed-Solomon decoder over GF(2^8). It is worth understanding because it is the machinery a phone scanner runs too:
1. **Syndromes** (`rsSyndromes`). Evaluate the received block as a polynomial at successive powers of the field generator. If every syndrome is zero, there are no errors and the block is returned as-is.
2. **Error locator** (`rsErrorLocator`). The Berlekamp-Massey algorithm builds the error-locator polynomial from the syndromes. Its degree is the number of errors; if that exceeds the correctable budget, the block is declared uncorrectable.
3. **Error positions** (`rsErrorPositions`). A Chien search evaluates the locator at every position to find the roots, which point at the corrupted codewords. If the root count does not match the locator degree, it fails cleanly.
4. **Error magnitudes** (`rsSolveMagnitudes`). With positions known, crypha solves a small Vandermonde linear system by Gaussian elimination over the field to find each error's value, then verifies the solution reproduces the syndromes before trusting it.
The field arithmetic underneath, in `gf.go`, is the standard log/exp-table trick: `init` walks the powers of the generator under the QR primitive polynomial `0x11D` to fill `gfExp` and `gfLog`, so multiplication becomes an addition of logarithms and inversion becomes a subtraction. This is the same GF(2^8) every QR implementation uses.
The Chien search hides the second of the two real bugs the differential tests caught: the mapping from a root of the locator polynomial back to a codeword index depends on a position convention, and getting that convention backward finds the right number of errors at the wrong places. The fix is the `(len(recv)-1-p)` term you see in both `rsErrorPositions` and the magnitude setup; it aligns the locator's root positions with the codeword ordering the encoder used.
### The two oracles that keep it honest
crypha reimplemented a spec, and a spec reimplementation is exactly where silent, plausible-looking bugs breed. The defense is differential testing against two independent reference implementations, and neither is used at runtime:
- **Generation oracle: `skip2/go-qrcode`.** For a given cover and version, crypha's own clean-matrix reconstruction must match `skip2`'s `Bitmap()` bit for bit. This validates placement, masking, and block handling all at once. The 8-wide finder-block off-by-one failed this test loudly.
- **Scan oracle: `makiuchi-d/gozxing`.** After injection, the stego PNG must still decode, via a completely independent QR decoder, back to the original cover content. This proves the injection stayed inside the correction budget and the code genuinely self-heals. If crypha ever injected one error too many, `gozxing` would fail to recover the cover and the test would catch it.
Both oracles are test-only dependencies. At runtime crypha does its own generation introspection and its own Reed-Solomon decode; it never calls `gozxing` to read a code. That independence is the whole point: a bug in crypha's decoder cannot hide behind the same bug in its encoder, because the encoder is `skip2` and the scan check is `gozxing`.
## Where to go next
[04-CHALLENGES.md](./04-CHALLENGES.md) turns the reader loose: add a sixth carrier, build a variable-density LSB mode, implement a chi-square detector against crypha's own output, or push the QR carrier past version 10.

View File

@ -0,0 +1,44 @@
<!-- ©AngelaMos | 2026 -->
<!-- 04-CHALLENGES.md -->
# crypha: Challenges
The best way to understand a hiding technique is to extend it, and the best way to understand a hiding technique's weakness is to build the tool that detects it. This chapter is a graded set of projects. Each one names the files and functions you would touch and the test that would prove you finished. They are ordered roughly by effort. Nothing here is a hint at incomplete work; the tool is complete, and these are the doors it deliberately leaves open.
Before you start: `just test` runs the suite, `just test-race` runs it under the race detector, and `just lint` runs the linter pinned to a Go 1.25 toolchain. Every challenge below should end with a green suite and a test that would have failed before your change.
## Warm-ups
**Add a new cipher to the envelope.** The envelope already carries a one-byte cipher identifier, and the AEAD is selected by `newAEAD` and `aeadByID` in `internal/payload`. Add a third AEAD (XChaCha20-Poly1305 is the natural choice; its 24-byte nonce removes any lingering nonce-collision worry) behind a new `--cipher` value. The interesting constraint is the nonce size: `Pack` already writes `aead.NonceSize()` bytes, so the encode side is nonce-agnostic, but `parse` reads a fixed 12-byte nonce field. Decide whether to widen the field (a version bump) or store the nonce length. This teaches you why the header is versioned. Prove it with a round-trip test and a known-answer test that a wrong key fails to open.
**Make the QR quiet zone and module size configurable.** `renderPNG` in `matrix.go` hard-codes an 8-pixel module and a 4-module quiet zone through `modulePixels` and `quietZoneModules`. Move them behind flags, then make `readGrid` tolerant of any module size it can infer rather than assuming 8. The test that matters: a stego produced at module size 4 must still round-trip through `Reveal`, and must still scan under the `gozxing` oracle in the QR test suite.
**Add a capacity-safety flag.** The QR carrier injects only half the correctable budget (`injectionSafetyRatio = 2` in `blocks.go`). Expose that ratio as a `--qr-margin` option so a user can trade robustness for capacity. Then measure the trade honestly: write a test that injects at ratio 1 (the full Reed-Solomon limit) and confirm whether `gozxing` still recovers the cover. You will learn where the real decode margin lives.
## Intermediate
**Build a variable-density LSB mode.** Both the image and audio carriers embed one bit per channel or sample. Two bits per sample doubles capacity and is the technique Invoke-PSImage actually used. Add a `--bits` option (1 or 2) to the image carrier. The work is in `pixOffset` and the read/write loops in `image.go`: instead of masking the single low bit, mask the low two bits and pack two payload bits per channel. The lesson is in the follow-up challenge, because 2-bit embedding is markedly easier to detect.
**Write a chi-square detector against crypha's own output.** This is the single most valuable challenge in the file, because it makes the concepts chapter concrete. Implement the Westfeld-Pfitzmann chi-square attack as a small command or test: for a suspect PNG, build the histogram of the RGB values, measure how close each pair of values that differ only in their low bit (2k and 2k+1) is to being equally frequent, and compute a chi-square statistic. Run it against a clean cover and against a crypha 1-bit stego and a crypha 2-bit stego of the same image. You should see the 2-bit stego light up clearly, the 1-bit stego light up faintly, and the clean cover stay quiet. You will have built the detector that breaks your own tool, which is the entire point of the project.
**Randomize the embedding path from the key.** crypha's image carrier fills channel LSBs sequentially, which is exactly what the chi-square attack keys on. Derive a permutation of the channel-slot order from the passphrase (or a stored seed) and embed along that permutation instead of in raster order. `Reveal` reconstructs the same permutation and reads in the same order. Then run your chi-square detector again: sequential detection should collapse, because the attack assumes contiguous embedding. This connects steganography and cryptography in one change, since the hiding order now depends on the key.
**Add the Tags-block alternate for zero-width text.** The text carrier uses U+200B and U+2060. The Unicode Tags block (U+E0000 to U+E007F) is also invisible and maps directly onto ASCII, so it can be more compact for text-heavy payloads, at the cost of a glaring byte signature (`F3 A0 8x`). Add it behind a flag in `text.go`, keeping the two-rune scheme the default. The teaching value is in documenting, in a test comment or the learn track, exactly why the default is the safer alphabet.
## Advanced
**Implement native FLAC output.** Today a FLAC cover is decoded and re-emitted as WAV, because the only Go FLAC encoder emits frames that strict parsers reject and has a panic path in `frame.Hash`. Implement `--flac-native` properly: encode back to FLAC, recover defensively from the known panic, and verify that crypha-in to crypha-out round-trips even if universal player compatibility does not hold. The honest deliverable is a test that proves the round-trip and a documented limit on which players accept the output. This is a real, unsolved-in-Go problem, not a toy.
**Implement the invisible-text PDF technique.** The PDF carrier offers attachment, metadata, and append. The fourth technique from the research, text drawn in render mode 3 (neither filled nor stroked, so invisible), is deferred because pdfcpu has no write-page-content API and it requires surgery on the PDF's internal object table that breaks between library versions. Building it means learning the PDF content-stream model deeply. Success is a stego PDF whose invisible text carries the envelope, survives a round-trip, and does not render anything a human sees.
**Push the QR carrier past version 10, or below level H.** The carrier fixes error-correction level H and supports versions 1 to 10, which bounds capacity at tens of bytes. Extend `versionTable` in `blocks.go` with the block structures for versions 11 and up (transcribed carefully from the ISO tables), or generalize `parseFormat` and the injection logic to accept levels Q, M, and L. Lower levels have fewer EC codewords per block, so `t` shrinks and capacity with it, which is a counter-intuitive result worth confirming with a test. Every addition must pass both differential oracles: your clean matrix must match `skip2`, and your stego must still scan under `gozxing`.
**Build adaptive, edge-aware embedding.** Uniform LSB embedding in a smooth region (a clear sky, a sustained tone) is where steganalysis is strongest, because the local statistics are predictable. Adaptive embedding hides only in high-variance regions, edges, texture, transients, where a one-bit change is statistically invisible. Compute a local variance map of the cover, embed only where variance exceeds a threshold, and store the threshold so `Reveal` walks the same regions. Then measure the result with your chi-square detector. This is the frontier of practical image steganography and a genuine research direction.
## A capstone: the full adversary loop
If you want one project that ties the whole tool together, build the complete loop crypha models: hide a payload, run a battery of your own detectors against the stego (chi-square, RS analysis, a zero-width rune scan, a `strings | tail` check on PDFs), score how detectable each carrier is, and then improve the weakest carrier until your own detectors miss it. When you can no longer detect your own embedding, you will have learned more about steganography than any tutorial teaches, because you will have played both sides of the exchange the tool exists to teach.
## Where to go next
You have read the whole track. Re-read [01-CONCEPTS.md](./01-CONCEPTS.md) with the code in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) fresh in mind; the theory reads differently once you have seen the nine lines of `injectFramed` that make it real. Then pick one challenge above and make the test suite fail, then pass.

View File

@ -25,7 +25,7 @@
<h2 align="center"><strong>View Complete Projects:</strong></h2>
<div align="center">
<a href="https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS">
<img src="https://img.shields.io/badge/Full_Source_Code-39/70-blue?style=for-the-badge&logo=github" alt="Projects"/>
<img src="https://img.shields.io/badge/Full_Source_Code-40/70-blue?style=for-the-badge&logo=github" alt="Projects"/>
</a>
</div>
@ -92,7 +92,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr
| **[Metadata Scrubber Tool](./PROJECTS/beginner/metadata-scrubber-tool)**<br>Remove EXIF and privacy metadata [@Heritage-XioN](https://github.com/Heritage-XioN) | ![2-3h](https://img.shields.io/badge/⏱_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | EXIF data • Privacy protection • Batch processing<br>[Source Code](./PROJECTS/beginner/metadata-scrubber-tool) \| [Docs](./PROJECTS/beginner/metadata-scrubber-tool/learn) |
| **[Network Traffic Analyzer](./PROJECTS/beginner/network-traffic-analyzer)**<br>Capture and analyze packets | ![3-5h](https://img.shields.io/badge/⏱_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C++](https://img.shields.io/badge/C%2B%2B-00599C?logo=cplusplus&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Packet capture • Protocol analysis • Traffic visualization<br>[Source (C++)](./PROJECTS/beginner/network-traffic-analyzer/cpp) \| [Docs (C++)](./PROJECTS/beginner/network-traffic-analyzer/cpp/learn) \| [Source (Python)](./PROJECTS/beginner/network-traffic-analyzer/python) \| [Docs (Python)](./PROJECTS/beginner/network-traffic-analyzer/python/learn) |
| **[Hash Cracker](./PROJECTS/beginner/hash-cracker)**<br>Dictionary and brute-force cracking | ![3-4h](https://img.shields.io/badge/⏱_5--6h-blue) ![C++](https://img.shields.io/badge/C%2B%2B-00599C?logo=cplusplus&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Hash algorithms • Dictionary attacks • Password security<br>[Source Code](./PROJECTS/beginner/hash-cracker) \| [Docs](./PROJECTS/beginner/hash-cracker/learn) |
| **[Steganography Multi-Tool](./SYNOPSES/beginner/Steganography.Multi.Tool.md)**<br>Hide data in images, audio, QR, PDFs, text | ![2-3h](https://img.shields.io/badge/⏱_8--10h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Multi-format steganography • Zero-width Unicode • Audio LSB • QR exploitation<br>[Learn More](./SYNOPSES/beginner/Steganography.Multi.Tool.md) |
| **[Steganography Multi-Tool](./SYNOPSES/beginner/Steganography.Multi.Tool.md)**<br>Hide data in images, audio, QR, PDFs, text | ![8-10h](https://img.shields.io/badge/⏱_8--10h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Multi-format steganography • Encrypted AEAD envelope • Zero-width Unicode • Audio LSB • QR Reed-Solomon injection<br>[Source Code](./PROJECTS/beginner/steganography-multi-tool) \| [Docs](./PROJECTS/beginner/steganography-multi-tool/learn) |
| **[Ghost on the Wire](./SYNOPSES/beginner/Ghost.On.The.Wire.md)**<br>L2 attack & defense: MAC spoofing + ARP detection | ![2-3h](https://img.shields.io/badge/⏱_6--8h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | ARP protocol • MAC spoofing • MITM detection • L2 trust mapping<br>[Learn More](./SYNOPSES/beginner/Ghost.On.The.Wire.md) |
| **[Canary Token Generator](./PROJECTS/beginner/canary-token-generator)**<br>Self-hosted honeytokens that alert on access | ![2-3h](https://img.shields.io/badge/⏱_8--10h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Deception defense • Honeytokens • MySQL wire protocol • PDF/DOCX patching • Webhook + Telegram alerting<br>[Source Code](./PROJECTS/beginner/canary-token-generator) \| [Docs](./PROJECTS/beginner/canary-token-generator/learn)<br>[![iglowinthedark.com](https://img.shields.io/badge/iglowinthedark.com-8B5CF6?style=flat&logo=googlechrome&logoColor=white)](https://iglowinthedark.com/) |
| **[Phishing Domain Generator & Quishing Scanner](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md)**<br>Typosquat generation + QR phishing detection | ![2-3h](https://img.shields.io/badge/⏱_6--8h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Homoglyph attacks • Typosquatting • QR code analysis • Domain intelligence<br>[Learn More](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md) |