Google Authenticator 等で利用されている HOTP (HMAC-Based One-Time Password) や TOTP (Time-Based One-Time Password) を JavaScript で計算する方法を書きます。
"HMAC-Based" は「カウンタベース」、"Time-Based" は「時間ベース」や「タイムベース」と訳されます。
参考「RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm 日本語訳」
参考「RFC 6238 - TOTP: Time-Based One-Time Password Algorithm 日本語訳」
1. 鍵
1.1. 鍵をやり取りするために Base32 エンコードを利用する
HOTP や TOTP の仕様として決められているわけではないですが、多くの場合、鍵を Base32 エンコードしてやり取りするため、Base32 エンコードを利用できるように準備します。
参考「[JavaScript] Unicode 文字列やバイナリデータを Base32 エンコードおよびデコードする - Qiita」
// メモ: RFC 4648 で定義されている Base32 文字
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const encodeBase32 = uint8Array => {
const byteLength = uint8Array.byteLength;
let dataBuffer = 0;
let dataBufferBitLength = 0;
let byteOffset = 0;
let result = '';
// バッファにデータが残っているか、またはバッファに読み込めるデータが残っていたら継続
while ( dataBufferBitLength > 0 || byteOffset < byteLength ) {
// バッファのデータが少なければデータを追加する
if ( dataBufferBitLength < 5 ) {
if ( byteOffset < byteLength ) {
// 読み込めるデータが残っていたら読み込む
dataBuffer <<= 8;
dataBuffer |= uint8Array[byteOffset++];
dataBufferBitLength += 8;
} else {
// 読み込めるデータがなければ値が 0 のパディングビットを追加して長さを 5 ビットにする
dataBuffer <<= 5 - dataBufferBitLength;
dataBufferBitLength = 5;
}
}
// バッファのデータの左の長さ 5 ビットの値を取得する
dataBufferBitLength -= 5;
const value = dataBuffer >>> dataBufferBitLength & 0x1f;
// 値を Base32 文字に変換
result += base32Alphabet[value];
}
// パディング文字 '=' を追加
const targetLength = Math.ceil(result.length / 8) * 8;
const resultPadded = result.padEnd(targetLength, '=');
return resultPadded;
};
1.2. 鍵を生成する
後述しますが、本記事では HOTP も TOTP も HMAC-SHA-1
ハッシュ値を用いるため、鍵の長さは 20 バイトにします。
// 鍵を生成
const seedUint8Array = new Uint8Array(20);
crypto.getRandomValues(seedUint8Array);
console.log(Array.from(seedUint8Array, uint8 => `0x${uint8.toString(16).padStart(2, '0')}`).join(', '));
const seedString = encodeBase32(seedUint8Array);
console.log(seedString);
0xdd, 0x1e, 0x26, 0x25, 0xa8, 0xda, 0x7f, 0xa3, 0xb8, 0x6b, 0x80, 0x9e, 0xd9, 0x20, 0xc8, 0x68, 0x8f, 0x53, 0x83, 0x46
3UPCMJNI3J72HODLQCPNSIGINCHVHA2G
鍵は乱数で生成しますが、暗号強度の弱い Math.random()
でなく暗号強度の強い Crypto.getRandomValues()
を用います。
参考「Crypto.getRandomValues() - Web API | MDN」
鍵をやり取りするために鍵を Base32 エンコードした文字列を得ます。
2. HOTP を計算する
HOTP (HMAC-Based One-Time Password) は名前の通り HMAC を利用します。
「鍵」と「カウンタ」と呼ばれる値から HMAC ハッシュ値を求めます。
仕様上はダイジェスト関数に SHA-256
や SHA-512
を用いることができますが、多くの場合 SHA-1
が使われるため、ここでは HMAC-SHA-1
ハッシュ値を使用します。
HMAC-SHA-1
の場合は鍵の長さを 20 バイトにします。
「カウンタ」はビッグエンディアン形式で長さは 8 バイトとします。
カウンタの初期値は自由ですが、0 または 1 に設定される場合が多いようです (Google Authenticator で鍵を手動入力した場合は 1 に設定されるようです) 。
ここでは HOTP の桁数は良く使われている 6 とします。
//
const hmacSHA1 = async (keyUint8Array, dataUint8Array) => {
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyUint8Array,
{
name: 'HMAC',
hash: { name: 'SHA-1' },
},
false,
['sign'],
);
const signatureArrayBuffer = await crypto.subtle.sign('HMAC', cryptoKey, dataUint8Array);
const signatureUint8Array = new Uint8Array(signatureArrayBuffer);
return signatureUint8Array;
};
/**
* 動的切り捨てする
*/
const dynamicTruncate = digestUint8Array => {
// 下位 4 ビットを offset とする
// digestUint8Array.length === 20;
const offset = digestUint8Array[19] & 0xf;
// offset から 4 バイトの数値を得る
const binary = (
digestUint8Array[offset ] << 24 |
digestUint8Array[offset + 1] << 16 |
digestUint8Array[offset + 2] << 8 |
digestUint8Array[offset + 3]
);
// 符号有無の混乱を防ぐために最上位ビットを除外する
const binaryMasked = binary & 0x7fffffff;
return binaryMasked;
};
/**
* HOTP を計算する
*
* カウンタはビッグエンディアンで 8 バイト
*/
const generateHOTP = async (seedUint8Array, counterUint8Array) => {
const digestUint8Array = await hmacSHA1(seedUint8Array, counterUint8Array);
const otp = dynamicTruncate(digestUint8Array) % 1000000;
const otpString = otp.toString().padStart(6, '0');
return otpString;
};
JavaScript では Web Crypto API を用いて HMAC-SHA-1
ハッシュ値を計算できます。
参考「SubtleCrypto: importKey() method - Web APIs | MDN」
参考「SubtleCrypto: sign() method - Web APIs | MDN」
HMAC-SHA-1
ハッシュ値の長さは 20 バイトですが、HOTP の長さをそこまで長くしないため、RFC 4226 で定義されている「動的切り捨て (dynamic truncation)」を行って 4 バイトの長さの値を得ます (下位 4 ビットをオフセットとみなし、そのオフセットから長さ 4 バイトを切り出す) 。
また、32 ビットの値の符号有無により後の計算結果が異なることを防ぐため、最上位ビットを除外して 31 ビットの値にします。
HOTP の桁数を 6 にするため、$10^6 = 1000000$ で割った余りを求めます。
参考「5.3. Generating an HOTP Value - RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm 日本語訳」
// HOTP を計算する
const seedUint8Array = new Uint8Array([0xdd, 0x1e, 0x26, 0x25, 0xa8, 0xda, 0x7f, 0xa3, 0xb8, 0x6b, 0x80, 0x9e, 0xd9, 0x20, 0xc8, 0x68, 0x8f, 0x53, 0x83, 0x46]);
const counterUint8Array = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]);
const hotp = await generateHOTP(seedUint8Array, counterUint8Array);
console.log(hotp);
836609
※ HOTP を実用的に使うは別途「ブルートフォース攻撃対策」や「カウンタ再同期」の機能を実装する必要があります。
参考「7. Security Requirements - RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm 日本語訳」
3. TOTP を計算する
TOTP は、秒単位の UNIX 時間から「ステップ数」と呼ばれる値を計算し、その値をカウンタの値として HOTP を計算します。
「ステップ数」は UNIX 時間を「時間ステップ」と呼ばれる時間で割った商です。
「時間ステップ」は多くの場合 30 秒とされるため、ここでも 30 秒とします。
//
const timeStep = 30;
//
const getCurrentUnixTime = () => Math.floor(Date.now() / 1000);
const getCurrentSteps = () => Math.floor(getCurrentUnixTime() / timeStep);
/**
* 整数をビッグエンディアン形式で Uint8Array に変換する
*/
const intToUint8ArrayInBigEndian = number => {
// メモ: number が負の場合、Math.floor() なしだと正しい結果を得られないため注意
const highOrderDigits = Math.floor(number / 0x1_00000000);
//
const uint8Array = new Uint8Array(8);
uint8Array[0] = highOrderDigits >>> 24 & 0xff;
uint8Array[1] = highOrderDigits >>> 16 & 0xff;
uint8Array[2] = highOrderDigits >>> 8 & 0xff;
uint8Array[3] = highOrderDigits & 0xff;
uint8Array[4] = number >>> 24 & 0xff;
uint8Array[5] = number >>> 16 & 0xff;
uint8Array[6] = number >>> 8 & 0xff;
uint8Array[7] = number & 0xff;
return uint8Array;
};
/**
* TOTP を計算する
*/
const generateTOTP = async (seedUint8Array, steps) => {
const stepsUint8Array = intToUint8ArrayInBigEndian(steps);
const otpString = await generateHOTP(seedUint8Array, stepsUint8Array);
return otpString;
};
JavaScript では Date.now()
を用いてミリ秒単位の UNIX 時間を取得できます。
参考「Date.now() - JavaScript | MDN」
前述の通り HOTP のカウンタは 8 バイトの長さで扱うため、ステップ数を 8 バイトにします。
参考「[JavaScript] 整数を長さ 64 ビットの Uint8Array に変換する - Qiita」
// TOTP を計算する
const seedUint8Array = new Uint8Array([0xdd, 0x1e, 0x26, 0x25, 0xa8, 0xda, 0x7f, 0xa3, 0xb8, 0x6b, 0x80, 0x9e, 0xd9, 0x20, 0xc8, 0x68, 0x8f, 0x53, 0x83, 0x46]);
const totp = await generateTOTP(seedUint8Array, getCurrentSteps());
console.log(totp);
524240
4. Google Authenticator で HOTP または TOTP を利用する
Google Authenticator の「セットアップキーを入力 (Enter a setup key)」から、前述の seedString
の値を入力し、「カウンタベース (Counter based)」または「時間ベース (Time based)」を選択することで、Google Authenticator 上にコードが表示されるようになります。
HOTP の場合、カウンタが同期されていれば前述のコードの hotp
の値と一致します。
TOTP の場合、時刻がしっかり同期されていれば前述のコードの totp
の値と一致します。
※ HOTP または TOTP 用の QR コードの生成方法は後述。
5. TOTP で時間のズレを許容する
本記事では深く触れませんが、前回や次回の TOTP を許容することで多少の時間のズレがあってもコードを受け入れることができます。
// TOTP を計算する
const seedUint8Array = new Uint8Array([0xdd, 0x1e, 0x26, 0x25, 0xa8, 0xda, 0x7f, 0xa3, 0xb8, 0x6b, 0x80, 0x9e, 0xd9, 0x20, 0xc8, 0x68, 0x8f, 0x53, 0x83, 0x46]);
const steps = getCurrentSteps();
const totpPrev = await generateTOTP(seedUint8Array, steps - 1);
console.log(totpPrev);
const totp = await generateTOTP(seedUint8Array, steps);
console.log(totp);
const totpNext = await generateTOTP(seedUint8Array, steps + 1);
console.log(totpNext);
262912
147845
014180
6. HOTP または TOTP 用の QR コードを生成する
鍵等をやり取りするために「otpauth URI」が用いられます。
otpauth URI を QR コードにすることで Google Authenticator 等で読み込めるようになります。
TOTP の場合は以下のようにして otpauth URI を生成できます。
//
const timeStep = 30;
//
const seedString = '3UPCMJNI3J72HODLQCPNSIGINCHVHA2G';
// otpauth URI を生成する
const issuer = encodeURIComponent('Test Issuer');
const accountname = encodeURIComponent('Test Account Name');
const type = 'totp';
const label = `${issuer}:${accountname}`;
const parameters = `secret=${seedString}&issuer=${issuer}&algorithm=SHA1&digits=6&period=${timeStep}`;
const otpauthURI = `otpauth://${type}/${label}?${parameters}`;
console.log(otpauthURI);
otpauth://totp/Test%20Issuer:Test%20Account%20Name?secret=3UPCMJNI3J72HODLQCPNSIGINCHVHA2G&issuer=Test%20Issuer&algorithm=SHA1&digits=6&period=30
参考「Key Uri Format · google/google-authenticator Wiki · GitHub」
※QRコードは株式会社デンソーウェーブの登録商標です。