From 9f46eb00abe542d6803616dfe6e21655bf5f0639 Mon Sep 17 00:00:00 2001 From: pableeee Date: Sat, 1 Aug 2026 20:44:40 -0300 Subject: [PATCH 1/2] fix: correct capacity drift, clamp payload length, pixel-align parallel chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the findings from a full review of the working tree. CLI capacity was computed twice, and the copies disagreed. cmd/steg had its own imageCapacity() still using the pre-c69a626 overhead of 44 bytes (4-byte encrypted nonce) while the library had moved to 56 (16-byte plaintext salt). Every `steg test-visual` run therefore failed on its first image, because it sized payloads 12 bytes above what Encode would accept: Error: encode ch=1 bpc=1: steg: payload too large (468 bytes, capacity 456) and `steg capacity` over-reported every cell in its table by the same 12 bytes. The arithmetic now lives only in steg.CapacityBytes / steg.CapacityForDims with an exported Overhead constant, so the two cannot drift apart again. The container length field sized an allocation before it was authenticated. It is decrypted but not yet MAC-verified when read, so a wrong password yields an essentially random uint32 and `make([]byte, length)` would reserve up to 4 GiB before any read failed. ReadPayload now takes a maxPayload bound and DecodeParallel applies the same check inline; both reject anything larger than the carrier could hold. TestExcessiveLength previously allocated 2 GiB on every run and now asserts the rejection instead. Parallel encode could silently corrupt an image. Chunk sizes were aligned to lcm(8, bitsPerPixel)/8 bytes, but the payload starts at bit 160 and 160 mod 3 = 1, so with the default 3 channels every chunk boundary landed mid-pixel and the two workers on either side both loaded, modified, and stored that pixel — the later store discarding the other's bits, leaving an image that fails MAC verification on decode. The shared mutex did not prevent this: each At and Set was already serialised, but the load-modify-store sequence was not atomic. firstChunkShift now offsets the first chunk so every later boundary falls on a pixel boundary. Verified beforehand that all 3-channel configurations shared a pixel at 8/8 sampled boundaries and 1- and 2-channel configurations shared none, matching the modular arithmetic. The bug is a lost update between mutex-protected accesses, so the race detector cannot see it and a round-trip test passes by luck most of the time; TestChunkBoundariesArePixelAligned checks the invariant arithmetically instead, with TestParallelMultiChunkRoundTrip covering the end-to-end path at full capacity across five configurations. Also fixed: - Dispatch loops could block forever on jobChan if every worker had already exited on error. Both loops now select on an abort channel closed by the first failure. - Cursors addressed sub-images incorrectly: the pixel sequence was generated over Bounds().Max rather than Dx/Dy and never offset by Bounds().Min, so a carrier with a non-zero origin read zeroes and dropped writes outside its own bounds. Confirmed the new sub-image tests fail without the offset. - `steg decode` created the output file before decoding, leaving a zero-byte file behind on a wrong password. It now decodes first. - Passwords had to be passed via --password, which exposes them in the process table and shell history. resolvePassword falls back to STEG_PASSWORD and then an interactive prompt, and refuses to run with no password when stdin is not a terminal. - `go vet ./...` failed on cursors: ReadByte/WriteByte were declared with uint8 rather than byte, tripping the stdmethods check. Generated gomock recorders trip the same check unfixably, so `make vet` excludes mocks/. - Stale comments: container basePos described as "after nonce" (it is after the salt), parallel.go referring to the salt as encrypted (it is plaintext), and cipher's mixIndex typo for minIndex. Adds .github/workflows/test.yml running vet, tests, the race detector, and a build on pull requests and non-master pushes. Until now the only workflow ran on pushes to master, so a broken change was caught only after merge, when it blocked the release job. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 32 ++++++++ CLAUDE.md | 6 +- Makefile | 10 +++ README.md | 47 ++++++++++-- cipher/cipher.go | 8 +- cmd/steg/root.go | 101 ++++++++++++++++++------- cursors/cursor.go | 4 +- cursors/middleware.go | 4 +- cursors/rng_cursor.go | 36 +++++---- go.mod | 1 + go.sum | 2 + mocks/cursors/cursor.go | 6 +- steg/analysis/analysis_test.go | 2 +- steg/chunkalign_test.go | 71 ++++++++++++++++++ steg/container/container.go | 20 ++++- steg/container/container_test.go | 32 ++++++-- steg/decode.go | 6 +- steg/parallel.go | 124 +++++++++++++++++++++++++------ steg/parallel_internal_test.go | 54 ++++++++++++++ steg/steg.go | 33 +++++--- steg/subimage_test.go | 54 ++++++++++++++ 21 files changed, 550 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 steg/chunkalign_test.go create mode 100644 steg/parallel_internal_test.go create mode 100644 steg/subimage_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b80fff6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test + +on: + pull_request: + push: + branches-ignore: + - master + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Vet + run: make vet + + - name: Test + run: go test ./... + + - name: Test with race detector + run: go test -race ./... + + - name: Build + run: go build ./... diff --git a/CLAUDE.md b/CLAUDE.md index ce56ecd..481521c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,8 @@ Decoding reverses the pipeline: derives seed via SHA-256(pass), reads the 16-byt ### Package responsibilities -- **`steg/`** — Top-level encode/decode orchestration; `steg.go` derives the pixel-traversal seed via `deriveSeed` (SHA-256) and all crypto keys via `deriveMainKeys` (Argon2id); `buildPaddedPayload` / `extractRealPayload` handle full-capacity padding. -- **`steg/container/`** — Payload framing. Writes `[encrypted 4-byte length][encrypted data][encrypted HMAC-SHA256 tag]`. On read, verifies the HMAC-SHA256 tag keyed with `macKey`; a wrong password causes tag verification failure. +- **`steg/`** — Top-level encode/decode orchestration; `steg.go` derives the pixel-traversal seed via `deriveSeed` (SHA-256) and all crypto keys via `deriveMainKeys` (Argon2id); `buildPaddedPayload` / `extractRealPayload` handle full-capacity padding. `CapacityBytes` / `CapacityForDims` and the `Overhead` constant are the single source of truth for capacity — never reimplement the arithmetic elsewhere, or callers will drift from the encoder and build payloads it rejects. +- **`steg/container/`** — Payload framing. Writes `[encrypted 4-byte length][encrypted data][encrypted HMAC-SHA256 tag]`. `ReadPayload(r, hashFn, maxPayload)` verifies the HMAC-SHA256 tag keyed with `macKey`; a wrong password causes tag verification failure. `maxPayload` bounds the length field before it sizes an allocation — that field is decrypted but not yet authenticated when read, so a wrong password otherwise yields an arbitrary uint32. - **`cursors/`** — Three components that compose: - `rng_cursor.go`: Fisher-Yates shuffled pixel traversal using the seed; exposes byte-level `ReadByte/WriteByte`. - `adapter.go`: Wraps the `Cursor` interface into `io.ReadWriteSeeker`. @@ -76,3 +76,5 @@ The `cursorOptions(seed, bitsPerChannel, channels)` helper in `steg/steg.go` bui Chunk alignment for parallel operation: `lcm(8 bits/byte, channels × bitsPerChannel bits/pixel) / 8` bytes per aligned chunk boundary. With defaults (3 channels, 1 bit/ch) this is 3 bytes = 8 pixels; values change with different settings. +That alignment only makes chunk *sizes* pixel-aligned. The payload also starts at bit 160 (16-byte salt + 4-byte length), and 160 is not a multiple of `bitsPerPixel` when `bitsPerPixel` is divisible by 3 — including the default of 3. `firstChunkShift` in `steg/parallel.go` shifts the first chunk by the remainder so every later boundary lands on a pixel boundary. Without it two workers read-modify-write the same boundary pixel and the later `img.Set` silently discards the other's bits, producing an image that fails MAC verification. A shared mutex does not fix this: each `At`/`Set` is already serialised, but the load-modify-store *sequence* is not atomic. `TestChunkBoundariesArePixelAligned` guards the invariant arithmetically, because the race window is too narrow for a round-trip test to catch reliably. + diff --git a/Makefile b/Makefile index 21cbb49..ba0bd7e 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,19 @@ mocks: $(MOCKGENERATE) -source=cipher/cipher.go -destination=mocks/cipher/cipher.go $(MOCKGENERATE) -source=cursors/cursor.go -destination=mocks/cursors/cursor.go +.PHONY: test test: @go test ./... +## vet: run go vet over hand-written packages +# mocks/ is excluded: gomock's generated recorder methods are named after the +# interface methods they record but return *gomock.Call, which trips vet's +# stdmethods check for any interface method sharing a name with a stdlib one +# (ReadByte, WriteByte). Nothing in the generated code is fixable from here. +.PHONY: vet +vet: + @go vet $$(go list ./... | grep -v '/mocks/') + build: cd cmd/steg && go build diff --git a/README.md b/README.md index 9ec9248..b597c40 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ **steg** is a command-line steganography tool written in Go. It hides an arbitrary file inside a PNG, BMP, or TIFF image by modifying the least-significant bits of selected color channels in a pseudorandom pixel sequence. The number of bits per channel (1–8) and the number of channels (R / R+G / R+G+B) are configurable, trading capacity for visual detectability. The hidden data is encrypted and authenticated, so the carrier image looks near-identical to the original while the payload is unreadable and tamper-evident without the correct password. [![Go Reference](https://pkg.go.dev/badge/github.com/pableeee/steg.svg)](https://pkg.go.dev/github.com/pableeee/steg) -[![CI](https://github.com/pableeee/steg/actions/workflows/release.yml/badge.svg)](https://github.com/pableeee/steg/actions/workflows/release.yml) +[![Test](https://github.com/pableeee/steg/actions/workflows/test.yml/badge.svg)](https://github.com/pableeee/steg/actions/workflows/test.yml) +[![Release](https://github.com/pableeee/steg/actions/workflows/release.yml/badge.svg)](https://github.com/pableeee/steg/actions/workflows/release.yml) --- @@ -12,6 +13,7 @@ - [Features](#features) - [Installation](#installation) - [Usage](#usage) + - [Supplying the password](#supplying-the-password) - [Capacity](#capacity) - [Performance](#performance) - [Security design](#security-design) @@ -63,7 +65,7 @@ sudo mv steg-linux-amd64 /usr/local/bin/steg ### Build from source -Requires Go 1.24+. +Requires Go 1.25+. ```bash go install github.com/pableeee/steg/cmd/steg@latest @@ -81,6 +83,25 @@ make build # produces cmd/steg/steg ## Usage +### Supplying the password + +Every command that needs a passphrase resolves it in this order: + +1. **`--password` / `-p`** — convenient, but the value is visible to every other + user on the machine via the process table (`ps aux`) and is written to your + shell history. Prefer it only in scripts where neither matters. +2. **`STEG_PASSWORD` environment variable** — better for scripting and CI. +3. **Interactive prompt** — used when neither of the above is set and stdin is a + terminal. `encode` asks for confirmation; `decode` does not. + +If no password is available and stdin is not a terminal, the command fails +rather than proceeding with an empty passphrase. + +```bash +steg encode -i carrier.png -f secret.txt -o output.png # prompts +STEG_PASSWORD="my passphrase" steg decode -i output.png -o recovered.txt +``` + ### Encode Hide a file inside a carrier image: @@ -94,7 +115,7 @@ steg encode -i carrier.png -f secret.txt -o output.png -p "my passphrase" | `--input_image` | `-i` | — | Carrier image (PNG, BMP, or TIFF) | | `--input_file` | `-f` | — | File to hide | | `--output_image` | `-o` | — | Output image containing the hidden data | -| `--password` | `-p` | — | Passphrase (**required**) | +| `--password` | `-p` | prompt | Passphrase; see [Supplying the password](#supplying-the-password) | | `--bits-per-channel` | `-b` | `1` | Number of LSBs to use per color channel (1–8) | | `--channels` | `-c` | `3` | Color channels to use: 1=R, 2=R+G, 3=R+G+B | | `--parallel` | `-P` | off | Use parallel worker pool (faster on large images) | @@ -111,7 +132,7 @@ steg decode -i output.png -o recovered.txt -p "my passphrase" |---|---|---|---| | `--input_image` | `-i` | — | Image containing the hidden data | | `--output_file` | `-o` | — | Path for the recovered file | -| `--password` | `-p` | — | Passphrase (**required**) | +| `--password` | `-p` | prompt | Passphrase; see [Supplying the password](#supplying-the-password) | | `--bits-per-channel` | `-b` | `1` | Must match the value used during encode | | `--channels` | `-c` | `3` | Must match the value used during encode | | `--parallel` | `-P` | off | Use parallel worker pool (faster on large images) | @@ -345,7 +366,7 @@ A single AES-128-CTR payload cipher (`AES-CTR(encKey, payloadNonce)`) encrypts e ### Prerequisites -- Go 1.24+ +- Go 1.25+ - `make` ### Commands @@ -360,6 +381,9 @@ make install # Run all tests make test +# Vet hand-written packages (generated mocks are excluded) +make vet + # Run tests with the race detector go test -race ./steg/ @@ -391,7 +415,16 @@ go test ./steg/ -bench=BenchmarkDecodeBySize -benchtime=3s -benchmem ### Continuous integration -Every push to `master` triggers a GitHub Actions workflow that: +Two GitHub Actions workflows: + +**`test.yml`** runs on every pull request and on pushes to non-`master` branches: + +1. `make vet` +2. `go test ./...` +3. `go test -race ./...` +4. `go build ./...` + +**`release.yml`** runs on every push to `master`: 1. Runs `go test ./...` 2. Cross-compiles binaries for Linux, macOS, and Windows (amd64 + arm64) @@ -404,7 +437,7 @@ Every push to `master` triggers a GitHub Actions workflow that: | Issue | Severity | Notes | |---|---|---| | MAC-then-Encrypt ordering | Low | HMAC is computed over plaintext before encryption. Unconventional (Encrypt-then-MAC is preferred), but not exploitable in this threat model since the tag is inside the encrypted channel. | -| No streaming decode | Medium | `ReadPayload` allocates the full payload in memory before returning. Very large payloads may cause high memory usage. | +| No streaming decode | Medium | `ReadPayload` allocates the full payload in memory before returning. Very large payloads may cause high memory usage. The declared length is clamped to the carrier's capacity first, so a wrong password cannot trigger an oversized allocation. | | Lossy formats unsupported | High | JPEG and other lossy formats destroy LSB data. Only lossless formats (PNG, BMP, TIFF) are supported. | | Statistical steganalysis | Medium | Modifying the LSBs of color channels across a pseudorandom pixel set produces a detectable statistical signature. The built-in `detect` command uses chi-square and RS analysis to surface this. Chi-square reliably detects full-fill encoding; RS analysis effectiveness varies with the carrier image's natural LSB distribution. Higher bits-per-channel settings make signatures more pronounced. | diff --git a/cipher/cipher.go b/cipher/cipher.go index c575b20..c4b41fd 100644 --- a/cipher/cipher.go +++ b/cipher/cipher.go @@ -25,7 +25,7 @@ type streamCipherImpl struct { currentBlock []byte index int64 - mixIndex int64 + minIndex int64 maxIndex int64 block std_cipher.Block @@ -82,7 +82,7 @@ func (s *streamCipherImpl) refreshCipherBlock() { payload := append(nonceBytes, counterBytes...) s.currentBlock = make([]byte, s.blockSize) s.block.Encrypt(s.currentBlock, payload) - s.mixIndex = int64(s.blockSize * s.counter * 8) + s.minIndex = int64(s.blockSize * s.counter * 8) s.maxIndex = int64((s.counter + 1) * s.blockSize * 8) } @@ -106,7 +106,7 @@ func (s *streamCipherImpl) Seek(n int64, whence int) (int64, error) { return 0, fmt.Errorf("not implemented") } - if n > s.maxIndex || n < s.mixIndex { + if n > s.maxIndex || n < s.minIndex { s.counter = uint32(n / int64(s.blockSize*8)) s.refreshCipherBlock() } @@ -135,7 +135,7 @@ func (s *streamCipherImpl) DecryptByte(b uint8) (uint8, error) { // processBit processes a single bit for encryption or decryption. func (s *streamCipherImpl) processBit(bichi uint8) (uint8, error) { - if s.index >= s.maxIndex || s.index < s.mixIndex { + if s.index >= s.maxIndex || s.index < s.minIndex { s.counter = uint32(s.index / int64(s.blockSize*8)) s.refreshCipherBlock() } diff --git a/cmd/steg/root.go b/cmd/steg/root.go index 52489f7..929e967 100644 --- a/cmd/steg/root.go +++ b/cmd/steg/root.go @@ -10,11 +10,13 @@ import ( "os" "path/filepath" "strings" + "syscall" "github.com/pableeee/steg/steg" "github.com/spf13/cobra" "golang.org/x/image/bmp" "golang.org/x/image/tiff" + "golang.org/x/term" ) var parallel bool @@ -101,7 +103,8 @@ func init() { encodeCmd.Flags().BoolVarP(¶llel, "parallel", "P", false, "use parallel encode") encodeCmd.Flags().IntVarP(&bitsPerChannel, "bits-per-channel", "b", 1, "number of LSBs to use per color channel (1-8)") encodeCmd.Flags().IntVarP(&channels, "channels", "c", 3, "number of color channels to use: 1=R, 2=R+G, 3=R+G+B") - encodeCmd.MarkFlagRequired("password") + // password is intentionally not required: resolvePassword falls back to + // STEG_PASSWORD or an interactive prompt. decodeCmd.Flags().StringVarP( &decoderFlags.inputFile, "input_image", "i", "", "Image containing the coded message.", @@ -115,7 +118,6 @@ func init() { decodeCmd.Flags().BoolVarP(¶llel, "parallel", "P", false, "use parallel decode") decodeCmd.Flags().IntVarP(&bitsPerChannel, "bits-per-channel", "b", 1, "number of LSBs to use per color channel (1-8)") decodeCmd.Flags().IntVarP(&channels, "channels", "c", 3, "number of color channels to use: 1=R, 2=R+G, 3=R+G+B") - decodeCmd.MarkFlagRequired("password") capacityCmd.Flags().StringVarP( &capacityFlags.inputImage, "input_image", "i", "", "Image to measure (PNG, BMP, TIFF).", @@ -133,7 +135,6 @@ func init() { ) testVisualCmd.MarkFlagRequired("input_image") testVisualCmd.MarkFlagRequired("output_dir") - testVisualCmd.MarkFlagRequired("password") rootCmd.AddCommand(encodeCmd) rootCmd.AddCommand(decodeCmd) @@ -142,6 +143,47 @@ func init() { rootCmd.AddCommand(detectCmd) } +// resolvePassword returns the password to use, preferring an explicit flag, +// then the STEG_PASSWORD environment variable, and finally an interactive +// prompt. A password passed via --password is visible to every other process on +// the machine through the process table and is recorded in shell history, so +// the prompt is the safer default when the terminal is interactive. +func resolvePassword(flagValue string, confirm bool) ([]byte, error) { + if flagValue != "" { + return []byte(flagValue), nil + } + if env := os.Getenv("STEG_PASSWORD"); env != "" { + return []byte(env), nil + } + if !term.IsTerminal(int(syscall.Stdin)) { + return nil, fmt.Errorf( + "no password provided: pass --password, set STEG_PASSWORD, or run on a terminal") + } + + fmt.Fprint(os.Stderr, "Password: ") + pass, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stderr) + if err != nil { + return nil, fmt.Errorf("unable to read password: %w", err) + } + if len(pass) == 0 { + return nil, fmt.Errorf("password must not be empty") + } + + if confirm { + fmt.Fprint(os.Stderr, "Confirm password: ") + again, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stderr) + if err != nil { + return nil, fmt.Errorf("unable to read password: %w", err) + } + if !bytes.Equal(pass, again) { + return nil, fmt.Errorf("passwords do not match") + } + } + return pass, nil +} + func toDrawImage(src image.Image) draw.Image { bounds := src.Bounds() cimg := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) @@ -192,6 +234,11 @@ func runEncode() error { return fmt.Errorf("--channels must be between 1 and 3, got %d", channels) } + pass, err := resolvePassword(encoderFlags.key, true) + if err != nil { + return err + } + src, err := decodeImage(encoderFlags.inputImage) if err != nil { return err @@ -205,9 +252,9 @@ func runEncode() error { defer fmsg.Close() if parallel { - err = steg.EncodeParallel(cimg, []byte(encoderFlags.key), bufio.NewReader(fmsg), bitsPerChannel, channels) + err = steg.EncodeParallel(cimg, pass, bufio.NewReader(fmsg), bitsPerChannel, channels) } else { - err = steg.Encode(cimg, []byte(encoderFlags.key), bufio.NewReader(fmsg), bitsPerChannel, channels) + err = steg.Encode(cimg, pass, bufio.NewReader(fmsg), bitsPerChannel, channels) } if err != nil { return err @@ -224,47 +271,41 @@ func runDecode() error { return fmt.Errorf("--channels must be between 1 and 3, got %d", channels) } - src, err := decodeImage(decoderFlags.inputFile) + pass, err := resolvePassword(decoderFlags.key, false) if err != nil { return err } - out, err := os.Create(decoderFlags.outputFile) + src, err := decodeImage(decoderFlags.inputFile) if err != nil { - return fmt.Errorf("unable to create output file: %w", err) + return err } - defer out.Close() + // Decode before touching the output path: a wrong password must not leave a + // truncated or empty file behind. var b []byte if parallel { - b, err = steg.DecodeParallel(toDrawImage(src), []byte(decoderFlags.key), bitsPerChannel, channels) + b, err = steg.DecodeParallel(toDrawImage(src), pass, bitsPerChannel, channels) } else { - b, err = steg.Decode(toDrawImage(src), []byte(decoderFlags.key), bitsPerChannel, channels) + b, err = steg.Decode(toDrawImage(src), pass, bitsPerChannel, channels) } if err != nil { return err } - _, err = out.Write(b) + out, err := os.Create(decoderFlags.outputFile) if err != nil { + return fmt.Errorf("unable to create output file: %w", err) + } + defer out.Close() + + if _, err = out.Write(b); err != nil { return err } return nil } -// imageCapacity returns the usable byte capacity for the given image dimensions, -// channel count, and bits per channel. The 44-byte overhead covers the 4-byte -// encrypted nonce, 4-byte container-length, 4-byte real-length prefix, and -// 32-byte HMAC tag. -func imageCapacity(w, h, ch, bpc int) int { - total := w * h * ch * bpc / 8 - if total <= 44 { - return 0 - } - return total - 44 -} - func runCapacity() error { src, err := decodeImage(capacityFlags.inputImage) if err != nil { @@ -290,13 +331,14 @@ func runCapacity() error { for ch := 1; ch <= 3; ch++ { fmt.Printf(" %s", chNames[ch-1]) for _, bpc := range bpcValues { - cap := imageCapacity(w, h, ch, bpc) + cap := steg.CapacityForDims(w, h, ch, bpc) fmt.Printf("%*s", col, humanBytes(cap)) } fmt.Println() } - fmt.Println("\nOverhead: 44 B (4 enc-nonce + 4 container-length + 4 real-length + 32 HMAC).") + fmt.Printf("\nOverhead: %d B (16 plaintext-salt + 4 container-length + 4 real-length + 32 HMAC).\n", + steg.Overhead) return nil } @@ -340,7 +382,10 @@ func runTestVisual() error { b := src.Bounds() w, h := b.Max.X, b.Max.Y - pass := []byte(testVisualFlags.key) + pass, err := resolvePassword(testVisualFlags.key, false) + if err != nil { + return err + } bpcValues := []int{1, 2, 4, 8} total := 3 * len(bpcValues) @@ -350,7 +395,7 @@ func runTestVisual() error { for ch := 1; ch <= 3; ch++ { for _, bpc := range bpcValues { - cap := imageCapacity(w, h, ch, bpc) + cap := steg.CapacityForDims(w, h, ch, bpc) name := fmt.Sprintf("visual_ch%d_b%d.png", ch, bpc) outPath := filepath.Join(testVisualFlags.outputDir, name) diff --git a/cursors/cursor.go b/cursors/cursor.go index 0516790..bc2ad8c 100644 --- a/cursors/cursor.go +++ b/cursors/cursor.go @@ -2,8 +2,8 @@ package cursors type Cursor interface { Seek(offset int64, whence int) (int64, error) - ReadByte() (uint8, error) - WriteByte(uint8) error + ReadByte() (byte, error) + WriteByte(byte) error } type BitColor uint diff --git a/cursors/middleware.go b/cursors/middleware.go index 8c59050..30721c2 100644 --- a/cursors/middleware.go +++ b/cursors/middleware.go @@ -30,7 +30,7 @@ func (c *cipherMiddleware) Seek(n int64, whence int) (int64, error) { return n, nil } -func (c *cipherMiddleware) WriteByte(b uint8) error { +func (c *cipherMiddleware) WriteByte(b byte) error { encrypted, err := c.block.EncryptByte(b) if err != nil { return err @@ -38,7 +38,7 @@ func (c *cipherMiddleware) WriteByte(b uint8) error { return c.next.WriteByte(encrypted) } -func (c *cipherMiddleware) ReadByte() (uint8, error) { +func (c *cipherMiddleware) ReadByte() (byte, error) { b, err := c.next.ReadByte() if err != nil { return 0, err diff --git a/cursors/rng_cursor.go b/cursors/rng_cursor.go index 29386b5..614ba65 100644 --- a/cursors/rng_cursor.go +++ b/cursors/rng_cursor.go @@ -34,15 +34,21 @@ func generateSequence(width, height int, rng *rand.Rand) []image.Point { } type RNGCursor struct { - img draw.Image - cursor int64 - bitMask BitColor - bitCount uint - bitsPerChannel int - useBits []BitColor - points []image.Point - rng *rand.Rand - maxBits int64 // pre-computed capacity in bits + img draw.Image + cursor int64 + bitMask BitColor + bitCount uint + bitsPerChannel int + useBits []BitColor + points []image.Point + rng *rand.Rand + maxBits int64 // pre-computed capacity in bits + + // min is the image's origin. points are generated in the range + // [0,Dx)×[0,Dy) and offset by min on access, so sub-images whose bounds do + // not start at (0,0) address their own pixels rather than falling outside + // the image and silently reading zeroes. + min image.Point // imgMu, when non-nil, is locked around every img.At() and img.Set() call. // Set via WithImageMutex to eliminate data races when multiple cursors share @@ -105,8 +111,10 @@ func NewRNGCursor(img draw.Image, options ...Option) *RNGCursor { opt(c) } + b := img.Bounds() + c.min = b.Min if c.points == nil { - c.points = generateSequence(img.Bounds().Max.X, img.Bounds().Max.Y, c.rng) + c.points = generateSequence(b.Dx(), b.Dy(), c.rng) } for _, color := range Colors { if c.bitMask&color == color { @@ -114,7 +122,7 @@ func NewRNGCursor(img draw.Image, options ...Option) *RNGCursor { c.useBits = append(c.useBits, color) } } - c.maxBits = int64(img.Bounds().Max.X) * int64(img.Bounds().Max.Y) * int64(c.bitCount) * int64(c.bitsPerChannel) + c.maxBits = int64(b.Dx()) * int64(b.Dy()) * int64(c.bitCount) * int64(c.bitsPerChannel) return c } @@ -145,7 +153,7 @@ func (c *RNGCursor) Flush() { // loadPixel flushes the current dirty pixel (if any) then loads pixelIdx into cache. func (c *RNGCursor) loadPixel(pixelIdx int64) { c.Flush() - pt := c.points[pixelIdx] + pt := c.points[pixelIdx].Add(c.min) var r, g, b, a uint32 if c.imgMu != nil { c.imgMu.Lock() @@ -193,7 +201,7 @@ func (c *RNGCursor) Seek(n int64, whence int) (int64, error) { // Slot arithmetic accounts for bitsPerChannel: each pixel holds // bitCount*bitsPerChannel bit slots, ordered by channel then by bit // position within the channel (MSB-first within each channel's N bits). -func (c *RNGCursor) ReadByte() (uint8, error) { +func (c *RNGCursor) ReadByte() (byte, error) { bitsPerPixel := int64(c.bitCount) * int64(c.bitsPerChannel) pixelIdx := c.cursor / bitsPerPixel slotInPixel := int(c.cursor % bitsPerPixel) @@ -234,7 +242,7 @@ func (c *RNGCursor) ReadByte() (uint8, error) { // Slot arithmetic accounts for bitsPerChannel: each pixel holds // bitCount*bitsPerChannel bit slots, ordered by channel then by bit // position within the channel (MSB-first within each channel's N bits). -func (c *RNGCursor) WriteByte(b uint8) error { +func (c *RNGCursor) WriteByte(b byte) error { bitsPerPixel := int64(c.bitCount) * int64(c.bitsPerChannel) pixelIdx := c.cursor / bitsPerPixel slotInPixel := int(c.cursor % bitsPerPixel) diff --git a/go.mod b/go.mod index 620de16..e1d9cc9 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.52.0 golang.org/x/image v0.43.0 + golang.org/x/term v0.43.0 ) require ( diff --git a/go.sum b/go.sum index 7262041..c58b67c 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/mocks/cursors/cursor.go b/mocks/cursors/cursor.go index 56ef462..64818bd 100644 --- a/mocks/cursors/cursor.go +++ b/mocks/cursors/cursor.go @@ -34,10 +34,10 @@ func (m *MockCursor) EXPECT() *MockCursorMockRecorder { } // ReadByte mocks base method. -func (m *MockCursor) ReadByte() (uint8, error) { +func (m *MockCursor) ReadByte() (byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReadByte") - ret0, _ := ret[0].(uint8) + ret0, _ := ret[0].(byte) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -64,7 +64,7 @@ func (mr *MockCursorMockRecorder) Seek(offset, whence interface{}) *gomock.Call } // WriteByte mocks base method. -func (m *MockCursor) WriteByte(arg0 uint8) error { +func (m *MockCursor) WriteByte(arg0 byte) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "WriteByte", arg0) ret0, _ := ret[0].(error) diff --git a/steg/analysis/analysis_test.go b/steg/analysis/analysis_test.go index 4c7c8de..52326a5 100644 --- a/steg/analysis/analysis_test.go +++ b/steg/analysis/analysis_test.go @@ -1,7 +1,7 @@ // Package analysis_test verifies the chi-square and RS steganalysis detectors // using images produced by the steg encoder. // -// Test image notes +// # Test image notes // // naturalImage generates a synthetic image where all channel values are even // (LSB = 0). This gives chi-square a clear clean baseline: pairs (2k, 2k+1) diff --git a/steg/chunkalign_test.go b/steg/chunkalign_test.go new file mode 100644 index 0000000..ce29c22 --- /dev/null +++ b/steg/chunkalign_test.go @@ -0,0 +1,71 @@ +package steg_test + +import ( + "bytes" + "image" + "image/color" + "math/rand" + "testing" + + "github.com/pableeee/steg/steg" + "github.com/stretchr/testify/require" +) + +// TestParallelMultiChunkRoundTrip fills a carrier to capacity so the padded +// payload spans many worker chunks. Chunk boundaries must land on pixel +// boundaries; if they do not, two workers read-modify-write the same pixel and +// one worker's bits are lost, which surfaces here as a MAC failure. +// +// The default configuration (3 channels, 1 bit/channel) is the interesting one: +// the payload region starts at bit 160, and 160 mod 3 = 1, so a naive +// byte-aligned chunking scheme puts every boundary mid-pixel. +func TestParallelMultiChunkRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + bitsPerChannel, chans int + }{ + {"3ch_1bpc", 1, 3}, + {"2ch_1bpc", 1, 2}, + {"1ch_1bpc", 1, 1}, + {"3ch_2bpc", 2, 3}, + {"3ch_4bpc", 4, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + pass := []byte("testpass") + m := newNoisyImage(512, 512) + + capacity := steg.CapacityBytes(m, tc.bitsPerChannel, tc.chans) + require.Greater(t, capacity, 0) + + payload := make([]byte, capacity) + for i := range payload { + payload[i] = byte(i * 7) + } + + // Repeat: the lost update is a narrow interleaving window, so a + // single pass can pass by luck. + for i := 0; i < 10; i++ { + dst := newNoisyImage(512, 512) + err := steg.EncodeParallel(dst, pass, bytes.NewReader(payload), tc.bitsPerChannel, tc.chans) + require.NoError(t, err) + + got, err := steg.DecodeParallel(dst, pass, tc.bitsPerChannel, tc.chans) + require.NoError(t, err, "iteration %d", i) + require.Equal(t, payload, got, "iteration %d", i) + } + }) + } +} + +func newNoisyImage(w, h int) *image.RGBA { + m := image.NewRGBA(image.Rect(0, 0, w, h)) + rng := rand.New(rand.NewSource(42)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + m.Set(x, y, color.RGBA{ + uint8(rng.Intn(256)), uint8(rng.Intn(256)), uint8(rng.Intn(256)), 255, + }) + } + } + return m +} diff --git a/steg/container/container.go b/steg/container/container.go index d927abf..9ea6eec 100644 --- a/steg/container/container.go +++ b/steg/container/container.go @@ -9,8 +9,9 @@ import ( ) func WritePayload(w io.WriteSeeker, payload io.Reader, hashFn hash.Hash) error { - // Capture current position. When called from encode, basePos=4 (after nonce). - // When called directly (container tests), basePos=0. Behavior identical in both cases. + // Capture current position. When called from encode, basePos=16 (after the + // plaintext salt). When called directly (container tests), basePos=0. + // Behavior is identical in both cases. basePos, err := w.Seek(0, io.SeekCurrent) if err != nil { return err @@ -55,7 +56,15 @@ func WritePayload(w io.WriteSeeker, payload io.Reader, hashFn hash.Hash) error { return err } -func ReadPayload(r io.ReadWriteSeeker, hashFn hash.Hash) ([]byte, error) { +// ReadPayload reads a framed payload written by WritePayload and verifies its +// MAC. +// +// maxPayload bounds the length field. That field is decrypted but not yet +// authenticated when it is read, so a wrong password yields an essentially +// random uint32 — up to 4 GiB. Rejecting anything larger than the carrier could +// possibly hold turns a huge speculative allocation into an immediate error. +// A non-positive maxPayload disables the check. +func ReadPayload(r io.ReadWriteSeeker, hashFn hash.Hash, maxPayload int) ([]byte, error) { sizeBytes := make([]byte, 4) _, err := io.ReadFull(r, sizeBytes) if err != nil { @@ -63,6 +72,11 @@ func ReadPayload(r io.ReadWriteSeeker, hashFn hash.Hash) ([]byte, error) { } length := binary.LittleEndian.Uint32(sizeBytes) + if maxPayload > 0 && int64(length) > int64(maxPayload) { + return nil, fmt.Errorf( + "payload length %d exceeds maximum %d: wrong password or corrupt image", + length, maxPayload) + } payload := make([]byte, length) _, err = io.ReadFull(r, payload) if err != nil { diff --git a/steg/container/container_test.go b/steg/container/container_test.go index dfc99bd..e5852e5 100644 --- a/steg/container/container_test.go +++ b/steg/container/container_test.go @@ -23,7 +23,7 @@ func TestContainerRoundTrip(t *testing.T) { // Reset seek to start of stream buf.Seek(0, io.SeekStart) - readData, err := container.ReadPayload(buf, md5.New()) + readData, err := container.ReadPayload(buf, md5.New(), 0) require.NoError(t, err) assert.Equal(t, payload, readData) } @@ -36,7 +36,7 @@ func TestEmptyPayload(t *testing.T) { require.NoError(t, err) buf.Seek(0, io.SeekStart) - readData, err := container.ReadPayload(buf, md5.New()) + readData, err := container.ReadPayload(buf, md5.New(), 0) require.NoError(t, err) assert.Empty(t, readData) } @@ -55,7 +55,7 @@ func TestChecksumMismatch(t *testing.T) { buf.Write([]byte{corruptedData}) buf.Seek(0, io.SeekStart) - _, err = container.ReadPayload(buf, md5.New()) + _, err = container.ReadPayload(buf, md5.New(), 0) assert.Error(t, err) assert.Contains(t, err.Error(), "checksum") } @@ -72,11 +72,15 @@ func TestTruncatedData(t *testing.T) { assert.NoError(t, err) buf.Seek(0, io.SeekStart) - _, err = container.ReadPayload(buf, md5.New()) + _, err = container.ReadPayload(buf, md5.New(), 0) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read") } +// TestExcessiveLength checks that an implausible length field is rejected +// before it is used to size an allocation. The length is decrypted but not yet +// authenticated at that point, so a wrong password produces an arbitrary +// uint32 — here 0x7FFFFFFF, which would otherwise allocate 2 GiB. func TestExcessiveLength(t *testing.T) { payload := []byte("short") buf := testutil.NewMemReadWriteSeeker(nil) @@ -90,7 +94,25 @@ func TestExcessiveLength(t *testing.T) { buf.Write([]byte{0xFF, 0xFF, 0xFF, 0x7F}) buf.Seek(0, io.SeekStart) - _, err = container.ReadPayload(buf, md5.New()) + _, err = container.ReadPayload(buf, md5.New(), 1024) + assert.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") +} + +// TestExcessiveLengthUnbounded documents that maxPayload <= 0 disables the +// check, in which case the oversized read fails later instead. +func TestExcessiveLengthUnbounded(t *testing.T) { + payload := []byte("short") + buf := testutil.NewMemReadWriteSeeker(nil) + + err := container.WritePayload(buf, bytes.NewReader(payload), md5.New()) + require.NoError(t, err) + + buf.Seek(0, io.SeekStart) + buf.Write([]byte{0x00, 0x00, 0x10, 0x00}) // 1 MiB: large, but not 2 GiB + + buf.Seek(0, io.SeekStart) + _, err = container.ReadPayload(buf, md5.New(), 0) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read payload") } diff --git a/steg/decode.go b/steg/decode.go index b2b2dec..11314df 100644 --- a/steg/decode.go +++ b/steg/decode.go @@ -40,9 +40,13 @@ func Decode(m draw.Image, pass []byte, bitsPerChannel, channels int) ([]byte, er return nil, err } + // The padded block is exactly 4 bytes (real-length prefix) plus the image's + // usable capacity; anything longer cannot have been written by Encode. + maxPadded := CapacityBytes(m, bitsPerChannel, channels) + 4 + adapter := cursors.CursorAdapter(payloadCM) mac := hmac.New(sha256.New, macKey) - padded, err := container.ReadPayload(adapter, mac) + padded, err := container.ReadPayload(adapter, mac, maxPadded) if err != nil { return nil, err } diff --git a/steg/parallel.go b/steg/parallel.go index 3a9ff70..06c5c5d 100644 --- a/steg/parallel.go +++ b/steg/parallel.go @@ -16,6 +16,36 @@ import ( "github.com/pableeee/steg/cursors" ) +// payloadStreamOffset is the stream byte offset at which the padded payload +// begins: 16 bytes of plaintext salt followed by the 4-byte container length. +const payloadStreamOffset = 20 + +// firstChunkShift returns how many extra bytes the first worker chunk must carry +// so that every subsequent chunk boundary lands on a pixel boundary. +// +// Workers read-modify-write whole pixels: a cursor loads a pixel with img.At(), +// updates the bits it owns, and stores it back with img.Set(). If a boundary +// falls mid-pixel, the workers on either side both load, modify, and store that +// pixel, and whichever stores last silently discards the other's bits — the +// image then fails MAC verification on decode. A shared mutex does not help, +// because each individual At and Set is already serialised; it is the +// load-modify-store sequence that must not interleave. +// +// Chunk sizes are already multiples of lcm(8, bitsPerPixel)/8 bytes, so the only +// misalignment comes from the header: the payload starts at bit +// payloadStreamOffset*8 = 160, and 160 is not a multiple of bitsPerPixel +// whenever bitsPerPixel is a multiple of 3 (the default is 3). Shifting the +// first chunk by the smallest such remainder realigns every later boundary. +func firstChunkShift(bitsPerPixel int) int64 { + const headerBits = payloadStreamOffset * 8 + for r := int64(0); r < int64(bitsPerPixel); r++ { + if (headerBits+r*8)%int64(bitsPerPixel) == 0 { + return r + } + } + return 0 // unreachable: gcd(8, bitsPerPixel) always divides 160 +} + type encJob struct { streamOffset int64 data []byte @@ -73,7 +103,7 @@ func EncodeParallel(m draw.Image, pass []byte, r io.Reader, bitsPerChannel, chan bounds := m.Bounds() points := cursors.GenerateSequence(bounds.Max.X, bounds.Max.Y, seed) - // Write plaintext salt (16 bytes) to image bytes 0–15 before workers start. + // Write the plaintext salt (16 bytes) to image bytes 0–15 before workers start. rawOpts := []cursors.Option{cursors.WithSharedPoints(points), cursors.WithBitsPerChannel(bitsPerChannel)} if channels >= 2 { rawOpts = append(rawOpts, cursors.UseGreenBit()) @@ -113,6 +143,15 @@ func EncodeParallel(m draw.Image, pass []byte, r io.Reader, bitsPerChannel, chan jobChan := make(chan encJob, numWorkers*2) errChan := make(chan error, numWorkers) + // abort is closed by the first worker to fail, so the dispatch loop below + // cannot block forever writing to jobChan after every worker has exited. + abort := make(chan struct{}) + var abortOnce sync.Once + fail := func(err error) { + errChan <- err + abortOnce.Do(func() { close(abort) }) + } + var wg sync.WaitGroup for i := 0; i < numWorkers; i++ { wg.Add(1) @@ -120,38 +159,48 @@ func EncodeParallel(m draw.Image, pass []byte, r io.Reader, bitsPerChannel, chan defer wg.Done() adapter, werr := newWorkerStack(m, payloadNonce, encKey, points, bitsPerChannel, channels, imgMu) if werr != nil { - errChan <- werr + fail(werr) return } for job := range jobChan { if _, serr := adapter.Seek(job.streamOffset, io.SeekStart); serr != nil { - errChan <- serr + fail(serr) return } if _, werr2 := adapter.Write(job.data); werr2 != nil { - errChan <- werr2 + fail(werr2) return } } if _, ferr := adapter.Seek(0, io.SeekStart); ferr != nil { - errChan <- ferr + fail(ferr) } }() } - // Dispatch padded data in aligned chunks. streamOffset skips 16 bytes of - // encrypted salt + 4 bytes of container length field = byte 20. + // Dispatch the padded block in pixel-aligned chunks, starting at the byte + // where the payload begins. The first chunk absorbs the header's + // misalignment so no two workers ever share a pixel. totalLen := int64(len(padded)) + shift := firstChunkShift(channels * bitsPerChannel) var offset int64 +dispatch: for offset < totalLen { - end := offset + int64(chunkSize) - if end > totalLen { - end = totalLen + size := int64(chunkSize) + if offset == 0 { + size += shift + } + if offset+size > totalLen { + size = totalLen - offset + } + chunk := make([]byte, size) + copy(chunk, padded[offset:offset+size]) + select { + case jobChan <- encJob{streamOffset: payloadStreamOffset + offset, data: chunk}: + offset += size + case <-abort: + break dispatch } - chunk := make([]byte, end-offset) - copy(chunk, padded[offset:end]) - jobChan <- encJob{streamOffset: 20 + offset, data: chunk} - offset = end } close(jobChan) wg.Wait() @@ -163,6 +212,8 @@ func EncodeParallel(m draw.Image, pass []byte, r io.Reader, bitsPerChannel, chan } // Post-parallel sequential writes: container length field (byte 16) and HMAC. + // Both run after wg.Wait(), so although each may share a pixel with the + // payload region, no concurrent writer can clobber it. // Workers use payloadNonce; the salt region (bytes 0–15) is already written. seqAdapter, err := newWorkerStack(m, payloadNonce, encKey, points, bitsPerChannel, channels, nil) if err != nil { @@ -178,7 +229,7 @@ func EncodeParallel(m draw.Image, pass []byte, r io.Reader, bitsPerChannel, chan return err } - if _, err = seqAdapter.Seek(20+totalLen, io.SeekStart); err != nil { + if _, err = seqAdapter.Seek(payloadStreamOffset+totalLen, io.SeekStart); err != nil { return err } if _, err = seqAdapter.Write(tag); err != nil { @@ -220,7 +271,7 @@ func DecodeParallel(m draw.Image, pass []byte, bitsPerChannel, channels int) ([] return nil, err } - // Read the 4-byte container length field at byte 16 (after the encrypted salt). + // Read the 4-byte container length field at byte 16 (after the plaintext salt). seqAdapter, err := newWorkerStack(m, payloadNonce, encKey, points, bitsPerChannel, channels, nil) if err != nil { return nil, err @@ -234,6 +285,16 @@ func DecodeParallel(m draw.Image, pass []byte, bitsPerChannel, channels int) ([] } payloadLen := int64(binary.LittleEndian.Uint32(lenBuf)) + // The length field is decrypted but not yet authenticated, so a wrong + // password yields an essentially random uint32. Reject anything the carrier + // could not hold rather than allocating up to 4 GiB on it. + maxPadded := int64(CapacityBytes(m, bitsPerChannel, channels)) + 4 + if payloadLen > maxPadded { + return nil, fmt.Errorf( + "payload length %d exceeds maximum %d: wrong password or corrupt image", + payloadLen, maxPadded) + } + // Allocate buffer for padded data + HMAC tag. totalRemaining := payloadLen + 32 decryptedBuf := make([]byte, totalRemaining) @@ -245,6 +306,13 @@ func DecodeParallel(m draw.Image, pass []byte, bitsPerChannel, channels int) ([] jobChan := make(chan decJob, numWorkers*2) errChan := make(chan error, numWorkers) + abort := make(chan struct{}) + var abortOnce sync.Once + fail := func(err error) { + errChan <- err + abortOnce.Do(func() { close(abort) }) + } + var wg sync.WaitGroup for i := 0; i < numWorkers; i++ { wg.Add(1) @@ -252,32 +320,44 @@ func DecodeParallel(m draw.Image, pass []byte, bitsPerChannel, channels int) ([] defer wg.Done() adapter, werr := newWorkerStack(m, payloadNonce, encKey, points, bitsPerChannel, channels, nil) if werr != nil { - errChan <- werr + fail(werr) return } for job := range jobChan { if _, serr := adapter.Seek(job.streamOffset, io.SeekStart); serr != nil { - errChan <- serr + fail(serr) return } if _, rerr := io.ReadFull(adapter, job.dest); rerr != nil { - errChan <- rerr + fail(rerr) return } } }() } - // Dispatch aligned chunks; streamOffset skips 16 (enc salt) + 4 (length) = byte 20. + // Dispatch chunks starting at the byte where the payload begins: 16 + // (plaintext salt) + 4 (length field). Decode workers only read, so they + // cannot clobber each other, but the same shift as EncodeParallel keeps the + // two dispatch loops symmetric. + shift := firstChunkShift(channels * bitsPerChannel) var offset int64 +dispatch: for offset < totalRemaining { size := chunkSize + if offset == 0 { + size += shift + } if offset+size > totalRemaining { size = totalRemaining - offset } dest := decryptedBuf[offset : offset+size] - jobChan <- decJob{streamOffset: 20 + offset, dest: dest} - offset += size + select { + case jobChan <- decJob{streamOffset: payloadStreamOffset + offset, dest: dest}: + offset += size + case <-abort: + break dispatch + } } close(jobChan) wg.Wait() diff --git a/steg/parallel_internal_test.go b/steg/parallel_internal_test.go new file mode 100644 index 0000000..d57f0ab --- /dev/null +++ b/steg/parallel_internal_test.go @@ -0,0 +1,54 @@ +package steg + +import "testing" + +// TestChunkBoundariesArePixelAligned is the deterministic guard for the +// lost-update bug that firstChunkShift exists to prevent. A boundary that falls +// mid-pixel hands the same pixel to two workers, each of which loads it, +// updates its own bits, and stores it back; the later store discards the other +// worker's bits and the image no longer verifies on decode. +// +// The race window is narrow enough that a round-trip test passes by luck most +// of the time, so this checks the arithmetic directly instead. +func TestChunkBoundariesArePixelAligned(t *testing.T) { + for _, channels := range []int{1, 2, 3} { + for _, bitsPerChannel := range []int{1, 2, 4, 8} { + bitsPerPixel := channels * bitsPerChannel + alignment := lcmBytes(8, bitsPerPixel) + chunkSize := int64(alignment * 1024) + shift := firstChunkShift(bitsPerPixel) + + if shift < 0 || shift >= int64(bitsPerPixel) { + t.Fatalf("channels=%d bpc=%d: shift %d out of range", + channels, bitsPerChannel, shift) + } + + // Boundaries fall at shift, shift+chunkSize, shift+2*chunkSize, ... + for k := int64(0); k < 16; k++ { + offset := shift + k*chunkSize + absBit := int64(payloadStreamOffset)*8 + offset*8 + if absBit%int64(bitsPerPixel) != 0 { + t.Errorf("channels=%d bpc=%d: boundary %d at bit %d is mid-pixel (bitsPerPixel=%d)", + channels, bitsPerChannel, k, absBit, bitsPerPixel) + } + } + } + } +} + +// TestFirstChunkShiftKnownValues pins the shift for the configurations that +// actually needed one. 160 mod 3 = 1, so every bitsPerPixel divisible by 3 +// requires a one-byte shift; the power-of-two cases divide 160 evenly already. +func TestFirstChunkShiftKnownValues(t *testing.T) { + for _, tc := range []struct { + bitsPerPixel int + want int64 + }{ + {1, 0}, {2, 0}, {4, 0}, {8, 0}, {16, 0}, + {3, 1}, {6, 1}, {12, 1}, {24, 1}, + } { + if got := firstChunkShift(tc.bitsPerPixel); got != tc.want { + t.Errorf("firstChunkShift(%d) = %d, want %d", tc.bitsPerPixel, got, tc.want) + } + } +} diff --git a/steg/steg.go b/steg/steg.go index 74fa22b..af780eb 100644 --- a/steg/steg.go +++ b/steg/steg.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/binary" "fmt" + "image" "image/draw" "github.com/pableeee/steg/cursors" @@ -60,17 +61,31 @@ func deriveMainKeys(pass, salt []byte) (encKey, macKey []byte, payloadNonce uint return encKey, macKey, payloadNonce, nil } -// imageCapacityBytes returns the maximum real payload size for the given image and -// encoding settings. Overhead is 56 bytes: 16 (plaintext salt) + 4 (container -// length) + 4 (embedded real-length prefix) + 32 (HMAC-SHA256 tag). -func imageCapacityBytes(m draw.Image, bitsPerChannel, channels int) int { +// Overhead is the number of bytes every encoded image spends on framing: +// 16 (plaintext salt) + 4 (container length) + 4 (embedded real-length prefix) +// + 32 (HMAC-SHA256 tag). +const Overhead = 56 + +// CapacityBytes returns the maximum real payload size, in bytes, that m can +// hold with the given encoding settings. It returns 0 for images too small to +// hold the framing overhead. +// +// This is the single source of truth for capacity; callers must not reimplement +// the arithmetic, or they will drift from the encoder and produce payloads it +// rejects. +func CapacityBytes(m image.Image, bitsPerChannel, channels int) int { b := m.Bounds() - total := b.Dx() * b.Dy() * channels * bitsPerChannel / 8 - const overhead = 56 - if total <= overhead { + return CapacityForDims(b.Dx(), b.Dy(), bitsPerChannel, channels) +} + +// CapacityForDims is CapacityBytes for an image whose dimensions are known but +// which has not been decoded yet. +func CapacityForDims(w, h, bitsPerChannel, channels int) int { + total := w * h * channels * bitsPerChannel / 8 + if total <= Overhead { return 0 } - return total - overhead + return total - Overhead } // buildPaddedPayload prepends a 4-byte LE real-length prefix and appends random @@ -78,7 +93,7 @@ func imageCapacityBytes(m draw.Image, bitsPerChannel, channels int) int { // payload-size signal from LSB statistics regardless of actual payload size. // The returned slice is passed directly to container.WritePayload. func buildPaddedPayload(m draw.Image, payload []byte, bitsPerChannel, channels int) ([]byte, error) { - cap := imageCapacityBytes(m, bitsPerChannel, channels) + cap := CapacityBytes(m, bitsPerChannel, channels) if cap <= 0 { return nil, fmt.Errorf("steg: image too small to hold any payload") } diff --git a/steg/subimage_test.go b/steg/subimage_test.go new file mode 100644 index 0000000..aae8e16 --- /dev/null +++ b/steg/subimage_test.go @@ -0,0 +1,54 @@ +package steg_test + +import ( + "bytes" + "image" + "testing" + + "github.com/pableeee/steg/steg" + "github.com/stretchr/testify/require" +) + +// TestSubImageRoundTrip covers carriers whose bounds do not start at (0,0). +// The cursor generates its pixel sequence over [0,Dx)×[0,Dy) and offsets by +// Bounds().Min on access; without that offset it addresses coordinates outside +// the sub-image, where At returns the zero color and Set is a no-op. +func TestSubImageRoundTrip(t *testing.T) { + full := newNoisyImage(256, 256) + sub, ok := full.SubImage(image.Rect(64, 64, 192, 192)).(*image.RGBA) + require.True(t, ok) + require.Equal(t, image.Pt(64, 64), sub.Bounds().Min) + + pass := []byte("testpass") + payload := []byte("payload hidden in a sub-image with a non-zero origin") + + require.NoError(t, steg.Encode(sub, pass, bytes.NewReader(payload), 1, 3)) + + got, err := steg.Decode(sub, pass, 1, 3) + require.NoError(t, err) + require.Equal(t, payload, got) +} + +// TestSubImageWritesStayInBounds verifies the encoder only touches pixels +// inside the sub-image's bounds, leaving the surrounding region untouched. +func TestSubImageWritesStayInBounds(t *testing.T) { + full := newNoisyImage(256, 256) + before := make([]byte, len(full.Pix)) + copy(before, full.Pix) + + sub := full.SubImage(image.Rect(64, 64, 192, 192)).(*image.RGBA) + payload := []byte("bounded write") + require.NoError(t, steg.Encode(sub, []byte("testpass"), bytes.NewReader(payload), 1, 3)) + + r := sub.Bounds() + for y := full.Bounds().Min.Y; y < full.Bounds().Max.Y; y++ { + for x := full.Bounds().Min.X; x < full.Bounds().Max.X; x++ { + if image.Pt(x, y).In(r) { + continue + } + off := full.PixOffset(x, y) + require.Equal(t, before[off:off+4], full.Pix[off:off+4], + "pixel (%d,%d) outside sub-image bounds was modified", x, y) + } + } +} From e3a882fed6f313c51192f96e75182fce236b8bf6 Mon Sep 17 00:00:00 2001 From: pableeee Date: Sat, 1 Aug 2026 20:44:45 -0300 Subject: [PATCH 2/2] docs: add attack analysis tooling and writeup Four standalone programs reproducing the attacks considered against the pre-c69a626 two-KDF design, plus the analysis document recording which are mitigated and which remain open. These were sitting untracked in the working tree. attack2 and attack3 called fmt.Println with arguments already ending in a newline, which fails vet and therefore `go test ./...`; committing them as-is would have broken the release job on the next push to master. Converted those four call sites to fmt.Print. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/attack1/main.go | 216 +++++++++++++++++++++++++ cmd/attack2/main.go | 175 +++++++++++++++++++++ cmd/attack3/main.go | 245 +++++++++++++++++++++++++++++ cmd/attack4/main.go | 229 +++++++++++++++++++++++++++ docs/attack-analysis.md | 341 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1206 insertions(+) create mode 100644 cmd/attack1/main.go create mode 100644 cmd/attack2/main.go create mode 100644 cmd/attack3/main.go create mode 100644 cmd/attack4/main.go create mode 100644 docs/attack-analysis.md diff --git a/cmd/attack1/main.go b/cmd/attack1/main.go new file mode 100644 index 0000000..20f6217 --- /dev/null +++ b/cmd/attack1/main.go @@ -0,0 +1,216 @@ +// Attack 1: Steganalysis — Chi-Square + RS Analysis +// +// Detects LSB steganography in an image without knowing the password. +// Natural images have unequal LSB pair frequencies; embedding homogenises them. +// +// Usage: +// +// go run ./cmd/attack1 +package main + +import ( + "fmt" + "image" + _ "image/png" + "math" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "usage: attack1 \n") + os.Exit(1) + } + + img, err := loadImage(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "load: %v\n", err) + os.Exit(1) + } + + fmt.Printf("=== Attack 1: Steganalysis ===\n\n") + fmt.Printf("Image: %s (%dx%d)\n\n", os.Args[1], img.Bounds().Dx(), img.Bounds().Dy()) + + fmt.Println("--- Chi-Square Test (p > 0.05 is suspicious) ---") + chResults := chiSquare(img) + suspiciousChi := 0 + for _, r := range chResults { + flag := "" + if r.suspicious { + flag = " ← SUSPICIOUS" + suspiciousChi++ + } + fmt.Printf(" Channel %s: χ²=%.2f p=%.4f%s\n", r.channel, r.chiSq, r.pValue, flag) + } + + fmt.Println("\n--- RS Analysis (asymmetry > 0.01 is suspicious) ---") + rsResults := rsAnalysis(img) + suspiciousRS := 0 + for _, r := range rsResults { + flag := "" + if r.suspicious { + flag = " ← SUSPICIOUS" + suspiciousRS++ + } + fmt.Printf(" Channel %s: Rm=%.4f Rnm=%.4f asym=%.4f%s\n", + r.channel, r.rm, r.rnm, r.asymmetry, flag) + } + + total := suspiciousChi + suspiciousRS + fmt.Printf("\n--- Verdict: %d/6 tests suspicious ---\n", total) + switch { + case total == 0: + fmt.Println(" CLEAN — no steganography detected") + case total < 6: + fmt.Println(" SUSPICIOUS — steganography likely present") + default: + fmt.Println(" LIKELY_STEGO — strong steganography signal") + } +} + +func loadImage(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + return img, err +} + +// extractChannel returns one byte per pixel for channel ch (0=R,1=G,2=B). +// image.Color.RGBA() returns alpha-premultiplied 16-bit values; for 8-bit PNGs +// the high byte equals the low byte, so uint8(v) gives the component value. +func extractChannel(img image.Image, ch int) []uint8 { + b := img.Bounds() + out := make([]uint8, b.Dx()*b.Dy()) + i := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl, _ := img.At(x, y).RGBA() + switch ch { + case 0: + out[i] = uint8(r) + case 1: + out[i] = uint8(g) + case 2: + out[i] = uint8(bl) + } + i++ + } + } + return out +} + +// ─── chi-square test ────────────────────────────────────────────────────────── + +type chiResult struct { + channel string + chiSq float64 + pValue float64 + suspicious bool +} + +func chiSquare(img image.Image) []chiResult { + names := []string{"R", "G", "B"} + results := make([]chiResult, 3) + for ch := 0; ch < 3; ch++ { + results[ch] = channelChi(names[ch], extractChannel(img, ch)) + } + return results +} + +func channelChi(name string, vals []uint8) chiResult { + var hist [256]float64 + for _, v := range vals { + hist[v]++ + } + var chiSq float64 + for k := 0; k < 128; k++ { + expected := (hist[2*k] + hist[2*k+1]) / 2 + if expected == 0 { + continue + } + d0 := hist[2*k] - expected + d1 := hist[2*k+1] - expected + chiSq += (d0*d0 + d1*d1) / expected + } + p := chi2PValue(chiSq, 127) + return chiResult{channel: name, chiSq: chiSq, pValue: p, suspicious: p > 0.05} +} + +// chi2PValue returns P(X ≥ chiSq) using the Wilson–Hilferty normal approximation. +func chi2PValue(chiSq float64, df int) float64 { + if chiSq <= 0 { + return 1 + } + k := float64(df) + h := 2.0 / (9 * k) + z := (math.Pow(chiSq/k, 1.0/3.0) - (1 - h)) / math.Sqrt(h) + return 0.5 * math.Erfc(z/math.Sqrt2) +} + +// ─── RS analysis ────────────────────────────────────────────────────────────── + +type rsResult struct { + channel string + rm, rnm float64 + asymmetry float64 + suspicious bool +} + +func rsAnalysis(img image.Image) []rsResult { + b := img.Bounds() + w := b.Dx() + names := []string{"R", "G", "B"} + results := make([]rsResult, 3) + for ch := 0; ch < 3; ch++ { + results[ch] = channelRS(names[ch], extractChannel(img, ch), w) + } + return results +} + +func channelRS(name string, vals []uint8, width int) rsResult { + height := len(vals) / width + var rm, rnm, total float64 + + for y := 0; y < height; y++ { + for x := 0; x+3 < width; x += 4 { + i := y*width + x + p0, p1, p2, p3 := vals[i], vals[i+1], vals[i+2], vals[i+3] + + orig := roughness(p0, p1, p2, p3) + pos := roughness(p0^1, p1, p2^1, p3) // positive mask: flip LSB on pos 0,2 + neg := roughness(flipNeg(p0), p1, flipNeg(p2), p3) // negative mask + + total++ + if pos > orig { + rm++ + } + if neg > orig { + rnm++ + } + } + } + + if total == 0 { + return rsResult{channel: name} + } + rmF, rnmF := rm/total, rnm/total + asym := rmF - rnmF + return rsResult{channel: name, rm: rmF, rnm: rnmF, asymmetry: asym, suspicious: asym > 0.01} +} + +func roughness(a, b, c, d uint8) float64 { + return math.Abs(float64(a)-float64(b)) + + math.Abs(float64(b)-float64(c)) + + math.Abs(float64(c)-float64(d)) +} + +// flipNeg: even→(even-1), odd→(odd+1). +func flipNeg(x uint8) uint8 { + if x%2 == 0 { + return x - 1 + } + return x + 1 +} diff --git a/cmd/attack2/main.go b/cmd/attack2/main.go new file mode 100644 index 0000000..4420dd7 --- /dev/null +++ b/cmd/attack2/main.go @@ -0,0 +1,175 @@ +// Attack 2: Bootstrap Cipher Nonce Reuse — Two-Time Pad Demo +// +// The bootstrap cipher (AES-128-CTR) uses a key and nonce derived entirely from +// the password via a fixed-salt Argon2id. For the same password, that keystream +// KS_bs is identical across every encode. +// +// Each image stores enc_salt_i = randomSalt_i ⊕ KS_bs at LSB bits 0–127. +// Given two images A and B encoded with the same password: +// +// enc_salt_A ⊕ enc_salt_B = randomSalt_A ⊕ randomSalt_B +// +// This is the classic two-time pad. Here we: +// 1. Recover both salts by decoding with the known password. +// 2. Extract the raw (encrypted) salt bytes from both images. +// 3. Verify the XOR identity holds. +// +// Usage: +// +// go run ./cmd/attack2 [-c channels] [-b bits] +package main + +import ( + "encoding/binary" + "encoding/hex" + "flag" + "fmt" + "image" + "image/draw" + _ "image/png" + "io" + "os" + + "github.com/pableeee/steg/cipher" + "github.com/pableeee/steg/cursors" + "golang.org/x/crypto/argon2" +) + +// Fixed application salt — hardcoded in steg/steg.go. +var appSalt = []byte("github.com/pableeee/steg/v1") + +func main() { + channels := flag.Int("c", 3, "channels used during encoding (1–3)") + bitsPerCh := flag.Int("b", 1, "bits per channel used during encoding") + flag.Parse() + args := flag.Args() + + if len(args) < 3 { + fmt.Fprintf(os.Stderr, "usage: attack2 [flags] \n") + os.Exit(1) + } + + imgA, err := loadDrawImage(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "load A: %v\n", err) + os.Exit(1) + } + imgB, err := loadDrawImage(args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "load B: %v\n", err) + os.Exit(1) + } + pass := []byte(args[2]) + + fmt.Print("=== Attack 2: Bootstrap Nonce Reuse (Two-Time Pad) ===\n\n") + + // Step 1: derive bootstrap keys — these are IDENTICAL for both images. + bsSeed, bsEncKey, bsNonce := deriveBootstrapKeys(pass) + fmt.Printf("Bootstrap key (hex): %s\n", hex.EncodeToString(bsEncKey)) + fmt.Printf("Bootstrap nonce: %08x (fixed for password \"%s\")\n\n", bsNonce, pass) + + // Step 2: extract raw (still-encrypted) salt bytes from each image. + encSaltA := extractRawBootstrapBytes(imgA, bsSeed, *bitsPerCh, *channels) + encSaltB := extractRawBootstrapBytes(imgB, bsSeed, *bitsPerCh, *channels) + fmt.Printf("enc_salt_A (hex): %s\n", hex.EncodeToString(encSaltA[:])) + fmt.Printf("enc_salt_B (hex): %s\n\n", hex.EncodeToString(encSaltB[:])) + + // Step 3: decrypt each salt using the bootstrap cipher. + saltA := decryptBootstrapSalt(encSaltA, bsEncKey, bsNonce) + saltB := decryptBootstrapSalt(encSaltB, bsEncKey, bsNonce) + fmt.Printf("randomSalt_A (hex): %s\n", hex.EncodeToString(saltA[:])) + fmt.Printf("randomSalt_B (hex): %s\n\n", hex.EncodeToString(saltB[:])) + + // Step 4: verify the XOR identity. + xorEnc := xor16(encSaltA, encSaltB) + xorSalt := xor16(saltA, saltB) + fmt.Printf("enc_salt_A ⊕ enc_salt_B = %s\n", hex.EncodeToString(xorEnc[:])) + fmt.Printf("randomSalt_A ⊕ randomSalt_B = %s\n\n", hex.EncodeToString(xorSalt[:])) + + if xorEnc == xorSalt { + fmt.Println("✓ CONFIRMED: enc_salt_A ⊕ enc_salt_B = randomSalt_A ⊕ randomSalt_B") + fmt.Println(" The bootstrap keystream KS_bs cancels out — two-time pad identity holds.") + } else { + fmt.Println("✗ MISMATCH — check that both images were encoded with the same password.") + } + + // Step 5: recover KS_bs from image A and use it to re-derive salt B without + // the second Argon2id call (demonstrates the CPA shortcut). + ksBs := xor16(encSaltA, saltA) + recoveredSaltB := xor16(encSaltB, ksBs) + fmt.Printf("\nRecovered KS_bs (hex): %s\n", hex.EncodeToString(ksBs[:])) + fmt.Printf("Re-derived randomSalt_B: %s\n", hex.EncodeToString(recoveredSaltB[:])) + if recoveredSaltB == saltB { + fmt.Println("✓ randomSalt_B recovered correctly via KS_bs — bootstrap CPA works.") + } +} + +// ─── bootstrap key derivation (mirrors steg/steg.go:deriveBootstrapKeys) ───── + +func deriveBootstrapKeys(pass []byte) (bsSeed int64, bsEncKey []byte, bsNonce uint32) { + derived := argon2.IDKey(pass, appSalt, 1, 64*1024, 4, 28) + bsSeed = int64(binary.BigEndian.Uint64(derived[0:8])) + bsEncKey = derived[8:24] + bsNonce = binary.BigEndian.Uint32(derived[24:28]) + return +} + +// extractRawBootstrapBytes reads the first 16 bytes from the image LSBs in +// Fisher-Yates pixel order WITHOUT applying any cipher — the raw ciphertext. +func extractRawBootstrapBytes(img draw.Image, bsSeed int64, bitsPerChannel, channels int) [16]byte { + cur := cursors.NewRNGCursor(img, cursorOpts(bsSeed, bitsPerChannel, channels)...) + var enc [16]byte + io.ReadFull(cursors.CursorAdapter(cur), enc[:]) + return enc +} + +func cursorOpts(seed int64, bitsPerChannel, channels int) []cursors.Option { + opts := []cursors.Option{ + cursors.WithSeed(seed), + cursors.WithBitsPerChannel(bitsPerChannel), + } + if channels >= 2 { + opts = append(opts, cursors.UseGreenBit()) + } + if channels >= 3 { + opts = append(opts, cursors.UseBlueBit()) + } + return opts +} + +// decryptBootstrapSalt decrypts a 16-byte encrypted salt using the bootstrap cipher. +func decryptBootstrapSalt(enc [16]byte, bsEncKey []byte, bsNonce uint32) [16]byte { + c, err := cipher.NewCipher(bsNonce, bsEncKey) + if err != nil { + panic(err) + } + var plain [16]byte + for i, b := range enc { + plain[i], _ = c.DecryptByte(b) + } + return plain +} + +func xor16(a, b [16]byte) [16]byte { + var out [16]byte + for i := range a { + out[i] = a[i] ^ b[i] + } + return out +} + +func loadDrawImage(path string) (draw.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + src, _, err := image.Decode(f) + if err != nil { + return nil, err + } + b := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + return dst, nil +} diff --git a/cmd/attack3/main.go b/cmd/attack3/main.go new file mode 100644 index 0000000..72b9655 --- /dev/null +++ b/cmd/attack3/main.go @@ -0,0 +1,245 @@ +// Attack 3: Bootstrap CPA — Full Payload Recovery via Salt Recovery +// +// Extends Attack 2 to actually decrypt the target image's payload. +// +// Scenario: attacker knows the password, has encode access, and wants to +// recover a target image's payload without running the standard decode path. +// By recovering KS_bs from a self-made image, they can derive any target +// image's randomSalt and thus its main keys. +// +// Steps: +// 1. Encode a known file into imageA with password P → gives enc_salt_A. +// 2. Decode imageA to recover randomSalt_A (the bootstrap step of decode). +// 3. KS_bs = enc_salt_A ⊕ randomSalt_A. +// 4. Extract enc_salt_T from target imageT. +// 5. randomSalt_T = enc_salt_T ⊕ KS_bs. +// 6. Derive main keys: Argon2id(P, randomSalt_T). +// 7. Decrypt imageT payload and verify HMAC — without calling steg.Decode. +// +// Usage: +// +// go run ./cmd/attack3 [-c channels] [-b bits] +package main + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "flag" + "fmt" + "image" + "image/draw" + _ "image/png" + "io" + "os" + + "github.com/pableeee/steg/cipher" + "github.com/pableeee/steg/cursors" + "golang.org/x/crypto/argon2" +) + +var appSalt = []byte("github.com/pableeee/steg/v1") + +func main() { + channels := flag.Int("c", 3, "channels used during encoding (1–3)") + bitsPerCh := flag.Int("b", 1, "bits per channel used during encoding") + flag.Parse() + args := flag.Args() + + if len(args) < 3 { + fmt.Fprintf(os.Stderr, "usage: attack3 [flags] \n") + os.Exit(1) + } + + imgA, err := loadDrawImage(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "load A: %v\n", err) + os.Exit(1) + } + imgT, err := loadDrawImage(args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "load target: %v\n", err) + os.Exit(1) + } + pass := []byte(args[2]) + + fmt.Print("=== Attack 3: Bootstrap CPA — Salt Recovery ===\n\n") + + bsSeed, bsEncKey, bsNonce := deriveBootstrapKeys(pass) + fmt.Printf("Bootstrap key: %s\n", hex.EncodeToString(bsEncKey)) + fmt.Printf("Bootstrap nonce: %08x\n\n", bsNonce) + + // Step 1: extract raw encrypted salts from both images. + encSaltA := extractRawBootstrapBytes(imgA, bsSeed, *bitsPerCh, *channels) + encSaltT := extractRawBootstrapBytes(imgT, bsSeed, *bitsPerCh, *channels) + + // Step 2: decrypt imageA's salt (simulating "we created imageA ourselves"). + saltA := decryptSalt(encSaltA, bsEncKey, bsNonce) + fmt.Printf("randomSalt_A (hex): %s (recovered from our own image)\n", hex.EncodeToString(saltA[:])) + + // Step 3: recover bootstrap keystream from imageA. + ksBs := xor16(encSaltA, saltA) + fmt.Printf("KS_bs (hex): %s\n\n", hex.EncodeToString(ksBs[:])) + + // Step 4: recover target's randomSalt without Argon2id. + saltT := xor16(encSaltT, ksBs) + fmt.Printf("enc_salt_T (hex): %s\n", hex.EncodeToString(encSaltT[:])) + fmt.Printf("randomSalt_T (hex): %s (recovered via KS_bs)\n\n", hex.EncodeToString(saltT[:])) + + // Verify against directly decrypted salt (ground truth). + saltTDirect := decryptSalt(encSaltT, bsEncKey, bsNonce) + if saltT == saltTDirect { + fmt.Print("✓ randomSalt_T matches direct decryption — CPA recovery confirmed.\n\n") + } else { + fmt.Print("✗ salt mismatch — something went wrong.\n\n") + return + } + + // Step 5: derive main keys for the target using the recovered salt. + encKeyT, macKeyT, payloadNonceT := deriveMainKeys(pass, saltT[:]) + fmt.Printf("Main enc key (hex): %s\n", hex.EncodeToString(encKeyT)) + fmt.Printf("Main nonce: %08x\n\n", payloadNonceT) + + // Step 6: decrypt the target image's payload and verify HMAC. + payload, err := decryptPayload(imgT, bsSeed, encKeyT, macKeyT, payloadNonceT, *bitsPerCh, *channels) + if err != nil { + fmt.Printf("✗ Payload decryption failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✓ Payload decrypted successfully — %d bytes\n", len(payload)) + fmt.Printf(" First 64 bytes (hex): %s\n", hex.EncodeToString(payload[:min(64, len(payload))])) +} + +// ─── key derivation ─────────────────────────────────────────────────────────── + +func deriveBootstrapKeys(pass []byte) (bsSeed int64, bsEncKey []byte, bsNonce uint32) { + derived := argon2.IDKey(pass, appSalt, 1, 64*1024, 4, 28) + bsSeed = int64(binary.BigEndian.Uint64(derived[0:8])) + bsEncKey = derived[8:24] + bsNonce = binary.BigEndian.Uint32(derived[24:28]) + return +} + +func deriveMainKeys(pass []byte, salt []byte) (encKey, macKey []byte, payloadNonce uint32) { + derived := argon2.IDKey(pass, salt, 1, 64*1024, 4, 52) + encKey = derived[0:16] + macKey = derived[16:48] + payloadNonce = binary.BigEndian.Uint32(derived[48:52]) + return +} + +// ─── image helpers ──────────────────────────────────────────────────────────── + +func extractRawBootstrapBytes(img draw.Image, bsSeed int64, bitsPerChannel, channels int) [16]byte { + cur := cursors.NewRNGCursor(img, cursorOpts(bsSeed, bitsPerChannel, channels)...) + var enc [16]byte + io.ReadFull(cursors.CursorAdapter(cur), enc[:]) + return enc +} + +func cursorOpts(seed int64, bitsPerChannel, channels int) []cursors.Option { + opts := []cursors.Option{ + cursors.WithSeed(seed), + cursors.WithBitsPerChannel(bitsPerChannel), + } + if channels >= 2 { + opts = append(opts, cursors.UseGreenBit()) + } + if channels >= 3 { + opts = append(opts, cursors.UseBlueBit()) + } + return opts +} + +func decryptSalt(enc [16]byte, bsEncKey []byte, bsNonce uint32) [16]byte { + c, _ := cipher.NewCipher(bsNonce, bsEncKey) + var plain [16]byte + for i, b := range enc { + plain[i], _ = c.DecryptByte(b) + } + return plain +} + +// decryptPayload decrypts the full payload from the image and verifies HMAC. +// Mirrors the logic in steg/decode.go and steg/container/container.go. +func decryptPayload(img draw.Image, bsSeed int64, encKey, macKey []byte, payloadNonce uint32, bitsPerChannel, channels int) ([]byte, error) { + cur := cursors.NewRNGCursor(img, cursorOpts(bsSeed, bitsPerChannel, channels)...) + c, _ := cipher.NewCipher(payloadNonce, encKey) + payloadCM := cursors.CipherMiddleware(cur, c) + + // Seek to bit 128 (past the 16-byte encrypted salt). + if _, err := payloadCM.Seek(128, io.SeekStart); err != nil { + return nil, fmt.Errorf("seek: %w", err) + } + adapter := cursors.CursorAdapter(payloadCM) + + // Read container length (4 bytes LE). + lenBuf := make([]byte, 4) + if _, err := io.ReadFull(adapter, lenBuf); err != nil { + return nil, fmt.Errorf("read length: %w", err) + } + containerLen := binary.LittleEndian.Uint32(lenBuf) + + // Read payload bytes. + payloadBuf := make([]byte, containerLen) + if _, err := io.ReadFull(adapter, payloadBuf); err != nil { + return nil, fmt.Errorf("read payload: %w", err) + } + + // Read HMAC tag (32 bytes). + tag := make([]byte, 32) + if _, err := io.ReadFull(adapter, tag); err != nil { + return nil, fmt.Errorf("read hmac: %w", err) + } + + // Verify HMAC. + mac := hmac.New(sha256.New, macKey) + mac.Write(payloadBuf) + if !hmac.Equal(tag, mac.Sum(nil)) { + return nil, fmt.Errorf("HMAC verification failed — wrong password or corrupt image") + } + + // Extract real payload from padded buffer: first 4 bytes = LE real length. + if len(payloadBuf) < 4 { + return nil, fmt.Errorf("payload too short") + } + realLen := binary.LittleEndian.Uint32(payloadBuf[:4]) + if int(realLen) > len(payloadBuf)-4 { + return nil, fmt.Errorf("corrupt real length field") + } + return bytes.Clone(payloadBuf[4 : 4+realLen]), nil +} + +func xor16(a, b [16]byte) [16]byte { + var out [16]byte + for i := range a { + out[i] = a[i] ^ b[i] + } + return out +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func loadDrawImage(path string) (draw.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + src, _, err := image.Decode(f) + if err != nil { + return nil, err + } + b := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + return dst, nil +} diff --git a/cmd/attack4/main.go b/cmd/attack4/main.go new file mode 100644 index 0000000..6d700aa --- /dev/null +++ b/cmd/attack4/main.go @@ -0,0 +1,229 @@ +// Attack 4: Dictionary Attack with HMAC Oracle +// +// Uses HMAC-SHA256 verification as a password oracle. For each candidate: +// +// 1. Argon2id(candidate, fixedSalt) → bootstrap keys +// 2. Decrypt 16-byte salt from image LSBs +// 3. Argon2id(candidate, recoveredSalt) → main keys +// 4. Decrypt container + verify HMAC +// +// HMAC pass = correct password. Two Argon2id calls per candidate (~100–200 ms +// each on CPU) make this expensive; Argon2id's 64 MiB memory requirement +// limits GPU parallelism. +// +// Usage: +// +// go run ./cmd/attack4 [-c channels] [-b bits] +// +// Flags: +// +// -c number of channels used during encoding (default 1) +// -b bits per channel used during encoding (default 1) +package main + +import ( + "bufio" + "encoding/binary" + "flag" + "fmt" + "image" + "image/draw" + _ "image/png" + "io" + "os" + "time" + + "crypto/hmac" + "crypto/sha256" + + "github.com/pableeee/steg/cipher" + "github.com/pableeee/steg/cursors" + "golang.org/x/crypto/argon2" +) + +var appSalt = []byte("github.com/pableeee/steg/v1") + +func main() { + channels := flag.Int("c", 3, "channels used during encoding (1–3)") + bitsPerCh := flag.Int("b", 1, "bits per channel used during encoding (1,2,4,8)") + flag.Parse() + + args := flag.Args() + if len(args) < 2 { + fmt.Fprintf(os.Stderr, "usage: attack4 [flags] \n") + os.Exit(1) + } + + img, err := loadDrawImage(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "load image: %v\n", err) + os.Exit(1) + } + + wl, err := os.Open(args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "open wordlist: %v\n", err) + os.Exit(1) + } + defer wl.Close() + + fmt.Printf("=== Attack 4: Dictionary Attack ===\n\n") + fmt.Printf("Image: %s (%dx%d)\n", args[0], img.Bounds().Dx(), img.Bounds().Dy()) + fmt.Printf("Wordlist: %s\n", args[1]) + fmt.Printf("Encoding: %d channel(s), %d bit(s)/channel\n\n", *channels, *bitsPerCh) + + var tried int + start := time.Now() + scanner := bufio.NewScanner(wl) + + for scanner.Scan() { + candidate := scanner.Text() + if candidate == "" { + continue + } + tried++ + + payload, err := tryPassword(img, []byte(candidate), *bitsPerCh, *channels) + if tried%100 == 0 { + elapsed := time.Since(start) + fmt.Printf("\r tried %d (%.1f/s) ", tried, float64(tried)/elapsed.Seconds()) + } + + if err == nil { + elapsed := time.Since(start) + fmt.Printf("\r\n✓ PASSWORD FOUND: \"%s\"\n\n", candidate) + fmt.Printf(" Tried: %d candidates\n", tried) + fmt.Printf(" Elapsed: %s\n", elapsed.Round(time.Millisecond)) + fmt.Printf(" Rate: %.2f candidates/s\n\n", float64(tried)/elapsed.Seconds()) + fmt.Printf(" Payload (%d bytes):\n%s\n", len(payload), string(payload)) + return + } + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "\nwordlist read error: %v\n", err) + os.Exit(1) + } + + elapsed := time.Since(start) + fmt.Printf("\r\n✗ Password not found in wordlist.\n") + fmt.Printf(" Tried: %d candidates in %s\n", tried, elapsed.Round(time.Millisecond)) +} + +// tryPassword attempts to decode the image with the given password. +// Returns the plaintext payload on success, or an error if the HMAC fails. +func tryPassword(img draw.Image, pass []byte, bitsPerChannel, channels int) ([]byte, error) { + // Step 1: bootstrap keys. + bsSeed, bsEncKey, bsNonce := deriveBootstrapKeys(pass) + + // Step 2: decrypt random salt from image LSB bits 0–127. + cur := newCursor(img, bsSeed, bitsPerChannel, channels) + bsCipher, err := cipher.NewCipher(bsNonce, bsEncKey) + if err != nil { + return nil, err + } + bsAdapter := cursors.CursorAdapter(cursors.CipherMiddleware(cur, bsCipher)) + var randomSalt [16]byte + if _, err = io.ReadFull(bsAdapter, randomSalt[:]); err != nil { + return nil, err + } + + // Step 3: derive main keys from the recovered salt. + encKey, macKey, payloadNonce := deriveMainKeys(pass, randomSalt[:]) + + // Step 4: decrypt payload and verify HMAC (the oracle). + cur2 := newCursor(img, bsSeed, bitsPerChannel, channels) + pCipher, err := cipher.NewCipher(payloadNonce, encKey) + if err != nil { + return nil, err + } + payloadCM := cursors.CipherMiddleware(cur2, pCipher) + if _, err = payloadCM.Seek(128, io.SeekStart); err != nil { + return nil, err + } + adapter := cursors.CursorAdapter(payloadCM) + + // Read container length. + lenBuf := make([]byte, 4) + if _, err = io.ReadFull(adapter, lenBuf); err != nil { + return nil, err + } + containerLen := binary.LittleEndian.Uint32(lenBuf) + + // Read padded payload. + paddedBuf := make([]byte, containerLen) + if _, err = io.ReadFull(adapter, paddedBuf); err != nil { + return nil, err + } + + // Read HMAC tag. + tag := make([]byte, 32) + if _, err = io.ReadFull(adapter, tag); err != nil { + return nil, err + } + + // Verify HMAC — this is the oracle. + mac := hmac.New(sha256.New, macKey) + mac.Write(paddedBuf) + if !hmac.Equal(tag, mac.Sum(nil)) { + return nil, fmt.Errorf("hmac mismatch") + } + + // Extract real payload (first 4 bytes = LE real length). + if len(paddedBuf) < 4 { + return nil, fmt.Errorf("payload too short") + } + realLen := binary.LittleEndian.Uint32(paddedBuf[:4]) + if int(realLen) > len(paddedBuf)-4 { + return nil, fmt.Errorf("corrupt length") + } + return paddedBuf[4 : 4+realLen], nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func newCursor(img draw.Image, bsSeed int64, bitsPerChannel, channels int) *cursors.RNGCursor { + opts := []cursors.Option{ + cursors.WithSeed(bsSeed), + cursors.WithBitsPerChannel(bitsPerChannel), + } + if channels >= 2 { + opts = append(opts, cursors.UseGreenBit()) + } + if channels >= 3 { + opts = append(opts, cursors.UseBlueBit()) + } + return cursors.NewRNGCursor(img, opts...) +} + +func deriveBootstrapKeys(pass []byte) (bsSeed int64, bsEncKey []byte, bsNonce uint32) { + derived := argon2.IDKey(pass, appSalt, 1, 64*1024, 4, 28) + bsSeed = int64(binary.BigEndian.Uint64(derived[0:8])) + bsEncKey = derived[8:24] + bsNonce = binary.BigEndian.Uint32(derived[24:28]) + return +} + +func deriveMainKeys(pass, salt []byte) (encKey, macKey []byte, payloadNonce uint32) { + derived := argon2.IDKey(pass, salt, 1, 64*1024, 4, 52) + encKey = derived[0:16] + macKey = derived[16:48] + payloadNonce = binary.BigEndian.Uint32(derived[48:52]) + return +} + +func loadDrawImage(path string) (draw.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + src, _, err := image.Decode(f) + if err != nil { + return nil, err + } + b := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + return dst, nil +} diff --git a/docs/attack-analysis.md b/docs/attack-analysis.md new file mode 100644 index 0000000..e66aaa6 --- /dev/null +++ b/docs/attack-analysis.md @@ -0,0 +1,341 @@ +# Attack Analysis: steg Cryptographic Security + +This document catalogues known attack surfaces against the `steg` tool's +steganography and encryption scheme, framed as cryptopals-style exercises. +Each section describes the theoretical basis, the exploit path, expected +difficulty, and a results section to be filled in after implementation. + +--- + +## Background: Cryptographic Design + +``` +Password + │ + ▼ Argon2id(pass, FIXED_SALT="github.com/pableeee/steg/v1", t=1, m=64MiB) + │ → bsSeed (8B) — Fisher-Yates pixel traversal seed + │ → bsEncKey (16B) — bootstrap AES-128 key + │ → bsNonce (4B) — bootstrap CTR nonce ← CONSTANT PER PASSWORD + │ + ▼ crypto/rand → randomSalt (16B) + │ → encrypted with bootstrap cipher (key=bsEncKey, nonce=bsNonce) + │ → written to image LSB bits 0–127 + │ + ▼ Argon2id(pass, randomSalt, t=1, m=64MiB) + → encKey (16B) — payload AES-128-CTR key + → macKey (32B) — HMAC-SHA256 key + → payloadNonce (4B) — unique per encode (randomSalt drives this) +``` + +On-image binary layout (in Fisher-Yates pixel bit order): + +``` +Bits Size Cipher Field +────────────────────────────────────────────────────────── +0–127 16 B AES-CTR bootstrap enc(randomSalt) +128–159 4 B AES-CTR payload container length (LE uint32) +160–191 4 B AES-CTR payload real payload length (LE uint32) +192–… N B AES-CTR payload real payload bytes +…–… P B AES-CTR payload random padding (fills capacity) +…–(…+256) 32 B AES-CTR payload HMAC-SHA256 tag +``` + +--- + +## Attack 1: Steganalysis (Chi-Square Test) + +### Theory + +Natural images have uneven distributions of pixel LSB values: adjacent grey +levels `(2k, 2k+1)` appear with different frequencies because the image +content is correlated. LSB steganography replaces those bits with (pseudo-) +random cipher output, which equalises the pair frequencies. + +The chi-square test measures this equalisation: + +``` +χ² = Σ (observed_i − expected_i)² / expected_i +``` + +A high p-value (> 0.05) suggests the LSBs have been homogenised — a +steganalysis signal detectable **without knowing the password**. + +### Exploit Path + +1. Load the suspect image. +2. For each colour channel (R, G, B), collect all pixel values. +3. Build a histogram of pair counts: `count[2k]` and `count[2k+1]` for k = 0..127. +4. Chi-square statistic over the 128 pairs. +5. Convert to a p-value; threshold at 0.05. + +No password, no pixel-order knowledge required. Works on the whole image. + +### Notes + +- The Fisher-Yates pixel ordering does **not** prevent steganalysis because + the statistical properties of the overall LSB set are unchanged regardless + of traversal order. +- Random padding (fills the entire image capacity) amplifies the signal: even + a 1-byte payload causes all remaining pixels to receive uniformly random + LSBs. +- The `steg detect` command already implements this; re-implementing it + independently confirms the design. + +### Results + +| Image | Channels | Bits/ch | Chi-square p (R/G/B) | RS asym (R/G/B) | Verdict | +|-------|----------|---------|---------------------|----------------|---------| +| `test/dude.png` (clean) | 3 | 1 | 0.00 / 0.00 / 0.00 | -0.0045 / -0.0017 / -0.0055 | CLEAN (0/6) | +| `steg_a.png` (19B payload) | 3 | 1 | 0.03 / 0.29 / 1.00 | -0.04 / -0.04 / -0.04 | SUSPICIOUS (2/6 chi) | + +**Observations:** +- Clean image: all chi-square p ≈ 0, all RS asymmetries negative and small — correct CLEAN verdict. +- Stego image: G and B chi-square p become suspicious because filling the whole image with random + padding homogenises those channels even though only R was not directly modified (format conversion + during encode/decode changed G/B statistics). +- RS asymmetry is strongly *negative* for stego images (Rm < Rnm), the opposite sign from the + theoretical expectation. This is because the random padding fills the entire image capacity, + randomising all LSBs uniformly — the RS test catches this as suspicious but with inverted sign. + The built-in `steg detect` command uses a `> 0.01` threshold so it misses this; the attack + implementation could be extended to also flag large *negative* asymmetries. + +--- + +## Attack 2: Bootstrap Cipher Nonce Reuse (Two-Time Pad) + +### Theory + +The bootstrap cipher uses key `bsEncKey` and nonce `bsNonce`, both derived +from `Argon2id(pass, FIXED_SALT)`. For a given password these values are +**constant**. The bootstrap keystream `KS_bs` is therefore identical for +every encode with that password. + +Each image's first 128 bits of LSB data hold: + +``` +C_i = randomSalt_i ⊕ KS_bs +``` + +XOR-ing two images encoded with the same password: + +``` +C_A ⊕ C_B = randomSalt_A ⊕ randomSalt_B +``` + +This is the **two-time pad** problem from Set 3, Challenge 19/20. The +plaintexts here are uniformly random salts (not ASCII), so letter-frequency +attacks do not apply directly — but the structural weakness enables a +chosen-plaintext attack (see Attack 3). + +### Exploit Path (demonstrating the leak) + +1. Encode two files `A` and `B` with the **same password** into two carrier + images. +2. Extract the first 128 bits of LSB data from each image in pixel order + (requires knowing `bsSeed`, which requires the password — acceptable + since we own both images in this demo). +3. XOR the two 16-byte values: `C_A ⊕ C_B`. +4. Verify that `C_A ⊕ C_B = randomSalt_A ⊕ randomSalt_B` by independently + computing each salt (instrument the encoder to emit them). + +### Expected Outcome + +The XOR of two encoded images' bootstrap regions equals the XOR of their +random salts — a direct consequence of keystream reuse. + +### Notes + +- The payload bytes (bits 128+) do **not** have this problem: each image uses + unique main keys derived from its unique `randomSalt`. +- The fix is to generate the bootstrap nonce randomly (stored in the image + before the encrypted salt) rather than deriving it from the fixed-salt KDF. + +### Results + +| Image pair | `C_A ⊕ C_B` (hex) | `salt_A ⊕ salt_B` (hex) | Match? | +|------------|-------------------|------------------------|--------| +| steg_a + steg_b (password "hunter2") | `b6ec52e9594b6f123b61b539f9e0237e` | `b6ec52e9594b6f123b61b539f9e0237e` | ✓ | + +**Observations:** +- XOR identity confirmed — bootstrap keystream `KS_bs` cancels out exactly. +- `KS_bs` = `e920916134b0b976fc3b4a2857a43a99` — same value for every encode with password "hunter2". +- Re-deriving `randomSalt_B` from `KS_bs` via XOR succeeds, demonstrating the CPA shortcut. + +--- + +## Attack 3: Bootstrap CPA — Recovering a Target Salt + +### Theory + +Extends Attack 2. If the attacker: + +- Knows the password `P`, AND +- Can create a stego image with `P` while observing the internal `randomSalt` + (grey-box / instrumented encoder), + +then they can recover the bootstrap keystream: + +``` +KS_bs = C_own ⊕ randomSalt_own +``` + +and use it to decrypt any target image's salt: + +``` +randomSalt_target = C_target ⊕ KS_bs +``` + +With `randomSalt_target` and `P` they run `Argon2id(P, randomSalt_target)` to +derive the target's main keys and decrypt its payload — without running the +standard `Decode` path. + +### Practical Relevance + +If you already know `P`, you can call `steg decode` directly. This attack +matters in a grey-box auditing scenario: it demonstrates that the bootstrap +region leaks the keystream to anyone with encode access + instrumentation, +bypassing the two-Argon2id decode path. + +### Exploit Path + +1. Instrument `steg/encode.go` to log `randomSalt` after `rand.Read`. +2. Encode a file with password `P` → record `randomSalt_own` and the + resulting image `I_own`. +3. Extract `C_own` (first 16 LSB bytes) from `I_own`. +4. Compute `KS_bs = C_own ⊕ randomSalt_own`. +5. Extract `C_target` from the target image `I_target` (same password `P`). +6. Compute `randomSalt_target = C_target ⊕ KS_bs`. +7. Derive main keys: `Argon2id(P, randomSalt_target)`. +8. Decrypt payload from `I_target` and verify HMAC. + +### Results + +| Step | Value | Notes | +|------|-------|-------| +| `randomSalt_A` | `b805d1d8522bd105f5b650e37bb1ec0d` | recovered via bootstrap decrypt of image A | +| `C_A` (first 16 LSB bytes) | `512540b9669b6873098d1acb2c15d694` | extracted from image A | +| `KS_bs` | `e920916134b0b976fc3b4a2857a43a99` | `C_A ⊕ randomSalt_A` | +| `C_target` | `e7c912503fd0076132ecaff2d5f5f5ea` | extracted from target image B | +| `randomSalt_target` | `0ee983310b60be17ced7e5da8251cf73` | `C_target ⊕ KS_bs` | +| Payload decrypted? | ✓ "another secret message\n" (23 bytes) | HMAC passed | + +**Observations:** +- Salt recovery via XOR is instantaneous — no second Argon2id call needed to recover the salt. +- Still requires Argon2id(P, randomSalt_target) to get the main keys, so attack cost is one + Argon2id instead of two — a 2× speedup for the key derivation phase. +- The main practical value: demonstrates the bootstrap keystream is a shared secret derivable + by any party with encode access and the ability to observe their own randomSalt. + +--- + +## Attack 4: Dictionary Attack with HMAC Oracle + +### Theory + +The HMAC tag (last 32 bytes of the payload cipher stream) acts as a +password-verification oracle. For each candidate password `P_c`: + +1. `Argon2id(P_c, fixedSalt)` → bootstrap keys +2. Decrypt first 16 LSB bytes → `randomSalt_c` +3. `Argon2id(P_c, randomSalt_c)` → main keys +4. Decrypt container → attempt HMAC verification + +If HMAC passes, `P_c` is the correct password. The oracle is constant-time +(`hmac.Equal`) so timing attacks are not applicable. + +### Cost per Candidate + +Each attempt requires **two** Argon2id calls (64 MiB each, `t=1`): + +- ~100–200 ms per candidate on modern CPU hardware +- A 100k-word dictionary takes ~3–6 hours +- A 1M-word dictionary takes ~30–60 hours + +This is the primary practical attack against `steg`-encrypted images when +the password is weak. + +### Exploit Path + +1. Collect a wordlist (e.g. `rockyou.txt`). +2. For each candidate: + a. Run bootstrap KDF. + b. Extract and decrypt `randomSalt` from the image. + c. Run payload KDF. + d. Decrypt container length + padded payload. + e. Verify HMAC. + f. On success: extract real payload length and return plaintext. +3. Report elapsed time per attempt and total. + +### Notes + +- Running Argon2id twice per candidate (vs. once in a simpler design) is an + accidental defence: the two-stage KDF roughly doubles attacker cost. +- Raising `time` from 1 to 2–3 would further harden against this at modest + legitimate-use cost (~200–400 ms per decode instead of ~100 ms). +- GPU acceleration of Argon2id is limited by the 64 MiB memory-per-instance + requirement — this is the core defence. + +### Results + +| Wordlist | Candidates tried | Time/candidate | Total time | Found? | Password | +|----------|-----------------|----------------|-----------|--------|---------| +| 10-word custom list | 7 | ~559 ms | 3.9 s | ✓ | "hunter2" | + +**Observations:** +- ~1.79 candidates/s on this machine — this is purely Argon2id bottlenecked (2 calls × ~280 ms each). +- The 64 MiB memory requirement limits GPU parallelism significantly. +- Recovered payload: "top secret message\n" (19 bytes) — HMAC verified. +- A 1M-word rockyou dictionary at this rate would take ~155 hours single-threaded. + +--- + +## Attack 5: MAC-then-Encrypt Analysis + +### Theory + +`container.WritePayload` computes HMAC over **plaintext** bytes, then +encrypts both the data and the tag together (MAC-then-Encrypt, MtE): + +```go +hashFn.Write(buf[:n]) // HMAC over plaintext +w.Write(buf[:n]) // write ciphertext (encrypted plaintext) +// ... +w.Write(checksum) // write encrypted HMAC tag +``` + +Modern best practice (TLS 1.3, AEAD) is Encrypt-then-MAC (EtM). MtE is +known to enable padding oracle attacks under CBC mode. + +### Why This Is Safe Here (But Still a Design Smell) + +AES-CTR has no padding. There is no padding oracle. The HMAC-SHA256 tag +still provides strong integrity once decrypted: a wrong password produces +a random-looking tag that will fail `hmac.Equal` with overwhelming probability. + +The risk would materialise if the cipher were ever swapped to CBC mode +without revisiting the MAC ordering. + +### Exercise + +Confirm by experiment that a 1-bit flip in the ciphertext: +1. Produces a 1-bit flip in the corresponding plaintext byte (CTR stream + property), AND +2. Causes HMAC verification failure (integrity holds). + +### Results + +| Flip position | Plaintext changed? | HMAC fail? | Notes | +|--------------|--------------------|-----------|-------| +| | | | | + +--- + +## Summary Table + +| # | Attack | Requires Password | Practical? | Severity | +|---|--------|------------------|-----------|---------| +| 1 | Chi-square steganalysis | No | Yes | Medium — detects presence | +| 2 | Two-time pad leak (`C_A⊕C_B`) | Yes (to extract bits) | Demo only | Low — leaks salt XOR | +| 3 | Bootstrap CPA salt recovery | Yes + grey-box encode | Grey-box only | Medium — bypasses decode path | +| 4 | Dictionary / brute-force | No (finding it) | Yes, for weak passwords | High | +| 5 | MtE bit-flip confirmation | Yes (to decrypt) | No direct exploit | Informational |