セキュリティ miharu

Google Authenticatorのあの6桁はどう作られている?ワンタイムパスワード(TOTP)の仕組みを調べてみた

はじめに

ログイン時の2段階認証(2FA)で、パスワードアプリ「Google Authenticator」をよく使用しています。

アプリを開くたびに30秒ごとに更新される6桁の数字で、スマホがオフラインであってもサーバー側とアプリ側で全く同じ6桁の数字が生成されるようになっています。

こちらの数字の生成には TOTP(Time-based One-Time Password) という標準化されたアルゴリズムが使用されているのですが、6ケタの数字がどういった仕組みで作られているのかを調べてみたので記事にしてみます。


1. TOTPとは?

概要

TOTPは Time-based One-Time Password の略で、「時間経過によって変化する一回限りのパスワード」 を生成する標準規格(RFC6238)です。

二要素認証(2FA)や多要素認証(MFA)におけるセキュリティ要素として、Google認証システムや1Passwordなど多くのサービスで利用されています。

セットアップの流れは大まかに以下となっています。(よくあるQRコードをスマホで読み取るときのイメージです)

  1. サーバー側: 秘密鍵を生成する
  2. QRコード表示: 生成された秘密鍵を含めた認証の情報をQRコードとして表示する
  3. 認証アプリ: QRコードを読み込み、アプリの中に保存する

と非常にシンプルな流れになります。サーバー側とアプリ側で秘密鍵を共有した状態になっているので、認証の時は「同じものを」「同じように計算する」ことで、別の端末の間でも同じ結果が出せるようになっています。

QRコードの中身は???

登録時に読み取るQRコードですが、中身は以下のような URIスキーム(otpauth://) の文字列が格納されています。

otpauth://totp/サイトの情報  # 識別用のラベルとしてサービス名やアカウント名が記載される
?secret=XXXXXXXXXXXXXXXX # ここに秘密鍵が記載される
&issuer=発行者名 # 発行者名やサービス名
&algorithm=SHA1 # 暗号化方式
&digits=発行者名 # ケタ数
&period=30 # カウントの周期

認証アプリはQRコードからこういった文字列を読み取り、secretの値を秘密鍵として端末内に安全に保存しています。


2. TOTPのアルゴリズムと仕組み

TOTPの規格(RFC6238)は、もともと存在する HOTP(HMAC-based One-Time Password, RFC 4226) という「カウンターベース」のワンタイムパスワード規格を応用したものです。

HOTPが「ボタンを押した回数(カウンター)」を増やすのに対し、TOTPは 「現在時刻(Unixタイムスタンプ)」 をカウンターとして利用しています。それにより、どこかに通信をすることなく、オフラインでも認証に使用できるようになっています。

全体の計算フロー

TOTPの生成プロセスは、大きく分けて以下の4つのステップで行われます。

  1. タイムステップ(カウンター値)の算出
  2. HMACハッシュ値の計算
  3. Dynamic Truncation(動的切り詰め)
  4. 6桁の数値への変換(Modulo演算)

このように書くと難しそうですが、一つ一つは非常にシンプルな内容でしたので、以下に記載していきます。


ステップ1:タイムステップ(カウンター値)の算出

まず、現在時刻(Unixタイムスタンプ)を取得し、30秒ごとの区切りに変換します。

counter = | Unix時刻 / 30 |
※Unix時刻は1970年1月1日 00:00:00 からの経過秒数

今の時刻を30で割ることで、30秒ごとに+1される(=30秒間は同じ数字が維持される)ようになっています。

※ちなみに、先程のQRコード例にperiod=30という記載がありました。もしこの数値が60の場合は、一分単位で更新されるようになります。

ステップ2:HMACハッシュ値の計算

次に、登録時に共有した 秘密鍵 と、ステップ1で求めた カウンター値を使って、HMAC(Hash-based Message Authentication Code)を計算します。標準的には SHA-1 が使われます。

hash = HMAC-SHA1(秘密鍵, counter)
※鍵とカウンタを「鍵付きハッシュ関数」に投入

HMACはハッシュの仕組みで、鍵とデータを混ぜて結果を出力するものになります。詳細は割愛しますが、「同じ鍵とデータからは必ず同じ結果が出る」が、「結果から元には戻せない」という点がポイントです。

ここで出力されるハッシュ値は、20バイト(160ビット)のバイナリデータになります。


ステップ3:Dynamic Truncation(動的切り詰め)

20バイトのハッシュ値から4バイト(32ビット)だけ切り出します。切り出す位置は末尾バイトの値から決定します。

  1. オフセットの決定: 20バイトのハッシュ値の 最後の1バイトの下位4ビット を確認する(0 〜 15 の値を得る)
  2. 4バイトの抽出: 得られたオフセットの位置から連続する4バイトを取得する。
  3. 整数への変換: 一番上のビットは符号に使われるため、先頭を切り落として正の整数にする。
offset = hash[末尾バイト] & 0x0F
int = 4バイトを整数化 & 0x7FFFFFFF

この処理によって、7ケタの整数(~999,999)になります。ですがまだ6ケタにはなっていません。


ステップ4:6桁の数値への変換(Modulo演算)

最後に、7ケタの整数を1,000,000で割り、その余り(Modulo)を求めます。

余りは必ず 0 〜 999999 の範囲になります。もし結果が6桁に満たない場合(例: 1234)は、左側にゼロを詰めて 001234 とします。

これが、画面に表示されている 6桁のワンタイムパスワード になります。


おわりに

RFCだけ見ると少し難しそうに見えましたが、順番に流れを追ってみるとシンプルな仕組みであることがわかりました。

これだけの処理であるものの

  • リプレイ攻撃(再利用)の防止:有効期限を30秒と限定するため、通信ログの盗聴や盗み見などでワンタイムパスワードが漏洩しても、すぐに失効して再利用できない
  • 通信上の秘密鍵の非露出:現コードを生成する元の秘密鍵はネットワーク上を流れない
  • オフライン動作:コードの生成時に通信が不要なため、環境を問わず使用できる
  • 軽量性: 内部処理がシンプルなため、スマホに限らずカード型トークンなどでも運用できる

という点を実現できており、非常によく出来た仕組みであることがわかりました。

参考:

https://www.rfc-editor.org/info/rfc4226/

https://www.rfc-editor.org/info/rfc6238/