From b89b57347dba52b02ad3ab0caebeecfcfd76c47d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 14 Jul 2026 17:03:34 -0400 Subject: [PATCH 01/13] feat(crypha): scaffold steganography multi-tool with payload engine (M0-M1) New beginner-tier Go project (crypha): a multi-format steganography tool that hides an encrypted payload across five carriers (image, audio, QR, zero-width text, PDF) from a cobra CLI and a planned bubbletea TUI. M0 scaffold: cobra skeleton, config constants, justfile, golangci config. M1 engine spine: internal/bitio (MSB-first bit reader/writer), internal/payload (versioned AEAD envelope: Argon2id KDF, ChaCha20-Poly1305 default / AES-256-GCM alternate, flate compression, CRC32 integrity, header-as-AAD, hostile-parameter hardening), internal/carrier (Carrier interface + self-registering registry). Verified: go build, go vet, gofmt, golangci-lint (0 issues), go test -race all green; coverage bitio 100% / carrier 91% / payload 88%. --- .../steganography-multi-tool/.gitignore | 17 ++ .../steganography-multi-tool/.golangci.yml | 32 +++ .../cmd/crypha/main.go | 14 ++ .../beginner/steganography-multi-tool/go.mod | 14 ++ .../beginner/steganography-multi-tool/go.sum | 14 ++ .../internal/bitio/bitio.go | 69 ++++++ .../internal/bitio/bitio_test.go | 82 +++++++ .../internal/carrier/carrier.go | 67 ++++++ .../internal/carrier/carrier_test.go | 73 +++++++ .../internal/cli/root.go | 34 +++ .../internal/config/config.go | 17 ++ .../internal/payload/compress.go | 35 +++ .../internal/payload/crypto.go | 75 +++++++ .../internal/payload/envelope.go | 202 ++++++++++++++++++ .../internal/payload/envelope_test.go | 158 ++++++++++++++ .../internal/payload/payload.go | 69 ++++++ .../steganography-multi-tool/justfile | 89 ++++++++ 17 files changed, 1061 insertions(+) create mode 100644 PROJECTS/beginner/steganography-multi-tool/.gitignore create mode 100644 PROJECTS/beginner/steganography-multi-tool/.golangci.yml create mode 100644 PROJECTS/beginner/steganography-multi-tool/cmd/crypha/main.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/go.mod create mode 100644 PROJECTS/beginner/steganography-multi-tool/go.sum create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/config/config.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/compress.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/crypto.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/justfile diff --git a/PROJECTS/beginner/steganography-multi-tool/.gitignore b/PROJECTS/beginner/steganography-multi-tool/.gitignore new file mode 100644 index 00000000..2ab2b026 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/.gitignore @@ -0,0 +1,17 @@ +# build output +/dist/ +/crypha + +# test / coverage +coverage.out +*.test +*.prof + +# local dev docs (research / plans / context stay local) +/docs/ + +# editor / os +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/PROJECTS/beginner/steganography-multi-tool/.golangci.yml b/PROJECTS/beginner/steganography-multi-tool/.golangci.yml new file mode 100644 index 00000000..0d61cf12 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/.golangci.yml @@ -0,0 +1,32 @@ +# ©AngelaMos | 2026 +# .golangci.yml + +version: "2" + +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - unconvert + - bodyclose + - gosec + settings: + gosec: + excludes: + - G115 + +formatters: + enable: + - gofmt + - goimports + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/PROJECTS/beginner/steganography-multi-tool/cmd/crypha/main.go b/PROJECTS/beginner/steganography-multi-tool/cmd/crypha/main.go new file mode 100644 index 00000000..c639ef1c --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/cmd/crypha/main.go @@ -0,0 +1,14 @@ +/* +©AngelaMos | 2026 +main.go + +Entry point for the crypha steganography multi-tool +*/ + +package main + +import "github.com/CarterPerez-dev/crypha/internal/cli" + +func main() { + cli.Execute() +} diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod new file mode 100644 index 00000000..fa9968d3 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -0,0 +1,14 @@ +module github.com/CarterPerez-dev/crypha + +go 1.25.0 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.52.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum new file mode 100644 index 00000000..666fe98d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio.go b/PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio.go new file mode 100644 index 00000000..398c1b8e --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio.go @@ -0,0 +1,69 @@ +/* +©AngelaMos | 2026 +bitio.go + +MSB-first bit reader and writer for carriers that embed data bit by bit +*/ + +package bitio + +import "io" + +type Reader struct { + in []byte + pos int +} + +func NewReader(b []byte) *Reader { + return &Reader{in: b} +} + +func (r *Reader) ReadBit() (byte, error) { + if r.pos >= len(r.in)*8 { + return 0, io.EOF + } + byteIdx := r.pos / 8 + bitIdx := 7 - (r.pos % 8) + bit := (r.in[byteIdx] >> bitIdx) & 1 + r.pos++ + return bit, nil +} + +func (r *Reader) TotalBits() int { + return len(r.in) * 8 +} + +func (r *Reader) Remaining() int { + return len(r.in)*8 - r.pos +} + +type Writer struct { + out []byte + cur byte + fill int +} + +func NewWriter() *Writer { + return &Writer{} +} + +func (w *Writer) WriteBit(bit byte) { + w.cur = (w.cur << 1) | (bit & 1) + w.fill++ + if w.fill == 8 { + w.out = append(w.out, w.cur) + w.cur = 0 + w.fill = 0 + } +} + +func (w *Writer) Bytes() []byte { + if w.fill == 0 { + return w.out + } + return append(w.out, w.cur<<(8-w.fill)) +} + +func (w *Writer) BitsWritten() int { + return len(w.out)*8 + w.fill +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio_test.go new file mode 100644 index 00000000..64999062 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/bitio/bitio_test.go @@ -0,0 +1,82 @@ +/* +©AngelaMos | 2026 +bitio_test.go + +Round-trip and boundary tests for the MSB-first bit reader and writer +*/ + +package bitio + +import ( + "bytes" + "io" + "testing" +) + +func TestRoundTripByteAligned(t *testing.T) { + cases := [][]byte{ + {}, + {0x00}, + {0xFF}, + {0xA5, 0x5A, 0x00, 0xFF, 0x0F, 0xF0}, + []byte("crypha carries the secret"), + } + for _, in := range cases { + r := NewReader(in) + w := NewWriter() + for { + bit, err := r.ReadBit() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("ReadBit: %v", err) + } + w.WriteBit(bit) + } + if got := w.Bytes(); !bytes.Equal(got, in) { + t.Errorf("round-trip mismatch: got %x want %x", got, in) + } + } +} + +func TestMSBFirstOrder(t *testing.T) { + r := NewReader([]byte{0b10000001}) + want := []byte{1, 0, 0, 0, 0, 0, 0, 1} + for i, wbit := range want { + got, err := r.ReadBit() + if err != nil { + t.Fatalf("bit %d: %v", i, err) + } + if got != wbit { + t.Errorf("bit %d: got %d want %d", i, got, wbit) + } + } +} + +func TestReaderCounters(t *testing.T) { + r := NewReader([]byte{0x00, 0x00}) + if r.TotalBits() != 16 { + t.Fatalf("TotalBits: got %d want 16", r.TotalBits()) + } + if _, err := r.ReadBit(); err != nil { + t.Fatal(err) + } + if r.Remaining() != 15 { + t.Errorf("Remaining: got %d want 15", r.Remaining()) + } +} + +func TestWriterPadsPartialByte(t *testing.T) { + w := NewWriter() + w.WriteBit(1) + w.WriteBit(1) + w.WriteBit(1) + if w.BitsWritten() != 3 { + t.Fatalf("BitsWritten: got %d want 3", w.BitsWritten()) + } + got := w.Bytes() + if len(got) != 1 || got[0] != 0b11100000 { + t.Errorf("partial byte: got %08b want 11100000", got[0]) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier.go new file mode 100644 index 00000000..a1b829a2 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier.go @@ -0,0 +1,67 @@ +/* +©AngelaMos | 2026 +carrier.go + +The Carrier interface and self-registering registry that every format plugs into +*/ + +package carrier + +import ( + "io" + "sort" +) + +type Carrier interface { + Format() string + Hide(cover io.Reader, payload []byte, out io.Writer) error + Reveal(stego io.Reader) ([]byte, error) + Capacity(cover io.Reader) (int, error) + Sniff(stego io.ReadSeeker) bool +} + +var registry = map[string]Carrier{} + +func Register(c Carrier) { + registry[c.Format()] = c +} + +func Get(name string) (Carrier, bool) { + c, ok := registry[name] + return c, ok +} + +func All() []Carrier { + out := make([]Carrier, 0, len(registry)) + for _, c := range registry { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Format() < out[j].Format() + }) + return out +} + +func Formats() []string { + out := make([]string, 0, len(registry)) + for name := range registry { + out = append(out, name) + } + sort.Strings(out) + return out +} + +func Detect(stego io.ReadSeeker) (Carrier, bool) { + for _, c := range All() { + if _, err := stego.Seek(0, io.SeekStart); err != nil { + return nil, false + } + if c.Sniff(stego) { + if _, err := stego.Seek(0, io.SeekStart); err != nil { + return nil, false + } + return c, true + } + } + return nil, false +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier_test.go new file mode 100644 index 00000000..a435e00e --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/carrier_test.go @@ -0,0 +1,73 @@ +/* +©AngelaMos | 2026 +carrier_test.go + +Registry behaviour tests using fake carriers +*/ + +package carrier + +import ( + "bytes" + "io" + "testing" +) + +type fakeCarrier struct { + format string + magic byte +} + +func (f fakeCarrier) Format() string { return f.format } + +func (f fakeCarrier) Hide(_ io.Reader, _ []byte, _ io.Writer) error { return nil } + +func (f fakeCarrier) Reveal(_ io.Reader) ([]byte, error) { return nil, nil } + +func (f fakeCarrier) Capacity(_ io.Reader) (int, error) { return 0, nil } + +func (f fakeCarrier) Sniff(stego io.ReadSeeker) bool { + head := make([]byte, 1) + if _, err := io.ReadFull(stego, head); err != nil { + return false + } + return head[0] == f.magic +} + +func TestRegisterGetFormats(t *testing.T) { + registry = map[string]Carrier{} + Register(fakeCarrier{format: "zeta", magic: 0x01}) + Register(fakeCarrier{format: "alpha", magic: 0x02}) + + if _, ok := Get("alpha"); !ok { + t.Fatal("expected alpha to be registered") + } + if _, ok := Get("missing"); ok { + t.Fatal("did not expect missing to resolve") + } + + got := Formats() + if len(got) != 2 || got[0] != "alpha" || got[1] != "zeta" { + t.Errorf("Formats not sorted: %v", got) + } + if all := All(); len(all) != 2 || all[0].Format() != "alpha" { + t.Errorf("All not sorted: %v", all) + } +} + +func TestDetect(t *testing.T) { + registry = map[string]Carrier{} + Register(fakeCarrier{format: "alpha", magic: 0x02}) + Register(fakeCarrier{format: "beta", magic: 0x03}) + + stego := bytes.NewReader([]byte{0x03, 0xFF, 0xEE}) + c, ok := Detect(stego) + if !ok || c.Format() != "beta" { + t.Fatalf("Detect: got %v ok=%v want beta", c, ok) + } + + none := bytes.NewReader([]byte{0x99}) + if _, ok := Detect(none); ok { + t.Error("expected no match for unknown magic") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go new file mode 100644 index 00000000..07885afb --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go @@ -0,0 +1,34 @@ +/* +©AngelaMos | 2026 +root.go + +Cobra root command and CLI entry wiring for crypha +*/ + +package cli + +import ( + "fmt" + "os" + + "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: config.BinaryName, + Short: config.ShortDescription, + Long: config.LongDescription, + Version: config.Version, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.SetVersionTemplate(config.BinaryName + " {{.Version}}\n") +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/config/config.go b/PROJECTS/beginner/steganography-multi-tool/internal/config/config.go new file mode 100644 index 00000000..ba5c5af3 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/config/config.go @@ -0,0 +1,17 @@ +/* +©AngelaMos | 2026 +config.go + +Central constants for crypha so no magic numbers or strings live elsewhere +*/ + +package config + +const ( + BinaryName = "crypha" + Version = "0.1.0" + ShortDescription = "Multi-format steganography for images, audio, QR, text, and PDFs" + LongDescription = "crypha hides an encrypted payload inside five carrier types " + + "(image, audio, QR, zero-width text, PDF) and extracts it back, " + + "from a scriptable CLI or an interactive TUI." +) diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/compress.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/compress.go new file mode 100644 index 00000000..eab0ac0f --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/compress.go @@ -0,0 +1,35 @@ +/* +©AngelaMos | 2026 +compress.go + +Optional flate compression applied before encryption in the payload envelope +*/ + +package payload + +import ( + "bytes" + "compress/flate" + "io" +) + +func compress(data []byte) ([]byte, error) { + var buf bytes.Buffer + w, err := flate.NewWriter(&buf, flate.BestCompression) + if err != nil { + return nil, err + } + if _, err := w.Write(data); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func decompress(data []byte) ([]byte, error) { + r := flate.NewReader(bytes.NewReader(data)) + defer func() { _ = r.Close() }() + return io.ReadAll(r) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/crypto.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/crypto.go new file mode 100644 index 00000000..8c55e248 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/crypto.go @@ -0,0 +1,75 @@ +/* +©AngelaMos | 2026 +crypto.go + +Argon2id key derivation and AEAD selection for the payload envelope +*/ + +package payload + +import ( + "crypto/aes" + "crypto/cipher" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/chacha20poly1305" +) + +type kdfParams struct { + time uint32 + memory uint32 + threads uint8 +} + +func paramsForStrength(s Strength) kdfParams { + if s == StrengthHigh { + return kdfParams{time: argonHighTime, memory: argonHighMemory, threads: argonThreads} + } + return kdfParams{time: argonDefaultTime, memory: argonDefaultMemory, threads: argonThreads} +} + +func (p kdfParams) valid() bool { + if p.threads == 0 || p.time == 0 { + return false + } + if p.memory > argonMaxMemory { + return false + } + return p.memory >= 8*uint32(p.threads) +} + +func deriveKey(passphrase, salt []byte, p kdfParams) []byte { + return argon2.IDKey(passphrase, salt, p.time, p.memory, p.threads, keyLen) +} + +func newAEAD(c Cipher, key []byte) (cipher.AEAD, byte, error) { + switch c { + case CipherAES256GCM: + aead, err := gcm(key) + return aead, cipherIDAES256GCM, err + case CipherChaCha20, "": + aead, err := chacha20poly1305.New(key) + return aead, cipherIDChaCha20, err + default: + return nil, 0, ErrUnknownCipher + } +} + +func aeadByID(id byte, key []byte) (cipher.AEAD, error) { + switch id { + case cipherIDAES256GCM: + return gcm(key) + case cipherIDChaCha20: + return chacha20poly1305.New(key) + default: + return nil, ErrUnknownCipher + } +} + +func gcm(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope.go new file mode 100644 index 00000000..77bc466d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope.go @@ -0,0 +1,202 @@ +/* +©AngelaMos | 2026 +envelope.go + +Pack and unpack the crypha payload envelope + +Layout: magic(4) ver(1) flags(1) [cipher(1) params(9) salt(16) nonce(12) if encrypted] + len(4) body(N) crc32(4). The header up to nonce is the AEAD additional data. +*/ + +package payload + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "hash/crc32" +) + +func Pack(data []byte, opts Options) ([]byte, error) { + if len(data) == 0 { + return nil, ErrEmptyPayload + } + + var flags byte + body := data + + if opts.Compress { + c, err := compress(body) + if err != nil { + return nil, err + } + body = c + flags |= flagCompressed + } + + header := new(bytes.Buffer) + header.Write(magic[:]) + header.WriteByte(currentVersion) + + if len(opts.Passphrase) > 0 { + flags |= flagEncrypted + header.WriteByte(flags) + + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + params := paramsForStrength(opts.Strength) + key := deriveKey(opts.Passphrase, salt, params) + aead, cipherID, err := newAEAD(opts.Cipher, key) + if err != nil { + return nil, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + header.WriteByte(cipherID) + var pbuf [paramsLen]byte + binary.BigEndian.PutUint32(pbuf[0:4], params.time) + binary.BigEndian.PutUint32(pbuf[4:8], params.memory) + pbuf[8] = params.threads + header.Write(pbuf[:]) + header.Write(salt) + header.Write(nonce) + + body = aead.Seal(nil, nonce, body, header.Bytes()) + } else { + header.WriteByte(flags) + } + + out := new(bytes.Buffer) + out.Write(header.Bytes()) + var meta [lenField]byte + binary.BigEndian.PutUint32(meta[:], uint32(len(body))) + out.Write(meta[:]) + out.Write(body) + binary.BigEndian.PutUint32(meta[:], crc32.ChecksumIEEE(body)) + out.Write(meta[:]) + + return out.Bytes(), nil +} + +type parsed struct { + flags byte + aad []byte + cipherID byte + params kdfParams + salt []byte + nonce []byte + body []byte +} + +func parse(env []byte) (*parsed, error) { + off := 0 + remaining := func(n int) bool { return off+n <= len(env) } + + if !remaining(len(magic)) || !bytes.Equal(env[0:len(magic)], magic[:]) { + return nil, ErrBadMagic + } + off = len(magic) + + if !remaining(1) { + return nil, ErrTruncated + } + if env[off] != currentVersion { + return nil, ErrUnsupportedVersion + } + off++ + + if !remaining(1) { + return nil, ErrTruncated + } + p := &parsed{flags: env[off]} + off++ + + if p.flags&flagEncrypted != 0 { + if !remaining(1 + paramsLen + saltLen + nonceLen) { + return nil, ErrTruncated + } + p.cipherID = env[off] + off++ + p.params = kdfParams{ + time: binary.BigEndian.Uint32(env[off : off+4]), + memory: binary.BigEndian.Uint32(env[off+4 : off+8]), + threads: env[off+8], + } + off += paramsLen + if !p.params.valid() { + return nil, ErrBadParams + } + p.salt = env[off : off+saltLen] + off += saltLen + p.nonce = env[off : off+nonceLen] + off += nonceLen + p.aad = env[0:off] + } + + if !remaining(lenField) { + return nil, ErrTruncated + } + bodyLen := int(binary.BigEndian.Uint32(env[off : off+lenField])) + off += lenField + if bodyLen < 0 || !remaining(bodyLen) { + return nil, ErrTruncated + } + p.body = env[off : off+bodyLen] + off += bodyLen + + if !remaining(crcField) { + return nil, ErrTruncated + } + if crc32.ChecksumIEEE(p.body) != binary.BigEndian.Uint32(env[off:off+crcField]) { + return nil, ErrChecksumMismatch + } + + return p, nil +} + +func Unpack(env, passphrase []byte) ([]byte, error) { + p, err := parse(env) + if err != nil { + return nil, err + } + + body := p.body + if p.flags&flagEncrypted != 0 { + if len(passphrase) == 0 { + return nil, ErrPassphraseRequired + } + key := deriveKey(passphrase, p.salt, p.params) + aead, err := aeadByID(p.cipherID, key) + if err != nil { + return nil, err + } + plain, err := aead.Open(nil, p.nonce, body, p.aad) + if err != nil { + return nil, ErrDecrypt + } + body = plain + } + + if p.flags&flagCompressed != 0 { + return decompress(body) + } + return body, nil +} + +func Validate(env []byte) error { + _, err := parse(env) + return err +} + +func IsEncrypted(env []byte) (bool, error) { + p, err := parse(env) + if err != nil { + return false, err + } + return p.flags&flagEncrypted != 0, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go new file mode 100644 index 00000000..7b94845e --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go @@ -0,0 +1,158 @@ +/* +©AngelaMos | 2026 +envelope_test.go + +Round-trip, integrity, and hardening tests for the payload envelope +*/ + +package payload + +import ( + "bytes" + "errors" + "testing" +) + +var secret = []byte("the eagle lands at midnight -- coordinates 41.40N 2.17E") + +func TestRoundTrip(t *testing.T) { + pass := []byte("correct horse battery staple") + cases := []struct { + name string + opts Options + }{ + {"plain", Options{}}, + {"compressed", Options{Compress: true}}, + {"encrypted-chacha", Options{Passphrase: pass}}, + {"encrypted-aes", Options{Passphrase: pass, Cipher: CipherAES256GCM}}, + {"encrypted-compressed", Options{Passphrase: pass, Compress: true}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env, err := Pack(secret, tc.opts) + if err != nil { + t.Fatalf("Pack: %v", err) + } + got, err := Unpack(env, tc.opts.Passphrase) + if err != nil { + t.Fatalf("Unpack: %v", err) + } + if !bytes.Equal(got, secret) { + t.Errorf("round-trip mismatch: got %q", got) + } + }) + } +} + +func TestWrongPassphraseFails(t *testing.T) { + env, err := Pack(secret, Options{Passphrase: []byte("right")}) + if err != nil { + t.Fatal(err) + } + if _, err := Unpack(env, []byte("wrong")); !errors.Is(err, ErrDecrypt) { + t.Errorf("got %v want ErrDecrypt", err) + } +} + +func TestMissingPassphraseFails(t *testing.T) { + env, err := Pack(secret, Options{Passphrase: []byte("right")}) + if err != nil { + t.Fatal(err) + } + if _, err := Unpack(env, nil); !errors.Is(err, ErrPassphraseRequired) { + t.Errorf("got %v want ErrPassphraseRequired", err) + } +} + +func TestTamperDetected(t *testing.T) { + env, err := Pack(secret, Options{Passphrase: []byte("right")}) + if err != nil { + t.Fatal(err) + } + env[len(env)-6] ^= 0xFF // flip a byte inside the ciphertext body + if _, err := Unpack(env, []byte("right")); err == nil { + t.Error("expected an error on tampered envelope") + } +} + +func TestChecksumMismatch(t *testing.T) { + env, err := Pack(secret, Options{}) + if err != nil { + t.Fatal(err) + } + env[len(env)-5] ^= 0xFF // flip a body byte on the plaintext path + if _, err := Unpack(env, nil); !errors.Is(err, ErrChecksumMismatch) { + t.Errorf("got %v want ErrChecksumMismatch", err) + } +} + +func TestBadMagic(t *testing.T) { + env, _ := Pack(secret, Options{}) + env[0] ^= 0xFF + if _, err := Unpack(env, nil); !errors.Is(err, ErrBadMagic) { + t.Errorf("got %v want ErrBadMagic", err) + } +} + +func TestVersionReject(t *testing.T) { + env, _ := Pack(secret, Options{}) + env[len(magic)] = 0x7F + if _, err := Unpack(env, nil); !errors.Is(err, ErrUnsupportedVersion) { + t.Errorf("got %v want ErrUnsupportedVersion", err) + } +} + +func TestTruncated(t *testing.T) { + env, _ := Pack(secret, Options{}) + if _, err := Unpack(env[:5], nil); !errors.Is(err, ErrTruncated) { + t.Errorf("got %v want ErrTruncated", err) + } +} + +func TestEmptyPayload(t *testing.T) { + if _, err := Pack(nil, Options{}); !errors.Is(err, ErrEmptyPayload) { + t.Errorf("got %v want ErrEmptyPayload", err) + } +} + +func TestHostileParamsRejected(t *testing.T) { + env, err := Pack(secret, Options{Passphrase: []byte("right")}) + if err != nil { + t.Fatal(err) + } + // zero out the Argon2 time field (offset magic+ver+flags+cipherID) + timeOff := len(magic) + 1 + 1 + 1 + for i := 0; i < 4; i++ { + env[timeOff+i] = 0 + } + if _, err := Unpack(env, []byte("right")); !errors.Is(err, ErrBadParams) { + t.Errorf("got %v want ErrBadParams", err) + } +} + +func TestValidateAndIsEncrypted(t *testing.T) { + plain, _ := Pack(secret, Options{}) + enc, _ := Pack(secret, Options{Passphrase: []byte("k")}) + + if err := Validate(plain); err != nil { + t.Errorf("Validate(plain): %v", err) + } + if err := Validate([]byte("garbage")); err == nil { + t.Error("Validate(garbage) should fail") + } + if e, _ := IsEncrypted(plain); e { + t.Error("plain reported encrypted") + } + if e, _ := IsEncrypted(enc); !e { + t.Error("encrypted reported plain") + } +} + +func TestStrengthParams(t *testing.T) { + if p := paramsForStrength(StrengthHigh); p.memory != argonHighMemory || p.time != argonHighTime { + t.Errorf("high params wrong: %+v", p) + } + if p := paramsForStrength(StrengthDefault); p.memory != argonDefaultMemory { + t.Errorf("default params wrong: %+v", p) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go new file mode 100644 index 00000000..4dc532e6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go @@ -0,0 +1,69 @@ +/* +©AngelaMos | 2026 +payload.go + +Envelope types, constants, and errors shared across the crypha payload codec +*/ + +package payload + +import "errors" + +type Cipher string + +const ( + CipherChaCha20 Cipher = "chacha20" + CipherAES256GCM Cipher = "aes256gcm" +) + +type Strength string + +const ( + StrengthDefault Strength = "default" + StrengthHigh Strength = "high" +) + +type Options struct { + Passphrase []byte + Compress bool + Cipher Cipher + Strength Strength +} + +const ( + currentVersion byte = 0x01 + + flagEncrypted byte = 1 << 0 + flagCompressed byte = 1 << 1 + + cipherIDChaCha20 byte = 0x00 + cipherIDAES256GCM byte = 0x01 + + saltLen = 16 + nonceLen = 12 + keyLen = 32 + lenField = 4 + crcField = 4 + paramsLen = 9 + + argonDefaultTime uint32 = 3 + argonDefaultMemory uint32 = 64 * 1024 + argonHighTime uint32 = 1 + argonHighMemory uint32 = 2048 * 1024 + argonMaxMemory uint32 = argonHighMemory + argonThreads uint8 = 4 +) + +var magic = [4]byte{0xC7, 0x1A, 0x9E, 0x5B} + +var ( + ErrEmptyPayload = errors.New("crypha: empty payload") + ErrBadMagic = errors.New("crypha: not a crypha payload (bad magic)") + ErrUnsupportedVersion = errors.New("crypha: unsupported envelope version") + ErrTruncated = errors.New("crypha: truncated envelope") + ErrBadParams = errors.New("crypha: invalid key-derivation parameters") + ErrChecksumMismatch = errors.New("crypha: checksum mismatch") + ErrPassphraseRequired = errors.New("crypha: payload is encrypted, passphrase required") + ErrDecrypt = errors.New("crypha: decryption failed (wrong passphrase or tampered data)") + ErrUnknownCipher = errors.New("crypha: unknown cipher") +) diff --git a/PROJECTS/beginner/steganography-multi-tool/justfile b/PROJECTS/beginner/steganography-multi-tool/justfile new file mode 100644 index 00000000..8cc8a2c1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/justfile @@ -0,0 +1,89 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# justfile - crypha steganography multi-tool +# ============================================================================= + +set shell := ["bash", "-uc"] +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +binary := "crypha" +version := `git describe --tags --always 2>/dev/null || echo "dev"` + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Formatting and Linting +# ============================================================================= + +[group('lint')] +fmt: + gofmt -w -s cmd/ internal/ + +[group('lint')] +vet: + go vet ./... + +[group('lint')] +lint: + GOTOOLCHAIN=go1.25.7 golangci-lint run ./... + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + go test ./... {{ARGS}} + +[group('test')] +test-race: + go test -race ./... + +[group('test')] +cov: + go test -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + +[group('test')] +cov-html: cov + go tool cover -html=coverage.out + +# ============================================================================= +# Build and Run +# ============================================================================= + +[group('build')] +build: + go build -o dist/{{binary}} ./cmd/{{binary}} + +[group('build')] +run *ARGS: + go run ./cmd/{{binary}} {{ARGS}} + +# ============================================================================= +# CI / Housekeeping +# ============================================================================= + +[group('ci')] +ci: fmt vet test build + +[group('ci')] +tidy: + go mod tidy + +[group('ci')] +info: + @echo "Binary: {{binary}}" + @echo "Version: {{version}}" + @echo "Go: $(go version)" + +[group('ci')] +clean: + -rm -rf dist + -rm -f coverage.out + @echo "Cleaned" From 57636380065012ab772973dffc7232c0f7f47e37 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 15 Jul 2026 00:27:46 -0400 Subject: [PATCH 02/13] feat(crypha): add image and zero-width text carriers (M2-M3) M2 image carrier: LSB embedding in PNG and 24-bit BMP covers via the mandatory NRGBA conversion (avoids the premultiply LSB-corruption trap on both encode paths), RGB-only with alpha untouched, uint32 length prefix, paletted/16-bit/JPEG rejection, Capacity and Sniff, self-registering. M3 text carrier: encrypted payload hidden as zero-width Unicode (U+200B/U+2060) appended after cover text, magic + length framing that survives incidental zero-width in the cover and NFC/NFD/NFKC/NFKD normalization. Frame extraction consumes exactly to the end of the carrier-bit stream so nested stego reveals the last-hidden layer. New internal/carrier/all sentinel blank-imports carriers into the registry. Adds golang.org/x/image/bmp and (test-only) golang.org/x/text. --- .../beginner/steganography-multi-tool/go.mod | 2 + .../beginner/steganography-multi-tool/go.sum | 4 + .../internal/carrier/all/all.go | 13 + .../internal/carrier/image/image.go | 218 ++++++++++ .../internal/carrier/image/image_test.go | 378 ++++++++++++++++++ .../internal/carrier/text/text.go | 190 +++++++++ .../internal/carrier/text/text_test.go | 313 +++++++++++++++ 7 files changed, 1118 insertions(+) create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index fa9968d3..fee62039 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -5,6 +5,8 @@ go 1.25.0 require ( github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.52.0 + golang.org/x/image v0.44.0 + golang.org/x/text v0.40.0 ) require ( diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index 666fe98d..989d7242 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -9,6 +9,10 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go new file mode 100644 index 00000000..f4544cf3 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go @@ -0,0 +1,13 @@ +/* +©AngelaMos | 2026 +all.go + +Side-effect imports that register every carrier implementation into the registry +*/ + +package all + +import ( + _ "github.com/CarterPerez-dev/crypha/internal/carrier/image" + _ "github.com/CarterPerez-dev/crypha/internal/carrier/text" +) diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go new file mode 100644 index 00000000..4f87e9b9 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image.go @@ -0,0 +1,218 @@ +/* +©AngelaMos | 2026 +image.go + +LSB image carrier for PNG and 24-bit BMP covers via the mandatory NRGBA path +*/ + +package image + +import ( + "encoding/binary" + "errors" + "fmt" + stdimage "image" + "image/draw" + "image/png" + "io" + + "github.com/CarterPerez-dev/crypha/internal/bitio" + "github.com/CarterPerez-dev/crypha/internal/carrier" + xbmp "golang.org/x/image/bmp" +) + +const ( + Format = "image" + + formatPNG = "png" + formatBMP = "bmp" + + bytesPerPixel = 4 + channelsPerPixel = 3 + bitsPerByte = 8 + lengthPrefixBytes = 4 + lengthPrefixBits = lengthPrefixBytes * bitsPerByte +) + +var ( + pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + bmpSignature = []byte{'B', 'M'} +) + +var ( + ErrEmptyPayload = errors.New("crypha/image: empty payload") + ErrUnsupportedFormat = errors.New("crypha/image: cover must be a PNG or 24-bit BMP") + ErrPaletted = errors.New("crypha/image: paletted images are not supported, provide a truecolor PNG or 24-bit BMP") + Err16Bit = errors.New("crypha/image: 16-bit images are not supported, provide an 8-bit truecolor image") + ErrPayloadTooLarge = errors.New("crypha/image: payload exceeds carrier capacity") + ErrTooSmall = errors.New("crypha/image: image is too small to contain a payload") + ErrNoPayload = errors.New("crypha/image: no crypha payload found") +) + +type imageCarrier struct{} + +func init() { + carrier.Register(imageCarrier{}) +} + +func (imageCarrier) Format() string { + return Format +} + +func (imageCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error { + if len(payload) == 0 { + return ErrEmptyPayload + } + + src, format, err := stdimage.Decode(cover) + if err != nil { + return fmt.Errorf("crypha/image: decode cover: %w", err) + } + if format != formatPNG && format != formatBMP { + return ErrUnsupportedFormat + } + if err := rejectLossy(src); err != nil { + return err + } + + dst := toNRGBA(src) + slots := channelSlots(dst) + framed := frame(payload) + needBits := len(framed) * bitsPerByte + if needBits > slots { + return fmt.Errorf("%w: need %d bytes, capacity is %d", ErrPayloadTooLarge, len(payload), capacityFromSlots(slots)) + } + + reader := bitio.NewReader(framed) + for slot := 0; slot < needBits; slot++ { + bit, rerr := reader.ReadBit() + if rerr != nil { + return rerr + } + off := pixOffset(slot) + dst.Pix[off] = (dst.Pix[off] &^ 1) | bit + } + + switch format { + case formatPNG: + return png.Encode(out, dst) + default: + return xbmp.Encode(out, dst) + } +} + +func (imageCarrier) Reveal(stego io.Reader) ([]byte, error) { + src, format, err := stdimage.Decode(stego) + if err != nil { + return nil, fmt.Errorf("crypha/image: decode stego: %w", err) + } + if format != formatPNG && format != formatBMP { + return nil, ErrUnsupportedFormat + } + if err := rejectLossy(src); err != nil { + return nil, err + } + + img := toNRGBA(src) + slots := channelSlots(img) + if slots < lengthPrefixBits { + return nil, ErrTooSmall + } + + length := binary.BigEndian.Uint32(readBits(img, 0, lengthPrefixBits)) + maxPayload := capacityFromSlots(slots) + if length == 0 || uint64(length) > uint64(maxPayload) { + return nil, ErrNoPayload + } + + payloadBits := int(length) * bitsPerByte + return readBits(img, lengthPrefixBits, payloadBits), nil +} + +func (imageCarrier) Capacity(cover io.Reader) (int, error) { + cfg, format, err := stdimage.DecodeConfig(cover) + if err != nil { + return 0, fmt.Errorf("crypha/image: decode cover config: %w", err) + } + if format != formatPNG && format != formatBMP { + return 0, ErrUnsupportedFormat + } + slots := cfg.Width * cfg.Height * channelsPerPixel + return capacityFromSlots(slots), nil +} + +func (imageCarrier) Sniff(stego io.ReadSeeker) bool { + head := make([]byte, len(pngSignature)) + if _, err := io.ReadFull(stego, head); err != nil { + return false + } + if bytesHavePrefix(head, pngSignature) { + return true + } + return bytesHavePrefix(head, bmpSignature) +} + +func rejectLossy(src stdimage.Image) error { + switch src.(type) { + case *stdimage.Paletted: + return ErrPaletted + case *stdimage.RGBA64, *stdimage.NRGBA64, *stdimage.Gray16, *stdimage.CMYK: + return Err16Bit + default: + return nil + } +} + +func toNRGBA(src stdimage.Image) *stdimage.NRGBA { + if n, ok := src.(*stdimage.NRGBA); ok && n.Rect.Min == (stdimage.Point{}) { + return n + } + b := src.Bounds() + dst := stdimage.NewNRGBA(stdimage.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + return dst +} + +func channelSlots(img *stdimage.NRGBA) int { + return img.Rect.Dx() * img.Rect.Dy() * channelsPerPixel +} + +func capacityFromSlots(slots int) int { + usable := slots - lengthPrefixBits + if usable < 0 { + return 0 + } + return usable / bitsPerByte +} + +func frame(payload []byte) []byte { + framed := make([]byte, lengthPrefixBytes+len(payload)) + binary.BigEndian.PutUint32(framed, uint32(len(payload))) + copy(framed[lengthPrefixBytes:], payload) + return framed +} + +func pixOffset(slot int) int { + return bytesPerPixel*(slot/channelsPerPixel) + (slot % channelsPerPixel) +} + +func readBits(img *stdimage.NRGBA, startSlot, count int) []byte { + writer := bitio.NewWriter() + for slot := startSlot; slot < startSlot+count; slot++ { + off := pixOffset(slot) + writer.WriteBit(img.Pix[off] & 1) + } + return writer.Bytes() +} + +func bytesHavePrefix(b, prefix []byte) bool { + if len(b) < len(prefix) { + return false + } + for i := range prefix { + if b[i] != prefix[i] { + return false + } + } + return true +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go new file mode 100644 index 00000000..8028b4c2 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/image/image_test.go @@ -0,0 +1,378 @@ +/* +©AngelaMos | 2026 +image_test.go + +Round-trip, rejection, capacity, and sniff tests for the LSB image carrier +*/ + +package image + +import ( + "bytes" + stdimage "image" + "image/color" + "image/color/palette" + "image/jpeg" + "image/png" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/CarterPerez-dev/crypha/internal/payload" + xbmp "golang.org/x/image/bmp" +) + +const ( + coverWidth = 8 + coverHeight = 8 + coverBytes = 20 +) + +func nrgbaCover(w, h int) *stdimage.NRGBA { + img := stdimage.NewNRGBA(stdimage.Rect(0, 0, w, h)) + for i := 0; i < len(img.Pix); i += bytesPerPixel { + img.Pix[i] = byte(i) + img.Pix[i+1] = byte(i * 3) + img.Pix[i+2] = byte(i * 7) + img.Pix[i+3] = 0xFF + } + return img +} + +func encodePNG(t *testing.T, img stdimage.Image) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png cover: %v", err) + } + return buf.Bytes() +} + +func encodeBMP(t *testing.T, img stdimage.Image) []byte { + t.Helper() + var buf bytes.Buffer + if err := xbmp.Encode(&buf, img); err != nil { + t.Fatalf("encode bmp cover: %v", err) + } + return buf.Bytes() +} + +func hideReveal(t *testing.T, cover, payload []byte) []byte { + t.Helper() + var stego bytes.Buffer + if err := (imageCarrier{}).Hide(bytes.NewReader(cover), payload, &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + got, err := (imageCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + return got +} + +func TestRoundTripPNG(t *testing.T) { + cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight)) + cases := []struct { + name string + payload []byte + }{ + {"single byte", []byte{0x42}}, + {"text", []byte("crypha")}, + {"full capacity", bytes.Repeat([]byte{0xAB}, coverBytes)}, + {"high bits set", bytes.Repeat([]byte{0xFF}, coverBytes)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := hideReveal(t, cover, tc.payload) + if !bytes.Equal(got, tc.payload) { + t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload) + } + }) + } +} + +func TestRoundTripBMP(t *testing.T) { + cover := encodeBMP(t, nrgbaCover(coverWidth, coverHeight)) + payload := []byte("bmp carrier") + got := hideReveal(t, cover, payload) + if !bytes.Equal(got, payload) { + t.Fatalf("bmp round-trip mismatch: got %x want %x", got, payload) + } +} + +func TestRGBASourceSafety(t *testing.T) { + rgba := stdimage.NewRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight)) + for i := 0; i < len(rgba.Pix); i += bytesPerPixel { + rgba.Pix[i] = byte(i * 5) + rgba.Pix[i+1] = byte(i * 11) + rgba.Pix[i+2] = byte(i * 13) + rgba.Pix[i+3] = 0xFF + } + cover := encodePNG(t, rgba) + payload := []byte("premultiply safe") + got := hideReveal(t, cover, payload) + if !bytes.Equal(got, payload) { + t.Fatalf("rgba-source round-trip mismatch: got %x want %x", got, payload) + } +} + +func TestTransparentCoverAlphaUntouched(t *testing.T) { + cover := stdimage.NewNRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight)) + for i := 0; i < len(cover.Pix); i += bytesPerPixel { + cover.Pix[i] = byte(i) + cover.Pix[i+1] = byte(i * 2) + cover.Pix[i+2] = byte(i * 4) + cover.Pix[i+3] = byte(0x80 + i%64) + } + encoded := encodePNG(t, cover) + + var stego bytes.Buffer + if err := (imageCarrier{}).Hide(bytes.NewReader(encoded), []byte("hi"), &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + decoded, _, err := stdimage.Decode(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("decode stego: %v", err) + } + out := toNRGBA(decoded) + for i := 0; i < len(out.Pix); i += bytesPerPixel { + if out.Pix[i+3] != cover.Pix[i+3] { + t.Fatalf("alpha modified at pixel %d: got %d want %d", i/bytesPerPixel, out.Pix[i+3], cover.Pix[i+3]) + } + } +} + +func pseudoRandom(n, seed int) []byte { + b := make([]byte, n) + x := uint32(seed)*2654435761 + 1 + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } + return b +} + +func TestRandomBinaryRoundTrip(t *testing.T) { + cover := encodePNG(t, nrgbaCover(64, 64)) + for _, size := range []int{1, 17, 100, 500} { + payload := pseudoRandom(size, size) + got := hideReveal(t, cover, payload) + if !bytes.Equal(got, payload) { + t.Fatalf("random round-trip mismatch at size %d", size) + } + } +} + +func TestPalettedRejected(t *testing.T) { + pal := stdimage.NewPaletted(stdimage.Rect(0, 0, coverWidth, coverHeight), palette.WebSafe) + for y := 0; y < coverHeight; y++ { + for x := 0; x < coverWidth; x++ { + pal.Set(x, y, color.RGBA{R: byte(x * 30), G: byte(y * 30), B: 0x40, A: 0xFF}) + } + } + cover := encodePNG(t, pal) + err := (imageCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}) + if err != ErrPaletted { + t.Fatalf("expected ErrPaletted, got %v", err) + } +} + +func TestSixteenBitRejected(t *testing.T) { + img := stdimage.NewNRGBA64(stdimage.Rect(0, 0, coverWidth, coverHeight)) + for y := 0; y < coverHeight; y++ { + for x := 0; x < coverWidth; x++ { + img.Set(x, y, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9ABC, A: 0xFFFF}) + } + } + cover := encodePNG(t, img) + err := (imageCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}) + if err != Err16Bit { + t.Fatalf("expected Err16Bit, got %v", err) + } +} + +func TestUnsupportedFormatRejected(t *testing.T) { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, nrgbaCover(coverWidth, coverHeight), nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + err := (imageCarrier{}).Hide(bytes.NewReader(buf.Bytes()), []byte("x"), &bytes.Buffer{}) + if err != ErrUnsupportedFormat { + t.Fatalf("expected ErrUnsupportedFormat, got %v", err) + } +} + +func TestEmptyPayloadRejected(t *testing.T) { + cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight)) + err := (imageCarrier{}).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{}) + if err != ErrEmptyPayload { + t.Fatalf("expected ErrEmptyPayload, got %v", err) + } +} + +func TestCapacityBoundary(t *testing.T) { + cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight)) + + atCap := bytes.Repeat([]byte{0x01}, coverBytes) + if got := hideReveal(t, cover, atCap); !bytes.Equal(got, atCap) { + t.Fatal("payload at exact capacity failed to round-trip") + } + + overCap := bytes.Repeat([]byte{0x01}, coverBytes+1) + err := (imageCarrier{}).Hide(bytes.NewReader(cover), overCap, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected capacity error for oversized payload") + } +} + +func TestCapacityReport(t *testing.T) { + cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight)) + got, err := (imageCarrier{}).Capacity(bytes.NewReader(cover)) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + want := (coverWidth*coverHeight*channelsPerPixel - lengthPrefixBits) / bitsPerByte + if got != want { + t.Fatalf("Capacity: got %d want %d", got, want) + } + if want != coverBytes { + t.Fatalf("test constant coverBytes stale: computed %d", want) + } +} + +func TestRevealNoPayload(t *testing.T) { + clean := stdimage.NewNRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight)) + for i := range clean.Pix { + clean.Pix[i] = 0xFE + } + cover := encodePNG(t, clean) + _, err := (imageCarrier{}).Reveal(bytes.NewReader(cover)) + if err != ErrNoPayload { + t.Fatalf("expected ErrNoPayload on a cover with zeroed length bits, got %v", err) + } +} + +func TestSniff(t *testing.T) { + pngCover := encodePNG(t, nrgbaCover(coverWidth, coverHeight)) + bmpCover := encodeBMP(t, nrgbaCover(coverWidth, coverHeight)) + cases := []struct { + name string + data []byte + want bool + }{ + {"png", pngCover, true}, + {"bmp", bmpCover, true}, + {"random", []byte("not an image file at all"), false}, + {"short", []byte{0x89, 'P'}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (imageCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want { + t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestRevealRejectsUnsupportedAndGarbage(t *testing.T) { + var jpg bytes.Buffer + if err := jpeg.Encode(&jpg, nrgbaCover(coverWidth, coverHeight), nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + if _, err := (imageCarrier{}).Reveal(bytes.NewReader(jpg.Bytes())); err != ErrUnsupportedFormat { + t.Fatalf("Reveal jpeg: got %v want ErrUnsupportedFormat", err) + } + if _, err := (imageCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err == nil { + t.Fatal("Reveal garbage: expected decode error") + } +} + +func TestRevealRejectsLossy(t *testing.T) { + pal := stdimage.NewPaletted(stdimage.Rect(0, 0, coverWidth, coverHeight), palette.WebSafe) + for y := 0; y < coverHeight; y++ { + for x := 0; x < coverWidth; x++ { + pal.Set(x, y, color.RGBA{R: byte(x * 30), G: byte(y * 30), B: 0x40, A: 0xFF}) + } + } + if _, err := (imageCarrier{}).Reveal(bytes.NewReader(encodePNG(t, pal))); err != ErrPaletted { + t.Fatalf("Reveal paletted: got %v want ErrPaletted", err) + } + + wide := stdimage.NewNRGBA64(stdimage.Rect(0, 0, coverWidth, coverHeight)) + wide.Set(0, 0, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9ABC, A: 0xFFFF}) + if _, err := (imageCarrier{}).Reveal(bytes.NewReader(encodePNG(t, wide))); err != Err16Bit { + t.Fatalf("Reveal 16-bit: got %v want Err16Bit", err) + } +} + +func TestRevealTooSmall(t *testing.T) { + cover := encodePNG(t, nrgbaCover(1, 1)) + if _, err := (imageCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrTooSmall { + t.Fatalf("Reveal 1x1: got %v want ErrTooSmall", err) + } +} + +func TestCapacityRejectsUnsupportedAndTiny(t *testing.T) { + var jpg bytes.Buffer + if err := jpeg.Encode(&jpg, nrgbaCover(coverWidth, coverHeight), nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + if _, err := (imageCarrier{}).Capacity(bytes.NewReader(jpg.Bytes())); err != ErrUnsupportedFormat { + t.Fatalf("Capacity jpeg: got %v want ErrUnsupportedFormat", err) + } + if _, err := (imageCarrier{}).Capacity(bytes.NewReader([]byte("garbage"))); err == nil { + t.Fatal("Capacity garbage: expected decode error") + } + tiny := encodePNG(t, nrgbaCover(1, 1)) + got, err := (imageCarrier{}).Capacity(bytes.NewReader(tiny)) + if err != nil { + t.Fatalf("Capacity 1x1: %v", err) + } + if got != 0 { + t.Fatalf("Capacity 1x1: got %d want 0", got) + } +} + +func TestEncryptedEnvelopeThroughCarrier(t *testing.T) { + secret := []byte("meet at the docks at midnight") + envelope, err := payload.Pack(secret, payload.Options{ + Passphrase: []byte("correct horse battery staple"), + Compress: true, + Cipher: payload.CipherChaCha20, + Strength: payload.StrengthDefault, + }) + if err != nil { + t.Fatalf("Pack: %v", err) + } + + cover := encodePNG(t, nrgbaCover(96, 96)) + var stego bytes.Buffer + if err := (imageCarrier{}).Hide(bytes.NewReader(cover), envelope, &stego); err != nil { + t.Fatalf("Hide envelope: %v", err) + } + + recovered, err := (imageCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal envelope: %v", err) + } + if !bytes.Equal(recovered, envelope) { + t.Fatal("carrier did not return the exact envelope bytes") + } + + plain, err := payload.Unpack(recovered, []byte("correct horse battery staple")) + if err != nil { + t.Fatalf("Unpack: %v", err) + } + if !bytes.Equal(plain, secret) { + t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret) + } +} + +func TestRegisteredInRegistry(t *testing.T) { + c, ok := carrier.Get(Format) + if !ok { + t.Fatal("image carrier did not self-register") + } + if c.Format() != Format { + t.Fatalf("registry returned wrong carrier: %s", c.Format()) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go new file mode 100644 index 00000000..78b496a4 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text.go @@ -0,0 +1,190 @@ +/* +©AngelaMos | 2026 +text.go + +Zero-width Unicode text carrier that appends an invisible framed payload after cover text +*/ + +package text + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "unicode/utf8" + + "github.com/CarterPerez-dev/crypha/internal/bitio" + "github.com/CarterPerez-dev/crypha/internal/carrier" +) + +const ( + Format = "text" + + zeroRune rune = 0x200B + oneRune rune = 0x2060 + + bitsPerByte = 8 + lengthBytes = 4 + lengthBits = lengthBytes * bitsPerByte + + unboundedCapacity = math.MaxInt32 +) + +var ( + textMagic = [4]byte{0x7A, 0x57, 0x43, 0x52} + textMagicBits = bytesToBits(textMagic[:]) +) + +var ( + ErrEmptyPayload = errors.New("crypha/text: empty payload") + ErrNoPayload = errors.New("crypha/text: no crypha payload found") +) + +type textCarrier struct{} + +func init() { + carrier.Register(textCarrier{}) +} + +func (textCarrier) Format() string { + return Format +} + +func (textCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error { + if len(payload) == 0 { + return ErrEmptyPayload + } + + coverBytes, err := io.ReadAll(cover) + if err != nil { + return fmt.Errorf("crypha/text: read cover: %w", err) + } + + frame := make([]byte, 0, len(textMagic)+lengthBytes+len(payload)) + frame = append(frame, textMagic[:]...) + var lenField [lengthBytes]byte + binary.BigEndian.PutUint32(lenField[:], uint32(len(payload))) + frame = append(frame, lenField[:]...) + frame = append(frame, payload...) + + zw := make([]byte, 0, len(frame)*bitsPerByte*utf8.UTFMax) + reader := bitio.NewReader(frame) + for { + bit, rerr := reader.ReadBit() + if rerr != nil { + break + } + zw = utf8.AppendRune(zw, runeForBit(bit)) + } + + if _, err := out.Write(coverBytes); err != nil { + return err + } + _, err = out.Write(zw) + return err +} + +func (textCarrier) Reveal(stego io.Reader) ([]byte, error) { + data, err := io.ReadAll(stego) + if err != nil { + return nil, fmt.Errorf("crypha/text: read stego: %w", err) + } + payload, ok := findFrame(extractBits(data)) + if !ok { + return nil, ErrNoPayload + } + return payload, nil +} + +func (textCarrier) Capacity(_ io.Reader) (int, error) { + return unboundedCapacity, nil +} + +func (textCarrier) Sniff(stego io.ReadSeeker) bool { + data, err := io.ReadAll(stego) + if err != nil { + return false + } + _, ok := findFrame(extractBits(data)) + return ok +} + +func runeForBit(bit byte) rune { + if bit == 1 { + return oneRune + } + return zeroRune +} + +func findFrame(bits []byte) ([]byte, bool) { + for start := 0; start+len(textMagicBits) <= len(bits); start++ { + if !matchAt(bits, textMagicBits, start) { + continue + } + if payload, ok := parseFrame(bits, start+len(textMagicBits)); ok { + return payload, true + } + } + return nil, false +} + +func extractBits(data []byte) []byte { + bits := make([]byte, 0, len(data)) + for _, r := range string(data) { + switch r { + case zeroRune: + bits = append(bits, 0) + case oneRune: + bits = append(bits, 1) + } + } + return bits +} + +func bytesToBits(b []byte) []byte { + out := make([]byte, 0, len(b)*bitsPerByte) + for _, by := range b { + for shift := bitsPerByte - 1; shift >= 0; shift-- { + out = append(out, (by>>uint(shift))&1) + } + } + return out +} + +func bitsToBytes(bits []byte) []byte { + writer := bitio.NewWriter() + for _, bit := range bits { + writer.WriteBit(bit) + } + return writer.Bytes() +} + +func matchAt(haystack, needle []byte, at int) bool { + if at+len(needle) > len(haystack) { + return false + } + for i := range needle { + if haystack[at+i] != needle[i] { + return false + } + } + return true +} + +func parseFrame(bits []byte, headerStart int) ([]byte, bool) { + if headerStart+lengthBits > len(bits) { + return nil, false + } + length := binary.BigEndian.Uint32(bitsToBytes(bits[headerStart : headerStart+lengthBits])) + if length == 0 { + return nil, false + } + payloadStart := headerStart + lengthBits + remaining := len(bits) - payloadStart + if uint64(length)*bitsPerByte != uint64(remaining) { + return nil, false + } + return bitsToBytes(bits[payloadStart:]), true +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go new file mode 100644 index 00000000..9081b3f6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/text/text_test.go @@ -0,0 +1,313 @@ +/* +©AngelaMos | 2026 +text_test.go + +Round-trip, incidental-zero-width, normalization, and framing tests for the text carrier +*/ + +package text + +import ( + "bytes" + "io" + "math" + "strings" + "testing" + "unicode/utf8" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/CarterPerez-dev/crypha/internal/payload" + "golang.org/x/text/unicode/norm" +) + +func hide(t *testing.T, cover string, data []byte) []byte { + t.Helper() + var out bytes.Buffer + if err := (textCarrier{}).Hide(strings.NewReader(cover), data, &out); err != nil { + t.Fatalf("Hide: %v", err) + } + return out.Bytes() +} + +func reveal(t *testing.T, stego []byte) []byte { + t.Helper() + got, err := (textCarrier{}).Reveal(bytes.NewReader(stego)) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + return got +} + +func pseudoRandom(n, seed int) []byte { + b := make([]byte, n) + x := uint32(seed)*2654435761 + 1 + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } + return b +} + +func TestRoundTripCovers(t *testing.T) { + cases := []struct { + name string + cover string + }{ + {"empty cover", ""}, + {"ascii", "the quick brown fox"}, + {"multiline", "line one\nline two\nline three\n"}, + {"unicode cover", "café naïve 你好 \U0001F600 text"}, + {"whitespace only", " \t\n "}, + } + payloadBytes := []byte("attack at dawn") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stego := hide(t, tc.cover, payloadBytes) + got := reveal(t, stego) + if !bytes.Equal(got, payloadBytes) { + t.Fatalf("round-trip mismatch: got %q want %q", got, payloadBytes) + } + }) + } +} + +func TestRandomBinaryRoundTrip(t *testing.T) { + cover := "cover text that stays visible" + for _, size := range []int{1, 7, 64, 1000} { + data := pseudoRandom(size, size) + got := reveal(t, hide(t, cover, data)) + if !bytes.Equal(got, data) { + t.Fatalf("random round-trip mismatch at size %d", size) + } + } +} + +func TestCoverStaysVisible(t *testing.T) { + cover := "this text is unchanged" + data := []byte("hidden") + stego := hide(t, cover, data) + + if !bytes.HasPrefix(stego, []byte(cover)) { + t.Fatal("cover bytes were altered") + } + suffix := stego[len(cover):] + for _, r := range string(suffix) { + if r != zeroRune && r != oneRune { + t.Fatalf("appended data contains a non-carrier rune: U+%04X", r) + } + } + + visible := strings.Map(func(r rune) rune { + if r == zeroRune || r == oneRune { + return -1 + } + return r + }, string(stego)) + if visible != cover { + t.Fatalf("stripping carrier runes did not restore the cover: got %q", visible) + } +} + +func TestIncidentalZeroWidthInCover(t *testing.T) { + cover := "prefix" + string(zeroRune) + string(oneRune) + string(zeroRune) + "suffix" + data := []byte("payload survives noise") + got := reveal(t, hide(t, cover, data)) + if !bytes.Equal(got, data) { + t.Fatalf("incidental zero-width broke extraction: got %q", got) + } +} + +func TestNFCNormalizationSurvival(t *testing.T) { + cover := "café test cover" + data := []byte("normalization is not a threat") + stego := hide(t, cover, data) + + for _, form := range []norm.Form{norm.NFC, norm.NFD, norm.NFKC, norm.NFKD} { + normalized := form.Bytes(stego) + got, err := (textCarrier{}).Reveal(bytes.NewReader(normalized)) + if err != nil { + t.Fatalf("Reveal after normalization: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("payload lost through normalization form: got %q", got) + } + } +} + +func TestCapacityUnbounded(t *testing.T) { + got, err := (textCarrier{}).Capacity(strings.NewReader("anything")) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if got != math.MaxInt32 { + t.Fatalf("Capacity: got %d want unbounded (%d)", got, math.MaxInt32) + } +} + +func TestEmptyPayloadRejected(t *testing.T) { + var out bytes.Buffer + if err := (textCarrier{}).Hide(strings.NewReader("cover"), nil, &out); err != ErrEmptyPayload { + t.Fatalf("expected ErrEmptyPayload, got %v", err) + } +} + +func TestRevealNoPayload(t *testing.T) { + cases := []struct { + name string + stego string + }{ + {"plain text", "just some ordinary text with no secrets"}, + {"empty", ""}, + {"incidental zero-width without magic", "a" + string(zeroRune) + string(oneRune) + "b"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := (textCarrier{}).Reveal(strings.NewReader(tc.stego)); err != ErrNoPayload { + t.Fatalf("expected ErrNoPayload, got %v", err) + } + }) + } +} + +func TestSniff(t *testing.T) { + framed := hide(t, "cover", []byte("secret")) + cases := []struct { + name string + data []byte + want bool + }{ + {"framed", framed, true}, + {"plain", []byte("nothing hidden here"), false}, + {"incidental zw no magic", []byte("x" + string(zeroRune) + string(oneRune) + "y"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (textCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want { + t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestOverheadRatio(t *testing.T) { + data := []byte{0x00} + stego := hide(t, "", data) + runeCount := utf8.RuneCount(stego) + wantRunes := (len(textMagic) + lengthBytes + len(data)) * bitsPerByte + if runeCount != wantRunes { + t.Fatalf("carrier-rune count: got %d want %d", runeCount, wantRunes) + } +} + +func TestEncryptedEnvelopeThroughCarrier(t *testing.T) { + secret := []byte("the account number is 4815162342") + envelope, err := payload.Pack(secret, payload.Options{ + Passphrase: []byte("open sesame"), + Compress: true, + Cipher: payload.CipherChaCha20, + Strength: payload.StrengthDefault, + }) + if err != nil { + t.Fatalf("Pack: %v", err) + } + + stego := hide(t, "innocuous cover message", envelope) + recovered := reveal(t, stego) + if !bytes.Equal(recovered, envelope) { + t.Fatal("carrier did not return the exact envelope bytes") + } + + plain, err := payload.Unpack(recovered, []byte("open sesame")) + if err != nil { + t.Fatalf("Unpack: %v", err) + } + if !bytes.Equal(plain, secret) { + t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret) + } +} + +type errReader struct{} + +func (errReader) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +type errWriter struct{} + +func (errWriter) Write(_ []byte) (int, error) { return 0, io.ErrShortWrite } + +func TestHideWriteError(t *testing.T) { + if err := (textCarrier{}).Hide(strings.NewReader("cover"), []byte("x"), errWriter{}); err != io.ErrShortWrite { + t.Fatalf("expected write error to propagate, got %v", err) + } +} + +func TestReadErrorsPropagate(t *testing.T) { + if _, err := (textCarrier{}).Reveal(errReader{}); err == nil { + t.Fatal("expected Reveal read error") + } + if (textCarrier{}).Sniff(readSeekerErr{}) { + t.Fatal("Sniff should be false when the reader errors") + } +} + +type readSeekerErr struct{} + +func (readSeekerErr) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (readSeekerErr) Seek(_ int64, _ int) (int64, error) { return 0, nil } + +func zeroWidth(b []byte) string { + var sb strings.Builder + for _, bit := range bytesToBits(b) { + sb.WriteRune(runeForBit(bit)) + } + return sb.String() +} + +func TestCorruptFramesRejected(t *testing.T) { + cases := []struct { + name string + stego string + }{ + {"zero length field", zeroWidth(append(append([]byte{}, textMagic[:]...), 0, 0, 0, 0))}, + {"truncated length field", zeroWidth(textMagic[:]) + strings.Repeat(string(zeroRune), 10)}, + {"length exceeds payload bits", zeroWidth(append(append([]byte{}, textMagic[:]...), 0, 0, 0, 100)) + strings.Repeat(string(oneRune), bitsPerByte)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := (textCarrier{}).Reveal(strings.NewReader(tc.stego)); err != ErrNoPayload { + t.Fatalf("expected ErrNoPayload, got %v", err) + } + }) + } +} + +func TestNestedStegoReturnsAppendedFrame(t *testing.T) { + first := []byte("first hidden layer") + stegoA := hide(t, "public cover text", first) + if got := reveal(t, stegoA); !bytes.Equal(got, first) { + t.Fatalf("layer A: got %q want %q", got, first) + } + + second := []byte("second layer wins") + stegoB := hide(t, string(stegoA), second) + if got := reveal(t, stegoB); !bytes.Equal(got, second) { + t.Fatalf("nested reveal must return the appended frame: got %q want %q", got, second) + } +} + +func TestPayloadContainingMagicRoundTrips(t *testing.T) { + data := append(append([]byte("head"), textMagic[:]...), []byte("tail after magic bytes")...) + got := reveal(t, hide(t, "cover", data)) + if !bytes.Equal(got, data) { + t.Fatalf("payload containing magic bytes corrupted: got %q want %q", got, data) + } +} + +func TestRegisteredInRegistry(t *testing.T) { + c, ok := carrier.Get(Format) + if !ok { + t.Fatal("text carrier did not self-register") + } + if c.Format() != Format { + t.Fatalf("registry returned wrong carrier: %s", c.Format()) + } +} From 2c0d3ae4091bc957a78bbb301c97490e8a49fc4c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 15 Jul 2026 06:33:11 -0400 Subject: [PATCH 03/13] 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). --- .../beginner/steganography-multi-tool/go.mod | 17 +- .../beginner/steganography-multi-tool/go.sum | 36 +- .../internal/carrier/all/all.go | 2 + .../internal/carrier/audio/audio.go | 285 ++++++++++ .../internal/carrier/audio/audio_test.go | 493 ++++++++++++++++++ .../internal/carrier/audio/writeseeker.go | 54 ++ .../internal/carrier/pdf/pdf.go | 272 ++++++++++ .../internal/carrier/pdf/pdf_test.go | 395 ++++++++++++++ 8 files changed, 1552 insertions(+), 2 deletions(-) create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/audio_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/audio/writeseeker.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/pdf/pdf_test.go 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()) + } +} From 0ae323eb5dd39a22e68ea88121daa97b27137ebd Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 15 Jul 2026 18:09:38 -0400 Subject: [PATCH 04/13] feat(crypha): add qr carrier with reed-solomon error injection (M6) Reimplements the QR internals needed for the covert channel from ISO/IEC 18004: the function-module map, format-info parse, data mask, zigzag placement, and error-correction block de-interleave, plus a from-scratch Reed-Solomon decoder over GF(2^8) (syndromes, Berlekamp- Massey, Chien search, and a Vandermonde magnitude solve). skip2 generates the clean symbol; crypha introspects it and reuses none of its internals. The payload is hidden as up to floor(t/2) correctable codeword errors per block in the data region, leaving the error-correction codewords intact, so any scanner's Reed-Solomon decoder self-heals to the cover and never sees it. Reveal reads the module grid, RS-decodes each block itself, and diffs the corrected data against the stego to recover the payload. EC level is fixed at H; versions 1-10 auto-select by cover and payload size. Capacity is tens of bytes, so an encrypted envelope (which exceeds it) is rejected cleanly. Differentially tested: the extracted codewords match skip2 as valid RS codewords across all supported versions, and every stego still scans back to the cover via gozxing at the full injection budget. skip2 is a runtime dependency (the generator); gozxing is test-only. No toolchain bump; the go directive stays 1.25.0. --- .../beginner/steganography-multi-tool/go.mod | 3 + .../beginner/steganography-multi-tool/go.sum | 6 + .../internal/carrier/all/all.go | 1 + .../internal/carrier/qr/blocks.go | 190 ++++++++ .../internal/carrier/qr/gf.go | 57 +++ .../internal/carrier/qr/matrix.go | 343 ++++++++++++++ .../internal/carrier/qr/matrix_test.go | 121 +++++ .../internal/carrier/qr/qr.go | 266 +++++++++++ .../internal/carrier/qr/qr_test.go | 444 ++++++++++++++++++ .../internal/carrier/qr/rs.go | 221 +++++++++ .../internal/carrier/qr/rs_test.go | 199 ++++++++ 11 files changed, 1851 insertions(+) create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index c427d71a..d9201611 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -5,8 +5,10 @@ go 1.25.0 require ( github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 + github.com/makiuchi-d/gozxing v0.1.1 github.com/mewkiz/flac v1.0.13 github.com/pdfcpu/pdfcpu v0.13.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.52.0 golang.org/x/image v0.44.0 @@ -27,5 +29,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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 d9dc8938..cef14c9a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -19,6 +19,8 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lTo 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/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= +github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= 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= @@ -32,6 +34,8 @@ github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdB 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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -46,6 +50,8 @@ 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= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= 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 127b7783..1181c081 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/all/all.go @@ -11,5 +11,6 @@ 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/qr" _ "github.com/CarterPerez-dev/crypha/internal/carrier/text" ) diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go new file mode 100644 index 00000000..cfa28783 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/blocks.go @@ -0,0 +1,190 @@ +/* +©AngelaMos | 2026 +blocks.go + +Version and error-correction block tables (level H) plus codeword interleaving from ISO/IEC 18004 +*/ + +package qr + +const ( + ecLevelHigh = 2 + framePrefixBytes = 4 + bitsPerCodeword = 8 + injectionSafetyRatio = 2 + minSupportedVersion = 1 + maxSupportedVersion = 10 + baseSymbolSize = 21 + versionSizeStep = 4 +) + +type blockGroup struct { + count int + total int + data int +} + +type qrVersion struct { + version int + remainderBits int + groups []blockGroup +} + +var versionTable = map[int]qrVersion{ + 1: {1, 0, []blockGroup{{1, 26, 9}}}, + 2: {2, 7, []blockGroup{{1, 44, 16}}}, + 3: {3, 7, []blockGroup{{2, 35, 13}}}, + 4: {4, 7, []blockGroup{{4, 25, 9}}}, + 5: {5, 7, []blockGroup{{2, 33, 11}, {2, 34, 12}}}, + 6: {6, 7, []blockGroup{{4, 43, 15}}}, + 7: {7, 0, []blockGroup{{4, 39, 13}, {1, 40, 14}}}, + 8: {8, 0, []blockGroup{{4, 40, 14}, {2, 41, 15}}}, + 9: {9, 0, []blockGroup{{4, 36, 12}, {4, 37, 13}}}, + 10: {10, 0, []blockGroup{{6, 43, 15}, {2, 44, 16}}}, +} + +func lookupVersion(version int) (qrVersion, bool) { + v, ok := versionTable[version] + return v, ok +} + +func symbolSize(version int) int { + return baseSymbolSize + (version-1)*versionSizeStep +} + +func versionForSize(size int) (int, bool) { + if size < baseSymbolSize || (size-baseSymbolSize)%versionSizeStep != 0 { + return 0, false + } + version := (size-baseSymbolSize)/versionSizeStep + 1 + if version < minSupportedVersion || version > maxSupportedVersion { + return 0, false + } + return version, true +} + +func (v qrVersion) numBlocks() int { + n := 0 + for _, g := range v.groups { + n += g.count + } + return n +} + +func (v qrVersion) totalCodewords() int { + n := 0 + for _, g := range v.groups { + n += g.count * g.total + } + return n +} + +func (v qrVersion) ecPerBlock() int { + return v.groups[0].total - v.groups[0].data +} + +func (v qrVersion) correctable() int { + return v.ecPerBlock() / 2 +} + +func (v qrVersion) injectPerBlock() int { + return v.correctable() / injectionSafetyRatio +} + +func (v qrVersion) dataModules() int { + return v.totalCodewords()*bitsPerCodeword + v.remainderBits +} + +func (v qrVersion) capacityBytes() int { + usable := v.numBlocks()*v.injectPerBlock() - framePrefixBytes + if usable < 0 { + return 0 + } + return usable +} + +func (v qrVersion) blockLayout() (dataLens, ecLens []int) { + for _, g := range v.groups { + for i := 0; i < g.count; i++ { + dataLens = append(dataLens, g.data) + ecLens = append(ecLens, g.total-g.data) + } + } + return dataLens, ecLens +} + +func (v qrVersion) interleave(dataBlocks, ecBlocks [][]byte) []byte { + out := make([]byte, 0, v.totalCodewords()) + maxData := 0 + for _, b := range dataBlocks { + if len(b) > maxData { + maxData = len(b) + } + } + for i := 0; i < maxData; i++ { + for _, b := range dataBlocks { + if i < len(b) { + out = append(out, b[i]) + } + } + } + maxEC := 0 + for _, b := range ecBlocks { + if len(b) > maxEC { + maxEC = len(b) + } + } + for i := 0; i < maxEC; i++ { + for _, b := range ecBlocks { + if i < len(b) { + out = append(out, b[i]) + } + } + } + return out +} + +func (v qrVersion) deinterleave(serial []byte) (dataBlocks, ecBlocks [][]byte, ok bool) { + if len(serial) < v.totalCodewords() { + return nil, nil, false + } + dataLens, ecLens := v.blockLayout() + nb := len(dataLens) + dataBlocks = make([][]byte, nb) + ecBlocks = make([][]byte, nb) + for b := 0; b < nb; b++ { + dataBlocks[b] = make([]byte, dataLens[b]) + ecBlocks[b] = make([]byte, ecLens[b]) + } + + pos := 0 + maxData := 0 + for _, d := range dataLens { + if d > maxData { + maxData = d + } + } + for i := 0; i < maxData; i++ { + for b := 0; b < nb; b++ { + if i < dataLens[b] { + dataBlocks[b][i] = serial[pos] + pos++ + } + } + } + maxEC := 0 + for _, e := range ecLens { + if e > maxEC { + maxEC = e + } + } + for i := 0; i < maxEC; i++ { + for b := 0; b < nb; b++ { + if i < ecLens[b] { + ecBlocks[b][i] = serial[pos] + pos++ + } + } + } + return dataBlocks, ecBlocks, true +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go new file mode 100644 index 00000000..f8d4f5c1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/gf.go @@ -0,0 +1,57 @@ +/* +©AngelaMos | 2026 +gf.go + +Arithmetic over GF(2^8) under the QR primitive polynomial for Reed-Solomon coding +*/ + +package qr + +const ( + gfOrder = 255 + gfFieldSize = 256 + gfPrimitive = 0x11D + gfGenerator = 2 + gfHighBit = 0x100 +) + +var ( + gfExp [gfOrder * 2]byte + gfLog [gfFieldSize]byte +) + +func init() { + x := 1 + for i := 0; i < gfOrder; i++ { + gfExp[i] = byte(x) + gfLog[x] = byte(i) + x <<= 1 + if x&gfHighBit != 0 { + x ^= gfPrimitive + } + } + for i := gfOrder; i < gfOrder*2; i++ { + gfExp[i] = gfExp[i-gfOrder] + } +} + +func gfMul(a, b byte) byte { + if a == 0 || b == 0 { + return 0 + } + return gfExp[int(gfLog[a])+int(gfLog[b])] +} + +func gfInv(a byte) byte { + return gfExp[gfOrder-int(gfLog[a])] +} + +func gfPow(base byte, exp int) byte { + if base == 0 { + if exp == 0 { + return 1 + } + return 0 + } + return gfExp[(int(gfLog[base])*exp)%gfOrder] +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go new file mode 100644 index 00000000..702afd52 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix.go @@ -0,0 +1,343 @@ +/* +©AngelaMos | 2026 +matrix.go + +QR module geometry from ISO/IEC 18004: function map, format info, masks, placement, rendering +*/ + +package qr + +import ( + "fmt" + "image" + "image/color" + "image/png" + "io" + "math/bits" +) + +const ( + quietZoneModules = 4 + modulePixels = 8 + darkThreshold = 128 + formatBitCount = 15 + formatDataBits = 5 + formatGenPoly = 0x537 + formatMaskPoly = 0x5412 + formatMaxDistance = 3 + formatCodeCount = 32 + alignmentRadius = 2 + timingLine = 6 + finderBlockEdge = 8 + finderPatternSize = 7 + versionInfoOrigin = 11 + versionInfoModules = 18 + versionInfoBand = 3 + minVersionWithInfo = 7 + darkLight = 0xFF + darkDark = 0x00 +) + +var formatBitCells = [formatBitCount]point{ + {8, 0}, {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5}, + {8, 7}, {8, 8}, {7, 8}, + {5, 8}, {4, 8}, {3, 8}, {2, 8}, {1, 8}, {0, 8}, +} + +var alignmentCenters = map[int][]int{ + 1: {}, + 2: {6, 18}, + 3: {6, 22}, + 4: {6, 26}, + 5: {6, 30}, + 6: {6, 34}, + 7: {6, 22, 38}, + 8: {6, 24, 42}, + 9: {6, 26, 46}, + 10: {6, 28, 50}, +} + +type matrix struct { + size int + grid [][]bool +} + +type point struct { + x, y int +} + +func newMatrix(size int) matrix { + grid := make([][]bool, size) + for i := range grid { + grid[i] = make([]bool, size) + } + return matrix{size: size, grid: grid} +} + +func (m matrix) clone() matrix { + out := newMatrix(m.size) + for y := range m.grid { + copy(out.grid[y], m.grid[y]) + } + return out +} + +func functionModules(version int) [][]bool { + size := symbolSize(version) + isFunc := make([][]bool, size) + for i := range isFunc { + isFunc[i] = make([]bool, size) + } + mark := func(x, y int) { + if x >= 0 && x < size && y >= 0 && y < size { + isFunc[y][x] = true + } + } + + for y := 0; y <= finderBlockEdge; y++ { + for x := 0; x <= finderBlockEdge; x++ { + mark(x, y) + } + } + for y := 0; y <= finderBlockEdge; y++ { + for x := size - finderBlockEdge; x < size; x++ { + mark(x, y) + } + } + for y := size - finderBlockEdge; y < size; y++ { + for x := 0; x <= finderBlockEdge; x++ { + mark(x, y) + } + } + + for i := 0; i < size; i++ { + mark(timingLine, i) + mark(i, timingLine) + } + + centers := alignmentCenters[version] + for _, cx := range centers { + for _, cy := range centers { + if inFinderBlock(cx, cy, size) { + continue + } + for dy := -alignmentRadius; dy <= alignmentRadius; dy++ { + for dx := -alignmentRadius; dx <= alignmentRadius; dx++ { + mark(cx+dx, cy+dy) + } + } + } + } + + if version >= minVersionWithInfo { + for i := 0; i < versionInfoModules; i++ { + mark(i/versionInfoBand, size-versionInfoOrigin+i%versionInfoBand) + mark(size-versionInfoOrigin+i%versionInfoBand, i/versionInfoBand) + } + } + + return isFunc +} + +func inFinderBlock(cx, cy, size int) bool { + span := finderPatternSize + 1 + inTopLeft := cx < span && cy < span + inTopRight := cx >= size-span && cy < span + inBottomLeft := cx < span && cy >= size-span + return inTopLeft || inTopRight || inBottomLeft +} + +func maskBit(maskID, row, col int) bool { + switch maskID { + case 0: + return (row+col)%2 == 0 + case 1: + return row%2 == 0 + case 2: + return col%3 == 0 + case 3: + return (row+col)%3 == 0 + case 4: + return (row/2+col/3)%2 == 0 + case 5: + return (row*col)%2+(row*col)%3 == 0 + case 6: + return ((row*col)%2+(row*col)%3)%2 == 0 + case 7: + return ((row+col)%2+(row*col)%3)%2 == 0 + } + return false +} + +func formatCode(dataBits int) int { + remainder := dataBits << (formatBitCount - formatDataBits) + for i := formatBitCount - 1; i >= formatBitCount-formatDataBits; i-- { + if remainder&(1< formatMaxDistance { + return 0, 0, false + } + return bestData & 0x7, bestData >> 3, true +} + +func placementOrder(version int, isFunc [][]bool) []point { + size := symbolSize(version) + v, _ := lookupVersion(version) + count := v.dataModules() + order := make([]point, 0, count) + + xOffset := 1 + dirUp := true + x := size - 2 + y := size - 1 + + for i := 0; i < count; i++ { + order = append(order, point{x: x + xOffset, y: y}) + if i == count-1 { + break + } + for { + if xOffset == 1 { + xOffset = 0 + } else { + xOffset = 1 + if dirUp { + if y > 0 { + y-- + } else { + dirUp = false + x -= 2 + } + } else { + if y < size-1 { + y++ + } else { + dirUp = true + x -= 2 + } + } + } + if x == timingLine-1 { + x-- + } + if x < 0 { + return order + } + if !isFunc[y][x+xOffset] { + break + } + } + } + return order +} + +func readSerial(m matrix, order []point, maskID, totalCodewords int) []byte { + out := make([]byte, totalCodewords) + for c := 0; c < totalCodewords; c++ { + var b byte + for bit := 0; bit < bitsPerCodeword; bit++ { + p := order[c*bitsPerCodeword+bit] + v := m.grid[p.y][p.x] + if maskBit(maskID, p.y, p.x) { + v = !v + } + b <<= 1 + if v { + b |= 1 + } + } + out[c] = b + } + return out +} + +func writeSerial(m matrix, order []point, maskID int, serial []byte) { + for c := 0; c < len(serial); c++ { + b := serial[c] + for bit := 0; bit < bitsPerCodeword; bit++ { + p := order[c*bitsPerCodeword+bit] + v := (b>>(7-bit))&1 == 1 + if maskBit(maskID, p.y, p.x) { + v = !v + } + m.grid[p.y][p.x] = v + } + } +} + +func renderPNG(m matrix, out io.Writer) error { + dim := (m.size + 2*quietZoneModules) * modulePixels + img := image.NewGray(image.Rect(0, 0, dim, dim)) + for i := range img.Pix { + img.Pix[i] = darkLight + } + for y := 0; y < m.size; y++ { + for x := 0; x < m.size; x++ { + if !m.grid[y][x] { + continue + } + px := (x + quietZoneModules) * modulePixels + py := (y + quietZoneModules) * modulePixels + for dy := 0; dy < modulePixels; dy++ { + for dx := 0; dx < modulePixels; dx++ { + img.SetGray(px+dx, py+dy, color.Gray{Y: darkDark}) + } + } + } + } + return png.Encode(out, img) +} + +func readGrid(r io.Reader) (matrix, int, error) { + img, _, err := image.Decode(r) + if err != nil { + return matrix{}, 0, fmt.Errorf("crypha/qr: decode stego: %w", err) + } + b := img.Bounds() + width, height := b.Dx(), b.Dy() + if width != height || width%modulePixels != 0 { + return matrix{}, 0, ErrNotQR + } + across := width / modulePixels + size := across - 2*quietZoneModules + version, ok := versionForSize(size) + if !ok { + return matrix{}, 0, ErrNotQR + } + m := newMatrix(size) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + cx := b.Min.X + (quietZoneModules+x)*modulePixels + modulePixels/2 + cy := b.Min.Y + (quietZoneModules+y)*modulePixels + modulePixels/2 + gray := color.GrayModel.Convert(img.At(cx, cy)).(color.Gray) + m.grid[y][x] = gray.Y < darkThreshold + } + } + return m, version, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go new file mode 100644 index 00000000..47f2545d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/matrix_test.go @@ -0,0 +1,121 @@ +/* +©AngelaMos | 2026 +matrix_test.go + +Unit coverage for QR geometry: mask formulas, version sizing, and capacity edge cases +*/ + +package qr + +import ( + "bytes" + "strings" + "testing" +) + +func TestMaskFormulas(t *testing.T) { + cases := []struct { + maskID int + row, col int + want bool + }{ + {0, 0, 0, true}, {0, 0, 1, false}, + {1, 0, 3, true}, {1, 1, 3, false}, + {2, 4, 0, true}, {2, 4, 1, false}, + {3, 0, 0, true}, {3, 0, 1, false}, + {4, 0, 0, true}, {4, 2, 0, false}, + {5, 0, 0, true}, {5, 1, 1, false}, + {6, 1, 1, true}, {6, 1, 5, false}, + {7, 0, 0, true}, {7, 0, 1, false}, + } + for _, tc := range cases { + if got := maskBit(tc.maskID, tc.row, tc.col); got != tc.want { + t.Fatalf("maskBit(%d,%d,%d): got %v want %v", tc.maskID, tc.row, tc.col, got, tc.want) + } + } + if maskBit(99, 0, 0) { + t.Fatal("maskBit with out-of-range id should be false") + } +} + +func TestFunctionMapCountMatchesDataModules(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + isFunc := functionModules(version) + size := symbolSize(version) + funcCount := 0 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if isFunc[y][x] { + funcCount++ + } + } + } + if nonFunc := size*size - funcCount; nonFunc != versionTable[version].dataModules() { + t.Fatalf("v%d: non-function modules %d, want dataModules %d", version, nonFunc, versionTable[version].dataModules()) + } + } +} + +func TestVersionForSize(t *testing.T) { + cases := []struct { + size int + version int + ok bool + }{ + {21, 1, true}, + {25, 2, true}, + {57, 10, true}, + {17, 0, false}, + {23, 0, false}, + {61, 0, false}, + } + for _, tc := range cases { + version, ok := versionForSize(tc.size) + if ok != tc.ok || version != tc.version { + t.Fatalf("versionForSize(%d): got (%d,%v) want (%d,%v)", tc.size, version, ok, tc.version, tc.ok) + } + } +} + +func TestCapacityBytesFloorsAtZero(t *testing.T) { + tiny := qrVersion{version: 0, groups: []blockGroup{{count: 1, total: 9, data: 8}}} + if got := tiny.capacityBytes(); got != 0 { + t.Fatalf("capacityBytes for a sub-frame budget: got %d want 0", got) + } +} + +func TestSupportedVersionCapacities(t *testing.T) { + want := map[int]int{ + 1: 0, 2: 3, 3: 6, 4: 12, 5: 16, + 6: 24, 7: 26, 8: 32, 9: 44, 10: 52, + } + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + if got := versionTable[version].capacityBytes(); got != want[version] { + t.Fatalf("v%d capacity: got %d want %d", version, got, want[version]) + } + } +} + +func TestRoundTripAcrossCovers(t *testing.T) { + covers := []string{ + "crypha", + "https://angelamos.com/x", + "TEST 123", + "The quick brown fox jumps", + "0123456789", + } + secret := []byte("covert channel") + for _, cover := range covers { + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(cover), secret, &stego); err != nil { + t.Fatalf("Hide cover %q: %v", cover, err) + } + got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal cover %q: %v", cover, err) + } + if !bytes.Equal(got, secret) { + t.Fatalf("cover %q: round-trip mismatch", cover) + } + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go new file mode 100644 index 00000000..a65bd1e7 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr.go @@ -0,0 +1,266 @@ +/* +©AngelaMos | 2026 +qr.go + +QR carrier that hides a payload as Reed-Solomon-correctable errors so scanners self-heal to the cover +*/ + +package qr + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + qrcode "github.com/skip2/go-qrcode" +) + +const Format = "qr" + +var ( + ErrEmptyPayload = errors.New("crypha/qr: empty payload") + ErrCoverRequired = errors.New("crypha/qr: qr cover text is required") + ErrCoverTooLarge = errors.New("crypha/qr: cover text too large for the supported qr versions") + ErrPayloadTooLarge = errors.New("crypha/qr: payload exceeds carrier capacity") + ErrNoPayload = errors.New("crypha/qr: no crypha payload found") + ErrNotQR = errors.New("crypha/qr: stego is not a crypha qr image") + errBadSymbol = errors.New("crypha/qr: unexpected qr symbol geometry") +) + +type qrCarrier struct{} + +func init() { + carrier.Register(qrCarrier{}) +} + +func (qrCarrier) Format() string { + return Format +} + +func (qrCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error { + if len(payload) == 0 { + return ErrEmptyPayload + } + coverText, err := io.ReadAll(cover) + if err != nil { + return fmt.Errorf("crypha/qr: read cover: %w", err) + } + if len(coverText) == 0 { + return ErrCoverRequired + } + if len(payload) > maxCapacity() { + return fmt.Errorf("%w: need %d bytes, max is %d", ErrPayloadTooLarge, len(payload), maxCapacity()) + } + + code, version, err := selectVersion(string(coverText), len(payload)) + if err != nil { + return err + } + + clean, err := matrixFromBitmap(code.Bitmap(), version) + if err != nil { + return err + } + + maskID, level, ok := parseFormat(clean) + if !ok || level != ecLevelHigh { + return errBadSymbol + } + + spec, _ := lookupVersion(version) + isFunc := functionModules(version) + order := placementOrder(version, isFunc) + serial := readSerial(clean, order, maskID, spec.totalCodewords()) + + dataBlocks, ecBlocks, ok := spec.deinterleave(serial) + if !ok { + return errBadSymbol + } + + framed := frame(payload) + if err := injectFramed(dataBlocks, spec, framed); err != nil { + return err + } + + stego := clean.clone() + writeSerial(stego, order, maskID, spec.interleave(dataBlocks, ecBlocks)) + return renderPNG(stego, out) +} + +func (qrCarrier) Reveal(stego io.Reader) ([]byte, error) { + m, version, err := readGrid(stego) + if err != nil { + return nil, err + } + + maskID, level, ok := parseFormat(m) + if !ok || level != ecLevelHigh { + return nil, ErrNoPayload + } + + spec, _ := lookupVersion(version) + isFunc := functionModules(version) + order := placementOrder(version, isFunc) + serial := readSerial(m, order, maskID, spec.totalCodewords()) + + dataBlocks, ecBlocks, ok := spec.deinterleave(serial) + if !ok { + return nil, ErrNoPayload + } + + framed, err := extractFramed(dataBlocks, ecBlocks, spec) + if err != nil { + return nil, err + } + return unframe(framed) +} + +func (qrCarrier) Capacity(cover io.Reader) (int, error) { + coverText, err := io.ReadAll(cover) + if err != nil { + return 0, fmt.Errorf("crypha/qr: read cover: %w", err) + } + if len(coverText) == 0 { + return 0, ErrCoverRequired + } + best := 0 + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + if _, err := qrcode.NewWithForcedVersion(string(coverText), version, qrcode.Highest); err != nil { + continue + } + if c := versionTable[version].capacityBytes(); c > best { + best = c + } + } + return best, nil +} + +func (qrCarrier) Sniff(stego io.ReadSeeker) bool { + m, _, err := readGrid(stego) + if err != nil { + return false + } + return hasFinderPatterns(m) +} + +func selectVersion(coverText string, payloadLen int) (*qrcode.QRCode, int, error) { + coverFits := false + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + spec := versionTable[version] + code, err := qrcode.NewWithForcedVersion(coverText, version, qrcode.Highest) + if err != nil { + continue + } + coverFits = true + if spec.capacityBytes() < payloadLen { + continue + } + code.DisableBorder = true + return code, version, nil + } + if !coverFits { + return nil, 0, ErrCoverTooLarge + } + return nil, 0, fmt.Errorf("%w: need %d bytes", ErrPayloadTooLarge, payloadLen) +} + +func matrixFromBitmap(bitmap [][]bool, version int) (matrix, error) { + size := symbolSize(version) + if len(bitmap) != size { + return matrix{}, errBadSymbol + } + m := newMatrix(size) + for y := 0; y < size; y++ { + if len(bitmap[y]) != size { + return matrix{}, errBadSymbol + } + copy(m.grid[y], bitmap[y]) + } + return m, nil +} + +func injectFramed(dataBlocks [][]byte, spec qrVersion, framed []byte) error { + nb := spec.numBlocks() + inject := spec.injectPerBlock() + for t := 0; t < len(framed); t++ { + block := t % nb + slot := t / nb + if slot >= inject || slot >= len(dataBlocks[block]) { + return fmt.Errorf("%w: framed length %d", ErrPayloadTooLarge, len(framed)) + } + dataBlocks[block][slot] ^= framed[t] + } + return nil +} + +func extractFramed(dataBlocks, ecBlocks [][]byte, spec qrVersion) ([]byte, error) { + nb := spec.numBlocks() + inject := spec.injectPerBlock() + ec := spec.ecPerBlock() + + clean := make([][]byte, nb) + for b := 0; b < nb; b++ { + recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...) + corrected, err := rsDecode(recv, ec) + if err != nil { + return nil, ErrNoPayload + } + clean[b] = corrected[:len(dataBlocks[b])] + } + + framed := make([]byte, nb*inject) + for t := 0; t < len(framed); t++ { + block := t % nb + slot := t / nb + framed[t] = clean[block][slot] ^ dataBlocks[block][slot] + } + return framed, nil +} + +func frame(payload []byte) []byte { + out := make([]byte, framePrefixBytes+len(payload)) + binary.BigEndian.PutUint32(out, uint32(len(payload))) + copy(out[framePrefixBytes:], payload) + return out +} + +func unframe(framed []byte) ([]byte, error) { + if len(framed) < framePrefixBytes { + return nil, ErrNoPayload + } + length := binary.BigEndian.Uint32(framed) + if length == 0 || uint64(length) > uint64(len(framed)-framePrefixBytes) { + return nil, ErrNoPayload + } + out := make([]byte, length) + copy(out, framed[framePrefixBytes:framePrefixBytes+int(length)]) + return out, nil +} + +func hasFinderPatterns(m matrix) bool { + return isFinder(m, 0, 0) && + isFinder(m, m.size-finderPatternSize, 0) && + isFinder(m, 0, m.size-finderPatternSize) +} + +func isFinder(m matrix, ox, oy int) bool { + if ox < 0 || oy < 0 || ox+finderPatternSize > m.size || oy+finderPatternSize > m.size { + return false + } + for y := 0; y < finderPatternSize; y++ { + for x := 0; x < finderPatternSize; x++ { + border := x == 0 || x == finderPatternSize-1 || y == 0 || y == finderPatternSize-1 + center := x >= 2 && x <= 4 && y >= 2 && y <= 4 + if m.grid[oy+y][ox+x] != (border || center) { + return false + } + } + } + return true +} + +func maxCapacity() int { + return versionTable[maxSupportedVersion].capacityBytes() +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go new file mode 100644 index 00000000..f8f9a8f2 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/qr_test.go @@ -0,0 +1,444 @@ +/* +©AngelaMos | 2026 +qr_test.go + +Differential tests against skip2 and gozxing plus round-trip, capacity, sniff, and registry checks +*/ + +package qr + +import ( + "bytes" + "errors" + stdimage "image" + "image/png" + "strings" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + "github.com/CarterPerez-dev/crypha/internal/payload" + gozxing "github.com/makiuchi-d/gozxing" + gozxingqr "github.com/makiuchi-d/gozxing/qrcode" + qrcode "github.com/skip2/go-qrcode" +) + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("crypha/qr test: forced read error") +} + +const testCover = "crypha" + +func qrRandom(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 skip2Clean(t *testing.T, cover string, version int) matrix { + t.Helper() + code, err := qrcode.NewWithForcedVersion(cover, version, qrcode.Highest) + if err != nil { + t.Fatalf("skip2 encode v%d: %v", version, err) + } + code.DisableBorder = true + m, err := matrixFromBitmap(code.Bitmap(), version) + if err != nil { + t.Fatalf("matrixFromBitmap v%d: %v", version, err) + } + return m +} + +func hideReveal(t *testing.T, cover string, payloadBytes []byte) []byte { + t.Helper() + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(cover), payloadBytes, &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + return got +} + +func decodeWithGozxing(t *testing.T, pngBytes []byte) string { + t.Helper() + img, _, err := stdimage.Decode(bytes.NewReader(pngBytes)) + if err != nil { + t.Fatalf("decode stego png: %v", err) + } + bmp, err := gozxing.NewBinaryBitmapFromImage(img) + if err != nil { + t.Fatalf("gozxing bitmap: %v", err) + } + res, err := gozxingqr.NewQRCodeReader().Decode(bmp, nil) + if err != nil { + t.Fatalf("gozxing decode: %v", err) + } + return res.GetText() +} + +func TestFormatCodeKAT(t *testing.T) { + cases := map[int]int{ + 0: 0x5412, + 1: 0x5125, + 9: 0x72f3, + 16: 0x1689, + 31: 0x2bed, + } + for data, want := range cases { + if got := formatCode(data); got != want { + t.Fatalf("formatCode(%d): got %#x want %#x", data, got, want) + } + } +} + +func TestCleanBlocksAreValidRSCodewords(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + clean := skip2Clean(t, testCover, version) + maskID, level, ok := parseFormat(clean) + if !ok { + t.Fatalf("v%d: parseFormat failed", version) + } + if level != ecLevelHigh { + t.Fatalf("v%d: parsed level %d want %d", version, level, ecLevelHigh) + } + spec := versionTable[version] + order := placementOrder(version, functionModules(version)) + if len(order) != spec.dataModules() { + t.Fatalf("v%d: placement visited %d modules want %d", version, len(order), spec.dataModules()) + } + serial := readSerial(clean, order, maskID, spec.totalCodewords()) + dataBlocks, ecBlocks, ok := spec.deinterleave(serial) + if !ok { + t.Fatalf("v%d: deinterleave failed", version) + } + for b := range dataBlocks { + recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...) + if !rsAllZero(rsSyndromes(recv, spec.ecPerBlock())) { + t.Fatalf("v%d block %d: extracted codeword is not a valid RS codeword", version, b) + } + } + } +} + +func TestReadWriteSymmetry(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + clean := skip2Clean(t, testCover, version) + maskID, _, _ := parseFormat(clean) + spec := versionTable[version] + order := placementOrder(version, functionModules(version)) + serial := readSerial(clean, order, maskID, spec.totalCodewords()) + dataBlocks, ecBlocks, _ := spec.deinterleave(serial) + + rebuilt := clean.clone() + writeSerial(rebuilt, order, maskID, spec.interleave(dataBlocks, ecBlocks)) + for y := 0; y < clean.size; y++ { + for x := 0; x < clean.size; x++ { + if rebuilt.grid[y][x] != clean.grid[y][x] { + t.Fatalf("v%d: re-render differs at (%d,%d)", version, x, y) + } + } + } + } +} + +func TestRoundTrip(t *testing.T) { + cases := []struct { + name string + payload []byte + }{ + {"single byte", []byte{0x42}}, + {"embedded zeros", []byte{0x00, 0x00, 0xFF, 0x00, 0x7F}}, + {"tiny text", []byte("hi")}, + {"medium text", []byte("meet at the docks")}, + {"twenty four bytes", bytes.Repeat([]byte{0xAB}, 24)}, + {"twenty six bytes", bytes.Repeat([]byte{0x5A}, 26)}, + {"high bits", bytes.Repeat([]byte{0xFF}, 40)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := hideReveal(t, testCover, tc.payload) + if !bytes.Equal(got, tc.payload) { + t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload) + } + }) + } +} + +func TestRandomBinaryRoundTrip(t *testing.T) { + for _, size := range []int{1, 3, 12, 27, 44, 52} { + payloadBytes := qrRandom(size, size*7+1) + got := hideReveal(t, testCover, payloadBytes) + if !bytes.Equal(got, payloadBytes) { + t.Fatalf("random round-trip mismatch at size %d", size) + } + } +} + +func TestStegoDecodesToCoverViaGozxing(t *testing.T) { + covers := []string{"crypha", "https://angelamos.com", "HELLO WORLD 123"} + for _, cover := range covers { + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(cover), qrRandom(16, len(cover)), &stego); err != nil { + t.Fatalf("Hide cover %q: %v", cover, err) + } + if got := decodeWithGozxing(t, stego.Bytes()); got != cover { + t.Fatalf("gozxing decoded stego to %q, want cover %q", got, cover) + } + } +} + +func TestPerVersionMaxCapacityRoundTripAndScan(t *testing.T) { + for version := minSupportedVersion; version <= maxSupportedVersion; version++ { + capBytes := versionTable[version].capacityBytes() + if capBytes == 0 { + continue + } + payloadBytes := qrRandom(capBytes, version*101+7) + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(testCover), payloadBytes, &stego); err != nil { + t.Fatalf("v%d Hide at capacity %d: %v", version, capBytes, err) + } + got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes())) + if err != nil { + t.Fatalf("v%d Reveal: %v", version, err) + } + if !bytes.Equal(got, payloadBytes) { + t.Fatalf("v%d: round-trip mismatch at max capacity", version) + } + if scanned := decodeWithGozxing(t, stego.Bytes()); scanned != testCover { + t.Fatalf("v%d: at full injection budget, gozxing decoded %q want cover %q", version, scanned, testCover) + } + } +} + +func TestCleanQRHasNoPayload(t *testing.T) { + clean := skip2Clean(t, testCover, 6) + var buf bytes.Buffer + if err := renderPNG(clean, &buf); err != nil { + t.Fatalf("render clean: %v", err) + } + if _, err := (qrCarrier{}).Reveal(bytes.NewReader(buf.Bytes())); err != ErrNoPayload { + t.Fatalf("clean QR reveal: got %v want ErrNoPayload", err) + } +} + +func TestCapacityBoundary(t *testing.T) { + atCap := bytes.Repeat([]byte{0x01}, maxCapacity()) + if got := hideReveal(t, testCover, atCap); !bytes.Equal(got, atCap) { + t.Fatal("payload at exact capacity failed to round-trip") + } + over := bytes.Repeat([]byte{0x01}, maxCapacity()+1) + err := (qrCarrier{}).Hide(strings.NewReader(testCover), over, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected capacity error for oversized payload") + } +} + +func TestCapacityReport(t *testing.T) { + got, err := (qrCarrier{}).Capacity(strings.NewReader(testCover)) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if got != maxCapacity() { + t.Fatalf("Capacity: got %d want %d", got, maxCapacity()) + } +} + +func TestCapacityTooLargeCoverIsZero(t *testing.T) { + large := strings.Repeat("a", 200) + got, err := (qrCarrier{}).Capacity(strings.NewReader(large)) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if got != 0 { + t.Fatalf("Capacity for a cover too large for any version: got %d want 0", got) + } +} + +func TestEmptyPayloadRejected(t *testing.T) { + if err := (qrCarrier{}).Hide(strings.NewReader(testCover), nil, &bytes.Buffer{}); err != ErrEmptyPayload { + t.Fatalf("expected ErrEmptyPayload, got %v", err) + } +} + +func TestEmptyCoverRejected(t *testing.T) { + if err := (qrCarrier{}).Hide(strings.NewReader(""), []byte("x"), &bytes.Buffer{}); err != ErrCoverRequired { + t.Fatalf("expected ErrCoverRequired, got %v", err) + } +} + +func TestCoverTooLargeRejected(t *testing.T) { + huge := strings.Repeat("A", 4000) + err := (qrCarrier{}).Hide(strings.NewReader(huge), []byte("x"), &bytes.Buffer{}) + if err != ErrCoverTooLarge { + t.Fatalf("expected ErrCoverTooLarge, got %v", err) + } +} + +func TestEncryptedEnvelopeExceedsQRCapacity(t *testing.T) { + env, err := payload.Pack([]byte("secret"), payload.Options{ + Passphrase: []byte("correct horse battery staple"), + Cipher: payload.CipherChaCha20, + Strength: payload.StrengthDefault, + }) + if err != nil { + t.Fatalf("Pack: %v", err) + } + if len(env) <= maxCapacity() { + t.Fatalf("encrypted envelope is %d bytes, expected to exceed qr capacity %d", len(env), maxCapacity()) + } + err = (qrCarrier{}).Hide(strings.NewReader(testCover), env, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected oversized encrypted envelope to be rejected") + } +} + +func TestUnencryptedEnvelopeThroughCarrier(t *testing.T) { + secret := []byte("qr covert") + env, err := payload.Pack(secret, payload.Options{Compress: true}) + if err != nil { + t.Fatalf("Pack: %v", err) + } + got := hideReveal(t, testCover, env) + if !bytes.Equal(got, env) { + t.Fatal("carrier did not return the exact envelope bytes") + } + plain, err := payload.Unpack(got, nil) + 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 TestSniff(t *testing.T) { + var stego bytes.Buffer + if err := (qrCarrier{}).Hide(strings.NewReader(testCover), []byte("payload"), &stego); err != nil { + t.Fatalf("Hide: %v", err) + } + + plain := stdimage.NewGray(stdimage.Rect(0, 0, 200, 200)) + for i := range plain.Pix { + plain.Pix[i] = 0xFF + } + var notQR bytes.Buffer + if err := png.Encode(¬QR, plain); err != nil { + t.Fatalf("encode plain png: %v", err) + } + + cases := []struct { + name string + data []byte + want bool + }{ + {"stego qr", stego.Bytes(), true}, + {"blank png", notQR.Bytes(), false}, + {"garbage", []byte("not an image at all"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (qrCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want { + t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestRevealRejectsGarbage(t *testing.T) { + if _, err := (qrCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err == nil { + t.Fatal("expected decode error for garbage input") + } + plain := stdimage.NewGray(stdimage.Rect(0, 0, 200, 200)) + for i := range plain.Pix { + plain.Pix[i] = 0xFF + } + var blank bytes.Buffer + if err := png.Encode(&blank, plain); err != nil { + t.Fatalf("encode blank: %v", err) + } + if _, err := (qrCarrier{}).Reveal(bytes.NewReader(blank.Bytes())); err == nil { + t.Fatal("blank png reveal: expected an error, got nil") + } +} + +func TestRegisteredInRegistry(t *testing.T) { + c, ok := carrier.Get(Format) + if !ok { + t.Fatal("qr carrier did not self-register") + } + if c.Format() != Format { + t.Fatalf("registry returned wrong carrier: %s", c.Format()) + } +} + +func TestReadErrorsPropagate(t *testing.T) { + if err := (qrCarrier{}).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil { + t.Fatal("Hide: expected cover read error") + } + if _, err := (qrCarrier{}).Capacity(errReader{}); err == nil { + t.Fatal("Capacity: expected cover read error") + } + if _, err := (qrCarrier{}).Reveal(errReader{}); err == nil { + t.Fatal("Reveal: expected stego read error") + } +} + +func TestUnframeRejectsBadLength(t *testing.T) { + if _, err := unframe([]byte{0, 1}); err != ErrNoPayload { + t.Fatalf("short frame: got %v want ErrNoPayload", err) + } + if _, err := unframe([]byte{0, 0, 0, 0}); err != ErrNoPayload { + t.Fatalf("zero-length frame: got %v want ErrNoPayload", err) + } + if _, err := unframe([]byte{0, 0, 0, 10, 1, 2, 3}); err != ErrNoPayload { + t.Fatalf("overlong length prefix: got %v want ErrNoPayload", err) + } +} + +func TestMatrixFromBitmapRejectsWrongSize(t *testing.T) { + tooSmall := make([][]bool, 10) + if _, err := matrixFromBitmap(tooSmall, 1); err != errBadSymbol { + t.Fatalf("wrong height: got %v want errBadSymbol", err) + } + raggedRows := make([][]bool, symbolSize(1)) + for i := range raggedRows { + raggedRows[i] = make([]bool, 5) + } + if _, err := matrixFromBitmap(raggedRows, 1); err != errBadSymbol { + t.Fatalf("wrong width: got %v want errBadSymbol", err) + } +} + +func TestInjectFramedRejectsOverflow(t *testing.T) { + spec := versionTable[2] + dataBlocks := [][]byte{make([]byte, spec.groups[0].data)} + oversized := make([]byte, spec.numBlocks()*spec.injectPerBlock()+1) + if err := injectFramed(dataBlocks, spec, oversized); !errors.Is(err, ErrPayloadTooLarge) { + t.Fatalf("overflow inject: got %v want ErrPayloadTooLarge", err) + } +} + +func TestSniffRejectsNonFinderImage(t *testing.T) { + size := symbolSize(1) + dim := (size + 2*quietZoneModules) * modulePixels + white := stdimage.NewGray(stdimage.Rect(0, 0, dim, dim)) + for i := range white.Pix { + white.Pix[i] = 0xFF + } + var buf bytes.Buffer + if err := png.Encode(&buf, white); err != nil { + t.Fatalf("encode white qr-sized png: %v", err) + } + if (qrCarrier{}).Sniff(bytes.NewReader(buf.Bytes())) { + t.Fatal("Sniff should reject a qr-sized image with no finder patterns") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go new file mode 100644 index 00000000..35036219 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs.go @@ -0,0 +1,221 @@ +/* +©AngelaMos | 2026 +rs.go + +Systematic Reed-Solomon over GF(2^8): encode plus a syndrome-Berlekamp-Massey decoder +*/ + +package qr + +import "errors" + +var ( + ErrRSInput = errors.New("crypha/qr: invalid reed-solomon block") + ErrRSUncorrectable = errors.New("crypha/qr: reed-solomon block is uncorrectable") +) + +func gfPolyEval(p []byte, x byte) byte { + y := p[0] + for i := 1; i < len(p); i++ { + y = gfMul(y, x) ^ p[i] + } + return y +} + +func gfPolyScale(p []byte, s byte) []byte { + out := make([]byte, len(p)) + for i := range p { + out[i] = gfMul(p[i], s) + } + return out +} + +func gfPolyAdd(a, b []byte) []byte { + n := len(a) + if len(b) > n { + n = len(b) + } + out := make([]byte, n) + for i := range a { + out[i+n-len(a)] = a[i] + } + for i := range b { + out[i+n-len(b)] ^= b[i] + } + return out +} + +func gfPolyMul(a, b []byte) []byte { + out := make([]byte, len(a)+len(b)-1) + for i := range a { + for j := range b { + out[i+j] ^= gfMul(a[i], b[j]) + } + } + return out +} + +func rsGenerator(nsym int) []byte { + g := []byte{1} + for i := 0; i < nsym; i++ { + g = gfPolyMul(g, []byte{1, gfPow(gfGenerator, i)}) + } + return g +} + +func rsEncode(data []byte, nsym int) []byte { + gen := rsGenerator(nsym) + out := make([]byte, len(data)+nsym) + copy(out, data) + for i := 0; i < len(data); i++ { + coef := out[i] + if coef != 0 { + for j := 1; j < len(gen); j++ { + out[i+j] ^= gfMul(gen[j], coef) + } + } + } + copy(out, data) + return out +} + +func rsSyndromes(recv []byte, nsym int) []byte { + s := make([]byte, nsym) + for i := 0; i < nsym; i++ { + s[i] = gfPolyEval(recv, gfPow(gfGenerator, i)) + } + return s +} + +func rsAllZero(s []byte) bool { + for _, v := range s { + if v != 0 { + return false + } + } + return true +} + +func rsErrorLocator(synd []byte) []byte { + errLoc := []byte{1} + oldLoc := []byte{1} + for i := 0; i < len(synd); i++ { + delta := synd[i] + for j := 1; j < len(errLoc); j++ { + delta ^= gfMul(errLoc[len(errLoc)-1-j], synd[i-j]) + } + oldLoc = append(oldLoc, 0) + if delta != 0 { + if len(oldLoc) > len(errLoc) { + newLoc := gfPolyScale(oldLoc, delta) + oldLoc = gfPolyScale(errLoc, gfInv(delta)) + errLoc = newLoc + } + errLoc = gfPolyAdd(errLoc, gfPolyScale(oldLoc, delta)) + } + } + for len(errLoc) > 0 && errLoc[0] == 0 { + errLoc = errLoc[1:] + } + return errLoc +} + +func rsErrorPositions(errLoc []byte, n int) ([]int, bool) { + numErr := len(errLoc) - 1 + positions := make([]int, 0, numErr) + for p := 0; p < n; p++ { + locator := gfPow(gfGenerator, (n-1-p)%gfOrder) + if gfPolyEval(errLoc, gfInv(locator)) == 0 { + positions = append(positions, p) + } + } + if len(positions) != numErr { + return nil, false + } + return positions, true +} + +func rsSolveMagnitudes(locators, synd []byte) ([]byte, bool) { + e := len(locators) + matrix := make([][]byte, e) + for row := 0; row < e; row++ { + matrix[row] = make([]byte, e+1) + for col := 0; col < e; col++ { + matrix[row][col] = gfPow(locators[col], row) + } + matrix[row][e] = synd[row] + } + + for col := 0; col < e; col++ { + pivot := -1 + for row := col; row < e; row++ { + if matrix[row][col] != 0 { + pivot = row + break + } + } + if pivot < 0 { + return nil, false + } + matrix[col], matrix[pivot] = matrix[pivot], matrix[col] + inv := gfInv(matrix[col][col]) + for k := col; k <= e; k++ { + matrix[col][k] = gfMul(matrix[col][k], inv) + } + for row := 0; row < e; row++ { + if row != col && matrix[row][col] != 0 { + factor := matrix[row][col] + for k := col; k <= e; k++ { + matrix[row][k] ^= gfMul(factor, matrix[col][k]) + } + } + } + } + + magnitudes := make([]byte, e) + for row := 0; row < e; row++ { + magnitudes[row] = matrix[row][e] + } + for j := 0; j < len(synd); j++ { + var acc byte + for m := 0; m < e; m++ { + acc ^= gfMul(magnitudes[m], gfPow(locators[m], j)) + } + if acc != synd[j] { + return nil, false + } + } + return magnitudes, true +} + +func rsDecode(recv []byte, nsym int) ([]byte, error) { + if nsym <= 0 || len(recv) <= nsym || len(recv) > gfOrder { + return nil, ErrRSInput + } + synd := rsSyndromes(recv, nsym) + if rsAllZero(synd) { + return append([]byte(nil), recv...), nil + } + errLoc := rsErrorLocator(synd) + numErr := len(errLoc) - 1 + if numErr <= 0 || numErr > nsym/2 { + return nil, ErrRSUncorrectable + } + positions, ok := rsErrorPositions(errLoc, len(recv)) + if !ok { + return nil, ErrRSUncorrectable + } + locators := make([]byte, numErr) + for m, p := range positions { + locators[m] = gfPow(gfGenerator, (len(recv)-1-p)%gfOrder) + } + magnitudes, ok := rsSolveMagnitudes(locators, synd) + if !ok { + return nil, ErrRSUncorrectable + } + corrected := append([]byte(nil), recv...) + for m, p := range positions { + corrected[p] ^= magnitudes[m] + } + return corrected, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go new file mode 100644 index 00000000..e8849e94 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/carrier/qr/rs_test.go @@ -0,0 +1,199 @@ +/* +©AngelaMos | 2026 +rs_test.go + +Field-table sanity plus Reed-Solomon encode/decode correctness under injected errors +*/ + +package qr + +import ( + "bytes" + "testing" +) + +func rsRandom(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 TestGFFieldTables(t *testing.T) { + if gfExp[0] != 1 { + t.Fatalf("gfExp[0]: got %d want 1", gfExp[0]) + } + if gfExp[8] != 29 { + t.Fatalf("gfExp[8]: got %d want 29 (primitive 0x11D)", gfExp[8]) + } + if gfMul(2, 2) != 4 { + t.Fatalf("gfMul(2,2): got %d want 4", gfMul(2, 2)) + } + if gfPow(2, 8) != 29 { + t.Fatalf("gfPow(2,8): got %d want 29", gfPow(2, 8)) + } + for a := 1; a < gfFieldSize; a++ { + if gfMul(byte(a), gfInv(byte(a))) != 1 { + t.Fatalf("gfInv broken at %d", a) + } + } + for a := 1; a < gfFieldSize; a++ { + for b := 1; b < gfFieldSize; b++ { + if gfMul(byte(a), byte(b)) != gfMul(byte(b), byte(a)) { + t.Fatalf("gfMul not commutative at %d,%d", a, b) + } + } + } +} + +func TestGFPowEdges(t *testing.T) { + if gfPow(gfGenerator, 0) != 1 { + t.Fatalf("gfPow(2,0): got %d want 1", gfPow(gfGenerator, 0)) + } + if gfPow(gfGenerator, gfOrder) != 1 { + t.Fatalf("gfPow(2,255): got %d want 1", gfPow(gfGenerator, gfOrder)) + } + if gfPow(0, 0) != 1 { + t.Fatalf("gfPow(0,0): got %d want 1", gfPow(0, 0)) + } + if gfPow(0, 5) != 0 { + t.Fatalf("gfPow(0,5): got %d want 0", gfPow(0, 5)) + } +} + +func TestRSGeneratorIsMonic(t *testing.T) { + for _, nsym := range []int{7, 17, 22, 28} { + gen := rsGenerator(nsym) + if len(gen) != nsym+1 { + t.Fatalf("generator degree: nsym=%d got len %d", nsym, len(gen)) + } + if gen[0] != 1 { + t.Fatalf("generator not monic: nsym=%d leading %d", nsym, gen[0]) + } + } +} + +func TestRSCleanRoundTrip(t *testing.T) { + cases := []struct{ k, nsym int }{ + {9, 17}, + {13, 22}, + {16, 28}, + {15, 28}, + } + for _, tc := range cases { + data := rsRandom(tc.k, tc.k*tc.nsym) + code := rsEncode(data, tc.nsym) + if len(code) != tc.k+tc.nsym { + t.Fatalf("encode length: got %d want %d", len(code), tc.k+tc.nsym) + } + if !bytes.Equal(code[:tc.k], data) { + t.Fatal("encode is not systematic (data prefix altered)") + } + got, err := rsDecode(code, tc.nsym) + if err != nil { + t.Fatalf("decode clean codeword: %v", err) + } + if !bytes.Equal(got[:tc.k], data) { + t.Fatal("clean decode did not return data") + } + } +} + +func TestRSCorrectsUpToT(t *testing.T) { + cases := []struct{ k, nsym int }{ + {9, 17}, + {13, 22}, + {16, 28}, + } + for _, tc := range cases { + n := tc.k + tc.nsym + maxErr := tc.nsym / 2 + data := rsRandom(tc.k, tc.k+7*tc.nsym) + clean := rsEncode(data, tc.nsym) + for numErr := 1; numErr <= maxErr; numErr++ { + corrupt := append([]byte(nil), clean...) + offsets := rsRandom(numErr, numErr*97+tc.nsym) + mags := rsRandom(numErr, numErr*131+tc.k) + used := map[int]bool{} + placed := 0 + for i := 0; placed < numErr; i++ { + pos := int(offsets[placed%numErr]) % n + pos = (pos + i) % n + if used[pos] { + continue + } + mag := mags[placed%numErr] + if mag == 0 { + mag = 1 + } + corrupt[pos] ^= mag + used[pos] = true + placed++ + } + got, err := rsDecode(corrupt, tc.nsym) + if err != nil { + t.Fatalf("k=%d nsym=%d numErr=%d: decode failed: %v", tc.k, tc.nsym, numErr, err) + } + if !bytes.Equal(got[:tc.k], data) { + t.Fatalf("k=%d nsym=%d numErr=%d: did not recover data", tc.k, tc.nsym, numErr) + } + } + } +} + +func TestRSDoesNotFakeCorrectBeyondT(t *testing.T) { + k, nsym := 13, 22 + n := k + nsym + data := rsRandom(k, 999) + clean := rsEncode(data, nsym) + tooMany := nsym/2 + 1 + for seed := 0; seed < 16; seed++ { + corrupt := append([]byte(nil), clean...) + offsets := rsRandom(tooMany, seed*17+3) + mags := rsRandom(tooMany, seed*29+5) + used := map[int]bool{} + placed := 0 + for i := 0; placed < tooMany; i++ { + pos := (int(offsets[placed%tooMany]) + i) % n + if used[pos] { + continue + } + mag := mags[placed%tooMany] + if mag == 0 { + mag = 1 + } + corrupt[pos] ^= mag + used[pos] = true + placed++ + } + got, err := rsDecode(corrupt, nsym) + if err == nil && bytes.Equal(got[:k], data) { + t.Fatalf("seed %d: decoder silently recovered original from %d errors (> t)", seed, tooMany) + } + } +} + +func TestGFPolyAddAsymmetric(t *testing.T) { + want := []byte{1, 1, 0} + if got := gfPolyAdd([]byte{1, 0, 1}, []byte{1, 1}); !bytes.Equal(got, want) { + t.Fatalf("gfPolyAdd longer-first: got %v want %v", got, want) + } + if got := gfPolyAdd([]byte{1, 1}, []byte{1, 0, 1}); !bytes.Equal(got, want) { + t.Fatalf("gfPolyAdd longer-second: got %v want %v", got, want) + } +} + +func TestRSRejectsMalformedBlocks(t *testing.T) { + if _, err := rsDecode([]byte{1, 2, 3}, 0); err != ErrRSInput { + t.Fatalf("nsym=0: got %v want ErrRSInput", err) + } + if _, err := rsDecode([]byte{1, 2, 3}, 3); err != ErrRSInput { + t.Fatalf("recv==nsym: got %v want ErrRSInput", err) + } + if _, err := rsDecode([]byte{1, 2, 3}, 5); err != ErrRSInput { + t.Fatalf("recv Date: Wed, 15 Jul 2026 21:39:26 -0400 Subject: [PATCH 05/13] feat(crypha): add cobra cli, shared engine, and report layer (M7) --- .../steganography-multi-tool/.golangci.yml | 2 + .../beginner/steganography-multi-tool/go.mod | 1 + .../beginner/steganography-multi-tool/go.sum | 2 + .../internal/cli/capacity.go | 63 +++ .../internal/cli/cli_test.go | 409 ++++++++++++++++++ .../internal/cli/flags.go | 67 +++ .../internal/cli/formats.go | 24 + .../internal/cli/hide.go | 114 +++++ .../internal/cli/interactive_test.go | 198 +++++++++ .../internal/cli/io.go | 66 +++ .../internal/cli/passphrase.go | 82 ++++ .../internal/cli/reveal.go | 105 +++++ .../internal/cli/root.go | 37 +- .../internal/cli/version.go | 24 + .../internal/config/cli.go | 48 ++ .../internal/engine/engine.go | 205 +++++++++ .../internal/engine/engine_test.go | 233 ++++++++++ .../internal/payload/envelope_test.go | 20 + .../internal/payload/payload.go | 24 +- .../internal/report/report.go | 295 +++++++++++++ .../internal/report/report_test.go | 166 +++++++ 21 files changed, 2168 insertions(+), 17 deletions(-) create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/report/report.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go diff --git a/PROJECTS/beginner/steganography-multi-tool/.golangci.yml b/PROJECTS/beginner/steganography-multi-tool/.golangci.yml index 0d61cf12..da718b77 100644 --- a/PROJECTS/beginner/steganography-multi-tool/.golangci.yml +++ b/PROJECTS/beginner/steganography-multi-tool/.golangci.yml @@ -20,7 +20,9 @@ linters: settings: gosec: excludes: + - G101 - G115 + - G304 formatters: enable: diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index d9201611..c653432a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -12,6 +12,7 @@ require ( github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.52.0 golang.org/x/image v0.44.0 + golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 ) diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index cef14c9a..d6cb166a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -48,6 +48,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 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= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go new file mode 100644 index 00000000..8fc5232d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go @@ -0,0 +1,63 @@ +/* +©AngelaMos | 2026 +capacity.go + +The capacity command: report how much a cover can hold, per carrier +*/ + +package cli + +import ( + "bytes" + "os" + + "github.com/CarterPerez-dev/crypha/internal/engine" + "github.com/CarterPerez-dev/crypha/internal/report" + "github.com/spf13/cobra" +) + +func newCapacityCmd() *cobra.Command { + var format, in string + + cmd := &cobra.Command{ + Use: cmdCapacity + " -i COVER [--format F]", + Short: "Report how many bytes a cover can hide", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := pathFromFlagOrArg(in, args) + if err != nil { + return err + } + if path == "" { + return errNoCover + } + cover, err := os.ReadFile(path) + if err != nil { + return err + } + + rows, err := capacityRows(format, cover) + if err != nil { + return err + } + return report.Capacity(cmd.OutOrStdout(), rows, jsonEnabled(cmd)) + }, + } + + f := cmd.Flags() + f.StringVar(&format, flagFormat, "", "carrier format (all applicable formats if omitted)") + f.StringVarP(&in, flagIn, shortIn, "", "cover input path (or pass as an argument)") + + return cmd +} + +func capacityRows(format string, cover []byte) ([]engine.CapacityRow, error) { + if format == "" { + return engine.CapacityAll(cover), nil + } + if _, err := engine.ResolveCarrier(format, ""); err != nil { + return nil, err + } + n, cerr := engine.Capacity(format, bytes.NewReader(cover)) + return []engine.CapacityRow{{Format: format, Capacity: n, Err: cerr}}, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go new file mode 100644 index 00000000..edea0f78 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go @@ -0,0 +1,409 @@ +/* +©AngelaMos | 2026 +cli_test.go + +End-to-end tests that drive the crypha commands through the cobra tree +*/ + +package cli + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/payload" + goaudio "github.com/go-audio/audio" + "github.com/go-audio/wav" + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +const secret = "meet at dawn" + +func run(t *testing.T, stdin string, args ...string) (string, string, error) { + t.Helper() + root := newRootCmd() + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetIn(strings.NewReader(stdin)) + root.SetArgs(args) + err := root.Execute() + return out.String(), errb.String(), err +} + +func makeTextCover(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "cover.txt") + if err := os.WriteFile(path, []byte("the quick brown fox jumps over the lazy dog"), 0o600); err != nil { + t.Fatalf("write text cover: %v", err) + } + return path +} + +func makeQRCover(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "cover.qrtext") + if err := os.WriteFile(path, []byte("crypha qr cover"), 0o600); err != nil { + t.Fatalf("write qr cover: %v", err) + } + return path +} + +func makePNGCover(t *testing.T, dir string) string { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, 96, 96)) + for y := 0; y < 96; y++ { + for x := 0; x < 96; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x ^ y), A: 255}) + } + } + path := filepath.Join(dir, "cover.png") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create png: %v", err) + } + defer func() { _ = f.Close() }() + if err := png.Encode(f, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return path +} + +func makeWAVCover(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "cover.wav") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create wav: %v", err) + } + defer func() { _ = f.Close() }() + enc := wav.NewEncoder(f, 44100, 16, 1, 1) + data := make([]int, 16000) + x := uint32(1) + for i := range data { + x = x*1664525 + 1013904223 + data[i] = int(int16(x >> 16)) + } + buf := &goaudio.IntBuffer{ + Format: &goaudio.Format{NumChannels: 1, SampleRate: 44100}, + Data: data, + SourceBitDepth: 16, + } + if err := enc.Write(buf); err != nil { + t.Fatalf("write wav: %v", err) + } + if err := enc.Close(); err != nil { + t.Fatalf("close wav: %v", err) + } + return path +} + +func makePDFCover(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "cover.pdf") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create pdf: %v", err) + } + defer func() { _ = f.Close() }() + conf := model.NewDefaultConfiguration() + conf.ValidationMode = model.ValidationRelaxed + if err := api.Create(nil, strings.NewReader(`{"pages":{"1":{"content":{}}}}`), f, conf); err != nil { + t.Fatalf("create pdf content: %v", err) + } + return path +} + +func TestHideRevealPerFormat(t *testing.T) { + dir := t.TempDir() + covers := map[string]string{ + "text": makeTextCover(t, dir), + "image": makePNGCover(t, dir), + "audio": makeWAVCover(t, dir), + "pdf": makePDFCover(t, dir), + "qr": makeQRCover(t, dir), + } + for format, cover := range covers { + t.Run(format, func(t *testing.T) { + stego := filepath.Join(dir, format+".stego") + if _, _, err := run(t, "", "hide", "--format", format, "-i", cover, "-o", stego, "-m", secret); err != nil { + t.Fatalf("hide: %v", err) + } + forced, _, err := run(t, "", "reveal", "--format", format, "-i", stego) + if err != nil { + t.Fatalf("reveal --format: %v", err) + } + if forced != secret { + t.Fatalf("reveal --format = %q, want %q", forced, secret) + } + auto, _, err := run(t, "", "reveal", "-i", stego) + if err != nil { + t.Fatalf("reveal auto-detect: %v", err) + } + if auto != secret { + t.Fatalf("reveal auto-detect = %q, want %q", auto, secret) + } + }) + } +} + +func TestPDFTechniques(t *testing.T) { + dir := t.TempDir() + cover := makePDFCover(t, dir) + for _, tech := range []string{"attachment", "metadata", "append"} { + t.Run(tech, func(t *testing.T) { + stego := filepath.Join(dir, tech+".pdf") + if _, _, err := run(t, "", "hide", "--format", "pdf", "--technique", tech, "-i", cover, "-o", stego, "-m", secret); err != nil { + t.Fatalf("hide %s: %v", tech, err) + } + out, _, err := run(t, "", "reveal", "--format", "pdf", "-i", stego) + if err != nil { + t.Fatalf("reveal %s: %v", tech, err) + } + if out != secret { + t.Fatalf("technique %s = %q, want %q", tech, out, secret) + } + }) + } +} + +func TestRevealPositionalArg(t *testing.T) { + dir := t.TempDir() + cover := makeTextCover(t, dir) + stego := filepath.Join(dir, "positional.txt") + if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil { + t.Fatalf("hide: %v", err) + } + out, _, err := run(t, "", "reveal", stego) + if err != nil { + t.Fatalf("reveal positional: %v", err) + } + if out != secret { + t.Fatalf("reveal positional = %q, want %q", out, secret) + } +} + +func TestPayloadFromFileAndStdin(t *testing.T) { + dir := t.TempDir() + cover := makePNGCover(t, dir) + payloadFile := filepath.Join(dir, "payload.bin") + if err := os.WriteFile(payloadFile, []byte(secret), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + fileStego := filepath.Join(dir, "fromfile.png") + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", fileStego, "-f", payloadFile); err != nil { + t.Fatalf("hide -f: %v", err) + } + out, _, err := run(t, "", "reveal", "-i", fileStego) + if err != nil || out != secret { + t.Fatalf("reveal from -f = %q err %v", out, err) + } + + stdinStego := filepath.Join(dir, "fromstdin.png") + if _, _, err := run(t, secret, "hide", "--format", "image", "-i", cover, "-o", stdinStego, "-f", stdioPath); err != nil { + t.Fatalf("hide -f -: %v", err) + } + out, _, err = run(t, "", "reveal", "-i", stdinStego) + if err != nil || out != secret { + t.Fatalf("reveal from stdin = %q err %v", out, err) + } +} + +func TestEncryptedRoundTripCLI(t *testing.T) { + t.Setenv(envPassphrase, "") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "enc.png") + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt", "-k", "hunter2"); err != nil { + t.Fatalf("hide encrypted: %v", err) + } + + out, _, err := run(t, "", "reveal", "-i", stego, "-k", "hunter2") + if err != nil || out != secret { + t.Fatalf("reveal with key = %q err %v", out, err) + } + + if _, _, err := run(t, "", "reveal", "-i", stego); !errors.Is(err, payload.ErrPassphraseRequired) { + t.Fatalf("no-key reveal err = %v, want ErrPassphraseRequired", err) + } + if _, _, err := run(t, "", "reveal", "-i", stego, "-k", "wrong"); !errors.Is(err, payload.ErrDecrypt) { + t.Fatalf("wrong-key reveal err = %v, want ErrDecrypt", err) + } +} + +func TestEnvPassphrase(t *testing.T) { + t.Setenv(envPassphrase, "from-the-env") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "env.png") + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt"); err != nil { + t.Fatalf("hide with env: %v", err) + } + out, _, err := run(t, "", "reveal", "-i", stego) + if err != nil || out != secret { + t.Fatalf("reveal with env = %q err %v", out, err) + } +} + +func TestAES256GCMAndCompress(t *testing.T) { + t.Setenv(envPassphrase, "") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "aes.png") + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, + "-k", "hunter2", "--cipher", "aes256gcm", "--compress", "--strength", "high"); err != nil { + t.Fatalf("hide aes: %v", err) + } + out, _, err := run(t, "", "reveal", "-i", stego, "-k", "hunter2") + if err != nil || out != secret { + t.Fatalf("reveal aes = %q err %v", out, err) + } +} + +func TestFormatsCommand(t *testing.T) { + out, _, err := run(t, "", "formats") + if err != nil { + t.Fatalf("formats: %v", err) + } + for _, f := range []string{"image", "audio", "qr", "text", "pdf"} { + if !strings.Contains(out, f) { + t.Errorf("formats missing %q", f) + } + } + jsonOut, _, err := run(t, "", "formats", "--json") + if err != nil { + t.Fatalf("formats --json: %v", err) + } + if !json.Valid([]byte(jsonOut)) { + t.Errorf("formats --json is not valid json: %s", jsonOut) + } +} + +func TestVersionCommand(t *testing.T) { + out, _, err := run(t, "", "version") + if err != nil { + t.Fatalf("version: %v", err) + } + if !strings.Contains(out, "crypha") || !strings.Contains(out, "0.1.0") { + t.Errorf("version = %q", out) + } +} + +func TestCapacityCommand(t *testing.T) { + dir := t.TempDir() + cover := makePNGCover(t, dir) + + all, _, err := run(t, "", "capacity", "-i", cover) + if err != nil { + t.Fatalf("capacity: %v", err) + } + if !strings.Contains(all, "image") { + t.Errorf("capacity table missing image row:\n%s", all) + } + + one, _, err := run(t, "", "capacity", "-i", cover, "--format", "image") + if err != nil { + t.Fatalf("capacity --format: %v", err) + } + if !strings.Contains(one, "image") { + t.Errorf("single capacity missing image:\n%s", one) + } + + jsonOut, _, err := run(t, "", "capacity", "-i", cover, "--json") + if err != nil { + t.Fatalf("capacity --json: %v", err) + } + if !json.Valid([]byte(jsonOut)) { + t.Errorf("capacity --json invalid: %s", jsonOut) + } +} + +func TestErrorPaths(t *testing.T) { + dir := t.TempDir() + cover := makePNGCover(t, dir) + out := filepath.Join(dir, "out.png") + + cases := []struct { + name string + args []string + want string + }{ + {"unknown format", []string{"hide", "--format", "bogus", "-i", cover, "-o", out, "-m", "x"}, "unknown carrier format"}, + {"unknown cipher", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-k", "p", "--cipher", "des"}, "unknown cipher"}, + {"both sources", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-f", cover}, "only one of"}, + {"technique on non-pdf", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "--technique", "append"}, "technique only applies"}, + {"no payload source", []string{"hide", "--format", "image", "-i", cover, "-o", out}, "provide a payload"}, + {"reveal no stego", []string{"reveal"}, "provide a stego file"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := run(t, "", tc.args...) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %q, want it to contain %q", err.Error(), tc.want) + } + }) + } +} + +func TestHideMissingRequiredFlags(t *testing.T) { + if _, _, err := run(t, "", "hide", "--format", "image", "-m", "x"); err == nil { + t.Fatal("expected required-flag error for missing -i/-o") + } +} + +func TestRevealJSONToStdout(t *testing.T) { + dir := t.TempDir() + cover := makeTextCover(t, dir) + stego := filepath.Join(dir, "json.txt") + if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil { + t.Fatalf("hide: %v", err) + } + + stdout, _, err := run(t, "", "reveal", "-i", stego, "--json") + if err != nil { + t.Fatalf("reveal --json: %v", err) + } + if !json.Valid([]byte(stdout)) { + t.Fatalf("reveal --json stdout is not valid json: %s", stdout) + } + var obj struct { + Format string `json:"format"` + Data string `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if obj.Format != "text" { + t.Errorf("format = %q, want text", obj.Format) + } + decoded, err := base64.StdEncoding.DecodeString(obj.Data) + if err != nil || string(decoded) != secret { + t.Fatalf("decoded data = %q err %v, want %q", decoded, err, secret) + } +} + +func TestRevealAmbiguousPath(t *testing.T) { + dir := t.TempDir() + cover := makeTextCover(t, dir) + stego := filepath.Join(dir, "amb.txt") + if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil { + t.Fatalf("hide: %v", err) + } + if _, _, err := run(t, "", "reveal", "-i", stego, stego); !errors.Is(err, errAmbiguousPath) { + t.Fatalf("err = %v, want errAmbiguousPath", err) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go new file mode 100644 index 00000000..38a6d13f --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go @@ -0,0 +1,67 @@ +/* +©AngelaMos | 2026 +flags.go + +Shared command and flag identifiers plus small helpers for the crypha cobra tree +*/ + +package cli + +import "github.com/spf13/cobra" + +const ( + cmdHide = "hide" + cmdReveal = "reveal" + cmdCapacity = "capacity" + cmdFormats = "formats" + cmdVersion = "version" + + flagJSON = "json" + flagFormat = "format" + flagIn = "in" + flagOut = "out" + flagMessage = "message" + flagFile = "file" + flagEncrypt = "encrypt" + flagPassphrase = "passphrase" + flagCompress = "compress" + flagCipher = "cipher" + flagStrength = "strength" + flagTechnique = "technique" + + shortIn = "i" + shortOut = "o" + shortMessage = "m" + shortFile = "f" + shortPassphrase = "k" + + envPassphrase = "CRYPHA_PASSPHRASE" + + stdioPath = "-" + stdoutName = "(stdout)" + outFilePerm = 0o600 +) + +func markRequired(cmd *cobra.Command, names ...string) { + for _, name := range names { + _ = cmd.MarkFlagRequired(name) + } +} + +func jsonEnabled(cmd *cobra.Command) bool { + on, _ := cmd.Flags().GetBool(flagJSON) + return on +} + +func pathFromFlagOrArg(flagValue string, args []string) (string, error) { + if flagValue != "" && len(args) == 1 { + return "", errAmbiguousPath + } + if flagValue != "" { + return flagValue, nil + } + if len(args) == 1 { + return args[0], nil + } + return "", nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go new file mode 100644 index 00000000..06209a43 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go @@ -0,0 +1,24 @@ +/* +©AngelaMos | 2026 +formats.go + +The formats command: list every registered carrier and its capabilities +*/ + +package cli + +import ( + "github.com/CarterPerez-dev/crypha/internal/report" + "github.com/spf13/cobra" +) + +func newFormatsCmd() *cobra.Command { + return &cobra.Command{ + Use: cmdFormats, + Short: "List the available carrier formats", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return report.Formats(cmd.OutOrStdout(), jsonEnabled(cmd)) + }, + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go new file mode 100644 index 00000000..c9743c53 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go @@ -0,0 +1,114 @@ +/* +©AngelaMos | 2026 +hide.go + +The hide command: pack a payload into the envelope and embed it in a cover +*/ + +package cli + +import ( + "bytes" + "errors" + "fmt" + "os" + + "github.com/CarterPerez-dev/crypha/internal/engine" + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/CarterPerez-dev/crypha/internal/report" + "github.com/spf13/cobra" +) + +var ( + errUnknownCipher = errors.New("unknown cipher") + errUnknownStrength = errors.New("unknown key-derivation strength") +) + +func newHideCmd() *cobra.Command { + var ( + format, technique string + in, out string + message, file string + passphrase string + cipher, strength string + encrypt, compress bool + ) + + cmd := &cobra.Command{ + Use: cmdHide + " --format F -i COVER -o OUT (-m MSG | -f FILE)", + Short: "Hide a payload inside a cover file", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + data, err := readPayloadSource(cmd, message, file) + if err != nil { + return err + } + + pass, err := resolveHidePassphrase(passphrase, encrypt) + if err != nil { + return err + } + + opts, err := buildOptions(pass, compress, cipher, strength) + if err != nil { + return err + } + + cover, err := os.ReadFile(in) + if err != nil { + return err + } + + var stego bytes.Buffer + res, err := engine.Hide(engine.HideRequest{ + Format: format, + Technique: technique, + Cover: bytes.NewReader(cover), + Payload: data, + Out: &stego, + Options: opts, + }) + if err != nil { + return err + } + + if err := writeOutputFile(out, stego.Bytes()); err != nil { + return err + } + return report.HideSummary(cmd.OutOrStdout(), res, out, jsonEnabled(cmd)) + }, + } + + f := cmd.Flags() + f.StringVar(&format, flagFormat, "", "carrier format: image, audio, qr, text, pdf") + f.StringVarP(&in, flagIn, shortIn, "", "cover input path") + f.StringVarP(&out, flagOut, shortOut, "", "stego output path") + f.StringVarP(&message, flagMessage, shortMessage, "", "inline message payload") + f.StringVarP(&file, flagFile, shortFile, "", "payload file path (- for stdin)") + f.BoolVar(&encrypt, flagEncrypt, false, "encrypt the payload with a passphrase") + f.StringVarP(&passphrase, flagPassphrase, shortPassphrase, "", "passphrase (prefer the prompt or "+envPassphrase+")") + f.BoolVar(&compress, flagCompress, false, "compress the payload before hiding") + f.StringVar(&cipher, flagCipher, string(payload.CipherChaCha20), "cipher: chacha20 or aes256gcm") + f.StringVar(&strength, flagStrength, string(payload.StrengthDefault), "key-derivation strength: default or high") + f.StringVar(&technique, flagTechnique, "", "pdf technique: attachment, metadata, or append") + markRequired(cmd, flagFormat, flagIn, flagOut) + + return cmd +} + +func buildOptions(pass []byte, compress bool, cipher, strength string) (payload.Options, error) { + c := payload.Cipher(cipher) + if c != payload.CipherChaCha20 && c != payload.CipherAES256GCM { + return payload.Options{}, fmt.Errorf("%w: %q", errUnknownCipher, cipher) + } + s := payload.Strength(strength) + if s != payload.StrengthDefault && s != payload.StrengthHigh { + return payload.Options{}, fmt.Errorf("%w: %q", errUnknownStrength, strength) + } + return payload.Options{ + Passphrase: pass, + Compress: compress, + Cipher: c, + Strength: s, + }, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go new file mode 100644 index 00000000..40743ce6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go @@ -0,0 +1,198 @@ +/* +©AngelaMos | 2026 +interactive_test.go + +Tests for passphrase prompting, reprompt loops, and output branches +*/ + +package cli + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/payload" +) + +func stubTerminal(t *testing.T, inter bool, secrets ...string) { + t.Helper() + origInter, origRead := interactive, readSecret + t.Cleanup(func() { interactive, readSecret = origInter, origRead }) + interactive = func() bool { return inter } + idx := 0 + readSecret = func() ([]byte, error) { + if idx >= len(secrets) { + return nil, io.EOF + } + s := secrets[idx] + idx++ + return []byte(s), nil + } +} + +func TestPromptForError(t *testing.T) { + if got := promptForError(payload.ErrPassphraseRequired); got != promptEnterPassphrase { + t.Errorf("passphrase-required prompt = %q", got) + } + if got := promptForError(payload.ErrDecrypt); got != promptRetryPassphrase { + t.Errorf("decrypt prompt = %q", got) + } +} + +func TestInteractiveHidePromptRoundTrip(t *testing.T) { + t.Setenv(envPassphrase, "") + stubTerminal(t, true, "prompted-secret", "prompted-secret") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "prompted.png") + + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt"); err != nil { + t.Fatalf("hide with prompt: %v", err) + } + out, _, err := run(t, "", "reveal", "-i", stego, "-k", "prompted-secret") + if err != nil || out != secret { + t.Fatalf("reveal = %q err %v", out, err) + } +} + +func TestInteractiveHidePromptMismatch(t *testing.T) { + t.Setenv(envPassphrase, "") + stubTerminal(t, true, "one", "two") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "mismatch.png") + + _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt") + if !errors.Is(err, errPassphraseMismatch) { + t.Fatalf("err = %v, want errPassphraseMismatch", err) + } +} + +func TestHideEncryptNonInteractiveNoKey(t *testing.T) { + t.Setenv(envPassphrase, "") + stubTerminal(t, false) + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "nokey.png") + + _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt") + if !errors.Is(err, errPassphraseNonInteractive) { + t.Fatalf("err = %v, want errPassphraseNonInteractive", err) + } +} + +func TestRevealReprompt(t *testing.T) { + t.Setenv(envPassphrase, "") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "reprompt.png") + + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "rightpass"); err != nil { + t.Fatalf("hide: %v", err) + } + + stubTerminal(t, true, "wrongpass", "rightpass") + out, _, err := run(t, "", "reveal", "-i", stego) + if err != nil || out != secret { + t.Fatalf("reveal after reprompt = %q err %v", out, err) + } +} + +func TestRevealRepromptExhausted(t *testing.T) { + t.Setenv(envPassphrase, "") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "exhausted.png") + + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "rightpass"); err != nil { + t.Fatalf("hide: %v", err) + } + + stubTerminal(t, true, "no", "no", "no") + _, _, err := run(t, "", "reveal", "-i", stego) + if !errors.Is(err, payload.ErrDecrypt) { + t.Fatalf("err = %v, want ErrDecrypt after exhausting attempts", err) + } +} + +func TestRevealToFile(t *testing.T) { + dir := t.TempDir() + cover := makeTextCover(t, dir) + stego := filepath.Join(dir, "tofile.txt") + if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil { + t.Fatalf("hide: %v", err) + } + + out := filepath.Join(dir, "revealed.txt") + stdout, _, err := run(t, "", "reveal", "-i", stego, "-o", out) + if err != nil { + t.Fatalf("reveal -o: %v", err) + } + if stdout != "" { + t.Errorf("stdout should be empty when writing to a file, got %q", stdout) + } + got, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read revealed file: %v", err) + } + if string(got) != secret { + t.Errorf("revealed file = %q, want %q", got, secret) + } +} + +func TestBuildOptionsUnknownStrength(t *testing.T) { + dir := t.TempDir() + cover := makePNGCover(t, dir) + out := filepath.Join(dir, "out.png") + _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-k", "p", "--strength", "ultra") + if err == nil || !errors.Is(err, errUnknownStrength) { + t.Fatalf("err = %v, want errUnknownStrength", err) + } +} + +func TestCapacityUnknownFormat(t *testing.T) { + dir := t.TempDir() + cover := makePNGCover(t, dir) + _, _, err := run(t, "", "capacity", "-i", cover, "--format", "bogus") + if err == nil { + t.Fatal("expected error for unknown format") + } +} + +func TestMissingFileErrors(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "does-not-exist") + out := filepath.Join(dir, "out.png") + cases := [][]string{ + {"hide", "--format", "image", "-i", missing, "-o", out, "-m", "x"}, + {"reveal", "-i", missing}, + {"capacity", "-i", missing}, + } + for _, args := range cases { + if _, _, err := run(t, "", args...); err == nil { + t.Errorf("%v: expected error for missing file", args) + } + } +} + +func TestRevealPromptReadError(t *testing.T) { + t.Setenv(envPassphrase, "") + dir := t.TempDir() + cover := makePNGCover(t, dir) + stego := filepath.Join(dir, "readerr.png") + if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "pw"); err != nil { + t.Fatalf("hide: %v", err) + } + + origInter, origRead := interactive, readSecret + t.Cleanup(func() { interactive, readSecret = origInter, origRead }) + interactive = func() bool { return true } + readSecret = func() ([]byte, error) { return nil, io.ErrUnexpectedEOF } + + if _, _, err := run(t, "", "reveal", "-i", stego); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("err = %v, want a terminal read error", err) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go new file mode 100644 index 00000000..2dae77e1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go @@ -0,0 +1,66 @@ +/* +©AngelaMos | 2026 +io.go + +Payload source reading and revealed-output writing for the crypha CLI +*/ + +package cli + +import ( + "errors" + "io" + "os" + "unicode/utf8" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var ( + errNoStego = errors.New("provide a stego file with -i or as an argument") + errNoCover = errors.New("provide a cover file with -i or as an argument") + errNoPayloadSource = errors.New("provide a payload with -m or -f") + errBothSources = errors.New("use only one of -m or -f") + errBinaryToTTY = errors.New("payload is binary; write it to a file with -o") + errAmbiguousPath = errors.New("pass the file once, via -i or as an argument, not both") +) + +func readPayloadSource(cmd *cobra.Command, message, file string) ([]byte, error) { + switch { + case message != "" && file != "": + return nil, errBothSources + case message != "": + return []byte(message), nil + case file == stdioPath: + return io.ReadAll(cmd.InOrStdin()) + case file != "": + return os.ReadFile(file) + default: + return nil, errNoPayloadSource + } +} + +func writeOutputFile(outPath string, data []byte) error { + return os.WriteFile(outPath, data, outFilePerm) +} + +func writeRevealed(cmd *cobra.Command, data []byte, outPath string) (string, error) { + if outPath != "" { + if err := os.WriteFile(outPath, data, outFilePerm); err != nil { + return "", err + } + return outPath, nil + } + if stdoutIsTerminal() && !utf8.Valid(data) { + return "", errBinaryToTTY + } + if _, err := cmd.OutOrStdout().Write(data); err != nil { + return "", err + } + return stdoutName, nil +} + +func stdoutIsTerminal() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go new file mode 100644 index 00000000..46c500e1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go @@ -0,0 +1,82 @@ +/* +©AngelaMos | 2026 +passphrase.go + +Passphrase acquisition from flag, environment, or a no-echo terminal prompt +*/ + +package cli + +import ( + "bytes" + "errors" + "fmt" + "os" + + "golang.org/x/term" +) + +const ( + promptEnterPassphrase = "Passphrase: " + promptConfirmPassphrase = "Confirm passphrase: " + promptRetryPassphrase = "Wrong passphrase, try again: " + maxPassphraseAttempts = 3 +) + +var ( + errPassphraseNonInteractive = errors.New("encryption requested but no passphrase provided; pass -k or set " + envPassphrase) + errPassphraseMismatch = errors.New("passphrases did not match") +) + +var ( + interactive = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + readSecret = func() ([]byte, error) { return term.ReadPassword(int(os.Stdin.Fd())) } +) + +func passphraseFromFlagOrEnv(flagValue string) []byte { + if flagValue != "" { + return []byte(flagValue) + } + if env := os.Getenv(envPassphrase); env != "" { + return []byte(env) + } + return nil +} + +func resolveHidePassphrase(flagValue string, encryptWanted bool) ([]byte, error) { + if p := passphraseFromFlagOrEnv(flagValue); p != nil { + return p, nil + } + if !encryptWanted { + return nil, nil + } + if !interactive() { + return nil, errPassphraseNonInteractive + } + return promptPassphraseConfirmed() +} + +func promptPassphraseConfirmed() ([]byte, error) { + first, err := promptPassphrase(promptEnterPassphrase) + if err != nil { + return nil, err + } + second, err := promptPassphrase(promptConfirmPassphrase) + if err != nil { + return nil, err + } + if !bytes.Equal(first, second) { + return nil, errPassphraseMismatch + } + return first, nil +} + +func promptPassphrase(prompt string) ([]byte, error) { + fmt.Fprint(os.Stderr, prompt) + pass, err := readSecret() + fmt.Fprintln(os.Stderr) + if err != nil { + return nil, err + } + return pass, nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go new file mode 100644 index 00000000..3613a98c --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go @@ -0,0 +1,105 @@ +/* +©AngelaMos | 2026 +reveal.go + +The reveal command: extract the envelope from a stego file and unpack the payload +*/ + +package cli + +import ( + "errors" + "os" + + "github.com/CarterPerez-dev/crypha/internal/engine" + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/CarterPerez-dev/crypha/internal/report" + "github.com/spf13/cobra" +) + +func newRevealCmd() *cobra.Command { + var format, in, out, passphrase string + + cmd := &cobra.Command{ + Use: cmdReveal + " [-i] STEGO", + Short: "Reveal a hidden payload from a stego file", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := pathFromFlagOrArg(in, args) + if err != nil { + return err + } + if path == "" { + return errNoStego + } + + stego, err := os.ReadFile(path) + if err != nil { + return err + } + + res, err := revealWithPassphrase(format, stego, passphraseFromFlagOrEnv(passphrase)) + if err != nil { + return err + } + + if jsonEnabled(cmd) { + if out != "" { + if err := writeOutputFile(out, res.Data); err != nil { + return err + } + } + return report.RevealJSON(cmd.OutOrStdout(), res, out) + } + + outPath, err := writeRevealed(cmd, res.Data, out) + if err != nil { + return err + } + return report.RevealStatus(cmd.ErrOrStderr(), res, outPath) + }, + } + + f := cmd.Flags() + f.StringVar(&format, flagFormat, "", "force a carrier format (auto-detect if omitted)") + f.StringVarP(&in, flagIn, shortIn, "", "stego input path (or pass as an argument)") + f.StringVarP(&out, flagOut, shortOut, "", "write the revealed payload here (default stdout)") + f.StringVarP(&passphrase, flagPassphrase, shortPassphrase, "", "passphrase for an encrypted payload") + + return cmd +} + +func revealWithPassphrase(format string, stego, pass []byte) (engine.RevealResult, error) { + for attempt := 0; attempt <= maxPassphraseAttempts; attempt++ { + res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass}) + if err == nil { + return res, nil + } + if !shouldReprompt(err, attempt) { + return engine.RevealResult{}, err + } + entered, perr := promptPassphrase(promptForError(err)) + if perr != nil { + return engine.RevealResult{}, perr + } + pass = entered + } + return engine.RevealResult{}, payload.ErrDecrypt +} + +func shouldReprompt(err error, attempt int) bool { + if !interactive() { + return false + } + if errors.Is(err, payload.ErrPassphraseRequired) { + return true + } + return errors.Is(err, payload.ErrDecrypt) && attempt < maxPassphraseAttempts +} + +func promptForError(err error) string { + if errors.Is(err, payload.ErrPassphraseRequired) { + return promptEnterPassphrase + } + return promptRetryPassphrase +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go index 07885afb..40a4572c 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go @@ -10,25 +10,40 @@ package cli import ( "fmt" "os" + "strings" "github.com/CarterPerez-dev/crypha/internal/config" "github.com/spf13/cobra" ) -var rootCmd = &cobra.Command{ - Use: config.BinaryName, - Short: config.ShortDescription, - Long: config.LongDescription, - Version: config.Version, +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: config.BinaryName, + Short: config.ShortDescription, + Long: config.LongDescription, + Version: config.Version, + SilenceUsage: true, + SilenceErrors: true, + } + root.SetVersionTemplate(config.BinaryName + " {{.Version}}\n") + root.PersistentFlags().Bool(flagJSON, false, "emit machine-readable JSON") + root.AddCommand( + newHideCmd(), + newRevealCmd(), + newCapacityCmd(), + newFormatsCmd(), + newVersionCmd(), + ) + return root } func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + if err := newRootCmd().Execute(); err != nil { + msg := err.Error() + if !strings.HasPrefix(msg, config.BinaryName) { + msg = config.BinaryName + ": " + msg + } + fmt.Fprintln(os.Stderr, msg) os.Exit(1) } } - -func init() { - rootCmd.SetVersionTemplate(config.BinaryName + " {{.Version}}\n") -} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go new file mode 100644 index 00000000..f8e8d34d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go @@ -0,0 +1,24 @@ +/* +©AngelaMos | 2026 +version.go + +The version command: print the crypha build version +*/ + +package cli + +import ( + "github.com/CarterPerez-dev/crypha/internal/report" + "github.com/spf13/cobra" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: cmdVersion, + Short: "Print the crypha version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return report.Version(cmd.OutOrStdout(), jsonEnabled(cmd)) + }, + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go b/PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go new file mode 100644 index 00000000..28d05850 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go @@ -0,0 +1,48 @@ +/* +©AngelaMos | 2026 +cli.go + +Descriptive catalog metadata for each carrier, surfaced by the formats and help output +*/ + +package config + +type FormatDetail struct { + Blurb string + CoverInput string + Output string + Notes string +} + +var FormatDetails = map[string]FormatDetail{ + "image": { + Blurb: "LSB of RGB pixel data", + CoverInput: "PNG or 24-bit BMP", + Output: "PNG", + Notes: "alpha channel untouched; paletted and 16-bit covers are rejected", + }, + "audio": { + Blurb: "LSB of 16-bit PCM samples", + CoverInput: "16-bit PCM WAV or FLAC", + Output: "WAV", + Notes: "FLAC covers are decoded and re-emitted as WAV", + }, + "qr": { + Blurb: "Reed-Solomon-correctable error injection", + CoverInput: "UTF-8 text (the QR's visible content)", + Output: "PNG", + Notes: "capacity is tens of bytes, so an encrypted envelope will not fit", + }, + "text": { + Blurb: "zero-width U+200B and U+2060 characters", + CoverInput: "any UTF-8 text", + Output: "text", + Notes: "the payload is appended to the cover as invisible characters", + }, + "pdf": { + Blurb: "embedded attachment, metadata, or append-after-EOF", + CoverInput: "PDF", + Output: "PDF", + Notes: "default technique is a lossless embedded-file attachment", + }, +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go new file mode 100644 index 00000000..9cbf5d72 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go @@ -0,0 +1,205 @@ +/* +©AngelaMos | 2026 +engine.go + +The shared steganography engine that both frontends drive, wiring carriers to the payload envelope +*/ + +package engine + +import ( + "bytes" + "errors" + "fmt" + "io" + + "github.com/CarterPerez-dev/crypha/internal/carrier" + _ "github.com/CarterPerez-dev/crypha/internal/carrier/all" + "github.com/CarterPerez-dev/crypha/internal/carrier/pdf" + "github.com/CarterPerez-dev/crypha/internal/payload" +) + +var ( + ErrNoFormat = errors.New("crypha: a carrier format is required") + ErrUnknownFormat = errors.New("crypha: unknown carrier format") + ErrUnknownTechnique = errors.New("crypha: unknown pdf technique") + ErrTechniqueOnNonPDF = errors.New("crypha: technique only applies to the pdf format") + ErrUndetected = errors.New("crypha: no crypha payload detected; pass a format to force a carrier") +) + +type HideRequest struct { + Format string + Technique string + Cover io.Reader + Payload []byte + Out io.Writer + Options payload.Options +} + +type HideResult struct { + Format string + Technique string + PayloadBytes int + EnvelopeBytes int + Encrypted bool + Compressed bool +} + +type RevealRequest struct { + Format string + Stego []byte + Passphrase []byte +} + +type RevealResult struct { + Format string + Data []byte + Encrypted bool +} + +type CapacityRow struct { + Format string + Capacity int + Err error +} + +type FormatInfo struct { + Name string + Techniques []string +} + +func ResolveCarrier(format, technique string) (carrier.Carrier, error) { + if format == "" { + return nil, ErrNoFormat + } + if format == pdf.Format { + t := pdf.TechniqueAttachment + if technique != "" { + t = pdf.Technique(technique) + } + switch t { + case pdf.TechniqueAttachment, pdf.TechniqueMetadata, pdf.TechniqueAppend: + return pdf.New(t), nil + default: + return nil, fmt.Errorf("%w: %q", ErrUnknownTechnique, technique) + } + } + if technique != "" { + return nil, ErrTechniqueOnNonPDF + } + c, ok := carrier.Get(format) + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownFormat, format) + } + return c, nil +} + +func Hide(req HideRequest) (HideResult, error) { + c, err := ResolveCarrier(req.Format, req.Technique) + if err != nil { + return HideResult{}, err + } + env, err := payload.Pack(req.Payload, req.Options) + if err != nil { + return HideResult{}, err + } + if err := c.Hide(req.Cover, env, req.Out); err != nil { + return HideResult{}, err + } + return HideResult{ + Format: c.Format(), + Technique: req.Technique, + PayloadBytes: len(req.Payload), + EnvelopeBytes: len(env), + Encrypted: len(req.Options.Passphrase) > 0, + Compressed: req.Options.Compress, + }, nil +} + +func Reveal(req RevealRequest) (RevealResult, error) { + c, env, err := locate(req.Format, req.Stego) + if err != nil { + return RevealResult{}, err + } + encrypted, err := payload.IsEncrypted(env) + if err != nil { + return RevealResult{Format: c.Format()}, err + } + if encrypted && len(req.Passphrase) == 0 { + return RevealResult{Format: c.Format(), Encrypted: true}, payload.ErrPassphraseRequired + } + data, err := payload.Unpack(env, req.Passphrase) + if err != nil { + return RevealResult{Format: c.Format(), Encrypted: encrypted}, err + } + return RevealResult{Format: c.Format(), Data: data, Encrypted: encrypted}, nil +} + +func Capacity(format string, cover io.Reader) (int, error) { + c, err := ResolveCarrier(format, "") + if err != nil { + return 0, err + } + return c.Capacity(cover) +} + +func CapacityAll(cover []byte) []CapacityRow { + carriers := carrier.All() + rows := make([]CapacityRow, 0, len(carriers)) + for _, c := range carriers { + n, err := c.Capacity(bytes.NewReader(cover)) + rows = append(rows, CapacityRow{Format: c.Format(), Capacity: n, Err: err}) + } + return rows +} + +func Catalog() []FormatInfo { + names := carrier.Formats() + out := make([]FormatInfo, 0, len(names)) + for _, name := range names { + out = append(out, FormatInfo{Name: name, Techniques: Techniques(name)}) + } + return out +} + +func Techniques(format string) []string { + if format != pdf.Format { + return nil + } + return []string{ + string(pdf.TechniqueAttachment), + string(pdf.TechniqueMetadata), + string(pdf.TechniqueAppend), + } +} + +func locate(format string, stego []byte) (carrier.Carrier, []byte, error) { + if format != "" { + c, err := ResolveCarrier(format, "") + if err != nil { + return nil, nil, err + } + env, err := c.Reveal(bytes.NewReader(stego)) + if err != nil { + return nil, nil, err + } + return c, env, nil + } + return detect(stego) +} + +func detect(stego []byte) (carrier.Carrier, []byte, error) { + for _, c := range carrier.All() { + if !c.Sniff(bytes.NewReader(stego)) { + continue + } + env, err := c.Reveal(bytes.NewReader(stego)) + if err != nil { + continue + } + if payload.Validate(env) == nil { + return c, env, nil + } + } + return nil, nil, ErrUndetected +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go new file mode 100644 index 00000000..6a307af4 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go @@ -0,0 +1,233 @@ +/* +©AngelaMos | 2026 +engine_test.go + +Resolution, round-trip, auto-detect, and capacity tests for the shared engine +*/ + +package engine + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/png" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/carrier/pdf" + "github.com/CarterPerez-dev/crypha/internal/payload" +) + +const ( + qrCoverText = "crypha qr cover" + textCover = "the quick brown fox jumps over the lazy dog" +) + +func pngCover(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x + y), A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func hideBytes(t *testing.T, format string, cover, plaintext []byte, opts payload.Options) []byte { + t.Helper() + var out bytes.Buffer + if _, err := Hide(HideRequest{ + Format: format, + Cover: bytes.NewReader(cover), + Payload: plaintext, + Out: &out, + Options: opts, + }); err != nil { + t.Fatalf("Hide(%s): %v", format, err) + } + return out.Bytes() +} + +func TestResolveCarrier(t *testing.T) { + cases := []struct { + name string + format string + technique string + wantErr error + wantFmt string + }{ + {"image", "image", "", nil, "image"}, + {"pdf default", "pdf", "", nil, "pdf"}, + {"pdf attachment", "pdf", "attachment", nil, "pdf"}, + {"pdf metadata", "pdf", "metadata", nil, "pdf"}, + {"pdf append", "pdf", "append", nil, "pdf"}, + {"pdf bad technique", "pdf", "nonsense", ErrUnknownTechnique, ""}, + {"technique on non-pdf", "image", "attachment", ErrTechniqueOnNonPDF, ""}, + {"unknown format", "nope", "", ErrUnknownFormat, ""}, + {"empty format", "", "", ErrNoFormat, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, err := ResolveCarrier(tc.format, tc.technique) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if c.Format() != tc.wantFmt { + t.Fatalf("format = %q, want %q", c.Format(), tc.wantFmt) + } + }) + } +} + +func TestPlaintextRoundTrip(t *testing.T) { + cases := []struct { + format string + cover []byte + }{ + {"text", []byte(textCover)}, + {"image", pngCover(t, 64, 64)}, + {"qr", []byte(qrCoverText)}, + } + secret := []byte("meet at dawn") + for _, tc := range cases { + t.Run(tc.format, func(t *testing.T) { + stego := hideBytes(t, tc.format, tc.cover, secret, payload.Options{}) + res, err := Reveal(RevealRequest{Format: tc.format, Stego: stego}) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if !bytes.Equal(res.Data, secret) { + t.Fatalf("data = %q, want %q", res.Data, secret) + } + if res.Encrypted { + t.Fatal("plaintext payload reported as encrypted") + } + }) + } +} + +func TestEncryptedRoundTrip(t *testing.T) { + cover := pngCover(t, 64, 64) + secret := []byte("classified") + pass := []byte("correct horse battery staple") + stego := hideBytes(t, "image", cover, secret, payload.Options{Passphrase: pass}) + + res, err := Reveal(RevealRequest{Format: "image", Stego: stego, Passphrase: pass}) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if !bytes.Equal(res.Data, secret) { + t.Fatalf("data = %q, want %q", res.Data, secret) + } + if !res.Encrypted { + t.Fatal("encrypted payload reported as plaintext") + } + + if _, err := Reveal(RevealRequest{Format: "image", Stego: stego}); !errors.Is(err, payload.ErrPassphraseRequired) { + t.Fatalf("missing passphrase err = %v, want ErrPassphraseRequired", err) + } + if _, err := Reveal(RevealRequest{Format: "image", Stego: stego, Passphrase: []byte("wrong")}); !errors.Is(err, payload.ErrDecrypt) { + t.Fatalf("wrong passphrase err = %v, want ErrDecrypt", err) + } +} + +func TestAutoDetect(t *testing.T) { + secret := []byte("hi") + cases := []struct { + format string + cover []byte + }{ + {"text", []byte(textCover)}, + {"image", pngCover(t, 64, 64)}, + {"qr", []byte(qrCoverText)}, + } + for _, tc := range cases { + t.Run(tc.format, func(t *testing.T) { + stego := hideBytes(t, tc.format, tc.cover, secret, payload.Options{}) + res, err := Reveal(RevealRequest{Stego: stego}) + if err != nil { + t.Fatalf("auto-detect Reveal: %v", err) + } + if res.Format != tc.format { + t.Fatalf("detected %q, want %q", res.Format, tc.format) + } + if !bytes.Equal(res.Data, secret) { + t.Fatalf("data = %q, want %q", res.Data, secret) + } + }) + } +} + +func TestAutoDetectQRNotShadowedByImage(t *testing.T) { + stego := hideBytes(t, "qr", []byte(qrCoverText), []byte("hi"), payload.Options{}) + res, err := Reveal(RevealRequest{Stego: stego}) + if err != nil { + t.Fatalf("Reveal: %v", err) + } + if res.Format != "qr" { + t.Fatalf("a qr PNG resolved to %q; the image carrier shadowed qr", res.Format) + } +} + +func TestAutoDetectUndetected(t *testing.T) { + if _, err := Reveal(RevealRequest{Stego: []byte("just some plain bytes, not a carrier")}); !errors.Is(err, ErrUndetected) { + t.Fatalf("err = %v, want ErrUndetected", err) + } +} + +func TestCatalogAndTechniques(t *testing.T) { + cat := Catalog() + if len(cat) != 5 { + t.Fatalf("catalog has %d formats, want 5", len(cat)) + } + for _, fi := range cat { + if fi.Name == pdf.Format { + if len(fi.Techniques) != 3 { + t.Fatalf("pdf techniques = %v, want 3", fi.Techniques) + } + } else if fi.Techniques != nil { + t.Fatalf("%s has techniques %v, want none", fi.Name, fi.Techniques) + } + } +} + +func TestCapacityAll(t *testing.T) { + rows := CapacityAll(pngCover(t, 64, 64)) + var imageRow *CapacityRow + for i := range rows { + if rows[i].Format == "image" { + imageRow = &rows[i] + } + } + if imageRow == nil { + t.Fatal("no image row in capacity table") + } + if imageRow.Err != nil { + t.Fatalf("image capacity err: %v", imageRow.Err) + } + if imageRow.Capacity <= 0 { + t.Fatalf("image capacity = %d, want positive", imageRow.Capacity) + } +} + +func TestCapacitySingleFormat(t *testing.T) { + n, err := Capacity("image", bytes.NewReader(pngCover(t, 32, 32))) + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if n <= 0 { + t.Fatalf("capacity = %d, want positive", n) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go index 7b94845e..7ee9e640 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go @@ -156,3 +156,23 @@ func TestStrengthParams(t *testing.T) { t.Errorf("default params wrong: %+v", p) } } + +func TestOverheadMatchesEnvelope(t *testing.T) { + plain := []byte("a modest secret") + + clear, err := Pack(plain, Options{}) + if err != nil { + t.Fatalf("pack plaintext: %v", err) + } + if got := len(clear) - len(plain); got != Overhead(false) { + t.Errorf("plaintext overhead = %d, Overhead(false) = %d", got, Overhead(false)) + } + + sealed, err := Pack(plain, Options{Passphrase: []byte("pw"), Cipher: CipherChaCha20}) + if err != nil { + t.Fatalf("pack encrypted: %v", err) + } + if got := len(sealed) - len(plain); got != Overhead(true) { + t.Errorf("encrypted overhead = %d, Overhead(true) = %d", got, Overhead(true)) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go index 4dc532e6..0d349096 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go @@ -39,12 +39,16 @@ const ( cipherIDChaCha20 byte = 0x00 cipherIDAES256GCM byte = 0x01 - saltLen = 16 - nonceLen = 12 - keyLen = 32 - lenField = 4 - crcField = 4 - paramsLen = 9 + saltLen = 16 + nonceLen = 12 + keyLen = 32 + lenField = 4 + crcField = 4 + paramsLen = 9 + versionLen = 1 + flagsLen = 1 + cipherLen = 1 + tagLen = 16 argonDefaultTime uint32 = 3 argonDefaultMemory uint32 = 64 * 1024 @@ -67,3 +71,11 @@ var ( ErrDecrypt = errors.New("crypha: decryption failed (wrong passphrase or tampered data)") ErrUnknownCipher = errors.New("crypha: unknown cipher") ) + +func Overhead(encrypted bool) int { + base := len(magic) + versionLen + flagsLen + lenField + crcField + if !encrypted { + return base + } + return base + cipherLen + paramsLen + saltLen + nonceLen + tagLen +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/report/report.go b/PROJECTS/beginner/steganography-multi-tool/internal/report/report.go new file mode 100644 index 00000000..1d9abe64 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/report/report.go @@ -0,0 +1,295 @@ +/* +©AngelaMos | 2026 +report.go + +Human-readable tables and JSON rendering for crypha command output +*/ + +package report + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "runtime/debug" + "strings" + "text/tabwriter" + + "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/CarterPerez-dev/crypha/internal/engine" + "github.com/CarterPerez-dev/crypha/internal/payload" +) + +const ( + unboundedLabel = "unbounded" + notApplicable = "n/a" + doesNotFit = "does not fit" + yesLabel = "yes" + noLabel = "no" + minCellWidth = 0 + tabWidth = 2 + padding = 2 + padChar = ' ' +) + +type formatLine struct { + Format string `json:"format"` + Cover string `json:"cover"` + Output string `json:"output"` + Techniques []string `json:"techniques,omitempty"` + Description string `json:"description"` +} + +type capacityLine struct { + Format string `json:"format"` + Applicable bool `json:"applicable"` + Capacity int `json:"capacity_bytes,omitempty"` + MaxPlaintext int `json:"max_plaintext_bytes,omitempty"` + MaxEncrypted int `json:"max_encrypted_bytes,omitempty"` + Unbounded bool `json:"unbounded,omitempty"` + Note string `json:"note,omitempty"` +} + +type hideLine struct { + Format string `json:"format"` + Technique string `json:"technique,omitempty"` + PayloadBytes int `json:"payload_bytes"` + EnvelopeBytes int `json:"envelope_bytes"` + Encrypted bool `json:"encrypted"` + Compressed bool `json:"compressed"` + Output string `json:"output"` +} + +type revealLine struct { + Format string `json:"format"` + Bytes int `json:"bytes"` + Encrypted bool `json:"encrypted"` + Output string `json:"output,omitempty"` + Data string `json:"data,omitempty"` +} + +type versionLine struct { + Name string `json:"name"` + Version string `json:"version"` + GoVersion string `json:"go_version"` +} + +type tableWriter struct { + tw *tabwriter.Writer + err error +} + +func newTableWriter(w io.Writer) *tableWriter { + return &tableWriter{tw: tabwriter.NewWriter(w, minCellWidth, tabWidth, padding, padChar, 0)} +} + +func (t *tableWriter) row(format string, a ...any) { + if t.err != nil { + return + } + _, t.err = fmt.Fprintf(t.tw, format, a...) +} + +func (t *tableWriter) flush() error { + if t.err != nil { + return t.err + } + return t.tw.Flush() +} + +func Formats(w io.Writer, jsonOut bool) error { + cat := engine.Catalog() + lines := make([]formatLine, 0, len(cat)) + for _, fi := range cat { + d := config.FormatDetails[fi.Name] + lines = append(lines, formatLine{ + Format: fi.Name, + Cover: d.CoverInput, + Output: d.Output, + Techniques: fi.Techniques, + Description: d.Blurb, + }) + } + if jsonOut { + return writeJSON(w, lines) + } + tw := newTableWriter(w) + tw.row("FORMAT\tCOVER\tOUTPUT\tOPTIONS\tDESCRIPTION\n") + for _, l := range lines { + tw.row("%s\t%s\t%s\t%s\t%s\n", l.Format, l.Cover, l.Output, options(l.Techniques), l.Description) + } + return tw.flush() +} + +func Capacity(w io.Writer, rows []engine.CapacityRow, jsonOut bool) error { + lines := make([]capacityLine, 0, len(rows)) + for _, r := range rows { + lines = append(lines, capacityRow(r)) + } + if jsonOut { + return writeJSON(w, lines) + } + tw := newTableWriter(w) + tw.row("FORMAT\tENVELOPE\tMAX PLAINTEXT\tMAX ENCRYPTED\tNOTE\n") + for _, l := range lines { + tw.row("%s\t%s\t%s\t%s\t%s\n", l.Format, capacityCell(l), plaintextCell(l), encryptedCell(l), l.Note) + } + return tw.flush() +} + +func HideSummary(w io.Writer, res engine.HideResult, outPath string, jsonOut bool) error { + line := hideLine{ + Format: res.Format, + Technique: res.Technique, + PayloadBytes: res.PayloadBytes, + EnvelopeBytes: res.EnvelopeBytes, + Encrypted: res.Encrypted, + Compressed: res.Compressed, + Output: outPath, + } + if jsonOut { + return writeJSON(w, line) + } + tw := newTableWriter(w) + tw.row("OUTPUT\t%s\n", line.Output) + tw.row("FORMAT\t%s\n", formatCell(res.Format, res.Technique)) + tw.row("PAYLOAD\t%d bytes\n", line.PayloadBytes) + tw.row("ENVELOPE\t%d bytes\n", line.EnvelopeBytes) + tw.row("ENCRYPTED\t%s\n", boolLabel(line.Encrypted)) + tw.row("COMPRESSED\t%s\n", boolLabel(line.Compressed)) + return tw.flush() +} + +func RevealStatus(w io.Writer, res engine.RevealResult, outPath string) error { + _, err := fmt.Fprintf(w, "revealed %d bytes via %s -> %s\n", len(res.Data), res.Format, outPath) + return err +} + +func RevealJSON(w io.Writer, res engine.RevealResult, outPath string) error { + line := revealLine{ + Format: res.Format, + Bytes: len(res.Data), + Encrypted: res.Encrypted, + Output: outPath, + } + if outPath == "" { + line.Data = base64.StdEncoding.EncodeToString(res.Data) + } + return writeJSON(w, line) +} + +func Version(w io.Writer, jsonOut bool) error { + line := versionLine{ + Name: config.BinaryName, + Version: config.Version, + GoVersion: goVersion(), + } + if jsonOut { + return writeJSON(w, line) + } + _, err := fmt.Fprintf(w, "%s %s (%s)\n", line.Name, line.Version, line.GoVersion) + return err +} + +func capacityRow(r engine.CapacityRow) capacityLine { + l := capacityLine{Format: r.Format} + if r.Err != nil { + l.Note = reason(r.Err) + return l + } + l.Applicable = true + if r.Capacity >= math.MaxInt32 { + l.Unbounded = true + return l + } + l.Capacity = r.Capacity + l.MaxPlaintext = clampZero(r.Capacity - payload.Overhead(false)) + l.MaxEncrypted = clampZero(r.Capacity - payload.Overhead(true)) + return l +} + +func capacityCell(l capacityLine) string { + switch { + case !l.Applicable: + return notApplicable + case l.Unbounded: + return unboundedLabel + default: + return fmt.Sprintf("%d", l.Capacity) + } +} + +func plaintextCell(l capacityLine) string { + switch { + case !l.Applicable: + return notApplicable + case l.Unbounded: + return unboundedLabel + default: + return fmt.Sprintf("%d", l.MaxPlaintext) + } +} + +func encryptedCell(l capacityLine) string { + switch { + case !l.Applicable: + return notApplicable + case l.Unbounded: + return unboundedLabel + case l.MaxEncrypted <= 0: + return doesNotFit + default: + return fmt.Sprintf("%d", l.MaxEncrypted) + } +} + +func options(techniques []string) string { + if len(techniques) == 0 { + return "-" + } + return strings.Join(techniques, " | ") +} + +func formatCell(format, technique string) string { + if technique == "" { + return format + } + return format + " (" + technique + ")" +} + +func boolLabel(b bool) string { + if b { + return yesLabel + } + return noLabel +} + +func reason(err error) string { + msg := err.Error() + if i := strings.Index(msg, ": "); i >= 0 { + return msg[i+2:] + } + return msg +} + +func clampZero(n int) int { + if n < 0 { + return 0 + } + return n +} + +func goVersion() string { + if info, ok := debug.ReadBuildInfo(); ok && info.GoVersion != "" { + return info.GoVersion + } + return "unknown" +} + +func writeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go new file mode 100644 index 00000000..f867a56f --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go @@ -0,0 +1,166 @@ +/* +©AngelaMos | 2026 +report_test.go + +Table and JSON rendering tests for command output +*/ + +package report + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "math" + "strings" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/CarterPerez-dev/crypha/internal/engine" +) + +func TestFormatsHuman(t *testing.T) { + var buf bytes.Buffer + if err := Formats(&buf, false); err != nil { + t.Fatalf("Formats: %v", err) + } + out := buf.String() + for _, f := range []string{"image", "audio", "qr", "text", "pdf"} { + if !strings.Contains(out, f) { + t.Errorf("formats output missing %q", f) + } + } + if !strings.Contains(out, "attachment") { + t.Error("pdf technique options not listed") + } +} + +func TestFormatsJSON(t *testing.T) { + var buf bytes.Buffer + if err := Formats(&buf, true); err != nil { + t.Fatalf("Formats: %v", err) + } + if !json.Valid(buf.Bytes()) { + t.Fatal("formats json is invalid") + } + var lines []formatLine + if err := json.Unmarshal(buf.Bytes(), &lines); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(lines) != 5 { + t.Fatalf("got %d formats, want 5", len(lines)) + } +} + +func TestCapacityHuman(t *testing.T) { + rows := []engine.CapacityRow{ + {Format: "qr", Capacity: 52}, + {Format: "pdf", Capacity: math.MaxInt32}, + {Format: "text", Err: errors.New("crypha/text: cover too small")}, + } + var buf bytes.Buffer + if err := Capacity(&buf, rows, false); err != nil { + t.Fatalf("Capacity: %v", err) + } + out := buf.String() + for _, want := range []string{doesNotFit, unboundedLabel, notApplicable, "cover too small", "38"} { + if !strings.Contains(out, want) { + t.Errorf("capacity output missing %q\n%s", want, out) + } + } +} + +func TestCapacityJSON(t *testing.T) { + rows := []engine.CapacityRow{ + {Format: "qr", Capacity: 52}, + {Format: "pdf", Capacity: math.MaxInt32}, + {Format: "text", Err: errors.New("crypha/text: cover too small")}, + } + var buf bytes.Buffer + if err := Capacity(&buf, rows, true); err != nil { + t.Fatalf("Capacity: %v", err) + } + var lines []capacityLine + if err := json.Unmarshal(buf.Bytes(), &lines); err != nil { + t.Fatalf("unmarshal: %v", err) + } + byFormat := map[string]capacityLine{} + for _, l := range lines { + byFormat[l.Format] = l + } + if got := byFormat["qr"]; got.MaxPlaintext != 38 || got.MaxEncrypted != 0 { + t.Errorf("qr line = %+v, want plaintext 38 encrypted 0", got) + } + if !byFormat["pdf"].Unbounded { + t.Error("pdf should be unbounded") + } + if byFormat["text"].Applicable { + t.Error("rejected cover should be inapplicable") + } +} + +func TestHideSummary(t *testing.T) { + res := engine.HideResult{ + Format: "image", + PayloadBytes: 12, + EnvelopeBytes: 80, + Encrypted: true, + } + var buf bytes.Buffer + if err := HideSummary(&buf, res, "out.png", false); err != nil { + t.Fatalf("HideSummary: %v", err) + } + out := buf.String() + for _, want := range []string{"image", "12", "80", "out.png", yesLabel} { + if !strings.Contains(out, want) { + t.Errorf("hide summary missing %q\n%s", want, out) + } + } +} + +func TestVersion(t *testing.T) { + var buf bytes.Buffer + if err := Version(&buf, false); err != nil { + t.Fatalf("Version: %v", err) + } + out := buf.String() + if !strings.Contains(out, config.BinaryName) || !strings.Contains(out, config.Version) { + t.Errorf("version output = %q", out) + } +} + +func TestRevealJSON(t *testing.T) { + res := engine.RevealResult{Format: "image", Data: []byte("hidden bytes"), Encrypted: true} + + var toStdout bytes.Buffer + if err := RevealJSON(&toStdout, res, ""); err != nil { + t.Fatalf("RevealJSON: %v", err) + } + var line revealLine + if err := json.Unmarshal(toStdout.Bytes(), &line); err != nil { + t.Fatalf("unmarshal: %v", err) + } + decoded, err := base64.StdEncoding.DecodeString(line.Data) + if err != nil || string(decoded) != "hidden bytes" { + t.Fatalf("data = %q err %v", decoded, err) + } + if line.Bytes != len(res.Data) || !line.Encrypted || line.Format != "image" { + t.Fatalf("line = %+v", line) + } + + var toFile bytes.Buffer + if err := RevealJSON(&toFile, res, "out.bin"); err != nil { + t.Fatalf("RevealJSON with file: %v", err) + } + var fileLine revealLine + if err := json.Unmarshal(toFile.Bytes(), &fileLine); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if fileLine.Data != "" { + t.Error("data should be omitted when writing to a file") + } + if fileLine.Output != "out.bin" { + t.Errorf("output = %q, want out.bin", fileLine.Output) + } +} From f0e90271e7a5cf469d3f026e4e7119c23d4a2bde Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Thu, 16 Jul 2026 10:11:33 -0400 Subject: [PATCH 06/13] feat(crypha): add bubbletea tui wizard over the shared engine (M8) Guided hide/reveal/capacity wizard in internal/tui as a pure view over internal/engine (no carrier logic): HCL gradient engine, live capacity meter, embed animation, and a secure-options form. Bare crypha on a TTY launches it; piped or --help prints help. Adds engine.Overhead and engine.EnvelopeSize for exact payload-fit preflight (flate pass only, no KDF). --- .../beginner/steganography-multi-tool/go.mod | 21 + .../beginner/steganography-multi-tool/go.sum | 46 ++ .../internal/cli/flags.go | 1 + .../internal/cli/root.go | 8 + .../internal/cli/tui.go | 31 ++ .../internal/cli/tui_test.go | 40 ++ .../internal/engine/engine.go | 8 + .../internal/engine/overhead_test.go | 19 + .../internal/payload/envelopesize_test.go | 57 +++ .../internal/payload/payload.go | 12 + .../internal/tui/chrome.go | 94 ++++ .../internal/tui/commands.go | 84 ++++ .../internal/tui/flows_test.go | 224 +++++++++ .../internal/tui/meter.go | 134 ++++++ .../internal/tui/model.go | 404 ++++++++++++++++ .../internal/tui/picker.go | 65 +++ .../internal/tui/secure.go | 224 +++++++++ .../internal/tui/steps.go | 96 ++++ .../internal/tui/theme.go | 217 +++++++++ .../internal/tui/theme_test.go | 149 ++++++ .../internal/tui/tui.go | 17 + .../internal/tui/tui_test.go | 443 ++++++++++++++++++ .../internal/tui/update.go | 399 ++++++++++++++++ .../internal/tui/view.go | 329 +++++++++++++ 24 files changed, 3122 insertions(+) create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index c653432a..8a000b2b 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -3,8 +3,12 @@ module github.com/CarterPerez-dev/crypha go 1.25.0 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 + github.com/lucasb-eyer/go-colorful v1.3.0 github.com/makiuchi-d/gozxing v0.1.1 github.com/mewkiz/flac v1.0.13 github.com/pdfcpu/pdfcpu v0.13.0 @@ -17,18 +21,35 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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 d6cb166a..f07bb6d5 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -1,6 +1,32 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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= @@ -19,8 +45,14 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lTo 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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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= @@ -29,10 +61,18 @@ github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HN 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= @@ -41,11 +81,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT 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= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go index 38a6d13f..c560f5f5 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go @@ -15,6 +15,7 @@ const ( cmdCapacity = "capacity" cmdFormats = "formats" cmdVersion = "version" + cmdTUI = "tui" flagJSON = "json" flagFormat = "format" diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go index 40a4572c..dacecd8f 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/CarterPerez-dev/crypha/internal/tui" "github.com/spf13/cobra" ) @@ -27,12 +28,19 @@ func newRootCmd() *cobra.Command { } root.SetVersionTemplate(config.BinaryName + " {{.Version}}\n") root.PersistentFlags().Bool(flagJSON, false, "emit machine-readable JSON") + root.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && launchInteractive() { + return tui.Run() + } + return cmd.Help() + } root.AddCommand( newHideCmd(), newRevealCmd(), newCapacityCmd(), newFormatsCmd(), newVersionCmd(), + newTuiCmd(), ) return root } diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go new file mode 100644 index 00000000..c4c94f29 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go @@ -0,0 +1,31 @@ +/* +©AngelaMos | 2026 +tui.go + +The tui command plus the terminal check that launches the wizard on a bare invocation +*/ + +package cli + +import ( + "os" + + "github.com/CarterPerez-dev/crypha/internal/tui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var launchInteractive = func() bool { + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) +} + +func newTuiCmd() *cobra.Command { + return &cobra.Command{ + Use: cmdTUI, + Short: "Launch the interactive terminal wizard", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return tui.Run() + }, + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go new file mode 100644 index 00000000..48dc02d8 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go @@ -0,0 +1,40 @@ +/* +©AngelaMos | 2026 +tui_test.go + +Tests for the tui command registration and the bare-invocation help fallback +*/ + +package cli + +import ( + "strings" + "testing" +) + +func TestBareInvocationPrintsHelpWhenNonInteractive(t *testing.T) { + old := launchInteractive + launchInteractive = func() bool { return false } + defer func() { launchInteractive = old }() + + out, _, err := run(t, "") + if err != nil { + t.Fatalf("bare invocation errored: %v", err) + } + if !strings.Contains(out, cmdTUI) { + t.Fatalf("help output missing the tui command:\n%s", out) + } + if !strings.Contains(out, "Available Commands") { + t.Fatalf("bare invocation did not print help:\n%s", out) + } +} + +func TestTuiCommandRegistered(t *testing.T) { + root := newRootCmd() + for _, c := range root.Commands() { + if c.Name() == cmdTUI { + return + } + } + t.Fatalf("tui command not registered on root") +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go index 9cbf5d72..91208cab 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go @@ -173,6 +173,14 @@ func Techniques(format string) []string { } } +func Overhead(encrypted bool) int { + return payload.Overhead(encrypted) +} + +func EnvelopeSize(data []byte, opts payload.Options) (int, error) { + return payload.EnvelopeSize(data, opts) +} + func locate(format string, stego []byte) (carrier.Carrier, []byte, error) { if format != "" { c, err := ResolveCarrier(format, "") diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go new file mode 100644 index 00000000..94bc98c0 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go @@ -0,0 +1,19 @@ +/* +©AngelaMos | 2026 +overhead_test.go + +Known-answer test tying the engine overhead helper to the documented envelope sizes +*/ + +package engine + +import "testing" + +func TestOverheadKnownValues(t *testing.T) { + if got := Overhead(false); got != 14 { + t.Fatalf("Overhead(false) = %d, want 14", got) + } + if got := Overhead(true); got != 68 { + t.Fatalf("Overhead(true) = %d, want 68", got) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go new file mode 100644 index 00000000..1e199b6a --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go @@ -0,0 +1,57 @@ +/* +©AngelaMos | 2026 +envelopesize_test.go + +Known-answer tests proving EnvelopeSize equals the real packed envelope length +*/ + +package payload + +import ( + "bytes" + "testing" +) + +func TestEnvelopeSizeMatchesPack(t *testing.T) { + data := bytes.Repeat([]byte("crypha steganography "), 8) + cases := []Options{ + {}, + {Compress: true}, + {Passphrase: []byte("pw"), Cipher: CipherChaCha20, Strength: StrengthDefault}, + {Passphrase: []byte("pw"), Cipher: CipherAES256GCM, Strength: StrengthDefault, Compress: true}, + } + for i, opts := range cases { + env, err := Pack(data, opts) + if err != nil { + t.Fatalf("case %d Pack: %v", i, err) + } + size, err := EnvelopeSize(data, opts) + if err != nil { + t.Fatalf("case %d EnvelopeSize: %v", i, err) + } + if size != len(env) { + t.Fatalf("case %d: EnvelopeSize=%d, len(Pack)=%d", i, size, len(env)) + } + } +} + +func TestEnvelopeSizeOverhead(t *testing.T) { + data := []byte("hello") + plain, _ := EnvelopeSize(data, Options{}) + if plain != len(data)+Overhead(false) { + t.Fatalf("plaintext size = %d, want %d", plain, len(data)+Overhead(false)) + } + enc, _ := EnvelopeSize(data, Options{Passphrase: []byte("x")}) + if enc != len(data)+Overhead(true) { + t.Fatalf("encrypted size = %d, want %d", enc, len(data)+Overhead(true)) + } +} + +func TestEnvelopeSizeCompressionShrinks(t *testing.T) { + data := bytes.Repeat([]byte("A"), 1024) + plain, _ := EnvelopeSize(data, Options{}) + comp, _ := EnvelopeSize(data, Options{Compress: true}) + if comp >= plain { + t.Fatalf("compression did not shrink the envelope: comp=%d plain=%d", comp, plain) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go index 0d349096..80957eed 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go @@ -79,3 +79,15 @@ func Overhead(encrypted bool) int { } return base + cipherLen + paramsLen + saltLen + nonceLen + tagLen } + +func EnvelopeSize(data []byte, opts Options) (int, error) { + body := data + if opts.Compress { + c, err := compress(data) + if err != nil { + return 0, err + } + body = c + } + return Overhead(len(opts.Passphrase) > 0) + len(body), nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go new file mode 100644 index 00000000..3e5952bc --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go @@ -0,0 +1,94 @@ +/* +©AngelaMos | 2026 +chrome.go + +Header wordmark, wizard stepper, footer keybinds, and shared panel primitives +*/ + +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + wordmarkRows = 3 + wordmarkGap = " " + labelWidth = 12 + tagline = "multi-carrier steganography" +) + +var wordmarkGlyphs = [][wordmarkRows]string{ + {"█▀▀", "█ ", "▀▀▀"}, + {"█▀▄", "█▀▄", "▀ ▀"}, + {"█ █", "▀█▀", " ▀ "}, + {"█▀▄", "█▀▀", "█ "}, + {"█ █", "█▀█", "▀ ▀"}, + {"▄▀▄", "█▀█", "▀ ▀"}, +} + +type keyHint struct { + key string + desc string +} + +func wordmarkLines() []string { + lines := make([]string, wordmarkRows) + for row := range wordmarkRows { + parts := make([]string, len(wordmarkGlyphs)) + for i, g := range wordmarkGlyphs { + parts[i] = g[row] + } + lines[row] = strings.Join(parts, wordmarkGap) + } + return lines +} + +func renderHeader(width int) string { + mark := gradientBlock(wordmarkLines(), brandStops, true) + tag := styleTagline.Render(tagline) + rule := gradientRule(width, brandStops) + return lipgloss.JoinVertical(lipgloss.Left, mark, tag, "", rule) +} + +func renderStepper(labels []string, current int) string { + parts := make([]string, 0, len(labels)*2) + for i, label := range labels { + var token string + switch { + case i < current: + token = styleStepDone.Render(label) + case i == current: + token = styleCursor.Render(accentBar) + styleStepCurrent.Render(label) + default: + token = styleStepFuture.Render(label) + } + parts = append(parts, token) + if i < len(labels)-1 { + parts = append(parts, styleHelpSep.Render(" "+connector+" ")) + } + } + return strings.Join(parts, "") +} + +func renderFooter(hints []keyHint) string { + parts := make([]string, 0, len(hints)) + for _, h := range hints { + parts = append(parts, styleHelpKey.Render(h.key)+" "+styleHelpDesc.Render(h.desc)) + } + return strings.Join(parts, styleHelpSep.Render(" │ ")) +} + +func sectionTitle(text string) string { + return stylePanelTitle.Render(accentBar) + " " + styleValueBold.Render(text) +} + +func kv(label, value string) string { + return styleLabel.Width(labelWidth).Render(label) + styleValue.Render(value) +} + +func kvStyled(label, value string, valueStyle lipgloss.Style) string { + return styleLabel.Width(labelWidth).Render(label) + valueStyle.Render(value) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go new file mode 100644 index 00000000..8fc8ab4b --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go @@ -0,0 +1,84 @@ +/* +©AngelaMos | 2026 +commands.go + +Bubbletea commands and messages for engine calls, file IO, and the embed animation +*/ + +package tui + +import ( + "bytes" + "os" + "time" + + "github.com/CarterPerez-dev/crypha/internal/engine" + tea "github.com/charmbracelet/bubbletea" +) + +type fileLoadedMsg struct { + origin stage + path string + data []byte + err error +} + +type hideDoneMsg struct { + res engine.HideResult + data []byte + err error +} + +type revealDoneMsg struct { + res engine.RevealResult + err error +} + +type savedMsg struct { + path string + err error +} + +type tickMsg time.Time + +func loadFileCmd(origin stage, path string) tea.Cmd { + return func() tea.Msg { + data, err := os.ReadFile(path) + return fileLoadedMsg{origin: origin, path: path, data: data, err: err} + } +} + +func hideCmd(req engine.HideRequest, outPath string) tea.Cmd { + return func() tea.Msg { + var buf bytes.Buffer + req.Out = &buf + res, err := engine.Hide(req) + if err != nil { + return hideDoneMsg{err: err} + } + if err := os.WriteFile(outPath, buf.Bytes(), outFilePerm); err != nil { + return hideDoneMsg{err: err} + } + return hideDoneMsg{res: res, data: buf.Bytes()} + } +} + +func revealCmd(format string, stego, pass []byte) tea.Cmd { + return func() tea.Msg { + res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass}) + return revealDoneMsg{res: res, err: err} + } +} + +func saveCmd(path string, data []byte) tea.Cmd { + return func() tea.Msg { + err := os.WriteFile(path, data, outFilePerm) + return savedMsg{path: path, err: err} + } +} + +func tick() tea.Cmd { + return tea.Tick(animInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go new file mode 100644 index 00000000..bf7a2314 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go @@ -0,0 +1,224 @@ +/* +©AngelaMos | 2026 +flows_test.go + +Coverage for the file-mode payload, save flow, reset, and command plumbing +*/ + +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type nopMsg struct{} + +func hideStego(t *testing.T, secret string) []byte { + t.Helper() + out := filepath.Join(t.TempDir(), "s.png") + cover := makePNG(t, 64, 64) + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, typeText(secret)) + m, _ = step(m, keyEnter()) + m, _ = step(m, keyEnter()) + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + m, cmd := step(m, keyEnter()) + m = finishRun(t, m, cmd) + if m.engineErr != nil { + t.Fatalf("hide errored: %v", m.engineErr) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read stego: %v", err) + } + return data +} + +func TestInitReturnsCmd(t *testing.T) { + if New().Init() == nil { + t.Fatalf("Init returned nil") + } +} + +func TestLoadFileCmd(t *testing.T) { + p := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(p, []byte("hi"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + msg, ok := loadFileCmd(stageCover, p)().(fileLoadedMsg) + if !ok || msg.err != nil || string(msg.data) != "hi" || msg.origin != stageCover { + t.Fatalf("loadFileCmd = %+v ok=%v", msg, ok) + } + miss, _ := loadFileCmd(stageCover, filepath.Join(t.TempDir(), "nope"))().(fileLoadedMsg) + if miss.err == nil { + t.Fatalf("expected an error for a missing file") + } +} + +func TestPayloadFileMode(t *testing.T) { + cover := makePNG(t, 48, 48) + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + if m.stage != stagePayload { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keyTab()) + if m.payloadMode != payloadFile { + t.Fatalf("tab did not switch to file mode") + } + m, _ = step(m, fileLoadedMsg{origin: stagePayload, path: "/x/secret.bin", data: []byte("payloaddata")}) + if m.stage != stageSecure { + t.Fatalf("after payload file: stage = %v", m.stage) + } + if m.payloadLabel != "secret.bin" { + t.Fatalf("payload label = %q", m.payloadLabel) + } + if string(m.payloadBytes) != "payloaddata" { + t.Fatalf("payload bytes = %q", m.payloadBytes) + } +} + +func TestRevealSaveFlow(t *testing.T) { + stego := hideStego(t, "save me") + saveTo := filepath.Join(t.TempDir(), "recovered.txt") + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + r, _ = step(r, keyEnter()) + r, cmd := step(r, fileLoadedMsg{origin: stageCover, path: "s.png", data: stego}) + r = finishRun(t, r, cmd) + if r.stage != stageResult || r.engineErr != nil { + t.Fatalf("reveal: stage=%v err=%v", r.stage, r.engineErr) + } + if !r.canSaveReveal() { + t.Fatalf("revealed payload should be saveable") + } + r, _ = step(r, typeText("s")) + if !r.saving { + t.Fatalf("s did not enter saving mode") + } + r.outPath.SetValue(saveTo) + r, cmd = step(r, keyEnter()) + for _, msg := range drain(cmd) { + r, _ = step(r, msg) + } + if r.savedAt != saveTo { + t.Fatalf("savedAt = %q, want %q", r.savedAt, saveTo) + } + got, err := os.ReadFile(saveTo) + if err != nil || string(got) != "save me" { + t.Fatalf("saved file = %q err=%v", got, err) + } +} + +func TestResetOnNewRun(t *testing.T) { + m := ready() + m.stage = stageResult + m, _ = step(m, typeText("n")) + if m.stage != stageOperation { + t.Fatalf("new run did not reset, stage = %v", m.stage) + } +} + +func TestCoverKeyForwarding(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + if m.stage != stageCover { + t.Fatalf("stage = %v", m.stage) + } + browse, _ := step(m, keyDown()) + if browse.stage != stageCover { + t.Fatalf("browsing changed the stage to %v", browse.stage) + } + back, _ := step(m, keyEsc()) + if back.stage != stageFormat { + t.Fatalf("esc from cover went to %v", back.stage) + } +} + +func TestCapacityReviewKeys(t *testing.T) { + cover := makePNG(t, 32, 32) + m := ready() + m, _ = step(m, keyDown()) + m, _ = step(m, keyDown()) + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + if m.stage != stageReview { + t.Fatalf("stage = %v", m.stage) + } + back, _ := step(m, keyEsc()) + if back.stage != stageCover { + t.Fatalf("esc from capacity review went to %v", back.stage) + } + fresh, _ := step(m, typeText("n")) + if fresh.stage != stageOperation { + t.Fatalf("n from capacity review went to %v", fresh.stage) + } +} + +func TestForwardNonKeyMsgs(t *testing.T) { + for _, s := range []stage{stageCover, stagePayload, stageSecure, stageSave, stagePassphrase, stageResult} { + m := ready() + m.stage = s + m.saving = s == stageResult + m, _ = step(m, nopMsg{}) + if got := m.View(); got == "" { + t.Fatalf("empty view after nop msg at stage %v", s) + } + } +} + +func TestPassPromptsResetOnCoverLoad(t *testing.T) { + m := ready() + m.op = opReveal + m.stage = stageCover + m.passPrompts = 2 + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "x", data: []byte("not a stego")}) + if m.passPrompts != 0 { + t.Fatalf("passPrompts = %d after loading a fresh file, want 0", m.passPrompts) + } +} + +func TestHideFitsUsesCompressedSize(t *testing.T) { + cover := makePNG(t, 16, 16) + big := strings.Repeat("A", 200) + + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, typeText(big)) + m, _ = step(m, keyEnter()) + m, _ = step(m, keyDown()) + m, _ = step(m, keySpace()) + if !m.secure.compress { + t.Fatalf("compression not enabled") + } + m, _ = step(m, keyEnter()) + m.outPath.SetValue(filepath.Join(t.TempDir(), "o.png")) + m, _ = step(m, keyEnter()) + if m.stage != stageReview { + t.Fatalf("stage = %v", m.stage) + } + if len(big)+14 <= m.capValue { + t.Fatalf("precondition broken: raw payload already fits (cap=%d)", m.capValue) + } + if !m.hideFits() { + t.Fatalf("compressible payload should fit: envelope=%d cap=%d", m.envelopeSize, m.capValue) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go new file mode 100644 index 00000000..ee1c7bc1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go @@ -0,0 +1,134 @@ +/* +©AngelaMos | 2026 +meter.go + +The spectral capacity meter: payload-vs-carrier fill for hide, comparative table for capacity +*/ + +package tui + +import ( + "fmt" + "math" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + fullPercent = 100 + highWaterPercent = 80 + formatColumn = 7 + unboundedTag = "unbounded" +) + +type capRow struct { + format string + applicable bool + unbounded bool + capacity int + maxPlaintext int + maxEncrypted int + note string +} + +func renderHideMeter(payloadLen, envelope, capacity int, capErr error, barWidth int) string { + if capErr != nil { + return styleWarn.Render(reasonText(capErr)) + } + if capacity >= math.MaxInt32 { + bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false) + return lipgloss.JoinVertical(lipgloss.Left, + bar+" "+styleSuccess.Render(unboundedTag), + "", + kv("payload", fmt.Sprintf("%d B", payloadLen)), + kv("envelope", fmt.Sprintf("%d B", envelope)), + styleSuccess.Render("this carrier has effectively unbounded room; your payload always fits"), + ) + } + if capacity <= 0 { + return styleWarn.Render("this cover is too small to hold a payload") + } + + frac := float64(envelope) / float64(capacity) + over := envelope > capacity + pct := int(math.Round(math.Min(frac, 1) * fullPercent)) + + bar := spectralBar(barWidth, frac, capacityStops) + pctStyle := styleSuccess + if over { + pctStyle = styleError + } else if pct >= highWaterPercent { + pctStyle = styleWarn + } + + lines := []string{ + bar + " " + pctStyle.Render(fmt.Sprintf("%d%%", pct)), + "", + kv("payload", fmt.Sprintf("%d B", payloadLen)), + kv("envelope", fmt.Sprintf("%d B", envelope)), + kv("capacity", fmt.Sprintf("%d B", capacity)), + } + if over { + lines = append(lines, "", styleError.Render( + fmt.Sprintf("payload exceeds capacity by %d B: shorten it, enable compression, or pick a roomier carrier", envelope-capacity))) + } else { + lines = append(lines, "", styleSuccess.Render( + fmt.Sprintf("fits with %d B of headroom", capacity-envelope))) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func renderCapacityTable(rows []capRow, barWidth int) string { + maxCap := 0 + for _, r := range rows { + if r.applicable && !r.unbounded && r.capacity > maxCap { + maxCap = r.capacity + } + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + name := styleValueBold.Width(formatColumn).Render(r.format) + switch { + case !r.applicable: + out = append(out, name+styleHint.Render(r.note)) + case r.unbounded: + bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false) + out = append(out, name+bar+" "+styleHint.Render(unboundedTag)) + default: + frac := 0.0 + if maxCap > 0 { + frac = float64(r.capacity) / float64(maxCap) + } + bar := spectralBar(barWidth, frac, capacityStops) + out = append(out, name+bar+" "+capacityFacts(r)) + } + } + return lipgloss.JoinVertical(lipgloss.Left, out...) +} + +func capacityFacts(r capRow) string { + plain := styleValue.Render(fmt.Sprintf("%d B", r.maxPlaintext)) + styleLabel.Render(" plain") + var enc string + if r.maxEncrypted <= 0 { + enc = styleWarn.Render("encrypted does not fit") + } else { + enc = styleValue.Render(fmt.Sprintf("%d B", r.maxEncrypted)) + styleLabel.Render(" encrypted") + } + return plain + styleHelpSep.Render(" · ") + enc +} + +func reasonText(err error) string { + msg := err.Error() + if i := strings.Index(msg, ": "); i >= 0 { + return msg[i+2:] + } + return msg +} + +func clampZero(n int) int { + if n < 0 { + return 0 + } + return n +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go new file mode 100644 index 00000000..9012f4c7 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go @@ -0,0 +1,404 @@ +/* +©AngelaMos | 2026 +model.go + +The wizard model: state, flow definition, stage navigation, and engine-facing helpers +*/ + +package tui + +import ( + "bytes" + "errors" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/CarterPerez-dev/crypha/internal/engine" + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + appMaxWidth = 88 + appMinWidth = 46 + meterBarWidth = 44 + capBarWidth = 24 + embedBarWidth = 44 + filepickerHeight = 12 + messageWidth = 58 + passFieldWidth = 34 + outFieldWidth = 50 + passLimit = 256 + pathLimit = 512 + maxRevealPrompts = 3 + animStep = 0.06 + animInterval = 45 * time.Millisecond + outFilePerm = 0o600 + inlineLabel = "inline message" +) + +var ( + errNeedPayload = errors.New("enter a message, or press tab to choose a payload file") + errEmptyFile = errors.New("that file is empty; choose a file with contents") + errNeedPassphrase = errors.New("encryption is on: enter a passphrase or turn encryption off") + errNeedOutput = errors.New("enter an output path") + errPayloadTooBig = errors.New("payload does not fit: go back and shorten it, enable compression, or pick a roomier carrier") +) + +type stage int + +const ( + stageOperation stage = iota + stageFormat + stageTechnique + stageCover + stagePayload + stageSecure + stageSave + stageReview + stagePassphrase + stageRunning + stageResult +) + +type operation int + +const ( + opHide operation = iota + opReveal + opCapacity +) + +type payloadMode int + +const ( + payloadMessage payloadMode = iota + payloadFile +) + +type Model struct { + width int + height int + ready bool + quitting bool + + stage stage + op operation + + opPick picker + fmtPick picker + techPick picker + + files filepicker.Model + message textinput.Model + secure secureForm + outPath textinput.Model + revPass textinput.Model + + format string + technique string + coverPath string + coverBytes []byte + payloadBytes []byte + payloadMode payloadMode + payloadLabel string + pass []byte + + capValue int + capErr error + capRows []capRow + envelopeSize int + envelopeErr error + + hideRes engine.HideResult + revealRes engine.RevealResult + stego []byte + outputAt string + savedAt string + saving bool + + passPrompts int + engineDone bool + engineErr error + animFrac float64 + + err error +} + +func New() Model { + fp := filepicker.New() + if wd, err := os.Getwd(); err == nil { + fp.CurrentDirectory = wd + } + fp.ShowPermissions = false + fp.AutoHeight = false + fp.SetHeight(filepickerHeight) + + msg := textinput.New() + msg.Prompt = "" + msg.Placeholder = "type a secret message" + msg.Width = messageWidth + + out := textinput.New() + out.Prompt = "" + out.CharLimit = pathLimit + out.Width = outFieldWidth + + rev := textinput.New() + rev.Prompt = "" + rev.EchoMode = textinput.EchoPassword + rev.CharLimit = passLimit + rev.Width = passFieldWidth + + return Model{ + files: fp, + message: msg, + outPath: out, + revPass: rev, + secure: newSecureForm(), + opPick: newPicker(operationItems()), + } +} + +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +func (m Model) flow() []stage { + switch m.op { + case opReveal: + return []stage{stageOperation, stageFormat, stageCover, stageRunning, stageResult} + case opCapacity: + return []stage{stageOperation, stageCover, stageReview} + default: + steps := []stage{stageOperation, stageFormat} + if hasTechniques(m.format) { + steps = append(steps, stageTechnique) + } + return append(steps, stageCover, stagePayload, stageSecure, stageSave, stageReview, stageRunning, stageResult) + } +} + +func (m Model) adjacentStage(dir int) (stage, bool) { + f := m.flow() + for i, s := range f { + if s != m.stage { + continue + } + j := i + dir + if j < 0 || j >= len(f) { + return m.stage, false + } + return f[j], true + } + return m.stage, false +} + +func (m Model) advance() (tea.Model, tea.Cmd) { + next, ok := m.adjacentStage(1) + if !ok { + return m, nil + } + m.err = nil + cmd := m.enter(next) + return m, cmd +} + +func (m Model) back() (tea.Model, tea.Cmd) { + prev, ok := m.adjacentStage(-1) + if !ok { + return m, nil + } + m.err = nil + cmd := m.enter(prev) + return m, cmd +} + +func (m *Model) enter(s stage) tea.Cmd { + m.stage = s + switch s { + case stageFormat: + m.fmtPick = newPicker(formatItems(m.op == opReveal)) + case stageTechnique: + m.techPick = newPicker(techniqueItems(m.format)) + case stageCover: + return m.files.Init() + case stagePayload: + m.payloadMode = payloadMessage + return m.message.Focus() + case stageSave: + if strings.TrimSpace(m.outPath.Value()) == "" { + m.outPath.SetValue(suggestOutput(m.coverPath, m.format)) + } + return m.outPath.Focus() + case stageReview: + m.prepareReview() + case stageRunning: + return m.startRun() + } + return nil +} + +func (m *Model) prepareReview() { + switch m.op { + case opHide: + m.capValue, m.capErr = engine.Capacity(m.format, bytes.NewReader(m.coverBytes)) + m.envelopeSize, m.envelopeErr = engine.EnvelopeSize(m.payloadBytes, m.buildOptions()) + case opCapacity: + m.capRows = buildCapRows(engine.CapacityAll(m.coverBytes)) + } +} + +func (m *Model) startRun() tea.Cmd { + m.animFrac = 0 + m.engineDone = false + m.engineErr = nil + switch m.op { + case opReveal: + return tea.Batch(revealCmd(m.format, m.coverBytes, m.revPassValue()), tick()) + default: + req := engine.HideRequest{ + Format: m.format, + Technique: m.technique, + Cover: bytes.NewReader(m.coverBytes), + Payload: m.payloadBytes, + Options: m.buildOptions(), + } + return tea.Batch(hideCmd(req, m.outPath.Value()), tick()) + } +} + +func (m Model) reset() (tea.Model, tea.Cmd) { + next := New() + next.width = m.width + next.height = m.height + next.ready = m.ready + return next, textinput.Blink +} + +func (m Model) buildOptions() payload.Options { + return payload.Options{ + Passphrase: m.secure.passphrase(), + Compress: m.secure.compress, + Cipher: payload.Cipher(m.secure.cipherValue()), + Strength: payload.Strength(m.secure.strengthValue()), + } +} + +func (m Model) revPassValue() []byte { + v := m.revPass.Value() + if v == "" { + return nil + } + return []byte(v) +} + +func (m Model) encrypted() bool { + return len(m.pass) > 0 +} + +func (m Model) hideFits() bool { + if m.capErr != nil || m.envelopeErr != nil { + return false + } + if m.capValue >= math.MaxInt32 { + return true + } + return m.envelopeSize <= m.capValue +} + +func hasTechniques(format string) bool { + return len(engine.Techniques(format)) > 0 +} + +func suggestOutput(coverPath, format string) string { + base := strings.TrimSuffix(filepath.Base(coverPath), filepath.Ext(coverPath)) + if base == "" || base == "." { + base = config.BinaryName + } + return base + ".stego" + outputExt(format) +} + +func (m Model) suggestReveal() string { + base := strings.TrimSuffix(filepath.Base(m.coverPath), filepath.Ext(m.coverPath)) + if base == "" || base == "." { + base = config.BinaryName + } + return base + ".revealed" +} + +func outputExt(format string) string { + switch strings.ToLower(config.FormatDetails[format].Output) { + case "png": + return ".png" + case "wav": + return ".wav" + case "pdf": + return ".pdf" + case "text": + return ".txt" + default: + return ".out" + } +} + +func buildCapRows(rows []engine.CapacityRow) []capRow { + out := make([]capRow, 0, len(rows)) + for _, r := range rows { + cr := capRow{format: r.Format} + switch { + case r.Err != nil: + cr.note = reasonText(r.Err) + case r.Capacity >= math.MaxInt32: + cr.applicable = true + cr.unbounded = true + default: + cr.applicable = true + cr.capacity = r.Capacity + cr.maxPlaintext = clampZero(r.Capacity - engine.Overhead(false)) + cr.maxEncrypted = clampZero(r.Capacity - engine.Overhead(true)) + } + out = append(out, cr) + } + return out +} + +func operationItems() []pickItem { + return []pickItem{ + {title: "hide", desc: "embed an encrypted payload inside a cover file"}, + {title: "reveal", desc: "extract and decrypt a payload hidden in a stego file"}, + {title: "capacity", desc: "measure how many payload bytes each carrier can hold for a cover"}, + } +} + +func formatItems(withAuto bool) []pickItem { + items := make([]pickItem, 0) + if withAuto { + items = append(items, pickItem{title: "auto-detect", value: "", desc: "let crypha identify the carrier by inspecting the file"}) + } + for _, fi := range engine.Catalog() { + d := config.FormatDetails[fi.Name] + desc := d.Blurb + " · cover: " + d.CoverInput + " · output: " + d.Output + items = append(items, pickItem{title: fi.Name, value: fi.Name, desc: desc}) + } + return items +} + +func techniqueItems(format string) []pickItem { + blurbs := map[string]string{ + "attachment": "embed the payload as a lossless PDF file attachment (default)", + "metadata": "stash the payload inside the PDF Info dictionary", + "append": "append the payload after the PDF end-of-file marker", + } + items := make([]pickItem, 0) + for _, t := range engine.Techniques(format) { + items = append(items, pickItem{title: t, value: t, desc: blurbs[t]}) + } + return items +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go new file mode 100644 index 00000000..86d1a188 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go @@ -0,0 +1,65 @@ +/* +©AngelaMos | 2026 +picker.go + +A compact keyboard-driven selection list with an inline blurb for the highlighted item +*/ + +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +type pickItem struct { + title string + desc string + value string +} + +type picker struct { + items []pickItem + cursor int +} + +func newPicker(items []pickItem) picker { + return picker{items: items} +} + +func (p *picker) up() { + if len(p.items) == 0 { + return + } + p.cursor = (p.cursor - 1 + len(p.items)) % len(p.items) +} + +func (p *picker) down() { + if len(p.items) == 0 { + return + } + p.cursor = (p.cursor + 1) % len(p.items) +} + +func (p picker) selected() pickItem { + if len(p.items) == 0 { + return pickItem{} + } + return p.items[p.cursor] +} + +func (p picker) view(width int) string { + rows := make([]string, 0, len(p.items)) + for i, it := range p.items { + if i == p.cursor { + rows = append(rows, styleCursor.Render(accentBar)+" "+styleSelected.Render(it.title)) + } else { + rows = append(rows, " "+styleUnselected.Render(it.title)) + } + } + list := lipgloss.JoinVertical(lipgloss.Left, rows...) + if sel := p.selected(); sel.desc != "" { + blurb := styleHint.Width(width).Render(sel.desc) + return lipgloss.JoinVertical(lipgloss.Left, list, "", blurb) + } + return list +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go new file mode 100644 index 00000000..18672ef6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go @@ -0,0 +1,224 @@ +/* +©AngelaMos | 2026 +secure.go + +The encryption sub-form: a focusable list of toggle, choice, and secret controls +*/ + +package tui + +import ( + "strings" + + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type secField int + +const ( + secEncrypt secField = iota + secPass + secCipher + secStrength + secCompress +) + +var ( + cipherChoices = []string{string(payload.CipherChaCha20), string(payload.CipherAES256GCM)} + strengthChoices = []string{string(payload.StrengthDefault), string(payload.StrengthHigh)} +) + +type secureForm struct { + encrypt bool + pass textinput.Model + cipher int + strength int + compress bool + focus int +} + +func newSecureForm() secureForm { + ti := textinput.New() + ti.Prompt = "" + ti.Placeholder = "passphrase" + ti.EchoMode = textinput.EchoPassword + ti.CharLimit = passLimit + ti.Width = passFieldWidth + return secureForm{pass: ti} +} + +func (f secureForm) fields() []secField { + if f.encrypt { + return []secField{secEncrypt, secPass, secCipher, secStrength, secCompress} + } + return []secField{secEncrypt, secCompress} +} + +func (f secureForm) focusField() secField { + fs := f.fields() + i := f.focus + if i < 0 { + i = 0 + } + if i >= len(fs) { + i = len(fs) - 1 + } + return fs[i] +} + +func (f *secureForm) move(delta int) { + n := len(f.fields()) + f.focus += delta + if f.focus < 0 { + f.focus = 0 + } + if f.focus >= n { + f.focus = n - 1 + } + f.syncFocus() +} + +func (f *secureForm) syncFocus() tea.Cmd { + if f.focusField() == secPass { + return f.pass.Focus() + } + f.pass.Blur() + return nil +} + +func (f secureForm) cipherValue() string { + return cipherChoices[f.cipher] +} + +func (f secureForm) strengthValue() string { + return strengthChoices[f.strength] +} + +func (f secureForm) passphrase() []byte { + if !f.encrypt { + return nil + } + v := f.pass.Value() + if v == "" { + return nil + } + return []byte(v) +} + +func (f secureForm) update(msg tea.Msg) (secureForm, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + var cmd tea.Cmd + f.pass, cmd = f.pass.Update(msg) + return f, cmd + } + switch km.String() { + case "up", "shift+tab": + cmd := f.moveWith(-1) + return f, cmd + case "down", "tab": + cmd := f.moveWith(1) + return f, cmd + } + switch f.focusField() { + case secEncrypt: + if isToggleKey(km) { + f.encrypt = !f.encrypt + f.syncFocus() + } + case secCompress: + if isToggleKey(km) { + f.compress = !f.compress + } + case secCipher: + f.cipher = cycleChoice(f.cipher, len(cipherChoices), km) + case secStrength: + f.strength = cycleChoice(f.strength, len(strengthChoices), km) + case secPass: + var cmd tea.Cmd + f.pass, cmd = f.pass.Update(msg) + return f, cmd + } + return f, nil +} + +func (f *secureForm) moveWith(delta int) tea.Cmd { + f.move(delta) + return f.syncFocus() +} + +func isToggleKey(km tea.KeyMsg) bool { + switch km.String() { + case " ", "left", "right": + return true + } + return false +} + +func cycleChoice(idx, n int, km tea.KeyMsg) int { + switch km.String() { + case "left": + return (idx - 1 + n) % n + case "right", " ": + return (idx + 1) % n + } + return idx +} + +func (f secureForm) view(width int) string { + rows := make([]string, 0, len(f.fields())) + for i, fld := range f.fields() { + rows = append(rows, f.fieldRow(fld, i == f.focus)) + } + note := styleHint.Width(width).Render("up/down move · space or left/right change · enter continues") + return lipgloss.JoinVertical(lipgloss.Left, lipgloss.JoinVertical(lipgloss.Left, rows...), "", note) +} + +func (f secureForm) fieldRow(fld secField, focused bool) string { + prefix := " " + labelStyle := styleLabel + if focused { + prefix = styleCursor.Render(accentBar) + " " + labelStyle = styleValueBold + } + label, control := f.labelControl(fld) + return prefix + labelStyle.Width(labelWidth).Render(label) + control +} + +func (f secureForm) labelControl(fld secField) (string, string) { + switch fld { + case secEncrypt: + return "encrypt", toggleControl(f.encrypt) + case secPass: + return "passphrase", f.pass.View() + case secCipher: + return "cipher", choiceControl(cipherChoices, f.cipher) + case secStrength: + return "key strength", choiceControl(strengthChoices, f.strength) + case secCompress: + return "compress", toggleControl(f.compress) + } + return "", "" +} + +func toggleControl(on bool) string { + return pill("off", !on) + " " + pill("on", on) +} + +func choiceControl(choices []string, idx int) string { + parts := make([]string, len(choices)) + for i, c := range choices { + parts[i] = pill(c, i == idx) + } + return strings.Join(parts, " ") +} + +func pill(text string, active bool) string { + if active { + return badge(text, hexViolet) + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)).Padding(0, 1).Render(text) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go new file mode 100644 index 00000000..a230bb71 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go @@ -0,0 +1,96 @@ +/* +©AngelaMos | 2026 +steps.go + +Per-operation wizard step labels, current-step mapping, and contextual footer hints +*/ + +package tui + +func (m Model) stepLabels() []string { + switch m.op { + case opReveal: + return []string{"operation", "carrier", "stego", "reveal"} + case opCapacity: + return []string{"operation", "cover", "report"} + default: + labels := []string{"operation", "carrier"} + if hasTechniques(m.format) { + labels = append(labels, "technique") + } + return append(labels, "cover", "payload", "secure", "save", "embed") + } +} + +func (m Model) stepStages() []stage { + switch m.op { + case opReveal: + return []stage{stageOperation, stageFormat, stageCover, stageRunning} + case opCapacity: + return []stage{stageOperation, stageCover, stageReview} + default: + stages := []stage{stageOperation, stageFormat} + if hasTechniques(m.format) { + stages = append(stages, stageTechnique) + } + return append(stages, stageCover, stagePayload, stageSecure, stageSave, stageReview) + } +} + +func (m Model) stepIndex() int { + target := m.stage + switch m.stage { + case stagePassphrase: + target = stageRunning + case stageRunning, stageResult: + if m.op == opReveal { + target = stageRunning + } else { + target = stageReview + } + } + for i, s := range m.stepStages() { + if s == target { + return i + } + } + return 0 +} + +func (m Model) footerHints() []keyHint { + switch m.stage { + case stageOperation: + return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"q", "quit"}} + case stageFormat, stageTechnique: + return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"esc", "back"}, {"q", "quit"}} + case stageCover: + return []keyHint{{"up/down", "browse"}, {"enter", "open or select"}, {"esc", "back"}} + case stagePayload: + if m.payloadMode == payloadFile { + return []keyHint{{"up/down", "browse"}, {"enter", "choose"}, {"tab", "message"}, {"esc", "back"}} + } + return []keyHint{{"type", "message"}, {"enter", "continue"}, {"tab", "file"}, {"esc", "back"}} + case stageSecure: + return []keyHint{{"up/down", "field"}, {"space", "change"}, {"enter", "continue"}, {"esc", "back"}} + case stageSave: + return []keyHint{{"type", "path"}, {"enter", "continue"}, {"esc", "back"}} + case stageReview: + if m.op == opCapacity { + return []keyHint{{"esc", "back"}, {"n", "new run"}, {"q", "quit"}} + } + return []keyHint{{"enter", "embed"}, {"esc", "back"}, {"q", "quit"}} + case stagePassphrase: + return []keyHint{{"enter", "unlock"}, {"esc", "cancel"}} + case stageResult: + if m.saving { + return []keyHint{{"enter", "write"}, {"esc", "cancel"}} + } + if m.canSaveReveal() { + return []keyHint{{"s", "save"}, {"n", "new run"}, {"q", "quit"}} + } + return []keyHint{{"n", "new run"}, {"q", "quit"}} + case stageRunning: + return []keyHint{{"working", "..."}} + } + return nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go new file mode 100644 index 00000000..2af46d52 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go @@ -0,0 +1,217 @@ +/* +©AngelaMos | 2026 +theme.go + +Spectral cybercore palette, HCL gradient engine, and lipgloss styles for the TUI +*/ + +package tui + +import ( + "math" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/lucasb-eyer/go-colorful" +) + +const ( + hexVioletLight = "#A78BFA" + hexViolet = "#8B5CF6" + hexVioletDeep = "#6D4AFF" + + hexBlue = "#4457E8" + hexBlueBright = "#6E86FF" + hexBlueDim = "#5A63A8" + + hexRed = "#CE2417" + + hexBright = "#E9E6F5" + hexNormal = "#C4C0D6" + hexDim = "#8B86A6" + hexFaint = "#46425F" + + hexFrame = "#39346B" + hexInk = "#0C0A16" +) + +const ( + blockFull = "█" + blockEmpty = "░" + ruleGlyph = "━" + accentBar = "▌" + connector = "─" +) + +var ( + brandStops = mustStops(hexVioletLight, hexViolet, hexVioletDeep, hexBlue) + capacityStops = mustStops(hexBlue, hexVioletLight, hexViolet, hexVioletDeep) + unboundedStops = mustStops(hexBlue, hexViolet) +) + +var ( + styleTagline = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)).Italic(true) + + styleLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueDim)) + styleValue = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)) + styleValueBold = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true) + + stylePanelTitle = lipgloss.NewStyle().Foreground(lipgloss.Color(hexViolet)).Bold(true) + styleCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true) + + styleStepCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true) + styleStepDone = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue)) + styleStepFuture = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)) + + styleSelected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true) + styleUnselected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexNormal)) + styleHint = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)) + + styleError = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true) + styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueBright)).Bold(true) + styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)) + + styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue)).Bold(true) + styleHelpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)) + styleHelpSep = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)) + + styleTrack = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)) +) + +const ( + framePadX = 3 + framePadY = 1 + frameBorder = 1 + horizontalGutter = 2 +) + +func frameStyle(width int) lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(hexFrame)). + Padding(framePadY, framePadX). + Width(width) +} + +func badge(text, hex string) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(hexInk)). + Background(lipgloss.Color(hex)). + Bold(true). + Padding(0, 1). + Render(text) +} + +func mustStops(hexes ...string) []colorful.Color { + stops := make([]colorful.Color, len(hexes)) + for i, h := range hexes { + c, err := colorful.Hex(h) + if err != nil { + panic("tui: invalid palette hex " + h) + } + stops[i] = c + } + return stops +} + +func ramp(stops []colorful.Color, n int) []lipgloss.Color { + out := make([]lipgloss.Color, n) + if n == 0 { + return out + } + if n == 1 || len(stops) == 1 { + for i := range out { + out[i] = lipgloss.Color(stops[0].Hex()) + } + return out + } + segments := len(stops) - 1 + for i := range out { + x := float64(i) / float64(n-1) * float64(segments) + idx := int(x) + if idx >= segments { + idx = segments - 1 + } + local := x - float64(idx) + out[i] = lipgloss.Color(stops[idx].BlendHcl(stops[idx+1], local).Clamped().Hex()) + } + return out +} + +func gradientText(text string, stops []colorful.Color, bold bool) string { + runes := []rune(text) + cols := ramp(stops, len(runes)) + var b strings.Builder + for i, r := range runes { + s := lipgloss.NewStyle().Foreground(cols[i]) + if bold { + s = s.Bold(true) + } + b.WriteString(s.Render(string(r))) + } + return b.String() +} + +func gradientBlock(lines []string, stops []colorful.Color, bold bool) string { + width := 0 + for _, ln := range lines { + if w := len([]rune(ln)); w > width { + width = w + } + } + cols := ramp(stops, width) + out := make([]string, len(lines)) + for li, ln := range lines { + var b strings.Builder + for i, r := range []rune(ln) { + s := lipgloss.NewStyle().Foreground(cols[i]) + if bold { + s = s.Bold(true) + } + b.WriteString(s.Render(string(r))) + } + out[li] = b.String() + } + return strings.Join(out, "\n") +} + +func gradientRule(width int, stops []colorful.Color) string { + if width <= 0 { + return "" + } + return gradientText(strings.Repeat(ruleGlyph, width), stops, false) +} + +func spectralBar(width int, frac float64, stops []colorful.Color) string { + if width <= 0 { + return "" + } + over := frac > 1 + if frac < 0 { + frac = 0 + } + if frac > 1 { + frac = 1 + } + filled := int(math.Round(frac * float64(width))) + if filled > width { + filled = width + } + cols := ramp(capacityStops, width) + if stops != nil { + cols = ramp(stops, width) + } + var b strings.Builder + for i := range width { + if i < filled { + col := cols[i] + if over { + col = lipgloss.Color(hexRed) + } + b.WriteString(lipgloss.NewStyle().Foreground(col).Render(blockFull)) + } else { + b.WriteString(styleTrack.Render(blockEmpty)) + } + } + return b.String() +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go new file mode 100644 index 00000000..077bcd45 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go @@ -0,0 +1,149 @@ +/* +©AngelaMos | 2026 +theme_test.go + +Unit tests for the gradient engine, meter geometry, and pure view helpers +*/ + +package tui + +import ( + "testing" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" +) + +func TestRampLength(t *testing.T) { + for _, n := range []int{0, 1, 2, 5, 30, 88} { + if got := len(ramp(brandStops, n)); got != n { + t.Fatalf("ramp(%d) length = %d", n, got) + } + } +} + +func TestSpectralBarWidthInvariant(t *testing.T) { + for _, w := range []int{1, 8, 24, 44} { + for _, frac := range []float64{-0.5, 0, 0.33, 0.5, 1, 2.5} { + bar := spectralBar(w, frac, capacityStops) + if got := lipgloss.Width(bar); got != w { + t.Fatalf("spectralBar(w=%d frac=%.2f) width = %d", w, frac, got) + } + } + } +} + +func TestGradientTextPreservesWidth(t *testing.T) { + if got := lipgloss.Width(gradientText("crypha", brandStops, true)); got != 6 { + t.Fatalf("gradientText width = %d, want 6", got) + } +} + +func TestWordmarkRowsAligned(t *testing.T) { + lines := wordmarkLines() + if len(lines) != wordmarkRows { + t.Fatalf("wordmark has %d rows, want %d", len(lines), wordmarkRows) + } + width := utf8.RuneCountInString(lines[0]) + for i, ln := range lines { + if got := utf8.RuneCountInString(ln); got != width { + t.Fatalf("row %d width = %d, want %d", i, got, width) + } + } +} + +func TestPickerWraps(t *testing.T) { + p := newPicker([]pickItem{{title: "a"}, {title: "b"}, {title: "c"}}) + p.up() + if p.cursor != 2 { + t.Fatalf("up from 0 = %d, want 2", p.cursor) + } + p.down() + if p.cursor != 0 { + t.Fatalf("down from 2 = %d, want 0", p.cursor) + } +} + +func TestSecureFormFieldsAndToggle(t *testing.T) { + f := newSecureForm() + if len(f.fields()) != 2 { + t.Fatalf("plaintext fields = %d, want 2", len(f.fields())) + } + f, _ = f.update(keySpace()) + if !f.encrypt { + t.Fatalf("space did not enable encryption") + } + if len(f.fields()) != 5 { + t.Fatalf("encrypted fields = %d, want 5", len(f.fields())) + } + f, _ = f.update(keyDown()) + f, _ = f.update(typeText("s3cret")) + if string(f.passphrase()) != "s3cret" { + t.Fatalf("passphrase = %q", f.passphrase()) + } + if f.cipherValue() != "chacha20" { + t.Fatalf("default cipher = %q", f.cipherValue()) + } +} + +func TestSecureFormChoiceCycles(t *testing.T) { + f := newSecureForm() + f, _ = f.update(keySpace()) + f, _ = f.update(keyDown()) + f, _ = f.update(keyDown()) + if f.focusField() != secCipher { + t.Fatalf("focus = %d, want secCipher", f.focusField()) + } + before := f.cipherValue() + f, _ = f.update(keySpace()) + if f.cipherValue() == before { + t.Fatalf("cipher did not cycle from %q", before) + } +} + +func TestOutputExtensions(t *testing.T) { + cases := map[string]string{"image": ".png", "audio": ".wav", "qr": ".png", "text": ".txt", "pdf": ".pdf"} + for format, want := range cases { + if got := outputExt(format); got != want { + t.Fatalf("outputExt(%q) = %q, want %q", format, got, want) + } + } +} + +func TestSuggestOutput(t *testing.T) { + if got := suggestOutput("/home/x/photo.png", "image"); got != "photo.stego.png" { + t.Fatalf("suggestOutput = %q", got) + } + if got := suggestOutput("", "audio"); got != "crypha.stego.wav" { + t.Fatalf("suggestOutput empty = %q", got) + } +} + +func TestIsPrintable(t *testing.T) { + if !isPrintable([]byte("hello\nworld\t!")) { + t.Fatalf("printable text rejected") + } + if isPrintable([]byte{0x00, 0x01, 0xff}) { + t.Fatalf("binary accepted as printable") + } +} + +func TestHexPreviewTruncates(t *testing.T) { + data := make([]byte, 100) + got := hexPreview(data, 4) + if got != "00 00 00 00 ..." { + t.Fatalf("hexPreview = %q", got) + } +} + +func TestFormatLabel(t *testing.T) { + if formatLabel("", "") != "auto-detect" { + t.Fatalf("empty format label") + } + if formatLabel("pdf", "append") != "pdf (append)" { + t.Fatalf("technique label") + } + if formatLabel("image", "") != "image" { + t.Fatalf("plain format label") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go new file mode 100644 index 00000000..d5a44121 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go @@ -0,0 +1,17 @@ +/* +©AngelaMos | 2026 +tui.go + +Program entry that constructs and runs the interactive bubbletea wizard +*/ + +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func Run() error { + _, err := tea.NewProgram(New(), tea.WithAltScreen()).Run() + return err +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go new file mode 100644 index 00000000..22857fdf --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go @@ -0,0 +1,443 @@ +/* +©AngelaMos | 2026 +tui_test.go + +Model navigation, engine round-trips through the wizard, and view smoke tests +*/ + +package tui + +import ( + "bytes" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/engine" + tea "github.com/charmbracelet/bubbletea" +) + +func ready() Model { + m := New() + nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 44}) + return nm.(Model) +} + +func step(m Model, msg tea.Msg) (Model, tea.Cmd) { + nm, cmd := m.Update(msg) + return nm.(Model), cmd +} + +func keyEnter() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} } +func keyEsc() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} } +func keyDown() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDown} } +func keyTab() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyTab} } +func keySpace() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeySpace} } + +func typeText(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func drain(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + var out []tea.Msg + for _, c := range batch { + out = append(out, drain(c)...) + } + return out + } + return []tea.Msg{msg} +} + +func finishRun(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + for _, msg := range drain(cmd) { + m, _ = step(m, msg) + } + for i := 0; i < 60 && m.stage == stageRunning; i++ { + m, _ = step(m, tickMsg{}) + } + return m +} + +func makePNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x + y), A: 0xFF}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func pickFormat(t *testing.T, m Model, name string) Model { + t.Helper() + for i := 0; i <= len(m.fmtPick.items); i++ { + if m.fmtPick.selected().value == name { + return m + } + m, _ = step(m, keyDown()) + } + t.Fatalf("format %q not present in picker", name) + return m +} + +func TestHideRevealRoundTripPlaintext(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "out.png") + cover := makePNG(t, 64, 64) + const secret = "attack at dawn" + + m := ready() + m, _ = step(m, keyEnter()) + if m.stage != stageFormat { + t.Fatalf("after operation: stage = %v", m.stage) + } + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + if m.stage != stageCover { + t.Fatalf("after format: stage = %v", m.stage) + } + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover}) + if m.stage != stagePayload { + t.Fatalf("after cover: stage = %v", m.stage) + } + m, _ = step(m, typeText(secret)) + m, _ = step(m, keyEnter()) + if m.stage != stageSecure { + t.Fatalf("after payload: stage = %v", m.stage) + } + m, _ = step(m, keyEnter()) + if m.stage != stageSave { + t.Fatalf("after secure: stage = %v", m.stage) + } + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + if m.stage != stageReview { + t.Fatalf("after save: stage = %v", m.stage) + } + if !m.hideFits() { + t.Fatalf("payload should fit a 64x64 cover") + } + m, cmd := step(m, keyEnter()) + if m.stage != stageRunning { + t.Fatalf("after review: stage = %v", m.stage) + } + m = finishRun(t, m, cmd) + if m.stage != stageResult { + t.Fatalf("run did not finish: stage = %v", m.stage) + } + if m.engineErr != nil { + t.Fatalf("hide errored: %v", m.engineErr) + } + if m.hideRes.Format != "image" { + t.Fatalf("hide format = %q", m.hideRes.Format) + } + if _, err := os.Stat(out); err != nil { + t.Fatalf("stego file not written: %v", err) + } + + stego, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read stego: %v", err) + } + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + if r.op != opReveal || r.stage != stageFormat { + t.Fatalf("reveal setup: op=%v stage=%v", r.op, r.stage) + } + r, _ = step(r, keyEnter()) + if r.format != "" || r.stage != stageCover { + t.Fatalf("reveal auto-detect setup: format=%q stage=%v", r.format, r.stage) + } + r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego}) + if r.stage != stageRunning { + t.Fatalf("reveal after stego: stage = %v", r.stage) + } + r = finishRun(t, r, cmd) + if r.stage != stageResult { + t.Fatalf("reveal did not finish: stage = %v", r.stage) + } + if r.engineErr != nil { + t.Fatalf("reveal errored: %v", r.engineErr) + } + if got := string(r.revealRes.Data); got != secret { + t.Fatalf("revealed %q, want %q", got, secret) + } +} + +func TestHideRevealRoundTripEncrypted(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "enc.png") + cover := makePNG(t, 64, 64) + const secret = "the eagle lands at noon" + const pass = "correct horse battery staple" + + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover}) + m, _ = step(m, typeText(secret)) + m, _ = step(m, keyEnter()) + if m.stage != stageSecure { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keySpace()) + if !m.secure.encrypt { + t.Fatalf("encrypt toggle did not engage") + } + m, _ = step(m, keyDown()) + m, _ = step(m, typeText(pass)) + m, _ = step(m, keyEnter()) + if m.stage != stageSave { + t.Fatalf("after secure: stage = %v", m.stage) + } + if len(m.pass) == 0 { + t.Fatalf("passphrase not captured") + } + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + m, cmd := step(m, keyEnter()) + m = finishRun(t, m, cmd) + if m.engineErr != nil { + t.Fatalf("encrypted hide errored: %v", m.engineErr) + } + if !m.hideRes.Encrypted { + t.Fatalf("result not marked encrypted") + } + + stego, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read stego: %v", err) + } + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + r = pickFormat(t, r, "image") + r, _ = step(r, keyEnter()) + if r.format != "image" { + t.Fatalf("reveal format = %q", r.format) + } + r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego}) + r = finishRun(t, r, cmd) + if r.stage != stagePassphrase { + t.Fatalf("encrypted reveal should prompt for a passphrase, stage = %v", r.stage) + } + r, _ = step(r, typeText(pass)) + r, cmd = step(r, keyEnter()) + if r.stage != stageRunning { + t.Fatalf("after passphrase: stage = %v", r.stage) + } + r = finishRun(t, r, cmd) + if r.stage != stageResult || r.engineErr != nil { + t.Fatalf("encrypted reveal failed: stage=%v err=%v", r.stage, r.engineErr) + } + if got := string(r.revealRes.Data); got != secret { + t.Fatalf("revealed %q, want %q", got, secret) + } + if !r.revealRes.Encrypted { + t.Fatalf("revealed payload not marked encrypted") + } +} + +func TestWrongPassphraseReprompts(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "wp.png") + cover := makePNG(t, 64, 64) + + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, typeText("classified")) + m, _ = step(m, keyEnter()) + m, _ = step(m, keySpace()) + m, _ = step(m, keyDown()) + m, _ = step(m, typeText("realpass")) + m, _ = step(m, keyEnter()) + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + m, cmd := step(m, keyEnter()) + m = finishRun(t, m, cmd) + if m.engineErr != nil { + t.Fatalf("hide errored: %v", m.engineErr) + } + stego, _ := os.ReadFile(out) + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + r = pickFormat(t, r, "image") + r, _ = step(r, keyEnter()) + r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego}) + r = finishRun(t, r, cmd) + if r.stage != stagePassphrase { + t.Fatalf("stage = %v", r.stage) + } + r, _ = step(r, typeText("wrongpass")) + r, cmd = step(r, keyEnter()) + r = finishRun(t, r, cmd) + if r.stage != stagePassphrase { + t.Fatalf("wrong passphrase should re-prompt, stage = %v", r.stage) + } + if r.passPrompts < 2 { + t.Fatalf("passPrompts = %d, want >= 2", r.passPrompts) + } +} + +func TestCapacityFlow(t *testing.T) { + cover := makePNG(t, 48, 48) + m := ready() + m, _ = step(m, keyDown()) + m, _ = step(m, keyDown()) + m, _ = step(m, keyEnter()) + if m.op != opCapacity || m.stage != stageCover { + t.Fatalf("capacity setup: op=%v stage=%v", m.op, m.stage) + } + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover}) + if m.stage != stageReview { + t.Fatalf("after cover: stage = %v", m.stage) + } + if len(m.capRows) == 0 { + t.Fatalf("capacity rows not built") + } + var haveImage bool + for _, r := range m.capRows { + if r.format == "image" { + haveImage = true + if !r.applicable || r.maxPlaintext <= 0 { + t.Fatalf("image row = %+v", r) + } + } + } + if !haveImage { + t.Fatalf("no image row in capacity report") + } +} + +func TestPdfTechniqueBranch(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "pdf") + m, _ = step(m, keyEnter()) + if m.format != "pdf" || m.stage != stageTechnique { + t.Fatalf("pdf branch: format=%q stage=%v", m.format, m.stage) + } + m, _ = step(m, keyEnter()) + if m.technique != "attachment" || m.stage != stageCover { + t.Fatalf("technique branch: technique=%q stage=%v", m.technique, m.stage) + } +} + +func TestTechniqueClearedOnFormatChange(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "pdf") + m, _ = step(m, keyEnter()) + if m.stage != stageTechnique { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keyDown()) + m, _ = step(m, keyEnter()) + if m.technique == "" { + t.Fatalf("technique should be set after choosing one") + } + m, _ = step(m, keyEsc()) + m, _ = step(m, keyEsc()) + if m.stage != stageFormat { + t.Fatalf("back to format: stage = %v", m.stage) + } + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + if m.technique != "" { + t.Fatalf("technique not cleared after switching to image: %q", m.technique) + } + if m.stage != stageCover { + t.Fatalf("stage = %v", m.stage) + } +} + +func TestBackNavigation(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + if m.stage != stageFormat { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keyEsc()) + if m.stage != stageOperation { + t.Fatalf("back did not return to operation: %v", m.stage) + } +} + +func TestEmptyMessageBlocks(t *testing.T) { + cover := makePNG(t, 32, 32) + m := ready() + m, _ = step(m, keyEnter()) + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, keyEnter()) + if m.stage != stagePayload { + t.Fatalf("empty message should not advance, stage = %v", m.stage) + } + if m.err == nil { + t.Fatalf("expected a validation error") + } +} + +func TestViewAllStagesNoPanic(t *testing.T) { + cover := makePNG(t, 16, 16) + stages := []stage{ + stageOperation, stageFormat, stageTechnique, stageCover, stagePayload, + stageSecure, stageSave, stageReview, stagePassphrase, stageRunning, stageResult, + } + for _, op := range []operation{opHide, opReveal, opCapacity} { + for _, s := range stages { + for _, w := range []int{50, 100} { + m := ready() + m.width = w + m.op = op + m.stage = s + m.format = "image" + m.coverPath = "cover.png" + m.payloadBytes = []byte("preview") + m.payloadLabel = inlineLabel + m.capValue = 4096 + m.envelopeSize = 200 + m.capRows = buildCapRows(engine.CapacityAll(cover)) + m.fmtPick = newPicker(formatItems(op == opReveal)) + m.techPick = newPicker(techniqueItems("pdf")) + m.revealRes = engine.RevealResult{Format: "image", Data: []byte("hi")} + m.hideRes = engine.HideResult{Format: "image", PayloadBytes: 7, EnvelopeBytes: 21} + if got := m.View(); got == "" { + t.Fatalf("empty view at op=%d stage=%d", op, s) + } + } + } + } +} + +func TestQuitFromResult(t *testing.T) { + m := ready() + m.stage = stageResult + _, cmd := step(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + if cmd == nil { + t.Fatalf("q on result should return a quit command") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go new file mode 100644 index 00000000..d05d2ece --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go @@ -0,0 +1,399 @@ +/* +©AngelaMos | 2026 +update.go + +Message dispatch, per-stage key handling, and async engine result wiring +*/ + +package tui + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/CarterPerez-dev/crypha/internal/payload" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.onResize(msg) + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + return m.onKey(msg) + case fileLoadedMsg: + return m.onFileLoaded(msg) + case hideDoneMsg: + return m.onHideDone(msg) + case revealDoneMsg: + return m.onRevealDone(msg) + case savedMsg: + return m.onSaved(msg) + case tickMsg: + return m.onTick() + default: + return m.forward(msg) + } +} + +func (m Model) onResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + m.ready = true + var cmd tea.Cmd + m.files, cmd = m.files.Update(msg) + return m, cmd +} + +func (m Model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.err = nil + switch m.stage { + case stageOperation: + return m.keyOperation(msg) + case stageFormat: + return m.keyFormat(msg) + case stageTechnique: + return m.keyTechnique(msg) + case stageCover: + return m.keyCover(msg) + case stagePayload: + return m.keyPayload(msg) + case stageSecure: + return m.keySecure(msg) + case stageSave: + return m.keySave(msg) + case stageReview: + return m.keyReview(msg) + case stagePassphrase: + return m.keyPassphrase(msg) + case stageResult: + return m.keyResult(msg) + } + return m, nil +} + +func (m Model) keyOperation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + m.opPick.up() + case "down", "j": + m.opPick.down() + case "enter": + m.op = operation(m.opPick.cursor) + return m.advance() + } + return m, nil +} + +func (m Model) keyFormat(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + return m.back() + case "up", "k": + m.fmtPick.up() + case "down", "j": + m.fmtPick.down() + case "enter": + m.format = m.fmtPick.selected().value + m.technique = "" + return m.advance() + } + return m, nil +} + +func (m Model) keyTechnique(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + return m.back() + case "up", "k": + m.techPick.up() + case "down", "j": + m.techPick.down() + case "enter": + m.technique = m.techPick.selected().value + return m.advance() + } + return m, nil +} + +func (m Model) keyCover(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "esc" { + return m.back() + } + var cmd tea.Cmd + m.files, cmd = m.files.Update(msg) + if ok, path := m.files.DidSelectFile(msg); ok { + return m, loadFileCmd(stageCover, path) + } + return m, cmd +} + +func (m Model) keyPayload(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.payloadMode == payloadFile { + switch msg.String() { + case "esc", "tab": + m.payloadMode = payloadMessage + return m, m.message.Focus() + } + var cmd tea.Cmd + m.files, cmd = m.files.Update(msg) + if ok, path := m.files.DidSelectFile(msg); ok { + return m, loadFileCmd(stagePayload, path) + } + return m, cmd + } + switch msg.String() { + case "esc": + return m.back() + case "tab": + m.payloadMode = payloadFile + m.message.Blur() + return m, m.files.Init() + case "enter": + if strings.TrimSpace(m.message.Value()) == "" { + m.err = errNeedPayload + return m, nil + } + m.payloadBytes = []byte(m.message.Value()) + m.payloadLabel = inlineLabel + return m.advance() + } + var cmd tea.Cmd + m.message, cmd = m.message.Update(msg) + return m, cmd +} + +func (m Model) keySecure(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m.back() + case "enter": + if m.secure.encrypt && len(m.secure.passphrase()) == 0 { + m.err = errNeedPassphrase + return m, nil + } + m.pass = m.secure.passphrase() + return m.advance() + } + var cmd tea.Cmd + m.secure, cmd = m.secure.update(msg) + return m, cmd +} + +func (m Model) keySave(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m.back() + case "enter": + if strings.TrimSpace(m.outPath.Value()) == "" { + m.err = errNeedOutput + return m, nil + } + return m.advance() + } + var cmd tea.Cmd + m.outPath, cmd = m.outPath.Update(msg) + return m, cmd +} + +func (m Model) keyReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + return m.back() + case "n": + return m.reset() + } + if m.op == opCapacity { + return m, nil + } + if msg.String() == "enter" { + if !m.hideFits() { + m.err = errPayloadTooBig + return m, nil + } + return m.advance() + } + return m, nil +} + +func (m Model) keyPassphrase(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.revPass.Blur() + cmd := m.enter(stageCover) + return m, cmd + case "enter": + return m, m.enter(stageRunning) + } + var cmd tea.Cmd + m.revPass, cmd = m.revPass.Update(msg) + return m, cmd +} + +func (m Model) keyResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.saving { + return m.keyResultSaving(msg) + } + switch msg.String() { + case "q", "esc", "enter": + m.quitting = true + return m, tea.Quit + case "n": + return m.reset() + case "s": + if m.canSaveReveal() { + m.saving = true + m.outPath.SetValue(m.suggestReveal()) + return m, m.outPath.Focus() + } + } + return m, nil +} + +func (m Model) keyResultSaving(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.saving = false + m.outPath.Blur() + return m, nil + case "enter": + if strings.TrimSpace(m.outPath.Value()) == "" { + m.err = errNeedOutput + return m, nil + } + return m, saveCmd(strings.TrimSpace(m.outPath.Value()), m.revealRes.Data) + } + var cmd tea.Cmd + m.outPath, cmd = m.outPath.Update(msg) + return m, cmd +} + +func (m Model) onFileLoaded(msg fileLoadedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + return m, nil + } + switch msg.origin { + case stageCover: + m.coverPath = msg.path + m.coverBytes = msg.data + m.passPrompts = 0 + return m.advance() + case stagePayload: + if len(msg.data) == 0 { + m.err = errEmptyFile + return m, nil + } + m.payloadBytes = msg.data + m.payloadLabel = filepath.Base(msg.path) + m.payloadMode = payloadMessage + return m.advance() + } + return m, nil +} + +func (m Model) onHideDone(msg hideDoneMsg) (tea.Model, tea.Cmd) { + m.engineDone = true + if msg.err != nil { + m.engineErr = msg.err + } else { + m.hideRes = msg.res + m.stego = msg.data + m.outputAt = m.outPath.Value() + } + return m.maybeFinish() +} + +func (m Model) onRevealDone(msg revealDoneMsg) (tea.Model, tea.Cmd) { + m.engineDone = true + m.revealRes = msg.res + m.engineErr = msg.err + return m.maybeFinish() +} + +func (m Model) onSaved(msg savedMsg) (tea.Model, tea.Cmd) { + m.saving = false + m.outPath.Blur() + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.savedAt = msg.path + return m, nil +} + +func (m Model) onTick() (tea.Model, tea.Cmd) { + if m.stage != stageRunning { + return m, nil + } + m.animFrac += animStep + if m.animFrac >= 1 { + m.animFrac = 1 + return m.maybeFinish() + } + return m, tick() +} + +func (m Model) maybeFinish() (tea.Model, tea.Cmd) { + if m.stage != stageRunning || !m.engineDone || m.animFrac < 1 { + return m, nil + } + if m.op == opReveal && needsPassphrase(m.engineErr) && m.passPrompts < maxRevealPrompts { + m.passPrompts++ + m.revPass.Reset() + m.stage = stagePassphrase + return m, m.revPass.Focus() + } + m.stage = stageResult + return m, nil +} + +func (m Model) forward(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch m.stage { + case stageCover: + m.files, cmd = m.files.Update(msg) + case stagePayload: + if m.payloadMode == payloadFile { + m.files, cmd = m.files.Update(msg) + } else { + m.message, cmd = m.message.Update(msg) + } + case stageSecure: + m.secure, cmd = m.secure.update(msg) + case stageSave: + m.outPath, cmd = m.outPath.Update(msg) + case stagePassphrase: + m.revPass, cmd = m.revPass.Update(msg) + case stageResult: + if m.saving { + m.outPath, cmd = m.outPath.Update(msg) + } + } + return m, cmd +} + +func (m Model) canSaveReveal() bool { + return m.op == opReveal && m.engineErr == nil && len(m.revealRes.Data) > 0 +} + +func needsPassphrase(err error) bool { + return errors.Is(err, payload.ErrPassphraseRequired) || errors.Is(err, payload.ErrDecrypt) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go new file mode 100644 index 00000000..dea789be --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go @@ -0,0 +1,329 @@ +/* +©AngelaMos | 2026 +view.go + +The render layer: layout composition and one render per wizard stage +*/ + +package tui + +import ( + "fmt" + "math" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" +) + +const ( + previewMaxLines = 6 + previewHexBytes = 32 + startingMessage = "\n starting crypha ...\n" + farewell = "crypha out\n" +) + +func (m Model) View() string { + if m.quitting { + return styleHint.Render(farewell) + } + if !m.ready { + return startingMessage + } + inner := m.contentWidth() + parts := []string{ + renderHeader(inner), + "", + renderStepper(m.stepLabels(), m.stepIndex()), + "", + m.stageView(inner), + } + if m.err != nil { + parts = append(parts, "", styleError.Width(inner).Render(m.err.Error())) + } + parts = append(parts, "", renderFooter(m.footerHints())) + framed := frameStyle(inner).Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) + return lipgloss.PlaceHorizontal(m.width, lipgloss.Center, framed) +} + +func (m Model) boxWidth() int { + w := m.width - horizontalGutter + if w > appMaxWidth { + w = appMaxWidth + } + if w < appMinWidth { + w = appMinWidth + } + return w +} + +func (m Model) contentWidth() int { + return m.boxWidth() - framePadX*2 - frameBorder*2 +} + +func (m Model) stageView(w int) string { + switch m.stage { + case stageOperation: + return section("choose an operation", m.opPick.view(w)) + case stageFormat: + return section(m.formatPrompt(), m.fmtPick.view(w)) + case stageTechnique: + return section("choose a pdf technique", m.techPick.view(w)) + case stageCover: + return section(m.coverPrompt(), m.files.View()) + case stagePayload: + return m.payloadView() + case stageSecure: + return section("secure the payload", m.secure.view(w)) + case stageSave: + return m.saveView() + case stageReview: + return m.reviewView(w) + case stagePassphrase: + return m.passphraseView() + case stageRunning: + return m.runningView() + case stageResult: + return m.resultView(w) + } + return "" +} + +func (m Model) formatPrompt() string { + if m.op == opReveal { + return "choose a carrier, or auto-detect" + } + return "choose a carrier" +} + +func (m Model) coverPrompt() string { + if m.op == opReveal { + return "select a stego file to inspect" + } + return "select a cover file" +} + +func (m Model) payloadView() string { + if m.payloadMode == payloadFile { + body := lipgloss.JoinVertical(lipgloss.Left, + styleHint.Render("choose a payload file · tab or esc to type a message instead"), + "", + m.files.View()) + return section("payload from a file", body) + } + body := lipgloss.JoinVertical(lipgloss.Left, + m.message.View(), + "", + styleHint.Render("enter continues · tab loads a file instead")) + return section("payload as an inline message", body) +} + +func (m Model) saveView() string { + body := lipgloss.JoinVertical(lipgloss.Left, + m.outPath.View(), + "", + styleHint.Render("where to write the stego file · enter continues")) + return section("save the stego file as", body) +} + +func (m Model) reviewView(w int) string { + if m.op == opCapacity { + title := "capacity for " + filepath.Base(m.coverPath) + return section(title, renderCapacityTable(m.capRows, capBarWidth)) + } + meter := renderHideMeter(len(m.payloadBytes), m.envelopeSize, m.capValue, m.capErr, meterBarWidth) + body := lipgloss.JoinVertical(lipgloss.Left, + m.hideSummary(), + "", + meter, + "", + styleHint.Render("enter to embed · esc to go back")) + return section("review", body) +} + +func (m Model) hideSummary() string { + lines := []string{ + kv("carrier", formatLabel(m.format, m.technique)), + kv("payload", fmt.Sprintf("%s (%d B)", m.payloadLabel, len(m.payloadBytes))), + kv("security", m.securitySummary()), + kv("output", m.outPath.Value()), + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m Model) securitySummary() string { + var parts []string + if m.encrypted() { + parts = append(parts, badge("encrypted", hexViolet), + styleHint.Render(m.secure.cipherValue()+" · "+m.secure.strengthValue())) + } else { + parts = append(parts, styleHint.Render("plaintext")) + } + if m.secure.compress { + parts = append(parts, badge("compressed", hexBlue)) + } + return strings.Join(parts, " ") +} + +func (m Model) runningView() string { + verb := "embedding payload" + if m.op == opReveal { + verb = "revealing payload" + } + bar := spectralBar(embedBarWidth, m.animFrac, brandStops) + pct := int(math.Round(m.animFrac * fullPercent)) + body := lipgloss.JoinVertical(lipgloss.Left, + bar+" "+styleValueBold.Render(fmt.Sprintf("%d%%", pct)), + "", + styleHint.Render(verb+" ...")) + return section(verb, body) +} + +func (m Model) passphraseView() string { + prompt := "this payload is encrypted; enter the passphrase to unlock it" + if m.passPrompts > 1 { + prompt = "that passphrase did not work, try again" + } + body := lipgloss.JoinVertical(lipgloss.Left, + styleWarn.Render(prompt), + "", + m.revPass.View(), + "", + styleHint.Render("enter to unlock · esc to cancel")) + return section("unlock", body) +} + +func (m Model) resultView(w int) string { + if m.engineErr != nil { + return section("failed", styleError.Width(w).Render(reasonText(m.engineErr))) + } + if m.op == opReveal { + return m.revealResultView(w) + } + return m.hideResultView() +} + +func (m Model) hideResultView() string { + r := m.hideRes + lines := []string{ + kvStyled("status", "payload hidden", styleSuccess), + kv("carrier", formatLabel(r.Format, r.Technique)), + kv("payload", fmt.Sprintf("%d B", r.PayloadBytes)), + kv("envelope", fmt.Sprintf("%d B", r.EnvelopeBytes)), + kv("security", boolBadges(r.Encrypted, r.Compressed)), + kv("output", m.outputAt), + } + body := lipgloss.JoinVertical(lipgloss.Left, + lipgloss.JoinVertical(lipgloss.Left, lines...), + "", + styleHint.Render("n new run · q quit")) + return section("done", body) +} + +func (m Model) revealResultView(w int) string { + data := m.revealRes.Data + lines := []string{ + kvStyled("status", "payload revealed", styleSuccess), + kv("carrier", m.revealRes.Format), + kv("encrypted", boolText(m.revealRes.Encrypted)), + kv("size", fmt.Sprintf("%d B", len(data))), + } + if m.savedAt != "" { + lines = append(lines, kvStyled("saved", m.savedAt, styleSuccess)) + } + segments := []string{ + lipgloss.JoinVertical(lipgloss.Left, lines...), + "", + revealPreview(data, w), + } + if m.saving { + segments = append(segments, "", + styleLabel.Render("save as"), + m.outPath.View(), + styleHint.Render("enter to write · esc to cancel")) + } else { + segments = append(segments, "", styleHint.Render(m.revealHints())) + } + return section("revealed", lipgloss.JoinVertical(lipgloss.Left, segments...)) +} + +func (m Model) revealHints() string { + if m.canSaveReveal() { + return "s save to file · n new run · q quit" + } + return "n new run · q quit" +} + +func section(title, body string) string { + return lipgloss.JoinVertical(lipgloss.Left, sectionTitle(title), "", body) +} + +func revealPreview(data []byte, w int) string { + if len(data) == 0 { + return styleHint.Render("(empty payload)") + } + if isPrintable(data) { + text := lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Width(w).MaxHeight(previewMaxLines).Render(string(data)) + return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("message"), text) + } + return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("hex preview"), styleValue.Width(w).Render(hexPreview(data, previewHexBytes))) +} + +func formatLabel(format, technique string) string { + if format == "" { + return "auto-detect" + } + if technique == "" { + return format + } + return format + " (" + technique + ")" +} + +func boolText(b bool) string { + if b { + return "yes" + } + return "no" +} + +func boolBadges(encrypted, compressed bool) string { + var parts []string + if encrypted { + parts = append(parts, badge("encrypted", hexViolet)) + } else { + parts = append(parts, styleHint.Render("plaintext")) + } + if compressed { + parts = append(parts, badge("compressed", hexBlue)) + } + return strings.Join(parts, " ") +} + +func isPrintable(data []byte) bool { + if !utf8.Valid(data) { + return false + } + for _, r := range string(data) { + if r == '\n' || r == '\t' || r == '\r' { + continue + } + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +func hexPreview(data []byte, n int) string { + truncated := false + if len(data) > n { + data = data[:n] + truncated = true + } + s := fmt.Sprintf("% x", data) + if truncated { + s += " ..." + } + return s +} From 3f92f93e7701ff1fa6d761741b891a739884ec21 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 19 Jul 2026 02:39:18 -0400 Subject: [PATCH 07/13] build(crypha): add one-shot installer, goreleaser, CI, and AGPL license (M9) --- .../.github/workflows/ci.yml | 35 + .../.github/workflows/release.yml | 32 + .../steganography-multi-tool/.goreleaser.yaml | 45 ++ .../beginner/steganography-multi-tool/LICENSE | 661 ++++++++++++++++++ .../steganography-multi-tool/install.sh | 260 +++++++ 5 files changed, 1033 insertions(+) create mode 100644 PROJECTS/beginner/steganography-multi-tool/.github/workflows/ci.yml create mode 100644 PROJECTS/beginner/steganography-multi-tool/.github/workflows/release.yml create mode 100644 PROJECTS/beginner/steganography-multi-tool/.goreleaser.yaml create mode 100644 PROJECTS/beginner/steganography-multi-tool/LICENSE create mode 100755 PROJECTS/beginner/steganography-multi-tool/install.sh diff --git a/PROJECTS/beginner/steganography-multi-tool/.github/workflows/ci.yml b/PROJECTS/beginner/steganography-multi-tool/.github/workflows/ci.yml new file mode 100644 index 00000000..94e2ddda --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +# ©AngelaMos | 2026 +# ci.yml + +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build + run: go build ./... + - name: Vet + run: go vet ./... + - name: Test + run: go test -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v8 + with: + version: v2.10.1 diff --git a/PROJECTS/beginner/steganography-multi-tool/.github/workflows/release.yml b/PROJECTS/beginner/steganography-multi-tool/.github/workflows/release.yml new file mode 100644 index 00000000..f77de352 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/.github/workflows/release.yml @@ -0,0 +1,32 @@ +# ©AngelaMos | 2026 +# release.yml + +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/PROJECTS/beginner/steganography-multi-tool/.goreleaser.yaml b/PROJECTS/beginner/steganography-multi-tool/.goreleaser.yaml new file mode 100644 index 00000000..d7f22579 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/.goreleaser.yaml @@ -0,0 +1,45 @@ +# ©AngelaMos | 2026 +# .goreleaser.yaml + +version: 2 + +project_name: crypha + +before: + hooks: + - go mod tidy + +builds: + - id: crypha + main: ./cmd/crypha + binary: crypha + env: + - CGO_ENABLED=0 + goos: [linux, darwin] + goarch: [amd64, arm64] + ldflags: + - -s -w + +archives: + - id: crypha + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + +release: + github: + owner: CarterPerez-dev + name: crypha diff --git a/PROJECTS/beginner/steganography-multi-tool/LICENSE b/PROJECTS/beginner/steganography-multi-tool/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/beginner/steganography-multi-tool/install.sh b/PROJECTS/beginner/steganography-multi-tool/install.sh new file mode 100755 index 00000000..816d8dc2 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/install.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh +# +# One-shot installer for crypha. Takes a fresh machine to `crypha` runnable by +# its bare name, with zero further steps, whether run from a clone or piped from +# a domain via curl. Prefers a prebuilt release binary (no Go needed); falls +# back to building from source (auto-installs the Go toolchain if absent). + +set -euo pipefail + +# ============================================================================ +# CONFIG +# ============================================================================ +REPO_OWNER="CarterPerez-dev" +REPO_NAME="crypha" +BINARY="crypha" +TAGLINE="Hide an encrypted payload in an image, audio, QR, text, or PDF." +REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git" +INSTALL_DIR="${CRYPHA_INSTALL_DIR:-$HOME/.local/bin}" +DEFAULT_BRANCH="main" +GO_MIN="1.21" +PREBUILT=1 + +# ============================================================================ +# Colors +# ============================================================================ +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m' + YELLOW=$'\033[33m'; CYAN=$'\033[36m'; RESET=$'\033[0m' +else + BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET="" +fi + +info() { printf '%s\n' " ${CYAN}+${RESET} $*" >&2; } +ok() { printf '%s\n' " ${GREEN}+${RESET} $*" >&2; } +warn() { printf '%s\n' " ${YELLOW}!${RESET} $*" >&2; } +die() { printf '%s\n' " ${RED}x $*${RESET}" >&2; exit 1; } +header(){ printf '\n%s\n\n' "${BOLD}${CYAN}--- $* ---${RESET}" >&2; } +have() { command -v "$1" >/dev/null 2>&1; } + +trap 'printf "%s\n" "${RED}x install failed${RESET}" >&2' ERR +TMP_DIR="" +cleanup() { [ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR"; return 0; } +trap cleanup EXIT + +banner() { + printf '%s' "${CYAN}${BOLD}" >&2 + cat >&2 <<'ART' + ╭───────────────────────╮ + │ c r y p h a │ + ╰───────────────────────╯ +ART + printf '%s\n' "${RESET}" >&2 + printf '%s\n' " ${DIM}${TAGLINE}${RESET}" >&2 +} + +# ============================================================================ +# Privilege + package-manager fan +# ============================================================================ +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + if have sudo; then SUDO="sudo"; fi +fi + +pkg_install() { + if have apt-get; then $SUDO apt-get update -y || warn "apt update had errors; continuing" + $SUDO apt-get install -y --no-install-recommends "$@" + elif have dnf; then $SUDO dnf install -y "$@" + elif have pacman; then $SUDO pacman -S --needed --noconfirm "$@" + elif have zypper; then $SUDO zypper install -y "$@" + elif have apk; then $SUDO apk add "$@" + elif have brew; then brew install "$@" + else die "no known package manager. Install manually: $*"; fi +} + +download() { + if have curl; then curl -fsSL "$1" -o "$2" || return 1 + elif have wget; then wget -qO "$2" "$1" || return 1 + else die "need curl or wget"; fi +} + +# ============================================================================ +# Args +# ============================================================================ +usage() { + cat >&2 </dev/null || warn "pull failed; using existing clone" + else + info "cloning ${REPO_URL}" + git clone --depth 1 --branch "$DEFAULT_BRANCH" --quiet "$REPO_URL" "$cache" \ + || die "clone failed from ${REPO_URL}" + fi + printf '%s\n' "$cache" +} + +# ============================================================================ +# Toolchain (Go) + build from source +# ============================================================================ +install_go() { + info "installing a current Go toolchain" + local latest tgz + latest="$(download "https://go.dev/VERSION?m=text" /dev/stdout 2>/dev/null | head -n1)" || latest="" + case "$latest" in go*) ;; *) latest="go1.25.5" ;; esac + tgz="${latest}.${OS}-${ARCH}.tar.gz" + TMP_DIR="${TMP_DIR:-$(mktemp -d)}" + download "https://go.dev/dl/${tgz}" "$TMP_DIR/go.tgz" || die "failed to download ${tgz} from go.dev/dl" + rm -rf "$HOME/.local/go" + mkdir -p "$HOME/.local" + tar -C "$HOME/.local" -xzf "$TMP_DIR/go.tgz" || die "failed to extract Go" + export PATH="$HOME/.local/go/bin:$PATH" + export GOTOOLCHAIN=auto + have go || die "Go toolchain install failed" + ok "go $(go env GOVERSION 2>/dev/null | sed 's/^go//') at ~/.local/go" +} + +need_toolchain() { + local cur + if have go; then + cur="$(go env GOVERSION 2>/dev/null | sed 's/^go//')" + if [ -n "$cur" ] && [ "$(printf '%s\n%s\n' "$GO_MIN" "$cur" | sort -V | head -n1)" = "$GO_MIN" ]; then + export GOTOOLCHAIN=auto + ok "go $cur (auto-toolchain fetches the go.mod-pinned version if newer)" + return + fi + warn "go ${cur:-unknown} predates toolchain auto-download; installing a current Go" + fi + install_go +} + +build_from_source() { + info "building ${BINARY} (static, CGO-free binary; give it a minute)" + mkdir -p "$INSTALL_DIR" + GOBIN="$INSTALL_DIR" go install ./cmd/crypha || die "go install failed" + ok "installed ${BINARY} -> ${INSTALL_DIR}/${BINARY}" +} + +try_prebuilt() { + [ "$PREBUILT" = "1" ] || return 1 + local ver archive url + ver="$(download "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" /dev/stdout 2>/dev/null \ + | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')" || true + [ -n "$ver" ] || return 1 + archive="${BINARY}_${ver#v}_${OS}_${ARCH}.tar.gz" + url="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${ver}/${archive}" + TMP_DIR="$(mktemp -d)" + download "$url" "$TMP_DIR/a.tgz" || { warn "no prebuilt for ${OS}/${ARCH}; will build from source"; return 1; } + tar -xzf "$TMP_DIR/a.tgz" -C "$TMP_DIR" || return 1 + mkdir -p "$INSTALL_DIR"; install -m 0755 "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY" + ok "installed prebuilt ${ver} -> ${INSTALL_DIR}/${BINARY}" +} + +# ============================================================================ +# PATH wiring +# ============================================================================ +wire_path() { + case ":$PATH:" in *":$INSTALL_DIR:"*) ok "$INSTALL_DIR already on PATH"; return ;; esac + local shell rc="" + shell="$(basename "${SHELL:-bash}")" + case "$shell" in + zsh) rc="$HOME/.zshrc" ;; + fish) mkdir -p "$HOME/.config/fish/conf.d" + echo "fish_add_path $INSTALL_DIR" > "$HOME/.config/fish/conf.d/${BINARY}.fish" + ok "added to fish conf.d" ;; + bash) rc="$HOME/.bashrc"; [ -f "$rc" ] || rc="$HOME/.bash_profile" ;; + *) rc="$HOME/.profile" ;; + esac + if [ -n "$rc" ] && ! grep -q "$INSTALL_DIR" "$rc" 2>/dev/null; then + printf '\nexport PATH="%s:$PATH"\n' "$INSTALL_DIR" >> "$rc" + ok "added $INSTALL_DIR to PATH in $rc" + fi + export PATH="$INSTALL_DIR:$PATH" +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + banner + have "$BINARY" && info "existing install at $(command -v "$BINARY"), updating" + + REPO="" + if ! try_prebuilt; then + header "Building from source" + REPO="$(resolve_repo)"; cd "$REPO" + need_toolchain + build_from_source + fi + + wire_path + + header "Verify" + if have "$BINARY"; then + ok "$BINARY -> $(command -v "$BINARY")" + "$BINARY" version 2>/dev/null || true + else + warn "installed to $INSTALL_DIR but not yet on PATH; open a new shell" + fi + + printf '\n%s\n\n' " ${GREEN}${BOLD}${BINARY} is ready.${RESET}" >&2 + if have just && [ -n "$REPO" ] && [ -f "${REPO}/justfile" ]; then + printf '%s\n' " ${DIM}dev commands:${RESET} just" >&2 + fi + cat >&2 <