同一組密碼在 Windows 上能解開檔案,移到 iOS 卻顯示密碼錯誤,先別急著重設資料。問題可能不在加密演算法,而是密碼編碼、迭代次數或衍生長度不一致。先在雲端 Mac 上執行已知答案測試,再讓 iOS 目標呼叫實際使用的封裝,就能將問題定位到明確的層級。
固定輸入契約
本文適用於已採用 PBKDF2 協定的跨平台專案,並非將它視為所有新系統的預設選擇。先明確定義六個欄位:演算法、PRF、密碼位元組編碼、鹽值位元組、迭代次數、輸出長度。這裡固定使用 PBKDF2-HMAC-SHA256,輸出為 32 位元組。
密碼以 UTF-8 編碼,不自動去除空白、轉換大小寫或進行 Unicode 正規化。看起來相同的文字,可能對應不同的位元組;若既有協定已定義正規化規則,就必須沿用,並將規則納入版本管理。
已知答案測試用來判斷「計算是否正確」,裝置實測則用來判斷「工作因子是否合適」。測試向量的一輪、兩輪迭代絕不能直接用於正式環境設定。
同時確認傳入的鹽值是原始位元組,而不是十六進位文字。將字串 73616c74 當作鹽值,與傳入單字 salt 的位元組,是兩種不同的輸入。
執行標準向量
在已選定完整開發工具鏈的 Mac 上,將下列內容儲存為 pbkdf2-check.swift。它直接呼叫系統的 CommonCrypto,不必下載相依套件。測試失敗時會終止執行,不會輸出密碼或衍生結果。
import Foundation
import CommonCrypto
enum DerivationError: Error {
case invalidInput
case failed(Int32)
}
func derive(
password: String,
salt: [UInt8],
rounds: UInt32
) throws -> [UInt8] {
guard rounds > 0, !salt.isEmpty else {
throw DerivationError.invalidInput
}
let passwordBytes = password.utf8CString
var output = [UInt8](repeating: 0, count: 32)
let status = passwordBytes.withUnsafeBufferPointer { p in
salt.withUnsafeBufferPointer { s in
output.withUnsafeMutableBufferPointer { o in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
p.baseAddress,
p.count - 1,
s.baseAddress,
s.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
rounds,
o.baseAddress,
o.count
)
}
}
}
guard status == kCCSuccess else {
throw DerivationError.failed(status)
}
return output
}
func hex(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
let salt = Array("salt".utf8)
let vectors: [(UInt32, String)] = [
(1, "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"),
(2, "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43")
]
for (rounds, expected) in vectors {
let actual = try derive(
password: "password",
salt: salt,
rounds: rounds
)
precondition(hex(actual) == expected)
}
let full = try derive(password: "ab\u{0000}cd", salt: salt, rounds: 2)
let prefix = try derive(password: "ab", salt: salt, rounds: 2)
precondition(full != prefix)
let composed = try derive(password: "\u{00e9}", salt: salt, rounds: 2)
let decomposed = try derive(password: "e\u{0301}", salt: salt, rounds: 2)
precondition(composed != decomposed)
執行 xcrun swift pbkdf2-check.swift,結束代碼為零就表示這些斷言皆已通過。這裡採用直譯執行,不要改用會停用斷言的最佳化建置。
程式碼明確傳入密碼的位元組長度,排除結尾的終止字元,但保留中間的空字元。若改用遇到空字元就判定字串結束的介面,密碼後半段便會遭到截斷。
接入業務封裝
指令稿通過後,將相同樣本加入 iOS 的 XCTest 目標,但要呼叫應用程式實際使用的衍生入口,不要只是複製一份測試專用實作。否則即使底層正確,上層仍可能錯誤解碼鹽值。
至少補齊四組檢查:
- 空密碼:依協定明確允許或拒絕,不能默默改成其他值。
- 中文、表情符號與內嵌空字元:核對兩端的輸入位元組與最終結果。
- 零次迭代、損壞的鹽值編碼、未知的演算法識別碼:在呼叫衍生函式前拒絕。
- 相同密碼搭配不同鹽值:以固定樣本檢查結果是否不同,避免誤用固定鹽值。
範例函式拒絕空鹽值,是本專案的輸入政策,並不是說演算法無法處理空鹽值。正式環境的鹽值應使用系統安全亂數來源產生;亂數來源失敗時必須回傳錯誤,不能退而改用時間戳記。
區分測試層級
Mac 指令稿驗證演算法的串接,模擬器測試驗證應用程式封裝,實機測試則量測耗時與對介面的影響。三個層級都應記錄相同的輸入契約,但不要將 Mac 的速度視為手機的速度。
儲存更新參數
衍生紀錄至少應儲存格式版本、演算法識別碼、迭代次數、鹽值與輸出長度。涉及文字編碼時,也要寫清楚格式,例如鹽值採用哪一種 Base64 規則。鹽值不是密碼,安全性不應仰賴隱藏鹽值。
讀取紀錄時,先驗證欄位型別、長度與運算預算,再執行衍生。不能任由損壞的檔案提供極大的迭代次數,讓應用程式長時間占用 CPU。工作因子應依安全需求與支援裝置的實測結果訂定,不能直接照抄測試向量。
更新參數時,不要覆寫全域常數後就直接讀取舊紀錄。驗證舊紀錄必須使用舊參數;成功取得所需明文後,才能使用新鹽值與新政策產生新版紀錄。
若衍生結果用於解密,僅憑舊密文無法直接換成新參數。應先成功解密,再重新加密,並在新紀錄完整寫入且驗證完成前保留舊紀錄。若儲存的是密碼驗證值,比較時應使用經過審查的恆定時間比較實作,不要照搬範例中的一般陣列比較。
建立合併檢查
在持續整合中保留兩項獨立檢查:標準向量必須逐位元組一致,業務紀錄必須能讀取舊版本並拒絕非法參數。另外加入一份測試專用的舊紀錄,驗證更新後仍能開啟,且新寫入的紀錄確實使用新版本。
失敗報告只保留測試案例名稱、演算法與非敏感參數,不上傳密碼、衍生金鑰或真實使用者紀錄。效能量測應安排在受控的實機任務中,不要以共用執行環境的單次耗時阻擋合併。
最後檢查遷移失敗的處理路徑:取消、寫入磁碟失敗或應用程式結束後,舊紀錄仍應可讀。如此驗收的就不只是一個密碼函式,而是一條不會因參數更新而遺失資料的完整流程。
常見問題
範例的一次、兩次迭代可以用在正式環境嗎?
不可以。這些次數只用於已知答案測試,方便找出演算法或參數傳遞錯誤。正式環境的工作因子應依安全策略與目標裝置實測決定,且必須限制不可信參數所要求的計算量。
在雲端 Mac 通過測試,就算完成 iOS 驗收嗎?
還不算。Mac 程式可快速檢查演算法與位元組契約;仍需在 iOS 測試目標呼叫實際業務封裝,並在目標實機量測衍生耗時與操作影響。