同一个密码在 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 测试目标调用实际业务封装,并在目标真机上测量派生耗时与交互影响。