feat(crypha): add cobra cli, shared engine, and report layer (M7)

This commit is contained in:
CarterPerez-dev 2026-07-15 21:39:26 -04:00
parent 0ae323eb5d
commit 3b790e5956
21 changed files with 2168 additions and 17 deletions

View File

@ -20,7 +20,9 @@ linters:
settings:
gosec:
excludes:
- G101
- G115
- G304
formatters:
enable:

View File

@ -12,6 +12,7 @@ require (
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.52.0
golang.org/x/image v0.44.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
)

View File

@ -48,6 +48,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=

View File

@ -0,0 +1,63 @@
/*
©AngelaMos | 2026
capacity.go
The capacity command: report how much a cover can hold, per carrier
*/
package cli
import (
"bytes"
"os"
"github.com/CarterPerez-dev/crypha/internal/engine"
"github.com/CarterPerez-dev/crypha/internal/report"
"github.com/spf13/cobra"
)
func newCapacityCmd() *cobra.Command {
var format, in string
cmd := &cobra.Command{
Use: cmdCapacity + " -i COVER [--format F]",
Short: "Report how many bytes a cover can hide",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path, err := pathFromFlagOrArg(in, args)
if err != nil {
return err
}
if path == "" {
return errNoCover
}
cover, err := os.ReadFile(path)
if err != nil {
return err
}
rows, err := capacityRows(format, cover)
if err != nil {
return err
}
return report.Capacity(cmd.OutOrStdout(), rows, jsonEnabled(cmd))
},
}
f := cmd.Flags()
f.StringVar(&format, flagFormat, "", "carrier format (all applicable formats if omitted)")
f.StringVarP(&in, flagIn, shortIn, "", "cover input path (or pass as an argument)")
return cmd
}
func capacityRows(format string, cover []byte) ([]engine.CapacityRow, error) {
if format == "" {
return engine.CapacityAll(cover), nil
}
if _, err := engine.ResolveCarrier(format, ""); err != nil {
return nil, err
}
n, cerr := engine.Capacity(format, bytes.NewReader(cover))
return []engine.CapacityRow{{Format: format, Capacity: n, Err: cerr}}, nil
}

View File

@ -0,0 +1,409 @@
/*
©AngelaMos | 2026
cli_test.go
End-to-end tests that drive the crypha commands through the cobra tree
*/
package cli
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"github.com/CarterPerez-dev/crypha/internal/payload"
goaudio "github.com/go-audio/audio"
"github.com/go-audio/wav"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
)
const secret = "meet at dawn"
func run(t *testing.T, stdin string, args ...string) (string, string, error) {
t.Helper()
root := newRootCmd()
var out, errb bytes.Buffer
root.SetOut(&out)
root.SetErr(&errb)
root.SetIn(strings.NewReader(stdin))
root.SetArgs(args)
err := root.Execute()
return out.String(), errb.String(), err
}
func makeTextCover(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "cover.txt")
if err := os.WriteFile(path, []byte("the quick brown fox jumps over the lazy dog"), 0o600); err != nil {
t.Fatalf("write text cover: %v", err)
}
return path
}
func makeQRCover(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "cover.qrtext")
if err := os.WriteFile(path, []byte("crypha qr cover"), 0o600); err != nil {
t.Fatalf("write qr cover: %v", err)
}
return path
}
func makePNGCover(t *testing.T, dir string) string {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, 96, 96))
for y := 0; y < 96; y++ {
for x := 0; x < 96; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x ^ y), A: 255})
}
}
path := filepath.Join(dir, "cover.png")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create png: %v", err)
}
defer func() { _ = f.Close() }()
if err := png.Encode(f, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return path
}
func makeWAVCover(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "cover.wav")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create wav: %v", err)
}
defer func() { _ = f.Close() }()
enc := wav.NewEncoder(f, 44100, 16, 1, 1)
data := make([]int, 16000)
x := uint32(1)
for i := range data {
x = x*1664525 + 1013904223
data[i] = int(int16(x >> 16))
}
buf := &goaudio.IntBuffer{
Format: &goaudio.Format{NumChannels: 1, SampleRate: 44100},
Data: data,
SourceBitDepth: 16,
}
if err := enc.Write(buf); err != nil {
t.Fatalf("write wav: %v", err)
}
if err := enc.Close(); err != nil {
t.Fatalf("close wav: %v", err)
}
return path
}
func makePDFCover(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "cover.pdf")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create pdf: %v", err)
}
defer func() { _ = f.Close() }()
conf := model.NewDefaultConfiguration()
conf.ValidationMode = model.ValidationRelaxed
if err := api.Create(nil, strings.NewReader(`{"pages":{"1":{"content":{}}}}`), f, conf); err != nil {
t.Fatalf("create pdf content: %v", err)
}
return path
}
func TestHideRevealPerFormat(t *testing.T) {
dir := t.TempDir()
covers := map[string]string{
"text": makeTextCover(t, dir),
"image": makePNGCover(t, dir),
"audio": makeWAVCover(t, dir),
"pdf": makePDFCover(t, dir),
"qr": makeQRCover(t, dir),
}
for format, cover := range covers {
t.Run(format, func(t *testing.T) {
stego := filepath.Join(dir, format+".stego")
if _, _, err := run(t, "", "hide", "--format", format, "-i", cover, "-o", stego, "-m", secret); err != nil {
t.Fatalf("hide: %v", err)
}
forced, _, err := run(t, "", "reveal", "--format", format, "-i", stego)
if err != nil {
t.Fatalf("reveal --format: %v", err)
}
if forced != secret {
t.Fatalf("reveal --format = %q, want %q", forced, secret)
}
auto, _, err := run(t, "", "reveal", "-i", stego)
if err != nil {
t.Fatalf("reveal auto-detect: %v", err)
}
if auto != secret {
t.Fatalf("reveal auto-detect = %q, want %q", auto, secret)
}
})
}
}
func TestPDFTechniques(t *testing.T) {
dir := t.TempDir()
cover := makePDFCover(t, dir)
for _, tech := range []string{"attachment", "metadata", "append"} {
t.Run(tech, func(t *testing.T) {
stego := filepath.Join(dir, tech+".pdf")
if _, _, err := run(t, "", "hide", "--format", "pdf", "--technique", tech, "-i", cover, "-o", stego, "-m", secret); err != nil {
t.Fatalf("hide %s: %v", tech, err)
}
out, _, err := run(t, "", "reveal", "--format", "pdf", "-i", stego)
if err != nil {
t.Fatalf("reveal %s: %v", tech, err)
}
if out != secret {
t.Fatalf("technique %s = %q, want %q", tech, out, secret)
}
})
}
}
func TestRevealPositionalArg(t *testing.T) {
dir := t.TempDir()
cover := makeTextCover(t, dir)
stego := filepath.Join(dir, "positional.txt")
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
t.Fatalf("hide: %v", err)
}
out, _, err := run(t, "", "reveal", stego)
if err != nil {
t.Fatalf("reveal positional: %v", err)
}
if out != secret {
t.Fatalf("reveal positional = %q, want %q", out, secret)
}
}
func TestPayloadFromFileAndStdin(t *testing.T) {
dir := t.TempDir()
cover := makePNGCover(t, dir)
payloadFile := filepath.Join(dir, "payload.bin")
if err := os.WriteFile(payloadFile, []byte(secret), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
fileStego := filepath.Join(dir, "fromfile.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", fileStego, "-f", payloadFile); err != nil {
t.Fatalf("hide -f: %v", err)
}
out, _, err := run(t, "", "reveal", "-i", fileStego)
if err != nil || out != secret {
t.Fatalf("reveal from -f = %q err %v", out, err)
}
stdinStego := filepath.Join(dir, "fromstdin.png")
if _, _, err := run(t, secret, "hide", "--format", "image", "-i", cover, "-o", stdinStego, "-f", stdioPath); err != nil {
t.Fatalf("hide -f -: %v", err)
}
out, _, err = run(t, "", "reveal", "-i", stdinStego)
if err != nil || out != secret {
t.Fatalf("reveal from stdin = %q err %v", out, err)
}
}
func TestEncryptedRoundTripCLI(t *testing.T) {
t.Setenv(envPassphrase, "")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "enc.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt", "-k", "hunter2"); err != nil {
t.Fatalf("hide encrypted: %v", err)
}
out, _, err := run(t, "", "reveal", "-i", stego, "-k", "hunter2")
if err != nil || out != secret {
t.Fatalf("reveal with key = %q err %v", out, err)
}
if _, _, err := run(t, "", "reveal", "-i", stego); !errors.Is(err, payload.ErrPassphraseRequired) {
t.Fatalf("no-key reveal err = %v, want ErrPassphraseRequired", err)
}
if _, _, err := run(t, "", "reveal", "-i", stego, "-k", "wrong"); !errors.Is(err, payload.ErrDecrypt) {
t.Fatalf("wrong-key reveal err = %v, want ErrDecrypt", err)
}
}
func TestEnvPassphrase(t *testing.T) {
t.Setenv(envPassphrase, "from-the-env")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "env.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt"); err != nil {
t.Fatalf("hide with env: %v", err)
}
out, _, err := run(t, "", "reveal", "-i", stego)
if err != nil || out != secret {
t.Fatalf("reveal with env = %q err %v", out, err)
}
}
func TestAES256GCMAndCompress(t *testing.T) {
t.Setenv(envPassphrase, "")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "aes.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret,
"-k", "hunter2", "--cipher", "aes256gcm", "--compress", "--strength", "high"); err != nil {
t.Fatalf("hide aes: %v", err)
}
out, _, err := run(t, "", "reveal", "-i", stego, "-k", "hunter2")
if err != nil || out != secret {
t.Fatalf("reveal aes = %q err %v", out, err)
}
}
func TestFormatsCommand(t *testing.T) {
out, _, err := run(t, "", "formats")
if err != nil {
t.Fatalf("formats: %v", err)
}
for _, f := range []string{"image", "audio", "qr", "text", "pdf"} {
if !strings.Contains(out, f) {
t.Errorf("formats missing %q", f)
}
}
jsonOut, _, err := run(t, "", "formats", "--json")
if err != nil {
t.Fatalf("formats --json: %v", err)
}
if !json.Valid([]byte(jsonOut)) {
t.Errorf("formats --json is not valid json: %s", jsonOut)
}
}
func TestVersionCommand(t *testing.T) {
out, _, err := run(t, "", "version")
if err != nil {
t.Fatalf("version: %v", err)
}
if !strings.Contains(out, "crypha") || !strings.Contains(out, "0.1.0") {
t.Errorf("version = %q", out)
}
}
func TestCapacityCommand(t *testing.T) {
dir := t.TempDir()
cover := makePNGCover(t, dir)
all, _, err := run(t, "", "capacity", "-i", cover)
if err != nil {
t.Fatalf("capacity: %v", err)
}
if !strings.Contains(all, "image") {
t.Errorf("capacity table missing image row:\n%s", all)
}
one, _, err := run(t, "", "capacity", "-i", cover, "--format", "image")
if err != nil {
t.Fatalf("capacity --format: %v", err)
}
if !strings.Contains(one, "image") {
t.Errorf("single capacity missing image:\n%s", one)
}
jsonOut, _, err := run(t, "", "capacity", "-i", cover, "--json")
if err != nil {
t.Fatalf("capacity --json: %v", err)
}
if !json.Valid([]byte(jsonOut)) {
t.Errorf("capacity --json invalid: %s", jsonOut)
}
}
func TestErrorPaths(t *testing.T) {
dir := t.TempDir()
cover := makePNGCover(t, dir)
out := filepath.Join(dir, "out.png")
cases := []struct {
name string
args []string
want string
}{
{"unknown format", []string{"hide", "--format", "bogus", "-i", cover, "-o", out, "-m", "x"}, "unknown carrier format"},
{"unknown cipher", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-k", "p", "--cipher", "des"}, "unknown cipher"},
{"both sources", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-f", cover}, "only one of"},
{"technique on non-pdf", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "--technique", "append"}, "technique only applies"},
{"no payload source", []string{"hide", "--format", "image", "-i", cover, "-o", out}, "provide a payload"},
{"reveal no stego", []string{"reveal"}, "provide a stego file"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := run(t, "", tc.args...)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %q, want it to contain %q", err.Error(), tc.want)
}
})
}
}
func TestHideMissingRequiredFlags(t *testing.T) {
if _, _, err := run(t, "", "hide", "--format", "image", "-m", "x"); err == nil {
t.Fatal("expected required-flag error for missing -i/-o")
}
}
func TestRevealJSONToStdout(t *testing.T) {
dir := t.TempDir()
cover := makeTextCover(t, dir)
stego := filepath.Join(dir, "json.txt")
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
t.Fatalf("hide: %v", err)
}
stdout, _, err := run(t, "", "reveal", "-i", stego, "--json")
if err != nil {
t.Fatalf("reveal --json: %v", err)
}
if !json.Valid([]byte(stdout)) {
t.Fatalf("reveal --json stdout is not valid json: %s", stdout)
}
var obj struct {
Format string `json:"format"`
Data string `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &obj); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if obj.Format != "text" {
t.Errorf("format = %q, want text", obj.Format)
}
decoded, err := base64.StdEncoding.DecodeString(obj.Data)
if err != nil || string(decoded) != secret {
t.Fatalf("decoded data = %q err %v, want %q", decoded, err, secret)
}
}
func TestRevealAmbiguousPath(t *testing.T) {
dir := t.TempDir()
cover := makeTextCover(t, dir)
stego := filepath.Join(dir, "amb.txt")
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
t.Fatalf("hide: %v", err)
}
if _, _, err := run(t, "", "reveal", "-i", stego, stego); !errors.Is(err, errAmbiguousPath) {
t.Fatalf("err = %v, want errAmbiguousPath", err)
}
}

View File

@ -0,0 +1,67 @@
/*
©AngelaMos | 2026
flags.go
Shared command and flag identifiers plus small helpers for the crypha cobra tree
*/
package cli
import "github.com/spf13/cobra"
const (
cmdHide = "hide"
cmdReveal = "reveal"
cmdCapacity = "capacity"
cmdFormats = "formats"
cmdVersion = "version"
flagJSON = "json"
flagFormat = "format"
flagIn = "in"
flagOut = "out"
flagMessage = "message"
flagFile = "file"
flagEncrypt = "encrypt"
flagPassphrase = "passphrase"
flagCompress = "compress"
flagCipher = "cipher"
flagStrength = "strength"
flagTechnique = "technique"
shortIn = "i"
shortOut = "o"
shortMessage = "m"
shortFile = "f"
shortPassphrase = "k"
envPassphrase = "CRYPHA_PASSPHRASE"
stdioPath = "-"
stdoutName = "(stdout)"
outFilePerm = 0o600
)
func markRequired(cmd *cobra.Command, names ...string) {
for _, name := range names {
_ = cmd.MarkFlagRequired(name)
}
}
func jsonEnabled(cmd *cobra.Command) bool {
on, _ := cmd.Flags().GetBool(flagJSON)
return on
}
func pathFromFlagOrArg(flagValue string, args []string) (string, error) {
if flagValue != "" && len(args) == 1 {
return "", errAmbiguousPath
}
if flagValue != "" {
return flagValue, nil
}
if len(args) == 1 {
return args[0], nil
}
return "", nil
}

View File

@ -0,0 +1,24 @@
/*
©AngelaMos | 2026
formats.go
The formats command: list every registered carrier and its capabilities
*/
package cli
import (
"github.com/CarterPerez-dev/crypha/internal/report"
"github.com/spf13/cobra"
)
func newFormatsCmd() *cobra.Command {
return &cobra.Command{
Use: cmdFormats,
Short: "List the available carrier formats",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return report.Formats(cmd.OutOrStdout(), jsonEnabled(cmd))
},
}
}

View File

@ -0,0 +1,114 @@
/*
©AngelaMos | 2026
hide.go
The hide command: pack a payload into the envelope and embed it in a cover
*/
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"github.com/CarterPerez-dev/crypha/internal/engine"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/CarterPerez-dev/crypha/internal/report"
"github.com/spf13/cobra"
)
var (
errUnknownCipher = errors.New("unknown cipher")
errUnknownStrength = errors.New("unknown key-derivation strength")
)
func newHideCmd() *cobra.Command {
var (
format, technique string
in, out string
message, file string
passphrase string
cipher, strength string
encrypt, compress bool
)
cmd := &cobra.Command{
Use: cmdHide + " --format F -i COVER -o OUT (-m MSG | -f FILE)",
Short: "Hide a payload inside a cover file",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
data, err := readPayloadSource(cmd, message, file)
if err != nil {
return err
}
pass, err := resolveHidePassphrase(passphrase, encrypt)
if err != nil {
return err
}
opts, err := buildOptions(pass, compress, cipher, strength)
if err != nil {
return err
}
cover, err := os.ReadFile(in)
if err != nil {
return err
}
var stego bytes.Buffer
res, err := engine.Hide(engine.HideRequest{
Format: format,
Technique: technique,
Cover: bytes.NewReader(cover),
Payload: data,
Out: &stego,
Options: opts,
})
if err != nil {
return err
}
if err := writeOutputFile(out, stego.Bytes()); err != nil {
return err
}
return report.HideSummary(cmd.OutOrStdout(), res, out, jsonEnabled(cmd))
},
}
f := cmd.Flags()
f.StringVar(&format, flagFormat, "", "carrier format: image, audio, qr, text, pdf")
f.StringVarP(&in, flagIn, shortIn, "", "cover input path")
f.StringVarP(&out, flagOut, shortOut, "", "stego output path")
f.StringVarP(&message, flagMessage, shortMessage, "", "inline message payload")
f.StringVarP(&file, flagFile, shortFile, "", "payload file path (- for stdin)")
f.BoolVar(&encrypt, flagEncrypt, false, "encrypt the payload with a passphrase")
f.StringVarP(&passphrase, flagPassphrase, shortPassphrase, "", "passphrase (prefer the prompt or "+envPassphrase+")")
f.BoolVar(&compress, flagCompress, false, "compress the payload before hiding")
f.StringVar(&cipher, flagCipher, string(payload.CipherChaCha20), "cipher: chacha20 or aes256gcm")
f.StringVar(&strength, flagStrength, string(payload.StrengthDefault), "key-derivation strength: default or high")
f.StringVar(&technique, flagTechnique, "", "pdf technique: attachment, metadata, or append")
markRequired(cmd, flagFormat, flagIn, flagOut)
return cmd
}
func buildOptions(pass []byte, compress bool, cipher, strength string) (payload.Options, error) {
c := payload.Cipher(cipher)
if c != payload.CipherChaCha20 && c != payload.CipherAES256GCM {
return payload.Options{}, fmt.Errorf("%w: %q", errUnknownCipher, cipher)
}
s := payload.Strength(strength)
if s != payload.StrengthDefault && s != payload.StrengthHigh {
return payload.Options{}, fmt.Errorf("%w: %q", errUnknownStrength, strength)
}
return payload.Options{
Passphrase: pass,
Compress: compress,
Cipher: c,
Strength: s,
}, nil
}

View File

@ -0,0 +1,198 @@
/*
©AngelaMos | 2026
interactive_test.go
Tests for passphrase prompting, reprompt loops, and output branches
*/
package cli
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
"github.com/CarterPerez-dev/crypha/internal/payload"
)
func stubTerminal(t *testing.T, inter bool, secrets ...string) {
t.Helper()
origInter, origRead := interactive, readSecret
t.Cleanup(func() { interactive, readSecret = origInter, origRead })
interactive = func() bool { return inter }
idx := 0
readSecret = func() ([]byte, error) {
if idx >= len(secrets) {
return nil, io.EOF
}
s := secrets[idx]
idx++
return []byte(s), nil
}
}
func TestPromptForError(t *testing.T) {
if got := promptForError(payload.ErrPassphraseRequired); got != promptEnterPassphrase {
t.Errorf("passphrase-required prompt = %q", got)
}
if got := promptForError(payload.ErrDecrypt); got != promptRetryPassphrase {
t.Errorf("decrypt prompt = %q", got)
}
}
func TestInteractiveHidePromptRoundTrip(t *testing.T) {
t.Setenv(envPassphrase, "")
stubTerminal(t, true, "prompted-secret", "prompted-secret")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "prompted.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt"); err != nil {
t.Fatalf("hide with prompt: %v", err)
}
out, _, err := run(t, "", "reveal", "-i", stego, "-k", "prompted-secret")
if err != nil || out != secret {
t.Fatalf("reveal = %q err %v", out, err)
}
}
func TestInteractiveHidePromptMismatch(t *testing.T) {
t.Setenv(envPassphrase, "")
stubTerminal(t, true, "one", "two")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "mismatch.png")
_, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt")
if !errors.Is(err, errPassphraseMismatch) {
t.Fatalf("err = %v, want errPassphraseMismatch", err)
}
}
func TestHideEncryptNonInteractiveNoKey(t *testing.T) {
t.Setenv(envPassphrase, "")
stubTerminal(t, false)
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "nokey.png")
_, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt")
if !errors.Is(err, errPassphraseNonInteractive) {
t.Fatalf("err = %v, want errPassphraseNonInteractive", err)
}
}
func TestRevealReprompt(t *testing.T) {
t.Setenv(envPassphrase, "")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "reprompt.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "rightpass"); err != nil {
t.Fatalf("hide: %v", err)
}
stubTerminal(t, true, "wrongpass", "rightpass")
out, _, err := run(t, "", "reveal", "-i", stego)
if err != nil || out != secret {
t.Fatalf("reveal after reprompt = %q err %v", out, err)
}
}
func TestRevealRepromptExhausted(t *testing.T) {
t.Setenv(envPassphrase, "")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "exhausted.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "rightpass"); err != nil {
t.Fatalf("hide: %v", err)
}
stubTerminal(t, true, "no", "no", "no")
_, _, err := run(t, "", "reveal", "-i", stego)
if !errors.Is(err, payload.ErrDecrypt) {
t.Fatalf("err = %v, want ErrDecrypt after exhausting attempts", err)
}
}
func TestRevealToFile(t *testing.T) {
dir := t.TempDir()
cover := makeTextCover(t, dir)
stego := filepath.Join(dir, "tofile.txt")
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
t.Fatalf("hide: %v", err)
}
out := filepath.Join(dir, "revealed.txt")
stdout, _, err := run(t, "", "reveal", "-i", stego, "-o", out)
if err != nil {
t.Fatalf("reveal -o: %v", err)
}
if stdout != "" {
t.Errorf("stdout should be empty when writing to a file, got %q", stdout)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read revealed file: %v", err)
}
if string(got) != secret {
t.Errorf("revealed file = %q, want %q", got, secret)
}
}
func TestBuildOptionsUnknownStrength(t *testing.T) {
dir := t.TempDir()
cover := makePNGCover(t, dir)
out := filepath.Join(dir, "out.png")
_, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-k", "p", "--strength", "ultra")
if err == nil || !errors.Is(err, errUnknownStrength) {
t.Fatalf("err = %v, want errUnknownStrength", err)
}
}
func TestCapacityUnknownFormat(t *testing.T) {
dir := t.TempDir()
cover := makePNGCover(t, dir)
_, _, err := run(t, "", "capacity", "-i", cover, "--format", "bogus")
if err == nil {
t.Fatal("expected error for unknown format")
}
}
func TestMissingFileErrors(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "does-not-exist")
out := filepath.Join(dir, "out.png")
cases := [][]string{
{"hide", "--format", "image", "-i", missing, "-o", out, "-m", "x"},
{"reveal", "-i", missing},
{"capacity", "-i", missing},
}
for _, args := range cases {
if _, _, err := run(t, "", args...); err == nil {
t.Errorf("%v: expected error for missing file", args)
}
}
}
func TestRevealPromptReadError(t *testing.T) {
t.Setenv(envPassphrase, "")
dir := t.TempDir()
cover := makePNGCover(t, dir)
stego := filepath.Join(dir, "readerr.png")
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "pw"); err != nil {
t.Fatalf("hide: %v", err)
}
origInter, origRead := interactive, readSecret
t.Cleanup(func() { interactive, readSecret = origInter, origRead })
interactive = func() bool { return true }
readSecret = func() ([]byte, error) { return nil, io.ErrUnexpectedEOF }
if _, _, err := run(t, "", "reveal", "-i", stego); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("err = %v, want a terminal read error", err)
}
}

View File

@ -0,0 +1,66 @@
/*
©AngelaMos | 2026
io.go
Payload source reading and revealed-output writing for the crypha CLI
*/
package cli
import (
"errors"
"io"
"os"
"unicode/utf8"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
errNoStego = errors.New("provide a stego file with -i or as an argument")
errNoCover = errors.New("provide a cover file with -i or as an argument")
errNoPayloadSource = errors.New("provide a payload with -m or -f")
errBothSources = errors.New("use only one of -m or -f")
errBinaryToTTY = errors.New("payload is binary; write it to a file with -o")
errAmbiguousPath = errors.New("pass the file once, via -i or as an argument, not both")
)
func readPayloadSource(cmd *cobra.Command, message, file string) ([]byte, error) {
switch {
case message != "" && file != "":
return nil, errBothSources
case message != "":
return []byte(message), nil
case file == stdioPath:
return io.ReadAll(cmd.InOrStdin())
case file != "":
return os.ReadFile(file)
default:
return nil, errNoPayloadSource
}
}
func writeOutputFile(outPath string, data []byte) error {
return os.WriteFile(outPath, data, outFilePerm)
}
func writeRevealed(cmd *cobra.Command, data []byte, outPath string) (string, error) {
if outPath != "" {
if err := os.WriteFile(outPath, data, outFilePerm); err != nil {
return "", err
}
return outPath, nil
}
if stdoutIsTerminal() && !utf8.Valid(data) {
return "", errBinaryToTTY
}
if _, err := cmd.OutOrStdout().Write(data); err != nil {
return "", err
}
return stdoutName, nil
}
func stdoutIsTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}

View File

@ -0,0 +1,82 @@
/*
©AngelaMos | 2026
passphrase.go
Passphrase acquisition from flag, environment, or a no-echo terminal prompt
*/
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"golang.org/x/term"
)
const (
promptEnterPassphrase = "Passphrase: "
promptConfirmPassphrase = "Confirm passphrase: "
promptRetryPassphrase = "Wrong passphrase, try again: "
maxPassphraseAttempts = 3
)
var (
errPassphraseNonInteractive = errors.New("encryption requested but no passphrase provided; pass -k or set " + envPassphrase)
errPassphraseMismatch = errors.New("passphrases did not match")
)
var (
interactive = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }
readSecret = func() ([]byte, error) { return term.ReadPassword(int(os.Stdin.Fd())) }
)
func passphraseFromFlagOrEnv(flagValue string) []byte {
if flagValue != "" {
return []byte(flagValue)
}
if env := os.Getenv(envPassphrase); env != "" {
return []byte(env)
}
return nil
}
func resolveHidePassphrase(flagValue string, encryptWanted bool) ([]byte, error) {
if p := passphraseFromFlagOrEnv(flagValue); p != nil {
return p, nil
}
if !encryptWanted {
return nil, nil
}
if !interactive() {
return nil, errPassphraseNonInteractive
}
return promptPassphraseConfirmed()
}
func promptPassphraseConfirmed() ([]byte, error) {
first, err := promptPassphrase(promptEnterPassphrase)
if err != nil {
return nil, err
}
second, err := promptPassphrase(promptConfirmPassphrase)
if err != nil {
return nil, err
}
if !bytes.Equal(first, second) {
return nil, errPassphraseMismatch
}
return first, nil
}
func promptPassphrase(prompt string) ([]byte, error) {
fmt.Fprint(os.Stderr, prompt)
pass, err := readSecret()
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, err
}
return pass, nil
}

View File

@ -0,0 +1,105 @@
/*
©AngelaMos | 2026
reveal.go
The reveal command: extract the envelope from a stego file and unpack the payload
*/
package cli
import (
"errors"
"os"
"github.com/CarterPerez-dev/crypha/internal/engine"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/CarterPerez-dev/crypha/internal/report"
"github.com/spf13/cobra"
)
func newRevealCmd() *cobra.Command {
var format, in, out, passphrase string
cmd := &cobra.Command{
Use: cmdReveal + " [-i] STEGO",
Short: "Reveal a hidden payload from a stego file",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path, err := pathFromFlagOrArg(in, args)
if err != nil {
return err
}
if path == "" {
return errNoStego
}
stego, err := os.ReadFile(path)
if err != nil {
return err
}
res, err := revealWithPassphrase(format, stego, passphraseFromFlagOrEnv(passphrase))
if err != nil {
return err
}
if jsonEnabled(cmd) {
if out != "" {
if err := writeOutputFile(out, res.Data); err != nil {
return err
}
}
return report.RevealJSON(cmd.OutOrStdout(), res, out)
}
outPath, err := writeRevealed(cmd, res.Data, out)
if err != nil {
return err
}
return report.RevealStatus(cmd.ErrOrStderr(), res, outPath)
},
}
f := cmd.Flags()
f.StringVar(&format, flagFormat, "", "force a carrier format (auto-detect if omitted)")
f.StringVarP(&in, flagIn, shortIn, "", "stego input path (or pass as an argument)")
f.StringVarP(&out, flagOut, shortOut, "", "write the revealed payload here (default stdout)")
f.StringVarP(&passphrase, flagPassphrase, shortPassphrase, "", "passphrase for an encrypted payload")
return cmd
}
func revealWithPassphrase(format string, stego, pass []byte) (engine.RevealResult, error) {
for attempt := 0; attempt <= maxPassphraseAttempts; attempt++ {
res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass})
if err == nil {
return res, nil
}
if !shouldReprompt(err, attempt) {
return engine.RevealResult{}, err
}
entered, perr := promptPassphrase(promptForError(err))
if perr != nil {
return engine.RevealResult{}, perr
}
pass = entered
}
return engine.RevealResult{}, payload.ErrDecrypt
}
func shouldReprompt(err error, attempt int) bool {
if !interactive() {
return false
}
if errors.Is(err, payload.ErrPassphraseRequired) {
return true
}
return errors.Is(err, payload.ErrDecrypt) && attempt < maxPassphraseAttempts
}
func promptForError(err error) string {
if errors.Is(err, payload.ErrPassphraseRequired) {
return promptEnterPassphrase
}
return promptRetryPassphrase
}

View File

@ -10,25 +10,40 @@ package cli
import (
"fmt"
"os"
"strings"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: config.BinaryName,
Short: config.ShortDescription,
Long: config.LongDescription,
Version: config.Version,
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: config.BinaryName,
Short: config.ShortDescription,
Long: config.LongDescription,
Version: config.Version,
SilenceUsage: true,
SilenceErrors: true,
}
root.SetVersionTemplate(config.BinaryName + " {{.Version}}\n")
root.PersistentFlags().Bool(flagJSON, false, "emit machine-readable JSON")
root.AddCommand(
newHideCmd(),
newRevealCmd(),
newCapacityCmd(),
newFormatsCmd(),
newVersionCmd(),
)
return root
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
if err := newRootCmd().Execute(); err != nil {
msg := err.Error()
if !strings.HasPrefix(msg, config.BinaryName) {
msg = config.BinaryName + ": " + msg
}
fmt.Fprintln(os.Stderr, msg)
os.Exit(1)
}
}
func init() {
rootCmd.SetVersionTemplate(config.BinaryName + " {{.Version}}\n")
}

View File

@ -0,0 +1,24 @@
/*
©AngelaMos | 2026
version.go
The version command: print the crypha build version
*/
package cli
import (
"github.com/CarterPerez-dev/crypha/internal/report"
"github.com/spf13/cobra"
)
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: cmdVersion,
Short: "Print the crypha version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return report.Version(cmd.OutOrStdout(), jsonEnabled(cmd))
},
}
}

View File

@ -0,0 +1,48 @@
/*
©AngelaMos | 2026
cli.go
Descriptive catalog metadata for each carrier, surfaced by the formats and help output
*/
package config
type FormatDetail struct {
Blurb string
CoverInput string
Output string
Notes string
}
var FormatDetails = map[string]FormatDetail{
"image": {
Blurb: "LSB of RGB pixel data",
CoverInput: "PNG or 24-bit BMP",
Output: "PNG",
Notes: "alpha channel untouched; paletted and 16-bit covers are rejected",
},
"audio": {
Blurb: "LSB of 16-bit PCM samples",
CoverInput: "16-bit PCM WAV or FLAC",
Output: "WAV",
Notes: "FLAC covers are decoded and re-emitted as WAV",
},
"qr": {
Blurb: "Reed-Solomon-correctable error injection",
CoverInput: "UTF-8 text (the QR's visible content)",
Output: "PNG",
Notes: "capacity is tens of bytes, so an encrypted envelope will not fit",
},
"text": {
Blurb: "zero-width U+200B and U+2060 characters",
CoverInput: "any UTF-8 text",
Output: "text",
Notes: "the payload is appended to the cover as invisible characters",
},
"pdf": {
Blurb: "embedded attachment, metadata, or append-after-EOF",
CoverInput: "PDF",
Output: "PDF",
Notes: "default technique is a lossless embedded-file attachment",
},
}

View File

@ -0,0 +1,205 @@
/*
©AngelaMos | 2026
engine.go
The shared steganography engine that both frontends drive, wiring carriers to the payload envelope
*/
package engine
import (
"bytes"
"errors"
"fmt"
"io"
"github.com/CarterPerez-dev/crypha/internal/carrier"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/all"
"github.com/CarterPerez-dev/crypha/internal/carrier/pdf"
"github.com/CarterPerez-dev/crypha/internal/payload"
)
var (
ErrNoFormat = errors.New("crypha: a carrier format is required")
ErrUnknownFormat = errors.New("crypha: unknown carrier format")
ErrUnknownTechnique = errors.New("crypha: unknown pdf technique")
ErrTechniqueOnNonPDF = errors.New("crypha: technique only applies to the pdf format")
ErrUndetected = errors.New("crypha: no crypha payload detected; pass a format to force a carrier")
)
type HideRequest struct {
Format string
Technique string
Cover io.Reader
Payload []byte
Out io.Writer
Options payload.Options
}
type HideResult struct {
Format string
Technique string
PayloadBytes int
EnvelopeBytes int
Encrypted bool
Compressed bool
}
type RevealRequest struct {
Format string
Stego []byte
Passphrase []byte
}
type RevealResult struct {
Format string
Data []byte
Encrypted bool
}
type CapacityRow struct {
Format string
Capacity int
Err error
}
type FormatInfo struct {
Name string
Techniques []string
}
func ResolveCarrier(format, technique string) (carrier.Carrier, error) {
if format == "" {
return nil, ErrNoFormat
}
if format == pdf.Format {
t := pdf.TechniqueAttachment
if technique != "" {
t = pdf.Technique(technique)
}
switch t {
case pdf.TechniqueAttachment, pdf.TechniqueMetadata, pdf.TechniqueAppend:
return pdf.New(t), nil
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownTechnique, technique)
}
}
if technique != "" {
return nil, ErrTechniqueOnNonPDF
}
c, ok := carrier.Get(format)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownFormat, format)
}
return c, nil
}
func Hide(req HideRequest) (HideResult, error) {
c, err := ResolveCarrier(req.Format, req.Technique)
if err != nil {
return HideResult{}, err
}
env, err := payload.Pack(req.Payload, req.Options)
if err != nil {
return HideResult{}, err
}
if err := c.Hide(req.Cover, env, req.Out); err != nil {
return HideResult{}, err
}
return HideResult{
Format: c.Format(),
Technique: req.Technique,
PayloadBytes: len(req.Payload),
EnvelopeBytes: len(env),
Encrypted: len(req.Options.Passphrase) > 0,
Compressed: req.Options.Compress,
}, nil
}
func Reveal(req RevealRequest) (RevealResult, error) {
c, env, err := locate(req.Format, req.Stego)
if err != nil {
return RevealResult{}, err
}
encrypted, err := payload.IsEncrypted(env)
if err != nil {
return RevealResult{Format: c.Format()}, err
}
if encrypted && len(req.Passphrase) == 0 {
return RevealResult{Format: c.Format(), Encrypted: true}, payload.ErrPassphraseRequired
}
data, err := payload.Unpack(env, req.Passphrase)
if err != nil {
return RevealResult{Format: c.Format(), Encrypted: encrypted}, err
}
return RevealResult{Format: c.Format(), Data: data, Encrypted: encrypted}, nil
}
func Capacity(format string, cover io.Reader) (int, error) {
c, err := ResolveCarrier(format, "")
if err != nil {
return 0, err
}
return c.Capacity(cover)
}
func CapacityAll(cover []byte) []CapacityRow {
carriers := carrier.All()
rows := make([]CapacityRow, 0, len(carriers))
for _, c := range carriers {
n, err := c.Capacity(bytes.NewReader(cover))
rows = append(rows, CapacityRow{Format: c.Format(), Capacity: n, Err: err})
}
return rows
}
func Catalog() []FormatInfo {
names := carrier.Formats()
out := make([]FormatInfo, 0, len(names))
for _, name := range names {
out = append(out, FormatInfo{Name: name, Techniques: Techniques(name)})
}
return out
}
func Techniques(format string) []string {
if format != pdf.Format {
return nil
}
return []string{
string(pdf.TechniqueAttachment),
string(pdf.TechniqueMetadata),
string(pdf.TechniqueAppend),
}
}
func locate(format string, stego []byte) (carrier.Carrier, []byte, error) {
if format != "" {
c, err := ResolveCarrier(format, "")
if err != nil {
return nil, nil, err
}
env, err := c.Reveal(bytes.NewReader(stego))
if err != nil {
return nil, nil, err
}
return c, env, nil
}
return detect(stego)
}
func detect(stego []byte) (carrier.Carrier, []byte, error) {
for _, c := range carrier.All() {
if !c.Sniff(bytes.NewReader(stego)) {
continue
}
env, err := c.Reveal(bytes.NewReader(stego))
if err != nil {
continue
}
if payload.Validate(env) == nil {
return c, env, nil
}
}
return nil, nil, ErrUndetected
}

View File

@ -0,0 +1,233 @@
/*
©AngelaMos | 2026
engine_test.go
Resolution, round-trip, auto-detect, and capacity tests for the shared engine
*/
package engine
import (
"bytes"
"errors"
"image"
"image/color"
"image/png"
"testing"
"github.com/CarterPerez-dev/crypha/internal/carrier/pdf"
"github.com/CarterPerez-dev/crypha/internal/payload"
)
const (
qrCoverText = "crypha qr cover"
textCover = "the quick brown fox jumps over the lazy dog"
)
func pngCover(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x + y), A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func hideBytes(t *testing.T, format string, cover, plaintext []byte, opts payload.Options) []byte {
t.Helper()
var out bytes.Buffer
if _, err := Hide(HideRequest{
Format: format,
Cover: bytes.NewReader(cover),
Payload: plaintext,
Out: &out,
Options: opts,
}); err != nil {
t.Fatalf("Hide(%s): %v", format, err)
}
return out.Bytes()
}
func TestResolveCarrier(t *testing.T) {
cases := []struct {
name string
format string
technique string
wantErr error
wantFmt string
}{
{"image", "image", "", nil, "image"},
{"pdf default", "pdf", "", nil, "pdf"},
{"pdf attachment", "pdf", "attachment", nil, "pdf"},
{"pdf metadata", "pdf", "metadata", nil, "pdf"},
{"pdf append", "pdf", "append", nil, "pdf"},
{"pdf bad technique", "pdf", "nonsense", ErrUnknownTechnique, ""},
{"technique on non-pdf", "image", "attachment", ErrTechniqueOnNonPDF, ""},
{"unknown format", "nope", "", ErrUnknownFormat, ""},
{"empty format", "", "", ErrNoFormat, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := ResolveCarrier(tc.format, tc.technique)
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want %v", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if c.Format() != tc.wantFmt {
t.Fatalf("format = %q, want %q", c.Format(), tc.wantFmt)
}
})
}
}
func TestPlaintextRoundTrip(t *testing.T) {
cases := []struct {
format string
cover []byte
}{
{"text", []byte(textCover)},
{"image", pngCover(t, 64, 64)},
{"qr", []byte(qrCoverText)},
}
secret := []byte("meet at dawn")
for _, tc := range cases {
t.Run(tc.format, func(t *testing.T) {
stego := hideBytes(t, tc.format, tc.cover, secret, payload.Options{})
res, err := Reveal(RevealRequest{Format: tc.format, Stego: stego})
if err != nil {
t.Fatalf("Reveal: %v", err)
}
if !bytes.Equal(res.Data, secret) {
t.Fatalf("data = %q, want %q", res.Data, secret)
}
if res.Encrypted {
t.Fatal("plaintext payload reported as encrypted")
}
})
}
}
func TestEncryptedRoundTrip(t *testing.T) {
cover := pngCover(t, 64, 64)
secret := []byte("classified")
pass := []byte("correct horse battery staple")
stego := hideBytes(t, "image", cover, secret, payload.Options{Passphrase: pass})
res, err := Reveal(RevealRequest{Format: "image", Stego: stego, Passphrase: pass})
if err != nil {
t.Fatalf("Reveal: %v", err)
}
if !bytes.Equal(res.Data, secret) {
t.Fatalf("data = %q, want %q", res.Data, secret)
}
if !res.Encrypted {
t.Fatal("encrypted payload reported as plaintext")
}
if _, err := Reveal(RevealRequest{Format: "image", Stego: stego}); !errors.Is(err, payload.ErrPassphraseRequired) {
t.Fatalf("missing passphrase err = %v, want ErrPassphraseRequired", err)
}
if _, err := Reveal(RevealRequest{Format: "image", Stego: stego, Passphrase: []byte("wrong")}); !errors.Is(err, payload.ErrDecrypt) {
t.Fatalf("wrong passphrase err = %v, want ErrDecrypt", err)
}
}
func TestAutoDetect(t *testing.T) {
secret := []byte("hi")
cases := []struct {
format string
cover []byte
}{
{"text", []byte(textCover)},
{"image", pngCover(t, 64, 64)},
{"qr", []byte(qrCoverText)},
}
for _, tc := range cases {
t.Run(tc.format, func(t *testing.T) {
stego := hideBytes(t, tc.format, tc.cover, secret, payload.Options{})
res, err := Reveal(RevealRequest{Stego: stego})
if err != nil {
t.Fatalf("auto-detect Reveal: %v", err)
}
if res.Format != tc.format {
t.Fatalf("detected %q, want %q", res.Format, tc.format)
}
if !bytes.Equal(res.Data, secret) {
t.Fatalf("data = %q, want %q", res.Data, secret)
}
})
}
}
func TestAutoDetectQRNotShadowedByImage(t *testing.T) {
stego := hideBytes(t, "qr", []byte(qrCoverText), []byte("hi"), payload.Options{})
res, err := Reveal(RevealRequest{Stego: stego})
if err != nil {
t.Fatalf("Reveal: %v", err)
}
if res.Format != "qr" {
t.Fatalf("a qr PNG resolved to %q; the image carrier shadowed qr", res.Format)
}
}
func TestAutoDetectUndetected(t *testing.T) {
if _, err := Reveal(RevealRequest{Stego: []byte("just some plain bytes, not a carrier")}); !errors.Is(err, ErrUndetected) {
t.Fatalf("err = %v, want ErrUndetected", err)
}
}
func TestCatalogAndTechniques(t *testing.T) {
cat := Catalog()
if len(cat) != 5 {
t.Fatalf("catalog has %d formats, want 5", len(cat))
}
for _, fi := range cat {
if fi.Name == pdf.Format {
if len(fi.Techniques) != 3 {
t.Fatalf("pdf techniques = %v, want 3", fi.Techniques)
}
} else if fi.Techniques != nil {
t.Fatalf("%s has techniques %v, want none", fi.Name, fi.Techniques)
}
}
}
func TestCapacityAll(t *testing.T) {
rows := CapacityAll(pngCover(t, 64, 64))
var imageRow *CapacityRow
for i := range rows {
if rows[i].Format == "image" {
imageRow = &rows[i]
}
}
if imageRow == nil {
t.Fatal("no image row in capacity table")
}
if imageRow.Err != nil {
t.Fatalf("image capacity err: %v", imageRow.Err)
}
if imageRow.Capacity <= 0 {
t.Fatalf("image capacity = %d, want positive", imageRow.Capacity)
}
}
func TestCapacitySingleFormat(t *testing.T) {
n, err := Capacity("image", bytes.NewReader(pngCover(t, 32, 32)))
if err != nil {
t.Fatalf("Capacity: %v", err)
}
if n <= 0 {
t.Fatalf("capacity = %d, want positive", n)
}
}

View File

@ -156,3 +156,23 @@ func TestStrengthParams(t *testing.T) {
t.Errorf("default params wrong: %+v", p)
}
}
func TestOverheadMatchesEnvelope(t *testing.T) {
plain := []byte("a modest secret")
clear, err := Pack(plain, Options{})
if err != nil {
t.Fatalf("pack plaintext: %v", err)
}
if got := len(clear) - len(plain); got != Overhead(false) {
t.Errorf("plaintext overhead = %d, Overhead(false) = %d", got, Overhead(false))
}
sealed, err := Pack(plain, Options{Passphrase: []byte("pw"), Cipher: CipherChaCha20})
if err != nil {
t.Fatalf("pack encrypted: %v", err)
}
if got := len(sealed) - len(plain); got != Overhead(true) {
t.Errorf("encrypted overhead = %d, Overhead(true) = %d", got, Overhead(true))
}
}

View File

@ -39,12 +39,16 @@ const (
cipherIDChaCha20 byte = 0x00
cipherIDAES256GCM byte = 0x01
saltLen = 16
nonceLen = 12
keyLen = 32
lenField = 4
crcField = 4
paramsLen = 9
saltLen = 16
nonceLen = 12
keyLen = 32
lenField = 4
crcField = 4
paramsLen = 9
versionLen = 1
flagsLen = 1
cipherLen = 1
tagLen = 16
argonDefaultTime uint32 = 3
argonDefaultMemory uint32 = 64 * 1024
@ -67,3 +71,11 @@ var (
ErrDecrypt = errors.New("crypha: decryption failed (wrong passphrase or tampered data)")
ErrUnknownCipher = errors.New("crypha: unknown cipher")
)
func Overhead(encrypted bool) int {
base := len(magic) + versionLen + flagsLen + lenField + crcField
if !encrypted {
return base
}
return base + cipherLen + paramsLen + saltLen + nonceLen + tagLen
}

View File

@ -0,0 +1,295 @@
/*
©AngelaMos | 2026
report.go
Human-readable tables and JSON rendering for crypha command output
*/
package report
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math"
"runtime/debug"
"strings"
"text/tabwriter"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/CarterPerez-dev/crypha/internal/engine"
"github.com/CarterPerez-dev/crypha/internal/payload"
)
const (
unboundedLabel = "unbounded"
notApplicable = "n/a"
doesNotFit = "does not fit"
yesLabel = "yes"
noLabel = "no"
minCellWidth = 0
tabWidth = 2
padding = 2
padChar = ' '
)
type formatLine struct {
Format string `json:"format"`
Cover string `json:"cover"`
Output string `json:"output"`
Techniques []string `json:"techniques,omitempty"`
Description string `json:"description"`
}
type capacityLine struct {
Format string `json:"format"`
Applicable bool `json:"applicable"`
Capacity int `json:"capacity_bytes,omitempty"`
MaxPlaintext int `json:"max_plaintext_bytes,omitempty"`
MaxEncrypted int `json:"max_encrypted_bytes,omitempty"`
Unbounded bool `json:"unbounded,omitempty"`
Note string `json:"note,omitempty"`
}
type hideLine struct {
Format string `json:"format"`
Technique string `json:"technique,omitempty"`
PayloadBytes int `json:"payload_bytes"`
EnvelopeBytes int `json:"envelope_bytes"`
Encrypted bool `json:"encrypted"`
Compressed bool `json:"compressed"`
Output string `json:"output"`
}
type revealLine struct {
Format string `json:"format"`
Bytes int `json:"bytes"`
Encrypted bool `json:"encrypted"`
Output string `json:"output,omitempty"`
Data string `json:"data,omitempty"`
}
type versionLine struct {
Name string `json:"name"`
Version string `json:"version"`
GoVersion string `json:"go_version"`
}
type tableWriter struct {
tw *tabwriter.Writer
err error
}
func newTableWriter(w io.Writer) *tableWriter {
return &tableWriter{tw: tabwriter.NewWriter(w, minCellWidth, tabWidth, padding, padChar, 0)}
}
func (t *tableWriter) row(format string, a ...any) {
if t.err != nil {
return
}
_, t.err = fmt.Fprintf(t.tw, format, a...)
}
func (t *tableWriter) flush() error {
if t.err != nil {
return t.err
}
return t.tw.Flush()
}
func Formats(w io.Writer, jsonOut bool) error {
cat := engine.Catalog()
lines := make([]formatLine, 0, len(cat))
for _, fi := range cat {
d := config.FormatDetails[fi.Name]
lines = append(lines, formatLine{
Format: fi.Name,
Cover: d.CoverInput,
Output: d.Output,
Techniques: fi.Techniques,
Description: d.Blurb,
})
}
if jsonOut {
return writeJSON(w, lines)
}
tw := newTableWriter(w)
tw.row("FORMAT\tCOVER\tOUTPUT\tOPTIONS\tDESCRIPTION\n")
for _, l := range lines {
tw.row("%s\t%s\t%s\t%s\t%s\n", l.Format, l.Cover, l.Output, options(l.Techniques), l.Description)
}
return tw.flush()
}
func Capacity(w io.Writer, rows []engine.CapacityRow, jsonOut bool) error {
lines := make([]capacityLine, 0, len(rows))
for _, r := range rows {
lines = append(lines, capacityRow(r))
}
if jsonOut {
return writeJSON(w, lines)
}
tw := newTableWriter(w)
tw.row("FORMAT\tENVELOPE\tMAX PLAINTEXT\tMAX ENCRYPTED\tNOTE\n")
for _, l := range lines {
tw.row("%s\t%s\t%s\t%s\t%s\n", l.Format, capacityCell(l), plaintextCell(l), encryptedCell(l), l.Note)
}
return tw.flush()
}
func HideSummary(w io.Writer, res engine.HideResult, outPath string, jsonOut bool) error {
line := hideLine{
Format: res.Format,
Technique: res.Technique,
PayloadBytes: res.PayloadBytes,
EnvelopeBytes: res.EnvelopeBytes,
Encrypted: res.Encrypted,
Compressed: res.Compressed,
Output: outPath,
}
if jsonOut {
return writeJSON(w, line)
}
tw := newTableWriter(w)
tw.row("OUTPUT\t%s\n", line.Output)
tw.row("FORMAT\t%s\n", formatCell(res.Format, res.Technique))
tw.row("PAYLOAD\t%d bytes\n", line.PayloadBytes)
tw.row("ENVELOPE\t%d bytes\n", line.EnvelopeBytes)
tw.row("ENCRYPTED\t%s\n", boolLabel(line.Encrypted))
tw.row("COMPRESSED\t%s\n", boolLabel(line.Compressed))
return tw.flush()
}
func RevealStatus(w io.Writer, res engine.RevealResult, outPath string) error {
_, err := fmt.Fprintf(w, "revealed %d bytes via %s -> %s\n", len(res.Data), res.Format, outPath)
return err
}
func RevealJSON(w io.Writer, res engine.RevealResult, outPath string) error {
line := revealLine{
Format: res.Format,
Bytes: len(res.Data),
Encrypted: res.Encrypted,
Output: outPath,
}
if outPath == "" {
line.Data = base64.StdEncoding.EncodeToString(res.Data)
}
return writeJSON(w, line)
}
func Version(w io.Writer, jsonOut bool) error {
line := versionLine{
Name: config.BinaryName,
Version: config.Version,
GoVersion: goVersion(),
}
if jsonOut {
return writeJSON(w, line)
}
_, err := fmt.Fprintf(w, "%s %s (%s)\n", line.Name, line.Version, line.GoVersion)
return err
}
func capacityRow(r engine.CapacityRow) capacityLine {
l := capacityLine{Format: r.Format}
if r.Err != nil {
l.Note = reason(r.Err)
return l
}
l.Applicable = true
if r.Capacity >= math.MaxInt32 {
l.Unbounded = true
return l
}
l.Capacity = r.Capacity
l.MaxPlaintext = clampZero(r.Capacity - payload.Overhead(false))
l.MaxEncrypted = clampZero(r.Capacity - payload.Overhead(true))
return l
}
func capacityCell(l capacityLine) string {
switch {
case !l.Applicable:
return notApplicable
case l.Unbounded:
return unboundedLabel
default:
return fmt.Sprintf("%d", l.Capacity)
}
}
func plaintextCell(l capacityLine) string {
switch {
case !l.Applicable:
return notApplicable
case l.Unbounded:
return unboundedLabel
default:
return fmt.Sprintf("%d", l.MaxPlaintext)
}
}
func encryptedCell(l capacityLine) string {
switch {
case !l.Applicable:
return notApplicable
case l.Unbounded:
return unboundedLabel
case l.MaxEncrypted <= 0:
return doesNotFit
default:
return fmt.Sprintf("%d", l.MaxEncrypted)
}
}
func options(techniques []string) string {
if len(techniques) == 0 {
return "-"
}
return strings.Join(techniques, " | ")
}
func formatCell(format, technique string) string {
if technique == "" {
return format
}
return format + " (" + technique + ")"
}
func boolLabel(b bool) string {
if b {
return yesLabel
}
return noLabel
}
func reason(err error) string {
msg := err.Error()
if i := strings.Index(msg, ": "); i >= 0 {
return msg[i+2:]
}
return msg
}
func clampZero(n int) int {
if n < 0 {
return 0
}
return n
}
func goVersion() string {
if info, ok := debug.ReadBuildInfo(); ok && info.GoVersion != "" {
return info.GoVersion
}
return "unknown"
}
func writeJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}

View File

@ -0,0 +1,166 @@
/*
©AngelaMos | 2026
report_test.go
Table and JSON rendering tests for command output
*/
package report
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"math"
"strings"
"testing"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/CarterPerez-dev/crypha/internal/engine"
)
func TestFormatsHuman(t *testing.T) {
var buf bytes.Buffer
if err := Formats(&buf, false); err != nil {
t.Fatalf("Formats: %v", err)
}
out := buf.String()
for _, f := range []string{"image", "audio", "qr", "text", "pdf"} {
if !strings.Contains(out, f) {
t.Errorf("formats output missing %q", f)
}
}
if !strings.Contains(out, "attachment") {
t.Error("pdf technique options not listed")
}
}
func TestFormatsJSON(t *testing.T) {
var buf bytes.Buffer
if err := Formats(&buf, true); err != nil {
t.Fatalf("Formats: %v", err)
}
if !json.Valid(buf.Bytes()) {
t.Fatal("formats json is invalid")
}
var lines []formatLine
if err := json.Unmarshal(buf.Bytes(), &lines); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(lines) != 5 {
t.Fatalf("got %d formats, want 5", len(lines))
}
}
func TestCapacityHuman(t *testing.T) {
rows := []engine.CapacityRow{
{Format: "qr", Capacity: 52},
{Format: "pdf", Capacity: math.MaxInt32},
{Format: "text", Err: errors.New("crypha/text: cover too small")},
}
var buf bytes.Buffer
if err := Capacity(&buf, rows, false); err != nil {
t.Fatalf("Capacity: %v", err)
}
out := buf.String()
for _, want := range []string{doesNotFit, unboundedLabel, notApplicable, "cover too small", "38"} {
if !strings.Contains(out, want) {
t.Errorf("capacity output missing %q\n%s", want, out)
}
}
}
func TestCapacityJSON(t *testing.T) {
rows := []engine.CapacityRow{
{Format: "qr", Capacity: 52},
{Format: "pdf", Capacity: math.MaxInt32},
{Format: "text", Err: errors.New("crypha/text: cover too small")},
}
var buf bytes.Buffer
if err := Capacity(&buf, rows, true); err != nil {
t.Fatalf("Capacity: %v", err)
}
var lines []capacityLine
if err := json.Unmarshal(buf.Bytes(), &lines); err != nil {
t.Fatalf("unmarshal: %v", err)
}
byFormat := map[string]capacityLine{}
for _, l := range lines {
byFormat[l.Format] = l
}
if got := byFormat["qr"]; got.MaxPlaintext != 38 || got.MaxEncrypted != 0 {
t.Errorf("qr line = %+v, want plaintext 38 encrypted 0", got)
}
if !byFormat["pdf"].Unbounded {
t.Error("pdf should be unbounded")
}
if byFormat["text"].Applicable {
t.Error("rejected cover should be inapplicable")
}
}
func TestHideSummary(t *testing.T) {
res := engine.HideResult{
Format: "image",
PayloadBytes: 12,
EnvelopeBytes: 80,
Encrypted: true,
}
var buf bytes.Buffer
if err := HideSummary(&buf, res, "out.png", false); err != nil {
t.Fatalf("HideSummary: %v", err)
}
out := buf.String()
for _, want := range []string{"image", "12", "80", "out.png", yesLabel} {
if !strings.Contains(out, want) {
t.Errorf("hide summary missing %q\n%s", want, out)
}
}
}
func TestVersion(t *testing.T) {
var buf bytes.Buffer
if err := Version(&buf, false); err != nil {
t.Fatalf("Version: %v", err)
}
out := buf.String()
if !strings.Contains(out, config.BinaryName) || !strings.Contains(out, config.Version) {
t.Errorf("version output = %q", out)
}
}
func TestRevealJSON(t *testing.T) {
res := engine.RevealResult{Format: "image", Data: []byte("hidden bytes"), Encrypted: true}
var toStdout bytes.Buffer
if err := RevealJSON(&toStdout, res, ""); err != nil {
t.Fatalf("RevealJSON: %v", err)
}
var line revealLine
if err := json.Unmarshal(toStdout.Bytes(), &line); err != nil {
t.Fatalf("unmarshal: %v", err)
}
decoded, err := base64.StdEncoding.DecodeString(line.Data)
if err != nil || string(decoded) != "hidden bytes" {
t.Fatalf("data = %q err %v", decoded, err)
}
if line.Bytes != len(res.Data) || !line.Encrypted || line.Format != "image" {
t.Fatalf("line = %+v", line)
}
var toFile bytes.Buffer
if err := RevealJSON(&toFile, res, "out.bin"); err != nil {
t.Fatalf("RevealJSON with file: %v", err)
}
var fileLine revealLine
if err := json.Unmarshal(toFile.Bytes(), &fileLine); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if fileLine.Data != "" {
t.Error("data should be omitted when writing to a file")
}
if fileLine.Output != "out.bin" {
t.Errorf("output = %q, want out.bin", fileLine.Output)
}
}