From b89b57347dba52b02ad3ab0caebeecfcfd76c47d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 14 Jul 2026 17:03:34 -0400 Subject: [PATCH] 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"