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