Crypto Agility: Make algorithms change safely

密碼學敏捷性:把「密碼有天會被攻破」作為系統設計前提

前言

今天安全的演算法,不代表十年後仍然安全。

計算能力會增加、密碼分析會進步、實作漏洞會被發現,合規要求也會改變;有些變化甚至不是演算法本身失效,而是金鑰外洩、函式庫停止維護,或協定版本被淘汰。

資料庫裡的密文、簽發給客戶的 Token、裝置韌體中的公開金鑰、服務之間的協定,都可能存活多年,還要考慮到:

  • 如何應對逐漸不安全的演算法?
  • 如何平順替換上更安全的演算法?
  • 如何兼容新舊演算法?

Crypto Agility(密碼學敏捷性) 就是預先回答這些問題的能力:系統能在可控、可觀測、可回復的情況下,替換演算法、參數、金鑰或協定,而不必大改整個產品。

為什麼需要密碼學敏捷性

安全演算法的安全有前提

密碼學安全不是永久成立的性質,它是一個在特定攻擊模型、可用計算資源與已知研究下的判斷。當其中因素變了,原本合理的選擇都可能需要重新評估:

  • 新的攻擊方法:研究者找到碰撞、降級或更有效的破解方法
  • 硬體進步:GPU、ASIC、雲端叢集讓暴力破解的成本快速下降
  • 實作與供應鏈風險:函式庫有漏洞、供應商停止支援、硬體模組退役
  • 政策與合規:瀏覽器、作業系統或政府標準不再接受舊的 TLS、雜湊或金鑰長度
  • 威脅模型改變:例如攻擊者今天先收集加密流量,等未來取得量子電腦後再解密

加密的遷移成本會隨時間上升

寫死使用一個演算法或一把金鑰看似很快很方便,但隨著應用開發可能變成一場難以收拾的爛攤子⋯⋯寫死的演算法出現在不同快取、備份、服務、合作夥伴情境當中。

加密的遷移成本會隨時間上升,所以提倡密碼學敏捷的核心價值不是讓切換變得零成本,而是讓成本可預估:知道哪些資料受影響、哪些版本仍在使用、先部署什麼、何時能移除舊金鑰。

回顧過去案例:DES

DES 是很具體的例子。它在 1977 年成為美國聯邦標準;1998 年,EFF 以名為 Deep Crack 的專用硬體在約 56 小時內找出一把 DES 金鑰,證明 56-bit 金鑰已經不足以抵抗有資源的攻擊者。直到 2005 年,NIST 才正式撤回 DES 標準。

1977FIPS 46 發布,DES成為標準1998EFF Deep Crack在約 56 小時內破解DES 金鑰2001AES成為新的聯邦加密標準2005NIST 撤回DES;不再核准用於保護聯邦資料DES:從採用到退役的升級窗口

這條時間線有兩個值得注意的間隔:DES 在「被採用」約 21 年後,硬體成本終於讓其 56-bit 金鑰變得不夠安全;而從 1998 年的公開示警到 2005 年正式退役,又有約 7 年。這不是可以原地不動的緩衝期,而是系統必須完成升級的窗口。

因此,問題不只是「目前的演算法夠不夠強」,更是「當它不夠強時,我們能多快停止使用它」。當前的演算法也會經歷相同的生命週期,密碼敏捷性就是為這段必然存在的升級窗口預先準備。

面對未來量子威脅:對稱加密、非對稱加密、雜湊遭遇的不是同一種問題

非對稱加密:面臨結構性威脅

像是 RSA 安全性依賴大整數因數分解困難;Diffie–Hellman 與橢圓曲線密碼學(ECC)的安全性則依賴離散對數困難,但面對量子電腦可以用 Shor’s algorithm 大幅加速這兩類問題。一旦量子運算達到實用規模,這些傳統系統都必須更換。

對稱加密:安全強度下降,而非立刻失效

對稱加密通常直接保護大量資料,例如資料庫欄位、檔案與傳輸內容。對它而言,Grover’s algorithm 理論上可把暴力搜尋的工作量從約 2^n 降到約 2^(n/2)。以 AES 為例,可粗略理解為:AES-128 的 128-bit 暴力破解安全強度可能降至約 64-bit;AES-256 則仍約為 128-bit。

雜湊:不是加密,但安全強度也會下降

雜湊函式不用來保密而是把任意長度輸入映射成固定長度摘要。數位簽章、檔案完整性、HMAC 與金鑰衍生⋯⋯等機制都依賴它的不同性質:

  • 原像抗性(preimage resistance):給定摘要,難以找出能產生它的輸入
  • 第二原像抗性(second-preimage resistance):給定一份輸入,難以找到另一份不同輸入卻有相同摘要
  • 碰撞抗性(collision resistance):難以找出任意兩份不同輸入,卻有相同摘要

同一個雜湊演算法用在不同地方,依賴的性質、面對的攻擊與真正的防線都不一樣。以 SHA-256 為例:

用途依賴的性質攻擊者要做的事傳統安全強度量子後真正的防線
數位簽章碰撞抗性自己造兩份同摘要的文件,騙你簽無害那份,再換成惡意那份約 128-bit(生日攻擊,n/2)理論約 85-bit(BHT,n/3),實務上難以加速更長摘要:SHA-384/SHA-512
完整性驗證(下載檔案 checksum)第二原像抗性對一個「不是他選的」既定摘要找出另一份輸入約 256-bit約 128-bit摘要本身要從可信管道取得,否則攻擊者連檔案帶摘要一起換掉
HMAC金鑰保密與 PRF 性質,不依賴碰撞抗性猜出金鑰取金鑰長度與輸出長度較小者金鑰強度約減半夠長的隨機金鑰
密碼儲存以上性質都不是重點對低熵密碼做離線字典攻擊與摘要長度幾乎無關同左慢速、加鹽、耗記憶體的演算法:Argon2id、scrypt、bcrypt
KDF(如 HKDF)隨機性萃取/PRF攻擊輸入的金鑰材料受限於輸入金鑰的熵約減半高熵的輸入金鑰

真實案例:SHA-1 在 2017 年被 SHAttered 做出實際碰撞,因此不再適用於簽章;但 HMAC-SHA1 不依賴碰撞抗性,並沒有因此被攻破(雖然仍建議淘汰)。MD5 也是同樣的情況。

和 AES 一樣,這不代表 SHA-256 已經不安全。重點是盤查時不能只記「用了 SHA-256」,而要記「演算法 × 用途」:簽章用途最依賴碰撞抗性,應優先升級,密碼儲存的解法則不是換更長的雜湊,而是改用 Argon2id,並把成本參數一起版本化。

Harvest now, decrypt later

有些資料的機密性比系統生命週期更長,例如醫療紀錄、政府文件、商業機密。攻擊者可以今天先錄下以傳統非對稱加密保護的流量或密文,未來再嘗試解密。這種「現在收集、未來解密」的風險,讓後量子密碼(PQC)的遷移不能只等到量子電腦出現才開始。

實踐密碼學敏捷性

設計一個單向不可回退的系統,永遠只能逐步替換上更新的演算法
  1. 識別:從資料、Token、憑證或協定交握中辨識版本、演算法與金鑰識別碼(Key ID / kid)
  2. 選擇:由集中且明確的政策決定新資料使用什麼方案,而不是讓呼叫端任意指定演算法
  3. 共存:在必要期間能解密/驗證受支援的舊版本,同時只產生新版本
  4. 輪替與退役:能加入新金鑰或新版本、觀測使用量,並在截止後移除舊方案
  5. 可觀測與可測試:知道線上還有多少舊資料、遷移是否失敗,以及不同實作是否真的互通

以實作一個加解密介面為例

一、定義密文元資料與其介面

// 1. 密文格式應包含其版本與使用的 KeyID
type Envelope struct {
Version string `json:"v"` // 例如 v2
KeyID string `json:"kid"` // 例如 customer-2026-01,金鑰 ID 本身不是秘密,意義在於方便將真實金鑰存放到更安全的地方與輪替
Payload string `json:"ciphertext"`
}
// 2. 密文應抽象出「加密」、「解密」兩種方法
type Cipher interface {
Encrypt(plaintext, aad []byte) (Envelope, error)
Decrypt(envelope Envelope, aad []byte) ([]byte, error)
}

只拿到這一串密文,解密端就知道該用哪一套規則與哪一把金鑰。實際格式可以是 JSON、二進位欄位或前綴字串。

二、新寫入永遠走目前版本,舊版本只讀

v2

v1

新寫入

當前政策設定

v2 + 新金鑰

讀取密文

版本與 kid

新解密器

舊解密器:只讀

明文

前面 Cipher 同時要求 Encrypt 與 Decrypt 意味著每個版本也被迫實作完整的 Cipher,它就必須提供 Encrypt 方法,等於留了一條還能寫出舊格式的路,任何人在新功能裡都可能不小心呼叫到它,所以改良的結構如下:

type Decrypter interface {
Decrypt(envelope Envelope, aad []byte) ([]byte, error)
}
type Encrypter interface {
Encrypt(plaintext, aad []byte) (Envelope, error)
}
// 只有目前版本需要同時加密與解密。
type CurrentCipher interface {
Encrypter
Decrypter
}
// v1 只實作 Decrypter;刻意沒有 Encrypt。
type LegacyV1 struct{}
// v2 是目前版本,因此實作 CurrentCipher。
type AESGCMV2 struct{}

省略具體實作,重點是 LegacyV1 只符合 Decrypter,而 AESGCMV2 同時符合 Encrypter 和 Decrypter。

  • 介面
    • Decrypter:只能解密。
    • Encrypter:只能加密。
    • CurrentCipher:兩者組合,只給目前版本使用。
  • 實作
    • LegacyV1 只符合 Decrypter,刻意不寫 Encrypt。
    • AESGCMV2 同時符合 Encrypter 與 Decrypter。

把「舊版只讀」做成程式結構,而不是俗成約定。例如舊模組只提供 Decrypt,根本沒辦法 Encrypt。如此就算有人在新功能裡想沿用舊方案,也會在編譯時被擋下。

實際使用上在程式啟動時,從其他地方取得金鑰,建立目前的 v2 加密器與仍支援讀取的 v1 解密器:

type CipherRef struct {
Version string
KeyID string
}
type CryptoService struct {
writer Encrypter
decrypters map[CipherRef]Decrypter
}
currentV2 := AESGCMV2{ /* keyID: customer-2026-01, key: ... */ }
legacyV1 := LegacyV1{ /* keyID: customer-2019, key: ... */ }
crypto := CryptoService{
writer: currentV2,
decrypters: map[CipherRef]Decrypter{
{Version: "v2", KeyID: "customer-2026-01"}: currentV2,
{Version: "v1", KeyID: "customer-2019"}: legacyV1,
},
}
解密時查表實踐細節
func (c CryptoService) Decrypt(env Envelope, aad []byte) ([]byte, error) {
ref := CipherRef{Version: env.Version, KeyID: env.KeyID}
d, ok := c.decrypters[ref]
if !ok {
return nil, errors.New("unsupported version or key id")
}
return d.Decrypt(env, aad)
}

如下使用者輸入稅務 ID 後,應用程式以不可變的紀錄身分組成 AAD,然後只呼叫服務的加密入口:

plaintext := []byte("A123456789")
aad := []byte("tenant/acme:customers:42:tax-id")
encrypted, err := crypto.Encrypt(plaintext, aad)
// encrypted 約為:
// {"v":"v2", "kid":"customer-2026-01", "ciphertext":"Base64..."}
db.SaveCustomerTaxID(42, encrypted)

讀取時,服務從儲存的 Version 和 KeyID 找到對應的解密器,再以相同的 AAD 解密:

stored := db.LoadCustomerTaxID(42)
plaintext, err := crypto.Decrypt(
stored,
[]byte("tenant/acme:customers:42:tax-id"),
)
// plaintext == []byte("A123456789")

如果這筆資料是歷史資料,stored 會帶著 v1 與 customer-2019,讀取路徑就會交給 LegacyV1.Decrypt(...)。

延伸閱讀