Кодирование Base64 в C: полное руководство
У вас есть байты. Может быть, это JPEG, прочитанный с диска, может быть, токен, может быть, пароль, который клиент вот-вот передаст, а может быть, сырые байты файла, который какой-то конвейер ждёт внутри JSON-поля. А где-то дальше по дороге есть канал, который говорит только на языке текста: JSON-строка, URL, тело письма, файл настроек, столбец базы данных, который притворяется текстом. Именно тут на сцену выходит кодирование Base64: оно переписывает каждые три байта сырых данных в четыре символа из 64-буквенного алфавита, так что результат - чистый ASCII, который переживёт любой текстовый конвейер на земле. Домашняя страница этого сайта полностью объясняет формат; эта статья о том, как хорошо выполнить работу на C, где - как обычно - язык не сделает её за вас.
Единственное число, которое стоит держать в голове: кодирование растит ваши данные. Три байта становятся четырьмя символами, так что каждый пелод покидает вашу программу примерно на треть большим, а при добавлении переводов строк - ещё чуть больше. Это и есть налог, и обойти его не получится, - но на C у налога есть конкретная строка в расчёте, потому что выходной буфер выделяете вы сами, и буфер должен быть ровно достаточно большим. Разберитесь с арифметикой один раз - и каждый энкодер в этой статье станет предсказуемым: без переполнений, без недобора, без гаданий, куда приземлится следующий байт. Дальше есть чем выбрать - OpenSSL, Mbed TLS, APR-Util, GLib, - и выбор имеет значение, потому что каждый переносит по-своему, завершает по-своему и ошибается по-своему.
Четыре энкодера, четыре характера
Все четыре библиотеки кодируют стандартный алфавит правильно и идентично: одни и те же байты на входе - те же символы на выходе, всегда. Различия - в упаковке, и именно в упаковке прячутся баги совместимости. Вот картина:
| Библиотека | Заголовочный файл | Стиль вывода | Режим сбоя |
|---|---|---|---|
| OpenSSL (libcrypto) | <openssl/evp.h> |
Без переводов строк; дописывает завершающий NUL | По сути нет (только выделение памяти) |
| Mbed TLS | <mbedtls/base64.h> |
Без переводов строк; завершён NUL | Код «буфер слишком мал» с нужным размером |
| APR-Util | <apr-1.0/apr_base64.h> |
Без переводов строк; дописывает NUL | Нет - доверяйте только размеру своего буфера |
| GLib | <glib.h> |
Без переводов строк; завершён NUL, выделено в куче | Возвращает NULL (только выделение памяти) |
Заметьте, чего нет в таблице: ни одна из них по умолчанию не переносит строки. Это сделано намеренно - RFC 4648 говорит, что реализации не должны добавлять переводы строк, если окружающая спецификация явно их не просит, - и это облегчение: случайный перевод строки внутри JSON-строки или URL - это баг, а не фича. Переносы существуют для почты и PEM, и когда они нужны, их либо дают поточный путь OpenSSL, либо переносите сами за пять строк (раздел о почте показывает оба варианта). Для выбора библиотеки: OpenSSL, если вы уже линкуете его, Mbed TLS для встроенных сборок, где спорят о каждом килобайте, APR-Util внутри экосистемы Apache, и GLib, если остальная часть программы уже GLib. Установка: libssl-dev (Debian/Ubuntu) или openssl-devel (Fedora/RHEL) или brew install openssl (macOS); libmbedtls-dev для Mbed TLS; libaprutil1-dev плюс libapr1-dev для APR-Util; glib2.0-dev для GLib.
Сделайте расчёт до того, как выделять буфер
Прежде чем какой-либо код - арифметика, потому что C не спасёт вас от слишком маленького буфера. Каждые три входных байта дают ровно четыре выходных символа. Если длина входа не кратна трём, последняя группа всё равно даёт четыре символа, а неиспользуемые слоты помечаются знаками заполнения =: один входной байт становится четырьмя символами с двумя знаками заполнения, два входных байта - четырьмя символами с одним. Значит, точная закодированная длина для n байтов:
size_t encoded_chars(size_t n) {
return ((n + 2) / 3) * 4;
}
Для 1000 байтов это 1336 символов; для 1 байта - 4; для 0 - 0. Из этого следуют две поправки. Первая: и OpenSSL, и Mbed TLS дописывают завершающий NUL после данных (а Mbed TLS при запросе размера зарезервирует под него место), так что буферу нужен один лишний байт: encoded_chars(n) + 1. Вторая: если нужен вывод с переносами строк, добавьте по одному переводу строки на строку: поточный энкодер OpenSSL выдаёт 64-символьную строку на каждые 48 входных байтов, так что перенесённая длина - encoded_chars(n) + (n + 47) / 48. Проверьте на 1000 байтах: 1336 символов плюс 21 перевод строки - это 1357, и ровно это произведёт энкодер. Запишите формулу один раз в виде функции и используйте повсюду; именно она отделяет «влезает» от повреждения кучи в три часа ночи.
size_t b64_buffer_size(size_t in_len) {
return ((in_len + 2) / 3) * 4 + 1; /* символы + NUL */
}
size_t b64_buffer_size_wrapped(size_t in_len) {
return ((in_len + 2) / 3) * 4 + (in_len + 47) / 48 + 1;
}
OpenSSL: один блок или кран, который не закрывают
Разовая функция OpenSSL - рабочая лошадка, и самая дружелюбная из всех:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *text = "Mane";
unsigned char out[32];
int n = EVP_EncodeBlock(out, (const unsigned char *)text,
(int)strlen(text));
printf("len=%d str=%s\n", n, (char *)out);
return 0;
}
Она записывает закодированные символы в out, дописывает после них NUL и возвращает длину без NUL - так что печать через %s безопасна, а длина доступна, если она нужна. Выводной буфер должен вмещать encoded_chars(n) + 1 байт. Пути ошибки, который нужно обрабатывать, нет: кодирование не может провалиться, потому что любой байт - законный вход, и у функции нет понятия валидации входа, на котором она могла бы споткнуться. Единственный способ сломать её - дать буфер слишком маленький, а раздел про арифметику - противоядие.
Поточная пара - для случаев, когда данные большие или приходят кусками. EVP_EncodeUpdate обрабатывает вход блоками по 48 байтов и пишет 64 символа плюс перевод строки (65 байтов) на каждый полный блок, удерживая остаток в контексте до новых данных или финального вызова:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
if (ctx == NULL) {
return 1;
}
EVP_EncodeInit(ctx);
unsigned char in[1000];
for (int i = 0; i < 1000; i++) {
in[i] = (unsigned char)(i % 251);
}
unsigned char out[1400]; /* 1336 символов + 21 перевод строки + запас */
int outl = 0;
int total = 0;
EVP_EncodeUpdate(ctx, out + total, &outl, in, 1000);
total += outl;
EVP_EncodeFinal(ctx, out + total, &outl);
total += outl;
int nl = 0;
for (int i = 0; i < total; i++) {
if (out[i] == '\n') nl++;
}
printf("encoded 1000 bytes into %d chars, %d newlines\n",
total, nl);
EVP_ENCODE_CTX_free(ctx);
return 0;
}
Вывод этой программы - 1357 байтов и 21 перевод строки: формула из раздела про арифметику, ставшая явью. Две практические заметки. Заметка о версии: с OpenSSL 1.1.0 (2016) тип контекста - непрозрачный, так что выделяйте через EVP_ENCODE_CTX_new() и освобождайте через EVP_ENCODE_CTX_free(); старый паттерн на стеке EVP_ENCODE_CTX ctx;, которую можно найти в туториалах для 1.0.2 и старше, не компилируется с современными заголовками, включая OpenSSL 3.x. И заметка о дизайне: поскольку из EVP_EncodeUpdate выходят только полные блоки по 48 байтов, самая чистая кусочная конвейерная цепочка кормит его размерами, кратными 48 - тогда каждая написанная функцией строка - законченная строка, и только EVP_EncodeFinal решает, как завернуть хвост. Если ваш вход приходит произвольными размерами (чтение из сети), контекст всё равно выровняет всё за вас; привычка к кратности 48 - это просто то, что делает вывод предсказуемым.
Mbed TLS: спросите, затем кодируйте и получите строку
Кодирование Mbed TLS - с самым чистым контрактом в компании; оно построено вокруг запроса размера, который можно вызвать с NULL-назначением:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
const char *text = "Mane";
size_t slen = strlen(text);
size_t needed = 0;
int rc = mbedtls_base64_encode(NULL, 0, &needed,
(const unsigned char *)text, slen);
if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
printf("size query failed: %d\n", rc);
return 1;
}
printf("needs %zu bytes\n", needed);
unsigned char *out = malloc(needed);
size_t olen = 0;
rc = mbedtls_base64_encode(out, needed, &olen,
(const unsigned char *)text, slen);
if (rc != 0) {
printf("encode failed: %d\n", rc);
free(out);
return 1;
}
printf("olen=%zu str=%s\n", olen, out);
free(out);
return 0;
}
Прочитайте детали внимательно - это мастер-класс по дружелюбному API. Запрос размера сообщает needed как закодированные символы плюс один за NUL - для «Mane» это 8 плюс 1, то есть 9, - и обозначает себя кодом «буфер слишком мал» (MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL, это -0x002A), потому что NULL-назначение по определению слишком мало. Затем настоящий вызов пишет символы и NUL, и *olen возвращается равным 8 - длине без терминатора, - так что буфер уже готовая печатаемая C-строка. Если дать ему буфер на байт меньше, вернётся тот же код «слишком мало», а в *olen - нужный размер, так что сбой говорит, на сколько именно вы не дотянули. Ещё одна заметка: библиотека делает обращения к таблицам через помощников постоянного времени - маленькая аккуратность, которой не увидите у большинства энкодеров.
APR-Util и GLib: два других
Энкодер APR-Util - простая пара функций с длинами int:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <apr-1.0/apr_base64.h>
int main(void) {
const char *text = "Mane";
int needed = apr_base64_encode_len((int)strlen(text));
char *out = malloc((size_t)needed);
int n = apr_base64_encode(out, text, (int)strlen(text));
printf("n=%d (includes NUL) str=%s\n", n, out);
free(out);
return 0;
}
Здесь и apr_base64_encode_len(), и возвращаемое значение считают NUL, так что n на один больше, чем число символов, - разница в учёте по сравнению с OpenSSL и Mbed TLS, которая уже рождала off-by-one баги в более чем одной кодовой базе. Действует тот же 32-битный лимит длины: для значений вблизи 2 ГБ и выше это не ваш инструмент. Есть ещё apr_base64_encode_binary(), который на EBCDIC-машинах пропускает перевод входа из EBCDIC в ASCII - то есть на тех мейнфреймах, где этот перевод иначе бы случился; во всех остальных местах это не меняет ничего. Та пара, бинарный вариант и соответствующие функции декодирования - фактически вся поверхность base64, которую показывает apr-util: нет пулового варианта, нет поточного, так что malloc-паттерн выше - единственный паттерн.
Энкодер GLib - в куче-выделяющем стиле: вы получаете NUL-завершённую строку и обязанность за неё:
#include <stdio.h>
#include <glib.h>
int main(void) {
const char *text = "Mane";
gchar *enc = g_base64_encode((const guchar *)text, strlen(text));
printf("%s\n", enc);
g_free(enc);
return 0;
}
Без переносов, NUL-завершено, освобождайте через g_free - именно это сообщает статическим анализаторам аннотация G_GNUC_MALLOC на прототипе. Когда переводы строк всё-таки нужны, инструмент - инкрементная пара: g_base64_encode_step() принимает целое состояния и флаг break_lines и говорит, сколько выходных байтов он записал, а g_base64_encode_close() доделывает последнюю неполную группу. Это та же форма машины состояний, что и у поточной пары OpenSSL, только в стиле параметров GLib.
URL-безопасный Base64 кустарным способом
Все четыре библиотеки выше говорят на стандартном алфавите: A-Z, a-z, 0-9, плюс и слэш. Веб, однако, всё чаще говорит на втором диалекте из раздела 5 RFC 4648, названном base64url: та же кодировка, где + заменён на -, / заменён на _, а завершающее заполнение = выброшено, когда длина известна. JSON Web Tokens, state-параметры OAuth и несметные API-идентификаторы используют его, потому что + и / в URL оба опасны, а - и _ - незарезервированные символы, которые проходят свободно. Поскольку ни одна C-библиотека не выдаёт этот диалект из коробки, вы делаете его сами - и это всего два небольших изменения, потому что энкодер со стандартным алфавитом у вас уже есть:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
size_t i = 0;
for (const char *p = std_b64; *p && *p != '='; p++) {
char c = *p;
if (c == '+') c = '-';
if (c == '/') c = '_';
out[i++] = c;
}
out[i] = '\0'; /* дополнение намеренно выброшено */
}
Использование в два шага - закодировать стандартно, затем перевести:
unsigned char enc[32];
EVP_EncodeBlock(enc, (const unsigned char *)"hi>there", 8);
char url_safe[32];
to_base64url((const char *)enc, url_safe, sizeof(url_safe));
printf("%s\n", url_safe); /* aGk-dGhlcmU */
Два предостережения. Цикл останавливается на первом = - именно это и выбрасывает заполнение; не «чините» это, выброс заполнения и есть цель (получатель, которому оно нужно, может дособрать его по длине). И размер out берите под полную закодированную длину, а не меньше: перевод идёт символ в символ до знаков заполнения, так что ёмкость, которую вы уже выделили под стандартную форму, подходит ровно. Честная заметка про совместимость: если в ваших данных случайно нет байтов, которые кодируются в + или /, стандартная и URL-безопасная формы идентичны, и ничего никогда не пожалуется на перепутывание - баг проявится, только когда данные наконец будут содержать один из них. Считайте диалект свойством канала (URL, токены), а не свойством данных.
Текст и кодировки символов: UTF-8 - это просто байты
Вопрос, который удивляет новичков в C: что случится с текстом с акцентами, эмодзи, CJK-символами? Ответ - самый освобождающий факт этой статьи: ничего не должно случиться. Base64 работает с байтами, и C - язык байтов. Если ваш текст в UTF-8 (в 2026 году он, скорее всего, именно в нём), то UTF-8-кодировка «café» - это пять байтов - 63 61 66 c3 a9 - и Base64 кодирует эти пять байтов ровно так же, как любые другие пять, производя Y2Fmw6k=. Без параметра кодировки, без BOM, без шага конвертации, без вызова библиотеки. Кодек не знает и не заботится, что означают байты; в этом и состоит весь дизайн.
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *utf8 = "caf\303\251"; /* café в UTF-8 */
unsigned char enc[32];
int n = EVP_EncodeBlock(enc, (const unsigned char *)utf8,
(int)strlen(utf8));
printf("%.*s\n", n, (char *)enc); /* Y2Fmw6k= */
return 0;
}
Две ловушки сидят на краях этого раздела. Первая - wchar_t: если ваши данные пришли в виде широких символов, сначала нужно конвертировать их в последовательность байтов (на Linux это UTF-8, через wcstombs() или ваш локальный механизм), и только потом кодировать - Base64 массива wchar_t - это кодирование внутреннего представления, а не текста, и оно будет отличаться между платформами. Вторая - кодировка исходника: строковый литерал в вашем C-файле закодирован в кодировке исходного файла (в любом современном проекте это UTF-8), так что запись "café" напрямую работает, пока файл действительно UTF-8 и компилятор об этом знает (в современных тулчейнах - да, по умолчанию). Кодируйте те байты, которые вы собираетесь отправить, и оставьте получателю решение, что эти байты значат.
Изображения: из буфера в строку
Самая частая «боевая» кодировочная задача в C для веба: бинарный файл - JPEG, PNG, иконка - должен пройти через текстовый канал, так что превращается в Base64-строку. Рецепт: прочитать файл в буфер, рассчитать размер вывода формулой, закодировать и двигаться дальше. Часть с чтением файла заслуживает внимания, потому что именно там C-программы действительно ломаются:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("photo.png", "rb");
if (f == NULL) {
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char *data = malloc((size_t)size);
size_t got = fread(data, 1, (size_t)size, f);
fclose(f);
size_t out_cap = ((got + 2) / 3) * 4 + 1;
unsigned char *enc = malloc(out_cap);
int n = EVP_EncodeBlock(enc, data, (int)got);
printf("png of %zu bytes becomes %d base64 chars\n", got, n);
free(data);
free(enc);
return 0;
}
Заметки: rb для чтения бинарного - не подлежит обсуждению на любой платформе, потому что текстовый режим может переписать байты и изменить got; пара fseek/ftell для определения размера (для пайпов и сокетов без seek читайте в растущий буфер); и got вместо size для кодирования, потому что короткий read - реальная возможность. Изображение в 1 МБ становится текстом примерно на 1,33 МБ - это тот самый налог, выставленный заранее, и именно поэтому изображение в JSON в виде base64 стоит заставить вас остановиться и спросить, не дешевле ли была бы настоящая загрузка файла.
Файлы и привычка к .b64
Другая сторона работы с изображениями: нужно записать на диск Base64-форму файла - .b64-компаньон, бэкап бинарника в тексто-безопасном хранилище, вложение для почтовика. Та же арифметика, другой код записи. Привычка, которую стоит перенять, - писать текстовый вывод с явными переводами строк той длины, которую ожидает принимающая сторона: 76 для почты, 64 для PEM-подобных потребителей, или вообще без переносов, если получатель - ваш собственный код:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("data.bin", "rb");
if (f == NULL) {
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char *data = malloc((size_t)size);
size_t got = fread(data, 1, (size_t)size, f);
fclose(f);
unsigned char *enc = malloc(((got + 2) / 3) * 4 + 1);
int n = EVP_EncodeBlock(enc, data, (int)got);
FILE *out = fopen("data.b64", "w");
for (int i = 0; i < n; i += 76) {
int chunk = i + 76 < n ? i + 76 : n;
fwrite(enc + i, 1, (size_t)(chunk - i), out);
fputc('\n', out);
}
fclose(out);
free(data);
free(enc);
return 0;
}
Цикл пишет 76-символьные строки и последнюю, короче; декодер, который пропускает пробельные символы (каждый серьёзный делает это), вообще не будет заботиться о длине строк, - поэтому консультироваться нужно с получателем, а не со своим вкусом. Оставляйте выходной файл в текстовом режиме (w) на пишущей стороне, если хотите платформенные окончания строк, или в wb, если получатель строго считает символы; а если считает - он хочет ровно то, что вы пообещали: 76 символов плюс один перевод строки, и ничего больше. Это обещание, а не байты - оно и делает файл .b64 форматом.
Data URI: встраивание по-настоящему
Data URI (RFC 2397) - обратная сторона любимого прибытия из статьи про декодирование: вместо того чтобы принимать data:image/png;base64,..., вы собираете его сами. Форма такая: data:, media type, ;base64, запятая, пелод, - а собрать его в C - один snprintf после кодирования:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
/* 8-байтовая сигнатура PNG */
const unsigned char png_sig[8] =
{ 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' };
unsigned char enc[32];
int n = EVP_EncodeBlock(enc, png_sig, 8);
char uri[96];
snprintf(uri, sizeof(uri), "data:image/png;base64,%.*s",
n, (char *)enc);
printf("%s\n", uri);
return 0;
}
Три заметки о дизайне. Media type, который вы положите в URI, - это заявление, за которое отвечаете вы: сначала понюхайте магические байты настоящего файла, иначе data:image/png, несущий JPEG, запутает каждого потребителя по-своему. Флаг ;base64 обязателен, когда пелод - это Base64; опустите его, и пелод должен стать перцент-закодированным текстом, что совсем другой формат. И сам RFC советует, что data URI - для коротких значений: встроить логотип на 5 МБ прямо в HTML-страницу - сработает, но это дизайн-запах, который уберёт настоящий URL ресурса. То же построение всё время встречается в JSON-API, где клиент хочет аватар в том же запросе, что и данные формы: закодировал, приписал, отправил.
HTTP и JSON: пелоды, которые выживают
Главная современная причина кодировать на C - это JSON. JSON-строка - последовательность символов с правилами экранирования, и сырые байты в неё не вписываются: NUL посреди строкового литерала - проблема C, литеральный перевод строки внутри JSON-строки - невалидный JSON, а произвольным байтам нужна определённая история с экранированием. Base64 обходит всю проблему, выдавая только те символы, которые JSON никогда не обязан экранировать: 64 символа алфавита плюс, в стандартном диалекте, =, и ни один из них - ни кавычка, ни обратный слэш. Бинарные данные входят как строка и выходят по ту сторону ровно теми же, какими вошли:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
unsigned char enc[64];
int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello", 5);
char json[160];
snprintf(json, sizeof(json),
"{\"avatar\": \"%.*s\"}", n, (char *)enc);
printf("%s\n", json);
return 0;
}
Напечатается {"avatar": "aGVsbG8="} - полноценный, валидный JSON-объект, без всякого механизма экранирования, а точность %.*s держит длину точной даже если вы когда-нибудь пересядете на энкодер, который не завершает NUL. Честная плата - размер: каждый байт, отправленный как JSON-строка, стоит вам 4/3 байта воздуха плюс имя поля и кавычки, так что бинарник на 10 КБ становится строкой на 13,3 КБ внутри JSON. Для изредка попадающихся небольших blob (иконки, миниатюры, подписи, токены) это приемлемая цена; для загрузки на 500 МБ это архитектура, о которой вы пожалеете, и настоящая загрузка файла - инструмент для этой работы. И ещё одна строка: + и / стандартного алфавита безопасны внутри JSON-строки, но если та же строка потом поедет в URL-запросе, они уже не безопасны - это забота раздела про URL-безопасность.
JWT: три части, один алфавит
Флагманский потребитель base64url на C - это JSON Web Token. Компактный JWT по RFC 7519 - три base64url-части, склеенные точками: заголовок, пелод, подпись, - и собрать его - приятное упражнение, потому что каждая часть - это функция, которая у вас уже есть: закодировать стандартно, перевести в URL-безопасный, подписать, повторить. Вот HS256-токен, собранный HMAC из OpenSSL:
#include <stdio.h>
#include <string.h>
#include <openssl/hmac.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
size_t i = 0;
for (const char *p = std_b64; *p && *p != '='; p++) {
char c = *p;
if (c == '+') c = '-';
if (c == '/') c = '_';
out[i++] = c;
}
out[i] = '\0';
}
int main(void) {
const char *secret = "my-hmac-secret-key";
const char *header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
const char *payload = "{\"sub\":\"114365\",\"name\":\"Alice\"}";
unsigned char hb[64], pb[64];
EVP_EncodeBlock(hb, (const unsigned char *)header,
(int)strlen(header));
EVP_EncodeBlock(pb, (const unsigned char *)payload,
(int)strlen(payload));
char hu[64], pu[64];
to_base64url((const char *)hb, hu, sizeof(hu));
to_base64url((const char *)pb, pu, sizeof(pu));
char signing_input[256];
snprintf(signing_input, sizeof(signing_input), "%s.%s", hu, pu);
unsigned char mac[EVP_MAX_MD_SIZE];
unsigned int mac_len = 0;
HMAC(EVP_sha256(), secret, (int)strlen(secret),
(const unsigned char *)signing_input,
(size_t)strlen(signing_input), mac, &mac_len);
unsigned char mb[64];
EVP_EncodeBlock(mb, mac, (int)mac_len);
char mu[128];
to_base64url((const char *)mb, mu, sizeof(mu));
printf("%s.%s.%s\n", hu, pu, mu);
return 0;
}
Структура учит двум вещам. Первая: вход для подписи - это две URL-безопасные части, склеенные точкой, - ровно те байты, которые увидит получатель, - поэтому перевод в base64url должен случиться до подписи, а не после; подпишете форму со стандартным алфавитом, и проверка у получателя провалится, а это баг, который компилируется, запускается и выглядит как несовпадение ключей. Вторая: заголовок и пелод - обычный JSON в Base64-обёртке: их может прочитать кто угодно, и в этом весь замысел. Токен - подписанная записка, а не запечатанный конверт, - поэтому не кладите в него ничего, что вы не хотели бы, чтобы прочёл перехвативший пользователь, и никогда, никогда не кладите пароль в JWT-пелод «потому что он закодирован». Base64-часть этой работы мала и скучна, а это высшая похвала, которую можно поставить JWT-реализации.
HTTP Basic Auth: собираем токен
Самый старый заголовок аутентификации - ещё и самая простая Base64-задача: username:password, закодированные в стандартном алфавите, после слова Basic. Собрать это в C - две строки, и единственная тонкость в том, что пароль может содержать двоеточие (и разрез на принимающей стороне нужно делать по первому):
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *user = "alice";
const char *pass = "s3cr3t";
char creds[128];
snprintf(creds, sizeof(creds), "%s:%s", user, pass);
unsigned char enc[160];
int n = EVP_EncodeBlock(enc, (const unsigned char *)creds,
(int)strlen(creds));
printf("Authorization: Basic %.*s\n", n, (char *)enc);
return 0;
}
Напечатается Authorization: Basic YWxpY2U6czNjcjN0. Предупреждение RFC действует и на отправляющей стороне: это кодирование, а не защита. По простому HTTP-соединению учётные данные - в одной команде base64 -d от любого на проводе, так что Basic auth - привычка только для HTTPS. (Современные альтернативы - bearer-токены, mTLS, - переиспользуют тот же механизм: собери строку, закодируй и положи в заголовок. Base64 - способ HTTP провозить структурированные данные через текстовые заголовки с тех самых пор, как у протокола появились заголовки.)
Почта и PEM: где живёт перенос
Почта - причина, по которой перенос строк существует вообще. SMTP ограничивает длину строки, поэтому MIME ограничил закодированные строки 76 символами (PEM, его предок, - 64), и каждая почтовая система чтёт этот лимит тридцать лет. Если ваша C-программа производит Base64 для тела письма или вложения, перенос - не необязательное украшательство: неперенесённая строка на 200 КБ будет отклонена или испорчена частью почтовой инфраструктуры. Поточный энкодер OpenSSL даёт перенесённый вывод бесплатно (с его родовой длиной в 64 символа), а когда нужен ровно 76, переносить разовый результат - это пятистрочный цикл:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
unsigned char enc[64];
int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello world", 11);
for (int i = 0; i < n; i += 76) {
int chunk = i + 76 < n ? i + 76 : n;
printf("%.*s\r\n", chunk, (char *)enc + i);
}
return 0;
}
Обратите внимание на \r\n: почте нужны окончания строк CRLF, и если закодированный текст - одна из нескольких MIME-частей, всё вокруг base64-блока подчиняется тем же правилам: длины строк, окончания CRLF, без исключений. PEM-файлы (формат большинства ключей и сертификатов) используют ту же идею со строками по 64 символа между метками -----BEGIN и -----END, и инструменты OpenSSL ждут увидеть эту броню, когда вы переписываете ключ, - так что если ваша программа трогает PEM, переносите через 64 и сохраняйте метки. Везде в остальном - JSON, URL, API, базы данных - действует правило RFC, и вы вообще не переносите.
Провоз значений через конфиги и столбцы
Тихий сценарий: значения, которые сломали бы текстовый формат, упаковывают в Base64, чтобы не сломали. DSN базы данных с точками с запятыми, пароль с кавычками, токен с переводом строки - ops-инженер кодирует их один раз, и файл настроек больше никогда не видит проблемные символы:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *dsn = "pg:host=db;password=qu\"ote";
unsigned char enc[128];
int n = EVP_EncodeBlock(enc, (const unsigned char *)dsn,
(int)strlen(dsn));
printf("DB_DSN_B64=%.*s\n", n, (char *)enc);
return 0;
}
Программа печатает точную строку для вставки в .env-файл, а C-код, который потом её читает, - это getenv плюс декодирование. Три честные оговорки - всё о том, чем это не является. Это не шифрование: любой, кто может прочитать файл настроек, декодирует значение одним вызовом, так что никогда не упаковывайте секрет в Base64 и не называйте это защитой. Это не экранирование: если формату нужно сохранить структуру, правильный инструмент - настоящее кодирование (процентное кодирование для URL, JSON-экранирование для JSON), а Base64 - для тех значений, которые эти форматы выразить не могут, - бинарных. И это стоит размера: значение, сохранённое в TEXT-столбце базы в виде Base64, занимает примерно на 33 процента больше места, чем оригинал, - для токенов это нормально, а для столбцов с файлами - ощутимая величина (для этого и существуют BLOB-столбцы).
Кодирование из shell
Прежде чем тянуться за cc-вызовом, помните, что оба стандартных инструмента кодируют, и быстро. coreutils - универсальный инструмент: base64 по умолчанию кодирует с переносом через 76 символов, -w меняет столбец, а -w 0 отключает перенос совсем:
base64 photo.png > photo.b64
base64 -w 0 photo.png > photo-oneline.b64
cat note.txt | base64 -w 0
Инструмент OpenSSL - та же работа с TLS-родословной упаковкой: openssl base64 (дружелюбный псевдоним openssl enc -base64) переносит через 64 символа, а -A переключает его на одну строку:
openssl base64 < photo.png > photo.b64
openssl base64 -A < photo.png > photo-oneline.b64
Зачем заботиться о разнице в переносе? Потому что если нижестоящий парсер считает символы, выводы двух инструментов по умолчанию не взаимозаменяемы: 76 на строку против 64 на строку - заметная разница в файле; парсер, выкидывающий пробельные символы, не заботится, а парсер, валидирующий длину строк, - заботится решительно. Когда ваша C-программа - производитель, а shell - потребитель (или наоборот), сначала договоритесь о переносе. Заметка про диалект для BSD-систем: флаг декодирования там исторически был -D, и старые выпуски macOS его всё ещё помнят; на стороне кодирования везде base64, да и этот раздел всё равно только про это направление.
Перекачиваем крупное
Кодировать многигагабайтовый файл одним malloc - это проблема памяти, в которой вы не нуждались. Поточный путь существует ровно ради этого, и блочная дисциплина OpenSSL делает код почти тривиальным: подавайте EVP_EncodeUpdate сколько даст файл, пусть он держит остаток каждого неполного 48-байтового блока в контексте, и записывайте 65 выходных байтов каждого блока прямо в целевой файл. Пиковая память - ваши два буфера, несколько десятков килобайт, - независимо от того, насколько велик файл:
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
if (ctx == NULL) {
return 1;
}
EVP_EncodeInit(ctx);
FILE *in = fopen("video.mp4", "rb");
FILE *out = fopen("video.b64", "w");
if (in == NULL || out == NULL) {
return 1;
}
char inbuf[48 * 1024]; /* кратное 48: чистые строки */
unsigned char outbuf[1024 * 65 + 65]; /* 65 выходных байтов на 48 входных, плюс запас */
size_t got;
while ((got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
int outl = 0;
EVP_EncodeUpdate(ctx, outbuf, &outl,
(const unsigned char *)inbuf, (int)got);
fwrite(outbuf, 1, (size_t)outl, out);
}
unsigned char tail[66];
int outl = 0;
EVP_EncodeFinal(ctx, tail, &outl);
fwrite(tail, 1, (size_t)outl, out);
EVP_ENCODE_CTX_free(ctx);
fclose(in);
fclose(out);
return 0;
}
В этом цикле работают две детали дизайна. Входной буфер - кратное 48 байтов, так что каждый вызов отдаёт энкодеру полные блоки, и каждая написанная им строка - законченная 64-символьная строка; финальный вызов завершает настоящий хвост. Если ваш вход приходит произвольными размерами (сокет, медленный диск), контекст всё равно правильно поглотит невыравнивание: выбор кратного 48 - вопрос предсказуемости вывода, а не правильности. Вторая деталь - размер выходного буфера: 65 байтов на 48 входных плюс запас под финальный блок (66) - это формула переноса из раздела про арифметику, применённая к каждому куску. Отчёт о прогрессе - одна строка: считайте байты, записанные в out, относительно общего количества, которое вы посчитали из размера файла, - и поскольку кодирование растит данные, выходной файл окажется примерно на 33 процента больше входного: выставите счёт диску заранее.
Острые грани кодирования
Ловушки, собранные вместе, все - C-формы:
- Сдвиг на единицу, в обе стороны. OpenSSL возвращает длину без своего NUL; запрос размера Mbed TLS включает место под NUL; APR считает NUL в своих длинах. Три библиотеки, три конвенции учёта. Запишите функцию размера буфера один раз (раздел про арифметику) и перестаньте делать арифметику в голове в точке вызова.
- NUL - это байт, за который нужно заплатить. Каждый энкодер в этой статье хочет лишний байт на выходе под терминатор, и то же сделает любой ручной допис в буфер, который вы напишете. Буфер, рассчитанный ровно на
encoded_chars(n), оказывается на байт мал с того момента, как кому-то нужно, чтобыprintf("%s")работал. - Кодирование не может провалиться, так что нельзя давать ему переполняться. Нет ни одного кода ошибки, который поймал бы слишком маленький буфер: энкодер с удовольствием напишет за его конец. Модель сбоя кодирования Base64 на C - целиком ваша: рассчитайте размер правильно, иначе он испортит память без единой диагностики.
- Двойное кодирование - классическая тихая ошибка. Значение, которое уже Base64, пропущенное через энкодер ещё раз, даёт совершенно валидную Base64-строку, которая декодируется в Base64-строку, а не в данные. Симптом - «декодируется, но не в то» - на его поиск уходит полдня. Если значение пришло «предварительно закодированным», прежде чем считать его сырыми данными, проверьте, что его длина кратна четырём и что в нём только символы алфавита; если оно уже закодировано - пропустите кодирование.
- Плюс в URL. Вывод со стандартным алфавитом, положенный в строку запроса, приезжает с
+, превращённым в пробел, к тому моменту, когда ваш сервер разбирает форму: в перцент- и форм-кодировании+- это пробел. Токены и ID, путешествующие в URL, требуют URL-безопасный диалект - точка. - Текстовый режим не на той стороне конвейера. Чтение бинарного файла в текстовом режиме может переписать байты (на некоторых платформах) и изменить вашу длину; запись перенесённого Base64 с неверной конвенцией окончаний строк ломает получателей, которые считают символы.
rbдля бинарного входа, явные\r\nили\nтам, где спецификация требует одного из них, и никогда не давайте C-рантайму молча решать, какие у вас окончания строк. - Переполнение int в расчёте размера.
((n + 2) / 3) * 4вint-арифметике переполняется для входов выше примерно 1,5 ГБ, давая маленькое положительное «нужный размер» и размолотую кучу. Делайте расчёт вsize_t(илиuint64_t); в том числе поэтому API APR на int имеет 2-ГБ потолок, который нельзя убрать инженерией. - Перенос там, где получатель его не ждёт. RFC 4648 говорит: никаких переводов строк, если окружающая спецификация их не просит. Перевод строки внутри значения JSON-строки невалиден; внутри URL - это уже другой запрос. Переносите для почты, переносите для PEM и больше нигде.
Короткий чек-лист
Считайте размер буфера формулой, а не на глаз, и держите одну функцию размера на весь код. Держите (указатель, длина) вместе даже когда буфер NUL-завершён, потому что длина - это контракт, а NUL - это удобство. Выбирайте диалект по каналу: стандартный для JSON и тел, URL-безопасный для URL и токенов, перенесённый для почты и брони, неперенесённый - везде в остальном. Проверяйте магические байты, прежде чем заявлять MIME-тип в data URI. Никогда не используйте Base64 как шифрование, как замену перцент-кодированию и как место, где прятать секрет: это коробка, а не замок. И когда данные большие - перекачивайте: блочные энкодеры спроектированы ровно для этого, и постоянная память - в этом и есть весь смысл.
История: как упаковка стала стандартом
История энкодера - это история длин строк. Первый Base64 был C-программой начала 1990-х. Privacy-Enhanced Mail (RFC 1421, 1993) нужно было провозить бинарные данные через 7-битную почту, и его авторы выбрали по шесть бит на символ строками по 64 символа: 64 - это реликт терпимости SMTP к длине строк, и C-код выполнял упаковку табличным запросом за табличным запросом. Когда MIME стандартизовал тот же алфавит для веба (RFC 1521 в 1993, RFC 2045 в 1996), он ослабил строку до 76 символов, и мир нёс две привычки - 64 и 76, - обе претендовавшие на звание «того самого» размера строки Base64. Энкодеры подхватили: поточный путь OpenSSL держал 64 (его PEM-наследие), инструмент coreutils выбрал 76 (его MIME-наследие), и два инструмента на одной машине до сих пор не сходятся, куда ставить переводы строк. Стандарт занял позицию наконец-то в 2006 году: RFC 4648 сказал, что реализации вообще не должны добавлять переводы строк, если ссылающаяся спецификация явно не велит, - именно поэтому каждая библиотека в этой статье по умолчанию выдаёт неперенесённый вывод, и именно поэтому перенос теперь - фича по явному запросу для почты и брони. Сами алфавиты, правила заполнения и каноничное правило «дополнительные биты должны быть нулями» происходят из тех более ранних RFC о PEM и MIME, и 4648 переизлагает их как канонические правила семейства. А раздел 11 того RFC указывает на референсную реализацию - программу ISO C99, размещённую снаружи, потому что сам код «не мог быть включён в этот RFC по процедурным причинам», - ещё одно напоминание, что в этом формате C - не гражданин второго сорта. Стандартная библиотека C, со своей стороны, так и не догнала: C89 замёрз в 1990 году, до того как что-либо из этого существовало, и C23 в 2024 году по-прежнему поставляется без функции Base64. Так что библиотеки, которые вы линкуете, и есть стандарт, и выбор между ними - маленькое, но настоящее дизайн-решение, - именно об этом и была эта статья.
Странные маленькие факты
Факты, которые просто приятны, все - про упаковочную сторону на C:
- «64» - это основание: каждый выходной символ - шесть бит, и 2 в 6-й степени - 64. Формат называет свой алфавит так же, как C называет свои целые: по тому, чем число на самом деле является.
- 48-байтовый блок поточного энкодера OpenSSL - не произвольный учёт: 48 входных байтов - это ровно 16 групп по 3, а 64 выходных символа - ровно 16 групп по 4. Оба числа кратны 16, и это та самая округлость, от которой аппарат и cache-линии чувствуют себя прекрасно, - или хотя бы радуют людей, читающих код.
- Один входной байт кодируется в четыре символа, два из которых -
=. Самый маленький возможный непустой пелод - это 50 процентов заполнения: самое расточительное кодирование в формате, и именно его используют все тестовые наборы, потому что его так легко записать не так. - Mbed TLS - единственный энкодер в этой статье, который делает обращения в постоянном времени, потому что люди, пишущие встроенную криптографию, не доверяют обращениям к таблицам с переменным временем даже в кодеке, который не шифр. Паранойя передаётся.
- Правило канонического кодирования - неиспользуемые биты заполнения должны быть нулями - звучит тривиально, пока не узнаешь, что нарушение означает: две разные строки могут декодироваться в одни и те же байты, что ломает любую существующую проверку «является ли эта строка кодировкой того файла?». Ваши энкодеры все его соблюдают; именно поэтому base64 - хеш-стабильное представление и может подставлять имя файла в content-хранилище.
EVP_EncodeBlockOpenSSL - одна из редких C-функций, у которых возвращаемое значение, собственный вывод и NUL-терминатор все согласны: она пишетnсимволов, NUL и возвращаетn. В языке, прославленном off-by-one, это момент покоя.- APR-Util - единственный энкодер здесь, который спрашивает, что такое EBCDIC, потому что Apache до сих пор работает на машинах, где буквы стоят в другом порядке, чем в ASCII. На тех машинах «кодирование» строки включает молчаливую пересортировку её алфавита.
- Пустой вход кодируется в пустую строку во всех библиотеках, без заполнения и без переводов строк. Единичный элемент формата, на месте и в порядке во всех четырёх, - что делает его самым дешёвым unit-тестом, который вы когда-либо напишете.
Переходим на сторону декодирования
Итак, вот упаковочная сторона: арифметика, четыре энкодера, диалекты и места, куда уходят байты. Это спокойная половина работы, потому что у кодирования нет невалидного входа и нет декодера, который стал бы с вами спорить. Обратное направление - встреча с Base64 внешнего мира и возвращение байтов, - это место, где боль концентрируется: нулево-дополненные хвосты, тихое усечение, строгие и ленивые алфавиты и командная строка, съедающая завершающие переводы строк. Декодирование Base64 на C подробно разобрано в связанной статье, на которую ведёт ссылка с этой страницы, и это естественный компаньон этой статьи: энкодер заполняет коробку, декодер открывает её, и между ними у вас все Base64-задачи, с которыми C-программе когда-либо приходится иметь дело.
Последнее обновление: 2026-09-08
Связанная статья: Декодирование Base64 в C: полное руководство