feat(crypha): add image and zero-width text carriers (M2-M3)
M2 image carrier: LSB embedding in PNG and 24-bit BMP covers via the mandatory NRGBA conversion (avoids the premultiply LSB-corruption trap on both encode paths), RGB-only with alpha untouched, uint32 length prefix, paletted/16-bit/JPEG rejection, Capacity and Sniff, self-registering. M3 text carrier: encrypted payload hidden as zero-width Unicode (U+200B/U+2060) appended after cover text, magic + length framing that survives incidental zero-width in the cover and NFC/NFD/NFKC/NFKD normalization. Frame extraction consumes exactly to the end of the carrier-bit stream so nested stego reveals the last-hidden layer. New internal/carrier/all sentinel blank-imports carriers into the registry. Adds golang.org/x/image/bmp and (test-only) golang.org/x/text.
This commit is contained in:
parent
b89b57347d
commit
5763638006
|
|
@ -5,6 +5,8 @@ go 1.25.0
|
|||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/text v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
|||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
all.go
|
||||
|
||||
Side-effect imports that register every carrier implementation into the registry
|
||||
*/
|
||||
|
||||
package all
|
||||
|
||||
import (
|
||||
_ "github.com/CarterPerez-dev/crypha/internal/carrier/image"
|
||||
_ "github.com/CarterPerez-dev/crypha/internal/carrier/text"
|
||||
)
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
image.go
|
||||
|
||||
LSB image carrier for PNG and 24-bit BMP covers via the mandatory NRGBA path
|
||||
*/
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
stdimage "image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/bitio"
|
||||
"github.com/CarterPerez-dev/crypha/internal/carrier"
|
||||
xbmp "golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
const (
|
||||
Format = "image"
|
||||
|
||||
formatPNG = "png"
|
||||
formatBMP = "bmp"
|
||||
|
||||
bytesPerPixel = 4
|
||||
channelsPerPixel = 3
|
||||
bitsPerByte = 8
|
||||
lengthPrefixBytes = 4
|
||||
lengthPrefixBits = lengthPrefixBytes * bitsPerByte
|
||||
)
|
||||
|
||||
var (
|
||||
pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
bmpSignature = []byte{'B', 'M'}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyPayload = errors.New("crypha/image: empty payload")
|
||||
ErrUnsupportedFormat = errors.New("crypha/image: cover must be a PNG or 24-bit BMP")
|
||||
ErrPaletted = errors.New("crypha/image: paletted images are not supported, provide a truecolor PNG or 24-bit BMP")
|
||||
Err16Bit = errors.New("crypha/image: 16-bit images are not supported, provide an 8-bit truecolor image")
|
||||
ErrPayloadTooLarge = errors.New("crypha/image: payload exceeds carrier capacity")
|
||||
ErrTooSmall = errors.New("crypha/image: image is too small to contain a payload")
|
||||
ErrNoPayload = errors.New("crypha/image: no crypha payload found")
|
||||
)
|
||||
|
||||
type imageCarrier struct{}
|
||||
|
||||
func init() {
|
||||
carrier.Register(imageCarrier{})
|
||||
}
|
||||
|
||||
func (imageCarrier) Format() string {
|
||||
return Format
|
||||
}
|
||||
|
||||
func (imageCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
|
||||
if len(payload) == 0 {
|
||||
return ErrEmptyPayload
|
||||
}
|
||||
|
||||
src, format, err := stdimage.Decode(cover)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crypha/image: decode cover: %w", err)
|
||||
}
|
||||
if format != formatPNG && format != formatBMP {
|
||||
return ErrUnsupportedFormat
|
||||
}
|
||||
if err := rejectLossy(src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst := toNRGBA(src)
|
||||
slots := channelSlots(dst)
|
||||
framed := frame(payload)
|
||||
needBits := len(framed) * bitsPerByte
|
||||
if needBits > slots {
|
||||
return fmt.Errorf("%w: need %d bytes, capacity is %d", ErrPayloadTooLarge, len(payload), capacityFromSlots(slots))
|
||||
}
|
||||
|
||||
reader := bitio.NewReader(framed)
|
||||
for slot := 0; slot < needBits; slot++ {
|
||||
bit, rerr := reader.ReadBit()
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
off := pixOffset(slot)
|
||||
dst.Pix[off] = (dst.Pix[off] &^ 1) | bit
|
||||
}
|
||||
|
||||
switch format {
|
||||
case formatPNG:
|
||||
return png.Encode(out, dst)
|
||||
default:
|
||||
return xbmp.Encode(out, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func (imageCarrier) Reveal(stego io.Reader) ([]byte, error) {
|
||||
src, format, err := stdimage.Decode(stego)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crypha/image: decode stego: %w", err)
|
||||
}
|
||||
if format != formatPNG && format != formatBMP {
|
||||
return nil, ErrUnsupportedFormat
|
||||
}
|
||||
if err := rejectLossy(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
img := toNRGBA(src)
|
||||
slots := channelSlots(img)
|
||||
if slots < lengthPrefixBits {
|
||||
return nil, ErrTooSmall
|
||||
}
|
||||
|
||||
length := binary.BigEndian.Uint32(readBits(img, 0, lengthPrefixBits))
|
||||
maxPayload := capacityFromSlots(slots)
|
||||
if length == 0 || uint64(length) > uint64(maxPayload) {
|
||||
return nil, ErrNoPayload
|
||||
}
|
||||
|
||||
payloadBits := int(length) * bitsPerByte
|
||||
return readBits(img, lengthPrefixBits, payloadBits), nil
|
||||
}
|
||||
|
||||
func (imageCarrier) Capacity(cover io.Reader) (int, error) {
|
||||
cfg, format, err := stdimage.DecodeConfig(cover)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("crypha/image: decode cover config: %w", err)
|
||||
}
|
||||
if format != formatPNG && format != formatBMP {
|
||||
return 0, ErrUnsupportedFormat
|
||||
}
|
||||
slots := cfg.Width * cfg.Height * channelsPerPixel
|
||||
return capacityFromSlots(slots), nil
|
||||
}
|
||||
|
||||
func (imageCarrier) Sniff(stego io.ReadSeeker) bool {
|
||||
head := make([]byte, len(pngSignature))
|
||||
if _, err := io.ReadFull(stego, head); err != nil {
|
||||
return false
|
||||
}
|
||||
if bytesHavePrefix(head, pngSignature) {
|
||||
return true
|
||||
}
|
||||
return bytesHavePrefix(head, bmpSignature)
|
||||
}
|
||||
|
||||
func rejectLossy(src stdimage.Image) error {
|
||||
switch src.(type) {
|
||||
case *stdimage.Paletted:
|
||||
return ErrPaletted
|
||||
case *stdimage.RGBA64, *stdimage.NRGBA64, *stdimage.Gray16, *stdimage.CMYK:
|
||||
return Err16Bit
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func toNRGBA(src stdimage.Image) *stdimage.NRGBA {
|
||||
if n, ok := src.(*stdimage.NRGBA); ok && n.Rect.Min == (stdimage.Point{}) {
|
||||
return n
|
||||
}
|
||||
b := src.Bounds()
|
||||
dst := stdimage.NewNRGBA(stdimage.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func channelSlots(img *stdimage.NRGBA) int {
|
||||
return img.Rect.Dx() * img.Rect.Dy() * channelsPerPixel
|
||||
}
|
||||
|
||||
func capacityFromSlots(slots int) int {
|
||||
usable := slots - lengthPrefixBits
|
||||
if usable < 0 {
|
||||
return 0
|
||||
}
|
||||
return usable / bitsPerByte
|
||||
}
|
||||
|
||||
func frame(payload []byte) []byte {
|
||||
framed := make([]byte, lengthPrefixBytes+len(payload))
|
||||
binary.BigEndian.PutUint32(framed, uint32(len(payload)))
|
||||
copy(framed[lengthPrefixBytes:], payload)
|
||||
return framed
|
||||
}
|
||||
|
||||
func pixOffset(slot int) int {
|
||||
return bytesPerPixel*(slot/channelsPerPixel) + (slot % channelsPerPixel)
|
||||
}
|
||||
|
||||
func readBits(img *stdimage.NRGBA, startSlot, count int) []byte {
|
||||
writer := bitio.NewWriter()
|
||||
for slot := startSlot; slot < startSlot+count; slot++ {
|
||||
off := pixOffset(slot)
|
||||
writer.WriteBit(img.Pix[off] & 1)
|
||||
}
|
||||
return writer.Bytes()
|
||||
}
|
||||
|
||||
func bytesHavePrefix(b, prefix []byte) bool {
|
||||
if len(b) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
for i := range prefix {
|
||||
if b[i] != prefix[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
image_test.go
|
||||
|
||||
Round-trip, rejection, capacity, and sniff tests for the LSB image carrier
|
||||
*/
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdimage "image"
|
||||
"image/color"
|
||||
"image/color/palette"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/carrier"
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
xbmp "golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
const (
|
||||
coverWidth = 8
|
||||
coverHeight = 8
|
||||
coverBytes = 20
|
||||
)
|
||||
|
||||
func nrgbaCover(w, h int) *stdimage.NRGBA {
|
||||
img := stdimage.NewNRGBA(stdimage.Rect(0, 0, w, h))
|
||||
for i := 0; i < len(img.Pix); i += bytesPerPixel {
|
||||
img.Pix[i] = byte(i)
|
||||
img.Pix[i+1] = byte(i * 3)
|
||||
img.Pix[i+2] = byte(i * 7)
|
||||
img.Pix[i+3] = 0xFF
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func encodePNG(t *testing.T, img stdimage.Image) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png cover: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func encodeBMP(t *testing.T, img stdimage.Image) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := xbmp.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode bmp cover: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func hideReveal(t *testing.T, cover, payload []byte) []byte {
|
||||
t.Helper()
|
||||
var stego bytes.Buffer
|
||||
if err := (imageCarrier{}).Hide(bytes.NewReader(cover), payload, &stego); err != nil {
|
||||
t.Fatalf("Hide: %v", err)
|
||||
}
|
||||
got, err := (imageCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("Reveal: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestRoundTripPNG(t *testing.T) {
|
||||
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
|
||||
cases := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
}{
|
||||
{"single byte", []byte{0x42}},
|
||||
{"text", []byte("crypha")},
|
||||
{"full capacity", bytes.Repeat([]byte{0xAB}, coverBytes)},
|
||||
{"high bits set", bytes.Repeat([]byte{0xFF}, coverBytes)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := hideReveal(t, cover, tc.payload)
|
||||
if !bytes.Equal(got, tc.payload) {
|
||||
t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripBMP(t *testing.T) {
|
||||
cover := encodeBMP(t, nrgbaCover(coverWidth, coverHeight))
|
||||
payload := []byte("bmp carrier")
|
||||
got := hideReveal(t, cover, payload)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("bmp round-trip mismatch: got %x want %x", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRGBASourceSafety(t *testing.T) {
|
||||
rgba := stdimage.NewRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight))
|
||||
for i := 0; i < len(rgba.Pix); i += bytesPerPixel {
|
||||
rgba.Pix[i] = byte(i * 5)
|
||||
rgba.Pix[i+1] = byte(i * 11)
|
||||
rgba.Pix[i+2] = byte(i * 13)
|
||||
rgba.Pix[i+3] = 0xFF
|
||||
}
|
||||
cover := encodePNG(t, rgba)
|
||||
payload := []byte("premultiply safe")
|
||||
got := hideReveal(t, cover, payload)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("rgba-source round-trip mismatch: got %x want %x", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransparentCoverAlphaUntouched(t *testing.T) {
|
||||
cover := stdimage.NewNRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight))
|
||||
for i := 0; i < len(cover.Pix); i += bytesPerPixel {
|
||||
cover.Pix[i] = byte(i)
|
||||
cover.Pix[i+1] = byte(i * 2)
|
||||
cover.Pix[i+2] = byte(i * 4)
|
||||
cover.Pix[i+3] = byte(0x80 + i%64)
|
||||
}
|
||||
encoded := encodePNG(t, cover)
|
||||
|
||||
var stego bytes.Buffer
|
||||
if err := (imageCarrier{}).Hide(bytes.NewReader(encoded), []byte("hi"), &stego); err != nil {
|
||||
t.Fatalf("Hide: %v", err)
|
||||
}
|
||||
decoded, _, err := stdimage.Decode(bytes.NewReader(stego.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode stego: %v", err)
|
||||
}
|
||||
out := toNRGBA(decoded)
|
||||
for i := 0; i < len(out.Pix); i += bytesPerPixel {
|
||||
if out.Pix[i+3] != cover.Pix[i+3] {
|
||||
t.Fatalf("alpha modified at pixel %d: got %d want %d", i/bytesPerPixel, out.Pix[i+3], cover.Pix[i+3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pseudoRandom(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 TestRandomBinaryRoundTrip(t *testing.T) {
|
||||
cover := encodePNG(t, nrgbaCover(64, 64))
|
||||
for _, size := range []int{1, 17, 100, 500} {
|
||||
payload := pseudoRandom(size, size)
|
||||
got := hideReveal(t, cover, payload)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("random round-trip mismatch at size %d", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPalettedRejected(t *testing.T) {
|
||||
pal := stdimage.NewPaletted(stdimage.Rect(0, 0, coverWidth, coverHeight), palette.WebSafe)
|
||||
for y := 0; y < coverHeight; y++ {
|
||||
for x := 0; x < coverWidth; x++ {
|
||||
pal.Set(x, y, color.RGBA{R: byte(x * 30), G: byte(y * 30), B: 0x40, A: 0xFF})
|
||||
}
|
||||
}
|
||||
cover := encodePNG(t, pal)
|
||||
err := (imageCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
|
||||
if err != ErrPaletted {
|
||||
t.Fatalf("expected ErrPaletted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixteenBitRejected(t *testing.T) {
|
||||
img := stdimage.NewNRGBA64(stdimage.Rect(0, 0, coverWidth, coverHeight))
|
||||
for y := 0; y < coverHeight; y++ {
|
||||
for x := 0; x < coverWidth; x++ {
|
||||
img.Set(x, y, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9ABC, A: 0xFFFF})
|
||||
}
|
||||
}
|
||||
cover := encodePNG(t, img)
|
||||
err := (imageCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
|
||||
if err != Err16Bit {
|
||||
t.Fatalf("expected Err16Bit, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedFormatRejected(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, nrgbaCover(coverWidth, coverHeight), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
err := (imageCarrier{}).Hide(bytes.NewReader(buf.Bytes()), []byte("x"), &bytes.Buffer{})
|
||||
if err != ErrUnsupportedFormat {
|
||||
t.Fatalf("expected ErrUnsupportedFormat, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPayloadRejected(t *testing.T) {
|
||||
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
|
||||
err := (imageCarrier{}).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{})
|
||||
if err != ErrEmptyPayload {
|
||||
t.Fatalf("expected ErrEmptyPayload, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityBoundary(t *testing.T) {
|
||||
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
|
||||
|
||||
atCap := bytes.Repeat([]byte{0x01}, coverBytes)
|
||||
if got := hideReveal(t, cover, atCap); !bytes.Equal(got, atCap) {
|
||||
t.Fatal("payload at exact capacity failed to round-trip")
|
||||
}
|
||||
|
||||
overCap := bytes.Repeat([]byte{0x01}, coverBytes+1)
|
||||
err := (imageCarrier{}).Hide(bytes.NewReader(cover), overCap, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected capacity error for oversized payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityReport(t *testing.T) {
|
||||
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
|
||||
got, err := (imageCarrier{}).Capacity(bytes.NewReader(cover))
|
||||
if err != nil {
|
||||
t.Fatalf("Capacity: %v", err)
|
||||
}
|
||||
want := (coverWidth*coverHeight*channelsPerPixel - lengthPrefixBits) / bitsPerByte
|
||||
if got != want {
|
||||
t.Fatalf("Capacity: got %d want %d", got, want)
|
||||
}
|
||||
if want != coverBytes {
|
||||
t.Fatalf("test constant coverBytes stale: computed %d", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealNoPayload(t *testing.T) {
|
||||
clean := stdimage.NewNRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight))
|
||||
for i := range clean.Pix {
|
||||
clean.Pix[i] = 0xFE
|
||||
}
|
||||
cover := encodePNG(t, clean)
|
||||
_, err := (imageCarrier{}).Reveal(bytes.NewReader(cover))
|
||||
if err != ErrNoPayload {
|
||||
t.Fatalf("expected ErrNoPayload on a cover with zeroed length bits, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniff(t *testing.T) {
|
||||
pngCover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
|
||||
bmpCover := encodeBMP(t, nrgbaCover(coverWidth, coverHeight))
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want bool
|
||||
}{
|
||||
{"png", pngCover, true},
|
||||
{"bmp", bmpCover, true},
|
||||
{"random", []byte("not an image file at all"), false},
|
||||
{"short", []byte{0x89, 'P'}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := (imageCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want {
|
||||
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealRejectsUnsupportedAndGarbage(t *testing.T) {
|
||||
var jpg bytes.Buffer
|
||||
if err := jpeg.Encode(&jpg, nrgbaCover(coverWidth, coverHeight), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(jpg.Bytes())); err != ErrUnsupportedFormat {
|
||||
t.Fatalf("Reveal jpeg: got %v want ErrUnsupportedFormat", err)
|
||||
}
|
||||
if _, err := (imageCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err == nil {
|
||||
t.Fatal("Reveal garbage: expected decode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealRejectsLossy(t *testing.T) {
|
||||
pal := stdimage.NewPaletted(stdimage.Rect(0, 0, coverWidth, coverHeight), palette.WebSafe)
|
||||
for y := 0; y < coverHeight; y++ {
|
||||
for x := 0; x < coverWidth; x++ {
|
||||
pal.Set(x, y, color.RGBA{R: byte(x * 30), G: byte(y * 30), B: 0x40, A: 0xFF})
|
||||
}
|
||||
}
|
||||
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(encodePNG(t, pal))); err != ErrPaletted {
|
||||
t.Fatalf("Reveal paletted: got %v want ErrPaletted", err)
|
||||
}
|
||||
|
||||
wide := stdimage.NewNRGBA64(stdimage.Rect(0, 0, coverWidth, coverHeight))
|
||||
wide.Set(0, 0, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9ABC, A: 0xFFFF})
|
||||
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(encodePNG(t, wide))); err != Err16Bit {
|
||||
t.Fatalf("Reveal 16-bit: got %v want Err16Bit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealTooSmall(t *testing.T) {
|
||||
cover := encodePNG(t, nrgbaCover(1, 1))
|
||||
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrTooSmall {
|
||||
t.Fatalf("Reveal 1x1: got %v want ErrTooSmall", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityRejectsUnsupportedAndTiny(t *testing.T) {
|
||||
var jpg bytes.Buffer
|
||||
if err := jpeg.Encode(&jpg, nrgbaCover(coverWidth, coverHeight), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if _, err := (imageCarrier{}).Capacity(bytes.NewReader(jpg.Bytes())); err != ErrUnsupportedFormat {
|
||||
t.Fatalf("Capacity jpeg: got %v want ErrUnsupportedFormat", err)
|
||||
}
|
||||
if _, err := (imageCarrier{}).Capacity(bytes.NewReader([]byte("garbage"))); err == nil {
|
||||
t.Fatal("Capacity garbage: expected decode error")
|
||||
}
|
||||
tiny := encodePNG(t, nrgbaCover(1, 1))
|
||||
got, err := (imageCarrier{}).Capacity(bytes.NewReader(tiny))
|
||||
if err != nil {
|
||||
t.Fatalf("Capacity 1x1: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Fatalf("Capacity 1x1: got %d want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedEnvelopeThroughCarrier(t *testing.T) {
|
||||
secret := []byte("meet at the docks at midnight")
|
||||
envelope, err := payload.Pack(secret, payload.Options{
|
||||
Passphrase: []byte("correct horse battery staple"),
|
||||
Compress: true,
|
||||
Cipher: payload.CipherChaCha20,
|
||||
Strength: payload.StrengthDefault,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
cover := encodePNG(t, nrgbaCover(96, 96))
|
||||
var stego bytes.Buffer
|
||||
if err := (imageCarrier{}).Hide(bytes.NewReader(cover), envelope, &stego); err != nil {
|
||||
t.Fatalf("Hide envelope: %v", err)
|
||||
}
|
||||
|
||||
recovered, err := (imageCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("Reveal envelope: %v", err)
|
||||
}
|
||||
if !bytes.Equal(recovered, envelope) {
|
||||
t.Fatal("carrier did not return the exact envelope bytes")
|
||||
}
|
||||
|
||||
plain, err := payload.Unpack(recovered, []byte("correct horse battery staple"))
|
||||
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 TestRegisteredInRegistry(t *testing.T) {
|
||||
c, ok := carrier.Get(Format)
|
||||
if !ok {
|
||||
t.Fatal("image carrier did not self-register")
|
||||
}
|
||||
if c.Format() != Format {
|
||||
t.Fatalf("registry returned wrong carrier: %s", c.Format())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
text.go
|
||||
|
||||
Zero-width Unicode text carrier that appends an invisible framed payload after cover text
|
||||
*/
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/bitio"
|
||||
"github.com/CarterPerez-dev/crypha/internal/carrier"
|
||||
)
|
||||
|
||||
const (
|
||||
Format = "text"
|
||||
|
||||
zeroRune rune = 0x200B
|
||||
oneRune rune = 0x2060
|
||||
|
||||
bitsPerByte = 8
|
||||
lengthBytes = 4
|
||||
lengthBits = lengthBytes * bitsPerByte
|
||||
|
||||
unboundedCapacity = math.MaxInt32
|
||||
)
|
||||
|
||||
var (
|
||||
textMagic = [4]byte{0x7A, 0x57, 0x43, 0x52}
|
||||
textMagicBits = bytesToBits(textMagic[:])
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyPayload = errors.New("crypha/text: empty payload")
|
||||
ErrNoPayload = errors.New("crypha/text: no crypha payload found")
|
||||
)
|
||||
|
||||
type textCarrier struct{}
|
||||
|
||||
func init() {
|
||||
carrier.Register(textCarrier{})
|
||||
}
|
||||
|
||||
func (textCarrier) Format() string {
|
||||
return Format
|
||||
}
|
||||
|
||||
func (textCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
|
||||
if len(payload) == 0 {
|
||||
return ErrEmptyPayload
|
||||
}
|
||||
|
||||
coverBytes, err := io.ReadAll(cover)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crypha/text: read cover: %w", err)
|
||||
}
|
||||
|
||||
frame := make([]byte, 0, len(textMagic)+lengthBytes+len(payload))
|
||||
frame = append(frame, textMagic[:]...)
|
||||
var lenField [lengthBytes]byte
|
||||
binary.BigEndian.PutUint32(lenField[:], uint32(len(payload)))
|
||||
frame = append(frame, lenField[:]...)
|
||||
frame = append(frame, payload...)
|
||||
|
||||
zw := make([]byte, 0, len(frame)*bitsPerByte*utf8.UTFMax)
|
||||
reader := bitio.NewReader(frame)
|
||||
for {
|
||||
bit, rerr := reader.ReadBit()
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
zw = utf8.AppendRune(zw, runeForBit(bit))
|
||||
}
|
||||
|
||||
if _, err := out.Write(coverBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = out.Write(zw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (textCarrier) Reveal(stego io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(stego)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crypha/text: read stego: %w", err)
|
||||
}
|
||||
payload, ok := findFrame(extractBits(data))
|
||||
if !ok {
|
||||
return nil, ErrNoPayload
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (textCarrier) Capacity(_ io.Reader) (int, error) {
|
||||
return unboundedCapacity, nil
|
||||
}
|
||||
|
||||
func (textCarrier) Sniff(stego io.ReadSeeker) bool {
|
||||
data, err := io.ReadAll(stego)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := findFrame(extractBits(data))
|
||||
return ok
|
||||
}
|
||||
|
||||
func runeForBit(bit byte) rune {
|
||||
if bit == 1 {
|
||||
return oneRune
|
||||
}
|
||||
return zeroRune
|
||||
}
|
||||
|
||||
func findFrame(bits []byte) ([]byte, bool) {
|
||||
for start := 0; start+len(textMagicBits) <= len(bits); start++ {
|
||||
if !matchAt(bits, textMagicBits, start) {
|
||||
continue
|
||||
}
|
||||
if payload, ok := parseFrame(bits, start+len(textMagicBits)); ok {
|
||||
return payload, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func extractBits(data []byte) []byte {
|
||||
bits := make([]byte, 0, len(data))
|
||||
for _, r := range string(data) {
|
||||
switch r {
|
||||
case zeroRune:
|
||||
bits = append(bits, 0)
|
||||
case oneRune:
|
||||
bits = append(bits, 1)
|
||||
}
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
func bytesToBits(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b)*bitsPerByte)
|
||||
for _, by := range b {
|
||||
for shift := bitsPerByte - 1; shift >= 0; shift-- {
|
||||
out = append(out, (by>>uint(shift))&1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bitsToBytes(bits []byte) []byte {
|
||||
writer := bitio.NewWriter()
|
||||
for _, bit := range bits {
|
||||
writer.WriteBit(bit)
|
||||
}
|
||||
return writer.Bytes()
|
||||
}
|
||||
|
||||
func matchAt(haystack, needle []byte, at int) bool {
|
||||
if at+len(needle) > len(haystack) {
|
||||
return false
|
||||
}
|
||||
for i := range needle {
|
||||
if haystack[at+i] != needle[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseFrame(bits []byte, headerStart int) ([]byte, bool) {
|
||||
if headerStart+lengthBits > len(bits) {
|
||||
return nil, false
|
||||
}
|
||||
length := binary.BigEndian.Uint32(bitsToBytes(bits[headerStart : headerStart+lengthBits]))
|
||||
if length == 0 {
|
||||
return nil, false
|
||||
}
|
||||
payloadStart := headerStart + lengthBits
|
||||
remaining := len(bits) - payloadStart
|
||||
if uint64(length)*bitsPerByte != uint64(remaining) {
|
||||
return nil, false
|
||||
}
|
||||
return bitsToBytes(bits[payloadStart:]), true
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
text_test.go
|
||||
|
||||
Round-trip, incidental-zero-width, normalization, and framing tests for the text carrier
|
||||
*/
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/carrier"
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func hide(t *testing.T, cover string, data []byte) []byte {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
if err := (textCarrier{}).Hide(strings.NewReader(cover), data, &out); err != nil {
|
||||
t.Fatalf("Hide: %v", err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func reveal(t *testing.T, stego []byte) []byte {
|
||||
t.Helper()
|
||||
got, err := (textCarrier{}).Reveal(bytes.NewReader(stego))
|
||||
if err != nil {
|
||||
t.Fatalf("Reveal: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func pseudoRandom(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 TestRoundTripCovers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cover string
|
||||
}{
|
||||
{"empty cover", ""},
|
||||
{"ascii", "the quick brown fox"},
|
||||
{"multiline", "line one\nline two\nline three\n"},
|
||||
{"unicode cover", "café naïve 你好 \U0001F600 text"},
|
||||
{"whitespace only", " \t\n "},
|
||||
}
|
||||
payloadBytes := []byte("attack at dawn")
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stego := hide(t, tc.cover, payloadBytes)
|
||||
got := reveal(t, stego)
|
||||
if !bytes.Equal(got, payloadBytes) {
|
||||
t.Fatalf("round-trip mismatch: got %q want %q", got, payloadBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomBinaryRoundTrip(t *testing.T) {
|
||||
cover := "cover text that stays visible"
|
||||
for _, size := range []int{1, 7, 64, 1000} {
|
||||
data := pseudoRandom(size, size)
|
||||
got := reveal(t, hide(t, cover, data))
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("random round-trip mismatch at size %d", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverStaysVisible(t *testing.T) {
|
||||
cover := "this text is unchanged"
|
||||
data := []byte("hidden")
|
||||
stego := hide(t, cover, data)
|
||||
|
||||
if !bytes.HasPrefix(stego, []byte(cover)) {
|
||||
t.Fatal("cover bytes were altered")
|
||||
}
|
||||
suffix := stego[len(cover):]
|
||||
for _, r := range string(suffix) {
|
||||
if r != zeroRune && r != oneRune {
|
||||
t.Fatalf("appended data contains a non-carrier rune: U+%04X", r)
|
||||
}
|
||||
}
|
||||
|
||||
visible := strings.Map(func(r rune) rune {
|
||||
if r == zeroRune || r == oneRune {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, string(stego))
|
||||
if visible != cover {
|
||||
t.Fatalf("stripping carrier runes did not restore the cover: got %q", visible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncidentalZeroWidthInCover(t *testing.T) {
|
||||
cover := "prefix" + string(zeroRune) + string(oneRune) + string(zeroRune) + "suffix"
|
||||
data := []byte("payload survives noise")
|
||||
got := reveal(t, hide(t, cover, data))
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("incidental zero-width broke extraction: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNFCNormalizationSurvival(t *testing.T) {
|
||||
cover := "café test cover"
|
||||
data := []byte("normalization is not a threat")
|
||||
stego := hide(t, cover, data)
|
||||
|
||||
for _, form := range []norm.Form{norm.NFC, norm.NFD, norm.NFKC, norm.NFKD} {
|
||||
normalized := form.Bytes(stego)
|
||||
got, err := (textCarrier{}).Reveal(bytes.NewReader(normalized))
|
||||
if err != nil {
|
||||
t.Fatalf("Reveal after normalization: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("payload lost through normalization form: got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityUnbounded(t *testing.T) {
|
||||
got, err := (textCarrier{}).Capacity(strings.NewReader("anything"))
|
||||
if err != nil {
|
||||
t.Fatalf("Capacity: %v", err)
|
||||
}
|
||||
if got != math.MaxInt32 {
|
||||
t.Fatalf("Capacity: got %d want unbounded (%d)", got, math.MaxInt32)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPayloadRejected(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
if err := (textCarrier{}).Hide(strings.NewReader("cover"), nil, &out); err != ErrEmptyPayload {
|
||||
t.Fatalf("expected ErrEmptyPayload, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealNoPayload(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stego string
|
||||
}{
|
||||
{"plain text", "just some ordinary text with no secrets"},
|
||||
{"empty", ""},
|
||||
{"incidental zero-width without magic", "a" + string(zeroRune) + string(oneRune) + "b"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := (textCarrier{}).Reveal(strings.NewReader(tc.stego)); err != ErrNoPayload {
|
||||
t.Fatalf("expected ErrNoPayload, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniff(t *testing.T) {
|
||||
framed := hide(t, "cover", []byte("secret"))
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want bool
|
||||
}{
|
||||
{"framed", framed, true},
|
||||
{"plain", []byte("nothing hidden here"), false},
|
||||
{"incidental zw no magic", []byte("x" + string(zeroRune) + string(oneRune) + "y"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := (textCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want {
|
||||
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverheadRatio(t *testing.T) {
|
||||
data := []byte{0x00}
|
||||
stego := hide(t, "", data)
|
||||
runeCount := utf8.RuneCount(stego)
|
||||
wantRunes := (len(textMagic) + lengthBytes + len(data)) * bitsPerByte
|
||||
if runeCount != wantRunes {
|
||||
t.Fatalf("carrier-rune count: got %d want %d", runeCount, wantRunes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedEnvelopeThroughCarrier(t *testing.T) {
|
||||
secret := []byte("the account number is 4815162342")
|
||||
envelope, err := payload.Pack(secret, payload.Options{
|
||||
Passphrase: []byte("open sesame"),
|
||||
Compress: true,
|
||||
Cipher: payload.CipherChaCha20,
|
||||
Strength: payload.StrengthDefault,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
stego := hide(t, "innocuous cover message", envelope)
|
||||
recovered := reveal(t, stego)
|
||||
if !bytes.Equal(recovered, envelope) {
|
||||
t.Fatal("carrier did not return the exact envelope bytes")
|
||||
}
|
||||
|
||||
plain, err := payload.Unpack(recovered, []byte("open sesame"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
|
||||
type errWriter struct{}
|
||||
|
||||
func (errWriter) Write(_ []byte) (int, error) { return 0, io.ErrShortWrite }
|
||||
|
||||
func TestHideWriteError(t *testing.T) {
|
||||
if err := (textCarrier{}).Hide(strings.NewReader("cover"), []byte("x"), errWriter{}); err != io.ErrShortWrite {
|
||||
t.Fatalf("expected write error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadErrorsPropagate(t *testing.T) {
|
||||
if _, err := (textCarrier{}).Reveal(errReader{}); err == nil {
|
||||
t.Fatal("expected Reveal read error")
|
||||
}
|
||||
if (textCarrier{}).Sniff(readSeekerErr{}) {
|
||||
t.Fatal("Sniff should be false when the reader errors")
|
||||
}
|
||||
}
|
||||
|
||||
type readSeekerErr struct{}
|
||||
|
||||
func (readSeekerErr) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
func (readSeekerErr) Seek(_ int64, _ int) (int64, error) { return 0, nil }
|
||||
|
||||
func zeroWidth(b []byte) string {
|
||||
var sb strings.Builder
|
||||
for _, bit := range bytesToBits(b) {
|
||||
sb.WriteRune(runeForBit(bit))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func TestCorruptFramesRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stego string
|
||||
}{
|
||||
{"zero length field", zeroWidth(append(append([]byte{}, textMagic[:]...), 0, 0, 0, 0))},
|
||||
{"truncated length field", zeroWidth(textMagic[:]) + strings.Repeat(string(zeroRune), 10)},
|
||||
{"length exceeds payload bits", zeroWidth(append(append([]byte{}, textMagic[:]...), 0, 0, 0, 100)) + strings.Repeat(string(oneRune), bitsPerByte)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := (textCarrier{}).Reveal(strings.NewReader(tc.stego)); err != ErrNoPayload {
|
||||
t.Fatalf("expected ErrNoPayload, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedStegoReturnsAppendedFrame(t *testing.T) {
|
||||
first := []byte("first hidden layer")
|
||||
stegoA := hide(t, "public cover text", first)
|
||||
if got := reveal(t, stegoA); !bytes.Equal(got, first) {
|
||||
t.Fatalf("layer A: got %q want %q", got, first)
|
||||
}
|
||||
|
||||
second := []byte("second layer wins")
|
||||
stegoB := hide(t, string(stegoA), second)
|
||||
if got := reveal(t, stegoB); !bytes.Equal(got, second) {
|
||||
t.Fatalf("nested reveal must return the appended frame: got %q want %q", got, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadContainingMagicRoundTrips(t *testing.T) {
|
||||
data := append(append([]byte("head"), textMagic[:]...), []byte("tail after magic bytes")...)
|
||||
got := reveal(t, hide(t, "cover", data))
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("payload containing magic bytes corrupted: got %q want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredInRegistry(t *testing.T) {
|
||||
c, ok := carrier.Get(Format)
|
||||
if !ok {
|
||||
t.Fatal("text carrier did not self-register")
|
||||
}
|
||||
if c.Format() != Format {
|
||||
t.Fatalf("registry returned wrong carrier: %s", c.Format())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue