给 iOS 项目接入动态口令后,最难复现的错误往往是“刚才还能用,现在不一致”。在 Windows 上开发跨平台界面、再到云端 Mac 验收原生模块时,不要靠反复输入口令碰运气。把时间作为输入,把标准向量作为裁判,才能区分算法错误、显示错误与服务端时间容差。
拆清契约
TOTP 把 Unix 时间换成递增计数器,再通过 HMAC 生成定长数字。先固定四项参数:密钥原始字节、摘要算法、时间步长、输出位数。本文采用 RFC 6238 的 SHA-1 测试配置:起点为零、步长三十秒、输出八位。
这里使用 SHA-1 是为了核对标准兼容向量,并非建议给新协议随意选择算法。双方若约定其他摘要算法,必须连同对应测试向量一起更换,不能只改客户端。
配置页面常见的 Base32 密钥文本不是 HMAC 的直接输入。应先按约定解码,再传入原始字节;不要把文本的 UTF-8 字节误当成解码结果。
下文密钥是公开测试数据,只能用于回归测试。真实密钥、二维码内容和动态口令都不应写入构建日志。
实现计算
在 XcodeHost 云端 Mac 的项目目录保存 totp-check.swift。先确认 xcrun swift --version 可执行,再加入以下代码。它只依赖系统 SDK,不需要下载第三方包。
计数器必须编码为八字节大端序。动态截断取最后一个摘要字节的低四位作为偏移,再清除结果的最高位,最后按位数取模。返回值必须是字符串,否则前导零会丢失。
import Foundation
import CryptoKit
enum TOTPError: Error {
case invalidInput
}
func totp(
secret: Data,
unixSeconds: Int64,
digits: Int = 8,
period: Int64 = 30
) throws -> String {
guard !secret.isEmpty,
unixSeconds >= 0,
period > 0,
digits == 6 || digits == 8 else {
throw TOTPError.invalidInput
}
let counter = UInt64(unixSeconds / period)
let message = Data((0..<8).map { index in
UInt8(truncatingIfNeeded: counter >> ((7 - index) * 8))
})
let digest = Array(HMAC<Insecure.SHA1>.authenticationCode(
for: message,
using: SymmetricKey(data: secret)
))
let offset = Int(digest[digest.count - 1] & 0x0f)
let binary =
(UInt32(digest[offset] & 0x7f) << 24) |
(UInt32(digest[offset + 1]) << 16) |
(UInt32(digest[offset + 2]) << 8) |
UInt32(digest[offset + 3])
let modulus: UInt32 = digits == 6 ? 1_000_000 : 100_000_000
let text = String(binary % modulus)
return String(repeating: "0", count: digits - text.count) + text
}
不要用本地日期字符串、时区偏移或界面倒计时推导计数器。协议需要的是 Unix 秒数,倒计时只是显示结果;修改显示时区不应改变同一时刻的口令。
跑通向量
继续在同一文件末尾添加六组标准向量。这里的二十字节 ASCII 密钥是标准明确给出的原始字节,不经过 Base32 解码。大时间戳还能发现实现偷偷使用窄整数的问题。
let secret = Data("12345678901234567890".utf8)
let vectors: [(Int64, String)] = [
(59, "94287082"),
(1111111109, "07081804"),
(1111111111, "14050471"),
(1234567890, "89005924"),
(2000000000, "69279037"),
(20000000000, "65353130")
]
for (time, expected) in vectors {
let actual = try totp(secret: secret, unixSeconds: time)
precondition(actual == expected, "TOTP vector mismatch")
}
使用 xcrun swift totp-check.swift 执行,成功时没有输出,断言失败则返回非零状态。不要加会禁用检查的优化选项。接入应用测试目标后,将这些断言迁成 XCTAssertEqual,让每个失败样本独立报告。
六组全部失败,先查密钥和字节序;只有带零的样本失败,先查数字转字符串;只有大时间戳失败,先查整数宽度,而不是扩大认证容差。
卡住边界
标准向量通过后,还要覆盖取整与参数拒绝。下面验证五十九秒仍在第二个时间步,而六十秒已经切换;不需要休眠,也不需要改机器时钟。
let at30 = try totp(secret: secret, unixSeconds: 30)
let at59 = try totp(secret: secret, unixSeconds: 59)
let at60 = try totp(secret: secret, unixSeconds: 60)
precondition(at30 == "94287082")
precondition(at59 == at30)
precondition(at60 == "37359152")
do {
_ = try totp(secret: secret, unixSeconds: -1)
preconditionFailure("Negative time accepted")
} catch TOTPError.invalidInput {
}
同样补上空密钥、零步长与不支持位数的拒绝测试。不同时间步的口令可能偶然相同,所以不要把“跨窗口必定不同”当成通用断言,应像上面一样比较已知结果。
如果应用接收毫秒时间戳,在适配层明确换算一次。重复除以一千和完全不换算,都会得到形式正确但无法认证的数字。
接入回归
把生成函数放进可被测试目标调用的模块。界面层负责获取当前时间并传入,不让函数内部直接读取时钟。应用进入后台再恢复时,应重新读取时间,不能靠遗漏的定时器回调继续递减倒计时。
模拟器可以验收位数、前导零和窗口切换显示;服务端验收必须另外覆盖错误口令、超出容差和重复提交。允许相邻时间步只是兼容时钟偏差,不代表口令可以无限重用。
上线前留下三类证据:标准向量结果、参数拒绝结果、服务端重放拒绝结果。只记录用例名称与通过状态,不保存真实认证材料。这样再次换云端 Mac 工作站或升级工具链时,仍能离线重跑相同检查,把偶发认证故障收敛成明确的契约差异。
常见问题
TOTP 回归测试需要访问网络或等待真实时间吗?
不需要。把 Unix 秒数作为显式参数传入生成函数,使用固定密钥和标准向量,即可离线覆盖正常值与窗口边界。
客户端生成正确,是否就代表动态口令认证安全?
不是。服务端仍需限制尝试次数、明确允许的时间偏差,并按凭据记录已经接受的时间步,阻止同一口令在有效窗口内重复使用。