💡 先搞懂問題
虛構的「晴空咖啡」正在改寫會員系統,程式碼審查時一口氣找到三個問題。第一位工程師把會員手機號碼用 base64.b64encode() 處理後存進資料庫,註解寫著「已加密」;但任何人把那串 MDkxMi0wMDAtMDAw 丟回 b64decode(),不需要任何金鑰,就得到 0912-000-000。第二位工程師為了「保護」會員的信用卡號,存的是 hashlib.sha256() 的結果,等到要退款時才發現卡號再也拿不回來。第三位工程師替金流通知(Webhook)加了檢查:收到通知後重算內容的 SHA-256,和通知附上的值比對;可是偽造通知的人也能自己算 SHA-256,這個檢查等於沒有設防。
三個錯誤的根源相同:Base64 的輸出、SHA-256 的摘要和加密後的密文,在畫面上都是一串看不懂的字,很容易被當成同一類東西。真正該問的是三件事:能不能還原回原本的資料?還原時需不需要金鑰?輸出的長度是否固定?用這三個問題去看,編碼(encoding)、雜湊(hash)和加密(encryption)的分工就清楚了:編碼是換一種表示方式,誰都能還原;雜湊是單向的固定長度指紋,無法還原,只能拿來比對;加密要有金鑰才能還原,用來保護之後還要讀回的資料。
生活比喻:處理一份重要文件的三種方式
想像晴空咖啡的店長要處理一份供應商合約。第一種做法是把合約翻成摩斯電碼,方便用無線電念給總部聽:這樣做是為了「方便傳送」,懂摩斯電碼的人誰都翻得回來,它從來沒打算保密。第二種做法是替合約做一張「指紋卡」:不論合約是兩頁還是兩百頁,指紋卡都一樣大;同一份合約每次做出來的指紋卡都相同,合約只要改一個字,指紋卡就整張不一樣;但光看指紋卡,沒有人能把合約內容寫回來。總部收到合約時重做一張指紋卡比對,就知道合約有沒有被換過。第三種做法是把合約鎖進保險箱,鑰匙只交給總部,路上的人拿到箱子也打不開,總部用鑰匙就能把合約完整取出。
指紋卡有一個漏洞:如果攔截的人換掉合約,順便重做一張指紋卡一起換掉,總部比對起來一樣吻合。所以店長和總部約定,每張指紋卡都要再蓋上一顆只有他們兩人才有的印章,攔截的人沒有那顆章,換了合約也蓋不出正確的印記。
base64.b64encode(),它解決的是「二進位資料怎麼放進只收文字的地方」,不是保密。指紋卡對應的是雜湊,hashlib.sha256(data).hexdigest() 永遠輸出 64 個十六進位字元,用來比對完整性。蓋上兩人共有的印章,就是 HMAC(Hash-based Message Authentication Code,訊息鑑別碼):hmac.new(key, msg, "sha256") 把金鑰混進雜湊,沒有金鑰的人算不出正確值。保險箱對應的是加密,例如 cryptography 套件的 Fernet(key).encrypt(),有同一把金鑰才能 decrypt() 取回原文。
這個比喻有三個地方要修正。第一,真實的指紋偶爾會被誤認,但 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 編碼
指SHA-256 雜湊
鎖AES-GCM 加密
雪崩效應:改一個字
SHA-256 改變的位元
同樣的改變,Base64 呢?
紅底是 B 的 Base64 和 A 不同的字元。Base64 是逐段換字,改一個字只影響附近幾個字元,看得出規律;雜湊則像整張重算。
🎮 互動實驗室二:用途配對
下面 11 張卡片是晴空咖啡開發團隊遇到的實際需求,請把每一張放進最適合的工具格(電腦上工具格在右邊,手機上固定在卡片上方)。電腦上可以直接拖曳;手機或想用點的話,先點一張卡片(會被框起來),再點工具格。放對會說明理由並把卡片收進格子;放錯會說明你選的工具差在哪裡,卡片留在原處讓你再試。判斷時先問:之後要不要讀回原文?要不要保密?要不要證明是誰送的?是不是密碼?
🎮 互動實驗室三:程式判斷卡
每張卡是一小段 Python,請判斷它最主要的問題是什麼;有幾張其實寫得沒問題,要選「這段寫法沒問題」。答完會說明原因,並附上安全寫法。卡片順序每輪隨機,每張只計第一次作答。
📘 原理補完
三個問題、六種工具
實驗室裡的三個問題,正好可以把常見的工具分開。下表把編碼、雜湊、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 只是換一種寫法
很多地方只收「可列印的文字」,例如 JSON 字串、網址參數、電子郵件內文、只吃文字的設定檔。二進位資料(圖片、金鑰、雜湊的原始位元組)直接塞進去會出問題,所以要先編碼。Base64 每次拿 3 個位元組(24 位元),切成 4 組 6 位元,每組對應到 64 個可列印字元之一;最後不足 3 個位元組時用 = 補齊,所以輸出長度大約是輸入的 4/3 倍,而且會隨輸入變長。Base64URL(base64.urlsafe_b64encode)把 + 和 / 換成 - 和 _,方便放進網址,JWT 就是用它,而且通常省略結尾的 =。整個過程沒有任何秘密,對照表是公開標準(RFC 4648),所以 Base64 不提供任何保護,看起來看不懂只是因為人類不習慣讀它。
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% 附近。
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 的設計正是為了避開這個問題。
secrets.token_bytes(32) 這類安全亂數產生,並放在秘密管理服務裡。比對 HMAC 時要用 hmac.compare_digest(a, b),不要用 ==。一般的字串比較會在第一個不同的字元就提早結束,花的時間隨「前面猜對幾個字元」而變。攻擊者若能大量送出請求並精確量測回應時間,理論上可以一個字元一個字元地逼近正確值,這叫時序攻擊(timing attack)。compare_digest 的比較時間不取決於內容在哪裡不同;兩個參數要同為 str(只含 ASCII)或同為位元組,混用會丟 TypeError。
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。
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。
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 加密資料。
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} 讀出來的內容只能用來除錯,不能拿來判斷權限。
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"])) # 驗簽章時一定要指定允許的演算法
判斷步驟
- 先問資料之後要不要讀回原文。要讀回就只剩編碼或加密;不用讀回就是雜湊家族。
- 要讀回時,再問需不需要保密。只是為了放進文字欄位或網址,用 Base64;不能讓別人看到,用 Fernet 或 AES-GCM,金鑰放在程式與資料庫以外。
- 不用讀回時,先問是不是使用者密碼。是的話直接用 Argon2id 等密碼雜湊,不要用 SHA-256。
- 不是密碼時,再問要不要證明是誰送的。只比對完整性用 SHA-256;要證明來源而雙方共用金鑰,用 HMAC;要讓第三方也能驗證,用數位簽章。
- 最後檢查比對方式與演算法: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