||-Just-Func-||

了解 Base64

Photo by picsum

在了解 base64 之前,我們先來了解一下編碼是什麼。

編碼是什麼?

編碼是將一種將資料轉換成另一種資料的過程,而這個過程是可以被反轉的。

舉個例子,我們可以透過一張字母對應表,將一串英文字轉換成一串數字。

字母對應表:

a b c d e f g h i j k l m n o p q r s t u v w x y z
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ... 26

這樣我們就可以將 hello 轉換成 8 5 12 12 15
而這個過程是可以被反轉的,也就是說我們可以透過這張字母對應表,
8 5 12 12 15 轉換回 hello

這個轉換的過程就是編碼,而這張字母對應表就是這個轉換的規則。

並且從 hello 轉換成 8 5 12 12 15 的過程,我們稱之為 encode
而從 8 5 12 12 15 轉換回 hello 的過程,我們稱之為 decode

常見的編碼方式有 base64hexbinaryascii 等等,
它們都是將資料轉換成另一種資料的規則,且具備不同的用途。

為什麼要編碼?

舉個例子,我們的電腦是以二進位的方式儲存資料的,
也就是說,我們的電腦只能儲存 01
所以當我們要儲存一個字元時,我們必須將這個字元轉換成二進位的資料,
且轉換必須是可以被反轉的,這樣我們才能將這個二進位的資料轉換回字元。

諸如此類因為儲存資料的方式不同,所以必須要轉換的情況,
就會需要編碼來幫助我們將資料轉換成另一種資料。

這邊我們也可以發現到資料轉換能夠成立的條件,
是必須要基於共同的規則,例如前面提到的字母對應表,
這樣我們才能將資料轉換回來,也才能進而將資料用於儲存或溝通。

所以如果有人說他的檔案編碼跑掉 (或是說 出現亂碼 之類的),
那其實就是指他用了跟你當初撰寫時不同的編碼 (字母對應表) 來開啟檔案。

編碼適用於任何資料

雖然上面舉了文字的例子,但是編碼這個概念適用在任何資料上,
例如. 圖片、影片、音樂、檔案等等。

任何牽涉到資料轉換的過程,都稱之為編碼,
所以不用太疑惑為什麼處理圖像或影片的函式庫會出現 encode 或 decode 之類的函式,
例如. go-image-jpegrust-video

Base64 是什麼?

base64 是一種將二進位資料編碼成 ASCII 字元的編碼方式,
它的名稱來自於它的編碼字元表。

雖然 base64 的名稱是 base64,但是它的編碼字元表有 65 個字元,
= 是 pending 字元,它的用途是用來填補資料長度不足的情況。

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=

詳細的對照表可以參考這裡

它被設計出來的原因,就是想透過文字的方式來呈現二進位資料,
畢竟並不是所有的地方都能夠直接儲存二進位資料,
例如. HTML、JSON,這些檔案格式並沒辦法直接儲存二進位資料,
所以才會需要透過 base64 透過文字的方式來儲存二進位資料。

正常不會直接去看 base64 來理解資訊,它一開始就不是要設計來給人看的

Base64 的應用場景

base64 通常用來解決儲存或是傳輸時,
只能使用 ASCII 字元 (包含它的超集 UTF-8) 這類無法接受任意二進位資料的情況,
已確保資料在傳輸或儲存的過程中保持資訊的完整而不會被破壞。

舉個例子,
HTML 是純文本的檔案類型,所以我們無法直接嵌入二進位資料,
但是透過 base64 我們可以將二進位資料轉換成文字,
搭配 data:URL 來直接嵌入二進位資料。

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIA..." />

Base64 不是加密法

這是一個很常見也常被誤解的問題,

加密是為了保密而對資訊進行轉換的過程,所以它也屬於編碼的一種,
但是編碼不一定是為了保密而進行的,
例如. base64 並不是為了保密而進行的編碼,
它只是原封不動的將二進位資料轉換成 ASCII 字元,
所以你可能不會希望看到 base64 這種編碼方式被用在加密上。

如果你是想設計一張只有你跟你的朋友們才能看懂的字母對應表,
這種方式叫做 凱薩加密法 (Caesar cipher)

Base64 的變體 base64url

base64 有一個變體叫做 base64url
它是為了讓 base64 可以用於 URL 或是 檔案名稱 而設計的。

因為 base64 會使用 +/ 這兩個字元,
而這兩個字元在 URL 中有特殊的意義,
所以 base64url+/ 分別替換成 -_

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=

詳細的對照表可以參考這裡

如何在 base64 跟 base64url 之間轉換

這裡提供 JavaScript 的實作方式。

const base64Tobase64url = (text) =>
  text
   .replace(/\//g, "_")
   .replace(/\+/g, "-")
   .replace(/=/g, "");
 
base64Tobase64url("8J+mhA=="); // 8J-mhA
const base64urlTobase64 = (text) =>
  text
   .replace(/_/g, "/")
   .replace(/-/g, "+")
   .padEnd(text.length + (text.length % 4), "=");
 
base64urlTobase64("8J-mhA"); // 8J+mhA==

Base64 會增加資料的大小

常見的誤解認為 base64 可以將資料壓縮,
但是事實上,base64 會增加資料的大小。

根據 RFC 4648
base64 裡的一個字元只能替代原始資料的 6 個位元,
但一個字元是卻是 8 個位元組成的。

所以,假設我們有筆資料長度為 3 個 bytes (3x8 bits = 24 bits)
base64 就會將資料拆分成 4 個 6-bit 為一組 (4x6 bits = 24 bits)
而一個字元是由 8 個位元組成的 (4x8 bits = 32 bits)
32 / 24 = 1.333
也就是說 base64 大概會增加 33.3% 的資料大小 (a.k.a 大概大了 1/3)。

無論任何資料形式,都是由二進位資料組成的,計算大小的單位都是位元 (bits),
一個 bit 只能儲存 01

Base64 去除不可視字元

這個說法其實倒果為因了,
應該說它在設計階段就刻意避開了非英文單字 (Non-Alphabet) 的字元。

這樣做可以在理論上避開的兩個資安相關的風險:

  1. convert channel
  2. buffer overflow attacks

但本文主要是在講 base64 的 encode/decode,在此就不多做說明。

JavaScript 中的 Base64

btoaatob

在 JavaScript 中,我們可以透過 btoaatob 來進行 base64 的 encode 跟 decode。

btoa 就是 binary to ascii 的縮寫,用來將字串轉換成 base64

btoa("hello");
// 'aGVsbG8='

atob 就是 ascii to binary 的縮寫,就是用來將 base64 轉換回字串。

atob("aGVsbG8=");
// 'hello'

但這兩個函式其實並沒有辦法完全將任意的字串轉換成 base64
他們能處理的只有 0x000xFF 這個範圍而已,
所以像是拋入 🦄 這類超過範圍的字元就會拋出 InvalidCharacterError

btoa("🦄");
// Uncaught DOMException: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.

這兩個函式並沒有支援轉換任意二進位資料的原因是
因為他們誕生的比 web 平台支援二進制資料型別的時間還早,
早期並沒有人覺得 web 平台需要支援二進制資料型別。

在現今大部分平台都支援 UTF-8 這個範圍字元集的情況下,
直接使用 btoaatob 來進行 base64 的 encode 跟 decode 其實並不是很理想。

Encoding API

Encoding API 被設計用於處理各種字元編碼轉換,包括 UTF-8 範圍字元集,
以下介紹 Encoding API 中的 TextEncoderTextDecoder

TextEncoder 主要用於將字串轉換成二進位資料,

const encoder = new TextEncoder();
const encoded = encoder.encode("🦄");
console.log(encoded);
// Uint8Array(4) [240, 159, 166, 132]

Uint8Array 是一種固定長度的陣列,每格由 8 個位元組成的,
是 JavaScript 處理二進位資料的一種資料型別。

TextDecoder 則用於將二進位資料轉換成字串,

const decoder = new TextDecoder();
const decoded = decoder.decode(new Uint8Array([240, 159, 166, 132]));
console.log(decoded);
// 🦄

上面雖然可以將 UTF-8 範圍字元集的字串轉換成二進位資料,但是還不是 base64
但搭配 btoa, atobString.fromCodePoint 就可以進行 base64 的 encode 跟 decode 了。

用 🦄 舉個例子,我們要將其轉換成 base64,整個轉換邏輯演算如下:

  1. UTF-8 字元轉換成二進位,讓 🦄 展現原貌變成 Uint8Array(4)
    ( 🦄 => Uint8Array(4)[240, 159, 166, 132] )
    這時,單看每格裏面的數值已落在 0x000xFF 的範圍了。
    (Uint8 的範圍 0 ~ 255 就是 0x00 ~ 0xFF)
  2. 將每格裡面的數值拆出來,個別轉換成文字。
    ( Uint8Array(4) => [240, 159, 166, 132] )
    ( String.fromCodePoint(240) => ð )
    ( String.fromCodePoint(159) => \x9F )
    ( String.fromCodePoint(166) => ¦ )
    ( String.fromCodePoint(132) => \x84 )
  3. 因為分別轉換的文字已經落在 0x000xFF 的範圍了 (0 ~ 255),
    所以可以透過 btoa 來進行 base64 的 encode。
    ( btoa("ð\x9F¦\x84") => 8J+mhA== )
const encoder = new TextEncoder();
const encoded = encoder.encode("🦄");
const binString = String.fromCodePoint(...encoded);
const base64 = btoa(binString);
console.log(base64);
// 8J+mhA==

反之,要將 base64 轉換回 🦄,整個轉換邏輯演算如下:

  1. 透過 atobbase64 轉換成個別文字。
    ( atob('8J+mhA==') => "ð\x9F¦\x84" )
  2. 將個別文字資料轉換成 Uint8Array(4)
    ( "ð\x9F¦\x84" => Uint8Array(4)[240, 159, 166, 132] )
  3. 再將 Uint8Array(4) 透過 TextDecoder 轉換成 🦄。
    ( Uint8Array(4)[240, 159, 166, 132] => 🦄 )
const binString = atob("8J+mhA==");
const bytes = Uint8Array.from(binString, (char) => char.codePointAt(0));
const decoder = new TextDecoder();
const decoded = decoder.decode(bytes);
console.log(decoded);
// 🦄

在現今大部分系統都支援 UTF-8 範圍字元集的情況下,
在處理文字與二進位資料轉換的時候,
使用上述方法來進行 base64 的 encode 跟 decode 會比 btoaatob 更理想。

轉換任意二進制資料

Encoding 中提到的方法其實就可以用在任意二進位資料的轉換上,

const byteArray = new Uint8Array([240, 159, 166, 132]);
const binString = String.fromCodePoint(...byteArray);
const base64 = btoa(binString);

除此之外,我們也可以透過 FileReader.readAsDataURL() 來將檔案轉換成 base64
readAsDataURL 會回傳 data:URL 而它的內容就是 base64

function bytesToBase64DataUrl(bytes, type) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(new File([bytes], "", { type }));
  });
}
 
const bytes = new Uint8Array([240, 159, 166, 132]);
const dataUrl = await bytesToBase64DataUrl(bytes, "text/plain");
// 'data:text/plain;base64,8J+mhA=='
 
// regexp match dataurl base64 content
const dataUrlRegex = /^data:(.+\/.+);base64,(.*)$/;
const extractBase64 = (url) => dataUrlRegex.exec(url)?.[2] ?? "";
 
const base64 = extractBase64(dataUrl);
// '8J+mhA=='

Node.js 的 Buffer

在 Node.js,暫時無法使用 Encoding API
但是 Encoding APIWHATWG 提出的標準規格,
在未來應該會被 Node.js 支援。 (此文章發布時,Node.js LTS v18.18.0)

所以在 Node.js 中,我們可以透過 Buffer 來進行 base64 的 encode 跟 decode。

const encoded = Buffer.from("🦄").toString("base64");
console.log(encoded);
// 8J+mhA==
const decoded = Buffer.from("8J+mhA==", "base64").toString();
console.log(decoded);
// 🦄