C++(Cpp)での Base64 デコード:完全ガイド
文字列を手にした。文字や数字の長いリボンで、ときどき + や / が現れ、最後尾には = が1つか2つついているかもしれません。チケット、契約書、データベースの列のどこかに、それがBase64であるという約束があります。今度はC++で元のバイトを取り戻さなければなりません。しかも正しく。このサイトのホームページはフォーマットを詳しく説明しているので、ここでは短縮版だけ:文字表の文字4つがバイト3つを運び、1文字か2文字の = の尾が本当のデータはどこまでだったかを印しており、エンコードされた形は元の約33パーセント大きくなります。デコードは縮む方向なので、デコーダーはすでに手にしているペイロードより大きなメモリを必要とすることはありません。それは本当に気持ちの良い性質で、この方向で働くことの静かな喜びのひとつです。
もっと大きな見出しは、C++そのものが1文字たりともデコードしてくれないということです。標準ライブラリにはbase64関数を育む30年があったのに、そのすべてを他のことに使ってきたので、C++プログラムはどれも、個性がまったく違う3体のベンチから自分のデコーダーを持ち込み、約40行を自分で書くという選択肢もあります。ひとつは1990年代からインターネットを担ってきた主力、ひとつは文章の途中で止まってそれを一言も言わない黙ったタイプ、ひとつは放って置かれたたった1つの空白に例外を投げる堅物です。それぞれが何を許し、何を拒み、あなたの知らないところで黙々と何をしているかを知れば、デコードは謎バグの源ではなくなります。いくつかパッケージを開けてみましょう。
工具箱:デコーダー4つ、気性4つ
全体像を1瞥で示しましょう。4つとも標準文字表を扱います。違いは端にあり、端こそバグが棲む場所です。
| デコーダー | 出所 | エラーモデル | 覚えておくべきクセ |
|---|---|---|---|
| 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 を投げる |
= を実在のゼロ値として扱う |
| 自分で書く40行 | どこにもない:あなたのものだから | バイト位置まであなたの自由 | 全エッジケースを永遠に所有する |
インストールはディストリビューションごとにパッケージ名1つです。OpenSSLなら:DebianとUbuntuで libssl-dev、FedoraとRHELで openssl-devel、Archで openssl、macOSで brew install openssl。Boostなら:現在のリリースは1998年からライブラリを作り続けてきたプロジェクトの2026年8月版1.92.0で、libboost-dev または boost-devel。下記の2つのBoostデコーダーはどちらもヘッダーオンリーなので、リンクするものはまったくない。プロジェクトがCMakeベースなら、セットアップ全体は3行で済みます:
find_package(OpenSSL REQUIRED)
find_package(Boost REQUIRED)
target_link_libraries(my_app PRIVATE OpenSSL::Crypto)
コードに入る前にバージョンの注意を1つ。あなたのデコーダーの返り値が変わるからです。2025年4月に長期サポートラインとしてリリースされたOpenSSL 3.5は、ストリーミングデコーダーの本当のバグを修正し(少し後に詳しく)、2026年4月の新しい4.0フィーチャーリリースはその修正を継承しました。ビルドが古い3.0や3.3にピン留めしているなら、末尾の長さを信頼する前に、下記のOpenSSLセクションのバージョン段落を読んでください。
OpenSSL:おそらくすでにリンクしているデコーダー
C++プログラムがTLS、ハッシュ、証明書に触れているなら、OpenSSLはすでにバイナリの中にあり、そのEVP base64ルーチンは業界で最も戦場で鍛えられたデコーダーです。ワンショット関数は呼び出し1つです:
int EVP_DecodeBlock(unsigned char *t, const unsigned char *f, int n);
base64文字のバッファとその長さを渡すと、デコードされたバイトを t に書き込みます。先頭の空白を落とし、末尾の空白・改行・キャリッジリターンを落とした上で、妥協のないルールを適用します:内部の空白は不可、トリミング後の長さは4の倍数。入力文字4つが常にちょうど出力バイト3つを生み、ここで人々を驚かせる部分ですが、パディング文字は6つのゼロビットにデコードされ、manページは呼び出し側が末尾のパディングを考慮する責任があるとはっきりと記しています。つまり、この関数は計算を代わりにやって、最後にゼロバイトが最大2つボーナスで付いてくるわけです。慣例的な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());
}
実行すると、ゼロ埋めは文書が約束したまさにその場所に現れます:4文字の文字列 TQ== をデコードすると3バイト、文字Mとゼロ2つが、ラッパーがそれを切り落とす前に得られます。TWFuZQ== を渡すと、「Mane」というきれいな4バイトが得られます。文字表外の文字を1つ渡すと空文字列が返ります。関数が -1 で答えたからです。このラッパーで std::string が黙々としていることに注目してください:自身の長さを管理し、ゼロバイトを喜んで含むので、デコードしたJPEGがテキスト型の中で生き、比較され、ハッシュ化され、回されても大丈夫です。C言語なら長さ変数があることに感謝していたはずです。ここでは文字列がただ動くだけです。
断片的に届くデータには、OpenSSLがチャンクを投入して結果を引き出すコンテキストを渡してくれます。3つの関数はコンパクトな返り値の語彙を持っています:
| 呼び出し | 返り値 | 意味 |
|---|---|---|
EVP_DecodeUpdate |
-1 |
不正な文字、またはデータの途中にあるパッド文字 |
EVP_DecodeUpdate |
1 |
さらに入力があることを示す |
EVP_DecodeUpdate |
0 |
データの終わり:最後のグループがパディングを持っていた、あるいはソフトな入力終了マーカーが出現した |
EVP_DecodeFinal |
1 / -1 |
ストリームが正常に終了 / 残り文字が4の倍数ではなかった |
2つの動作が、ストリーミングデコーダーを工具箱で最も寛容なものにしています。ストリームのどこでもスペース、タブ、キャリッジリターン、改行をスキップするので、76文字のCRLF行を持つMIMEメールブロックは1行の文字列とまったく同じように流れ、本当のバイト数を報告します:ワンショット関数を惑わせた同じ TQ== がここでちょうど1バイトを返し、計算は不要です。64 base64文字までのチャンクで入力をかじりつき、80バイトの内部バッファで動き、4つのグループに収まらないものをバッファリングします。だから好きな大きさの断片で渡せます。ラッパー:
#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;
}
ここで工具箱の表にあったバージョン注を出します。「解決済み」チケットを再び開いてしまうのがまさにこういうものだからです。3.5以前のすべてのOpenSSLリリースで、ストリーミングパスはブロックデコーダーと同じゼロ埋めの癖を持っていました。2025年2月にissue 26677として報告され、修正コミットは2025年2月27日にmasterに乗りました。関連するプルリクエストも同日にクローズされました。公式manページは現在、履歴セクションにそれを記録しています:OpenSSL 3.5以降、EVP_DecodeUpdate は文書が常に主張してきたバイト数を生成し、パディングをゼロビットにデコードしなくなりました。コードベースが古いOpenSSLにピン留めしていて、末尾の長さが1バイトか2バイトずれて見えるなら、これがまず確認すべきことです。そしてPEM時代から受け継いだ1つの奇癖があります:ハイフン - は文字表の文字ではなく、ソフトな入力終了マーカーです。4の倍数の正当な文字の後にそれが含まれていると、デコーダーは0を返して停止を求めます。だから、本当に - 文字を必要とするbase64url文字列はそのままデコードできません。下のセクションで先にトランスコードして、時間旅行者を消してください。
Boost.Beast:決して文句を言わない高速デコーダー
プロジェクトにすでにBoostがあるなら、そのHTTPライブラリは boost/beast/core/detail/base64.hpp という意外な住所にbase64コーデックを搭載しています。detail:: 名前空間はBoostの「これは内部の話だ」という言い方で、メンテナたちは公開APIへの昇格を断ってきました。それでも誰もが使います。小さいから、速いから、ヘッダーオンリーのから:includeの前に BOOST_BEAST_HEADER_ONLY を定義すれば、リンクするものは何もありません。ついでに、これは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 | パディングは問題なく処理された |
その表をもう一度読んでください。それが、決して文句を言わないデコーダーの脅威モデルのすべてだからです:最善を尽くし、止まるべきところで止まり、気づくのはあなたの仕事です。チェックには1つひねりがあります:パディング付きの入力では「読んだ文字数」は最初の = で止まるので、比較する前にパッドを足し戻し、さらに合計が4の倍数であることを要求します。本当のペイロードはそれしかない形をしているからです:
#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;
}
2つの詳細を整理しておきましょう。第一に、ヘッダーが向かせてくれるヘルパー decoded_size(n) は、n が4の倍数のときのみ有効な上限です - 関数自身のコメントもそう言っています - だから上記のラッパーは、任意の長さにそれを信頼する代わりに、数バイトの余裕を追加しています。第二に、出所:ソースファイルはVinnie Falcoの2016-2019年著作権表示を持っており、足元には2004-2008年のRene Nyffeneggerのスニペットの一部を帰属性するフッターがあります。そのスニペットは、20年間にわたり英語圏のインターネットでコピー&ペーストされてきたbase64のペアで、今やBoostの中に、あなたのバイナリの中に、ウェブ全体のHTTP Basic認証のために搭載されています。
Boost.Serialization:放って置かれた1つの空白に例外を投げるデコーダー
Boostのシリアライズライブラリは、C++エコシステムで最も古いbase64を運びます:2002年の組み合わせ可能なイテレータアダプターのセットで、Robert Rameyが執筆し、「受け入れる側は寛容であれ」を個人的な侮辱とみなします。デコード方向は binary_from_base64.hpp にあります(はい、名前は出力側の視点から)で、6ビット値を8ビットバイトに詰め直す幅トランスフォーマーとペアを組みます:
#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文字を6ビット値に変換し、外側のイテレータはそれらの値をバイトに再グループ化します。その厳格さは完全です:文字表外のどの文字も - 1つのスペースも含めて - イテレータに「base64文字セットにない値をデコードしようとしました」というメッセージ付きの boost::archive::iterators::dataflow_exception を投げさせます。これはRFCたちが後に明示した「別の指示がない限り拒否する」動作で、2002年に実装されました。RFC 3548が同じルールを条文にしたのは丸1年後の話です。実務上の帰結は、MIMEで折返された入力は、このイテレータに触れる前に改行を剥がす必要があるということです。2つ目のクセはより微妙です:参照表ではパディング文字 = はスキップされず - 値ゼロとして描画されます。TWFuZQ== をデコードすると6バイト - 4d 61 6e 65 00 00 - が生じます。2つのパッド文字が実在の(ゼロ)データを寄与したからです。スニペットの resize(size - pads) 行は構造材であり、装飾ではありません。TQ== をデコードすると、たった1文字のMにまで切り詰まる3バイトが得られ、まさにそこに着地できます。
あなたが持つ40行
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は、囲む仕様書がそう言わない限りデコーダーは必ず文字表外の文字を拒否しなければならないと言い、セキュリティ境界では厳格な読み方を持ちたいからです。データの途中にあるパッド文字も拒否されます。現実のペイロードに対応しえない長さも拒否されます:グループ1文字足りないことは不可能で、パッド1つは本文3文字の後ろでのみ合法です。最後の正規性チェックは、ほとんどの実装がスキップするものです:最後のグループにパッド文字が1つまたは2つある場合、最後の文字表文字の未使用の低位ビットはゼロでなければならず、そうでなければ同じバイトが見た目には2つの異なる文字列として書けてしまいます。その可変性こそが、このチェックが存在する理由で、コストは4行だけです。最後に、このデコーダーはパディングなしの入力も受け入れます。それはまさにJWTセグメントの姿です。そして失敗時は位置を渡してくれます:TWF!ZQ== を渡すと、エラーはインデックス3、驚嘆符の上に座っていて、それこそがバグ報告と修正の違いです。
Base64url:トークンが話す文字表
標準文字表には、URLを生き延びられない文字が2つあります:+ はクエリ文字列ではスペースを意味し、/ はパスではディレクトリを意味します。RFC 4648セクション5は、2つの文字交換でこれを直します - + が - に、/ が _ に - そして結果については率直です:このエンコードは「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に適用すると、最初の2セグメントは起こるのを待つだけの素のJSONです。古典的な例のトークンは、ヘッダ {"alg":"HS256","typ":"JWT"} と、subject、name、発行時刻タイムスタンプを含むclaimsのセットにデコードされます。3つ目のセグメントも同じようにデコードされ、生の署名バイトが得られます - テキストではなく、何の証明でもありません。トークンをデコードすることは、それが主張していることを教えてくれます。署名を検証することは、それを信じるべきか教えてくれます。そしてそれは、どんな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、その仲間たち - には、標準の道具はglibc同梱のPOSIX iconv です。まずバイトにデコードし、そのバイトをソースに合ったコーデックで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で、それはこの記事全体で最も解放的な答えです。
ファイルのデコード
小さいファイルは4ステップのダンスです:バイナリで開き、vectorに読み込み、デコードし、結果をバイナリで書き戻す。バイナリモード、毎回、すべてのプラットフォームで - 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>()};
}
上記のコードの波括弧に注目してください。丸括弧1つなら、std::vector<unsigned char> bytes(istreambuf_iterator<char>(file), istreambuf_iterator<char>()) という形の行は、あの有名な「最も厄介なパース」になります:コンパイラはそれはvectorを返す関数の宣言と読み、それも完全に正しい。上記の波括弧イニシャライザ形式は、文法を丸ごと回避します。バイトを手にしたら、この記事のどのデコーダーでも通して、std::ofstream で std::ios::binary を使って結果を書き、ストリーム演算子ではなく write(data.data(), data.size()) を使えば、埋め込まれたゼロバイトがディスクへの旅を生き延びます。.b64 ファイルとそのデコード済みペアは、まさに入口で払った33パーセントの税金分だけ違い、チェックサムを確認する満足感のある瞬間になります。
大きなファイル:両方向をストリーム処理
メモリに収まらないほど大きなファイルには、OpenSSLセクションのストリーミングデコーダーが仕事全体を担います:チャンクを読み、コンテキストに通し、出てきたものを書き出し、繰り返す。RAMに生きるバッファは常に小さいので、10 GBのbase64ファイルも10 KBのファイルと同じコードでデコードでき、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 なので、1回の呼び出しは2 GBまで安全で、上記の数値は各チャンクを48 KBの出力バッファに対して64 KBの入力に保っています。まさに4分の3です。その int の天井こそが、ストリーミングパスが存在する唯一の理由で、プラットフォームバグとして発見するのではなく、確かな事実として知っておく価値があります。入力が本当に壊れていることが判明したら、関数はデコードできない最初のチャンクでfalseを返し、出力ファイルはその前に有効だったものを保持します - あなたのパイプラインによっては、それがまさに欲しかった部分結果かもしれません。
HTTP、API、そしてバイトを隠すJSONフィールド
Base64はHTTPで2つの形で見つかります。第一はデータです:"certificate" や "avatar" フィールドにbase64で満たされたJSONレスポンス、テキストセーフな列でバイトを受け取るアップロードエンドポイント、.b64 ファイルを渡してくるダウンロードエンドポイント。パターンはいつも同じです - JSONをパースし、文字列を引き出し、デコードし、結果をバイトとして扱う - この記事のデコード側がそのまま実装全部です。第二の形は認証情報です:Authorization: Basic ヘッダは user:password のbase64で、30年にわたって標準のたった1つのbase64ユースケースでした。パースは2ステップで、最初の1つが人を困らせる場所です。バイナリ寄りデータの真ん中でNUL終端のC文字列に手を伸ばし、なぜなのか首をかしげる、というやつです:
#include <optional>
#include <string>
/* 「あなたが持つ40行」セクションの 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とpasswordが長さ付きのちゃんとした文字列として渡され、ペイロードが user:pass ペアでなければ何も返りません。セキュリティ注はC++の話題ではないけれど、ここに属します:Basic認証は隠蔽であり、保護ではありません。ヘッダはネットワークを読める誰にとってもプレーンで乗っているので、TLSの後ろでのみ許容でき、それでもそれは人のためではなく、マシン間呼び出しのための選択です。
JWT:トークンが主張するものを読む
JSON Web Tokenはドットでくっつけられた3つのbase64urlセグメントです:ヘッダ、claims、署名。最初の2つはJSONオブジェクトで、3つ目はヘッダに名前が書かれたアルゴリズムで、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"} として返り、claimsは {"sub":"1234567890","name":"John Doe","iat":1516239022} として返ります - subject、name、発行時刻タイムスタンプです。読み取り側はこれが全部で、本当に役立ちます:トークンが何を主張しているかをログに残す、401のデバッグで有効期限フィールドを見る、どのclaimsを信頼するか決める、どれもデコード1つで済みます。でも、検証ではないのです。署名セグメントもbase64urlで、デコードすると、それだけでは何も証明しない生のバイト32または64が得られます。署名が意味を持つのは、共有シークレットや公開鍵で header.claims のハッシュを再計算して比較するときだけです。デコードしたJWTは手紙の扱いで:書かれていることは書かれていることだし、封印の検証は別件の暗号的な仕事です。
data URI:ページに自分自身を貼りつけたファイル
data URIは、ペイロードがアドレスの中にそこそこにあるURLです:data: の後には任意のメディアタイプ、任意の ;base64 マーカー、カンマ、そしてデータ自体が続きます - RFC 2397のスキーム全体です。ブラウザはこれを使って、追加のリクエストなしに画像、フォント、小さなスクリプトをHTMLやCSSに直接埋め込み、ネットワークを無効にしても動き続けるページを見れば、data URIは有力な容疑者です。C++側では、デコードの仕事はURIを分割し、ペイロードをいつものデコーダーに通すことです。;base64 マーカーがあるとき、ペイロードは素の標準base64なので - 普通はパディング付き、普通は1行です:
#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=... で呼び出すと、ペイロードと、どのデコードパスを選ぶかを教えてくれるフラグが渡されます。罠は2つ。第一に、マーカーがないとき、ペイロードはbase64ではなくURLエンコード済みテキストなので、フラグは形式的なものではありません - base64に見えてパーセントエンコード済みテキストとして生成されたURIは、ゴミにデコードされます。第二に、一部のジェネレータはMIMEのように長いdata URIに改行で折り返します。厳格なデコーダーはそれを拒否するので、ソースがあなたの管理下になければ、デコード前に改行を剥がしてください。data:image/png URIのペイロードをデコードすると、ヘッダも含めた正確なPNGバイトが得られます。それがこの練習全体の静かな満足感です。
メール、MIME、そして76文字の習慣
メールこそが、base64に行の折り返しを覚えさせた理由です。SMTPは元の形では7ビットASCIIを運ぶよう作られていたので、バイナリは旅行できる前に印字可能なテキストに書き直さなければなりませんでした。Privacy-Enhanced Mailは1987年に64文字行でそれを行い、MIMEが1993年にメール向けエンコードを標準化したとき、制限を76文字に緩め、準拠デコーダーは改行を無視すればよいというルールを追加しました。その習慣は生き残りました:今日のメール添付ファイルもまだ76で折り返されたbase64で、正確な計算は 4/3 掛け 78/76 - 元のサイズの約137パーセント、それに数百バイトのヘッダが加わります。あなたのC++デコーダーはそれをすべて100パーセントまで縮め戻します。それがこのフォーマットの全部の目的です。
C++のひねりは、この記事のデコーダーが改行について意見が分かれ、それぞれに理由があることです。OpenSSLのストリーミングデコーダーはストリームのどこでもスキップします。それはまさにMIMEのルールです。OpenSSLのワンショット関数はペイロード内部の空白をすべて拒否します。Boost.Beastは最初の改行で黙って止まります。Boostのイテレータは1つのスペースに例外を投げます。だからペイロードがメールから来たとき、最初の判断はどのデコーダーを使うか、あるいは自分で改行を剥がすことです - \r と \n への1行のerase-removeパス - 好きなデコーダーに本当の仕事をさせます。最初に剥がしておくのが退屈だが確かな選択で、デコーダーの選択をペイロードの履歴から独立させるのはこれです。
データベース、設定ファイル、環境変数
Base64の第三の住処はストレージ層です:レガシーデータベースの、ドキュメントが「base64」としか言わない列、JSONファイルの設定ブロブ、あるサービスがシェルを生き延びさせるためにbase64化した環境変数のペイロード。デコードのパターンはどこでも同じです - 文字列を読み、デコードし、バイトとして扱う - が、ラベルのついていない入力は特別な段落に値します。本当にどの文字表が使われたか分からないことがあるからです。分からないけれど、テストできます。文字4つがほとんど仕事をしてくれるので:
+または/を含む - 正しいのは標準文字表のみ。-または_を含む - 正しいのはURLセーフ文字表のみ。- どちらも含まないが
=で終わる - パディング付き標準、あるいは交換された2文字をたまたま必要としなかっただけのパディング付きURLセーフ文字列。 - どちらも含まず、パディングなし - どちらもありうる。生のURLセーフ形はウェブでは普通のほうで、URLやトークンで生まれたデータなら、まずそちらを試す。
4つの識別文字をどれにも使わない文字列は、両方の文字表で同じにデコードされるので、それらは試し順がデータの出所の問題になります:メールで生まれたものは標準文字表を、URLで生まれたものはURLものを要求します。そして同じ文字列のパディングあり読みとパディングなし読みを両方試すのを忘れないでください - = が1つ足りないのは「拒否」と「解決」の違いで、上記の厳格なデコーダーが両方を受け入れる理由です。
コマンドラインには2つのBase64がある
使い捨ての仕事には、Linuxのボックスには普通2つの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バイトのどこかに改行があることを期待するので、長い1行文字列は空で、終了コード0で返ってきます。-A 付きでは1行を読み、enc コマンドのドキュメント化されたバグリストは2項目の博物館です:-A オプションは大きなファイルで正しく動作しない、そして -A なしで最初の1024バイトに改行がない場合、入力の最初の2行が無視される、というものです。パイプラインの中で、黙って空になるファイルは、空ペイロードの成功したデコードとまったく同じに見えます。
# 誠実なワンライナーたち
base64 -d < payload.b64 > payload.bin
openssl base64 -d -A < payload.b64 > payload.bin
どちらもbase64urlをネイティブで話せません。トランスコードスニペットが筋肉の記憶に属すべき理由が、さらに1つ増えました。大切なものには、プログラムの中でデコードしてください。エラーはテストできる数字で戻り、沈黙するツールの終了コードが唯一のシグナルではなくなるからです。
C++に特有の罠
- ゼロ埋め。
EVP_DecodeBlockはTQ==に対して3バイトを返します:文字Mとゼロ2つ。本当の長さはパディングから回復するか、カウントに正直なストリーミングAPIを使います。 - 3.5以前のストリーミングのクセ。 3.5.0以前のOpenSSLリリース(2025年4月)では、
EVP_DecodeUpdateは同じゼロ埋めの癖を持っていました。3.0や3.3のピンに対して書かれたコードは、末尾の長さについて嘘をついているかもしれません。修正はmanページの履歴セクションに記録されています。 - 沈黙の停止。 Boost.Beastの
decodeにはエラーチャネルがありません:不正な文字、改行、ありえない末尾長、どれもで止まり、部分結果をまっとうな顔で返します。consumed + pads == input.size()かつ合計が4の倍数であることをチェックしてください。しなければ、あなたがデコードしているのは、その関数がデコードすると決めたものです。 - decoded_sizeの罠。
b64::decoded_size(n)はnが4で割り切れることを前提にします。入力が2文字で1バイトになることがありますが、decoded_size(2)はゼロと言います - 奇数長には余裕を追加してください。 - ゼロバイトのパッド。 Boostのイテレータは
=を値ゼロとしてデコードするので、TWFuZQ==は末尾のゼロ2つを含む6バイトになります。パッド数を引くか、ゴーストを楽しんでください。 - 空白、4つの扱い。 OpenSSLストリーミングはスキップし、OpenSSLワンショットは内部を拒否し、archiveイテレータは例外を投げ、Beastはそこで止まる。貼り付けられた文字列は空白を運ぶのが好きで、各デコーダーには自分なりの意見があります。
- 符号付きcharのインデックス。 文字でインデックスするデコードテーブルを自分で組むなら、
unsigned charでインデックスしてください。charが符号付きのプラットフォームでは、127を超えるバイトが負のインデックスになり、実験室コートを着た未定義の挙動になります。 - マイナス符号は時間旅行者。 OpenSSLでは
-は文字表の文字ではなく、PEM時代からのソフトな入力終了マーカーです。デコード前にbase64urlをトランスコードしてください。 - int、size_tではない。 EVPの長さパラメータは
intです。2 GB以上では、チャンク化ストリーミングパスだけが安全です。それが存在する理由です。 - Windowsのテキストモード。 ファイルをテキストで開くとCRLFがLFに変換され、デコード前に入力が壊されます。
std::ios::binary、毎回、すべてのプラットフォームで。 - 最も厄介なパース。
std::vector<char> v(istreambuf_iterator<char>(f), istreambuf_iterator<char>())は関数宣言です。波括弧イニシャライズか、ポインタペアを使ってください。 - 非正規のパッドビット。 寛容なデコーダーは、未使用パッドビットがゼロでない文字列を受け入れるかもしれず、見た目には2つの異なる文字列が同じバイトにデコードされることがあります(base64の可変性)。セキュリティ境界では、必要ないものを拒否してください - RFC 4648はデコーダーがまさにそれをしてよいと言っています。
- コマンドラインの沈黙の失敗。
-Aなしopenssl base64 -dは1行入力を飲み込みます(出力空、終了0)。ドキュメント化されたバグは両方向で大きなファイルと改行なし入力をカバーします。パイプラインで出力をチェックしてください。 - バイナリへのstrlen。
std::stringはゼロバイトを幸せに保持しますが、C文字列をレガシーAPIに渡した瞬間、strlenは最初のNULで止まります。長さとポインタを渡し、素のポインタだけを渡さないでください。
C++におけるBase64の短い歴史
フォーマットは、言語の現代より古いです。現在MIME base64と呼ばれるこのエンコードの最初の標準化された利用は、Privacy-Enhanced Mailプロトコルでした:1987年に提案され、64文字行と、末尾に貼り付けられたRSA-MD2/MD5メッセージ完全性チェックを持ち、「base64」という名前自体が初めてやって来たのは1993年で、MIME標準がそう名付けたときです。C++はC++98として1998年に登場しました - MIMEの5年後 - そしてこの言語の開発者が最初に手を伸ばしたbase64コードは、Rene NyffeneggerのCペア(2004-2008)で、2008年12月4日のStack Overflowの質問がそれをウェブ中に広めました。その話で最高の部分は、誰が登場しなかったかです:著者自身は答えを投稿しなかったのに、そのスニペットは誰もがコピーした民謡になったのです。スレッドの答えの1つは、サイトが閉じることがないように、彼のウェブサイトから完全な実装を再掲載していました。
それからエコシステムは、エコシステムがやることをやりました。2002年、Robert RameyのBoost.Serializationがイテレータアダプターを搭載しました - C++の工具箱で最も古いbase64で、1つのスペースに例外を投げるほどの厳格さは、RFC 3548がすでに強制していたルールを条文にする1年前のことです。2017年、Boost 1.66がBeastを持ち込み、そのヘッダーオンリーのコーデックは今日でもフッターにNyffenegger帰属性とともに搭載されています。その間、標準自体はC++11、C++14、C++17、C++20、C++23(2024年発行)を経て、それらすべての、すべてが64文字の文字表を見て先へ進みました。C++26はテキストコーデック作業のための新しい <text_encoding> ヘッダを追加します。2022年に承認され、その技術的な内容は2026年3月のロンドンでのISO会議で完成し、委員会が114-12-3で発行へ送り出すことを採択しました。委員会のその後の2026年の会議 - 2026年11月16-21日のブジウス(ブラジル)でのものを含む - は、これについて採決するのではなく、次の作業草案C++29を開くことに費やされます。Base64は草案に一度も入りませんでした。7つの標準、30年、テキストエンコードのためのヘッダは1つ - 委員会はbase64を追加するあらゆる言い訳が手元に来たのに、それをすべて断ってきたのです。C++におけるbase64の実用的な歴史は、そしてこれからも、そのライブラリの歴史です:OpenSSLのEVPルーチン、Boostの2つの風味、Windows APIの呼び出し、そしてあなたが持つ40行のスニペット。
豆知識、C++版
- 同じ関数のペアは、2008年のStack Overflowの質問の答えに、帰属性フッター付きのBoost.Beastのソースに、無数のプライベートコードベースのヘッダファイルに登場します。C++開発者にbase64の出所を聞くと、最も誠実な答えは「分からない。インターネットにも分からない」です。
- Boostのarchiveイテレータは、この記事で最も古いbase64で、著作権2002年 - .NET Framework 1.0 SDKが出たのと同じ年です。1つのスペースに例外を投げるので、「文字表外の文字を拒否する」ルールをRFCたちが追いつく前に強制していたことになります:RFC 3548は2003年に条文にし、RFC 4648は2006年に繰り返しました。
- OpenSSLのストリーミングデコーダーは80バイトの内部バッファで動きますが、64 base64文字ごとにフラッシュします。それはPEMアーマーが1987年から使ってきた行幅と同じです。その静かな64は、2026年にも古いフォーマットがまだ構造材の仕事をしている最後の場所の1つです。
- 考えられる最小のパディング付きbase64は4文字、
TQ==:2文字のコスチュームをまとった1バイトです。最小のパディングなしは2文字、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です:11文字、パディングなし、URLの近くには
+や/がどこにもない。地球上で最も視聴されているエンコードフォーマットは、RFC 4648が1ページに収まるセクションで追加した「URLとファイル名セーフ」のバリアントで動いています。
代わりに詰む必要があるとき
あなたがさっきデコードしたものは全部、向こう側で同じ工具箱が詰んだものです:ワンショットには EVP_EncodeBlock、ストリームには EVP_EncodeUpdate と EVP_EncodeFinal(あの64文字行はそこから来ます)、同じバッファ計算の逆、そしてデコードが黙って返金してくれる同じ33パーセントの税金。詰むことの話 - サイズ計算の明細書、出力をNUL終端するエンコーダー、パッド文字に一度も出会ったことのないBoostイテレータ、base64url、MIMEの折返し、ファイル、CRLFの癖を持つWindows API - は姉妹サイトのC++エンコードガイドにあります。読みに行き、それから戻ってきて、何か大きいものを開いてください。それが全部のゲームです:標準ライブラリなし、3つの異なる気性を持つ3人の信頼できるベンダー、傷をつけた正確なバイトを指すデコーダー、ストリーミングの末尾を変えた2025年のバグ修正、そして永遠に覚えるべきゼロ埋めの3つ組。楽しい解包を。
最終更新: 2026-10-09