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