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:
CarterPerez-dev 2026-07-15 18:09:38 -04:00
parent 2c0d3ae409
commit 0ae323eb5d
11 changed files with 1851 additions and 0 deletions

View File

@ -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
)

View File

@ -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=

View File

@ -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"
)

View File

@ -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
}

View File

@ -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]
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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(&notQR, 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")
}
}

View File

@ -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
}

View File

@ -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)
}
}