Проверка TOTP в iOS на облачном Mac

Проверка TOTP в iOS на облачном Mac

Проверка 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 на день, неделю, месяц или квартал. Планируйте сборки под свои проекты — по окончании срока аренды компьютер отключится.

Выбрать модель

Начните планировать сборку

Выберите модель и срок аренды, проверьте дату окончания. По окончании срока аренды компьютер отключится.