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:
CarterPerez-dev 2026-07-15 00:27:46 -04:00
parent b89b57347d
commit 5763638006
7 changed files with 1118 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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