fix: correct capacity drift, clamp payload length, pixel-align parallel chunks - #7
Merged
Conversation
…el chunks
Addresses the findings from a full review of the working tree.
CLI capacity was computed twice, and the copies disagreed. cmd/steg had its
own imageCapacity() still using the pre-c69a626 overhead of 44 bytes (4-byte
encrypted nonce) while the library had moved to 56 (16-byte plaintext salt).
Every `steg test-visual` run therefore failed on its first image, because it
sized payloads 12 bytes above what Encode would accept:
Error: encode ch=1 bpc=1: steg: payload too large (468 bytes, capacity 456)
and `steg capacity` over-reported every cell in its table by the same 12
bytes. The arithmetic now lives only in steg.CapacityBytes /
steg.CapacityForDims with an exported Overhead constant, so the two cannot
drift apart again.
The container length field sized an allocation before it was authenticated.
It is decrypted but not yet MAC-verified when read, so a wrong password
yields an essentially random uint32 and `make([]byte, length)` would reserve
up to 4 GiB before any read failed. ReadPayload now takes a maxPayload bound
and DecodeParallel applies the same check inline; both reject anything larger
than the carrier could hold. TestExcessiveLength previously allocated 2 GiB
on every run and now asserts the rejection instead.
Parallel encode could silently corrupt an image. Chunk sizes were aligned to
lcm(8, bitsPerPixel)/8 bytes, but the payload starts at bit 160 and 160 mod 3
= 1, so with the default 3 channels every chunk boundary landed mid-pixel and
the two workers on either side both loaded, modified, and stored that pixel —
the later store discarding the other's bits, leaving an image that fails MAC
verification on decode. The shared mutex did not prevent this: each At and
Set was already serialised, but the load-modify-store sequence was not
atomic. firstChunkShift now offsets the first chunk so every later boundary
falls on a pixel boundary. Verified beforehand that all 3-channel
configurations shared a pixel at 8/8 sampled boundaries and 1- and 2-channel
configurations shared none, matching the modular arithmetic.
The bug is a lost update between mutex-protected accesses, so the race
detector cannot see it and a round-trip test passes by luck most of the time;
TestChunkBoundariesArePixelAligned checks the invariant arithmetically
instead, with TestParallelMultiChunkRoundTrip covering the end-to-end path at
full capacity across five configurations.
Also fixed:
- Dispatch loops could block forever on jobChan if every worker had already
exited on error. Both loops now select on an abort channel closed by the
first failure.
- Cursors addressed sub-images incorrectly: the pixel sequence was generated
over Bounds().Max rather than Dx/Dy and never offset by Bounds().Min, so a
carrier with a non-zero origin read zeroes and dropped writes outside its
own bounds. Confirmed the new sub-image tests fail without the offset.
- `steg decode` created the output file before decoding, leaving a zero-byte
file behind on a wrong password. It now decodes first.
- Passwords had to be passed via --password, which exposes them in the
process table and shell history. resolvePassword falls back to
STEG_PASSWORD and then an interactive prompt, and refuses to run with no
password when stdin is not a terminal.
- `go vet ./...` failed on cursors: ReadByte/WriteByte were declared with
uint8 rather than byte, tripping the stdmethods check. Generated gomock
recorders trip the same check unfixably, so `make vet` excludes mocks/.
- Stale comments: container basePos described as "after nonce" (it is after
the salt), parallel.go referring to the salt as encrypted (it is
plaintext), and cipher's mixIndex typo for minIndex.
Adds .github/workflows/test.yml running vet, tests, the race detector, and a
build on pull requests and non-master pushes. Until now the only workflow ran
on pushes to master, so a broken change was caught only after merge, when it
blocked the release job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four standalone programs reproducing the attacks considered against the pre-c69a626 two-KDF design, plus the analysis document recording which are mitigated and which remain open. These were sitting untracked in the working tree. attack2 and attack3 called fmt.Println with arguments already ending in a newline, which fails vet and therefore `go test ./...`; committing them as-is would have broken the release job on the next push to master. Converted those four call sites to fmt.Print. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the findings from a full review of the working tree. Three substantive bugs, plus hygiene.
1. Capacity drift broke
test-visualoutrightcmd/stegcarried its ownimageCapacity()still using the pre-c69a626 overhead of 44 bytes (4-byte encrypted nonce), while the library had moved to 56 (16-byte plaintext salt). Everysteg test-visualrun failed on its first image:steg capacityalso over-reported every cell by 12 bytes. The arithmetic now lives only insteg.CapacityBytes/steg.CapacityForDimswith an exportedOverheadconstant, so the two copies cannot drift apart again.2. Unauthenticated length field sized an allocation
The container length is decrypted but not yet MAC-verified when it is read, so a wrong password yields an essentially random
uint32andmake([]byte, length)would reserve up to 4 GiB before any read failed.ReadPayloadnow takes amaxPayloadbound andDecodeParallelapplies the same check inline.Visible on a wrong password:
TestExcessiveLengthpreviously allocated 2 GiB on every run; it now asserts the rejection instead.3. Parallel encode could silently corrupt an image
Chunk sizes were aligned to
lcm(8, bitsPerPixel)/8bytes, but the payload starts at bit 160 and160 mod 3 = 1. With the default 3 channels, every chunk boundary landed mid-pixel, so the two workers on either side both loaded, modified, and stored that pixel — the later store discarding the other's bits, leaving an image that fails MAC verification on decode.The shared mutex did not prevent this: each
AtandSetwas already serialised, but the load-modify-store sequence was not atomic.Verified the overlap arithmetically before changing anything:
firstChunkShiftnow offsets the first chunk so every later boundary falls on a pixel boundary.Note on testing: this is a lost update between two mutex-protected accesses, so the race detector cannot see it, and a round-trip test passes by luck most of the time — the existing parallel tests used single-chunk payloads and never hit a boundary at all.
TestChunkBoundariesArePixelAlignedtherefore checks the invariant arithmetically, withTestParallelMultiChunkRoundTripcovering the end-to-end path at full capacity across five configurations.Also fixed
jobChanif every worker had already exited on error. They nowselecton an abort channel closed by the first failure.Bounds().Maxrather thanDx/Dyand never offset byBounds().Min, so a carrier with a non-zero origin read zeroes and dropped writes outside its own bounds. Confirmed the new tests fail without the offset.steg decodecreated the output file before decoding, leaving a zero-byte file behind on a wrong password. It now decodes first.--passwordexposes the passphrase in the process table and shell history.resolvePasswordfalls back toSTEG_PASSWORDand then an interactive prompt, and refuses to run passwordless when stdin is not a terminal.go vet ./...failed oncursors:ReadByte/WriteBytewere declared withuint8rather thanbyte, trippingstdmethods. Generated gomock recorders trip the same check unfixably, somake vetexcludesmocks/.basePosdescribed as "after nonce" (it is after the salt),parallel.gocalling the salt encrypted (it is plaintext), and cipher'smixIndextypo forminIndex.CI
Adds
.github/workflows/test.ymlrunning vet, tests, the race detector, and a build on pull requests and non-masterpushes. Until now the only workflow ran on pushes tomaster, so a broken change was caught only after merge — when it blocked the release job.The second commit adds the previously-untracked attack analysis tooling and writeup.
attack2andattack3calledfmt.Printlnwith arguments already ending in a newline, which fails vet and thereforego test ./...; committing them as-is would have broken the release job on the next push tomaster.Verification
gofmtclean,make vetclean,go build ./...,go test ./...passes,go test -race ./...passes,govulncheck0 reachable, CLI round-trips confirmed sequential, parallel, and both interop directions.Behavior change worth a look
Making
--passwordoptional means scripts relying on cobra rejecting a missing-pwill now prompt or readSTEG_PASSWORDinstead.🤖 Generated with Claude Code