はじめに
こんにちは、freee で認証認可基盤の開発をしている okarin です。
freee のプロダクトでは二要素認証を有効にすると、メールアドレスとパスワードを入力した後、ワンタイムパスワードを求められるようになります。この二要素認証を利用することで、より安全に freee をご利用いただけるようになっています。本記事では二要素認証の仕組みを解説していきたいと思います。
そもそも二要素認証とは?
「記憶情報」、「所持情報」、「生体情報」のうち、2つを組み合わせる認証方式を二要素認証と呼んでいます。これらの2つ以上を組み合わせた認証方式を多要素認証と呼ぶこともあります。 freee では、「記憶情報」であるパスワードと、「所持情報」であるワンタイムパスワード(OTP)を利用して二要素認証を実現しています。
OTP の仕組み
では、どうやって OTP を生成しているのかをみていきます。
まず、何らかの手段でクライアントとサーバー間でシークレットを共有します。その後、クライアントは共有したシークレットと現在時刻をもとに OTP を計算して、サーバーに送信します。 OTP をサーバー側で検証し、同じシークレットから生成された OTP であることを確認できたら、認証成功となります。
freee の場合だと、二要素認証の設定を開始すると、シークレットを含んだ QR コードが表示されます。OTP を計算するアプリ(Google Authenticator など)を利用してこの QR コードを読み取ると、シークレットを保存して一定時間ごとに OTP を生成するようになります。
OTP の生成には TOTP (Time-Based One-Time Password) アルゴリズムを利用しています。次のセクションから TOTP の詳細をみていきます。
TOTP
TOTP は一定時間ごとに HOTP (HMAC-Based One-Time Password) を生成します。数式で表すと以下のようになります。
TOTP = HOTP(K, T)
ここで、 K はクライアントとサーバー間で共有するシークレットで、 T は初期時刻 T0 と現在の Unix 時刻と間のタイムステップ数となります。これも数式で表すと以下のようになります。
T = (Current Unix time - T0) / X
ここで X は1タイムステップあたりの時間で、デフォルトでは 30秒 となります。
例として、T0 = 0, X = 30 として現在の Unix 時刻を 59秒 とすると、タイムステップ数は T = 1 となります。ここから 1秒 経過すると、Unix 時刻が 60秒 となるので、タイムステップ数は T = 2 となります。
このように計算したタイムステップと、事前に共有してあるシークレットから HOTP を計算することで、 OTP を求めることができます。
ちなみに、一度 Accept した TOTP を同じタイムステップウィンドウ内で複数回 Accept してはならないと RFC6238 に記載があります。そのため、なんらかの手段で一時的に TOTP を保存しておいて、一度 Accept した TOTP が同じタイムステップウィンドウ内で送信された場合は、リクエストを拒否する必要があります。
Note that a prover may send the same OTP inside a given time-step window multiple times to a verifier. The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP, which ensures one-time only use of an OTP.
HOTP
HOTP アルゴリズムは、以下のようになります。
# Step 1: HMAC-SHA-1 で 20バイト(=160ビット)のビット列を得る hmac_result = HMAC-SHA-1(K,C) # Step 2: Dynamic Truncation で31ビットのビット列を得る bits = DynamicTruncation(hmac_result) # Step 3: 31ビットのビット列を数字に変換 bin_code = StringToNumber(bits) # 0...2^{31}-1 # Step 4: 求めた数値を「10^OTPの桁数」で割った余りを求める OTP = bin_code mod 10^Digit
HMAC-SHA-1
# Step 1: HMAC-SHA-1 で 20バイト(=160ビット)のビット列を得る hmac_result = HMAC-SHA-1(K,C)
HMAC-SHA-1 の詳細は RFC2104 に記載されています。今回はメッセージ認証やハッシュ関数のアルゴリズムの詳細には立ち入らず、出力が20バイトであることだけ考えていきます。
Dynamic Truncation
# Step 2: Dynamic Truncation で31ビットのビット列を得る bits = DynamicTruncation(hmac_result)
HMAC-SHA-1 の計算で、20バイト(=160ビット)のビット列が得られたので、ここから31ビットを抽出します。
HMAC-SHA-1 の計算結果を hmac_result とします。 hmac_result は20バイトで、1要素のサイズが1バイトの配列となります。ここで hmac_result[19]
の末尾4ビットを数値に変換すると offset になります。4ビットなので offset の値の範囲は 0 <= offset <= 15
となります。
// 0 <= offset <= 15 int offset = hmac_result[19] & 0xf;
hmac_result[offset] ~ hmac_result[offset+3]
の4バイト(=32ビット)のうち、末尾31ビットが DynamicTruncation の値となります。ここで先頭の1ビットを破棄して31ビットにしている理由は、数値に変換するときに符号を考慮する必要がなくなるためです。
数値に変換
31ビットのビット列を数値に変換します。
# Step 3: 31ビットのビット列を数値に変換 bin_code = StringToNumber(bits) # 0...2^{31}-1
DynamicTrancate と数値への変換をまとめて計算する方法が RFC4226 に書いてあり、以下のようになります。
int bin_code = (hmac_result[offset] & 0x7f) << 24 | (hmac_result[offset+1] & 0xff) << 16 | (hmac_result[offset+2] & 0xff) << 8 | (hmac_result[offset+3] & 0xff);
OTP 計算
求めたい OTP の桁数を Digit として、上で求めた bin_code
を 10^Digit
で割った余りを計算すると、OTP になります。
# Step 4: 31ビットの数字を「10^OTPの桁数」で割った余りを求める OTP = num mod 10^Digit
OTP 計算例
OTP の計算方法がわかったので、以下の条件で実際に OTP を計算してみます。
- 2023年1月1日午前0時0分0秒時点の OTP を計算する
- タイムステップ間隔は30秒とする
- シークレットキーを
shared secret between client and server
とする - OTP の桁数は6桁とする
コード全体は以下の通りになります。
package main import ( "crypto/hmac" "crypto/sha1" "encoding/binary" "fmt" "math" "os" "time" ) func main() { // 事前にシークレットをクライアントとサーバーで共有する secret := []byte("shared secret between client and server") mac := hmac.New(sha1.New, secret) targetTime := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.Local) var timeStepInterval int64 = 30 timeStep := uint64(targetTime.Unix() / timeStepInterval) // Time step is 55749960 fmt.Printf("Time step is %d\n", timeStep) // タイムステップを []byte に変換する // uint64 => 8 byte counter := make([]byte, 8) binary.BigEndian.PutUint64(counter, timeStep) _, err := mac.Write(counter) if err != nil { fmt.Fprintf(os.Stderr, "Write() failed: %v", err) return } hmacResult := mac.Sum(nil) // last index of hmacResult is 01001001 fmt.Printf("last index of hmacResult is %08b\n", hmacResult[len(hmacResult)-1]) offset := hmacResult[len(hmacResult)-1] & 0xf // offset is 9 fmt.Printf("offset is %d\n", offset) binCode := int64(((int(hmacResult[offset]) & 0x7f) << 24) | ((int(hmacResult[offset+1] & 0xff)) << 16) | ((int(hmacResult[offset+2] & 0xff)) << 8) | (int(hmacResult[offset+3]) & 0xff)) // binCode is 1257599582 fmt.Printf("binCode is %d\n", binCode) // OTP is 599582 fmt.Printf("OTP is %06d\n", binCode%int64(math.Pow10(6))) }
タイムステップの計算
タイムステップの計算は以下の通りになります。
targetTime := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.Local) var timeStepInterval int64 = 30 timeStep := uint64(targetTime.Unix() / timeStepInterval) // Time step is 55749960 fmt.Printf("Time step is %d\n", timeStep)
HMAC の計算
求めたタイムステップをカウンタ値として、HMAC-SHA-1 を計算します。すると、末尾の要素が 01001001
となります。
counter := make([]byte, 8) binary.BigEndian.PutUint64(counter, timeStep) _, err := mac.Write(counter) if err != nil { fmt.Fprintf(os.Stderr, "Write() failed: %v", err) return } hmacResult := mac.Sum(nil) // last index of hmacResult is 01001001 fmt.Printf("last index of hmacResult is %08b\n", hmacResult[len(hmacResult)-1])
DynamicTrancate の計算
つづけて offset を計算します。01001001
の下位4ビットは 9
になります。
offset := hmacResult[len(hmacResult)-1] & 0xf // offset is 9 fmt.Printf("offset is %d\n", offset)
求めた offset を利用し hmac_result[offset] ~ hmac_result[offset+3]
の4バイト(=32ビット)を取得して、先頭1ビットを破棄すると 1257599582
となります。
binCode := int64(((int(hmacResult[offset]) & 0x7f) << 24) | ((int(hmacResult[offset+1] & 0xff)) << 16) | ((int(hmacResult[offset+2] & 0xff)) << 8) | (int(hmacResult[offset+3]) & 0xff)) // binCode is 1257599582 fmt.Printf("binCode is %d\n", binCode)
OTP の計算
さいごに、1257599582
を 10^6
で割った余りを求めると、OTPの値は 599582
となります。
// OTP is 599582 fmt.Printf("OTP is %06d\n", binCode%int64(math.Pow10(6)))
ちなみに、 pquerna/otp というパッケージを利用して生成した OTP も同じ値となります。
code, err := totp.GenerateCode(base32.StdEncoding.EncodeToString(secret), targetTime) if err != nil { fmt.Fprintf(os.Stderr, "GenerateCode() failed: %v", err) return } // OTP calculated by package is 599582 fmt.Printf("OTP calculated by package is %s\n", code)
まとめ
ここまで、 OTP 生成の仕組みを詳しくみてきました。RFC では運用上の制約等も細かく定義されているので、ライブラリを使って実装する場合でもしっかりと仕様を確認してプロダクトに取り入れてみてください。自分も RFC を読むことで、ライブラリを使うだけだと気づけなかった点を見つけられてとても良い経験になりました。