
Прочитал я как то раз статью о том, как спрятать фото в другом фото, вот ее перевод. Статья довольно короткая и задумка описанная в ней никакой новизны не несет. И не своей простотой привлекла меня описанная идея, а довольно широким кругом возможных расширений.
Коротко излагаю суть идеи: в одно фото (PNG) можно встроить другое фото или совсем не фото, а чего сами хотите. Реализация проста: каждый младший бит в RGB матрице несет полезную нагрузку, собрав их вместе, вы получите массив байтов, который хотели спрятать, а изменение в исходном изображении не ощутимо человеческим глазом. Кому интересно, ознакомьтесь с исходной статьей, ну а в этой статье мы попробуем рассмотреть возможные юзкейсы и улучшения.

Реализация выполнена на языке GO и доступна на моем гитхабе. Там же найдете руководство по эксплуатации с примерами запуска с разными ключами. А в папке demo рабочая демка (правда приложение все равно придется сначала скомпилировать). Но, если компилировать лень, то не беда, для ленивых я опубликовал приложение, как веб-сервис. На этой страничке вы можете попробовать спрятать шифрованное послание в своем PNG и расшифровать его.
Итак, теперь об улучшениях исходной идеи. Для того, чтобы было интереснее читать, я буду приводить примеры использования этого приложения двумя секретными агентами, находящимися в разных странах. Они пытаются передать друг-другу защищенные сообщения по открытым каналам связи. А мы им в этом поможем. Ну а тем, кто нетерпелив, я сразу скажу, что плюшки вот такие:
- AES шифрование;
- XOR ключом длиной равной длине сообщения;
- Цифровая подпись.
Нибблы
Но сначала давайте разберемся, как отцепить от исходного массива байтов по одному биту и спрятать в каждом байте каждого RGB вектора в изображении?

Сама концепция проста и понятна: берем первый бит, прячем в R первого вектора RGB, берем второй бит, прячем в G первого вектора RGB и т.д. На рисунке мы видим — в верхней части идет массив данных, который мы хотим спрятать, а в нижней — части RGB изображения. Каждый младший бит мы заменяем на бит данных и не паримся — изменения настолько незначительны, что глазом не отличить. Альфа-канал я не нарисовал умышленно — в нем мы ничего не прячем, потому что палево =).
Для реализации задумки мы “пилим” исходные данные на нибблы по три бита в каждом. Каждый ниббл будет целиком ложится на RGB вектор. Таким образом, в R мы заменим младший бит на nibble & 1, в G заменим младший бит на nibble & 2, а в B заменим младший бит на nibble & 4. Альфа-канал оставляем без изменений.
Под спойлером код, который пилит данные на нибблы.
package nibbles type nibble struct { mask int16 size int current int data []byte } const ( MaxNibbleSize = 6 MinNibbleSize = 1 DefaultNibbleSize = 4 bitsInByte = 8 ) func New(size int, data []byte) *nibble { var mask int16 if size < MinNibbleSize || size > MaxNibbleSize { size = DefaultNibbleSize } for i := 0; i < size; i++ { mask |= 1 << i } return &nibble{ mask: mask, size: size, data: data, } } func (n *nibble) Next() (byte, bool) { byteIndex := (n.current * n.size) / bitsInByte if byteIndex >= len(n.data) { return 0, false } bitIndex := (n.current * n.size) % bitsInByte n.current++ word := int16(n.data[byteIndex]) if len(n.data) > byteIndex+1 && bitIndex > bitsInByte-n.size { word |= int16(n.data[byteIndex+1]) << bitsInByte } result := (word >> bitIndex) & n.mask return byte(result), true } func Convert(data []byte, size int) (result []byte) { var ( filledBits int bitBuffer int16 ) for _, b := range data { bitBuffer |= int16(b) << filledBits filledBits += size if filledBits >= bitsInByte { result = append(result, byte(bitBuffer&0xff)) bitBuffer = bitBuffer >> bitsInByte filledBits -= bitsInByte } } if filledBits >= size { result = append(result, byte(bitBuffer&0xff)) } return }
Итоговый файл сохраняем, как изображение PNG. И теперь, когда все готово для реализации основной идеи, давайте приступим к улучшениям и будем отталкиваться от возникающих в процессе эксплуатации потребностей.
AES шифрование

Агент Маша хочет передать агенту Вите сообщение. Она договаривается со своим другом (который живет в другой стране) о том, что в определенный день и определенный час выложит в сети фотографию внутри которой скрыто послание. Но есть проблема: агенты, ее прослушивающие, узнают об этом ходе и получают файл, анализируют его и восстанавливают исходное сообщение. Почему бы ей не зашифровать сообщение?
Давайте поможем им и добавим немного симметричного шифрования AES. В GO шифрование этим алгоритмом реализуется пакетом crypto/aes. Достаточно просто создать шифрующий блок, вызвав функцию aes.NewCipher(key). И теперь мы можем нарезать данные блоками и применить к каждому из них метод Encrypt.
Как видите, полезная нагрузка шифруется поблочно и, если мы попытаемся зашифровать фотографию, то может выйти так, что очертания на шифрованной картинке все-таки останутся, хоть и потеряются цвета. Так что для увеличения криптостойкости мы применим режим распространяющегося сцепления блоков — это когда первый блок шифруется нашим ключом, а в каждый последующий подмешивается шифротекст, полученный на предыдущем шаге. О синхронных шифрах AES можно почитать тут.
О ключах Маша и Витя должны договориться заранее: при личной встрече им необходимо обменяться несколькими ключами, один основной и несколько резервных на случай, если основной будет скомпрометирован. Очень важно, чтобы эти ключи не просачивались в публичную сеть!
Под спойлером шифрующая функция.
func EncryptDataAES(data []byte, key []byte) ([]byte, error) { aesEncoder, err := newAES(key) if err != nil { return nil, err } chainSize := aesEncoder.blockSize() // первым блоком будет блок информации о размере исходного сообщения // т.к. мы собираемся выровнять его по chainSize infoBlock := newSizeInfoChunk(len(data), chainSize) data = alignDataBy(data, chainSize) encrypted := make([]byte, len(infoBlock)+len(data)) // шифруем блок с информацией if err = aesEncoder.encode(encrypted[0:len(infoBlock)], infoBlock); err != nil { return nil, err } // шифруем все сообщение for n := 0; n < len(data)/chainSize; n++ { var dst, src = encrypted[(n+1)*chainSize : (n+2)*chainSize], data[n*chainSize : (n+1)*chainSize] if err = aesEncoder.encode(dst, src); err != nil { return nil, err } } return encrypted, nil } type encoder struct { cipher cipher.Block initVc []byte } func newAES(key []byte) (*encoder, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } enc := encoder{ cipher: block, initVc: make([]byte, block.BlockSize()), } return &enc, nil } func (e *encoder) blockSize() int { . . . func (e *encoder) encode(dst, src []byte) (err error) { . . . func (e *encoder) decode(dst, src []byte) (err error) { . . .
Длина ключа равна длине сообщения

Шифр AES — дело хорошее, но говорят, что самый криптостойкий ключ — это ключ равный по длине исходному сообщению. Прослушивающие агенты могут проанализировать достаточно сообщений, чтобы по первому блоку (в который не подмешивается шифротекст) получить ключ.
Маша и Витя не дураки, они используют двойное шифрование: сразу после того, как сообщение зашифровано алгоритмом AES, они применяют простой XOR с ключом равным по длине исходному сообщению. Мы добавляем эту возможность в наше приложение: ключом будет какая-нибудь другая фотография (или любой файл), который тоже можно передать по публичным каналам. Дата и время следующей передачи такого ключа Маша прикрепляет к каждому сообщению. Очень важно для каждого следующего сообщения применять новый ключ. Если прослушивающие агенты не смогли расшифровать сообщение — каждый следующий раз ключ будет меняться, что затрудняет криптоанализ.
Теперь немного об энтропии. Ежу понятно, что в качестве ключа необходимо использовать “случайные данные”, а в нашей логике описано использование изображения, которое может содержать невысокую энтропию. Ничего страшного, мы добавим в алгоритм нашей программы функцию moreStrongKey(key []byte) []byte которая “замесит” биты в файле так, что они станут похожи на случайные. Функция скалярная и при выполнении с одним и тем же файлом дает один и тот же массив перемешанных данных.
Под спойлером функция шифровки/расшифровки.
func EncryptDecryptData(data []byte, key []byte) error { key = moreStrongKey(key) if len(key) < len(data) { return ErrKeyShortedThanData } for i, d := range data { data[i] = d ^ key[i] } return nil } func moreStrongKey(key []byte) []byte { const ( salt = 170 bufLen = 16 ) var ( buf [bufLen * 2]byte unf int out []byte ) flush := func() { unf = 0 h := md5.Sum(buf[:]) out = append(out, h[:]...) } for i, b := range key { r := key[len(key)-i-1] p := i % bufLen buf[p*2] = b buf[p*2+1] = b ^ r ^ salt unf++ if (i+1)%bufLen == 0 { flush() } } if unf > 0 { flush() } return out }
Маша и Витя шифруют свои сообщения двумя каскадами меняя XOR-ключ с каждым новым сообщением. И мы почти уверены, что никто их не подслушивает. Но есть еще один случай, когда всех примененных хитростей будет недостаточно.
Цифровая подпись

Теперь о плохом: Машу накрыли. AES ключи оказались в руках злоумышленников и с помощью них удалось расшифровать какие-то сообщения! Но в последний момент ей удалось сбежать и теперь она должна сообщить Вите, что это провал.
“Не доверяй никому” пишет она в последнем сообщении и выкладывает его в условленное время. Но вот незадача. Теперь злоумышленники, воспользовавшись ключами, могут выложить свое сообщение и полностью захватить их канал связи. Как ей доказать, что ее сообщение истинное?
Давайте добавим в наш код возможность ставить цифровую подпись на сообщение с помощью асинхронных ключей? Вот тут можно немного узнать о цифровых подписях. Мы используем ключи RSA и научим наше приложение генерировать такие ключи, хотя подойдут и свои.
Очень хорошо, что Маша держит ключи шифрования отдельно от ключей для подписи в своем секретном месте. В асинхронных ключах есть одно очень положительное свойство: публичный ключ, с помощью которого цифровая подпись проверяется, можно передавать по открытым каналам связи, а сама подпись выполняется с помощью приватного ключа, который невозможно вычислить (за разумный срок), имея на руках публичный ключ. Несколько сообщений назад Маша передала Вите новый публичный ключ для проверки сообщений и теперь, даже если это сообщение расшифруют, все, что можно будет сделать с этим ключом — это проверить достоверность сообщения.
Маша подписывает новое сообщение в котором говорит о провале и подписывает его приватным ключом, теперь она уверена, что ее сообщению Витя будет доверять и злоумышленникам никак не удастся скомпрометировать их канал связи.
Под спойлером функции цифровой подписи и ее проверки.
func SignData(data []byte, privateKey string) ([]byte, error) { private, err := getPrivateKey(privateKey) if err != nil { return nil, fmt.Errorf("cannot parse private key: %w", err) } sign, err := rsa.SignPSS(rand.Reader, private, signHashFn, hashData(data), nil) if err != nil { return nil, fmt.Errorf("error while signing: %w", err) } return sign, nil } func SignVerify(data, sign []byte, publicKey string) error { public, err := getPublicKey(publicKey) if err != nil { return fmt.Errorf("cannot parse public key `%s`: %w", publicKey, err) } err = rsa.VerifyPSS(public, signHashFn, hashData(data), sign, nil) if err != nil { return fmt.Errorf("error while sign checking: %w", err) } return nil }
Заключение
В заключении хочу поблагодарить читателя за то, что он помог Маше и Вите установить секретный канал связи в публичных сетях. Но как вы понимаете, это просто маленькая игра. В действительности все гораздо сложнее и я тут много о чем умолчал. Например, если Маша прячет секретные данные в картинке PNG, то это палево. Ну согласитесь, если вы выкладываете фотографии в сети, то это наверняка JPEG?
Однако такого приложения явно хватит, чтобы поиграть со своим другом (или подругой) в секретных агентов и просто ощутить, как можно защищать каналы связи в публичных сетях.
Как я уже сказал выше, код можете почитать на моем гитхабе. В каталоге crypt найдете все три описанных алгоритма и еще две хеш-функции — одна для усиления XOR-ключа, а другая для формирования отпечатка для цифровой подписи.
В папке demo найдете мою PNG фотографию с зашифрованным внутри посланием, необходимые для расшифровки ключи прошиты в decode.sh файле, который позволит получить расшифрованное послание и проверить его цифровую подпись.
В папке carrier лежит код, который позволяет разбить сообщение на биты и встроить их в PNG картинку. А разбивать данные на маленькие кусочки битов, которые легко встраиваются в RGB вектор, нам позволяет код, который лежит в папке nibbles. Так что тут все очень интересно.
А те, у кого нет компилятора GO или кому лень, можете попробовать мой онлайн сервис стеганографии, о нем я тоже уже сказал в первой части этой статьи. Ну, а мы с Машей и Витей прощаемся с вами, надеюсь, не надолго.
