PHP で HOTP および TOTP を計算する

Google Authenticator 等で利用されている HOTP (HMAC-Based One-Time Password) や TOTP (Time-Based One-Time Password) を PHP で計算する方法を書きます。

"HMAC-Based" は「カウンタベース」、"Time-Based" は「時間ベース」や「タイムベース」と訳されます。

参考「[RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm 日本語訳](https://tex2e.github.io/rfc-translater/html/rfc4226.html)」
参考「[RFC 6238 - TOTP: Time-Based One-Time Password Algorithm 日本語訳](https://tex2e.github.io/rfc-translater/html/rfc6238.html)」

# 1. 鍵

## 1.1. 鍵をやり取りするために Base32 エンコードを利用する

HOTP や TOTP の仕様として決められているわけではないですが、多くの場合、鍵を Base32 エンコードしてやり取りするため、Base32 エンコードを利用できるように準備します。

参考「[[PHP] Base32 エンコードおよびデコードする - Qiita](https://qiita.com/kerupani129/items/12983ca858260707be50)」

```php:Base32 エンコード
// メモ: RFC 4648 で定義されている Base32 文字
define('BASE32_ALPHABET', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');

function base32_encode(string $string): string {

	$byte_length = strlen($string);

	$data_buffer = 0;
	$data_buffer_bit_length = 0;

	$byte_offset = 0;

	$result = '';

	// バッファにデータが残っているか、またはバッファに読み込めるデータが残っていたら継続
	while ( $data_buffer_bit_length > 0 || $byte_offset < $byte_length ) {

		// バッファのデータが少なければデータを追加する
		if ( $data_buffer_bit_length < 5 ) {
			if ( $byte_offset < $byte_length ) {
				// 読み込めるデータが残っていたら読み込む
				$data_buffer <<= 8;
				$data_buffer |= ord($string[$byte_offset++]);
				$data_buffer_bit_length += 8;
			} else {
				// 読み込めるデータがなければ値が 0 のパディングビットを追加して長さを 5 ビットにする
				$data_buffer <<= 5 - $data_buffer_bit_length;
				$data_buffer_bit_length = 5;
			}
		}

		// バッファのデータの左の長さ 5 ビットの値を取得する
		$data_buffer_bit_length -= 5;
		$value = $data_buffer >> $data_buffer_bit_length & 0x1f;

		// 値を Base32 文字に変換
		$result .= BASE32_ALPHABET[$value];

	}

	// パディング文字 '=' を追加
	$target_length = ceil(strlen($result) / 8) * 8;
	$result_padded = str_pad($result, $target_length, '=');

	return $result_padded;

}
```

## 1.2. 鍵を生成する

後述しますが、本記事では HOTP も TOTP も `HMAC-SHA-1` ハッシュ値を用いるため、鍵の長さは 20 バイトにします。

```php
// 鍵を生成
$seed = random_bytes(20);

echo chunk_split(bin2hex($seed), 2, ' '), PHP_EOL;

$seed_string = base32_encode($seed);
echo $seed_string, PHP_EOL;
```

```text:実行結果の例
dd 1e 26 25 a8 da 7f a3 b8 6b 80 9e d9 20 c8 68 8f 53 83 46 
3UPCMJNI3J72HODLQCPNSIGINCHVHA2G
```

鍵は乱数で生成しますが、暗号強度の弱い `rand()` や `mt_rand()` 等でなく暗号強度の強い `random_bytes()` を用います。

参考「[PHP: random_bytes - Manual](https://www.php.net/manual/ja/function.random-bytes.php)」

鍵をやり取りするために鍵を Base32 エンコードした文字列を得ます。

# 2. HOTP を計算する

HOTP (HMAC-Based One-Time Password) は名前の通り HMAC を利用します。

「鍵」と「カウンタ」と呼ばれる値から HMAC ハッシュ値を求めます。

仕様上はダイジェスト関数に `SHA-256` や `SHA-512` を用いることができますが、多くの場合 `SHA-1` が使われるため、ここでは `HMAC-SHA-1` ハッシュ値を使用します。

`HMAC-SHA-1` の場合は鍵の長さを 20 バイトにします。

「カウンタ」はビッグエンディアン形式で長さは 8 バイトとします。

カウンタの初期値は自由ですが、0 または 1 に設定される場合が多いようです (Google Authenticator で鍵を手動入力した場合は 1 に設定されるようです) 。

ここでは HOTP の桁数は良く使われている 6 とします。

```php
// 
/**
 * 動的切り捨てする
 */
function dynamic_truncate(string $digest): int {

	// 下位 4 ビットを offset とする
	// strlen($digest) === 20;
	$offset = ord($digest[19]) & 0xf;

	// offset から 4 バイトの数値を得る
	// メモ: unpack はメモリ消費量が多いため、ここではあえて ord を使用する
	$binary = (
		ord($digest[$offset    ]) << 24 |
		ord($digest[$offset + 1]) << 16 |
		ord($digest[$offset + 2]) <<  8 |
		ord($digest[$offset + 3])
	);

	// 符号有無の混乱を防ぐために最上位ビットを除外する
	$binary_masked = $binary & 0x7fffffff;

	return $binary_masked;

}

/**
 * HOTP を計算する
 * 
 * カウンタはビッグエンディアンで 8 バイト
 */
function generate_hotp(string $seed, string $counter): string {

	$digest = hash_hmac('sha1', $counter, $seed, true);

	$otp = dynamic_truncate($digest) % 1000000;

	$otp_string = str_pad($otp, 6, '0', STR_PAD_LEFT);

	return $otp_string;

}
```

PHP では `hash_hmac()` を用いて `HMAC-SHA-1` ハッシュ値を計算できます。

参考「[PHP: hash_hmac - Manual](https://www.php.net/manual/ja/function.hash-hmac.php)」

`HMAC-SHA-1` ハッシュ値の長さは 20 バイトですが、HOTP の長さをそこまで長くしないため、RFC 4226 で定義されている「動的切り捨て (dynamic truncation)」を行って 4 バイトの長さの値を得ます (下位 4 ビットをオフセットとみなし、そのオフセットから長さ 4 バイトを切り出す) 。

また、32 ビットの値の符号有無により後の計算結果が異なることを防ぐため、最上位ビットを除外して 31 ビットの値にします。

HOTP の桁数を 6 にするため、$10^6 = 1000000$ で割った余りを求めます。

参考「[5.3. Generating an HOTP Value - RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm 日本語訳](https://tex2e.github.io/rfc-translater/html/rfc4226.html#5-3--Generating-an-HOTP-Value)」

```php:例
// HOTP を計算する
$seed = "\xdd\x1e\x26\x25\xa8\xda\x7f\xa3\xb8\x6b\x80\x9e\xd9\x20\xc8\x68\x8f\x53\x83\x46";

$counter = "\0\0\0\0\0\0\0\1";

$hotp = generate_hotp($seed, $counter);
echo $hotp, PHP_EOL;
```

```text:例の実行結果
836609
```

※ HOTP を実用的に使うは別途「ブルートフォース攻撃対策」や「カウンタ再同期」の機能を実装する必要があります。

参考「[7. Security Requirements - RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm 日本語訳](https://tex2e.github.io/rfc-translater/html/rfc4226.html#7--Security-Requirements)」

# 3. TOTP を計算する

TOTP は、秒単位の UNIX 時間から「ステップ数」と呼ばれる値を計算し、その値をカウンタの値として HOTP を計算します。

「ステップ数」は UNIX 時間を「時間ステップ」と呼ばれる時間で割った商です。

「時間ステップ」は多くの場合 30 秒とされるため、ここでも 30 秒とします。

```php
// 
$time_step = 30;

// 
function get_current_steps(): int {
	global $time_step;
	return intdiv(time(), $time_step);
}

/**
 * 長さ 64 ビットの符号付き整数をビッグエンディアン形式でバイト列に変換する
 */
function int64_to_bytes_in_big_endian(int $number): string {

	$bytes = '';

	// メモ: pack はメモリ消費量が多いため、ここではあえて chr を使用する
	$bytes[0] = chr($number >> 56 & 0xff);
	$bytes[1] = chr($number >> 48 & 0xff);
	$bytes[2] = chr($number >> 40 & 0xff);
	$bytes[3] = chr($number >> 32 & 0xff);
	$bytes[4] = chr($number >> 24 & 0xff);
	$bytes[5] = chr($number >> 16 & 0xff);
	$bytes[6] = chr($number >>  8 & 0xff);
	$bytes[7] = chr($number       & 0xff);

	return $bytes;

}

/**
 * TOTP を計算する
 */
function generate_totp(string $seed, int $steps): string {

	$steps_string = int64_to_bytes_in_big_endian($steps);

	$otp_string = generate_hotp($seed, $steps_string);

	return $otp_string;

}
```

PHP では `time()` を用いて秒単位の UNIX 時間を取得できます。

参考「[PHP: time - Manual](https://www.php.net/manual/ja/function.time.php)」

前述の通り HOTP のカウンタは 8 バイトの長さで扱うため、ステップ数を 8 バイトにします。

```php:例
// TOTP を計算する
$seed = "\xdd\x1e\x26\x25\xa8\xda\x7f\xa3\xb8\x6b\x80\x9e\xd9\x20\xc8\x68\x8f\x53\x83\x46";

$totp = generate_totp($seed, get_current_steps());
echo $totp, PHP_EOL;
```

```text:例の実行結果の例
524240
```

# 4. Google Authenticator で HOTP または TOTP を利用する

Google Authenticator の「セットアップキーを入力 (Enter a setup key)」から、前述の `$seed_string` の値を入力し、「カウンタベース (Counter based)」または「時間ベース (Time based)」を選択することで、Google Authenticator 上にコードが表示されるようになります。

HOTP の場合、カウンタが同期されていれば前述のコードの `$hotp` の値と一致します。

TOTP の場合、時刻がしっかり同期されていれば前述のコードの `$totp` の値と一致します。

※ HOTP または TOTP 用の QR コードの生成方法は後述。

# 5. TOTP で時間のズレを許容する

本記事では深く触れませんが、前回や次回の TOTP を許容することで多少の時間のズレがあってもコードを受け入れることができます。

```php:例
// TOTP を計算する
$seed = "\xdd\x1e\x26\x25\xa8\xda\x7f\xa3\xb8\x6b\x80\x9e\xd9\x20\xc8\x68\x8f\x53\x83\x46";

$steps = get_current_steps();

$totp_prev = generate_totp($seed, $steps - 1);
echo $totp_prev, PHP_EOL;

$totp = generate_totp($seed, $steps);
echo $totp, PHP_EOL;

$totp_next = generate_totp($seed, $steps + 1);
echo $totp_next, PHP_EOL;
```

```text:例の実行結果の例
262912
147845
014180
```

# 6. HOTP または TOTP 用の QR コードを生成する

鍵等をやり取りするために「otpauth URI」が用いられます。

otpauth URI を QR コードにすることで Google Authenticator 等で読み込めるようになります。

TOTP の場合は以下のようにして otpauth URI を生成できます。

```php:例
// 
$time_step = 30;

// 
$seed_string = '3UPCMJNI3J72HODLQCPNSIGINCHVHA2G';

// otpauth URI を生成する
$issuer = rawurlencode('Test Issuer');
$accountname = rawurlencode('Test Account Name');

$type = 'totp';
$label = "{$issuer}:{$accountname}";
$parameters = "secret={$seed_string}&issuer={$issuer}&algorithm=SHA1&digits=6&period={$time_step}";

$otpauth_uri = "otpauth://{$type}/{$label}?{$parameters}";

echo $otpauth_uri, PHP_EOL;
```

```text:例の実行結果
otpauth://totp/Test%20Issuer:Test%20Account%20Name?secret=3UPCMJNI3J72HODLQCPNSIGINCHVHA2G&issuer=Test%20Issuer&algorithm=SHA1&digits=6&period=30
```

参考「[Key Uri Format · google/google-authenticator Wiki · GitHub](https://github.com/google/google-authenticator/wiki/Key-Uri-Format)」

---

※QRコードは株式会社デンソーウェーブの登録商標です。