Base64 編碼 / 解碼

它是編碼不是加密,而這個差別會出事。

本機處理
Base64 編碼結果
5aCx5biz5piO57SwIDIwMjYg5bm0IDgg5pyI77ya5ZKW5ZWhIE5UJDEyMOOAgeioiOeoi+i7iiBOVCQyNTXjgII=
  • 輸入的 UTF-8 位元組65
  • 輸出的 Base64 字元88
  • 大小變化+35%

你的文字先被轉成 65 個 UTF-8 位元組才拿去編碼,因為 Base64 編的是位元組不是字元。這一步在這段文字上有差別:其中14 個字元的碼位在 U+00FF 以上,btoa() 碰到它們會直接丟出例外。

完全在你的瀏覽器裡執行 —— 打開開發者工具的 Network 分頁就能驗證

貼上文字就得到它的 Base64 形式,按下交換鈕則是反過來解碼。跟隨手找到的第一個線上工具比起來,這裡有兩件事不一樣:文字會先轉成 UTF-8 再編碼,所以中文與帶重音的字母不會壞;而解碼這一側收得下現實中真正拿得到的髒輸入 —— 換過行的、少了補位符號的、URL 安全版的 —— 而且會逐項告訴你它幫你修了什麼。

它換的是一套字母表,不是上了一把鎖

關於 Base64 最花錢的誤解,是以為它把東西藏起來了。它沒有。沒有金鑰、沒有祕密,也沒有「只有某一方解得開」這回事。它只是把同一串位元組改用 64 個字元重新寫一遍,好讓這串東西能安全通過那些原本只為英文純文字設計的系統。誰拿到這串字,誰就拿到了內容本身。

這件事在真實系統裡是會出事的。HTTP 標頭裡 Authorization: Basic 這種寫法,內容就是帳號與密碼用冒號接起來之後做這一道手續 —— 這正是那個機制非得走 TLS 不可的原因,也是為什麼一個被側錄到的標頭等於一組被側錄到的密碼。JWT 由三段用點分開的東西組成,中間那一段同樣沒有加密:它是 Base64,裝著帳號識別碼、到期時間,以及簽發者放進去的其他宣告。最後那個簽章證明的是「這個 token 沒有被改過」,它一個字元都沒有藏起來。

正確的心智模型是「運送用的外包裝」。內容如果需要保密,那件事必須由這一步之前的某個東西完成,而這層包裝只負責把已經保護好的結果送出去。

每 3 個位元組換成 4 個字元,那多出來的三分之一從哪來

Base64 以 6 個位元為一個單位運作,因為 6 個位元剛好夠從 64 個字元裡挑一個。而儲存以 8 個位元為單位。這兩個數字第一次對齊是在 24 個位元,也就是一邊 3 個位元組、另一邊 4 個字元 —— 這一個比例撐起了整個格式的算術。

4 除以 3 是 1.333,所以輸出永遠比輸入大三分之一。那是下限不是估計值:不會有哪種輸入編得比較省,因為對應關係是固定的。這也是為什麼一張內嵌進樣式表的圖片會比同一張獨立檔案多花三分之一的量,以及為什麼一封附件在編碼前看起來離上限還很寬鬆,寄出去卻被郵件伺服器擋下來。

上面那幾張指標卡會算出框裡那段東西的真實數字,包括百分比是拿哪一個位元組數當分母。輸入很短的時候,結尾的補位符號會把比例推得比三分之一更高;輸入愈長,它就愈接近剛好 33%。

兩張長得幾乎相同的字母表,直到某一天不再相同

RFC 4648 定義了兩種變體。標準版的表以一個加號和一個斜線結尾,URL 安全版則換成連字號和底線。除此之外的另外 62 個字元,兩張表完全相同。

這段共用的前綴會造成一類特定又惱人的 bug。一串比較短的字串,或者剛好沒有用到最後那兩格的字串,在兩種變體下會編出一模一樣的結果。它在開發者的機器上通過所有測試、上線,然後在幾個星期後某一筆剛好編出斜線的輸入上壞掉。上面那個切換讓你明確選一種,而底下的說明會告訴你這一次的結果到底有沒有真的用到那兩個不同的字元 —— 那是唯一能判斷「你的測試有沒有證明任何事」的方法。

URL 安全版會被造出來,原因是加號在 query string 被解析時代表空白,而斜線是路徑的分隔符號。把標準版的 Base64 直接放進網址,因此必須再做一次百分號編碼,而漏掉這第二步是「值送到對面就微妙地變了」最常見的成因之一。檔名也有同樣的斜線問題。

結尾那幾個等號在做什麼,什麼時候可以拿掉

補位符號完全不帶資料。它唯一的工作是把字串湊成 4 的倍數,好讓一次讀 4 個字元的解碼器知道哪裡是結尾,同時記錄最後一組裝的是 1 個、2 個還是 3 個位元組。一個等號代表最後一組裝了 2 個位元組,兩個等號代表裝了 1 個。

既然長度本身已經隱含了這個資訊,不少規格乾脆就不放它。JWT 的規格甚至是強制要求拿掉。所以誠實的講法是:補位符號在實務上可有可無,在某些解析器上卻是必要的 —— 這正是上面做成一個開關而不是替你決定的原因。在這裡把它關掉,畫面會告訴你少了幾個字元,而不是默默把字串變短。

有一種長度是不可能存在的:字元數除以 4 餘 1 的字串不合法,因為單獨剩下的那一個字元帶不了任何一個完整的位元組。你貼進這種東西,這一頁會明講並附上實際長度,不會默默丟掉那個多餘的字元、然後交還一串尾巴缺了一塊的位元組。那種無聲截斷是好幾個被廣泛使用的函式庫真實存在的行為。

文字要先變成位元組,而工具都是在這一步壞掉的

Base64 對語言沒有任何意見,因為它根本看不到語言,它看到的是位元組。所以「把文字編碼」這件事需要一個在 Base64 登場之前就要做完的決定:這段文字用哪一串位元組表示。這一頁的答案永遠是 UTF-8,而且會把產生的位元組數報給你看。

瀏覽器內建了一個叫 btoa 的函式,而數量龐大的線上編碼器就是它薄薄的一層外皮。它有兩種截然不同的壞法,而第二種比第一種糟糕得多。碰到碼位在 U+00FF 以上的字元 —— 中日韓任何一種文字、任何 emoji —— 它會直接丟出錯誤,這至少還告訴你有事情不對。碰到碼位落在 U+0080 到 U+00FF 之間的字元,例如帶重音的 e 或德文的 ß,它不會抱怨:它編出去的是 Latin-1 的那一個位元組,而不是 UTF-8 的兩個位元組,於是這串東西之後解回來會變成另一個字母。

第二種才是危險的那個,因為從頭到尾沒有任何地方報錯。一個帶重音的客戶姓名經過編碼器、在另一個系統上經過解碼器,回來就拼錯了。這一頁輸出底下的說明會把你貼的這段文字的兩個數字分別點出來,所以你可以立刻看出這段特定的文字在一個以 btoa 為底的工具上會不會活下來。

data URI,以及頁面為什麼會拒絕載入它

data URI 是把一整個檔案塞進一個網址裡:協定、媒體類型、base64 這個字、一個逗號,然後是編碼過的位元組。瀏覽器允許它出現在圖片來源、樣式表背景或連結上,這讓它適合用在「小到不值得多開一個請求」的素材,以及任何必須以單獨一行文字的形式被複製傳遞的東西。

代價值得在你伸手去拿之前先講清楚。內容會大三分之一、它沒辦法獨立於包著它的那份文件被快取、而且壓縮效果比原本的二進位差。一個省下一個請求卻讓每次載入都多背四十 KB 的 data URI,是一筆划不來的交易。

還有一條會讓人栽跟頭的安全邊界。Content-Security-Policy 列出允許來源時,並不會順便允許 data 這個協定 —— 它必須被明確寫出來。一張內嵌圖片在開發環境好好的、上線之後變成破圖,多半就是撞到這件事,而瀏覽器主控台會直接說出來,只要你去看。腳本則永遠不要開放 data 協定:那等於讓一段解碼出來的字串變成可執行的程式碼,這也正是預設拒絕它的理由。

真實世界的 Base64 都是髒的,所以解碼這側刻意做得寬鬆

從現實中複製出來的字串幾乎沒有乾淨的。PEM 格式的憑證每 64 個字元換一行。MIME 內文每 76 個字元換一行。從瀏覽器 session 撈出來的 token 補位符號被拿掉了。從 query string 抄下來的值是 URL 安全版。這四種都是合法的資料,只是長成嚴格解碼器會拒絕的樣子。

這裡的解碼方向四種全收。空白會先去掉、兩張字母表都認、缺少的補位符號會補回來,而上面那三個設定在這個方向完全不生效 —— 這一點頁面會直接說出來,不會讓你在那裡納悶為什麼切換字母表沒有反應。每一項修補都會回報:去掉了幾個空白字元、補回了幾個等號、看到幾個 URL 安全版的字元。安靜地把輸入修好,跟直接拒絕它幾乎一樣沒有幫助,因為「修了什麼」往往正是判斷這個值是哪個上游系統產生的線索。

真的遇到不屬於任何一張字母表的字元時,訊息會指出它在第幾個位置、是哪一個字元、以及它的碼位。實務上罪魁禍首通常是從 JSON 檔或 log 行複製時一起被框到的引號或逗號,而知道它是第 8 個字元,這件事一眼就看明白了。

解出來的位元組不一定是文字,假裝它是就是在說謊

Base64 編的是位元組,所以解碼拿回來的也是位元組。那些位元組是不是可讀的文字,是另一個問題,而且答案很有機會是「不是」。那串東西可能是一張 PNG、一份 PDF、一個壓縮檔或一段密碼學簽章,這些東西根本沒有文字形式。

常見的做法是用 UTF-8 解碼並把解不開的位元組換成替代字元,於是畫面上出現一整片菱形問號,而介面看起來像是成功了。這一頁改成嚴格解碼:位元組不是合法的 UTF-8 就直說,並改用十六進位把它們印出來,旁邊附上位元組數。你仍然拿得到資料 —— 光是位元組數往往就足以判斷手上這東西是什麼 —— 而且不會被誤導成「內容被弄壞了」,事實是它從來就不是文字。

造成這個結果的另一個常見原因是舊編碼。在 UTF-8 普及之前的系統產生的文字,存的是 Big5 或 Shift-JIS 這類編碼,而那些位元組序列經常不是合法的 UTF-8。這種情況下拿到十六進位而不是一段文字,本身就是一個有意義的結論:它告訴你問題出在上游的編碼,不在 Base64 這一段。

在台灣最常撞到 Base64 的四個地方

最常撞到的是中文郵件。信件主旨與附件檔名如果含中文,是不能直接放進郵件標頭的 —— 標頭天生只吃 ASCII,所以 RFC 2047 規定要寫成 =?UTF-8?B?…?= 這種形式,中間那一段就是 Base64。收到一封主旨整串變成這種東西的信,把 =?UTF-8?B? 與結尾的 ?= 去掉、剩下那段貼進來解碼,就會知道對方原本寫的是什麼。倒過來看也有用:某些舊的郵件軟體會寫成 =?big5?B?…?=,那一段解出來就會是不合法的 UTF-8,畫面上會出現十六進位而不是中文 —— 那正好證實了問題出在寄件方用的是 Big5,不是你的信箱壞了。

第二個是憑證與金鑰檔。自然人憑證、報稅與電子發票 API、各家銀行的測試環境,交付的檔案常常是 PEM 格式:開頭一行 BEGIN、結尾一行 END,中間那一大片每 64 個字元換行的東西就是 Base64。把中間那段貼進來解碼,會得到十六進位而不是文字 —— 那是對的,憑證本來就是二進位(DER)而不是文字。這個結果本身很有用:它讓你確認手上這份檔案沒有在傳輸過程中被截斷,因為截斷過的內容解出來的位元組數會對不上。上面的「每幾個字元換一行」填 64,也可以把一段內容反過來排成 PEM 該有的樣子。

第三個是串接台灣金流與政府 API 時的變體地雷。這類文件常常只寫「請將結果轉為 Base64」,卻沒有講是標準版還是 URL 安全版,也沒有講要不要保留補位符號。實務上出錯的樣子很典型:測試資料短、剛好沒編出加號或斜線,兩邊怎麼測都對得起來,等到正式環境跑出一筆含斜線的值,簽章驗證就突然對不上。做法是拿一段夠長、確定會用到最後兩個字元的資料去測 —— 這一頁的說明會直接告訴你這一次到底有沒有用到,不必自己一個字元一個字元看。

最後是舊系統撈出來的資料。台灣不少還在服役的系統是 Big5 時代留下來的,從那裡匯出、再被某一層順手做成 Base64 的中文欄位,解回 UTF-8 一定會失敗。這一頁不會給你一整片問號讓你以為資料壞了,它會明說這些位元組不是合法的 UTF-8 並改印十六進位。分辨清楚這一點很重要:資料本身好端端的,缺的只是一步從 Big5 轉成 UTF-8 的轉碼,而那要在來源端做,不是在這裡。

大家貼進這個框裡的東西,很多時候是一組憑證

這正是「只在本機執行」這件事在這一頁比在多數頁面更要緊的原因。人們拿到 Base64 解碼器面前的字串,敏感的比例高得不成比例:想讀中間那一段的 session token、裝著一組還能用的密碼的 Basic 授權標頭、單一登入流程裡簽過章的斷言、藏著 API 金鑰的設定字串。把一個 token 貼進別人架的解碼器,等於把一組還在生效的憑證交給陌生人,而且它在過期之前一直有效。

在這裡保護你的不是一句「我們不會留存」的承諾。是這一頁根本沒有一條程式路徑有能力把框裡的內容送到任何地方。編碼與解碼加起來只有兩百多行程式碼,它們裝在這份文件裡一起送達,並且交給你這台裝置自己的晶片去跑;載入結束之後,這一頁不會再發出任何一個請求。

這個主張特別容易被推翻,而那正是把它寫出來的意義。把機器切到飛航模式,然後繼續編碼與解碼 —— 兩個方向都照常運作,因為它們從一開始就不需要跟我們要任何東西。

常見問題

Base64 可以拿來藏密碼或 API 金鑰嗎?

不行,而且把它當成那種東西用,是一場遲早會發生的資安事故。它是同一串位元組的可逆改寫,中間不涉及任何金鑰,所以誰有那串字誰就有內容。值真的要保密就先加密,Base64 再負責把加密後的結果安全地送過那些只認文字的系統。

為什麼編碼之後比原本大了三分之一?

因為 4 個輸出字元裝 3 個輸入位元組,4 除以 3 就是 1.333。這個比例是格式本身決定的,不是這個實作的問題,所以世界上不會有哪個編碼器編得比較小。輸入很短的時候因為結尾的補位符號,結果會比三分之一再糟一點。

標準版跟 URL 安全版,我怎麼知道對方要哪一種?

值要放進網址路徑、query string 或檔名就選 URL 安全版,其餘選標準版。文件只寫「Base64」而沒有講清楚時,先當成標準版,然後拿一段長到真的會編出加號或斜線的資料去測 —— 短樣本在兩種變體下長得一模一樣,測了等於沒測。

別的解碼器說這串不合法,你們卻解得出來,誰對?

通常兩邊都沒錯。嚴格的解碼器會拒絕含換行、沒有補位符號或使用 URL 安全字元的輸入,而這三種在真實資料裡都很平常。這一頁收下它們,並把每一項修補列在結果底下,所以你看得出來要讓那個嚴格的工具接受它,到底該改什麼。

為什麼我拿到的是十六進位而不是看得懂的文字?

因為解出來的位元組不是合法的 UTF-8,沒有文字可以顯示。這通常代表你手上是二進位檔案,例如圖片或憑證,或者是 Big5 這類舊編碼的文字。把位元組印出來,比印一排替代字元、假裝解碼成功了要有用得多。

可以直接丟檔案進來編碼,而不是自己打字嗎?

這一頁不行 —— 這個框收的是文字,而檔案那個情境需要不一樣的介面與大小警告,因為一個 1 MB 的檔案會變成大約 1.37 MB 的字元,沒有哪個瀏覽器想把那個量放進一個文字框裡。它已經排進規劃、會做成獨立的工具。目前文字、以及任何你貼得進來的東西都可以用。

結尾的等號該留著還是拿掉?

除非收這個值的那一端叫你拿掉,否則留著。它們不帶資料,但嚴格的解析器會拒絕沒有它們的輸入。JWT 是主要的例外:它的規格要求把補位符號去掉,大量 token 的結尾因此不帶等號。

我在這裡解一個 session token,它會以任何形式傳到你們那邊嗎?

不會。編碼與解碼都只在這一個分頁內執行,而頁面載入結束後不再發出任何請求,所以那個 token 自始至終不曾離開這台機器。這件事請不要相信,請自己驗:把網路整個關掉,編碼與解碼兩邊都會照常運作。