雲端 Mac 上的 iOS TOTP 動態密碼驗收
為 iOS 專案接入動態密碼後,最難重現的錯誤往往是「剛才還能用,現在卻對不上」。在 Windows 上開發跨平台介面,再到雲端 Mac 驗收原生模組時,別靠反覆輸入密碼碰運氣。把時間當成輸入,以標準測試向量作為判準,才能分辨問題出在演算法、顯示邏輯,還是伺服器的時間容許範圍。
釐清契約
TOTP 會將 Unix 時間轉成遞增計數器,再透過 HMAC 產生固定位數的數字。首先要確定四項參數:密鑰原始位元組、雜湊演算法、時間步長,以及輸出位數。本文採用 RFC 6238 的 SHA-1 測試設定:起點為零、步長三十秒、輸出八位。
這裡使用 SHA-1,是為了核對標準相容性測試向量,並不是建議在新協定中隨意選用演算法。若雙方約定使用其他雜湊演算法,就必須一併更換對應的測試向量,不能只修改用戶端。
設定頁面常見的 Base32 密鑰文字,並不是 HMAC 的直接輸入。應先依約定解碼,再傳入原始位元組;別把文字的 UTF-8 位元組誤當成解碼結果。
下文的密鑰是公開測試資料,僅供迴歸測試使用。真實密鑰、QR Code 內容與動態密碼都不應寫入建置日誌。
實作計算邏輯
在 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 秒數明確傳入產生函式,搭配固定金鑰及標準向量,即可離線涵蓋一般輸入與時間窗邊界。
用戶端產生正確,就代表認證安全嗎?
不是。伺服器仍須限制嘗試次數、明訂可接受的時間偏差,並依憑證記錄已接受的時間步,防止有效時間窗內重複使用。