持久化資料加密
Package:
io.leandev.appfuse.security.crypto.*Feature:persistence-encryption(optional)
當應用不得不把「之後仍要取回」的機密保存到資料庫時,AppFuse 提供一個應用層持有設定、 框架層提供密碼學 primitive 的邊界。部署只管理一組 root key ring;mail 或其他由應用明確 選用的用途,以固定 purpose 經 HKDF-SHA-256 衍生互相隔離的 AES-256 key。
這不是密碼雜湊或 secret manager 的替代品:
- 使用者密碼仍用 adaptive one-way hash。
- 不需取回的高熵 token/API key 優先只存 hash。
- 能放在外部 secret manager、且不需進 DB 的秘密不要複製進資料庫。
- 只有必須從 DB 取回的值才使用本能力;需要查詢時另存不可逆 fingerprint。
邊界與威脅模型
部署 secret
└─ root key ring(應用設定)
├─ HKDF("appfuse/mail/v1") → mail AES-256 key
└─ HKDF("my-app/customer-token/v1") → 應用選配的 AES-256 key
root key 不直接加密資料,也不進 DB、log 或 API。每次加密使用獨立 96-bit nonce,AES-GCM 同時提供機密性與竄改偵測;purpose 及 envelope metadata 會綁入 AAD。
這能降低 DB dump、備份或唯讀帳號外洩時的明文暴露,但不能防止已取得應用程序記憶體、root key 或合法解密 API 權限的攻擊者。資料庫備份與 key ring 必須分開保存,但災難復原時兩者缺一 不可。
Feature 與設定
參考實作把 Spring binding 留在應用層:
# config/feature-persistence-encryption.yml
app:
persistence-encryption:
active-key-id: v1
keys:
# @conf-env tiers(dev,test,prod) generate(secret) secret(true)
v1: ${APP_PERSISTENCE_ENCRYPTION_KEY:}
APP_PERSISTENCE_ENCRYPTION_KEY 是 Base64 編碼的 32 random bytes。/env-config 的
generate(secret) 會在首次產生環境設定時建立它,之後預設沿用;應用不得在每次啟動時產生
ephemeral key,否則重啟後即無法解密既有資料。
persistence-encryption 本身是 optional Feature;選用 mail 時,catalog 的 requires 會自動
把它帶入。notification 不為 Outbox protection 直接依賴此 Feature,reference implementation
的 Outbox 維持 plaintext;但 notification 目前依賴 mail,因此 provision 的遞移閉包仍會包含
persistence-encryption。應用若經資料分類後決定保護其他可取回資料,可自行取得
purpose-scoped TextCipher,不需再增加一組部署 secret。
空白 root key 不阻止 Spring context 啟動,讓未使用持久化機密的組裝仍可運行;第一次實際 encrypt/decrypt 時會明確失敗。首次保存或讀取 MailSetting credential 前,部署檢核必須確認 active key 存在。
Java 使用方式
框架 primitive 不讀 Spring 或環境變數;應用先建立 key ring,再把固定 purpose 的
TextCipher 交給消費端:
import io.leandev.appfuse.security.crypto.PersistenceEncryption;
import io.leandev.appfuse.security.crypto.TextCipher;
PersistenceEncryption encryption = PersistenceEncryption.fromBase64(
"v1", Map.of("v1", encodedRootKey));
TextCipher customerTokenCipher = encryption.scoped("my-app/customer-token/v1");
String ciphertext = customerTokenCipher.encrypt("recoverable-secret");
String plaintext = customerTokenCipher.decrypt(ciphertext);
purpose 是公開的 domain-separation label,不是 secret。命名使用穩定 namespace 與版本,例如
my-app/customer-token/v1;不得由 request data 動態組成,也不要只用 token 這類可能撞名的
短字串。改名會使既有密文無法由新 purpose 解密,效果等同不相容 migration。
密文格式為:
v1:<root-key-id>:<base64url(nonce + ciphertext + authentication-tag)>
第一個 v1 是 envelope format version;purpose 尾端的 /v1 是用途契約版本,兩者意義不同。
應用不得解析或拼接 envelope,僅透過 TextCipher 操作。
Root key 輪替
輪替採 key ring,不需要停機,但 rolling deployment 必須分四階段:
- 展開讀取能力:產生
v2,所有節點配置v1+v2,但active-key-id仍為v1。 - 切換寫入:確認所有節點都認得
v2後,把active-key-id切到v2。 - 重加密:批次讀取
v1:*envelope,以原 purpose 解密後重新寫入;checkpoint、錯誤重試與 row-level concurrency policy 由應用 migration 決定。 - 撤除舊 key:確認 production DB、replica、待重送 outbox 與需要還原的備份都不再依賴
v1,才移除舊 key。
輪替期間的外部設定示例:
app:
persistence-encryption:
active-key-id: v2
keys:
v1: ${APP_PERSISTENCE_ENCRYPTION_KEY_V1}
v2: ${APP_PERSISTENCE_ENCRYPTION_KEY_V2}
不可在第一階段就讓部分節點寫 v2:尚未取得 v2 的舊節點會無法讀取新資料。回滾 artifact
前也必須確認舊版認得目前 active key 與 envelope。
參考實作與選配用途
| purpose | 保護資料 | 讀取位置 |
|---|---|---|
appfuse/mail/v1 | MailSetting 的 SMTP password、OAuth2 client secret | 建立實際 Mailer 設定時 |
MailSetting credential 是 reference implementation 唯一預設加密的資料。business data
encryption 屬應用選配:框架的 NotificationService/NotificationOutboxDispatcher 雖接受
TextCipher,未傳入時使用 TextCipher.identity(),reference implementation 不注入 cipher。
若產品決定加密 Outbox 或其他業務資料,必須自行擁有 purpose、schema、查詢策略、管理 API
解密邊界與 key rotation;不能只把欄位加密後期待原查詢仍可工作。
採用與既有資料
reference implementation 不提供 startup migration runner。尚未保存任何 MailSetting credential 時,只需先佈建 root key;沒有資料需要轉換,也不應加入 migration 開關或一次性 migration 程式。若採用端確實存在舊資料,才依實際格式另外規劃離線轉換:
- 舊版 mail ciphertext 不與新 envelope 相容。若 DB 已有資料,須在仍持有舊
MAIL_SETTINGS_ENCRYPTION_KEY時離線解密,再以appfuse/mail/v1重加密;確認沒有既有列時 才能直接移除舊 key。 - 舊版 plaintext mail secret 必須批次加密,且 read API、log、exception、
toString()不得暴露 明文。 - envelope 會比 plaintext 長;採用端須確認 MailSetting credential 欄位可容納實際密文。
- 轉換完成後,以 DB 唯讀查詢確認 credential 欄位只剩預期 envelope prefix,並做一次真實 mail smoke test。