Merge pull request #311 from CarterPerez-dev/project/steganography-multi-tool

Project/steganography multi tool
This commit is contained in:
Carter Perez 2026-07-19 03:24:47 -04:00 committed by GitHub
commit 2979a0b858
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 13209 additions and 6 deletions

View File

@ -23,6 +23,16 @@ PUBLIC_BASE_URL=https://canary.your.domain
VITE_APP_TITLE="Canary Token Generator"
VITE_API_URL=/api
# ---------------------------------------------------------------------------
# Reverse proxy / client IP (load-bearing for detection)
# ---------------------------------------------------------------------------
# Comma-separated CIDRs whose X-Forwarded-For header is trusted. RealIP only
# reads XFF when the immediate peer is in this list. Too narrow and every event
# logs the proxy's IP (GeoIP + dedup break); too wide and a client can spoof its
# source IP. Set it to the CIDR your reverse proxy connects from.
# Default: 127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# ---------------------------------------------------------------------------
# Cloudflare Turnstile (free anti-bot)
# ---------------------------------------------------------------------------

View File

@ -5,6 +5,7 @@ package config
import (
"fmt"
"strings"
"sync"
"time"
@ -159,7 +160,7 @@ func Load(configPath string) (*Config, error) {
}
if err := k.Load(
env.Provider("", ".", envKeyReplacer),
env.ProviderWithValue("", ".", envCallback),
nil,
); err != nil {
loadErr = fmt.Errorf("load env vars: %w", err)
@ -315,10 +316,14 @@ var envKeyMap = map[string]string{
"CANARY_BASE_URL": "canary.base_url",
"PUBLIC_BASE_URL": "canary.base_url",
"CANARY_MANAGE_URL": "canary.manage_url",
"TRUSTED_PROXY_CIDRS": "server.trusted_proxy_cidrs",
"TURNSTILE_SECRET_KEY": "turnstile.secret_key",
"TURNSTILE_SECRET": "turnstile.secret_key",
"TURNSTILE_SITE_KEY": "turnstile.site_key",
"MYSQL_ENABLED": "mysql.enabled",
"MYSQL_FAKE_ENABLED": "mysql.enabled",
"MYSQL_ADDR": "mysql.addr",
"MYSQL_FAKE_ADDR": "mysql.addr",
"MYSQL_PUBLIC_HOST": "mysql.public_host",
"MYSQL_PUBLIC_PORT": "mysql.public_port",
"RATE_LIMIT_CREATE_MIN_RATE": "rate_limit.create_min_rate",
@ -339,6 +344,10 @@ var envKeyMap = map[string]string{
"GEOLITE_PATH": "geoip.path",
}
var envSliceKeys = map[string]struct{}{
"server.trusted_proxy_cidrs": {},
}
func envKeyReplacer(s string) string {
if mapped, ok := envKeyMap[s]; ok {
return mapped
@ -346,6 +355,27 @@ func envKeyReplacer(s string) string {
return ""
}
func envCallback(key, value string) (string, any) {
mapped := envKeyReplacer(key)
if mapped == "" {
return "", nil
}
if _, isSlice := envSliceKeys[mapped]; isSlice {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
out = append(out, trimmed)
}
}
if len(out) == 0 {
return "", nil // blank/empty: leave the configured default intact
}
return mapped, out
}
return mapped, value
}
func validate(c *Config) error {
if c.Database.URL == "" {
return fmt.Errorf("DATABASE_URL is required")

View File

@ -0,0 +1,118 @@
// ©AngelaMos | 2026
// config_test.go
package config
import (
"reflect"
"testing"
)
func TestEnvCallbackScalarAndAliases(t *testing.T) {
cases := []struct {
name string
key string
value string
wantKey string
wantVal any
}{
{
"mysql fake enabled alias",
"MYSQL_FAKE_ENABLED",
"true",
"mysql.enabled",
"true",
},
{
"mysql canonical enabled",
"MYSQL_ENABLED",
"true",
"mysql.enabled",
"true",
},
{
"mysql fake addr alias",
"MYSQL_FAKE_ADDR",
"0.0.0.0:33306",
"mysql.addr",
"0.0.0.0:33306",
},
{
"turnstile secret alias",
"TURNSTILE_SECRET",
"sk",
"turnstile.secret_key",
"sk",
},
{"unmapped key dropped", "SOME_RANDOM_VAR", "x", "", nil},
{
"blank slice dropped keeps default",
"TRUSTED_PROXY_CIDRS",
"",
"",
nil,
},
{
"whitespace-only slice dropped",
"TRUSTED_PROXY_CIDRS",
" , ,",
"",
nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotKey, gotVal := envCallback(c.key, c.value)
if gotKey != c.wantKey {
t.Fatalf("key: got %q, want %q", gotKey, c.wantKey)
}
if !reflect.DeepEqual(gotVal, c.wantVal) {
t.Fatalf("value: got %#v, want %#v", gotVal, c.wantVal)
}
})
}
}
func TestEnvCallbackSliceSplitting(t *testing.T) {
cases := []struct {
name string
value string
want []string
}{
{"single", "10.0.0.0/8", []string{"10.0.0.0/8"}},
{
"multiple",
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
[]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"},
},
{
"spaces trimmed",
" 10.0.0.0/8 , 172.16.0.0/12 ",
[]string{"10.0.0.0/8", "172.16.0.0/12"},
},
{
"empty entries dropped",
"10.0.0.0/8,,192.168.0.0/16,",
[]string{"10.0.0.0/8", "192.168.0.0/16"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotKey, gotVal := envCallback("TRUSTED_PROXY_CIDRS", c.value)
if gotKey != "server.trusted_proxy_cidrs" {
t.Fatalf(
"key: got %q, want %q",
gotKey,
"server.trusted_proxy_cidrs",
)
}
got, ok := gotVal.([]string)
if !ok {
t.Fatalf("value type: got %T, want []string", gotVal)
}
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("value: got %#v, want %#v", got, c.want)
}
})
}
}

View File

@ -51,6 +51,7 @@ services:
- GEOLITE_PATH=/data/GeoLite2-City.mmdb
- WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-}
- MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false}
- TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-}
- LOG_LEVEL=${LOG_LEVEL:-info}
- LOG_FORMAT=${LOG_FORMAT:-json}
- OTEL_ENABLED=${OTEL_ENABLED:-false}

View File

@ -64,6 +64,7 @@ services:
- GEOLITE_PATH=${GEOLITE_PATH:-/app/testdata/GeoLite2-City.mmdb}
- WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-}
- MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false}
- TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-}
- LOG_LEVEL=debug
- LOG_FORMAT=text
- OTEL_ENABLED=${OTEL_ENABLED:-true}

View File

@ -44,7 +44,6 @@ packages = [
[tool.ruff]
target-version = "py314"
line-length = 88
preview = true
src = [
"src/netanal",
]

View File

@ -0,0 +1,35 @@
# ©AngelaMos | 2026
# ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test -race ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: golangci/golangci-lint-action@v8
with:
version: v2.10.1

View File

@ -0,0 +1,32 @@
# ©AngelaMos | 2026
# release.yml
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -0,0 +1,17 @@
# build output
/dist/
/crypha
# test / coverage
coverage.out
*.test
*.prof
# local dev docs (research / plans / context stay local)
/docs/
# editor / os
.idea/
.vscode/
*.swp
.DS_Store

View File

@ -0,0 +1,34 @@
# ©AngelaMos | 2026
# .golangci.yml
version: "2"
run:
timeout: 5m
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- misspell
- unconvert
- bodyclose
- gosec
settings:
gosec:
excludes:
- G101
- G115
- G304
formatters:
enable:
- gofmt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0

View File

@ -0,0 +1,45 @@
# ©AngelaMos | 2026
# .goreleaser.yaml
version: 2
project_name: crypha
before:
hooks:
- go mod tidy
builds:
- id: crypha
main: ./cmd/crypha
binary: crypha
env:
- CGO_ENABLED=0
goos: [linux, darwin]
goarch: [amd64, arm64]
ldflags:
- -s -w
archives:
- id: crypha
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
formats: [tar.gz]
files:
- README.md
- LICENSE
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
release:
github:
owner: CarterPerez-dev
name: crypha

View File

@ -0,0 +1,257 @@
<!-- ©AngelaMos | 2026 -->
<!-- DEMO.md -->
# crypha demo
A hands-on tour of every carrier, end to end, with the real output crypha produces. Nothing here is faked; each block is a command you can run and the result it gives back.
Two things to know before you start:
- crypha writes the recovered payload to **stdout** and a one-line status to **stderr**. On a terminal they run together, because the payload has no trailing newline. Redirect with `-o out.bin`, or use `--json`, when you want them cleanly separated.
- A passphrase from any source (`-k`, `CRYPHA_PASSPHRASE`, or the interactive prompt) always encrypts. crypha never writes plaintext when you asked for a key.
## Sample covers
Any files work. To reproduce this page exactly, make a throwaway set with tools you probably already have:
```bash
convert -size 640x480 gradient:navy-white -depth 8 PNG24:cover.png # an 8-bit truecolor image
ffmpeg -f lavfi -i "sine=frequency=440:duration=2" \
-ac 1 -c:a pcm_s16le cover.wav # 2 seconds of mono audio
ffmpeg -i cover.wav cover.flac # the same audio, as FLAC
convert -size 612x792 xc:white cover.pdf # a one-page PDF
printf 'The quick brown fox jumps over the lazy dog.' > cover.txt
printf 'https://angelamos.com/crypha' > qr.txt # the QR's visible content
```
The image carrier wants an 8-bit truecolor PNG (or a 24-bit BMP); the `-depth 8 PNG24:` above forces that, since ImageMagick otherwise defaults to a 16-bit PNG, which crypha refuses.
## What can it hide, and where
```console
$ crypha formats
FORMAT COVER OUTPUT OPTIONS DESCRIPTION
audio 16-bit PCM WAV or FLAC WAV - LSB of 16-bit PCM samples
image PNG or 24-bit BMP PNG - LSB of RGB pixel data
pdf PDF PDF attachment | metadata | append embedded attachment, metadata, or append-after-EOF
qr UTF-8 text (the QR's visible content) PNG - Reed-Solomon-correctable error injection
text any UTF-8 text text - zero-width U+200B and U+2060 characters
```
`capacity` tells you how much a specific cover will hold, plaintext and encrypted:
```console
$ crypha capacity -i cover.png --format image
FORMAT ENVELOPE MAX PLAINTEXT MAX ENCRYPTED NOTE
image 115196 115182 115128
$ crypha capacity -i qr.txt --format qr
FORMAT ENVELOPE MAX PLAINTEXT MAX ENCRYPTED NOTE
qr 52 38 does not fit
```
Point `capacity` at a cover with no `--format` and it reports every carrier at once, flagging the ones the cover cannot serve:
```console
$ crypha capacity -i cover.png
FORMAT ENVELOPE MAX PLAINTEXT MAX ENCRYPTED NOTE
audio n/a n/a n/a cover must be a 16-bit PCM WAV or a FLAC file
image 115196 115182 115128
pdf n/a n/a n/a cover must be a PDF
qr 0 0 does not fit
text unbounded unbounded unbounded
```
## image: LSB of RGB pixels
```console
$ crypha hide --format image -i cover.png -o stego.png -m "The treasure is buried under the third oak past the old mill."
OUTPUT stego.png
FORMAT image
PAYLOAD 61 bytes
ENVELOPE 75 bytes
ENCRYPTED no
COMPRESSED no
```
`reveal` needs no `--format`; it detects the carrier itself:
```console
$ crypha reveal stego.png
The treasure is buried under the third oak past the old mill.
revealed 61 bytes via image -> (stdout)
```
`stego.png` is pixel-for-pixel indistinguishable to the eye. The 61-byte message became a 75-byte envelope (a plaintext payload adds 14 bytes of framing) and rode in the low bit of the RGB channels.
## text: zero-width characters
```console
$ crypha hide --format text -i cover.txt -o stego.txt -m "Zero-width characters are invisible to the eye."
OUTPUT stego.txt
FORMAT text
PAYLOAD 47 bytes
ENVELOPE 61 bytes
ENCRYPTED no
COMPRESSED no
```
The stego file reads identically to the cover, but the byte count gives it away: the cover was 44 bytes and `stego.txt` is far larger, because the envelope was appended as invisible U+200B and U+2060 runes.
```console
$ crypha reveal stego.txt
Zero-width characters are invisible to the eye.
revealed 47 bytes via text -> (stdout)
```
## audio: LSB of PCM samples
```console
$ crypha hide --format audio -i cover.wav -o stego.wav -m "Frequencies carry more than music."
OUTPUT stego.wav
FORMAT audio
PAYLOAD 34 bytes
ENVELOPE 48 bytes
ENCRYPTED no
COMPRESSED no
$ crypha reveal stego.wav
Frequencies carry more than music.
revealed 34 bytes via audio -> (stdout)
```
The tone sounds the same; the payload lives in the low bit of each 16-bit sample. Hand it a FLAC cover and crypha decodes it, embeds, and writes the result back as a standard WAV:
```console
$ crypha hide --format audio -i cover.flac -o from-flac.wav -m "Decoded from FLAC, embedded, written back as WAV."
OUTPUT from-flac.wav
FORMAT audio
PAYLOAD 49 bytes
ENVELOPE 63 bytes
ENCRYPTED no
COMPRESSED no
$ crypha reveal from-flac.wav
Decoded from FLAC, embedded, written back as WAV.
revealed 49 bytes via audio -> (stdout)
```
## qr: Reed-Solomon error injection
This is the showpiece. The payload is injected as errors into the QR's data codewords, inside the Reed-Solomon correction budget. An ordinary scanner reads the visible content and silently self-heals the injected errors away; crypha reads the errors back as the hidden bytes.
```console
$ crypha hide --format qr -i qr.txt -o stego-qr.png -m "meet me at the docks, midnight"
OUTPUT stego-qr.png
FORMAT qr
PAYLOAD 30 bytes
ENVELOPE 44 bytes
ENCRYPTED no
COMPRESSED no
```
Scan `stego-qr.png` with any phone and you get `https://angelamos.com/crypha`. Point crypha at it and you get the secret. Auto-detect has to be careful here, because a QR stego is a PNG: it must not be mistaken for an ordinary image carrier. It is not.
```console
$ crypha reveal stego-qr.png
meet me at the docks, midnight
revealed 30 bytes via qr -> (stdout)
```
The 30-byte message fit in the 38-byte plaintext budget of this cover. An encrypted envelope (68 bytes of overhead) would not, which is exactly what `capacity` warned above.
## pdf: attachment, metadata, or append
The default technique embeds the envelope as a lossless file attachment:
```console
$ crypha hide --format pdf -i cover.pdf -o stego.pdf -m "Attached, not appended. Look inside the file."
OUTPUT stego.pdf
FORMAT pdf
PAYLOAD 45 bytes
ENVELOPE 59 bytes
ENCRYPTED no
COMPRESSED no
$ crypha reveal stego.pdf
Attached, not appended. Look inside the file.
revealed 45 bytes via pdf -> (stdout)
```
`--technique append` writes the envelope after the `%%EOF` marker instead, where it survives naive copies:
```console
$ crypha hide --format pdf --technique append -i cover.pdf -o stego-append.pdf -m "This rides after the EOF marker."
OUTPUT stego-append.pdf
FORMAT pdf (append)
PAYLOAD 32 bytes
ENVELOPE 46 bytes
ENCRYPTED no
COMPRESSED no
```
`reveal` tries every technique, so you never have to remember which one you used.
## Encryption, end to end
Set a passphrase, ask to encrypt, and (optionally) compress. Here the passphrase comes from the environment so it never lands in your shell history:
```console
$ export CRYPHA_PASSPHRASE="correct horse battery staple"
$ crypha hide --format image -i cover.png -o enc.png -m "Only the passphrase opens this." --encrypt --compress
OUTPUT enc.png
FORMAT image
PAYLOAD 31 bytes
ENVELOPE 105 bytes
ENCRYPTED yes
COMPRESSED yes
```
With the right passphrase it comes straight back:
```console
$ crypha reveal enc.png
Only the passphrase opens this.
revealed 31 bytes via image -> (stdout)
```
With the wrong one, it fails closed. The header is authenticated, so a bad key or a tampered byte fails to open rather than returning garbage:
```console
$ CRYPHA_PASSPHRASE="wrong" crypha reveal enc.png
crypha: decryption failed (wrong passphrase or tampered data)
$ echo $?
1
```
## JSON for scripts
Every command takes a global `--json`. `reveal --json` base64-encodes the payload so binary is safe to pipe:
```console
$ crypha hide --format image -i cover.png -o j.png -m "json demo" --json
{
"format": "image",
"payload_bytes": 9,
"envelope_bytes": 23,
"encrypted": false,
"compressed": false,
"output": "j.png"
}
$ crypha reveal j.png --json
{
"format": "image",
"bytes": 9,
"encrypted": false,
"data": "anNvbiBkZW1v"
}
```
## The interactive wizard
Run `crypha` with no arguments in a terminal and it launches the bubbletea wizard: pick an operation, choose a format, browse to a cover with the file picker, type your message or select a payload file, set the secure options, and watch a live capacity meter fill as it checks the fit before embedding. It is the same engine as the CLI, so anything above works there too, with nothing to memorize.
```bash
crypha
```

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,175 @@
<!-- ©AngelaMos | 2026 -->
<!-- README.md -->
```json
██████╗██████╗ ██╗ ██╗██████╗ ██╗ ██╗ █████╗
██╔════╝██╔══██╗╚██╗ ██╔╝██╔══██╗██║ ██║██╔══██╗
██║ ██████╔╝ ╚████╔╝ ██████╔╝███████║███████║
██║ ██╔══██╗ ╚██╔╝ ██╔═══╝ ██╔══██║██╔══██║
╚██████╗██║ ██║ ██║ ██║ ██║ ██║██║ ██║
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-beginner-8B5CF6?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/steganography-multi-tool)
[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev)
[![Single static binary](https://img.shields.io/badge/binary-single%20static-6D4AFF?style=flat)](https://go.dev)
[![Carriers](https://img.shields.io/badge/carriers-5-4457E8?style=flat)](#the-five-carriers)
[![AEAD](https://img.shields.io/badge/AEAD-ChaCha20--Poly1305-8B5CF6?style=flat)](https://datatracker.ietf.org/doc/html/rfc8439)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
> A multi-format steganography tool in Go. It takes a message or a file, seals it in a passphrase-encrypted, compressed, integrity-checked envelope, and hides that envelope inside an ordinary-looking carrier: the low bits of an image or an audio file, the Reed-Solomon slack of a QR code, zero-width characters in a block of text, or the structure of a PDF. Point `reveal` at the result and it auto-detects the carrier and hands the message back. It ships as a single static, dependency-free binary and drives from either a scriptable CLI or a guided terminal wizard.
## Why hide an encrypted message
Cryptography and steganography answer two different questions. Encryption makes a message unreadable; steganography makes it unnoticeable. Encryption on its own still announces that a secret exists, and an opaque blob is itself a signal, exactly the thing a data-loss-prevention scanner, an intrusion-detection rule, or a border inspection is trained to flag. Steganography removes the signal: the carrier looks like a holiday photo, a voice memo, a PDF invoice, or a QR code on a poster.
The technique is not academic. In 2022 the Witchetty espionage group concealed a backdoor inside a bitmap of an old Windows logo hosted on a public cloud service, so the payload arrived looking like an image download rather than malware (Symantec). The Stegano exploit kit hid malicious code in the alpha channel of PNG banner ads served to millions of visitors (ESET, 2016). The open-source Invoke-PSImage tool packs a PowerShell script into the pixels of a PNG, and the Stegoloader/Gatak family pulled its own components out of images fetched at runtime to keep obvious code off disk (Dell SecureWorks, 2015). Defenders answer with steganalysis: the statistical hunt for the faint fingerprint that embedding leaves behind.
crypha exists to teach both sides of that exchange honestly. It encrypts first, so a discovered payload is still unreadable, then hides the ciphertext across five very different carriers, each with its own capacity, fragility, and detection story. The `learn/` track walks the steganalysis that breaks each one.
## What it is
Not a stub. Every capability below is exercised by table-driven unit tests, round-trip tests over text and random-binary payloads, and, for the QR carrier, differential tests against reference encoders and decoders.
**The encrypted envelope (every payload, every carrier)**
- A passphrase-derived key via Argon2id (RFC 9106): the 64 MiB default profile, or a 2 GiB profile under `--strength high`
- Authenticated encryption with ChaCha20-Poly1305 by default (constant-time in software on any CPU), or AES-256-GCM behind `--cipher aes256gcm`
- Optional DEFLATE compression before encryption, a CRC32 integrity check, and the header bound in as authenticated associated data, so a single flipped byte fails to open rather than decrypting to garbage
- A passphrase from any source, the `-k` flag, the `CRYPHA_PASSPHRASE` environment variable, or a no-echo terminal prompt, always means the payload is encrypted; crypha never silently writes plaintext when you asked for a key
**Five carriers behind one interface**
- **image**: LSB of RGB in PNG or 24-bit BMP, via the mandatory NRGBA conversion that stops `png.Encode`'s alpha pass from corrupting the low bits; paletted and 16-bit covers are refused rather than silently mangled
- **audio**: LSB of 16-bit PCM samples in WAV; a FLAC cover is decoded, embedded, and re-emitted as WAV
- **qr**: the payload is injected as Reed-Solomon-correctable errors into a QR code's data codewords, so an ordinary scanner silently self-heals to the visible content while crypha reads the injected bytes back. The placement, masking, and block de-interleave are reimplemented from ISO/IEC 18004
- **text**: zero-width U+200B and U+2060 characters appended to any UTF-8 cover; the stego text is visually identical to the original and survives Unicode normalization
- **pdf**: a lossless embedded-file attachment by default, or `--technique metadata` / `--technique append`
**Two frontends over one engine**
- A scriptable cobra CLI: `hide`, `reveal`, `capacity`, `formats`, with a global `--json` for machine consumption
- A guided bubbletea terminal wizard on bare `crypha`, at full parity with the CLI, that walks operation to format to files to options and shows a live capacity meter before it embeds
- `reveal` with no `--format` auto-detects the carrier by trying each one and validating the envelope, so a QR-PNG is never mistaken for an ordinary image
## Quick Start
```bash
curl -fsSL https://angelamos.com/crypha/install.sh | bash
```
One command, zero further steps: it grabs a prebuilt binary for your platform (no Go toolchain needed), drops it on your `PATH`, and leaves `crypha` runnable by name. Then hide your first message:
```bash
crypha # launch the guided wizard
crypha capacity -i photo.png # how much can this cover hold?
crypha hide -i photo.png -o secret.png --format image -m "meet at noon"
crypha reveal secret.png # auto-detects image, prints the message
```
Add a passphrase and `reveal` will ask for it before it decrypts:
```bash
crypha hide -i photo.png -o secret.png --format image -m "coordinates inside" --encrypt --compress
crypha reveal secret.png # prompts for the passphrase, then decrypts
```
Prefer the Go toolchain? `go install github.com/CarterPerez-dev/crypha/cmd/crypha@latest` works too, and `just build` builds from a checkout. Building from source needs Go 1.25+, fetched automatically if you are on an older Go.
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## The five carriers
| Format | Technique | Cover to output | Capacity (envelope bytes) | Notes |
|--------|-----------|-----------------|---------------------------|-------|
| image | LSB of RGB pixels | PNG / 24-bit BMP to PNG | `width x height x 3 / 8` minus a 4-byte prefix (640x480 holds **115,196**) | alpha untouched; paletted and 16-bit covers refused |
| audio | LSB of 16-bit PCM | WAV / FLAC to WAV | `samples x channels / 8` (2s of 44.1 kHz mono holds **11,021**) | FLAC is decoded and re-emitted as WAV |
| text | zero-width U+200B / U+2060 | any UTF-8 to UTF-8 | effectively **unbounded** | stego text is visually identical; survives NFC/NFKC |
| pdf | attachment / metadata / append | PDF to PDF | effectively **unbounded** | attachment is lossless; append rides after `%%EOF` |
| qr | Reed-Solomon error injection | UTF-8 text to PNG | tens of bytes (a 28-char cover holds **52**) | too small for an encrypted envelope; plaintext only |
Run `crypha capacity -i <cover>` for the exact number on your file. The envelope adds **14 bytes** of framing to a plaintext payload and about **68 bytes** to an encrypted one, which is precisely why a 52-byte QR envelope has no room left for encryption.
## The encrypted envelope
Whatever the carrier, every payload is packed into one versioned envelope before it is hidden. The carrier only ever stores opaque bytes; all crypto, compression, integrity, and versioning live here.
```
plaintext magic(4) ver(1) flags(1) │ len(4) body(N) │ crc32(4) +14 bytes
encrypted magic(4) ver(1) flags(1) cipher(1) params(9) salt(16) nonce(12) │ len(4) ciphertext+tag(N+16) │ crc32(4)
└──────────────── authenticated as AEAD associated data ───────┘ +68 bytes
```
The `params` field carries the Argon2id time, memory, and parallelism used, so `reveal` reproduces the exact key without you re-declaring the profile. Because the whole header up to the nonce is authenticated, tampering with the version, cipher choice, or KDF parameters fails `AEAD.Open` cleanly instead of decrypting to noise. An unknown version is rejected, not guessed. Compression runs before encryption, which is safe for an offline one-shot file tool: the CRIME/BREACH compression oracles need an adaptive network attacker, and there is none here.
## Honest limits
Steganography is a set of trade-offs, not magic. crypha is explicit about them.
- **QR holds tens of bytes.** The channel is the `floor((n - k) / 2)` correctable errors per Reed-Solomon block, which is real but small. An encrypted envelope does not fit, so QR is plaintext-only, and `capacity` says so.
- **Image and audio LSB are fragile.** They survive a byte-for-byte copy, not re-encoding. Re-save the PNG as JPEG, or the WAV as MP3, and the payload is gone. This is a property of LSB steganography, not a bug.
- **Zero-width text is easy to detect and strip.** It is invisible to a human reader, but trivially visible to any tool that looks for U+200B/U+2060. It is a teaching carrier for the technique, not a covert channel against a motivated inspector.
- **FLAC is WAV-primary.** A FLAC cover is decoded and re-emitted as WAV, because the only Go FLAC encoder emits strict-parser-incompatible frames; native FLAC output is deferred by design.
## Architecture
One engine, two frontends. The engine is the brain; cobra and bubbletea are thin, interchangeable faces over it, and neither holds any carrier logic.
```
cobra CLI ───┐
├──> engine ──> carrier registry ──> image audio qr text pdf
bubbletea TUI ──┘ │
└──> payload envelope (Argon2id · AEAD · flate · CRC32) ──> bitio
```
Each carrier is an isolated package implementing a single `Carrier` interface (`Hide`, `Reveal`, `Capacity`, `Sniff`) and self-registers through a blank import. `hide`, `reveal`, and `capacity` dispatch through the registry; auto-detect walks every carrier's `Sniff` and then confirms by validating the envelope it extracts, so a carrier that merely *could* read the bytes never shadows the one that actually owns them. If the TUI needs a value, it asks the engine for it (that is how the exact capacity meter works); it never reaches into a carrier.
## Build and Test
```bash
just build # -> ./dist/crypha (or: go build -o dist/crypha ./cmd/crypha)
just test # go test ./...
just test-race # the race detector across the suite
just lint # golangci-lint, pinned to a Go 1.25 toolchain
```
Coverage is table-driven per carrier. Every carrier round-trips text and random-binary payloads and checks the exact capacity boundary: a payload at the limit succeeds and one byte over fails cleanly. The crypto path is known-answer tested (encrypt-decrypt returns the plaintext, a flipped ciphertext byte fails to open, and the echoed Argon2id parameters reproduce the key). The QR carrier is differentially tested against `skip2/go-qrcode` (its clean matrix must match) and `gozxing` (the stego image must still scan to the cover), so an injection can never exceed the correctable budget without a test noticing.
## Project Structure
```
steganography-multi-tool/
├── cmd/crypha/ # tiny main: cli.Execute()
├── internal/
│ ├── cli/ # cobra: hide reveal capacity formats version tui + secure passphrase
│ ├── tui/ # bubbletea wizard, a pure view over the engine
│ ├── engine/ # the shared brain both frontends call
│ ├── carrier/ # Carrier interface + Register/Get/All/Detect registry
│ │ ├── all/ # blank-import aggregator that registers every carrier
│ │ └── image/ audio/ qr/ text/ pdf/
│ ├── payload/ # the encrypted envelope: Argon2id, AEAD, flate, CRC32, framing
│ ├── bitio/ # MSB-first BitReader / BitWriter
│ ├── config/ # every constant (magic, KDF params, format catalog)
│ └── report/ # human tables and --json rendering
├── learn/ # the teaching track (public)
├── install.sh # the one-shot curl-able installer
├── .goreleaser.yaml # cross-platform release binaries
└── justfile # every recipe
```
## Learn
This project ships a full teaching track. Read it in order, or jump to what you need.
| Doc | What it covers |
|-----|----------------|
| [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What the tool is, prerequisites, the project layout, and a quick tour |
| [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | Steganography vs cryptography, the LSB and zero-width channels, and steganalysis, grounded in real incidents |
| [`learn/02-ARCHITECTURE.md`](learn/02-ARCHITECTURE.md) | The one-engine-two-frontends design, the carrier registry, and the envelope format |
| [`learn/03-IMPLEMENTATION.md`](learn/03-IMPLEMENTATION.md) | A code walkthrough, with the QR-from-ISO-18004 Reed-Solomon injection as the showpiece |
| [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | Extension ideas, from a new carrier to 2-LSB density modes and stronger steganalysis resistance |
## License
[AGPL 3.0](LICENSE).

View File

@ -0,0 +1,14 @@
/*
©AngelaMos | 2026
main.go
Entry point for the crypha steganography multi-tool
*/
package main
import "github.com/CarterPerez-dev/crypha/internal/cli"
func main() {
cli.Execute()
}

View File

@ -0,0 +1,56 @@
module github.com/CarterPerez-dev/crypha
go 1.25.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-audio/audio v1.0.0
github.com/go-audio/wav v1.1.0
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/makiuchi-d/gozxing v0.1.1
github.com/mewkiz/flac v1.0.13
github.com/pdfcpu/pdfcpu v0.13.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
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
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/pkcs7 v0.2.2 // indirect
github.com/hhrutter/tiff v1.0.3 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

View File

@ -0,0 +1,106 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=
github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pdfcpu/pdfcpu v0.13.0 h1:7maI7K0w4pJsgX9u7eeCsK4+An/+xEVkJwAwyd7/n3M=
github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdBsvKCj4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@ -0,0 +1,260 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# install.sh
#
# One-shot installer for crypha. Takes a fresh machine to `crypha` runnable by
# its bare name, with zero further steps, whether run from a clone or piped from
# a domain via curl. Prefers a prebuilt release binary (no Go needed); falls
# back to building from source (auto-installs the Go toolchain if absent).
set -euo pipefail
# ============================================================================
# CONFIG
# ============================================================================
REPO_OWNER="CarterPerez-dev"
REPO_NAME="crypha"
BINARY="crypha"
TAGLINE="Hide an encrypted payload in an image, audio, QR, text, or PDF."
REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git"
INSTALL_DIR="${CRYPHA_INSTALL_DIR:-$HOME/.local/bin}"
DEFAULT_BRANCH="main"
GO_MIN="1.21"
PREBUILT=1
# ============================================================================
# Colors
# ============================================================================
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
YELLOW=$'\033[33m'; CYAN=$'\033[36m'; RESET=$'\033[0m'
else
BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
fi
info() { printf '%s\n' " ${CYAN}+${RESET} $*" >&2; }
ok() { printf '%s\n' " ${GREEN}+${RESET} $*" >&2; }
warn() { printf '%s\n' " ${YELLOW}!${RESET} $*" >&2; }
die() { printf '%s\n' " ${RED}x $*${RESET}" >&2; exit 1; }
header(){ printf '\n%s\n\n' "${BOLD}${CYAN}--- $* ---${RESET}" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
trap 'printf "%s\n" "${RED}x install failed${RESET}" >&2' ERR
TMP_DIR=""
cleanup() { [ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR"; return 0; }
trap cleanup EXIT
banner() {
printf '%s' "${CYAN}${BOLD}" >&2
cat >&2 <<'ART'
╭───────────────────────╮
│ c r y p h a │
╰───────────────────────╯
ART
printf '%s\n' "${RESET}" >&2
printf '%s\n' " ${DIM}${TAGLINE}${RESET}" >&2
}
# ============================================================================
# Privilege + package-manager fan
# ============================================================================
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if have sudo; then SUDO="sudo"; fi
fi
pkg_install() {
if have apt-get; then $SUDO apt-get update -y || warn "apt update had errors; continuing"
$SUDO apt-get install -y --no-install-recommends "$@"
elif have dnf; then $SUDO dnf install -y "$@"
elif have pacman; then $SUDO pacman -S --needed --noconfirm "$@"
elif have zypper; then $SUDO zypper install -y "$@"
elif have apk; then $SUDO apk add "$@"
elif have brew; then brew install "$@"
else die "no known package manager. Install manually: $*"; fi
}
download() {
if have curl; then curl -fsSL "$1" -o "$2" || return 1
elif have wget; then wget -qO "$2" "$1" || return 1
else die "need curl or wget"; fi
}
# ============================================================================
# Args
# ============================================================================
usage() {
cat >&2 <<USAGE
install.sh: install ${BINARY}
./install.sh [options]
curl -fsSL https://angelamos.com/${BINARY}/install.sh | bash
options:
--prefix DIR install dir (default: ${INSTALL_DIR})
-h, --help this help
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--prefix) INSTALL_DIR="$2"; shift 2 ;;
--prefix=*) INSTALL_DIR="${1#*=}"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done
# ============================================================================
# OS / arch
# ============================================================================
OS="$(uname -s)"; ARCH="$(uname -m)"
case "$OS" in
Linux) OS="linux" ;; Darwin) OS="darwin" ;;
MINGW*|MSYS*|CYGWIN*) die "Windows unsupported. Use WSL, or: go install github.com/${REPO_OWNER}/${REPO_NAME}/cmd/${BINARY}@latest" ;;
*) die "unsupported OS: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;;
*) die "unsupported arch: $ARCH" ;;
esac
# ============================================================================
# Bootstrap
# ============================================================================
resolve_repo() {
if [ -f "./go.mod" ]; then pwd; return; fi
have git || { warn "git missing, installing it"; pkg_install git; }
have git || die "could not install git; install it then re-run"
local cache="${XDG_CACHE_HOME:-$HOME/.cache}/${BINARY}"
if [ -d "$cache/.git" ]; then
info "updating cached clone at $cache"
git -C "$cache" pull --ff-only --quiet 2>/dev/null || warn "pull failed; using existing clone"
else
info "cloning ${REPO_URL}"
git clone --depth 1 --branch "$DEFAULT_BRANCH" --quiet "$REPO_URL" "$cache" \
|| die "clone failed from ${REPO_URL}"
fi
printf '%s\n' "$cache"
}
# ============================================================================
# Toolchain (Go) + build from source
# ============================================================================
install_go() {
info "installing a current Go toolchain"
local latest tgz
latest="$(download "https://go.dev/VERSION?m=text" /dev/stdout 2>/dev/null | head -n1)" || latest=""
case "$latest" in go*) ;; *) latest="go1.25.5" ;; esac
tgz="${latest}.${OS}-${ARCH}.tar.gz"
TMP_DIR="${TMP_DIR:-$(mktemp -d)}"
download "https://go.dev/dl/${tgz}" "$TMP_DIR/go.tgz" || die "failed to download ${tgz} from go.dev/dl"
rm -rf "$HOME/.local/go"
mkdir -p "$HOME/.local"
tar -C "$HOME/.local" -xzf "$TMP_DIR/go.tgz" || die "failed to extract Go"
export PATH="$HOME/.local/go/bin:$PATH"
export GOTOOLCHAIN=auto
have go || die "Go toolchain install failed"
ok "go $(go env GOVERSION 2>/dev/null | sed 's/^go//') at ~/.local/go"
}
need_toolchain() {
local cur
if have go; then
cur="$(go env GOVERSION 2>/dev/null | sed 's/^go//')"
if [ -n "$cur" ] && [ "$(printf '%s\n%s\n' "$GO_MIN" "$cur" | sort -V | head -n1)" = "$GO_MIN" ]; then
export GOTOOLCHAIN=auto
ok "go $cur (auto-toolchain fetches the go.mod-pinned version if newer)"
return
fi
warn "go ${cur:-unknown} predates toolchain auto-download; installing a current Go"
fi
install_go
}
build_from_source() {
info "building ${BINARY} (static, CGO-free binary; give it a minute)"
mkdir -p "$INSTALL_DIR"
GOBIN="$INSTALL_DIR" go install ./cmd/crypha || die "go install failed"
ok "installed ${BINARY} -> ${INSTALL_DIR}/${BINARY}"
}
try_prebuilt() {
[ "$PREBUILT" = "1" ] || return 1
local ver archive url
ver="$(download "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" /dev/stdout 2>/dev/null \
| grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')" || true
[ -n "$ver" ] || return 1
archive="${BINARY}_${ver#v}_${OS}_${ARCH}.tar.gz"
url="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${ver}/${archive}"
TMP_DIR="$(mktemp -d)"
download "$url" "$TMP_DIR/a.tgz" || { warn "no prebuilt for ${OS}/${ARCH}; will build from source"; return 1; }
tar -xzf "$TMP_DIR/a.tgz" -C "$TMP_DIR" || return 1
mkdir -p "$INSTALL_DIR"; install -m 0755 "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
ok "installed prebuilt ${ver} -> ${INSTALL_DIR}/${BINARY}"
}
# ============================================================================
# PATH wiring
# ============================================================================
wire_path() {
case ":$PATH:" in *":$INSTALL_DIR:"*) ok "$INSTALL_DIR already on PATH"; return ;; esac
local shell rc=""
shell="$(basename "${SHELL:-bash}")"
case "$shell" in
zsh) rc="$HOME/.zshrc" ;;
fish) mkdir -p "$HOME/.config/fish/conf.d"
echo "fish_add_path $INSTALL_DIR" > "$HOME/.config/fish/conf.d/${BINARY}.fish"
ok "added to fish conf.d" ;;
bash) rc="$HOME/.bashrc"; [ -f "$rc" ] || rc="$HOME/.bash_profile" ;;
*) rc="$HOME/.profile" ;;
esac
if [ -n "$rc" ] && ! grep -q "$INSTALL_DIR" "$rc" 2>/dev/null; then
printf '\nexport PATH="%s:$PATH"\n' "$INSTALL_DIR" >> "$rc"
ok "added $INSTALL_DIR to PATH in $rc"
fi
export PATH="$INSTALL_DIR:$PATH"
}
# ============================================================================
# Main
# ============================================================================
main() {
banner
have "$BINARY" && info "existing install at $(command -v "$BINARY"), updating"
REPO=""
if ! try_prebuilt; then
header "Building from source"
REPO="$(resolve_repo)"; cd "$REPO"
need_toolchain
build_from_source
fi
wire_path
header "Verify"
if have "$BINARY"; then
ok "$BINARY -> $(command -v "$BINARY")"
"$BINARY" version 2>/dev/null || true
else
warn "installed to $INSTALL_DIR but not yet on PATH; open a new shell"
fi
printf '\n%s\n\n' " ${GREEN}${BOLD}${BINARY} is ready.${RESET}" >&2
if have just && [ -n "$REPO" ] && [ -f "${REPO}/justfile" ]; then
printf '%s\n' " ${DIM}dev commands:${RESET} just" >&2
fi
cat >&2 <<FOOTER
${DIM}quick start:${RESET}
${CYAN}${BINARY}${RESET} launch the guided interactive wizard
${CYAN}${BINARY} hide --format image -i cover.png -o secret.png -m "..."${RESET}
${CYAN}${BINARY} reveal secret.png${RESET} auto-detect the carrier and extract
${CYAN}${BINARY} capacity -i cover.png${RESET} how many bytes a cover can hide
${CYAN}${BINARY} formats${RESET} list every carrier and its options
${DIM}docs: https://github.com/${REPO_OWNER}/${REPO_NAME}${RESET}
FOOTER
return 0
}
main "$@" </dev/null

View File

@ -0,0 +1,69 @@
/*
©AngelaMos | 2026
bitio.go
MSB-first bit reader and writer for carriers that embed data bit by bit
*/
package bitio
import "io"
type Reader struct {
in []byte
pos int
}
func NewReader(b []byte) *Reader {
return &Reader{in: b}
}
func (r *Reader) ReadBit() (byte, error) {
if r.pos >= len(r.in)*8 {
return 0, io.EOF
}
byteIdx := r.pos / 8
bitIdx := 7 - (r.pos % 8)
bit := (r.in[byteIdx] >> bitIdx) & 1
r.pos++
return bit, nil
}
func (r *Reader) TotalBits() int {
return len(r.in) * 8
}
func (r *Reader) Remaining() int {
return len(r.in)*8 - r.pos
}
type Writer struct {
out []byte
cur byte
fill int
}
func NewWriter() *Writer {
return &Writer{}
}
func (w *Writer) WriteBit(bit byte) {
w.cur = (w.cur << 1) | (bit & 1)
w.fill++
if w.fill == 8 {
w.out = append(w.out, w.cur)
w.cur = 0
w.fill = 0
}
}
func (w *Writer) Bytes() []byte {
if w.fill == 0 {
return w.out
}
return append(w.out, w.cur<<(8-w.fill))
}
func (w *Writer) BitsWritten() int {
return len(w.out)*8 + w.fill
}

View File

@ -0,0 +1,82 @@
/*
©AngelaMos | 2026
bitio_test.go
Round-trip and boundary tests for the MSB-first bit reader and writer
*/
package bitio
import (
"bytes"
"io"
"testing"
)
func TestRoundTripByteAligned(t *testing.T) {
cases := [][]byte{
{},
{0x00},
{0xFF},
{0xA5, 0x5A, 0x00, 0xFF, 0x0F, 0xF0},
[]byte("crypha carries the secret"),
}
for _, in := range cases {
r := NewReader(in)
w := NewWriter()
for {
bit, err := r.ReadBit()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("ReadBit: %v", err)
}
w.WriteBit(bit)
}
if got := w.Bytes(); !bytes.Equal(got, in) {
t.Errorf("round-trip mismatch: got %x want %x", got, in)
}
}
}
func TestMSBFirstOrder(t *testing.T) {
r := NewReader([]byte{0b10000001})
want := []byte{1, 0, 0, 0, 0, 0, 0, 1}
for i, wbit := range want {
got, err := r.ReadBit()
if err != nil {
t.Fatalf("bit %d: %v", i, err)
}
if got != wbit {
t.Errorf("bit %d: got %d want %d", i, got, wbit)
}
}
}
func TestReaderCounters(t *testing.T) {
r := NewReader([]byte{0x00, 0x00})
if r.TotalBits() != 16 {
t.Fatalf("TotalBits: got %d want 16", r.TotalBits())
}
if _, err := r.ReadBit(); err != nil {
t.Fatal(err)
}
if r.Remaining() != 15 {
t.Errorf("Remaining: got %d want 15", r.Remaining())
}
}
func TestWriterPadsPartialByte(t *testing.T) {
w := NewWriter()
w.WriteBit(1)
w.WriteBit(1)
w.WriteBit(1)
if w.BitsWritten() != 3 {
t.Fatalf("BitsWritten: got %d want 3", w.BitsWritten())
}
got := w.Bytes()
if len(got) != 1 || got[0] != 0b11100000 {
t.Errorf("partial byte: got %08b want 11100000", got[0])
}
}

View File

@ -0,0 +1,16 @@
/*
©AngelaMos | 2026
all.go
Side-effect imports that register every carrier implementation into the registry
*/
package all
import (
_ "github.com/CarterPerez-dev/crypha/internal/carrier/audio"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/image"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/pdf"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/qr"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/text"
)

View File

@ -0,0 +1,285 @@
/*
©AngelaMos | 2026
audio.go
LSB audio carrier for 16-bit PCM WAV covers, accepting FLAC covers decoded to WAV
*/
package audio
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/CarterPerez-dev/crypha/internal/bitio"
"github.com/CarterPerez-dev/crypha/internal/carrier"
goaudio "github.com/go-audio/audio"
"github.com/go-audio/wav"
"github.com/mewkiz/flac"
)
const (
Format = "audio"
bitsPerByte = 8
lengthPrefixBytes = 4
lengthPrefixBits = lengthPrefixBytes * bitsPerByte
supportedBitDepth = 16
wavFormatPCM = 1
riffTagBytes = 4
riffSizeBytes = 4
waveTagOffset = riffTagBytes + riffSizeBytes
waveTagBytes = 4
sniffHeaderBytes = waveTagOffset + waveTagBytes
)
var (
riffTag = []byte("RIFF")
waveTag = []byte("WAVE")
flacTag = []byte("fLaC")
)
var (
ErrEmptyPayload = errors.New("crypha/audio: empty payload")
ErrUnsupportedFormat = errors.New("crypha/audio: cover must be a 16-bit PCM WAV or a FLAC file")
ErrUnsupportedBitDepth = errors.New("crypha/audio: audio must be 16-bit, provide 16-bit PCM WAV or 16-bit FLAC")
ErrNotPCM = errors.New("crypha/audio: WAV must be uncompressed PCM")
ErrNoSamples = errors.New("crypha/audio: cover contains no audio samples")
ErrPayloadTooLarge = errors.New("crypha/audio: payload exceeds carrier capacity")
ErrTooSmall = errors.New("crypha/audio: audio is too small to contain a payload")
ErrNoPayload = errors.New("crypha/audio: no crypha payload found")
)
type pcm struct {
samples []int
numChannels int
sampleRate int
}
type audioCarrier struct{}
func init() {
carrier.Register(audioCarrier{})
}
func (audioCarrier) Format() string {
return Format
}
func (audioCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
if len(payload) == 0 {
return ErrEmptyPayload
}
data, err := io.ReadAll(cover)
if err != nil {
return fmt.Errorf("crypha/audio: read cover: %w", err)
}
src, err := decodeCover(data)
if err != nil {
return err
}
framed := frame(payload)
needBits := len(framed) * bitsPerByte
if needBits > len(src.samples) {
return fmt.Errorf("%w: need %d bytes, capacity is %d", ErrPayloadTooLarge, len(payload), capacityFromSlots(len(src.samples)))
}
reader := bitio.NewReader(framed)
for slot := 0; slot < needBits; slot++ {
bit, rerr := reader.ReadBit()
if rerr != nil {
return rerr
}
src.samples[slot] = (src.samples[slot] &^ 1) | int(bit)
}
return encodeWAV(out, src)
}
func (audioCarrier) Reveal(stego io.Reader) ([]byte, error) {
data, err := io.ReadAll(stego)
if err != nil {
return nil, fmt.Errorf("crypha/audio: read stego: %w", err)
}
if !isWAV(data) {
return nil, ErrUnsupportedFormat
}
src, err := decodeWAV(data)
if err != nil {
return nil, err
}
slots := len(src.samples)
if slots < lengthPrefixBits {
return nil, ErrTooSmall
}
length := binary.BigEndian.Uint32(readBits(src.samples, 0, lengthPrefixBits))
maxPayload := capacityFromSlots(slots)
if length == 0 || uint64(length) > uint64(maxPayload) {
return nil, ErrNoPayload
}
payloadBits := int(length) * bitsPerByte
return readBits(src.samples, lengthPrefixBits, payloadBits), nil
}
func (audioCarrier) Capacity(cover io.Reader) (int, error) {
data, err := io.ReadAll(cover)
if err != nil {
return 0, fmt.Errorf("crypha/audio: read cover: %w", err)
}
src, err := decodeCover(data)
if err != nil {
return 0, err
}
return capacityFromSlots(len(src.samples)), nil
}
func (audioCarrier) Sniff(stego io.ReadSeeker) bool {
head := make([]byte, sniffHeaderBytes)
if _, err := io.ReadFull(stego, head); err != nil {
return false
}
return isWAV(head)
}
func decodeCover(data []byte) (pcm, error) {
switch {
case isWAV(data):
return decodeWAV(data)
case hasPrefix(data, flacTag):
return decodeFLAC(data)
default:
return pcm{}, ErrUnsupportedFormat
}
}
func decodeWAV(data []byte) (pcm, error) {
dec := wav.NewDecoder(bytes.NewReader(data))
dec.ReadInfo()
if err := dec.Err(); err != nil {
return pcm{}, fmt.Errorf("crypha/audio: decode wav: %w", err)
}
if dec.WavAudioFormat != wavFormatPCM {
return pcm{}, ErrNotPCM
}
if dec.BitDepth != supportedBitDepth {
return pcm{}, ErrUnsupportedBitDepth
}
buf, err := dec.FullPCMBuffer()
if err != nil {
return pcm{}, fmt.Errorf("crypha/audio: read wav samples: %w", err)
}
if len(buf.Data) == 0 {
return pcm{}, ErrNoSamples
}
return pcm{
samples: buf.Data,
numChannels: buf.Format.NumChannels,
sampleRate: buf.Format.SampleRate,
}, nil
}
func decodeFLAC(data []byte) (pcm, error) {
stream, err := flac.New(bytes.NewReader(data))
if err != nil {
return pcm{}, fmt.Errorf("crypha/audio: decode flac: %w", err)
}
defer func() { _ = stream.Close() }()
if stream.Info.BitsPerSample != supportedBitDepth {
return pcm{}, ErrUnsupportedBitDepth
}
numChannels := int(stream.Info.NChannels)
samples := make([]int, 0, int(stream.Info.NSamples)*numChannels)
for {
f, ferr := stream.ParseNext()
if ferr == io.EOF {
break
}
if ferr != nil {
return pcm{}, fmt.Errorf("crypha/audio: read flac frame: %w", ferr)
}
if len(f.Subframes) == 0 {
continue
}
block := len(f.Subframes[0].Samples)
for i := 0; i < block; i++ {
for _, sub := range f.Subframes {
samples = append(samples, int(sub.Samples[i]))
}
}
}
if len(samples) == 0 {
return pcm{}, ErrNoSamples
}
return pcm{
samples: samples,
numChannels: numChannels,
sampleRate: int(stream.Info.SampleRate),
}, nil
}
func encodeWAV(out io.Writer, src pcm) error {
ws := &memWriteSeeker{}
enc := wav.NewEncoder(ws, src.sampleRate, supportedBitDepth, src.numChannels, wavFormatPCM)
buf := &goaudio.IntBuffer{
Format: &goaudio.Format{NumChannels: src.numChannels, SampleRate: src.sampleRate},
Data: src.samples,
SourceBitDepth: supportedBitDepth,
}
if err := enc.Write(buf); err != nil {
return fmt.Errorf("crypha/audio: encode wav: %w", err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("crypha/audio: finalize wav: %w", err)
}
_, err := out.Write(ws.buf)
return err
}
func capacityFromSlots(slots int) int {
usable := slots - lengthPrefixBits
if usable < 0 {
return 0
}
return usable / bitsPerByte
}
func frame(payload []byte) []byte {
framed := make([]byte, lengthPrefixBytes+len(payload))
binary.BigEndian.PutUint32(framed, uint32(len(payload)))
copy(framed[lengthPrefixBytes:], payload)
return framed
}
func readBits(samples []int, startSlot, count int) []byte {
writer := bitio.NewWriter()
for slot := startSlot; slot < startSlot+count; slot++ {
writer.WriteBit(byte(samples[slot] & 1))
}
return writer.Bytes()
}
func isWAV(data []byte) bool {
return len(data) >= sniffHeaderBytes &&
hasPrefix(data, riffTag) &&
bytes.Equal(data[waveTagOffset:waveTagOffset+waveTagBytes], waveTag)
}
func hasPrefix(data, prefix []byte) bool {
return len(data) >= len(prefix) && bytes.Equal(data[:len(prefix)], prefix)
}

View File

@ -0,0 +1,493 @@
/*
©AngelaMos | 2026
audio_test.go
Round-trip, FLAC-in, rejection, capacity, and sniff tests for the LSB audio carrier
*/
package audio
import (
"bytes"
"encoding/binary"
"errors"
"io"
"testing"
"github.com/CarterPerez-dev/crypha/internal/carrier"
"github.com/CarterPerez-dev/crypha/internal/payload"
goaudio "github.com/go-audio/audio"
"github.com/go-audio/wav"
"github.com/mewkiz/flac"
flacframe "github.com/mewkiz/flac/frame"
"github.com/mewkiz/flac/meta"
)
const (
sampleRate = 44100
coverSlots = 1000
coverBytes = (coverSlots - lengthPrefixBits) / bitsPerByte
roomySlots = 8192
rejectDepth = 8
minFLACBlock = 16
)
func pseudoSamples(n, seed int) []int {
out := make([]int, n)
x := uint32(seed)*2654435761 + 1
for i := range out {
x = x*1664525 + 1013904223
out[i] = int(int16(x >> 16))
}
return out
}
func synthWAV(t *testing.T, samples []int, chans int) []byte {
t.Helper()
var buf bytes.Buffer
if err := encodeWAV(&buf, pcm{samples: samples, numChannels: chans, sampleRate: sampleRate}); err != nil {
t.Fatalf("synth wav: %v", err)
}
return buf.Bytes()
}
func flacChannels(chans int) flacframe.Channels {
if chans == 1 {
return flacframe.ChannelsMono
}
return flacframe.ChannelsLR
}
func synthFLAC(t *testing.T, samples []int, chans, rate, bps int) []byte {
t.Helper()
perChan := len(samples) / chans
declaredBlock := perChan
if declaredBlock < minFLACBlock {
declaredBlock = minFLACBlock
}
info := &meta.StreamInfo{
SampleRate: uint32(rate),
NChannels: uint8(chans),
BitsPerSample: uint8(bps),
NSamples: uint64(perChan),
BlockSizeMin: uint16(declaredBlock),
BlockSizeMax: uint16(declaredBlock),
}
var out bytes.Buffer
enc, err := flac.NewEncoder(&out, info)
if err != nil {
t.Fatalf("flac new encoder: %v", err)
}
enc.EnablePredictionAnalysis(false)
if perChan > 0 {
subs := make([]*flacframe.Subframe, chans)
for ch := 0; ch < chans; ch++ {
s := make([]int32, perChan)
for i := 0; i < perChan; i++ {
s[i] = int32(samples[i*chans+ch])
}
subs[ch] = &flacframe.Subframe{
SubHeader: flacframe.SubHeader{Pred: flacframe.PredVerbatim},
Samples: s,
NSamples: perChan,
}
}
f := &flacframe.Frame{
Header: flacframe.Header{
HasFixedBlockSize: true,
BlockSize: uint16(perChan),
SampleRate: uint32(rate),
Channels: flacChannels(chans),
BitsPerSample: uint8(bps),
},
Subframes: subs,
}
if err := enc.WriteFrame(f); err != nil {
t.Fatalf("flac write frame: %v", err)
}
}
if err := enc.Close(); err != nil {
t.Fatalf("flac close: %v", err)
}
return out.Bytes()
}
func hideReveal(t *testing.T, cover, secret []byte) []byte {
t.Helper()
var stego bytes.Buffer
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), secret, &stego); err != nil {
t.Fatalf("Hide: %v", err)
}
got, err := (audioCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
return got
}
func TestRoundTripWAV(t *testing.T) {
cases := []struct {
name string
chans int
payload []byte
}{
{"mono single byte", 1, []byte{0x42}},
{"mono text", 1, []byte("crypha audio")},
{"stereo text", 2, []byte("left and right channels")},
{"high bits set", 1, bytes.Repeat([]byte{0xFF}, 40)},
{"binary blob", 2, pseudoBytes(300, 9)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cover := synthWAV(t, pseudoSamples(roomySlots, 1), tc.chans)
got := hideReveal(t, cover, tc.payload)
if !bytes.Equal(got, tc.payload) {
t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload)
}
})
}
}
func pseudoBytes(n, seed int) []byte {
b := make([]byte, n)
x := uint32(seed)*2654435761 + 1
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
func TestFLACInWAVOut(t *testing.T) {
cases := []struct {
name string
chans int
payload []byte
}{
{"mono", 1, []byte("decoded from flac, embedded, emitted as wav")},
{"stereo", 2, pseudoBytes(200, 3)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cover := synthFLAC(t, pseudoSamples(roomySlots, 2), tc.chans, sampleRate, supportedBitDepth)
var stego bytes.Buffer
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), tc.payload, &stego); err != nil {
t.Fatalf("Hide flac cover: %v", err)
}
if !isWAV(stego.Bytes()) {
t.Fatal("flac cover did not produce a WAV stego output")
}
got, err := (audioCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
if !bytes.Equal(got, tc.payload) {
t.Fatalf("flac-in round-trip mismatch: got %x want %x", got, tc.payload)
}
})
}
}
func TestCapacityBoundary(t *testing.T) {
cover := synthWAV(t, pseudoSamples(coverSlots, 5), 1)
atCap := bytes.Repeat([]byte{0x01}, coverBytes)
if got := hideReveal(t, cover, atCap); !bytes.Equal(got, atCap) {
t.Fatal("payload at exact capacity failed to round-trip")
}
overCap := bytes.Repeat([]byte{0x01}, coverBytes+1)
err := (audioCarrier{}).Hide(bytes.NewReader(cover), overCap, &bytes.Buffer{})
if err == nil {
t.Fatal("expected capacity error for oversized payload")
}
}
func TestCapacityReport(t *testing.T) {
wavCover := synthWAV(t, pseudoSamples(coverSlots, 7), 1)
got, err := (audioCarrier{}).Capacity(bytes.NewReader(wavCover))
if err != nil {
t.Fatalf("Capacity wav: %v", err)
}
if got != coverBytes {
t.Fatalf("Capacity wav: got %d want %d", got, coverBytes)
}
flacCover := synthFLAC(t, pseudoSamples(coverSlots*2, 8), 2, sampleRate, supportedBitDepth)
gotFlac, err := (audioCarrier{}).Capacity(bytes.NewReader(flacCover))
if err != nil {
t.Fatalf("Capacity flac: %v", err)
}
if want := capacityFromSlots(coverSlots * 2); gotFlac != want {
t.Fatalf("Capacity flac: got %d want %d", gotFlac, want)
}
}
func synthWAVCustom(t *testing.T, samples []int, chans, bitDepth, audioFormat int) []byte {
t.Helper()
ws := &memWriteSeeker{}
enc := wav.NewEncoder(ws, sampleRate, bitDepth, chans, audioFormat)
buf := &goaudio.IntBuffer{
Format: &goaudio.Format{NumChannels: chans, SampleRate: sampleRate},
Data: samples,
SourceBitDepth: bitDepth,
}
if err := enc.Write(buf); err != nil {
t.Fatalf("synth custom wav write: %v", err)
}
if err := enc.Close(); err != nil {
t.Fatalf("synth custom wav close: %v", err)
}
return ws.buf
}
func TestHideRejectsNonPCMWAV(t *testing.T) {
cover := synthWAVCustom(t, pseudoSamples(64, 1), 1, supportedBitDepth, 3)
err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
if err != ErrNotPCM {
t.Fatalf("expected ErrNotPCM, got %v", err)
}
}
func TestHideRejectsWrongBitDepthWAV(t *testing.T) {
cover := synthWAVCustom(t, pseudoSamples(64, 1), 1, rejectDepth, wavFormatPCM)
err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
if err != ErrUnsupportedBitDepth {
t.Fatalf("expected ErrUnsupportedBitDepth, got %v", err)
}
}
func TestHideRejectsWrongBitDepthFLAC(t *testing.T) {
cover := synthFLAC(t, pseudoSamples(64, 4), 1, sampleRate, rejectDepth)
err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
if err != ErrUnsupportedBitDepth {
t.Fatalf("expected ErrUnsupportedBitDepth, got %v", err)
}
}
func TestHideRejectsUnsupportedFormat(t *testing.T) {
garbage := []byte("this is not audio at all, just prose")
if err := (audioCarrier{}).Hide(bytes.NewReader(garbage), []byte("x"), &bytes.Buffer{}); err != ErrUnsupportedFormat {
t.Fatalf("Hide garbage: got %v want ErrUnsupportedFormat", err)
}
if _, err := (audioCarrier{}).Capacity(bytes.NewReader(garbage)); err != ErrUnsupportedFormat {
t.Fatalf("Capacity garbage: got %v want ErrUnsupportedFormat", err)
}
}
func TestHideEmptyPayloadRejected(t *testing.T) {
cover := synthWAV(t, pseudoSamples(coverSlots, 1), 1)
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{}); err != ErrEmptyPayload {
t.Fatalf("expected ErrEmptyPayload, got %v", err)
}
}
func TestRevealRejectsNonWAV(t *testing.T) {
flacData := synthFLAC(t, pseudoSamples(64, 2), 1, sampleRate, supportedBitDepth)
if _, err := (audioCarrier{}).Reveal(bytes.NewReader(flacData)); err != ErrUnsupportedFormat {
t.Fatalf("Reveal flac: got %v want ErrUnsupportedFormat", err)
}
if _, err := (audioCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err != ErrUnsupportedFormat {
t.Fatalf("Reveal garbage: got %v want ErrUnsupportedFormat", err)
}
}
func TestRevealRejectsUndecodableWAV(t *testing.T) {
cover := synthWAVCustom(t, pseudoSamples(64, 1), 1, supportedBitDepth, 3)
if _, err := (audioCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrNotPCM {
t.Fatalf("Reveal float WAV: got %v want ErrNotPCM", err)
}
}
func TestDecodeWAVTruncatedFmt(t *testing.T) {
var b bytes.Buffer
b.WriteString("RIFF")
_ = binary.Write(&b, binary.LittleEndian, uint32(0xFFFFFFFF))
b.WriteString("WAVE")
b.WriteString("fmt ")
_ = binary.Write(&b, binary.LittleEndian, uint32(16))
if err := (audioCarrier{}).Hide(bytes.NewReader(b.Bytes()), []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("expected decode error on a truncated fmt chunk")
}
}
func TestRevealNoPayload(t *testing.T) {
clean := make([]int, coverSlots)
cover := synthWAV(t, clean, 1)
if _, err := (audioCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrNoPayload {
t.Fatalf("expected ErrNoPayload on zeroed cover, got %v", err)
}
}
func TestRevealTooSmall(t *testing.T) {
cover := synthWAV(t, pseudoSamples(16, 1), 1)
if _, err := (audioCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrTooSmall {
t.Fatalf("expected ErrTooSmall, got %v", err)
}
}
func TestSniff(t *testing.T) {
wavCover := synthWAV(t, pseudoSamples(coverSlots, 1), 1)
flacCover := synthFLAC(t, pseudoSamples(64, 1), 1, sampleRate, supportedBitDepth)
cases := []struct {
name string
data []byte
want bool
}{
{"wav", wavCover, true},
{"flac not stego", flacCover, false},
{"random", []byte("not audio, definitely"), false},
{"short", []byte("RIFF"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := (audioCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want {
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
}
})
}
}
func TestEncryptedEnvelopeThroughCarrier(t *testing.T) {
secret := []byte("the drop is behind the third locker")
envelope, err := payload.Pack(secret, payload.Options{
Passphrase: []byte("correct horse battery staple"),
Compress: true,
Cipher: payload.CipherChaCha20,
Strength: payload.StrengthDefault,
})
if err != nil {
t.Fatalf("Pack: %v", err)
}
cover := synthWAV(t, pseudoSamples(roomySlots, 4), 2)
var stego bytes.Buffer
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), envelope, &stego); err != nil {
t.Fatalf("Hide envelope: %v", err)
}
recovered, err := (audioCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal envelope: %v", err)
}
if !bytes.Equal(recovered, envelope) {
t.Fatal("carrier did not return the exact envelope bytes")
}
plain, err := payload.Unpack(recovered, []byte("correct horse battery staple"))
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if !bytes.Equal(plain, secret) {
t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret)
}
}
type errReader struct{}
func (errReader) Read([]byte) (int, error) {
return 0, errors.New("forced read failure")
}
func TestReadErrorsPropagate(t *testing.T) {
if err := (audioCarrier{}).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("Hide: expected read error")
}
if _, err := (audioCarrier{}).Reveal(errReader{}); err == nil {
t.Fatal("Reveal: expected read error")
}
if _, err := (audioCarrier{}).Capacity(errReader{}); err == nil {
t.Fatal("Capacity: expected read error")
}
}
func TestDecodeWAVZeroSamples(t *testing.T) {
cover := synthWAV(t, nil, 1)
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}); err != ErrNoSamples {
t.Fatalf("expected ErrNoSamples on empty WAV, got %v", err)
}
}
func TestDecodeWAVMalformedHeader(t *testing.T) {
cover := []byte("RIFF\x04\x00\x00\x00WAVE")
err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
if err == nil {
t.Fatal("expected decode error on a RIFF/WAVE file with no fmt chunk")
}
}
func TestDecodeFLACZeroFrames(t *testing.T) {
cover := synthFLAC(t, nil, 1, sampleRate, supportedBitDepth)
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}); err != ErrNoSamples {
t.Fatalf("expected ErrNoSamples on frameless FLAC, got %v", err)
}
}
func TestDecodeFLACGarbage(t *testing.T) {
cover := append([]byte("fLaC"), pseudoBytes(64, 11)...)
if err := (audioCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("expected decode error on fLaC-tagged garbage")
}
}
func TestDecodeFLACTruncatedFrame(t *testing.T) {
full := synthFLAC(t, pseudoSamples(roomySlots, 6), 1, sampleRate, supportedBitDepth)
truncated := full[:len(full)-16]
if err := (audioCarrier{}).Hide(bytes.NewReader(truncated), []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("expected decode error on a FLAC truncated mid-frame")
}
}
func TestCapacityTinyCoverIsZero(t *testing.T) {
cover := synthWAV(t, pseudoSamples(16, 1), 1)
got, err := (audioCarrier{}).Capacity(bytes.NewReader(cover))
if err != nil {
t.Fatalf("Capacity tiny: %v", err)
}
if got != 0 {
t.Fatalf("Capacity tiny: got %d want 0", got)
}
}
func TestMemWriteSeeker(t *testing.T) {
m := &memWriteSeeker{}
if _, err := m.Write([]byte("hello world")); err != nil {
t.Fatalf("Write: %v", err)
}
if pos, err := m.Seek(6, io.SeekStart); err != nil || pos != 6 {
t.Fatalf("SeekStart: pos=%d err=%v", pos, err)
}
if pos, err := m.Seek(2, io.SeekCurrent); err != nil || pos != 8 {
t.Fatalf("SeekCurrent: pos=%d err=%v", pos, err)
}
if pos, err := m.Seek(-5, io.SeekEnd); err != nil || pos != 6 {
t.Fatalf("SeekEnd: pos=%d err=%v", pos, err)
}
if _, err := m.Seek(-1, io.SeekStart); err != errNegativeSeek {
t.Fatalf("negative seek: got %v want errNegativeSeek", err)
}
if _, err := m.Seek(0, 99); err != errInvalidWhence {
t.Fatalf("bad whence: got %v want errInvalidWhence", err)
}
if _, err := m.Seek(0, io.SeekStart); err != nil {
t.Fatalf("reset seek: %v", err)
}
if _, err := m.Write([]byte("HELLO")); err != nil {
t.Fatalf("overwrite: %v", err)
}
if !bytes.Equal(m.buf, []byte("HELLO world")) {
t.Fatalf("overwrite mismatch: %q", m.buf)
}
}
func TestRegisteredInRegistry(t *testing.T) {
c, ok := carrier.Get(Format)
if !ok {
t.Fatal("audio carrier did not self-register")
}
if c.Format() != Format {
t.Fatalf("registry returned wrong carrier: %s", c.Format())
}
}

View File

@ -0,0 +1,54 @@
/*
©AngelaMos | 2026
writeseeker.go
In-memory io.WriteSeeker so the WAV encoder can seek back and patch chunk sizes
*/
package audio
import (
"errors"
"io"
)
var (
errNegativeSeek = errors.New("crypha/audio: negative seek position")
errInvalidWhence = errors.New("crypha/audio: invalid seek whence")
)
type memWriteSeeker struct {
buf []byte
pos int64
}
func (m *memWriteSeeker) Write(p []byte) (int, error) {
end := m.pos + int64(len(p))
if end > int64(len(m.buf)) {
grown := make([]byte, end)
copy(grown, m.buf)
m.buf = grown
}
copy(m.buf[m.pos:end], p)
m.pos = end
return len(p), nil
}
func (m *memWriteSeeker) Seek(offset int64, whence int) (int64, error) {
var next int64
switch whence {
case io.SeekStart:
next = offset
case io.SeekCurrent:
next = m.pos + offset
case io.SeekEnd:
next = int64(len(m.buf)) + offset
default:
return 0, errInvalidWhence
}
if next < 0 {
return 0, errNegativeSeek
}
m.pos = next
return next, nil
}

View File

@ -0,0 +1,67 @@
/*
©AngelaMos | 2026
carrier.go
The Carrier interface and self-registering registry that every format plugs into
*/
package carrier
import (
"io"
"sort"
)
type Carrier interface {
Format() string
Hide(cover io.Reader, payload []byte, out io.Writer) error
Reveal(stego io.Reader) ([]byte, error)
Capacity(cover io.Reader) (int, error)
Sniff(stego io.ReadSeeker) bool
}
var registry = map[string]Carrier{}
func Register(c Carrier) {
registry[c.Format()] = c
}
func Get(name string) (Carrier, bool) {
c, ok := registry[name]
return c, ok
}
func All() []Carrier {
out := make([]Carrier, 0, len(registry))
for _, c := range registry {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Format() < out[j].Format()
})
return out
}
func Formats() []string {
out := make([]string, 0, len(registry))
for name := range registry {
out = append(out, name)
}
sort.Strings(out)
return out
}
func Detect(stego io.ReadSeeker) (Carrier, bool) {
for _, c := range All() {
if _, err := stego.Seek(0, io.SeekStart); err != nil {
return nil, false
}
if c.Sniff(stego) {
if _, err := stego.Seek(0, io.SeekStart); err != nil {
return nil, false
}
return c, true
}
}
return nil, false
}

View File

@ -0,0 +1,73 @@
/*
©AngelaMos | 2026
carrier_test.go
Registry behaviour tests using fake carriers
*/
package carrier
import (
"bytes"
"io"
"testing"
)
type fakeCarrier struct {
format string
magic byte
}
func (f fakeCarrier) Format() string { return f.format }
func (f fakeCarrier) Hide(_ io.Reader, _ []byte, _ io.Writer) error { return nil }
func (f fakeCarrier) Reveal(_ io.Reader) ([]byte, error) { return nil, nil }
func (f fakeCarrier) Capacity(_ io.Reader) (int, error) { return 0, nil }
func (f fakeCarrier) Sniff(stego io.ReadSeeker) bool {
head := make([]byte, 1)
if _, err := io.ReadFull(stego, head); err != nil {
return false
}
return head[0] == f.magic
}
func TestRegisterGetFormats(t *testing.T) {
registry = map[string]Carrier{}
Register(fakeCarrier{format: "zeta", magic: 0x01})
Register(fakeCarrier{format: "alpha", magic: 0x02})
if _, ok := Get("alpha"); !ok {
t.Fatal("expected alpha to be registered")
}
if _, ok := Get("missing"); ok {
t.Fatal("did not expect missing to resolve")
}
got := Formats()
if len(got) != 2 || got[0] != "alpha" || got[1] != "zeta" {
t.Errorf("Formats not sorted: %v", got)
}
if all := All(); len(all) != 2 || all[0].Format() != "alpha" {
t.Errorf("All not sorted: %v", all)
}
}
func TestDetect(t *testing.T) {
registry = map[string]Carrier{}
Register(fakeCarrier{format: "alpha", magic: 0x02})
Register(fakeCarrier{format: "beta", magic: 0x03})
stego := bytes.NewReader([]byte{0x03, 0xFF, 0xEE})
c, ok := Detect(stego)
if !ok || c.Format() != "beta" {
t.Fatalf("Detect: got %v ok=%v want beta", c, ok)
}
none := bytes.NewReader([]byte{0x99})
if _, ok := Detect(none); ok {
t.Error("expected no match for unknown magic")
}
}

View File

@ -0,0 +1,218 @@
/*
©AngelaMos | 2026
image.go
LSB image carrier for PNG and 24-bit BMP covers via the mandatory NRGBA path
*/
package image
import (
"encoding/binary"
"errors"
"fmt"
stdimage "image"
"image/draw"
"image/png"
"io"
"github.com/CarterPerez-dev/crypha/internal/bitio"
"github.com/CarterPerez-dev/crypha/internal/carrier"
xbmp "golang.org/x/image/bmp"
)
const (
Format = "image"
formatPNG = "png"
formatBMP = "bmp"
bytesPerPixel = 4
channelsPerPixel = 3
bitsPerByte = 8
lengthPrefixBytes = 4
lengthPrefixBits = lengthPrefixBytes * bitsPerByte
)
var (
pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
bmpSignature = []byte{'B', 'M'}
)
var (
ErrEmptyPayload = errors.New("crypha/image: empty payload")
ErrUnsupportedFormat = errors.New("crypha/image: cover must be a PNG or 24-bit BMP")
ErrPaletted = errors.New("crypha/image: paletted images are not supported, provide a truecolor PNG or 24-bit BMP")
Err16Bit = errors.New("crypha/image: 16-bit images are not supported, provide an 8-bit truecolor image")
ErrPayloadTooLarge = errors.New("crypha/image: payload exceeds carrier capacity")
ErrTooSmall = errors.New("crypha/image: image is too small to contain a payload")
ErrNoPayload = errors.New("crypha/image: no crypha payload found")
)
type imageCarrier struct{}
func init() {
carrier.Register(imageCarrier{})
}
func (imageCarrier) Format() string {
return Format
}
func (imageCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
if len(payload) == 0 {
return ErrEmptyPayload
}
src, format, err := stdimage.Decode(cover)
if err != nil {
return fmt.Errorf("crypha/image: decode cover: %w", err)
}
if format != formatPNG && format != formatBMP {
return ErrUnsupportedFormat
}
if err := rejectLossy(src); err != nil {
return err
}
dst := toNRGBA(src)
slots := channelSlots(dst)
framed := frame(payload)
needBits := len(framed) * bitsPerByte
if needBits > slots {
return fmt.Errorf("%w: need %d bytes, capacity is %d", ErrPayloadTooLarge, len(payload), capacityFromSlots(slots))
}
reader := bitio.NewReader(framed)
for slot := 0; slot < needBits; slot++ {
bit, rerr := reader.ReadBit()
if rerr != nil {
return rerr
}
off := pixOffset(slot)
dst.Pix[off] = (dst.Pix[off] &^ 1) | bit
}
switch format {
case formatPNG:
return png.Encode(out, dst)
default:
return xbmp.Encode(out, dst)
}
}
func (imageCarrier) Reveal(stego io.Reader) ([]byte, error) {
src, format, err := stdimage.Decode(stego)
if err != nil {
return nil, fmt.Errorf("crypha/image: decode stego: %w", err)
}
if format != formatPNG && format != formatBMP {
return nil, ErrUnsupportedFormat
}
if err := rejectLossy(src); err != nil {
return nil, err
}
img := toNRGBA(src)
slots := channelSlots(img)
if slots < lengthPrefixBits {
return nil, ErrTooSmall
}
length := binary.BigEndian.Uint32(readBits(img, 0, lengthPrefixBits))
maxPayload := capacityFromSlots(slots)
if length == 0 || uint64(length) > uint64(maxPayload) {
return nil, ErrNoPayload
}
payloadBits := int(length) * bitsPerByte
return readBits(img, lengthPrefixBits, payloadBits), nil
}
func (imageCarrier) Capacity(cover io.Reader) (int, error) {
cfg, format, err := stdimage.DecodeConfig(cover)
if err != nil {
return 0, fmt.Errorf("crypha/image: decode cover config: %w", err)
}
if format != formatPNG && format != formatBMP {
return 0, ErrUnsupportedFormat
}
slots := cfg.Width * cfg.Height * channelsPerPixel
return capacityFromSlots(slots), nil
}
func (imageCarrier) Sniff(stego io.ReadSeeker) bool {
head := make([]byte, len(pngSignature))
if _, err := io.ReadFull(stego, head); err != nil {
return false
}
if bytesHavePrefix(head, pngSignature) {
return true
}
return bytesHavePrefix(head, bmpSignature)
}
func rejectLossy(src stdimage.Image) error {
switch src.(type) {
case *stdimage.Paletted:
return ErrPaletted
case *stdimage.RGBA64, *stdimage.NRGBA64, *stdimage.Gray16, *stdimage.CMYK:
return Err16Bit
default:
return nil
}
}
func toNRGBA(src stdimage.Image) *stdimage.NRGBA {
if n, ok := src.(*stdimage.NRGBA); ok && n.Rect.Min == (stdimage.Point{}) {
return n
}
b := src.Bounds()
dst := stdimage.NewNRGBA(stdimage.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
return dst
}
func channelSlots(img *stdimage.NRGBA) int {
return img.Rect.Dx() * img.Rect.Dy() * channelsPerPixel
}
func capacityFromSlots(slots int) int {
usable := slots - lengthPrefixBits
if usable < 0 {
return 0
}
return usable / bitsPerByte
}
func frame(payload []byte) []byte {
framed := make([]byte, lengthPrefixBytes+len(payload))
binary.BigEndian.PutUint32(framed, uint32(len(payload)))
copy(framed[lengthPrefixBytes:], payload)
return framed
}
func pixOffset(slot int) int {
return bytesPerPixel*(slot/channelsPerPixel) + (slot % channelsPerPixel)
}
func readBits(img *stdimage.NRGBA, startSlot, count int) []byte {
writer := bitio.NewWriter()
for slot := startSlot; slot < startSlot+count; slot++ {
off := pixOffset(slot)
writer.WriteBit(img.Pix[off] & 1)
}
return writer.Bytes()
}
func bytesHavePrefix(b, prefix []byte) bool {
if len(b) < len(prefix) {
return false
}
for i := range prefix {
if b[i] != prefix[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,378 @@
/*
©AngelaMos | 2026
image_test.go
Round-trip, rejection, capacity, and sniff tests for the LSB image carrier
*/
package image
import (
"bytes"
stdimage "image"
"image/color"
"image/color/palette"
"image/jpeg"
"image/png"
"testing"
"github.com/CarterPerez-dev/crypha/internal/carrier"
"github.com/CarterPerez-dev/crypha/internal/payload"
xbmp "golang.org/x/image/bmp"
)
const (
coverWidth = 8
coverHeight = 8
coverBytes = 20
)
func nrgbaCover(w, h int) *stdimage.NRGBA {
img := stdimage.NewNRGBA(stdimage.Rect(0, 0, w, h))
for i := 0; i < len(img.Pix); i += bytesPerPixel {
img.Pix[i] = byte(i)
img.Pix[i+1] = byte(i * 3)
img.Pix[i+2] = byte(i * 7)
img.Pix[i+3] = 0xFF
}
return img
}
func encodePNG(t *testing.T, img stdimage.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png cover: %v", err)
}
return buf.Bytes()
}
func encodeBMP(t *testing.T, img stdimage.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := xbmp.Encode(&buf, img); err != nil {
t.Fatalf("encode bmp cover: %v", err)
}
return buf.Bytes()
}
func hideReveal(t *testing.T, cover, payload []byte) []byte {
t.Helper()
var stego bytes.Buffer
if err := (imageCarrier{}).Hide(bytes.NewReader(cover), payload, &stego); err != nil {
t.Fatalf("Hide: %v", err)
}
got, err := (imageCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
return got
}
func TestRoundTripPNG(t *testing.T) {
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
cases := []struct {
name string
payload []byte
}{
{"single byte", []byte{0x42}},
{"text", []byte("crypha")},
{"full capacity", bytes.Repeat([]byte{0xAB}, coverBytes)},
{"high bits set", bytes.Repeat([]byte{0xFF}, coverBytes)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := hideReveal(t, cover, tc.payload)
if !bytes.Equal(got, tc.payload) {
t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload)
}
})
}
}
func TestRoundTripBMP(t *testing.T) {
cover := encodeBMP(t, nrgbaCover(coverWidth, coverHeight))
payload := []byte("bmp carrier")
got := hideReveal(t, cover, payload)
if !bytes.Equal(got, payload) {
t.Fatalf("bmp round-trip mismatch: got %x want %x", got, payload)
}
}
func TestRGBASourceSafety(t *testing.T) {
rgba := stdimage.NewRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight))
for i := 0; i < len(rgba.Pix); i += bytesPerPixel {
rgba.Pix[i] = byte(i * 5)
rgba.Pix[i+1] = byte(i * 11)
rgba.Pix[i+2] = byte(i * 13)
rgba.Pix[i+3] = 0xFF
}
cover := encodePNG(t, rgba)
payload := []byte("premultiply safe")
got := hideReveal(t, cover, payload)
if !bytes.Equal(got, payload) {
t.Fatalf("rgba-source round-trip mismatch: got %x want %x", got, payload)
}
}
func TestTransparentCoverAlphaUntouched(t *testing.T) {
cover := stdimage.NewNRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight))
for i := 0; i < len(cover.Pix); i += bytesPerPixel {
cover.Pix[i] = byte(i)
cover.Pix[i+1] = byte(i * 2)
cover.Pix[i+2] = byte(i * 4)
cover.Pix[i+3] = byte(0x80 + i%64)
}
encoded := encodePNG(t, cover)
var stego bytes.Buffer
if err := (imageCarrier{}).Hide(bytes.NewReader(encoded), []byte("hi"), &stego); err != nil {
t.Fatalf("Hide: %v", err)
}
decoded, _, err := stdimage.Decode(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("decode stego: %v", err)
}
out := toNRGBA(decoded)
for i := 0; i < len(out.Pix); i += bytesPerPixel {
if out.Pix[i+3] != cover.Pix[i+3] {
t.Fatalf("alpha modified at pixel %d: got %d want %d", i/bytesPerPixel, out.Pix[i+3], cover.Pix[i+3])
}
}
}
func pseudoRandom(n, seed int) []byte {
b := make([]byte, n)
x := uint32(seed)*2654435761 + 1
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
func TestRandomBinaryRoundTrip(t *testing.T) {
cover := encodePNG(t, nrgbaCover(64, 64))
for _, size := range []int{1, 17, 100, 500} {
payload := pseudoRandom(size, size)
got := hideReveal(t, cover, payload)
if !bytes.Equal(got, payload) {
t.Fatalf("random round-trip mismatch at size %d", size)
}
}
}
func TestPalettedRejected(t *testing.T) {
pal := stdimage.NewPaletted(stdimage.Rect(0, 0, coverWidth, coverHeight), palette.WebSafe)
for y := 0; y < coverHeight; y++ {
for x := 0; x < coverWidth; x++ {
pal.Set(x, y, color.RGBA{R: byte(x * 30), G: byte(y * 30), B: 0x40, A: 0xFF})
}
}
cover := encodePNG(t, pal)
err := (imageCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
if err != ErrPaletted {
t.Fatalf("expected ErrPaletted, got %v", err)
}
}
func TestSixteenBitRejected(t *testing.T) {
img := stdimage.NewNRGBA64(stdimage.Rect(0, 0, coverWidth, coverHeight))
for y := 0; y < coverHeight; y++ {
for x := 0; x < coverWidth; x++ {
img.Set(x, y, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9ABC, A: 0xFFFF})
}
}
cover := encodePNG(t, img)
err := (imageCarrier{}).Hide(bytes.NewReader(cover), []byte("x"), &bytes.Buffer{})
if err != Err16Bit {
t.Fatalf("expected Err16Bit, got %v", err)
}
}
func TestUnsupportedFormatRejected(t *testing.T) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, nrgbaCover(coverWidth, coverHeight), nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
err := (imageCarrier{}).Hide(bytes.NewReader(buf.Bytes()), []byte("x"), &bytes.Buffer{})
if err != ErrUnsupportedFormat {
t.Fatalf("expected ErrUnsupportedFormat, got %v", err)
}
}
func TestEmptyPayloadRejected(t *testing.T) {
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
err := (imageCarrier{}).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{})
if err != ErrEmptyPayload {
t.Fatalf("expected ErrEmptyPayload, got %v", err)
}
}
func TestCapacityBoundary(t *testing.T) {
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
atCap := bytes.Repeat([]byte{0x01}, coverBytes)
if got := hideReveal(t, cover, atCap); !bytes.Equal(got, atCap) {
t.Fatal("payload at exact capacity failed to round-trip")
}
overCap := bytes.Repeat([]byte{0x01}, coverBytes+1)
err := (imageCarrier{}).Hide(bytes.NewReader(cover), overCap, &bytes.Buffer{})
if err == nil {
t.Fatal("expected capacity error for oversized payload")
}
}
func TestCapacityReport(t *testing.T) {
cover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
got, err := (imageCarrier{}).Capacity(bytes.NewReader(cover))
if err != nil {
t.Fatalf("Capacity: %v", err)
}
want := (coverWidth*coverHeight*channelsPerPixel - lengthPrefixBits) / bitsPerByte
if got != want {
t.Fatalf("Capacity: got %d want %d", got, want)
}
if want != coverBytes {
t.Fatalf("test constant coverBytes stale: computed %d", want)
}
}
func TestRevealNoPayload(t *testing.T) {
clean := stdimage.NewNRGBA(stdimage.Rect(0, 0, coverWidth, coverHeight))
for i := range clean.Pix {
clean.Pix[i] = 0xFE
}
cover := encodePNG(t, clean)
_, err := (imageCarrier{}).Reveal(bytes.NewReader(cover))
if err != ErrNoPayload {
t.Fatalf("expected ErrNoPayload on a cover with zeroed length bits, got %v", err)
}
}
func TestSniff(t *testing.T) {
pngCover := encodePNG(t, nrgbaCover(coverWidth, coverHeight))
bmpCover := encodeBMP(t, nrgbaCover(coverWidth, coverHeight))
cases := []struct {
name string
data []byte
want bool
}{
{"png", pngCover, true},
{"bmp", bmpCover, true},
{"random", []byte("not an image file at all"), false},
{"short", []byte{0x89, 'P'}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := (imageCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want {
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
}
})
}
}
func TestRevealRejectsUnsupportedAndGarbage(t *testing.T) {
var jpg bytes.Buffer
if err := jpeg.Encode(&jpg, nrgbaCover(coverWidth, coverHeight), nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(jpg.Bytes())); err != ErrUnsupportedFormat {
t.Fatalf("Reveal jpeg: got %v want ErrUnsupportedFormat", err)
}
if _, err := (imageCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err == nil {
t.Fatal("Reveal garbage: expected decode error")
}
}
func TestRevealRejectsLossy(t *testing.T) {
pal := stdimage.NewPaletted(stdimage.Rect(0, 0, coverWidth, coverHeight), palette.WebSafe)
for y := 0; y < coverHeight; y++ {
for x := 0; x < coverWidth; x++ {
pal.Set(x, y, color.RGBA{R: byte(x * 30), G: byte(y * 30), B: 0x40, A: 0xFF})
}
}
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(encodePNG(t, pal))); err != ErrPaletted {
t.Fatalf("Reveal paletted: got %v want ErrPaletted", err)
}
wide := stdimage.NewNRGBA64(stdimage.Rect(0, 0, coverWidth, coverHeight))
wide.Set(0, 0, color.NRGBA64{R: 0x1234, G: 0x5678, B: 0x9ABC, A: 0xFFFF})
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(encodePNG(t, wide))); err != Err16Bit {
t.Fatalf("Reveal 16-bit: got %v want Err16Bit", err)
}
}
func TestRevealTooSmall(t *testing.T) {
cover := encodePNG(t, nrgbaCover(1, 1))
if _, err := (imageCarrier{}).Reveal(bytes.NewReader(cover)); err != ErrTooSmall {
t.Fatalf("Reveal 1x1: got %v want ErrTooSmall", err)
}
}
func TestCapacityRejectsUnsupportedAndTiny(t *testing.T) {
var jpg bytes.Buffer
if err := jpeg.Encode(&jpg, nrgbaCover(coverWidth, coverHeight), nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
if _, err := (imageCarrier{}).Capacity(bytes.NewReader(jpg.Bytes())); err != ErrUnsupportedFormat {
t.Fatalf("Capacity jpeg: got %v want ErrUnsupportedFormat", err)
}
if _, err := (imageCarrier{}).Capacity(bytes.NewReader([]byte("garbage"))); err == nil {
t.Fatal("Capacity garbage: expected decode error")
}
tiny := encodePNG(t, nrgbaCover(1, 1))
got, err := (imageCarrier{}).Capacity(bytes.NewReader(tiny))
if err != nil {
t.Fatalf("Capacity 1x1: %v", err)
}
if got != 0 {
t.Fatalf("Capacity 1x1: got %d want 0", got)
}
}
func TestEncryptedEnvelopeThroughCarrier(t *testing.T) {
secret := []byte("meet at the docks at midnight")
envelope, err := payload.Pack(secret, payload.Options{
Passphrase: []byte("correct horse battery staple"),
Compress: true,
Cipher: payload.CipherChaCha20,
Strength: payload.StrengthDefault,
})
if err != nil {
t.Fatalf("Pack: %v", err)
}
cover := encodePNG(t, nrgbaCover(96, 96))
var stego bytes.Buffer
if err := (imageCarrier{}).Hide(bytes.NewReader(cover), envelope, &stego); err != nil {
t.Fatalf("Hide envelope: %v", err)
}
recovered, err := (imageCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal envelope: %v", err)
}
if !bytes.Equal(recovered, envelope) {
t.Fatal("carrier did not return the exact envelope bytes")
}
plain, err := payload.Unpack(recovered, []byte("correct horse battery staple"))
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if !bytes.Equal(plain, secret) {
t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret)
}
}
func TestRegisteredInRegistry(t *testing.T) {
c, ok := carrier.Get(Format)
if !ok {
t.Fatal("image carrier did not self-register")
}
if c.Format() != Format {
t.Fatalf("registry returned wrong carrier: %s", c.Format())
}
}

View File

@ -0,0 +1,272 @@
/*
©AngelaMos | 2026
pdf.go
PDF carrier with three techniques: embedded-file attachment, Info-dict metadata keys, and raw append-after-EOF
*/
package pdf
import (
"bytes"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"strconv"
"time"
"github.com/CarterPerez-dev/crypha/internal/carrier"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
)
type Technique string
const (
TechniqueAttachment Technique = "attachment"
TechniqueMetadata Technique = "metadata"
TechniqueAppend Technique = "append"
)
const (
Format = "pdf"
lengthPrefixBytes = 4
unboundedCapacity = math.MaxInt32
attachmentID = "crypha-payload.bin"
attachmentDesc = "crypha embedded payload"
metaKeyPrefix = "X-Crypha-Payload-"
metaCountKey = "X-Crypha-Payload-Count"
metaChunkSize = 8192
)
var (
pdfSignature = []byte("%PDF-")
appendMagic = []byte{0x43, 0x72, 0x79, 0x50}
epoch = time.Unix(0, 0)
)
var (
ErrEmptyPayload = errors.New("crypha/pdf: empty payload")
ErrUnsupportedFormat = errors.New("crypha/pdf: cover must be a PDF")
ErrNoPayload = errors.New("crypha/pdf: no crypha payload found")
)
type pdfCarrier struct {
technique Technique
}
func init() {
model.ConfigPath = "disable"
carrier.Register(pdfCarrier{technique: TechniqueAttachment})
}
func New(t Technique) carrier.Carrier {
return pdfCarrier{technique: t}
}
func (pdfCarrier) Format() string {
return Format
}
func (c pdfCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
if len(payload) == 0 {
return ErrEmptyPayload
}
data, err := io.ReadAll(cover)
if err != nil {
return fmt.Errorf("crypha/pdf: read cover: %w", err)
}
if !isPDF(data) {
return ErrUnsupportedFormat
}
switch c.technique {
case TechniqueMetadata:
return hideMetadata(data, payload, out)
case TechniqueAppend:
return hideAppend(data, payload, out)
default:
return hideAttachment(data, payload, out)
}
}
func (pdfCarrier) Reveal(stego io.Reader) ([]byte, error) {
data, err := io.ReadAll(stego)
if err != nil {
return nil, fmt.Errorf("crypha/pdf: read stego: %w", err)
}
if !isPDF(data) {
return nil, ErrUnsupportedFormat
}
if payload, ok := revealAttachment(data); ok && len(payload) > 0 {
return payload, nil
}
if payload, ok := revealMetadata(data); ok && len(payload) > 0 {
return payload, nil
}
if payload, ok := revealAppend(data); ok && len(payload) > 0 {
return payload, nil
}
return nil, ErrNoPayload
}
func (pdfCarrier) Capacity(cover io.Reader) (int, error) {
data, err := io.ReadAll(cover)
if err != nil {
return 0, fmt.Errorf("crypha/pdf: read cover: %w", err)
}
if !isPDF(data) {
return 0, ErrUnsupportedFormat
}
return unboundedCapacity, nil
}
func (pdfCarrier) Sniff(stego io.ReadSeeker) bool {
head := make([]byte, len(pdfSignature))
if _, err := io.ReadFull(stego, head); err != nil {
return false
}
return bytes.Equal(head, pdfSignature)
}
func hideAttachment(cover, payload []byte, out io.Writer) error {
conf := newConfig()
ctx, err := api.ReadValidateAndOptimize(bytes.NewReader(cover), conf)
if err != nil {
return fmt.Errorf("crypha/pdf: read cover: %w", err)
}
att := model.Attachment{
Reader: bytes.NewReader(payload),
ID: attachmentID,
FileName: attachmentID,
Desc: attachmentDesc,
ModTime: &epoch,
}
if err := ctx.AddAttachment(att, false); err != nil {
return fmt.Errorf("crypha/pdf: attach payload: %w", err)
}
if err := api.Write(ctx, out, conf); err != nil {
return fmt.Errorf("crypha/pdf: write pdf: %w", err)
}
return nil
}
func revealAttachment(stego []byte) ([]byte, bool) {
conf := newConfig()
attachments, err := api.ExtractAttachmentsRaw(bytes.NewReader(stego), "", nil, conf)
if err != nil {
return nil, false
}
for _, att := range attachments {
if att.ID != attachmentID && att.FileName != attachmentID {
continue
}
payload, rerr := io.ReadAll(att)
if rerr != nil {
return nil, false
}
return payload, true
}
return nil, false
}
func hideMetadata(cover, payload []byte, out io.Writer) error {
encoded := base64.RawURLEncoding.EncodeToString(payload)
props := map[string]string{}
count := 0
for offset := 0; offset < len(encoded); offset += metaChunkSize {
end := offset + metaChunkSize
if end > len(encoded) {
end = len(encoded)
}
props[metaKeyPrefix+strconv.Itoa(count)] = encoded[offset:end]
count++
}
props[metaCountKey] = strconv.Itoa(count)
if err := api.AddProperties(bytes.NewReader(cover), out, props, newConfig()); err != nil {
return fmt.Errorf("crypha/pdf: write metadata: %w", err)
}
return nil
}
func revealMetadata(stego []byte) ([]byte, bool) {
props, err := api.Properties(bytes.NewReader(stego), newConfig())
if err != nil {
return nil, false
}
countStr, ok := props[metaCountKey]
if !ok {
return nil, false
}
count, err := strconv.Atoi(countStr)
if err != nil || count <= 0 {
return nil, false
}
var encoded bytes.Buffer
for i := 0; i < count; i++ {
chunk, ok := props[metaKeyPrefix+strconv.Itoa(i)]
if !ok {
return nil, false
}
encoded.WriteString(chunk)
}
payload, err := base64.RawURLEncoding.DecodeString(encoded.String())
if err != nil {
return nil, false
}
return payload, true
}
func hideAppend(cover, payload []byte, out io.Writer) error {
if _, err := out.Write(cover); err != nil {
return err
}
if _, err := out.Write(appendMagic); err != nil {
return err
}
if _, err := out.Write(payload); err != nil {
return err
}
var lenField [lengthPrefixBytes]byte
binary.BigEndian.PutUint32(lenField[:], uint32(len(payload)))
_, err := out.Write(lenField[:])
return err
}
func revealAppend(stego []byte) ([]byte, bool) {
trailer := len(appendMagic) + lengthPrefixBytes
if len(stego) < trailer {
return nil, false
}
payloadLen := binary.BigEndian.Uint32(stego[len(stego)-lengthPrefixBytes:])
if uint64(len(appendMagic))+uint64(payloadLen)+uint64(lengthPrefixBytes) > uint64(len(stego)) {
return nil, false
}
magicStart := len(stego) - lengthPrefixBytes - int(payloadLen) - len(appendMagic)
if !bytes.Equal(stego[magicStart:magicStart+len(appendMagic)], appendMagic) {
return nil, false
}
payloadStart := magicStart + len(appendMagic)
return stego[payloadStart : payloadStart+int(payloadLen)], true
}
func newConfig() *model.Configuration {
conf := model.NewDefaultConfiguration()
conf.ValidationMode = model.ValidationRelaxed
return conf
}
func isPDF(data []byte) bool {
return bytes.HasPrefix(data, pdfSignature)
}

View File

@ -0,0 +1,395 @@
/*
©AngelaMos | 2026
pdf_test.go
Per-technique round-trip, auto-detect, capacity, ordering, and sniff tests for the PDF carrier
*/
package pdf
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/CarterPerez-dev/crypha/internal/carrier"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
)
const pageJSON = `{"pages": {"1": {"content": {}}}}`
var allTechniques = []Technique{TechniqueAttachment, TechniqueMetadata, TechniqueAppend}
func minimalPDF(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := api.Create(nil, strings.NewReader(pageJSON), &buf, newConfig()); err != nil {
t.Fatalf("create demo pdf: %v", err)
}
return buf.Bytes()
}
func pseudoBytes(n, seed int) []byte {
b := make([]byte, n)
x := uint32(seed)*2654435761 + 1
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
func hideWith(t *testing.T, tech Technique, cover, payload []byte) []byte {
t.Helper()
var stego bytes.Buffer
if err := New(tech).Hide(bytes.NewReader(cover), payload, &stego); err != nil {
t.Fatalf("Hide(%s): %v", tech, err)
}
return stego.Bytes()
}
func TestFixtureIsValidPDF(t *testing.T) {
cover := minimalPDF(t)
if !isPDF(cover) {
t.Fatal("fixture is not a PDF")
}
}
func TestRoundTripTechniques(t *testing.T) {
cover := minimalPDF(t)
payloads := []struct {
name string
data []byte
}{
{"short text", []byte("meet at the docks")},
{"single byte", []byte{0x42}},
{"binary blob", pseudoBytes(500, 7)},
}
for _, tech := range allTechniques {
for _, pl := range payloads {
t.Run(string(tech)+"/"+pl.name, func(t *testing.T) {
stego := hideWith(t, tech, cover, pl.data)
got, err := New(tech).Reveal(bytes.NewReader(stego))
if err != nil {
t.Fatalf("Reveal(%s): %v", tech, err)
}
if !bytes.Equal(got, pl.data) {
t.Fatalf("%s round-trip mismatch: got %x want %x", tech, got, pl.data)
}
})
}
}
}
func TestRevealAutoDetect(t *testing.T) {
cover := minimalPDF(t)
detector, ok := carrier.Get(Format)
if !ok {
t.Fatal("pdf carrier not registered")
}
for _, tech := range allTechniques {
t.Run(string(tech), func(t *testing.T) {
payload := []byte("auto-detect me: " + string(tech))
stego := hideWith(t, tech, cover, payload)
got, err := detector.Reveal(bytes.NewReader(stego))
if err != nil {
t.Fatalf("auto-detect Reveal for %s: %v", tech, err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("auto-detect mismatch for %s: got %q want %q", tech, got, payload)
}
})
}
}
func TestMetadataChunking(t *testing.T) {
cover := minimalPDF(t)
payload := pseudoBytes(metaChunkSize, 3)
stego := hideWith(t, TechniqueMetadata, cover, payload)
got, err := New(TechniqueMetadata).Reveal(bytes.NewReader(stego))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatal("multi-chunk metadata round-trip mismatch")
}
}
func TestAppendStrippedByPdfcpuRewrite(t *testing.T) {
cover := minimalPDF(t)
appendStego := hideWith(t, TechniqueAppend, cover, []byte("fragile append payload"))
if _, ok := revealAppend(appendStego); !ok {
t.Fatal("append payload not present before rewrite")
}
rewritten := hideWith(t, TechniqueAttachment, appendStego, []byte("new attachment"))
if _, ok := revealAppend(rewritten); ok {
t.Fatal("expected pdfcpu rewrite to strip the trailing append payload")
}
if _, ok := revealAttachment(rewritten); !ok {
t.Fatal("attachment payload should survive the rewrite")
}
}
func TestCapacityUnbounded(t *testing.T) {
cover := minimalPDF(t)
got, err := New(TechniqueAttachment).Capacity(bytes.NewReader(cover))
if err != nil {
t.Fatalf("Capacity: %v", err)
}
if got != unboundedCapacity {
t.Fatalf("Capacity: got %d want %d", got, unboundedCapacity)
}
}
func TestSniff(t *testing.T) {
cover := minimalPDF(t)
cases := []struct {
name string
data []byte
want bool
}{
{"pdf", cover, true},
{"random", []byte("not a pdf at all, just text"), false},
{"short", []byte("%PD"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := New(TechniqueAttachment).Sniff(bytes.NewReader(tc.data)); got != tc.want {
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
}
})
}
}
func TestRejectNonPDF(t *testing.T) {
garbage := []byte("this is plainly not a pdf document")
if err := New(TechniqueAttachment).Hide(bytes.NewReader(garbage), []byte("x"), &bytes.Buffer{}); err != ErrUnsupportedFormat {
t.Fatalf("Hide non-pdf: got %v want ErrUnsupportedFormat", err)
}
if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(garbage)); err != ErrUnsupportedFormat {
t.Fatalf("Reveal non-pdf: got %v want ErrUnsupportedFormat", err)
}
if _, err := New(TechniqueAttachment).Capacity(bytes.NewReader(garbage)); err != ErrUnsupportedFormat {
t.Fatalf("Capacity non-pdf: got %v want ErrUnsupportedFormat", err)
}
}
func TestEmptyPayloadRejected(t *testing.T) {
cover := minimalPDF(t)
if err := New(TechniqueAttachment).Hide(bytes.NewReader(cover), nil, &bytes.Buffer{}); err != ErrEmptyPayload {
t.Fatalf("expected ErrEmptyPayload, got %v", err)
}
}
func TestRevealNoPayload(t *testing.T) {
cover := minimalPDF(t)
if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(cover)); err != ErrNoPayload {
t.Fatalf("expected ErrNoPayload on a clean PDF, got %v", err)
}
}
type errReader struct{}
func (errReader) Read([]byte) (int, error) {
return 0, errors.New("forced read failure")
}
func TestReadErrorsPropagate(t *testing.T) {
if err := New(TechniqueAttachment).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("Hide: expected read error")
}
if _, err := New(TechniqueAttachment).Reveal(errReader{}); err == nil {
t.Fatal("Reveal: expected read error")
}
if _, err := New(TechniqueAttachment).Capacity(errReader{}); err == nil {
t.Fatal("Capacity: expected read error")
}
}
func TestEncryptedEnvelopeThroughCarrier(t *testing.T) {
cover := minimalPDF(t)
secret := []byte("the account number is 4417 1234 5678 9012")
envelope, err := payload.Pack(secret, payload.Options{
Passphrase: []byte("correct horse battery staple"),
Compress: true,
Cipher: payload.CipherChaCha20,
Strength: payload.StrengthDefault,
})
if err != nil {
t.Fatalf("Pack: %v", err)
}
for _, tech := range allTechniques {
t.Run(string(tech), func(t *testing.T) {
stego := hideWith(t, tech, cover, envelope)
recovered, err := New(tech).Reveal(bytes.NewReader(stego))
if err != nil {
t.Fatalf("Reveal envelope: %v", err)
}
if !bytes.Equal(recovered, envelope) {
t.Fatal("carrier did not return the exact envelope bytes")
}
plain, err := payload.Unpack(recovered, []byte("correct horse battery staple"))
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if !bytes.Equal(plain, secret) {
t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret)
}
})
}
}
func TestCorruptPDFRejectedByTechniques(t *testing.T) {
corrupt := []byte("%PDF-1.7\nnot a real pdf body, no xref, no objects\n%%EOF")
if err := New(TechniqueAttachment).Hide(bytes.NewReader(corrupt), []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("Hide attachment on corrupt PDF: expected error")
}
if err := New(TechniqueMetadata).Hide(bytes.NewReader(corrupt), []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("Hide metadata on corrupt PDF: expected error")
}
if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(corrupt)); err != ErrNoPayload {
t.Fatalf("Reveal corrupt PDF: got %v want ErrNoPayload", err)
}
}
func TestForeignAttachmentIgnored(t *testing.T) {
cover := minimalPDF(t)
conf := newConfig()
ctx, err := api.ReadValidateAndOptimize(bytes.NewReader(cover), conf)
if err != nil {
t.Fatalf("read cover: %v", err)
}
other := model.Attachment{
Reader: bytes.NewReader([]byte("someone else's file")),
ID: "other.txt",
FileName: "other.txt",
ModTime: &epoch,
}
if err := ctx.AddAttachment(other, false); err != nil {
t.Fatalf("add foreign attachment: %v", err)
}
var buf bytes.Buffer
if err := api.Write(ctx, &buf, conf); err != nil {
t.Fatalf("write: %v", err)
}
if _, ok := revealAttachment(buf.Bytes()); ok {
t.Fatal("revealAttachment must ignore a non-crypha attachment")
}
}
type errWriter struct {
failAt int
count int
}
func (w *errWriter) Write(p []byte) (int, error) {
w.count++
if w.count > w.failAt {
return 0, errors.New("forced write failure")
}
return len(p), nil
}
func TestAppendWriteErrorsPropagate(t *testing.T) {
cover := minimalPDF(t)
for failAt := 0; failAt < 4; failAt++ {
if err := hideAppend(cover, []byte("payload"), &errWriter{failAt: failAt}); err == nil {
t.Fatalf("hideAppend failAt=%d: expected write error", failAt)
}
}
}
func TestAttachmentWriteErrorPropagates(t *testing.T) {
cover := minimalPDF(t)
if err := hideAttachment(cover, []byte("payload"), &errWriter{failAt: 0}); err == nil {
t.Fatal("hideAttachment: expected write error")
}
}
func metaStego(t *testing.T, cover []byte, props map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
if err := api.AddProperties(bytes.NewReader(cover), &buf, props, newConfig()); err != nil {
t.Fatalf("add properties: %v", err)
}
return buf.Bytes()
}
func TestMetadataTamperedRejected(t *testing.T) {
cover := minimalPDF(t)
cases := []struct {
name string
props map[string]string
}{
{"non-numeric count", map[string]string{metaCountKey: "not-a-number"}},
{"zero count", map[string]string{metaCountKey: "0"}},
{"missing chunk", map[string]string{metaCountKey: "1"}},
{"invalid base64", map[string]string{metaCountKey: "1", metaKeyPrefix + "0": "@@@not-base64@@@"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stego := metaStego(t, cover, tc.props)
if _, ok := revealMetadata(stego); ok {
t.Fatalf("revealMetadata must reject %s", tc.name)
}
})
}
}
func TestEmptyMetadataYieldsNoPayload(t *testing.T) {
cover := minimalPDF(t)
stego := metaStego(t, cover, map[string]string{metaCountKey: "0"})
if _, err := New(TechniqueAttachment).Reveal(bytes.NewReader(stego)); err != ErrNoPayload {
t.Fatalf("count=0 metadata: got %v want ErrNoPayload", err)
}
}
func TestEmptyMetadataDoesNotMaskAppend(t *testing.T) {
cover := minimalPDF(t)
secret := []byte("the real secret rides under a spurious empty-metadata key")
metaPart := metaStego(t, cover, map[string]string{metaCountKey: "0"})
var stego bytes.Buffer
if err := hideAppend(metaPart, secret, &stego); err != nil {
t.Fatalf("hideAppend: %v", err)
}
got, err := New(TechniqueAttachment).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
if !bytes.Equal(got, secret) {
t.Fatalf("empty metadata masked the append payload: got %q", got)
}
}
func TestRevealAppendEdgeCases(t *testing.T) {
if _, ok := revealAppend([]byte("tiny")); ok {
t.Fatal("revealAppend on too-short input must return false")
}
cover := minimalPDF(t)
payload := []byte("magic mismatch probe")
stego := hideWith(t, TechniqueAppend, cover, payload)
magicPos := len(stego) - lengthPrefixBytes - len(payload) - len(appendMagic)
corrupt := append([]byte{}, stego...)
corrupt[magicPos] ^= 0xFF
if _, ok := revealAppend(corrupt); ok {
t.Fatal("revealAppend must reject a mismatched magic")
}
}
func TestRegisteredInRegistry(t *testing.T) {
c, ok := carrier.Get(Format)
if !ok {
t.Fatal("pdf carrier did not self-register")
}
if c.Format() != Format {
t.Fatalf("registry returned wrong carrier: %s", c.Format())
}
}

View File

@ -0,0 +1,190 @@
/*
©AngelaMos | 2026
blocks.go
Version and error-correction block tables (level H) plus codeword interleaving from ISO/IEC 18004
*/
package qr
const (
ecLevelHigh = 2
framePrefixBytes = 4
bitsPerCodeword = 8
injectionSafetyRatio = 2
minSupportedVersion = 1
maxSupportedVersion = 10
baseSymbolSize = 21
versionSizeStep = 4
)
type blockGroup struct {
count int
total int
data int
}
type qrVersion struct {
version int
remainderBits int
groups []blockGroup
}
var versionTable = map[int]qrVersion{
1: {1, 0, []blockGroup{{1, 26, 9}}},
2: {2, 7, []blockGroup{{1, 44, 16}}},
3: {3, 7, []blockGroup{{2, 35, 13}}},
4: {4, 7, []blockGroup{{4, 25, 9}}},
5: {5, 7, []blockGroup{{2, 33, 11}, {2, 34, 12}}},
6: {6, 7, []blockGroup{{4, 43, 15}}},
7: {7, 0, []blockGroup{{4, 39, 13}, {1, 40, 14}}},
8: {8, 0, []blockGroup{{4, 40, 14}, {2, 41, 15}}},
9: {9, 0, []blockGroup{{4, 36, 12}, {4, 37, 13}}},
10: {10, 0, []blockGroup{{6, 43, 15}, {2, 44, 16}}},
}
func lookupVersion(version int) (qrVersion, bool) {
v, ok := versionTable[version]
return v, ok
}
func symbolSize(version int) int {
return baseSymbolSize + (version-1)*versionSizeStep
}
func versionForSize(size int) (int, bool) {
if size < baseSymbolSize || (size-baseSymbolSize)%versionSizeStep != 0 {
return 0, false
}
version := (size-baseSymbolSize)/versionSizeStep + 1
if version < minSupportedVersion || version > maxSupportedVersion {
return 0, false
}
return version, true
}
func (v qrVersion) numBlocks() int {
n := 0
for _, g := range v.groups {
n += g.count
}
return n
}
func (v qrVersion) totalCodewords() int {
n := 0
for _, g := range v.groups {
n += g.count * g.total
}
return n
}
func (v qrVersion) ecPerBlock() int {
return v.groups[0].total - v.groups[0].data
}
func (v qrVersion) correctable() int {
return v.ecPerBlock() / 2
}
func (v qrVersion) injectPerBlock() int {
return v.correctable() / injectionSafetyRatio
}
func (v qrVersion) dataModules() int {
return v.totalCodewords()*bitsPerCodeword + v.remainderBits
}
func (v qrVersion) capacityBytes() int {
usable := v.numBlocks()*v.injectPerBlock() - framePrefixBytes
if usable < 0 {
return 0
}
return usable
}
func (v qrVersion) blockLayout() (dataLens, ecLens []int) {
for _, g := range v.groups {
for i := 0; i < g.count; i++ {
dataLens = append(dataLens, g.data)
ecLens = append(ecLens, g.total-g.data)
}
}
return dataLens, ecLens
}
func (v qrVersion) interleave(dataBlocks, ecBlocks [][]byte) []byte {
out := make([]byte, 0, v.totalCodewords())
maxData := 0
for _, b := range dataBlocks {
if len(b) > maxData {
maxData = len(b)
}
}
for i := 0; i < maxData; i++ {
for _, b := range dataBlocks {
if i < len(b) {
out = append(out, b[i])
}
}
}
maxEC := 0
for _, b := range ecBlocks {
if len(b) > maxEC {
maxEC = len(b)
}
}
for i := 0; i < maxEC; i++ {
for _, b := range ecBlocks {
if i < len(b) {
out = append(out, b[i])
}
}
}
return out
}
func (v qrVersion) deinterleave(serial []byte) (dataBlocks, ecBlocks [][]byte, ok bool) {
if len(serial) < v.totalCodewords() {
return nil, nil, false
}
dataLens, ecLens := v.blockLayout()
nb := len(dataLens)
dataBlocks = make([][]byte, nb)
ecBlocks = make([][]byte, nb)
for b := 0; b < nb; b++ {
dataBlocks[b] = make([]byte, dataLens[b])
ecBlocks[b] = make([]byte, ecLens[b])
}
pos := 0
maxData := 0
for _, d := range dataLens {
if d > maxData {
maxData = d
}
}
for i := 0; i < maxData; i++ {
for b := 0; b < nb; b++ {
if i < dataLens[b] {
dataBlocks[b][i] = serial[pos]
pos++
}
}
}
maxEC := 0
for _, e := range ecLens {
if e > maxEC {
maxEC = e
}
}
for i := 0; i < maxEC; i++ {
for b := 0; b < nb; b++ {
if i < ecLens[b] {
ecBlocks[b][i] = serial[pos]
pos++
}
}
}
return dataBlocks, ecBlocks, true
}

View File

@ -0,0 +1,57 @@
/*
©AngelaMos | 2026
gf.go
Arithmetic over GF(2^8) under the QR primitive polynomial for Reed-Solomon coding
*/
package qr
const (
gfOrder = 255
gfFieldSize = 256
gfPrimitive = 0x11D
gfGenerator = 2
gfHighBit = 0x100
)
var (
gfExp [gfOrder * 2]byte
gfLog [gfFieldSize]byte
)
func init() {
x := 1
for i := 0; i < gfOrder; i++ {
gfExp[i] = byte(x)
gfLog[x] = byte(i)
x <<= 1
if x&gfHighBit != 0 {
x ^= gfPrimitive
}
}
for i := gfOrder; i < gfOrder*2; i++ {
gfExp[i] = gfExp[i-gfOrder]
}
}
func gfMul(a, b byte) byte {
if a == 0 || b == 0 {
return 0
}
return gfExp[int(gfLog[a])+int(gfLog[b])]
}
func gfInv(a byte) byte {
return gfExp[gfOrder-int(gfLog[a])]
}
func gfPow(base byte, exp int) byte {
if base == 0 {
if exp == 0 {
return 1
}
return 0
}
return gfExp[(int(gfLog[base])*exp)%gfOrder]
}

View File

@ -0,0 +1,343 @@
/*
©AngelaMos | 2026
matrix.go
QR module geometry from ISO/IEC 18004: function map, format info, masks, placement, rendering
*/
package qr
import (
"fmt"
"image"
"image/color"
"image/png"
"io"
"math/bits"
)
const (
quietZoneModules = 4
modulePixels = 8
darkThreshold = 128
formatBitCount = 15
formatDataBits = 5
formatGenPoly = 0x537
formatMaskPoly = 0x5412
formatMaxDistance = 3
formatCodeCount = 32
alignmentRadius = 2
timingLine = 6
finderBlockEdge = 8
finderPatternSize = 7
versionInfoOrigin = 11
versionInfoModules = 18
versionInfoBand = 3
minVersionWithInfo = 7
darkLight = 0xFF
darkDark = 0x00
)
var formatBitCells = [formatBitCount]point{
{8, 0}, {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5},
{8, 7}, {8, 8}, {7, 8},
{5, 8}, {4, 8}, {3, 8}, {2, 8}, {1, 8}, {0, 8},
}
var alignmentCenters = map[int][]int{
1: {},
2: {6, 18},
3: {6, 22},
4: {6, 26},
5: {6, 30},
6: {6, 34},
7: {6, 22, 38},
8: {6, 24, 42},
9: {6, 26, 46},
10: {6, 28, 50},
}
type matrix struct {
size int
grid [][]bool
}
type point struct {
x, y int
}
func newMatrix(size int) matrix {
grid := make([][]bool, size)
for i := range grid {
grid[i] = make([]bool, size)
}
return matrix{size: size, grid: grid}
}
func (m matrix) clone() matrix {
out := newMatrix(m.size)
for y := range m.grid {
copy(out.grid[y], m.grid[y])
}
return out
}
func functionModules(version int) [][]bool {
size := symbolSize(version)
isFunc := make([][]bool, size)
for i := range isFunc {
isFunc[i] = make([]bool, size)
}
mark := func(x, y int) {
if x >= 0 && x < size && y >= 0 && y < size {
isFunc[y][x] = true
}
}
for y := 0; y <= finderBlockEdge; y++ {
for x := 0; x <= finderBlockEdge; x++ {
mark(x, y)
}
}
for y := 0; y <= finderBlockEdge; y++ {
for x := size - finderBlockEdge; x < size; x++ {
mark(x, y)
}
}
for y := size - finderBlockEdge; y < size; y++ {
for x := 0; x <= finderBlockEdge; x++ {
mark(x, y)
}
}
for i := 0; i < size; i++ {
mark(timingLine, i)
mark(i, timingLine)
}
centers := alignmentCenters[version]
for _, cx := range centers {
for _, cy := range centers {
if inFinderBlock(cx, cy, size) {
continue
}
for dy := -alignmentRadius; dy <= alignmentRadius; dy++ {
for dx := -alignmentRadius; dx <= alignmentRadius; dx++ {
mark(cx+dx, cy+dy)
}
}
}
}
if version >= minVersionWithInfo {
for i := 0; i < versionInfoModules; i++ {
mark(i/versionInfoBand, size-versionInfoOrigin+i%versionInfoBand)
mark(size-versionInfoOrigin+i%versionInfoBand, i/versionInfoBand)
}
}
return isFunc
}
func inFinderBlock(cx, cy, size int) bool {
span := finderPatternSize + 1
inTopLeft := cx < span && cy < span
inTopRight := cx >= size-span && cy < span
inBottomLeft := cx < span && cy >= size-span
return inTopLeft || inTopRight || inBottomLeft
}
func maskBit(maskID, row, col int) bool {
switch maskID {
case 0:
return (row+col)%2 == 0
case 1:
return row%2 == 0
case 2:
return col%3 == 0
case 3:
return (row+col)%3 == 0
case 4:
return (row/2+col/3)%2 == 0
case 5:
return (row*col)%2+(row*col)%3 == 0
case 6:
return ((row*col)%2+(row*col)%3)%2 == 0
case 7:
return ((row+col)%2+(row*col)%3)%2 == 0
}
return false
}
func formatCode(dataBits int) int {
remainder := dataBits << (formatBitCount - formatDataBits)
for i := formatBitCount - 1; i >= formatBitCount-formatDataBits; i-- {
if remainder&(1<<i) != 0 {
remainder ^= formatGenPoly << (i - (formatBitCount - formatDataBits))
}
}
return ((dataBits << (formatBitCount - formatDataBits)) | remainder) ^ formatMaskPoly
}
func readFormatBits(m matrix) int {
value := 0
for i, cell := range formatBitCells {
if m.grid[cell.y][cell.x] {
value |= 1 << i
}
}
return value
}
func parseFormat(m matrix) (maskID, level int, ok bool) {
stored := readFormatBits(m)
bestDist := formatBitCount + 1
bestData := -1
for d := 0; d < formatCodeCount; d++ {
dist := bits.OnesCount(uint(stored ^ formatCode(d)))
if dist < bestDist {
bestDist = dist
bestData = d
}
}
if bestData < 0 || bestDist > formatMaxDistance {
return 0, 0, false
}
return bestData & 0x7, bestData >> 3, true
}
func placementOrder(version int, isFunc [][]bool) []point {
size := symbolSize(version)
v, _ := lookupVersion(version)
count := v.dataModules()
order := make([]point, 0, count)
xOffset := 1
dirUp := true
x := size - 2
y := size - 1
for i := 0; i < count; i++ {
order = append(order, point{x: x + xOffset, y: y})
if i == count-1 {
break
}
for {
if xOffset == 1 {
xOffset = 0
} else {
xOffset = 1
if dirUp {
if y > 0 {
y--
} else {
dirUp = false
x -= 2
}
} else {
if y < size-1 {
y++
} else {
dirUp = true
x -= 2
}
}
}
if x == timingLine-1 {
x--
}
if x < 0 {
return order
}
if !isFunc[y][x+xOffset] {
break
}
}
}
return order
}
func readSerial(m matrix, order []point, maskID, totalCodewords int) []byte {
out := make([]byte, totalCodewords)
for c := 0; c < totalCodewords; c++ {
var b byte
for bit := 0; bit < bitsPerCodeword; bit++ {
p := order[c*bitsPerCodeword+bit]
v := m.grid[p.y][p.x]
if maskBit(maskID, p.y, p.x) {
v = !v
}
b <<= 1
if v {
b |= 1
}
}
out[c] = b
}
return out
}
func writeSerial(m matrix, order []point, maskID int, serial []byte) {
for c := 0; c < len(serial); c++ {
b := serial[c]
for bit := 0; bit < bitsPerCodeword; bit++ {
p := order[c*bitsPerCodeword+bit]
v := (b>>(7-bit))&1 == 1
if maskBit(maskID, p.y, p.x) {
v = !v
}
m.grid[p.y][p.x] = v
}
}
}
func renderPNG(m matrix, out io.Writer) error {
dim := (m.size + 2*quietZoneModules) * modulePixels
img := image.NewGray(image.Rect(0, 0, dim, dim))
for i := range img.Pix {
img.Pix[i] = darkLight
}
for y := 0; y < m.size; y++ {
for x := 0; x < m.size; x++ {
if !m.grid[y][x] {
continue
}
px := (x + quietZoneModules) * modulePixels
py := (y + quietZoneModules) * modulePixels
for dy := 0; dy < modulePixels; dy++ {
for dx := 0; dx < modulePixels; dx++ {
img.SetGray(px+dx, py+dy, color.Gray{Y: darkDark})
}
}
}
}
return png.Encode(out, img)
}
func readGrid(r io.Reader) (matrix, int, error) {
img, _, err := image.Decode(r)
if err != nil {
return matrix{}, 0, fmt.Errorf("crypha/qr: decode stego: %w", err)
}
b := img.Bounds()
width, height := b.Dx(), b.Dy()
if width != height || width%modulePixels != 0 {
return matrix{}, 0, ErrNotQR
}
across := width / modulePixels
size := across - 2*quietZoneModules
version, ok := versionForSize(size)
if !ok {
return matrix{}, 0, ErrNotQR
}
m := newMatrix(size)
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
cx := b.Min.X + (quietZoneModules+x)*modulePixels + modulePixels/2
cy := b.Min.Y + (quietZoneModules+y)*modulePixels + modulePixels/2
gray := color.GrayModel.Convert(img.At(cx, cy)).(color.Gray)
m.grid[y][x] = gray.Y < darkThreshold
}
}
return m, version, nil
}

View File

@ -0,0 +1,121 @@
/*
©AngelaMos | 2026
matrix_test.go
Unit coverage for QR geometry: mask formulas, version sizing, and capacity edge cases
*/
package qr
import (
"bytes"
"strings"
"testing"
)
func TestMaskFormulas(t *testing.T) {
cases := []struct {
maskID int
row, col int
want bool
}{
{0, 0, 0, true}, {0, 0, 1, false},
{1, 0, 3, true}, {1, 1, 3, false},
{2, 4, 0, true}, {2, 4, 1, false},
{3, 0, 0, true}, {3, 0, 1, false},
{4, 0, 0, true}, {4, 2, 0, false},
{5, 0, 0, true}, {5, 1, 1, false},
{6, 1, 1, true}, {6, 1, 5, false},
{7, 0, 0, true}, {7, 0, 1, false},
}
for _, tc := range cases {
if got := maskBit(tc.maskID, tc.row, tc.col); got != tc.want {
t.Fatalf("maskBit(%d,%d,%d): got %v want %v", tc.maskID, tc.row, tc.col, got, tc.want)
}
}
if maskBit(99, 0, 0) {
t.Fatal("maskBit with out-of-range id should be false")
}
}
func TestFunctionMapCountMatchesDataModules(t *testing.T) {
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
isFunc := functionModules(version)
size := symbolSize(version)
funcCount := 0
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
if isFunc[y][x] {
funcCount++
}
}
}
if nonFunc := size*size - funcCount; nonFunc != versionTable[version].dataModules() {
t.Fatalf("v%d: non-function modules %d, want dataModules %d", version, nonFunc, versionTable[version].dataModules())
}
}
}
func TestVersionForSize(t *testing.T) {
cases := []struct {
size int
version int
ok bool
}{
{21, 1, true},
{25, 2, true},
{57, 10, true},
{17, 0, false},
{23, 0, false},
{61, 0, false},
}
for _, tc := range cases {
version, ok := versionForSize(tc.size)
if ok != tc.ok || version != tc.version {
t.Fatalf("versionForSize(%d): got (%d,%v) want (%d,%v)", tc.size, version, ok, tc.version, tc.ok)
}
}
}
func TestCapacityBytesFloorsAtZero(t *testing.T) {
tiny := qrVersion{version: 0, groups: []blockGroup{{count: 1, total: 9, data: 8}}}
if got := tiny.capacityBytes(); got != 0 {
t.Fatalf("capacityBytes for a sub-frame budget: got %d want 0", got)
}
}
func TestSupportedVersionCapacities(t *testing.T) {
want := map[int]int{
1: 0, 2: 3, 3: 6, 4: 12, 5: 16,
6: 24, 7: 26, 8: 32, 9: 44, 10: 52,
}
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
if got := versionTable[version].capacityBytes(); got != want[version] {
t.Fatalf("v%d capacity: got %d want %d", version, got, want[version])
}
}
}
func TestRoundTripAcrossCovers(t *testing.T) {
covers := []string{
"crypha",
"https://angelamos.com/x",
"TEST 123",
"The quick brown fox jumps",
"0123456789",
}
secret := []byte("covert channel")
for _, cover := range covers {
var stego bytes.Buffer
if err := (qrCarrier{}).Hide(strings.NewReader(cover), secret, &stego); err != nil {
t.Fatalf("Hide cover %q: %v", cover, err)
}
got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal cover %q: %v", cover, err)
}
if !bytes.Equal(got, secret) {
t.Fatalf("cover %q: round-trip mismatch", cover)
}
}
}

View File

@ -0,0 +1,266 @@
/*
©AngelaMos | 2026
qr.go
QR carrier that hides a payload as Reed-Solomon-correctable errors so scanners self-heal to the cover
*/
package qr
import (
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/CarterPerez-dev/crypha/internal/carrier"
qrcode "github.com/skip2/go-qrcode"
)
const Format = "qr"
var (
ErrEmptyPayload = errors.New("crypha/qr: empty payload")
ErrCoverRequired = errors.New("crypha/qr: qr cover text is required")
ErrCoverTooLarge = errors.New("crypha/qr: cover text too large for the supported qr versions")
ErrPayloadTooLarge = errors.New("crypha/qr: payload exceeds carrier capacity")
ErrNoPayload = errors.New("crypha/qr: no crypha payload found")
ErrNotQR = errors.New("crypha/qr: stego is not a crypha qr image")
errBadSymbol = errors.New("crypha/qr: unexpected qr symbol geometry")
)
type qrCarrier struct{}
func init() {
carrier.Register(qrCarrier{})
}
func (qrCarrier) Format() string {
return Format
}
func (qrCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
if len(payload) == 0 {
return ErrEmptyPayload
}
coverText, err := io.ReadAll(cover)
if err != nil {
return fmt.Errorf("crypha/qr: read cover: %w", err)
}
if len(coverText) == 0 {
return ErrCoverRequired
}
if len(payload) > maxCapacity() {
return fmt.Errorf("%w: need %d bytes, max is %d", ErrPayloadTooLarge, len(payload), maxCapacity())
}
code, version, err := selectVersion(string(coverText), len(payload))
if err != nil {
return err
}
clean, err := matrixFromBitmap(code.Bitmap(), version)
if err != nil {
return err
}
maskID, level, ok := parseFormat(clean)
if !ok || level != ecLevelHigh {
return errBadSymbol
}
spec, _ := lookupVersion(version)
isFunc := functionModules(version)
order := placementOrder(version, isFunc)
serial := readSerial(clean, order, maskID, spec.totalCodewords())
dataBlocks, ecBlocks, ok := spec.deinterleave(serial)
if !ok {
return errBadSymbol
}
framed := frame(payload)
if err := injectFramed(dataBlocks, spec, framed); err != nil {
return err
}
stego := clean.clone()
writeSerial(stego, order, maskID, spec.interleave(dataBlocks, ecBlocks))
return renderPNG(stego, out)
}
func (qrCarrier) Reveal(stego io.Reader) ([]byte, error) {
m, version, err := readGrid(stego)
if err != nil {
return nil, err
}
maskID, level, ok := parseFormat(m)
if !ok || level != ecLevelHigh {
return nil, ErrNoPayload
}
spec, _ := lookupVersion(version)
isFunc := functionModules(version)
order := placementOrder(version, isFunc)
serial := readSerial(m, order, maskID, spec.totalCodewords())
dataBlocks, ecBlocks, ok := spec.deinterleave(serial)
if !ok {
return nil, ErrNoPayload
}
framed, err := extractFramed(dataBlocks, ecBlocks, spec)
if err != nil {
return nil, err
}
return unframe(framed)
}
func (qrCarrier) Capacity(cover io.Reader) (int, error) {
coverText, err := io.ReadAll(cover)
if err != nil {
return 0, fmt.Errorf("crypha/qr: read cover: %w", err)
}
if len(coverText) == 0 {
return 0, ErrCoverRequired
}
best := 0
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
if _, err := qrcode.NewWithForcedVersion(string(coverText), version, qrcode.Highest); err != nil {
continue
}
if c := versionTable[version].capacityBytes(); c > best {
best = c
}
}
return best, nil
}
func (qrCarrier) Sniff(stego io.ReadSeeker) bool {
m, _, err := readGrid(stego)
if err != nil {
return false
}
return hasFinderPatterns(m)
}
func selectVersion(coverText string, payloadLen int) (*qrcode.QRCode, int, error) {
coverFits := false
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
spec := versionTable[version]
code, err := qrcode.NewWithForcedVersion(coverText, version, qrcode.Highest)
if err != nil {
continue
}
coverFits = true
if spec.capacityBytes() < payloadLen {
continue
}
code.DisableBorder = true
return code, version, nil
}
if !coverFits {
return nil, 0, ErrCoverTooLarge
}
return nil, 0, fmt.Errorf("%w: need %d bytes", ErrPayloadTooLarge, payloadLen)
}
func matrixFromBitmap(bitmap [][]bool, version int) (matrix, error) {
size := symbolSize(version)
if len(bitmap) != size {
return matrix{}, errBadSymbol
}
m := newMatrix(size)
for y := 0; y < size; y++ {
if len(bitmap[y]) != size {
return matrix{}, errBadSymbol
}
copy(m.grid[y], bitmap[y])
}
return m, nil
}
func injectFramed(dataBlocks [][]byte, spec qrVersion, framed []byte) error {
nb := spec.numBlocks()
inject := spec.injectPerBlock()
for t := 0; t < len(framed); t++ {
block := t % nb
slot := t / nb
if slot >= inject || slot >= len(dataBlocks[block]) {
return fmt.Errorf("%w: framed length %d", ErrPayloadTooLarge, len(framed))
}
dataBlocks[block][slot] ^= framed[t]
}
return nil
}
func extractFramed(dataBlocks, ecBlocks [][]byte, spec qrVersion) ([]byte, error) {
nb := spec.numBlocks()
inject := spec.injectPerBlock()
ec := spec.ecPerBlock()
clean := make([][]byte, nb)
for b := 0; b < nb; b++ {
recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...)
corrected, err := rsDecode(recv, ec)
if err != nil {
return nil, ErrNoPayload
}
clean[b] = corrected[:len(dataBlocks[b])]
}
framed := make([]byte, nb*inject)
for t := 0; t < len(framed); t++ {
block := t % nb
slot := t / nb
framed[t] = clean[block][slot] ^ dataBlocks[block][slot]
}
return framed, nil
}
func frame(payload []byte) []byte {
out := make([]byte, framePrefixBytes+len(payload))
binary.BigEndian.PutUint32(out, uint32(len(payload)))
copy(out[framePrefixBytes:], payload)
return out
}
func unframe(framed []byte) ([]byte, error) {
if len(framed) < framePrefixBytes {
return nil, ErrNoPayload
}
length := binary.BigEndian.Uint32(framed)
if length == 0 || uint64(length) > uint64(len(framed)-framePrefixBytes) {
return nil, ErrNoPayload
}
out := make([]byte, length)
copy(out, framed[framePrefixBytes:framePrefixBytes+int(length)])
return out, nil
}
func hasFinderPatterns(m matrix) bool {
return isFinder(m, 0, 0) &&
isFinder(m, m.size-finderPatternSize, 0) &&
isFinder(m, 0, m.size-finderPatternSize)
}
func isFinder(m matrix, ox, oy int) bool {
if ox < 0 || oy < 0 || ox+finderPatternSize > m.size || oy+finderPatternSize > m.size {
return false
}
for y := 0; y < finderPatternSize; y++ {
for x := 0; x < finderPatternSize; x++ {
border := x == 0 || x == finderPatternSize-1 || y == 0 || y == finderPatternSize-1
center := x >= 2 && x <= 4 && y >= 2 && y <= 4
if m.grid[oy+y][ox+x] != (border || center) {
return false
}
}
}
return true
}
func maxCapacity() int {
return versionTable[maxSupportedVersion].capacityBytes()
}

View File

@ -0,0 +1,444 @@
/*
©AngelaMos | 2026
qr_test.go
Differential tests against skip2 and gozxing plus round-trip, capacity, sniff, and registry checks
*/
package qr
import (
"bytes"
"errors"
stdimage "image"
"image/png"
"strings"
"testing"
"github.com/CarterPerez-dev/crypha/internal/carrier"
"github.com/CarterPerez-dev/crypha/internal/payload"
gozxing "github.com/makiuchi-d/gozxing"
gozxingqr "github.com/makiuchi-d/gozxing/qrcode"
qrcode "github.com/skip2/go-qrcode"
)
type errReader struct{}
func (errReader) Read([]byte) (int, error) {
return 0, errors.New("crypha/qr test: forced read error")
}
const testCover = "crypha"
func qrRandom(n, seed int) []byte {
b := make([]byte, n)
x := uint32(seed)*2654435761 + 1
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
func skip2Clean(t *testing.T, cover string, version int) matrix {
t.Helper()
code, err := qrcode.NewWithForcedVersion(cover, version, qrcode.Highest)
if err != nil {
t.Fatalf("skip2 encode v%d: %v", version, err)
}
code.DisableBorder = true
m, err := matrixFromBitmap(code.Bitmap(), version)
if err != nil {
t.Fatalf("matrixFromBitmap v%d: %v", version, err)
}
return m
}
func hideReveal(t *testing.T, cover string, payloadBytes []byte) []byte {
t.Helper()
var stego bytes.Buffer
if err := (qrCarrier{}).Hide(strings.NewReader(cover), payloadBytes, &stego); err != nil {
t.Fatalf("Hide: %v", err)
}
got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
return got
}
func decodeWithGozxing(t *testing.T, pngBytes []byte) string {
t.Helper()
img, _, err := stdimage.Decode(bytes.NewReader(pngBytes))
if err != nil {
t.Fatalf("decode stego png: %v", err)
}
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
t.Fatalf("gozxing bitmap: %v", err)
}
res, err := gozxingqr.NewQRCodeReader().Decode(bmp, nil)
if err != nil {
t.Fatalf("gozxing decode: %v", err)
}
return res.GetText()
}
func TestFormatCodeKAT(t *testing.T) {
cases := map[int]int{
0: 0x5412,
1: 0x5125,
9: 0x72f3,
16: 0x1689,
31: 0x2bed,
}
for data, want := range cases {
if got := formatCode(data); got != want {
t.Fatalf("formatCode(%d): got %#x want %#x", data, got, want)
}
}
}
func TestCleanBlocksAreValidRSCodewords(t *testing.T) {
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
clean := skip2Clean(t, testCover, version)
maskID, level, ok := parseFormat(clean)
if !ok {
t.Fatalf("v%d: parseFormat failed", version)
}
if level != ecLevelHigh {
t.Fatalf("v%d: parsed level %d want %d", version, level, ecLevelHigh)
}
spec := versionTable[version]
order := placementOrder(version, functionModules(version))
if len(order) != spec.dataModules() {
t.Fatalf("v%d: placement visited %d modules want %d", version, len(order), spec.dataModules())
}
serial := readSerial(clean, order, maskID, spec.totalCodewords())
dataBlocks, ecBlocks, ok := spec.deinterleave(serial)
if !ok {
t.Fatalf("v%d: deinterleave failed", version)
}
for b := range dataBlocks {
recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...)
if !rsAllZero(rsSyndromes(recv, spec.ecPerBlock())) {
t.Fatalf("v%d block %d: extracted codeword is not a valid RS codeword", version, b)
}
}
}
}
func TestReadWriteSymmetry(t *testing.T) {
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
clean := skip2Clean(t, testCover, version)
maskID, _, _ := parseFormat(clean)
spec := versionTable[version]
order := placementOrder(version, functionModules(version))
serial := readSerial(clean, order, maskID, spec.totalCodewords())
dataBlocks, ecBlocks, _ := spec.deinterleave(serial)
rebuilt := clean.clone()
writeSerial(rebuilt, order, maskID, spec.interleave(dataBlocks, ecBlocks))
for y := 0; y < clean.size; y++ {
for x := 0; x < clean.size; x++ {
if rebuilt.grid[y][x] != clean.grid[y][x] {
t.Fatalf("v%d: re-render differs at (%d,%d)", version, x, y)
}
}
}
}
}
func TestRoundTrip(t *testing.T) {
cases := []struct {
name string
payload []byte
}{
{"single byte", []byte{0x42}},
{"embedded zeros", []byte{0x00, 0x00, 0xFF, 0x00, 0x7F}},
{"tiny text", []byte("hi")},
{"medium text", []byte("meet at the docks")},
{"twenty four bytes", bytes.Repeat([]byte{0xAB}, 24)},
{"twenty six bytes", bytes.Repeat([]byte{0x5A}, 26)},
{"high bits", bytes.Repeat([]byte{0xFF}, 40)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := hideReveal(t, testCover, tc.payload)
if !bytes.Equal(got, tc.payload) {
t.Fatalf("round-trip mismatch: got %x want %x", got, tc.payload)
}
})
}
}
func TestRandomBinaryRoundTrip(t *testing.T) {
for _, size := range []int{1, 3, 12, 27, 44, 52} {
payloadBytes := qrRandom(size, size*7+1)
got := hideReveal(t, testCover, payloadBytes)
if !bytes.Equal(got, payloadBytes) {
t.Fatalf("random round-trip mismatch at size %d", size)
}
}
}
func TestStegoDecodesToCoverViaGozxing(t *testing.T) {
covers := []string{"crypha", "https://angelamos.com", "HELLO WORLD 123"}
for _, cover := range covers {
var stego bytes.Buffer
if err := (qrCarrier{}).Hide(strings.NewReader(cover), qrRandom(16, len(cover)), &stego); err != nil {
t.Fatalf("Hide cover %q: %v", cover, err)
}
if got := decodeWithGozxing(t, stego.Bytes()); got != cover {
t.Fatalf("gozxing decoded stego to %q, want cover %q", got, cover)
}
}
}
func TestPerVersionMaxCapacityRoundTripAndScan(t *testing.T) {
for version := minSupportedVersion; version <= maxSupportedVersion; version++ {
capBytes := versionTable[version].capacityBytes()
if capBytes == 0 {
continue
}
payloadBytes := qrRandom(capBytes, version*101+7)
var stego bytes.Buffer
if err := (qrCarrier{}).Hide(strings.NewReader(testCover), payloadBytes, &stego); err != nil {
t.Fatalf("v%d Hide at capacity %d: %v", version, capBytes, err)
}
got, err := (qrCarrier{}).Reveal(bytes.NewReader(stego.Bytes()))
if err != nil {
t.Fatalf("v%d Reveal: %v", version, err)
}
if !bytes.Equal(got, payloadBytes) {
t.Fatalf("v%d: round-trip mismatch at max capacity", version)
}
if scanned := decodeWithGozxing(t, stego.Bytes()); scanned != testCover {
t.Fatalf("v%d: at full injection budget, gozxing decoded %q want cover %q", version, scanned, testCover)
}
}
}
func TestCleanQRHasNoPayload(t *testing.T) {
clean := skip2Clean(t, testCover, 6)
var buf bytes.Buffer
if err := renderPNG(clean, &buf); err != nil {
t.Fatalf("render clean: %v", err)
}
if _, err := (qrCarrier{}).Reveal(bytes.NewReader(buf.Bytes())); err != ErrNoPayload {
t.Fatalf("clean QR reveal: got %v want ErrNoPayload", err)
}
}
func TestCapacityBoundary(t *testing.T) {
atCap := bytes.Repeat([]byte{0x01}, maxCapacity())
if got := hideReveal(t, testCover, atCap); !bytes.Equal(got, atCap) {
t.Fatal("payload at exact capacity failed to round-trip")
}
over := bytes.Repeat([]byte{0x01}, maxCapacity()+1)
err := (qrCarrier{}).Hide(strings.NewReader(testCover), over, &bytes.Buffer{})
if err == nil {
t.Fatal("expected capacity error for oversized payload")
}
}
func TestCapacityReport(t *testing.T) {
got, err := (qrCarrier{}).Capacity(strings.NewReader(testCover))
if err != nil {
t.Fatalf("Capacity: %v", err)
}
if got != maxCapacity() {
t.Fatalf("Capacity: got %d want %d", got, maxCapacity())
}
}
func TestCapacityTooLargeCoverIsZero(t *testing.T) {
large := strings.Repeat("a", 200)
got, err := (qrCarrier{}).Capacity(strings.NewReader(large))
if err != nil {
t.Fatalf("Capacity: %v", err)
}
if got != 0 {
t.Fatalf("Capacity for a cover too large for any version: got %d want 0", got)
}
}
func TestEmptyPayloadRejected(t *testing.T) {
if err := (qrCarrier{}).Hide(strings.NewReader(testCover), nil, &bytes.Buffer{}); err != ErrEmptyPayload {
t.Fatalf("expected ErrEmptyPayload, got %v", err)
}
}
func TestEmptyCoverRejected(t *testing.T) {
if err := (qrCarrier{}).Hide(strings.NewReader(""), []byte("x"), &bytes.Buffer{}); err != ErrCoverRequired {
t.Fatalf("expected ErrCoverRequired, got %v", err)
}
}
func TestCoverTooLargeRejected(t *testing.T) {
huge := strings.Repeat("A", 4000)
err := (qrCarrier{}).Hide(strings.NewReader(huge), []byte("x"), &bytes.Buffer{})
if err != ErrCoverTooLarge {
t.Fatalf("expected ErrCoverTooLarge, got %v", err)
}
}
func TestEncryptedEnvelopeExceedsQRCapacity(t *testing.T) {
env, err := payload.Pack([]byte("secret"), payload.Options{
Passphrase: []byte("correct horse battery staple"),
Cipher: payload.CipherChaCha20,
Strength: payload.StrengthDefault,
})
if err != nil {
t.Fatalf("Pack: %v", err)
}
if len(env) <= maxCapacity() {
t.Fatalf("encrypted envelope is %d bytes, expected to exceed qr capacity %d", len(env), maxCapacity())
}
err = (qrCarrier{}).Hide(strings.NewReader(testCover), env, &bytes.Buffer{})
if err == nil {
t.Fatal("expected oversized encrypted envelope to be rejected")
}
}
func TestUnencryptedEnvelopeThroughCarrier(t *testing.T) {
secret := []byte("qr covert")
env, err := payload.Pack(secret, payload.Options{Compress: true})
if err != nil {
t.Fatalf("Pack: %v", err)
}
got := hideReveal(t, testCover, env)
if !bytes.Equal(got, env) {
t.Fatal("carrier did not return the exact envelope bytes")
}
plain, err := payload.Unpack(got, nil)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if !bytes.Equal(plain, secret) {
t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret)
}
}
func TestSniff(t *testing.T) {
var stego bytes.Buffer
if err := (qrCarrier{}).Hide(strings.NewReader(testCover), []byte("payload"), &stego); err != nil {
t.Fatalf("Hide: %v", err)
}
plain := stdimage.NewGray(stdimage.Rect(0, 0, 200, 200))
for i := range plain.Pix {
plain.Pix[i] = 0xFF
}
var notQR bytes.Buffer
if err := png.Encode(&notQR, plain); err != nil {
t.Fatalf("encode plain png: %v", err)
}
cases := []struct {
name string
data []byte
want bool
}{
{"stego qr", stego.Bytes(), true},
{"blank png", notQR.Bytes(), false},
{"garbage", []byte("not an image at all"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := (qrCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want {
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
}
})
}
}
func TestRevealRejectsGarbage(t *testing.T) {
if _, err := (qrCarrier{}).Reveal(bytes.NewReader([]byte("garbage"))); err == nil {
t.Fatal("expected decode error for garbage input")
}
plain := stdimage.NewGray(stdimage.Rect(0, 0, 200, 200))
for i := range plain.Pix {
plain.Pix[i] = 0xFF
}
var blank bytes.Buffer
if err := png.Encode(&blank, plain); err != nil {
t.Fatalf("encode blank: %v", err)
}
if _, err := (qrCarrier{}).Reveal(bytes.NewReader(blank.Bytes())); err == nil {
t.Fatal("blank png reveal: expected an error, got nil")
}
}
func TestRegisteredInRegistry(t *testing.T) {
c, ok := carrier.Get(Format)
if !ok {
t.Fatal("qr carrier did not self-register")
}
if c.Format() != Format {
t.Fatalf("registry returned wrong carrier: %s", c.Format())
}
}
func TestReadErrorsPropagate(t *testing.T) {
if err := (qrCarrier{}).Hide(errReader{}, []byte("x"), &bytes.Buffer{}); err == nil {
t.Fatal("Hide: expected cover read error")
}
if _, err := (qrCarrier{}).Capacity(errReader{}); err == nil {
t.Fatal("Capacity: expected cover read error")
}
if _, err := (qrCarrier{}).Reveal(errReader{}); err == nil {
t.Fatal("Reveal: expected stego read error")
}
}
func TestUnframeRejectsBadLength(t *testing.T) {
if _, err := unframe([]byte{0, 1}); err != ErrNoPayload {
t.Fatalf("short frame: got %v want ErrNoPayload", err)
}
if _, err := unframe([]byte{0, 0, 0, 0}); err != ErrNoPayload {
t.Fatalf("zero-length frame: got %v want ErrNoPayload", err)
}
if _, err := unframe([]byte{0, 0, 0, 10, 1, 2, 3}); err != ErrNoPayload {
t.Fatalf("overlong length prefix: got %v want ErrNoPayload", err)
}
}
func TestMatrixFromBitmapRejectsWrongSize(t *testing.T) {
tooSmall := make([][]bool, 10)
if _, err := matrixFromBitmap(tooSmall, 1); err != errBadSymbol {
t.Fatalf("wrong height: got %v want errBadSymbol", err)
}
raggedRows := make([][]bool, symbolSize(1))
for i := range raggedRows {
raggedRows[i] = make([]bool, 5)
}
if _, err := matrixFromBitmap(raggedRows, 1); err != errBadSymbol {
t.Fatalf("wrong width: got %v want errBadSymbol", err)
}
}
func TestInjectFramedRejectsOverflow(t *testing.T) {
spec := versionTable[2]
dataBlocks := [][]byte{make([]byte, spec.groups[0].data)}
oversized := make([]byte, spec.numBlocks()*spec.injectPerBlock()+1)
if err := injectFramed(dataBlocks, spec, oversized); !errors.Is(err, ErrPayloadTooLarge) {
t.Fatalf("overflow inject: got %v want ErrPayloadTooLarge", err)
}
}
func TestSniffRejectsNonFinderImage(t *testing.T) {
size := symbolSize(1)
dim := (size + 2*quietZoneModules) * modulePixels
white := stdimage.NewGray(stdimage.Rect(0, 0, dim, dim))
for i := range white.Pix {
white.Pix[i] = 0xFF
}
var buf bytes.Buffer
if err := png.Encode(&buf, white); err != nil {
t.Fatalf("encode white qr-sized png: %v", err)
}
if (qrCarrier{}).Sniff(bytes.NewReader(buf.Bytes())) {
t.Fatal("Sniff should reject a qr-sized image with no finder patterns")
}
}

View File

@ -0,0 +1,221 @@
/*
©AngelaMos | 2026
rs.go
Systematic Reed-Solomon over GF(2^8): encode plus a syndrome-Berlekamp-Massey decoder
*/
package qr
import "errors"
var (
ErrRSInput = errors.New("crypha/qr: invalid reed-solomon block")
ErrRSUncorrectable = errors.New("crypha/qr: reed-solomon block is uncorrectable")
)
func gfPolyEval(p []byte, x byte) byte {
y := p[0]
for i := 1; i < len(p); i++ {
y = gfMul(y, x) ^ p[i]
}
return y
}
func gfPolyScale(p []byte, s byte) []byte {
out := make([]byte, len(p))
for i := range p {
out[i] = gfMul(p[i], s)
}
return out
}
func gfPolyAdd(a, b []byte) []byte {
n := len(a)
if len(b) > n {
n = len(b)
}
out := make([]byte, n)
for i := range a {
out[i+n-len(a)] = a[i]
}
for i := range b {
out[i+n-len(b)] ^= b[i]
}
return out
}
func gfPolyMul(a, b []byte) []byte {
out := make([]byte, len(a)+len(b)-1)
for i := range a {
for j := range b {
out[i+j] ^= gfMul(a[i], b[j])
}
}
return out
}
func rsGenerator(nsym int) []byte {
g := []byte{1}
for i := 0; i < nsym; i++ {
g = gfPolyMul(g, []byte{1, gfPow(gfGenerator, i)})
}
return g
}
func rsEncode(data []byte, nsym int) []byte {
gen := rsGenerator(nsym)
out := make([]byte, len(data)+nsym)
copy(out, data)
for i := 0; i < len(data); i++ {
coef := out[i]
if coef != 0 {
for j := 1; j < len(gen); j++ {
out[i+j] ^= gfMul(gen[j], coef)
}
}
}
copy(out, data)
return out
}
func rsSyndromes(recv []byte, nsym int) []byte {
s := make([]byte, nsym)
for i := 0; i < nsym; i++ {
s[i] = gfPolyEval(recv, gfPow(gfGenerator, i))
}
return s
}
func rsAllZero(s []byte) bool {
for _, v := range s {
if v != 0 {
return false
}
}
return true
}
func rsErrorLocator(synd []byte) []byte {
errLoc := []byte{1}
oldLoc := []byte{1}
for i := 0; i < len(synd); i++ {
delta := synd[i]
for j := 1; j < len(errLoc); j++ {
delta ^= gfMul(errLoc[len(errLoc)-1-j], synd[i-j])
}
oldLoc = append(oldLoc, 0)
if delta != 0 {
if len(oldLoc) > len(errLoc) {
newLoc := gfPolyScale(oldLoc, delta)
oldLoc = gfPolyScale(errLoc, gfInv(delta))
errLoc = newLoc
}
errLoc = gfPolyAdd(errLoc, gfPolyScale(oldLoc, delta))
}
}
for len(errLoc) > 0 && errLoc[0] == 0 {
errLoc = errLoc[1:]
}
return errLoc
}
func rsErrorPositions(errLoc []byte, n int) ([]int, bool) {
numErr := len(errLoc) - 1
positions := make([]int, 0, numErr)
for p := 0; p < n; p++ {
locator := gfPow(gfGenerator, (n-1-p)%gfOrder)
if gfPolyEval(errLoc, gfInv(locator)) == 0 {
positions = append(positions, p)
}
}
if len(positions) != numErr {
return nil, false
}
return positions, true
}
func rsSolveMagnitudes(locators, synd []byte) ([]byte, bool) {
e := len(locators)
matrix := make([][]byte, e)
for row := 0; row < e; row++ {
matrix[row] = make([]byte, e+1)
for col := 0; col < e; col++ {
matrix[row][col] = gfPow(locators[col], row)
}
matrix[row][e] = synd[row]
}
for col := 0; col < e; col++ {
pivot := -1
for row := col; row < e; row++ {
if matrix[row][col] != 0 {
pivot = row
break
}
}
if pivot < 0 {
return nil, false
}
matrix[col], matrix[pivot] = matrix[pivot], matrix[col]
inv := gfInv(matrix[col][col])
for k := col; k <= e; k++ {
matrix[col][k] = gfMul(matrix[col][k], inv)
}
for row := 0; row < e; row++ {
if row != col && matrix[row][col] != 0 {
factor := matrix[row][col]
for k := col; k <= e; k++ {
matrix[row][k] ^= gfMul(factor, matrix[col][k])
}
}
}
}
magnitudes := make([]byte, e)
for row := 0; row < e; row++ {
magnitudes[row] = matrix[row][e]
}
for j := 0; j < len(synd); j++ {
var acc byte
for m := 0; m < e; m++ {
acc ^= gfMul(magnitudes[m], gfPow(locators[m], j))
}
if acc != synd[j] {
return nil, false
}
}
return magnitudes, true
}
func rsDecode(recv []byte, nsym int) ([]byte, error) {
if nsym <= 0 || len(recv) <= nsym || len(recv) > gfOrder {
return nil, ErrRSInput
}
synd := rsSyndromes(recv, nsym)
if rsAllZero(synd) {
return append([]byte(nil), recv...), nil
}
errLoc := rsErrorLocator(synd)
numErr := len(errLoc) - 1
if numErr <= 0 || numErr > nsym/2 {
return nil, ErrRSUncorrectable
}
positions, ok := rsErrorPositions(errLoc, len(recv))
if !ok {
return nil, ErrRSUncorrectable
}
locators := make([]byte, numErr)
for m, p := range positions {
locators[m] = gfPow(gfGenerator, (len(recv)-1-p)%gfOrder)
}
magnitudes, ok := rsSolveMagnitudes(locators, synd)
if !ok {
return nil, ErrRSUncorrectable
}
corrected := append([]byte(nil), recv...)
for m, p := range positions {
corrected[p] ^= magnitudes[m]
}
return corrected, nil
}

View File

@ -0,0 +1,199 @@
/*
©AngelaMos | 2026
rs_test.go
Field-table sanity plus Reed-Solomon encode/decode correctness under injected errors
*/
package qr
import (
"bytes"
"testing"
)
func rsRandom(n, seed int) []byte {
b := make([]byte, n)
x := uint32(seed)*2654435761 + 1
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
func TestGFFieldTables(t *testing.T) {
if gfExp[0] != 1 {
t.Fatalf("gfExp[0]: got %d want 1", gfExp[0])
}
if gfExp[8] != 29 {
t.Fatalf("gfExp[8]: got %d want 29 (primitive 0x11D)", gfExp[8])
}
if gfMul(2, 2) != 4 {
t.Fatalf("gfMul(2,2): got %d want 4", gfMul(2, 2))
}
if gfPow(2, 8) != 29 {
t.Fatalf("gfPow(2,8): got %d want 29", gfPow(2, 8))
}
for a := 1; a < gfFieldSize; a++ {
if gfMul(byte(a), gfInv(byte(a))) != 1 {
t.Fatalf("gfInv broken at %d", a)
}
}
for a := 1; a < gfFieldSize; a++ {
for b := 1; b < gfFieldSize; b++ {
if gfMul(byte(a), byte(b)) != gfMul(byte(b), byte(a)) {
t.Fatalf("gfMul not commutative at %d,%d", a, b)
}
}
}
}
func TestGFPowEdges(t *testing.T) {
if gfPow(gfGenerator, 0) != 1 {
t.Fatalf("gfPow(2,0): got %d want 1", gfPow(gfGenerator, 0))
}
if gfPow(gfGenerator, gfOrder) != 1 {
t.Fatalf("gfPow(2,255): got %d want 1", gfPow(gfGenerator, gfOrder))
}
if gfPow(0, 0) != 1 {
t.Fatalf("gfPow(0,0): got %d want 1", gfPow(0, 0))
}
if gfPow(0, 5) != 0 {
t.Fatalf("gfPow(0,5): got %d want 0", gfPow(0, 5))
}
}
func TestRSGeneratorIsMonic(t *testing.T) {
for _, nsym := range []int{7, 17, 22, 28} {
gen := rsGenerator(nsym)
if len(gen) != nsym+1 {
t.Fatalf("generator degree: nsym=%d got len %d", nsym, len(gen))
}
if gen[0] != 1 {
t.Fatalf("generator not monic: nsym=%d leading %d", nsym, gen[0])
}
}
}
func TestRSCleanRoundTrip(t *testing.T) {
cases := []struct{ k, nsym int }{
{9, 17},
{13, 22},
{16, 28},
{15, 28},
}
for _, tc := range cases {
data := rsRandom(tc.k, tc.k*tc.nsym)
code := rsEncode(data, tc.nsym)
if len(code) != tc.k+tc.nsym {
t.Fatalf("encode length: got %d want %d", len(code), tc.k+tc.nsym)
}
if !bytes.Equal(code[:tc.k], data) {
t.Fatal("encode is not systematic (data prefix altered)")
}
got, err := rsDecode(code, tc.nsym)
if err != nil {
t.Fatalf("decode clean codeword: %v", err)
}
if !bytes.Equal(got[:tc.k], data) {
t.Fatal("clean decode did not return data")
}
}
}
func TestRSCorrectsUpToT(t *testing.T) {
cases := []struct{ k, nsym int }{
{9, 17},
{13, 22},
{16, 28},
}
for _, tc := range cases {
n := tc.k + tc.nsym
maxErr := tc.nsym / 2
data := rsRandom(tc.k, tc.k+7*tc.nsym)
clean := rsEncode(data, tc.nsym)
for numErr := 1; numErr <= maxErr; numErr++ {
corrupt := append([]byte(nil), clean...)
offsets := rsRandom(numErr, numErr*97+tc.nsym)
mags := rsRandom(numErr, numErr*131+tc.k)
used := map[int]bool{}
placed := 0
for i := 0; placed < numErr; i++ {
pos := int(offsets[placed%numErr]) % n
pos = (pos + i) % n
if used[pos] {
continue
}
mag := mags[placed%numErr]
if mag == 0 {
mag = 1
}
corrupt[pos] ^= mag
used[pos] = true
placed++
}
got, err := rsDecode(corrupt, tc.nsym)
if err != nil {
t.Fatalf("k=%d nsym=%d numErr=%d: decode failed: %v", tc.k, tc.nsym, numErr, err)
}
if !bytes.Equal(got[:tc.k], data) {
t.Fatalf("k=%d nsym=%d numErr=%d: did not recover data", tc.k, tc.nsym, numErr)
}
}
}
}
func TestRSDoesNotFakeCorrectBeyondT(t *testing.T) {
k, nsym := 13, 22
n := k + nsym
data := rsRandom(k, 999)
clean := rsEncode(data, nsym)
tooMany := nsym/2 + 1
for seed := 0; seed < 16; seed++ {
corrupt := append([]byte(nil), clean...)
offsets := rsRandom(tooMany, seed*17+3)
mags := rsRandom(tooMany, seed*29+5)
used := map[int]bool{}
placed := 0
for i := 0; placed < tooMany; i++ {
pos := (int(offsets[placed%tooMany]) + i) % n
if used[pos] {
continue
}
mag := mags[placed%tooMany]
if mag == 0 {
mag = 1
}
corrupt[pos] ^= mag
used[pos] = true
placed++
}
got, err := rsDecode(corrupt, nsym)
if err == nil && bytes.Equal(got[:k], data) {
t.Fatalf("seed %d: decoder silently recovered original from %d errors (> t)", seed, tooMany)
}
}
}
func TestGFPolyAddAsymmetric(t *testing.T) {
want := []byte{1, 1, 0}
if got := gfPolyAdd([]byte{1, 0, 1}, []byte{1, 1}); !bytes.Equal(got, want) {
t.Fatalf("gfPolyAdd longer-first: got %v want %v", got, want)
}
if got := gfPolyAdd([]byte{1, 1}, []byte{1, 0, 1}); !bytes.Equal(got, want) {
t.Fatalf("gfPolyAdd longer-second: got %v want %v", got, want)
}
}
func TestRSRejectsMalformedBlocks(t *testing.T) {
if _, err := rsDecode([]byte{1, 2, 3}, 0); err != ErrRSInput {
t.Fatalf("nsym=0: got %v want ErrRSInput", err)
}
if _, err := rsDecode([]byte{1, 2, 3}, 3); err != ErrRSInput {
t.Fatalf("recv==nsym: got %v want ErrRSInput", err)
}
if _, err := rsDecode([]byte{1, 2, 3}, 5); err != ErrRSInput {
t.Fatalf("recv<nsym: got %v want ErrRSInput", err)
}
}

View File

@ -0,0 +1,190 @@
/*
©AngelaMos | 2026
text.go
Zero-width Unicode text carrier that appends an invisible framed payload after cover text
*/
package text
import (
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"unicode/utf8"
"github.com/CarterPerez-dev/crypha/internal/bitio"
"github.com/CarterPerez-dev/crypha/internal/carrier"
)
const (
Format = "text"
zeroRune rune = 0x200B
oneRune rune = 0x2060
bitsPerByte = 8
lengthBytes = 4
lengthBits = lengthBytes * bitsPerByte
unboundedCapacity = math.MaxInt32
)
var (
textMagic = [4]byte{0x7A, 0x57, 0x43, 0x52}
textMagicBits = bytesToBits(textMagic[:])
)
var (
ErrEmptyPayload = errors.New("crypha/text: empty payload")
ErrNoPayload = errors.New("crypha/text: no crypha payload found")
)
type textCarrier struct{}
func init() {
carrier.Register(textCarrier{})
}
func (textCarrier) Format() string {
return Format
}
func (textCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
if len(payload) == 0 {
return ErrEmptyPayload
}
coverBytes, err := io.ReadAll(cover)
if err != nil {
return fmt.Errorf("crypha/text: read cover: %w", err)
}
frame := make([]byte, 0, len(textMagic)+lengthBytes+len(payload))
frame = append(frame, textMagic[:]...)
var lenField [lengthBytes]byte
binary.BigEndian.PutUint32(lenField[:], uint32(len(payload)))
frame = append(frame, lenField[:]...)
frame = append(frame, payload...)
zw := make([]byte, 0, len(frame)*bitsPerByte*utf8.UTFMax)
reader := bitio.NewReader(frame)
for {
bit, rerr := reader.ReadBit()
if rerr != nil {
break
}
zw = utf8.AppendRune(zw, runeForBit(bit))
}
if _, err := out.Write(coverBytes); err != nil {
return err
}
_, err = out.Write(zw)
return err
}
func (textCarrier) Reveal(stego io.Reader) ([]byte, error) {
data, err := io.ReadAll(stego)
if err != nil {
return nil, fmt.Errorf("crypha/text: read stego: %w", err)
}
payload, ok := findFrame(extractBits(data))
if !ok {
return nil, ErrNoPayload
}
return payload, nil
}
func (textCarrier) Capacity(_ io.Reader) (int, error) {
return unboundedCapacity, nil
}
func (textCarrier) Sniff(stego io.ReadSeeker) bool {
data, err := io.ReadAll(stego)
if err != nil {
return false
}
_, ok := findFrame(extractBits(data))
return ok
}
func runeForBit(bit byte) rune {
if bit == 1 {
return oneRune
}
return zeroRune
}
func findFrame(bits []byte) ([]byte, bool) {
for start := 0; start+len(textMagicBits) <= len(bits); start++ {
if !matchAt(bits, textMagicBits, start) {
continue
}
if payload, ok := parseFrame(bits, start+len(textMagicBits)); ok {
return payload, true
}
}
return nil, false
}
func extractBits(data []byte) []byte {
bits := make([]byte, 0, len(data))
for _, r := range string(data) {
switch r {
case zeroRune:
bits = append(bits, 0)
case oneRune:
bits = append(bits, 1)
}
}
return bits
}
func bytesToBits(b []byte) []byte {
out := make([]byte, 0, len(b)*bitsPerByte)
for _, by := range b {
for shift := bitsPerByte - 1; shift >= 0; shift-- {
out = append(out, (by>>uint(shift))&1)
}
}
return out
}
func bitsToBytes(bits []byte) []byte {
writer := bitio.NewWriter()
for _, bit := range bits {
writer.WriteBit(bit)
}
return writer.Bytes()
}
func matchAt(haystack, needle []byte, at int) bool {
if at+len(needle) > len(haystack) {
return false
}
for i := range needle {
if haystack[at+i] != needle[i] {
return false
}
}
return true
}
func parseFrame(bits []byte, headerStart int) ([]byte, bool) {
if headerStart+lengthBits > len(bits) {
return nil, false
}
length := binary.BigEndian.Uint32(bitsToBytes(bits[headerStart : headerStart+lengthBits]))
if length == 0 {
return nil, false
}
payloadStart := headerStart + lengthBits
remaining := len(bits) - payloadStart
if uint64(length)*bitsPerByte != uint64(remaining) {
return nil, false
}
return bitsToBytes(bits[payloadStart:]), true
}

View File

@ -0,0 +1,313 @@
/*
©AngelaMos | 2026
text_test.go
Round-trip, incidental-zero-width, normalization, and framing tests for the text carrier
*/
package text
import (
"bytes"
"io"
"math"
"strings"
"testing"
"unicode/utf8"
"github.com/CarterPerez-dev/crypha/internal/carrier"
"github.com/CarterPerez-dev/crypha/internal/payload"
"golang.org/x/text/unicode/norm"
)
func hide(t *testing.T, cover string, data []byte) []byte {
t.Helper()
var out bytes.Buffer
if err := (textCarrier{}).Hide(strings.NewReader(cover), data, &out); err != nil {
t.Fatalf("Hide: %v", err)
}
return out.Bytes()
}
func reveal(t *testing.T, stego []byte) []byte {
t.Helper()
got, err := (textCarrier{}).Reveal(bytes.NewReader(stego))
if err != nil {
t.Fatalf("Reveal: %v", err)
}
return got
}
func pseudoRandom(n, seed int) []byte {
b := make([]byte, n)
x := uint32(seed)*2654435761 + 1
for i := range b {
x = x*1664525 + 1013904223
b[i] = byte(x >> 24)
}
return b
}
func TestRoundTripCovers(t *testing.T) {
cases := []struct {
name string
cover string
}{
{"empty cover", ""},
{"ascii", "the quick brown fox"},
{"multiline", "line one\nline two\nline three\n"},
{"unicode cover", "café naïve 你好 \U0001F600 text"},
{"whitespace only", " \t\n "},
}
payloadBytes := []byte("attack at dawn")
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stego := hide(t, tc.cover, payloadBytes)
got := reveal(t, stego)
if !bytes.Equal(got, payloadBytes) {
t.Fatalf("round-trip mismatch: got %q want %q", got, payloadBytes)
}
})
}
}
func TestRandomBinaryRoundTrip(t *testing.T) {
cover := "cover text that stays visible"
for _, size := range []int{1, 7, 64, 1000} {
data := pseudoRandom(size, size)
got := reveal(t, hide(t, cover, data))
if !bytes.Equal(got, data) {
t.Fatalf("random round-trip mismatch at size %d", size)
}
}
}
func TestCoverStaysVisible(t *testing.T) {
cover := "this text is unchanged"
data := []byte("hidden")
stego := hide(t, cover, data)
if !bytes.HasPrefix(stego, []byte(cover)) {
t.Fatal("cover bytes were altered")
}
suffix := stego[len(cover):]
for _, r := range string(suffix) {
if r != zeroRune && r != oneRune {
t.Fatalf("appended data contains a non-carrier rune: U+%04X", r)
}
}
visible := strings.Map(func(r rune) rune {
if r == zeroRune || r == oneRune {
return -1
}
return r
}, string(stego))
if visible != cover {
t.Fatalf("stripping carrier runes did not restore the cover: got %q", visible)
}
}
func TestIncidentalZeroWidthInCover(t *testing.T) {
cover := "prefix" + string(zeroRune) + string(oneRune) + string(zeroRune) + "suffix"
data := []byte("payload survives noise")
got := reveal(t, hide(t, cover, data))
if !bytes.Equal(got, data) {
t.Fatalf("incidental zero-width broke extraction: got %q", got)
}
}
func TestNFCNormalizationSurvival(t *testing.T) {
cover := "café test cover"
data := []byte("normalization is not a threat")
stego := hide(t, cover, data)
for _, form := range []norm.Form{norm.NFC, norm.NFD, norm.NFKC, norm.NFKD} {
normalized := form.Bytes(stego)
got, err := (textCarrier{}).Reveal(bytes.NewReader(normalized))
if err != nil {
t.Fatalf("Reveal after normalization: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("payload lost through normalization form: got %q", got)
}
}
}
func TestCapacityUnbounded(t *testing.T) {
got, err := (textCarrier{}).Capacity(strings.NewReader("anything"))
if err != nil {
t.Fatalf("Capacity: %v", err)
}
if got != math.MaxInt32 {
t.Fatalf("Capacity: got %d want unbounded (%d)", got, math.MaxInt32)
}
}
func TestEmptyPayloadRejected(t *testing.T) {
var out bytes.Buffer
if err := (textCarrier{}).Hide(strings.NewReader("cover"), nil, &out); err != ErrEmptyPayload {
t.Fatalf("expected ErrEmptyPayload, got %v", err)
}
}
func TestRevealNoPayload(t *testing.T) {
cases := []struct {
name string
stego string
}{
{"plain text", "just some ordinary text with no secrets"},
{"empty", ""},
{"incidental zero-width without magic", "a" + string(zeroRune) + string(oneRune) + "b"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := (textCarrier{}).Reveal(strings.NewReader(tc.stego)); err != ErrNoPayload {
t.Fatalf("expected ErrNoPayload, got %v", err)
}
})
}
}
func TestSniff(t *testing.T) {
framed := hide(t, "cover", []byte("secret"))
cases := []struct {
name string
data []byte
want bool
}{
{"framed", framed, true},
{"plain", []byte("nothing hidden here"), false},
{"incidental zw no magic", []byte("x" + string(zeroRune) + string(oneRune) + "y"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := (textCarrier{}).Sniff(bytes.NewReader(tc.data)); got != tc.want {
t.Fatalf("Sniff(%s): got %v want %v", tc.name, got, tc.want)
}
})
}
}
func TestOverheadRatio(t *testing.T) {
data := []byte{0x00}
stego := hide(t, "", data)
runeCount := utf8.RuneCount(stego)
wantRunes := (len(textMagic) + lengthBytes + len(data)) * bitsPerByte
if runeCount != wantRunes {
t.Fatalf("carrier-rune count: got %d want %d", runeCount, wantRunes)
}
}
func TestEncryptedEnvelopeThroughCarrier(t *testing.T) {
secret := []byte("the account number is 4815162342")
envelope, err := payload.Pack(secret, payload.Options{
Passphrase: []byte("open sesame"),
Compress: true,
Cipher: payload.CipherChaCha20,
Strength: payload.StrengthDefault,
})
if err != nil {
t.Fatalf("Pack: %v", err)
}
stego := hide(t, "innocuous cover message", envelope)
recovered := reveal(t, stego)
if !bytes.Equal(recovered, envelope) {
t.Fatal("carrier did not return the exact envelope bytes")
}
plain, err := payload.Unpack(recovered, []byte("open sesame"))
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if !bytes.Equal(plain, secret) {
t.Fatalf("end-to-end mismatch: got %q want %q", plain, secret)
}
}
type errReader struct{}
func (errReader) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF }
type errWriter struct{}
func (errWriter) Write(_ []byte) (int, error) { return 0, io.ErrShortWrite }
func TestHideWriteError(t *testing.T) {
if err := (textCarrier{}).Hide(strings.NewReader("cover"), []byte("x"), errWriter{}); err != io.ErrShortWrite {
t.Fatalf("expected write error to propagate, got %v", err)
}
}
func TestReadErrorsPropagate(t *testing.T) {
if _, err := (textCarrier{}).Reveal(errReader{}); err == nil {
t.Fatal("expected Reveal read error")
}
if (textCarrier{}).Sniff(readSeekerErr{}) {
t.Fatal("Sniff should be false when the reader errors")
}
}
type readSeekerErr struct{}
func (readSeekerErr) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func (readSeekerErr) Seek(_ int64, _ int) (int64, error) { return 0, nil }
func zeroWidth(b []byte) string {
var sb strings.Builder
for _, bit := range bytesToBits(b) {
sb.WriteRune(runeForBit(bit))
}
return sb.String()
}
func TestCorruptFramesRejected(t *testing.T) {
cases := []struct {
name string
stego string
}{
{"zero length field", zeroWidth(append(append([]byte{}, textMagic[:]...), 0, 0, 0, 0))},
{"truncated length field", zeroWidth(textMagic[:]) + strings.Repeat(string(zeroRune), 10)},
{"length exceeds payload bits", zeroWidth(append(append([]byte{}, textMagic[:]...), 0, 0, 0, 100)) + strings.Repeat(string(oneRune), bitsPerByte)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := (textCarrier{}).Reveal(strings.NewReader(tc.stego)); err != ErrNoPayload {
t.Fatalf("expected ErrNoPayload, got %v", err)
}
})
}
}
func TestNestedStegoReturnsAppendedFrame(t *testing.T) {
first := []byte("first hidden layer")
stegoA := hide(t, "public cover text", first)
if got := reveal(t, stegoA); !bytes.Equal(got, first) {
t.Fatalf("layer A: got %q want %q", got, first)
}
second := []byte("second layer wins")
stegoB := hide(t, string(stegoA), second)
if got := reveal(t, stegoB); !bytes.Equal(got, second) {
t.Fatalf("nested reveal must return the appended frame: got %q want %q", got, second)
}
}
func TestPayloadContainingMagicRoundTrips(t *testing.T) {
data := append(append([]byte("head"), textMagic[:]...), []byte("tail after magic bytes")...)
got := reveal(t, hide(t, "cover", data))
if !bytes.Equal(got, data) {
t.Fatalf("payload containing magic bytes corrupted: got %q want %q", got, data)
}
}
func TestRegisteredInRegistry(t *testing.T) {
c, ok := carrier.Get(Format)
if !ok {
t.Fatal("text carrier did not self-register")
}
if c.Format() != Format {
t.Fatalf("registry returned wrong carrier: %s", c.Format())
}
}

View File

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

View File

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

View File

@ -0,0 +1,68 @@
/*
©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"
cmdTUI = "tui"
flagJSON = "json"
flagFormat = "format"
flagIn = "in"
flagOut = "out"
flagMessage = "message"
flagFile = "file"
flagEncrypt = "encrypt"
flagPassphrase = "passphrase"
flagCompress = "compress"
flagCipher = "cipher"
flagStrength = "strength"
flagTechnique = "technique"
shortIn = "i"
shortOut = "o"
shortMessage = "m"
shortFile = "f"
shortPassphrase = "k"
envPassphrase = "CRYPHA_PASSPHRASE"
stdioPath = "-"
stdoutName = "(stdout)"
outFilePerm = 0o600
)
func markRequired(cmd *cobra.Command, names ...string) {
for _, name := range names {
_ = cmd.MarkFlagRequired(name)
}
}
func jsonEnabled(cmd *cobra.Command) bool {
on, _ := cmd.Flags().GetBool(flagJSON)
return on
}
func pathFromFlagOrArg(flagValue string, args []string) (string, error) {
if flagValue != "" && len(args) == 1 {
return "", errAmbiguousPath
}
if flagValue != "" {
return flagValue, nil
}
if len(args) == 1 {
return args[0], nil
}
return "", nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@
/*
©AngelaMos | 2026
root.go
Cobra root command and CLI entry wiring for crypha
*/
package cli
import (
"fmt"
"os"
"strings"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/CarterPerez-dev/crypha/internal/tui"
"github.com/spf13/cobra"
)
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.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && launchInteractive() {
return tui.Run()
}
return cmd.Help()
}
root.AddCommand(
newHideCmd(),
newRevealCmd(),
newCapacityCmd(),
newFormatsCmd(),
newVersionCmd(),
newTuiCmd(),
)
return root
}
func Execute() {
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)
}
}

View File

@ -0,0 +1,31 @@
/*
©AngelaMos | 2026
tui.go
The tui command plus the terminal check that launches the wizard on a bare invocation
*/
package cli
import (
"os"
"github.com/CarterPerez-dev/crypha/internal/tui"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var launchInteractive = func() bool {
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
}
func newTuiCmd() *cobra.Command {
return &cobra.Command{
Use: cmdTUI,
Short: "Launch the interactive terminal wizard",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return tui.Run()
},
}
}

View File

@ -0,0 +1,40 @@
/*
©AngelaMos | 2026
tui_test.go
Tests for the tui command registration and the bare-invocation help fallback
*/
package cli
import (
"strings"
"testing"
)
func TestBareInvocationPrintsHelpWhenNonInteractive(t *testing.T) {
old := launchInteractive
launchInteractive = func() bool { return false }
defer func() { launchInteractive = old }()
out, _, err := run(t, "")
if err != nil {
t.Fatalf("bare invocation errored: %v", err)
}
if !strings.Contains(out, cmdTUI) {
t.Fatalf("help output missing the tui command:\n%s", out)
}
if !strings.Contains(out, "Available Commands") {
t.Fatalf("bare invocation did not print help:\n%s", out)
}
}
func TestTuiCommandRegistered(t *testing.T) {
root := newRootCmd()
for _, c := range root.Commands() {
if c.Name() == cmdTUI {
return
}
}
t.Fatalf("tui command not registered on root")
}

View File

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

View File

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

View File

@ -0,0 +1,17 @@
/*
©AngelaMos | 2026
config.go
Central constants for crypha so no magic numbers or strings live elsewhere
*/
package config
const (
BinaryName = "crypha"
Version = "0.1.0"
ShortDescription = "Multi-format steganography for images, audio, QR, text, and PDFs"
LongDescription = "crypha hides an encrypted payload inside five carrier types " +
"(image, audio, QR, zero-width text, PDF) and extracts it back, " +
"from a scriptable CLI or an interactive TUI."
)

View File

@ -0,0 +1,213 @@
/*
©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 Overhead(encrypted bool) int {
return payload.Overhead(encrypted)
}
func EnvelopeSize(data []byte, opts payload.Options) (int, error) {
return payload.EnvelopeSize(data, opts)
}
func locate(format string, stego []byte) (carrier.Carrier, []byte, error) {
if format != "" {
c, err := ResolveCarrier(format, "")
if err != nil {
return nil, nil, err
}
env, err := c.Reveal(bytes.NewReader(stego))
if err != nil {
return nil, nil, err
}
return c, env, nil
}
return detect(stego)
}
func detect(stego []byte) (carrier.Carrier, []byte, error) {
for _, c := range carrier.All() {
if !c.Sniff(bytes.NewReader(stego)) {
continue
}
env, err := c.Reveal(bytes.NewReader(stego))
if err != nil {
continue
}
if payload.Validate(env) == nil {
return c, env, nil
}
}
return nil, nil, ErrUndetected
}

View File

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

View File

@ -0,0 +1,19 @@
/*
©AngelaMos | 2026
overhead_test.go
Known-answer test tying the engine overhead helper to the documented envelope sizes
*/
package engine
import "testing"
func TestOverheadKnownValues(t *testing.T) {
if got := Overhead(false); got != 14 {
t.Fatalf("Overhead(false) = %d, want 14", got)
}
if got := Overhead(true); got != 68 {
t.Fatalf("Overhead(true) = %d, want 68", got)
}
}

View File

@ -0,0 +1,35 @@
/*
©AngelaMos | 2026
compress.go
Optional flate compression applied before encryption in the payload envelope
*/
package payload
import (
"bytes"
"compress/flate"
"io"
)
func compress(data []byte) ([]byte, error) {
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.BestCompression)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func decompress(data []byte) ([]byte, error) {
r := flate.NewReader(bytes.NewReader(data))
defer func() { _ = r.Close() }()
return io.ReadAll(r)
}

View File

@ -0,0 +1,75 @@
/*
©AngelaMos | 2026
crypto.go
Argon2id key derivation and AEAD selection for the payload envelope
*/
package payload
import (
"crypto/aes"
"crypto/cipher"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
)
type kdfParams struct {
time uint32
memory uint32
threads uint8
}
func paramsForStrength(s Strength) kdfParams {
if s == StrengthHigh {
return kdfParams{time: argonHighTime, memory: argonHighMemory, threads: argonThreads}
}
return kdfParams{time: argonDefaultTime, memory: argonDefaultMemory, threads: argonThreads}
}
func (p kdfParams) valid() bool {
if p.threads == 0 || p.time == 0 {
return false
}
if p.memory > argonMaxMemory {
return false
}
return p.memory >= 8*uint32(p.threads)
}
func deriveKey(passphrase, salt []byte, p kdfParams) []byte {
return argon2.IDKey(passphrase, salt, p.time, p.memory, p.threads, keyLen)
}
func newAEAD(c Cipher, key []byte) (cipher.AEAD, byte, error) {
switch c {
case CipherAES256GCM:
aead, err := gcm(key)
return aead, cipherIDAES256GCM, err
case CipherChaCha20, "":
aead, err := chacha20poly1305.New(key)
return aead, cipherIDChaCha20, err
default:
return nil, 0, ErrUnknownCipher
}
}
func aeadByID(id byte, key []byte) (cipher.AEAD, error) {
switch id {
case cipherIDAES256GCM:
return gcm(key)
case cipherIDChaCha20:
return chacha20poly1305.New(key)
default:
return nil, ErrUnknownCipher
}
}
func gcm(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}

View File

@ -0,0 +1,202 @@
/*
©AngelaMos | 2026
envelope.go
Pack and unpack the crypha payload envelope
Layout: magic(4) ver(1) flags(1) [cipher(1) params(9) salt(16) nonce(12) if encrypted]
len(4) body(N) crc32(4). The header up to nonce is the AEAD additional data.
*/
package payload
import (
"bytes"
"crypto/rand"
"encoding/binary"
"hash/crc32"
)
func Pack(data []byte, opts Options) ([]byte, error) {
if len(data) == 0 {
return nil, ErrEmptyPayload
}
var flags byte
body := data
if opts.Compress {
c, err := compress(body)
if err != nil {
return nil, err
}
body = c
flags |= flagCompressed
}
header := new(bytes.Buffer)
header.Write(magic[:])
header.WriteByte(currentVersion)
if len(opts.Passphrase) > 0 {
flags |= flagEncrypted
header.WriteByte(flags)
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
params := paramsForStrength(opts.Strength)
key := deriveKey(opts.Passphrase, salt, params)
aead, cipherID, err := newAEAD(opts.Cipher, key)
if err != nil {
return nil, err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
header.WriteByte(cipherID)
var pbuf [paramsLen]byte
binary.BigEndian.PutUint32(pbuf[0:4], params.time)
binary.BigEndian.PutUint32(pbuf[4:8], params.memory)
pbuf[8] = params.threads
header.Write(pbuf[:])
header.Write(salt)
header.Write(nonce)
body = aead.Seal(nil, nonce, body, header.Bytes())
} else {
header.WriteByte(flags)
}
out := new(bytes.Buffer)
out.Write(header.Bytes())
var meta [lenField]byte
binary.BigEndian.PutUint32(meta[:], uint32(len(body)))
out.Write(meta[:])
out.Write(body)
binary.BigEndian.PutUint32(meta[:], crc32.ChecksumIEEE(body))
out.Write(meta[:])
return out.Bytes(), nil
}
type parsed struct {
flags byte
aad []byte
cipherID byte
params kdfParams
salt []byte
nonce []byte
body []byte
}
func parse(env []byte) (*parsed, error) {
off := 0
remaining := func(n int) bool { return off+n <= len(env) }
if !remaining(len(magic)) || !bytes.Equal(env[0:len(magic)], magic[:]) {
return nil, ErrBadMagic
}
off = len(magic)
if !remaining(1) {
return nil, ErrTruncated
}
if env[off] != currentVersion {
return nil, ErrUnsupportedVersion
}
off++
if !remaining(1) {
return nil, ErrTruncated
}
p := &parsed{flags: env[off]}
off++
if p.flags&flagEncrypted != 0 {
if !remaining(1 + paramsLen + saltLen + nonceLen) {
return nil, ErrTruncated
}
p.cipherID = env[off]
off++
p.params = kdfParams{
time: binary.BigEndian.Uint32(env[off : off+4]),
memory: binary.BigEndian.Uint32(env[off+4 : off+8]),
threads: env[off+8],
}
off += paramsLen
if !p.params.valid() {
return nil, ErrBadParams
}
p.salt = env[off : off+saltLen]
off += saltLen
p.nonce = env[off : off+nonceLen]
off += nonceLen
p.aad = env[0:off]
}
if !remaining(lenField) {
return nil, ErrTruncated
}
bodyLen := int(binary.BigEndian.Uint32(env[off : off+lenField]))
off += lenField
if bodyLen < 0 || !remaining(bodyLen) {
return nil, ErrTruncated
}
p.body = env[off : off+bodyLen]
off += bodyLen
if !remaining(crcField) {
return nil, ErrTruncated
}
if crc32.ChecksumIEEE(p.body) != binary.BigEndian.Uint32(env[off:off+crcField]) {
return nil, ErrChecksumMismatch
}
return p, nil
}
func Unpack(env, passphrase []byte) ([]byte, error) {
p, err := parse(env)
if err != nil {
return nil, err
}
body := p.body
if p.flags&flagEncrypted != 0 {
if len(passphrase) == 0 {
return nil, ErrPassphraseRequired
}
key := deriveKey(passphrase, p.salt, p.params)
aead, err := aeadByID(p.cipherID, key)
if err != nil {
return nil, err
}
plain, err := aead.Open(nil, p.nonce, body, p.aad)
if err != nil {
return nil, ErrDecrypt
}
body = plain
}
if p.flags&flagCompressed != 0 {
return decompress(body)
}
return body, nil
}
func Validate(env []byte) error {
_, err := parse(env)
return err
}
func IsEncrypted(env []byte) (bool, error) {
p, err := parse(env)
if err != nil {
return false, err
}
return p.flags&flagEncrypted != 0, nil
}

View File

@ -0,0 +1,178 @@
/*
©AngelaMos | 2026
envelope_test.go
Round-trip, integrity, and hardening tests for the payload envelope
*/
package payload
import (
"bytes"
"errors"
"testing"
)
var secret = []byte("the eagle lands at midnight -- coordinates 41.40N 2.17E")
func TestRoundTrip(t *testing.T) {
pass := []byte("correct horse battery staple")
cases := []struct {
name string
opts Options
}{
{"plain", Options{}},
{"compressed", Options{Compress: true}},
{"encrypted-chacha", Options{Passphrase: pass}},
{"encrypted-aes", Options{Passphrase: pass, Cipher: CipherAES256GCM}},
{"encrypted-compressed", Options{Passphrase: pass, Compress: true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env, err := Pack(secret, tc.opts)
if err != nil {
t.Fatalf("Pack: %v", err)
}
got, err := Unpack(env, tc.opts.Passphrase)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if !bytes.Equal(got, secret) {
t.Errorf("round-trip mismatch: got %q", got)
}
})
}
}
func TestWrongPassphraseFails(t *testing.T) {
env, err := Pack(secret, Options{Passphrase: []byte("right")})
if err != nil {
t.Fatal(err)
}
if _, err := Unpack(env, []byte("wrong")); !errors.Is(err, ErrDecrypt) {
t.Errorf("got %v want ErrDecrypt", err)
}
}
func TestMissingPassphraseFails(t *testing.T) {
env, err := Pack(secret, Options{Passphrase: []byte("right")})
if err != nil {
t.Fatal(err)
}
if _, err := Unpack(env, nil); !errors.Is(err, ErrPassphraseRequired) {
t.Errorf("got %v want ErrPassphraseRequired", err)
}
}
func TestTamperDetected(t *testing.T) {
env, err := Pack(secret, Options{Passphrase: []byte("right")})
if err != nil {
t.Fatal(err)
}
env[len(env)-6] ^= 0xFF // flip a byte inside the ciphertext body
if _, err := Unpack(env, []byte("right")); err == nil {
t.Error("expected an error on tampered envelope")
}
}
func TestChecksumMismatch(t *testing.T) {
env, err := Pack(secret, Options{})
if err != nil {
t.Fatal(err)
}
env[len(env)-5] ^= 0xFF // flip a body byte on the plaintext path
if _, err := Unpack(env, nil); !errors.Is(err, ErrChecksumMismatch) {
t.Errorf("got %v want ErrChecksumMismatch", err)
}
}
func TestBadMagic(t *testing.T) {
env, _ := Pack(secret, Options{})
env[0] ^= 0xFF
if _, err := Unpack(env, nil); !errors.Is(err, ErrBadMagic) {
t.Errorf("got %v want ErrBadMagic", err)
}
}
func TestVersionReject(t *testing.T) {
env, _ := Pack(secret, Options{})
env[len(magic)] = 0x7F
if _, err := Unpack(env, nil); !errors.Is(err, ErrUnsupportedVersion) {
t.Errorf("got %v want ErrUnsupportedVersion", err)
}
}
func TestTruncated(t *testing.T) {
env, _ := Pack(secret, Options{})
if _, err := Unpack(env[:5], nil); !errors.Is(err, ErrTruncated) {
t.Errorf("got %v want ErrTruncated", err)
}
}
func TestEmptyPayload(t *testing.T) {
if _, err := Pack(nil, Options{}); !errors.Is(err, ErrEmptyPayload) {
t.Errorf("got %v want ErrEmptyPayload", err)
}
}
func TestHostileParamsRejected(t *testing.T) {
env, err := Pack(secret, Options{Passphrase: []byte("right")})
if err != nil {
t.Fatal(err)
}
// zero out the Argon2 time field (offset magic+ver+flags+cipherID)
timeOff := len(magic) + 1 + 1 + 1
for i := 0; i < 4; i++ {
env[timeOff+i] = 0
}
if _, err := Unpack(env, []byte("right")); !errors.Is(err, ErrBadParams) {
t.Errorf("got %v want ErrBadParams", err)
}
}
func TestValidateAndIsEncrypted(t *testing.T) {
plain, _ := Pack(secret, Options{})
enc, _ := Pack(secret, Options{Passphrase: []byte("k")})
if err := Validate(plain); err != nil {
t.Errorf("Validate(plain): %v", err)
}
if err := Validate([]byte("garbage")); err == nil {
t.Error("Validate(garbage) should fail")
}
if e, _ := IsEncrypted(plain); e {
t.Error("plain reported encrypted")
}
if e, _ := IsEncrypted(enc); !e {
t.Error("encrypted reported plain")
}
}
func TestStrengthParams(t *testing.T) {
if p := paramsForStrength(StrengthHigh); p.memory != argonHighMemory || p.time != argonHighTime {
t.Errorf("high params wrong: %+v", p)
}
if p := paramsForStrength(StrengthDefault); p.memory != argonDefaultMemory {
t.Errorf("default params wrong: %+v", p)
}
}
func TestOverheadMatchesEnvelope(t *testing.T) {
plain := []byte("a modest secret")
clear, err := Pack(plain, Options{})
if err != nil {
t.Fatalf("pack plaintext: %v", err)
}
if got := len(clear) - len(plain); got != Overhead(false) {
t.Errorf("plaintext overhead = %d, Overhead(false) = %d", got, Overhead(false))
}
sealed, err := Pack(plain, Options{Passphrase: []byte("pw"), Cipher: CipherChaCha20})
if err != nil {
t.Fatalf("pack encrypted: %v", err)
}
if got := len(sealed) - len(plain); got != Overhead(true) {
t.Errorf("encrypted overhead = %d, Overhead(true) = %d", got, Overhead(true))
}
}

View File

@ -0,0 +1,57 @@
/*
©AngelaMos | 2026
envelopesize_test.go
Known-answer tests proving EnvelopeSize equals the real packed envelope length
*/
package payload
import (
"bytes"
"testing"
)
func TestEnvelopeSizeMatchesPack(t *testing.T) {
data := bytes.Repeat([]byte("crypha steganography "), 8)
cases := []Options{
{},
{Compress: true},
{Passphrase: []byte("pw"), Cipher: CipherChaCha20, Strength: StrengthDefault},
{Passphrase: []byte("pw"), Cipher: CipherAES256GCM, Strength: StrengthDefault, Compress: true},
}
for i, opts := range cases {
env, err := Pack(data, opts)
if err != nil {
t.Fatalf("case %d Pack: %v", i, err)
}
size, err := EnvelopeSize(data, opts)
if err != nil {
t.Fatalf("case %d EnvelopeSize: %v", i, err)
}
if size != len(env) {
t.Fatalf("case %d: EnvelopeSize=%d, len(Pack)=%d", i, size, len(env))
}
}
}
func TestEnvelopeSizeOverhead(t *testing.T) {
data := []byte("hello")
plain, _ := EnvelopeSize(data, Options{})
if plain != len(data)+Overhead(false) {
t.Fatalf("plaintext size = %d, want %d", plain, len(data)+Overhead(false))
}
enc, _ := EnvelopeSize(data, Options{Passphrase: []byte("x")})
if enc != len(data)+Overhead(true) {
t.Fatalf("encrypted size = %d, want %d", enc, len(data)+Overhead(true))
}
}
func TestEnvelopeSizeCompressionShrinks(t *testing.T) {
data := bytes.Repeat([]byte("A"), 1024)
plain, _ := EnvelopeSize(data, Options{})
comp, _ := EnvelopeSize(data, Options{Compress: true})
if comp >= plain {
t.Fatalf("compression did not shrink the envelope: comp=%d plain=%d", comp, plain)
}
}

View File

@ -0,0 +1,93 @@
/*
©AngelaMos | 2026
payload.go
Envelope types, constants, and errors shared across the crypha payload codec
*/
package payload
import "errors"
type Cipher string
const (
CipherChaCha20 Cipher = "chacha20"
CipherAES256GCM Cipher = "aes256gcm"
)
type Strength string
const (
StrengthDefault Strength = "default"
StrengthHigh Strength = "high"
)
type Options struct {
Passphrase []byte
Compress bool
Cipher Cipher
Strength Strength
}
const (
currentVersion byte = 0x01
flagEncrypted byte = 1 << 0
flagCompressed byte = 1 << 1
cipherIDChaCha20 byte = 0x00
cipherIDAES256GCM byte = 0x01
saltLen = 16
nonceLen = 12
keyLen = 32
lenField = 4
crcField = 4
paramsLen = 9
versionLen = 1
flagsLen = 1
cipherLen = 1
tagLen = 16
argonDefaultTime uint32 = 3
argonDefaultMemory uint32 = 64 * 1024
argonHighTime uint32 = 1
argonHighMemory uint32 = 2048 * 1024
argonMaxMemory uint32 = argonHighMemory
argonThreads uint8 = 4
)
var magic = [4]byte{0xC7, 0x1A, 0x9E, 0x5B}
var (
ErrEmptyPayload = errors.New("crypha: empty payload")
ErrBadMagic = errors.New("crypha: not a crypha payload (bad magic)")
ErrUnsupportedVersion = errors.New("crypha: unsupported envelope version")
ErrTruncated = errors.New("crypha: truncated envelope")
ErrBadParams = errors.New("crypha: invalid key-derivation parameters")
ErrChecksumMismatch = errors.New("crypha: checksum mismatch")
ErrPassphraseRequired = errors.New("crypha: payload is encrypted, passphrase required")
ErrDecrypt = errors.New("crypha: decryption failed (wrong passphrase or tampered data)")
ErrUnknownCipher = errors.New("crypha: unknown cipher")
)
func Overhead(encrypted bool) int {
base := len(magic) + versionLen + flagsLen + lenField + crcField
if !encrypted {
return base
}
return base + cipherLen + paramsLen + saltLen + nonceLen + tagLen
}
func EnvelopeSize(data []byte, opts Options) (int, error) {
body := data
if opts.Compress {
c, err := compress(data)
if err != nil {
return 0, err
}
body = c
}
return Overhead(len(opts.Passphrase) > 0) + len(body), nil
}

View File

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

View File

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

View File

@ -0,0 +1,94 @@
/*
©AngelaMos | 2026
chrome.go
Header wordmark, wizard stepper, footer keybinds, and shared panel primitives
*/
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
const (
wordmarkRows = 3
wordmarkGap = " "
labelWidth = 12
tagline = "multi-carrier steganography"
)
var wordmarkGlyphs = [][wordmarkRows]string{
{"█▀▀", "█ ", "▀▀▀"},
{"█▀▄", "█▀▄", "▀ ▀"},
{"█ █", "▀█▀", " ▀ "},
{"█▀▄", "█▀▀", "█ "},
{"█ █", "█▀█", "▀ ▀"},
{"▄▀▄", "█▀█", "▀ ▀"},
}
type keyHint struct {
key string
desc string
}
func wordmarkLines() []string {
lines := make([]string, wordmarkRows)
for row := range wordmarkRows {
parts := make([]string, len(wordmarkGlyphs))
for i, g := range wordmarkGlyphs {
parts[i] = g[row]
}
lines[row] = strings.Join(parts, wordmarkGap)
}
return lines
}
func renderHeader(width int) string {
mark := gradientBlock(wordmarkLines(), brandStops, true)
tag := styleTagline.Render(tagline)
rule := gradientRule(width, brandStops)
return lipgloss.JoinVertical(lipgloss.Left, mark, tag, "", rule)
}
func renderStepper(labels []string, current int) string {
parts := make([]string, 0, len(labels)*2)
for i, label := range labels {
var token string
switch {
case i < current:
token = styleStepDone.Render(label)
case i == current:
token = styleCursor.Render(accentBar) + styleStepCurrent.Render(label)
default:
token = styleStepFuture.Render(label)
}
parts = append(parts, token)
if i < len(labels)-1 {
parts = append(parts, styleHelpSep.Render(" "+connector+" "))
}
}
return strings.Join(parts, "")
}
func renderFooter(hints []keyHint) string {
parts := make([]string, 0, len(hints))
for _, h := range hints {
parts = append(parts, styleHelpKey.Render(h.key)+" "+styleHelpDesc.Render(h.desc))
}
return strings.Join(parts, styleHelpSep.Render(" │ "))
}
func sectionTitle(text string) string {
return stylePanelTitle.Render(accentBar) + " " + styleValueBold.Render(text)
}
func kv(label, value string) string {
return styleLabel.Width(labelWidth).Render(label) + styleValue.Render(value)
}
func kvStyled(label, value string, valueStyle lipgloss.Style) string {
return styleLabel.Width(labelWidth).Render(label) + valueStyle.Render(value)
}

View File

@ -0,0 +1,84 @@
/*
©AngelaMos | 2026
commands.go
Bubbletea commands and messages for engine calls, file IO, and the embed animation
*/
package tui
import (
"bytes"
"os"
"time"
"github.com/CarterPerez-dev/crypha/internal/engine"
tea "github.com/charmbracelet/bubbletea"
)
type fileLoadedMsg struct {
origin stage
path string
data []byte
err error
}
type hideDoneMsg struct {
res engine.HideResult
data []byte
err error
}
type revealDoneMsg struct {
res engine.RevealResult
err error
}
type savedMsg struct {
path string
err error
}
type tickMsg time.Time
func loadFileCmd(origin stage, path string) tea.Cmd {
return func() tea.Msg {
data, err := os.ReadFile(path)
return fileLoadedMsg{origin: origin, path: path, data: data, err: err}
}
}
func hideCmd(req engine.HideRequest, outPath string) tea.Cmd {
return func() tea.Msg {
var buf bytes.Buffer
req.Out = &buf
res, err := engine.Hide(req)
if err != nil {
return hideDoneMsg{err: err}
}
if err := os.WriteFile(outPath, buf.Bytes(), outFilePerm); err != nil {
return hideDoneMsg{err: err}
}
return hideDoneMsg{res: res, data: buf.Bytes()}
}
}
func revealCmd(format string, stego, pass []byte) tea.Cmd {
return func() tea.Msg {
res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass})
return revealDoneMsg{res: res, err: err}
}
}
func saveCmd(path string, data []byte) tea.Cmd {
return func() tea.Msg {
err := os.WriteFile(path, data, outFilePerm)
return savedMsg{path: path, err: err}
}
}
func tick() tea.Cmd {
return tea.Tick(animInterval, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}

View File

@ -0,0 +1,224 @@
/*
©AngelaMos | 2026
flows_test.go
Coverage for the file-mode payload, save flow, reset, and command plumbing
*/
package tui
import (
"os"
"path/filepath"
"strings"
"testing"
)
type nopMsg struct{}
func hideStego(t *testing.T, secret string) []byte {
t.Helper()
out := filepath.Join(t.TempDir(), "s.png")
cover := makePNG(t, 64, 64)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, typeText(secret))
m, _ = step(m, keyEnter())
m, _ = step(m, keyEnter())
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
m, cmd := step(m, keyEnter())
m = finishRun(t, m, cmd)
if m.engineErr != nil {
t.Fatalf("hide errored: %v", m.engineErr)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read stego: %v", err)
}
return data
}
func TestInitReturnsCmd(t *testing.T) {
if New().Init() == nil {
t.Fatalf("Init returned nil")
}
}
func TestLoadFileCmd(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(p, []byte("hi"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
msg, ok := loadFileCmd(stageCover, p)().(fileLoadedMsg)
if !ok || msg.err != nil || string(msg.data) != "hi" || msg.origin != stageCover {
t.Fatalf("loadFileCmd = %+v ok=%v", msg, ok)
}
miss, _ := loadFileCmd(stageCover, filepath.Join(t.TempDir(), "nope"))().(fileLoadedMsg)
if miss.err == nil {
t.Fatalf("expected an error for a missing file")
}
}
func TestPayloadFileMode(t *testing.T) {
cover := makePNG(t, 48, 48)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
if m.stage != stagePayload {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keyTab())
if m.payloadMode != payloadFile {
t.Fatalf("tab did not switch to file mode")
}
m, _ = step(m, fileLoadedMsg{origin: stagePayload, path: "/x/secret.bin", data: []byte("payloaddata")})
if m.stage != stageSecure {
t.Fatalf("after payload file: stage = %v", m.stage)
}
if m.payloadLabel != "secret.bin" {
t.Fatalf("payload label = %q", m.payloadLabel)
}
if string(m.payloadBytes) != "payloaddata" {
t.Fatalf("payload bytes = %q", m.payloadBytes)
}
}
func TestRevealSaveFlow(t *testing.T) {
stego := hideStego(t, "save me")
saveTo := filepath.Join(t.TempDir(), "recovered.txt")
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
r, _ = step(r, keyEnter())
r, cmd := step(r, fileLoadedMsg{origin: stageCover, path: "s.png", data: stego})
r = finishRun(t, r, cmd)
if r.stage != stageResult || r.engineErr != nil {
t.Fatalf("reveal: stage=%v err=%v", r.stage, r.engineErr)
}
if !r.canSaveReveal() {
t.Fatalf("revealed payload should be saveable")
}
r, _ = step(r, typeText("s"))
if !r.saving {
t.Fatalf("s did not enter saving mode")
}
r.outPath.SetValue(saveTo)
r, cmd = step(r, keyEnter())
for _, msg := range drain(cmd) {
r, _ = step(r, msg)
}
if r.savedAt != saveTo {
t.Fatalf("savedAt = %q, want %q", r.savedAt, saveTo)
}
got, err := os.ReadFile(saveTo)
if err != nil || string(got) != "save me" {
t.Fatalf("saved file = %q err=%v", got, err)
}
}
func TestResetOnNewRun(t *testing.T) {
m := ready()
m.stage = stageResult
m, _ = step(m, typeText("n"))
if m.stage != stageOperation {
t.Fatalf("new run did not reset, stage = %v", m.stage)
}
}
func TestCoverKeyForwarding(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
if m.stage != stageCover {
t.Fatalf("stage = %v", m.stage)
}
browse, _ := step(m, keyDown())
if browse.stage != stageCover {
t.Fatalf("browsing changed the stage to %v", browse.stage)
}
back, _ := step(m, keyEsc())
if back.stage != stageFormat {
t.Fatalf("esc from cover went to %v", back.stage)
}
}
func TestCapacityReviewKeys(t *testing.T) {
cover := makePNG(t, 32, 32)
m := ready()
m, _ = step(m, keyDown())
m, _ = step(m, keyDown())
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
if m.stage != stageReview {
t.Fatalf("stage = %v", m.stage)
}
back, _ := step(m, keyEsc())
if back.stage != stageCover {
t.Fatalf("esc from capacity review went to %v", back.stage)
}
fresh, _ := step(m, typeText("n"))
if fresh.stage != stageOperation {
t.Fatalf("n from capacity review went to %v", fresh.stage)
}
}
func TestForwardNonKeyMsgs(t *testing.T) {
for _, s := range []stage{stageCover, stagePayload, stageSecure, stageSave, stagePassphrase, stageResult} {
m := ready()
m.stage = s
m.saving = s == stageResult
m, _ = step(m, nopMsg{})
if got := m.View(); got == "" {
t.Fatalf("empty view after nop msg at stage %v", s)
}
}
}
func TestPassPromptsResetOnCoverLoad(t *testing.T) {
m := ready()
m.op = opReveal
m.stage = stageCover
m.passPrompts = 2
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "x", data: []byte("not a stego")})
if m.passPrompts != 0 {
t.Fatalf("passPrompts = %d after loading a fresh file, want 0", m.passPrompts)
}
}
func TestHideFitsUsesCompressedSize(t *testing.T) {
cover := makePNG(t, 16, 16)
big := strings.Repeat("A", 200)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, typeText(big))
m, _ = step(m, keyEnter())
m, _ = step(m, keyDown())
m, _ = step(m, keySpace())
if !m.secure.compress {
t.Fatalf("compression not enabled")
}
m, _ = step(m, keyEnter())
m.outPath.SetValue(filepath.Join(t.TempDir(), "o.png"))
m, _ = step(m, keyEnter())
if m.stage != stageReview {
t.Fatalf("stage = %v", m.stage)
}
if len(big)+14 <= m.capValue {
t.Fatalf("precondition broken: raw payload already fits (cap=%d)", m.capValue)
}
if !m.hideFits() {
t.Fatalf("compressible payload should fit: envelope=%d cap=%d", m.envelopeSize, m.capValue)
}
}

View File

@ -0,0 +1,134 @@
/*
©AngelaMos | 2026
meter.go
The spectral capacity meter: payload-vs-carrier fill for hide, comparative table for capacity
*/
package tui
import (
"fmt"
"math"
"strings"
"github.com/charmbracelet/lipgloss"
)
const (
fullPercent = 100
highWaterPercent = 80
formatColumn = 7
unboundedTag = "unbounded"
)
type capRow struct {
format string
applicable bool
unbounded bool
capacity int
maxPlaintext int
maxEncrypted int
note string
}
func renderHideMeter(payloadLen, envelope, capacity int, capErr error, barWidth int) string {
if capErr != nil {
return styleWarn.Render(reasonText(capErr))
}
if capacity >= math.MaxInt32 {
bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false)
return lipgloss.JoinVertical(lipgloss.Left,
bar+" "+styleSuccess.Render(unboundedTag),
"",
kv("payload", fmt.Sprintf("%d B", payloadLen)),
kv("envelope", fmt.Sprintf("%d B", envelope)),
styleSuccess.Render("this carrier has effectively unbounded room; your payload always fits"),
)
}
if capacity <= 0 {
return styleWarn.Render("this cover is too small to hold a payload")
}
frac := float64(envelope) / float64(capacity)
over := envelope > capacity
pct := int(math.Round(math.Min(frac, 1) * fullPercent))
bar := spectralBar(barWidth, frac, capacityStops)
pctStyle := styleSuccess
if over {
pctStyle = styleError
} else if pct >= highWaterPercent {
pctStyle = styleWarn
}
lines := []string{
bar + " " + pctStyle.Render(fmt.Sprintf("%d%%", pct)),
"",
kv("payload", fmt.Sprintf("%d B", payloadLen)),
kv("envelope", fmt.Sprintf("%d B", envelope)),
kv("capacity", fmt.Sprintf("%d B", capacity)),
}
if over {
lines = append(lines, "", styleError.Render(
fmt.Sprintf("payload exceeds capacity by %d B: shorten it, enable compression, or pick a roomier carrier", envelope-capacity)))
} else {
lines = append(lines, "", styleSuccess.Render(
fmt.Sprintf("fits with %d B of headroom", capacity-envelope)))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func renderCapacityTable(rows []capRow, barWidth int) string {
maxCap := 0
for _, r := range rows {
if r.applicable && !r.unbounded && r.capacity > maxCap {
maxCap = r.capacity
}
}
out := make([]string, 0, len(rows))
for _, r := range rows {
name := styleValueBold.Width(formatColumn).Render(r.format)
switch {
case !r.applicable:
out = append(out, name+styleHint.Render(r.note))
case r.unbounded:
bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false)
out = append(out, name+bar+" "+styleHint.Render(unboundedTag))
default:
frac := 0.0
if maxCap > 0 {
frac = float64(r.capacity) / float64(maxCap)
}
bar := spectralBar(barWidth, frac, capacityStops)
out = append(out, name+bar+" "+capacityFacts(r))
}
}
return lipgloss.JoinVertical(lipgloss.Left, out...)
}
func capacityFacts(r capRow) string {
plain := styleValue.Render(fmt.Sprintf("%d B", r.maxPlaintext)) + styleLabel.Render(" plain")
var enc string
if r.maxEncrypted <= 0 {
enc = styleWarn.Render("encrypted does not fit")
} else {
enc = styleValue.Render(fmt.Sprintf("%d B", r.maxEncrypted)) + styleLabel.Render(" encrypted")
}
return plain + styleHelpSep.Render(" · ") + enc
}
func reasonText(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
}

View File

@ -0,0 +1,404 @@
/*
©AngelaMos | 2026
model.go
The wizard model: state, flow definition, stage navigation, and engine-facing helpers
*/
package tui
import (
"bytes"
"errors"
"math"
"os"
"path/filepath"
"strings"
"time"
"github.com/CarterPerez-dev/crypha/internal/config"
"github.com/CarterPerez-dev/crypha/internal/engine"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
const (
appMaxWidth = 88
appMinWidth = 46
meterBarWidth = 44
capBarWidth = 24
embedBarWidth = 44
filepickerHeight = 12
messageWidth = 58
passFieldWidth = 34
outFieldWidth = 50
passLimit = 256
pathLimit = 512
maxRevealPrompts = 3
animStep = 0.06
animInterval = 45 * time.Millisecond
outFilePerm = 0o600
inlineLabel = "inline message"
)
var (
errNeedPayload = errors.New("enter a message, or press tab to choose a payload file")
errEmptyFile = errors.New("that file is empty; choose a file with contents")
errNeedPassphrase = errors.New("encryption is on: enter a passphrase or turn encryption off")
errNeedOutput = errors.New("enter an output path")
errPayloadTooBig = errors.New("payload does not fit: go back and shorten it, enable compression, or pick a roomier carrier")
)
type stage int
const (
stageOperation stage = iota
stageFormat
stageTechnique
stageCover
stagePayload
stageSecure
stageSave
stageReview
stagePassphrase
stageRunning
stageResult
)
type operation int
const (
opHide operation = iota
opReveal
opCapacity
)
type payloadMode int
const (
payloadMessage payloadMode = iota
payloadFile
)
type Model struct {
width int
height int
ready bool
quitting bool
stage stage
op operation
opPick picker
fmtPick picker
techPick picker
files filepicker.Model
message textinput.Model
secure secureForm
outPath textinput.Model
revPass textinput.Model
format string
technique string
coverPath string
coverBytes []byte
payloadBytes []byte
payloadMode payloadMode
payloadLabel string
pass []byte
capValue int
capErr error
capRows []capRow
envelopeSize int
envelopeErr error
hideRes engine.HideResult
revealRes engine.RevealResult
stego []byte
outputAt string
savedAt string
saving bool
passPrompts int
engineDone bool
engineErr error
animFrac float64
err error
}
func New() Model {
fp := filepicker.New()
if wd, err := os.Getwd(); err == nil {
fp.CurrentDirectory = wd
}
fp.ShowPermissions = false
fp.AutoHeight = false
fp.SetHeight(filepickerHeight)
msg := textinput.New()
msg.Prompt = ""
msg.Placeholder = "type a secret message"
msg.Width = messageWidth
out := textinput.New()
out.Prompt = ""
out.CharLimit = pathLimit
out.Width = outFieldWidth
rev := textinput.New()
rev.Prompt = ""
rev.EchoMode = textinput.EchoPassword
rev.CharLimit = passLimit
rev.Width = passFieldWidth
return Model{
files: fp,
message: msg,
outPath: out,
revPass: rev,
secure: newSecureForm(),
opPick: newPicker(operationItems()),
}
}
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
func (m Model) flow() []stage {
switch m.op {
case opReveal:
return []stage{stageOperation, stageFormat, stageCover, stageRunning, stageResult}
case opCapacity:
return []stage{stageOperation, stageCover, stageReview}
default:
steps := []stage{stageOperation, stageFormat}
if hasTechniques(m.format) {
steps = append(steps, stageTechnique)
}
return append(steps, stageCover, stagePayload, stageSecure, stageSave, stageReview, stageRunning, stageResult)
}
}
func (m Model) adjacentStage(dir int) (stage, bool) {
f := m.flow()
for i, s := range f {
if s != m.stage {
continue
}
j := i + dir
if j < 0 || j >= len(f) {
return m.stage, false
}
return f[j], true
}
return m.stage, false
}
func (m Model) advance() (tea.Model, tea.Cmd) {
next, ok := m.adjacentStage(1)
if !ok {
return m, nil
}
m.err = nil
cmd := m.enter(next)
return m, cmd
}
func (m Model) back() (tea.Model, tea.Cmd) {
prev, ok := m.adjacentStage(-1)
if !ok {
return m, nil
}
m.err = nil
cmd := m.enter(prev)
return m, cmd
}
func (m *Model) enter(s stage) tea.Cmd {
m.stage = s
switch s {
case stageFormat:
m.fmtPick = newPicker(formatItems(m.op == opReveal))
case stageTechnique:
m.techPick = newPicker(techniqueItems(m.format))
case stageCover:
return m.files.Init()
case stagePayload:
m.payloadMode = payloadMessage
return m.message.Focus()
case stageSave:
if strings.TrimSpace(m.outPath.Value()) == "" {
m.outPath.SetValue(suggestOutput(m.coverPath, m.format))
}
return m.outPath.Focus()
case stageReview:
m.prepareReview()
case stageRunning:
return m.startRun()
}
return nil
}
func (m *Model) prepareReview() {
switch m.op {
case opHide:
m.capValue, m.capErr = engine.Capacity(m.format, bytes.NewReader(m.coverBytes))
m.envelopeSize, m.envelopeErr = engine.EnvelopeSize(m.payloadBytes, m.buildOptions())
case opCapacity:
m.capRows = buildCapRows(engine.CapacityAll(m.coverBytes))
}
}
func (m *Model) startRun() tea.Cmd {
m.animFrac = 0
m.engineDone = false
m.engineErr = nil
switch m.op {
case opReveal:
return tea.Batch(revealCmd(m.format, m.coverBytes, m.revPassValue()), tick())
default:
req := engine.HideRequest{
Format: m.format,
Technique: m.technique,
Cover: bytes.NewReader(m.coverBytes),
Payload: m.payloadBytes,
Options: m.buildOptions(),
}
return tea.Batch(hideCmd(req, m.outPath.Value()), tick())
}
}
func (m Model) reset() (tea.Model, tea.Cmd) {
next := New()
next.width = m.width
next.height = m.height
next.ready = m.ready
return next, textinput.Blink
}
func (m Model) buildOptions() payload.Options {
return payload.Options{
Passphrase: m.secure.passphrase(),
Compress: m.secure.compress,
Cipher: payload.Cipher(m.secure.cipherValue()),
Strength: payload.Strength(m.secure.strengthValue()),
}
}
func (m Model) revPassValue() []byte {
v := m.revPass.Value()
if v == "" {
return nil
}
return []byte(v)
}
func (m Model) encrypted() bool {
return len(m.pass) > 0
}
func (m Model) hideFits() bool {
if m.capErr != nil || m.envelopeErr != nil {
return false
}
if m.capValue >= math.MaxInt32 {
return true
}
return m.envelopeSize <= m.capValue
}
func hasTechniques(format string) bool {
return len(engine.Techniques(format)) > 0
}
func suggestOutput(coverPath, format string) string {
base := strings.TrimSuffix(filepath.Base(coverPath), filepath.Ext(coverPath))
if base == "" || base == "." {
base = config.BinaryName
}
return base + ".stego" + outputExt(format)
}
func (m Model) suggestReveal() string {
base := strings.TrimSuffix(filepath.Base(m.coverPath), filepath.Ext(m.coverPath))
if base == "" || base == "." {
base = config.BinaryName
}
return base + ".revealed"
}
func outputExt(format string) string {
switch strings.ToLower(config.FormatDetails[format].Output) {
case "png":
return ".png"
case "wav":
return ".wav"
case "pdf":
return ".pdf"
case "text":
return ".txt"
default:
return ".out"
}
}
func buildCapRows(rows []engine.CapacityRow) []capRow {
out := make([]capRow, 0, len(rows))
for _, r := range rows {
cr := capRow{format: r.Format}
switch {
case r.Err != nil:
cr.note = reasonText(r.Err)
case r.Capacity >= math.MaxInt32:
cr.applicable = true
cr.unbounded = true
default:
cr.applicable = true
cr.capacity = r.Capacity
cr.maxPlaintext = clampZero(r.Capacity - engine.Overhead(false))
cr.maxEncrypted = clampZero(r.Capacity - engine.Overhead(true))
}
out = append(out, cr)
}
return out
}
func operationItems() []pickItem {
return []pickItem{
{title: "hide", desc: "embed an encrypted payload inside a cover file"},
{title: "reveal", desc: "extract and decrypt a payload hidden in a stego file"},
{title: "capacity", desc: "measure how many payload bytes each carrier can hold for a cover"},
}
}
func formatItems(withAuto bool) []pickItem {
items := make([]pickItem, 0)
if withAuto {
items = append(items, pickItem{title: "auto-detect", value: "", desc: "let crypha identify the carrier by inspecting the file"})
}
for _, fi := range engine.Catalog() {
d := config.FormatDetails[fi.Name]
desc := d.Blurb + " · cover: " + d.CoverInput + " · output: " + d.Output
items = append(items, pickItem{title: fi.Name, value: fi.Name, desc: desc})
}
return items
}
func techniqueItems(format string) []pickItem {
blurbs := map[string]string{
"attachment": "embed the payload as a lossless PDF file attachment (default)",
"metadata": "stash the payload inside the PDF Info dictionary",
"append": "append the payload after the PDF end-of-file marker",
}
items := make([]pickItem, 0)
for _, t := range engine.Techniques(format) {
items = append(items, pickItem{title: t, value: t, desc: blurbs[t]})
}
return items
}

View File

@ -0,0 +1,65 @@
/*
©AngelaMos | 2026
picker.go
A compact keyboard-driven selection list with an inline blurb for the highlighted item
*/
package tui
import (
"github.com/charmbracelet/lipgloss"
)
type pickItem struct {
title string
desc string
value string
}
type picker struct {
items []pickItem
cursor int
}
func newPicker(items []pickItem) picker {
return picker{items: items}
}
func (p *picker) up() {
if len(p.items) == 0 {
return
}
p.cursor = (p.cursor - 1 + len(p.items)) % len(p.items)
}
func (p *picker) down() {
if len(p.items) == 0 {
return
}
p.cursor = (p.cursor + 1) % len(p.items)
}
func (p picker) selected() pickItem {
if len(p.items) == 0 {
return pickItem{}
}
return p.items[p.cursor]
}
func (p picker) view(width int) string {
rows := make([]string, 0, len(p.items))
for i, it := range p.items {
if i == p.cursor {
rows = append(rows, styleCursor.Render(accentBar)+" "+styleSelected.Render(it.title))
} else {
rows = append(rows, " "+styleUnselected.Render(it.title))
}
}
list := lipgloss.JoinVertical(lipgloss.Left, rows...)
if sel := p.selected(); sel.desc != "" {
blurb := styleHint.Width(width).Render(sel.desc)
return lipgloss.JoinVertical(lipgloss.Left, list, "", blurb)
}
return list
}

View File

@ -0,0 +1,224 @@
/*
©AngelaMos | 2026
secure.go
The encryption sub-form: a focusable list of toggle, choice, and secret controls
*/
package tui
import (
"strings"
"github.com/CarterPerez-dev/crypha/internal/payload"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type secField int
const (
secEncrypt secField = iota
secPass
secCipher
secStrength
secCompress
)
var (
cipherChoices = []string{string(payload.CipherChaCha20), string(payload.CipherAES256GCM)}
strengthChoices = []string{string(payload.StrengthDefault), string(payload.StrengthHigh)}
)
type secureForm struct {
encrypt bool
pass textinput.Model
cipher int
strength int
compress bool
focus int
}
func newSecureForm() secureForm {
ti := textinput.New()
ti.Prompt = ""
ti.Placeholder = "passphrase"
ti.EchoMode = textinput.EchoPassword
ti.CharLimit = passLimit
ti.Width = passFieldWidth
return secureForm{pass: ti}
}
func (f secureForm) fields() []secField {
if f.encrypt {
return []secField{secEncrypt, secPass, secCipher, secStrength, secCompress}
}
return []secField{secEncrypt, secCompress}
}
func (f secureForm) focusField() secField {
fs := f.fields()
i := f.focus
if i < 0 {
i = 0
}
if i >= len(fs) {
i = len(fs) - 1
}
return fs[i]
}
func (f *secureForm) move(delta int) {
n := len(f.fields())
f.focus += delta
if f.focus < 0 {
f.focus = 0
}
if f.focus >= n {
f.focus = n - 1
}
f.syncFocus()
}
func (f *secureForm) syncFocus() tea.Cmd {
if f.focusField() == secPass {
return f.pass.Focus()
}
f.pass.Blur()
return nil
}
func (f secureForm) cipherValue() string {
return cipherChoices[f.cipher]
}
func (f secureForm) strengthValue() string {
return strengthChoices[f.strength]
}
func (f secureForm) passphrase() []byte {
if !f.encrypt {
return nil
}
v := f.pass.Value()
if v == "" {
return nil
}
return []byte(v)
}
func (f secureForm) update(msg tea.Msg) (secureForm, tea.Cmd) {
km, ok := msg.(tea.KeyMsg)
if !ok {
var cmd tea.Cmd
f.pass, cmd = f.pass.Update(msg)
return f, cmd
}
switch km.String() {
case "up", "shift+tab":
cmd := f.moveWith(-1)
return f, cmd
case "down", "tab":
cmd := f.moveWith(1)
return f, cmd
}
switch f.focusField() {
case secEncrypt:
if isToggleKey(km) {
f.encrypt = !f.encrypt
f.syncFocus()
}
case secCompress:
if isToggleKey(km) {
f.compress = !f.compress
}
case secCipher:
f.cipher = cycleChoice(f.cipher, len(cipherChoices), km)
case secStrength:
f.strength = cycleChoice(f.strength, len(strengthChoices), km)
case secPass:
var cmd tea.Cmd
f.pass, cmd = f.pass.Update(msg)
return f, cmd
}
return f, nil
}
func (f *secureForm) moveWith(delta int) tea.Cmd {
f.move(delta)
return f.syncFocus()
}
func isToggleKey(km tea.KeyMsg) bool {
switch km.String() {
case " ", "left", "right":
return true
}
return false
}
func cycleChoice(idx, n int, km tea.KeyMsg) int {
switch km.String() {
case "left":
return (idx - 1 + n) % n
case "right", " ":
return (idx + 1) % n
}
return idx
}
func (f secureForm) view(width int) string {
rows := make([]string, 0, len(f.fields()))
for i, fld := range f.fields() {
rows = append(rows, f.fieldRow(fld, i == f.focus))
}
note := styleHint.Width(width).Render("up/down move · space or left/right change · enter continues")
return lipgloss.JoinVertical(lipgloss.Left, lipgloss.JoinVertical(lipgloss.Left, rows...), "", note)
}
func (f secureForm) fieldRow(fld secField, focused bool) string {
prefix := " "
labelStyle := styleLabel
if focused {
prefix = styleCursor.Render(accentBar) + " "
labelStyle = styleValueBold
}
label, control := f.labelControl(fld)
return prefix + labelStyle.Width(labelWidth).Render(label) + control
}
func (f secureForm) labelControl(fld secField) (string, string) {
switch fld {
case secEncrypt:
return "encrypt", toggleControl(f.encrypt)
case secPass:
return "passphrase", f.pass.View()
case secCipher:
return "cipher", choiceControl(cipherChoices, f.cipher)
case secStrength:
return "key strength", choiceControl(strengthChoices, f.strength)
case secCompress:
return "compress", toggleControl(f.compress)
}
return "", ""
}
func toggleControl(on bool) string {
return pill("off", !on) + " " + pill("on", on)
}
func choiceControl(choices []string, idx int) string {
parts := make([]string, len(choices))
for i, c := range choices {
parts[i] = pill(c, i == idx)
}
return strings.Join(parts, " ")
}
func pill(text string, active bool) string {
if active {
return badge(text, hexViolet)
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)).Padding(0, 1).Render(text)
}

View File

@ -0,0 +1,96 @@
/*
©AngelaMos | 2026
steps.go
Per-operation wizard step labels, current-step mapping, and contextual footer hints
*/
package tui
func (m Model) stepLabels() []string {
switch m.op {
case opReveal:
return []string{"operation", "carrier", "stego", "reveal"}
case opCapacity:
return []string{"operation", "cover", "report"}
default:
labels := []string{"operation", "carrier"}
if hasTechniques(m.format) {
labels = append(labels, "technique")
}
return append(labels, "cover", "payload", "secure", "save", "embed")
}
}
func (m Model) stepStages() []stage {
switch m.op {
case opReveal:
return []stage{stageOperation, stageFormat, stageCover, stageRunning}
case opCapacity:
return []stage{stageOperation, stageCover, stageReview}
default:
stages := []stage{stageOperation, stageFormat}
if hasTechniques(m.format) {
stages = append(stages, stageTechnique)
}
return append(stages, stageCover, stagePayload, stageSecure, stageSave, stageReview)
}
}
func (m Model) stepIndex() int {
target := m.stage
switch m.stage {
case stagePassphrase:
target = stageRunning
case stageRunning, stageResult:
if m.op == opReveal {
target = stageRunning
} else {
target = stageReview
}
}
for i, s := range m.stepStages() {
if s == target {
return i
}
}
return 0
}
func (m Model) footerHints() []keyHint {
switch m.stage {
case stageOperation:
return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"q", "quit"}}
case stageFormat, stageTechnique:
return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"esc", "back"}, {"q", "quit"}}
case stageCover:
return []keyHint{{"up/down", "browse"}, {"enter", "open or select"}, {"esc", "back"}}
case stagePayload:
if m.payloadMode == payloadFile {
return []keyHint{{"up/down", "browse"}, {"enter", "choose"}, {"tab", "message"}, {"esc", "back"}}
}
return []keyHint{{"type", "message"}, {"enter", "continue"}, {"tab", "file"}, {"esc", "back"}}
case stageSecure:
return []keyHint{{"up/down", "field"}, {"space", "change"}, {"enter", "continue"}, {"esc", "back"}}
case stageSave:
return []keyHint{{"type", "path"}, {"enter", "continue"}, {"esc", "back"}}
case stageReview:
if m.op == opCapacity {
return []keyHint{{"esc", "back"}, {"n", "new run"}, {"q", "quit"}}
}
return []keyHint{{"enter", "embed"}, {"esc", "back"}, {"q", "quit"}}
case stagePassphrase:
return []keyHint{{"enter", "unlock"}, {"esc", "cancel"}}
case stageResult:
if m.saving {
return []keyHint{{"enter", "write"}, {"esc", "cancel"}}
}
if m.canSaveReveal() {
return []keyHint{{"s", "save"}, {"n", "new run"}, {"q", "quit"}}
}
return []keyHint{{"n", "new run"}, {"q", "quit"}}
case stageRunning:
return []keyHint{{"working", "..."}}
}
return nil
}

View File

@ -0,0 +1,217 @@
/*
©AngelaMos | 2026
theme.go
Spectral cybercore palette, HCL gradient engine, and lipgloss styles for the TUI
*/
package tui
import (
"math"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/lucasb-eyer/go-colorful"
)
const (
hexVioletLight = "#A78BFA"
hexViolet = "#8B5CF6"
hexVioletDeep = "#6D4AFF"
hexBlue = "#4457E8"
hexBlueBright = "#6E86FF"
hexBlueDim = "#5A63A8"
hexRed = "#CE2417"
hexBright = "#E9E6F5"
hexNormal = "#C4C0D6"
hexDim = "#8B86A6"
hexFaint = "#46425F"
hexFrame = "#39346B"
hexInk = "#0C0A16"
)
const (
blockFull = "█"
blockEmpty = "░"
ruleGlyph = "━"
accentBar = "▌"
connector = "─"
)
var (
brandStops = mustStops(hexVioletLight, hexViolet, hexVioletDeep, hexBlue)
capacityStops = mustStops(hexBlue, hexVioletLight, hexViolet, hexVioletDeep)
unboundedStops = mustStops(hexBlue, hexViolet)
)
var (
styleTagline = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)).Italic(true)
styleLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueDim))
styleValue = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright))
styleValueBold = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true)
stylePanelTitle = lipgloss.NewStyle().Foreground(lipgloss.Color(hexViolet)).Bold(true)
styleCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true)
styleStepCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true)
styleStepDone = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue))
styleStepFuture = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint))
styleSelected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true)
styleUnselected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexNormal))
styleHint = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim))
styleError = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true)
styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueBright)).Bold(true)
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed))
styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue)).Bold(true)
styleHelpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim))
styleHelpSep = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint))
styleTrack = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint))
)
const (
framePadX = 3
framePadY = 1
frameBorder = 1
horizontalGutter = 2
)
func frameStyle(width int) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(hexFrame)).
Padding(framePadY, framePadX).
Width(width)
}
func badge(text, hex string) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(hexInk)).
Background(lipgloss.Color(hex)).
Bold(true).
Padding(0, 1).
Render(text)
}
func mustStops(hexes ...string) []colorful.Color {
stops := make([]colorful.Color, len(hexes))
for i, h := range hexes {
c, err := colorful.Hex(h)
if err != nil {
panic("tui: invalid palette hex " + h)
}
stops[i] = c
}
return stops
}
func ramp(stops []colorful.Color, n int) []lipgloss.Color {
out := make([]lipgloss.Color, n)
if n == 0 {
return out
}
if n == 1 || len(stops) == 1 {
for i := range out {
out[i] = lipgloss.Color(stops[0].Hex())
}
return out
}
segments := len(stops) - 1
for i := range out {
x := float64(i) / float64(n-1) * float64(segments)
idx := int(x)
if idx >= segments {
idx = segments - 1
}
local := x - float64(idx)
out[i] = lipgloss.Color(stops[idx].BlendHcl(stops[idx+1], local).Clamped().Hex())
}
return out
}
func gradientText(text string, stops []colorful.Color, bold bool) string {
runes := []rune(text)
cols := ramp(stops, len(runes))
var b strings.Builder
for i, r := range runes {
s := lipgloss.NewStyle().Foreground(cols[i])
if bold {
s = s.Bold(true)
}
b.WriteString(s.Render(string(r)))
}
return b.String()
}
func gradientBlock(lines []string, stops []colorful.Color, bold bool) string {
width := 0
for _, ln := range lines {
if w := len([]rune(ln)); w > width {
width = w
}
}
cols := ramp(stops, width)
out := make([]string, len(lines))
for li, ln := range lines {
var b strings.Builder
for i, r := range []rune(ln) {
s := lipgloss.NewStyle().Foreground(cols[i])
if bold {
s = s.Bold(true)
}
b.WriteString(s.Render(string(r)))
}
out[li] = b.String()
}
return strings.Join(out, "\n")
}
func gradientRule(width int, stops []colorful.Color) string {
if width <= 0 {
return ""
}
return gradientText(strings.Repeat(ruleGlyph, width), stops, false)
}
func spectralBar(width int, frac float64, stops []colorful.Color) string {
if width <= 0 {
return ""
}
over := frac > 1
if frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
filled := int(math.Round(frac * float64(width)))
if filled > width {
filled = width
}
cols := ramp(capacityStops, width)
if stops != nil {
cols = ramp(stops, width)
}
var b strings.Builder
for i := range width {
if i < filled {
col := cols[i]
if over {
col = lipgloss.Color(hexRed)
}
b.WriteString(lipgloss.NewStyle().Foreground(col).Render(blockFull))
} else {
b.WriteString(styleTrack.Render(blockEmpty))
}
}
return b.String()
}

View File

@ -0,0 +1,149 @@
/*
©AngelaMos | 2026
theme_test.go
Unit tests for the gradient engine, meter geometry, and pure view helpers
*/
package tui
import (
"testing"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
)
func TestRampLength(t *testing.T) {
for _, n := range []int{0, 1, 2, 5, 30, 88} {
if got := len(ramp(brandStops, n)); got != n {
t.Fatalf("ramp(%d) length = %d", n, got)
}
}
}
func TestSpectralBarWidthInvariant(t *testing.T) {
for _, w := range []int{1, 8, 24, 44} {
for _, frac := range []float64{-0.5, 0, 0.33, 0.5, 1, 2.5} {
bar := spectralBar(w, frac, capacityStops)
if got := lipgloss.Width(bar); got != w {
t.Fatalf("spectralBar(w=%d frac=%.2f) width = %d", w, frac, got)
}
}
}
}
func TestGradientTextPreservesWidth(t *testing.T) {
if got := lipgloss.Width(gradientText("crypha", brandStops, true)); got != 6 {
t.Fatalf("gradientText width = %d, want 6", got)
}
}
func TestWordmarkRowsAligned(t *testing.T) {
lines := wordmarkLines()
if len(lines) != wordmarkRows {
t.Fatalf("wordmark has %d rows, want %d", len(lines), wordmarkRows)
}
width := utf8.RuneCountInString(lines[0])
for i, ln := range lines {
if got := utf8.RuneCountInString(ln); got != width {
t.Fatalf("row %d width = %d, want %d", i, got, width)
}
}
}
func TestPickerWraps(t *testing.T) {
p := newPicker([]pickItem{{title: "a"}, {title: "b"}, {title: "c"}})
p.up()
if p.cursor != 2 {
t.Fatalf("up from 0 = %d, want 2", p.cursor)
}
p.down()
if p.cursor != 0 {
t.Fatalf("down from 2 = %d, want 0", p.cursor)
}
}
func TestSecureFormFieldsAndToggle(t *testing.T) {
f := newSecureForm()
if len(f.fields()) != 2 {
t.Fatalf("plaintext fields = %d, want 2", len(f.fields()))
}
f, _ = f.update(keySpace())
if !f.encrypt {
t.Fatalf("space did not enable encryption")
}
if len(f.fields()) != 5 {
t.Fatalf("encrypted fields = %d, want 5", len(f.fields()))
}
f, _ = f.update(keyDown())
f, _ = f.update(typeText("s3cret"))
if string(f.passphrase()) != "s3cret" {
t.Fatalf("passphrase = %q", f.passphrase())
}
if f.cipherValue() != "chacha20" {
t.Fatalf("default cipher = %q", f.cipherValue())
}
}
func TestSecureFormChoiceCycles(t *testing.T) {
f := newSecureForm()
f, _ = f.update(keySpace())
f, _ = f.update(keyDown())
f, _ = f.update(keyDown())
if f.focusField() != secCipher {
t.Fatalf("focus = %d, want secCipher", f.focusField())
}
before := f.cipherValue()
f, _ = f.update(keySpace())
if f.cipherValue() == before {
t.Fatalf("cipher did not cycle from %q", before)
}
}
func TestOutputExtensions(t *testing.T) {
cases := map[string]string{"image": ".png", "audio": ".wav", "qr": ".png", "text": ".txt", "pdf": ".pdf"}
for format, want := range cases {
if got := outputExt(format); got != want {
t.Fatalf("outputExt(%q) = %q, want %q", format, got, want)
}
}
}
func TestSuggestOutput(t *testing.T) {
if got := suggestOutput("/home/x/photo.png", "image"); got != "photo.stego.png" {
t.Fatalf("suggestOutput = %q", got)
}
if got := suggestOutput("", "audio"); got != "crypha.stego.wav" {
t.Fatalf("suggestOutput empty = %q", got)
}
}
func TestIsPrintable(t *testing.T) {
if !isPrintable([]byte("hello\nworld\t!")) {
t.Fatalf("printable text rejected")
}
if isPrintable([]byte{0x00, 0x01, 0xff}) {
t.Fatalf("binary accepted as printable")
}
}
func TestHexPreviewTruncates(t *testing.T) {
data := make([]byte, 100)
got := hexPreview(data, 4)
if got != "00 00 00 00 ..." {
t.Fatalf("hexPreview = %q", got)
}
}
func TestFormatLabel(t *testing.T) {
if formatLabel("", "") != "auto-detect" {
t.Fatalf("empty format label")
}
if formatLabel("pdf", "append") != "pdf (append)" {
t.Fatalf("technique label")
}
if formatLabel("image", "") != "image" {
t.Fatalf("plain format label")
}
}

View File

@ -0,0 +1,17 @@
/*
©AngelaMos | 2026
tui.go
Program entry that constructs and runs the interactive bubbletea wizard
*/
package tui
import (
tea "github.com/charmbracelet/bubbletea"
)
func Run() error {
_, err := tea.NewProgram(New(), tea.WithAltScreen()).Run()
return err
}

View File

@ -0,0 +1,443 @@
/*
©AngelaMos | 2026
tui_test.go
Model navigation, engine round-trips through the wizard, and view smoke tests
*/
package tui
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
"github.com/CarterPerez-dev/crypha/internal/engine"
tea "github.com/charmbracelet/bubbletea"
)
func ready() Model {
m := New()
nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 44})
return nm.(Model)
}
func step(m Model, msg tea.Msg) (Model, tea.Cmd) {
nm, cmd := m.Update(msg)
return nm.(Model), cmd
}
func keyEnter() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} }
func keyEsc() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} }
func keyDown() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDown} }
func keyTab() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyTab} }
func keySpace() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeySpace} }
func typeText(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
func drain(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if batch, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range batch {
out = append(out, drain(c)...)
}
return out
}
return []tea.Msg{msg}
}
func finishRun(t *testing.T, m Model, cmd tea.Cmd) Model {
t.Helper()
for _, msg := range drain(cmd) {
m, _ = step(m, msg)
}
for i := 0; i < 60 && m.stage == stageRunning; i++ {
m, _ = step(m, tickMsg{})
}
return m
}
func makePNG(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: 0xFF})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func pickFormat(t *testing.T, m Model, name string) Model {
t.Helper()
for i := 0; i <= len(m.fmtPick.items); i++ {
if m.fmtPick.selected().value == name {
return m
}
m, _ = step(m, keyDown())
}
t.Fatalf("format %q not present in picker", name)
return m
}
func TestHideRevealRoundTripPlaintext(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "out.png")
cover := makePNG(t, 64, 64)
const secret = "attack at dawn"
m := ready()
m, _ = step(m, keyEnter())
if m.stage != stageFormat {
t.Fatalf("after operation: stage = %v", m.stage)
}
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
if m.stage != stageCover {
t.Fatalf("after format: stage = %v", m.stage)
}
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover})
if m.stage != stagePayload {
t.Fatalf("after cover: stage = %v", m.stage)
}
m, _ = step(m, typeText(secret))
m, _ = step(m, keyEnter())
if m.stage != stageSecure {
t.Fatalf("after payload: stage = %v", m.stage)
}
m, _ = step(m, keyEnter())
if m.stage != stageSave {
t.Fatalf("after secure: stage = %v", m.stage)
}
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
if m.stage != stageReview {
t.Fatalf("after save: stage = %v", m.stage)
}
if !m.hideFits() {
t.Fatalf("payload should fit a 64x64 cover")
}
m, cmd := step(m, keyEnter())
if m.stage != stageRunning {
t.Fatalf("after review: stage = %v", m.stage)
}
m = finishRun(t, m, cmd)
if m.stage != stageResult {
t.Fatalf("run did not finish: stage = %v", m.stage)
}
if m.engineErr != nil {
t.Fatalf("hide errored: %v", m.engineErr)
}
if m.hideRes.Format != "image" {
t.Fatalf("hide format = %q", m.hideRes.Format)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("stego file not written: %v", err)
}
stego, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read stego: %v", err)
}
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
if r.op != opReveal || r.stage != stageFormat {
t.Fatalf("reveal setup: op=%v stage=%v", r.op, r.stage)
}
r, _ = step(r, keyEnter())
if r.format != "" || r.stage != stageCover {
t.Fatalf("reveal auto-detect setup: format=%q stage=%v", r.format, r.stage)
}
r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego})
if r.stage != stageRunning {
t.Fatalf("reveal after stego: stage = %v", r.stage)
}
r = finishRun(t, r, cmd)
if r.stage != stageResult {
t.Fatalf("reveal did not finish: stage = %v", r.stage)
}
if r.engineErr != nil {
t.Fatalf("reveal errored: %v", r.engineErr)
}
if got := string(r.revealRes.Data); got != secret {
t.Fatalf("revealed %q, want %q", got, secret)
}
}
func TestHideRevealRoundTripEncrypted(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "enc.png")
cover := makePNG(t, 64, 64)
const secret = "the eagle lands at noon"
const pass = "correct horse battery staple"
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover})
m, _ = step(m, typeText(secret))
m, _ = step(m, keyEnter())
if m.stage != stageSecure {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keySpace())
if !m.secure.encrypt {
t.Fatalf("encrypt toggle did not engage")
}
m, _ = step(m, keyDown())
m, _ = step(m, typeText(pass))
m, _ = step(m, keyEnter())
if m.stage != stageSave {
t.Fatalf("after secure: stage = %v", m.stage)
}
if len(m.pass) == 0 {
t.Fatalf("passphrase not captured")
}
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
m, cmd := step(m, keyEnter())
m = finishRun(t, m, cmd)
if m.engineErr != nil {
t.Fatalf("encrypted hide errored: %v", m.engineErr)
}
if !m.hideRes.Encrypted {
t.Fatalf("result not marked encrypted")
}
stego, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read stego: %v", err)
}
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
r = pickFormat(t, r, "image")
r, _ = step(r, keyEnter())
if r.format != "image" {
t.Fatalf("reveal format = %q", r.format)
}
r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego})
r = finishRun(t, r, cmd)
if r.stage != stagePassphrase {
t.Fatalf("encrypted reveal should prompt for a passphrase, stage = %v", r.stage)
}
r, _ = step(r, typeText(pass))
r, cmd = step(r, keyEnter())
if r.stage != stageRunning {
t.Fatalf("after passphrase: stage = %v", r.stage)
}
r = finishRun(t, r, cmd)
if r.stage != stageResult || r.engineErr != nil {
t.Fatalf("encrypted reveal failed: stage=%v err=%v", r.stage, r.engineErr)
}
if got := string(r.revealRes.Data); got != secret {
t.Fatalf("revealed %q, want %q", got, secret)
}
if !r.revealRes.Encrypted {
t.Fatalf("revealed payload not marked encrypted")
}
}
func TestWrongPassphraseReprompts(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "wp.png")
cover := makePNG(t, 64, 64)
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, typeText("classified"))
m, _ = step(m, keyEnter())
m, _ = step(m, keySpace())
m, _ = step(m, keyDown())
m, _ = step(m, typeText("realpass"))
m, _ = step(m, keyEnter())
m.outPath.SetValue(out)
m, _ = step(m, keyEnter())
m, cmd := step(m, keyEnter())
m = finishRun(t, m, cmd)
if m.engineErr != nil {
t.Fatalf("hide errored: %v", m.engineErr)
}
stego, _ := os.ReadFile(out)
r := ready()
r, _ = step(r, keyDown())
r, _ = step(r, keyEnter())
r = pickFormat(t, r, "image")
r, _ = step(r, keyEnter())
r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego})
r = finishRun(t, r, cmd)
if r.stage != stagePassphrase {
t.Fatalf("stage = %v", r.stage)
}
r, _ = step(r, typeText("wrongpass"))
r, cmd = step(r, keyEnter())
r = finishRun(t, r, cmd)
if r.stage != stagePassphrase {
t.Fatalf("wrong passphrase should re-prompt, stage = %v", r.stage)
}
if r.passPrompts < 2 {
t.Fatalf("passPrompts = %d, want >= 2", r.passPrompts)
}
}
func TestCapacityFlow(t *testing.T) {
cover := makePNG(t, 48, 48)
m := ready()
m, _ = step(m, keyDown())
m, _ = step(m, keyDown())
m, _ = step(m, keyEnter())
if m.op != opCapacity || m.stage != stageCover {
t.Fatalf("capacity setup: op=%v stage=%v", m.op, m.stage)
}
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover})
if m.stage != stageReview {
t.Fatalf("after cover: stage = %v", m.stage)
}
if len(m.capRows) == 0 {
t.Fatalf("capacity rows not built")
}
var haveImage bool
for _, r := range m.capRows {
if r.format == "image" {
haveImage = true
if !r.applicable || r.maxPlaintext <= 0 {
t.Fatalf("image row = %+v", r)
}
}
}
if !haveImage {
t.Fatalf("no image row in capacity report")
}
}
func TestPdfTechniqueBranch(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "pdf")
m, _ = step(m, keyEnter())
if m.format != "pdf" || m.stage != stageTechnique {
t.Fatalf("pdf branch: format=%q stage=%v", m.format, m.stage)
}
m, _ = step(m, keyEnter())
if m.technique != "attachment" || m.stage != stageCover {
t.Fatalf("technique branch: technique=%q stage=%v", m.technique, m.stage)
}
}
func TestTechniqueClearedOnFormatChange(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
m = pickFormat(t, m, "pdf")
m, _ = step(m, keyEnter())
if m.stage != stageTechnique {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keyDown())
m, _ = step(m, keyEnter())
if m.technique == "" {
t.Fatalf("technique should be set after choosing one")
}
m, _ = step(m, keyEsc())
m, _ = step(m, keyEsc())
if m.stage != stageFormat {
t.Fatalf("back to format: stage = %v", m.stage)
}
m = pickFormat(t, m, "image")
m, _ = step(m, keyEnter())
if m.technique != "" {
t.Fatalf("technique not cleared after switching to image: %q", m.technique)
}
if m.stage != stageCover {
t.Fatalf("stage = %v", m.stage)
}
}
func TestBackNavigation(t *testing.T) {
m := ready()
m, _ = step(m, keyEnter())
if m.stage != stageFormat {
t.Fatalf("stage = %v", m.stage)
}
m, _ = step(m, keyEsc())
if m.stage != stageOperation {
t.Fatalf("back did not return to operation: %v", m.stage)
}
}
func TestEmptyMessageBlocks(t *testing.T) {
cover := makePNG(t, 32, 32)
m := ready()
m, _ = step(m, keyEnter())
m, _ = step(m, keyEnter())
m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover})
m, _ = step(m, keyEnter())
if m.stage != stagePayload {
t.Fatalf("empty message should not advance, stage = %v", m.stage)
}
if m.err == nil {
t.Fatalf("expected a validation error")
}
}
func TestViewAllStagesNoPanic(t *testing.T) {
cover := makePNG(t, 16, 16)
stages := []stage{
stageOperation, stageFormat, stageTechnique, stageCover, stagePayload,
stageSecure, stageSave, stageReview, stagePassphrase, stageRunning, stageResult,
}
for _, op := range []operation{opHide, opReveal, opCapacity} {
for _, s := range stages {
for _, w := range []int{50, 100} {
m := ready()
m.width = w
m.op = op
m.stage = s
m.format = "image"
m.coverPath = "cover.png"
m.payloadBytes = []byte("preview")
m.payloadLabel = inlineLabel
m.capValue = 4096
m.envelopeSize = 200
m.capRows = buildCapRows(engine.CapacityAll(cover))
m.fmtPick = newPicker(formatItems(op == opReveal))
m.techPick = newPicker(techniqueItems("pdf"))
m.revealRes = engine.RevealResult{Format: "image", Data: []byte("hi")}
m.hideRes = engine.HideResult{Format: "image", PayloadBytes: 7, EnvelopeBytes: 21}
if got := m.View(); got == "" {
t.Fatalf("empty view at op=%d stage=%d", op, s)
}
}
}
}
}
func TestQuitFromResult(t *testing.T) {
m := ready()
m.stage = stageResult
_, cmd := step(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
if cmd == nil {
t.Fatalf("q on result should return a quit command")
}
}

View File

@ -0,0 +1,399 @@
/*
©AngelaMos | 2026
update.go
Message dispatch, per-stage key handling, and async engine result wiring
*/
package tui
import (
"errors"
"path/filepath"
"strings"
"github.com/CarterPerez-dev/crypha/internal/payload"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m.onResize(msg)
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
m.quitting = true
return m, tea.Quit
}
return m.onKey(msg)
case fileLoadedMsg:
return m.onFileLoaded(msg)
case hideDoneMsg:
return m.onHideDone(msg)
case revealDoneMsg:
return m.onRevealDone(msg)
case savedMsg:
return m.onSaved(msg)
case tickMsg:
return m.onTick()
default:
return m.forward(msg)
}
}
func (m Model) onResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.ready = true
var cmd tea.Cmd
m.files, cmd = m.files.Update(msg)
return m, cmd
}
func (m Model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.err = nil
switch m.stage {
case stageOperation:
return m.keyOperation(msg)
case stageFormat:
return m.keyFormat(msg)
case stageTechnique:
return m.keyTechnique(msg)
case stageCover:
return m.keyCover(msg)
case stagePayload:
return m.keyPayload(msg)
case stageSecure:
return m.keySecure(msg)
case stageSave:
return m.keySave(msg)
case stageReview:
return m.keyReview(msg)
case stagePassphrase:
return m.keyPassphrase(msg)
case stageResult:
return m.keyResult(msg)
}
return m, nil
}
func (m Model) keyOperation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "up", "k":
m.opPick.up()
case "down", "j":
m.opPick.down()
case "enter":
m.op = operation(m.opPick.cursor)
return m.advance()
}
return m, nil
}
func (m Model) keyFormat(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "esc":
return m.back()
case "up", "k":
m.fmtPick.up()
case "down", "j":
m.fmtPick.down()
case "enter":
m.format = m.fmtPick.selected().value
m.technique = ""
return m.advance()
}
return m, nil
}
func (m Model) keyTechnique(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "esc":
return m.back()
case "up", "k":
m.techPick.up()
case "down", "j":
m.techPick.down()
case "enter":
m.technique = m.techPick.selected().value
return m.advance()
}
return m, nil
}
func (m Model) keyCover(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "esc" {
return m.back()
}
var cmd tea.Cmd
m.files, cmd = m.files.Update(msg)
if ok, path := m.files.DidSelectFile(msg); ok {
return m, loadFileCmd(stageCover, path)
}
return m, cmd
}
func (m Model) keyPayload(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.payloadMode == payloadFile {
switch msg.String() {
case "esc", "tab":
m.payloadMode = payloadMessage
return m, m.message.Focus()
}
var cmd tea.Cmd
m.files, cmd = m.files.Update(msg)
if ok, path := m.files.DidSelectFile(msg); ok {
return m, loadFileCmd(stagePayload, path)
}
return m, cmd
}
switch msg.String() {
case "esc":
return m.back()
case "tab":
m.payloadMode = payloadFile
m.message.Blur()
return m, m.files.Init()
case "enter":
if strings.TrimSpace(m.message.Value()) == "" {
m.err = errNeedPayload
return m, nil
}
m.payloadBytes = []byte(m.message.Value())
m.payloadLabel = inlineLabel
return m.advance()
}
var cmd tea.Cmd
m.message, cmd = m.message.Update(msg)
return m, cmd
}
func (m Model) keySecure(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
return m.back()
case "enter":
if m.secure.encrypt && len(m.secure.passphrase()) == 0 {
m.err = errNeedPassphrase
return m, nil
}
m.pass = m.secure.passphrase()
return m.advance()
}
var cmd tea.Cmd
m.secure, cmd = m.secure.update(msg)
return m, cmd
}
func (m Model) keySave(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
return m.back()
case "enter":
if strings.TrimSpace(m.outPath.Value()) == "" {
m.err = errNeedOutput
return m, nil
}
return m.advance()
}
var cmd tea.Cmd
m.outPath, cmd = m.outPath.Update(msg)
return m, cmd
}
func (m Model) keyReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
m.quitting = true
return m, tea.Quit
case "esc":
return m.back()
case "n":
return m.reset()
}
if m.op == opCapacity {
return m, nil
}
if msg.String() == "enter" {
if !m.hideFits() {
m.err = errPayloadTooBig
return m, nil
}
return m.advance()
}
return m, nil
}
func (m Model) keyPassphrase(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.revPass.Blur()
cmd := m.enter(stageCover)
return m, cmd
case "enter":
return m, m.enter(stageRunning)
}
var cmd tea.Cmd
m.revPass, cmd = m.revPass.Update(msg)
return m, cmd
}
func (m Model) keyResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.saving {
return m.keyResultSaving(msg)
}
switch msg.String() {
case "q", "esc", "enter":
m.quitting = true
return m, tea.Quit
case "n":
return m.reset()
case "s":
if m.canSaveReveal() {
m.saving = true
m.outPath.SetValue(m.suggestReveal())
return m, m.outPath.Focus()
}
}
return m, nil
}
func (m Model) keyResultSaving(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.saving = false
m.outPath.Blur()
return m, nil
case "enter":
if strings.TrimSpace(m.outPath.Value()) == "" {
m.err = errNeedOutput
return m, nil
}
return m, saveCmd(strings.TrimSpace(m.outPath.Value()), m.revealRes.Data)
}
var cmd tea.Cmd
m.outPath, cmd = m.outPath.Update(msg)
return m, cmd
}
func (m Model) onFileLoaded(msg fileLoadedMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.err = msg.err
return m, nil
}
switch msg.origin {
case stageCover:
m.coverPath = msg.path
m.coverBytes = msg.data
m.passPrompts = 0
return m.advance()
case stagePayload:
if len(msg.data) == 0 {
m.err = errEmptyFile
return m, nil
}
m.payloadBytes = msg.data
m.payloadLabel = filepath.Base(msg.path)
m.payloadMode = payloadMessage
return m.advance()
}
return m, nil
}
func (m Model) onHideDone(msg hideDoneMsg) (tea.Model, tea.Cmd) {
m.engineDone = true
if msg.err != nil {
m.engineErr = msg.err
} else {
m.hideRes = msg.res
m.stego = msg.data
m.outputAt = m.outPath.Value()
}
return m.maybeFinish()
}
func (m Model) onRevealDone(msg revealDoneMsg) (tea.Model, tea.Cmd) {
m.engineDone = true
m.revealRes = msg.res
m.engineErr = msg.err
return m.maybeFinish()
}
func (m Model) onSaved(msg savedMsg) (tea.Model, tea.Cmd) {
m.saving = false
m.outPath.Blur()
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.savedAt = msg.path
return m, nil
}
func (m Model) onTick() (tea.Model, tea.Cmd) {
if m.stage != stageRunning {
return m, nil
}
m.animFrac += animStep
if m.animFrac >= 1 {
m.animFrac = 1
return m.maybeFinish()
}
return m, tick()
}
func (m Model) maybeFinish() (tea.Model, tea.Cmd) {
if m.stage != stageRunning || !m.engineDone || m.animFrac < 1 {
return m, nil
}
if m.op == opReveal && needsPassphrase(m.engineErr) && m.passPrompts < maxRevealPrompts {
m.passPrompts++
m.revPass.Reset()
m.stage = stagePassphrase
return m, m.revPass.Focus()
}
m.stage = stageResult
return m, nil
}
func (m Model) forward(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch m.stage {
case stageCover:
m.files, cmd = m.files.Update(msg)
case stagePayload:
if m.payloadMode == payloadFile {
m.files, cmd = m.files.Update(msg)
} else {
m.message, cmd = m.message.Update(msg)
}
case stageSecure:
m.secure, cmd = m.secure.update(msg)
case stageSave:
m.outPath, cmd = m.outPath.Update(msg)
case stagePassphrase:
m.revPass, cmd = m.revPass.Update(msg)
case stageResult:
if m.saving {
m.outPath, cmd = m.outPath.Update(msg)
}
}
return m, cmd
}
func (m Model) canSaveReveal() bool {
return m.op == opReveal && m.engineErr == nil && len(m.revealRes.Data) > 0
}
func needsPassphrase(err error) bool {
return errors.Is(err, payload.ErrPassphraseRequired) || errors.Is(err, payload.ErrDecrypt)
}

View File

@ -0,0 +1,329 @@
/*
©AngelaMos | 2026
view.go
The render layer: layout composition and one render per wizard stage
*/
package tui
import (
"fmt"
"math"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
)
const (
previewMaxLines = 6
previewHexBytes = 32
startingMessage = "\n starting crypha ...\n"
farewell = "crypha out\n"
)
func (m Model) View() string {
if m.quitting {
return styleHint.Render(farewell)
}
if !m.ready {
return startingMessage
}
inner := m.contentWidth()
parts := []string{
renderHeader(inner),
"",
renderStepper(m.stepLabels(), m.stepIndex()),
"",
m.stageView(inner),
}
if m.err != nil {
parts = append(parts, "", styleError.Width(inner).Render(m.err.Error()))
}
parts = append(parts, "", renderFooter(m.footerHints()))
framed := frameStyle(inner).Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
return lipgloss.PlaceHorizontal(m.width, lipgloss.Center, framed)
}
func (m Model) boxWidth() int {
w := m.width - horizontalGutter
if w > appMaxWidth {
w = appMaxWidth
}
if w < appMinWidth {
w = appMinWidth
}
return w
}
func (m Model) contentWidth() int {
return m.boxWidth() - framePadX*2 - frameBorder*2
}
func (m Model) stageView(w int) string {
switch m.stage {
case stageOperation:
return section("choose an operation", m.opPick.view(w))
case stageFormat:
return section(m.formatPrompt(), m.fmtPick.view(w))
case stageTechnique:
return section("choose a pdf technique", m.techPick.view(w))
case stageCover:
return section(m.coverPrompt(), m.files.View())
case stagePayload:
return m.payloadView()
case stageSecure:
return section("secure the payload", m.secure.view(w))
case stageSave:
return m.saveView()
case stageReview:
return m.reviewView(w)
case stagePassphrase:
return m.passphraseView()
case stageRunning:
return m.runningView()
case stageResult:
return m.resultView(w)
}
return ""
}
func (m Model) formatPrompt() string {
if m.op == opReveal {
return "choose a carrier, or auto-detect"
}
return "choose a carrier"
}
func (m Model) coverPrompt() string {
if m.op == opReveal {
return "select a stego file to inspect"
}
return "select a cover file"
}
func (m Model) payloadView() string {
if m.payloadMode == payloadFile {
body := lipgloss.JoinVertical(lipgloss.Left,
styleHint.Render("choose a payload file · tab or esc to type a message instead"),
"",
m.files.View())
return section("payload from a file", body)
}
body := lipgloss.JoinVertical(lipgloss.Left,
m.message.View(),
"",
styleHint.Render("enter continues · tab loads a file instead"))
return section("payload as an inline message", body)
}
func (m Model) saveView() string {
body := lipgloss.JoinVertical(lipgloss.Left,
m.outPath.View(),
"",
styleHint.Render("where to write the stego file · enter continues"))
return section("save the stego file as", body)
}
func (m Model) reviewView(w int) string {
if m.op == opCapacity {
title := "capacity for " + filepath.Base(m.coverPath)
return section(title, renderCapacityTable(m.capRows, capBarWidth))
}
meter := renderHideMeter(len(m.payloadBytes), m.envelopeSize, m.capValue, m.capErr, meterBarWidth)
body := lipgloss.JoinVertical(lipgloss.Left,
m.hideSummary(),
"",
meter,
"",
styleHint.Render("enter to embed · esc to go back"))
return section("review", body)
}
func (m Model) hideSummary() string {
lines := []string{
kv("carrier", formatLabel(m.format, m.technique)),
kv("payload", fmt.Sprintf("%s (%d B)", m.payloadLabel, len(m.payloadBytes))),
kv("security", m.securitySummary()),
kv("output", m.outPath.Value()),
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (m Model) securitySummary() string {
var parts []string
if m.encrypted() {
parts = append(parts, badge("encrypted", hexViolet),
styleHint.Render(m.secure.cipherValue()+" · "+m.secure.strengthValue()))
} else {
parts = append(parts, styleHint.Render("plaintext"))
}
if m.secure.compress {
parts = append(parts, badge("compressed", hexBlue))
}
return strings.Join(parts, " ")
}
func (m Model) runningView() string {
verb := "embedding payload"
if m.op == opReveal {
verb = "revealing payload"
}
bar := spectralBar(embedBarWidth, m.animFrac, brandStops)
pct := int(math.Round(m.animFrac * fullPercent))
body := lipgloss.JoinVertical(lipgloss.Left,
bar+" "+styleValueBold.Render(fmt.Sprintf("%d%%", pct)),
"",
styleHint.Render(verb+" ..."))
return section(verb, body)
}
func (m Model) passphraseView() string {
prompt := "this payload is encrypted; enter the passphrase to unlock it"
if m.passPrompts > 1 {
prompt = "that passphrase did not work, try again"
}
body := lipgloss.JoinVertical(lipgloss.Left,
styleWarn.Render(prompt),
"",
m.revPass.View(),
"",
styleHint.Render("enter to unlock · esc to cancel"))
return section("unlock", body)
}
func (m Model) resultView(w int) string {
if m.engineErr != nil {
return section("failed", styleError.Width(w).Render(reasonText(m.engineErr)))
}
if m.op == opReveal {
return m.revealResultView(w)
}
return m.hideResultView()
}
func (m Model) hideResultView() string {
r := m.hideRes
lines := []string{
kvStyled("status", "payload hidden", styleSuccess),
kv("carrier", formatLabel(r.Format, r.Technique)),
kv("payload", fmt.Sprintf("%d B", r.PayloadBytes)),
kv("envelope", fmt.Sprintf("%d B", r.EnvelopeBytes)),
kv("security", boolBadges(r.Encrypted, r.Compressed)),
kv("output", m.outputAt),
}
body := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinVertical(lipgloss.Left, lines...),
"",
styleHint.Render("n new run · q quit"))
return section("done", body)
}
func (m Model) revealResultView(w int) string {
data := m.revealRes.Data
lines := []string{
kvStyled("status", "payload revealed", styleSuccess),
kv("carrier", m.revealRes.Format),
kv("encrypted", boolText(m.revealRes.Encrypted)),
kv("size", fmt.Sprintf("%d B", len(data))),
}
if m.savedAt != "" {
lines = append(lines, kvStyled("saved", m.savedAt, styleSuccess))
}
segments := []string{
lipgloss.JoinVertical(lipgloss.Left, lines...),
"",
revealPreview(data, w),
}
if m.saving {
segments = append(segments, "",
styleLabel.Render("save as"),
m.outPath.View(),
styleHint.Render("enter to write · esc to cancel"))
} else {
segments = append(segments, "", styleHint.Render(m.revealHints()))
}
return section("revealed", lipgloss.JoinVertical(lipgloss.Left, segments...))
}
func (m Model) revealHints() string {
if m.canSaveReveal() {
return "s save to file · n new run · q quit"
}
return "n new run · q quit"
}
func section(title, body string) string {
return lipgloss.JoinVertical(lipgloss.Left, sectionTitle(title), "", body)
}
func revealPreview(data []byte, w int) string {
if len(data) == 0 {
return styleHint.Render("(empty payload)")
}
if isPrintable(data) {
text := lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Width(w).MaxHeight(previewMaxLines).Render(string(data))
return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("message"), text)
}
return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("hex preview"), styleValue.Width(w).Render(hexPreview(data, previewHexBytes)))
}
func formatLabel(format, technique string) string {
if format == "" {
return "auto-detect"
}
if technique == "" {
return format
}
return format + " (" + technique + ")"
}
func boolText(b bool) string {
if b {
return "yes"
}
return "no"
}
func boolBadges(encrypted, compressed bool) string {
var parts []string
if encrypted {
parts = append(parts, badge("encrypted", hexViolet))
} else {
parts = append(parts, styleHint.Render("plaintext"))
}
if compressed {
parts = append(parts, badge("compressed", hexBlue))
}
return strings.Join(parts, " ")
}
func isPrintable(data []byte) bool {
if !utf8.Valid(data) {
return false
}
for _, r := range string(data) {
if r == '\n' || r == '\t' || r == '\r' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func hexPreview(data []byte, n int) string {
truncated := false
if len(data) > n {
data = data[:n]
truncated = true
}
s := fmt.Sprintf("% x", data)
if truncated {
s += " ..."
}
return s
}

View File

@ -0,0 +1,89 @@
# =============================================================================
# ©AngelaMos | 2026
# justfile - crypha steganography multi-tool
# =============================================================================
set shell := ["bash", "-uc"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
binary := "crypha"
version := `git describe --tags --always 2>/dev/null || echo "dev"`
# =============================================================================
# Default
# =============================================================================
default:
@just --list --unsorted
# =============================================================================
# Formatting and Linting
# =============================================================================
[group('lint')]
fmt:
gofmt -w -s cmd/ internal/
[group('lint')]
vet:
go vet ./...
[group('lint')]
lint:
GOTOOLCHAIN=go1.25.7 golangci-lint run ./...
# =============================================================================
# Testing
# =============================================================================
[group('test')]
test *ARGS:
go test ./... {{ARGS}}
[group('test')]
test-race:
go test -race ./...
[group('test')]
cov:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
[group('test')]
cov-html: cov
go tool cover -html=coverage.out
# =============================================================================
# Build and Run
# =============================================================================
[group('build')]
build:
go build -o dist/{{binary}} ./cmd/{{binary}}
[group('build')]
run *ARGS:
go run ./cmd/{{binary}} {{ARGS}}
# =============================================================================
# CI / Housekeeping
# =============================================================================
[group('ci')]
ci: fmt vet test build
[group('ci')]
tidy:
go mod tidy
[group('ci')]
info:
@echo "Binary: {{binary}}"
@echo "Version: {{version}}"
@echo "Go: $(go version)"
[group('ci')]
clean:
-rm -rf dist
-rm -f coverage.out
@echo "Cleaned"

View File

@ -0,0 +1,153 @@
<!-- ©AngelaMos | 2026 -->
<!-- 00-OVERVIEW.md -->
# crypha: Overview
## What This Is
A multi-format steganography tool written in Go. You hand it a message or a file, and it seals that payload in a passphrase-encrypted, compressed, integrity-checked envelope, then hides the envelope inside an ordinary-looking carrier: the low bits of an image or an audio file, the Reed-Solomon slack of a QR code, zero-width characters in a block of text, or the structure of a PDF. Point `reveal` at the result and it auto-detects the carrier and hands the message back. It ships as a single static, dependency-free binary and drives from either a scriptable CLI or a guided terminal wizard.
The point of the project is to understand, by building it, the honest exchange between two adversaries. Steganography hides that a message exists; steganalysis is the statistical hunt that finds it anyway. crypha implements five very different hiding channels, each with its own capacity, fragility, and detection story, and the `learn/` track walks the analysis that breaks each one. Nothing here is a stub. Every carrier round-trips text and random-binary payloads under test, and the QR carrier is differentially tested against reference encoders and decoders.
## Why This Matters
Cryptography and steganography answer two different questions, and people constantly conflate them. Encryption makes a message unreadable. Steganography makes it unnoticeable. Encryption on its own still announces that a secret exists, and an opaque high-entropy blob is itself a signal, the exact thing a data-loss-prevention scanner, an intrusion-detection rule, or a border inspection is trained to flag. Steganography removes the signal by making the carrier look like a holiday photo, a voice memo, a PDF invoice, or a QR code on a poster.
The technique is not academic. It shows up in real intrusions on both sides of the line.
- **Witchetty, 2022.** The espionage group concealed a backdoor inside a bitmap of an old Windows logo hosted on a public cloud service, so the payload arrived on the target looking like an ordinary image download rather than malware (Symantec). The image still rendered as a logo. The code rode in the bits underneath.
- **The Stegano exploit kit, 2016.** Malicious script was hidden in the alpha channel of PNG banner ads served to millions of visitors on mainstream sites (ESET). A tiny per-pixel change to transparency, invisible on the page, carried the redirect logic.
- **Invoke-PSImage, 2017.** An open-source tool that packs a full PowerShell script into the two least-significant bits of the RGB channels of a PNG. It turned image LSB steganography into a point-and-click red-team primitive, and it is the direct ancestor of crypha's `image` carrier.
- **Stegoloader / Gatak, 2015.** This malware family pulled its own components out of images fetched at runtime, keeping the obvious executable code off disk where a scanner would look for it (Dell SecureWorks).
Defenders answer with steganalysis. The chi-square attack, RS analysis, and sample-pair analysis each estimate how much of an image has been touched, without ever needing the key. crypha exists to teach both moves. It encrypts first, so a discovered payload is still unreadable, then hides the ciphertext, and then tells you honestly how each carrier gets caught.
**Real-world scenarios where this applies:**
- **Covert-channel research.** Understanding how a payload survives, or fails to survive, a copy, a re-encode, a normalization pass, or a PDF save-as.
- **Blue-team detection.** Building the intuition to spot LSB embedding, zero-width runes, and appended-after-EOF data before an exfiltration tool uses them against you.
- **Learning applied cryptography.** The envelope is a complete, correct AEAD construction: Argon2id key derivation, ChaCha20-Poly1305, associated-data binding, and fail-closed verification, in about two hundred readable lines.
## What You'll Learn
**Security concepts:**
- **Steganography versus cryptography, and why you want both.** Concealment and secrecy are independent properties. crypha layers them: encrypt for secrecy, hide for concealment.
- **Five hiding channels and their trade-offs.** LSB in pixels and PCM samples, Reed-Solomon error injection in QR codes, zero-width Unicode in text, and three structural techniques in PDF, each with a different capacity, robustness, and detection profile.
- **Steganalysis, honestly.** How a defender detects each carrier: the chi-square and RS attacks on LSB, a one-pass rune scan on zero-width text, `strings | tail` on an appended PDF payload.
- **AEAD done correctly.** Why the header is authenticated as associated data, why a flipped byte must fail to open rather than decrypt to garbage, and why compress-then-encrypt is safe for an offline file tool when it would be dangerous for a network protocol.
**Technical skills:**
- **A plugin architecture in Go.** One `Carrier` interface, a self-registering registry via blank imports, and an engine that dispatches through it without knowing any carrier's internals.
- **Reimplementing a spec.** The QR carrier rebuilds module placement, mask reversal, block de-interleaving, and Reed-Solomon decode from ISO/IEC 18004, because the available Go library exposes only a finished bitmap.
- **Bit-level I/O and format quirks.** MSB-first bit packing, the mandatory NRGBA conversion that stops Go's PNG encoder from corrupting your low bits, and the WAV encoder's mandatory seek-back on close.
- **One engine, two frontends.** A shared brain that a cobra CLI and a bubbletea wizard both drive as thin, interchangeable faces, so the terminal UI holds zero carrier logic.
**Tools and techniques:**
- **`skip2/go-qrcode`** to generate a clean QR matrix, used as a generator and a differential-test oracle, never at runtime for decode.
- **`golang.org/x/crypto`** for Argon2id and ChaCha20-Poly1305, plus `crypto/aes` for the AES-256-GCM alternate.
- **`go-audio/wav`** for PCM read and write, and **`mewkiz/flac`** to decode a FLAC cover to samples.
- **`pdfcpu`** for lossless attachment and metadata techniques, and plain `io` for the append-after-EOF technique.
## Prerequisites
You do not need prior steganography experience. You do need some comfort with the following.
**Required knowledge:**
- **Go basics.** Structs, interfaces, slices, and errors. If you can read a method on an interface value, you can read this code.
- **Bytes and bits.** What a byte is, what "the least-significant bit" means, and that a `uint32` is four big-endian bytes on the wire.
- **What encryption is, roughly.** That a key turns readable data into unreadable data and back. The envelope chapter in [01-CONCEPTS.md](./01-CONCEPTS.md) explains the rest.
**Tools you'll need:**
- **A Go toolchain**, 1.25 or newer, only if you build from source. The prebuilt binary needs nothing. The `install.sh` script fetches a toolchain for you if one is missing and you asked to build.
- **Nothing else to run it.** No API keys, no network, no services. crypha is a one-shot offline file tool.
**Helpful but not required:**
- **ImageMagick and ffmpeg**, to synthesize throwaway covers for experimenting. [DEMO.md](../DEMO.md) shows the exact commands.
- A skim of how QR codes are structured, if you want the showpiece in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) to land faster.
## Quick Start
```bash
# Install (grabs a prebuilt binary, no Go needed):
curl -fsSL https://angelamos.com/crypha/install.sh | bash
# or, with a Go toolchain:
go install github.com/CarterPerez-dev/crypha/cmd/crypha@latest
# Launch the guided wizard:
crypha
# Ask how much a cover can hold:
crypha capacity -i photo.png
# Hide a message in an image, then read it back:
crypha hide -i photo.png -o secret.png --format image -m "meet at noon"
crypha reveal secret.png
```
Expected output: `capacity` prints a per-carrier table with an exact envelope byte count for the cover you gave it. `hide` prints a short receipt (output file, format, payload bytes, envelope bytes, whether it was encrypted and compressed). `reveal` needs no `--format`; it detects the carrier itself, writes the recovered message to stdout, and prints a one-line status to stderr like `revealed 11 bytes via image -> (stdout)`.
Add a passphrase and `reveal` asks for it before it decrypts:
```bash
crypha hide -i photo.png -o secret.png --format image -m "coordinates inside" --encrypt --compress
crypha reveal secret.png # prompts for the passphrase, then decrypts
```
A passphrase from any source, the `-k` flag, the `CRYPHA_PASSPHRASE` environment variable, or the no-echo prompt, always means the payload is encrypted. crypha never silently writes plaintext when you asked for a key.
## Project Structure
```
steganography-multi-tool/
├── cmd/crypha/ # tiny main: it only calls cli.Execute()
├── internal/
│ ├── cli/ # cobra: hide reveal capacity formats version tui, plus secure passphrase input
│ ├── tui/ # the bubbletea wizard, a pure view over the engine
│ ├── engine/ # the shared brain both frontends call
│ ├── carrier/ # the Carrier interface + Register/Get/All/Detect registry
│ │ ├── all/ # blank-import aggregator that registers every carrier
│ │ └── image/ audio/ qr/ text/ pdf/
│ ├── payload/ # the encrypted envelope: Argon2id, AEAD, flate, CRC32, framing
│ ├── bitio/ # MSB-first BitReader / BitWriter
│ ├── config/ # every constant: magic bytes, KDF params, the format catalog
│ └── report/ # human tables and --json rendering
├── learn/ # this teaching track
├── install.sh # the one-shot curl-able installer
├── .goreleaser.yaml # cross-platform release binaries
└── justfile # every recipe
```
The single most important thing to understand first is the seam in `internal/engine/engine.go`. Both frontends call `engine.Hide`, `engine.Reveal`, and `engine.Capacity`; the engine wraps the payload in an envelope and dispatches to a carrier through the registry. Everything in `internal/carrier` exists to store and retrieve opaque bytes, and everything in `internal/payload` exists to build those bytes. Neither knows about the other.
## Next Steps
1. **Understand the ideas.** Read [01-CONCEPTS.md](./01-CONCEPTS.md) for steganography versus cryptography, each of the five hiding channels, the AEAD envelope, and the steganalysis that breaks each carrier, grounded in real incidents.
2. **See the design.** Read [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) for the one-engine-two-frontends design, the self-registering carrier registry, the exact envelope byte layout, and how auto-detect avoids mistaking a QR-PNG for an ordinary image.
3. **Walk the code.** Read [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) to trace a payload from message to hidden bytes, with the QR-from-ISO/IEC-18004 Reed-Solomon injection as the showpiece.
4. **Extend it.** Read [04-CHALLENGES.md](./04-CHALLENGES.md) for projects from a new carrier to variable-density LSB modes to stronger steganalysis resistance.
## Common Issues
**`hide --format image` refuses the cover**
```
crypha: image cover must be an 8-bit PNG or a 24-bit BMP
```
Solution: the image carrier refuses paletted and 16-bit PNGs on purpose, because editing palette indices or high bytes would visibly wreck the cover. ImageMagick defaults to a 16-bit PNG for synthetic gradients; force 8-bit truecolor with `-depth 8 PNG24:cover.png`. A normal photo saved as PNG or a 24-bit BMP just works.
**`reveal` says it found nothing**
```
crypha: no crypha payload detected; pass a format to force a carrier
```
Solution: the file either does not contain a crypha payload, or the carrier was destroyed. LSB carriers survive a byte-for-byte copy but not a re-encode; if you re-saved the stego PNG as JPEG, or the WAV as MP3, the payload is gone. If you know the format, pass `--format` to skip detection and get a specific error.
**`capacity` on a QR cover says "does not fit" for encryption**
```
qr 52 38 does not fit
```
Solution: this is correct, not a bug. A QR code holds only tens of bytes in its correctable-error budget, and an encrypted envelope adds 68 bytes of overhead. QR is a plaintext-only carrier by design, and `capacity` tells you so up front.
## Related Projects
If you found this interesting, look at:
- **metadata-scrubber-tool**: the inverse instinct, stripping the hidden metadata out of a file instead of adding to it.
- **binary-analysis-tool**: static analysis of a binary's structure, the same "read a file format precisely" muscle applied to executables.
- **nadezhda** (security-news-scraper): the same single-static-binary, one-engine-two-frontends shape, applied to security-news intelligence.

View File

@ -0,0 +1,140 @@
<!-- ©AngelaMos | 2026 -->
<!-- 01-CONCEPTS.md -->
# crypha: Concepts
This chapter is the theory crypha is built on. It explains why concealment and secrecy are different problems, how each of the five carriers hides bytes, what the encrypted envelope does, and how a defender catches each carrier anyway. Every claim here is exercised by the code you will read in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md).
## Steganography is not cryptography
These two words get used interchangeably and they should not be. They solve different problems and they compose.
- **Cryptography provides secrecy.** It transforms a message so that without the key, the content is unreadable. It does not hide that a message exists. A PGP block, an encrypted zip, a TLS session: all of them loudly announce "there is a secret here," they just deny you the content.
- **Steganography provides concealment.** It hides that a message exists at all, by embedding it in something that looks unremarkable. Classic steganography does not, by itself, protect the content: if you find the hiding scheme, you read the message.
The failure mode of each is the strength of the other. Encryption's opaque blob is a signal, and signals get flagged. A data-loss-prevention system does not need to break your encryption to stop an exfiltration; it just needs to notice a 40 KB high-entropy attachment leaving the network and quarantine it. Steganography defeats that by never producing a suspicious object in the first place. The carrier is a photo, a song, an invoice.
crypha layers both, in this order:
```
message ──> [ compress ] ──> [ encrypt ] ──> envelope bytes ──> [ hide in carrier ] ──> stego file
```
Encrypt for secrecy, then hide for concealment. If the concealment fails and someone extracts the envelope, they still hold ciphertext they cannot read. If the encryption were somehow broken, they would still have had to notice the payload was there at all. The two layers cover each other's weaknesses. This is the whole design thesis of the tool.
## The carrier idea
A **carrier** is a file format with room to store bytes that a casual observer, and ideally an automated one, will not notice. crypha treats a carrier as a black box with four operations: hide bytes in a cover, reveal bytes from a stego file, report how many bytes a given cover can hold, and sniff whether a file even looks like this kind of carrier. The bytes it stores are always the opaque envelope; the carrier never knows or cares what is inside.
There are two broad families of carrier, and crypha implements both:
- **Substitution carriers** overwrite low-importance bits of existing data. The image and audio carriers replace least-significant bits. The QR carrier substitutes whole codewords within an error-correction budget. These change the cover's data slightly and are detectable by statistics.
- **Additive / structural carriers** append data the format ignores, or use fields the format tolerates. The zero-width text carrier appends invisible runes. The PDF carriers attach a file, write custom metadata keys, or append bytes after the end-of-file marker. These do not alter the visible content at all, but they are trivially found by anyone who looks at the raw bytes.
Neither family is "better." They trade capacity, robustness, and stealth against each other differently, which is exactly why a teaching tool implements five.
## Channel one and two: least-significant-bit substitution
An 8-bit color channel stores a value from 0 to 255. Changing its lowest bit changes the value by at most 1, a difference no eye can see in a photograph. A 16-bit audio sample sits on a scale of roughly 98 dB of dynamic range; flipping its lowest bit moves it by about -96 dBFS, below the noise floor of any real listening environment. So both images and audio have a spare bit per sample that you can overwrite with payload, one payload bit at a time.
```
original R channel 1 0 1 1 0 0 1 [1] payload bit = 0
after embedding 1 0 1 1 0 0 1 [0] value changed 179 -> 178, invisible
^ least-significant bit carries one payload bit
```
Capacity is straightforward. An image holds `width x height x 3 / 8` bytes, three channels of RGB per pixel at one bit each, eight bits to a byte. The alpha channel is deliberately never touched, because a pixel at 254/255 alpha instead of 255 shows as a faint fringe on a non-white background and is a direct steganalysis tell. A 640x480 image gives `640 x 480 x 3 / 8 = 115,200` channel-bits worth of bytes; crypha reserves a four-byte length prefix and reports **115,196** usable envelope bytes. Audio holds `samples x channels / 8`; two seconds of 44.1 kHz mono holds **11,021**.
The catch, and it is a big one, is fragility. LSB steganography survives a byte-for-byte copy and nothing else. Re-encode the PNG as JPEG and the discrete-cosine quantization discards exactly the low-order spatial detail your payload lived in. Re-encode the WAV as MP3 and psychoacoustic compression throws away the inaudible bits, including yours. This is not a bug in crypha; it is the defining property of substitution in a lossless-only channel. crypha refuses JPEG covers outright for this reason.
## Channel three: Reed-Solomon error injection in QR codes
This is the showpiece, and it is the one people get wrong. A QR code is not just a bitmap of a URL. It is a systematic error-correcting code: the data is stored in **data codewords**, and alongside them the encoder computes **error-correction codewords** so a scanner can still read the code when part of it is dirty, torn, or obscured by a logo. At error-correction level H, a QR code can lose about 30% of its codewords and still decode.
The naive, wrong idea is "hide data in the error-correction bits." The correct idea inverts it. You leave the error-correction codewords untouched and you deliberately **corrupt the data codewords**, staying within the code's correction budget. Reed-Solomon over the field GF(2^8) can correct up to `t = floor((n - k) / 2)` unknown-location errors per block, where `n` is the total codewords in a block and `k` is the data codewords. So:
1. Encode the benign cover content (say, a URL) into a clean QR code.
2. Inject up to `t` codeword-errors per block into the data region. The pattern of errors is your payload.
3. An ordinary scanner's Reed-Solomon decoder corrects those errors, recovers the original URL, and shows the user a perfectly normal link. It never knows anything was there.
4. crypha reads the same code, re-derives what the clean data should have been by RS-decoding each block, diffs the corrected data against the received data, and reads the injected error pattern back as the payload.
```
clean data block D0 D1 D2 D3 ... EC codewords intact
inject payload D0 D1^p0 D2 D3^p1 ... up to t corrupted codewords per block
phone scanner RS-corrects -> D0 D1 D2 D3, reads the URL, sees nothing
crypha RS-corrects -> D0 D1 D2 D3, XOR-diffs -> p0 p1, reads the payload
```
The receiver needs only the code and the version, not a separate copy of the cover; the correction machinery reconstructs the clean data for it. This is "blind" extraction.
The honest cost is capacity. Each corrupted codeword carries one payload byte, and crypha stays at half the theoretical budget for decode margin, so a small QR holds tens of bytes. The 28-character cover `https://angelamos.com/crypha` gives an envelope capacity of **52 bytes**, which is **38 bytes** of plaintext after framing and **does not fit** an encrypted envelope at all. The widely-quoted 7/15/25/30% figures for L/M/Q/H are the error-correction fraction of total codewords, not payload size. Do not confuse them. QR is a plaintext-only carrier, and the tool says so.
## Channel four: zero-width Unicode text
Unicode contains characters that render to nothing. crypha uses exactly two:
```
U+200B ZERO WIDTH SPACE -> bit 0
U+2060 WORD JOINER -> bit 1
```
Both are format characters with the `Default_Ignorable` property, both take zero display width, and neither causes any shaping or joining behavior, which rules out characters like U+200D (the zero-width joiner) that visibly fuse emoji. Eight of these runes encode one payload byte. crypha appends a framed run of them after the visible cover text, so `The quick brown fox.` still reads as `The quick brown fox.` while carrying an invisible payload behind it.
A persistent myth says Unicode normalization strips these characters. It does not. None of the four normalization forms, NFC, NFD, NFKC, NFKD, has a decomposition mapping for U+200B or U+2060, so all four leave them exactly in place. The myth comes from misreading two unrelated algorithms: IDNA2003 maps zero-width joiners to nothing, but only inside domain-name labels, not message text; and the UTS #39 confusable-skeleton algorithm removes ignorable characters, but that is an identifier-security check, not a text pipeline. A normal copy-paste, a database round-trip, or a chat message preserves the payload.
The trade-off here is the opposite of QR. Capacity is effectively unbounded and the visible text is byte-for-byte identical, but detection is trivial. This is concealment from a human skim, not a covert channel against a machine.
## Channel five: PDF structure
A PDF is a container with slack in several places, and crypha uses three, offered as `--technique`:
- **attachment** (default): PDF supports embedded file attachments as a first-class feature. crypha attaches the envelope as a lossless embedded file. It is robust, it survives a normal save in Acrobat or Preview, and it is the correct default for a teaching carrier because the round-trip is clean and easy to verify. Its stealth is the weakest of the three: the attachment shows in the panel and in `pdfinfo`.
- **metadata**: the PDF Info dictionary tolerates custom keys, and the spec requires readers to ignore keys they do not recognize. crypha stores a base64 payload across custom keys. It survives controlled delivery but is fragile against an "optimize" or "linearize" pass that drops non-standard keys.
- **append**: the spec says a reader seeks back to the last cross-reference table, so anything after the final `%%EOF` marker with no valid xref is ignored. crypha appends the framed envelope there. It is the purest "zero change to the document structure" option and survives naive copies, but any full save-as rewrite discards it.
All three are structural, not statistical, so none of them changes a single rendered pixel of the document. All three are found instantly by anyone who looks at the raw bytes.
## The encrypted envelope
Whatever the carrier, every payload is packed into one versioned envelope first. The carrier only ever stores these opaque bytes; all cryptography, compression, integrity, and versioning live in one place. This is the layout, with the exact overhead:
```
plaintext magic(4) ver(1) flags(1) │ len(4) body(N) │ crc32(4) +14 bytes
encrypted magic(4) ver(1) flags(1) cipher(1) params(9) salt(16) nonce(12) │ len(4) body(N+16) │ crc32(4)
└───────────────── authenticated as AEAD associated data ───────┘ +68 bytes
```
Three ideas do the work here.
**Argon2id for the key.** A passphrase is not a key. Argon2id (RFC 9106) is a memory-hard key-derivation function: it turns a passphrase plus a random 16-byte salt into a 32-byte key while deliberately burning memory and time, so an attacker guessing passphrases pays that cost per guess. crypha's default is the RFC's laptop-safe profile (64 MiB, three passes); `--strength high` uses the 2 GiB profile. The exact parameters used are written into the envelope, so `reveal` reproduces the same key without you re-declaring the profile.
**Authenticated encryption for secrecy and integrity together.** crypha defaults to ChaCha20-Poly1305 (RFC 8439), with AES-256-GCM behind `--cipher aes256gcm`. ChaCha20 is the default deliberately: it is constant-time in software on any CPU, while Go's AES-GCM is only constant-time with AES-NI hardware, and a portable file tool cannot assume the target has it. Both are AEAD constructions, meaning they produce a 16-byte authentication tag alongside the ciphertext. If any ciphertext byte is flipped, `Open` returns an error, not garbage.
**The header is authenticated as associated data.** This is the subtle, important part. The entire header up to and including the nonce is passed to the AEAD as "additional authenticated data." It is not encrypted, but it is covered by the tag. So an attacker cannot flip the "encrypted" flag, downgrade the cipher, or tamper with the Argon2id parameters without breaking authentication. An unknown version is rejected loudly rather than guessed. A tampered byte or a wrong passphrase fails closed.
One design question worth naming: crypha compresses **before** it encrypts. Compressing after encryption is pointless (ciphertext does not compress), but compressing before encryption is what enabled the CRIME and BREACH attacks on TLS. Those attacks require an adaptive network attacker who can inject chosen plaintext and watch the compressed-then-encrypted length across many requests. A one-shot offline file tool has no such oracle: there is no attacker in the loop injecting guesses. So compress-then-encrypt is safe here, and it means the ciphertext, and therefore the required carrier capacity, is smaller.
## Steganalysis: how each carrier gets caught
A teaching tool that only showed you how to hide would be lying by omission. Here is how a defender breaks each carrier. crypha's threat model assumes the defender is competent, which is exactly why the payload is always encrypted first.
**Image and audio LSB are caught by statistics.** LSB embedding leaves a faint, measurable fingerprint even though it is invisible. The classic attacks:
- **The chi-square attack** (Westfeld and Pfitzmann, 1999) exploits that sequential LSB embedding drives pairs of values, like 178 and 179, toward equal frequency. It measures how close each such pair is to a 50/50 split and flags images where they are suspiciously balanced.
- **RS analysis** (Fridrich, Goljan, and Du, 2001) flips LSBs in groups and watches how a "smoothness" measure of the image responds. Clean and embedded images respond differently, and the method estimates the embedded payload fraction, not just its presence.
- **Sample-pair analysis** (Dumitrescu, Wu, and Wang, 2002) does something similar with a precise statistical model of sample pairs.
- **StegExpose** (Boehm, 2014) fuses these into one detector for PNG and BMP.
The practical defense against all of them is to embed sparsely and randomly rather than filling every low bit sequentially, which is why crypha defaults to a single bit per channel and treats a 2-bit mode as a documented extension, not a default.
**Zero-width text is caught in one pass.** There is no statistical subtlety. `unicode.Is(unicode.Cf, r)` over the runes, or `grep -P '\xe2\x80\x8b'` over the bytes, or simply noticing that the visible character count is far below the total rune count, finds every hidden byte. There is no deniability against an automated scan, which is again why the payload is encrypted.
**QR injection hides from a scanner, not from an analyst.** A phone scanner genuinely cannot see the payload; its Reed-Solomon decoder erases it as noise. But an analyst who suspects a QR code and re-encodes the visible content into a clean QR can diff it against the suspect image, the same operation crypha's `reveal` performs, and recover the injected pattern. The hiding is robust against the intended consumer and transparent to a motivated investigator.
**PDF structural tricks are caught by reading the file.** `pdfinfo` and the attachment panel expose an attachment. `exiftool` dumps custom metadata keys. `strings file.pdf | tail` reveals bytes appended after `%%EOF`. None of these techniques is statistically covert; they hide from a person who opens the document, not from a person who inspects it.
The honest summary: crypha's carriers are teaching-grade concealment, and the encryption is production-grade secrecy. Against a competent inspector the concealment will often fail, and when it does, the encryption is what still protects the message. That layering is the point.
## Where to go next
[02-ARCHITECTURE.md](./02-ARCHITECTURE.md) turns these ideas into structure: the `Carrier` interface, the self-registering registry, the exact envelope encoding, and the auto-detect logic that keeps a QR-PNG from being mistaken for an ordinary image.

View File

@ -0,0 +1,171 @@
<!-- ©AngelaMos | 2026 -->
<!-- 02-ARCHITECTURE.md -->
# crypha: Architecture
This chapter is the shape of the code: how the pieces are separated, why they are separated that way, and how a request flows from a keystroke to a hidden file. The guiding rule is a strict separation of concerns. Carriers know nothing about cryptography. The envelope knows nothing about carriers. The frontends know nothing about either. One engine in the middle wires them together.
## One engine, two frontends
crypha has two ways to drive it: a scriptable cobra CLI and a guided bubbletea wizard. A tempting but wrong design would let each frontend do its own hiding. That path duplicates the important logic, and the two copies drift. crypha instead puts every operation behind one package, `internal/engine`, and makes both frontends thin faces over it.
```
cobra CLI ───┐
├──> engine ──> carrier registry ──> image audio qr text pdf
bubbletea TUI ──┘ │
└──> payload envelope (Argon2id · AEAD · flate · CRC32) ──> bitio
```
The engine exposes exactly the verbs the tool has: `Hide`, `Reveal`, `Capacity`, `CapacityAll`, `Catalog`, plus two preflight helpers, `Overhead` and `EnvelopeSize`, that let the wizard show an exact capacity meter before it commits. The cobra commands in `internal/cli` parse flags and call these. The bubbletea wizard in `internal/tui` collects the same inputs through a series of steps and calls the same functions. The TUI holds zero carrier logic; when it needs the exact envelope size for its live meter, it asks `engine.EnvelopeSize`, it does not reach into a carrier. Both auditors of this project confirmed the wizard is a pure view. That is the design working.
The payoff is that a bug fixed in the engine is fixed in both frontends at once, and a new carrier appears in both the CLI and the wizard without either frontend changing.
## The Carrier interface
Every hiding channel implements one small interface, in `internal/carrier/carrier.go`:
```go
type Carrier interface {
Format() string
Hide(cover io.Reader, payload []byte, out io.Writer) error
Reveal(stego io.Reader) ([]byte, error)
Capacity(cover io.Reader) (int, error)
Sniff(stego io.ReadSeeker) bool
}
```
Four verbs and a name. `Hide` reads a cover and writes a stego file carrying the given bytes. `Reveal` reads a stego file and returns the bytes. `Capacity` reports how many bytes a cover can hold. `Sniff` is a cheap "does this file even look like my format" check used by auto-detect. Notice what is absent: the interface takes and returns `[]byte`, never a message, never a passphrase, never an `Options`. A carrier is handed the finished envelope and stores it. It does not know whether those bytes are encrypted, compressed, or plaintext, and it does not care. That ignorance is the separation of concerns made concrete.
Each carrier lives in its own package, `image`, `audio`, `qr`, `text`, `pdf`, and depends only on `internal/carrier` and standard or third-party libraries. No carrier imports another. You can read, test, or replace any one of them in isolation.
## The self-registering registry
Carriers register themselves. Each carrier package has an `init` function that calls `carrier.Register` with an instance of its type:
```go
func init() {
carrier.Register(qrCarrier{})
}
```
The registry is a plain map from format name to `Carrier`, with `Register`, `Get`, `All`, `Formats`, and `Detect` helpers. The trick that makes registration fire is the blank-import aggregator `internal/carrier/all/all.go`, which imports every carrier package purely for its side effect:
```go
import (
_ "github.com/CarterPerez-dev/crypha/internal/carrier/image"
_ "github.com/CarterPerez-dev/crypha/internal/carrier/audio"
// ... qr, text, pdf
)
```
The engine imports `all` once, also with a blank import. That single import chain runs every carrier's `init`, populating the registry before `main` starts. To add a sixth carrier you write its package with an `init` that registers it, add one line to `all.go`, and the engine, both frontends, `formats`, `capacity`, and auto-detect all pick it up with no further wiring. This is the plugin pattern that Go's `image` and `database/sql` packages use, applied here.
`All` returns the carriers sorted by name, so any iteration over them, capacity tables, auto-detect order, format listings, is deterministic. Determinism matters for the tests and for reproducible output.
## The engine as dispatcher
`internal/engine/engine.go` is the seam. Its job on `Hide` is three steps:
```go
func Hide(req HideRequest) (HideResult, error) {
c, err := ResolveCarrier(req.Format, req.Technique) // pick the carrier
env, err := payload.Pack(req.Payload, req.Options) // build the envelope
err = c.Hide(req.Cover, env, req.Out) // store the opaque bytes
return HideResult{ /* receipt */ }, nil
}
```
`ResolveCarrier` is where the one special case lives. Four of the five carriers are resolved by name straight out of the registry. PDF is different because it has techniques: `hide --format pdf --technique attachment|metadata|append`. So `ResolveCarrier` constructs a PDF carrier via `pdf.New(technique)` when the format is `pdf`, and rejects a `--technique` flag on any non-PDF format. This keeps the technique concept contained to the one carrier that has it, rather than polluting the interface for the other four.
`Reveal` runs the reverse: locate the carrier and extract the envelope, check whether the envelope is encrypted, demand a passphrase if it is and none was given, then `payload.Unpack`. `Capacity` and `CapacityAll` just call the carriers' `Capacity` and format the results into rows. The engine never touches bits or ciphers directly; it orchestrates the two subsystems that do.
## The envelope encoding
The envelope is built and parsed in `internal/payload/envelope.go` by `Pack`, `parse`, and `Unpack`. This is the exact byte layout, and the overhead is exact, not approximate:
```
plaintext magic(4) ver(1) flags(1) │ len(4) body(N) │ crc32(4) 14 bytes overhead
encrypted magic(4) ver(1) flags(1) cipher(1) params(9) salt(16) nonce(12) │ len(4) body(N+16) │ crc32(4)
└───────────────── AAD: authenticated, not encrypted ───────────┘ 68 bytes overhead
```
- **magic (4)** is a fixed constant, so `reveal` and auto-detect can reject random bytes cheaply before doing real work.
- **ver (1)** starts at 1. `parse` rejects any other version loudly rather than guessing at a layout it does not understand.
- **flags (1)** carries two bits today: encrypted and compressed, with room to grow.
- When encrypted, the header continues with **cipher (1)** identifying ChaCha20-Poly1305 or AES-256-GCM, **params (9)** holding the Argon2id time, memory, and threads, **salt (16)** for the key derivation, and **nonce (12)** for the AEAD.
- **len (4)** is the big-endian length of the body that follows.
- **body (N)** is the payload after the optional compress and encrypt steps. When encrypted it includes the 16-byte authentication tag.
- **crc32 (4)** covers the body. On the plaintext path it is the only integrity check and is what lets auto-detect confirm "this is a real crypha payload." On the encrypted path the AEAD tag already guarantees integrity, so the CRC is a cheap post-decrypt sanity check.
You can verify these numbers yourself. `crypha capacity -i cover.png --format image` on a 640x480 cover reports an envelope capacity of 115196, a max plaintext of 115182 (14 less), and a max encrypted of 115128 (68 less). The framing overhead is not a rounded estimate; it is these two constants.
The order of operations is the crux of correctness. On hide: compress if asked, then encrypt if asked, then frame. On reveal: unframe, then decrypt, then decompress. `Pack` writes the header first specifically so it can pass the header bytes to `aead.Seal` as associated data before it appends the ciphertext. `parse` records that same header slice as `aad` so `Unpack` can pass it to `aead.Open`. That is how tampering with the version or cipher choice breaks authentication.
## Bit I/O and the carrier plumbing
The substitution carriers need to read and write individual bits, not bytes, and they must agree on bit order. `internal/bitio` provides an MSB-first `BitReader` and `BitWriter`: the high bit of a byte is emitted first. As long as hide and reveal use the same order, any consistent choice works; crypha fixes MSB-first everywhere so a carrier written today and read tomorrow agree.
`internal/config` holds every constant the tool uses: the magic bytes, the KDF parameter profiles, the format catalog and its descriptions, the cipher identifiers. Nothing magic is hard-coded at a call site. `internal/report` renders results two ways, as human-readable aligned tables and, under a global `--json` flag, as machine-readable JSON where `reveal` base64-encodes the payload so binary data survives a pipe.
## Auto-detect, and the shadowing trap
The most interesting piece of the architecture is how `reveal` works with no `--format`. Naively you would sniff each carrier and use the first that says yes. That is exactly what the registry's `carrier.Detect` does, and it is not good enough, because of a shadowing problem:
**a QR stego is a PNG.** If auto-detect sniffs the image carrier first and the image carrier says "yes, I can read LSBs out of this PNG," it will happily extract 115 KB of noise and hand it back, shadowing the QR carrier that actually owns the bytes. Sniffing alone cannot tell the two apart, because the image carrier genuinely can read bits from any PNG; they just are not a valid payload.
The engine solves this in `detect` by adding a validation step. For each carrier in deterministic order it sniffs, then actually reveals, then validates the extracted envelope against the payload format:
```go
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 { // does it have our magic + a good CRC?
return c, env, nil
}
}
return nil, nil, ErrUndetected
}
```
A carrier only wins if the bytes it extracts carry the crypha magic and pass the CRC check. When the image carrier reads noise out of a QR-PNG, `payload.Validate` fails on the magic, the loop moves on, and the QR carrier, which extracts a real framed payload, wins. The correctness auditor for this stage confirmed the behavior empirically: across hundreds of QR stegos, none was shadowed by the image carrier, and across hundreds of random files, none produced a false positive. The `Sniff` step stays fast and envelope-agnostic; the validation step is what makes detection correct.
This is why `reveal` can auto-detect safely and why the DEMO can say, of a QR stego, "it must not be mistaken for an ordinary image carrier. It is not."
## The full data flow
Putting it together, here is one message from `crypha hide` to a file and back:
```
hide -m "meet at noon" --format image --encrypt
├─ cli parses flags, resolves the passphrase (k / env / no-echo prompt)
├─ engine.Hide
│ ├─ ResolveCarrier("image", "") -> image carrier
│ ├─ payload.Pack(msg, {encrypt, compress})
│ │ compress > Argon2id(salt) > ChaCha20-Poly1305.Seal(header as AAD) > frame
│ └─ image.Hide(cover, envelope, out)
│ toNRGBA(cover) > write length prefix + envelope bits into RGB LSBs > png.Encode
└─ receipt: image, 12 bytes payload, 92 bytes envelope, encrypted, compressed
reveal secret.png
├─ engine.Reveal
│ ├─ detect: sniff each carrier, reveal, payload.Validate -> image wins
│ ├─ payload.IsEncrypted(env) -> true, so demand a passphrase
│ └─ payload.Unpack(env, passphrase)
│ unframe > Argon2id(stored salt+params) > AEAD.Open(header as AAD) > decompress
└─ "meet at noon" to stdout, status to stderr
```
Every arrow crosses exactly one seam, and each seam is a package boundary you can test on its own. The next chapter walks the code that lives inside these boxes, with the QR carrier as the showpiece.
## Where to go next
[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) is the code walkthrough: the NRGBA trap in the image carrier, the WAV encoder's seek-back, and the full QR-from-ISO/IEC-18004 injection and Reed-Solomon decode, function by function.

View File

@ -0,0 +1,218 @@
<!-- ©AngelaMos | 2026 -->
<!-- 03-IMPLEMENTATION.md -->
# crypha: Implementation
This chapter walks the code. It starts with the envelope every carrier shares, moves through the three simpler carriers to build intuition, and then spends most of its length on the showpiece: the QR carrier, which reimplements QR module geometry and Reed-Solomon decoding from ISO/IEC 18004 because no Go library exposes what the covert channel needs. Function names are given so you can find each piece; there are no line-number references, since those rot the moment the file changes.
## The envelope: `payload.Pack` and `payload.Unpack`
Everything hidden by any carrier is first built by `Pack` in `internal/payload/envelope.go`. `Pack` takes the raw payload and an `Options` struct and returns the framed envelope bytes. The body of the function is the order of operations from the concepts chapter, made literal:
```go
if opts.Compress {
body, _ = compress(body) // flate, sets flagCompressed
flags |= flagCompressed
}
header.Write(magic[:])
header.WriteByte(currentVersion)
if len(opts.Passphrase) > 0 {
flags |= flagEncrypted
header.WriteByte(flags)
// salt, params, cipher id, nonce all written into the header ...
body = aead.Seal(nil, nonce, body, header.Bytes()) // header IS the AAD
} else {
header.WriteByte(flags)
}
```
The single most important line is `aead.Seal(nil, nonce, body, header.Bytes())`. The fourth argument is the associated data. By passing the entire header, magic, version, flags, cipher id, Argon2id parameters, salt, and nonce, `Pack` binds all of that metadata to the ciphertext's authentication tag. Nothing in the header is secret, but nothing in the header can be changed without invalidating the tag.
`parse` reads the layout back and, crucially, records the exact header slice it consumed as `p.aad`. `Unpack` then calls `aead.Open(nil, p.nonce, body, p.aad)` with that same slice. If an attacker flipped the encrypted flag, downgraded the cipher, or edited the KDF parameters, the `aad` no longer matches what was sealed and `Open` returns an error. crypha maps that to `ErrDecrypt`, "decryption failed (wrong passphrase or tampered data)," and exits non-zero. It never returns partially-decrypted garbage.
The Argon2id parameters travel in the envelope precisely so `reveal` needs no flags. `parse` reads `params` into a `kdfParams` struct, `Unpack` calls `deriveKey(passphrase, p.salt, p.params)`, and the exact same key falls out. `parse` also validates the parameters before use (`p.params.valid()`), because `argon2.IDKey` panics rather than errors on a zero time cost or an impossible memory-to-threads ratio, and a hostile envelope must not be able to trigger that panic.
## The image carrier: the NRGBA trap
`internal/carrier/image/image.go` is short, and almost all of its subtlety is in one helper, `toNRGBA`. The naive version of an LSB image carrier decodes the PNG, edits the low bits, and re-encodes. It produces corrupt output, and the reason is a genuine Go gotcha.
A standard 24-bit truecolor PNG decodes to `*image.RGBA`, which stores color with **premultiplied** alpha. When you hand an `*image.RGBA` to `png.Encode`, the encoder runs an alpha-unmultiply pass that does arithmetic on your pixel values, and that arithmetic rewrites exactly the low bits you just carefully set. Your payload is destroyed on the way out. A color-type-6 PNG decodes to `*image.NRGBA` (non-premultiplied), and for that type `png.Encode` copies the pixel bytes directly with no color math, so the low bits survive.
The fix is to always convert the cover to `*image.NRGBA` before touching it:
```go
func toNRGBA(src stdimage.Image) *stdimage.NRGBA {
if n, ok := src.(*stdimage.NRGBA); ok && n.Rect.Min == (stdimage.Point{}) {
return n
}
dst := stdimage.NewNRGBA(stdimage.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
return dst
}
```
For an opaque source, every pixel at alpha 255, the RGBA-to-NRGBA conversion is exactly lossless, so this costs nothing but a copy. `Hide` then walks payload bits into the low bit of each channel with `pixOffset`, which is where the alpha channel gets protected:
```go
func pixOffset(slot int) int {
return bytesPerPixel*(slot/channelsPerPixel) + (slot % channelsPerPixel)
}
```
The NRGBA pixel buffer is laid out `[R G B A R G B A ...]`, four bytes per pixel. By mapping slot `n` to pixel `n/3` and channel `n%3`, `pixOffset` visits only R, G, and B and steps over every A. The alpha byte is never written, because a pixel at alpha 254 instead of 255 shows as a faint fringe on a composited background and is a direct steganalysis tell.
Before any of this, `rejectLossy` refuses covers that cannot round-trip: `*image.Paletted` (editing a palette index jumps to a whole different color) and the 16-bit types like `*image.RGBA64` (eight bytes per pixel, a different layout). crypha would rather refuse a cover than silently mangle it, which is why the DEMO forces `-depth 8 PNG24:` for its synthetic gradient. A `frame` helper prefixes the payload with a big-endian `uint32` length so `Reveal` reads the length first, then exactly that many payload bytes, and stops.
## The zero-width text carrier: exact-length framing
`internal/carrier/text/text.go` maps bits to the two invisible runes and back. `Hide` builds a frame of `[magic][length][payload]`, walks it bit by bit with the shared `bitio.NewReader`, appends `zeroRune` (U+200B) for a 0 and `oneRune` (U+2060) for a 1, and writes the visible cover followed by the invisible run. Reading is the mirror: `extractBits` ranges over the runes and collects a 0 or 1 for each of the two carrier characters, ignoring everything else.
The interesting defensive detail is `parseFrame`. Because this carrier can be stacked (a stego text can itself be used as a cover), and because a scanner walks every possible magic offset in `findFrame`, the parser must not accept a frame whose declared length disagrees with the bits actually present:
```go
remaining := len(bits) - payloadStart
if uint64(length)*bitsPerByte != uint64(remaining) {
return nil, false // declared length must consume the run exactly
}
```
Requiring the payload to consume the run to its exact end, rather than merely fit inside it, is what stops a spurious earlier magic match from shadowing the real frame. This exactness was a finding fixed during the carrier's audit, and it is the difference between a robust parser and one that occasionally reads the wrong bytes.
## The audio carrier: WAV, and the mandatory Close
`internal/carrier/audio` decodes 16-bit PCM samples with `go-audio/wav`, sets the low bit of each sample the same way the image carrier sets a channel LSB, and re-encodes. The one non-obvious requirement is that the WAV encoder's `Close` is mandatory, not optional cleanup: WAV writes a data-chunk size field in its header that is only known once all samples are written, so `Close` seeks back to the start and patches it. crypha writes to an in-memory `writeseeker` so the encoder can perform that seek even when the ultimate destination is a plain `io.Writer` that cannot seek. A FLAC cover is decoded to the same PCM samples with `mewkiz/flac` and then re-emitted as WAV; native FLAC output is deferred because the only Go FLAC encoder emits frames that strict parsers reject.
## The showpiece: QR Reed-Solomon error injection
Now the main event. The QR carrier hides a payload as deliberate, correctable errors in a QR code's data codewords, so an ordinary phone scanner reads the visible cover and silently repairs the injected errors away, while crypha reads them back as the secret. This is the highest-effort carrier in the project, because the Go ecosystem gives you almost nothing to work with.
### The problem: `skip2` only hands you a finished bitmap
`github.com/skip2/go-qrcode` can generate a QR code and, via `NewWithForcedVersion`, pin its version and error-correction level. But its public surface exposes only `Bitmap() [][]bool`: the final, masked, fully-assembled matrix with function patterns baked in. The codewords, the block structure, and the chosen data mask are all unexported. To inject errors into specific data codewords and later read them back, crypha has to take that finished bitmap apart and reconstruct everything the library hid, from the spec. That reconstruction lives across `qr.go`, `matrix.go`, `blocks.go`, `gf.go`, and `rs.go`. crypha fixes the error-correction level at H and supports versions 1 through 10.
Here is the whole `Hide` pipeline, then each stage.
```go
code, version, _ := selectVersion(string(coverText), len(payload)) // 1. clean QR
clean, _ := matrixFromBitmap(code.Bitmap(), version) // 2. bitmap -> matrix
maskID, level, ok := parseFormat(clean) // 3. read format info
isFunc := functionModules(version) // 4. function map
order := placementOrder(version, isFunc) // 5. zigzag order
serial := readSerial(clean, order, maskID, spec.totalCodewords()) // unmask + read codewords
dataBlocks, ecBlocks, _ := spec.deinterleave(serial) // 6. split into blocks
framed := frame(payload)
injectFramed(dataBlocks, spec, framed) // 7. inject errors
stego := clean.clone()
writeSerial(stego, order, maskID, spec.interleave(dataBlocks, ecBlocks)) // 8. re-lay + write
renderPNG(stego, out) // encode PNG
```
### Stage 1: a clean cover matrix
`selectVersion` asks `skip2` for the smallest supported version (1 to 10) whose H-level capacity holds the payload, and whose cover text fits. It disables the library's quiet-zone border so crypha controls the geometry. `matrixFromBitmap` copies the returned `[][]bool` into crypha's `matrix` type, checking that the dimensions match the version's expected `symbolSize` (21 modules for version 1, growing by 4 per version).
### Stage 3: reading the format information
The format information is a 15-bit field stored twice in the code, encoding the mask id and error-correction level under a BCH error-correcting code. `parseFormat` reads the 15 module cells listed in `formatBitCells`, then recovers the intended value the way a real decoder does, by nearest codeword:
```go
for d := 0; d < formatCodeCount; d++ {
dist := bits.OnesCount(uint(stored ^ formatCode(d)))
if dist < bestDist { bestDist, bestData = dist, d }
}
if bestData < 0 || bestDist > formatMaxDistance { return 0, 0, false }
```
`formatCode` recomputes the BCH encoding of each of the 32 possible format values (including the mandatory mask `0x5412`), and the loop picks whichever is closest in Hamming distance to what was read, rejecting anything more than the code's 3-bit correction radius away. crypha then insists the level is H; any other level means this is not a crypha QR.
### Stage 4: the function map
Payload can only ride in data modules, never in the fixed patterns a scanner uses to orient itself. `functionModules` builds a boolean grid marking every non-data module for the given version: the three finder patterns and their separators, the two timing lines, the alignment patterns (looked up per version in `alignmentCenters`, skipping any that collide with a finder block), and, for version 7 and up, the two version-information blocks. This mask is what the placement walk consults to know which cells to skip.
One subtlety worth calling out: the finder-plus-separator block is 8 modules across, not the 7 of the finder pattern alone. Getting that edge off by one shifts the entire codeword placement and produces garbage. That exact off-by-one, the top-right and bottom-left finder blocks being marked 8 wide rather than 9 or 7, was one of two real bugs the differential tests caught during development. The test that catches it is described at the end of this section.
### Stage 5: the zigzag placement and mask reversal
QR modules are filled in a serpentine order: two columns at a time, starting from the bottom-right, snaking up then down, skipping function modules and the vertical timing line. `placementOrder` reproduces that walk exactly, appending each data-module coordinate to an ordered slice until it has visited every data module the version has:
```go
x := size - 2; y := size - 1; dirUp := true; xOffset := 1
for i := 0; i < count; i++ {
order = append(order, point{x: x + xOffset, y: y})
// toggle between the two columns, step up or down, bounce at the edges,
// hop over the timing column, and skip any function module
}
```
With that ordering in hand, `readSerial` walks it one codeword (8 modules) at a time and reads the bits, but with a mask reversal baked in. A QR code XORs a checkerboard-like mask over its data region so no large blank areas confuse a scanner. To recover the true codeword bits you must XOR the same mask back off:
```go
v := m.grid[p.y][p.x]
if maskBit(maskID, p.y, p.x) {
v = !v // undo the data mask
}
```
`maskBit` implements all eight ISO mask formulas. Skipping this step is not a subtle error; it turns every codeword into noise, and an early version of the carrier that omitted it produced a meaningless module diff. `writeSerial` is the exact inverse, re-applying the mask as it lays codewords back down.
### Stage 6: de-interleaving into blocks
A QR code does not store its blocks end to end; it interleaves their codewords so a physical smudge damages a little of each block rather than destroying one entirely. `blocks.go` holds the per-version, level-H block tables (`versionTable`), and `deinterleave` reverses the interleave to recover the individual data and error-correction blocks, using `blockLayout` to know each block's data and EC lengths. `interleave` is its inverse for the write path. These tables are transcribed from the ISO block-structure tables; they are the least glamorous and most error-prone part of the whole carrier, which is why the differential test against `skip2`'s clean bitmap matters so much.
### Stage 7: injecting the payload
This is the actual hiding, and it is nine lines. `injectFramed` distributes the framed payload bytes across the blocks round-robin, XORing each payload byte into one data codeword:
```go
for t := 0; t < len(framed); t++ {
block := t % nb // spread across blocks round-robin
slot := t / nb // next free slot within the block
if slot >= inject || slot >= len(dataBlocks[block]) {
return ErrPayloadTooLarge
}
dataBlocks[block][slot] ^= framed[t]
}
```
The budget is `inject := spec.injectPerBlock()`, which is `correctable() / 2`, which is `(ecPerBlock / 2) / 2`. The first halving is the Reed-Solomon limit itself, `t = floor((n - k) / 2)` correctable errors per block. The second halving is a deliberate safety margin (`injectionSafetyRatio = 2`): crypha injects only half the errors the code can correct, so the visible content still decodes with comfortable margin on a real scanner. This is exactly why capacity is honest and small. A version-10 code has 8 blocks, 28 EC codewords per block, so `correctable` is 14 and `injectPerBlock` is 7; `8 x 7` is 56 codewords, minus the 4-byte frame prefix, gives the **52**-byte envelope capacity you see in `capacity`.
Because a corrupted data codeword differs from the clean one, and the clean one is recoverable by Reed-Solomon, XOR is the perfect channel: `stego = clean XOR payload`, so `payload = clean XOR stego`, and the receiver recovers `clean` for free by decoding.
### Stage 8: writing it back out
`interleave` re-serializes the now-corrupted data blocks with the untouched EC blocks, `writeSerial` lays them back into a clone of the clean matrix (re-applying the mask), and `renderPNG` scales each module to an 8x8 pixel block, adds a 4-module quiet zone, and encodes a grayscale PNG. The result scans as the cover on any phone.
### Reveal: correcting the errors to read them
`Reveal` reverses the geometry, exactly the same `parseFormat` to `deinterleave` chain, and then `extractFramed` does the cryptographically-satisfying part: it recovers each block's clean data by Reed-Solomon decoding, and XOR-diffs it against what was received.
```go
recv := append(append([]byte(nil), dataBlocks[b]...), ecBlocks[b]...)
corrected, err := rsDecode(recv, ec) // repair the injected errors
clean[b] = corrected[:len(dataBlocks[b])]
// ... then framed[t] = clean[block][slot] ^ dataBlocks[block][slot]
```
`rsDecode`, in `rs.go`, is a from-scratch systematic Reed-Solomon decoder over GF(2^8). It is worth understanding because it is the machinery a phone scanner runs too:
1. **Syndromes** (`rsSyndromes`). Evaluate the received block as a polynomial at successive powers of the field generator. If every syndrome is zero, there are no errors and the block is returned as-is.
2. **Error locator** (`rsErrorLocator`). The Berlekamp-Massey algorithm builds the error-locator polynomial from the syndromes. Its degree is the number of errors; if that exceeds the correctable budget, the block is declared uncorrectable.
3. **Error positions** (`rsErrorPositions`). A Chien search evaluates the locator at every position to find the roots, which point at the corrupted codewords. If the root count does not match the locator degree, it fails cleanly.
4. **Error magnitudes** (`rsSolveMagnitudes`). With positions known, crypha solves a small Vandermonde linear system by Gaussian elimination over the field to find each error's value, then verifies the solution reproduces the syndromes before trusting it.
The field arithmetic underneath, in `gf.go`, is the standard log/exp-table trick: `init` walks the powers of the generator under the QR primitive polynomial `0x11D` to fill `gfExp` and `gfLog`, so multiplication becomes an addition of logarithms and inversion becomes a subtraction. This is the same GF(2^8) every QR implementation uses.
The Chien search hides the second of the two real bugs the differential tests caught: the mapping from a root of the locator polynomial back to a codeword index depends on a position convention, and getting that convention backward finds the right number of errors at the wrong places. The fix is the `(len(recv)-1-p)` term you see in both `rsErrorPositions` and the magnitude setup; it aligns the locator's root positions with the codeword ordering the encoder used.
### The two oracles that keep it honest
crypha reimplemented a spec, and a spec reimplementation is exactly where silent, plausible-looking bugs breed. The defense is differential testing against two independent reference implementations, and neither is used at runtime:
- **Generation oracle: `skip2/go-qrcode`.** For a given cover and version, crypha's own clean-matrix reconstruction must match `skip2`'s `Bitmap()` bit for bit. This validates placement, masking, and block handling all at once. The 8-wide finder-block off-by-one failed this test loudly.
- **Scan oracle: `makiuchi-d/gozxing`.** After injection, the stego PNG must still decode, via a completely independent QR decoder, back to the original cover content. This proves the injection stayed inside the correction budget and the code genuinely self-heals. If crypha ever injected one error too many, `gozxing` would fail to recover the cover and the test would catch it.
Both oracles are test-only dependencies. At runtime crypha does its own generation introspection and its own Reed-Solomon decode; it never calls `gozxing` to read a code. That independence is the whole point: a bug in crypha's decoder cannot hide behind the same bug in its encoder, because the encoder is `skip2` and the scan check is `gozxing`.
## Where to go next
[04-CHALLENGES.md](./04-CHALLENGES.md) turns the reader loose: add a sixth carrier, build a variable-density LSB mode, implement a chi-square detector against crypha's own output, or push the QR carrier past version 10.

View File

@ -0,0 +1,44 @@
<!-- ©AngelaMos | 2026 -->
<!-- 04-CHALLENGES.md -->
# crypha: Challenges
The best way to understand a hiding technique is to extend it, and the best way to understand a hiding technique's weakness is to build the tool that detects it. This chapter is a graded set of projects. Each one names the files and functions you would touch and the test that would prove you finished. They are ordered roughly by effort. Nothing here is a hint at incomplete work; the tool is complete, and these are the doors it deliberately leaves open.
Before you start: `just test` runs the suite, `just test-race` runs it under the race detector, and `just lint` runs the linter pinned to a Go 1.25 toolchain. Every challenge below should end with a green suite and a test that would have failed before your change.
## Warm-ups
**Add a new cipher to the envelope.** The envelope already carries a one-byte cipher identifier, and the AEAD is selected by `newAEAD` and `aeadByID` in `internal/payload`. Add a third AEAD (XChaCha20-Poly1305 is the natural choice; its 24-byte nonce removes any lingering nonce-collision worry) behind a new `--cipher` value. The interesting constraint is the nonce size: `Pack` already writes `aead.NonceSize()` bytes, so the encode side is nonce-agnostic, but `parse` reads a fixed 12-byte nonce field. Decide whether to widen the field (a version bump) or store the nonce length. This teaches you why the header is versioned. Prove it with a round-trip test and a known-answer test that a wrong key fails to open.
**Make the QR quiet zone and module size configurable.** `renderPNG` in `matrix.go` hard-codes an 8-pixel module and a 4-module quiet zone through `modulePixels` and `quietZoneModules`. Move them behind flags, then make `readGrid` tolerant of any module size it can infer rather than assuming 8. The test that matters: a stego produced at module size 4 must still round-trip through `Reveal`, and must still scan under the `gozxing` oracle in the QR test suite.
**Add a capacity-safety flag.** The QR carrier injects only half the correctable budget (`injectionSafetyRatio = 2` in `blocks.go`). Expose that ratio as a `--qr-margin` option so a user can trade robustness for capacity. Then measure the trade honestly: write a test that injects at ratio 1 (the full Reed-Solomon limit) and confirm whether `gozxing` still recovers the cover. You will learn where the real decode margin lives.
## Intermediate
**Build a variable-density LSB mode.** Both the image and audio carriers embed one bit per channel or sample. Two bits per sample doubles capacity and is the technique Invoke-PSImage actually used. Add a `--bits` option (1 or 2) to the image carrier. The work is in `pixOffset` and the read/write loops in `image.go`: instead of masking the single low bit, mask the low two bits and pack two payload bits per channel. The lesson is in the follow-up challenge, because 2-bit embedding is markedly easier to detect.
**Write a chi-square detector against crypha's own output.** This is the single most valuable challenge in the file, because it makes the concepts chapter concrete. Implement the Westfeld-Pfitzmann chi-square attack as a small command or test: for a suspect PNG, build the histogram of the RGB values, measure how close each pair of values that differ only in their low bit (2k and 2k+1) is to being equally frequent, and compute a chi-square statistic. Run it against a clean cover and against a crypha 1-bit stego and a crypha 2-bit stego of the same image. You should see the 2-bit stego light up clearly, the 1-bit stego light up faintly, and the clean cover stay quiet. You will have built the detector that breaks your own tool, which is the entire point of the project.
**Randomize the embedding path from the key.** crypha's image carrier fills channel LSBs sequentially, which is exactly what the chi-square attack keys on. Derive a permutation of the channel-slot order from the passphrase (or a stored seed) and embed along that permutation instead of in raster order. `Reveal` reconstructs the same permutation and reads in the same order. Then run your chi-square detector again: sequential detection should collapse, because the attack assumes contiguous embedding. This connects steganography and cryptography in one change, since the hiding order now depends on the key.
**Add the Tags-block alternate for zero-width text.** The text carrier uses U+200B and U+2060. The Unicode Tags block (U+E0000 to U+E007F) is also invisible and maps directly onto ASCII, so it can be more compact for text-heavy payloads, at the cost of a glaring byte signature (`F3 A0 8x`). Add it behind a flag in `text.go`, keeping the two-rune scheme the default. The teaching value is in documenting, in a test comment or the learn track, exactly why the default is the safer alphabet.
## Advanced
**Implement native FLAC output.** Today a FLAC cover is decoded and re-emitted as WAV, because the only Go FLAC encoder emits frames that strict parsers reject and has a panic path in `frame.Hash`. Implement `--flac-native` properly: encode back to FLAC, recover defensively from the known panic, and verify that crypha-in to crypha-out round-trips even if universal player compatibility does not hold. The honest deliverable is a test that proves the round-trip and a documented limit on which players accept the output. This is a real, unsolved-in-Go problem, not a toy.
**Implement the invisible-text PDF technique.** The PDF carrier offers attachment, metadata, and append. The fourth technique from the research, text drawn in render mode 3 (neither filled nor stroked, so invisible), is deferred because pdfcpu has no write-page-content API and it requires surgery on the PDF's internal object table that breaks between library versions. Building it means learning the PDF content-stream model deeply. Success is a stego PDF whose invisible text carries the envelope, survives a round-trip, and does not render anything a human sees.
**Push the QR carrier past version 10, or below level H.** The carrier fixes error-correction level H and supports versions 1 to 10, which bounds capacity at tens of bytes. Extend `versionTable` in `blocks.go` with the block structures for versions 11 and up (transcribed carefully from the ISO tables), or generalize `parseFormat` and the injection logic to accept levels Q, M, and L. Lower levels have fewer EC codewords per block, so `t` shrinks and capacity with it, which is a counter-intuitive result worth confirming with a test. Every addition must pass both differential oracles: your clean matrix must match `skip2`, and your stego must still scan under `gozxing`.
**Build adaptive, edge-aware embedding.** Uniform LSB embedding in a smooth region (a clear sky, a sustained tone) is where steganalysis is strongest, because the local statistics are predictable. Adaptive embedding hides only in high-variance regions, edges, texture, transients, where a one-bit change is statistically invisible. Compute a local variance map of the cover, embed only where variance exceeds a threshold, and store the threshold so `Reveal` walks the same regions. Then measure the result with your chi-square detector. This is the frontier of practical image steganography and a genuine research direction.
## A capstone: the full adversary loop
If you want one project that ties the whole tool together, build the complete loop crypha models: hide a payload, run a battery of your own detectors against the stego (chi-square, RS analysis, a zero-width rune scan, a `strings | tail` check on PDFs), score how detectable each carrier is, and then improve the weakest carrier until your own detectors miss it. When you can no longer detect your own embedding, you will have learned more about steganography than any tutorial teaches, because you will have played both sides of the exchange the tool exists to teach.
## Where to go next
You have read the whole track. Re-read [01-CONCEPTS.md](./01-CONCEPTS.md) with the code in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) fresh in mind; the theory reads differently once you have seen the nine lines of `injectFramed` that make it real. Then pick one challenge above and make the test suite fail, then pass.

View File

@ -13,7 +13,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com)
[![Live Demo](https://img.shields.io/badge/Live-axumortem.carterperez--dev.com-darkgreen?style=flat&logo=googlechrome)](https://axumortem.carterperez-dev.com)
[![Live Demo](https://img.shields.io/badge/Live-elitenetwork4life.com-darkgreen?style=flat&logo=googlechrome)](https://elitenetwork4life.com)
> Static binary analysis engine with multi-format parsing, YARA scanning, x86 disassembly, and MITRE ATT&CK threat scoring.

View File

@ -41,6 +41,6 @@ USER axumortem
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/axumortem", "--help"] || exit 1
CMD ["/usr/local/bin/axumortem", "--help"]
ENTRYPOINT ["/usr/local/bin/axumortem"]

View File

@ -25,7 +25,7 @@
<h2 align="center"><strong>View Complete Projects:</strong></h2>
<div align="center">
<a href="https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS">
<img src="https://img.shields.io/badge/Full_Source_Code-39/70-blue?style=for-the-badge&logo=github" alt="Projects"/>
<img src="https://img.shields.io/badge/Full_Source_Code-40/70-blue?style=for-the-badge&logo=github" alt="Projects"/>
</a>
</div>
@ -92,7 +92,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr
| **[Metadata Scrubber Tool](./PROJECTS/beginner/metadata-scrubber-tool)**<br>Remove EXIF and privacy metadata [@Heritage-XioN](https://github.com/Heritage-XioN) | ![2-3h](https://img.shields.io/badge/⏱_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | EXIF data • Privacy protection • Batch processing<br>[Source Code](./PROJECTS/beginner/metadata-scrubber-tool) \| [Docs](./PROJECTS/beginner/metadata-scrubber-tool/learn) |
| **[Network Traffic Analyzer](./PROJECTS/beginner/network-traffic-analyzer)**<br>Capture and analyze packets | ![3-5h](https://img.shields.io/badge/⏱_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C++](https://img.shields.io/badge/C%2B%2B-00599C?logo=cplusplus&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Packet capture • Protocol analysis • Traffic visualization<br>[Source (C++)](./PROJECTS/beginner/network-traffic-analyzer/cpp) \| [Docs (C++)](./PROJECTS/beginner/network-traffic-analyzer/cpp/learn) \| [Source (Python)](./PROJECTS/beginner/network-traffic-analyzer/python) \| [Docs (Python)](./PROJECTS/beginner/network-traffic-analyzer/python/learn) |
| **[Hash Cracker](./PROJECTS/beginner/hash-cracker)**<br>Dictionary and brute-force cracking | ![3-4h](https://img.shields.io/badge/⏱_5--6h-blue) ![C++](https://img.shields.io/badge/C%2B%2B-00599C?logo=cplusplus&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Hash algorithms • Dictionary attacks • Password security<br>[Source Code](./PROJECTS/beginner/hash-cracker) \| [Docs](./PROJECTS/beginner/hash-cracker/learn) |
| **[Steganography Multi-Tool](./SYNOPSES/beginner/Steganography.Multi.Tool.md)**<br>Hide data in images, audio, QR, PDFs, text | ![2-3h](https://img.shields.io/badge/⏱_8--10h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Multi-format steganography • Zero-width Unicode • Audio LSB • QR exploitation<br>[Learn More](./SYNOPSES/beginner/Steganography.Multi.Tool.md) |
| **[Steganography Multi-Tool](./SYNOPSES/beginner/Steganography.Multi.Tool.md)**<br>Hide data in images, audio, QR, PDFs, text | ![8-10h](https://img.shields.io/badge/⏱_8--10h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Multi-format steganography • Encrypted AEAD envelope • Zero-width Unicode • Audio LSB • QR Reed-Solomon injection<br>[Source Code](./PROJECTS/beginner/steganography-multi-tool) \| [Docs](./PROJECTS/beginner/steganography-multi-tool/learn) |
| **[Ghost on the Wire](./SYNOPSES/beginner/Ghost.On.The.Wire.md)**<br>L2 attack & defense: MAC spoofing + ARP detection | ![2-3h](https://img.shields.io/badge/⏱_6--8h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | ARP protocol • MAC spoofing • MITM detection • L2 trust mapping<br>[Learn More](./SYNOPSES/beginner/Ghost.On.The.Wire.md) |
| **[Canary Token Generator](./PROJECTS/beginner/canary-token-generator)**<br>Self-hosted honeytokens that alert on access | ![2-3h](https://img.shields.io/badge/⏱_8--10h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Deception defense • Honeytokens • MySQL wire protocol • PDF/DOCX patching • Webhook + Telegram alerting<br>[Source Code](./PROJECTS/beginner/canary-token-generator) \| [Docs](./PROJECTS/beginner/canary-token-generator/learn)<br>[![iglowinthedark.com](https://img.shields.io/badge/iglowinthedark.com-8B5CF6?style=flat&logo=googlechrome&logoColor=white)](https://iglowinthedark.com/) |
| **[Phishing Domain Generator & Quishing Scanner](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md)**<br>Typosquat generation + QR phishing detection | ![2-3h](https://img.shields.io/badge/⏱_6--8h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Homoglyph attacks • Typosquatting • QR code analysis • Domain intelligence<br>[Learn More](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md) |