1. Сначала — в одну строку
HMAC — это общий секретный ключ, который используется, когда сервер выпускает ссылку-приглашение и проверяет её. Фронтенд не вычисляет подпись, и ключ не меняется для каждой ссылки. Для каждой среды используется один ключ, а для разных ссылок отличается полезная нагрузка, содержащая идентификатор пула и время истечения срока действия.
В этой статье описывается, почему при добавлении сроков действия к ссылкам-приглашениям в рабочий пул мы выбрали токены с подписью HMAC-SHA256, что я изначально понял неправильно, как мы фактически интегрировали код и разделили настройки развёртывания, а также приводятся практические замечания, которые помогут не повторять те же ошибки. Вместо теоретических объяснений статья сосредоточена на том, почему в тот момент мы приняли именно такие решения и что в них было неправильным.
2. Почему мы начали использовать эту технологию
2.1 Требования к продукту
Руководители команд используют общие ссылки, приглашая людей в рабочий пул. Существующие ссылки содержали в запросе только идентификатор пула. Когда сотрудник, получивший ссылку, входил в приложение, для него создавалось приглашение в этот пул. Проблема заключалась в отсутствии срока действия. После распространения ссылку можно было открывать снова и снова в течение неограниченного времени, а теоретически любой, кто знал URL, мог изменить идентификатор пула и указать другой пул.
Команда продукта запросила создание новой ссылки при каждом открытии листа приглашения с возможностью совместного использования этой ссылки несколькими сотрудниками. Ссылка должна была иметь срок действия, этот срок нельзя было скрывать на клиенте, а сервер должен был отклонять ссылку во время проверки. Было недостаточно просто скрывать просроченные приглашения на экране списка.
2.2 Почему мы выбрали этот вариант после рассмотрения альтернатив
Сначала мы сопоставили три варианта.
-
Оставить существующую общую ссылку без изменений и написать только уведомление: «Пожалуйста, перейдите по ссылке в течение нескольких дней».
-
Поместить идентификатор пула и время истечения срока действия в URL, используя только Base64. Без подписи.
-
Использовать идентификатор пула и время истечения срока действия как полезную нагрузку, добавить тег HMAC-SHA256 с использованием секретного ключа сервера и объединить их в один токен.
Первый вариант было проще всего реализовать. Однако требование заключалось в том, чтобы «проверять срок действия», поэтому одного уведомления было недостаточно. При втором варианте декодирование URL позволило бы кому-либо продлить срок действия или изменить идентификатор пула. Это была бы структура, которую мог изменить любой, кто знал принцип её работы. При третьем варианте только сервер с ключом мог создать такой же тег, поэтому при изменении URL проверка завершалась бы ошибкой. Этот вариант также соответствовал модели, в которой ссылкой пользуются несколько человек. Файлы cookie сеанса или одноразовые коды входа не подходили для общих ссылок.
Поэтому мы выбрали токен с подписью HMAC. Мы не использовали JWT, поскольку этому токену нужно было содержать лишь короткое утверждение: «это приглашение действительно для данного пула до указанного времени», — а не идентифицировать субъекта входа. При наличии всего двух утверждений вручную закодированная полезная нагрузка была проще для чтения.
3. Что я изначально думал и в чём ошибался
Между знанием технологии по названию и её интеграцией через процесс развёртывания оказался большой разрыв. Ниже перечислены мои тогдашние заблуждения.
3.1 Нам нужно создать новый общий HmacUtil
Сначала я думал, что мы можем просто добавить ещё одну общую утилиту, использующую стандартный Base64. Изучив код, я обнаружил, что HMAC-SHA256 уже существовал в доменном сервисе, а URL-безопасный Base64 (без дополнения) уже использовался для размещения значения в URL. Символы +, / и = в стандартном Base64 могут нарушать строки запросов. Перед созданием новой утилиты нам сначала нужно было использовать уже принятую схему кодирования.
3.2 Приложение автоматически прочитает ключ, если в Secret указать только имя ключа
Я думал, что если создать имя вроде hmac-secret в объекте Kubernetes Secret, Spring автоматически его прочитает. На самом деле application yml считывает имя переменной окружения, и это имя должно быть передано в pod как переменная env. Если ключ Secret отличается от заполнителя в yml хотя бы одним символом, приложение получает либо значение по умолчанию, либо пустое значение. «Создать Secret» и «сделать так, чтобы pod получил переменную с этим именем» — разные вещи.
3.3 Фронтенд вычисляет подпись
Поскольку экран создания ссылки находился на фронтенде, я думал, что фронтенд будет использовать ключ для выполнения HMAC и отправлять результат в API. Это поместило бы секретный ключ в bundle. Фактический процесс устроен противоположным образом. Когда руководитель команды запрашивает ссылку-приглашение, сервер возвращает строку токена, а фронтенд лишь добавляет эту строку в запрос. Когда сотрудник переходит по ссылке, та же строка без изменений отправляется в API регистрации. Проверка также выполняется сервером.
3.4 Ключ HMAC отличается для каждой ссылки
Если бы ключ менялся для каждого приглашения, не существовало бы практического способа хранить ключи и управлять ими. Для каждой среды — разработки, staging и production — используется один ключ, а для каждой ссылки отличается полезная нагрузка. Разные идентификаторы пулов и разные сроки действия подписываются одним и тем же ключом.
3.5 В staging также требуется ConfigMap
Если в yml указано значение по умолчанию, среды разработки и staging могут работать только за счёт развёртывания. В production мы используем Secret, чтобы переопределить значение по умолчанию. Для одной лишь этой функции не было необходимости сначала запрашивать ConfigMap для staging. Однако production и development не должны использовать один и тот же ключ.
4. Что делает HMAC в данном случае
HMAC-SHA256 создаёт тег фиксированной длины из сообщения и секретного ключа. Только сторона, располагающая ключом, может воспроизвести такой же тег. Поэтому даже если кто-то продлит срок действия на день или изменит идентификатор пула в URL, сервер отклонит ссылку, если тег не совпадёт. Это не шифрование. Полезная нагрузка видна после декодирования. Эта технология не препятствует просмотру содержимого; она не позволяет произвольно изменять видимое содержимое.
Концептуально токен этого проекта состоит из двух частей: payload.signature. Полезная нагрузка — это строка, созданная путём кодирования идентификатора пула и времени истечения срока действия, а затем повторного кодирования результата с помощью URL-безопасного Base64; подпись — это строка, созданная путём кодирования результата HMAC-SHA256 для этой строки полезной нагрузки тем же способом. Во время проверки мы заново вычисляем подпись с использованием полученной полезной нагрузки и сравниваем её, а затем проверяем срок действия. Проверка подписи в первую очередь гарантирует, что мы никогда не доверяем поддельному сроку действия.
5. Процесс реализации
5.1 Имя, считываемое конфигурацией
Конфигурация Spring выглядит примерно следующим образом. Часть после двоеточия — это значение по умолчанию, используемое при отсутствии переменной окружения в pod. В production предполагается, что это значение по умолчанию не используется, а в pod передаётся переменная окружения с тем же именем.
banjang:
contact-pool:
invite:
hmac-secret: ${BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET:dev-only-not-for-prod}
expire-hours: ${BANJANG_CONTACT_POOL_INVITE_EXPIRE_HOURS:72}
Здесь я часто путал синтаксис ${name:default value}. Kubernetes не изменяет yml напрямую. При запуске приложения, если существует переменная окружения BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET, используется её значение; в противном случае используется значение после двоеточия. Имя ключа объекта Secret также должно совпадать с именем этой переменной окружения, чтобы упростить сопоставление.
5.2 Где сервер создаёт токен (концептуальный код)
Ниже приведён концептуальный код, предполагающий, что соответствующее состояние отсутствует в репозитории. Фактические имена классов и пакетов могут отличаться от используемых в коде команды. Важно, чтобы кодирование было URL-безопасным и выполнялось без дополнения, а объектом подписи была «уже закодированная строка полезной нагрузки».
public String issue(String poolId, Instant expiresAt) {
String payloadJson = "{"p":"" + poolId + "","e":" + expiresAt.getEpochSecond() + "}";
String payload = base64UrlNoPad(payloadJson.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = base64UrlNoPad(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
return payload + "." + signature;
}
private static String base64UrlNoPad(byte[] src) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(src);
}
5.3 Где сервер проверяет токен (концептуальный код)
public InvitePayload verify(String token) {
String[] parts = token.split("\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("malformed token");
}
String payload = parts[0];
String given = parts[1];
String expected = sign(payload); // 발급과 동일한 HMAC
if (!MessageDigest.isEqual(
given.getBytes(StandardCharsets.US_ASCII),
expected.getBytes(StandardCharsets.US_ASCII))) {
throw new IllegalArgumentException("bad signature");
}
InvitePayload body = decode(payload);
if (Instant.now().isAfter(body.expiresAt())) {
throw new IllegalArgumentException("expired");
}
return body;
}
Причина использования MessageDigest.isEqual вместо сравнения строк с помощью == заключается в стремлении предотвратить зависимость времени сравнения от одной из сторон, даже если длина или содержимое отличаются. Мы не заметили существенной практической разницы для ссылок-приглашений, но для сравнения подписей это правильный выбор. Мы проверяем срок действия после успешной проверки подписи. Если проверять только срок действия, не проверяя подпись, можно принять токен, срок действия которого был изменён.
5.4 Что делает фронтенд
Фронтенд не знает ключ. Когда руководитель команды нажимает «Создать ссылку» на экране руководителя команды, сервер предоставляет токен, а фронтенд лишь добавляет его как параметр запроса. Когда сотрудник переходит по глубокой ссылке, та же строка снова помещается в тело API регистрации.
// 반장: 서버가 준 토큰만 붙인다
const shareUrl = `${origin}/job/jobs/pool-invitations?token=${encodeURIComponent(token)}`;
// 일꾼: 받은 토큰 문자열을 그대로 등록에 낸다
await JobWkrFlowApi.registerContactPoolInvitation({ token });
Мы следовали решению продукта создавать новый токен при каждом открытии листа. Поскольку один и тот же токен могут использовать несколько сотрудников, он не является одноразовым. «Новый токен» по сути означает назначение нового срока действия; если предыдущий токен ещё не истёк, оба токена могут быть действительными. Стоит ещё раз подтвердить этот момент с командой продукта.
5.5 Как ключ фактически передаётся во время развёртывания
Функция работает в development и staging, используя значение по умолчанию из yml. В production мы помещаем значение в Secret под ключом BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET и передаём это же имя pod через env или envFrom в Deployment. Мы не помещаем секретный ключ в ConfigMap. ConfigMap часто управляются как обычный текст.
# 운영 Secret 값 만들기 (실값은 기록하지 않음)
openssl rand -base64 32
# 개념적인 매핑. 키 이름은 yml placeholder와 동일해야 한다
apiVersion: v1
kind: Secret
metadata:
name: banjang-contact-pool-invite
type: Opaque
stringData:
BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET: "<openssl 결과>"
Запрос сначала создать ConfigMap для staging был преждевременным, если рассматривать только эту функцию. Мы можем разделить задачу переопределения значения по умолчанию с помощью Secret до развёртывания в production. Если development, staging и production используют одно и то же значение, ключ, утёкший из development, можно использовать для подделки ссылок production.
6. Моменты, на которых мы остановились во время реализации
6.1 Что следует использовать в качестве строки, которую нужно подписывать
Мы ненадолго застряли на вопросе, следует ли сначала вычислять HMAC для JSON, а затем применять Base64, или вычислять HMAC для строки полезной нагрузки в Base64. Сторона выпуска и сторона проверки просто должны использовать один и тот же подход. Мы выбрали строку полезной нагрузки, размещённую в URL, в качестве объекта подписи. Если после декодирования снова подписывать байты JSON, это может привести к ошибке, когда порядок ключей JSON или пробельные символы отличаются.
6.2 Прямое использование стандартного Base64 нарушает ссылки
В примере коллеги использовался стандартный Base64. При размещении в запросе существуют среды, где + интерпретируется как пробел. Дополнение = также создаёт неудобства. Только после перехода на URL-безопасное кодирование без дополнения токен перестал изменяться при передаче через приложения для обмена сообщениями или текстовые сообщения.
6.3 Проверку срока действия только как фильтр списка можно обойти
Скрытие просроченных приглашений в списке и отклонение просроченных токенов API регистрации — это разные вещи. Если сделать только первое, человек, сохранивший старую ссылку в закладках, сможет напрямую вызвать API. Фактическое требование заключается в сравнении Instant.now() во время проверки со сроком действия в полезной нагрузке.
6.4 Искушение повторно подписать на стороне фронтенда
Если вы хотите отображать на экране время истечения срока действия, можно позволить фронтенду декодировать полезную нагрузку. Декодирование и подпись — разные вещи. Полезная нагрузка предназначена для просмотра, а ключ, используемый для её изменения и повторной подписи, нельзя хранить на стороне фронтенда. Если требуется время истечения срока действия, сервер может включить expiresAt в ответ на выпуск.
7. Что изменилось в результате
-
Теперь ссылки-приглашения имеют срок действия, а регистрации после его истечения отклоняются сервером.
-
URL, в котором изменён только идентификатор пула, не проходит проверку, поскольку его подпись недействительна.
-
В репозитории фронтенда нет секретного ключа. Передаётся только строка токена.
-
Операционный ключ был выделен в отдельную задачу — сопоставить имя переменной окружения с ключом Secret.
В этой статье количественно не указано, сколько атак удалось сократить. Результат этой работы заключается в том, что теперь у нас есть ссылки, срок действия и подделку которых сервер может обнаружить. Вопрос о временной поддержке предыдущего формата ссылок относился к стратегии развёртывания, поэтому количество старых ссылок, которые следует принимать, было определено отдельно.
8. Что я узнал после выполнения этой работы
Вопросы о том, где размещать код, использующий HMAC, и где размещать ключ, — разные. Приложение уже использует этот алгоритм, а в операционной среде изменяется переменная окружения пода. Если одновременно думать о написании дополнительного кода и сопоставлении имён в кластере, легко ошибочно решить, что достаточно создать Secret.
Фронтенд — не калькулятор подписей. Он лишь передаёт строку токена. Кроме того, один ключ не соответствует одной ссылке. Для каждого окружения используется один ключ, а полезная нагрузка различается от ссылки к ссылке. Для одной лишь этой функции не требовалось сначала запрашивать ConfigMap для stage, а для переопределения значения по умолчанию в production была отдельная причина.
До создания общего Util я выработал привычку проверять, не используется ли HMAC уже в доменном сервисе. Даже если отличается только кодировка, причина часто не в отсутствии утилиты, а в намерении использовать другой алфавит вместо уже применяемого.
9. Что я хочу оставить после практического опыта
-
Даже при использовании подписанных токенов не размещайте секретный ключ на клиенте.
-
При создании операционного Secret используйте в точности имя переменной окружения, указанное в yml. Установите для неё значение, отличное от значений по умолчанию для development и stage.
-
Эксплуатация становится невозможной, если думать о создании отдельного ключа для каждой ссылки. Достаточно общего ключа и разных полезных нагрузок.
-
При размещении значений HMAC в URL символы +, / и = могут повредить ссылки, поэтому сначала убедитесь, что кодировка безопасна для URL и не содержит символов заполнения.
-
Проверяйте срок действия в момент верификации, а не в фильтре списка.
-
Не прикрепляйте реальные значения ключей к учебным заметкам или вики. Оставляйте только команду, использованную для их создания.
Например, генерация ключа означает помещение результата выполнения openssl rand -base64 32 в значение Secret. Я не использовал метод, при котором человек записывает длинную фразу.
10. Связанные понятия, которые стоит повторить
-
Безопасный для URL Base64: кодировка, сохраняющая строку неизменной даже при размещении в ссылках или запросах
-
Переменные окружения и Kubernetes Secret / ConfigMap: Secret для конфиденциальной информации, а ConfigMap — для конфигурации в открытом виде
-
Истечение срока действия токена: отклонение в момент верификации и скрытие элемента на экране списка — разные вещи
-
Общие ссылки и подписанные ссылки-приглашения: вопрос о том, может ли адрес открыть любой пользователь или же он защищён от подделки
11. Заключение
Добавление срока действия к ссылке-приглашению сначала выглядело как изменение текста на экране. Когда я действительно занялся этим, сразу возникли вопросы: «Кто её подписывает?», «Где находится ключ?» и «Есть ли у каждой ссылки свой ключ?». Сильнее, чем сам HMAC, мне запомнился процесс исключения секретов из клиента и точного сопоставления имён переменных окружения, без ошибки даже в одном символе.
Если такое же требование возникнет снова, я не стану сначала выбирать алгоритм. Сначала я запишу, является ли ссылка общей, кто определяет срок её действия и сколько ключей должно приходиться на каждое окружение, а затем выберу HMAC, JWT или что-то другое. Код из этой статьи не был загружен в репозиторий, поэтому он носит концептуальный характер. Имена классов и ключи конфигурации в коде команды следует ещё раз проверить по отношению к развёрнутой ветке.
Mina