Проверка TOTP в iOS на облачном Mac
После внедрения одноразовых паролей в iOS-проект сложнее всего воспроизвести ошибки вида «только что работало, а теперь код не совпадает». Если вы разрабатываете кроссплатформенный интерфейс на Windows, а нативные модули проверяете на облачном Mac, не стоит надеяться на удачу и вводить коды снова и снова. Передавайте время как входной параметр и сверяйте результат с эталонными векторами — так можно отделить ошибки алгоритма от ошибок отображения и проблем с допустимым расхождением времени на сервере.
Зафиксируйте контракт
TOTP преобразует Unix-время в возрастающий счётчик, а затем с помощью HMAC формирует цифровой код фиксированной длины. Сначала зафиксируйте четыре параметра: исходные байты ключа, алгоритм хеширования, длительность временного шага и количество цифр в коде. В этой статье используется тестовая конфигурация SHA-1 из RFC 6238: нулевое начало отсчёта, шаг тридцать секунд и восьмизначный код.
SHA-1 здесь нужен для проверки совместимости по стандартным векторам, а не как рекомендация произвольно выбирать алгоритм для нового протокола. Если стороны согласовали другой алгоритм хеширования, вместе с ним нужно заменить и тестовые векторы. Одних изменений на клиенте недостаточно.
Текстовый ключ в Base32, который часто встречается на странице настройки, нельзя напрямую передавать в HMAC. Сначала декодируйте его по согласованным правилам и передайте полученные исходные байты. Не путайте их с UTF-8-байтами самого текста.
Приведённый ниже ключ — общедоступные тестовые данные, пригодные только для регрессионных проверок. Настоящие ключи, содержимое QR-кодов и одноразовые пароли не должны попадать в журналы сборки.
Реализуйте генерацию кода
Сохраните файл totp-check.swift в каталоге проекта на облачном Mac XcodeHost. Сначала убедитесь, что команда xcrun swift --version выполняется, затем добавьте следующий код. Он использует только системный SDK и не требует загрузки сторонних пакетов.
Счётчик необходимо кодировать восемью байтами в порядке от старшего к младшему — big-endian. При динамическом усечении младшие четыре бита последнего байта хеша задают смещение. Затем старший бит полученного числа сбрасывается, а результат берётся по модулю, соответствующему количеству цифр. Возвращать нужно строку, иначе ведущие нули потеряются.
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
Аренда выделенного физического Mac на день, неделю, месяц или квартал. Планируйте сборки под свои проекты — по окончании срока аренды компьютер отключится.
Выбрать модель