Beta
This commit is contained in:
parent
94336a4569
commit
e7b1302012
15 changed files with 1922 additions and 0 deletions
76
NOTES.md
Normal file
76
NOTES.md
Normal 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
68
README.md
Normal 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
70
README.ru.md
Normal 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
25
cmd/stegterm/main.go
Normal 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
9
go.mod
Normal 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
77
internal/audio/fft.go
Normal 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
|
||||
}
|
||||
123
internal/audio/spectrogram.go
Normal file
123
internal/audio/spectrogram.go
Normal 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
296
internal/audio/sstv.go
Normal 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
143
internal/audio/wav.go
Normal 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
52
internal/stego/kitty.go
Normal 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
40
internal/stego/lsb.go
Normal 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
231
internal/stego/stego.go
Normal 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
144
internal/tui/model.go
Normal 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
516
internal/tui/stegoview.go
Normal 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
52
internal/tui/style.go
Normal 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])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue