TOTPの鍵がBase32で32文字・=なしになる理由をNode.jsで確かめる
2段階認証アプリに手入力する共有鍵は、たいてい GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ のような「英大文字と 2〜7 だけの 32 文字」で、末尾に = が付かない。これは Base32(RFC 4648)で 20 バイトの鍵を表した結果である。
この記事では、ハシトシステムの Base32 変換ツール と otpauth QRコード生成ツール のコードに沿って、Base32 がどうビットを詰めているか、なぜ TOTP の鍵では = が出ないのかを説明し、最後に Node.js で TOTP の値まで計算して RFC のテストベクタと突き合わせる。
Base32 は 5 ビットで 1 文字
Base32 のアルファベットは ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 の 32 文字で、1 文字が 5 ビットを表す。0・1・8・9 を使わないので、O と 0、I と 1 の取り違えが起きにくい。
バイト列は 8 ビット単位、Base32 は 5 ビット単位なので、両者の区切りが揃うのは 40 ビット(5 バイト = 8 文字)ごとになる。RFC 4648 の 6 章は、この 40 ビットに満たない末尾の扱いを次のように決めている。
| 末尾に残るビット数 | 出力する文字数 | 付ける = |
|---|---|---|
| 8 ビット(1 バイト) | 2 文字 | 6 個 |
| 16 ビット(2 バイト) | 4 文字 | 4 個 |
| 24 ビット(3 バイト) | 5 文字 | 3 個 |
| 32 ビット(4 バイト) | 7 文字 | 1 個 |
RFC 4648 の 10 章にあるテストベクタでいえば、"f" は MY======、"foobar"(6 バイト = 5 + 1)は MZXW6YTBOI====== になる。
ツールの実装: ビットバッファに積んで 5 ビットずつ取り出す
Base32 変換ツールのエンコード関数は、整数 val をビットバッファとして使い、1 バイト読むたびに 8 ビット積み、5 ビット以上たまったら上から 5 ビット取り出す。
1 | |
val は読み進めるほど左へずれていき、JavaScript のビット演算は 32 ビット整数で行われるので上位ビットは捨てられる。それでも問題にならないのは、使うのが常に下位の bits ビットだけで、bits は最大でも 4 + 8 = 12 にしかならないからである。
最後の (val<<(5-bits))&31 は、余ったビットの右側を 0 で埋めて 1 文字にしている。RFC 4648 の 3.5 節は、この詰め物ビットを 0 にすることを符号化側に求めている(同じバイト列から別の文字列が生まれないようにするため)。
TOTP の鍵で = が出ない理由
otpauth QR ツールの「ランダムなシークレットを作る」ボタンは、crypto.getRandomValues で 20 バイトを作り、Base32 に変換する。
1 | |
20 バイトは 160 ビットで、5 で割り切れる(160 / 5 = 32)。40 ビットの区切りでいえば 4 区切りちょうどなので、末尾の半端が出ず、= も付かない。冒頭の GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ は RFC 6238 のテスト用の鍵 12345678901234567890(ASCII 20 バイト)を Base32 にしたものである。同じく 10 バイト(80 ビット)の鍵なら 16 文字で、これも = は付かない。
鍵の長さが 5 バイトの倍数でない場合は = が出るが、otpauth URI では付けないことになっている。Google Authenticator の Key Uri Format の説明は、secret パラメータを RFC 3548 の Base32 とし、パディングは不要で省くべきだとしている。otpauth QR ツールは入力を次の関数で正規化していて、小文字は大文字に直し、= や空白を含め A〜Z・2〜7 以外の文字はすべて取り除く。
1 | |
そのうえで 16 文字(80 ビット)未満は「短すぎます」としてエラーにしている。なお HOTP の RFC 4226 は共有鍵の長さを 128 ビット以上とし、160 ビットを推奨している。20 バイトの乱数はこの推奨値に合わせた長さである。
実際に試す
Node.js 24.11.1 で確認した。Base32 ツールと同じ符号化・復号の関数に、RFC 6238 の TOTP 計算(HMAC-SHA1・動的切り詰め)を足したものである。外部パッケージは使わない。totp-base32.js として保存して node totp-base32.js で動く。
1 | |
実行結果:
1 | |
前半は RFC 4648 10 章のテストベクタと一致し、Python 3 の base64.b32encode でも同じ文字列になることを確かめた。T=59 などの 8 桁の値は、RFC 6238 付録 B の SHA1 のテストベクタ(94287082 / 07081804 / 69279037)と一致する。鍵を小文字にしてから復号しても同じ値になるのは、復号の先頭で toUpperCase() しているからである。
現在時刻のコードが欲しければ totp(key, Date.now() / 1000) とすればよい。認証アプリに GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ を手入力して、同じ 6 桁が出ることを見比べられる(テスト用の鍵なので、実際のアカウントには使わないこと)。
この実装が受け付けてしまうもの・受け付けないもの
実行結果の最後の3行は、復号関数の限界を示している。
=の後ろに改行があると失敗する。replace(/=+$/,'')で末尾の=を消してから空白を消しているので、MZXW6===の後ろに改行があると$が改行の手前にマッチせず、=が残って不正文字になる。コピーした行末の改行ごと貼ると起きる。空白の除去を先にすれば直る(s.replace(/\s+/g,'').replace(/=+$/,''))。otpauth QR ツールのほうは=も空白も一括で除去するので、この問題は起きない。- ありえない長さを検査しない。RFC 4648 の出力は、
=を除くと 8 文字単位で余りが 0・2・4・5・7 文字のどれかになる。MZ7のような 3 文字の余りは正しい符号化からは出てこないが、この関数は 8 ビットたまった分だけを返し、残りを黙って捨てる。MZ7がMZと同じfになるのはこのためで、詰め物ビットが 0 かどうかも見ていない。厳密に検証したいなら、余りの文字数と、最後に捨てるビットが 0 であることを確かめる処理を足す。
まとめ
- Base32 は 1 文字 5 ビット。バイトとの区切りが揃う 40 ビット(5 バイト・8 文字)に満たない末尾を
=で埋める。 - TOTP の鍵によくある 20 バイト(160 ビット)は 5 ビットで割り切れるので、ちょうど 32 文字・
=なしになる。otpauth URI では=を付けないのが決まりである。 - 復号は「空白を消す → 末尾の
=を消す」の順で行う。逆にすると、行末に改行の付いた鍵で失敗する。
ツール: Base32 エンコード / デコード(ハシトシステム)、otpauth QRコード生成。Base64 との違いは Base64 変換ツール と見比べるとわかりやすい。