feat(crypha): add qr carrier with reed-solomon error injection (M6)
Reimplements the QR internals needed for the covert channel from ISO/IEC 18004: the function-module map, format-info parse, data mask, zigzag placement, and error-correction block de-interleave, plus a from-scratch Reed-Solomon decoder over GF(2^8) (syndromes, Berlekamp- Massey, Chien search, and a Vandermonde magnitude solve). skip2 generates the clean symbol; crypha introspects it and reuses none of its internals. The payload is hidden as up to floor(t/2) correctable codeword errors per block in the data region, leaving the error-correction codewords intact, so any scanner's Reed-Solomon decoder self-heals to the cover and never sees it. Reveal reads the module grid, RS-decodes each block itself, and diffs the corrected data against the stego to recover the payload. EC level is fixed at H; versions 1-10 auto-select by cover and payload size. Capacity is tens of bytes, so an encrypted envelope (which exceeds it) is rejected cleanly. Differentially tested: the extracted codewords match skip2 as valid RS codewords across all supported versions, and every stego still scans back to the cover via gozxing at the full injection budget. skip2 is a runtime dependency (the generator); gozxing is test-only. No toolchain bump; the go directive stays 1.25.0.
This commit is contained in:
parent
2c0d3ae409
commit
0ae323eb5d
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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<<i) != 0 {
|
||||
remainder ^= formatGenPoly << (i - (formatBitCount - formatDataBits))
|
||||
}
|
||||
}
|
||||
return ((dataBits << (formatBitCount - formatDataBits)) | remainder) ^ formatMaskPoly
|
||||
}
|
||||
|
||||
func readFormatBits(m matrix) int {
|
||||
value := 0
|
||||
for i, cell := range formatBitCells {
|
||||
if m.grid[cell.y][cell.x] {
|
||||
value |= 1 << i
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseFormat(m matrix) (maskID, level int, ok bool) {
|
||||
stored := readFormatBits(m)
|
||||
bestDist := formatBitCount + 1
|
||||
bestData := -1
|
||||
for d := 0; d < formatCodeCount; d++ {
|
||||
dist := bits.OnesCount(uint(stored ^ formatCode(d)))
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
bestData = d
|
||||
}
|
||||
}
|
||||
if bestData < 0 || bestDist > 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<nsym: got %v want ErrRSInput", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue