ワンタイムパスワードを実装してみた
背景
2要素認証を有効にした状態でログインするとき、ワンタイムパスワード(6桁の数字)の入力を求められることがあるかと思います。
これはどうやって実装しているんだろうと気になったので、仕様を読んで実装してみました。
今回実装したのは、 TOTP(Time-Based One-Time Password) と呼ばれるもので、 Google Authenticator などのアプリを入れると、簡単にワンタイムパスワードを生成できます。
コード
本記事のコードはこちらに記載しています。
Docker を起動して、一連の流れを試すこともできます。
仕様
TOTP
TOTP は RFC6238 で、以下のように定義されています。
HOTP(HMAC-Based One-Time Password) に関しては後述します。
TOTP = HOTP(K, T)
T = (Current Unix time - T0) / X
K: HOTP を生成するためのキー
T: HOTP を生成するためのタイムステップのカウント
T0: タイムステップのカウントを開始するUnix 時間(デフォルト値は0)
X: タイムステップ(デフォルト値は X = 30秒)
コードでみると、以下のようになります。
// TOTP time step
const timeStepSecond int64 = 30
func New(secret []byte) string {
return hotp.New(secret, counter())
}
func counter() uint64 {
return uint64(time.Now().Unix() / timeStepSecond)
}
HOTP
HOTP は RFC4226 で、以下のように定義されています。
キー K と、カウンタ C をもとに HMAC-SHA-1 を計算して、いらない部分を切り捨てすることで、ワンタイムパスワードを生成します。
TOTP の場合は、カウンタが T = (Current Unix time - T0) / X
で計算した値となります。
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
ここからは、この Trancate 関数の中身は何をしているのかを詳しくみていきます。
Step 1
まずは単純に HMAC-SHA-1 を計算します。この値は、必ず20バイトになります。
HS = HMAC-SHA-1(K,C)
コードでは以下のようになります。
カウンタの値を []byte に変換するため、 binary パッケージを使っています。
package hotp
import (
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
)
func hmacSha1(secret []byte, counter uint64) []byte {
mac := hmac.New(sha1.New, secret)
// uint64 => 8 byte
byteCounter := make([]byte, 8)
binary.BigEndian.PutUint64(byteCounter, counter)
mac.Write(byteCounter)
return mac.Sum(nil)
}
Step 2
Step 1 で計算した HMAC-SHA-1 から、4バイトの文字列を生成して、整数に変換します。
まず4バイトの文字列を生成する方法ですが、仕様では以下のように書かれています。
DT(String) // String = String[0]...String[19]
Let OffsetBits be the low-order 4 bits of String[19]
Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
Let P = String[OffSet]...String[OffSet+3]
Return the Last 31 bits of P
HMAC-SHA-1 の末尾の下位4ビットを数値に変換して offset とします。(HMAC-SHA-1は20バイトなのでインデックスの19は末尾に相当)
そして、HMAC-SHA-1 の offset ~ offset+3 の位置に相当する文字列から下位31ビットを抽出する、といった流れになります。
これで得られた31ビットの値を整数に変換します。
コードでは、以下のようになります。
まず末尾の文字と 0xf との論理積を計算することで下位4ビットの offset を求めます。
その後、offset ~ offset+3 の下位31ビットを取得しています。
ここでは仕様に書いてある例のとおりに実装していますが、 binary.BigEndian.Uint32(hs[offset:offset+4]) & 0x7fffffff
のほうがイメージが湧きやすいかもしれません。
func dynamicTruncate(hs []byte) uint32 {
// get low-order 4 bits of hs[tail]
// 0xf => 0000 1111
offset := hs[len(hs)-1] & 0xf
// get last 31 bits for hs[offset]...hs[offset + 3]
// 0x7F => 0111 1111
return uint32(hs[offset]&0x7f)<<24 |
uint32(hs[offset+1]&0xff)<<16 |
uint32(hs[offset+2]&0xff)<<8 |
uint32(hs[offset+3]&0xff)
}
Step 3
ワンタイムパスワードの桁数を d とおくと、Step 2 で得られた値を 10^d
で割った余りが、最終的に得られる HOTP の値となります。
コードでは、以下のようになります。
num := dynamicTruncate(hs)
codeNum := num % uint32(math.Pow10(digits))
f := fmt.Sprintf("%%0%dd", digits)
oneTimePassword := fmt.Sprintf(f, code)
実際に使ってみる
Google Authenticator の準備
サンプルを使った実際の例を README に書いたので、試してみます。
まずは Google Authenticator をスマホにインストールして、QR コードを読み取ります。
QR コードで取得できる文字列は otpauth://totp/otp_example?secret=NBSWY3DP
となっており、この形式は Google Authenticator Key Uri Format に書かれています。
ちなみに NBSWY3DP
は hello
を Base32 エンコードした文字列になります。実運用する場合は、ユーザーごとにユニークでランダムな文字列を生成して、Base32 エンコードすることになります。
サーバーの起動
make run
コマンドを実行してサーバーを立ち上げます。
ログイン
localhost:8080 にアクセスして、まずは ID/Password でログインします。
name | value |
---|---|
id | hogehoge |
password | hogehoge |
ログイン後、ワンタイムパスワードが求められるので、Google Authenticator で生成された6桁の数字を入力します。
ここで送信された値と、サーバーで生成された値が同じかどうかを検証して、OK ならログイン成功となります。
細かいところ
- 1タイムステップ分は前のワンタイムパスワードが送られてもOKとしている(ネットワークのレイテンシーなどの影響があるため)
The validation system should compare OTPs not only with the receiving timestamp but also the past timestamps that are within the transmission delay.
- 1回使用したワンタイムパスワードは複数回利用できないようにしている(今回は Redis を利用)
その他
TOTP にはもう少し仕様があって、再同期などの仕組みが必要になりますが、今回は簡略化のため省いています。
詳細が気になった方がいましたら、仕様を読んでみるとより理解が深まると思います。
Discussion