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:
|
||||
gosec:
|
||||
excludes:
|
||||
- G101
|
||||
- G115
|
||||
- G304
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
"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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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