Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.

10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 40 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand All @@ -12,6 +13,7 @@
- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
- [Supplying the password](#supplying-the-password)
- [Capacity](#capacity)
- [Performance](#performance)
- [Security design](#security-design)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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) |
Expand All @@ -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) |
Expand Down Expand Up @@ -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
Expand All @@ -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/

Expand Down Expand Up @@ -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)
Expand All @@ -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. |

Expand Down
8 changes: 4 additions & 4 deletions cipher/cipher.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type streamCipherImpl struct {

currentBlock []byte
index int64
mixIndex int64
minIndex int64
maxIndex int64

block std_cipher.Block
Expand Down Expand Up @@ -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)
}

Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading