feat(crypha): add cobra cli, shared engine, and report layer (M7)
This commit is contained in:
parent
0ae323eb5d
commit
3b790e5956
|
|
@ -20,7 +20,9 @@ linters:
|
||||||
settings:
|
settings:
|
||||||
gosec:
|
gosec:
|
||||||
excludes:
|
excludes:
|
||||||
|
- G101
|
||||||
- G115
|
- G115
|
||||||
|
- G304
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ require (
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/image v0.44.0
|
golang.org/x/image v0.44.0
|
||||||
|
golang.org/x/term v0.45.0
|
||||||
golang.org/x/text v0.40.0
|
golang.org/x/text v0.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -10,25 +10,40 @@ package cli
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/CarterPerez-dev/crypha/internal/config"
|
"github.com/CarterPerez-dev/crypha/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
func newRootCmd() *cobra.Command {
|
||||||
Use: config.BinaryName,
|
root := &cobra.Command{
|
||||||
Short: config.ShortDescription,
|
Use: config.BinaryName,
|
||||||
Long: config.LongDescription,
|
Short: config.ShortDescription,
|
||||||
Version: config.Version,
|
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() {
|
func Execute() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := newRootCmd().Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
msg := err.Error()
|
||||||
|
if !strings.HasPrefix(msg, config.BinaryName) {
|
||||||
|
msg = config.BinaryName + ": " + msg
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, msg)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.SetVersionTemplate(config.BinaryName + " {{.Version}}\n")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -156,3 +156,23 @@ func TestStrengthParams(t *testing.T) {
|
||||||
t.Errorf("default params wrong: %+v", p)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,16 @@ const (
|
||||||
cipherIDChaCha20 byte = 0x00
|
cipherIDChaCha20 byte = 0x00
|
||||||
cipherIDAES256GCM byte = 0x01
|
cipherIDAES256GCM byte = 0x01
|
||||||
|
|
||||||
saltLen = 16
|
saltLen = 16
|
||||||
nonceLen = 12
|
nonceLen = 12
|
||||||
keyLen = 32
|
keyLen = 32
|
||||||
lenField = 4
|
lenField = 4
|
||||||
crcField = 4
|
crcField = 4
|
||||||
paramsLen = 9
|
paramsLen = 9
|
||||||
|
versionLen = 1
|
||||||
|
flagsLen = 1
|
||||||
|
cipherLen = 1
|
||||||
|
tagLen = 16
|
||||||
|
|
||||||
argonDefaultTime uint32 = 3
|
argonDefaultTime uint32 = 3
|
||||||
argonDefaultMemory uint32 = 64 * 1024
|
argonDefaultMemory uint32 = 64 * 1024
|
||||||
|
|
@ -67,3 +71,11 @@ var (
|
||||||
ErrDecrypt = errors.New("crypha: decryption failed (wrong passphrase or tampered data)")
|
ErrDecrypt = errors.New("crypha: decryption failed (wrong passphrase or tampered data)")
|
||||||
ErrUnknownCipher = errors.New("crypha: unknown cipher")
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue