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/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/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/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 | 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) + } + } +}