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%.
This commit is contained in:
parent
816dd4506c
commit
b89b57347d
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue