Merge pull request #311 from CarterPerez-dev/project/steganography-multi-tool
Project/steganography multi tool
This commit is contained in:
commit
2979a0b858
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ packages = [
|
|||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 88
|
||||
preview = true
|
||||
src = [
|
||||
"src/netanal",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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/>.
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- README.md -->
|
||||
|
||||
```json
|
||||
██████╗██████╗ ██╗ ██╗██████╗ ██╗ ██╗ █████╗
|
||||
██╔════╝██╔══██╗╚██╗ ██╔╝██╔══██╗██║ ██║██╔══██╗
|
||||
██║ ██████╔╝ ╚████╔╝ ██████╔╝███████║███████║
|
||||
██║ ██╔══██╗ ╚██╔╝ ██╔═══╝ ██╔══██║██╔══██║
|
||||
╚██████╗██║ ██║ ██║ ██║ ██║ ██║██║ ██║
|
||||
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/steganography-multi-tool)
|
||||
[](https://go.dev)
|
||||
[](https://go.dev)
|
||||
[](#the-five-carriers)
|
||||
[](https://datatracker.ietf.org/doc/html/rfc8439)
|
||||
[](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).
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(¬QR, 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
capacity.go
|
||||
|
||||
The capacity command: report how much a cover can hold, per carrier
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/engine"
|
||||
"github.com/CarterPerez-dev/crypha/internal/report"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCapacityCmd() *cobra.Command {
|
||||
var format, in string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdCapacity + " -i COVER [--format F]",
|
||||
Short: "Report how many bytes a cover can hide",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path, err := pathFromFlagOrArg(in, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return errNoCover
|
||||
}
|
||||
cover, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := capacityRows(format, cover)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report.Capacity(cmd.OutOrStdout(), rows, jsonEnabled(cmd))
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&format, flagFormat, "", "carrier format (all applicable formats if omitted)")
|
||||
f.StringVarP(&in, flagIn, shortIn, "", "cover input path (or pass as an argument)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func capacityRows(format string, cover []byte) ([]engine.CapacityRow, error) {
|
||||
if format == "" {
|
||||
return engine.CapacityAll(cover), nil
|
||||
}
|
||||
if _, err := engine.ResolveCarrier(format, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, cerr := engine.Capacity(format, bytes.NewReader(cover))
|
||||
return []engine.CapacityRow{{Format: format, Capacity: n, Err: cerr}}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
cli_test.go
|
||||
|
||||
End-to-end tests that drive the crypha commands through the cobra tree
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
goaudio "github.com/go-audio/audio"
|
||||
"github.com/go-audio/wav"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||
)
|
||||
|
||||
const secret = "meet at dawn"
|
||||
|
||||
func run(t *testing.T, stdin string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
root := newRootCmd()
|
||||
var out, errb bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errb)
|
||||
root.SetIn(strings.NewReader(stdin))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), errb.String(), err
|
||||
}
|
||||
|
||||
func makeTextCover(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "cover.txt")
|
||||
if err := os.WriteFile(path, []byte("the quick brown fox jumps over the lazy dog"), 0o600); err != nil {
|
||||
t.Fatalf("write text cover: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func makeQRCover(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "cover.qrtext")
|
||||
if err := os.WriteFile(path, []byte("crypha qr cover"), 0o600); err != nil {
|
||||
t.Fatalf("write qr cover: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func makePNGCover(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 96, 96))
|
||||
for y := 0; y < 96; y++ {
|
||||
for x := 0; x < 96; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x ^ y), A: 255})
|
||||
}
|
||||
}
|
||||
path := filepath.Join(dir, "cover.png")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create png: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func makeWAVCover(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "cover.wav")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create wav: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
enc := wav.NewEncoder(f, 44100, 16, 1, 1)
|
||||
data := make([]int, 16000)
|
||||
x := uint32(1)
|
||||
for i := range data {
|
||||
x = x*1664525 + 1013904223
|
||||
data[i] = int(int16(x >> 16))
|
||||
}
|
||||
buf := &goaudio.IntBuffer{
|
||||
Format: &goaudio.Format{NumChannels: 1, SampleRate: 44100},
|
||||
Data: data,
|
||||
SourceBitDepth: 16,
|
||||
}
|
||||
if err := enc.Write(buf); err != nil {
|
||||
t.Fatalf("write wav: %v", err)
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
t.Fatalf("close wav: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func makePDFCover(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "cover.pdf")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create pdf: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.ValidationMode = model.ValidationRelaxed
|
||||
if err := api.Create(nil, strings.NewReader(`{"pages":{"1":{"content":{}}}}`), f, conf); err != nil {
|
||||
t.Fatalf("create pdf content: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestHideRevealPerFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
covers := map[string]string{
|
||||
"text": makeTextCover(t, dir),
|
||||
"image": makePNGCover(t, dir),
|
||||
"audio": makeWAVCover(t, dir),
|
||||
"pdf": makePDFCover(t, dir),
|
||||
"qr": makeQRCover(t, dir),
|
||||
}
|
||||
for format, cover := range covers {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
stego := filepath.Join(dir, format+".stego")
|
||||
if _, _, err := run(t, "", "hide", "--format", format, "-i", cover, "-o", stego, "-m", secret); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
forced, _, err := run(t, "", "reveal", "--format", format, "-i", stego)
|
||||
if err != nil {
|
||||
t.Fatalf("reveal --format: %v", err)
|
||||
}
|
||||
if forced != secret {
|
||||
t.Fatalf("reveal --format = %q, want %q", forced, secret)
|
||||
}
|
||||
auto, _, err := run(t, "", "reveal", "-i", stego)
|
||||
if err != nil {
|
||||
t.Fatalf("reveal auto-detect: %v", err)
|
||||
}
|
||||
if auto != secret {
|
||||
t.Fatalf("reveal auto-detect = %q, want %q", auto, secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFTechniques(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makePDFCover(t, dir)
|
||||
for _, tech := range []string{"attachment", "metadata", "append"} {
|
||||
t.Run(tech, func(t *testing.T) {
|
||||
stego := filepath.Join(dir, tech+".pdf")
|
||||
if _, _, err := run(t, "", "hide", "--format", "pdf", "--technique", tech, "-i", cover, "-o", stego, "-m", secret); err != nil {
|
||||
t.Fatalf("hide %s: %v", tech, err)
|
||||
}
|
||||
out, _, err := run(t, "", "reveal", "--format", "pdf", "-i", stego)
|
||||
if err != nil {
|
||||
t.Fatalf("reveal %s: %v", tech, err)
|
||||
}
|
||||
if out != secret {
|
||||
t.Fatalf("technique %s = %q, want %q", tech, out, secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealPositionalArg(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makeTextCover(t, dir)
|
||||
stego := filepath.Join(dir, "positional.txt")
|
||||
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
out, _, err := run(t, "", "reveal", stego)
|
||||
if err != nil {
|
||||
t.Fatalf("reveal positional: %v", err)
|
||||
}
|
||||
if out != secret {
|
||||
t.Fatalf("reveal positional = %q, want %q", out, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadFromFileAndStdin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
payloadFile := filepath.Join(dir, "payload.bin")
|
||||
if err := os.WriteFile(payloadFile, []byte(secret), 0o600); err != nil {
|
||||
t.Fatalf("write payload: %v", err)
|
||||
}
|
||||
|
||||
fileStego := filepath.Join(dir, "fromfile.png")
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", fileStego, "-f", payloadFile); err != nil {
|
||||
t.Fatalf("hide -f: %v", err)
|
||||
}
|
||||
out, _, err := run(t, "", "reveal", "-i", fileStego)
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal from -f = %q err %v", out, err)
|
||||
}
|
||||
|
||||
stdinStego := filepath.Join(dir, "fromstdin.png")
|
||||
if _, _, err := run(t, secret, "hide", "--format", "image", "-i", cover, "-o", stdinStego, "-f", stdioPath); err != nil {
|
||||
t.Fatalf("hide -f -: %v", err)
|
||||
}
|
||||
out, _, err = run(t, "", "reveal", "-i", stdinStego)
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal from stdin = %q err %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedRoundTripCLI(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "enc.png")
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt", "-k", "hunter2"); err != nil {
|
||||
t.Fatalf("hide encrypted: %v", err)
|
||||
}
|
||||
|
||||
out, _, err := run(t, "", "reveal", "-i", stego, "-k", "hunter2")
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal with key = %q err %v", out, err)
|
||||
}
|
||||
|
||||
if _, _, err := run(t, "", "reveal", "-i", stego); !errors.Is(err, payload.ErrPassphraseRequired) {
|
||||
t.Fatalf("no-key reveal err = %v, want ErrPassphraseRequired", err)
|
||||
}
|
||||
if _, _, err := run(t, "", "reveal", "-i", stego, "-k", "wrong"); !errors.Is(err, payload.ErrDecrypt) {
|
||||
t.Fatalf("wrong-key reveal err = %v, want ErrDecrypt", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvPassphrase(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "from-the-env")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "env.png")
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt"); err != nil {
|
||||
t.Fatalf("hide with env: %v", err)
|
||||
}
|
||||
out, _, err := run(t, "", "reveal", "-i", stego)
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal with env = %q err %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAES256GCMAndCompress(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "aes.png")
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret,
|
||||
"-k", "hunter2", "--cipher", "aes256gcm", "--compress", "--strength", "high"); err != nil {
|
||||
t.Fatalf("hide aes: %v", err)
|
||||
}
|
||||
out, _, err := run(t, "", "reveal", "-i", stego, "-k", "hunter2")
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal aes = %q err %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatsCommand(t *testing.T) {
|
||||
out, _, err := run(t, "", "formats")
|
||||
if err != nil {
|
||||
t.Fatalf("formats: %v", err)
|
||||
}
|
||||
for _, f := range []string{"image", "audio", "qr", "text", "pdf"} {
|
||||
if !strings.Contains(out, f) {
|
||||
t.Errorf("formats missing %q", f)
|
||||
}
|
||||
}
|
||||
jsonOut, _, err := run(t, "", "formats", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("formats --json: %v", err)
|
||||
}
|
||||
if !json.Valid([]byte(jsonOut)) {
|
||||
t.Errorf("formats --json is not valid json: %s", jsonOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommand(t *testing.T) {
|
||||
out, _, err := run(t, "", "version")
|
||||
if err != nil {
|
||||
t.Fatalf("version: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "crypha") || !strings.Contains(out, "0.1.0") {
|
||||
t.Errorf("version = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
|
||||
all, _, err := run(t, "", "capacity", "-i", cover)
|
||||
if err != nil {
|
||||
t.Fatalf("capacity: %v", err)
|
||||
}
|
||||
if !strings.Contains(all, "image") {
|
||||
t.Errorf("capacity table missing image row:\n%s", all)
|
||||
}
|
||||
|
||||
one, _, err := run(t, "", "capacity", "-i", cover, "--format", "image")
|
||||
if err != nil {
|
||||
t.Fatalf("capacity --format: %v", err)
|
||||
}
|
||||
if !strings.Contains(one, "image") {
|
||||
t.Errorf("single capacity missing image:\n%s", one)
|
||||
}
|
||||
|
||||
jsonOut, _, err := run(t, "", "capacity", "-i", cover, "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("capacity --json: %v", err)
|
||||
}
|
||||
if !json.Valid([]byte(jsonOut)) {
|
||||
t.Errorf("capacity --json invalid: %s", jsonOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
out := filepath.Join(dir, "out.png")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"unknown format", []string{"hide", "--format", "bogus", "-i", cover, "-o", out, "-m", "x"}, "unknown carrier format"},
|
||||
{"unknown cipher", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-k", "p", "--cipher", "des"}, "unknown cipher"},
|
||||
{"both sources", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-f", cover}, "only one of"},
|
||||
{"technique on non-pdf", []string{"hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "--technique", "append"}, "technique only applies"},
|
||||
{"no payload source", []string{"hide", "--format", "image", "-i", cover, "-o", out}, "provide a payload"},
|
||||
{"reveal no stego", []string{"reveal"}, "provide a stego file"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := run(t, "", tc.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %q, want it to contain %q", err.Error(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHideMissingRequiredFlags(t *testing.T) {
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-m", "x"); err == nil {
|
||||
t.Fatal("expected required-flag error for missing -i/-o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealJSONToStdout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makeTextCover(t, dir)
|
||||
stego := filepath.Join(dir, "json.txt")
|
||||
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
|
||||
stdout, _, err := run(t, "", "reveal", "-i", stego, "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("reveal --json: %v", err)
|
||||
}
|
||||
if !json.Valid([]byte(stdout)) {
|
||||
t.Fatalf("reveal --json stdout is not valid json: %s", stdout)
|
||||
}
|
||||
var obj struct {
|
||||
Format string `json:"format"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &obj); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if obj.Format != "text" {
|
||||
t.Errorf("format = %q, want text", obj.Format)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(obj.Data)
|
||||
if err != nil || string(decoded) != secret {
|
||||
t.Fatalf("decoded data = %q err %v, want %q", decoded, err, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealAmbiguousPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makeTextCover(t, dir)
|
||||
stego := filepath.Join(dir, "amb.txt")
|
||||
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
if _, _, err := run(t, "", "reveal", "-i", stego, stego); !errors.Is(err, errAmbiguousPath) {
|
||||
t.Fatalf("err = %v, want errAmbiguousPath", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
formats.go
|
||||
|
||||
The formats command: list every registered carrier and its capabilities
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/CarterPerez-dev/crypha/internal/report"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newFormatsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: cmdFormats,
|
||||
Short: "List the available carrier formats",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return report.Formats(cmd.OutOrStdout(), jsonEnabled(cmd))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
hide.go
|
||||
|
||||
The hide command: pack a payload into the envelope and embed it in a cover
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/engine"
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
"github.com/CarterPerez-dev/crypha/internal/report"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnknownCipher = errors.New("unknown cipher")
|
||||
errUnknownStrength = errors.New("unknown key-derivation strength")
|
||||
)
|
||||
|
||||
func newHideCmd() *cobra.Command {
|
||||
var (
|
||||
format, technique string
|
||||
in, out string
|
||||
message, file string
|
||||
passphrase string
|
||||
cipher, strength string
|
||||
encrypt, compress bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdHide + " --format F -i COVER -o OUT (-m MSG | -f FILE)",
|
||||
Short: "Hide a payload inside a cover file",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
data, err := readPayloadSource(cmd, message, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pass, err := resolveHidePassphrase(passphrase, encrypt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts, err := buildOptions(pass, compress, cipher, strength)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cover, err := os.ReadFile(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var stego bytes.Buffer
|
||||
res, err := engine.Hide(engine.HideRequest{
|
||||
Format: format,
|
||||
Technique: technique,
|
||||
Cover: bytes.NewReader(cover),
|
||||
Payload: data,
|
||||
Out: &stego,
|
||||
Options: opts,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeOutputFile(out, stego.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
return report.HideSummary(cmd.OutOrStdout(), res, out, jsonEnabled(cmd))
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&format, flagFormat, "", "carrier format: image, audio, qr, text, pdf")
|
||||
f.StringVarP(&in, flagIn, shortIn, "", "cover input path")
|
||||
f.StringVarP(&out, flagOut, shortOut, "", "stego output path")
|
||||
f.StringVarP(&message, flagMessage, shortMessage, "", "inline message payload")
|
||||
f.StringVarP(&file, flagFile, shortFile, "", "payload file path (- for stdin)")
|
||||
f.BoolVar(&encrypt, flagEncrypt, false, "encrypt the payload with a passphrase")
|
||||
f.StringVarP(&passphrase, flagPassphrase, shortPassphrase, "", "passphrase (prefer the prompt or "+envPassphrase+")")
|
||||
f.BoolVar(&compress, flagCompress, false, "compress the payload before hiding")
|
||||
f.StringVar(&cipher, flagCipher, string(payload.CipherChaCha20), "cipher: chacha20 or aes256gcm")
|
||||
f.StringVar(&strength, flagStrength, string(payload.StrengthDefault), "key-derivation strength: default or high")
|
||||
f.StringVar(&technique, flagTechnique, "", "pdf technique: attachment, metadata, or append")
|
||||
markRequired(cmd, flagFormat, flagIn, flagOut)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildOptions(pass []byte, compress bool, cipher, strength string) (payload.Options, error) {
|
||||
c := payload.Cipher(cipher)
|
||||
if c != payload.CipherChaCha20 && c != payload.CipherAES256GCM {
|
||||
return payload.Options{}, fmt.Errorf("%w: %q", errUnknownCipher, cipher)
|
||||
}
|
||||
s := payload.Strength(strength)
|
||||
if s != payload.StrengthDefault && s != payload.StrengthHigh {
|
||||
return payload.Options{}, fmt.Errorf("%w: %q", errUnknownStrength, strength)
|
||||
}
|
||||
return payload.Options{
|
||||
Passphrase: pass,
|
||||
Compress: compress,
|
||||
Cipher: c,
|
||||
Strength: s,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
interactive_test.go
|
||||
|
||||
Tests for passphrase prompting, reprompt loops, and output branches
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
)
|
||||
|
||||
func stubTerminal(t *testing.T, inter bool, secrets ...string) {
|
||||
t.Helper()
|
||||
origInter, origRead := interactive, readSecret
|
||||
t.Cleanup(func() { interactive, readSecret = origInter, origRead })
|
||||
interactive = func() bool { return inter }
|
||||
idx := 0
|
||||
readSecret = func() ([]byte, error) {
|
||||
if idx >= len(secrets) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
s := secrets[idx]
|
||||
idx++
|
||||
return []byte(s), nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptForError(t *testing.T) {
|
||||
if got := promptForError(payload.ErrPassphraseRequired); got != promptEnterPassphrase {
|
||||
t.Errorf("passphrase-required prompt = %q", got)
|
||||
}
|
||||
if got := promptForError(payload.ErrDecrypt); got != promptRetryPassphrase {
|
||||
t.Errorf("decrypt prompt = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveHidePromptRoundTrip(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
stubTerminal(t, true, "prompted-secret", "prompted-secret")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "prompted.png")
|
||||
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt"); err != nil {
|
||||
t.Fatalf("hide with prompt: %v", err)
|
||||
}
|
||||
out, _, err := run(t, "", "reveal", "-i", stego, "-k", "prompted-secret")
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal = %q err %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveHidePromptMismatch(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
stubTerminal(t, true, "one", "two")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "mismatch.png")
|
||||
|
||||
_, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt")
|
||||
if !errors.Is(err, errPassphraseMismatch) {
|
||||
t.Fatalf("err = %v, want errPassphraseMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHideEncryptNonInteractiveNoKey(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
stubTerminal(t, false)
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "nokey.png")
|
||||
|
||||
_, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "--encrypt")
|
||||
if !errors.Is(err, errPassphraseNonInteractive) {
|
||||
t.Fatalf("err = %v, want errPassphraseNonInteractive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealReprompt(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "reprompt.png")
|
||||
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "rightpass"); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
|
||||
stubTerminal(t, true, "wrongpass", "rightpass")
|
||||
out, _, err := run(t, "", "reveal", "-i", stego)
|
||||
if err != nil || out != secret {
|
||||
t.Fatalf("reveal after reprompt = %q err %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealRepromptExhausted(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "exhausted.png")
|
||||
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "rightpass"); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
|
||||
stubTerminal(t, true, "no", "no", "no")
|
||||
_, _, err := run(t, "", "reveal", "-i", stego)
|
||||
if !errors.Is(err, payload.ErrDecrypt) {
|
||||
t.Fatalf("err = %v, want ErrDecrypt after exhausting attempts", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makeTextCover(t, dir)
|
||||
stego := filepath.Join(dir, "tofile.txt")
|
||||
if _, _, err := run(t, "", "hide", "--format", "text", "-i", cover, "-o", stego, "-m", secret); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
|
||||
out := filepath.Join(dir, "revealed.txt")
|
||||
stdout, _, err := run(t, "", "reveal", "-i", stego, "-o", out)
|
||||
if err != nil {
|
||||
t.Fatalf("reveal -o: %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout should be empty when writing to a file, got %q", stdout)
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read revealed file: %v", err)
|
||||
}
|
||||
if string(got) != secret {
|
||||
t.Errorf("revealed file = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOptionsUnknownStrength(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
out := filepath.Join(dir, "out.png")
|
||||
_, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", out, "-m", "x", "-k", "p", "--strength", "ultra")
|
||||
if err == nil || !errors.Is(err, errUnknownStrength) {
|
||||
t.Fatalf("err = %v, want errUnknownStrength", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityUnknownFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
_, _, err := run(t, "", "capacity", "-i", cover, "--format", "bogus")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "does-not-exist")
|
||||
out := filepath.Join(dir, "out.png")
|
||||
cases := [][]string{
|
||||
{"hide", "--format", "image", "-i", missing, "-o", out, "-m", "x"},
|
||||
{"reveal", "-i", missing},
|
||||
{"capacity", "-i", missing},
|
||||
}
|
||||
for _, args := range cases {
|
||||
if _, _, err := run(t, "", args...); err == nil {
|
||||
t.Errorf("%v: expected error for missing file", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealPromptReadError(t *testing.T) {
|
||||
t.Setenv(envPassphrase, "")
|
||||
dir := t.TempDir()
|
||||
cover := makePNGCover(t, dir)
|
||||
stego := filepath.Join(dir, "readerr.png")
|
||||
if _, _, err := run(t, "", "hide", "--format", "image", "-i", cover, "-o", stego, "-m", secret, "-k", "pw"); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
|
||||
origInter, origRead := interactive, readSecret
|
||||
t.Cleanup(func() { interactive, readSecret = origInter, origRead })
|
||||
interactive = func() bool { return true }
|
||||
readSecret = func() ([]byte, error) { return nil, io.ErrUnexpectedEOF }
|
||||
|
||||
if _, _, err := run(t, "", "reveal", "-i", stego); !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("err = %v, want a terminal read error", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
io.go
|
||||
|
||||
Payload source reading and revealed-output writing for the crypha CLI
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoStego = errors.New("provide a stego file with -i or as an argument")
|
||||
errNoCover = errors.New("provide a cover file with -i or as an argument")
|
||||
errNoPayloadSource = errors.New("provide a payload with -m or -f")
|
||||
errBothSources = errors.New("use only one of -m or -f")
|
||||
errBinaryToTTY = errors.New("payload is binary; write it to a file with -o")
|
||||
errAmbiguousPath = errors.New("pass the file once, via -i or as an argument, not both")
|
||||
)
|
||||
|
||||
func readPayloadSource(cmd *cobra.Command, message, file string) ([]byte, error) {
|
||||
switch {
|
||||
case message != "" && file != "":
|
||||
return nil, errBothSources
|
||||
case message != "":
|
||||
return []byte(message), nil
|
||||
case file == stdioPath:
|
||||
return io.ReadAll(cmd.InOrStdin())
|
||||
case file != "":
|
||||
return os.ReadFile(file)
|
||||
default:
|
||||
return nil, errNoPayloadSource
|
||||
}
|
||||
}
|
||||
|
||||
func writeOutputFile(outPath string, data []byte) error {
|
||||
return os.WriteFile(outPath, data, outFilePerm)
|
||||
}
|
||||
|
||||
func writeRevealed(cmd *cobra.Command, data []byte, outPath string) (string, error) {
|
||||
if outPath != "" {
|
||||
if err := os.WriteFile(outPath, data, outFilePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
if stdoutIsTerminal() && !utf8.Valid(data) {
|
||||
return "", errBinaryToTTY
|
||||
}
|
||||
if _, err := cmd.OutOrStdout().Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stdoutName, nil
|
||||
}
|
||||
|
||||
func stdoutIsTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
passphrase.go
|
||||
|
||||
Passphrase acquisition from flag, environment, or a no-echo terminal prompt
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
promptEnterPassphrase = "Passphrase: "
|
||||
promptConfirmPassphrase = "Confirm passphrase: "
|
||||
promptRetryPassphrase = "Wrong passphrase, try again: "
|
||||
maxPassphraseAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
errPassphraseNonInteractive = errors.New("encryption requested but no passphrase provided; pass -k or set " + envPassphrase)
|
||||
errPassphraseMismatch = errors.New("passphrases did not match")
|
||||
)
|
||||
|
||||
var (
|
||||
interactive = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }
|
||||
readSecret = func() ([]byte, error) { return term.ReadPassword(int(os.Stdin.Fd())) }
|
||||
)
|
||||
|
||||
func passphraseFromFlagOrEnv(flagValue string) []byte {
|
||||
if flagValue != "" {
|
||||
return []byte(flagValue)
|
||||
}
|
||||
if env := os.Getenv(envPassphrase); env != "" {
|
||||
return []byte(env)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveHidePassphrase(flagValue string, encryptWanted bool) ([]byte, error) {
|
||||
if p := passphraseFromFlagOrEnv(flagValue); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if !encryptWanted {
|
||||
return nil, nil
|
||||
}
|
||||
if !interactive() {
|
||||
return nil, errPassphraseNonInteractive
|
||||
}
|
||||
return promptPassphraseConfirmed()
|
||||
}
|
||||
|
||||
func promptPassphraseConfirmed() ([]byte, error) {
|
||||
first, err := promptPassphrase(promptEnterPassphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
second, err := promptPassphrase(promptConfirmPassphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
return nil, errPassphraseMismatch
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
|
||||
func promptPassphrase(prompt string) ([]byte, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
pass, err := readSecret()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pass, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
reveal.go
|
||||
|
||||
The reveal command: extract the envelope from a stego file and unpack the payload
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/engine"
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
"github.com/CarterPerez-dev/crypha/internal/report"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRevealCmd() *cobra.Command {
|
||||
var format, in, out, passphrase string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdReveal + " [-i] STEGO",
|
||||
Short: "Reveal a hidden payload from a stego file",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path, err := pathFromFlagOrArg(in, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return errNoStego
|
||||
}
|
||||
|
||||
stego, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := revealWithPassphrase(format, stego, passphraseFromFlagOrEnv(passphrase))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonEnabled(cmd) {
|
||||
if out != "" {
|
||||
if err := writeOutputFile(out, res.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return report.RevealJSON(cmd.OutOrStdout(), res, out)
|
||||
}
|
||||
|
||||
outPath, err := writeRevealed(cmd, res.Data, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report.RevealStatus(cmd.ErrOrStderr(), res, outPath)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&format, flagFormat, "", "force a carrier format (auto-detect if omitted)")
|
||||
f.StringVarP(&in, flagIn, shortIn, "", "stego input path (or pass as an argument)")
|
||||
f.StringVarP(&out, flagOut, shortOut, "", "write the revealed payload here (default stdout)")
|
||||
f.StringVarP(&passphrase, flagPassphrase, shortPassphrase, "", "passphrase for an encrypted payload")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func revealWithPassphrase(format string, stego, pass []byte) (engine.RevealResult, error) {
|
||||
for attempt := 0; attempt <= maxPassphraseAttempts; attempt++ {
|
||||
res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass})
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
if !shouldReprompt(err, attempt) {
|
||||
return engine.RevealResult{}, err
|
||||
}
|
||||
entered, perr := promptPassphrase(promptForError(err))
|
||||
if perr != nil {
|
||||
return engine.RevealResult{}, perr
|
||||
}
|
||||
pass = entered
|
||||
}
|
||||
return engine.RevealResult{}, payload.ErrDecrypt
|
||||
}
|
||||
|
||||
func shouldReprompt(err error, attempt int) bool {
|
||||
if !interactive() {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, payload.ErrPassphraseRequired) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, payload.ErrDecrypt) && attempt < maxPassphraseAttempts
|
||||
}
|
||||
|
||||
func promptForError(err error) string {
|
||||
if errors.Is(err, payload.ErrPassphraseRequired) {
|
||||
return promptEnterPassphrase
|
||||
}
|
||||
return promptRetryPassphrase
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
version.go
|
||||
|
||||
The version command: print the crypha build version
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/CarterPerez-dev/crypha/internal/report"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newVersionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: cmdVersion,
|
||||
Short: "Print the crypha version",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return report.Version(cmd.OutOrStdout(), jsonEnabled(cmd))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
cli.go
|
||||
|
||||
Descriptive catalog metadata for each carrier, surfaced by the formats and help output
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
type FormatDetail struct {
|
||||
Blurb string
|
||||
CoverInput string
|
||||
Output string
|
||||
Notes string
|
||||
}
|
||||
|
||||
var FormatDetails = map[string]FormatDetail{
|
||||
"image": {
|
||||
Blurb: "LSB of RGB pixel data",
|
||||
CoverInput: "PNG or 24-bit BMP",
|
||||
Output: "PNG",
|
||||
Notes: "alpha channel untouched; paletted and 16-bit covers are rejected",
|
||||
},
|
||||
"audio": {
|
||||
Blurb: "LSB of 16-bit PCM samples",
|
||||
CoverInput: "16-bit PCM WAV or FLAC",
|
||||
Output: "WAV",
|
||||
Notes: "FLAC covers are decoded and re-emitted as WAV",
|
||||
},
|
||||
"qr": {
|
||||
Blurb: "Reed-Solomon-correctable error injection",
|
||||
CoverInput: "UTF-8 text (the QR's visible content)",
|
||||
Output: "PNG",
|
||||
Notes: "capacity is tens of bytes, so an encrypted envelope will not fit",
|
||||
},
|
||||
"text": {
|
||||
Blurb: "zero-width U+200B and U+2060 characters",
|
||||
CoverInput: "any UTF-8 text",
|
||||
Output: "text",
|
||||
Notes: "the payload is appended to the cover as invisible characters",
|
||||
},
|
||||
"pdf": {
|
||||
Blurb: "embedded attachment, metadata, or append-after-EOF",
|
||||
CoverInput: "PDF",
|
||||
Output: "PDF",
|
||||
Notes: "default technique is a lossless embedded-file attachment",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,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."
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
report.go
|
||||
|
||||
Human-readable tables and JSON rendering for crypha command output
|
||||
*/
|
||||
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/config"
|
||||
"github.com/CarterPerez-dev/crypha/internal/engine"
|
||||
"github.com/CarterPerez-dev/crypha/internal/payload"
|
||||
)
|
||||
|
||||
const (
|
||||
unboundedLabel = "unbounded"
|
||||
notApplicable = "n/a"
|
||||
doesNotFit = "does not fit"
|
||||
yesLabel = "yes"
|
||||
noLabel = "no"
|
||||
minCellWidth = 0
|
||||
tabWidth = 2
|
||||
padding = 2
|
||||
padChar = ' '
|
||||
)
|
||||
|
||||
type formatLine struct {
|
||||
Format string `json:"format"`
|
||||
Cover string `json:"cover"`
|
||||
Output string `json:"output"`
|
||||
Techniques []string `json:"techniques,omitempty"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type capacityLine struct {
|
||||
Format string `json:"format"`
|
||||
Applicable bool `json:"applicable"`
|
||||
Capacity int `json:"capacity_bytes,omitempty"`
|
||||
MaxPlaintext int `json:"max_plaintext_bytes,omitempty"`
|
||||
MaxEncrypted int `json:"max_encrypted_bytes,omitempty"`
|
||||
Unbounded bool `json:"unbounded,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type hideLine struct {
|
||||
Format string `json:"format"`
|
||||
Technique string `json:"technique,omitempty"`
|
||||
PayloadBytes int `json:"payload_bytes"`
|
||||
EnvelopeBytes int `json:"envelope_bytes"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Compressed bool `json:"compressed"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type revealLine struct {
|
||||
Format string `json:"format"`
|
||||
Bytes int `json:"bytes"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type versionLine struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
type tableWriter struct {
|
||||
tw *tabwriter.Writer
|
||||
err error
|
||||
}
|
||||
|
||||
func newTableWriter(w io.Writer) *tableWriter {
|
||||
return &tableWriter{tw: tabwriter.NewWriter(w, minCellWidth, tabWidth, padding, padChar, 0)}
|
||||
}
|
||||
|
||||
func (t *tableWriter) row(format string, a ...any) {
|
||||
if t.err != nil {
|
||||
return
|
||||
}
|
||||
_, t.err = fmt.Fprintf(t.tw, format, a...)
|
||||
}
|
||||
|
||||
func (t *tableWriter) flush() error {
|
||||
if t.err != nil {
|
||||
return t.err
|
||||
}
|
||||
return t.tw.Flush()
|
||||
}
|
||||
|
||||
func Formats(w io.Writer, jsonOut bool) error {
|
||||
cat := engine.Catalog()
|
||||
lines := make([]formatLine, 0, len(cat))
|
||||
for _, fi := range cat {
|
||||
d := config.FormatDetails[fi.Name]
|
||||
lines = append(lines, formatLine{
|
||||
Format: fi.Name,
|
||||
Cover: d.CoverInput,
|
||||
Output: d.Output,
|
||||
Techniques: fi.Techniques,
|
||||
Description: d.Blurb,
|
||||
})
|
||||
}
|
||||
if jsonOut {
|
||||
return writeJSON(w, lines)
|
||||
}
|
||||
tw := newTableWriter(w)
|
||||
tw.row("FORMAT\tCOVER\tOUTPUT\tOPTIONS\tDESCRIPTION\n")
|
||||
for _, l := range lines {
|
||||
tw.row("%s\t%s\t%s\t%s\t%s\n", l.Format, l.Cover, l.Output, options(l.Techniques), l.Description)
|
||||
}
|
||||
return tw.flush()
|
||||
}
|
||||
|
||||
func Capacity(w io.Writer, rows []engine.CapacityRow, jsonOut bool) error {
|
||||
lines := make([]capacityLine, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
lines = append(lines, capacityRow(r))
|
||||
}
|
||||
if jsonOut {
|
||||
return writeJSON(w, lines)
|
||||
}
|
||||
tw := newTableWriter(w)
|
||||
tw.row("FORMAT\tENVELOPE\tMAX PLAINTEXT\tMAX ENCRYPTED\tNOTE\n")
|
||||
for _, l := range lines {
|
||||
tw.row("%s\t%s\t%s\t%s\t%s\n", l.Format, capacityCell(l), plaintextCell(l), encryptedCell(l), l.Note)
|
||||
}
|
||||
return tw.flush()
|
||||
}
|
||||
|
||||
func HideSummary(w io.Writer, res engine.HideResult, outPath string, jsonOut bool) error {
|
||||
line := hideLine{
|
||||
Format: res.Format,
|
||||
Technique: res.Technique,
|
||||
PayloadBytes: res.PayloadBytes,
|
||||
EnvelopeBytes: res.EnvelopeBytes,
|
||||
Encrypted: res.Encrypted,
|
||||
Compressed: res.Compressed,
|
||||
Output: outPath,
|
||||
}
|
||||
if jsonOut {
|
||||
return writeJSON(w, line)
|
||||
}
|
||||
tw := newTableWriter(w)
|
||||
tw.row("OUTPUT\t%s\n", line.Output)
|
||||
tw.row("FORMAT\t%s\n", formatCell(res.Format, res.Technique))
|
||||
tw.row("PAYLOAD\t%d bytes\n", line.PayloadBytes)
|
||||
tw.row("ENVELOPE\t%d bytes\n", line.EnvelopeBytes)
|
||||
tw.row("ENCRYPTED\t%s\n", boolLabel(line.Encrypted))
|
||||
tw.row("COMPRESSED\t%s\n", boolLabel(line.Compressed))
|
||||
return tw.flush()
|
||||
}
|
||||
|
||||
func RevealStatus(w io.Writer, res engine.RevealResult, outPath string) error {
|
||||
_, err := fmt.Fprintf(w, "revealed %d bytes via %s -> %s\n", len(res.Data), res.Format, outPath)
|
||||
return err
|
||||
}
|
||||
|
||||
func RevealJSON(w io.Writer, res engine.RevealResult, outPath string) error {
|
||||
line := revealLine{
|
||||
Format: res.Format,
|
||||
Bytes: len(res.Data),
|
||||
Encrypted: res.Encrypted,
|
||||
Output: outPath,
|
||||
}
|
||||
if outPath == "" {
|
||||
line.Data = base64.StdEncoding.EncodeToString(res.Data)
|
||||
}
|
||||
return writeJSON(w, line)
|
||||
}
|
||||
|
||||
func Version(w io.Writer, jsonOut bool) error {
|
||||
line := versionLine{
|
||||
Name: config.BinaryName,
|
||||
Version: config.Version,
|
||||
GoVersion: goVersion(),
|
||||
}
|
||||
if jsonOut {
|
||||
return writeJSON(w, line)
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%s %s (%s)\n", line.Name, line.Version, line.GoVersion)
|
||||
return err
|
||||
}
|
||||
|
||||
func capacityRow(r engine.CapacityRow) capacityLine {
|
||||
l := capacityLine{Format: r.Format}
|
||||
if r.Err != nil {
|
||||
l.Note = reason(r.Err)
|
||||
return l
|
||||
}
|
||||
l.Applicable = true
|
||||
if r.Capacity >= math.MaxInt32 {
|
||||
l.Unbounded = true
|
||||
return l
|
||||
}
|
||||
l.Capacity = r.Capacity
|
||||
l.MaxPlaintext = clampZero(r.Capacity - payload.Overhead(false))
|
||||
l.MaxEncrypted = clampZero(r.Capacity - payload.Overhead(true))
|
||||
return l
|
||||
}
|
||||
|
||||
func capacityCell(l capacityLine) string {
|
||||
switch {
|
||||
case !l.Applicable:
|
||||
return notApplicable
|
||||
case l.Unbounded:
|
||||
return unboundedLabel
|
||||
default:
|
||||
return fmt.Sprintf("%d", l.Capacity)
|
||||
}
|
||||
}
|
||||
|
||||
func plaintextCell(l capacityLine) string {
|
||||
switch {
|
||||
case !l.Applicable:
|
||||
return notApplicable
|
||||
case l.Unbounded:
|
||||
return unboundedLabel
|
||||
default:
|
||||
return fmt.Sprintf("%d", l.MaxPlaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func encryptedCell(l capacityLine) string {
|
||||
switch {
|
||||
case !l.Applicable:
|
||||
return notApplicable
|
||||
case l.Unbounded:
|
||||
return unboundedLabel
|
||||
case l.MaxEncrypted <= 0:
|
||||
return doesNotFit
|
||||
default:
|
||||
return fmt.Sprintf("%d", l.MaxEncrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func options(techniques []string) string {
|
||||
if len(techniques) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(techniques, " | ")
|
||||
}
|
||||
|
||||
func formatCell(format, technique string) string {
|
||||
if technique == "" {
|
||||
return format
|
||||
}
|
||||
return format + " (" + technique + ")"
|
||||
}
|
||||
|
||||
func boolLabel(b bool) string {
|
||||
if b {
|
||||
return yesLabel
|
||||
}
|
||||
return noLabel
|
||||
}
|
||||
|
||||
func reason(err error) string {
|
||||
msg := err.Error()
|
||||
if i := strings.Index(msg, ": "); i >= 0 {
|
||||
return msg[i+2:]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func clampZero(n int) int {
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func goVersion() string {
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.GoVersion != "" {
|
||||
return info.GoVersion
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
©AngelaMos | 2026
|
||||
report_test.go
|
||||
|
||||
Table and JSON rendering tests for command output
|
||||
*/
|
||||
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/CarterPerez-dev/crypha/internal/config"
|
||||
"github.com/CarterPerez-dev/crypha/internal/engine"
|
||||
)
|
||||
|
||||
func TestFormatsHuman(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Formats(&buf, false); err != nil {
|
||||
t.Fatalf("Formats: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, f := range []string{"image", "audio", "qr", "text", "pdf"} {
|
||||
if !strings.Contains(out, f) {
|
||||
t.Errorf("formats output missing %q", f)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "attachment") {
|
||||
t.Error("pdf technique options not listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatsJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Formats(&buf, true); err != nil {
|
||||
t.Fatalf("Formats: %v", err)
|
||||
}
|
||||
if !json.Valid(buf.Bytes()) {
|
||||
t.Fatal("formats json is invalid")
|
||||
}
|
||||
var lines []formatLine
|
||||
if err := json.Unmarshal(buf.Bytes(), &lines); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("got %d formats, want 5", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityHuman(t *testing.T) {
|
||||
rows := []engine.CapacityRow{
|
||||
{Format: "qr", Capacity: 52},
|
||||
{Format: "pdf", Capacity: math.MaxInt32},
|
||||
{Format: "text", Err: errors.New("crypha/text: cover too small")},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Capacity(&buf, rows, false); err != nil {
|
||||
t.Fatalf("Capacity: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{doesNotFit, unboundedLabel, notApplicable, "cover too small", "38"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("capacity output missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacityJSON(t *testing.T) {
|
||||
rows := []engine.CapacityRow{
|
||||
{Format: "qr", Capacity: 52},
|
||||
{Format: "pdf", Capacity: math.MaxInt32},
|
||||
{Format: "text", Err: errors.New("crypha/text: cover too small")},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Capacity(&buf, rows, true); err != nil {
|
||||
t.Fatalf("Capacity: %v", err)
|
||||
}
|
||||
var lines []capacityLine
|
||||
if err := json.Unmarshal(buf.Bytes(), &lines); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
byFormat := map[string]capacityLine{}
|
||||
for _, l := range lines {
|
||||
byFormat[l.Format] = l
|
||||
}
|
||||
if got := byFormat["qr"]; got.MaxPlaintext != 38 || got.MaxEncrypted != 0 {
|
||||
t.Errorf("qr line = %+v, want plaintext 38 encrypted 0", got)
|
||||
}
|
||||
if !byFormat["pdf"].Unbounded {
|
||||
t.Error("pdf should be unbounded")
|
||||
}
|
||||
if byFormat["text"].Applicable {
|
||||
t.Error("rejected cover should be inapplicable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHideSummary(t *testing.T) {
|
||||
res := engine.HideResult{
|
||||
Format: "image",
|
||||
PayloadBytes: 12,
|
||||
EnvelopeBytes: 80,
|
||||
Encrypted: true,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := HideSummary(&buf, res, "out.png", false); err != nil {
|
||||
t.Fatalf("HideSummary: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"image", "12", "80", "out.png", yesLabel} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("hide summary missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Version(&buf, false); err != nil {
|
||||
t.Fatalf("Version: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, config.BinaryName) || !strings.Contains(out, config.Version) {
|
||||
t.Errorf("version output = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealJSON(t *testing.T) {
|
||||
res := engine.RevealResult{Format: "image", Data: []byte("hidden bytes"), Encrypted: true}
|
||||
|
||||
var toStdout bytes.Buffer
|
||||
if err := RevealJSON(&toStdout, res, ""); err != nil {
|
||||
t.Fatalf("RevealJSON: %v", err)
|
||||
}
|
||||
var line revealLine
|
||||
if err := json.Unmarshal(toStdout.Bytes(), &line); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(line.Data)
|
||||
if err != nil || string(decoded) != "hidden bytes" {
|
||||
t.Fatalf("data = %q err %v", decoded, err)
|
||||
}
|
||||
if line.Bytes != len(res.Data) || !line.Encrypted || line.Format != "image" {
|
||||
t.Fatalf("line = %+v", line)
|
||||
}
|
||||
|
||||
var toFile bytes.Buffer
|
||||
if err := RevealJSON(&toFile, res, "out.bin"); err != nil {
|
||||
t.Fatalf("RevealJSON with file: %v", err)
|
||||
}
|
||||
var fileLine revealLine
|
||||
if err := json.Unmarshal(toFile.Bytes(), &fileLine); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if fileLine.Data != "" {
|
||||
t.Error("data should be omitted when writing to a file")
|
||||
}
|
||||
if fileLine.Output != "out.bin" {
|
||||
t.Errorf("output = %q, want out.bin", fileLine.Output)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
[](https://www.typescriptlang.org)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://www.docker.com)
|
||||
[](https://axumortem.carterperez-dev.com)
|
||||
[](https://elitenetwork4life.com)
|
||||
|
||||
> Static binary analysis engine with multi-format parsing, YARA scanning, x86 disassembly, and MITRE ATT&CK threat scoring.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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) |    | 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 |     | 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 |    | 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 |    | 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 |    | 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 |    | 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 |      | 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>[](https://iglowinthedark.com/) |
|
||||
| **[Phishing Domain Generator & Quishing Scanner](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md)**<br>Typosquat generation + QR phishing detection |    | Homoglyph attacks • Typosquatting • QR code analysis • Domain intelligence<br>[Learn More](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue