离线笔记保存时加密、读取时解密,正常流程很容易通过;真正危险的是文件少了一截、密钥拿错,应用却把错误吞掉,再保存一份空记录。在云端 Mac 上,把这段逻辑抽成独立 Swift Package,可以在接入界面前验证失败行为。本文只检查加密记录的完整性,不涉及密钥持久化或账号体系。
先定义记录与失败契约
使用 CryptoKit 的 AES-GCM,记录保存为 SealedBox.combined 返回的字节。默认生成的 nonce、密文和认证标签一起存储,读取时必须完整还原。不要自行截取标签,也不要把 nonce 当成秘密。
同一密钥下不能重复使用 nonce。日常调用让 AES.GCM.seal 自动生成即可;不要从文件名、当前秒数或记录序号推导它。密钥在测试内临时生成,不进入仓库与测试报告。
解密成功才允许把内容交给业务层。认证失败不等于空内容,也不能触发自动覆盖原文件。
如果业务还需要记录编号等外部字段,可将其规范编码后作为附加认证数据。加密与解密必须提供相同字节;本文先验证不带外部字段的最小记录。
建立可运行的测试包
在装有 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 目标上检查。
认证解密失败后可以返回空数据吗?
不应把认证失败转换成成功的空数据。应保留原记录并向调用方返回明确失败,否则会混淆合法空内容与损坏记录。