TOTP: Time-Based One-Time Password Algorithm とは、二要素認証でよく見る Google Authenticator 等で QRコード を読み込ませたりして、一定時間毎に替わる 6桁 くらいの数字を入力するアレだ。
RFC 6238 に詳しい仕様が書かれている。
コイツを TOTP をブラウザ上で生成したい。
幸い近年の Web API には、バイナリ操作やクリプトまわりの機能が出揃っているので、わりかし簡単に実装できそうだ。
…とまぁ、そんな愚生が思いつくような話など既に、偉大なる先人たちによる多くのサンプルが残されている。
せっかく自分で実装するなら、最新の ES 仕様や Web API を活用して簡潔に、それでいて汎用的な仕様で書いてみよう。
実装
// @ts-check
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const base32AlphabetValuesMap = new Map([
...Array.from(base32Alphabet, (key, index) => /** @type {[string, number]} */([key, index])),
]);
/** @type { (strBase32: string) => Uint8Array } */
function decodeBase32(strBase32) {
const strTrimmed = strBase32.replace(/=*$/, "");
const result = new Uint8Array(strTrimmed.length * 5 >>> 3);
let dataBuffer = 0;
let dataBufferBitLength = 0;
let byteOffset = 0;
for (const encoding of strTrimmed) {
const value = base32AlphabetValuesMap.get(encoding);
if (typeof value === "undefined") throw new Error("Invalid base32 string");
dataBuffer <<= 5;
dataBuffer |= value;
dataBufferBitLength += 5;
if (dataBufferBitLength >= 8) {
dataBufferBitLength -= 8;
result[byteOffset++] = dataBuffer >>> dataBufferBitLength;
}
}
if (dataBufferBitLength >= 5) throw new Error("Invalid base32 string");
if ((dataBuffer << (4 - dataBufferBitLength) & 0xf) !== 0) throw new Error("Invalid base32 string");
return result;
}
const hashAlgorithmNameMap = new Map([
["SHA1", "SHA-1"],
["SHA256", "SHA-256"],
["SHA512", "SHA-512"],
]);
/** @type { (secretUint8Array: Uint8Array, unixTimeMilliseconds: number, digits?: 6|7|8, period?: number, algorithm?: "SHA1"|"SHA256"|"SHA512") => Promise<string> } */
async function generateTOTP(secretUint8Array, unixTimeMilliseconds, digits = 6, period = 30, algorithm = "SHA1") {
// unixTimeMilliseconds to step binary
const stepsBuffer = new ArrayBuffer(8);
const dv = new DataView(stepsBuffer);
dv.setBigUint64(0, BigInt(Math.floor(unixTimeMilliseconds / 1000 / period)));
// calc Hash
const hashAlgorithmName = hashAlgorithmNameMap.get(algorithm);
if (!hashAlgorithmName) throw new Error(`invalid algorithm: ${algorithm}`);
const cryptKey = await crypto.subtle.importKey("raw", secretUint8Array, { name: "HMAC", hash: hashAlgorithmName }, false, ["sign"]);
const hash = new Uint8Array(await crypto.subtle.sign("HMAC", cryptKey, stepsBuffer));
// Truncate
const offset4Bit = hash.at(-1) & 0xf;
const binary = (
(hash[offset4Bit] & 0x7f) << 24 |
hash[offset4Bit + 1] << 16 |
hash[offset4Bit + 2] << 8 |
hash[offset4Bit + 3]
);
// stringify
const otp = binary % Math.pow(10, digits);
return otp.toString().padStart(digits, "0");
}
実行例
// 実行例
await generateTOTP(decodeBase32('XXXXXXXXXXXXXXXX'), Date.now(), 8, 30, "SHA1");
// 実行例2 => "94287082"
await generateTOTP(decodeBase32('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), Date.parse("1970-01-01T00:00:59Z"), 8)
// 実行例2.2 => "94287082"
await generateTOTP(decodeBase32('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), Date.parse("1970-01-01T00:00:59Z"), 8, 30, "SHA1")
// 実行例3 => "32247374"
await generateTOTP(decodeBase32('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), Date.parse("1970-01-01T00:00:59Z"), 8, 30, "SHA256")
// 実行例4 => "69342147"
await generateTOTP(decodeBase32('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), Date.parse("1970-01-01T00:00:59Z"), 8, 30, "SHA512")
ハッシュアルゴリズムや、表示桁数、表示期間と言ったパラメータを変化させて、TOTP を出力できる。
応用例
例えば、前回の QRコード リーダーと組み合わせれば、 TOTP の管理を Web アプリ側で完結させるような実装もできる。
ちゃんと多要素認証の一要素として所有者認証を満たせるよう、実装にはかなり気をつける必要はあるだろうが。