Приходится иметь дело с форматом Base64? Тогда этот сайт идеально вам подойдет! Воспользуйтесь нашим невероятно удобным онлайн-инструментом для кодирования или декодирования ваших данных.

Декодирование 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: полное руководство