Традиционные способы передачи данных по протоколу HTTP имеют существенный недостаток – это транспорт исключительно для текстовой информации, о чём и свидетельствует его название "Hyper-Text Transfer Protocol" (протокол передачи гипер-текста). Он находится на самом верхнем уровне(7) модели OSI, а потому пользователям в нашем лице приходится взаимодействовать именно с ним. Это создаёт определённые трудности, когда нужно передать по e-mail не текстовые, а бинарные данные ..например картинки, аудио/видео-файлы, или зипнутые архивы. Вопрос встал остро с приходом графических интерфейсов, и чтобы организовать обмен двоичной информацией по текстовым каналам, было решено кодировать BIN в читаемые символы ASCII. Решать проблемы именно такого характера выпало на долю алгоритма Base64 – рассмотрим его реализацию.
Оглавление:
1. MIME – общие сведения;
2. Принцип ручного кодирования Base64;
3. Алгоритм декодирования;
4. Родственный алгоритм Base32;
5. Эпилог.
----------------------------------------------------
1. Общие сведения
Перед тем-как положить Base64 на операционный стол, не помешает рассмотреть область её применения. Этот раздел можно смело пропустить без ущерба понимания основ кодирования информации, однако новичкам в данной области настоятельно рекомендуется к прочтению.
В общем случае, кодировка Base64 живёт внутри сетевого трафика, частью которого является MIME – Multipurpose Internet Mail Extension, или просто зоопарк расширений почты. Стандарт MIME описывает спецификация
Идея передачи BIN по текстовым каналам с помощью MIME применяется не только для электронных вложений. Например в браузерах, стандарт используется для определения типа контента, что позволяет ему открывать в своём окне файлы *.doc/pdf/mp3 и т.д, при помощи соответствующих программ. Названия типов указываются в разделе "Content-Type" заголовка данных, в формате "тип/подтип". Среди
На самом деле их огромное кол-во, а система хранит поддерживаемый ею лист в разделе реестра [HKEY_CLASSES_ROOT]. Когда браузер встречает в контексте принятых данных незнакомый ему MIME-тип, то в надежде найти его он лезет именно в этот раздел и сопоставив значение ключа "Content-Type" с расширением (в левом окне на рис.ниже), пытается привязать к нему соответствующий софт. Если найденное приложение удовлетворяет системе безопасности браузера, он запустит контент прямо в своей тушке:
Чтобы пролить свет на вышесказанное, я провёл такой эксперимент..
С одной своей почты на яндексе, отправил себе-же письмо на mail.ru со-вложенной картинкой(png) и архивом(zip). Теперь зайдя на мэйл, сохранил письмо на диск в формате *.eml, после чего открыл его в обычном текстовом редакторе. В результате, получил все технические сведения об отправителе, но в данном случае мне интересны лишь MIME-заголовок и тело сообщения в том виде, в котором оформил его почтовый сервер яндекса:
MIME-заголовок начинается со-строки с указанием версии стандарта(1.0).
Строка "X-Mailer" сообщает, каким софтом сервер создал и отправил письмо – здесь это "Yamail ver.5.0" (видимо почтовый клиент "Yahoo Mail"). Далее указывается тип письма "multipart/mixed", что характеризует его как "письмо со-смешанными данными". Аргумент последней строки "boundary" информирует, какую последовательность символов нужно воспринимать в качестве "разделителя частей" одного письма. Сервер генерит его от-балды, а потому в следующий раз он будет уже иным.
На этом, интересующий нас контент в MIME-заголовке заканчивается и дальше идут непосредственно части электронного письма:
Как видим, перед нами текстовая часть послания типа "text/plain" – обычный текст. О том, что это действительно отдельный фрагмент содержимого, свидетельствует разделитель. Если обратить внимание, он дополняется вначале двумя дефисами, а не в точности как предписывал "boundary" в MIME-заголовке.
Поскольку в тексте письма было всего одно слово кириллицей "Привет", то серверу пришлось перекодировать его в UTF-8 (Unicode Trans-Format), алфавит которого требует как-минимум все/256 символов расширенной таблицы ASCII. Агент оповещает нас об этом строкой "Content-Transfer-Encoding: 8bit", т.е. 8-битная кодировка, т.к. помимо латиницы, UTF использует и символы кириллицы из верхней половины таблицы с
Чтобы продемонстрировать разницу, в повторном письме я отправил текст латиницей, буквально все печатные символы которой лежат уже в нижней половине таблицы
Теперь, дадим "верблюду по-почкам" и двинемся дальше..
Здесь мы натыкаемся на следующие две части письма, по заголовкам которых можно сделать вывод, что это вложенные архив и картинка. Как и в предыдущем случае, обе части начинаются с разделителей, с указанием типа их содержимого "Content-Type: application/zip", а в нижнем фрагменте "image/png":
Вот мы и подобрались к кодированию бинарной информации в текст..
Поле "Encoding: base64" прямым текстом сообщает, что содержимое находится под алгоритмом Base64, т.к. передача его в явном (двоичном) виде не поддерживается текущим почтовым сервером. Есть и другой важный момент – спека MIME требует, чтобы в каждой (не считая последней) строке закодированных данных было не более 76-символов, после которых должен следовать перенос в виде пары
Разбор заголовков сырых писем весьма увлекательное занятие. Например учитывая, что каждый из серверов в HTTP-заголовках оставляет свой айпи, можно вычислять по ним спамеров. Или если на Ваш адрес пришла корреспонденция из какой-нибудь корпоративной сети, можно узнать расположение отправителя, вплоть до его кабинета. В общем есть чем позабавиться..
2. Алгоритм кодирования "Base64"
Чтобы из бинарного потока сделать печатную строку, нужно 8-битный байт превратить в 6-битный индекс, который будет указывать на конкретный элемент в "таблице-символов" Base64. Причём алгоритм обязательно должен быть обратимым, чтобы на приёмном узле можно было восстановить переданную информацию. Ясно, что решить проблему без потерь никак не получится, поэтому в Base64 имеем соотношение 4/3. То-есть из поступающего конвейера берём пачками по 3-двоичных байта, а на выход отправляем по 4 символа. В результате получаем лишний вес ~33%.
Забегая вперёд скажу, что это неплохой показатель. К примеру у алгоритма Base32 расход вообще 60% с соотношением 8/5, а у Base16 – все 100%, когда каждый бинарный байт кодируется двумя печатными символами. Именно поэтому повсеместно используют исключительно 64-символьный алфавит, а не 32 или 16. В таблицы ниже представлены наборы символов, которые разрешено использовать в соответствующих алгоритмах кодирования:
Ниже представлена схема кодирования Base64..
Суть в том, что рассматривая каждые
Но размер регистров у процессора минимум 8-бит, и в них физически нельзя запихать(6). Поэтому, расширим каждый из индексов до 8-бит, просто добавив к ним старшими два нуля – они выделены на рисунке красным, и не будут играть никакой роли, присутствуя лишь в качестве "массовки". На практике, достаточно очистить 8-битный регистр и задвинуть в него 6-бит из источника. Таким способом нам удалось получить четыре 6-битных байта (звучит непривычно), и это будут порядковые номера символов внутри алфавита:
Как видим, алгоритм довольно прост, зато решает вполне серьёзные проблемы.
При его реализации мы должны учитывать следующие моменты:
Система команд современных процессоров поражает своим разнообразием. В наборе есть как простые, так и составные команды – по сути этим и отличаются процессоры архитектуры CISC (Complex Instruction Set Computing) от процессоров с одиночными инструкциями RISC. Не смотря на то, что большинство из этих команд специфичны и нужны далеко не всегда, для каждого шага кодирования Base64 всё-же нашлась отдельная инструкция. В результате, можно реализовать на ассемблере такую процедуру, которая будет летать на реактивной скорости, и не нужны никакие библиотеки высокого уровня.
Код ниже демонстрирует, как в 28-ми байтах запрограммировать полноценный кодер Base64. Такой размер без зазрения совести можно таскать с собой, задействуя его в токсических зонах по мере необходимости.
В демонстрационном примере ниже напишем программу, которая запросит у юзера имя файла, и закодировав его содержимое сохранит на диск. Всю основную работу будет делать вышеприведённый участок кода, а остальное – вспомогательные операции типа: открыть файл, вычислить его размер, сделать кратным трём, выделить память и прочая лабуда. Каждая строка прокомментирована, поэтому не буду повторяться:
Алгоритму Base64 без разницы, какого типа данные поступают на вход – хоть двоичные, хоть текст. В этом примере нет переноса строки на каждом 76-ом символе, поскольку отформатировать текст можно и после кодирования. Хотя без проблем можно было и дополнить код этим функционалом. Тут всё зависит от задачи – если собираемся программно передавать бинарный файл по почте, то нужно вставить разрывы строк, т.к. декодер удалённого браузера рассчитывает на это (см.вводную часть). В данном случае я просто перекодировал текстовый файл, чтобы спрятать его содержимое от глаз любопытных юзверов. Учитывая, что Base64 обратимый, можно применять и рекурсивное кодирование, например одни данные кодировать 2-3 раза подряд. Только потом и разворачивать придётся столько-же раз.
3. Обратное преобразование – декодер "Base64"
Теперь поменяем вектор на противоположный и рассмотрим, как можно восстановить закодированные алгоритмом Base64 данные. В этом деле, основную роль играет правильно оформленная "таблица-преобразований". Если при кодировании мы делали ставку на алфавит из 64-х символов латиницы, то теперь эти символы сами будут выступать в роли индексов в таблице декодера. Соответственно размер данной таблицы должен быть 128-байт, и значения в ней должны отражать порядковый номер текущего символа в "алфавите кодирования".
На рисунке ниже я представил содержимое этой таблицы, где значения
Чтобы программно реализовать декодер, нужно включить реверс и двигаться назад. Для начала, разбиваем строку на блоки по 4 символа, и используя каждый из них в качестве индекса, вытягиваем значения из "таблицы-декодера". Затем, отсекаем у каждого значения старшие 2-бита "массовки" и получаем единую группу из 24-бит. На заключительном этапе, необходимо раскроить 24 на три части по 8-бит, которые и отправляем на выход. Вот демо-пример, который автоматизирует этот процесс.
Обратите внимание на поведение инструкции
4. Родственный алгоритм Base32
Под занавес рассмотрим принцип кодирования Base32. Здесь всё аналогично, только алфавит включает в себя 32 печатных символа (без символов нижнего регистра латиницы), и соответственно для выбора одного из них достаточно 5-битного индекса, поскольку
5. Эпилог
Для работы с кодировкой Base64, на вооружении Win имеются специально натренированные функции – это CryptBinaryToString(), которая выступает в роли кодера, и её кузен для обратного преобразования CryptStringToBinary(). Они живут в системной библиотеке crypt32.dll и в силу своего веса уже давно понижены в звании программистами. Приведённый здесь пример хоть и исповедует идентичные концепции, но детали реализации совершенно разные. Я пробовал трейсить эту CryptBinaryToString() в отладчике, так он повёл меня за собой в непроходимые джунгли кода, и даже после 100-шагов так и не дождавшись полезной нагрузки, я решил прекратить этот балаган. Системе конечно-же видней, как правильно нужно делать, но в большинстве случаях для нас это не приемлемо и проще написать свою короткую процедуру, что я и попытался сделать в этой статье.
Как обычно, в скрепке лежат два представленных здесь исполняемых файла – кодер и декодер Base64.
Всех с наступающим 2021-годом, и до встречи теперь уже в новом году!
Оглавление:
1. MIME – общие сведения;
2. Принцип ручного кодирования Base64;
3. Алгоритм декодирования;
4. Родственный алгоритм Base32;
5. Эпилог.
----------------------------------------------------
1. Общие сведения
Перед тем-как положить Base64 на операционный стол, не помешает рассмотреть область её применения. Этот раздел можно смело пропустить без ущерба понимания основ кодирования информации, однако новичкам в данной области настоятельно рекомендуется к прочтению.
В общем случае, кодировка Base64 живёт внутри сетевого трафика, частью которого является MIME – Multipurpose Internet Mail Extension, или просто зоопарк расширений почты. Стандарт MIME описывает спецификация
Ссылка скрыта от гостей
(предшественником является ARPA, rfc-822), где в красках расписываются способы передачи по сети файлов различного типа: изображений, музыки, текста и т.д. Фактически, благодаря именно MIME мы имеем возможность пересылать по почте не только текст, но и другие виды данных.Идея передачи BIN по текстовым каналам с помощью MIME применяется не только для электронных вложений. Например в браузерах, стандарт используется для определения типа контента, что позволяет ему открывать в своём окне файлы *.doc/pdf/mp3 и т.д, при помощи соответствующих программ. Названия типов указываются в разделе "Content-Type" заголовка данных, в формате "тип/подтип". Среди
Ссылка скрыта от гостей
MIME-типов стоит отметить следующие:• text/plain - обычный текст (тип по умолчанию);
• text/html - гипертекст;
• image/jpg - изображение;
• audio/mp3 - аудиозапись;
• video/mp4 - видеофайл;
• application/zip - ZIP-архив;
• multipart/mixed - текст с разнообразным вложением;
• message/rfc822 – сообщение в сообщении.
На самом деле их огромное кол-во, а система хранит поддерживаемый ею лист в разделе реестра [HKEY_CLASSES_ROOT]. Когда браузер встречает в контексте принятых данных незнакомый ему MIME-тип, то в надежде найти его он лезет именно в этот раздел и сопоставив значение ключа "Content-Type" с расширением (в левом окне на рис.ниже), пытается привязать к нему соответствующий софт. Если найденное приложение удовлетворяет системе безопасности браузера, он запустит контент прямо в своей тушке:
Чтобы пролить свет на вышесказанное, я провёл такой эксперимент..
С одной своей почты на яндексе, отправил себе-же письмо на mail.ru со-вложенной картинкой(png) и архивом(zip). Теперь зайдя на мэйл, сохранил письмо на диск в формате *.eml, после чего открыл его в обычном текстовом редакторе. В результате, получил все технические сведения об отправителе, но в данном случае мне интересны лишь MIME-заголовок и тело сообщения в том виде, в котором оформил его почтовый сервер яндекса:
Код:
;//------------8<---------- Вырезано ----------------8<-------------
MIME-Version: 1.0
X-Mailer: Yamail [ http://yandex.ru ] 5.0
Message-Id: <952291608987613@sas1-941a8864505d.qloud-c.yandex.net>
Content-Type: multipart/mixed;
boundary="----==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net"
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Transfer-Encoding: 8bit
Content-Type: text/plain; charset=utf-8
Привет
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Type: application/zip; name="123.zip"
Content-Disposition: attachment; filename="123.zip"
Content-Transfer-Encoding: base64
UEsDBBQAAgAIAFqVeVATGlqDwAMAAL8IAAAJAAAAQ1JDMzIuYXNtrVVda9tIFH0P+D/cxy7ITdO+
BJdC2jRhH1K6ZHehEEqRJXfdxbGM7TTp3wu7j2Up2x8wVj3xWNKM9VHbLQrrvTMjybIr+lSBGI1m
aqCykWIctgLH8NLlUn+aS5GW0PutIc5aXbtX2/kfUEsBAhQAFAACAAgAWpV5UBMaWoPAAwAAvwgA
AAkAAAAAAAAAAAAgAAAAAAAAAENSQzMyLmFzbVBLBQYAAAAAAQABADcAAADnAwAAAAA=
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Type: image/png; name="lilu.png"
Content-Disposition: attachment; filename="lilu.png"
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAASwAAADsCAMAAAD5PGtHAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ
bWFnZVJlYWR5ccllPAAAAMBQTFRFzGkxzqyX1tTR14ldso511Lan0HZHtXBJuGYzs0sSbSIL+fv6
c5wmi0WXSySLXkPbPTc2EQ/e8mFPj4hGtgysISRLMaF1pjUXIu/p+Ma2f2MunAgJK3Y/B+sckNRz
V1hqtIybxza1/jxrNNDAwmZVCXx5UmpD0HDBYEe4lKY6wdqg1jSx7I4pEvh8LAGjCZdJU9uo0Br3
030ZBE8zyaqEQcUFMT3rYFkvS8/zTsui0vp/AgwAbOvpPweEKAAAAAAASUVORK5CYII=
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net--
Код:
MIME-Version: 1.0
X-Mailer: Yamail [ http://yandex.ru ] 5.0
Content-Type: multipart/mixed;
boundary="----==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net"
MIME-заголовок начинается со-строки с указанием версии стандарта(1.0).
Строка "X-Mailer" сообщает, каким софтом сервер создал и отправил письмо – здесь это "Yamail ver.5.0" (видимо почтовый клиент "Yahoo Mail"). Далее указывается тип письма "multipart/mixed", что характеризует его как "письмо со-смешанными данными". Аргумент последней строки "boundary" информирует, какую последовательность символов нужно воспринимать в качестве "разделителя частей" одного письма. Сервер генерит его от-балды, а потому в следующий раз он будет уже иным.
На этом, интересующий нас контент в MIME-заголовке заканчивается и дальше идут непосредственно части электронного письма:
Код:
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit
Привет
Как видим, перед нами текстовая часть послания типа "text/plain" – обычный текст. О том, что это действительно отдельный фрагмент содержимого, свидетельствует разделитель. Если обратить внимание, он дополняется вначале двумя дефисами, а не в точности как предписывал "boundary" в MIME-заголовке.
Поскольку в тексте письма было всего одно слово кириллицей "Привет", то серверу пришлось перекодировать его в UTF-8 (Unicode Trans-Format), алфавит которого требует как-минимум все/256 символов расширенной таблицы ASCII. Агент оповещает нас об этом строкой "Content-Transfer-Encoding: 8bit", т.е. 8-битная кодировка, т.к. помимо латиницы, UTF использует и символы кириллицы из верхней половины таблицы с
80h
до FFh
.Чтобы продемонстрировать разницу, в повторном письме я отправил текст латиницей, буквально все печатные символы которой лежат уже в нижней половине таблицы
00..7Fh
. Для кодирования этого диапазона достаточно 7-бит, поэтому сервер пропустил текст "в чём мать родила", не забыв отметить сей факт в заголовке части. Таким образом, если в хидере фрагмента мы видим "Encoding: 7bit", значит данные не требуют преобразования на приёмном узле. Это касается не только текста, но и любого содержимого (см.спеку):
Код:
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
Hello World!
Теперь, дадим "верблюду по-почкам" и двинемся дальше..
Здесь мы натыкаемся на следующие две части письма, по заголовкам которых можно сделать вывод, что это вложенные архив и картинка. Как и в предыдущем случае, обе части начинаются с разделителей, с указанием типа их содержимого "Content-Type: application/zip", а в нижнем фрагменте "image/png":
Код:
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Type: application/zip; name="123.zip"
Content-Disposition: attachment; filename="123.zip"
Content-Transfer-Encoding: base64
UEsDBBQAAgAIAFqVeVATGlqDwAMAAL8IAAAJAAAAQ1JDMzIuYXNtrVVda9tIFH0P+D/cxy7ITdO+
BJdC2jRhH1K6ZHehEEqRJXfdxbGM7TTp3wu7j2Up2x8wVj3xWNKM9VHbLQrrvTMjybIr+lSBGI1m
aqCykWIctgLH8NLlUn+aS5GW0PutIc5aXbtX2/kfUEsBAhQAFAACAAgAWpV5UBMaWoPAAwAAvwgA
AAkAAAAAAAAAAAAgAAAAAAAAAENSQzMyLmFzbVBLBQYAAAAAAQABADcAAADnAwAAAAA=
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net
Content-Type: image/png; name="lilu.png"
Content-Disposition: attachment; filename="lilu.png"
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAASwAAADsCAMAAAD5PGtHAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ
bWFnZVJlYWR5ccllPAAAAMBQTFRFzGkxzqyX1tTR14ldso511Lan0HZHtXBJuGYzs0sSbSIL+fv6
c5wmi0WXSySLXkPbPTc2EQ/e8mFPj4hGtgysISRLMaF1pjUXIu/p+Ma2f2MunAgJK3Y/B+sckNRz
V1hqtIybxza1/jxrNNDAwmZVCXx5UmpD0HDBYEe4lKY6wdqg1jSx7I4pEvh8LAGjCZdJU9uo0Br3
030ZBE8zyaqEQcUFMT3rYFkvS8/zTsui0vp/AgwAbOvpPweEKAAAAAAASUVORK5CYII=
------==--bound.94002.sas2-4fe1bb3c0a49.qloud-c.yandex.net--
Вот мы и подобрались к кодированию бинарной информации в текст..
Поле "Encoding: base64" прямым текстом сообщает, что содержимое находится под алгоритмом Base64, т.к. передача его в явном (двоичном) виде не поддерживается текущим почтовым сервером. Есть и другой важный момент – спека MIME требует, чтобы в каждой (не считая последней) строке закодированных данных было не более 76-символов, после которых должен следовать перенос в виде пары
CR/LF = 0D/0Ah
. Обратите внимание, что маркером информационного конца является тот-же разделитель "boundary", но дополненный в хвосте двумя терминальными символами(--).Разбор заголовков сырых писем весьма увлекательное занятие. Например учитывая, что каждый из серверов в HTTP-заголовках оставляет свой айпи, можно вычислять по ним спамеров. Или если на Ваш адрес пришла корреспонденция из какой-нибудь корпоративной сети, можно узнать расположение отправителя, вплоть до его кабинета. В общем есть чем позабавиться..
2. Алгоритм кодирования "Base64"
Чтобы из бинарного потока сделать печатную строку, нужно 8-битный байт превратить в 6-битный индекс, который будет указывать на конкретный элемент в "таблице-символов" Base64. Причём алгоритм обязательно должен быть обратимым, чтобы на приёмном узле можно было восстановить переданную информацию. Ясно, что решить проблему без потерь никак не получится, поэтому в Base64 имеем соотношение 4/3. То-есть из поступающего конвейера берём пачками по 3-двоичных байта, а на выход отправляем по 4 символа. В результате получаем лишний вес ~33%.
Забегая вперёд скажу, что это неплохой показатель. К примеру у алгоритма Base32 расход вообще 60% с соотношением 8/5, а у Base16 – все 100%, когда каждый бинарный байт кодируется двумя печатными символами. Именно поэтому повсеместно используют исключительно 64-символьный алфавит, а не 32 или 16. В таблицы ниже представлены наборы символов, которые разрешено использовать в соответствующих алгоритмах кодирования:
Ниже представлена схема кодирования Base64..
Суть в том, что рассматривая каждые
3х8=24 бит
как одно/целое, на выходе преобразуем их в форму 4х6=24 бит
. Это средний блок рисунка ниже. Поскольку в алфавите Base64 всего 64 символа, то 6-битного индекса как-раз хватит, чтобы выбрать любой из имеющихся в нём символов: 2^6=64.Но размер регистров у процессора минимум 8-бит, и в них физически нельзя запихать(6). Поэтому, расширим каждый из индексов до 8-бит, просто добавив к ним старшими два нуля – они выделены на рисунке красным, и не будут играть никакой роли, присутствуя лишь в качестве "массовки". На практике, достаточно очистить 8-битный регистр и задвинуть в него 6-бит из источника. Таким способом нам удалось получить четыре 6-битных байта (звучит непривычно), и это будут порядковые номера символов внутри алфавита:
Как видим, алгоритм довольно прост, зато решает вполне серьёзные проблемы.
При его реализации мы должны учитывать следующие моменты:
1. Поскольку читать из источника нужно строго 3-байтными блоками, то и размер входных данных должен быть кратен(3). Если это не так, на подготовительном этапе просто дополняем хвост необходимым кол-вом нулей, и обязательно запоминаем их число. По окончании, вместо этих нулей в выходной поток нужно будет вставить символы(=), которые послужат "маркерами лишних байт" декодеру Base64. Например если на входе имеем 10-байт, значит один лишний и добавляем пару символов(==). Таким образом, при кодировании информации входной поток должен быть всегда кратен(3), а выходной будет кратен(4).
2. Процессоры х86 устроены так, что при чтении "из памяти в регистр" получим обратный порядок байт. К примеру, если в памяти лежит слово со-значением
0х2548
, то в регистр оно попадёт в виде 4825h
(см.отладчик). Значит после чтения очередной партии данных, нужно непременно восстанавливать оригинальный порядок, для чего предусмотрена инструкция BSWAP
(Byte Swap).
3. Будем считать, что взяли дворд данных в регистр
EDX
– этот этап представлен на рис.выше самым первым/синим блоком. Теперь, если в какой-нибудь пустой регистр из EDX
вытолкнуть влево 6-бит, то как-раз получим готовый к употреблению индекс. С одного регистра в другой выдвигает биты инструкция SHLD
(Double Precision Shift Left). У неё три операнда – приёмник, источник, и счётчик бит для сдвига. Например, команда SHLD EAX,EDX,6
волшебным образом сбросит индекс в регистр AL
, избавляя нас от возни с отдельными битами.
4. Очередная инструкция
XLATB
залезет в алфавит-кодировки Base64, и используя значение регистра AL
в качестве индекса, вытащит из него соответствующий символ. Всё.. вот и весь алгоритм, на который потратили всего-то три инструкции: BSWAP
, SHLD
и XLATB
.Система команд современных процессоров поражает своим разнообразием. В наборе есть как простые, так и составные команды – по сути этим и отличаются процессоры архитектуры CISC (Complex Instruction Set Computing) от процессоров с одиночными инструкциями RISC. Не смотря на то, что большинство из этих команд специфичны и нужны далеко не всегда, для каждого шага кодирования Base64 всё-же нашлась отдельная инструкция. В результате, можно реализовать на ассемблере такую процедуру, которая будет летать на реактивной скорости, и не нужны никакие библиотеки высокого уровня.
Код ниже демонстрирует, как в 28-ми байтах запрограммировать полноценный кодер Base64. Такой размер без зазрения совести можно таскать с собой, задействуя его в токсических зонах по мере необходимости.
C-подобный:
xor eax,eax ;// очистить EAX
@encode: mov edx,dword[esi] ;// EDX = очередные 4-байта данных (нам нужны только 3 из них)
bswap edx ;// восстановить в нём порядок байт
mov ecx,4 ;// счётчик внутреннего цикла LOOP (4-раз по 6-бит)
@next: shld eax,edx,6 ;// выдвинуть 6-бит из EDX в EAX (они попадут в AL)
shl edx,6 ;// удалить их из EDX (shld сам не удаляет)
xlatb ;// взять в AL символ из алфавита, по индексу в AL
stosb ;// сохранить его в приёмнике!
xor al,al ;// очистить AL
loop @next ;// промотать внутр.цикл ECX-раз..
add esi,3 ;// указатель в источнике на сл.триаду байт
dec [counter] ;// счётчик по длине файла -1
jnz @encode ;// на повтор, пока он > 0
В демонстрационном примере ниже напишем программу, которая запросит у юзера имя файла, и закодировав его содержимое сохранит на диск. Всю основную работу будет делать вышеприведённый участок кода, а остальное – вспомогательные операции типа: открыть файл, вычислить его размер, сделать кратным трём, выделить память и прочая лабуда. Каждая строка прокомментирована, поэтому не буду повторяться:
C-подобный:
format pe console
include 'win32ax.inc'
entry start
;//-----------
.data
Base64: ;//<----------------- Алфавит 64-символов
times 26 db % +('A'-1) ;// ABCDEFGHIJKLMNOPQRSTUVWXYZ
times 26 db % +('a'-1) ;// abcdefghijklmnopqrstuvwxyz
times 10 db % +('0'-1) ;// 0123456789
db '+/',0 ;// +/
title db '*** Base64 Coder v0.1 ***',0
outFile db 'BinTo64.txt',0
fileName rb 128 ;// под имя входного файла с данными
fileHndl dd 0 ;// ..его дескриптор,
fileSize dd 0 ;// ..и размер.
readData dd 0 ;// указатель для чтения файла
writeData dd 0 ;// линк на память с закодированными данными
writeSize dd 0 ;// ..размер готовых данных, для записи в файл
shlCycle dd 0 ;// кол-во триад байтов в исходном файле
complete dd 0 ;//
buff db 0 ;//
;//-----------
.code
start: invoke SetConsoleTitle,title
;//====== Запросим у юзера файл для открытия
cinvoke printf,<10,' Open file name: ',0>
cinvoke scanf,<'%s',0>,fileName
;//====== Попытка его открыть..
invoke _lopen,fileName,OF_READ
mov [fileHndl],eax
cmp eax,-1
jne @f
cinvoke printf,<10,'*** ERROR! File not found! ***',0>
jmp @exit
;//====== ОК! Размер файла, и боксим его на консоль
@@: invoke GetFileSize,eax,0
mov [fileSize],eax
cinvoke printf,<' Input file size: %i byte',0>,eax
;//====== Выделить память, и считать туда файл.
;//====== адрес сохраняем в переменную "readData"
invoke VirtualAlloc,0,[fileSize], MEM_COMMIT, PAGE_READWRITE
mov [readData],eax
invoke _lread,[fileHndl],eax,[fileSize]
invoke _lclose,[fileHndl] ;// данные считали, файл больше не нужен
;//====== Получить кол-во триад (длину цикла),
;//====== и остаток для дополнения символом(=)
;//====== если остаток(1), то делаем его двойкой, иначе - наоборот.
mov eax,[fileSize] ;// размер файла
mov ebx,3 ;// делитель
xor edx,edx ;// очистить место под остаток
div ebx ;// разделить EAX на EBX
or edx,edx ;// проверить остаток на нуль
je @okey ;// если нет остатка, значит размер кратен(3)
inc eax ;// иначе: увеличить кол-во триад
cmp edx,1 ;// проверить остаток на 1
jne @f ;// если нет, значит остаток=2, и отнимаем(1)
inc edx ;// иначе: увеличить на 1 |
jmp @okey ;// |
@@: dec edx ;// <--------------<<----------------------+
@okey: add [fileSize],edx ;// сделать размер кратным трём
mov [complete],edx ;// запомнить остаток для добавления(=)
mov [shlCycle],eax ;// запомнить число проходов (триад)
;//====== Выделить память, куда будем сбрасывать закодированные данные
invoke VirtualAlloc,0,[fileSize], MEM_COMMIT, PAGE_READWRITE
mov [writeData],eax ;// запомнить указатель
;//====================================================================
;//====== КОДИРОВАНИЕ BASE64 ==========================================
;//====================================================================
mov ebx,Base64 ;// адрес алфавита для XLATB
mov esi,[readData] ;// источник данных,
mov edi,[writeData] ;// ..и их приёмник для записи в файл.
mov ebp,[shlCycle] ;// счётчик чтения по 3-байта
xor eax,eax ;// очистить EAX
@encode: mov edx,dword[esi] ;// EDX = очередные 4-байта из файла
bswap edx ;// восстановить в нём порядок байт
mov ecx,4 ;// счётчик внутреннего цикла для LOOP
@@: shld eax,edx,6 ;// выдвинуть 6-бит из EDX в EAX
shl edx,6 ;// удалить их из EDX (shld не удаляет)
xlatb ;// взять в AL символ из алфавита, по индексу в AL
stosb ;// сохранить его в приёмнике!
xor al,al ;// очистить AL
loop @b ;// промотать внутр.цикл ECX-раз..
add esi,3 ;// указатель в источнике на сл.триаду байт
dec ebp ;// число проходов -1
jnz @encode ;// повторить, пока EBP > 0
;//=====================================================================
mov ecx,[complete] ;// кол-во дополнителей(=)
sub edi,ecx ;// отсечь хвост (если он есть) в выходном потоке
mov al,'=' ;// что вместо них вставить
rep stosb ;// замена!
;//====== Вычисляем размер закодированных "Base64" данных
;//====== для этого будем искать нуль, которым заканчиваются данные.
mov edi,[writeData] ;// EDI = адрес начала данных
xor ecx,ecx ;// ставим счётчик ECX,
dec ecx ;// ..на макс = FFFFFFFFh
xor al,al ;// AL=0 (что искать)
repne scasb ;// поиск AL по всей строке EDI
not ecx ;// инверсия счётчика
dec ecx ;// ECX = размер данных!
push ecx ;//
cinvoke printf,<10,' Output file size: %i byte (BinTo64.txt)',0>,ecx
;//====== Сбросить закодированные данные на консоль, и в файл "BinTo64.txt"
cinvoke printf,<10,10,'%s',0>,[writeData]
invoke _lcreat,outFile,0 ;// создать файл: 0=RW, 1=R, 2=Hidden, 3=System.
mov [fileHndl],eax ;// запомнить его дескриптор
pop ebx ;// EBX = размер данных для записи
invoke _lwrite,eax,[writeData],ebx ;// куда, от куда, сколько байт!
invoke _lclose,[fileHndl] ;// прихлопнуть файл
@exit: cinvoke scanf,<'%s',0>,buff ;// GAME OVER <<--------------------------------
cinvoke exit,0 ;//
;//-----------
section '.idata' import data readable
library msvcrt,'msvcrt.dll',kernel32,'kernel32.dll'
import msvcrt, printf,'printf',scanf,'scanf',exit,'exit'
include 'api\kernel32.inc'
Алгоритму Base64 без разницы, какого типа данные поступают на вход – хоть двоичные, хоть текст. В этом примере нет переноса строки на каждом 76-ом символе, поскольку отформатировать текст можно и после кодирования. Хотя без проблем можно было и дополнить код этим функционалом. Тут всё зависит от задачи – если собираемся программно передавать бинарный файл по почте, то нужно вставить разрывы строк, т.к. декодер удалённого браузера рассчитывает на это (см.вводную часть). В данном случае я просто перекодировал текстовый файл, чтобы спрятать его содержимое от глаз любопытных юзверов. Учитывая, что Base64 обратимый, можно применять и рекурсивное кодирование, например одни данные кодировать 2-3 раза подряд. Только потом и разворачивать придётся столько-же раз.
3. Обратное преобразование – декодер "Base64"
Теперь поменяем вектор на противоположный и рассмотрим, как можно восстановить закодированные алгоритмом Base64 данные. В этом деле, основную роль играет правильно оформленная "таблица-преобразований". Если при кодировании мы делали ставку на алфавит из 64-х символов латиницы, то теперь эти символы сами будут выступать в роли индексов в таблице декодера. Соответственно размер данной таблицы должен быть 128-байт, и значения в ней должны отражать порядковый номер текущего символа в "алфавите кодирования".
На рисунке ниже я представил содержимое этой таблицы, где значения
0xFF
являются просто заполнителями, чтобы необходимые декодеру числа встали по своим индексам. Например, алфавит Base64 начинался с заглавного символа(А) с ascii-кодом 41h
, и заканчивался символом(/) с кодом 2Fh
. При этом внутри алфавита они имели свои индексы 00
и 63
соответственно. Выстраивая "таблицу декодера" мы просто проецируем алфавит на ASCII-таблицу. В результате должны получить такую картину:Чтобы программно реализовать декодер, нужно включить реверс и двигаться назад. Для начала, разбиваем строку на блоки по 4 символа, и используя каждый из них в качестве индекса, вытягиваем значения из "таблицы-декодера". Затем, отсекаем у каждого значения старшие 2-бита "массовки" и получаем единую группу из 24-бит. На заключительном этапе, необходимо раскроить 24 на три части по 8-бит, которые и отправляем на выход. Вот демо-пример, который автоматизирует этот процесс.
Обратите внимание на поведение инструкции
SHLD
. Помещённая в цикл она сама сдвигает биты в приёмнике, оставляя не тронутыми биты в источнике. Поскольку на каждом шаге предыдущий байт смещается влево (а не затирается вновь прибывшими битами), это позволяет без лишних телодвижений собирать результат в первый операнд:
C-подобный:
format pe console
include 'win32ax.inc'
entry start
;//-----------
.data
decodeTable:
db 43 dup(-1)
db 0x3E, -1, -1, -1, 0x3F
db 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, -1,-1,-1,-1,-1,-1,-1
db 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E
db 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, -1,-1,-1,-1,-1,-1
db 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28
db 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, -1,-1,-1,-1,-1
title db '*** Base64 Decoder v0.1 ***',0
outFile db '64toBin.txt',0
fileName rb 128 ;// под имя файла,
fileHndl dd 0 ;// ..его дескриптор,
fileSize dd 0 ;// ..и размер.
readData dd 0 ;// будет адресом буфера для чтения файла
writeData dd 0 ;// указатель на буфер записи
shrCycle dd 0 ;// количество тетрад (длина цикла)
buff db 0 ;//
;//-----------
.code
start: invoke SetConsoleTitle,title
;//====== Запросим у юзера файл с данными
cinvoke printf,<10,' Open file name: ',0>
cinvoke scanf,<'%s',0>,fileName
;//====== Попытка его открыть..
invoke _lopen,fileName,OF_READ
mov [fileHndl],eax ;//
cmp eax,-1 ;//
jne @f ;//
cinvoke printf,<10,'*** ERROR! File not found! ***',0>
jmp @exit ;//
;//====== ОК! Размер файла, и вычисляем кол-во тетрад в нём
@@: invoke GetFileSize,eax,0 ;// EAX = дескриптор
mov [fileSize],eax ;// в EAX вернулся размер файла
mov ebx,4 ;// делитель
xor edx,edx ;// очистить место под остаток
div ebx ;// EAX / EBX
mov [shrCycle],eax ;// запомнить кол-во тетрад (целая часть)
or edx,edx ;// проверить остаток на нуль
jz @f ;// пропустить, если ОК!
;// иначе: можно вывести ошибку
;// cinvoke printf,<10,'*** ERROR! This is not "Base64" correct file! ***',0>
;// jmp @exit
;//====== Выделить память, и считать туда файл
@@: invoke VirtualAlloc,0,[fileSize], MEM_COMMIT, PAGE_READWRITE
mov [readData],eax ;// EAX = адрес выделенной памяти
invoke _lread,[fileHndl],eax,[fileSize]
invoke _lclose,[fileHndl]
;//====== Выделить память для записи результата
invoke VirtualAlloc,0,[fileSize], MEM_COMMIT, PAGE_READWRITE
mov [writeData],eax ;// адрес памяти
;//=========================================================
;//====== ДЕКОДEР BASE64 ===================================
;//=========================================================
mov edi,eax ;// приёмник данных
mov esi,[readData] ;// источник с данными
mov ebp,[shrCycle] ;// кол-во проходов
mov ebx,decodeTable ;// адрес таблицы для XLATB
@decode: mov edx,dword[esi] ;// очередные 4-символа
bswap edx ;// восстановить порядок байт
mov ecx,4 ;// счётчик внутреннего цикла LOOP
@@: shld eax,edx,8 ;// выдвинуть в AL очередной байт
shl edx,8 ;// удалить его из EDX
xlatb ;// взять по нему число из таблицы
loop @b ;// собираем дворд в EAX
xchg eax,edx ;// обменять! EAX=0, EDX = полученный дворд
mov ecx,4 ;// счётчик внутреннего цикла LOOP
@@: shl edx,2 ;// убрать из EDX 2-бита "массовки"
shld eax,edx,6 ;// выдвинуть в EAX следующие 6-бит
shl edx,6 ;// (удалить их из источника)
loop @b ;// прокрутить внутренний цикл..
bswap eax ;// восстановить порядок байт
shr eax,8 ;// удалить лишний (младший) байт
stosd ;// сохранить триаду двоичных!
dec edi ;// коррекция указателя в приёмнике
add esi,4 ;// указатель на сл.дворд в источнике
dec ebp ;// внешний цикл -1
jnz @decode ;// повторить по длине EBP..
;//====== КОНЕЦ ДЕКОДЕРА ====================================//
;//====== Вычисляем размер раскодированных данных.
;//====== Cейчас EDI указывает на хвост буфера - отнимем от него начало.
sub edi,[writeData] ;// EDI = размер закодированных данных!
push edi ;// запомнить
;//====== Сбросить раскодированные данные на консоль, и в файл "64toBin.txt"
;//====== Если декодировали двоичный файл, то fn.printf() покажет его не весь, а до первого нуля.
;//====== Сохранения в файл это не касается, т.к. указываем реальный размер данных.
cinvoke printf,<10,10,'%s',0>,[writeData]
invoke _lcreat,outFile,0
pop ebx ;// размер данных
push eax ;// дескриптор файла
invoke _lwrite,eax,[writeData],ebx
pop eax ;//
invoke _lclose,eax ;// закрыть файл
@exit: cinvoke scanf,<'%s',0>,buff
cinvoke exit,0
;//-----------
section '.idata' import data readable
library msvcrt,'msvcrt.dll',kernel32,'kernel32.dll'
import msvcrt, printf,'printf',scanf,'scanf',exit,'exit'
include 'api\kernel32.inc'
4. Родственный алгоритм Base32
Под занавес рассмотрим принцип кодирования Base32. Здесь всё аналогично, только алфавит включает в себя 32 печатных символа (без символов нижнего регистра латиницы), и соответственно для выбора одного из них достаточно 5-битного индекса, поскольку
2^5=32
. Рисунок ниже показывает, сколько нужно собрать таких индексов, чтобы суммарная разрядность была в пределах байта. Пенты во-второй строке рисунка или не дотягивают до границы байта, или вылетают за его пределы. И только на 5-ом байте входных данных мы попали в диапазон. Именно такого размера требуются входные блоки, чтобы можно было корректно превратить их в индексы. В конечном счёте, это увеличивает выходной поток аж на 60%, с соотношением 8/5. Это слишком расточительно, поэтому алго Base32 применяется крайне редко.5. Эпилог
Для работы с кодировкой Base64, на вооружении Win имеются специально натренированные функции – это CryptBinaryToString(), которая выступает в роли кодера, и её кузен для обратного преобразования CryptStringToBinary(). Они живут в системной библиотеке crypt32.dll и в силу своего веса уже давно понижены в звании программистами. Приведённый здесь пример хоть и исповедует идентичные концепции, но детали реализации совершенно разные. Я пробовал трейсить эту CryptBinaryToString() в отладчике, так он повёл меня за собой в непроходимые джунгли кода, и даже после 100-шагов так и не дождавшись полезной нагрузки, я решил прекратить этот балаган. Системе конечно-же видней, как правильно нужно делать, но в большинстве случаях для нас это не приемлемо и проще написать свою короткую процедуру, что я и попытался сделать в этой статье.
Как обычно, в скрепке лежат два представленных здесь исполняемых файла – кодер и декодер Base64.
Всех с наступающим 2021-годом, и до встречи теперь уже в новом году!