不正な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バージョン2Invalid base64:の後に、渡した値が続く

Node.jsのBuffer.from(s, 'base64')だけは毛色が違い、決してエラーを投げません。認識できない文字を読み飛ばすため、壊れた文字列からエラーなしで壊れたバイト列が生成されます。

各デコーダーが許容するもの

入力atobPythonのb64decode.NETPHP(厳格モード)
パディングなし: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テキストはそのまま表示され、画像などそれ以外のものはダウンロード用のファイルとして提供されます。

よくある質問

Pythonの「Incorrect padding」とはどういう意味ですか?

文字列の長さが4の倍数ではなく、=のパディングが欠けているという意味です。Base64URLやJWTの各部分ではこれが普通です。長さが4の倍数になるまで=を追加してください。長さを4で割った余りが1なら、パディングの問題ではなく文字列が壊れています。

atobがInvalidCharacterErrorを投げるのはなぜですか?

文字列にBase64のアルファベット外の文字(Base64URLの-や_、引用符、バックスラッシュなど)が含まれているか、余分なパディングやあり得ない長さになっています。空白や改行は無視されるので、原因ではありません。

同じ文字列が、ある言語ではデコードでき、別の言語では失敗するのはなぜですか?

デコーダーによって厳格さが違うからです。PythonとNode.jsのBufferは既定で未知の文字を読み飛ばし、PHPの厳格モードとatobはパディングの欠落を受け入れ、.NETはパディングの欠落も未知の文字も拒否します。

Base64URLはどうデコードすればよいですか?

-を+に、_を/に置き換え、長さが4の倍数になるまで=を追加してから、通常どおりデコードします。Node.jsならBuffer.from(s, 'base64url')でそのまま受け付けます。