DevKitLab Logo DevKitLab
Base64 / エンコード / デバッグ

Base64 とは何か、そしてなぜデコードできないのか?

Base64 文字列をデコーダーに貼ると「Invalid character」——あるいはもっと悪いことに、デコードは「成功」して、画面いっぱいの文字化けが返ってくる。たいてい壊れてなどいない。Base64 が何なのかを取り違えているだけだ。暗号でも圧縮でもなく、バイトを可逆に詰め直してテキスト専用の経路を通すもの——そう見えれば、どのデコード失敗も、名前を付けられる短いリストの一項目になる。

Base64 文字列をデコーダーに貼ると、Invalid character が返ってくる。あるいはもっと悪いことに——デコードは成功して、画面いっぱいの文字化けが返ってくる。別のツールを試すと通り、ブラウザで atob() すると例外が飛び、コマンドラインでデコードするとまた微妙に違う結果になる。ここまで来ると、その文字列が壊れているのではと疑い、どこで切れたのかを探しに行く。だがほぼ毎回、何も壊れていない。ただ Base64 が何なのかを取り違えているだけだ。

言い換えればこうだ。Base64 は暗号でも、圧縮でも、チェックサムでもない。任意のバイトを、テキスト専用の経路を通り抜けられる 64 個の文字に詰め直す、可逆な方法だ。答えるのはただ一つの問い——「テキストしか受け付けないものに、生のバイトをどう通すか」——であって、秘匿性(誰でもデコードできる)についても、サイズ(むしろデータを約三分の一膨らませる)についても、何も約束しない。これをつかめば、「なぜデコードできないのか」は謎ではなくなり、具体的な食い違いの短いリストに分解される。アルファベットが違う、パディングが欠けている、+ であるべき場所が空白になっている、あるいはバイトは正しくデコードされていて、見た目が文字化けなのはそれが最初からテキストでなかったからだ、と。

この記事は、まず Base64 が実際に何なのか——3 バイト入れて 4 文字出す——を数分で説明し、それからデコードが失敗する・人を惑わせる、よくある原因を一つずつ歩く。手動でのデコードをつまずかせる「アルファベットが入れ替わった」変種から、そもそも失敗ですらない「デコードは成功した、ただテキストでないだけ」の罠まで。読み終えるころには、「ほどけない」どんな文字列にも当てられるチェックリストが手に入る。

唯一の核心:バイトからテキストへの詰め直しであって、金庫ではない

二つを分けて持てば、混乱のほとんどは晴れる。Base64 はバイトテキストへ、そして元へ戻す——無損失で、鍵もいらない。存在する理由は、あまりに多くの経路がテキスト向けに定義されていて、生のバイナリを壊すか拒むからだ。URL、HTTP ヘッダー、JSON 文字列、メール本文、data: URI。そこへヌルバイトや上位ビットの文字を一つ落とせば壊れる。Base64 は任意のバイナリを、安全な 64 文字の部分集合——A–Za–z0–9+/——に洗い、そのほとんどをおおむね無傷で通す。(一つ注意:標準 Base64 が使う +/= は、URL のクエリや HTML フォームではなお特別な意味を持つ。だからこの二つの場面では、URL-safe な変種を使うか、さらに URL エンコードを重ねる必要がある——下の失敗その三の元凶だ。)

刻み込むべき帰結が二つある。誤解の半分はここから来るからだ。

  • 公開されている。この変換は誰でも逆にたどれ、秘密は一切関わらない。パスワードや API キーを Base64 しても、保護にはならない——ざっと眺める人にとって少しだけ分かりにくくなるだけだ。秘匿が要るなら暗号が要る。Base64 は衣装であって、錠ではない。
  • 大きくなる。3 バイトごとに 4 文字になる——約 33% のオーバーヘッドだ。圧縮のである。小さくしようとして手を伸ばす人がいるが、結果は正反対になる。

実際の仕組み:3 バイト入れて 4 文字出す

この仕組みには三十秒かける価値がある。後のほとんどの失敗を説明してくれるからだ。Base64 は入力を3 バイト(24 ビット)ごとに刻み、その 24 ビットを四つの 6 ビットの数に切り直し、各 6 ビット値(0〜63)をアルファベットの一文字に対応させる。3 バイトは必ず 4 文字になる。

Man  →  01001101 01100001 01101110   (3 バイト = 24 ビット)
        010011 010110 000101 101110  (四つの 6 ビット組)
        T      W      F      u          → "TWFu"

入力が 3 の倍数でないとき、最後の組は足りず、Base64 は = で出力を 4 の倍数まで埋める。1 バイト余れば 2 文字と ==、2 バイト余れば 3 文字と = だ。その = はデータではなく、位置合わせである。だから標準 Base64 の長さは常に 4 の倍数になり、だから下の失敗はちょうど三つのもの——アルファベット、パディング、長さ——の周りに集まる。

失敗その一:これは Base64URL であって Base64 ではない

非常によくあるデコード失敗だ。Base64URL は変種で、URL や HTTP ヘッダーを安全に通すために設計され、標準デコーダーはこれに出会うと詰まる。重要な違いは二つ。

  • +- に、/_ になる。
  • 末尾の = パディングは通常省かれる

(テキストを固定幅で改行するかどうかは別の、転送レベルの選択であり、Base64URL のアルファベットの一部ではない——とはいえ URL-safe な文字列は慣習として改行なしで送られる。)厄介なのは、+/ がまさにハッシュや鍵や署名に現れやすい二文字だという点だ——だからこの破損は、よく起き、しかも静かだ。これは、手動で JWT をデコードするときに非常によくある落とし穴だ。トークンの三つのセグメントは Base64URL なので、一つを標準デコーダーに渡すと例外が飛ぶか、ゴミが返る——この食い違いが、多くの「トークンを自分でデコードしてみよう」を脱線させる。直し方は、まず翻訳すること——-/_+// に戻し、4 の倍数までパディングし直す——あるいは URL-safe モードを持つデコーダーを使うことだ。Base64 ツールで、同じ文字列を URL-safe モードと標準モードで走らせれば、同じ入力で一方はきれいに読め、もう一方はエラーになるのが見える。

失敗その二:パディング

パディングは同じ話のもう半分だ。長さが 4 の倍数でない Base64 文字列は、= パディングが欠けているだけかもしれない——あるいは不正か途中で切れている場合もある——そしてデコーダーは対応が分かれる。厳格なものは Invalid length を投げ、寛容なものは欠けたパディングを補って成功する。まさにその食い違いのせいで、同じ文字列があるツールではデコードでき、別のツールでは失敗する——存在しない破損を追わせる原因だ。長さを 4 で割った余りが、どのケースかを教えてくれる。余り 0 はすでに完全、2 なら == を足す、3 なら = を一つ足す。そして余り 1 はパディングの問題ではまったくない——不正か途中で切れた文字列だ。正当な Base64 の組が、ちょうど一文字だけ余ることはないからだ。(パディングは必ず末尾にだけ、最大二つの =、途中には決して現れない——文字列の途中の = はそれ自体が破損の兆候だ。)そして Base64URL は意図的にパディングを取り除くので、この問題と失敗その一はたいてい連れ立って現れる。URL-safe な文字列は、文字が入れ替わっていて、しかもパディングもない。

失敗その三:+ であるべき空白

厄介なのがこのケースで、この記事が次の記事へつながる場所でもある。ある Base64 文字列が URL のクエリ文字列や HTML フォームを経由した場合、その中の +空白に変わっているかもしれない——application/x-www-form-urlencoded では +空白を意味するからだ。すると、あなたのデコーダーは + があるべき場所に を見て、エラーになるか、誤ったバイトを返す。紛れ込んだ改行も同種の害をなす。一部の MIME や PEM 形式は改行を挿入し(形式によって、多くは 64 文字または 76 文字ごと)、その改行(コピペで入る空白も同様)はデータの一部ではない。直し方は出どころ次第で、順番が大事だ。まずフォームエンコードを経たかどうかを見極め——経ていれば、本来 + だったのに空白になった箇所を + に戻す——それとは別に、転送で挿入された改行やコピペの空白を取り除く。すべての空白を無条件に + へ変えてはいけない。もともとプラスだったのはフォームエンコードされたものだけだ。これは実のところ、Base64 の仮面をかぶった URL エンコードの問題だ。Base64URL は + をそもそも使わないことで、この一群のバグを丸ごと避ける。

失敗その四:ちゃんとデコードできた——ただテキストでないだけ

「失敗ではない失敗」だ。ときにデコードは成功しているのに、それでもゴミが返り、文字列が悪かったと結論づける。悪くなかった。デコードされたバイトが、読めるテキストである必要はない。Base64 が運ぶのは任意のバイトだ。元は PNG、gzip ストリーム、protobuf メッセージ、暗号化されたかたまりだったかもしれず、その生バイトをテキストとして描画すれば、まさに予想どおりの文字化けになる。中身が本当にテキストであっても、それは何らかのエンコードのテキストだ——UTF-8 のバイトを Latin-1 としてデコードすれば、cafécafé になって返る。だから、デコードを「失敗」と断じる前に、そのバイトが何かを問う。先頭の数バイトがしばしば正体を告げる(PNG なら \x89PNG、zip なら PK、JSON なら {)。「デコードできる」と「読める」は別々の主張だ——これは JWT でも出会う同じ分かれ目で、トークンの署名セグメントは、そもそもテキストでない生バイトに対する Base64URL である。JWT インスペクターに貼れば、ヘッダーとペイロードは JSON にデコードされ、署名はあえて読めないまま残るのが分かる。(ブラウザでは、atob() が返すのはバイナリ文字列——1 文字が 1 バイト——であって、デコード済みの UTF-8 テキストではない点に注意。UTF-8 バイトを読める文字にするには、さらに TextDecoder を通す必要がある。)

失敗その五:二重エンコードと data: 前置き

短い注意点が二つ、しかし詰まったデコードの意外に多くを占める。

  • 二重エンコード。ある値が二度 Base64 され、最初のデコードで返ってくるのが中身ではなくさらに別の Base64 文字列になる——もう一度デコードする。手がかりは、期待した内容ではなく、きれいな Base64 らしき ASCII が返ってくることだ。
  • data: URI 前置きdata:image/png;base64,iVBORw0KGgo... は全体が Base64 なのではない——カンマより後ろだけが Base64 だ。data:...;base64, の全体をデコーダーに渡すと、:; でエラーになる。まずカンマ(を含む)までを取り除く。

デコードできない Base64 のチェックリスト

文字列がどうしてもほどけないとき、まず切れていると決めつけない。この順番で確認する。

  1. 空白と経路。まず転送で挿入された改行やコピペの空白を取り除く。それとは別に、フォームエンコードを経ているなら、空白になった + を戻す——ただし、すべての空白を一律に変換しない(失敗その三)。
  2. アルファベット-_ が見えるか? それは Base64URL だ——+// に翻訳するか、デコーダーを URL-safe モードに切り替える(失敗その一)。
  3. パディングと長さ。まず長さを 4 で割った余りを見る。余り 2 なら ==、余り 3 なら = を一つ足す。余り 1 は不正か途中で切れている印で、単にパディングが足りないのではない——無闇に補わない(失敗その二)。
  4. 前置き。デコード前に先頭の data:...;base64, を取り除く(失敗その五)。
  5. デコードしてから、バイトが何かを問う。「成功」したのにゴミに見えるなら、ペイロードがバイナリか別の文字集合だった——デコード失敗ですらない(失敗その四)。最初のデコード結果がまた Base64 なら、もう一度デコードする。

この五つの底にあるのは、あの唯一の核心だ。Base64 は可逆なバイト↔テキストの詰め直しであって、それ以上ではない。データを隠しもせず、縮めもせず、結果が読めることも保証しない——そしてデコードの成功は、文字がバイトへ戻せたことを示すだけで、内容が完全であることも、改変されていないことも意味しない。「デコードできない」バグのほとんどは破損ではなく——アルファベット違い、パディング欠落、空白になった +、あるいは最初からテキストでなかったバイトだ。どれなのかを名指しすれば、壊れて見えた文字列は、きれいにほどける。