Encoding Hashing Encryption
這三個詞在後端和系統設計面試裡出現的頻率很高,而且很常被混用。最常見的是把「密碼加密存起來」講成加密,但那件事該做的其實是雜湊。
會踩到它們的題目也滿固定的:短網址要用什麼編碼、使用者密碼怎麼存、webhook 或 API token 怎麼簽章、主鍵要用什麼 ID。這幾題的難處通常不在演算法本身,而在有沒有辦法一句話講清楚這三個東西是在解不同的問題。
| 可逆? | 需要金鑰? | 目的 | 典型場景 | |
|---|---|---|---|---|
| Encoding(Base64/Base62/hex) | 可以,任何人都能還原 | 否 | 換一種表示法,方便傳輸或儲存 | 短網址、附件、把二進位塞進 JSON |
| Hashing(SHA-256/bcrypt) | 不行,單向而且有損 | 否 | 指紋、比對、完整性 | 密碼儲存、檔案校驗、分片 |
| Encryption(AES/RSA) | 可以,但要金鑰 | 是 | 保密 | 傳輸中的資料、靜態資料 |
要先講清楚的是 encoding 不是安全機制。Base64 誰都解得開,它解決的是「這段位元組能不能安全地放進一個只吃文字的通道」,跟保密完全沒有關係。看到有人說某個東西「有經過 Base64 編碼所以很安全」,那就是搞錯了。
Encoding
Base64
最常見的一個。Email 附件、把圖片塞進 HTML、把二進位放進 JSON,用的都是它。字元集是 A–Z、a–z、0–9 再加上 + 和 /,總共 64 個,另外有一個補位用的 =。
規則是每 3 個 byte 換成 4 個字元。因為 3 個 byte 是 24 bit,剛好可以切成 4 段 6 bit,而 6 bit 剛好能表示 64 個值。拿 Man 來看,先把三個字元攤成 24 個 bit:
| M | a | n | |
|---|---|---|---|
| ASCII | 77 | 97 | 110 |
| 二進位 | 01001101 | 01100001 | 01101110 |
然後把這 24 個 bit 重新切成 4 段。這裡的切點跟原本的 byte 邊界是錯開的,那大概是整個 Base64 唯一需要動腦的地方:
| 6 bit 區塊 | 010011 | 010110 | 000101 | 101110 |
|---|---|---|---|---|
| 十進位 | 19 | 22 | 5 | 46 |
| 查表 | T | W | F | u |
所以 Man 會變成 TWFu。
因為 + 和 / 在網址裡有特殊意義,要放進 URL 或 cookie 的時候得用 Base64URL 這個變體,把 + 換成 -、/ 換成 _,通常也把 = 省掉。JWT 用的就是這個版本。
Base62
字元集是 0–9、A–Z、a–z。說穿了就是 Base64 拿掉 + 和 /,所以天生就是 URL-safe,也不用補位。短網址和對外的 ID 幾乎都選它。
換算方式就是一直除以 62 取餘數,最後把餘數反過來讀。以十進位的 125 為例:
| 步驟 | 算式 | 商 | 餘數 |
|---|---|---|---|
| 1 | 125 ÷ 62 | 2 | 1 |
| 2 | 2 ÷ 62 | 0 | 2 |
餘數反過來是 2、1,查表得到 21。解回去驗算 。
其他幾種
Base58 是 Base62 再拿掉 0、O、I、l 這幾個看起來會混淆的字元,目的是讓人手抄的時候不會抄錯,比特幣的錢包地址就是這樣來的。
Base36 只有 0–9 和 A–Z,好處是大小寫不敏感,適合那種要念出來、或是要用鍵盤敲進去的短代碼。
Base32 更保守,只用 A–Z 和 2–7,一樣大小寫不敏感而且容錯性好,所以 QR code、DNS、還有 2FA 的 TOTP 金鑰都用它。
Base85 是這裡面最省空間的,代價是幾乎沒辦法讀,只有 PostScript 和少數壓縮工具在用。
到底省多少
編碼一定會讓資料變大,差別只在變大多少。每個 byte 需要幾個字元就是 :
| 編碼 | 每 byte 需要的字元數 | 膨脹 |
|---|---|---|
| Base16(hex) | 2.000 | +100% |
| Base32 | 1.600 | +60% |
| Base36 | 1.547 | +55% |
| Base58 | 1.366 | +37% |
| Base62 | 1.344 | +34% |
| Base64 | 1.333 | +33% |
| Base85 | 1.248 | +25% |
這裡有個常見的誤傳要修正。很多文章會說 Base85 比 Base64 小 25%,但那個 25% 是 Base85 相對於原始資料的膨脹率,不是它跟 Base64 的差距。實際算一下 大約是 0.938,也就是只小了 6% 左右。要多付出可讀性和實作複雜度才換到這 6%,大概就是它一直沒有普及的原因。
另外一件從這張表看得出來的事情是 Base62 和 Base64 幾乎一樣長,1.344 對 1.333。所以為了 URL-safe 從 Base64 換成 Base62,長度上等於不用付代價,短網址選它就是這個理由。
什麼時候用哪一個
| 場景 | 選擇 | 理由 |
|---|---|---|
| 短網址 | Base62 或 Base58 | 短、可讀、URL-safe、不用補位 |
| Email 附件(MIME) | Base64 | 把二進位安全地放進純文字協定,標準支援 |
| API 或 JSON 裡的二進位 | Base64URL | 放進網址或 header 不會被特殊字元弄壞 |
| 對外的訂單或使用者 ID | Base62 或 Base36 | 短、不連號、好念好抄 |
| 區塊鏈地址 | Base58 | 排除視覺上容易混淆的字元 |
| QR code、2FA 金鑰 | Base32 | 大小寫不敏感、容錯性好 |
| 追求傳輸效率的管線 | Base85 | 最省,但只有在機器對機器的時候才划算 |
Hashing
雜湊在面試裡其實是兩個不太相干的題目,連用的函式都不一樣。一種是拿它當指紋,看檔案有沒有被改過、兩份內容是不是一樣、或者決定資料要分到哪一個分片,這一類要快。另一種是拿它存密碼,這一類要慢。把第一類的函式拿去做第二類,是最經典的錯誤。
常見的雜湊函式
| 函式 | 輸出長度 | 現況 |
|---|---|---|
| MD5 | 128 bits | 已破,碰撞可以在幾秒內造出來,只能當非安全用途的 checksum |
| SHA-1 | 160 bits | 已破,2017 年 SHAttered 造出了真實的碰撞,已經淘汰 |
| SHA-2(SHA-256/512) | 256/512 bits | 目前的主流,沒有已知的實用攻擊 |
| SHA-3 | 可變 | 結構跟 SHA-2 完全不同,當作 SHA-2 萬一被破的備案 |
| BLAKE3 | 可變 | 非常快,適合大檔案校驗,但不是 NIST 標準 |
講「已破」的時候要說清楚破的是什麼。MD5 和 SHA-1 被破的是碰撞攻擊,也就是可以造出兩份雜湊值相同的不同內容,並不是可以從雜湊值反推回原文。不過對簽章和憑證那種場景來說,能造出碰撞就已經足夠致命了。
密碼不能用一般的雜湊
用 SHA-256 存密碼看起來很合理,反正它不可逆。問題不是它不安全,是它太快,而快本來就是它被設計出來的目的。這個優點放到密碼上就變成災難:現代 GPU 一秒可以算幾十億次,拿一份常見密碼的字典跑一輪,幾分鐘就能把大部分的人還原出來。
所以密碼要用刻意設計得慢的那幾個。bcrypt 算老牌,有 cost 參數可以調慢;scrypt 除了慢還刻意吃記憶體,讓 GPU 和 ASIC 難以平行化;Argon2id 是 2015 年 Password Hashing Competition 的冠軍,新專案大概直接選它就好。
它們都會處理 salt,每個使用者一組隨機值,所以兩個人用同樣的密碼也會存出不同的結果,彩虹表就沒用了。cost 則是留給未來的,硬體變快就把它調高,不用換演算法。慢在這裡是特性不是缺點。
HMAC
一般的雜湊誰都算得出來,所以它只能證明內容沒被改過,不能證明是誰送來的。HMAC 的作法是把一把只有雙方知道的金鑰混進雜湊裡,這樣就同時證明了兩件事。GitHub 和 Stripe 送 webhook 過來時的簽章、JWT 的 HS256、各種 API 請求簽名,用的都是它。
它是雜湊不是加密,收到的人做的事情是拿同樣的金鑰重算一次然後比對,而不是把它解開來看。
順帶一提
系統設計裡常出現的 consistent hashing 跟這一節的其他東西沒什麼關係,它只是拿雜湊來做分片,取的是分布均勻這個性質,跟安全無關。放在一起講很容易讓人搞混。
Encryption
| 金鑰 | 速度 | 代表 | 主要用途 | |
|---|---|---|---|---|
| 對稱加密 | 加解密同一把 | 快 | AES-256 | 真正拿來加大量資料 |
| 非對稱加密 | 公鑰私鑰一對 | 慢很多 | RSA、ECC(X25519、Ed25519) | 交換金鑰、數位簽章 |
對稱加密的模式建議直接用 AES-GCM,因為它是 authenticated encryption,機密性和完整性一起顧到。用 CBC 的話得自己再接一個 MAC,那一步很容易做錯。
非對稱有兩個方向,不要搞混。公鑰加密、私鑰解密是為了保密,只有拿著私鑰的人看得到內容;私鑰簽名、公鑰驗證則是為了證明來源,誰都可以驗,但只有私鑰持有者簽得出來。
實務上都是混合的
非對稱太慢,所以真實系統不會拿它來加大量資料。TLS 的做法是先用非對稱協商出一把臨時的對稱金鑰,之後整條連線的資料都用這把對稱金鑰加密。
靜態資料也是同一套,叫做信封加密。資料本身用一把隨機產生的資料金鑰加密,這把資料金鑰再用 KMS 裡的主金鑰加密之後存在旁邊。這樣要輪替主金鑰的時候,只要重新加密那些很短的資料金鑰就好,不用把所有資料重新加密一次。
傳輸中和靜態要分開回答
面試問加密通常會分成兩個面向。傳輸中就是 TLS,基本上是預設答案;靜態則是磁碟或儲存層的加密,或者敏感欄位在應用層就先加密起來。要分開講是因為它們防的威脅不一樣,前者防的是路上被竊聽,後者防的是儲存媒介或備份外流。
真正麻煩的是金鑰
演算法都是現成的,實務上會出事的幾乎都在金鑰上:放在哪裡(KMS 或 HSM,不是原始碼裡)、多久輪替一次、外洩之後怎麼撤銷、誰有權限拿。被問到加密卻只答得出演算法名字,會顯得沒有真的做過。
UUID 為什麼常跟前面混在一起講
很多時候我看到會有人把上面的內容跟 UUID 放在一起講,但是它們並不是同一件事。UUID 回答的是怎麼產生一個唯一的值,encoding 回答的是這個值要怎麼寫成字串。
會混在一起是因為同一個 UUID 可以用不同的編碼表示,長度差滿多的:
| 表示法 | 長度 |
|---|---|
| hex 加上連字號,也就是平常看到的樣子 | 36 字元 |
| hex | 32 字元 |
| Base32 | 26 字元 |
| Base62 或 Base64URL | 22 字元 |
短網址那種題目會同時碰到這兩件事,既要唯一又要短,所以兩個主題就被綁在一起講了。分開來看的話它們是兩層,先決定要產生什麼值,再決定怎麼表示它。
常見的版本
| 版本 | 組成 | 特性 |
|---|---|---|
| v1 | 時間戳加上網卡 MAC | 可以排序,但會洩漏機器資訊和產生時間 |
| v4 | 122 bits 隨機 | 最常見,不洩漏資訊,但完全無序 |
| v7 | 毫秒時間戳前綴加上隨機 | 2024 年 RFC 9562 納入標準,可以排序又不洩漏機器資訊 |
v4 當主鍵會痛在哪裡
這大概是實務上最值得記住的一點。v4 是隨機的,所以每次插入都會落在 B-tree 索引的隨機位置,造成頁面分裂、索引膨脹、快取命中率變差,資料量一大寫入效能就會明顯掉下來。
自增整數沒有這個問題,它永遠插在尾端,但它會洩漏你有多少筆資料,而且很容易被猜。
v7 就是為了同時解決這兩件事。前綴是毫秒時間戳,所以整體是遞增的、對索引友善;後面接隨機,所以猜不到也不會洩漏機器資訊。新專案要用 UUID 當主鍵的話,現在應該直接選 v7。
相近的東西
ULID 的想法跟 v7 一樣,時間戳加隨機,一樣是 128 bit,但它規定要用 Crockford Base32 編成 26 個字元,所以字串本身就可以直接拿來排序。
Snowflake 是 Twitter 的做法,只有 64 bit,切成時間戳、機器編號和序號三段。長度只有 UUID 的一半而且嚴格遞增,代價是需要協調機器編號,也就是得有一套發號的基礎設施。