クラウド Mac で iOS の TOTP 生成を検証する
iOS プロジェクトにワンタイムパスワードを組み込むと、再現が難しいのは「さっきは使えたのに、今は一致しない」という不具合です。Windows でクロスプラットフォーム UI を開発し、クラウド Mac でネイティブモジュールの動作を確認する場合も、何度もパスワードを入力して偶然うまくいくのを待つべきではありません。時刻を入力値として扱い、標準テストベクトルを判定基準にすることで、アルゴリズムの誤り、表示の不具合、サーバー側の時刻許容範囲を切り分けられます。
仕様上の取り決めを明確にする
TOTP は Unix 時刻を単調増加するカウンターに変換し、HMAC を使って固定桁数の数字列を生成します。まず、秘密鍵の生バイト列、ハッシュアルゴリズム、タイムステップ、出力桁数の四つを確定します。この記事では RFC 6238 の SHA-1 テスト設定を使い、起点をゼロ、タイムステップを三十秒、出力を八桁とします。
ここで SHA-1 を使うのは、標準の互換性テストベクトルと照合するためです。新しいプロトコルで安易にこのアルゴリズムを選ぶことを勧めるものではありません。クライアントとサーバーで別のハッシュアルゴリズムを採用する場合は、対応するテストベクトルも合わせて変更する必要があります。クライアントだけを変更してはいけません。
設定画面でよく使われる Base32 形式の秘密鍵テキストは、そのまま HMAC に渡す入力ではありません。取り決めに従ってデコードし、得られた生バイト列を渡します。テキストの UTF-8 バイト列を、デコード結果と取り違えないでください。
以下の秘密鍵は公開されたテストデータであり、回帰テスト専用です。実際の秘密鍵、QR コードの内容、ワンタイムパスワードをビルドログに出力してはいけません。
生成処理を実装する
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 {
}
同様に、空の秘密鍵、ゼロのタイムステップ、未対応の桁数を拒否するテストも追加します。異なるタイムステップで偶然同じパスワードになることもあるため、「時間窓をまたげば必ず異なる」という条件を汎用的なアサーションにしてはいけません。上の例のように、既知の結果と比較してください。
アプリがミリ秒単位のタイムスタンプを受け取る場合は、アダプター層で明示的に一度だけ変換します。千で繰り返し割った場合も、まったく変換しなかった場合も、見た目は正しいのに認証できない数字列が生成されます。
回帰テストに組み込む
生成関数は、テストターゲットから呼び出せるモジュールに配置します。現在時刻の取得と引き渡しは UI 層の責務とし、関数の内部で時計を直接読み取らないようにします。アプリがバックグラウンドから復帰したときは時刻を取得し直し、実行されなかったタイマーコールバックを前提にカウントダウンを減らし続けてはいけません。
シミュレーターでは、桁数、先頭ゼロ、時間窓が切り替わる際の表示を確認できます。サーバー側では別途、誤ったパスワード、許容範囲外の時刻、同じパスワードの再送を検証する必要があります。隣接するタイムステップを許容するのは時計のずれを吸収するためであり、パスワードを無制限に再利用してよいという意味ではありません。
リリース前には、標準テストベクトルの結果、パラメーター拒否の結果、サーバー側のリプレイ拒否の結果という三種類の証跡を残します。記録するのはテストケース名と合否だけにし、実際の認証情報は保存しません。こうしておけば、クラウド Mac ワークステーションの変更やツールチェーンの更新後も、同じチェックをオフラインで再実行でき、散発的な認証障害を明確な仕様上の不一致として切り分けられます。
よくある質問
TOTP の回帰テストに通信や実時間の待機は必要ですか?
不要です。Unix 秒を生成関数の引数にし、固定鍵と標準ベクトルを渡せば、通常ケースも時間窓の境界もオフラインで検証できます。
生成結果が正しければ認証も安全ですか?
いいえ。サーバー側で試行回数と許容時刻差を制限し、認証情報ごとに受理済みの時間ステップを記録して再利用を防ぐ必要があります。