Проверка подписей запросов iOS на облачном Mac: HMAC и защита от повтора

Проверка подписей запросов iOS на облачном Mac: HMAC и защита от повтора

Проверка подписей запросов iOS на облачном Mac: HMAC и защита от повтора

После подключения подписи запросов в кроссплатформенном приложении, разрабатываемом на Windows, обычные запросы проходят, а запросы с китайскими символами в строке запроса или с JSON в теле — нет. Проще локализовать проблему, если сначала отдельно запустить модуль подписи на облачном Mac, проверить правила формирования байтов и лишь затем вернуть его в iOS-проект, а не раз за разом вызывать API через удалённый рабочий стол.

Ниже мы создадим автономный проверочный скрипт на Swift с CryptoKit. Для него не нужны ни работающий API, ни производственные ключи, ни сертификаты подписи. Этот набор тестовых фикстур следует хранить и на стороне клиента, и на стороне сервера.

Сначала определите контракт

HMAC-SHA256 обрабатывает байты, а не запросы, которые «выглядят одинаково». Сначала согласуйте шесть полей: версию протокола, метод, цель запроса, временную метку, одноразовое значение nonce и SHA-256-хеш тела.

В этом примере метод всегда записывается в верхнем регистре. Цель запроса включает путь и исходную строку запроса, но не фрагмент. Временная метка задаётся десятичным целым числом секунд, а nonce генерируется заново для каждого запроса. Хеш тела представляется шестнадцатеричной строкой в нижнем регистре.

Не сортируйте параметры запроса по собственной инициативе. Только протокол API должен определять, считаются ли эквивалентными разные представления повторяющихся ключей, пустых значений, процентного кодирования и порядка параметров. Клиент должен вычислять подпись после окончательного кодирования, а сервер — получать согласованное представление до того, как фреймворк изменит путь или параметры запроса.

HMAC подтверждает соответствие сообщения ключу, но не шифрует тело и сам по себе не предотвращает повторное воспроизведение запросов. Он не заменяет шифрование при передаче и тем более не превращает общий секрет, встроенный в пакет приложения, в надёжное средство подтверждения подлинности.

Зафиксируйте байты

Не соединяйте поля простым переводом строки: если перевод строки встретится внутри поля, представление может стать неоднозначным. В этом примере перед каждым полем в UTF-8 записывается его длина — восьмибайтовое беззнаковое число в порядке big-endian, — а затем содержимое поля. Сервер должен реализовать в точности те же правила.

Сохраните следующий код в request-signing.swift. Сначала выполните на облачном Mac команду xcrun swift --version, чтобы проверить набор инструментов, а затем запустите xcrun swift request-signing.swift. При успешной проверке скрипт завершится штатно, а при нарушении проверяемого условия вернёт ненулевой код завершения.

import Foundation
import CryptoKit

func hex<S: Sequence>(_ bytes: S) -> String
where S.Element == UInt8 {
    bytes.map { String(format: "%02x", $0) }.joined()
}

func frame(_ fields: [String]) -> Data {
    var output = Data()
    for field in fields {
        let bytes = Data(field.utf8)
        var length = UInt64(bytes.count).bigEndian
        withUnsafeBytes(of: &length) {
            output.append(contentsOf: $0)
        }
        output.append(bytes)
    }
    return output
}

func canonical(
    target: String,
    body: Data,
    timestamp: String,
    nonce: String
) -> Data {
    frame([
        "request-v1",
        "POST",
        target,
        timestamp,
        nonce,
        hex(SHA256.hash(data: body))
    ])
}

let vectorKey = SymmetricKey(
    data: Data(repeating: 0x0b, count: 20)
)
let vector = HMAC<SHA256>.authenticationCode(
    for: Data("Hi There".utf8),
    using: vectorKey
)
precondition(
    hex(vector) ==
    "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
)

let key = SymmetricKey(data: Data(repeating: 0x42, count: 32))
let body = Data(#"{"count":1}"#.utf8)
let target = "/v1/orders?a=1&b=2"
let timestamp = "1700000000"
let nonce = "fixture-nonce-001"

let message = canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: nonce
)
let tag = Data(HMAC<SHA256>.authenticationCode(
    for: message, using: key
))

func accepts(_ candidate: Data) -> Bool {
    HMAC<SHA256>.isValidAuthenticationCode(
        tag, authenticating: candidate, using: key
    )
}

precondition(accepts(message))
precondition(!accepts(canonical(
    target: target, body: Data(#"{"count":2}"#.utf8),
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: "/v1/orders?b=2&a=1", body: body,
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: "fixture-nonce-002"
)))

Фиксированные ключ, временная метка и nonce предназначены только для тестовых фикстур. Стандартный тестовый вектор проверяет правильность вызова криптографического алгоритма, а последующие проверки — то, что поля протокола действительно участвуют в вычислении подписи. Эти два вида тестов не заменяют друг друга.

Учтите различия

При интеграции в приложение формируйте окончательное тело в виде Data только один раз и передавайте одни и те же данные как для вычисления хеша, так и для сетевого запроса. Не выполняйте повторное JSON-кодирование после вычисления подписи и не допускайте незаметной подмены тела промежуточными обработчиками.

Проверьте кодирование

Добавьте три группы фикстур: запросы с китайскими символами и эмодзи в параметрах, запросы с повторяющимися ключами параметров, а также пустое тело и пустой JSON-объект. Для каждой группы сохраните содержимое полей, шестнадцатеричное представление канонических байтов и ожидаемую подпись. Клиент и сервер должны независимо читать эти данные; генерировать ожидаемый результат проверяемой функцией во время теста нельзя.

Чётко различайте пустую строку, отсутствующее поле и строку "null". Если протокол не допускает определённый ввод, отклоняйте его до вычисления HMAC, а не подставляйте незаметно значение по умолчанию.

Проверьте передачу

Обратный прокси может переписать префикс пути, а фреймворк — декодировать %20 и знак плюса в один и тот же пробел. Необходимо явно определить, что именно подписывается: внешняя цель запроса или внутренний маршрут, — и получать байты на соответствующей границе обработки.

При диагностике записывайте в журнал только тестовые фикстуры или обезличенные хеши. Тела производственных запросов, заголовки аутентификации и ключи не должны попадать в журналы сборки. Ключи также нельзя передавать через аргументы командной строки.

Блокируйте повторные запросы

Даже после успешной проверки подписи сервер должен проверить формат временной метки и допустимое отклонение времени. Длительность допустимого окна определяется требованиями системы. Тесты должны охватывать как минимум четыре случая: время внутри окна, точно на его границе, сразу за границей и время в будущем.

Затем выполните атомарную запись, используя пару «идентификатор учётных данных + nonce» как ключ дедупликации, и храните её до тех пор, пока запрос уже не сможет пройти проверку времени. Схема «сначала проверить наличие, затем вставить» создаёт состояние гонки: два параллельных запроса могут пройти одновременно. Поэтому необходимы атомарная условная запись или ограничение уникальности.

Старую временную метку из автономного скрипта нельзя использовать для проверки работающего сервиса. Онлайн-запросы должны брать текущее время и генерировать новый nonce. Нужно ли при повторной попытке использовать тот же ключ идемпотентности бизнес-операции, следует оговорить отдельно: дедупликация по nonce не обеспечивает идемпотентность создания заказа.

Включите проверки в обязательный контроль

Сначала включите скрипт в проверки перед коммитом, затем подключите те же фикстуры к серверным тестам. Помимо изменения тела, добавьте сценарии, в которых должны отклоняться запросы с неверным ключом, усечённой подписью и недопустимым кодированием подписи. Принимающая сторона должна использовать функцию проверки из криптографической библиотеки, а не обычное сравнение строк.

В завершение отправьте параллельно два полностью одинаковых корректных запроса и убедитесь, что до бизнес-обработки доходит только один. Затем смоделируйте допустимую повторную попытку в пределах временного окна и проверьте, что она возвращает согласованный идемпотентный результат.

Достаточно сохранять версию набора инструментов, версию фикстур и номера неуспешных проверок. Критерий успешной приёмки — не «один запрос прошёл», а то, что обе стороны получают одинаковую подпись для одного и того же набора байтов и согласованно, воспроизводимо отклоняют недопустимые входные данные и повторные запросы.

Часто задаваемые вопросы

Защищает ли корректный HMAC от повторной отправки?

Нет. Неизменённый подписанный запрос можно отправить снова. Сервер должен проверить временное окно и атомарно исключить повтор пары идентификатора учётных данных и nonce. Для бизнес-повторов нужен отдельный ключ идемпотентности.

Нужно ли заново кодировать JSON перед подписанием?

В этой схеме — нет. Клиент хеширует фактически отправляемые байты тела, сервер — полученное необработанное тело. Повторное кодирование может изменить порядок ключей, пробелы и формат чисел.

Арендуйте облачный Mac

Аренда выделенного физического Mac на день, неделю, месяц или квартал. Планируйте сборки под свои проекты — по окончании срока аренды компьютер отключится.

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

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

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