JavaScript で TOTP を計算する

Pocket

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 アプリ側で完結させるような実装もできる。

ちゃんと多要素認証の一要素として所有者認証を満たせるよう、実装にはかなり気をつける必要はあるだろうが。

参考

コメントを残す

メールアドレスが公開されることはありません。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください