雲端 Mac 上驗證 iOS 密碼衍生:PBKDF2 向量與參數更新

雲端 Mac 上驗證 iOS 密碼衍生:PBKDF2 向量與參數更新

同一組密碼在 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 測試目標呼叫實際業務封裝,並在目標實機量測衍生耗時與操作影響。

租用雲端 Mac

可按天、週、月或季租用專屬實體主機。依專案安排建置,租期結束後不續租,服務即停止。

選擇機型

開始安排建置

選好機型與租用週期,再確認到期日。租期結束後不續租,服務即停止。