405 lines
8.9 KiB
Go
405 lines
8.9 KiB
Go
/*
|
|
©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
|
|
}
|