雲端 Mac 上的 iOS 加密紀錄驗收:竄改與截斷測試
離線筆記在儲存時加密、讀取時解密,正常流程很容易通過測試;真正危險的是檔案遭到截斷、使用了錯誤金鑰,應用程式卻忽略錯誤,接著儲存一份空紀錄。在雲端 Mac 上,將這段邏輯抽成獨立的 Swift Package,就能在整合使用者介面前驗證失敗時的行為。本文只檢查加密紀錄的完整性,不涉及金鑰的持久化儲存或帳號系統。
先定義紀錄與失敗契約
使用 CryptoKit 的 AES-GCM,將紀錄儲存為 SealedBox.combined 回傳的位元組。預設產生的 nonce、密文與驗證標籤會一起儲存,讀取時必須完整還原。不要自行截取標籤,也不要將 nonce 視為秘密。
同一把金鑰下不能重複使用 nonce。一般呼叫讓 AES.GCM.seal 自動產生即可;不要從檔名、目前秒數或紀錄序號推導 nonce。測試中的金鑰應臨時產生,不納入版本庫或測試報告。
只有解密成功,才能將內容交給業務邏輯層。驗證失敗不等於內容為空,也不能觸發自動覆寫原始檔案。
如果業務邏輯還需要紀錄編號等外部欄位,可以先以標準化格式編碼,再作為附加驗證資料。加密與解密時必須提供完全相同的位元組;本文先驗證不含外部欄位的最小紀錄。
建立可執行的測試套件
在已安裝 Xcode 工具鏈的獨享實體節點上,以一般開發使用者身分執行以下命令。目錄應為新建的工作區,不要覆蓋既有專案。先確認 swift --version 能正常回傳,再進行初始化:
mkdir SecureRecord
cd SecureRecord
swift --version
swift package init --type library
將產生的 Tests/SecureRecordTests/SecureRecordTests.swift 替換成下一節的內容。原始碼檔案可以保留預設實作:這組測試直接呼叫系統的 CryptoKit,不需要下載第三方相依套件。
這裡選擇 XCTest,避免範例依賴新測試框架的特定語法。實際專案應將輔助函式移至正式模組,並在測試中匯入該模組,避免「測試實作正確,應用程式的實作卻不同」。
涵蓋正常紀錄與四種異常
以下是一份完整的測試檔案。正常流程先證明加解密前後的位元組一致,再分別檢查標籤遭修改、錯誤金鑰、輸入遭截斷,以及合法的空內容。每個測試案例都使用各自的金鑰,不共用可變狀態。
import Foundation
import CryptoKit
import XCTest
final class SecureRecordTests: XCTestCase {
private func encode(
_ data: Data,
key: SymmetricKey
) throws -> Data {
let box = try AES.GCM.seal(data, using: key)
return try XCTUnwrap(box.combined)
}
private func decode(
_ record: Data,
key: SymmetricKey
) throws -> Data {
let box = try AES.GCM.SealedBox(combined: record)
return try AES.GCM.open(box, using: key)
}
func testRoundTripAndFreshNonce() throws {
let key = SymmetricKey(size: .bits256)
let input = Data("draft-v1".utf8)
let first = try encode(input, key: key)
let second = try encode(input, key: key)
XCTAssertEqual(try decode(first, key: key), input)
XCTAssertEqual(try decode(second, key: key), input)
XCTAssertNotEqual(first, second)
}
func testChangedTagIsRejected() throws {
let key = SymmetricKey(size: .bits256)
var record = try encode(Data("draft-v1".utf8), key: key)
let last = record.index(before: record.endIndex)
record[last] ^= 0x01
XCTAssertThrowsError(try decode(record, key: key))
}
func testWrongKeyIsRejected() throws {
let key = SymmetricKey(size: .bits256)
let otherKey = SymmetricKey(size: .bits256)
let record = try encode(Data("draft-v1".utf8), key: key)
XCTAssertThrowsError(try decode(record, key: otherKey))
}
func testTruncatedRecordsAreRejected() throws {
let key = SymmetricKey(size: .bits256)
let record = try encode(Data("draft-v1".utf8), key: key)
for length in [0, 1, record.count - 1] {
let truncated = Data(record.prefix(length))
XCTAssertThrowsError(try decode(truncated, key: key))
}
}
func testEmptyPlaintextIsValid() throws {
let key = SymmetricKey(size: .bits256)
let record = try encode(Data(), key: key)
XCTAssertFalse(record.isEmpty)
XCTAssertEqual(try decode(record, key: key), Data())
}
}
執行 swift test,要求五個測試全部通過。空明文仍會產生非空紀錄,因為封裝內容還包含 nonce 與標籤。不要以「檔案長度大於零」取代帶有驗證的解密。
斷言兩次密文不同,是為了找出實作意外改用固定 nonce 的回歸問題,並非證明亂數品質。測試也不比對完整密文快照,否則正常的隨機性反而會被判定為失敗。
將演算法失敗連結到應用程式行為
驗證失敗的測試通過,只能證明底層拒絕了異常位元組。還要為呼叫端補上驗收:讀取失敗後顯示可復原的錯誤、保留原始檔案,並禁止背景自動儲存空模型。如果有上一版副本,應透過明確的復原流程選用,而不是悄悄切換。
分別驗證格式與業務資料
完整紀錄成功解密後,業務資料的解碼仍可能失敗。將格式版本檢查、驗證解密與內容解析分成三個步驟,各自回傳可區分的內部錯誤。對使用者顯示簡短訊息即可;日誌只記錄階段與錯誤類別,不輸出明文或金鑰。
也要限制輸入大小:先檢查檔案長度,再載入與解密,避免異常大的檔案耗盡記憶體。上限應依業務允許的紀錄容量設定,不要直接套用任意數字。新增格式版本時,同時保留合法舊版與明確不支援版本的樣本。
留下可重複執行的驗收證據
提交測試檔案、格式說明與執行命令。每次執行都記錄提交識別碼、Swift 版本、測試數量與結束狀態,不上傳實際使用者紀錄。開發階段可以增加逐一修改位元組的測試案例,但隨機變異測試失敗時,必須保留變異位置與輸入長度。
最後再補一次 iOS 整合檢查:將應用程式實際寫出的紀錄交給同一個解碼函式,再驗證讀取損壞檔案時不會覆寫原始內容。雲端 Mac 適合執行共用加密核心的快速測試;檔案存取與介面復原則必須在應用程式目標中單獨驗收。以「異常遭到拒絕、原始資料仍在、使用者能夠復原」作為最終標準,而不是只確認一次加解密成功。
常見問題
這些測試一定要在 iOS 模擬器執行嗎?
純 CryptoKit 加解密邏輯可先在雲端 Mac 透過 swift test 驗收;應用程式的檔案讀取、錯誤提示與平台整合仍須在 iOS 目標檢查。
驗證解密失敗後可以回傳空資料嗎?
不應將驗證失敗轉成成功的空資料。應保留原始紀錄並向呼叫端回傳明確失敗,否則會混淆合法空內容與損壞紀錄。