雲端 Mac 上的 iOS TOTP 動態密碼驗收

雲端 Mac 上的 iOS TOTP 動態密碼驗收

雲端 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 秒數明確傳入產生函式,搭配固定金鑰及標準向量,即可離線涵蓋一般輸入與時間窗邊界。

用戶端產生正確,就代表認證安全嗎?

不是。伺服器仍須限制嘗試次數、明訂可接受的時間偏差,並依憑證記錄已接受的時間步,防止有效時間窗內重複使用。

租用雲端 Mac

可按天、週、月或季租用專屬實體主機。依專案安排建置,租期結束後不續租,服務即停止。

選擇機型

開始安排建置

選好機型與租用週期,再確認到期日。租期結束後不續租,服務即停止。