[PHP] Base32 エンコードおよびデコードする

PHP の標準の機能では Base32 エンコードおよびデコードを行えないため、PHP のコード上でアルゴリズムを実装する必要があります。

# 1. Base32 について

Base32 自体の説明は別記事にしました。

参考「[Base32 エンコーダおよびデコーダ実装時のセキュリティ上の注意点 - Qiita](https://qiita.com/kerupani129/items/838e088336c3ae879a1f)」

基本的な仕組みとして、データを長さ 5 ビットずつに区切り、それぞれの値 (0&ndash;31) を「Base32 文字」に割り当てて置き換えます。

実際に使用されている「Base32 文字」にはいくつか種類がありますが、本記事では RFC 4648 で定義されているものを使用します。

```:RFC 4648 で定義されている Base32 文字
ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
```

参考「[RFC 4648 - The Base16, Base32, and Base64 Data Encodings](https://www.ietf.org/rfc/rfc4648.html)」
参考「[Base32 - Wikipedia](https://en.wikipedia.org/wiki/Base32)」

# 2. Base32 エンコード

データを 5 ビットずつ切り出すときバイトをまたぐため、複数バイトの値を別途変数に読み込んでそこから切り出すことにします。

長さ 5 ビットで割り切れない場合は値が 0 のパディングビットを追加して長さ 5 ビットにします。

RFC 4648 の仕様にするために、エンコード後にパディング文字 `=` を追加します。

```php
// メモ: 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;

}

// 
$string_aa = "\x10\x20\x30\x40\x50\x60";
echo bin2hex($string_aa), PHP_EOL;

$base32_a = base32_encode($string_aa);
echo $base32_a, PHP_EOL;

// 
$string_ba = "\0\0\0";
echo bin2hex($string_ba), PHP_EOL;

$base32_b = base32_encode($string_ba);
echo $base32_b, PHP_EOL;

// 
$string_ca = '文字列';
echo $string_ca, PHP_EOL;

$base32_c = base32_encode($string_ca);
echo $base32_c, PHP_EOL;
```

```:実行結果 (文字列は文字コードが UTF-8 の場合)
102030405060
CAQDAQCQMA======
000000
AAAAA===
文字列
42LIPZNNS7SYRFY=
```

# 3. Base32 デコード

本記事では、デコード時に改行や空白等を含む Base32 文字以外の文字を許容せず、パディング文字 `=` を必須とし、入力文字列の長さが 8 の倍数でなければエラーとします。

Base32 エンコード時にデータの長さが 5 ビットで割り切れない場合はパディングビットが追加されて (長くなって) いるはずなので、デコード後のデータ長はエンコード済みのビット長を 8 で割って切り捨てて計算します。

パディングビットの長さが 5 ビット以上であったり値が 0 以外の場合は正しく Base32 エンコードされていないとみなします。

Base32 デコード時は大文字と小文字を区別しませんが、そのことによって脆弱性や不具合ができる可能性があることに注意が必要です。

参考「[12. Security Considerations - RFC 4648 - The Base16, Base32, and Base64 Data Encodings](https://www.ietf.org/rfc/rfc4648.html#section-12)」

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

// メモ: array_merge を使うと正しい結果が得られなくなるため注意
define('BASE32_ALPHABET_VALUES', (
	array_flip(str_split(BASE32_ALPHABET)) +
	array_flip(str_split(strtolower(BASE32_ALPHABET)))
));

/*
 * WARNING: Base32 デコード時に大文字と小文字を区別しないことによる脆弱性や不具合に注意
 * 参考: 12. Security Considerations - RFC 4648 - The Base16, Base32, and Base64 Data Encodings
 *       https://www.ietf.org/rfc/rfc4648.html#section-12
 */
function base32_decode(string $string): string {

	// メモ: 正しく Base32 エンコードされてパディングされていれば長さが 8 の倍数のはず
	//       長さが 8 の倍数でない場合はパディングされていないかまたは正しく Base32 エンコードされていない
	if ( (strlen($string) & 0x7) !== 0 ) throw new InvalidArgumentException('Invalid base32 string');

	// 末尾のパディング文字 '=' を除去する
	$string_trimmed = rtrim($string, '=');

	$byte_length = strlen($string_trimmed);

	// 
	$result = '';

	$data_buffer = 0;
	$data_buffer_bit_length = 0;

	for ($byte_offset = 0; $byte_offset < $byte_length; $byte_offset++) {

		$encoding = $string_trimmed[$byte_offset];

		// 
		if ( ! array_key_exists($encoding, BASE32_ALPHABET_VALUES) ) {
			throw new InvalidArgumentException('Invalid base32 string');
		}

		$value = BASE32_ALPHABET_VALUES[$encoding];

		// バッファに長さ 5 ビットの値を読み込む
		$data_buffer <<= 5;
		$data_buffer |= $value;
		$data_buffer_bit_length += 5;

		// バッファのデータが少なければデータを取得する
		if ( $data_buffer_bit_length >= 8 ) {
			$data_buffer_bit_length -= 8;
			$result .= chr($data_buffer >> $data_buffer_bit_length & 0xff);
		}

	}

	// 正しく Base32 エンコードされたデータであれば残る長さは 5 ビット未満のはず
	// 5 ビット以上残った場合は正しく Base32 エンコードされていない
	if ( $data_buffer_bit_length >= 5 ) throw new InvalidArgumentException('Invalid base32 string');

	// 正しく Base32 エンコードされたデータであれば残る値は 0 のはず
	// 0 以外のデータが残った場合は正しく Base32 エンコードされていない
	if ( ($data_buffer << (4 - $data_buffer_bit_length) & 0xf) !== 0 ) {
		throw new InvalidArgumentException('Invalid base32 string');
	}

	return $result;

};

// 
$base32_a = 'CAQDAQCQMA======';
echo $base32_a, PHP_EOL;

$string_ab = base32_decode($base32_a);
echo bin2hex($string_ab), PHP_EOL;

// 
$base32_b = 'AAAAA===';
echo $base32_b, PHP_EOL;

$string_bb = base32_decode($base32_b);
echo bin2hex($string_bb), PHP_EOL;

// 
$base32_c = '42LIPZNNS7SYRFY=';
echo $base32_c, PHP_EOL;

$string_cb = base32_decode($base32_c);
echo $string_cb, PHP_EOL;
```

```:実行結果 (文字列は文字コードが UTF-8 の場合)
CAQDAQCQMA======
102030405060
AAAAA===
000000
42LIPZNNS7SYRFY=
文字列
```