diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index c427d71a..d9201611 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -5,8 +5,10 @@ go 1.25.0 require ( github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 + github.com/makiuchi-d/gozxing v0.1.1 github.com/mewkiz/flac v1.0.13 github.com/pdfcpu/pdfcpu v0.13.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.52.0 golang.org/x/image v0.44.0 @@ -27,5 +29,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index d9dc8938..cef14c9a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -19,6 +19,8 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lTo github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= +github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= @@ -32,6 +34,8 @@ github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdB github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -46,6 +50,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go index 127b7783..1181c081 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go @@ -11,5 +11,6 @@ import ( _ "github.com/CarterPerez-dev/crypha/internal/carrier/audio" _ "github.com/CarterPerez-dev/crypha/internal/carrier/image" _ "github.com/CarterPerez-dev/crypha/internal/carrier/pdf" + _ "github.com/CarterPerez-dev/crypha/internal/carrier/qr" _ "github.com/CarterPerez-dev/crypha/internal/carrier/text" ) diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go new file mode 100644 index 00000000..cfa28783 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go @@ -0,0 +1,190 @@ +/* +©AngelaMos | 2026 +blocks.go + +Version and error-correction block tables (level H) plus codeword interleaving from ISO/IEC 18004 +*/ + +package qr + +const ( + ecLevelHigh = 2 + framePrefixBytes = 4 + bitsPerCodeword = 8 + injectionSafetyRatio = 2 + minSupportedVersion = 1 + maxSupportedVersion = 10 + baseSymbolSize = 21 + versionSizeStep = 4 +) + +type blockGroup struct { + count int + total int + data int +} + +type qrVersion struct { + version int + remainderBits int + groups []blockGroup +} + +var versionTable = map[int]qrVersion{ + 1: {1, 0, []blockGroup{{1, 26, 9}}}, + 2: {2, 7, []blockGroup{{1, 44, 16}}}, + 3: {3, 7, []blockGroup{{2, 35, 13}}}, + 4: {4, 7, []blockGroup{{4, 25, 9}}}, + 5: {5, 7, []blockGroup{{2, 33, 11}, {2, 34, 12}}}, + 6: {6, 7, []blockGroup{{4, 43, 15}}}, + 7: {7, 0, []blockGroup{{4, 39, 13}, {1, 40, 14}}}, + 8: {8, 0, []blockGroup{{4, 40, 14}, {2, 41, 15}}}, + 9: {9, 0, []blockGroup{{4, 36, 12}, {4, 37, 13}}}, + 10: {10, 0, []blockGroup{{6, 43, 15}, {2, 44, 16}}}, +} + +func lookupVersion(version int) (qrVersion, bool) { + v, ok := versionTable[version] + return v, ok +} + +func symbolSize(version int) int { + return baseSymbolSize + (version-1)*versionSizeStep +} + +func versionForSize(size int) (int, bool) { + if size < baseSymbolSize || (size-baseSymbolSize)%versionSizeStep != 0 { + return 0, false + } + version := (size-baseSymbolSize)/versionSizeStep + 1 + if version < minSupportedVersion || version > maxSupportedVersion { + return 0, false + } + return version, true +} + +func (v qrVersion) numBlocks() int { + n := 0 + for _, g := range v.groups { + n += g.count + } + return n +} + +func (v qrVersion) totalCodewords() int { + n := 0 + for _, g := range v.groups { + n += g.count * g.total + } + return n +} + +func (v qrVersion) ecPerBlock() int { + return v.groups[0].total - v.groups[0].data +} + +func (v qrVersion) correctable() int { + return v.ecPerBlock() / 2 +} + +func (v qrVersion) injectPerBlock() int { + return v.correctable() / injectionSafetyRatio +} + +func (v qrVersion) dataModules() int { + return v.totalCodewords()*bitsPerCodeword + v.remainderBits +} + +func (v qrVersion) capacityBytes() int { + usable := v.numBlocks()*v.injectPerBlock() - framePrefixBytes + if usable < 0 { + return 0 + } + return usable +} + +func (v qrVersion) blockLayout() (dataLens, ecLens []int) { + for _, g := range v.groups { + for i := 0; i < g.count; i++ { + dataLens = append(dataLens, g.data) + ecLens = append(ecLens, g.total-g.data) + } + } + return dataLens, ecLens +} + +func (v qrVersion) interleave(dataBlocks, ecBlocks [][]byte) []byte { + out := make([]byte, 0, v.totalCodewords()) + maxData := 0 + for _, b := range dataBlocks { + if len(b) > maxData { + maxData = len(b) + } + } + for i := 0; i < maxData; i++ { + for _, b := range dataBlocks { + if i < len(b) { + out = append(out, b[i]) + } + } + } + maxEC := 0 + for _, b := range ecBlocks { + if len(b) > maxEC { + maxEC = len(b) + } + } + for i := 0; i < maxEC; i++ { + for _, b := range ecBlocks { + if i < len(b) { + out = append(out, b[i]) + } + } + } + return out +} + +func (v qrVersion) deinterleave(serial []byte) (dataBlocks, ecBlocks [][]byte, ok bool) { + if len(serial) < v.totalCodewords() { + return nil, nil, false + } + dataLens, ecLens := v.blockLayout() + nb := len(dataLens) + dataBlocks = make([][]byte, nb) + ecBlocks = make([][]byte, nb) + for b := 0; b < nb; b++ { + dataBlocks[b] = make([]byte, dataLens[b]) + ecBlocks[b] = make([]byte, ecLens[b]) + } + + pos := 0 + maxData := 0 + for _, d := range dataLens { + if d > maxData { + maxData = d + } + } + for i := 0; i < maxData; i++ { + for b := 0; b < nb; b++ { + if i < dataLens[b] { + dataBlocks[b][i] = serial[pos] + pos++ + } + } + } + maxEC := 0 + for _, e := range ecLens { + if e > maxEC { + maxEC = e + } + } + for i := 0; i < maxEC; i++ { + for b := 0; b < nb; b++ { + if i < ecLens[b] { + ecBlocks[b][i] = serial[pos] + pos++ + } + } + } + return dataBlocks, ecBlocks, true +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go new file mode 100644 index 00000000..f8d4f5c1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go @@ -0,0 +1,57 @@ +/* +©AngelaMos | 2026 +gf.go + +Arithmetic over GF(2^8) under the QR primitive polynomial for Reed-Solomon coding +*/ + +package qr + +const ( + gfOrder = 255 + gfFieldSize = 256 + gfPrimitive = 0x11D + gfGenerator = 2 + gfHighBit = 0x100 +) + +var ( + gfExp [gfOrder * 2]byte + gfLog [gfFieldSize]byte +) + +func init() { + x := 1 + for i := 0; i < gfOrder; i++ { + gfExp[i] = byte(x) + gfLog[x] = byte(i) + x <<= 1 + if x&gfHighBit != 0 { + x ^= gfPrimitive + } + } + for i := gfOrder; i < gfOrder*2; i++ { + gfExp[i] = gfExp[i-gfOrder] + } +} + +func gfMul(a, b byte) byte { + if a == 0 || b == 0 { + return 0 + } + return gfExp[int(gfLog[a])+int(gfLog[b])] +} + +func gfInv(a byte) byte { + return gfExp[gfOrder-int(gfLog[a])] +} + +func gfPow(base byte, exp int) byte { + if base == 0 { + if exp == 0 { + return 1 + } + return 0 + } + return gfExp[(int(gfLog[base])*exp)%gfOrder] +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go new file mode 100644 index 00000000..702afd52 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go @@ -0,0 +1,343 @@ +/* +©AngelaMos | 2026 +matrix.go + +QR module geometry from ISO/IEC 18004: function map, format info, masks, placement, rendering +*/ + +package qr + +import ( + "fmt" + "image" + "image/color" + "image/png" + "io" + "math/bits" +) + +const ( + quietZoneModules = 4 + modulePixels = 8 + darkThreshold = 128 + formatBitCount = 15 + formatDataBits = 5 + formatGenPoly = 0x537 + formatMaskPoly = 0x5412 + formatMaxDistance = 3 + formatCodeCount = 32 + alignmentRadius = 2 + timingLine = 6 + finderBlockEdge = 8 + finderPatternSize = 7 + versionInfoOrigin = 11 + versionInfoModules = 18 + versionInfoBand = 3 + minVersionWithInfo = 7 + darkLight = 0xFF + darkDark = 0x00 +) + +var formatBitCells = [formatBitCount]point{ + {8, 0}, {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5}, + {8, 7}, {8, 8}, {7, 8}, + {5, 8}, {4, 8}, {3, 8}, {2, 8}, {1, 8}, {0, 8}, +} + +var alignmentCenters = map[int][]int{ + 1: {}, + 2: {6, 18}, + 3: {6, 22}, + 4: {6, 26}, + 5: {6, 30}, + 6: {6, 34}, + 7: {6, 22, 38}, + 8: {6, 24, 42}, + 9: {6, 26, 46}, + 10: {6, 28, 50}, +} + +type matrix struct { + size int + grid [][]bool +} + +type point struct { + x, y int +} + +func newMatrix(size int) matrix { + grid := make([][]bool, size) + for i := range grid { + grid[i] = make([]bool, size) + } + return matrix{size: size, grid: grid} +} + +func (m matrix) clone() matrix { + out := newMatrix(m.size) + for y := range m.grid { + copy(out.grid[y], m.grid[y]) + } + return out +} + +func functionModules(version int) [][]bool { + size := symbolSize(version) + isFunc := make([][]bool, size) + for i := range isFunc { + isFunc[i] = make([]bool, size) + } + mark := func(x, y int) { + if x >= 0 && x < size && y >= 0 && y < size { + isFunc[y][x] = true + } + } + + for y := 0; y <= finderBlockEdge; y++ { + for x := 0; x <= finderBlockEdge; x++ { + mark(x, y) + } + } + for y := 0; y <= finderBlockEdge; y++ { + for x := size - finderBlockEdge; x < size; x++ { + mark(x, y) + } + } + for y := size - finderBlockEdge; y < size; y++ { + for x := 0; x <= finderBlockEdge; x++ { + mark(x, y) + } + } + + for i := 0; i < size; i++ { + mark(timingLine, i) + mark(i, timingLine) + } + + centers := alignmentCenters[version] + for _, cx := range centers { + for _, cy := range centers { + if inFinderBlock(cx, cy, size) { + continue + } + for dy := -alignmentRadius; dy <= alignmentRadius; dy++ { + for dx := -alignmentRadius; dx <= alignmentRadius; dx++ { + mark(cx+dx, cy+dy) + } + } + } + } + + if version >= minVersionWithInfo { + for i := 0; i < versionInfoModules; i++ { + mark(i/versionInfoBand, size-versionInfoOrigin+i%versionInfoBand) + mark(size-versionInfoOrigin+i%versionInfoBand, i/versionInfoBand) + } + } + + return isFunc +} + +func inFinderBlock(cx, cy, size int) bool { + span := finderPatternSize + 1 + inTopLeft := cx < span && cy < span + inTopRight := cx >= size-span && cy < span + inBottomLeft := cx < span && cy >= size-span + return inTopLeft || inTopRight || inBottomLeft +} + +func maskBit(maskID, row, col int) bool { + switch maskID { + case 0: + return (row+col)%2 == 0 + case 1: + return row%2 == 0 + case 2: + return col%3 == 0 + case 3: + return (row+col)%3 == 0 + case 4: + return (row/2+col/3)%2 == 0 + case 5: + return (row*col)%2+(row*col)%3 == 0 + case 6: + return ((row*col)%2+(row*col)%3)%2 == 0 + case 7: + return ((row+col)%2+(row*col)%3)%2 == 0 + } + return false +} + +func formatCode(dataBits int) int { + remainder := dataBits << (formatBitCount - formatDataBits) + for i := formatBitCount - 1; i >= formatBitCount-formatDataBits; i-- { + if remainder&(1< formatMaxDistance { + return 0, 0, false + } + return bestData & 0x7, bestData >> 3, true +} + +func placementOrder(version int, isFunc [][]bool) []point { + size := symbolSize(version) + v, _ := lookupVersion(version) + count := v.dataModules() + order := make([]point, 0, count) + + xOffset := 1 + dirUp := true + x := size - 2 + y := size - 1 + + for i := 0; i < count; i++ { + order = append(order, point{x: x + xOffset, y: y}) + if i == count-1 { + break + } + for { + if xOffset == 1 { + xOffset = 0 + } else { + xOffset = 1 + if dirUp { + if y > 0 { + y-- + } else { + dirUp = false + x -= 2 + } + } else { + if y < size-1 { + y++ + } else { + dirUp = true + x -= 2 + } + } + } + if x == timingLine-1 { + x-- + } + if x < 0 { + return order + } + if !isFunc[y][x+xOffset] { + break + } + } + } + return order +} + +func readSerial(m matrix, order []point, maskID, totalCodewords int) []byte { + out := make([]byte, totalCodewords) + for c := 0; c < totalCodewords; c++ { + var b byte + for bit := 0; bit < bitsPerCodeword; bit++ { + p := order[c*bitsPerCodeword+bit] + v := m.grid[p.y][p.x] + if maskBit(maskID, p.y, p.x) { + v = !v + } + b <<= 1 + if v { + b |= 1 + } + } + out[c] = b + } + return out +} + +func writeSerial(m matrix, order []point, maskID int, serial []byte) { + for c := 0; c < len(serial); c++ { + b := serial[c] + for bit := 0; bit < bitsPerCodeword; bit++ { + p := order[c*bitsPerCodeword+bit] + v := (b>>(7-bit))&1 == 1 + if maskBit(maskID, p.y, p.x) { + v = !v + } + m.grid[p.y][p.x] = v + } + } +} + +func renderPNG(m matrix, out io.Writer) error { + dim := (m.size + 2*quietZoneModules) * modulePixels + img := image.NewGray(image.Rect(0, 0, dim, dim)) + for i := range img.Pix { + img.Pix[i] = darkLight + } + for y := 0; y < m.size; y++ { + for x := 0; x < m.size; x++ { + if !m.grid[y][x] { + continue + } + px := (x + quietZoneModules) * modulePixels + py := (y + quietZoneModules) * modulePixels + for dy := 0; dy < modulePixels; dy++ { + for dx := 0; dx < modulePixels; dx++ { + img.SetGray(px+dx, py+dy, color.Gray{Y: darkDark}) + } + } + } + } + return png.Encode(out, img) +} + +func readGrid(r io.Reader) (matrix, int, error) { + img, _, err := image.Decode(r) + if err != nil { + return matrix{}, 0, fmt.Errorf("crypha/qr: decode stego: %w", err) + } + b := img.Bounds() + width, height := b.Dx(), b.Dy() + if width != height || width%modulePixels != 0 { + return matrix{}, 0, ErrNotQR + } + across := width / modulePixels + size := across - 2*quietZoneModules + version, ok := versionForSize(size) + if !ok { + return matrix{}, 0, ErrNotQR + } + m := newMatrix(size) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + cx := b.Min.X + (quietZoneModules+x)*modulePixels + modulePixels/2 + cy := b.Min.Y + (quietZoneModules+y)*modulePixels + modulePixels/2 + gray := color.GrayModel.Convert(img.At(cx, cy)).(color.Gray) + m.grid[y][x] = gray.Y < darkThreshold + } + } + return m, version, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go new file mode 100644 index 00000000..47f2545d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go @@ -0,0 +1,121 @@ +/* +©AngelaMos | 2026 +matrix_test.go + +Unit coverage for QR geometry: mask formulas, version sizing, and capacity edge cases +*/ + +package qr + +import ( + "bytes" + "strings" + "testing" +) + +func TestMaskFormulas(t *testing.T) { + cases := []struct { + maskID int + row, col int + want bool + }{ + {0, 0, 0, true}, {0, 0, 1, false}, + {1, 0, 3, true}, {1, 1, 3, false}, + {2, 4, 0, true}, {2, 4, 1, false}, + {3, 0, 0, true}, {3, 0, 1, false}, + {4, 0, 0, true}, {4, 2, 0, false}, + {5, 0, 0, true}, {5, 1, 1, false}, + {6, 1, 1, true}, {6, 1, 5, false}, + {7, 0, 0, true}, {7, 0, 1, false}, + } + for _, tc := range cases { + if got := maskBit(tc.maskID, tc.row, tc.col); got != tc.want { + t.Fatalf("maskBit(%d,%d,%d): got %v want %v", tc.maskID, tc.row, tc.col, got, tc.want) + } + } + if maskBit(99, 0, 0) { + t.Fatal("maskBit with out-of-range id should be false") + } +} + +func TestFunctionMapCountMatchesDataModules(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + isFunc := functionModules(version) + size := symbolSize(version) + funcCount := 0 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if isFunc[y][x] { + funcCount++ + } + } + } + if nonFunc := size*size - funcCount; nonFunc != versionTable[version].dataModules() { + t.Fatalf("v%d: non-function modules %d, want dataModules %d", version, nonFunc, versionTable[version].dataModules()) + } + } +} + +func TestVersionForSize(t *testing.T) { + cases := []struct { + size int + version int + ok bool + }{ + {21, 1, true}, + {25, 2, true}, + {57, 10, true}, + {17, 0, false}, + {23, 0, false}, + {61, 0, false}, + } + for _, tc := range cases { + version, ok := versionForSize(tc.size) + if ok != tc.ok || version != tc.version { + t.Fatalf("versionForSize(%d): got (%d,%v) want (%d,%v)", tc.size, version, ok, tc.version, tc.ok) + } + } +} + +func TestCapacityBytesFloorsAtZero(t *testing.T) { + tiny := qrVersion{version: 0, groups: []blockGroup{{count: 1, total: 9, data: 8}}} + if got := tiny.capacityBytes(); got != 0 { + t.Fatalf("capacityBytes for a sub-frame budget: got %d want 0", got) + } +} + +func TestSupportedVersionCapacities(t *testing.T) { + want := map[int]int{ + 1: 0, 2: 3, 3: 6, 4: 12, 5: 16, + 6: 24, 7: 26, 8: 32, 9: 44, 10: 52, + } + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + if got := versionTable[version].capacityBytes(); got != want[version] { + t.Fatalf("v%d capacity: got %d want %d", version, got, want[version]) + } + } +} + +func TestRoundTripAcrossCovers(t *testing.T) { + covers := []string{ + "crypha", + "https://angelamos.com/x", + "TEST 123", + "The quick brown fox jumps", + "0123456789", + } + secret := []byte("covert channel") + for _, cover := range covers { + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(cover), secret, &stego); err != nil { + t.Fatalf("Hide cover %q: %v", cover, err) + } + got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal cover %q: %v", cover, err) + } + if !bytes.Equal(got, secret) { + t.Fatalf("cover %q: round-trip mismatch", cover) + } + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go new file mode 100644 index 00000000..a65bd1e7 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go @@ -0,0 +1,266 @@ +/* +©AngelaMos | 2026 +qr.go + +QR carrier that hides a payload as Reed-Solomon-correctable errors so scanners self-heal to the cover +*/ + +package qr + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + qrcode "github.com/skip2/go-qrcode" +) + +const Format = "qr" + +var ( + ErrEmptyPayload = errors.New("crypha/qr: empty payload") + ErrCoverRequired = errors.New("crypha/qr: qr cover text is required") + ErrCoverTooLarge = errors.New("crypha/qr: cover text too large for the supported qr versions") + ErrPayloadTooLarge = errors.New("crypha/qr: payload exceeds carrier capacity") + ErrNoPayload = errors.New("crypha/qr: no crypha payload found") + ErrNotQR = errors.New("crypha/qr: stego is not a crypha qr image") + errBadSymbol = errors.New("crypha/qr: unexpected qr symbol geometry") +) + +type qrCarrier struct{} + +func init() { + carrier.Register(qrCarrier{}) +} + +func (qrCarrier) Format() string { + return Format +} + +func (qrCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error { + if len(payload) == 0 { + return ErrEmptyPayload + } + coverText, err := io.ReadAll(cover) + if err != nil { + return fmt.Errorf("crypha/qr: read cover: %w", err) + } + if len(coverText) == 0 { + return ErrCoverRequired + } + if len(payload) > maxCapacity() { + return fmt.Errorf("%w: need %d bytes, max is %d", ErrPayloadTooLarge, len(payload), maxCapacity()) + } + + code, version, err := selectVersion(string(coverText), len(payload)) + if err != nil { + return err + } + + clean, err := matrixFromBitmap(code.Bitmap(), version) + if err != nil { + return err + } + + maskID, level, ok := parseFormat(clean) + if !ok || level != ecLevelHigh { + return errBadSymbol + } + + spec, _ := lookupVersion(version) + isFunc := functionModules(version) + order := placementOrder(version, isFunc) + serial := readSerial(clean, order, maskID, spec.totalCodewords()) + + dataBlocks, ecBlocks, ok := spec.deinterleave(serial) + if !ok { + return errBadSymbol + } + + framed := frame(payload) + if err := injectFramed(dataBlocks, spec, framed); err != nil { + return err + } + + stego := clean.clone() + writeSerial(stego, order, maskID, spec.interleave(dataBlocks, ecBlocks)) + return renderPNG(stego, out) +} + +func (qrCarrier) Reveal(stego io.Reader) ([]byte, error) { + m, version, err := readGrid(stego) + if err != nil { + return nil, err + } + + maskID, level, ok := parseFormat(m) + if !ok || level != ecLevelHigh { + return nil, ErrNoPayload + } + + spec, _ := lookupVersion(version) + isFunc := functionModules(version) + order := placementOrder(version, isFunc) + serial := readSerial(m, order, maskID, spec.totalCodewords()) + + dataBlocks, ecBlocks, ok := spec.deinterleave(serial) + if !ok { + return nil, ErrNoPayload + } + + framed, err := extractFramed(dataBlocks, ecBlocks, spec) + if err != nil { + return nil, err + } + return unframe(framed) +} + +func (qrCarrier) Capacity(cover io.Reader) (int, error) { + coverText, err := io.ReadAll(cover) + if err != nil { + return 0, fmt.Errorf("crypha/qr: read cover: %w", err) + } + if len(coverText) == 0 { + return 0, ErrCoverRequired + } + best := 0 + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + if _, err := qrcode.NewWithForcedVersion(string(coverText), version, qrcode.Highest); err != nil { + continue + } + if c := versionTable[version].capacityBytes(); c > best { + best = c + } + } + return best, nil +} + +func (qrCarrier) Sniff(stego io.ReadSeeker) bool { + m, _, err := readGrid(stego) + if err != nil { + return false + } + return hasFinderPatterns(m) +} + +func selectVersion(coverText string, payloadLen int) (*qrcode.QRCode, int, error) { + coverFits := false + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + spec := versionTable[version] + code, err := qrcode.NewWithForcedVersion(coverText, version, qrcode.Highest) + if err != nil { + continue + } + coverFits = true + if spec.capacityBytes() < payloadLen { + continue + } + code.DisableBorder = true + return code, version, nil + } + if !coverFits { + return nil, 0, ErrCoverTooLarge + } + return nil, 0, fmt.Errorf("%w: need %d bytes", ErrPayloadTooLarge, payloadLen) +} + +func matrixFromBitmap(bitmap [][]bool, version int) (matrix, error) { + size := symbolSize(version) + if len(bitmap) != size { + return matrix{}, errBadSymbol + } + m := newMatrix(size) + for y := 0; y < size; y++ { + if len(bitmap[y]) != size { + return matrix{}, errBadSymbol + } + copy(m.grid[y], bitmap[y]) + } + return m, nil +} + +func injectFramed(dataBlocks [][]byte, spec qrVersion, framed []byte) error { + nb := spec.numBlocks() + inject := spec.injectPerBlock() + for t := 0; t < len(framed); t++ { + block := t % nb + slot := t / nb + if slot >= inject || slot >= len(dataBlocks[block]) { + return fmt.Errorf("%w: framed length %d", ErrPayloadTooLarge, len(framed)) + } + dataBlocks[block][slot] ^= framed[t] + } + return nil +} + +func extractFramed(dataBlocks, ecBlocks [][]byte, spec qrVersion) ([]byte, error) { + nb := spec.numBlocks() + inject := spec.injectPerBlock() + ec := spec.ecPerBlock() + + clean := make([][]byte, nb) + for b := 0; b < nb; b++ { + recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...) + corrected, err := rsDecode(recv, ec) + if err != nil { + return nil, ErrNoPayload + } + clean[b] = corrected[:len(dataBlocks[b])] + } + + framed := make([]byte, nb*inject) + for t := 0; t < len(framed); t++ { + block := t % nb + slot := t / nb + framed[t] = clean[block][slot] ^ dataBlocks[block][slot] + } + return framed, nil +} + +func frame(payload []byte) []byte { + out := make([]byte, framePrefixBytes+len(payload)) + binary.BigEndian.PutUint32(out, uint32(len(payload))) + copy(out[framePrefixBytes:], payload) + return out +} + +func unframe(framed []byte) ([]byte, error) { + if len(framed) < framePrefixBytes { + return nil, ErrNoPayload + } + length := binary.BigEndian.Uint32(framed) + if length == 0 || uint64(length) > uint64(len(framed)-framePrefixBytes) { + return nil, ErrNoPayload + } + out := make([]byte, length) + copy(out, framed[framePrefixBytes:framePrefixBytes+int(length)]) + return out, nil +} + +func hasFinderPatterns(m matrix) bool { + return isFinder(m, 0, 0) && + isFinder(m, m.size-finderPatternSize, 0) && + isFinder(m, 0, m.size-finderPatternSize) +} + +func isFinder(m matrix, ox, oy int) bool { + if ox < 0 || oy < 0 || ox+finderPatternSize > m.size || oy+finderPatternSize > m.size { + return false + } + for y := 0; y < finderPatternSize; y++ { + for x := 0; x < finderPatternSize; x++ { + border := x == 0 || x == finderPatternSize-1 || y == 0 || y == finderPatternSize-1 + center := x >= 2 && x <= 4 && y >= 2 && y <= 4 + if m.grid[oy+y][ox+x] != (border || center) { + return false + } + } + } + return true +} + +func maxCapacity() int { + return versionTable[maxSupportedVersion].capacityBytes() +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go new file mode 100644 index 00000000..f8f9a8f2 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go @@ -0,0 +1,444 @@ +/* +©AngelaMos | 2026 +qr_test.go + +Differential tests against skip2 and gozxing plus round-trip, capacity, sniff, and registry checks +*/ + +package qr + +import ( + "bytes" + "errors" + stdimage "image" + "image/png" + "strings" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/CarterPerez-dev/crypha/internal/payload" + gozxing "github.com/makiuchi-d/gozxing" + gozxingqr "github.com/makiuchi-d/gozxing/qrcode" + qrcode "github.com/skip2/go-qrcode" +) + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("crypha/qr test: forced read error") +} + +const testCover = "crypha" + +func qrRandom(n, seed int) []byte { + b := make([]byte, n) + x := uint32(seed)*2654435761 + 1 + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } + return b +} + +func skip2Clean(t *testing.T, cover string, version int) matrix { + t.Helper() + code, err := qrcode.NewWithForcedVersion(cover, version, qrcode.Highest) + if err != nil { + t.Fatalf("skip2 encode v%d: %v", version, err) + } + code.DisableBorder = true + m, err := matrixFromBitmap(code.Bitmap(), version) + if err != nil { + t.Fatalf("matrixFromBitmap v%d: %v", version, err) + } + return m +} + +func hideReveal(t *testing.T, cover string, payloadBytes []byte) []byte { + t.Helper() + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(cover), payloadBytes, &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + return got +} + +func decodeWithGozxing(t *testing.T, pngBytes []byte) string { + t.Helper() + img, _, err := stdimage.Decode(bytes.NewReader(pngBytes)) + if err != nil { + t.Fatalf("decode stego png: %v", err) + } + bmp, err := gozxing.NewBinaryBitmapFromImage(img) + if err != nil { + t.Fatalf("gozxing bitmap: %v", err) + } + res, err := gozxingqr.NewQRCodeReader().Decode(bmp, nil) + if err != nil { + t.Fatalf("gozxing decode: %v", err) + } + return res.GetText() +} + +func TestFormatCodeKAT(t *testing.T) { + cases := map[int]int{ + 0: 0x5412, + 1: 0x5125, + 9: 0x72f3, + 16: 0x1689, + 31: 0x2bed, + } + for data, want := range cases { + if got := formatCode(data); got != want { + t.Fatalf("formatCode(%d): got %#x want %#x", data, got, want) + } + } +} + +func TestCleanBlocksAreValidRSCodewords(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + clean := skip2Clean(t, testCover, version) + maskID, level, ok := parseFormat(clean) + if !ok { + t.Fatalf("v%d: parseFormat failed", version) + } + if level != ecLevelHigh { + t.Fatalf("v%d: parsed level %d want %d", version, level, ecLevelHigh) + } + spec := versionTable[version] + order := placementOrder(version, functionModules(version)) + if len(order) != spec.dataModules() { + t.Fatalf("v%d: placement visited %d modules want %d", version, len(order), spec.dataModules()) + } + serial := readSerial(clean, order, maskID, spec.totalCodewords()) + dataBlocks, ecBlocks, ok := spec.deinterleave(serial) + if !ok { + t.Fatalf("v%d: deinterleave failed", version) + } + for b := range dataBlocks { + recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...) + if !rsAllZero(rsSyndromes(recv, spec.ecPerBlock())) { + t.Fatalf("v%d block %d: extracted codeword is not a valid RS codeword", version, b) + } + } + } +} + +func TestReadWriteSymmetry(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + clean := skip2Clean(t, testCover, version) + maskID, _, _ := parseFormat(clean) + spec := versionTable[version] + order := placementOrder(version, functionModules(version)) + serial := readSerial(clean, order, maskID, spec.totalCodewords()) + dataBlocks, ecBlocks, _ := spec.deinterleave(serial) + + rebuilt := clean.clone() + writeSerial(rebuilt, order, maskID, spec.interleave(dataBlocks, ecBlocks)) + for y := 0; y < clean.size; y++ { + for x := 0; x < clean.size; x++ { + if rebuilt.grid[y][x] != clean.grid[y][x] { + t.Fatalf("v%d: re-render differs at (%d,%d)", version, x, y) + } + } + } + } +} + +func TestRoundTrip(t *testing.T) { + cases := []struct { + name string + payload []byte + }{ + {"single byte", []byte{0x42}}, + {"embedded zeros", []byte{0x00, 0x00, 0xFF, 0x00, 0x7F}}, + {"tiny text", []byte("hi")}, + {"medium text", []byte("meet at the docks")}, + {"twenty four bytes", bytes.Repeat([]byte{0xAB}, 24)}, + {"twenty six bytes", bytes.Repeat([]byte{0x5A}, 26)}, + {"high bits", bytes.Repeat([]byte{0xFF}, 40)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := hideReveal(t, testCover, tc.payload) + if !bytes.Equal(got, tc.payload) { + t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload) + } + }) + } +} + +func TestRandomBinaryRoundTrip(t *testing.T) { + for _, size := range []int{1, 3, 12, 27, 44, 52} { + payloadBytes := qrRandom(size, size*7+1) + got := hideReveal(t, testCover, payloadBytes) + if !bytes.Equal(got, payloadBytes) { + t.Fatalf("random round-trip mismatch at size %d", size) + } + } +} + +func TestStegoDecodesToCoverViaGozxing(t *testing.T) { + covers := []string{"crypha", "https://angelamos.com", "HELLO WORLD 123"} + for _, cover := range covers { + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(cover), qrRandom(16, len(cover)), &stego); err != nil { + t.Fatalf("Hide cover %q: %v", cover, err) + } + if got := decodeWithGozxing(t, stego.Bytes()); got != cover { + t.Fatalf("gozxing decoded stego to %q, want cover %q", got, cover) + } + } +} + +func TestPerVersionMaxCapacityRoundTripAndScan(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + capBytes := versionTable[version].capacityBytes() + if capBytes == 0 { + continue + } + payloadBytes := qrRandom(capBytes, version*101+7) + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(testCover), payloadBytes, &stego); err != nil { + t.Fatalf("v%d Hide at capacity %d: %v", version, capBytes, err) + } + got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("v%d Reveal: %v", version, err) + } + if !bytes.Equal(got, payloadBytes) { + t.Fatalf("v%d: round-trip mismatch at max capacity", version) + } + if scanned := decodeWithGozxing(t, stego.Bytes()); scanned != testCover { + t.Fatalf("v%d: at full injection budget, gozxing decoded %q want cover %q", version, scanned, testCover) + } + } +} + +func TestCleanQRHasNoPayload(t *testing.T) { + clean := skip2Clean(t, testCover, 6) + var buf bytes.Buffer + if err := renderPNG(clean, &buf); err != nil { + t.Fatalf("render clean: %v", err) + } + if _, err := (qrCarrier{}).Reveal(bytes.NewReader(buf.Bytes())); err != ErrNoPayload { + t.Fatalf("clean QR reveal: got %v want ErrNoPayload", err) + } +} + +func TestCapacityBoundary(t *testing.T) { + atCap := bytes.Repeat([]byte{0x01}, maxCapacity()) + if got := hideReveal(t, testCover, atCap); !bytes.Equal(got, atCap) { + t.Fatal("payload at exact capacity failed to round-trip") + } + over := bytes.Repeat([]byte{0x01}, maxCapacity()+1) + err := (qrCarrier{}).Hide(strings.NewReader(testCover), over, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected capacity error for oversized payload") + } +} + +func TestCapacityReport(t *testing.T) { + got, err := (qrCarrier{}).Capacity(strings.NewReader(testCover)) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if got != maxCapacity() { + t.Fatalf("Capacity: got %d want %d", got, maxCapacity()) + } +} + +func TestCapacityTooLargeCoverIsZero(t *testing.T) { + large := strings.Repeat("a", 200) + got, err := (qrCarrier{}).Capacity(strings.NewReader(large)) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if got != 0 { + t.Fatalf("Capacity for a cover too large for any version: got %d want 0", got) + } +} + +func TestEmptyPayloadRejected(t *testing.T) { + if err := (qrCarrier{}).Hide(strings.NewReader(testCover), nil, &bytes.Buffer{}); err != ErrEmptyPayload { + t.Fatalf("expected ErrEmptyPayload, got %v", err) + } +} + +func TestEmptyCoverRejected(t *testing.T) { + if err := (qrCarrier{}).Hide(strings.NewReader(""), []byte("x"), &bytes.Buffer{}); err != ErrCoverRequired { + t.Fatalf("expected ErrCoverRequired, got %v", err) + } +} + +func TestCoverTooLargeRejected(t *testing.T) { + huge := strings.Repeat("A", 4000) + err := (qrCarrier{}).Hide(strings.NewReader(huge), []byte("x"), &bytes.Buffer{}) + if err != ErrCoverTooLarge { + t.Fatalf("expected ErrCoverTooLarge, got %v", err) + } +} + +func TestEncryptedEnvelopeExceedsQRCapacity(t *testing.T) { + env, err := payload.Pack([]byte("secret"), payload.Options{ + Passphrase: []byte("correct horse battery staple"), + Cipher: payload.CipherChaCha20, + Strength: payload.StrengthDefault, + }) + if err != nil { + t.Fatalf("Pack: %v", err) + } + if len(env) <= maxCapacity() { + t.Fatalf("encrypted envelope is %d bytes, expected to exceed qr capacity %d", len(env), maxCapacity()) + } + err = (qrCarrier{}).Hide(strings.NewReader(testCover), env, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected oversized encrypted envelope to be rejected") + } +} + +func TestUnencryptedEnvelopeThroughCarrier(t *testing.T) { + secret := []byte("qr covert") + env, err := payload.Pack(secret, payload.Options{Compress: true}) + if err != nil { + t.Fatalf("Pack: %v", err) + } + got := hideReveal(t, testCover, env) + if !bytes.Equal(got, env) { + t.Fatal("carrier did not return the exact envelope bytes") + } + plain, err := payload.Unpack(got, nil) + if err != nil { + t.Fatalf("Unpack: %v", err) + } + if !bytes.Equal(plain, secret) { + t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret) + } +} + +func TestSniff(t *testing.T) { + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(testCover), []byte("payload"), &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + + plain := stdimage.NewGray(stdimage.Rect(0, 0, 200, 200)) + for i := range plain.Pix { + plain.Pix[i] = 0xFF + } + var notQR bytes.Buffer + if err := png.Encode(¬QR, plain); err != nil { + t.Fatalf("encode plain png: %v", err) + } + + cases := []struct { + name string + data []byte + want bool + }{ + {"stego qr", stego.Bytes(), true}, + {"blank png", notQR.Bytes(), false}, + {"garbage", []byte("not an image at all"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (qrCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want { + t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestRevealRejectsGarbage(t *testing.T) { + if _, err := (qrCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err == nil { + t.Fatal("expected decode error for garbage input") + } + plain := stdimage.NewGray(stdimage.Rect(0, 0, 200, 200)) + for i := range plain.Pix { + plain.Pix[i] = 0xFF + } + var blank bytes.Buffer + if err := png.Encode(&blank, plain); err != nil { + t.Fatalf("encode blank: %v", err) + } + if _, err := (qrCarrier{}).Reveal(bytes.NewReader(blank.Bytes())); err == nil { + t.Fatal("blank png reveal: expected an error, got nil") + } +} + +func TestRegisteredInRegistry(t *testing.T) { + c, ok := carrier.Get(Format) + if !ok { + t.Fatal("qr carrier did not self-register") + } + if c.Format() != Format { + t.Fatalf("registry returned wrong carrier: %s", c.Format()) + } +} + +func TestReadErrorsPropagate(t *testing.T) { + if err := (qrCarrier{}).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("Hide: expected cover read error") + } + if _, err := (qrCarrier{}).Capacity(errReader{}); err == nil { + t.Fatal("Capacity: expected cover read error") + } + if _, err := (qrCarrier{}).Reveal(errReader{}); err == nil { + t.Fatal("Reveal: expected stego read error") + } +} + +func TestUnframeRejectsBadLength(t *testing.T) { + if _, err := unframe([]byte{0, 1}); err != ErrNoPayload { + t.Fatalf("short frame: got %v want ErrNoPayload", err) + } + if _, err := unframe([]byte{0, 0, 0, 0}); err != ErrNoPayload { + t.Fatalf("zero-length frame: got %v want ErrNoPayload", err) + } + if _, err := unframe([]byte{0, 0, 0, 10, 1, 2, 3}); err != ErrNoPayload { + t.Fatalf("overlong length prefix: got %v want ErrNoPayload", err) + } +} + +func TestMatrixFromBitmapRejectsWrongSize(t *testing.T) { + tooSmall := make([][]bool, 10) + if _, err := matrixFromBitmap(tooSmall, 1); err != errBadSymbol { + t.Fatalf("wrong height: got %v want errBadSymbol", err) + } + raggedRows := make([][]bool, symbolSize(1)) + for i := range raggedRows { + raggedRows[i] = make([]bool, 5) + } + if _, err := matrixFromBitmap(raggedRows, 1); err != errBadSymbol { + t.Fatalf("wrong width: got %v want errBadSymbol", err) + } +} + +func TestInjectFramedRejectsOverflow(t *testing.T) { + spec := versionTable[2] + dataBlocks := [][]byte{make([]byte, spec.groups[0].data)} + oversized := make([]byte, spec.numBlocks()*spec.injectPerBlock()+1) + if err := injectFramed(dataBlocks, spec, oversized); !errors.Is(err, ErrPayloadTooLarge) { + t.Fatalf("overflow inject: got %v want ErrPayloadTooLarge", err) + } +} + +func TestSniffRejectsNonFinderImage(t *testing.T) { + size := symbolSize(1) + dim := (size + 2*quietZoneModules) * modulePixels + white := stdimage.NewGray(stdimage.Rect(0, 0, dim, dim)) + for i := range white.Pix { + white.Pix[i] = 0xFF + } + var buf bytes.Buffer + if err := png.Encode(&buf, white); err != nil { + t.Fatalf("encode white qr-sized png: %v", err) + } + if (qrCarrier{}).Sniff(bytes.NewReader(buf.Bytes())) { + t.Fatal("Sniff should reject a qr-sized image with no finder patterns") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go new file mode 100644 index 00000000..35036219 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go @@ -0,0 +1,221 @@ +/* +©AngelaMos | 2026 +rs.go + +Systematic Reed-Solomon over GF(2^8): encode plus a syndrome-Berlekamp-Massey decoder +*/ + +package qr + +import "errors" + +var ( + ErrRSInput = errors.New("crypha/qr: invalid reed-solomon block") + ErrRSUncorrectable = errors.New("crypha/qr: reed-solomon block is uncorrectable") +) + +func gfPolyEval(p []byte, x byte) byte { + y := p[0] + for i := 1; i < len(p); i++ { + y = gfMul(y, x) ^ p[i] + } + return y +} + +func gfPolyScale(p []byte, s byte) []byte { + out := make([]byte, len(p)) + for i := range p { + out[i] = gfMul(p[i], s) + } + return out +} + +func gfPolyAdd(a, b []byte) []byte { + n := len(a) + if len(b) > n { + n = len(b) + } + out := make([]byte, n) + for i := range a { + out[i+n-len(a)] = a[i] + } + for i := range b { + out[i+n-len(b)] ^= b[i] + } + return out +} + +func gfPolyMul(a, b []byte) []byte { + out := make([]byte, len(a)+len(b)-1) + for i := range a { + for j := range b { + out[i+j] ^= gfMul(a[i], b[j]) + } + } + return out +} + +func rsGenerator(nsym int) []byte { + g := []byte{1} + for i := 0; i < nsym; i++ { + g = gfPolyMul(g, []byte{1, gfPow(gfGenerator, i)}) + } + return g +} + +func rsEncode(data []byte, nsym int) []byte { + gen := rsGenerator(nsym) + out := make([]byte, len(data)+nsym) + copy(out, data) + for i := 0; i < len(data); i++ { + coef := out[i] + if coef != 0 { + for j := 1; j < len(gen); j++ { + out[i+j] ^= gfMul(gen[j], coef) + } + } + } + copy(out, data) + return out +} + +func rsSyndromes(recv []byte, nsym int) []byte { + s := make([]byte, nsym) + for i := 0; i < nsym; i++ { + s[i] = gfPolyEval(recv, gfPow(gfGenerator, i)) + } + return s +} + +func rsAllZero(s []byte) bool { + for _, v := range s { + if v != 0 { + return false + } + } + return true +} + +func rsErrorLocator(synd []byte) []byte { + errLoc := []byte{1} + oldLoc := []byte{1} + for i := 0; i < len(synd); i++ { + delta := synd[i] + for j := 1; j < len(errLoc); j++ { + delta ^= gfMul(errLoc[len(errLoc)-1-j], synd[i-j]) + } + oldLoc = append(oldLoc, 0) + if delta != 0 { + if len(oldLoc) > len(errLoc) { + newLoc := gfPolyScale(oldLoc, delta) + oldLoc = gfPolyScale(errLoc, gfInv(delta)) + errLoc = newLoc + } + errLoc = gfPolyAdd(errLoc, gfPolyScale(oldLoc, delta)) + } + } + for len(errLoc) > 0 && errLoc[0] == 0 { + errLoc = errLoc[1:] + } + return errLoc +} + +func rsErrorPositions(errLoc []byte, n int) ([]int, bool) { + numErr := len(errLoc) - 1 + positions := make([]int, 0, numErr) + for p := 0; p < n; p++ { + locator := gfPow(gfGenerator, (n-1-p)%gfOrder) + if gfPolyEval(errLoc, gfInv(locator)) == 0 { + positions = append(positions, p) + } + } + if len(positions) != numErr { + return nil, false + } + return positions, true +} + +func rsSolveMagnitudes(locators, synd []byte) ([]byte, bool) { + e := len(locators) + matrix := make([][]byte, e) + for row := 0; row < e; row++ { + matrix[row] = make([]byte, e+1) + for col := 0; col < e; col++ { + matrix[row][col] = gfPow(locators[col], row) + } + matrix[row][e] = synd[row] + } + + for col := 0; col < e; col++ { + pivot := -1 + for row := col; row < e; row++ { + if matrix[row][col] != 0 { + pivot = row + break + } + } + if pivot < 0 { + return nil, false + } + matrix[col], matrix[pivot] = matrix[pivot], matrix[col] + inv := gfInv(matrix[col][col]) + for k := col; k <= e; k++ { + matrix[col][k] = gfMul(matrix[col][k], inv) + } + for row := 0; row < e; row++ { + if row != col && matrix[row][col] != 0 { + factor := matrix[row][col] + for k := col; k <= e; k++ { + matrix[row][k] ^= gfMul(factor, matrix[col][k]) + } + } + } + } + + magnitudes := make([]byte, e) + for row := 0; row < e; row++ { + magnitudes[row] = matrix[row][e] + } + for j := 0; j < len(synd); j++ { + var acc byte + for m := 0; m < e; m++ { + acc ^= gfMul(magnitudes[m], gfPow(locators[m], j)) + } + if acc != synd[j] { + return nil, false + } + } + return magnitudes, true +} + +func rsDecode(recv []byte, nsym int) ([]byte, error) { + if nsym <= 0 || len(recv) <= nsym || len(recv) > gfOrder { + return nil, ErrRSInput + } + synd := rsSyndromes(recv, nsym) + if rsAllZero(synd) { + return append([]byte(nil), recv...), nil + } + errLoc := rsErrorLocator(synd) + numErr := len(errLoc) - 1 + if numErr <= 0 || numErr > nsym/2 { + return nil, ErrRSUncorrectable + } + positions, ok := rsErrorPositions(errLoc, len(recv)) + if !ok { + return nil, ErrRSUncorrectable + } + locators := make([]byte, numErr) + for m, p := range positions { + locators[m] = gfPow(gfGenerator, (len(recv)-1-p)%gfOrder) + } + magnitudes, ok := rsSolveMagnitudes(locators, synd) + if !ok { + return nil, ErrRSUncorrectable + } + corrected := append([]byte(nil), recv...) + for m, p := range positions { + corrected[p] ^= magnitudes[m] + } + return corrected, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go new file mode 100644 index 00000000..e8849e94 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go @@ -0,0 +1,199 @@ +/* +©AngelaMos | 2026 +rs_test.go + +Field-table sanity plus Reed-Solomon encode/decode correctness under injected errors +*/ + +package qr + +import ( + "bytes" + "testing" +) + +func rsRandom(n, seed int) []byte { + b := make([]byte, n) + x := uint32(seed)*2654435761 + 1 + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } + return b +} + +func TestGFFieldTables(t *testing.T) { + if gfExp[0] != 1 { + t.Fatalf("gfExp[0]: got %d want 1", gfExp[0]) + } + if gfExp[8] != 29 { + t.Fatalf("gfExp[8]: got %d want 29 (primitive 0x11D)", gfExp[8]) + } + if gfMul(2, 2) != 4 { + t.Fatalf("gfMul(2,2): got %d want 4", gfMul(2, 2)) + } + if gfPow(2, 8) != 29 { + t.Fatalf("gfPow(2,8): got %d want 29", gfPow(2, 8)) + } + for a := 1; a < gfFieldSize; a++ { + if gfMul(byte(a), gfInv(byte(a))) != 1 { + t.Fatalf("gfInv broken at %d", a) + } + } + for a := 1; a < gfFieldSize; a++ { + for b := 1; b < gfFieldSize; b++ { + if gfMul(byte(a), byte(b)) != gfMul(byte(b), byte(a)) { + t.Fatalf("gfMul not commutative at %d,%d", a, b) + } + } + } +} + +func TestGFPowEdges(t *testing.T) { + if gfPow(gfGenerator, 0) != 1 { + t.Fatalf("gfPow(2,0): got %d want 1", gfPow(gfGenerator, 0)) + } + if gfPow(gfGenerator, gfOrder) != 1 { + t.Fatalf("gfPow(2,255): got %d want 1", gfPow(gfGenerator, gfOrder)) + } + if gfPow(0, 0) != 1 { + t.Fatalf("gfPow(0,0): got %d want 1", gfPow(0, 0)) + } + if gfPow(0, 5) != 0 { + t.Fatalf("gfPow(0,5): got %d want 0", gfPow(0, 5)) + } +} + +func TestRSGeneratorIsMonic(t *testing.T) { + for _, nsym := range []int{7, 17, 22, 28} { + gen := rsGenerator(nsym) + if len(gen) != nsym+1 { + t.Fatalf("generator degree: nsym=%d got len %d", nsym, len(gen)) + } + if gen[0] != 1 { + t.Fatalf("generator not monic: nsym=%d leading %d", nsym, gen[0]) + } + } +} + +func TestRSCleanRoundTrip(t *testing.T) { + cases := []struct{ k, nsym int }{ + {9, 17}, + {13, 22}, + {16, 28}, + {15, 28}, + } + for _, tc := range cases { + data := rsRandom(tc.k, tc.k*tc.nsym) + code := rsEncode(data, tc.nsym) + if len(code) != tc.k+tc.nsym { + t.Fatalf("encode length: got %d want %d", len(code), tc.k+tc.nsym) + } + if !bytes.Equal(code[:tc.k], data) { + t.Fatal("encode is not systematic (data prefix altered)") + } + got, err := rsDecode(code, tc.nsym) + if err != nil { + t.Fatalf("decode clean codeword: %v", err) + } + if !bytes.Equal(got[:tc.k], data) { + t.Fatal("clean decode did not return data") + } + } +} + +func TestRSCorrectsUpToT(t *testing.T) { + cases := []struct{ k, nsym int }{ + {9, 17}, + {13, 22}, + {16, 28}, + } + for _, tc := range cases { + n := tc.k + tc.nsym + maxErr := tc.nsym / 2 + data := rsRandom(tc.k, tc.k+7*tc.nsym) + clean := rsEncode(data, tc.nsym) + for numErr := 1; numErr <= maxErr; numErr++ { + corrupt := append([]byte(nil), clean...) + offsets := rsRandom(numErr, numErr*97+tc.nsym) + mags := rsRandom(numErr, numErr*131+tc.k) + used := map[int]bool{} + placed := 0 + for i := 0; placed < numErr; i++ { + pos := int(offsets[placed%numErr]) % n + pos = (pos + i) % n + if used[pos] { + continue + } + mag := mags[placed%numErr] + if mag == 0 { + mag = 1 + } + corrupt[pos] ^= mag + used[pos] = true + placed++ + } + got, err := rsDecode(corrupt, tc.nsym) + if err != nil { + t.Fatalf("k=%d nsym=%d numErr=%d: decode failed: %v", tc.k, tc.nsym, numErr, err) + } + if !bytes.Equal(got[:tc.k], data) { + t.Fatalf("k=%d nsym=%d numErr=%d: did not recover data", tc.k, tc.nsym, numErr) + } + } + } +} + +func TestRSDoesNotFakeCorrectBeyondT(t *testing.T) { + k, nsym := 13, 22 + n := k + nsym + data := rsRandom(k, 999) + clean := rsEncode(data, nsym) + tooMany := nsym/2 + 1 + for seed := 0; seed < 16; seed++ { + corrupt := append([]byte(nil), clean...) + offsets := rsRandom(tooMany, seed*17+3) + mags := rsRandom(tooMany, seed*29+5) + used := map[int]bool{} + placed := 0 + for i := 0; placed < tooMany; i++ { + pos := (int(offsets[placed%tooMany]) + i) % n + if used[pos] { + continue + } + mag := mags[placed%tooMany] + if mag == 0 { + mag = 1 + } + corrupt[pos] ^= mag + used[pos] = true + placed++ + } + got, err := rsDecode(corrupt, nsym) + if err == nil && bytes.Equal(got[:k], data) { + t.Fatalf("seed %d: decoder silently recovered original from %d errors (> t)", seed, tooMany) + } + } +} + +func TestGFPolyAddAsymmetric(t *testing.T) { + want := []byte{1, 1, 0} + if got := gfPolyAdd([]byte{1, 0, 1}, []byte{1, 1}); !bytes.Equal(got, want) { + t.Fatalf("gfPolyAdd longer-first: got %v want %v", got, want) + } + if got := gfPolyAdd([]byte{1, 1}, []byte{1, 0, 1}); !bytes.Equal(got, want) { + t.Fatalf("gfPolyAdd longer-second: got %v want %v", got, want) + } +} + +func TestRSRejectsMalformedBlocks(t *testing.T) { + if _, err := rsDecode([]byte{1, 2, 3}, 0); err != ErrRSInput { + t.Fatalf("nsym=0: got %v want ErrRSInput", err) + } + if _, err := rsDecode([]byte{1, 2, 3}, 3); err != ErrRSInput { + t.Fatalf("recv==nsym: got %v want ErrRSInput", err) + } + if _, err := rsDecode([]byte{1, 2, 3}, 5); err != ErrRSInput { + t.Fatalf("recv