diff --git a/PROJECTS/beginner/steganography-multi-tool/.golangci.yml b/PROJECTS/beginner/steganography-multi-tool/.golangci.yml index 0d61cf12..da718b77 100644 --- a/PROJECTS/beginner/steganography-multi-tool/.golangci.yml +++ b/PROJECTS/beginner/steganography-multi-tool/.golangci.yml @@ -20,7 +20,9 @@ linters: settings: gosec: excludes: + - G101 - G115 + - G304 formatters: enable: diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index d9201611..c653432a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -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 ) diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index cef14c9a..d6cb166a 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -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= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go new file mode 100644 index 00000000..8fc5232d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/capacity.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go new file mode 100644 index 00000000..edea0f78 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/cli_test.go @@ -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) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go new file mode 100644 index 00000000..38a6d13f --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go new file mode 100644 index 00000000..06209a43 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/formats.go @@ -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)) + }, + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go new file mode 100644 index 00000000..c9743c53 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/hide.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go new file mode 100644 index 00000000..40743ce6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/interactive_test.go @@ -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) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go new file mode 100644 index 00000000..2dae77e1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/io.go @@ -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())) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go new file mode 100644 index 00000000..46c500e1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/passphrase.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go new file mode 100644 index 00000000..3613a98c --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/reveal.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go index 07885afb..40a4572c 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go @@ -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") -} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go new file mode 100644 index 00000000..f8e8d34d --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/version.go @@ -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)) + }, + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go b/PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go new file mode 100644 index 00000000..28d05850 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/config/cli.go @@ -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", + }, +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go new file mode 100644 index 00000000..9cbf5d72 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go new file mode 100644 index 00000000..6a307af4 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine_test.go @@ -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) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go index 7b94845e..7ee9e640 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelope_test.go @@ -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)) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go index 4dc532e6..0d349096 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go @@ -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 +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/report/report.go b/PROJECTS/beginner/steganography-multi-tool/internal/report/report.go new file mode 100644 index 00000000..1d9abe64 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/report/report.go @@ -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) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go new file mode 100644 index 00000000..f867a56f --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/report/report_test.go @@ -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) + } +}