🗺️ 程式互動式地圖
資安線・密碼學函式庫

雜湊、加密、編碼:三個常被搞混的東西

Base64、SHA-256 和密文看起來都像一串亂碼,但一個誰都能還原、一個根本無法還原、一個要有金鑰才能還原。選錯工具,資料等於沒有保護;這一頁把三者並排比較,學會依用途選對寫法。

三台機器並排+雪崩效應 用途配對:11 個情境 程式判斷卡:14 張

💡 先搞懂問題

虛構的「晴空咖啡」正在改寫會員系統,程式碼審查時一口氣找到三個問題。第一位工程師把會員手機號碼用 base64.b64encode() 處理後存進資料庫,註解寫著「已加密」;但任何人把那串 MDkxMi0wMDAtMDAw 丟回 b64decode(),不需要任何金鑰,就得到 0912-000-000。第二位工程師為了「保護」會員的信用卡號,存的是 hashlib.sha256() 的結果,等到要退款時才發現卡號再也拿不回來。第三位工程師替金流通知(Webhook)加了檢查:收到通知後重算內容的 SHA-256,和通知附上的值比對;可是偽造通知的人也能自己算 SHA-256,這個檢查等於沒有設防。

三個錯誤的根源相同:Base64 的輸出、SHA-256 的摘要和加密後的密文,在畫面上都是一串看不懂的字,很容易被當成同一類東西。真正該問的是三件事:能不能還原回原本的資料?還原時需不需要金鑰?輸出的長度是否固定?用這三個問題去看,編碼(encoding)、雜湊(hash)和加密(encryption)的分工就清楚了:編碼是換一種表示方式,誰都能還原;雜湊是單向的固定長度指紋,無法還原,只能拿來比對;加密要有金鑰才能還原,用來保護之後還要讀回的資料。

輸入:pay 100 Base64 編碼 cGF5IDEwMA== SHA-256 雜湊 00ee27a7…ac30 AES-GCM 加密 每次都不同… 能還原嗎? 要金鑰嗎? 長度固定? 能,誰都能 不能 能 不需要 不需要 需要 隨輸入變長 固定 64 字 隨輸入變長 看起來都像亂碼,三個問題的答案卻完全不同
上排是同一段輸入經過三台機器的輸出(雜湊只顯示頭尾,密文每次執行都會變)。下排三列是判斷時該問的三個問題:只要能回答「能不能還原、要不要金鑰、長度固不固定」,就不會把 Base64 當成加密,也不會拿雜湊去存之後要讀回的資料。

生活比喻:處理一份重要文件的三種方式

想像晴空咖啡的店長要處理一份供應商合約。第一種做法是把合約翻成摩斯電碼,方便用無線電念給總部聽:這樣做是為了「方便傳送」,懂摩斯電碼的人誰都翻得回來,它從來沒打算保密。第二種做法是替合約做一張「指紋卡」:不論合約是兩頁還是兩百頁,指紋卡都一樣大;同一份合約每次做出來的指紋卡都相同,合約只要改一個字,指紋卡就整張不一樣;但光看指紋卡,沒有人能把合約內容寫回來。總部收到合約時重做一張指紋卡比對,就知道合約有沒有被換過。第三種做法是把合約鎖進保險箱,鑰匙只交給總部,路上的人拿到箱子也打不開,總部用鑰匙就能把合約完整取出。

指紋卡有一個漏洞:如果攔截的人換掉合約,順便重做一張指紋卡一起換掉,總部比對起來一樣吻合。所以店長和總部約定,每張指紋卡都要再蓋上一顆只有他們兩人才有的印章,攔截的人沒有那顆章,換了合約也蓋不出正確的印記。

回到程式:摩斯電碼對應的是編碼,例如 base64.b64encode(),它解決的是「二進位資料怎麼放進只收文字的地方」,不是保密。指紋卡對應的是雜湊,hashlib.sha256(data).hexdigest() 永遠輸出 64 個十六進位字元,用來比對完整性。蓋上兩人共有的印章,就是 HMAC(Hash-based Message Authentication Code,訊息鑑別碼):hmac.new(key, msg, "sha256") 把金鑰混進雜湊,沒有金鑰的人算不出正確值。保險箱對應的是加密,例如 cryptography 套件的 Fernet(key).encrypt(),有同一把金鑰才能 decrypt() 取回原文。
處理合約(比喻) 程式裡的工具 翻成摩斯電碼懂規則誰都翻得回來 編碼:base64.b64encode為了傳送與存放,不是保密 做一張指紋卡一樣大、改一字全變、寫不回去 雜湊:hashlib.sha256固定長度、單向,用來比對 指紋卡蓋上共有印章沒有章就偽造不了 HMAC:hmac.new(key, msg, "sha256")沒被改,而且來自持有金鑰的一方 鎖進保險箱有鑰匙才拿得出來 加密:Fernet、AESGCM同一把金鑰才能還原
左欄是比喻,右欄是 Python 裡對應的工具。前三列都沒有「還原」這個動作,只有最後一列的保險箱能把原文完整取回,而且一定要有鑰匙。

這個比喻有三個地方要修正。第一,真實的指紋偶爾會被誤認,但 SHA-256 這類現代雜湊在實務上找不到兩份內容不同、摘要卻相同的資料(稱為碰撞,collision);MD5 與 SHA-1 則已經有人做出碰撞,所以這兩種「指紋」不能再用在安全用途。第二,保險箱被撬過通常看得出來,但「只加密、不驗證」的密文被竄改時,解密端不一定會察覺;Fernet 與 AES-GCM 之所以是建議選項,正是因為它們同時附上了驗證標籤。第三,比喻裡的印章只有店長和總部兩人共有,任何一方都蓋得出來,所以 HMAC 無法向第三方證明「是誰蓋的」;需要這種證明時,要用私鑰簽章、公鑰驗證的數位簽章。

🎮 互動實驗室一:三台機器並排

在輸入框打任何文字(中文也可以),下面三台機器會即時算出 Base64、SHA-256 與 AES-GCM 密文,並標出「能不能還原、需不需要金鑰、長度是否固定」。每台機器都有按鈕讓你試著還原:Base64 不用金鑰就能解回;SHA-256 沒有「解回」這個動作;AES-GCM 用頁面上產生的示範金鑰解得開,換一把金鑰就失敗。再按「再加密一次」看看同樣的明文為什麼會得到不同的密文。下半部是雪崩效應:第二個輸入框預設只改了一個字,比較 SHA-256 與 Base64 各有多少內容跟著變。所有計算都在你的瀏覽器裡用 Web Crypto 完成,示範金鑰只存在這個分頁的記憶體裡。

碼Base64 編碼

base64.b64encode(data)
能還原 能,誰都能
需要金鑰 不需要
長度固定 否

指SHA-256 雜湊

hashlib.sha256(data).hexdigest()
能還原 不能
需要金鑰 不需要
長度固定 是,64 字

鎖AES-GCM 加密

AESGCM(key).encrypt(nonce, data, None)
能還原 能,有金鑰才行
需要金鑰 需要
長度固定 否

雪崩效應:改一個字

SHA-256 改變的位元

—

同樣的改變,Base64 呢?

紅底是 B 的 Base64 和 A 不同的字元。Base64 是逐段換字,改一個字只影響附近幾個字元,看得出規律;雜湊則像整張重算。

畫面說明:載入中。

🎮 互動實驗室二:用途配對

下面 11 張卡片是晴空咖啡開發團隊遇到的實際需求,請把每一張放進最適合的工具格(電腦上工具格在右邊,手機上固定在卡片上方)。電腦上可以直接拖曳;手機或想用點的話,先點一張卡片(會被框起來),再點工具格。放對會說明理由並把卡片收進格子;放錯會說明你選的工具差在哪裡,卡片留在原處讓你再試。判斷時先問:之後要不要讀回原文?要不要保密?要不要證明是誰送的?是不是密碼?

放對 0 / 11
放錯次數 0
畫面說明:先點選或拖曳一張需求卡。

🎮 互動實驗室三:程式判斷卡

每張卡是一小段 Python,請判斷它最主要的問題是什麼;有幾張其實寫得沒問題,要選「這段寫法沒問題」。答完會說明原因,並附上安全寫法。卡片順序每輪隨機,每張只計第一次作答。

答對 0 / 0
連續答對 0
畫面說明:先看資料最後要不要讀回、比對時用的是什麼函式。

📘 原理補完

三個問題、六種工具

實驗室裡的三個問題,正好可以把常見的工具分開。下表把編碼、雜湊、HMAC、兩種加密,以及下一頁的主角「密碼雜湊」放在一起比較。注意「長度是否固定」這一欄:雜湊與 HMAC 不論輸入多長都是固定長度,這也是它們無法還原的直觀理由,32 位元組裝不下一本書的所有資訊。

工具能還原嗎需要金鑰輸出長度主要用途Python 寫法
編碼(Base64、Base64URL)能,誰都能不需要約為輸入的 4/3 倍把二進位資料放進文字欄位、網址、JSON、電子郵件base64.b64encode、urlsafe_b64encode
雜湊(SHA-256、SHA-3、BLAKE2)不能不需要固定(SHA-256 為 32 位元組=64 個十六進位字元)檔案完整性、去重複、快取鍵hashlib.sha256(data).hexdigest()
HMAC不能需要,雙方共用一把固定(同所用的雜湊)Webhook 與 API 請求簽章hmac.new(key, msg, "sha256")+compare_digest
對稱加密(Fernet、AES-GCM)能,要同一把金鑰需要,加解密同一把隨輸入變長,另加 IV/nonce 與驗證標籤保護之後要讀回的資料:個資、備份、設定Fernet(key).encrypt、AESGCM(key).encrypt
非對稱(RSA、Ed25519)加密時能,要私鑰需要,公鑰與私鑰一對依演算法交換對稱金鑰、數位簽章cryptography 的 Ed25519PrivateKey 等
密碼雜湊(Argon2id、bcrypt)不能不需要(自動加鹽)固定格式字串,內含參數與鹽儲存使用者密碼PasswordHasher().hash/verify
之後要讀回原文嗎? 要 不用 需要保密嗎? 不用 要 編碼Base64 加密Fernet、AES-GCM 是使用者密碼嗎? 是 不是 密碼雜湊Argon2id 要證明是誰送的嗎? 不用 要 雜湊SHA-256 雙方共用金鑰 → HMAC第三方也要能驗 → 數位簽章 示意:實務上常組合使用,例如加密後再以 HMAC 或 AEAD 標籤保護完整性
從最上面的問題開始往下走。左支是「要讀回」:不需要保密時是編碼,需要保密時一定是加密。右支是「不用讀回」:使用者密碼直接走專用的密碼雜湊;其他資料再看要不要證明來源。把 Base64 放到右邊、把雜湊放到左邊,就是本頁開頭那兩個錯誤。

編碼:Base64 只是換一種寫法

很多地方只收「可列印的文字」,例如 JSON 字串、網址參數、電子郵件內文、只吃文字的設定檔。二進位資料(圖片、金鑰、雜湊的原始位元組)直接塞進去會出問題,所以要先編碼。Base64 每次拿 3 個位元組(24 位元),切成 4 組 6 位元,每組對應到 64 個可列印字元之一;最後不足 3 個位元組時用 = 補齊,所以輸出長度大約是輸入的 4/3 倍,而且會隨輸入變長。Base64URL(base64.urlsafe_b64encode)把 + 和 / 換成 - 和 _,方便放進網址,JWT 就是用它,而且通常省略結尾的 =。整個過程沒有任何秘密,對照表是公開標準(RFC 4648),所以 Base64 不提供任何保護,看起來看不懂只是因為人類不習慣讀它。

輸入 3 個位元組:p a y p = 01110000 a = 01100001 y = 01111001 重新切成 4 組、每組 6 位元 011100 000110 000101 111001 286557 c G F 5 查的是公開對照表(A–Z、a–z、0–9、+、/),沒有任何金鑰
中間一列是把 24 個位元重新分組,下面一列是查表結果,所以 b"pay" 編碼成 cGF5。每一步都可以倒著做回去,這就是為什麼 b64decode 不需要任何金鑰。實驗室一裡只改一個字時 Base64 只變動一兩個字元,也是因為它逐段換字、各段互不影響。

雜湊:固定長度、單向、雪崩效應

雜湊函式(hash function)把任意長度的輸入算成固定長度的摘要(digest)。安全的雜湊要具備三個性質:同樣輸入一定得到同樣輸出;從摘要無法反推輸入(單向,preimage resistance);實務上找不到兩個不同輸入有相同摘要(抗碰撞,collision resistance)。另外,輸入只改一個位元,輸出大約一半的位元都會翻轉,這叫雪崩效應(avalanche effect)。實驗室一的預設輸入只把 1200 改成 9200,SHA-256 的 256 個位元裡有 120 個改變(約 47%);pay 100 與 pay 900 則剛好有 128 個不同。兩組數字都用 python3 的 hashlib 核對過,不同輸入會落在 50% 附近。

A:…金額1200 B:…金額9200 只差一個字 Base64:48 個字元中只有 1 個不同 …6YeR6aGNMTIwMA== …6YeR6aGNOTIwMA== SHA-256:256 位元中 120 個不同(約 47%) A:8e5b0e89…49d69d B:81499c44…1ac504
同樣只改一個字,上方的 Base64 只有一個字元不同,看得出兩份輸入幾乎一樣;下方的 SHA-256 有將近一半的位元翻轉,從摘要完全看不出兩份輸入的關係。紅色長條表示改變的比例。這正是雜湊適合做完整性檢查的原因:任何微小的竄改都會讓摘要對不上。

MD5 與 SHA-1 的問題就出在抗碰撞性。MD5 從 2004 年起已能構造碰撞,SHA-1 在 2017 年也有研究人員公開了第一組實際碰撞,NIST 已規劃在 2030 年底前全面停用 SHA-1。攻擊者能做出「摘要相同、內容不同」的兩份檔案,簽章和完整性檢查就失去意義,所以安全用途請選 SHA-256、SHA-512、SHA3-256 或 BLAKE2。Python 的 hashlib.md5() 仍然可以呼叫,若只是拿來產生快取鍵這類非安全用途,可以加上 usedforsecurity=False(Python 3.9 起),讓讀程式的人與在 FIPS 模式下執行的環境都知道這不是安全用途。另外,Python 3.11 起有 hashlib.file_digest(f, "sha256"),可以直接對以二進位模式開啟的檔案計算,不用自己分塊讀取。

HMAC:把金鑰混進雜湊,比對時用固定時間

雜湊沒有金鑰,任何人都算得出來,所以它只能回答「內容和某個可信的摘要是否一致」。如果攻擊者能同時換掉內容和摘要,例如偽造一則 Webhook 並附上自己算的 SHA-256,單純雜湊就擋不住。HMAC 把雙方共用的金鑰混進計算,沒有金鑰的人算不出正確的值,於是同時證明了「沒被改」與「來自持有金鑰的一方」。hmac.new(key, msg, digestmod) 從 Python 3.8 起一定要指定 digestmod(例如 "sha256" 或 hashlib.sha256),漏掉會丟 TypeError。不要自己用 sha256(key + msg) 拼一個「有金鑰的雜湊」:SHA-256 這類結構有長度延伸(length extension)的弱點,知道摘要的人可以在不知道金鑰的情況下,替訊息尾端接上內容並算出新的有效摘要;HMAC 的設計正是為了避開這個問題。

傳送端(示意金流商) 內容:amount=1200 共用金鑰 key hmac.new → 簽章 內容+簽章(放在標頭) 接收端(晴空咖啡) 同一把 key 重算 compare_digest 一致 → 接受 偽造者:改成 amount=9200,附上自己算的 SHA-256 沒有 key 就算不出正確的 HMAC → compare_digest 回傳 False
上半部是正常流程:雙方用同一把金鑰各算一次,再用固定時間比較。下方紅框是偽造者能做到的事:他可以改內容,也可以算任何公開的雜湊,但沒有金鑰就做不出對的 HMAC。金鑰要用 secrets.token_bytes(32) 這類安全亂數產生,並放在秘密管理服務裡。

比對 HMAC 時要用 hmac.compare_digest(a, b),不要用 ==。一般的字串比較會在第一個不同的字元就提早結束,花的時間隨「前面猜對幾個字元」而變。攻擊者若能大量送出請求並精確量測回應時間,理論上可以一個字元一個字元地逼近正確值,這叫時序攻擊(timing attack)。compare_digest 的比較時間不取決於內容在哪裡不同;兩個參數要同為 str(只含 ASCII)或同為位元組,混用會丟 TypeError。

❌ == :遇到第一個不同的字元就結束(示意) 正確 8e5b0e 猜 1 1 猜 2 8e0 比 1 次就停 比 3 次才停:比較久 ✅ compare_digest:比較時間不取決於在哪裡不同 猜 1、猜 2 都一樣一樣久
上半部的紅色長條長短不同:猜對的字元越多,== 比得越久,回應時間就洩漏了「前面猜對幾個」。下半部的綠色長條一樣長,攻擊者從時間上得不到線索。實際的時間差非常小,要靠大量請求與統計才量得到,但防禦成本只是換一個函式,沒有理由不換。
import hashlib, hmac, io, secrets

# 1) 雜湊:確認下載檔和官方公布的值一致(完整性)
data = b"installer v3.2 binary..."                  # 假設是下載回來的檔案內容
published = hashlib.sha256(data).hexdigest()        # 示範:官方網站公布的 SHA-256
digest = hashlib.file_digest(io.BytesIO(data), "sha256").hexdigest()  # 3.11+ 直接吃檔案物件
print(len(digest), hmac.compare_digest(digest, published))  # 64 True

# 2) HMAC:確認 Webhook 真的是持有金鑰的一方送來的
key = secrets.token_bytes(32)                       # 共用金鑰:只有金流商與我方知道
body = b'{"order": 1001, "amount": 1200}'
sig = hmac.new(key, body, hashlib.sha256).hexdigest()   # 傳送端:放進請求標頭

def verify(body: bytes, received: str) -> bool:
    expected = hmac.new(key, body, hashlib.sha256).hexdigest()  # 用同一把金鑰重算
    return hmac.compare_digest(expected, received)   # 固定時間比較,不用 ==

print(verify(body, sig))                             # True:內容與金鑰都對
print(verify(body.replace(b"1200", b"9200"), sig))   # False:金額被改過
print(hashlib.md5(b"/menu?page=2", usedforsecurity=False).hexdigest()[:8])  # MD5 只做快取鍵

加密:Fernet 與 AES-GCM

需要之後讀回、又不能讓別人看到的資料,才輪到加密。對稱加密(symmetric encryption)用同一把金鑰加密與解密,速度快,適合加密存放中的資料。Python 社群的標準選擇是 cryptography 套件,它刻意分成兩層:上層的 Fernet 把演算法、IV、時間戳與驗證標籤都包好了,幾乎不可能用錯;下層 hazmat(hazardous materials)裡的 AESGCM 比較彈性,但 nonce 要自己管理。

Fernet.generate_key() 產生 32 位元組、以 URL-safe Base64 表示的 44 個字元金鑰,不能拿任意字串當金鑰(會丟 ValueError),要從使用者密碼得到金鑰必須經過金鑰衍生函式(KDF,例如 scrypt 或 PBKDF2)。Fernet 內部用 AES-128-CBC 加密、HMAC-SHA256 驗證,每次加密都產生新的 IV 並記下時間戳,所以同樣的明文每次密文都不同;decrypt(token, ttl=秒數) 可以拒收過期的密文,金鑰不符或密文被改都會丟 InvalidToken。

版本1 時間戳8 IV16(每次隨機) 密文16 的倍數 HMAC-SHA25632(驗證前面全部) 單位:位元組(示意 pay 100 的情況) 1 + 8 + 16 + 16 + 32 = 73 位元組 → URL-safe Base64 → 100 個字元 gAAAAA…(開頭固定,因為版本=0x80) 外層是 Base64 編碼,裡面才是真正的密文
Fernet token 是「先加密、再用 HMAC 封口、最後整串 Base64」。開頭的 gAAAAA 每次都一樣,常被誤以為加密很弱,其實那只是版本號和時間戳高位的編碼結果。這也示範了編碼和加密常常疊在一起用:Base64 負責讓密文能放進文字欄位,保密靠的是裡面的 AES。

AES-GCM 是認證加密(AEAD,Authenticated Encryption with Associated Data):加密時同時產生 16 位元組的驗證標籤,所以密文長度是明文加 16;解密時密文、nonce 或附加資料(AAD)任何一個不符,都會丟 InvalidTag。AAD 是不加密但受保護的資訊,例如訂單編號,可以防止有人把 A 訂單的密文搬到 B 訂單的欄位。nonce(number used once)建議 12 位元組,不需要保密,可以和密文存在一起,但同一把金鑰下絕對不能重複:重複使用時,兩份密文互相 XOR 就會抵消掉加密,洩漏兩份明文的關係,驗證標籤也可能被偽造。實驗室一按「再加密一次」時密文會變,就是因為每次都換了新的 nonce。

金鑰(保密) nonce(每次新) 明文 pay 100 AAD:order-1001 AESGCM.encrypt() 密文7 位元組 標籤16 len(ct) = 7 + 16 = 23 同一把金鑰+同一個 nonce 用兩次:密文 XOR 抵消加密,標籤也可能被偽造 常見錯誤:nonce = b"\x00" * 12 寫死,或用計數器卻在重啟後歸零
左邊四個輸入裡,只有金鑰需要保密;nonce 和 AAD 可以公開存放,但 nonce 必須每次不同。右邊的輸出長度隨明文變化,再加上固定 16 位元組的標籤。底部紅框是 AES-GCM 最需要防範的使用錯誤。
import os
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag

# 金鑰從環境變數或秘密管理服務讀取;這裡為了能直接執行才臨時產生
os.environ.setdefault("MEMBER_KEY", Fernet.generate_key().decode())
f = Fernet(os.environ["MEMBER_KEY"])                # 44 字元的 URL-safe Base64 金鑰

t1 = f.encrypt("會員電話 0912-000-000".encode())     # 加密+HMAC 驗證一次完成
t2 = f.encrypt("會員電話 0912-000-000".encode())
print(t1 == t2, t1[:6])                             # False b'gAAAAA':IV 與時間戳每次不同
print(f.decrypt(t1, ttl=3600).decode())             # 同一把金鑰才能還原;超過 1 小時拒收
try:
    Fernet(Fernet.generate_key()).decrypt(t1)       # 換一把金鑰
except InvalidToken:
    print("InvalidToken:金鑰不對或密文被改")        # 不能吞掉例外繼續用資料

aes = AESGCM(AESGCM.generate_key(bit_length=256))
nonce = os.urandom(12)                              # 每次加密都產生新的 nonce
ct = aes.encrypt(nonce, b"pay 100", b"order-1001")  # 第三個參數是 AAD
print(len(ct))                                      # 23:明文 7 位元組+標籤 16 位元組
try:
    aes.decrypt(nonce, ct, b"order-1002")           # AAD 不符(被搬到別筆訂單)
except InvalidTag:
    print("InvalidTag:資料或參數不符")

對稱與非對稱:誰負責什麼

對稱加密快,但雙方要先擁有同一把金鑰,「金鑰怎麼安全送過去」就成了問題。非對稱加密(asymmetric encryption)用一對金鑰:公鑰可以公開,用來加密或驗證簽章;私鑰只有擁有者持有,用來解密或簽章。它解決了金鑰分發,代價是運算慢、不適合大量資料。所以實務上兩者分工:用非對稱的方式交換或保護一把臨時的對稱金鑰,再用這把對稱金鑰加密真正的資料,這就是 TLS 每天在做的混合式加密。寫 Python 時,HTTPS 連線交給 ssl 與 requests 的預設憑證驗證,自己存資料用 Fernet 或 AES-GCM,要證明檔案出自自己才用 Ed25519 這類簽章;很少需要自己用 RSA 加密資料。

傳送端 接收端 公鑰(公開) 私鑰(自己留) ① 非對稱:只處理一小把金鑰 臨時 AES 金鑰 用接收端公鑰保護(或用金鑰協商建立) 接收端用私鑰取出同一把 AES 金鑰 ② 對稱:AES-GCM 加密大量資料 示意:TLS 1.3 實際上以 (EC)DHE 金鑰協商建立工作階段金鑰
上半部細細的綠線是非對稱的部分,只搬運一把很小的金鑰;下半部粗的藍色虛線才是資料本身,交給快速的對稱加密。記住這個分工,就不會在程式裡用 RSA 去加密整個檔案。

JWT:payload 只是 Base64URL 編碼

JWT(JSON Web Token)由三段以點分隔的字串組成:header、payload 與 signature,前兩段是 JSON 經過 Base64URL 編碼。一般使用的 JWT 是 JWS(簽章版),只保證內容沒被改、確實由持有金鑰的一方簽發,預設沒有加密:任何拿到 token 的人,把中間那段補上 = 後丟進 base64.urlsafe_b64decode,就能讀到使用者 ID、角色與到期時間。所以 payload 不能放密碼、身分證字號、信用卡號這類資料;真的需要隱藏內容時,要用 JWE(加密版)或乾脆只放一個隨機 ID。驗證時,PyJWT 的 jwt.decode(token, key, algorithms=["HS256"]) 一定要給允許的演算法清單;options={"verify_signature": False} 讀出來的內容只能用來除錯,不能拿來判斷權限。

eyJhbGciOi… . eyJzdWIiOi… . QPMo1Civ… Base64URL 解碼,不需金鑰 header{"alg":"HS256",…} payload{"role":"admin",…} signatureHMAC(key, 前兩段) 任何拿到 token 的人都讀得到不要放密碼、身分證字號、卡號 防竄改,不防偷看要隱藏內容用 JWE
上排是一個真正的 HS256 token 的三段(截短顯示)。左邊兩段只是編碼,往下解開就是 JSON;只有右邊的簽章需要金鑰才算得出來。看到「JWT 很安全,所以資料放進去就加密了」這種說法,就是把簽章和加密搞混了。
import base64, json
import jwt                                           # 套件名稱 PyJWT

key = "demo-only-key-load-from-secret-store!"        # 示範用;正式環境從秘密管理服務讀取
token = jwt.encode({"sub": "u42", "role": "admin"}, key, algorithm="HS256")
header, payload, signature = token.split(".")
pad = "=" * (-len(payload) % 4)                      # Base64URL 省略結尾的 =,先補回來
print(json.loads(base64.urlsafe_b64decode(payload + pad)))  # 不需要金鑰就讀得到內容
print(jwt.decode(token, key, algorithms=["HS256"]))  # 驗簽章時一定要指定允許的演算法

判斷步驟

  1. 先問資料之後要不要讀回原文。要讀回就只剩編碼或加密;不用讀回就是雜湊家族。
  2. 要讀回時,再問需不需要保密。只是為了放進文字欄位或網址,用 Base64;不能讓別人看到,用 Fernet 或 AES-GCM,金鑰放在程式與資料庫以外。
  3. 不用讀回時,先問是不是使用者密碼。是的話直接用 Argon2id 等密碼雜湊,不要用 SHA-256。
  4. 不是密碼時,再問要不要證明是誰送的。只比對完整性用 SHA-256;要證明來源而雙方共用金鑰,用 HMAC;要讓第三方也能驗證,用數位簽章。
  5. 最後檢查比對方式與演算法:MAC 和 token 用 compare_digest;不要在安全用途用 MD5 或 SHA-1;解密或驗證失敗的例外不能吞掉。

容易寫錯或考錯的地方

「Base64 加密」這四個字本身就是錯的。程式題常給 b64encode 的輸出問「哪個敘述正確」,干擾選項會說它需要金鑰才能還原,或說它能保護個資。看到 b64decode 不帶任何金鑰就還原了原文,就能刪掉這類選項。

雜湊沒有「解密」。選項寫「SHA-256 可以用金鑰解回原文」一定錯;寫「SHA-256 的 hexdigest 長度隨輸入變長」也錯,它固定是 64 個字元,digest() 則是 32 個位元組。

== 和 compare_digest。問哪一行有問題時,常把錯誤藏在比較那一行:演算法、金鑰都對,最後卻用 == 比 HMAC。另一個常見陷阱是 hmac.new(key, msg) 沒給 digestmod,在 Python 3.8 之後會直接丟 TypeError。

密文每次都不一樣是正常的。Fernet 與 AES-GCM 每次加密都換 IV 或 nonce,所以「同一段明文加密兩次結果相同」是錯的;反過來,如果你的程式加密兩次結果一樣,多半是 nonce 寫死了,或用了 ECB 模式。也因此加密後的值不能拿來做資料庫查詢比對,需要可查詢的欄位時,另外存一個 HMAC 當查詢鍵。

金鑰比演算法更常出事。Fernet 金鑰寫死在程式裡、和資料庫放在同一台主機、印在日誌裡,加密就形同虛設。Fernet.generate_key() 每次執行都會產生新的金鑰,若沒有保存下來,之前加密的資料就再也解不開。

JWT 不是加密。題目說「把使用者的身分證字號放進 JWT payload 傳給前端比較安全」是錯的;正確的說法是 payload 預設任何人都能讀,簽章只防竄改。

✅ 自我檢測

以下 6 題都是原創的程式閱讀題,選完會立即顯示對錯與解析,全部作答後會出現總分。題目裡的輸出都用 python3 實際執行確認過。目前得分:0 / 6