feat(crypha): add audio and pdf carriers (M4-M5)
M4 audio: 16-bit PCM WAV LSB carrier. Cover input is WAV or FLAC (FLAC decoded via the mewkiz decoder); output is always 16-bit PCM WAV. Native FLAC output is deferred. uint32 length-prefix framing with overflow-safe bounds; in-memory WriteSeeker so the WAV encoder can seek back and patch chunk sizes through the io.Writer interface. M5 pdf: three techniques behind one carrier. Attachment (default, pdfcpu embedded-file, lossless), metadata (base64url payload chunked across custom Info-dict keys), and append-after-EOF (raw trailing bytes, O(1) end-seek). Reveal auto-tries all three; technique selection is exposed via New(Technique). The package disables the pdfcpu config directory for hermeticity. Both carriers self-register and are blank-imported in carrier/all. No toolchain bump (go directive stays 1.25.0).
This commit is contained in:
parent
5763638006
commit
2c0d3ae409
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue