不正なBase64文字列:デコードが失敗する理由と直し方
Base64デコーダーが文字列を拒否する理由は3つです。アルファベット外の文字がある、長さがあり得ない値になっている、パディングの位置がおかしい、のいずれかです。
Base64が使う文字は64種類です。A〜Z、a〜z、0〜9、+と/で、これに加えて末尾のパディングに=を使います(RFC 4648の第4節)。それ以外の文字があると、厳格なデコーダーは失敗します。ややこしいのは、「それ以外」に何が含まれるかについてデコーダー同士の見解が一致しないことです。PDw_Pz8-Pg==のようなBase64URL文字列は、Node.jsのBufferでは正しくデコードされ、.NETでは失敗し、Pythonでは黙って間違ったバイト列になります。使っているデコーダーが何を許容するのかを知ることが、解決の大部分です。
言語ごとのエラー
| 環境 | 発生するもの |
|---|---|
ChromeとEdgeのatob() | InvalidCharacterError:Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded. |
Node.jsのatob() | InvalidCharacterError:Invalid character。長さが不正な場合はThe string to be decoded is not correctly encoded. |
Pythonのbase64.b64decode() | binascii.Error:Incorrect padding、Invalid base64-encoded string: number of data characters (5) cannot be 1 more than a multiple of 4、またはvalidate=True指定時はOnly base64 data is allowed |
.NETのConvert.FromBase64String() | FormatException:The input is not a valid Base-64 string as it contains a non-base 64 character, more than two padding characters, or an illegal character among the padding characters. |
JavaのBase64.getDecoder().decode() | IllegalArgumentException:Illegal base64 characterの後に、文字コードが16進数で続く(-なら2d) |
PHPのbase64_decode($s, true) | メッセージなし。関数がfalseを返す |
| AWS CLIバージョン2 | Invalid base64:の後に、渡した値が続く |
Node.jsのBuffer.from(s, 'base64')だけは毛色が違い、決してエラーを投げません。認識できない文字を読み飛ばすため、壊れた文字列からエラーなしで壊れたバイト列が生成されます。
各デコーダーが許容するもの
| 入力 | atob | Pythonのb64decode | .NET | PHP(厳格モード) |
|---|---|---|---|---|
パディングなし:SGVsbG8 | デコードされる | Incorrect padding | 失敗 | デコードされる |
余分なパディング:SGVsbG8== | 失敗 | デコードされる | 失敗 | falseを返す |
| 空白や改行 | 無視 | 無視(validate=Trueでは失敗) | 無視 | 無視 |
Base64URLの-と_ | 失敗 | 黙って除去:誤ったバイト列かエラー | 失敗 | falseを返す |
その他の文字:引用符、\、!、: | 失敗 | 黙って除去 | 失敗 | falseを返す |
Pythonの既定モードには注意が必要です。デコード前にアルファベット外の文字をすべて捨てるため、data:image/jpeg;base64,のような接頭辞の英字がデータとしてデコードされてしまいます。接頭辞の長さによって、文字数に関するわかりにくいエラーになるか、エラーは出ずに本来の内容の前に数バイトのゴミが付くかのどちらかです。不正な入力をはっきり失敗させたいときはvalidate=Trueを渡してください。
紛れ込んだ文字
厳格なデコーダーが不正な文字を報告するとき、文字列はほぼ確実に、手元に届くまでのどこかで余計なものを拾っています。
- 引用符や周囲のテキスト。引用符ごと
"SGVsbG8="をコピーしたり、"key": "value"の行をまるごとコピーしたりすると、Base64ではない文字が加わります。 - データURIの接頭辞。
data:image/png;base64,iVBOR...のうちBase64なのはカンマより後ろだけです。カンマを含めて、そこまでをすべて取り除いてください。 - JSONのエスケープ。PHPの
json_encodeは/を\/と書き出し、PEM証明書のように折り返された内容は行の間に\nを挟んで保存されます。パース後の値ではなく生のJSONテキストをコピーすると、バックスラッシュまで付いてきます。先にJSONをパースし、得られた文字列をデコードしてください。 - Base64URLの文字。
-と_があれば、その文字列はJWTと同じURLセーフなアルファベットを使っています。下のセクションを参照してください。 - 見えない文字。チャットアプリや文書から貼り付けたノーブレークスペースやゼロ幅スペースはASCIIの空白ではないため、通常の空白を無視する
atobでさえ拒否します。
まったくエラーにならないケースもひとつあります。標準Base64の値をパーセントエンコードせずにクエリ文字列に入れると、クエリの解析時に+がそれぞれ空白に変わります。atobやPythonはその空白を読み飛ばすため、++++YWJjは YWJjとして届いてabcにデコードされ、先頭の3バイトが黙って失われます。値はencodeURIComponentでエンコードするか、URLではBase64URLを使ってください。
長さとパディングの誤り
Base64は3バイトごとに4文字へ変換するため、パディング付きの文字列の長さは必ず4で割り切れます。パディングがない場合、最後のグループは2文字か3文字です。余りが1になることは、どんな入力でもあり得ません。length % 4 === 1なら文字が1つ失われたか余分に加わっており、パディングをいくら足しても直りません。
パディングは末尾にしか現れず、=が2つを超えることはありません。RFC 4648は、Base64を利用するフォーマット側が別に定めない限りパディングを必須としていますが、省略を定めているフォーマットもいくつかあります。JWTやPKCEのコードチャレンジはパディングを省きます。厳格なデコーダーに渡す前に、欠けたパディングを補うには次のようにします。
// JavaScript
const padded = s + '='.repeat((4 - s.length % 4) % 4);
# Python
padded = s + '=' * (-len(s) % 4)
標準デコーダーに渡されたBase64URL
Base64URL(RFC 4648の第5節)は+を-に、/を_に置き換え、通常はパディングも省きます。標準デコーダーはこの2文字をどちらも拒否しますが、失敗するのはたまたまそのどちらかを含む文字列だけなので、バグがランダムに起きているように見えます。ランダムな16バイトの値はBase64URLで22文字になり、そのうち21文字が-か_になり得ます。1文字がその両方を避ける確率は62/64なので、(62/64)の21乗、およそ51パーセントの値は問題なくデコードされ、残りの49パーセントは失敗します。アルファベットを変換してパディングを戻すか、この変種に対応したデコーダーを使ってください。
// Browser
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
// Node.js
Buffer.from(s, 'base64url');
# Python
base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
// Java
Base64.getUrlDecoder().decode(s);
Pythonのurlsafe_b64decodeでもパディングは必要なので、例ではパディングを追加しています。2つのアルファベットの違いはBase64とBase64URLの違いで詳しく解説しています。
メールやPEMファイルの改行
MIMEメールの本文はBase64を1行76文字で折り返し、証明書などのPEMファイルは64文字で折り返します。atob、.NET、PHPは改行を読み飛ばしますが、Javaの基本デコーダーは読み飛ばしません。折り返された入力にはBase64.getMimeDecoder()を使ってください。PEMの場合は、Base64ではない-----BEGIN ...-----と-----END ...-----の行も削除します。
デコードはできたが結果がおかしい
- 出力がまたBase64になっている。データが二重にエンコードされています。
YUdWc2JHOD0=をデコードするとaGVsbG8=になり、それをデコードするとhelloになります。もう一度デコードしてください。 - アクセント付きの文字が
éのように表示される。atobは1バイトにつき1文字を返すため、UTF-8テキストにはもう1段階の処理が必要です:new TextDecoder().decode(Uint8Array.from(atob(s), c => c.charCodeAt(0))) - デコード後にPythonが
UnicodeDecodeErrorを出す。バイト列はBase64としては正しいものの、UTF-8テキストではありません。たとえばLatin-1で保存されたテキストでは'utf-8' codec can't decode byte 0xe9となります。.decode('cp1252')のように正しいエンコーディングでデコードするか、結果をバイナリとして扱ってください。 - 出力が読めない記号の羅列になる。データは画像、PDF、圧縮データなどのバイナリです。表示するのではなく、バイト列をファイルに保存してください。
AWS CLIの「Invalid base64」
AWS CLIバージョン2は、既定でバイナリ型のパラメーターをBase64として受け取ります。aws lambda invoke --payload '{"id": 1}'のようなコマンドにプレーンなJSONを渡すと、Invalid base64で失敗します。テキストをそのまま渡すには--cli-binary-format raw-in-base64-outを追加するか、ペイロードを自分でエンコードしてください。
文字列をすばやく確認する
値をBase64デコードに貼り付けてください。デコードの前に空白とデータURIの接頭辞を取り除き、Base64URLの文字を変換し、欠けたパディングを補います。それでも有効なBase64ではないと表示されるなら、文字列には本当に異質な文字が含まれているか、あり得ない長さになっています。デコードできた場合、UTF-8テキストはそのまま表示され、画像などそれ以外のものはダウンロード用のファイルとして提供されます。