🔑

ワンタイムパスワードを実装してみた

2022/06/27に公開

背景

2要素認証を有効にした状態でログインするとき、ワンタイムパスワード(6桁の数字)の入力を求められることがあるかと思います。
これはどうやって実装しているんだろうと気になったので、仕様を読んで実装してみました。
今回実装したのは、 TOTP(Time-Based One-Time Password) と呼ばれるもので、 Google Authenticator などのアプリを入れると、簡単にワンタイムパスワードを生成できます。

コード

本記事のコードはこちらに記載しています。
Docker を起動して、一連の流れを試すこともできます。
https://github.com/ksrnnb/otp

仕様

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 に書かれています。
ちなみに NBSWY3DPhello を Base32 エンコードした文字列になります。実運用する場合は、ユーザーごとにユニークでランダムな文字列を生成して、Base32 エンコードすることになります。

qr code

サーバーの起動

make run コマンドを実行してサーバーを立ち上げます。

ログイン

localhost:8080 にアクセスして、まずは ID/Password でログインします。

login page

name value
id hogehoge
password hogehoge

ログイン後、ワンタイムパスワードが求められるので、Google Authenticator で生成された6桁の数字を入力します。

one-time password page

ここで送信された値と、サーバーで生成された値が同じかどうかを検証して、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