stegterm/internal/stego/stego.go
2026-09-14 11:00:27 +03:00

231 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}