diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index fa9968d3..fee62039 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -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 ( diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index 666fe98d..989d7242 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -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= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go new file mode 100644 index 00000000..f4544cf3 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go @@ -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" +) diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go new file mode 100644 index 00000000..4f87e9b9 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go new file mode 100644 index 00000000..8028b4c2 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go @@ -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()) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go new file mode 100644 index 00000000..78b496a4 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go new file mode 100644 index 00000000..9081b3f6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go @@ -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()) + } +}