Cybersecurity-Projects/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.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
}