Перейти к содержанию

Как провернуть JWT-ключ

Пошаговая процедура ротации HMAC-ключа, которым Gateway подписывает X-User-* headers, и JWT-ключа, которым user-сервис подписывает access/refresh токены. Правила хранения ключей и формата подписи — в ../conventions/security.md; разделение PRIMARY/PREVIOUS в конфиге — в ../conventions/configuration.md §Key rotation.

Зачем ротировать

Плановая ротация — защита от медленного leak'а (например, бэкап конфиг-репо попал к третьей стороне, про это узнали позже; свежий ключ уже не тот, что утёк). Стандартная частота — раз в 90 дней. Экстренная ротация — немедленно при подозрении на компрометацию.

Архитектурное напоминание

  • Gateway HMAC-подписывает заголовки X-User-ID, X-User-Role, X-Request-Id и шлёт результат в X-Gateway-Signature.
  • Backend-сервисы проверяют HMAC на входе через middleware GatewayAuth (см. ../conventions/security.md §Аутентификация в сервисе).
  • User-сервис дополнительно подписывает сам JWT — этот ключ другой и живёт только там.
  • Ключи в конфиге — парой:
  • AUTH_HMAC_PRIMARY — текущий, им подписываются новые запросы.
  • AUTH_HMAC_PREVIOUS — предыдущий, принимается при verify до истечения TTL старых запросов/токенов.
  • Аналогично для user-сервиса:
  • JWT_SECRET_PRIMARY / JWT_SECRET_PREVIOUS.
  • Внутренний API токен:
  • INTERNAL_API_TOKEN_PRIMARY / INTERNAL_API_TOKEN_PREVIOUS.

Verify в сервисе проверяет подпись сначала primary, потом previous (см. ../conventions/security.md §Rotation секретов). Если primary совпал — ok. Если только previous — тоже ok, но логируй WARN (полезно для отслеживания завершения ротации).

Нормальная ротация (90-day cycle)

Шаг 1. Сгенерировать новый ключ

openssl rand -base64 32

32 байта из crypto/rand — минимальная длина для HMAC-SHA256. Сохрани вывод в secret manager (Vault / SOPS / managed secret) под новым именем: auth-hmac-<YYYY-MM-DD>. Никогда не коммить ключ в git, никогда не пересылать через мессенджер.

Шаг 2. Применить как PREVIOUS + новый PRIMARY во всех сервисах

Логика: сервисы должны уметь принять новый ключ раньше, чем Gateway им подпишет. Порядок раскатки обязательный:

  1. Backend-сервисы (user, review, media, notification, ...) — сначала.
  2. Gateway — последним.

Для каждого backend-сервиса:

# добавить новый ключ; старый уходит в PREVIOUS
kubectl set env deployment/<service> \
    AUTH_HMAC_PRIMARY="<new-key>" \
    AUTH_HMAC_PREVIOUS="<old-key>"

Подожди rollout (kubectl rollout status deployment/<service>) для каждого сервиса.

Когда все backend'ы раскатились — обновляй Gateway:

kubectl set env deployment/gateway \
    AUTH_HMAC_PRIMARY="<new-key>" \
    AUTH_HMAC_PREVIOUS="<old-key>"

Если перепутать порядок (Gateway первым) — в промежутке raw Gateway подписывает новым ключом, а backend'ы ещё не знают о нём; старые pod'ы вернут 401. Несколько минут 401-х, пока rollout не догонит.

Шаг 3. Ожидание

После полного rollout Gateway подожди 24 часа. Причины:

  • Запросы, сформированные старыми клиентами с кэшем, должны успеть протечь через verify по PREVIOUS.
  • Любые отложенные cron'ы, батчи, worker'ы, которые формируют подпись сами, — должны увидеть новый ключ.

Шаг 4. Убрать PREVIOUS

Через 24 часа:

kubectl set env deployment/<service> AUTH_HMAC_PREVIOUS="" --all

или, если сервисов много:

for svc in user review media notification gateway; do
    kubectl set env deployment/$svc AUTH_HMAC_PREVIOUS=""
done

После этого только PRIMARY признаётся. Запуск мониторинга: alert на любые 401 от auth-middleware в течение следующего часа. Если есть — в цикле ротации что-то упущено, верни PREVIOUS обратно и разбирайся.

Экстренная ротация (подозрение на утечку)

Те же шаги, но без 24-часовой паузы. Плюс:

  1. Немедленно подменить ключи по §Шаг 1 — §Шаг 2.
  2. Revoke всех access-токенов через admin-endpoint user-сервиса. Типичная реализация: endpoint /internal/admin/tokens/invalidate-all ставит в Redis ключ jwt:revoked:since=<timestamp>, и middleware отказывает любому JWT с iat < timestamp.
  3. UX-эффект: пользователи де-авторизуются массово, клиенты должны перелогиниться. Это ожидаемо в случае компрометации.
  4. После стабилизации — разбор логов за возможное окно компрометации (запросы к чувствительным endpoint'ам, необычные паттерны IP).
  5. Пропусти §Шаг 3 (24h wait): PREVIOUS не нужен, потому что всё, что было подписано старым ключом, уже revoked.
  6. Через несколько часов (чтобы in-flight запросы долетели) убери PREVIOUS.

JWT-ключ user-сервиса

Отдельный ключ, отдельная ротация. Затрагивает только user-сервис.

  1. Сгенерируй новый ключ: openssl rand -base64 32.
  2. kubectl set env deployment/user JWT_SECRET_PRIMARY="<new>" JWT_SECRET_PREVIOUS="<old>".
  3. Ждать до окончания TTL refresh-токенов (по умолчанию ~30 дней). Access-токены короче (~15 минут), их держать не нужно.
  4. После 30 дней: JWT_SECRET_PREVIOUS="".

Если ротация экстренная — дополнительно revoke'ни все refresh-токены через admin-endpoint, чтобы не ждать 30 дней.

Internal API token

/internal/* endpoint'ы защищены отдельным статическим токеном (см. ../conventions/security.md §Internal endpoints). Ротация по тому же dual-key паттерну:

kubectl set env deployment/<service> \
    INTERNAL_API_TOKEN_PRIMARY="<new>" \
    INTERNAL_API_TOKEN_PREVIOUS="<old>"

Порядок раскатки: сначала потребители (сервисы, зовущие чужие /internal/*), потом хосты (сервисы, обслуживающие эти endpoint'ы). Почему в другую сторону, чем Gateway/backend? Потому что потребитель присылает токен, хост проверяет. Сначала учи потребителя слать новый — иначе хост его не увидит.

Частота — раз в 180 дней, если не было инцидента.

Валидация на стороне сервиса

Эталонная логика auth-middleware (см. ../conventions/security.md):

func GatewayAuth(cfg config.Auth) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            payload := buildPayload(r) // uid || role || request_id
            sig, _ := hex.DecodeString(r.Header.Get("X-Gateway-Signature"))

            if verifyHMAC(cfg.HMACPrimary, payload, sig) {
                next.ServeHTTP(w, r)
                return
            }
            if cfg.HMACPrevious != "" && verifyHMAC(cfg.HMACPrevious, payload, sig) {
                slog.WarnContext(r.Context(), "auth: verified via PREVIOUS key")
                next.ServeHTTP(w, r)
                return
            }
            writeError(w, http.StatusUnauthorized, "unauthorized", "bad signature")
        })
    }
}

verifyHMAC использует hmac.Equal (constant-time). WARN-лог на verify через PREVIOUSважный: в нормальной работе после wait таких записей быть не должно. Если они есть после §Шаг 4 — ротация неполная.

Чеклист ротации

Подготовка: - [ ] Новый ключ сгенерирован через openssl rand -base64 32 (32 байта, случайность из crypto-источника). - [ ] Ключ сохранён в secret manager, не в git, не в мессенджере. - [ ] Предыдущий ключ сохранён отдельно (на случай отката). - [ ] Incident log заведён (даже при плановой ротации).

Раскатка: - [ ] Для HMAC: сначала backend-сервисы, потом Gateway. - [ ] Для internal API token: сначала потребители, потом хосты. - [ ] PREVIOUS установлен во всех сервисах, не только в одном. - [ ] kubectl rollout status зелёный для каждого Deployment.

Ожидание: - [ ] 24 часа для HMAC (плановая ротация). - [ ] 30 дней для JWT (длительность refresh TTL). - [ ] Monitoring активен: dashboards auth errors / 401 rate.

Завершение: - [ ] PREVIOUS очищен во всех сервисах. - [ ] Smoke-test: login → получить JWT → сделать запрос в backend → 200. Repeat для internal API: сервис A → /internal/... сервиса B → 200. - [ ] 401 rate не вырос после очистки PREVIOUS.

Пост-ротация: - [ ] Incident log закрыт с пометкой ok. - [ ] Следующая плановая ротация запланирована (через 90 дней — для HMAC; через 180 — для internal token). - [ ] Старый ключ удалён из secret manager через 30 дней после завершения.

Anti-patterns

  • Ротация только Gateway без backend. Все запросы начнут валиться на 401, пока backend не подтянется. Всегда: backend → Gateway.
  • Ротация без PREVIOUS. Любой in-flight запрос с подписью от старого ключа упадёт на 401. Без 24h буфера не обойтись.
  • Хранение ключа в docker-compose.yml prod. Ключ в plain text в репо — это compromise на следующий же день.
  • Использование одного HMAC-ключа для Gateway и internal API. Разные доверительные границы; компрометация одного не должна ломать другое. Разные ключи, разные rotation-циклы.
  • Reuse старого ключа в новом сервисе. Если поднимаете новый сервис, не копируйте туда текущий PRIMARY «как dev-placeholder». Dev-стенд использует dev-ключи с маркером _dev_, prod — свежие из secret manager.
  • Ротация вручную без incident log. Через месяц никто не вспомнит, кто, когда и почему менял ключи; при инциденте это усложняет расследование.

Связанные разделы