diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..198e45c --- /dev/null +++ b/NOTES.md @@ -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()` заметнее, чем + стоит того. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb2d412 --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..b4dd108 --- /dev/null +++ b/README.ru.md @@ -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) +``` diff --git a/cmd/stegterm/main.go b/cmd/stegterm/main.go new file mode 100644 index 0000000..495a72b --- /dev/null +++ b/cmd/stegterm/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..13185e8 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/internal/audio/fft.go b/internal/audio/fft.go new file mode 100644 index 0000000..a0f7882 --- /dev/null +++ b/internal/audio/fft.go @@ -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 +} diff --git a/internal/audio/spectrogram.go b/internal/audio/spectrogram.go new file mode 100644 index 0000000..10313be --- /dev/null +++ b/internal/audio/spectrogram.go @@ -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) +} diff --git a/internal/audio/sstv.go b/internal/audio/sstv.go new file mode 100644 index 0000000..0c95972 --- /dev/null +++ b/internal/audio/sstv.go @@ -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 +} diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..be5ac4c --- /dev/null +++ b/internal/audio/wav.go @@ -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 + } +} diff --git a/internal/stego/kitty.go b/internal/stego/kitty.go new file mode 100644 index 0000000..cd668b5 --- /dev/null +++ b/internal/stego/kitty.go @@ -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() +} diff --git a/internal/stego/lsb.go b/internal/stego/lsb.go new file mode 100644 index 0000000..598026c --- /dev/null +++ b/internal/stego/lsb.go @@ -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 +} diff --git a/internal/stego/stego.go b/internal/stego/stego.go new file mode 100644 index 0000000..f68eb4a --- /dev/null +++ b/internal/stego/stego.go @@ -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 +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..c5a1a68 --- /dev/null +++ b/internal/tui/model.go @@ -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() +} diff --git a/internal/tui/stegoview.go b/internal/tui/stegoview.go new file mode 100644 index 0000000..443426a --- /dev/null +++ b/internal/tui/stegoview.go @@ -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:] +} diff --git a/internal/tui/style.go b/internal/tui/style.go new file mode 100644 index 0000000..b8892ef --- /dev/null +++ b/internal/tui/style.go @@ -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]) +}