Всем привет! Меня зовут Егор, я фронтенд-разработчик в Райффайзенбанке. В этой статье я хочу показать, как благодаря типизированным массивам мы можем взаимодействовать с бинарными данными в браузере. В качестве примера мы напишем приложение для шифрования текста внутрь изображения и посмотрим, как работают типизированные массивы.
Введение
Ни для кого не секрет, что компьютер обрабатывает данные в бинарном формате, где каждый бит указывает на наличие или отсутствие электрического сигнала. Для того, чтобы отобразить текстовые данные, мы передаем последовательность битов компьютеру, а он с помощью специальных утилит переводит ее в понятный человеку символ.
При обработке графических данных компьютеру тоже поступают сигналы, где последовательность из трех байт (или четырех, при наличии альфа-канала) является значением цвета RGB (RGBA). При разработке приложения мы будем взаимодействовать с BMP-форматом, но аналогичное возможно и с любым другим форматом файлов.
Для начала определим, что должно уметь наше приложение:
Кодировать текстовое сообщение в файл. При этом вес, структура и визуальное отображение файла не должны измениться.
Расшифровывать текстовое сообщение.
Демонстрация работы приложения
Работа с изображением
Для реализации приложения нам понадобится описание BMP-формата, которое достаточно подробно описано в «Википедии».
Описание заголовка BITMAPCOREHEADER
Смещение |
Размер (байты) |
Описание |
0 |
2 |
Отметка для отличия формата от других (сигнатура формата). Возможные значения: BM, BA, CI, CP, IC, PT |
2 |
4 |
Размер файла в байтах |
6 |
2 |
Зарезервированы. Должны содержать ноль |
8 |
2 |
|
10 |
4 |
Начальный адрес байта, в котором могут быть найдены данные растрового изображения (bitmap data) |
Что мы узнаем из описания этого формата?
По соображениям совместимости большинство приложений используют старые заголовки DIB для сохранения файлов. Поскольку OS / 2 больше не поддерживается после Windows 2000, на данный момент распространенным форматом Windows является заголовок BITMAPINFOHEADER
Поэтому во внимание берем только заголовок BITMAPINFOHEADER и сигнатуру BM.
Описание заголовка BITMAPINFOHEADER
Смещение |
Размер (байты) |
Описание |
14 |
4 |
Размер заголовка |
18 |
4 |
Ширина растрового изображения в пикселях (целое число со знаком) |
22 |
4 |
Высота растрового изображения в пикселях (целое число со знаком) |
26 |
2 |
Количество цветовых плоскостей. В BMP допустимо только значение 1 |
28 |
2 |
Количество бит на пиксель |
30 |
4 |
Используемый метод сжатия |
34 |
4 |
Размер пиксельных данных в байтах |
38 |
4 |
Количество пикселей на метр по горизонтали |
42 |
4 |
Количество пикселей на метр по вертикали |
46 |
4 |
Количество цветов в цветовой палитре |
50 |
4 |
Количество ячеек от начала цветовой палитры до последней используемой (включая её саму). |
Для начала создаем класс, отвечающий за работу с ArrayBuffer, полученным из изображения. После этого проверяем соответствие BMP-формату:
class BmpParser {
#BMP_HEADER_FIELD = 'BM'
#view
#decoder
constructor(buffer) {
this.#view = new DataView(buffer)
this.#decoder = new TextDecoder()
this.#checkHeaderField()
}
#checkHeaderField() {
if (this.#decoder.decode(new Uint8Array(this.#view.buffer, 0, 2)) !== this.#BMP_HEADER_FIELD) {
throw new Error('Ожидается .bmp файл!')
}
}
}
Теперь нам необходимо узнать смещение, где может быть найден массив пикселей (bitmap data). В таблице указан размер в 4 байта, поэтому мы используем маску Uint32Array, так как 1 байт равен 8 бит:
class BmpParser {
// ...
get offsetBits() {
return this.#view.getUint32(10, true)
}
// ...
}
Чтобы проверить, что текстовое сообщение не превышает размер пиксельных данных, мы будем использовать размер bitmap data. Его можно получить из заголовка BITMAPINFOHEADER:
class BmpParser {
// ...
get bitmapDataSize() {
return this.#view.getUint32(34, true)
}
// ...
}
Это все данные, которые необходимо получить из изображения.
Шифрование
Алгоритм для шифрования сообщения:
Конверуем текстовое сообщение в бинарный код. Возможные значения: «1», «0», «,».
-
Поочередно рассматриваем каждый символ:
Если этот символ имеет значение 0 или 1, оставляем без изменений.
Если этот символ имеет значение «,» — устанавливаем ему значение 2. Это будет свидетельствовать, что предыдущую последовательность нулей и единиц можно собрать в символ из зашифрованного сообщения.
Записать полученное число в bitmap data.
Добавить точку выхода, в нашем случае — значение «3».
Приступим к реализации.
Создаем класс, отвечающий за шифрование текстового сообщения внутрь изображения, добавив метод конвертации текста в бинарный код:
class Encryptor {
// Текущее смещение битов в ArrayBuffer
#offset = 0
#view
#encryptionText
#bmpParser
constructor(buffer, encryptionText) {
this.#view = new DataView(buffer)
this.#encryptionText = encryptionText
this.#bmpParser = new BmpParser(buffer)
}
// Конвертирует текст в бинарный код
// Тест => ["10000100010", "10000110101", "10001000001", "10001000010"]
encode(value) {
return value.split('').map(char => char.charCodeAt(0).toString(2))
}
}
Добавим проверку, что длина сообщения не превышает размер изображения в байтах:
class Encryptor {
// ...
#checkPhraseLength(binaryLength) {
if (binaryLength >= this.#bmpParser.bitmapDataSize) {
throw new Error('Фраза слишком велика для данного файла!')
}
}
// ...
}
Конфигурируем константы, которые потребуются для расшифровки:
export const MAX_HEXADECIMAL_VALUE = 0xFF
export const POSSIBLE_DIFFERENCE = {
EXIT_POINT: 3,
SEPARATOR: 2,
BINARY_ONE: 1,
BINARY_ZERO: 0,
}
Реализуем сам механизм шифрования. При обходе bitmap data мы будем использовать маску Uint8Array, так как каждый символ здесь равен одному байту:
class Encryptor {
// ...
#updateUint8(char) {
// Текущий элемент bitmap data
const currentValue = +this.#view.getUint8(this.#offset)
// Установка значения в зависимости от символа
const binaryChar = char === ',' ? POSSIBLE_DIFFERENCE.SEPARATOR : +char
// Проверка на возможность добавления
// Если текущее значение bitmap data после увеличения на 3
// больше верхней границы (255) - выполняем вычитание
if (currentValue >= MAX_HEXADECIMAL_VALUE - POSSIBLE_DIFFERENCE.EXIT_POINT) {
return currentValue - binaryChar
}
return currentValue + binaryChar
}
encrypt() {
// Получение начального положения bitmap data
this.#offset = this.#bmpParser.offsetBits
// Конвертация текстового сообщения в бинарный код
// и приведение полученного результата к виду ['1', '0', '1', '0', ',', ...]
const binaryChars = this.encode(this.#encryptionText).join().split('')
// Проверка длины сообщения
this.#checkPhraseLength(binaryChars.length)
binaryChars.forEach(char => {
this.#view.setUint8(this.#offset, this.#updateUint8(char))
this.#offset++
})
// Добавляем точку выхода
this.#view.setUint8(this.#offset, this.#updateUint8(POSSIBLE_DIFFERENCE.EXIT_POINT))
return this.#view
}
// ...
}
Зашифрованное изображение получится визуально неотличимо от оригинала. Это произойдет из-за того, что изменение значений в bitmap data происходит максимум на 3 пункта, а вес и структура файла при этом остаются неизменными.
Расшифровка
Для расшифровки потребуется оригинальное изображение — оно будет являться ключом, а также изображение с закодированным сообщением.
Алгоритм для расшифровки сообщения:
Нам потребуются две переменные для хранения бинарной последовательности символов и результата.
Запускаем цикл и, начиная с первого байта bitmap data, находим разность по модулю между значением ключа и закодированного изображения
Если разность равна 0 или 1 — добавляем значение в строку с бинарной последовательность.
Если разность равна 2 — очищаем строку с бинарной последовательностью, а ее значение конвертируем в текст и добавляем в результирующую строку.
Если разность равна 3 — выполняем действия из п. 2 и останавливаем цикл.
Рассмотрим алгоритм на примере следующей разности: [1, 0, 1, 2, 1, 0, 1, 0, 3]
Разность |
Значение бинарной последовательности |
Значение результирующей строки |
1 |
1 |
|
0 |
10 |
|
1 |
101 |
|
2 |
e |
|
1 |
1 |
e |
0 |
10 |
e |
1 |
101 |
e |
0 |
1010 |
e |
3 |
eϲ |
Приступим к реализации:
export class Decipher {
#offset = 0
#encryptedView
#viewKey
#bmpParserEncrypted
#bmpParserKey
constructor(encryptedBuffer, bufferKey) {
this.#encryptedView = new DataView(encryptedBuffer)
this.#viewKey = new DataView(bufferKey)
this.#bmpParserEncrypted = new BmpParser(encryptedBuffer)
this.#bmpParserKey = new BmpParser(bufferKey)
}
// Конвертация бинарного кода в текст
decode(value) {
return String.fromCharCode(parseInt(value, 2))
}
decrypt() {
this.#offset = this.#bmpParserKey.offsetBits
let binaryChar = ''
let string = ''
while (true) {
// Получение разности
const encryptedByte = Math.abs(this.#encryptedView.getUint8(this.#offset) - this.#viewKey.getUint8(this.#offset))
switch (encryptedByte) {
case POSSIBLE_DIFFERENCE.EXIT_POINT:
string += this.decode(binaryChar)
return string
case POSSIBLE_DIFFERENCE.SEPARATOR:
string += this.decode(binaryChar)
binaryChar = ''
break
case POSSIBLE_DIFFERENCE.BINARY_ONE:
case POSSIBLE_DIFFERENCE.BINARY_ZERO:
binaryChar += encryptedByte
break
default:
throw new Error('Недопустимое значение!')
}
this.#offset++
}
}
}
Заключение
Это небольшое приложение позволяет показать, как работать с бинарными данными. При этом возможностей у типизированных массивов при работе с файлами куда больше: можно добавить водяной знак в изображение или видео, рассчитать размер изображения перед выводом на экран или прочитать файлы из zip-архива.