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
2
3
4
5
6
7
8
9
10
function bytesToBase32(bytes){
var out='',bits=0,val=0;
for(var i=0;i<bytes.length;i++){
val=(val<<8)|bytes[i];bits+=8;
while(bits>=5){out+=ALPH[(val>>>(bits-5))&31];bits-=5;}
}
if(bits>0){out+=ALPH[(val<<(5-bits))&31];} // 残りは右を 0 で埋めて 1 文字
while(out.length%8!==0){out+='=';} // 8 文字単位まで = で埋める
return out;
}

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
2
var bytes=new Uint8Array(20);
(window.crypto||window.msCrypto).getRandomValues(bytes);

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
function normSecret(s){return String(s||'').toUpperCase().replace(/[^A-Z2-7]/g,'');}

そのうえで 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const crypto = require('node:crypto');
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

// ハシトシステムの Base32 ツールと同じビットバッファ方式
function bytesToBase32(bytes) {
let out = '', bits = 0, val = 0;
for (const b of bytes) {
val = (val << 8) | b; bits += 8;
while (bits >= 5) { out += ALPH[(val >>> (bits - 5)) & 31]; bits -= 5; }
}
if (bits > 0) out += ALPH[(val << (5 - bits)) & 31];
while (out.length % 8 !== 0) out += '=';
return out;
}
function base32ToBytes(str) {
const s = str.toUpperCase().replace(/=+$/, '').replace(/\s+/g, '');
let bits = 0, val = 0; const bytes = [];
for (const ch of s) {
const idx = ALPH.indexOf(ch);
if (idx < 0) throw new Error('bad char: ' + JSON.stringify(ch));
val = (val << 5) | idx; bits += 5;
if (bits >= 8) { bytes.push((val >>> (bits - 8)) & 255); bits -= 8; }
}
return Buffer.from(bytes);
}

// RFC 6238 TOTP(RFC 4226 の動的切り詰め)
function totp(key, unixSec, { period = 30, digits = 6, algo = 'sha1' } = {}) {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(unixSec / period)));
const h = crypto.createHmac(algo, key).update(counter).digest();
const off = h[h.length - 1] & 0x0f;
const bin = h.readUInt32BE(off) & 0x7fffffff;
return String(bin % 10 ** digits).padStart(digits, '0');
}

console.log('Node', process.version);
for (const t of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
console.log(JSON.stringify(t).padEnd(9), bytesToBase32(Buffer.from(t)));
}
console.log('10 bytes ->', bytesToBase32(crypto.randomBytes(10)).length, 'chars');
console.log('20 bytes ->', bytesToBase32(crypto.randomBytes(20)).length, 'chars');

const secret = bytesToBase32(Buffer.from('12345678901234567890'));
console.log('secret :', secret);
const key = base32ToBytes(secret.toLowerCase());
for (const t of [59, 1111111109, 2000000000]) {
console.log('T=' + t, totp(key, t, { digits: 8 }));
}

for (const s of ['MZXW6===', 'mzxw6', 'MZXW 6YQ=', 'MZXW6===\n']) {
try { console.log(JSON.stringify(s).padEnd(14), '->', base32ToBytes(s).toString()); }
catch (e) { console.log(JSON.stringify(s).padEnd(14), '->', e.message); }
}
console.log('"MZ" ->', JSON.stringify(base32ToBytes('MZ').toString()));
console.log('"MZ7" ->', JSON.stringify(base32ToBytes('MZ7').toString()));

実行結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Node v24.11.1
""
"f" MY======
"fo" MZXQ====
"foo" MZXW6===
"foob" MZXW6YQ=
"fooba" MZXW6YTB
"foobar" MZXW6YTBOI======
10 bytes -> 16 chars
20 bytes -> 32 chars
secret : GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
T=59 94287082
T=1111111109 07081804
T=2000000000 69279037
"MZXW6===" -> foo
"mzxw6" -> foo
"MZXW 6YQ=" -> foob
"MZXW6===\n" -> bad char: "="
"MZ" -> "f"
"MZ7" -> "f"

前半は 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 変換ツール と見比べるとわかりやすい。