[JavaScript] Unicode 文字列やバイナリデータを Base32 エンコードおよびデコードする

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

# 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. バイナリデータの場合

Unicode 文字列も結果的にバイナリデータとして扱うため、バイナリデータを Base32 エンコードする方法を先に説明します。

## 2.1. Base32 エンコード

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

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

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

```js:バイナリデータを Base32 エンコード
// メモ: RFC 4648 で定義されている Base32 文字
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

const encodeBase32 = uint8Array => {

	const byteLength = uint8Array.byteLength;

	let dataBuffer = 0;
	let dataBufferBitLength = 0;

	let byteOffset = 0;

	let result = '';

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

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

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

		// 値を Base32 文字に変換
		result += base32Alphabet[value];

	}

	// パディング文字 '=' を追加
	const targetLength = Math.ceil(result.length / 8) * 8;
	const resultPadded = result.padEnd(targetLength, '=');

	return resultPadded;

};

// 
const uint8ArrayA = new Uint8Array([0x10, 0x20, 0x30, 0x40, 0x50, 0x60]);
console.log(uint8ArrayA.toString());

const base32 = encodeBase32(uint8ArrayA);
console.log(base32);
```

```:実行結果
16,32,48,64,80,96
CAQDAQCQMA======
```

## 2.2. 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)」

```js:バイナリデータを Base32 デコード
// メモ: RFC 4648 で定義されている Base32 文字
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

const base32AlphabetValuesMap = new Map([
	...Array.from(base32Alphabet, (encoding, value) => [encoding, value]),
	...Array.from(base32Alphabet.toLowerCase(), (encoding, value) => [encoding, value]),
]);

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

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

	// 末尾のパディング文字 '=' を除去する
	const stringTrimmed = string.replace(/=*$/, '');

	// メモ: デコード後のサイズは切り捨てで計算する
	const result = new Uint8Array(stringTrimmed.length * 5 >>> 3);

	let dataBuffer = 0;
	let dataBufferBitLength = 0;

	let byteOffset = 0;

	for (const encoding of stringTrimmed) {

		const value = base32AlphabetValuesMap.get(encoding);
		if ( typeof value === 'undefined' ) throw new Error('Invalid base32 string');

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

		// バッファのデータが少なければデータを取得する
		if ( dataBufferBitLength >= 8 ) {
			dataBufferBitLength -= 8;
			result[byteOffset++] = dataBuffer >>> dataBufferBitLength;
		}

	}

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

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

	return result;

};

// 
const base32 = 'CAQDAQCQMA======';
console.log(base32);

const uint8ArrayB = decodeBase32(base32);
console.log(uint8ArrayB.toString());
```

```:実行結果
CAQDAQCQMA======
16,32,48,64,80,96
```

# 3. Unicode 文字列の場合

## 3.1. Base32 エンコード

`TextEncoder.prototype.encode` を用いて `Uint8Array` に変換することで、Unicode 文字列をバイナリデータとして Base32 エンコードすることができます。

```js:Unicode 文字列を Base32 エンコード
const textEncoder = new TextEncoder();
const encodeString = string => textEncoder.encode(string);

// メモ: RFC 4648 で定義されている Base32 文字
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

const encodeBase32 = uint8Array => {

	const byteLength = uint8Array.byteLength;

	let dataBuffer = 0;
	let dataBufferBitLength = 0;

	let byteOffset = 0;

	let result = '';

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

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

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

		// 値を Base32 文字に変換
		result += base32Alphabet[value];

	}

	// パディング文字 '=' を追加
	const targetLength = Math.ceil(result.length / 8) * 8;
	const resultPadded = result.padEnd(targetLength, '=');

	return resultPadded;

};

// 
const stringA = 'Unicode 文字列';
console.log(stringA);

const uint8ArrayA = encodeString(stringA);
const base32 = encodeBase32(uint8ArrayA);
console.log(base32);
```

```:実行結果
Unicode 文字列
KVXGSY3PMRSSBZUWQ7S23F7FRCLQ====
```

## 3.2. Base32 デコード

Base32 デコードする場合は `TextDecoder.prototype.decode` を用いて `Uint8Array` から Unicode 文字列に戻します。

```js:Unicode 文字列を Base32 デコード
const textDecoder = new TextDecoder();
const decodeString = buffer => textDecoder.decode(buffer);

// メモ: RFC 4648 で定義されている Base32 文字
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

const base32AlphabetValuesMap = new Map([
	...Array.from(base32Alphabet, (encoding, value) => [encoding, value]),
	...Array.from(base32Alphabet.toLowerCase(), (encoding, value) => [encoding, value]),
]);

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

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

	// 末尾のパディング文字 '=' を除去する
	const stringTrimmed = string.replace(/=*$/, '');

	// メモ: デコード後のサイズは切り捨てで計算する
	const result = new Uint8Array(stringTrimmed.length * 5 >>> 3);

	let dataBuffer = 0;
	let dataBufferBitLength = 0;

	let byteOffset = 0;

	for (const encoding of stringTrimmed) {

		const value = base32AlphabetValuesMap.get(encoding);
		if ( typeof value === 'undefined' ) throw new Error('Invalid base32 string');

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

		// バッファのデータが少なければデータを取得する
		if ( dataBufferBitLength >= 8 ) {
			dataBufferBitLength -= 8;
			result[byteOffset++] = dataBuffer >>> dataBufferBitLength;
		}

	}

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

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

	return result;

};

// 
const base32 = 'KVXGSY3PMRSSBZUWQ7S23F7FRCLQ====';
console.log(base32);

const uint8ArrayB = decodeBase32(base32);
const stringB = decodeString(uint8ArrayB);
console.log(stringB);
```

```:実行結果
KVXGSY3PMRSSBZUWQ7S23F7FRCLQ====
Unicode 文字列
```