Эй, пс, Gopher! Хочешь немного секретности? Стеганография для Маши и Вити

от автора

Прочитал я как то раз статью о том, как спрятать фото в другом фото, вот ее перевод. Статья довольно короткая и задумка описанная в ней никакой новизны не несет. И не своей простотой привлекла меня описанная идея, а довольно широким кругом возможных расширений.

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

Реализация выполнена на языке GO и доступна на моем гитхабе. Там же найдете руководство по эксплуатации с примерами запуска с разными ключами. А в папке demo рабочая демка (правда приложение все равно придется сначала скомпилировать). Но, если компилировать лень, то не беда, для ленивых я опубликовал приложение, как веб-сервис. На этой страничке вы можете попробовать спрятать шифрованное послание в своем PNG и расшифровать его.

Итак, теперь об улучшениях исходной идеи. Для того, чтобы было интереснее читать, я буду приводить примеры использования этого приложения двумя секретными агентами, находящимися в разных странах. Они пытаются передать друг-другу защищенные сообщения по открытым каналам связи. А мы им в этом поможем. Ну а тем, кто нетерпелив, я сразу скажу, что плюшки вот такие:

  1. AES шифрование;
  2. XOR ключом длиной равной длине сообщения;
  3. Цифровая подпись.

Нибблы

Но сначала давайте разберемся, как отцепить от исходного массива байтов по одному биту и спрятать в каждом байте каждого 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 или кому лень, можете попробовать мой онлайн сервис стеганографии, о нем я тоже уже сказал в первой части этой статьи. Ну, а мы с Машей и Витей прощаемся с вами, надеюсь, не надолго.


ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/672128/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *