JavaScript で TOTP を実装してみたので、その実装メモなど。
https://github.com/comame/TOTP
RFC 6238: TOTP: Time-Based One-Time Password Algorithm で規定される。Google Authenticator アプリなどを使って時刻ベースで生成される (大抵) 6 桁のワンタイムパスワードであり、2 要素認証に用いられる。
疑似コードで次のように表される。
K: 共有シークレット、QR コードとかで読み込むやつX: ステップ秒。大抵 30 秒。この値の周期でトークンが切り替わるT0: Unix Time の開始秒。大抵 0T = (Current Unix Time - T0) / X) as IntegerTOTP(K, T) = HOTP(K, T) = Truncate(HMAC-SHA-1(K, T))
トークンの生成アルゴリズムには HOTP を使用する。
のちに記述するように、HOTP では T の値が 32-bit までしかサポートされていないため、4.2 節では 32-bit より大きい整数をサポートするように規定されている。具体的にどう実装するのかは記述されていないので、Google Authenticator の実装などを見るのが良いと思われる。
RFC 4226: HOTP: An HMAC-Based One-Time Password Algorithm で規定される。カウンターベースのワンタイムパスワード。
K: 共有シークレットC: カウンター。8-byte の整数HS: 20-byteS: 31-bitD: 桁数が Digit の HOTP トークンDT(HS: bytes[20])OffsetBits = HS[19] & 0xF // HS[19] の下位 4-bitOffset = OffsetBits as Integer // 0 <= offset<= 15P = HS[Offset]...HS[Offset + 3]return P & 0x7FFFFFFF // 下位 31-bitHS = HMAC-SHA-1(K, C)S = DT(HS)D = (S as Integer) mod 10^Digit
RFC の 5.4 節の例は次の通りである。
HS = {1F, 86, 98, 69, 0E,02, CA, 16, 61, 85,50, EF, 7F, 19, DA,8E, 94, 5B, 55, 5A}OffsetBits = 0xAOffset = 10P = 0x50EF7F19S = 0x50EF7F19 & 0x7FFFFFFF = 0x50EF7F19D = 872921
https://www.ipa.go.jp/security/rfc/RFC2104JA.html に日本語での解説がある。
H: ハッシュ関数K: シークレットM: メッセージipad = 0x3636...opad = 0x5C5C...// ipad, opad の長さはハッシュ関数のブロック長 (SHA-1 の場合 512-bit) と同一||: ビットの連結HMAC(K, M) = H((K xor opad) || H((K xor ipad) || M))
K と opad, ipad とで排他的論理和をとっていることから分かるように、K の長さもハッシュ関数のブロック長と同一にする必要がある。K は入力値であることから、長さを揃えるために次のような処理を順に行う。
K の長さがブロック長より大きい場合、K をハッシュ関数に通す。K の長さがブロック長より小さい場合、ブロック長と同一になるまで末尾に 0 を追加する。
https://www.ipa.go.jp/security/rfc/RFC3174JA.html に日本語での解説がある。
まずはメッセージのパディングを行い、メッセージ長を 512-bit の倍数にする。オリジナルのメッセージ長が 512-bit の倍数であった時にもパディングは行う。パディングは次の手順で行う。
次の関数を定義する。
f(t; B, C, D) = (B & C) | ((~B) & D); (0 <= t <= 19)f(t; B, C, D) = B ^ C ^ D; (20 <= t <= 39)f(t; B, C, D) = (B & C) | (B & D) | (C & D) (40 <= t <= 59)f(t; B, C, D) = B ^ C ^ D; (60 <= t <= 79)K(t) = 0x5A827999; (0 <= t <= 19)K(t) = 0x6ED9EBA1; (20 <= t <= 39)K(t) = 0x8F1BBCDC; (40 <= t <= 59)K(t) = 0xCA62C1D6; (60 <= t <= 79)S^N(n) = (M << n) | (M >> (32 - n)) // 循環左シフトA + B = (A + B) mod (2 ** 32)
複数の計算方法があるようだが、今回は次の方法で計算した。
M: 512-bit ごとに区切ったメッセージH0 = 0x67452301H1 = 0xEFCDAB89H2 = 0x98BADCFEH3 = 0x10325476H4 = 0xC3D2E1F0for each M(i)W[16] = M(i) // M(i) を 32-bit ごとに分割するfor t from 16 to 79W[t] = S^1(W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16])A = H0; B = H1; C = H2; D = H3; E = H4for t from 0 to 79TMP = S^5(A) + f(t; B, C, D) + E + W[t] + K(t)E = D; D = C; C = s^30(B); A = TMPH0 += A; H1 += B; H2 += C; H3 += D; H4 += E// H0 H1 H2 H3 H4 をこの順で並べる
C は 8-byte 整数。
ハッシュ関数に SHA-1 を使用する場合、ブロック長は 64-byte、出力長は 20-byte であることから、K にハッシュを通した後に 0 を追加する処理を行う必要がある。
(追記あり) 左循環シフト。(n: number, x: number) => (x << n) | (x >> (32 - n)) と素直に書くと、x は 32-bit 整数、0 <= n < 32 であることからオーバーフローする。
オーバーフローをしないためにビットの配列に変換してシフト操作をしていたものを、number のまま計算できるように書き換えた。オーバーフローを起こさないため、32-bit 整数を 8-bit ずつに区切り、場合分けした。また、シフト演算子を使うとオーバーフローを起こすため、掛け算と割り算でシフト演算子と同等の計算を行うようにした。
次のようなコードで 10 回ずつ計測しそれぞれ平均をとったところ、高速化前は 616 ms、高速化後は 5 ms となった。
また、SHA-1 の計算自体も、おおむね半分から 5 分の 1 程度の実行時間に短縮された。
const t = Date.now()for (let i = 0; i < 100000; i +=1) circularShift(16, 0x12345678)console.log(Date.now() - t)
測定結果高速化前 [ms]608 605 611 598 612 599 698 605 612 611高速化後 [ms]4 4 6 4 5 5 5 4 5 5