Декодирование Base64 в C++ (Cpp): полное руководство
У вас есть строка. Длинная лента букв и цифр, изредка с + или /, может быть, ещё с одним-двумя = в конце, и где-то в вашем тикете, контракте или колонке базы данных - обещание, что это Base64. Теперь вам нужны исходные байты, на C++, и они должны быть верными. Домашняя страница этого сайта разбирает формат вглубь, поэтому здесь только короткая версия: четыре символа алфавита несут три байта, хвост из одного-двух знаков = показывает, где закончились настоящие данные, а закодированная форма примерно на 33 процента больше исходной. Декодирование - это сжимающее направление, поэтому декодеру никогда не понадобится больше памяти, чем тот пелод, который он уже держит. Это по-настоящему приятное свойство, и одно из тихих удовольствий, когда работаешь в этом направлении.
Но главная новость в том, что сам C++ не декодирует за вас ни одного символа. У стандартной библиотеки было тридцать лет, чтобы вырастить функцию base64, и она потратила их все на другие вещи, поэтому каждая C++-программа приносит свой декодер с лавки трёх очень разных характеров, плюс вариант написать сорок с небольшим строк самому. Один - рабочая лошадка, которая тащит интернет с 1990-х, другой - молчун, который обрывается на полуслове и ни словом не обмолвится об этом, а третий - перфекционист, который бросает исключение на единственном лишнем пробеле. Когда вы узнаете, что каждая из них прощает, в чём отказывает и что тихо делает за вашей спиной, декодирование перестанет быть источником мистических багов. Откроем несколько пакетов.
Инструменты: четыре декодера, четыре характера
Вот картина одним взглядом. Все четыре умеют стандартный алфавит; различия - на краях, и именно на краях и живут баги.
| Декодер | Откуда берётся | Модель ошибок | Особенность, которую стоит запомнить |
|---|---|---|---|
| OpenSSL EVP | <openssl/evp.h>, линкуется -lcrypto |
Возвращает -1 на сломанном входе |
Разовая версия заполняет хвост нулями |
| Boost.Beast | <boost/beast/core/detail/base64.hpp>, только заголовки |
Нет никакой: просто останавливается | Никакого канала ошибок вообще |
| Итераторы Boost.Serialization | <boost/archive/iterators/binary_from_base64.hpp>, только заголовки |
Бросает dataflow_exception |
Считает = настоящим нулевым значением |
| Ваши сорок строк | Нигде: это ваше | Ваш выбор, вплоть до позиции байта | Каждый крайний случай ваш навсегда |
Установка - одно имя пакета на дистрибутив. Для OpenSSL: libssl-dev на Debian и Ubuntu, openssl-devel на Fedora и RHEL, openssl на Arch и brew install openssl на macOS. Для Boost, чьим текущим релизом является 1.92.0 от августа 2026 года в проекте, который строит библиотеки с 1998-го: libboost-dev или boost-devel. Оба декодера Boost ниже состоят только из заголовков, так что линковать вообще нечего. Если ваш проект на CMake, вся настройка умещается в три строки:
find_package(OpenSSL REQUIRED)
find_package(Boost REQUIRED)
target_link_libraries(my_app PRIVATE OpenSSL::Crypto)
Одна заметка о версиях до кода, потому что от неё зависит, что вернёт ваш декодер. OpenSSL 3.5, вышедшая в апреле 2025 года как линия долгосрочной поддержки, починила настоящий баг в потоковом декодере (расскажу чуть позже), а более новый функциональный релиз 4.0 от апреля 2026 года унаследовал эту заплатку. Если ваша сборка приколота к старой 3.0 или 3.3, прочитайте абзац о версиях в разделе про OpenSSL ниже, прежде чем доверять длине хвоста.
OpenSSL: декодер, который у вас, скорее всего, уже пролинкован
Если ваша C++-программа трогает TLS, хэширование или сертификаты, OpenSSL уже есть в бинарнике, и его EVP-рутины для base64 - самые обкатанные декодеры в этом деле. Разовая функция - это один вызов:
int EVP_DecodeBlock(unsigned char *t, const unsigned char *f, int n);
Сдайте ей буфер из base64-символов и его длину, и она запишет декодированные байты в t. Она срезает начальные пробельные символы, срезает конечные пробелы, переводы строк и возврата каретки, а затем применяет правила без компромиссов: внутренних пробелов нет, а длина после обрезки должна кратно делиться на 4. Каждые четыре входных символа производят ровно три выходных байта, и вот часть, которая удивляет: знаки заполнения декодируются в шесть нулевых бит, и man-страница спокойно констатирует, что за учёт завершающего заполнения отвечает вызывающий. Другими словами, функция делает арифметику за вас, а потом тихо добавляет в конце до двух бонусных нулевых байтов. Идиоматичная C++-обёртка прячет буферную арифметику за std::string:
#include <cstddef>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>
std::string openssl_decode(const std::string &b64) {
std::vector<unsigned char> out(b64.size() * 3 / 4 + 4);
int n = EVP_DecodeBlock(out.data(),
reinterpret_cast<const unsigned char *>(b64.data()),
static_cast<int>(b64.size()));
if (n < 0) return {};
size_t pads = 0;
for (size_t i = b64.size(); i > 0 && b64[i - 1] == '='; --i) ++pads;
return std::string(reinterpret_cast<const char *>(out.data()),
static_cast<size_t>(n) - pads);
}
int main() {
std::string one = openssl_decode("TQ==");
std::printf("%zu bytes: %02x\n", one.size(),
static_cast<unsigned char>(one[0]));
std::string word = openssl_decode("TWFuZQ==");
std::printf("%zu bytes: %s\n", word.size(), word.c_str());
}
Запустите это, и нулевое заполнение проявится ровно там, где обещала документация: декодирование четырёхсимвольной строки TQ== даёт три байта, букву M плюс два нуля, прежде чем обёртка срезает их. Сдайте ей TWFuZQ==, и вы получите чистые четыре байта слова «Mane». Сдайте ей символ вне алфавита, и вернётся пустая строка, потому что функция ответила -1. Обратите внимание, что тихо делает std::string в этой обёртке: он сам ведёт учёт своей длины и охотно содержит нулевые байты, так что декодированный JPEG может жить в вашем текстовом типе, сравниваться, хэшироваться и передаваться. На C вам бы досталось молиться за переменную длины, а тут строка просто работает.
Для данных, которые прибывают кусками, OpenSSL выдаёт вам контекст, в который вы скармливаете чанки и из которого достаёте результаты, и у трёх функций компактный словарь значений возврата:
| Вызов | Возвращает | Что это значит |
|---|---|---|
EVP_DecodeUpdate |
-1 |
Недопустимый символ, либо знак заполнения посреди данных |
EVP_DecodeUpdate |
1 |
Ожидается ещё вход |
EVP_DecodeUpdate |
0 |
Конец данных: в последней группе было заполнение, либо появился мягкий маркер конца ввода |
EVP_DecodeFinal |
1 / -1 |
Поток закончился чисто / остаточные символы не кратны 4 |
Два поведения делают потоковый декодер самым терпимым в наборе инструментов. Он пропускает пробелы, табуляции, возвраты каретки и переводы строк где угодно в потоке, так что MIME-блок письма со строками CRLF длиной 76 символов проходит ровно так же, как строка в одну линию, и он сообщает истинное число байтов: тот же TQ==, который обманул разовую функцию, здесь даёт вам ровно один байт, никакой арифметики. Он жуёт вход чанками до 64 base64-символов, работая от внутреннего буфера на 80 байт, и держит в буфере всё, что не влезает в группу из четырёх, - именно поэтому его можно кормить кусками любого размера. Обёртка:
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>
std::string openssl_decode_stream(const std::string &b64) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_DecodeInit(ctx);
std::string out;
std::vector<unsigned char> chunk(1024);
int outl = 0;
for (size_t pos = 0; pos < b64.size(); pos += chunk.size()) {
size_t take = std::min(chunk.size(), b64.size() - pos);
int ret = EVP_DecodeUpdate(ctx, chunk.data(), &outl,
reinterpret_cast<const unsigned char *>(b64.data()) + pos,
static_cast<int>(take));
if (ret < 0) {
EVP_ENCODE_CTX_free(ctx);
return {};
}
out.append(reinterpret_cast<const char *>(chunk.data()), outl);
if (ret == 0) break;
}
unsigned char tail[3];
int tail_l = 0;
int fin = EVP_DecodeFinal(ctx, tail, &tail_l);
EVP_ENCODE_CTX_free(ctx);
if (fin != 1) return {};
out.append(reinterpret_cast<const char *>(tail), tail_l);
return out;
}
И теперь подстрочник о версиях из таблицы инструментов, потому что это ровно тот случай, который возвращает уже «решённый» тикет обратно в работу: во всех релизах OpenSSL до 3.5 потоковый путь имел ту же привычку к нулевому заполнению, что и блочный декодер. Его зарегистрировали в феврале 2025 года как проблему 26677, коммит с исправлением попал в ветку master 27 февраля 2025 года, а связанный запрос на слияние закрыли в тот же день. Официальная man-страница теперь фиксирует это в своём разделе истории: начиная с OpenSSL 3.5, EVP_DecodeUpdate производит то число байтов, которое документация всегда и обещала, и больше не декодирует заполнение в нулевые биты. Если ваш код приколочен к старому OpenSSL, а длины хвостов кажутся сдвинутыми на один-два байта, проверьте это в первую очередь. И есть одна эксцентричность, унаследованная от эпохи PEM: дефис - вообще не символ алфавита - это мягкий маркер конца ввода. Если ваш поток содержит его после количества символов, кратного 4, декодер вернёт 0 и попросит остановиться, именно поэтому base64url-строка, которой по-настоящему нужны её -, просто так не декодируется - сначала транскодирование, в разделе ниже, и путешественник во времени исчезает.
Boost.Beast: быстрый декодер, который никогда не жалуется
Если Boost уже есть в проекте, его HTTP-библиотека поставляет base64-кодек по непривычному адресу boost/beast/core/detail/base64.hpp. Пространство имён detail:: - это способ Boost сказать «это наши внутренние дела», и мейнтейнеры отказались повышать его до публичного API. Тем не менее его используют все, потому что он малый, быстрый и состоит только из заголовка: определите BOOST_BEAST_HEADER_ONLY перед include, и линковать нечего. К слову, это тот самый кодек, которым сам Boost.Beast пользуется в своей WebSocket-рукопожатии для вычисления Sec-WebSocket-Accept, так что он годами жуёт настоящий трафик.
Вот что удивляет: характер функции декодирования. Она принимает ваш выходной буфер, вход и его длину, и возвращает пару: число записанных октетов и число прочитанных символов. Она останавливается на первом = и на первом недопустимом символе - и в обоих случаях делает это, не сказав вам ни слова. Нет кода ошибки, нет исключения, нет статусного флага. Повреждённый пелод, пелод с перенесёнными строками и обрубленный хвост порождают успешный частичный результат:
| Вход | Декодированные байты | Прочитано символов | Почему остановился |
|---|---|---|---|
"TWFuZQ==" |
4 байта "Mane" |
6 | Остановился на первом = |
"TWF!ZQ==" |
2 байта "Ma" |
3 | Остановился на ! |
"TWFu\nZQ==" |
3 байта "Man" |
4 | Остановился на \n |
"TWFuZQ" |
4 байта "Mane" |
6 | Хвост был обрублен |
"TQ==" |
1 байт "M" |
2 | Заполнение обработано как надо |
Прочитайте эту таблицу второй раз, потому что это и есть вся модель угроз декодера, который не жалуется: он постарался на славу, остановился там, где остановился, и заметить это - ваше дело. В проверке есть один нюанс: для входа с заполнением счётчик «прочитанных символов» останавливается на первом =, так что перед сравнением его нужно вернуть назад, и дополнительно требовать, чтобы общая длина была кратна четырём, потому что это единственная форма, которую может иметь настоящий пелод:
#define BOOST_BEAST_HEADER_ONLY
#include <boost/beast/core/detail/base64.hpp>
#include <cstddef>
#include <string>
namespace b64 = boost::beast::detail::base64;
std::string beast_decode(const std::string &in) {
std::string out;
out.resize(in.size() / 4 * 3 + 3); /* запас для нечётных длин */
auto result = b64::decode(out.data(), in.data(), in.size());
out.resize(result.first);
size_t pads = 0;
for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
if (result.second + pads != in.size() || in.size() % 4 != 0)
return {}; /* остановился рано, или длина хвоста невозможна */
return out;
}
Две детали, которые стоит спрятать в карман. Первая: хелпер decoded_size(n), на который заголовок вас направляет, - валидная верхняя граница только тогда, когда n кратно 4, - собственный комментарий функции об этом и говорит, - именно поэтому обёртка выше добавляет пару байтов запаса, вместо того чтобы доверять хелперу при произвольных длинах. Вторая: происхождение. В исходных файлах стоит копирайт 2016-2019 за Vinnie Falco, а в подвале часть кода приписана сниппету 2004-2008 от Rene Nyffenegger. Тот сниппет - пара функций base64, которую двадцать лет копируют и вставляют по всему англоязычному интернету, и теперь она едет внутри Boost, в вашем бинарнике, и выполняет HTTP Basic-аутентификацию для всего веба.
Boost.Serialization: декодер, который бросает исключение на единственном лишнем пробеле
Библиотека сериализации Boost несёт самый старый base64 в экосистеме C++: набор компонуемых адаптеров-итераторов 2002 года, написанных Робертом Рэйми, которые относятся к лозунгу «будь снисходительным к тому, что принимаешь» как к личному оскорблению. Направление декодирования живёт в binary_from_base64.hpp (да, название дано с точки зрения выхода) и работает в паре с трансформатором ширины, который пересобирает шестибитовые значения в восьмибитные байты:
#include <boost/archive/iterators/binary_from_base64.hpp>
#include <boost/archive/iterators/transform_width.hpp>
#include <cstddef>
#include <string>
namespace it = boost::archive::iterators;
std::string boost_decode(const std::string &in) {
using dec =
it::transform_width<it::binary_from_base64<const char *>, 8, 6>;
std::string out(dec(in.data()), dec(in.data() + in.size()));
size_t pads = 0;
for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
out.resize(out.size() - pads);
return out;
}
Внутренний итератор превращает каждый base64-символ в его шестибитовое значение, а внешний собирает эти значения обратно в байты. Его строгость абсолютна: любой символ вне алфавита - в том числе единственный пробел - заставляет итератор бросать boost::archive::iterators::dataflow_exception с сообщением «попытка декодировать значение, которого нет в base64-наборе символов». Это поведение «отказываться, пока не скажут иное», которое RFC позже сформулировали явно, - реализованное в 2002 году, за целый год до того, как RFC 3548 закрепило это правило. Практическое следствие: MIME-обёрнутый вход нужно очистить от переводов строк, прежде чем он коснётся этого итератора. Вторая странность тоньше: в таблице поиска знак заполнения = не пропускается - он выдаётся как значение ноль. Поэтому декодирование TWFuZQ== даёт шесть байтов - 4d 61 6e 65 00 00 - потому что оба знака заполнения принесли настоящие (нулевые) данные, и строка resize(size - pads) в сниппете несущая, а не декоративная. Декодируйте TQ==, и получите три байта, которые срезаются до единственной буквы M, - ровно там, где нужно приземлиться.
Ваши сорок строк
Base64 достаточно мал, чтобы иметь собственный корректный декодер было вполне почётным занятием, а на C++ выгода ещё больше, чем на любом другом языке: std::string делает управление буфером приятным, и кустарный декодер способен на то, что не под силу ни одной из библиотечных версий выше, - указать точный байт, который навредил. Эта версия следует строгому прочтению RFC 4648: обрезать концы, отклонять внутренние пробелы, отклонять заполнение посреди данных, контролировать правила длины и даже проверять биты заполнения, которые RFC требует обнулять у соответствующего кодировщика:
#include <cstddef>
#include <cstring>
#include <string>
std::string strict_decode(const std::string &in, size_t *error_pos = nullptr) {
static const char *table =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
auto fail = [&](size_t pos) {
if (error_pos) *error_pos = pos;
return std::string();
};
size_t start = 0;
size_t end = in.size();
while (start < end && (in[start] == ' ' || in[start] == '\t' ||
in[start] == '\r' || in[start] == '\n'))
++start;
while (end > start && (in[end - 1] == ' ' || in[end - 1] == '\t' ||
in[end - 1] == '\r' || in[end - 1] == '\n'))
--end;
size_t pads = 0;
while (end > start && in[end - 1] == '=') {
--end;
++pads;
}
size_t body = end - start;
if (pads > 2 || (pads == 1 && body % 4 != 3) ||
(pads == 2 && body % 4 != 2) || (pads == 0 && body % 4 == 1))
return fail(in.size());
if (body >= 2 && body % 4 != 0) {
int leftover = static_cast<int>((body % 4) * 6 % 8);
int last = static_cast<int>(std::strchr(table, in[end - 1]) - table);
if ((last & ((1 << leftover) - 1)) != 0)
return fail(end - 1); /* неканонические биты заполнения */
}
int value = 0;
int bits = -8;
std::string out;
out.reserve(body / 4 * 3);
for (size_t i = start; i < end; ++i) {
const char *p = std::strchr(table, in[i]);
if (!p)
return fail(i);
value = (value << 6) + static_cast<int>(p - table);
bits += 6;
if (bits >= 0) {
out.push_back(static_cast<char>((value >> bits) & 0xFF));
bits -= 8;
}
}
return out;
}
Разберём, что именно он требует. Начальные и конечные пробельные символы обрезаются, потому что пелод, скопированный из заголовка письма, очень вероятно, прибудет в таком наряде. Внутренние пробелы отклоняются, потому что RFC 4648 говорит: декодер MUST отклонять символы вне алфавита, если окружающая спецификация не говорит иного, а на границе безопасности хочется строгого прочтения. Знак заполнения посреди данных отклоняется, равно как и любая длина, которая не может соответствовать настоящему пелоду: не хватает одного символа до группы - невозможно, а единственное заполнение легально только за тремя символами тела. Проверка каноничности в конце - та, которую пропускают большинство реализаций: если в последней группе было одно или два знака заполнения, неиспользуемые младшие биты последнего символа алфавита должны быть нулевыми, иначе те же самые байты можно записать двумя визуально разными строками. Именно эта мутабельность - причина, по которой проверка существует, и стоит она четырёх строк. Наконец, декодер принимает вход без заполнения, а это ровно то, что представляют собой сегменты JWT. А при неудаче он выдаёт вам позицию: скармливаете ему TWF!ZQ==, и ошибка сидит в индексе 3, на восклицательном знаке, - а это разница между отчётом о баге и его исправлением.
Base64url: алфавит, на котором говорят токены
В стандартном алфавите есть два символа, которые не переживают URL: в строке запроса + означает пробел, а в пути / означает каталог. Раздел 5 RFC 4648 чинит это двумя обменами символов - + становится -, а / становится _ - и откровенно говорит о результате: эту кодировку «не следует считать той же самой, что base64-кодировка». Это алфавит JWT, кодовых вызовов OAuth PKCE, идентификаторов видео YouTube и большинства API-токенов, и он регулярно сбрасывает и заполнение =, потому что в токене длина известна неявно, а дополняющие знаки были бы всего лишь процент-экранированием, которое обязательно случится. Ни один из приведённых выше C++-декодеров не понимает его нативно - OpenSSL к тому же считает - тем самым мягким маркером конца ввода, - поэтому решение: небольшое транскодирование перед декодированием. Оно настолько короткое, что его легко держать в голове:
#include <string>
std::string url_to_standard(std::string in) {
for (char &c : in) {
if (c == '-') c = '+';
else if (c == '_') c = '/';
}
switch (in.size() % 4) {
case 2: in += "=="; break; /* вернуть сброшенное заполнение */
case 3: in += '='; break;
default: break;
}
return in;
}
Примените это к настоящему JWT, и первые два сегмента окажутся чистым JSON, который вот-вот случится. Классический токен-пример декодируется в заголовок {"alg":"HS256","typ":"JWT"} и в набор утверждений с субъектом, именем и меткой времени выпуска. Третий сегмент декодируется тем же путём и выдаёт сырые байты подписи - не текст и не доказательство чего-либо. Декодирование токена говорит вам, что он утверждает; проверка подписи говорит, стоит ли ему верить, и это задача криптографии, которую ни одна base64-библиотека за вас не выполнит. На Windows ситуация односторонняя в том же направлении: у CryptBinaryToStringA есть флаг CRYPT_STRING_BASE64URI для направления кодирования, а у направления декодирования URL-безопасного флага нет вообще, так что приведённое выше транскодирование заслуживает места в вашей мышечной памяти на каждой платформе.
От байтов обратно к тексту
Спросите C++-декодер «какую кодировку я только что декодировал?», и вы получите самый честный ответ, какой у языка есть: никакой. Декодеры байтово-ориентированы от начала до конца. Они не видят текст, они видят байты, и возвращают ровно те байты, что были упакованы. Если исходник был UTF-8, теперь у вас UTF-8, и больше ничего не нужно. Приятная C++-фишка в том, что сам std::string - это контейнер байтов с членом-длиной, поэтому классический C-способ выхода из строя - строковая функция, которая останавливается на первом NUL, - в основном исчезает. Декодированный файл, декодированный сертификат, декодированное изображение: всё это может жить в string, сравниваться через ==, хэшироваться и передаваться по значению, а нулевые байты внутри - просто байты. Только не превращайте его в C-строку и не меряйте потом strlen; используйте size().
Для устаревших кодировок, которые всё ещё прячутся в старых базах, выгрузках и кустарных инструментах - ISO-8859-1, Windows-1252 и их родня, - стандартный инструмент: POSIX iconv, который поставляется вместе с glibc. Сначала декодируйте в байты, а затем превратите эти байты в UTF-8 кодеком, соответствующим источнику:
#include <iconv.h>
#include <cstddef>
#include <string>
#include <vector>
std::string to_utf8(const std::vector<unsigned char> &raw,
const char *source_charset) {
iconv_t cd = iconv_open("UTF-8", source_charset);
if (cd == (iconv_t)-1) return {};
char *inptr = reinterpret_cast<char *>(const_cast<unsigned char *>(raw.data()));
size_t inleft = raw.size();
std::vector<char> utf8buf(raw.size() * 4 + 8);
char *outptr = utf8buf.data();
size_t outleft = utf8buf.size();
if (iconv(cd, &inptr, &inleft, &outptr, &outleft) == (size_t)-1) {
iconv_close(cd);
return {};
}
iconv_close(cd);
return std::string(utf8buf.data(),
static_cast<size_t>(outptr - utf8buf.data()));
}
Полный цикл без потерь в обоих направлениях: упаковали строку как ISO-8859-1, прогнали через base64, отправили, декодировали, сконвертировали - и получили ровно то, с чего начали, со всеми акцентированными буквами на месте. А у бинарных данных вообще нет кодировки - PNG остаётся PNG, хотите вы этого или нет, и это самый освобождающий ответ во всей статье.
Декодирование файлов
Небольшие файлы - это танец в четыре шага: открыть в бинарном режиме, прочитать в вектор, декодировать, записать результат обратно в бинарном режиме. Бинарный режим - каждый раз, на каждой платформе. На Windows чтение в текстовом режиме превратило бы пары CRLF в одиночные переводы строк и тихо изменило бы ваши данные ещё до того, как декодер успел на них посмотреть:
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
std::vector<unsigned char> read_file(const std::string &path) {
std::ifstream in(path, std::ios::binary);
return {std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>()};
}
Обратите внимание на фигурные скобки в коде выше. С одинаковыми круглыми скобками строка вида std::vector<unsigned char> bytes(istreambuf_iterator<char>(file), istreambuf_iterator<char>()) превращается в знаменитый «самый коварный разбор»: компилятор прочитает её как объявление функции, возвращающей вектор, и будет вполне прав. Форму инициализации фигурными скобками выше - это способ обойти грамматику целиком. Как только байты у вас в руках, прогоните их через любой декодер из этой статьи и запишите результат через std::ofstream в std::ios::binary, используя write(data.data(), data.size()) вместо оператора потока, чтобы встроенные нулевые байты пережили путешествие на диск. Файл .b64 и его декодированный двойник будут отличаться ровно на те 33 процента налога, которые вы заплатили по пути на вход, - и это даёт приятный момент с контрольной суммой.
Большие файлы: поток в обе стороны
Для файлов, слишком больших, чтобы держать их в памяти, всю работу делает потоковый декодер из раздела про OpenSSL: прочитать чанк, прогнать его через контекст, записать то, что получилось, повторять. В любой момент в оперативной памяти живёт лишь небольшой буфер, поэтому base64-файл на 10 ГБ декодируется тем же кодом, что и на 10 КБ, а MIME-переносы строк не требуют никакой предобработки по пути, потому что потоковый декодер лишь пожимает плечами на переводы строк:
#include <fstream>
#include <string>
#include <vector>
#include <openssl/evp.h>
bool decode_stream_to_file(const std::string &in_path,
const std::string &out_path) {
std::ifstream in(in_path, std::ios::binary);
std::ofstream out(out_path, std::ios::binary);
if (!in || !out) return false;
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_DecodeInit(ctx);
std::string chunk(65536, '\0');
std::vector<unsigned char> decoded(49152);
bool ok = true;
for (;;) {
std::streamsize got = in.read(chunk.data(), chunk.size()).gcount();
if (got < 0) { ok = false; break; }
if (got == 0) break;
int outl = 0;
int ret = EVP_DecodeUpdate(ctx, decoded.data(), &outl,
reinterpret_cast<const unsigned char *>(chunk.data()),
static_cast<int>(got));
if (ret < 0) { ok = false; break; }
out.write(reinterpret_cast<const char *>(decoded.data()), outl);
if (ret == 0) break;
}
unsigned char tail[3];
int tail_l = 0;
if (ok && EVP_DecodeFinal(ctx, tail, &tail_l) != 1)
ok = false;
if (ok)
out.write(reinterpret_cast<const char *>(tail), tail_l);
EVP_ENCODE_CTX_free(ctx);
return ok;
}
Размеры буферов выбраны не наугад: параметры длин EVP-функций - это int, так что один вызов безопасен до 2 ГБ, а приведённые выше числа держат каждый чанк на 64 КБ входа и 48 КБ выходного буфера, что ровно три четверти. Именно этот потолок int - причина, по которой существует потоковый путь, и о нём стоит знать как о жёстком факте, а не открывать как платформенный баг. Если вход окажется повреждённым, функция вернёт false на первом чанке, который не декодируется, а выходной файл сохранит всё, что было валидным до того момента, - что, в зависимости от вашего конвейера, может оказаться ровно той частичной выдачей, которая нужна.
HTTP, API и поля JSON, в которых прячутся байты
Base64 появляется в HTTP в двух обличьях. Первое - данные: JSON-ответ с полем "certificate" или "avatar", доверху набитым base64, эндпоинт загрузки, который принимает байты в текстово-безопасной колонке, эндпоинт скачивания, который отдаёт вам файл .b64. Паттерн всегда один и тот же: разобрать JSON, вытащить строку, декодировать и относиться к результату как к байтам, - и декодирование, описанное в этой статье, и есть вся реализация. Второе обличье - учётные данные: заголовок Authorization: Basic - это base64 от user:password, и тридцать лет это единственный base64-кейс стандарта. Разбор - в два шага, и именно на первом люди тянутся за NUL-завершённой C-строкой посреди бинарно-соседних данных и удивляются, почему:
#include <optional>
#include <string>
/* strict_decode из раздела «Ваши сорок строк» */
std::optional<std::pair<std::string, std::string>> parse_basic_auth(
const std::string &b64) {
std::string raw = strict_decode(b64);
size_t colon = raw.find(':');
if (colon == std::string::npos)
return std::nullopt;
return std::make_pair(raw.substr(0, colon), raw.substr(colon + 1));
}
Сдайте ему значение заголовка после префикса Basic , и он выдаст вам пользователя и пароль как правильные строки с учётной длиной, либо вообще ничего, если пелод не является парой user:pass. Заметка о безопасности принадлежит сюда, даже хотя это не C++-тема: Basic-аутентификация - это обфускация, а не защита. Заголовок едет в открытом виде для всех, кто умеет читать сеть, поэтому он допустим только за TLS, и даже тогда это выбор для вызовов машина-к-машине, а не для людей.
JWT: читаем, что заявляет токен
JSON Web Token - это три base64url-сегмента, склеенных точками: заголовок, утверждения, подпись. Первые два - JSON-объекты, а третий - криптографическая подпись над строкой header.claims, вычисленная алгоритмом, названным в заголовке. В C++ нет встроенного JWT-типа, но для чтения токена достаточно транскодирования из раздела про base64url и декодера, потому что интересная часть - это именно чтение:
#include <cstddef>
#include <cstdio>
#include <string>
/* url_to_standard и strict_decode из предыдущих разделов */
int main() {
const std::string token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ."
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
size_t dot1 = token.find('.');
size_t dot2 = token.find('.', dot1 + 1);
std::string header = strict_decode(
url_to_standard(token.substr(0, dot1)));
std::string claims = strict_decode(
url_to_standard(token.substr(dot1 + 1, dot2 - dot1 - 1)));
std::printf("header: %s\n", header.c_str());
std::printf("claims: %s\n", claims.c_str());
}
Заголовок возвращается как {"alg":"HS256","typ":"JWT"}, а утверждения - как {"sub":"1234567890","name":"John Doe","iat":1516239022} - субъект, имя и метка времени выпуска. Это и есть вся читающая сторона, и она по-настоящему полезна: логировать, что заявляет токен, отлаживать 401, заглянув в поле истечения срока, или решать, каким утверждениям доверять, - всё это один декод от вас. Чего это не является, так это проверкой. Сегмент подписи тоже в base64url, и его декодирование даёт 32 или 64 сырых байта, которые сами по себе ничего не доказывают; подпись становится осмысленной только тогда, когда вы пересчитываете хэш header.claims общим секретом или открытым ключом и сравниваете. Относитесь к декодированному JWT так же, как к письму: в нём написано то, что написано, а проверка печати - отдельная криптографическая работа.
Data URI: файлы, которые вставили себя в страницу
Data URI - это URL, чей пелод лежит прямо в адресе: data:, затем опциональный медиатип, опциональный маркер ;base64, запятая, а дальше сами данные - весь механизм RFC 2397. Браузеры используют их, чтобы встраивать изображения, шрифты и мелкие скрипты прямо в HTML и CSS без лишних запросов, и если страница продолжает работать при отключённой сети, data URI - главный подозреваемый. На стороне C++ задача декодирования: разрезать URI и прогнать пелод через ваш обычный декодер, потому что при наличии маркера ;base64 пелод - это чистый стандартный base64, обычно с заполнением и обычно в одну строку:
#include <cstddef>
#include <string>
std::string data_uri_payload(const std::string &uri, bool *is_base64) {
const std::string prefix = "data:";
if (uri.rfind(prefix, 0) != 0)
return {};
size_t comma = uri.find(',');
if (comma == std::string::npos)
return {};
std::string meta = uri.substr(prefix.size(), comma - prefix.size());
*is_base64 = meta.size() >= 7 &&
meta.compare(meta.size() - 7, 7, ";base64") == 0;
return uri.substr(comma + 1);
}
Вызовите это с data:image/png;base64,iVBORw0KGgo=..., и получите пелод плюс флаг, который подскажет, какой путь декодирования выбрать. Две ямы. Первая: когда маркера нет, пелод - это URL-закодированный текст, а не base64, так что флаг - не формальность. URI, который выглядит как base64, но сгенерирован как процент-закодированный текст, декодируется в мусор. Вторая: некоторые генераторы оборачивают длинные data URI переводами строк, как MIME, а ваш строгий декодер такие отклонит, так что при неконтролируемом источнике срезайте переводы строк перед декодированием. Декодирование пелода URI вида data:image/png даёт точные байты PNG, заголовок и всё остальное, - и это тихое удовлетворение от всего упражнения.
Электронная почта, MIME и привычка к 76 символам
Именно почта научила base64 переносить строки. SMTP в своём исходном виде был построен для переноса 7-битного ASCII, так что всё бинарное должно было переписываться в печатаемый текст, прежде чем отправиться в путь. Privacy-Enhanced Mail сделал это в 1987 году строками по 64 символа, а MIME, стандартизовав кодировку для почты в 1993 году, ослабил лимит до 76 символов и добавил правило, что соответствующий декодер должен просто игнорировать переводы строк. Привычка пережила всё: вложение в письме до сих пор base64, обёрнутое по 76, и точная арифметика выходит в 4/3 умножить на 78/76 - примерно 137 процентов от исходного размера, плюс пара сотен байтов заголовков. Ваш C++-декодер сжимает всё обратно до 100 процентов, и в этом смысл всего формата.
Подвох на C++ в том, что декодеры в этой статье расходились во мнении о переводах строк, и у каждого на то есть причина. Потоковый декодер OpenSSL пропускает их где угодно в потоке, что ровно правило MIME. Разовая функция OpenSSL отказывает от любых пробельных символов внутри пелода. Boost.Beast останавливается на первом переводе строки, не сказав ни слова. Итераторы Boost бросают исключение на единственном пробеле. Так что когда пелод приходит из письма, ваше первое решение - какой декодер использовать, либо срезаете переводы строк сами, - однострочный проход erase-remove по \r и \n, - и пускаете делать настоящую работу любой декодер, который понравится. Предварительное срезание - скучный и надёжный выбор, и именно он держит ваш выбор декодера независимым от истории пелода.
Базы данных, файлы конфигурации и переменные окружения
Третий дом base64 - слой хранения: колонка в устаревшей базе, где в документации написано «base64» и больше ничего, блок конфигурации в JSON-файле, пелод в переменной окружения, которую один сервис закодировал в base64, чтобы она пережила оболочку. Паттерн декодирования тот же, что и везде: прочитать строку, декодировать, относиться как к байтам, - но вход без меток заслуживает отдельного абзаца, потому что иногда вы по-настоящему не знаете, какой алфавит был использован. Угадать нельзя, но можно проверить, потому что четыре символа делают большую часть работы:
- Есть
+или/- прав может быть только стандартный алфавит. - Есть
-или_- прав может быть только URL-безопасный алфавит. - Ничего из них, но в конце
=- дополненный стандартный, либо дополненный URL-безопасный, чьему пелоду просто не понадобились два заменённых символа. - Ничего из них и без заполнения - может быть что угодно; сырой URL-безопасный вариант - обычный для веба, так что для данных, родившихся в URL или токене, попробуйте его первым.
Строки, в которых нет ни одного из четырёх различающих символов, декодируются одинаково под обоими алфавитами, поэтому для них порядок проб - вопрос о том, откуда пришли данные: рождённое в почте хочет стандартный алфавит, рождённое в URL - URL-алфавит. И не забудьте попробовать и дополненное, и неполное чтение одной и той же строки: один недостающий = - разница между «отклонено» и «решено», именно поэтому строгий декодер выше принимает оба варианта.
В командной строке живут два Base64
Для разовых задач на Linux-машине обычно есть два base64-декодера, и они ведут себя по-разному ровно тем способом, который кусает людей. Первый - base64 из GNU coreutils (некоторые новые дистрибутивы вместо него поставляют переосмысленную реализацию uutils, и оба говорят на одних и тех же флагах - проверьте base64 --version). Он соответствует RFC 4648, при кодировании переносит строки по 76 символов (выключается -w 0), а при декодировании охотно принимает переводы строк где угодно; его флаг -i делает терпимость к мусору осознанной, а не случайной. Второй - от OpenSSL, и вот здесь поворот: openssl base64 вообще не отдельное приложение. Начиная с серии 1.1.0 (2016 год) программа enc проверяет имя, под которым её вызвали, и если её позвали «base64», она переключает себя в base64-режим - сравнение строк над argv[0], это и есть C-способ поставлять алиасы. Без -A она ждёт перевод строки где-то в первых 1024 байтах входа, поэтому длинная однострочная строка возвращается пустой, с кодом выхода 0. С -A она читает одну строку, и задокументированный список багов команды enc - это музей из двух экспонатов: опция -A не работает правильно с большими файлами, и без -A, если в первых 1024 байтах нет перевода строки, первые две строки входа игнорируются. В конвейере молчаливый пустой файл выглядит ровно как успешное декодирование пустого пелода.
# честные однострочники
base64 -d < payload.b64 > payload.bin
openssl base64 -d -A < payload.b64 > payload.bin
Ни один из них не понимает base64url нативно, что ещё на одну причину делает сниппет транскодирования достойным места в вашей мышечной памяти. Для всего, что важно, декодируйте в своей программе, где ошибки возвращаются числами, которые можно проверять, а код выхода молчаливого инструмента - не единственный сигнал.
Ловушки, которые кусают именно C++
- Нулевое заполнение.
EVP_DecodeBlockвозвращает три байта дляTQ==: букву M плюс два нуля. Восстановите истинную длину по заполнению либо используйте потоковый API, который честен в счёте. - Потоковая странность до 3.5. В релизах OpenSSL до 3.5.0 (апрель 2025)
EVP_DecodeUpdateимел ту же привычку к нулевому заполнению. Код, написанный под закреплённую 3.0 или 3.3, может врать вам о длинах хвостов; исправление зафиксировано в разделе истории man-страницы. - Тихая остановка.
decodeиз Boost.Beast не имеет канала ошибок: он останавливается на любом недопустимом символе, любом переводе строки и любой невозможной длине хвоста и возвращает частичный результат с самым серьёзным видом. Проверяйте, чтоconsumed + pads == input.size()и что сумма кратна 4, иначе вы декодируете то, что он решил декодировать. - Ловушка decoded_size.
b64::decoded_size(n)предполагает, чтоnделится на 4. Два входных символа могут дать один байт, аdecoded_size(2)говорит ноль - добавляйте запас для нечётных длин. - Заполнения нулевыми байтами. Итераторы Boost декодируют
=как значение ноль, поэтомуTWFuZQ==превращается в шесть байтов, включая два завершающих нуля. Вычитайте число знаков заполнения, иначе наслаждайтесь призраками. - Пробельные символы, четыре варианта. Потоковый OpenSSL пропускает их, разовый OpenSSL отклоняет их внутри, итераторы архива бросают на них исключение, а Beast на них останавливается. Скопированные строки обожают носить с собой пробелы, и у каждого декодера на этот счёт своё мнение.
- Индексация signed char. Если вы крутите собственную таблицу декодирования, индексированную по символу, индексируйте
unsigned char. На платформах, гдеcharсо знаком, байт выше 127 становится отрицательным индексом, а это неопределённое поведение в халате учёного. - Минус - это путешественник во времени. В OpenSSL
-- мягкий маркер конца ввода из эпохи PEM, а не символ алфавита. Транскодируйте base64url до декодирования. - int, а не size_t. Параметры длин EVP - это
int. Выше 2 ГБ безопасен только почанковый потоковый путь, и именно поэтому он существует. - Текстовый режим на Windows. Открытие файла для текста переводит CRLF в LF и портит ваш вход до декодирования.
std::ios::binary- каждый раз, на каждой платформе. - Самый коварный разбор.
std::vector<char> v(istreambuf_iterator<char>(f), istreambuf_iterator<char>())- это объявление функции. Используйте инициализацию фигурными скобками или пару указателей. - Неканонические биты заполнения. Снисходительный декодер может принять строки, у которых неиспользуемые биты заполнения ненулевые, и тогда две визуально разные строки декодируются в одни и те же байты (мутабельность base64). На границе безопасности отклоняйте то, что вам не нужно - RFC 4648 говорит, что декодер may сделать ровно это.
- Командная строка падает молча.
openssl base64 -dбез-Aпроглатывает однострочный вход (пустой вывод, код выхода 0); задокументированные баги покрывают большие файлы и вход без переводов строк в обоих направлениях. Проверяйте вывод в конвейерах. - strlen по бинарным данным.
std::stringохотно держит нулевые байты, но в момент, когда вы отдаёте C-строку унаследованному API,strlenостанавливается на первом NUL. Передавайте длину и указатель, никогда не голый указатель.
Краткая история Base64 в C++
Формат старше, чем современная эра языка. Первое стандартизированное применение кодировки, которую теперь называют MIME base64, - протокол Privacy-Enhanced Mail, предложенный в 1987 году со строками по 64 символа и проверкой целостности сообщения RSA-MD2/MD5, приклеенной в конце; само имя «base64» пришло лишь в 1993 году, когда стандарты MIME так его назвали. C++ появился на сцене как C++98 в 1998 году - через пять лет после MIME, - и первым base64-кодом, за который потянулись разработчики языка, стала C-пара Рене Ниффенегера (2004-2008), которую вопрос на Stack Overflow от 4 декабря 2008 года разнёс по всему вебу. Самая приятная часть этой истории - в том, кто не появился: автор так и не опубликовал ответ сам, но его сниппет стал народной песней, которую все копировали. Один из ответов в трите переиздал его полную реализацию с его же сайта на случай, если сайт отвалится.
А потом экосистема сделала то, что делают экосистемы. В 2002 году Boost.Serialization Роберта Рэйми выкатила адаптеры-итераторы - самый старый base64 в C++-наборе инструментов, строгий до бросания исключения на единственном пробеле, за год до того, как RFC 3548 кодифицировало правило, которое он уже выполнял. В 2017 году Boost 1.66 принёс Beast, а с ним кодек только из заголовка, который поставляется по сей день с атрибуцией Ниффенегера в подвале. Тем временем сам стандарт шёл C++11, C++14, C++17, C++20 и C++23 (опубликован в 2024), и каждый единственный из них посмотрел на 64-символьный алфавит и пошёл дальше. C++26 добавляет новый заголовок <text_encoding> для работы с текстовыми кодеками, одобренный ещё в 2022; его техническое содержание завершилось на мартовском совещании ISO C++ 2026 года в Лондоне, где комитет проголосовал 114 против 12 и 3 за отправку его на публикацию, а последующие совещания комитета в 2026 году - включая то, что пройдёт в Бюзиосе, Бразилия, 16-21 ноября, - потратятся на открытие следующей рабочей версии, C++29, а не на голосование по этому. Base64 никогда не было в черновике. Семь стандартов, три десятилетия, один заголовок для кодирования текста, - и у комитета теперь были все возможные предлоги добавить base64, и он от всех отказался. Практическая история base64 в C++ есть и остаётся историей его библиотек: EVP-рутины OpenSSL, два варианта Boost, вызов Windows API и сорокастрочный сниппет, которым владеете вы.
Забавные факты, версия для C++
- Та же самая пара функций встречается в ответах на вопрос Stack Overflow 2008 года, в исходниках Boost.Beast с атрибутивным подвалом и в заголовочных файлах бесчисленных частных кодовых баз. Спросите C++-разработчика, откуда у него base64, и самый честный ответ будет «не знаю, и интернет тоже не знает».
- Итераторы архива Boost - самый старый base64 в этой статье, копирайт 2002, тот же год, когда вышел SDK .NET Framework 1.0. Они бросают исключение на единственном пробеле, то есть выполняли правило «отклонять символы вне алфавита» раньше, чем RFC за ними догнали: RFC 3548 кодифицировал его в 2003, а RFC 4648 повторил в 2006.
- Потоковый декодер OpenSSL работает от внутреннего буфера на 80 байт, но сбрасывает результат каждые 64 base64-символа, та же ширина строки, которую PEM-броня использует с 1987 года. Это тихое 64 - одно из последних мест, где старый формат в 2026 году всё ещё несёт нагрузку.
- Самый маленький возможный дополненный base64 - это четыре символа,
TQ==: один байт в двухсимвольном костюме. Самый маленький без заполнения - два символа,TQ. Что именно вам доведётся декодировать, целиком зависит от того, кто это закодировал, и этот человек не думал о вас. - Математика MIME точная: 4/3 умножить на 78/76, поэтому вложение в письме прибывает примерно в 137 процентах от исходного размера (плюс пара сотен байтов заголовков сверху). Ваш C++-декодер сжимает всё обратно до 100 процентов, и в этом тихое удовольствие от всего упражнения.
- На типичном libstdc++ или MSVC
std::stringдержит мелкие пелоды в стековом буфере через оптимизацию малых строк, вместо того чтобы аллоцировать. Ввод в 9 байт декодируется в 6 байт и никогда не трогает кучу. Base64-форма вашего крошечного блоба конфигурации может буквально жить в кадре стека, и это тот бесплатный обед, который стандартная библиотека не рекламирует. - Команда
openssl base64, за которую вы можете схватиться в оболочке, - вообще не команда. Это программаenc, проверяющая собственное имя вargv[0]и переключающая персонажа. Алиас через сравнение строк, C++-способ делать вещи - на C. - Идентификаторы видео YouTube - это base64url: одиннадцать символов, без заполнения, без
+или/где-то рядом с URL. Самая просматриваемая кодировка на планете работает на варианте «безопасном для URL и имён файлов», который RFC 4648 добавил в разделе, помещающемся на одной странице.
Когда вместо этого нужна упаковка
Всё, что вы только что декодировали, было упаковано тем же набором инструментов по другую сторону: EVP_EncodeBlock для разовых случаев, EVP_EncodeUpdate плюс EVP_EncodeFinal для потоков (и именно оттуда берутся строки по 64 символа), та же буферная арифметика в обратную сторону и тот же налог в 33 процента, который декодирование молча возмещает. Полная история упаковки - арифметика размеров по пунктам, кодировщики, которые завершают вывод нулём, итератор Boost, который никогда не встречал знак заполнения, base64url, MIME-переносы, файлы и Windows API с его CRLF-привычкой, - живёт в C++-гайде по кодированию на сестринском сайте. Сходите прочтите, а потом возвращайтесь и откройте что-нибудь большое. В этом и вся игра: никакой стандартной библиотеки, три надёжных поставщика с тремя разными характерами, декодер, который указывает на точный байт, навредивший вам, багфикс 2025 года, изменивший хвост потока, и одна тройка с нулевым заполнением, которую вы запомните навсегда. Приятной распаковки.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в C++ (Cpp): полное руководство