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