This commit is contained in:
Magnus Root 2026-09-14 11:00:27 +03:00
parent 94336a4569
commit e7b1302012
15 changed files with 1922 additions and 0 deletions

76
NOTES.md Normal file
View file

@ -0,0 +1,76 @@
# Заметки по разработке stegterm
Чистое описание того, что есть — в README.md/README.ru.md (отдельного
ARCHITECTURE.md для проекта такого размера не заводил — три пакета и
один экран, README уже покрывает структуру).
Этот файл — то, что не годится в README, потому что описывает не «как
устроено», а «на что обратить внимание» и «откуда это унаследовано».
Весь код (и здесь, и в родительском burterm, откуда это выделено)
писался в среде без сети и без установленного Go — ни разу не проходил
через компилятор.
## Происхождение
Код `internal/stego` и `internal/audio` перенесён из burterm без
изменений в логике — только module path в импортах. `internal/tui`
переписан с нуля под односкреенный интерфейс (в burterm это была
одна из 14 вкладок с общей корневой моделью на все вкладки сразу;
здесь — тонкая обёртка `Model` вокруг того же `stegoModel`, без
таб-бара, без переключения между экранами).
## Реальный баг, найденный при разработке (до переноса)
**Задвоенное объявление `ifft`/`instantaneousFrequency`.** При
добавлении SSTV-декодера в дереве оказался файл `hilbert.go`,
дублирующий функции уже написанного `sstv.go` с другой сигнатурой —
гарантированная ошибка компиляции. Как он возник — не установлено;
удалён, вся логика осталась в `sstv.go`, проверено на отсутствие
повторных объявлений по всему пакету `audio`.
## Непроверенные места (по убыванию вероятности проблем)
1. **`go.mod`: версии `bubbles`/`bubbletea`/`lipgloss`** — указаны по
памяти (та же версия, что использовалась в burterm на момент
выделения этого кода), `go mod tidy` может подтянуть другие,
`go.sum` пересоберётся с нуля.
2. **bubbles API**`Focus()` у `textinput` в некоторых версиях
возвращает `tea.Cmd`, в некоторых — нет.
3. **Kitty graphics protocol** (`internal/stego/kitty.go`) —
единственное место, где неопределённость не в компиляции, а в
визуальном поведении: что произойдёт, когда bubbletea в alt-screen
режиме перерисует экран поверх уже показанной картинки, предсказать
без реального терминала нельзя. Отсюда клавиша `ctrl+v` — передать
escape-код заново.
4. **Калибровка SSTV Robot36** (`internal/audio/sstv.go`) — тайминги
строк (`robot36SyncMs`, `robot36YMs` и т.д.) взяты по опубликованным
спецификациям, без эталонной записи для сверки. Это качественно
другой риск, чем у спектрограммы: спектрограмма не требует точной
калибровки (громче = ярче, любой вход даёт осмысленный результат),
SSTV — требует, иначе на выходе шум/диагональные полосы вместо
картинки. Авто-детект VIS-заголовка не реализован — режим (пока
только Robot36) заложен в код напрямую, без выбора в UI.
5. **Бюджет высоты `View()` не проверен построчно.** В отличие от
burterm (где после нескольких провалов этот вопрос был разобран
построчно для каждой вкладки — см. историю в NOTES.md burterm),
здесь `stegoModel.SetSize` вообще не использует высоту для
раскладки (только ширину, для решения "две колонки или один
столбец") — то есть класс бага "рамка/футер выталкивают верх экрана
за пределы видимой области" сюда не переносится по конструкции. Но
само по себе это не проверено на реальном терминале, просто риск
принципиально другого рода (максимум — обрежется низ длинного
контента, а не потеряется верх экрана целиком).
## Известные ограничения — сознательный выбор, не недоделка
- **Только WAV**, не MP3/FLAC — сжатие с потерями убивает как раз ту
мелкую структуру спектра, в которую в CTF прячут сообщения.
- **Только Robot36** из всех SSTV-режимов — самый частый в CTF-задачах
и достаточно документированный, чтобы взяться за него первым. Другие
режимы (Martin, Scottie, PD) потребовали бы свой набор таймингов и
свою калибровку — тот же риск, что и с Robot36, умноженный на число
режимов.
- **Поля "второе изображение" (для XOR) и "длина LSB-извлечения"
всегда на экране**, даже в режиме аудио, где не используются —
условный layout ради их скрытия усложнил бы `View()` заметнее, чем
стоит того.

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# stegterm
A terminal tool for CTF steganography and audio forensics: image
bit-plane/channel analysis, XOR, LSB extraction, spectrogram rendering,
and Robot36 SSTV decoding — with preview via the Kitty graphics protocol.
Русская версия: [README.ru.md](README.ru.md).
Design notes, known risks, honest caveats: [NOTES.md](NOTES.md) (Russian only).
## History
Originally part of [burterm](https://github.com/r3g1tpr0cs/burterm), a
terminal web-pentest tool — split out into its own project because
steganography/SSTV have nothing to do with web pentesting; it was
functionality that accreted onto the wrong tool one feature at a time.
## Features
- **Images** — bit-plane isolation (classic first step for spotting
LSB-hidden text/patterns), single-channel isolation, grayscale, invert,
XOR between two same-size images, linear LSB extraction to raw bytes
(shown as both text and hex)
- **Audio (WAV only)** — spectrogram via a hand-written FFT (no external
DSP dependency), Robot36 SSTV decoding via Hilbert-transform-based FM
demodulation
- Everything renders through the Kitty graphics protocol; `ctrl+v`
re-sends the last rendered image if a TUI redraw clobbers it
WAV-only is deliberate, not a shortcut: lossy compression (MP3/etc.)
destroys the fine spectral structure CTF challenges hide messages in —
those tasks are almost always plain PCM WAV.
## Building
```sh
go mod tidy
go build -o stegterm ./cmd/stegterm
```
Requires Go 1.23+. Builds without cgo, no external DSP/image libraries —
everything (FFT, Hilbert transform, SSTV decoding) is a from-scratch
implementation on the standard library.
**The code has never gone through a compiler** — same caveat as its
parent project burterm; see [NOTES.md](NOTES.md) for what's most
likely to need fixing on the first build.
## Running
```sh
./stegterm
```
One screen. Load a file with `ctrl+o` (path typed into the input field);
`.wav` is treated as audio, anything else as an image. `ctrl+g` cycles
the operation (image transform mode, or spectrogram/SSTV for audio),
`ctrl+r` applies it, `ctrl+v` re-shows the last result, `ctrl+s` exports
the current result to `~/.stegterm/stego-exports/`.
## Repository layout
```
cmd/stegterm/ entry point
internal/
stego/ image transforms, Kitty protocol encoding
audio/ WAV parsing, FFT, spectrogram, SSTV decoding
tui/ the single-screen interface (bubbletea)
```

70
README.ru.md Normal file
View file

@ -0,0 +1,70 @@
# stegterm
Терминальный инструмент для CTF-стеганографии и аудио-форензики:
анализ изображений (битовые плоскости, каналы, XOR, LSB-извлечение),
спектрограмма и декодирование SSTV Robot36 для аудио — с превью через
Kitty graphics protocol.
English version: [README.md](README.md).
Заметки по разработке, честные оговорки о рисках: [NOTES.md](NOTES.md).
## История
Изначально было частью [burterm](https://github.com/r3g1tpr0cs/burterm) —
терминального инструмента для веб-пентеста, — выделено в отдельный
проект: стеганография и SSTV не имеют отношения к веб-пентесту, это
была функциональность, постепенно прицепившаяся не к тому инструменту.
## Возможности
- **Изображения** — изоляция битовой плоскости (классический первый шаг
разбора LSB-стеганографии), изоляция одного канала, градации серого,
инверсия, XOR двух изображений одинакового размера, линейное
LSB-извлечение в сырые байты (показывается и как текст, и как hex)
- **Аудио (только WAV)** — спектрограмма через собственную реализацию
БПФ (без внешних DSP-зависимостей), декодирование SSTV Robot36 через
FM-демодуляцию на основе преобразования Гильберта
- Всё рендерится через Kitty graphics protocol; `ctrl+v` передаёт
последнюю картинку заново, если перерисовка TUI её перекрыла
WAV-only — осознанный выбор, не недоделка: сжатие с потерями (MP3 и
т.п.) убивает мелкую структуру спектра, в которую в CTF прячут
сообщения — такие задачи почти всегда именно в несжатом PCM WAV.
## Сборка
```sh
go mod tidy
go build -o stegterm ./cmd/stegterm
```
Требуется Go 1.23+. Сборка без cgo, без внешних DSP/image-библиотек —
всё (БПФ, преобразование Гильберта, декодирование SSTV) написано с
нуля на стандартной библиотеке.
**Код ни разу не проходил через компилятор** — та же оговорка, что и у
родительского проекта burterm; что вероятнее всего потребует правки
при первой сборке — в [NOTES.md](NOTES.md).
## Запуск
```sh
./stegterm
```
Один экран. `ctrl+o` загружает файл (путь вводится в поле) — `.wav`
считается аудио, всё остальное изображением. `ctrl+g` переключает
операцию (режим преобразования картинки, либо спектрограмма/SSTV для
аудио), `ctrl+r` применяет, `ctrl+v` повторно показывает последний
результат, `ctrl+s` экспортирует текущий результат в
`~/.stegterm/stego-exports/`.
## Структура репозитория
```
cmd/stegterm/ точка входа
internal/
stego/ преобразования изображений, Kitty-протокол
audio/ разбор WAV, БПФ, спектрограмма, декодирование SSTV
tui/ единственный экран интерфейса (bubbletea)
```

25
cmd/stegterm/main.go Normal file
View file

@ -0,0 +1,25 @@
// stegterm — инструмент для CTF-стеганографии: изображения (изоляция
// битовых плоскостей/каналов, XOR, LSB-извлечение) и аудио (спектрограмма,
// декодирование SSTV Robot36), с превью через Kitty graphics protocol.
//
// Выделен из burterm (терминального инструмента для веб-пентеста) в
// отдельный проект — стеганография и SSTV к веб-пентесту отношения не
// имеют, это была случайно прицепившаяся к тому инструменту функциональность.
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/stegterm/internal/tui"
)
func main() {
p := tea.NewProgram(tui.New(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
}

9
go.mod Normal file
View file

@ -0,0 +1,9 @@
module github.com/r3g1tpr0cs/stegterm
go 1.23
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/lipgloss v1.0.0
)

77
internal/audio/fft.go Normal file
View file

@ -0,0 +1,77 @@
package audio
import (
"math"
"math/cmplx"
)
// fft выполняет БПФ по основанию 2 in-place (Кули-Тьюки, прореживание
// по времени). len(x) должна быть степенью двойки — вызывающий код
// (Spectrogram) сам округляет размер окна до ближайшей степени двойки.
//
// Классический учебный алгоритм: сначала битовая реверсия элементов
// (переставляет их в порядок, в котором рекурсивный Кули-Тьюки строил
// бы их снизу вверх, но без рекурсии и лишних аллокаций), затем
// итеративные "бабочки" по возрастающим длинам блоков (2, 4, 8, ..., n).
func fft(x []complex128) {
n := len(x)
if n <= 1 {
return
}
for i, j := 1, 0; i < n; i++ {
bit := n >> 1
for ; j&bit != 0; bit >>= 1 {
j ^= bit
}
j ^= bit
if i < j {
x[i], x[j] = x[j], x[i]
}
}
for length := 2; length <= n; length <<= 1 {
angle := -2 * math.Pi / float64(length)
wlen := cmplx.Exp(complex(0, angle))
for i := 0; i < n; i += length {
w := complex(1.0, 0.0)
half := length / 2
for j := 0; j < half; j++ {
u := x[i+j]
v := x[i+j+half] * w
x[i+j] = u + v
x[i+j+half] = u - v
w *= wlen
}
}
}
}
// hannWindow — стандартное окно Ханна: сглаживает края каждого
// STFT-окна перед БПФ, снижая "утечку спектра" (артефакты от резкого
// обрыва сигнала на границе окна, которые иначе размазывают чёткие
// частотные линии в шум).
func hannWindow(n int) []float64 {
w := make([]float64, n)
if n == 1 {
w[0] = 1
return w
}
for i := 0; i < n; i++ {
w[i] = 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))
}
return w
}
// nextPowerOfTwo округляет вверх до ближайшей степени двойки — БПФ
// в этой реализации требует именно такой размер.
func nextPowerOfTwo(n int) int {
if n < 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
return p
}

View file

@ -0,0 +1,123 @@
package audio
import (
"image"
"image/color"
"math"
"math/cmplx"
)
// Spectrogram считает STFT сигнала: режет samples на перекрывающиеся
// окна размером windowSize (округляется вверх до степени двойки) с
// шагом hopSize, на каждое окно накладывает окно Ханна и считает БПФ.
// Возвращает матрицу [номер_окна][номерастотного_бина] = амплитуда
// в дБ. Используются только первые windowSize/2 бинов — для
// вещественного сигнала спектр симметричен, вторая половина не несёт
// новой информации.
func Spectrogram(samples []float64, windowSize, hopSize int) [][]float64 {
windowSize = nextPowerOfTwo(windowSize)
if hopSize < 1 {
hopSize = windowSize / 4
}
window := hannWindow(windowSize)
var frames [][]float64
for start := 0; start+windowSize <= len(samples); start += hopSize {
buf := make([]complex128, windowSize)
for i := 0; i < windowSize; i++ {
buf[i] = complex(samples[start+i]*window[i], 0)
}
fft(buf)
bins := make([]float64, windowSize/2)
for i := 0; i < windowSize/2; i++ {
mag := cmplx.Abs(buf[i]) / float64(windowSize)
// +эпсилон — чтобы log10(0) не улетел в -Inf на полной тишине
bins[i] = 20 * math.Log10(mag+1e-12)
}
frames = append(frames, bins)
}
return frames
}
// RenderSpectrogram красит матрицу STFT в изображение: время по
// горизонтали (номер окна), частота по вертикали (низкие частоты
// внизу, высокие — вверху, как в любом спектрограф-вьюере вроде
// Sonic Visualiser). Амплитуда нормализуется по фактическому диапазону
// дБ в самой записи (а не по абсолютной шкале) — так тихие записи не
// превращаются в сплошной чёрный экран.
func RenderSpectrogram(frames [][]float64) *image.RGBA {
if len(frames) == 0 || len(frames[0]) == 0 {
return image.NewRGBA(image.Rect(0, 0, 1, 1))
}
width := len(frames)
height := len(frames[0])
minDB, maxDB := frames[0][0], frames[0][0]
for _, f := range frames {
for _, v := range f {
if v < minDB {
minDB = v
}
if v > maxDB {
maxDB = v
}
}
}
if maxDB-minDB < 1 {
maxDB = minDB + 1 // защита от деления на почти ноль на абсолютно однородном сигнале
}
img := image.NewRGBA(image.Rect(0, 0, width, height))
for x, f := range frames {
for bin, v := range f {
t := (v - minDB) / (maxDB - minDB)
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
y := height - 1 - bin // низкие частоты внизу изображения
img.Set(x, y, heatColor(t))
}
}
return img
}
// heatColor — точки градиента: чёрный (тихо) → синий → циан → жёлтый →
// белый (громко). Обычная "тепловая карта" для спектрограмм — резкий
// контраст между тихим фоном и громким сигналом, именно это и нужно,
// чтобы разглядеть спрятанный в спектре текст/QR-код/паттерн.
func heatColor(t float64) color.RGBA {
stops := []color.RGBA{
{R: 0, G: 0, B: 0, A: 255},
{R: 0, G: 0, B: 180, A: 255},
{R: 0, G: 200, B: 200, A: 255},
{R: 255, G: 230, B: 0, A: 255},
{R: 255, G: 255, B: 255, A: 255},
}
if t <= 0 {
return stops[0]
}
if t >= 1 {
return stops[len(stops)-1]
}
seg := t * float64(len(stops)-1)
i := int(seg)
if i >= len(stops)-1 {
return stops[len(stops)-1]
}
frac := seg - float64(i)
a, b := stops[i], stops[i+1]
return color.RGBA{
R: lerp8(a.R, b.R, frac),
G: lerp8(a.G, b.G, frac),
B: lerp8(a.B, b.B, frac),
A: 255,
}
}
func lerp8(a, b uint8, t float64) uint8 {
return uint8(float64(a) + (float64(b)-float64(a))*t)
}

296
internal/audio/sstv.go Normal file
View file

@ -0,0 +1,296 @@
package audio
import (
"fmt"
"image"
"image/color"
"math"
"math/cmplx"
)
// Этот файл — единственное по-настоящему рискованное место во всём
// проекте, риск другого рода, чем обычно: код скомпилируется и
// отработает без паники, но КАЛИБРОВКА (тайминги строк Robot36 ниже) —
// по опубликованным спецификациям, без эталонной записи под рукой для
// сверки. Спектрограмма (см. spectrogram.go) не нуждается в точной
// калибровке — громче звук, ярче пиксель, и на любом входе получится
// осмысленная картинка. SSTV — противоположность: либо тайминги
// совпадают почти до сэмпла, либо на выходе шум/диагональные полосы
// вместо изображения. Если результат выглядит именно так — это первое
// место для подстройки, см. константы robot36*Ms ниже.
//
// Сюда же: авто-детект VIS-заголовка (11-битная последовательность
// тонов в начале передачи, определяющая режим SSTV) не реализован —
// это ещё один пласт сложности поверх итак рискованной части. Вместо
// этого просто ищем первый устойчивый синхроимпульс ~1200Гц и
// декодируем от него, предполагая заранее известный режим (Robot36).
const (
robot36SyncMs = 9.0
robot36PorchMs = 3.0
robot36YMs = 88.0
robot36SepMs = 4.5
robot36ChromaMs = 44.0
robot36LineMs = robot36SyncMs + robot36PorchMs + robot36YMs + robot36SepMs + robot36ChromaMs
robot36Width = 320
robot36Height = 240
)
// DecodeRobot36 декодирует Robot36 SSTV-сигнал в изображение 320x240.
func DecodeRobot36(wav *WAV) (*image.RGBA, error) {
if wav.SampleRate <= 0 {
return nil, fmt.Errorf("некорректная частота дискретизации: %d", wav.SampleRate)
}
padded := padToPow2(wav.Samples)
analytic := analyticSignal(padded)
freqRaw := instantaneousFrequency(analytic, wav.SampleRate)
freq := smooth(freqRaw[:len(wav.Samples)], 8) // обрезаем хвост от zero-padding обратно до исходной длины
samplesPerMs := float64(wav.SampleRate) / 1000
minSyncSamples := int(robot36SyncMs * 0.7 * samplesPerMs) // с запасом на неточность/шум — не требуем идеальных 9мс
startIdx := findSyncStart(freq, minSyncSamples)
if startIdx < 0 {
return nil, fmt.Errorf("не удалось найти начальный синхроимпульс SSTV (~1200Гц) в записи — возможно, это не Robot36, или запись не начинается прямо с сигнала")
}
img := image.NewRGBA(image.Rect(0, 0, robot36Width, robot36Height))
lineSamples := int(robot36LineMs * samplesPerMs)
pos := startIdx
var lastCr, lastCb []uint8
for line := 0; line < robot36Height; line++ {
if pos+lineSamples > len(freq) {
break // запись оборвалась раньше ожидаемого — отдаём то, что успели декодировать, не ошибку
}
yStart := pos + int((robot36SyncMs+robot36PorchMs)*samplesPerMs)
ySamples := decodeScan(freq, yStart, robot36YMs*samplesPerMs, robot36Width)
chromaStart := yStart + int(robot36YMs*samplesPerMs) + int(robot36SepMs*samplesPerMs)
chromaSamples := decodeScan(freq, chromaStart, robot36ChromaMs*samplesPerMs, robot36Width)
// Robot36 экономит полосу: Cr и Cb передаются по очереди через
// строку (2:1 вертикальный сабсэмплинг цветности), а не оба
// каждую строку — недостающий канал берём с предыдущей строки.
var cr, cb []uint8
if line%2 == 0 {
cr = chromaSamples
cb = lastCb
} else {
cb = chromaSamples
cr = lastCr
}
if cr == nil {
cr = chromaSamples
}
if cb == nil {
cb = chromaSamples
}
lastCr, lastCb = cr, cb
for x := 0; x < robot36Width; x++ {
r, g, b := ycrcbToRGB(ySamples[x], cr[x], cb[x])
img.Set(x, line, color.RGBA{R: r, G: g, B: b, A: 255})
}
pos += lineSamples
}
return img, nil
}
// decodeScan вытаскивает n значений яркости/цвета из отрезка частотного
// ряда длиной durationSamples, начиная с start — по одному значению на
// пиксель, усредняя частоту в соответствующем под-интервале (не берём
// один-единственный сэмпл — так меньше шума на выходе).
func decodeScan(freq []float64, start int, durationSamples float64, n int) []uint8 {
out := make([]uint8, n)
step := durationSamples / float64(n)
for i := 0; i < n; i++ {
s := start + int(float64(i)*step)
e := start + int(float64(i+1)*step)
if e <= s {
e = s + 1
}
if s < 0 {
s = 0
}
if e > len(freq) {
e = len(freq)
}
if s >= e {
continue
}
var sum float64
for j := s; j < e; j++ {
sum += freq[j]
}
out[i] = freqToValue(sum / float64(e-s))
}
return out
}
// freqToValue — стандартная для SSTV шкала: 1500Гц = 0, 2300Гц = 255,
// линейно между ними.
func freqToValue(f float64) uint8 {
v := (f - 1500) / (2300 - 1500) * 255
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint8(v)
}
// ycrcbToRGB — стандартные коэффициенты ITU-R BT.601 (те же веса
// восприятия яркости, что и в stego.Grayscale).
func ycrcbToRGB(y, cr, cb uint8) (r, g, b uint8) {
yf := float64(y)
crf := float64(cr) - 128
cbf := float64(cb) - 128
return clamp8(yf + 1.402*crf), clamp8(yf - 0.344136*cbf - 0.714136*crf), clamp8(yf + 1.772*cbf)
}
func clamp8(v float64) uint8 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint8(v)
}
// findSyncStart ищет первый устойчивый участок частоты в районе 1200Гц
// (±100Гц) длиной не меньше minSamples — начало SSTV-передачи. Без
// декодирования VIS-заголовка (см. doc файла) это единственная точка
// привязки: от нас найденного момента дальше строки идут строго по
// заранее известным Robot36-таймингам.
func findSyncStart(freq []float64, minSamples int) int {
run := 0
for i, f := range freq {
if f > 1100 && f < 1300 {
run++
if run >= minSamples {
return i - run + 1
}
} else {
run = 0
}
}
return -1
}
// --- Демодуляция: преобразование Гильберта → мгновенная фаза → её
// производная = мгновенная частота. Классическая техника для FM-сигналов
// (в отличие от спектрограммы, где годится оконное FFT — здесь окно
// физически не может быть узким и точным одновременно на масштабе
// одного пикселя SSTV, порядка десятков сэмплов).
// analyticSignal строит аналитический сигнал через преобразование
// Гильберта: FFT → обнулить отрицательные частоты и удвоить
// положительные (кроме DC/Найквиста) → обратное FFT. Стандартный рецепт,
// переиспользует уже написанный fft() из fft.go — в оба конца, через
// тождество IFFT(x) = conj(FFT(conj(x)))/N (см. ifft ниже), лишний код
// отдельной обратной реализации не пишем.
func analyticSignal(x []float64) []complex128 {
n := len(x)
buf := make([]complex128, n)
for i, v := range x {
buf[i] = complex(v, 0)
}
fft(buf)
for k := 1; k < n/2; k++ {
buf[k] *= 2
}
for k := n/2 + 1; k < n; k++ {
buf[k] = 0
}
ifft(buf)
return buf
}
func ifft(x []complex128) {
n := len(x)
for i := range x {
x[i] = cmplx.Conj(x[i])
}
fft(x)
for i := range x {
x[i] = cmplx.Conj(x[i]) / complex(float64(n), 0)
}
}
// instantaneousFrequency считает мгновенную частоту по аналитическому
// сигналу: разворачиваем фазу (убираем скачки на 2π при переходе через
// границу arctan), берём её производную, умножаем на sampleRate/(2π).
func instantaneousFrequency(analytic []complex128, sampleRate int) []float64 {
n := len(analytic)
if n == 0 {
return nil
}
unwrapped := make([]float64, n)
unwrapped[0] = cmplx.Phase(analytic[0])
prevPhase := unwrapped[0]
for i := 1; i < n; i++ {
phase := cmplx.Phase(analytic[i])
delta := phase - prevPhase
for delta > math.Pi {
delta -= 2 * math.Pi
}
for delta < -math.Pi {
delta += 2 * math.Pi
}
unwrapped[i] = unwrapped[i-1] + delta
prevPhase = phase
}
freq := make([]float64, n)
for i := 0; i < n-1; i++ {
freq[i] = (unwrapped[i+1] - unwrapped[i]) * float64(sampleRate) / (2 * math.Pi)
}
if n > 1 {
freq[n-1] = freq[n-2]
}
return freq
}
// smooth — простое скользящее среднее, сглаживает шум в сырой оценке
// мгновенной частоты перед тем, как переводить её в яркость пикселя.
func smooth(x []float64, window int) []float64 {
if window < 2 || len(x) == 0 {
return x
}
out := make([]float64, len(x))
var sum float64
for i := range x {
sum += x[i]
if i >= window {
sum -= x[i-window]
}
n := window
if i < window {
n = i + 1
}
out[i] = sum / float64(n)
}
return out
}
func padToPow2(x []float64) []float64 {
n := nextPowerOfTwo(len(x))
if n == len(x) {
return x
}
out := make([]float64, n)
copy(out, x)
return out
}

143
internal/audio/wav.go Normal file
View file

@ -0,0 +1,143 @@
// Package audio реализует минимальный набор для аудио-стеганографии в
// CTF: разбор WAV, быстрое преобразование Фурье и построение
// спектрограммы. Только WAV, не MP3/FLAC — и это осознанный выбор, не
// недоделка: сжатие с потерями убивает как раз ту мелкую структуру
// спектра, в которую прячут сообщения, так что задачи на эту тему
// в CTF практически всегда именно в несжатом PCM WAV.
package audio
import (
"encoding/binary"
"fmt"
"os"
)
// WAV — результат разбора файла: моно-сигнал, нормализованный в
// диапазон [-1, 1] независимо от исходной битности. Многоканальные
// файлы усредняются в моно при разборе — для спектрограммы порядок
// каналов не важен, а городить в UI выбор канала ради этой конкретной
// задачи не стали.
type WAV struct {
SampleRate int
Samples []float64
}
// LoadWAV разбирает несжатый PCM WAV (RIFF/WAVE, formatTag=1).
func LoadWAV(path string) (*WAV, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("чтение %s: %w", path, err)
}
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return nil, fmt.Errorf("не похоже на WAV-файл (нет заголовка RIFF/WAVE)")
}
var (
sampleRate int
bitsPerSample int
channels int
audioFormat uint16
pcmData []byte
foundFmt bool
foundData bool
)
pos := 12
for pos+8 <= len(data) {
chunkID := string(data[pos : pos+4])
chunkSize := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8]))
chunkStart := pos + 8
if chunkStart+chunkSize > len(data) || chunkSize < 0 {
chunkSize = len(data) - chunkStart // повреждённый/обрезанный файл — берём что есть, не падаем
}
if chunkSize < 0 {
break
}
switch chunkID {
case "fmt ":
if chunkSize < 16 {
return nil, fmt.Errorf("повреждённый чанк fmt")
}
audioFormat = binary.LittleEndian.Uint16(data[chunkStart : chunkStart+2])
channels = int(binary.LittleEndian.Uint16(data[chunkStart+2 : chunkStart+4]))
sampleRate = int(binary.LittleEndian.Uint32(data[chunkStart+4 : chunkStart+8]))
bitsPerSample = int(binary.LittleEndian.Uint16(data[chunkStart+14 : chunkStart+16]))
foundFmt = true
case "data":
pcmData = data[chunkStart : chunkStart+chunkSize]
foundData = true
}
pos = chunkStart + chunkSize
if pos%2 == 1 { // чанки RIFF выровнены по чётной границе
pos++
}
}
if !foundFmt || !foundData {
return nil, fmt.Errorf("не найден чанк fmt или data — файл повреждён или это не WAV")
}
if audioFormat != 1 {
return nil, fmt.Errorf("поддерживается только несжатый PCM (formatTag=1), получен %d — если файл сжат (MP3/ADPCM и т.п.), сначала перекодируй в PCM WAV", audioFormat)
}
if channels < 1 {
return nil, fmt.Errorf("некорректное число каналов: %d", channels)
}
samples, err := decodePCM(pcmData, bitsPerSample, channels)
if err != nil {
return nil, err
}
if len(samples) == 0 {
return nil, fmt.Errorf("в файле нет сэмплов")
}
return &WAV{SampleRate: sampleRate, Samples: samples}, nil
}
// decodePCM разбирает сырые PCM-байты в моно float64 [-1, 1], усредняя
// каналы, если их больше одного.
func decodePCM(data []byte, bitsPerSample, channels int) ([]float64, error) {
bytesPerSample := bitsPerSample / 8
if bytesPerSample == 0 {
return nil, fmt.Errorf("неподдерживаемая битность: %d", bitsPerSample)
}
frameSize := bytesPerSample * channels
if frameSize == 0 {
return nil, fmt.Errorf("некорректный размер фрейма")
}
numFrames := len(data) / frameSize
samples := make([]float64, numFrames)
for i := 0; i < numFrames; i++ {
var sum float64
for c := 0; c < channels; c++ {
off := i*frameSize + c*bytesPerSample
sum += decodeSample(data[off:off+bytesPerSample], bitsPerSample)
}
samples[i] = sum / float64(channels)
}
return samples, nil
}
func decodeSample(b []byte, bitsPerSample int) float64 {
switch bitsPerSample {
case 8:
// 8-битный PCM в WAV — беззнаковый (0..255, тишина = 128).
return (float64(b[0]) - 128) / 128
case 16:
v := int16(binary.LittleEndian.Uint16(b))
return float64(v) / 32768
case 24:
v := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
if v&0x800000 != 0 {
v -= 0x1000000 // старший бит 24-битного числа установлен — это отрицательное значение, поправляем через вычитание 2^24
}
return float64(v) / 8388608
case 32:
v := int32(binary.LittleEndian.Uint32(b))
return float64(v) / 2147483648
default:
return 0
}
}

52
internal/stego/kitty.go Normal file
View file

@ -0,0 +1,52 @@
package stego
import (
"encoding/base64"
"fmt"
"strings"
)
// KittyEscape кодирует PNG-байты в escape-последовательность протокола
// Kitty graphics — Konsole его понимает (это уже выяснялось раньше, на
// другом проекте, где графические протоколы работали в Konsole, но не
// в Alacritty).
//
// ЧЕСТНАЯ ОГОВОРКА, серьёзнее обычной: сама генерация escape-кода по
// спецификации протокола — то, что я действительно знаю и могу
// аргументировать (a=T — передать и сразу отобразить одним действием;
// f=100 — формат PNG, протокол умеет декодировать его сам, кодировать
// в сырой RGBA не нужно; t=d — данные идут прямо в escape-последовательности,
// не через файл; чанки по 4096 base64-байт — так требует спецификация
// для больших изображений). А вот что произойдёт, когда bubbletea в
// alt-screen режиме в следующий раз перерисует экран поверх уже
// показанной Kitty-картинки — я предсказать не могу совсем: там нет
// реального терминала, чтобы проверить. Другие непроверенные места в
// проекте (например, зависимость на sqlite) — это "может не
// скомпилироваться, поправишь по ошибке компилятора". Здесь другое:
// код скомпилируется и, скорее всего, отработает без паники, вопрос
// только в том, что увидишь на экране — и тут я честно не знаю. Отсюда
// клавиша ctrl+v на вкладке Steganography — передать escape-код заново,
// если картинку перекрыло перерисовкой TUI.
func KittyEscape(pngData []byte) string {
b64 := base64.StdEncoding.EncodeToString(pngData)
const chunkSize = 4096
var b strings.Builder
for i := 0; i < len(b64); i += chunkSize {
end := i + chunkSize
if end > len(b64) {
end = len(b64)
}
chunk := b64[i:end]
more := 0
if end < len(b64) {
more = 1
}
if i == 0 {
fmt.Fprintf(&b, "\x1b_Ga=T,f=100,t=d,m=%d;%s\x1b\\", more, chunk)
} else {
fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, chunk)
}
}
return b.String()
}

40
internal/stego/lsb.go Normal file
View file

@ -0,0 +1,40 @@
package stego
import "image"
// ExtractLSB проходит пиксели построчно (слева направо, сверху вниз) и
// вытаскивает по одному биту (bit) из выбранного канала на пиксель,
// упаковывая их в байты (8 пикселей на байт), пока не наберётся maxBytes
// байт или не кончится изображение — что раньше.
//
// Это самая наивная, но и самая частая в CTF-задачах схема: линейная
// LSB-стеганография без перемешивания порядка пикселей и без ключа.
// Более хитрые схемы (с рассеиванием по PRNG-последовательности, с
// несколькими битами разных каналов одновременно, с посимвольным
// прыжком через фиксированный интервал пикселей и т.п.) этим не
// читаются — придётся разбирать руками, этот пакет вытаскивает только
// самый частый частный случай.
func ExtractLSB(img *image.RGBA, ch Channel, bit int, maxBytes int) []byte {
bounds := img.Bounds()
out := make([]byte, 0, maxBytes)
var cur byte
var count int
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if len(out) >= maxBytes {
return out
}
v := channelValue(img.RGBAAt(x, y), ch)
b := (v >> uint(bit)) & 1
cur = (cur << 1) | b
count++
if count == 8 {
out = append(out, cur)
cur = 0
count = 0
}
}
}
return out
}

231
internal/stego/stego.go Normal file
View file

@ -0,0 +1,231 @@
// Package stego реализует ручной разбор классической LSB-стеганографии
// в изображениях — аналог того, что StegSolve/zsteg делают для CTF-задач:
// изоляция битовых плоскостей и цветовых каналов, XOR между версиями
// картинки, извлечение младших бит в сырые байты.
//
// Вся логика — чистый Go на стандартной библиотеке (image/png/jpeg/gif),
// новых зависимостей не требует. Рендер результата в терминал — отдельный
// вопрос, см. kitty.go и честную оговорку там про то, что я не могу
// проверить эту часть без реального терминала.
package stego
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
_ "image/jpeg"
"image/png"
"os"
"strings"
)
// Channel — один из цветовых каналов пикселя.
type Channel int
const (
ChannelRed Channel = iota
ChannelGreen
ChannelBlue
ChannelAlpha
)
func (c Channel) String() string {
switch c {
case ChannelRed:
return "R"
case ChannelGreen:
return "G"
case ChannelBlue:
return "B"
case ChannelAlpha:
return "A"
default:
return "?"
}
}
// ParseChannel разбирает канал из строки — не используется TUI (там
// канал выбирается циклическим переключением, не вводом текста), но
// оставлен для потенциального headless/CLI-использования этого пакета
// в будущем, по аналогии с остальными частями приложения.
func ParseChannel(s string) (Channel, error) {
switch strings.ToLower(s) {
case "r", "red":
return ChannelRed, nil
case "g", "green":
return ChannelGreen, nil
case "b", "blue":
return ChannelBlue, nil
case "a", "alpha":
return ChannelAlpha, nil
default:
return 0, fmt.Errorf("неизвестный канал %q (ожидался r/g/b/a)", s)
}
}
func channelValue(px color.RGBA, c Channel) uint8 {
switch c {
case ChannelRed:
return px.R
case ChannelGreen:
return px.G
case ChannelBlue:
return px.B
case ChannelAlpha:
return px.A
default:
return 0
}
}
// Load читает изображение любого формата, поддерживаемого стандартной
// библиотекой (PNG/JPEG/GIF — декодеры регистрируются побочными
// импортами выше), и приводит его к *image.RGBA — единому формату для
// всех дальнейших побитовых манипуляций независимо от исходного формата.
// Второе возвращаемое значение — имя формата ("png"/"jpeg"/"gif"), как
// его определил image.Decode, для отображения в метаданных.
//
// Честная оговорка: у JPEG нет альфа-канала — после декодирования и
// приведения к RGBA канал A будет равномерно 255 у каждого пикселя, так
// что BitPlane/ExtractLSB по каналу Alpha на JPEG-источнике осмысленного
// результата не дадут (не баг, а следствие самого формата).
func Load(path string) (*image.RGBA, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("открытие %s: %w", path, err)
}
defer f.Close()
src, format, err := image.Decode(f)
if err != nil {
return nil, "", fmt.Errorf("декодирование изображения: %w", err)
}
bounds := src.Bounds()
rgba := image.NewRGBA(bounds)
draw.Draw(rgba, bounds, src, bounds.Min, draw.Src)
return rgba, format, nil
}
// EncodePNG сериализует изображение в PNG-байты — общий формат и для
// экспорта на диск, и для передачи через Kitty graphics protocol
// (который умеет декодировать PNG сам, кодировать в сырой RGBA не нужно).
func EncodePNG(img image.Image) ([]byte, error) {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("кодирование PNG: %w", err)
}
return buf.Bytes(), nil
}
// SaveFile сохраняет изображение в PNG-файл по указанному пути.
func SaveFile(img image.Image, path string) error {
data, err := EncodePNG(img)
if err != nil {
return err
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("запись %s: %w", path, err)
}
return nil
}
// BitPlane изолирует один бит (0 — младший/LSB, 7 — старший) одного
// канала: пиксели, где этот бит равен 1, становятся белыми, где 0 —
// чёрными. Классический первый шаг ручного разбора LSB-стеганографии —
// спрятанный текст/паттерн в младшем бите почти никогда не заметен на
// самом изображении, но полностью выдаёт себя на этой визуализации.
func BitPlane(img *image.RGBA, ch Channel, bit int) *image.RGBA {
bounds := img.Bounds()
out := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
v := channelValue(img.RGBAAt(x, y), ch)
var out8 uint8
if (v>>uint(bit))&1 == 1 {
out8 = 255
}
out.SetRGBA(x, y, color.RGBA{out8, out8, out8, 255})
}
}
return out
}
// ChannelIsolate обнуляет все каналы, кроме выбранного, оставляя его
// как есть (не переводя в градации серого) — полезно увидеть, не
// прячется ли что-то, заметное только в одном канале при полной яркости.
// Для Alpha, который сам по себе не цветной, результат показывается как
// градации серого — иначе показывать было бы просто нечего (R=G=B=0).
func ChannelIsolate(img *image.RGBA, ch Channel) *image.RGBA {
bounds := img.Bounds()
out := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
v := channelValue(img.RGBAAt(x, y), ch)
var r, g, b uint8
switch ch {
case ChannelRed:
r = v
case ChannelGreen:
g = v
case ChannelBlue:
b = v
case ChannelAlpha:
r, g, b = v, v, v
}
out.SetRGBA(x, y, color.RGBA{r, g, b, 255})
}
}
return out
}
// Grayscale переводит изображение в градации серого по стандартным
// весам восприятия яркости ITU-R BT.601.
func Grayscale(img *image.RGBA) *image.RGBA {
bounds := img.Bounds()
out := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
px := img.RGBAAt(x, y)
gray := uint8((299*int(px.R) + 587*int(px.G) + 114*int(px.B)) / 1000)
out.SetRGBA(x, y, color.RGBA{gray, gray, gray, 255})
}
}
return out
}
// Invert инвертирует цвета (не трогая альфа-канал).
func Invert(img *image.RGBA) *image.RGBA {
bounds := img.Bounds()
out := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
px := img.RGBAAt(x, y)
out.SetRGBA(x, y, color.RGBA{255 - px.R, 255 - px.G, 255 - px.B, px.A})
}
}
return out
}
// XOR требует одинаковых размеров у обоих изображений — иначе неясно,
// как сопоставлять пиксели. Частый CTF-приём: XOR "чистой" версии
// картинки с версией, куда что-то зашито, визуально выделяет именно
// то, что отличается, а не всё изображение целиком.
func XOR(a, b *image.RGBA) (*image.RGBA, error) {
boundsA, boundsB := a.Bounds(), b.Bounds()
if boundsA != boundsB {
return nil, fmt.Errorf("разные размеры изображений: %v и %v", boundsA.Size(), boundsB.Size())
}
out := image.NewRGBA(boundsA)
for y := boundsA.Min.Y; y < boundsA.Max.Y; y++ {
for x := boundsA.Min.X; x < boundsA.Max.X; x++ {
pa := a.RGBAAt(x, y)
pb := b.RGBAAt(x, y)
out.SetRGBA(x, y, color.RGBA{pa.R ^ pb.R, pa.G ^ pb.G, pa.B ^ pb.B, 255})
}
}
return out, nil
}

144
internal/tui/model.go Normal file
View file

@ -0,0 +1,144 @@
// Package tui реализует единственный экран stegterm — стеганография
// изображений и аудио/SSTV, без таб-бара: тут нечего переключать между
// собой, есть один режим работы, ветвящийся по типу загруженного
// файла (см. stegoKind в stegoview.go).
package tui
import (
"fmt"
"image"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/stegterm/internal/audio"
"github.com/r3g1tpr0cs/stegterm/internal/stego"
)
// --- Типы сообщений — в burterm они жили в общей корневой model.go
// (там таких вкладок было четырнадцать, у каждой свой набор), здесь
// вкладка одна, так что и сообщения естественно живут рядом с ней. ---
type stegoLoadedMsg struct {
img *image.RGBA
path string
format string
size int64
}
type audioLoadedMsg struct {
wav *audio.WAV
path string
size int64
}
type stegoResultMsg struct{ result *image.RGBA }
type stegoExtractedMsg struct{ data []byte }
type stegoSavedMsg struct{ path string }
type stegoErrMsg struct{ err error }
// Model — корневая модель приложения: тонкая обёртка над stegoModel,
// адаптирующая её под интерфейс tea.Model верхнего уровня (stegoModel
// сама по себе не реализует tea.Model — её Update возвращает stegoModel,
// не tea.Model, тот же паттерн "суб-модели", что был в burterm для
// каждой вкладки).
type Model struct {
stego stegoModel
}
// New создаёт начальное состояние.
func New() Model {
return Model{stego: newStegoView()}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.stego.SetSize(msg.Width-2, msg.Height-2)
return m, nil
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
case stegoLoadedMsg:
m.stego.kind = stegoKindImage
m.stego.original = msg.img
m.stego.wav = nil
m.stego.loadedPath = msg.path
m.stego.format = msg.format
m.stego.fileSize = msg.size
m.stego.result = nil
m.stego.kittyEscape = ""
m.stego.extractedText = ""
m.stego.extractedHex = ""
m.stego.errMsg = ""
m.stego.statusMsg = fmt.Sprintf("загружено: %s (%dx%d)", msg.path, msg.img.Bounds().Dx(), msg.img.Bounds().Dy())
return m, nil
case audioLoadedMsg:
m.stego.kind = stegoKindAudio
m.stego.wav = msg.wav
m.stego.original = nil
m.stego.loadedPath = msg.path
m.stego.fileSize = msg.size
m.stego.result = nil
m.stego.kittyEscape = ""
m.stego.extractedText = ""
m.stego.extractedHex = ""
m.stego.errMsg = ""
var dur float64
if msg.wav.SampleRate > 0 {
dur = float64(len(msg.wav.Samples)) / float64(msg.wav.SampleRate)
}
m.stego.statusMsg = fmt.Sprintf("загружено: %s (%d Гц, %.2fс) — ctrl+r считает результат", msg.path, msg.wav.SampleRate, dur)
return m, nil
case stegoResultMsg:
m.stego.result = msg.result
m.stego.extractedText = ""
m.stego.extractedHex = ""
pngData, err := stego.EncodePNG(msg.result)
if err != nil {
m.stego.errMsg = err.Error()
m.stego.kittyEscape = ""
return m, nil
}
m.stego.errMsg = ""
m.stego.kittyEscape = stego.KittyEscape(pngData)
bnd := msg.result.Bounds()
m.stego.statusMsg = fmt.Sprintf("готово: %dx%d (ctrl+v — показать/повторить)", bnd.Dx(), bnd.Dy())
return m, kittyPrintCmd(m.stego.kittyEscape)
case stegoExtractedMsg:
text, hexStr := formatExtracted(msg.data)
m.stego.extractedText = text
m.stego.extractedHex = hexStr
m.stego.result = nil
m.stego.kittyEscape = ""
m.stego.errMsg = ""
m.stego.statusMsg = fmt.Sprintf("извлечено %d байт", len(msg.data))
return m, nil
case stegoSavedMsg:
m.stego.errMsg = ""
m.stego.statusMsg = "сохранено в " + msg.path
return m, nil
case stegoErrMsg:
m.stego.errMsg = msg.err.Error()
return m, nil
}
var cmd tea.Cmd
m.stego, cmd = m.stego.Update(msg)
return m, cmd
}
func (m Model) View() string {
return m.stego.View()
}

516
internal/tui/stegoview.go Normal file
View file

@ -0,0 +1,516 @@
package tui
import (
"encoding/hex"
"fmt"
"image"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/r3g1tpr0cs/stegterm/internal/audio"
"github.com/r3g1tpr0cs/stegterm/internal/stego"
)
var stegoModes = []string{"bitplane", "channel", "grayscale", "invert", "xor", "lsb"}
var stegoChannels = []stego.Channel{stego.ChannelRed, stego.ChannelGreen, stego.ChannelBlue, stego.ChannelAlpha}
var audioWindowSizes = []int{512, 1024, 2048, 4096}
var audioOps = []string{"spectrogram", "sstv"}
var sstvModes = []string{"robot36"} // задел на будущее — пока только один режим
// stegoKind различает, что сейчас загружено — картинка или WAV. От
// этого зависит, что делает ctrl+g/ctrl+r и что показывает правая
// панель метаданных. Один и тот же экран обслуживает оба сценария —
// картинки и аудио/SSTV — не два разных режима запуска, а ветвление
// по типу загруженного файла.
type stegoKind int
const (
stegoKindNone stegoKind = iota
stegoKindImage
stegoKindAudio
)
// stegoModel — единственный экран stegterm. Само преобразование (и для
// картинок, и для спектрограммы) считается синхронно — это дёшево, не
// сетевой I/O, — но уходит в tea.Cmd ради единообразной дисциплины.
// Рендер результата — самая ненадёжная часть всего проекта, см.
// честную оговорку в stego.KittyEscape; для спектрограммы используется
// тот же путь (stegoResultMsg → EncodePNG → KittyEscape), что и для
// image-преобразований — с точки зрения рендера это просто ещё одна
// картинка.
//
// View() делит экран на широкую левую колонку (поля ввода, статус) и
// узкую правую панель метаданных — про причины см. комментарий там же.
type stegoModel struct {
pathInput textinput.Model
secondPathInput textinput.Model // путь ко второму изображению — используется только в режиме "xor" (для аудио неприменимо)
lsbLenInput textinput.Model // длина LSB-извлечения — используется только для картинок
kind stegoKind
// --- состояние для картинок ---
mode int // индекс в stegoModes
channel int // индекс в stegoChannels
bit int // 0..7
original *image.RGBA
// --- состояние для аудио ---
wav *audio.WAV
audioWindowIdx int // индекс в audioWindowSizes — используется только для операции "spectrogram"
audioOp int // индекс в audioOps: 0 — spectrogram, 1 — sstv
sstvModeIdx int // индекс в sstvModes — используется только для операции "sstv"
// --- общее ---
result *image.RGBA // и итог image-преобразования, и спектрограмма — один и тот же тип
loadedPath string
format string // для картинок — "png"/"jpeg"/"gif"; для аудио не используется
fileSize int64
kittyEscape string // готовая escape-последовательность под текущий result, пересчитывается один раз при получении результата, а не на каждый View()
extractedText string
extractedHex string
errMsg string
statusMsg string
width, height int
focus int // 0 путь, 1 второй путь (только картинки), 2 длина LSB-извлечения (только картинки)
}
func newStegoView() stegoModel {
pathIn := textinput.New()
pathIn.Placeholder = "/путь/к/файлу.png или .wav"
pathIn.CharLimit = 512
pathIn.Focus()
secondIn := textinput.New()
secondIn.Placeholder = "/путь/ко/второй/картинке.png (только для xor)"
secondIn.CharLimit = 512
lsbLenIn := textinput.New()
lsbLenIn.Placeholder = "256"
lsbLenIn.CharLimit = 8
return stegoModel{
pathInput: pathIn,
secondPathInput: secondIn,
lsbLenInput: lsbLenIn,
}
}
func (m *stegoModel) SetSize(width, height int) {
m.width = width
m.height = height
}
func (m stegoModel) Update(msg tea.Msg) (stegoModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 3
m.pathInput.Blur()
m.secondPathInput.Blur()
m.lsbLenInput.Blur()
switch m.focus {
case 0:
m.pathInput.Focus()
case 1:
m.secondPathInput.Focus()
case 2:
m.lsbLenInput.Focus()
}
return m, nil
case "ctrl+o":
return m, m.loadCmd()
case "ctrl+g":
if m.kind == stegoKindAudio {
m.audioOp = (m.audioOp + 1) % len(audioOps)
} else {
m.mode = (m.mode + 1) % len(stegoModes)
}
return m, nil
case "ctrl+n":
switch {
case m.kind == stegoKindAudio && audioOps[m.audioOp] == "spectrogram":
m.audioWindowIdx = (m.audioWindowIdx + 1) % len(audioWindowSizes)
case m.kind != stegoKindAudio:
m.channel = (m.channel + 1) % len(stegoChannels)
}
return m, nil
case "ctrl+b":
switch {
case m.kind == stegoKindAudio && audioOps[m.audioOp] == "sstv":
m.sstvModeIdx = (m.sstvModeIdx + 1) % len(sstvModes)
case m.kind != stegoKindAudio:
m.bit = (m.bit + 1) % 8
}
return m, nil
case "ctrl+r":
if m.kind == stegoKindAudio {
if audioOps[m.audioOp] == "sstv" {
return m, m.applySSTVCmd()
}
return m, m.applyAudioCmd()
}
return m, m.applyImageCmd()
case "ctrl+s":
return m, m.saveCmd()
case "ctrl+v":
return m, kittyPrintCmd(m.kittyEscape)
}
}
var cmd tea.Cmd
switch m.focus {
case 0:
m.pathInput, cmd = m.pathInput.Update(msg)
case 1:
m.secondPathInput, cmd = m.secondPathInput.Update(msg)
case 2:
m.lsbLenInput, cmd = m.lsbLenInput.Update(msg)
}
return m, cmd
}
// loadCmd (ctrl+o) различает картинку и WAV по расширению файла —
// просто и предсказуемо для CTF-контекста, где расширения обычно
// осмысленные. Для .wav — audio.LoadWAV, для всего остального — попытка
// декодировать как изображение (с честной ошибкой, если не вышло ни то,
// ни другое).
func (m stegoModel) loadCmd() tea.Cmd {
path := strings.TrimSpace(m.pathInput.Value())
return func() tea.Msg {
if path == "" {
return stegoErrMsg{fmt.Errorf("укажи путь к файлу")}
}
var size int64
if fi, err := os.Stat(path); err == nil {
size = fi.Size()
}
if strings.EqualFold(filepath.Ext(path), ".wav") {
w, err := audio.LoadWAV(path)
if err != nil {
return stegoErrMsg{err}
}
return audioLoadedMsg{wav: w, path: path, size: size}
}
img, format, err := stego.Load(path)
if err != nil {
return stegoErrMsg{fmt.Errorf("не удалось загрузить как изображение (для аудио нужно расширение .wav): %w", err)}
}
return stegoLoadedMsg{img: img, path: path, format: format, size: size}
}
}
// applyImageCmd (ctrl+r в режиме картинки) применяет текущий выбранный
// режим к загруженному изображению. Для xor заодно грузит второе
// изображение с диска — так не нужна отдельная команда "загрузить
// второе" до применения.
func (m stegoModel) applyImageCmd() tea.Cmd {
original := m.original
mode := stegoModes[m.mode]
channel := stegoChannels[m.channel]
bit := m.bit
secondPath := strings.TrimSpace(m.secondPathInput.Value())
lsbLenStr := m.lsbLenInput.Value()
return func() tea.Msg {
if original == nil {
return stegoErrMsg{fmt.Errorf("сначала загрузи изображение (ctrl+o)")}
}
switch mode {
case "bitplane":
return stegoResultMsg{result: stego.BitPlane(original, channel, bit)}
case "channel":
return stegoResultMsg{result: stego.ChannelIsolate(original, channel)}
case "grayscale":
return stegoResultMsg{result: stego.Grayscale(original)}
case "invert":
return stegoResultMsg{result: stego.Invert(original)}
case "xor":
if secondPath == "" {
return stegoErrMsg{fmt.Errorf("для xor укажи путь ко второму изображению")}
}
second, _, err := stego.Load(secondPath)
if err != nil {
return stegoErrMsg{err}
}
result, err := stego.XOR(original, second)
if err != nil {
return stegoErrMsg{err}
}
return stegoResultMsg{result: result}
case "lsb":
n, _ := strconv.Atoi(lsbLenStr)
if n <= 0 {
n = 256
}
data := stego.ExtractLSB(original, channel, bit, n)
return stegoExtractedMsg{data: data}
default:
return stegoErrMsg{fmt.Errorf("неизвестный режим %q", mode)}
}
}
}
// applyAudioCmd (ctrl+r в режиме аудио) считает спектрограмму текущим
// размером окна и оборачивает её в тот же stegoResultMsg, что и
// image-преобразования — с точки зрения рендера (EncodePNG/KittyEscape)
// разницы никакой, это просто *image.RGBA.
func (m stegoModel) applyAudioCmd() tea.Cmd {
wav := m.wav
windowSize := audioWindowSizes[m.audioWindowIdx]
return func() tea.Msg {
if wav == nil {
return stegoErrMsg{fmt.Errorf("сначала загрузи .wav-файл (ctrl+o)")}
}
hop := windowSize / 4 // 75% перекрытие — стандартный компромисс между временным и частотным разрешением
frames := audio.Spectrogram(wav.Samples, windowSize, hop)
if len(frames) == 0 {
return stegoErrMsg{fmt.Errorf("файл слишком короткий для окна в %d сэмплов — попробуй окно поменьше (ctrl+n)", windowSize)}
}
img := audio.RenderSpectrogram(frames)
return stegoResultMsg{result: img}
}
}
// applySSTVCmd (ctrl+r в режиме аудио, операция "sstv") декодирует
// Robot36 через audio.DecodeRobot36 — та же самая честная оговорка про
// риск калибровки, что и в doc-комментарии sstv.go, здесь не повторяю.
// Результат — снова *image.RGBA в общем stegoResultMsg, тот же путь
// рендера, что и у спектрограммы и у image-преобразований.
func (m stegoModel) applySSTVCmd() tea.Cmd {
wav := m.wav
return func() tea.Msg {
if wav == nil {
return stegoErrMsg{fmt.Errorf("сначала загрузи .wav-файл (ctrl+o)")}
}
img, err := audio.DecodeRobot36(wav)
if err != nil {
return stegoErrMsg{fmt.Errorf("декодирование SSTV: %w", err)}
}
return stegoResultMsg{result: img}
}
}
// saveCmd (ctrl+s) сохраняет текущий result (изображение или
// спектрограмма — не различает) в PNG-файл под
// ~/.stegterm/stego-exports/.
func (m stegoModel) saveCmd() tea.Cmd {
result := m.result
return func() tea.Msg {
if result == nil {
return stegoErrMsg{fmt.Errorf("нечего сохранять — сначала примени преобразование (ctrl+r)")}
}
path, err := defaultStegoExportPath()
if err != nil {
return stegoErrMsg{err}
}
if err := stego.SaveFile(result, path); err != nil {
return stegoErrMsg{err}
}
return stegoSavedMsg{path: path}
}
}
func defaultStegoExportPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
}
dir := filepath.Join(home, ".stegterm", "stego-exports")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
}
return filepath.Join(dir, fmt.Sprintf("stego-%d.png", time.Now().Unix())), nil
}
// kittyPrintCmd пишет готовую Kitty-escape-последовательность напрямую
// в stdout, в обход обычного View()-рендера bubbletea — см. развёрнутую
// оговорку в stego.KittyEscape про то, почему это прагматичный побочный
// эффект, а не гарантированно работающий встроенный механизм. Ничего не
// делает, если escape пуст (результата ещё нет или ошибка кодирования).
func kittyPrintCmd(escape string) tea.Cmd {
return func() tea.Msg {
if escape != "" {
fmt.Print(escape)
}
return nil
}
}
// formatExtracted представляет извлечённые LSB-байты и как текст
// (непечатные символы заменены на '.', чтобы не портить вывод терминала),
// и как hex — иногда искомое читается только в одном из двух видов.
func formatExtracted(data []byte) (text, hexStr string) {
var tb strings.Builder
for _, b := range data {
if b >= 0x20 && b < 0x7f {
tb.WriteByte(b)
} else {
tb.WriteByte('.')
}
}
return tb.String(), hex.EncodeToString(data)
}
// View делит экран на широкую левую колонку (3/4 ширины) и узкую
// панель метаданных справа (1/4, через panel() из style.go). На узком
// терминале (меньше ~70 колонок) колонки складываются друг под друга.
//
// Известный компромисс: поля "второе изображение" и "длина LSB" всегда
// на экране, даже в режиме аудио, где они не используются — условный
// layout ради их скрытия усложнил бы View() заметнее, чем стоит того;
// в audio-режиме они просто ничего не делают, tab всё ещё до них
// доходит, но эффекта от печати в них нет.
func (m stegoModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
var left strings.Builder
left.WriteString(focusMark(0) + "Файл (картинка или .wav)\n")
left.WriteString(m.pathInput.View())
left.WriteString("\n\n")
left.WriteString(focusMark(1) + "Второе изображение (для xor)\n")
left.WriteString(m.secondPathInput.View())
left.WriteString("\n\n")
if m.kind == stegoKindAudio {
if audioOps[m.audioOp] == "sstv" {
left.WriteString(fmt.Sprintf("Аудио: SSTV (ctrl+g) Режим: %s (ctrl+b)\n\n", sstvModes[m.sstvModeIdx]))
} else {
left.WriteString(fmt.Sprintf("Аудио: спектрограмма (ctrl+g) Окно FFT: %d (ctrl+n)\n\n", audioWindowSizes[m.audioWindowIdx]))
}
} else {
left.WriteString(fmt.Sprintf("Режим: %-9s (ctrl+g) Канал: %s (ctrl+n) Бит: %d (ctrl+b)\n\n",
stegoModes[m.mode], stegoChannels[m.channel], m.bit))
}
left.WriteString(focusMark(2) + "Длина LSB-извлечения, байт (только для картинок)\n")
left.WriteString(m.lsbLenInput.View())
left.WriteString("\n\n")
if m.errMsg != "" {
left.WriteString("Ошибка: " + m.errMsg + "\n")
}
if m.statusMsg != "" {
left.WriteString(m.statusMsg + "\n")
}
if m.extractedText != "" || m.extractedHex != "" {
left.WriteString("\nИзвлечённые байты (LSB):\n")
left.WriteString(" текст: " + m.extractedText + "\n")
left.WriteString(" hex: " + m.extractedHex + "\n")
}
left.WriteString("\ntab — между полями · ctrl+o — загрузить · ctrl+r — применить · ctrl+v — показать заново\n")
left.WriteString("ctrl+s — экспорт PNG · ctrl+c — выход\n")
var meta strings.Builder
switch m.kind {
case stegoKindAudio:
if m.wav != nil {
fmt.Fprintf(&meta, "Путь: %s\n", truncateMiddle(m.loadedPath, 28))
if m.fileSize > 0 {
fmt.Fprintf(&meta, "Файл: %s\n", humanBytes(uint64(m.fileSize)))
}
fmt.Fprintf(&meta, "Частота: %d Гц\n", m.wav.SampleRate)
if m.wav.SampleRate > 0 {
dur := time.Duration(float64(len(m.wav.Samples)) / float64(m.wav.SampleRate) * float64(time.Second))
fmt.Fprintf(&meta, "Длит-ть: %s\n", dur.Round(10*time.Millisecond))
}
fmt.Fprintf(&meta, "Сэмплов: %d\n", len(m.wav.Samples))
} else {
meta.WriteString("аудио не загружено\n")
}
fmt.Fprintf(&meta, "\nОперация: %s\n", audioOps[m.audioOp])
if audioOps[m.audioOp] == "sstv" {
fmt.Fprintf(&meta, "SSTV-режим: %s\n", sstvModes[m.sstvModeIdx])
} else {
fmt.Fprintf(&meta, "Окно FFT: %d\n", audioWindowSizes[m.audioWindowIdx])
}
if m.result != nil {
bnd := m.result.Bounds()
fmt.Fprintf(&meta, "\nРезультат:\n%dx%d\n", bnd.Dx(), bnd.Dy())
}
case stegoKindImage:
if m.original != nil {
bnd := m.original.Bounds()
fmt.Fprintf(&meta, "Путь: %s\n", truncateMiddle(m.loadedPath, 28))
fmt.Fprintf(&meta, "Формат: %s\n", strings.ToUpper(m.format))
if m.fileSize > 0 {
fmt.Fprintf(&meta, "Файл: %s\n", humanBytes(uint64(m.fileSize)))
}
fmt.Fprintf(&meta, "Размер: %dx%d\n", bnd.Dx(), bnd.Dy())
} else {
meta.WriteString("не загружено\n")
}
fmt.Fprintf(&meta, "\nРежим: %s\n", stegoModes[m.mode])
fmt.Fprintf(&meta, "Канал: %s\n", stegoChannels[m.channel])
fmt.Fprintf(&meta, "Бит: %d\n", m.bit)
if m.result != nil {
bnd := m.result.Bounds()
fmt.Fprintf(&meta, "\nРезультат:\n%dx%d\n", bnd.Dx(), bnd.Dy())
}
if m.extractedHex != "" {
fmt.Fprintf(&meta, "\nИзвлечено:\n%d байт\n", len(m.extractedHex)/2)
}
default:
meta.WriteString("файл не загружен\n")
}
totalWidth := m.width
if totalWidth <= 0 {
totalWidth = 80
}
if totalWidth < 70 {
return left.String() + "\n" + panel("метаданные", meta.String(), totalWidth-6)
}
rightWidth := totalWidth / 4
if rightWidth < 18 {
rightWidth = 18
}
leftWidth := totalWidth - rightWidth - 6
if leftWidth < 20 {
leftWidth = 20
}
leftStyled := lipgloss.NewStyle().Width(leftWidth).Render(left.String())
rightStyled := panel("метаданные", meta.String(), rightWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled)
}
// truncateMiddle обрезает середину длинного пути ("...", а не конец) —
// хвост файла (имя) обычно важнее начала (общие для всех файлов
// директории) при взгляде на узкую колонку.
func truncateMiddle(s string, max int) string {
if len(s) <= max {
return s
}
half := (max - 3) / 2
return s[:half] + "..." + s[len(s)-half:]
}

52
internal/tui/style.go Normal file
View file

@ -0,0 +1,52 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Палитра и панель с рамкой — перенесены из burterm без изменений
// (там это была лишь часть большей темы с таб-баром/логотипом; здесь
// переключать нечего — экран один, — так что взято только то, что
// реально используется вкладкой Steganography: рамка вокруг блока
// метаданных).
var (
colorBorder = lipgloss.Color("62") // приглушённый синий/фиолетовый — рамка панели
colorPanelTitle = lipgloss.Color("213") // розовый — заголовок панели
)
var (
panelBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(0, 1)
panelTitleStyle = lipgloss.NewStyle().
Foreground(colorPanelTitle).
Bold(true)
)
// panel оборачивает body в рамку с заголовком первой строкой внутри неё.
func panel(title, body string, width int) string {
if width < 10 {
width = 10
}
content := panelTitleStyle.Render(strings.ToUpper(title)) + "\n" + body
return panelBorderStyle.Width(width).Render(content)
}
// humanBytes форматирует размер в человекочитаемом виде (KiB/MiB/...).
func humanBytes(n uint64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(unit), 0
for n2 := n / unit; n2 >= unit; n2 /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}