Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Прочитал я как то раз статью о том, как спрятать фото в другом фото, вот ее перевод. Статья довольно короткая и задумка описанная в ней никакой новизны не несет. И не своей простотой привлекла меня описанная идея, а довольно широким кругом возможных расширений.
Коротко излагаю суть идеи: в одно фото (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 или кому лень, можете попробовать мой онлайн сервис стеганографии, о нем я тоже уже сказал в первой части этой статьи. Ну, а мы с Машей и Витей прощаемся с вами, надеюсь, не надолго.