云端 Mac 上的 iOS 加密记录验收:篡改与截断测试

云端 Mac 上的 iOS 加密记录验收:篡改与截断测试

离线笔记保存时加密、读取时解密,正常流程很容易通过;真正危险的是文件少了一截、密钥拿错,应用却把错误吞掉,再保存一份空记录。在云端 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 目标上检查。

认证解密失败后可以返回空数据吗?

不应把认证失败转换成成功的空数据。应保留原记录并向调用方返回明确失败,否则会混淆合法空内容与损坏记录。

为下一次构建订一台云端 Mac

选好机型、城市和租期,使用独享物理节点运行自己的开发工具。可用状态以控制台实时返回为准。

把阅读变成下一次构建

先核对机型与租期,再安排项目。