C での Base64 デコード:完全ガイド
APIレスポンスの中、設定ファイルの中、メールの添付ファイルの中、あるいはURLのど真ん中にいるのが、これです。文字や数字の長い文字列で、ときどき + や / が現れ、最後尾には = が1つか2つついているかもしれません。あなたはそれを瞬時に認めます。そして今度は、Cで元のバイトを取り戻さなければなりません。Base64デコードの仕事は、これだけのことです。文字表の文字4つが入り、生のバイト3つが出てくる。それを繰り返し、= の印が本物のデータはどこまでだったか教えてくれるまで続きます。このサイトのホームページはフォーマットを1歩ずつ案内してくれるので、この記事はエネルギーを本当に働く場所に注ぎます。バッファ、ライブラリ、そしてその間に潜む罠たちへ。
最初の malloc に手を付ける前に、知っておくべきことが2つあります。第一に、デコードは縮む方向の作業です。出力は入力の4分の3のサイズなので、デコーダーはすでに手元にあるペイロードより大きなメモリを必要とすることはありません。第二に - そしてここがヘッドラインです - CにはBase64デコーダーが同梱されていません。この言語の標準ライブラリが凍結したのはBase64が存在するずっと前で、その後のどの標準もこの穴を埋めませんでした。ですからBase64をデコードするCプログラムはどれもライブラリに寄りかかり、実務で重要なのは4つです。OpenSSL、Mbed TLS、APR-Util、GLib。それぞれに個性があります。何を許し、エラーをどう報告し、あなたの出力にそっと何をするのか。自分のデコーダーの個性を知れば、CでのBase64デコードは謎バグの源ではなくなり、眠ったままでも書けるようなルーティンに変わります。
工具箱:バイトを取り戻す4つの道
全体像を1瞥で示しましょう。4つとも標準文字表を扱います。違いは端にあり、端こそバグが生まれる場所です。
| ライブラリ | ヘッダー | エラーモデル | 覚えておくべき出力のクセ |
|---|---|---|---|
| OpenSSL(libcrypto) | <openssl/evp.h> |
不正な入力で -1 を返す |
ワンショットデコーダーは尾部をゼロ埋めする |
| Mbed TLS | <mbedtls/base64.h> |
返り値コード(-0x002C, -0x002A) |
4つの中で最も厳しい入力ルール |
| APR-Util | <apr-1.0/apr_base64.h> |
なし:最初の不審な文字で止まる | int長さAPIのため、上限は2 GB |
| GLib | <glib.h> |
ハードな失敗時のみ NULL を返す |
まき散らされたゴミを黙って無視する |
インストールはディストリビューションごとにパッケージ名1つです。OpenSSLなら:DebianとUbuntuで libssl-dev、FedoraとRHELで openssl-devel、Archで openssl、macOSで brew install openssl。Mbed TLSなら:libmbedtls-dev(または mbedtls)。APR-Utilなら:libaprutil1-dev と libapr1-dev。GLibなら:glib2.0-dev。そしてそれぞれ -lcrypto、-lmbedcrypto、-laprutil-1、-lglib-2.0 でリンクします。どれを選ぶか?もしTLSやハッシュのためにOpenSSLをすでにリンクしているなら(大半のサーバーはそうしています)、OpenSSLを使いましょう。組み込みやリソース制約のあるビルドなら、Mbed TLSが小さくて厳格な住人です。Apacheエコシステムの内部にいるなら、APR-Utilはもうそこにあります。コードベースがGNOMEやGTKベースなら、GLibがすべてを1つのランタイムにまとめてくれます。
OpenSSL:ギャップをゼロで埋めるデコーダー
OpenSSLにはBase64が2つの形で用意されています。大半のコードの主役はワンショット関数です:
#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文字の倍数でない入力、文字表外の文字を含む入力は拒否します。ここまでは、まったくもって合理的な契約です。ただし、1つの詳細だけが、いくつものデータベースのインポートをこっそり壊してきたことがあります。返り値は本当のデータ長ではないのです。
そのプログラムを実行すると、4 bytes... と思ったら、違います。TWFuZQ== は4文字グループが2つなので、関数は 6 を返し、バッファの中身は 4d 61 6e 65 00 00 になります。「Mane」という単語にゼロバイト2つが付き、というわけです。OpenSSLのワンショットデコーダーは固定の量子で動きます。入力文字4つが常にちょうど出力バイト3つを生み、最後のグループが持っていたのが実バイト1つだけなら、残りの2つのスロットはゼロで埋められるのです。マニュアルはこのことを1文だけ、静かに言及しています(「必要な場合は、出力は 0 ビットでパディングされます」)。その1文が、この関数のmanページ全体の中で最も重要な文です。
本当の長さはパディングから復元でき、2行の計算で済みます:
size_t real_length(const char *b64) {
size_t len = strlen(b64);
while (len > 0 && b64[len - 1] == '=') len--;
return len * 3 / 4;
}
文字表の文字を数え、末尾のパッドを捨て、3倍して4で割る。TQ==(文字Mをエンコードしたもの)では、これで (2 * 3) / 4 = 1 という実バイト数になります。一方 EVP_DecodeBlock は3と報告します。(ポインタ、長さ) のペアは常に一緒に持ち歩き、デコード済みデータに strlen を使うのはやめましょう。返ってきたバイトがJPEGかもしれないし、その1バイト目がNULかもしれないからです。
ストリーミングデコーダー:止め時を知っているデコーダー
それ以外のすべてに対応するのが、OpenSSLのストリーミング組 EVP_DecodeUpdate と EVP_DecodeFinal です。状態を呼び出しのあいだに運び回すのがコンテキストオブジェクトで、未完成のグループの1文字から3文字を保持しておくため、ペイロードをチャンクごとに与えられます。大事な挙動はこうです。空白(スペース、タブ、キャリッジリターン、ラインフィード)はストリームのどこに現れてもスキップされ、それ以外の文字表外の文字、あるいはデータの途中に現れた = には即座に -1 を返し、updateから 0 が返ることは「パディングが見られた、これ以上は不要」という意味です。EVP_DecodeFinal は、まだ未完成のグループが残留しているなら -1 で拒否します。空白を除いた長さが4の倍数でないものは、有効なペイロードではないからです。
コードの前にバージョンに関する注意を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 */
改行は消え、4つのバイトが出てきます。誰も入力を先にきれいにする必要はありません。ワンショット関数とのボーナスの違いもあります。ストリーミング経路はバイトを正直に数えます。TQ== を渡すとちょうど1バイト(4d)を返し、ゼロパディングはつきません。2つのパッドが3つの出力スロットのうち2つが埋められたことはないことを意味しているのを、この経路は知っているからです。OpenSSLから信頼できる長さが欲しいなら、使うべきはこの経路です。
Mbed TLS:厳格派
Mbed TLS(PolarSSLとして生まれ、現在はARMの組み込みスタックの中に入る暗号ライブラリ)は、非常にクリーンな契約を持つ2つの関数を与えてくれます:
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== をデコードすると1バイト、4d、それ以上でも以下でもありません。
入力のルールは4つのライブラリの中で最も厳格で、Mbed TLSにとって「有効」とは何かを定義するので、覚える価値があります:
- グループのあいだにCRLFとLFの改行が出てきてもよい - メールのペイロードはそのまま動きます。
- スペースは改行の直前とバッファの最末尾に許されますが、改行の後やグループの真ん中のスペースはエラーです。
=文字は最大2つで、末尾のみ。パッドの後にデータがあるのはエラーです。- 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;
}
(サイズクエリが「小さすぎる」コードを返すのは仕様です。それが、関数が書き出したはずの内容を報告する方法だからです。上記の2つの返り値コードはどちらも、関数が宣言されているのと同じヘッダー <mbedtls/base64.h> から来ています。)
APR-UtilとGLib:もう2脚の椅子
APR-Util - Apache Portable Runtimeのユーティリティライブラリ、Apache HTTP Serverがその上に建てられている基盤 - は、サーバーがBasic認証ヘッダーをデコードする必要があった限りずっとBase64を担い続けてきました。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);
手に取る前に知っておくべきことが2つあります。第一に、長さは int です。32ビットなので、実用上の上限は1呼び出しあたり2 GBで、ヘッダーや設定値なら問題なく、4 GBファイルをデコードするには問題です。第二に - そしてこちらが大物です - デコード関数にはエラー返り値がまったくないのです。その挙動はヘッダーではなく実装の中にしか見えません。デコーダーは、空白やNULを含むどんな不正文字も、そこで止まる印と受け取ります。認識できないものに当たるまでデコードし、どこまで進めたかを返して、何も言いません。切れたペイロード、末尾にコメント付きで貼り付けられたもの、真ん中で壊れたバイト - どれも、黙って短い出力を生みます。APRのデコーダーを使うなら、返った長さをペイロードが約束したものと自分で突き合わせなければなりません。関数が代わりにやってくれるわけではありません。プール割り当てのラッパーもありません - 宛先バッファはあなたが提供するものなので、プール主導のコードでは plain_dst を自分でプールから割り当てます。この記事のどこ他にも見られないEBCDICの側面もあります。EBCDICマシンでは、関数はエンコード前に入力をASCIIへ、デコード後にASCIIへ戻して変換するので、同じコードが今もhttpdを動かすメインフレームの上で動きます。
GTKやほとんどのGNOMEアプリケーションの背後にあるランタイム、GLibは、まったく逆の個性です。そのデコーダーは文字列を受け取り、常に新しく割り当てたバッファを返します(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」の3バイトを返してきます。便利なインプレース版もあります。g_base64_decode_inplace() は入力バッファの上からデコードします(出力は入力より短いので安全)、同じポインタを返すので結果はバッファの先頭から始まります - メモリが逼迫したコードにはいい芸で、CRLFで折り返された入力も喜んで食べます。C開発者への教訓はこうです。データが信頼できないなら、GLibは壊れたペイロードからあなたを守ってくれません。段階的デコードが必要なときには、_step系列(g_base64_decode_step と状態のint)が用意されており、対応する g_base64_encode_step/g_base64_encode_close の組はエンコード側にいます。
URL安全なBase64:もう一つの文字表
標準文字表とあなたのURLのどこかのあいだで、誰かが傷ついています。標準Base64は + と / を2つの最大符号として使いますが、どちらもURLでは厄介ものです。クエリ文字列の + は、あなたのサーバーが見るころには平気な顔でスペースと解釈され、/ はパスの区切り文字です。RFC 4648のセクション5がこの直し方を定義しています。名前は base64url。同じエンコーディングを、+ を - に、/ を _ に置き換え、長さが別の方法で分かるときは末尾の = パッドを落としたものです。JSON Web Token、OAuthの状態パラメータ、そして無数のAPIセッションIDがこの方言で暮らしています。
4つのCライブラリのうち、base64urlをネイティブでデコードできるものは1つもないので、この変換は1度書いて何度でも使う小さなヘルパーになります。2つの特殊文字を元の位置へ戻し、足りないパッドを補い、結果を標準デコーダーに渡す、それだけです。まず長さチェックをしてください。4の倍数に1を加えた長さは、どんな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;
}
この道には2つの落とし穴が番人として立っています。第一は方向です。文字の置換なしにURL安全なペイロードを標準デコーダーに渡すと、OpenSSLとMbed TLSは拒否します(その文字は彼らの文字表にない)。一方GLibは - と _ を黙ってスキップし、本来より短い文字列を返してきます - エラーもなしに。必ずヘルパーを経由させてください。第二は、真剣に受け取る価値のあるRFC自身の警告です。base64urlは「base64エンコーディングと同じとみなすべきではない」。もしあるペイロードに - や _ の文字が含まれないなら、2つの方言はそのデータに対してバイト単位で完全に同一になり、取り違えは見えなくなります。そしてまさにそのために、取り違えは1つでも含むペイロードに当たるまで生き延びるのです。
ファイル:元を取り戻す
最もよくあるファイル形状の仕事は、どこかのエクスポートルーティンがやったことの逆です。.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:image/png;base64,iVBORw0KGgo... のような完成したdata URIを差し出します。RFC 2397はその形を定義しています。data:、任意のメディアタイプ、任意の ;base64 フラグ、カンマ、そしてペイロード。フラグがあるとき、ペイロードはBase64です。ないとき、ペイロードはパーセントエンコードされたプレーンテキストで、めったに見かけませんが、合法です。メディアタイプが省略された場合、デフォルトは 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);
/* ここからペイロードを好みのライブラリでデコード */
}
このフォーマットには3つの落とし穴が住んでいます。まず ;base64 フラグの不在です。それがなくても合法なdata URIはパーセントエンコードされたペイロードを持ち、それをBase64デコーダーに通せばゴミが生まれます - フラグを確認してからデコーダーを選ぶのです。次に、主張されたメディアタイプです。それは送信者からのヒントであって、事実ではありません。ファイルのセクションのマジックナンバー・スニフこそがあなたの事実です。三つ目はサイズです。RFC自身の助言は、data URIは短い値用だということ。何メガバイトもの画像をURLの中に載せて走らせるのは、称賛すべきパターンではなく、あなたのアーキテクチャの悪臭です。
JWT:秘密でない部分を読む
ウェブ上で最も有名なBase64ペイロードはJSON Web Tokenで、形を知れば最も怖くないものです。RFC 7519によると、コンパクトJWTはドットで結ばれたbase64urlの3つの部分から成ります。ヘッダー、ペイロード、署名 - それぞれパディングなし、改行なしでエンコードされます。最初の2つの部分は素のJSONなので、誰にでも読め、だからこそ誰もトークンを触る前に読み続けたほうがいいのです。
最初の2つの部分を読むのは、上記の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"} になります。さて、ここからが大事なところです。3つ目の部分は署名で、あなたがさっきデコードした2つの部分は、秘密でも認証済みでもありません。パケットキャプチャを持つ誰にでも読め、テキストエディタを持つ誰にでも書き換わる。署名を検証する前にCでJWTペイロードを信頼するのは、定番中の定番の認証バグです。Base64はそれに気づきにくくします - トークンは割れない塊に見えながら、実体ははがきなのです。HS256トークンを検証するには、自分の秘密鍵で header.part に対するHMAC-SHA256を再計算し、<openssl/hmac.h> の HMAC() を使って CRYPTO_memcmp() で一定時間比較します。ダイジェストが一致しなければ、どんな主張をしようともトークンは拒否されます。Cには事実上の標準JWTライブラリがないので、本番ではその小さな検証ステップを自分で組むか、コミュニティのライブラリを1つ採用することになります - しかしこの仕事のBase64側は、上記の「分けてデコードする」ダンスそのもので、全体を理解しておくべきです。
Basic認証:プライバシーを学ばなかったヘッダー
ウェブで最古の認証ヘッダーは、今もBase64に載っています。Authorization: Basic のあとに username:password の標準文字表エンコーディングが続く形です(RFC 7617。RFC 9110はBasic方式についてこれに参照しています)。RFCは、これは保護ではなくエンコーディングだと明言しています - パケットキャプチャを持つ誰にでも、1コマンドで両半分をデコードできるからです -。ですから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を標準の転送エンコーディングの1つにし、2つの独自のルールを追加しました。エンコードされた行は76文字を超えてはならない、そしてデコードするソフトウェアは文字表外の文字を無視しなければならない - 改行を含めて。第2のルールのおかげで、上記のストリーミングデコーダーはゼロの前処理で折り返された添付ファイルを平らげ、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の証明書や鍵の関数が最終的に食べるのはこれです。2つの注記。本体は改行なしで収集する(上のループがやっているように)。そうすれば長さが4の倍数になります。ファイルに複数のブロックがあるなら、開いたBEGINラベルに対応するENDラベルを合わせること。最初のブロックだけ欲しいなら、ここで行っているような単純なフラグで十分です。
シークレット、設定ファイル、データベースカラム
Base64はテキストの容器です。だからこそ、予想もしない場所に繰り返し現れるのでしょう。設定ファイルや環境変数では、フォーマットを壊してしまう値を密かに運ぶ技として使われます。セミコロン入りのデータベースDSN、クォート入りのパスワード、改行を含む値。データベースでは、バイナリのブロブがテキストカラムの中をBase64として暮らせ、テキストを前提とするあらゆるツールを生き延びられます - ただし約3分の1のサイズ増という代償があるので、カラムはその分だけ広く取ってください(あるいは、なぜその値が最初から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;
}
注意は2回効きます。第一に、これはフォーマットの安全であって、秘密ではありえません。開発者が設定ファイルを読めるとき、その値は1回の呼び出しでデコードできてしまいます。RFCのセキュリティセクションには、サポートへプロトコルのやり取りを報告した人たちが「うっかりパスワードを明かしてしまった」という実際の事故が記録されています。Base64は見た目上カモフラージュしているだけで、計算上は保護しないからです。シークレットをBase64で保存して、それを暗号化だと呼ぶのはやめましょう。第二に、起動時に検証してください。貼り付け半分の環境変数値は、厳格な呼び出しから -1 を返します。1行のチェックが、3時間後にやってくる意味のつかめない失敗を、起動時点の行動可能なメッセージに変えてくれるのです。
シェルからデコードする
すべてのデコードがあなたのプログラムの内部で行われるわけではありません。CLIスクリプト、cronジョブ、ワンライナーは絶えずBase64をデコードしていて、C開発者なら、あらゆるLinuxマシンにすでにいる2つのツールを知っておくべきです。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 フラグは「1行」を意味します。64文字の折り返しなしでエンコードし、入力も1行であることを期待します。そして、読まなければ1晩を失うCLIの罠があります。OpenSSLのbase64デコードは行ベースで、まったく改行なしで届いたペイロードは、何も出ずにデコードされ、しかも黙ってです:
printf 'TQ==' | openssl base64 -d | wc -c # 0
printf 'TQ==\n' | openssl base64 -d | wc -c # 1
coreutilsのデコーダーにはその期待がないのが、接着作業に安全なデフォルトである理由の1つです。もう1つの方言に関する注記。BSD由来のシステム(特に古いmacOS)は歴史的にデコードフラグを -D と綴っていました。現行のリリースはGNUの慣例 -d に従うので、実際にいるマシンのmanページを確認してください。
大きなペイロード、小さなメモリ
デコードは、あなたを助けてくれる方向です。出力は入力の4分の3のサイズなので、Base64によるメモリ圧はめったに起きません。それでも、数百メガバイトの .b64 ファイルがディスクに降り立ったときは、先ほどのストリーミング経路があなたの道具で、見かけよりずっとシンプルです。エンコード済みファイルをチャンクごとに読み、各チャンクを EVP_DecodeUpdate に渡し、デコードされたバイトが来るたびに書き出します。コンテキストは呼び出しのあいだ、未完成グループの1から3文字を保持するので、チャンク境界はどこに落ても構いません - 揃える必要はありません:
#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;
}
ピークメモリはファイルサイズに関係なく、数十キロバイト程度の2つのバッファです。壊れたファイルは素早く失敗します - EVP_DecodeUpdate は損傷があるチャンクで -1 を返すので、肩をすくめる代わりにオフセットを報告できます。この経路におけるライブラリの注意点が1つ。APR-UtilのデコーダーはNUL終端文字列で動き、管理は int サイズです(返り値が int で、入力内部の定数によりちょうど3 GBに満たないところで上限)。だから数ギガバイトのファイルには出走資格がありません。進捗表示が必要なら、書き出したバイト数を数えましょう - それが出力におけるあなたの位置で、入力位置はそれの約3分の4倍です。
罠集め:すべてC固有のもの
Cでこれをするときに特有の罠を、1か所に集めておきます:
- ゼロ埋めのワンショット。
EVP_DecodeBlockはデータ長ではなく量子長を返します。TQ==は3バイトと報告しますが、実際に持っているのは1バイト。末尾のパッドから本当の長さを常に再計算するか、ストリーミング組を使いましょう。 - デコードされたバイトは文字列ではない。 結果はNULバイトを含み、UTF-8でないこともあります。
strlenもprintf("%s")も、テキストを前提とする関数に渡すのも、やめましょう。(ポインタ、長さ) のペアをどこまでも運びましょう。 - バッファサイズはあなたの仕事。 Cは出力バッファを大きくしてくれず、デコーダーもそうしません - OpenSSLのupdateは、デコードしたものをあなたが渡した空間に書き込むだけ。サイズは
in_len * 3 / 4 + 3にします(入力が折り返されていて、空白を剥がさないヘルパーでデコードするなら、折り返し分のオーバーヘッドを足して)。ラッパーにはいつも容量チェックを残しましょう。 - 符号付きcharでの参照。 もし自分でデコーダーを書くなら、古典的なバグは、charが符号付きのプラットフォームで素の
charを使って256エントリのテーブルのインデックスにすることです:バイト0xFFは-1になり、メモリを後ろ向きにインデックスしてしまいます。インデックスには常にunsigned charやunsignedの値を使いましょう。 - 沈黙する方が危険。 APR-Utilは最初の不正文字で黙って止まり、GLibはゴミを黙ってスキップします。OpenSSLとMbed TLSは音を立てて失敗します。入力が信頼できないなら、ライブラリの沈黙はあなたのプログラムのバグであって、ライブラリのバグではありません。
- コマンドラインは改行を食べる。 入力行に改行がないと、
openssl base64 -dはゼロバイトしかデコードしません。末尾の改行を剥ぐシェルパイプライン(tr -d '\n'、xargs、最終改行なしのエディタ保存)は、エラーも出さずに空の出力を生みます。 - intとsize_t。 OpenSSLのワンショットAPIは長さを
intで受け取り、APR-Utilはintを通貫で使用し、Mbed TLSとGLibのAPIはsize_tを使います。それらのあいだの長さの混合演算こそ、符号付き/非符号付きの警告が本当のバグを隠している場所で、APRの2 GBの上限もそこに住んでいます。 - 空白は均一ではない。 OpenSSLはどこでも全空白をスキップします。Mbed TLSはグループ間のCRLF/LFと改行直前のスペースを許しますが、改行の後や行の真ん中はダメ。CLIツールはそれぞれ違います。1つのデコーダーで有効なペイロードが、別のデコーダーでは無効になり得ます。「うちのマシンでは動いた」はたいてい「うちのデコーダーが甘かっただけ」を意味します。
良い習慣たちを集める
信頼する前に検証してください。形状チェック(文字表の文字、末尾のパッドは最大2つ)は、デコードに先立って明白なゴミを拾いますが、Base64の意味を理解するのは真のデコードだけです。だからこそ最後の決断は厳格なデコーダーに任せましょう。正直な長さやチャンク入力が要るときはOpenSSLのストリーミング組を、ペイロードが小さくその長さをすぐに補正できるならワンショットを。(ポインタ、長さ) のペアは常に一緒にし、デコード済みバッファが文字列関数に出会うのを許さないでください。認証材料の比較には CRYPTO_memcmp を。ファイル名や主張されたMIMEタイプを信じる前に、マジックバイトをスニフしてください。そしてBase64は、それが何であるかとして扱ってください - パッケージングフォーマット、バイトのための小さな箱 - 錠前としてはダメです:この64文字の何ひとつが、あなたのデータをプライベートにしてくれるわけではありません。
CにおけるBase64の短い歴史
物語はメールから始まります。1990年と1991年、暗号学者のグループがPrivacy Enhanced Mailの輪郭を描きました。署名と暗号化されたメールのためのシステムで、7ビットネットワークを通じてバイナリを運ぶ方法が必要だったのです。彼らの答えは、1993年にRFC 1421として標準化されました。1文字あたり6ビットのデータ - 「base 64」 - を64文字の行でエンコードするものです。実装はもちろん、Cでした。ほぼ同じころ、ウェブは自分だけのMIMEを連れてやって来ました。RFC 1521(1993)、その後RFC 2045(1996)は同じ文字表を保ち、行長を76に緩め、Base64を若いインターネットの添付フォーマットに変えたのです。
Cの標準ライブラリは、その船をまるごと乗り遅れました。C89標準は1990年、MIMEより3年前に発表され、言語の委員会はその後もBase64関数を一切追加していません - C99でも、C11でも、C23(2024年の改訂)でも。だからエコシステムはライブラリの周りで成長しました。OpenSSLは、誰かがTLSのためにOpenSSLをリンクしてきた限りずっとlibcryptoにEVPのエンコード/デコードルーティンを抱え、Mbed TLS(2015年にPolarSSLから改名)は組み込みシステムのために小さな厳格な組を維持し、APR-Utilはサーバーが自分自身の認証ヘッダーをデコードする必要があるときにApacheとともに同梱され、GLibはデスクトップのためにそのトリオを追加しました。標準は実装を追いました。2003年のRFC 3548が古い定義を整理し、2006年のRFC 4648(Base-N Encodings)が文字表、URL安全なバリアント、そしてこの記事が頼りにしているセキュリティルールを正式化したのです。ふさわしいことに、そのRFCのセクション11はISO C99のリファレンス実装を指します - 標準自身の例のデコーダーはCで書かれていて、このフォーマットがどこに棲んでいるかを物語るすべてがそこにあります。
楽しい豆知識:C版
知るだけで楽しい、C風味の事実をいくつか:
- 名前は数学で、マーケティングではない:出力文字はそれぞれちょうど6ビットを運び、2の6乗は64。「Base64」とは、基数を声に出して読んだものです。
- 文字表は64ではなく65文字です:64の記号に
=が加わり、RFC 4648はこれを特殊な処理機能に使う「追加の65番目の文字」と呼んでいます。パッドは労働者で、文字ではないのです。 - OpenSSLはエンコード済み出力を64文字で折り返し(PEMの習慣)、coreutilsは76文字で折り返します(MIMEの習慣)。12文字の差は20年のメールの歴史で、同じマシンの2つのコマンドの出力から見えます。
- GNU coreutilsの
base64コマンドの著者はSimon Josefsson - RFC 4648を書いた同じ人物です。標準と、最もよく使われる実装の1つが著者を共有しており、それがすべてのエッジケースで2つが一致する理由です。 - Mbed TLSはテーブル参照を一定時間ヘルパー(
mbedtls_ct_base64_*)経由で行うので、デコード速度がどの文字を見たと漏らしません。気づくことはないのに、存在してほっとする細部です。 TQ==は最小の自明でないペイロードです:実バイト1つ、パッド2つ。完璧なテストベクトルです - OpenSSLのワンショットデコーダーは3バイトを返し、ストリーミングデコーダーは1、Mbed TLSは1、GLibも1。4つのライブラリ、2つの答え、そしてその差はゼロパディングです。- APRのbase64関数が、この記事の中でEBCDICを気にする唯一の存在です。httpdは、文字がASCIIでないマシンで今も動いているからです。Cの標準ライブラリはメインフレームと出会ったことがありません。APRは出会いました。
- 空のペイロードは普遍的な恒等変換です:すべてのライブラリは、長さゼロの入力を長さゼロの出力にエンコードし、デコードし、エラーは出ません。あなたのデコーダーが空文字列で詰まるなら、それはフォーマットの問題ではなく、バグです。
エンコード側へ切り替えて
これがデコード側のすべてで、痛みのおおかたがここに住んでいます。なぜならデコードは、他の人々のデータに会う場所だからです。彼らのパディングの選択、彼らの改行、彼らの壊れたバイト、彼らのトークン。逆の方向 - バイトをBase64文字列への変換 - はもっと落ち着いた生き物で、独自の罠のキャストが揃っています。正確なバッファ計算、行折り返しという問い、そしてすべての送信者に降りかかるサイズの請求書。CでのBase64エンコードは、このページからリンクされている関連記事で詳しく扱われ、この記事とのペアは、デコーダーがエンコーダーとペアを組むのと同じものです。両方を読めば、どちらの方向でも二度と驚かされることはなくなります。
最終更新: 2026-10-09
関連記事: C での Base64 エンコード:完全ガイド