diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index fee62039..c427d71a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -3,6 +3,10 @@ module github.com/CarterPerez-dev/crypha go 1.25.0 require ( + github.com/go-audio/audio v1.0.0 + github.com/go-audio/wav v1.1.0 + github.com/mewkiz/flac v1.0.13 + github.com/pdfcpu/pdfcpu v0.13.0 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.52.0 golang.org/x/image v0.44.0 @@ -10,7 +14,18 @@ require ( ) require ( + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/go-audio/riff v1.0.0 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.2 // indirect + github.com/hhrutter/tiff v1.0.3 // indirect + github.com/icza/bitio v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.47.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index 989d7242..d9dc8938 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -1,11 +1,42 @@ +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= +github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8= +github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= +github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= +github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/pdfcpu/pdfcpu v0.13.0 h1:7maI7K0w4pJsgX9u7eeCsK4+An/+xEVkJwAwyd7/n3M= +github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdBsvKCj4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= @@ -15,4 +46,7 @@ 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go index f4544cf3..127b7783 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go @@ -8,6 +8,8 @@ Side-effect imports that register every carrier implementation into the registry package all import ( + _ "github.com/CarterPerez-dev/crypha/internal/carrier/audio" _ "github.com/CarterPerez-dev/crypha/internal/carrier/image" + _ "github.com/CarterPerez-dev/crypha/internal/carrier/pdf" _ "github.com/CarterPerez-dev/crypha/internal/carrier/text" ) diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio.go new file mode 100644 index 00000000..9642ef1c --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio.go @@ -0,0 +1,285 @@ +/* +©AngelaMos | 2026 +audio.go + +LSB audio carrier for 16-bit PCM WAV covers, accepting FLAC covers decoded to WAV +*/ + +package audio + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/CarterPerez-dev/crypha/internal/bitio" + "github.com/CarterPerez-dev/crypha/internal/carrier" + goaudio "github.com/go-audio/audio" + "github.com/go-audio/wav" + "github.com/mewkiz/flac" +) + +const ( + Format = "audio" + + bitsPerByte = 8 + lengthPrefixBytes = 4 + lengthPrefixBits = lengthPrefixBytes * bitsPerByte + + supportedBitDepth = 16 + wavFormatPCM = 1 + + riffTagBytes = 4 + riffSizeBytes = 4 + waveTagOffset = riffTagBytes + riffSizeBytes + waveTagBytes = 4 + sniffHeaderBytes = waveTagOffset + waveTagBytes +) + +var ( + riffTag = []byte("RIFF") + waveTag = []byte("WAVE") + flacTag = []byte("fLaC") +) + +var ( + ErrEmptyPayload = errors.New("crypha/audio: empty payload") + ErrUnsupportedFormat = errors.New("crypha/audio: cover must be a 16-bit PCM WAV or a FLAC file") + ErrUnsupportedBitDepth = errors.New("crypha/audio: audio must be 16-bit, provide 16-bit PCM WAV or 16-bit FLAC") + ErrNotPCM = errors.New("crypha/audio: WAV must be uncompressed PCM") + ErrNoSamples = errors.New("crypha/audio: cover contains no audio samples") + ErrPayloadTooLarge = errors.New("crypha/audio: payload exceeds carrier capacity") + ErrTooSmall = errors.New("crypha/audio: audio is too small to contain a payload") + ErrNoPayload = errors.New("crypha/audio: no crypha payload found") +) + +type pcm struct { + samples []int + numChannels int + sampleRate int +} + +type audioCarrier struct{} + +func init() { + carrier.Register(audioCarrier{}) +} + +func (audioCarrier) Format() string { + return Format +} + +func (audioCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error { + if len(payload) == 0 { + return ErrEmptyPayload + } + + data, err := io.ReadAll(cover) + if err != nil { + return fmt.Errorf("crypha/audio: read cover: %w", err) + } + src, err := decodeCover(data) + if err != nil { + return err + } + + framed := frame(payload) + needBits := len(framed) * bitsPerByte + if needBits > len(src.samples) { + return fmt.Errorf("%w: need %d bytes, capacity is %d", ErrPayloadTooLarge, len(payload), capacityFromSlots(len(src.samples))) + } + + reader := bitio.NewReader(framed) + for slot := 0; slot < needBits; slot++ { + bit, rerr := reader.ReadBit() + if rerr != nil { + return rerr + } + src.samples[slot] = (src.samples[slot] &^ 1) | int(bit) + } + + return encodeWAV(out, src) +} + +func (audioCarrier) Reveal(stego io.Reader) ([]byte, error) { + data, err := io.ReadAll(stego) + if err != nil { + return nil, fmt.Errorf("crypha/audio: read stego: %w", err) + } + if !isWAV(data) { + return nil, ErrUnsupportedFormat + } + + src, err := decodeWAV(data) + if err != nil { + return nil, err + } + slots := len(src.samples) + if slots < lengthPrefixBits { + return nil, ErrTooSmall + } + + length := binary.BigEndian.Uint32(readBits(src.samples, 0, lengthPrefixBits)) + maxPayload := capacityFromSlots(slots) + if length == 0 || uint64(length) > uint64(maxPayload) { + return nil, ErrNoPayload + } + + payloadBits := int(length) * bitsPerByte + return readBits(src.samples, lengthPrefixBits, payloadBits), nil +} + +func (audioCarrier) Capacity(cover io.Reader) (int, error) { + data, err := io.ReadAll(cover) + if err != nil { + return 0, fmt.Errorf("crypha/audio: read cover: %w", err) + } + src, err := decodeCover(data) + if err != nil { + return 0, err + } + return capacityFromSlots(len(src.samples)), nil +} + +func (audioCarrier) Sniff(stego io.ReadSeeker) bool { + head := make([]byte, sniffHeaderBytes) + if _, err := io.ReadFull(stego, head); err != nil { + return false + } + return isWAV(head) +} + +func decodeCover(data []byte) (pcm, error) { + switch { + case isWAV(data): + return decodeWAV(data) + case hasPrefix(data, flacTag): + return decodeFLAC(data) + default: + return pcm{}, ErrUnsupportedFormat + } +} + +func decodeWAV(data []byte) (pcm, error) { + dec := wav.NewDecoder(bytes.NewReader(data)) + dec.ReadInfo() + if err := dec.Err(); err != nil { + return pcm{}, fmt.Errorf("crypha/audio: decode wav: %w", err) + } + if dec.WavAudioFormat != wavFormatPCM { + return pcm{}, ErrNotPCM + } + if dec.BitDepth != supportedBitDepth { + return pcm{}, ErrUnsupportedBitDepth + } + + buf, err := dec.FullPCMBuffer() + if err != nil { + return pcm{}, fmt.Errorf("crypha/audio: read wav samples: %w", err) + } + if len(buf.Data) == 0 { + return pcm{}, ErrNoSamples + } + + return pcm{ + samples: buf.Data, + numChannels: buf.Format.NumChannels, + sampleRate: buf.Format.SampleRate, + }, nil +} + +func decodeFLAC(data []byte) (pcm, error) { + stream, err := flac.New(bytes.NewReader(data)) + if err != nil { + return pcm{}, fmt.Errorf("crypha/audio: decode flac: %w", err) + } + defer func() { _ = stream.Close() }() + + if stream.Info.BitsPerSample != supportedBitDepth { + return pcm{}, ErrUnsupportedBitDepth + } + + numChannels := int(stream.Info.NChannels) + samples := make([]int, 0, int(stream.Info.NSamples)*numChannels) + for { + f, ferr := stream.ParseNext() + if ferr == io.EOF { + break + } + if ferr != nil { + return pcm{}, fmt.Errorf("crypha/audio: read flac frame: %w", ferr) + } + if len(f.Subframes) == 0 { + continue + } + block := len(f.Subframes[0].Samples) + for i := 0; i < block; i++ { + for _, sub := range f.Subframes { + samples = append(samples, int(sub.Samples[i])) + } + } + } + if len(samples) == 0 { + return pcm{}, ErrNoSamples + } + + return pcm{ + samples: samples, + numChannels: numChannels, + sampleRate: int(stream.Info.SampleRate), + }, nil +} + +func encodeWAV(out io.Writer, src pcm) error { + ws := &memWriteSeeker{} + enc := wav.NewEncoder(ws, src.sampleRate, supportedBitDepth, src.numChannels, wavFormatPCM) + buf := &goaudio.IntBuffer{ + Format: &goaudio.Format{NumChannels: src.numChannels, SampleRate: src.sampleRate}, + Data: src.samples, + SourceBitDepth: supportedBitDepth, + } + if err := enc.Write(buf); err != nil { + return fmt.Errorf("crypha/audio: encode wav: %w", err) + } + if err := enc.Close(); err != nil { + return fmt.Errorf("crypha/audio: finalize wav: %w", err) + } + + _, err := out.Write(ws.buf) + return err +} + +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 readBits(samples []int, startSlot, count int) []byte { + writer := bitio.NewWriter() + for slot := startSlot; slot < startSlot+count; slot++ { + writer.WriteBit(byte(samples[slot] & 1)) + } + return writer.Bytes() +} + +func isWAV(data []byte) bool { + return len(data) >= sniffHeaderBytes && + hasPrefix(data, riffTag) && + bytes.Equal(data[waveTagOffset:waveTagOffset+waveTagBytes], waveTag) +} + +func hasPrefix(data, prefix []byte) bool { + return len(data) >= len(prefix) && bytes.Equal(data[:len(prefix)], prefix) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio_test.go new file mode 100644 index 00000000..c2e988d7 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio_test.go @@ -0,0 +1,493 @@ +/* +©AngelaMos | 2026 +audio_test.go + +Round-trip, FLAC-in, rejection, capacity, and sniff tests for the LSB audio carrier +*/ + +package audio + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/CarterPerez-dev/crypha/internal/payload" + goaudio "github.com/go-audio/audio" + "github.com/go-audio/wav" + "github.com/mewkiz/flac" + flacframe "github.com/mewkiz/flac/frame" + "github.com/mewkiz/flac/meta" +) + +const ( + sampleRate = 44100 + coverSlots = 1000 + coverBytes = (coverSlots - lengthPrefixBits) / bitsPerByte + roomySlots = 8192 + rejectDepth = 8 + minFLACBlock = 16 +) + +func pseudoSamples(n, seed int) []int { + out := make([]int, n) + x := uint32(seed)*2654435761 + 1 + for i := range out { + x = x*1664525 + 1013904223 + out[i] = int(int16(x >> 16)) + } + return out +} + +func synthWAV(t *testing.T, samples []int, chans int) []byte { + t.Helper() + var buf bytes.Buffer + if err := encodeWAV(&buf, pcm{samples: samples, numChannels: chans, sampleRate: sampleRate}); err != nil { + t.Fatalf("synth wav: %v", err) + } + return buf.Bytes() +} + +func flacChannels(chans int) flacframe.Channels { + if chans == 1 { + return flacframe.ChannelsMono + } + return flacframe.ChannelsLR +} + +func synthFLAC(t *testing.T, samples []int, chans, rate, bps int) []byte { + t.Helper() + perChan := len(samples) / chans + declaredBlock := perChan + if declaredBlock < minFLACBlock { + declaredBlock = minFLACBlock + } + info := &meta.StreamInfo{ + SampleRate: uint32(rate), + NChannels: uint8(chans), + BitsPerSample: uint8(bps), + NSamples: uint64(perChan), + BlockSizeMin: uint16(declaredBlock), + BlockSizeMax: uint16(declaredBlock), + } + var out bytes.Buffer + enc, err := flac.NewEncoder(&out, info) + if err != nil { + t.Fatalf("flac new encoder: %v", err) + } + enc.EnablePredictionAnalysis(false) + + if perChan > 0 { + subs := make([]*flacframe.Subframe, chans) + for ch := 0; ch < chans; ch++ { + s := make([]int32, perChan) + for i := 0; i < perChan; i++ { + s[i] = int32(samples[i*chans+ch]) + } + subs[ch] = &flacframe.Subframe{ + SubHeader: flacframe.SubHeader{Pred: flacframe.PredVerbatim}, + Samples: s, + NSamples: perChan, + } + } + f := &flacframe.Frame{ + Header: flacframe.Header{ + HasFixedBlockSize: true, + BlockSize: uint16(perChan), + SampleRate: uint32(rate), + Channels: flacChannels(chans), + BitsPerSample: uint8(bps), + }, + Subframes: subs, + } + if err := enc.WriteFrame(f); err != nil { + t.Fatalf("flac write frame: %v", err) + } + } + if err := enc.Close(); err != nil { + t.Fatalf("flac close: %v", err) + } + return out.Bytes() +} + +func hideReveal(t *testing.T, cover, secret []byte) []byte { + t.Helper() + var stego bytes.Buffer + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), secret, &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + got, err := (audioCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + return got +} + +func TestRoundTripWAV(t *testing.T) { + cases := []struct { + name string + chans int + payload []byte + }{ + {"mono single byte", 1, []byte{0x42}}, + {"mono text", 1, []byte("crypha audio")}, + {"stereo text", 2, []byte("left and right channels")}, + {"high bits set", 1, bytes.Repeat([]byte{0xFF}, 40)}, + {"binary blob", 2, pseudoBytes(300, 9)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cover := synthWAV(t, pseudoSamples(roomySlots, 1), tc.chans) + 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 pseudoBytes(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 TestFLACInWAVOut(t *testing.T) { + cases := []struct { + name string + chans int + payload []byte + }{ + {"mono", 1, []byte("decoded from flac, embedded, emitted as wav")}, + {"stereo", 2, pseudoBytes(200, 3)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cover := synthFLAC(t, pseudoSamples(roomySlots, 2), tc.chans, sampleRate, supportedBitDepth) + + var stego bytes.Buffer + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), tc.payload, &stego); err != nil { + t.Fatalf("Hide flac cover: %v", err) + } + if !isWAV(stego.Bytes()) { + t.Fatal("flac cover did not produce a WAV stego output") + } + got, err := (audioCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if !bytes.Equal(got, tc.payload) { + t.Fatalf("flac-in round-trip mismatch: got %x want %x", got, tc.payload) + } + }) + } +} + +func TestCapacityBoundary(t *testing.T) { + cover := synthWAV(t, pseudoSamples(coverSlots, 5), 1) + + 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 := (audioCarrier{}).Hide(bytes.NewReader(cover), overCap, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected capacity error for oversized payload") + } +} + +func TestCapacityReport(t *testing.T) { + wavCover := synthWAV(t, pseudoSamples(coverSlots, 7), 1) + got, err := (audioCarrier{}).Capacity(bytes.NewReader(wavCover)) + if err != nil { + t.Fatalf("Capacity wav: %v", err) + } + if got != coverBytes { + t.Fatalf("Capacity wav: got %d want %d", got, coverBytes) + } + + flacCover := synthFLAC(t, pseudoSamples(coverSlots*2, 8), 2, sampleRate, supportedBitDepth) + gotFlac, err := (audioCarrier{}).Capacity(bytes.NewReader(flacCover)) + if err != nil { + t.Fatalf("Capacity flac: %v", err) + } + if want := capacityFromSlots(coverSlots * 2); gotFlac != want { + t.Fatalf("Capacity flac: got %d want %d", gotFlac, want) + } +} + +func synthWAVCustom(t *testing.T, samples []int, chans, bitDepth, audioFormat int) []byte { + t.Helper() + ws := &memWriteSeeker{} + enc := wav.NewEncoder(ws, sampleRate, bitDepth, chans, audioFormat) + buf := &goaudio.IntBuffer{ + Format: &goaudio.Format{NumChannels: chans, SampleRate: sampleRate}, + Data: samples, + SourceBitDepth: bitDepth, + } + if err := enc.Write(buf); err != nil { + t.Fatalf("synth custom wav write: %v", err) + } + if err := enc.Close(); err != nil { + t.Fatalf("synth custom wav close: %v", err) + } + return ws.buf +} + +func TestHideRejectsNonPCMWAV(t *testing.T) { + cover := synthWAVCustom(t, pseudoSamples(64, 1), 1, supportedBitDepth, 3) + err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}) + if err != ErrNotPCM { + t.Fatalf("expected ErrNotPCM, got %v", err) + } +} + +func TestHideRejectsWrongBitDepthWAV(t *testing.T) { + cover := synthWAVCustom(t, pseudoSamples(64, 1), 1, rejectDepth, wavFormatPCM) + err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}) + if err != ErrUnsupportedBitDepth { + t.Fatalf("expected ErrUnsupportedBitDepth, got %v", err) + } +} + +func TestHideRejectsWrongBitDepthFLAC(t *testing.T) { + cover := synthFLAC(t, pseudoSamples(64, 4), 1, sampleRate, rejectDepth) + err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}) + if err != ErrUnsupportedBitDepth { + t.Fatalf("expected ErrUnsupportedBitDepth, got %v", err) + } +} + +func TestHideRejectsUnsupportedFormat(t *testing.T) { + garbage := []byte("this is not audio at all, just prose") + if err := (audioCarrier{}).Hide(bytes.NewReader(garbage), []byte("x"), &bytes.Buffer{}); err != ErrUnsupportedFormat { + t.Fatalf("Hide garbage: got %v want ErrUnsupportedFormat", err) + } + if _, err := (audioCarrier{}).Capacity(bytes.NewReader(garbage)); err != ErrUnsupportedFormat { + t.Fatalf("Capacity garbage: got %v want ErrUnsupportedFormat", err) + } +} + +func TestHideEmptyPayloadRejected(t *testing.T) { + cover := synthWAV(t, pseudoSamples(coverSlots, 1), 1) + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{}); err != ErrEmptyPayload { + t.Fatalf("expected ErrEmptyPayload, got %v", err) + } +} + +func TestRevealRejectsNonWAV(t *testing.T) { + flacData := synthFLAC(t, pseudoSamples(64, 2), 1, sampleRate, supportedBitDepth) + if _, err := (audioCarrier{}).Reveal(bytes.NewReader(flacData)); err != ErrUnsupportedFormat { + t.Fatalf("Reveal flac: got %v want ErrUnsupportedFormat", err) + } + if _, err := (audioCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err != ErrUnsupportedFormat { + t.Fatalf("Reveal garbage: got %v want ErrUnsupportedFormat", err) + } +} + +func TestRevealRejectsUndecodableWAV(t *testing.T) { + cover := synthWAVCustom(t, pseudoSamples(64, 1), 1, supportedBitDepth, 3) + if _, err := (audioCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrNotPCM { + t.Fatalf("Reveal float WAV: got %v want ErrNotPCM", err) + } +} + +func TestDecodeWAVTruncatedFmt(t *testing.T) { + var b bytes.Buffer + b.WriteString("RIFF") + _ = binary.Write(&b, binary.LittleEndian, uint32(0xFFFFFFFF)) + b.WriteString("WAVE") + b.WriteString("fmt ") + _ = binary.Write(&b, binary.LittleEndian, uint32(16)) + if err := (audioCarrier{}).Hide(bytes.NewReader(b.Bytes()), []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("expected decode error on a truncated fmt chunk") + } +} + +func TestRevealNoPayload(t *testing.T) { + clean := make([]int, coverSlots) + cover := synthWAV(t, clean, 1) + if _, err := (audioCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrNoPayload { + t.Fatalf("expected ErrNoPayload on zeroed cover, got %v", err) + } +} + +func TestRevealTooSmall(t *testing.T) { + cover := synthWAV(t, pseudoSamples(16, 1), 1) + if _, err := (audioCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrTooSmall { + t.Fatalf("expected ErrTooSmall, got %v", err) + } +} + +func TestSniff(t *testing.T) { + wavCover := synthWAV(t, pseudoSamples(coverSlots, 1), 1) + flacCover := synthFLAC(t, pseudoSamples(64, 1), 1, sampleRate, supportedBitDepth) + cases := []struct { + name string + data []byte + want bool + }{ + {"wav", wavCover, true}, + {"flac not stego", flacCover, false}, + {"random", []byte("not audio, definitely"), false}, + {"short", []byte("RIFF"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (audioCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want { + t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestEncryptedEnvelopeThroughCarrier(t *testing.T) { + secret := []byte("the drop is behind the third locker") + 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 := synthWAV(t, pseudoSamples(roomySlots, 4), 2) + var stego bytes.Buffer + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), envelope, &stego); err != nil { + t.Fatalf("Hide envelope: %v", err) + } + + recovered, err := (audioCarrier{}).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) + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("forced read failure") +} + +func TestReadErrorsPropagate(t *testing.T) { + if err := (audioCarrier{}).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("Hide: expected read error") + } + if _, err := (audioCarrier{}).Reveal(errReader{}); err == nil { + t.Fatal("Reveal: expected read error") + } + if _, err := (audioCarrier{}).Capacity(errReader{}); err == nil { + t.Fatal("Capacity: expected read error") + } +} + +func TestDecodeWAVZeroSamples(t *testing.T) { + cover := synthWAV(t, nil, 1) + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}); err != ErrNoSamples { + t.Fatalf("expected ErrNoSamples on empty WAV, got %v", err) + } +} + +func TestDecodeWAVMalformedHeader(t *testing.T) { + cover := []byte("RIFF\x04\x00\x00\x00WAVE") + err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}) + if err == nil { + t.Fatal("expected decode error on a RIFF/WAVE file with no fmt chunk") + } +} + +func TestDecodeFLACZeroFrames(t *testing.T) { + cover := synthFLAC(t, nil, 1, sampleRate, supportedBitDepth) + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}); err != ErrNoSamples { + t.Fatalf("expected ErrNoSamples on frameless FLAC, got %v", err) + } +} + +func TestDecodeFLACGarbage(t *testing.T) { + cover := append([]byte("fLaC"), pseudoBytes(64, 11)...) + if err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("expected decode error on fLaC-tagged garbage") + } +} + +func TestDecodeFLACTruncatedFrame(t *testing.T) { + full := synthFLAC(t, pseudoSamples(roomySlots, 6), 1, sampleRate, supportedBitDepth) + truncated := full[:len(full)-16] + if err := (audioCarrier{}).Hide(bytes.NewReader(truncated), []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("expected decode error on a FLAC truncated mid-frame") + } +} + +func TestCapacityTinyCoverIsZero(t *testing.T) { + cover := synthWAV(t, pseudoSamples(16, 1), 1) + got, err := (audioCarrier{}).Capacity(bytes.NewReader(cover)) + if err != nil { + t.Fatalf("Capacity tiny: %v", err) + } + if got != 0 { + t.Fatalf("Capacity tiny: got %d want 0", got) + } +} + +func TestMemWriteSeeker(t *testing.T) { + m := &memWriteSeeker{} + if _, err := m.Write([]byte("hello world")); err != nil { + t.Fatalf("Write: %v", err) + } + if pos, err := m.Seek(6, io.SeekStart); err != nil || pos != 6 { + t.Fatalf("SeekStart: pos=%d err=%v", pos, err) + } + if pos, err := m.Seek(2, io.SeekCurrent); err != nil || pos != 8 { + t.Fatalf("SeekCurrent: pos=%d err=%v", pos, err) + } + if pos, err := m.Seek(-5, io.SeekEnd); err != nil || pos != 6 { + t.Fatalf("SeekEnd: pos=%d err=%v", pos, err) + } + if _, err := m.Seek(-1, io.SeekStart); err != errNegativeSeek { + t.Fatalf("negative seek: got %v want errNegativeSeek", err) + } + if _, err := m.Seek(0, 99); err != errInvalidWhence { + t.Fatalf("bad whence: got %v want errInvalidWhence", err) + } + if _, err := m.Seek(0, io.SeekStart); err != nil { + t.Fatalf("reset seek: %v", err) + } + if _, err := m.Write([]byte("HELLO")); err != nil { + t.Fatalf("overwrite: %v", err) + } + if !bytes.Equal(m.buf, []byte("HELLO world")) { + t.Fatalf("overwrite mismatch: %q", m.buf) + } +} + +func TestRegisteredInRegistry(t *testing.T) { + c, ok := carrier.Get(Format) + if !ok { + t.Fatal("audio 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/audio/writeseeker.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/writeseeker.go new file mode 100644 index 00000000..878587e3 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/writeseeker.go @@ -0,0 +1,54 @@ +/* +©AngelaMos | 2026 +writeseeker.go + +In-memory io.WriteSeeker so the WAV encoder can seek back and patch chunk sizes +*/ + +package audio + +import ( + "errors" + "io" +) + +var ( + errNegativeSeek = errors.New("crypha/audio: negative seek position") + errInvalidWhence = errors.New("crypha/audio: invalid seek whence") +) + +type memWriteSeeker struct { + buf []byte + pos int64 +} + +func (m *memWriteSeeker) Write(p []byte) (int, error) { + end := m.pos + int64(len(p)) + if end > int64(len(m.buf)) { + grown := make([]byte, end) + copy(grown, m.buf) + m.buf = grown + } + copy(m.buf[m.pos:end], p) + m.pos = end + return len(p), nil +} + +func (m *memWriteSeeker) Seek(offset int64, whence int) (int64, error) { + var next int64 + switch whence { + case io.SeekStart: + next = offset + case io.SeekCurrent: + next = m.pos + offset + case io.SeekEnd: + next = int64(len(m.buf)) + offset + default: + return 0, errInvalidWhence + } + if next < 0 { + return 0, errNegativeSeek + } + m.pos = next + return next, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf.go new file mode 100644 index 00000000..a5775070 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf.go @@ -0,0 +1,272 @@ +/* +©AngelaMos | 2026 +pdf.go + +PDF carrier with three techniques: embedded-file attachment, Info-dict metadata keys, and raw append-after-EOF +*/ + +package pdf + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "strconv" + "time" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +type Technique string + +const ( + TechniqueAttachment Technique = "attachment" + TechniqueMetadata Technique = "metadata" + TechniqueAppend Technique = "append" +) + +const ( + Format = "pdf" + + lengthPrefixBytes = 4 + unboundedCapacity = math.MaxInt32 + + attachmentID = "crypha-payload.bin" + attachmentDesc = "crypha embedded payload" + + metaKeyPrefix = "X-Crypha-Payload-" + metaCountKey = "X-Crypha-Payload-Count" + metaChunkSize = 8192 +) + +var ( + pdfSignature = []byte("%PDF-") + appendMagic = []byte{0x43, 0x72, 0x79, 0x50} + epoch = time.Unix(0, 0) +) + +var ( + ErrEmptyPayload = errors.New("crypha/pdf: empty payload") + ErrUnsupportedFormat = errors.New("crypha/pdf: cover must be a PDF") + ErrNoPayload = errors.New("crypha/pdf: no crypha payload found") +) + +type pdfCarrier struct { + technique Technique +} + +func init() { + model.ConfigPath = "disable" + carrier.Register(pdfCarrier{technique: TechniqueAttachment}) +} + +func New(t Technique) carrier.Carrier { + return pdfCarrier{technique: t} +} + +func (pdfCarrier) Format() string { + return Format +} + +func (c pdfCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error { + if len(payload) == 0 { + return ErrEmptyPayload + } + + data, err := io.ReadAll(cover) + if err != nil { + return fmt.Errorf("crypha/pdf: read cover: %w", err) + } + if !isPDF(data) { + return ErrUnsupportedFormat + } + + switch c.technique { + case TechniqueMetadata: + return hideMetadata(data, payload, out) + case TechniqueAppend: + return hideAppend(data, payload, out) + default: + return hideAttachment(data, payload, out) + } +} + +func (pdfCarrier) Reveal(stego io.Reader) ([]byte, error) { + data, err := io.ReadAll(stego) + if err != nil { + return nil, fmt.Errorf("crypha/pdf: read stego: %w", err) + } + if !isPDF(data) { + return nil, ErrUnsupportedFormat + } + + if payload, ok := revealAttachment(data); ok && len(payload) > 0 { + return payload, nil + } + if payload, ok := revealMetadata(data); ok && len(payload) > 0 { + return payload, nil + } + if payload, ok := revealAppend(data); ok && len(payload) > 0 { + return payload, nil + } + return nil, ErrNoPayload +} + +func (pdfCarrier) Capacity(cover io.Reader) (int, error) { + data, err := io.ReadAll(cover) + if err != nil { + return 0, fmt.Errorf("crypha/pdf: read cover: %w", err) + } + if !isPDF(data) { + return 0, ErrUnsupportedFormat + } + return unboundedCapacity, nil +} + +func (pdfCarrier) Sniff(stego io.ReadSeeker) bool { + head := make([]byte, len(pdfSignature)) + if _, err := io.ReadFull(stego, head); err != nil { + return false + } + return bytes.Equal(head, pdfSignature) +} + +func hideAttachment(cover, payload []byte, out io.Writer) error { + conf := newConfig() + ctx, err := api.ReadValidateAndOptimize(bytes.NewReader(cover), conf) + if err != nil { + return fmt.Errorf("crypha/pdf: read cover: %w", err) + } + att := model.Attachment{ + Reader: bytes.NewReader(payload), + ID: attachmentID, + FileName: attachmentID, + Desc: attachmentDesc, + ModTime: &epoch, + } + if err := ctx.AddAttachment(att, false); err != nil { + return fmt.Errorf("crypha/pdf: attach payload: %w", err) + } + if err := api.Write(ctx, out, conf); err != nil { + return fmt.Errorf("crypha/pdf: write pdf: %w", err) + } + return nil +} + +func revealAttachment(stego []byte) ([]byte, bool) { + conf := newConfig() + attachments, err := api.ExtractAttachmentsRaw(bytes.NewReader(stego), "", nil, conf) + if err != nil { + return nil, false + } + for _, att := range attachments { + if att.ID != attachmentID && att.FileName != attachmentID { + continue + } + payload, rerr := io.ReadAll(att) + if rerr != nil { + return nil, false + } + return payload, true + } + return nil, false +} + +func hideMetadata(cover, payload []byte, out io.Writer) error { + encoded := base64.RawURLEncoding.EncodeToString(payload) + props := map[string]string{} + count := 0 + for offset := 0; offset < len(encoded); offset += metaChunkSize { + end := offset + metaChunkSize + if end > len(encoded) { + end = len(encoded) + } + props[metaKeyPrefix+strconv.Itoa(count)] = encoded[offset:end] + count++ + } + props[metaCountKey] = strconv.Itoa(count) + + if err := api.AddProperties(bytes.NewReader(cover), out, props, newConfig()); err != nil { + return fmt.Errorf("crypha/pdf: write metadata: %w", err) + } + return nil +} + +func revealMetadata(stego []byte) ([]byte, bool) { + props, err := api.Properties(bytes.NewReader(stego), newConfig()) + if err != nil { + return nil, false + } + countStr, ok := props[metaCountKey] + if !ok { + return nil, false + } + count, err := strconv.Atoi(countStr) + if err != nil || count <= 0 { + return nil, false + } + + var encoded bytes.Buffer + for i := 0; i < count; i++ { + chunk, ok := props[metaKeyPrefix+strconv.Itoa(i)] + if !ok { + return nil, false + } + encoded.WriteString(chunk) + } + + payload, err := base64.RawURLEncoding.DecodeString(encoded.String()) + if err != nil { + return nil, false + } + return payload, true +} + +func hideAppend(cover, payload []byte, out io.Writer) error { + if _, err := out.Write(cover); err != nil { + return err + } + if _, err := out.Write(appendMagic); err != nil { + return err + } + if _, err := out.Write(payload); err != nil { + return err + } + var lenField [lengthPrefixBytes]byte + binary.BigEndian.PutUint32(lenField[:], uint32(len(payload))) + _, err := out.Write(lenField[:]) + return err +} + +func revealAppend(stego []byte) ([]byte, bool) { + trailer := len(appendMagic) + lengthPrefixBytes + if len(stego) < trailer { + return nil, false + } + payloadLen := binary.BigEndian.Uint32(stego[len(stego)-lengthPrefixBytes:]) + if uint64(len(appendMagic))+uint64(payloadLen)+uint64(lengthPrefixBytes) > uint64(len(stego)) { + return nil, false + } + magicStart := len(stego) - lengthPrefixBytes - int(payloadLen) - len(appendMagic) + if !bytes.Equal(stego[magicStart:magicStart+len(appendMagic)], appendMagic) { + return nil, false + } + payloadStart := magicStart + len(appendMagic) + return stego[payloadStart : payloadStart+int(payloadLen)], true +} + +func newConfig() *model.Configuration { + conf := model.NewDefaultConfiguration() + conf.ValidationMode = model.ValidationRelaxed + return conf +} + +func isPDF(data []byte) bool { + return bytes.HasPrefix(data, pdfSignature) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf_test.go new file mode 100644 index 00000000..5230a946 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf_test.go @@ -0,0 +1,395 @@ +/* +©AngelaMos | 2026 +pdf_test.go + +Per-technique round-trip, auto-detect, capacity, ordering, and sniff tests for the PDF carrier +*/ + +package pdf + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +const pageJSON = `{"pages": {"1": {"content": {}}}}` + +var allTechniques = []Technique{TechniqueAttachment, TechniqueMetadata, TechniqueAppend} + +func minimalPDF(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + if err := api.Create(nil, strings.NewReader(pageJSON), &buf, newConfig()); err != nil { + t.Fatalf("create demo pdf: %v", err) + } + return buf.Bytes() +} + +func pseudoBytes(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 hideWith(t *testing.T, tech Technique, cover, payload []byte) []byte { + t.Helper() + var stego bytes.Buffer + if err := New(tech).Hide(bytes.NewReader(cover), payload, &stego); err != nil { + t.Fatalf("Hide(%s): %v", tech, err) + } + return stego.Bytes() +} + +func TestFixtureIsValidPDF(t *testing.T) { + cover := minimalPDF(t) + if !isPDF(cover) { + t.Fatal("fixture is not a PDF") + } +} + +func TestRoundTripTechniques(t *testing.T) { + cover := minimalPDF(t) + payloads := []struct { + name string + data []byte + }{ + {"short text", []byte("meet at the docks")}, + {"single byte", []byte{0x42}}, + {"binary blob", pseudoBytes(500, 7)}, + } + for _, tech := range allTechniques { + for _, pl := range payloads { + t.Run(string(tech)+"/"+pl.name, func(t *testing.T) { + stego := hideWith(t, tech, cover, pl.data) + got, err := New(tech).Reveal(bytes.NewReader(stego)) + if err != nil { + t.Fatalf("Reveal(%s): %v", tech, err) + } + if !bytes.Equal(got, pl.data) { + t.Fatalf("%s round-trip mismatch: got %x want %x", tech, got, pl.data) + } + }) + } + } +} + +func TestRevealAutoDetect(t *testing.T) { + cover := minimalPDF(t) + detector, ok := carrier.Get(Format) + if !ok { + t.Fatal("pdf carrier not registered") + } + for _, tech := range allTechniques { + t.Run(string(tech), func(t *testing.T) { + payload := []byte("auto-detect me: " + string(tech)) + stego := hideWith(t, tech, cover, payload) + got, err := detector.Reveal(bytes.NewReader(stego)) + if err != nil { + t.Fatalf("auto-detect Reveal for %s: %v", tech, err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("auto-detect mismatch for %s: got %q want %q", tech, got, payload) + } + }) + } +} + +func TestMetadataChunking(t *testing.T) { + cover := minimalPDF(t) + payload := pseudoBytes(metaChunkSize, 3) + + stego := hideWith(t, TechniqueMetadata, cover, payload) + got, err := New(TechniqueMetadata).Reveal(bytes.NewReader(stego)) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatal("multi-chunk metadata round-trip mismatch") + } +} + +func TestAppendStrippedByPdfcpuRewrite(t *testing.T) { + cover := minimalPDF(t) + appendStego := hideWith(t, TechniqueAppend, cover, []byte("fragile append payload")) + if _, ok := revealAppend(appendStego); !ok { + t.Fatal("append payload not present before rewrite") + } + + rewritten := hideWith(t, TechniqueAttachment, appendStego, []byte("new attachment")) + if _, ok := revealAppend(rewritten); ok { + t.Fatal("expected pdfcpu rewrite to strip the trailing append payload") + } + if _, ok := revealAttachment(rewritten); !ok { + t.Fatal("attachment payload should survive the rewrite") + } +} + +func TestCapacityUnbounded(t *testing.T) { + cover := minimalPDF(t) + got, err := New(TechniqueAttachment).Capacity(bytes.NewReader(cover)) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if got != unboundedCapacity { + t.Fatalf("Capacity: got %d want %d", got, unboundedCapacity) + } +} + +func TestSniff(t *testing.T) { + cover := minimalPDF(t) + cases := []struct { + name string + data []byte + want bool + }{ + {"pdf", cover, true}, + {"random", []byte("not a pdf at all, just text"), false}, + {"short", []byte("%PD"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := New(TechniqueAttachment).Sniff(bytes.NewReader(tc.data)); got != tc.want { + t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestRejectNonPDF(t *testing.T) { + garbage := []byte("this is plainly not a pdf document") + if err := New(TechniqueAttachment).Hide(bytes.NewReader(garbage), []byte("x"), &bytes.Buffer{}); err != ErrUnsupportedFormat { + t.Fatalf("Hide non-pdf: got %v want ErrUnsupportedFormat", err) + } + if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(garbage)); err != ErrUnsupportedFormat { + t.Fatalf("Reveal non-pdf: got %v want ErrUnsupportedFormat", err) + } + if _, err := New(TechniqueAttachment).Capacity(bytes.NewReader(garbage)); err != ErrUnsupportedFormat { + t.Fatalf("Capacity non-pdf: got %v want ErrUnsupportedFormat", err) + } +} + +func TestEmptyPayloadRejected(t *testing.T) { + cover := minimalPDF(t) + if err := New(TechniqueAttachment).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{}); err != ErrEmptyPayload { + t.Fatalf("expected ErrEmptyPayload, got %v", err) + } +} + +func TestRevealNoPayload(t *testing.T) { + cover := minimalPDF(t) + if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(cover)); err != ErrNoPayload { + t.Fatalf("expected ErrNoPayload on a clean PDF, got %v", err) + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("forced read failure") +} + +func TestReadErrorsPropagate(t *testing.T) { + if err := New(TechniqueAttachment).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("Hide: expected read error") + } + if _, err := New(TechniqueAttachment).Reveal(errReader{}); err == nil { + t.Fatal("Reveal: expected read error") + } + if _, err := New(TechniqueAttachment).Capacity(errReader{}); err == nil { + t.Fatal("Capacity: expected read error") + } +} + +func TestEncryptedEnvelopeThroughCarrier(t *testing.T) { + cover := minimalPDF(t) + secret := []byte("the account number is 4417 1234 5678 9012") + 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) + } + + for _, tech := range allTechniques { + t.Run(string(tech), func(t *testing.T) { + stego := hideWith(t, tech, cover, envelope) + recovered, err := New(tech).Reveal(bytes.NewReader(stego)) + 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 TestCorruptPDFRejectedByTechniques(t *testing.T) { + corrupt := []byte("%PDF-1.7\nnot a real pdf body, no xref, no objects\n%%EOF") + if err := New(TechniqueAttachment).Hide(bytes.NewReader(corrupt), []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("Hide attachment on corrupt PDF: expected error") + } + if err := New(TechniqueMetadata).Hide(bytes.NewReader(corrupt), []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("Hide metadata on corrupt PDF: expected error") + } + if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(corrupt)); err != ErrNoPayload { + t.Fatalf("Reveal corrupt PDF: got %v want ErrNoPayload", err) + } +} + +func TestForeignAttachmentIgnored(t *testing.T) { + cover := minimalPDF(t) + conf := newConfig() + ctx, err := api.ReadValidateAndOptimize(bytes.NewReader(cover), conf) + if err != nil { + t.Fatalf("read cover: %v", err) + } + other := model.Attachment{ + Reader: bytes.NewReader([]byte("someone else's file")), + ID: "other.txt", + FileName: "other.txt", + ModTime: &epoch, + } + if err := ctx.AddAttachment(other, false); err != nil { + t.Fatalf("add foreign attachment: %v", err) + } + var buf bytes.Buffer + if err := api.Write(ctx, &buf, conf); err != nil { + t.Fatalf("write: %v", err) + } + if _, ok := revealAttachment(buf.Bytes()); ok { + t.Fatal("revealAttachment must ignore a non-crypha attachment") + } +} + +type errWriter struct { + failAt int + count int +} + +func (w *errWriter) Write(p []byte) (int, error) { + w.count++ + if w.count > w.failAt { + return 0, errors.New("forced write failure") + } + return len(p), nil +} + +func TestAppendWriteErrorsPropagate(t *testing.T) { + cover := minimalPDF(t) + for failAt := 0; failAt < 4; failAt++ { + if err := hideAppend(cover, []byte("payload"), &errWriter{failAt: failAt}); err == nil { + t.Fatalf("hideAppend failAt=%d: expected write error", failAt) + } + } +} + +func TestAttachmentWriteErrorPropagates(t *testing.T) { + cover := minimalPDF(t) + if err := hideAttachment(cover, []byte("payload"), &errWriter{failAt: 0}); err == nil { + t.Fatal("hideAttachment: expected write error") + } +} + +func metaStego(t *testing.T, cover []byte, props map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + if err := api.AddProperties(bytes.NewReader(cover), &buf, props, newConfig()); err != nil { + t.Fatalf("add properties: %v", err) + } + return buf.Bytes() +} + +func TestMetadataTamperedRejected(t *testing.T) { + cover := minimalPDF(t) + cases := []struct { + name string + props map[string]string + }{ + {"non-numeric count", map[string]string{metaCountKey: "not-a-number"}}, + {"zero count", map[string]string{metaCountKey: "0"}}, + {"missing chunk", map[string]string{metaCountKey: "1"}}, + {"invalid base64", map[string]string{metaCountKey: "1", metaKeyPrefix + "0": "@@@not-base64@@@"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stego := metaStego(t, cover, tc.props) + if _, ok := revealMetadata(stego); ok { + t.Fatalf("revealMetadata must reject %s", tc.name) + } + }) + } +} + +func TestEmptyMetadataYieldsNoPayload(t *testing.T) { + cover := minimalPDF(t) + stego := metaStego(t, cover, map[string]string{metaCountKey: "0"}) + if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(stego)); err != ErrNoPayload { + t.Fatalf("count=0 metadata: got %v want ErrNoPayload", err) + } +} + +func TestEmptyMetadataDoesNotMaskAppend(t *testing.T) { + cover := minimalPDF(t) + secret := []byte("the real secret rides under a spurious empty-metadata key") + metaPart := metaStego(t, cover, map[string]string{metaCountKey: "0"}) + + var stego bytes.Buffer + if err := hideAppend(metaPart, secret, &stego); err != nil { + t.Fatalf("hideAppend: %v", err) + } + got, err := New(TechniqueAttachment).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if !bytes.Equal(got, secret) { + t.Fatalf("empty metadata masked the append payload: got %q", got) + } +} + +func TestRevealAppendEdgeCases(t *testing.T) { + if _, ok := revealAppend([]byte("tiny")); ok { + t.Fatal("revealAppend on too-short input must return false") + } + + cover := minimalPDF(t) + payload := []byte("magic mismatch probe") + stego := hideWith(t, TechniqueAppend, cover, payload) + magicPos := len(stego) - lengthPrefixBytes - len(payload) - len(appendMagic) + corrupt := append([]byte{}, stego...) + corrupt[magicPos] ^= 0xFF + if _, ok := revealAppend(corrupt); ok { + t.Fatal("revealAppend must reject a mismatched magic") + } +} + +func TestRegisteredInRegistry(t *testing.T) { + c, ok := carrier.Get(Format) + if !ok { + t.Fatal("pdf carrier did not self-register") + } + if c.Format() != Format { + t.Fatalf("registry returned wrong carrier: %s", c.Format()) + } +}