Декодирование Base64 в C: полное руководство
Она живёт в ответе API, в файле настроек, во вложении письма или посреди URL: длинная строка из букв и цифр, изредка с + или /, а может быть, ещё и с одним-двумя знаками = в конце. Вы узнаете её мгновенно, а теперь вам нужны исходные байты - на C. Это и есть вся работа декодирования Base64: четыре символа алфавита входят, три сырых байта выходят, снова и снова, пока знаки = не скажут, где закончились настоящие данные. Домашняя страница этого сайта разбирает формат шаг за шагом, поэтому вся энергия статьи направлена туда, где настоящая работа: на буферы, библиотеки и на ловушки, которые живут между ними.
Две вещи стоит знать до первого malloc. Первая: декодирование - это сжимающее направление. Результат занимает три четверти размера входа, поэтому декодеру никогда не нужно больше памяти, чем тот пелод, который он уже держит. Вторая - и это главная мысль: в C нет из коробки декодера Base64. Стандартная библиотека языка замёрзла задолго до появления Base64, и ни один последующий стандарт эту дыру не закрыл. Так что каждая C-программа, которая декодирует Base64, опирается на библиотеку, и четыре, которые действительно важны на практике, - OpenSSL, Mbed TLS, APR-Util и GLib. У каждой свой характер: что она прощает, как сообщает об ошибках и что тихо делает с вашим результатом. Узнайте характер своего декодера - и декодирование Base64 на C перестанет быть источником мистических багов, превратившись в рутину, которую пишется на автомате.
Инструменты: четыре способа вернуть свои байты
Вот картина целиком, одним взглядом. Все четыре библиотеки покрывают стандартный алфавит; различия - на краях, а именно на краях и рождаются баги.
| Библиотека | Заголовочный файл | Модель ошибок | Особенность вывода, которую стоит помнить |
|---|---|---|---|
| OpenSSL (libcrypto) | <openssl/evp.h> |
Возвращает -1 на некорректном входе |
Разовый декодер дополняет хвост нулями |
| Mbed TLS | <mbedtls/base64.h> |
Коды возврата (-0x002C, -0x002A) |
Самые строгие правила входа из четырёх |
| APR-Util | <apr-1.0/apr_base64.h> |
Нет: останавливается на первом странном символе | API с длинами типа int, так что потолок - 2 ГБ |
| GLib | <glib.h> |
Возвращает NULL только при жёстком сбое |
Бесшумно игнорирует случайный мусор |
Установка - одно имя пакета на дистрибутив. Для OpenSSL: libssl-dev на Debian и Ubuntu, openssl-devel на Fedora и RHEL, openssl на Arch, и brew install openssl на macOS. Для Mbed TLS: libmbedtls-dev (или mbedtls). Для APR-Util: libaprutil1-dev плюс libapr1-dev. Для GLib: glib2.0-dev. Затем линкуете с -lcrypto, -lmbedcrypto, -laprutil-1 или -lglib-2.0 соответственно. Какой выбрать? Если вы уже линкуете OpenSSL ради TLS или хеширования (большинство серверов так делают) - берите OpenSSL. Для встроенных и небогатых на ресурсы сборок Mbed TLS - маленький строгий гражданин. Если вы внутри экосистемы Apache, APR-Util уже там. Если ваш код построен на GNOME или GTK, GLib держит всё в одном рантайме.
OpenSSL: декодер, который заполняет пропуски нулями
У OpenSSL есть Base64 в двух вариантах. Разовая функция - звезда большинства кода:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
unsigned char out[16];
const char *payload = "TWFuZQ==";
int n = EVP_DecodeBlock(out, (const unsigned char *)payload,
(int)strlen(payload));
if (n < 0) {
printf("not base64\n");
return 1;
}
printf("%d bytes\n", n);
return 0;
}
Дайте ему буфер Base64-символов и длину - он запишет декодированные байты в out и скажет, сколько их. Он отбрасывает начальные пробельные символы, отбрасывает завершающие пробелы и переводы строк, и отказывается от входа, который после этой чистки не кратен четырём символам или содержит символ вне алфавита. Пока что - вполне разумный контракт. Но есть одна деталь, которая уже не раз тихо портила импорт в базу данных: возвратное значение - это не настоящая длина данных.
Запустите эту программу - и, кажется, получите 4 bytes... нет, подождите. TWFuZQ== - это две группы по четыре символа, значит функция вернёт 6, а в буфере окажется 4d 61 6e 65 00 00: слово «Mane» плюс два нулевых байта. Разовый декодер OpenSSL работает фиксированными квантами: каждые четыре входных символа всегда производят ровно три выходных байта, - и когда последняя группа несла всего один настоящий байт, два других слота заполняются нулями. В справке об этом сказано одним спокойным предложением («если нужно, вывод будет дополнен нулевыми битами»), и именно это предложение - самое важное во всей man-странице данной функции.
Настоящая длина восстанавливается из заполнения - это расчёт на две строки:
size_t real_length(const char *b64) {
size_t len = strlen(b64);
while (len > 0 && b64[len - 1] == '=') len--;
return len * 3 / 4;
}
Посчитайте символы алфавита, отбросьте завершающие знаки заполнения, умножьте на три, разделите на четыре. Для TQ== (буква M, закодированная) это даёт (2 * 3) / 4 = 1 настоящий байт - при том что EVP_DecodeBlock сообщит о трёх. Всегда держите вместе пару (указатель, длина) и никогда не применяйте strlen к декодированным данным: байты, которые вы получили, могут оказаться JPEG, а первым из них может быть NUL.
Поточный декодер: тот, что знает, когда остановиться
Для всего остального OpenSSL предлагает поточную пару EVP_DecodeUpdate плюс EVP_DecodeFinal. Контекстный объект - тот, кто переносит состояние между вызовами: он хранит один-три символа незавершённой группы, чтобы вы могли подавать пелод кусками. Поведение, которое действительно важно, таково: пробельные символы (пробелы, табуляции, возвраты каретки, переводы строк) пропускаются в любом месте потока; любой другой символ вне алфавита или = посреди данных сразу возвращают -1; а возврат 0 из update означает «заполнение уже увидено, дальше ничего не ждём». Затем EVP_DecodeFinal отказывает с кодом -1, если незавершённая группа всё ещё висит в воздухе: длина, не кратная четырём (после учёта пробелов), - это не валидный пелод.
Одно замечание по версии перед кодом, потому что старые туториалы вас подведут: в OpenSSL 3.x тип контекста EVP_ENCODE_CTX - непрозрачный, поэтому стоковая конструкция EVP_ENCODE_CTX ctx;, встречающаяся в куче интернет-кода, уже не компилируется. Выделяйте и освобождайте явно:
static int decode_b64(const unsigned char *in, int in_len,
unsigned char *out, int *out_len) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
if (ctx == NULL) {
return -1;
}
*out_len = 0;
EVP_DecodeInit(ctx);
int r = EVP_DecodeUpdate(ctx, out, out_len, in, in_len);
if (r < 0) {
EVP_ENCODE_CTX_free(ctx);
return -1;
}
int tail = 0;
r = EVP_DecodeFinal(ctx, out + *out_len, &tail);
EVP_ENCODE_CTX_free(ctx);
if (r < 0) {
return -1;
}
*out_len += tail;
return 0;
}
Сделайте выходной буфер размером in_len * 3 / 4 + 3 - и вызов будет безопасен для любого входа. Посмотрите, как он справляется с MIME-перенесённым пелодом, где перевод строки упал посреди группы:
const char *wrapped = "TWFu\nZQ==";
unsigned char out[16];
int out_len = 0;
if (decode_b64((const unsigned char *)wrapped,
(int)strlen(wrapped), out, &out_len) != 0) {
printf("invalid base64\n");
return 1;
}
printf("%.*s\n", out_len, out); /* Mane */
Перенос исчезает, выходят четыре байта, и никому не пришлось предварительно чистить вход. Есть ещё одно бонусное отличие от разовой функции: поточный путь считает байты честно. Дайте ему TQ== - он вернёт ровно один байт (4d), без нулевого заполнения, потому что понимает: два знака заполнения означают, что два из трёх выходных слотов так и не заполнились. Если вашему пелоду когда-нибудь нужна доверенная длина от OpenSSL - это тот самый путь.
Mbed TLS: строгий
Mbed TLS (библиотека криптографии, которая начинала жизнь под именем PolarSSL и теперь поставляется внутри встроенных стеков ARM) даёт вам две функции с очень чистым контрактом:
int mbedtls_base64_encode(unsigned char *dst, size_t dlen, size_t *olen,
const unsigned char *src, size_t slen);
int mbedtls_base64_decode(unsigned char *dst, size_t dlen, size_t *olen,
const unsigned char *src, size_t slen);
Декодируйте так, как сделал бы осторожный человек. Вызовите с dst, равным NULL (или dlen, равным нулю), - и функция сообщит нужный размер в *olen, ничего не делая; вызовите по-настоящему - и получите 0 при успехе, MBEDTLS_ERR_BASE64_INVALID_CHARACTER (это -0x002C), если во входе что-то не так, или MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL (это -0x002A), если в буфере назначения не хватает места. Декодированная длина попадает в *olen, и, в отличие от разовой функции OpenSSL, это всегда честное число: декодирование TQ== даёт один байт, 4d, и ничего больше.
Правила для входа - самые строгие из четырёх библиотек, и их стоит запомнить, потому что именно они определяют, что такое «валидный» для Mbed TLS:
- Переводы строк CRLF и LF допускаются между группами: почтовые пелоды работают как есть.
- Пробелы разрешены непосредственно перед переводом строки и в самом конце буфера, но пробел после перевода строки или посреди группы - ошибка.
- Не больше двух символов
=, и только в конце; любые данные после знака заполнения - ошибка. - Любой байт больше 127 (акценты, фрагменты UTF-8, бинарный мусор) - ошибка.
Именно последнее правило кусает: если пелод приходит от источника, который повредил кодировку символов, Mbed TLS отвергнет его там, где более ленивый декодер просто плечами пожал бы. Для всего, что имеет дело с недоверенным входом, строгость - это фича. Полное декодирование выглядит так:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
const char *payload = "TWFuZQ==";
size_t need = 0;
int rc = mbedtls_base64_decode(NULL, 0, &need,
(const unsigned char *)payload,
strlen(payload));
if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
printf("size query failed: %d\n", rc);
return 1;
}
unsigned char *out = malloc(need);
size_t olen = 0;
rc = mbedtls_base64_decode(out, need, &olen,
(const unsigned char *)payload,
strlen(payload));
if (rc != 0) {
printf("decode failed: %d\n", rc);
free(out);
return 1;
}
printf("%.*s\n", (int)olen, out);
free(out);
return 0;
}
(То, что запрос размера возвращает код «слишком мало» - сделано намеренно: именно так функция сообщает, что она записала бы. Оба кода возврата выше взяты из <mbedtls/base64.h> - того самого заголовочного файла, где функция и объявлена.)
APR-Util и GLib: ещё два кресла
APR-Util - утилитарная библиотека Apache Portable Runtime, фундамента, на котором стоит Apache HTTP Server, - носит Base64 с тех самых пор, как серверу понадобилось декодировать заголовки Basic auth. API - небольшая семья функций на базе int:
#include <apr-1.0/apr_base64.h>
int apr_base64_encode_len(int len);
int apr_base64_encode(char *coded_dst, const char *plain_src,
int len_plain_src);
int apr_base64_decode_len(const char *coded_src);
int apr_base64_decode(char *plain_dst, const char *coded_src);
Две вещи стоит знать, прежде чем тянуться к ней. Первая: длины - int, то есть 32-битные, так что практический потолок - 2 ГБ на вызов; для заголовков и значений из конфигов это отлично, а для декодирования файла в 4 ГБ - уже нет. Вторая - и вот она главная: у функции декодирования вообще нет возврата ошибки. Это поведение видно только в реализации, а не в заголовке: декодер воспринимает любой невалидный символ, включая пробелы и NUL, как стоп. Он декодирует до первой непонятной вещи, возвращает, как далеко дошёл, и молчит. Обрезанный пелод, вставка с комментарием в конце, повреждённый байт посреди - всё это даёт тихий, коротковатый результат. Если вы используете декодер APR, вам самому нужно сравнить возвращённую длину с тем, что обещал пелод; функция этого за вас не сделает. Пуловой обёртки нет - буфер назначения предоставляете вы, так что в пул-ориентированном коде plain_dst выделяете из пула сами. Есть ещё и EBCDIC-грань, которой нет больше нигде в этой статье: на EBCDIC-машинах функции переводят вход в ASCII перед кодированием и обратно после декодирования, так что один и тот же код работает на мейнфреймах, которые до сих пор гоняют httpd.
GLib, рантайм, стоящий за GTK и большинством приложений GNOME, - противоположный характер. Её декодер принимает строку и всегда возвращает свежевыделенный буфер (NULL только если вы передали указатель NULL), декодируя всё, что может, и молча пропуская остальное:
#include <glib.h>
gsize out_len = 0;
guchar *bytes = g_base64_decode(payload, &out_len);
if (bytes == NULL) {
printf("not base64\n");
} else {
printf("%u bytes\n", (unsigned)out_len);
g_free(bytes);
}
Ловушка прячется в слове «всегда». Декодер GLib - из снисходительной школы: символы вне алфавита пропускаются, а не считаются фатальными. Дайте ему TWFuZ@== - и он без единого вздоха отдаст три байта слова «Man». Есть ещё удобная версия, работающая на месте, g_base64_decode_inplace(): она декодирует прямо поверх входного буфера (безопасно, потому что выход короче входа) и возвращает тот же указатель, так что результат начинается с начала буфера - хороший трюк для кода с тугой памятью; к тому же она охотно ест CRLF-перенесённый вход. Вывод для C-разработчика: если ваши данные недоверенные, GLib не спасёт вас от повреждённого пелода. Варианты _step (g_base64_decode_step со счётчиком состояния) доступны, когда нужно инкрементное декодирование, а соответствующая пара g_base64_encode_step/g_base64_encode_close живёт на стороне кодирования.
URL-безопасный Base64: другой алфавит
Где-то между стандартным алфавитом и вашими URL кому-то пришлось пострадать. Стандартный Base64 использует + и / как свои два старших символа, и оба - беды в URL: + в строке запроса ко времени, когда его видит ваш сервер, как правило, уже превращается в пробел, а / - это разделитель пути. RFC 4648, раздел 5, определяет лекарство, названное base64url: та же кодировка, где + заменён на -, / заменён на _, а завершающее заполнение = выброшено, когда длина известна другим способом. JSON Web Tokens, state-параметры OAuth и очень многие сессионные идентификаторы API живут именно в этом диалекте.
Ни одна из четырёх C-библиотек не декодирует base64url из коробки, так что конвертация - это маленький хелпер, который пишется один раз и переиспользуется: вернуть два особых символа обратно, дособрать недостающее заполнение, а потом передать результат своему стандартному декодеру. Сначала - проверка длины: длина, на единицу больше кратного четырём, невозможна ни в одном диалекте Base64:
int base64url_decode(const char *url_safe, unsigned char *out,
size_t out_cap, size_t *out_len) {
size_t len = strlen(url_safe);
if (len % 4 == 1) {
return -1;
}
size_t needed = (len * 3) / 4;
if (needed > out_cap) {
return -2;
}
char *std = malloc(len + 4);
if (std == NULL) {
return -3;
}
for (size_t i = 0; i < len; i++) {
char c = url_safe[i];
if (c == '-') c = '+';
if (c == '_') c = '/';
std[i] = c;
}
size_t pad = (4 - len % 4) % 4;
for (size_t i = 0; i < pad; i++) {
std[len + i] = '=';
}
int n = EVP_DecodeBlock(out, (const unsigned char *)std,
(int)(len + pad));
free(std);
if (n < 0) {
return -1;
}
*out_len = needed;
return 0;
}
Этой дороге присмотривают две ловушки. Первая - направление: если скормить URL-безопасный пелод стандартному декодеру без замены символов, OpenSSL и Mbed TLS его отвергнут (таких символов в их алфавите нет), а GLib молча пропустит - и _ и отдаст строку короче, чем положено, - без единой ошибки. Всегда идите через хелпер. Вторая - предупреждение самого RFC, которое стоит принять всерьёз: base64url «не следует считать тем же, что кодировка base64». Если пелод случайно не содержит символов - или _, два диалекта побайтово совпадают для этих данных, и перепутать их незаметно, - а именно поэтому перепутать можно ровно до того момента, пока не подвернётся пелод, где один из них есть.
Файлы: возвращаем оригинал
Самая частая файловая работа - это обратная сторона того, что сделал какой-нибудь экспортный код: приходит текстовый файл .b64, и вам нужен исходный файл обратно. Прочитайте весь текст, декодируйте его, а затем дайте байтам самим объявиться, прежде чем верить какой-либо метке. В C нет finfo, так что практичный тест - заглянуть в магические числа первых нескольких байтов:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("upload.b64", "rb");
if (f == NULL) {
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
char *text = malloc((size_t)size + 1);
size_t got = fread(text, 1, (size_t)size, f);
fclose(f);
text[got] = '\0';
unsigned char *out = malloc((got * 3) / 4 + 3);
int out_len = 0;
if (decode_b64((const unsigned char *)text, (int)got,
out, &out_len) != 0) {
printf("not valid base64\n");
free(text);
free(out);
return 1;
}
free(text);
const char *kind = "unknown binary";
if (out_len >= 4 && memcmp(out, "\x89PNG", 4) == 0) kind = "png";
else if (out_len >= 5 && memcmp(out, "%PDF-", 5) == 0) kind = "pdf";
else if (out_len >= 4 && memcmp(out, "PK\x03\x04", 4) == 0) kind = "zip";
else if (out_len >= 3 && memcmp(out, "\xff\xd8\xff", 3) == 0) kind = "jpeg";
printf("looks like a %s, %d real bytes\n", kind, out_len);
free(out);
return 0;
}
Заметки на полях: открывайте файл в бинарном режиме (rb/wb) даже для текстовой части, потому что текстовый режим на некоторых платформах перепишет окончания строк и испортит счёт символов; и никогда не printf("%s") декодированный буфер «чтобы посмотреть, что это». Заглядывание в магические числа - честный способ задать этот вопрос, и если позже вы отдадите восстановленный файл браузеру, Content-Type должен прийти из того же заглядывания, а не из имени файла.
Data URI: изображение внутри URL
Любимое прибытие из веб-мира: кто-то вставляет изображение в форму, и фронтенд передаёт вашему серверу готовый data URI вида data:image/png;base64,iVBORw0KGgo.... RFC 2397 определяет форму: data:, необязательный media type, необязательный флаг ;base64, запятая, и далее пелод. Если флаг есть, пелод - Base64; если его нет, пелод - перцент-закодированный обычный текст, реже, но законно. Если media type опущен, по умолчанию подразумевается text/plain;charset=US-ASCII. Разобрать это в C - значит найти запятую и посмотреть, что сидит прямо перед ней:
int split_data_uri(const char *uri, char *mime, size_t mime_cap,
int *is_b64, const char **payload) {
if (strncmp(uri, "data:", 5) != 0) {
return -1;
}
const char *comma = strchr(uri, ',');
if (comma == NULL) {
return -1;
}
*is_b64 = 0;
const char *meta = uri + 5;
size_t meta_len = (size_t)(comma - meta);
if (meta_len >= 7 && strncmp(comma - 7, ";base64", 7) == 0) {
*is_b64 = 1;
meta_len -= 7;
}
if (meta_len == 0) {
snprintf(mime, mime_cap, "text/plain;charset=US-ASCII");
} else {
snprintf(mime, mime_cap, "%.*s", (int)meta_len, meta);
}
*payload = comma + 1;
return 0;
}
А вызывающий код читается как предложение:
char mime[256];
int is_b64 = 0;
const char *payload = NULL;
const char *uri = "data:image/png;base64,iVBORw0KGgo...";
if (split_data_uri(uri, mime, sizeof(mime), &is_b64, &payload) == 0) {
printf("mime=%s base64=%d\n", mime, is_b64);
/* теперь декодируем пелод любой любимой библиотекой */
}
В этом формате живут три ловушки. Первая - отсутствующий флаг ;base64: законный data URI без него несёт перцент-закодированный пелод, и прогон этого через Base64-декодер даёт мусор - проверьте флаг, а уже потом выбирайте декодер. Вторая - заявленный media type: это подсказка от отправителя, а не факт, и фактом остаётся заглядывание в магические числа из раздела про файлы. Третья - размер: сам RFC советует, что data URI - для коротких значений, так что многомегабайтное изображение, едущее внутри URL, - это запах в вашей архитектуре, а не паттерн, которым стоит гордиться.
JWT: читаем те части, что не секрет
Самый известный Base64-пелод в вебе - это JSON Web Token, и наименее пугающий, как только вы знаете его форму. Согласно RFC 7519, компактный JWT - это три base64url-части, склеенные точками: заголовок, пелод и подпись, - каждая закодирована без заполнения и без переводов строк. Две первые части - обычный JSON, поэтому их может прочитать кто угодно, и поэтому всем стоит дочитать до этого места, прежде чем трогать токен.
Прочитать две первые части - всего пара строк с base64url-хелпером, приведённым выше, и это самый быстрый способ снять с токена мистику:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int base64url_decode(const char *url_safe, unsigned char *out,
size_t out_cap, size_t *out_len);
int main(void) {
const char *token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
"TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ";
const char *dot1 = strchr(token, '.');
if (dot1 == NULL) {
return 1;
}
const char *part2 = dot1 + 1;
const char *dot2 = strchr(part2, '.');
if (dot2 == NULL) {
return 1;
}
const char *part3 = dot2 + 1;
char seg[512];
char buf[1024];
size_t n = 0;
size_t hlen = (size_t)(dot1 - token);
memcpy(seg, token, hlen);
seg[hlen] = '\0';
if (base64url_decode(seg, (unsigned char *)buf,
sizeof(buf), &n) == 0) {
printf("header: %.*s\n", (int)n, buf);
}
size_t plen = (size_t)(dot2 - part2);
memcpy(seg, part2, plen);
seg[plen] = '\0';
if (base64url_decode(seg, (unsigned char *)buf,
sizeof(buf), &n) == 0) {
printf("payload: %.*s\n", (int)n, buf);
}
printf("signature: %s (encoded, verify before trusting!)\n", part3);
return 0;
}
Напечатанные, заголовок - это {"alg":"HS256","typ":"JWT"}, а пелод - {"sub":"1234567890","name":"John Doe"}. Теперь то, что действительно важно: третья часть - это подпись, и две части, которые вы только что декодировали, не являются ни секретом, ни аутентифицированными. Любой, у кого есть дамп пакетов, может их прочитать, и любой, у кого есть текстовый редактор, может их переписать. Доверять JWT-пелоду на C до проверки подписи - классическая ошибка аутентификации, и Base64 помогает её не заметить: токен выглядит неразбиваемым куском, будучи по сути открыткой. Чтобы проверить HS256-токен, вы пересчитываете HMAC-SHA256 по header.part с вашим секретом, используя HMAC() из <openssl/hmac.h>, и сравниваете в постоянном времени через CRYPTO_memcmp(); если дайджесты не совпали, токен отклоняется, каковы бы ни были его заявления. Де-факто стандартной JWT-библиотеки на C нет, так что для продакшена либо вы сами соберёте этот маленький шаг проверки, либо примете одну из комьюнити-библиотек, - но Base64-часть работы - это показанный выше танец «раздели и декодируй», и понимать стоит всё.
Basic Auth: заголовок, который так и не выучил приватность
Самый старый заголовок аутентификации в вебе до сих пор едет на Base64: Authorization: Basic, за которым следует кодирование в стандартном алфавите строки username:password (RFC 7617, на который ссылается RFC 911 для схемы Basic). RFC прямо говорит, что это кодирование, а не защита: любой, у кого есть дамп пакетов, декодирует обе половины одной командой. Так что работа на стороне декодирования в C - разобрать заголовок, декодировать строго, разрезать по первому двоеточию (пароль законно может содержать двоеточия) и сравнить через тайминг-безопасную функцию:
#include <string.h>
#include <openssl/evp.h>
#include <openssl/crypto.h>
static size_t real_length(const char *b64);
int basic_auth_ok(const char *header, const char *expected_user,
const char *expected_pass) {
if (strncmp(header, "Basic ", 6) != 0) {
return 0;
}
const char *b64 = header + 6;
unsigned char out[256];
int n = EVP_DecodeBlock(out, (const unsigned char *)b64,
(int)strlen(b64));
if (n < 0) {
return 0;
}
size_t real = real_length(b64);
size_t u_len = strlen(expected_user);
size_t p_len = strlen(expected_pass);
if (real != u_len + 1 + p_len) {
return 0;
}
if (memcmp(out, expected_user, u_len) != 0) {
return 0;
}
if (out[u_len] != ':') {
return 0;
}
return CRYPTO_memcmp(out + u_len + 1, expected_pass, p_len) == 0;
}
Проверка длины делает настоящую работу: она не пускает пелод, который декодируется в «alice:secret» с мусором на хвосте, или обрезанный «alice:secre». А CRYPTO_memcmp (или memcmp - только если вы понимаете последствия для тайминга) - это то, что мешает атакующему пройти по списку ваших пользователей, измеряя время ответа. Отдавайте этот заголовок только по HTTPS или вообще не отдавайте: на простом соединении Base64-слой - просто витрина.
Почта и PEM: родной дом
Base64 родился под очень конкретную проблему: почтовая перевозка умела нести только 7-битный ASCII, а людям хотелось пропускать через неё бинарные данные. MIME (RFC 2045) сделал Base64 одной из стандартных кодировок пересылки и добавил два домашних правила: закодированная строка не должна превышать 76 символов, а декодирующая программа обязана игнорировать символы вне алфавита - переводы строк в том числе. Именно второе правило позволяет поточным декодерам выше пережёвывать перенесённое вложение без какой-либо предобработки, и именно поэтому привычка к 76 символам до сих пор въелась в каждую почтовую библиотеку на земле. Прародителем был PEM (Privacy Enhanced Mail, RFC 1421), где строки были 64-символьными: то самое расхождение 64/76, которое вы видите в инструментах, - это и есть история, а оба лимита в конечном счёте навязаны SMTP.
PEM-броня, в которой путешествуют ключи и сертификаты, - это просто промаркированный Base64: строка -----BEGIN ... -----, тело строками по 64 символа и подходящая строка END. Снять броню в C - значит просканировать строки, а дальше декодер сделает остальное:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("server.key", "r");
if (f == NULL) {
return 1;
}
char line[256];
char b64[8192];
size_t pos = 0;
int in_body = 0;
while (fgets(line, sizeof(line), f) != NULL) {
if (strncmp(line, "-----BEGIN", 10) == 0) {
in_body = 1;
continue;
}
if (strncmp(line, "-----END", 8) == 0) {
in_body = 0;
break;
}
if (in_body) {
size_t l = strlen(line);
while (l > 0 && (line[l - 1] == '\n' || line[l - 1] == '\r')) {
l--;
}
memcpy(b64 + pos, line, l);
pos += l;
}
}
fclose(f);
unsigned char der[8192];
int out_len = 0;
if (decode_b64((const unsigned char *)b64, (int)pos,
der, &out_len) != 0) {
printf("armor contained no valid base64\n");
return 1;
}
printf("DER payload decoded\n");
return 0;
}
Декодированные байты - это DER, компактная бинарная сериализация, и именно её в итоге потребляют функции OpenSSL для сертификатов и ключей. Две заметки: собирайте тело без его переводов строк (как это делает цикл), чтобы длина была кратна четырём, и если файл несёт несколько блоков, совмещайте метку END с меткой BEGIN, которую вы открыли: простой флаг сработает, если нужен только первый блок, как здесь.
Секреты, конфиги и столбцы баз данных
Base64 - текстовый контейнер, и поэтому он постоянно появляется там, где его не ждёшь. В файлах настроек и переменных окружения это способ провезти значения, которые иначе сломали бы формат: DSN базы данных с точками с запятыми, пароль с кавычками, значение с переводом строки. В базах данных бинарный blob может жить в текстовом столбце в виде Base64 и переживать любой инструмент, который предполагает текст, - но ценой примерно трети лишнего размера, так что подбирайте размер столбцов с учётом этого (или спросите себя, почему значение вообще не лежит в BLOB-столбце).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
static size_t real_length(const char *b64) {
size_t len = strlen(b64);
while (len > 0 && b64[len - 1] == '=') len--;
return len * 3 / 4;
}
int main(void) {
const char *b64 = getenv("API_KEY_B64");
if (b64 == NULL) {
printf("API_KEY_B64 is not set\n");
return 1;
}
size_t cap = strlen(b64);
unsigned char *out = malloc(cap);
int n = EVP_DecodeBlock(out, (const unsigned char *)b64, (int)cap);
if (n < 0) {
printf("API_KEY_B64 is not valid base64\n");
free(out);
return 1;
}
size_t real = real_length(b64);
printf("key is %zu bytes\n", real);
free(out);
return 0;
}
Осторожность действует дважды. Во-первых, это безопасность формата, а не секретность: как только разработчик может прочитать файл настроек, он может декодировать значение одним вызовом, и в разделе о безопасности RFC записаны настоящие инциденты, когда люди передавали саппорту обмен по протоколу и «случайно выкладывали пароль», потому что Base64 маскирует визуально, а не защищает вычислительно. Никогда не храните секрет в виде Base64 и не называйте это шифрованием. Во-вторых, проверяйте при старте: наполовину вставленное значение из окружения - это -1 от строгого вызова, и проверка в одну строку превращает загадочный сбой через три часа в понятное сообщение при запуске.
Декодирование из shell
Не всё декодирование происходит внутри вашей программы. CLI-скрипты, cron-задачи и однострочники декодируют Base64 постоянно, и C-разработчику стоит знать два инструмента, которые уже есть на каждой Linux-машине. Инструмент coreutils - универсальный: base64 -d декодирует, -i заставляет его игнорировать мусорные символы вместо того, чтобы падать, а -w задаёт столбец переноса (влияет только на кодирование, не на декодирование):
base64 -d < blob.b64 > blob.bin
base64 -d -i < messy.b64 > blob.bin
У OpenSSL есть собственный, доступный как openssl base64 (более дружелюбный псевдоним openssl enc -base64):
openssl base64 -d < blob.b64 > blob.bin
openssl base64 -d -A < blob.b64 > blob.bin
Флаг -A означает «одна строка»: кодируй без 64-символьного переноса и ожидай, что вход тоже одна строка. И вот CLI-ловушка, которая стоит вам вечера, если её не прочитать: декодирование base64 в OpenSSL ориентировано на строки, и пелод, пришедший вообще без перевода строки, молча декодируется в пустоту:
printf 'TQ==' | openssl base64 -d | wc -c # 0
printf 'TQ==\n' | openssl base64 -d | wc -c # 1
У декодера coreutils такого ожидания нет, и в этом одна из причин, почему он безопаснее по умолчанию для склеечных задач. Ещё одна заметка про диалекты: на системах из семейства BSD (в особенности на старых macOS) флаг декодирования исторически писался как -D; современные релисы следуют конвенции GNU -d, так что смотрите man-страницу на той машине, которая у вас реально стоит.
Большие пелоды, маленькая память
Декодирование - это направление, которое вам помогает: вывод занимает три четверти размера входа, поэтому давления на память со стороны Base64 почти не бывает. Но когда на диск приземляется многосотмегабайтный файл .b64, ваш инструмент - поточный путь, показанный выше, и он проще, чем кажется. Читаем закодированный файл кусками, подаём каждый кусок в EVP_DecodeUpdate и записываем декодированные байты по мере их поступления. Контекст между вызовами хранит один-три символа любой незавершённой группы, так что границы кусков могут падать куда угодно: выравнивать их не нужно:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_DecodeInit(ctx);
FILE *in = fopen("huge.b64", "rb");
FILE *outf = fopen("huge.bin", "wb");
char inbuf[65536];
unsigned char outbuf[49152 + 4];
size_t got;
int ok = 1;
while (ok && (got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
int outl = 0;
int r = EVP_DecodeUpdate(ctx, outbuf, &outl,
(const unsigned char *)inbuf, (int)got);
if (r < 0) {
ok = 0;
} else if (outl > 0) {
fwrite(outbuf, 1, (size_t)outl, outf);
}
}
int tail = 0;
if (ok && EVP_DecodeFinal(ctx, outbuf, &tail) == 1 && tail > 0) {
fwrite(outbuf, 1, (size_t)tail, outf);
}
EVP_ENCODE_CTX_free(ctx);
fclose(in);
fclose(outf);
return ok ? 0 : 1;
}
Пиковая память - два буфера в пределах десятков килобайт, независимо от размера файла, и повреждённый файл падает быстро: EVP_DecodeUpdate возвращает -1 на том куске, где повреждение, и вы можете сообщить смещение, а не пожимать плечами. Одна оговорка по библиотеке для этого пути: декодер APR-Util работает со строками, завершёнными NUL, и ведёт подсчёт в int (его возвращаемое значение - int, а вход ограничен внутренней константой чуть ниже 3 ГБ), так что для многигагабайтовых файлов он вне зачёта. Если нужен прогресс, считайте записанные байты - это ваша позиция во выводе, а позиция во входе - примерно четыре третьих от неё.
Ловушки, все - чисто C
Собраны в одном месте ловушки, специфичные для того, чтобы делать это на C:
- Разовый с нулевым заполнением.
EVP_DecodeBlockвозвращает длину кванта, а не длину данных.TQ==сообщает три байта, а несёт один. Всегда пересчитывайте настоящую длину из завершающих знаков заполнения - или используйте поточную пару. - Декодированные байты - это не строка. Результат может содержать NUL-байты и может не быть UTF-8. Никакого
strlen, никакогоprintf("%s"), никакой передачи функциям, которые предполагают текст. Носите (указатель, длина) повсюду. - Размер буфера - ваша работа. C не вырастит ваш выходной буфер, и декодеры тоже не будут: update от OpenSSL пишет то, что декодирует, в то пространство, что вы дали. Делайте его размером
in_len * 3 / 4 + 3(плюс накладные расходы на перенос, если вход перенесён, а декодируете вы хелпером, который не чистит), и держите проверку потолка в каждой обёртке. - Обращения к таблице через signed char. Если вы однажды напишете декодер сами, классический баг - использовать входной байт как индекс в таблицу на 256 ячеек обычным
charна платформе, где char имеет знак: байт0xFFпревращается в-1, и вы лезете в память назад. Индексируйте значениямиunsigned charилиunsigned, всегда. - Опасны молчаливые. APR-Util останавливается на первом невалидном символе и молчит; GLib пропускает мусор и молчит. OpenSSL и Mbed TLS падают громко. Если ваш вход недоверенный, молчание библиотеки - это баг в вашей программе, а не в библиотеке.
- Командная строка съедает переводы строк.
openssl base64 -dдекодирует ноль байтов, если во входе нет перевода строки. Shell-конвейеры, срезающие завершающие переводы строк (tr -d '\n',xargs, сохранение в редакторе без финального перевода строки), дадут пустой вывод без единой ошибки. - int против size_t. Разовый API OpenSSL принимает длину
int, APR-Util используетintповсюду, а API Mbed TLS и GLib -size_t. Арифметика со смешанными длинами между ними - то место, где signed/unsigned-предупреждения прячут настоящие баги, - и то место, где живёт 2-ГБ потолок APR. - Пробельные символы неоднородны. OpenSSL пропускает любые пробельные символы в любом месте; Mbed TLS допускает CRLF/LF между группами и пробелы сразу перед переносом, но не после него и не посреди строки; CLI-инструменты различаются. Пелод, валидный для одного декодера, может быть невалидным для другого, и «у меня на машине работало» обычно означает «мой декодер был ленивее».
Хорошие привычки, собранные вместе
Проверяйте, прежде чем доверять: проверка формы (символы алфавита, не больше двух завершающих знаков заполнения) ловит очевидный мусор ещё до декодирования, но только настоящее декодирование понимает семантику Base64, поэтому последнее слово - за строгим декодером. Используйте поточную пару OpenSSL, когда нужны честные длины или кусочный вход, и разовую, когда пелод маленький и вы тут же поправляете его длину. Держите пары (указатель, длина) вместе и никогда не давайте декодированному буферу встретиться со строковой функцией. Сравнивайте аутентификационный материал через CRYPTO_memcmp. Загляните в магические байты, прежде чем поверить имени файла или заявленному MIME-типу. И относитесь к Base64 как к тому, чем он является: к формату упаковки, к небольшой коробке для байтов, а не к замку. Ничего в этих 64 буквах не делает ваши данные приватными.
Краткая история Base64 в C
История начинается с почты. В 1990 и 1991 годах группа криптографов набросала Privacy Enhanced Mail - систему электронной почты с подписью и шифрованием, - и им нужен был способ провозить бинарные данные через 7-битную сеть. Их ответ, стандартизированный как RFC 1421 в 1993 году, кодировал данные по шесть бит на символ, «base 64», строками по 64 символа, и реализация была, разумеется, на C. Примерно в то же время пришёл веб со своим MIME: RFC 1521 (1993), а затем RFC 2045 (1996), который сохранил тот же алфавит, ослабил длину строки до 76 и превратил Base64 в формат вложений молодой интернет-сцены.
Стандартная библиотека C проспала весь рейс. Стандарт C89 вышел в 1990 году, за три года до MIME, и комитет языка с тех пор ни разу не добавил функцию Base64: ни в C99, ни в C11, ни в C23 (ревизия 2024 года). Так что экосистема выросла вокруг библиотек: OpenSSL носит EVP-рутины кодирования и декодирования в libcrypto с тех пор, как кто-либо линкует OpenSSL ради TLS, Mbed TLS (переименована из PolarSSL в 2015 году) держала маленькую строгую пару для встроенных систем, APR-Util поставлялась с Apache, когда серверу нужно было декодировать собственные заголовки аутентификации, а GLib добавила свою троицу функций для десктопа. Стандарты догоняли реализации: RFC 3548 в 2003 году навёл порядок в старых определениях, а RFC 4648 в 2006 году (Base-N Encodings) оформил алфавиты, URL-безопасный вариант и правила безопасности, на которые опирается эта статья. Как нельзя точнее: раздел 11 того RFC ссылается на референсную реализацию ISO C99 - собственный пример декодера стандарта написан на C, и это говорит обо всём о том, где живёт этот формат.
Весёлые факты, C-издание
Несколько фактов с C-привкусом, которые просто приятно знать:
- Название - это математика, а не маркетинг: каждый выходной символ несёт ровно шесть бит, и 2 в 6-й степени - 64. «Base64» - это основание, произнесённое вслух.
- В алфавите 65 символов, а не 64: 64 символа плюс
=, который RFC 4648 называет «дополнительным 65-м символом», используемым для специальной функции обработки. Знак заполнения - рабочий, а не буква. - OpenSSL переносит закодированный вывод каждые 64 символа (PEM-привычка), а coreutils - каждые 76 (MIME-привычка). Разница в 12 символов - это два десятилетия почтовой истории, которые видны в выводе двух команд на одной и той же машине.
- Автор команды
base64из GNU coreutils - Simon Josefsson, тот же человек, который написал RFC 4648. Стандарт и одна из самых используемых его реализаций делят автора, и именно поэтому они сошлись во мнениях по каждому краевому случаю. - Mbed TLS делает обращения к таблицам через помощников постоянного времени (
mbedtls_ct_base64_*), так что скорость декодирования не выдаёт, какие символы она видела. Деталь, которую вы никогда не заметите, но которой будете рады, что она есть. TQ==- самый маленький нетривиальный пелод: один настоящий байт, два знака заполнения. Это идеальный тестовый вектор: разовый декодер OpenSSL отдаёт для него три байта, его поточный декодер - один, Mbed TLS - один, и GLib - один. Четыре библиотеки, два ответа, и разница - нулевое заполнение.- Функции base64 APR - единственные в этой статье, кому не всё равно на EBCDIC, потому что httpd до сих пор работает на машинах, где буквы - не ASCII. Стандартная библиотека C никогда не встречала мейнфрейм; APR - встречала.
- Пустой пелод - универсальная единица: каждая библиотека кодирует и декодирует нулевой вход в нулевой вывод, без ошибки. Если ваш декодер задыхается на пустой строке, у вас баг, а не формат.
Переходим на сторону кодирования
Вот и вся сторона декодера, и именно здесь живёт большая часть боли, потому что при декодировании вы встречаете чужие данные: чужие решения по заполнению, чужие переводы строк, чужие повреждённые байты, чужие токены. Обратное направление - превращение байтов в Base64-строку, - это более спокойное существо со своим составом ловушек: точная арифметика буферов, вопрос о переносе строк и счёт за размер, который приходит каждому отправителю. Кодирование Base64 на C подробно разобрано в связанной статье, на которую ведёт ссылка с этой страницы, и она складывается в пару с этой статьёй так же, как декодер складывается в пару с энкодером: прочитайте обе - и ни одно направление больше не сможет вас удивить.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в C: полное руководство