Траст-менеджер здорового человека
Если у вас есть какой-никакой бэкенд, какая-никакая инфраструктура, то наверняка вам приходится возиться с TLS-сертификатами. Хорошо, когда у вас все сервера доступны из интернета, и на них можно поставить сертификаты Letsencrypt или его аналогов. В этом случае у вас каждый день запускается certbot, который проверяет срок действия ваших сертификатов, своевременно их перевыпускает и устанавливает. Надо только настроить хук, чтобы в случае любой ошибки вам куда-нибудь приходило уведомление. И у вас всегда будет достаточно времени, чтобы все исправить.
Другое дело, когда у вас десятки изолированных от интернета серверов и кластеров кубернетс, на которых хостятся сотни микросервисов, сделанные в разное время разными бригадами разработчиков. Тогда вам нужен свой кастомный CA, сертификат его лежит во всех трастсторах, этот CA выдает сертификаты всем желающим на их эндпоинты. Обычно в таких случаях единой системы автоматического перевыпуска и установки сертификатов нет, каждый настраивает TLS по-своему. Ребята, поддерживающие CA, выдают сертификаты на дискетах флэшках, разработчики работают с ними, кто во что горазд. Например, некоторые запаковывают сертификаты с ключами в докер-образ.
Выпускаются сертификаты по современным правилам на 1 год. В итоге, если у вас >2000 сертификатов, то у вас каждый день где-нибудь надо менять сертификат. И постоянно то там, то тут разработчики пропускают сроки, и что-нибудь отваливается. Сертификат на этом экземпляре постгрес истекает через месяц? Время еще есть, займемся в следующем спринте, правда девопс через неделю заболел, а тимлид ушел в отпуск, в итоге, сертификат истек, доступ к базе пропал, пока то да се, система целый час находилась в простое. И такая дребедень каждый день (почти).
Мы эту проблему полностью решили с помощью специальной java-библиотеки. В Java валидацией сертификатов TLS занимается так называемый TrustManager. Логика его работы примерно такая:
if (Date.now().after(cert.getNotAfter()) {
throw new CertificateExpiredException();
}
Основная идея
Где-то после 3-го падения прода у меня родилась идея поменять стандартную логику проверки валидности сертификата на такую:
if (Date.now().after(cert.getNotAfter()) {
throw new CertificateExpiredException();
} else if (Duration.between(Instant.now(), cert.getNotAfter()).toDays() < 30) {
notifyEverybody(cert);
}
То есть анализируется сертификат, используемый при установке TLS-соединения. Не абстрактный файл в каталоге, который периодически оказывается не тем, что надо, а реальный пришедший по сети сертификат. Во время проверки сертификата вместо того, чтобы просто кинуть исключение, когда уже, как говорится, поздно пить боржоми, мы заранее выпускаем предупреждение. В результате у меня появился полезнейший модуль под названием omni-tls-starter, который я сегодня с удовольствием представляю вашему вниманию.
Регистрация секьюрити-провайдера
Чтобы зарегистрировать траст-менеджер, надо реализовать секьюрити-провайдера. Наш провайдер не сможет реализовать все необходимые алгоритмы, поэтому он будет делегировать основную работу стандартным провайдерам, только добавляя свои фичи в некоторые методы.
Кастомный провайдер надо установить в начало списка провайдеров, тогда он будет иметь приоритет над остальными. Но это может оказаться и нежелательным в некоторых случаях. Если мы хотим использовать наш провайдер не везде, то надо его установить в конец списка и обращаться к нему по имени там, где это нужно. Кроме того, надо защититься от многократной установки из-за некорректной конфигурации. Инициализацию провайдера лучше всего сделать ленивой, так как с одной стороны мы точно не знаем, когда он нам понадобится, а с другой стороны, он может не понадобиться вообще. Всю эту логику я реализовал в классе OmniSecurityProvider. Вот, как выглядит его основной метод регистрации:
public static void registerOnTop() {
Provider[] providerArray = Security.getProviders();
int targetPos = 1;
if (providerArray != null && providerArray[targetPos - 1].equals(getInstance())) {
return;
}
int pos = Security.insertProviderAt(getInstance(), targetPos);
if (pos != targetPos) {
String msg = String.format(Locale.ROOT, "Не удалось зарегистрировать провайдер безопасности %s в позиции %d",
OmniSecurityProvider.class.getSimpleName(), targetPos);
throw new IllegalStateException(msg);
}
}
Этот метод нужно вызывать при старте приложения, чтобы наш траст-менеджер начал получать на валидацию все TLS-подключения. И вот здесь кроется первая засада. В современных фрэймворках очень много интеграций начинаются на старте, и если пользоваться стандартными способами автозапуска, то может так получиться, что многие интеграции установят TLS-соединения еще до того, как наш провайдер будет зарегистрирован. Поэтому регистрация провайдера реализована через ApplicationListener. Если библиотека используется без спринга, то надо вызвать метод registerOnTop () в начале main-метода.
Реализация траст-менеджера и кей-менеджера
Наша реализация траст-менеджера и кей-менеджера довольна проста. Они просто делегируют все вызовы стандартным реализациям, полученным от OmniSecurtyProvider.
На самом деле там есть подводные камни. Во-первых, основные интерфейсы задвоены, есть X509TrustManager, а есть и X509ExtendedTrustManager. Надо это все проверять, так как гарантий того, какой именно интерфейс будет получен, никаких. Во-вторых, перехватить создание стандартных менеджеров не так просто, для этого нужно реализовать внутренние интерфейсы TrustManagerFactorySpi и KeyManagerFactorySpi, стандартные реализации которых недоступны.
Поскольку следить надо не только за серверными, но и за клиентскими сертификатами, мы сразу реализуем и TrustManager, и KeyManager, а общий для обеих реализаций код размещаем в классе OmniX509Commons.
Логирование
Характерной особенностью библиотеки omni-tls-starter является то, что, с одной стороны, основная ее функция — это логирование, а с другой стороны — пользоваться привычными механизмами логирования в ней нельзя. Дело в том, что классы, осуществляющие логирование, сами могут и обычно устанавливают TLS-соединения, например, у вас может быть appender, который пишет логи в базу данных, в elasticsearch или в кафку. Если низкоуровневые классы во время TLS-подключения вызовут логирование, то получится рекурсия. Чтобы избежать рекурсии наш провайдер безопасности сохраняет все нотификации в очередь, при этом поднимается отдельный поток, который вынимает сообщения из очереди и осуществляет отправку их в логи.
Провайдер сообщений и потребитель сообщений между собой не связаны. Реализация сделана таким образом, чтобы логер можно было перенести в отдельную библиотеку. Пока ради простоты они собраны в одном модуле, так как не возникало необходимости иметь разные реализации логирования для кастомного провайдера безопасности.
Вот в этом методе (код-сниппет ниже) надо будет подшаманить, если нам понадобится динамически выбирать между различными реализациями логирования. Главное — секьюрити провайдер от реализации логирования не зависит.
/**
* Возвращает экземпляр логгера.
* @return экземпляр логгера
*/
static OmniSecurityNotifier getInstance() {
try {
// Провайдер безопасности не должен иметь внешних зависимостей, чтобы не допустить циклических вызовов.
// Поэтому реализация интерфейса подбирается через рефлексию.
return (OmniSecurityNotifier) Class.forName("ru.github.seregaizsbera.tls.starter.OmniSecurityNotifierImpl")
.getDeclaredMethod("getInstance")
.invoke(null);
} catch (ReflectiveOperationException e) {
return new OmniSecurityNotifier() {
@Override
public boolean notify(OmniX509EventModel event) {
return false;
}
@Override
public boolean isFull() {
return false;
}
};
}
}
Ограничение объема выдачи диагностики
Итак, предположим, у нас где-то в rabbit-mq, про который мы даже не подозревали, что он у нас есть, сертификат истекает через месяц, нам пора об этом узнать, omni-tls-starter замечает это при создании соединения по протоколу TLS и начинает писать об этом в лог. Очень скоро вся елка окажется завалена однотипными сообщениями вида «сертификат такой-то истекает через 29 дней». Сертификат, конечно, перевыпустят и поставят, но назойливый логер, скорее всего, при этом отключат, чтобы не спамил. Поэтому нужно ограничить количество выдаваемых сообщений. Для этого в модуле omni-tls-starter реализован EventLimiter. Этот класс универсальный, он вообще ничего не знает о сертификатах. Ему на вход подаются ключи событий, он запоминает, какое событие когда произошло, и сообщает, нужно принять данное событие или его нужно пропустить. Ну, а чтобы случайно не забить память, он еще сам чистит свои внутренние контейнеры. Логер использует в качестве ключа серийный номер сертификата, поэтому за заданный период (по умолчанию 1 час) об одном сертификате в лог попадет не более одного сообщения.
Продвинутая диагностика
Зачастую просто сообщить о проблеме с сертификатом мало, нужно еще и понять, где он находится. Например, у нас может быть wildcard-сертификат, установленный на сотнях серверов, вроде бы везде его уже обновили, а в логах все равно пишется, что где-то остался старый. Мы его быстро найдем, когда он истечет, и какой-нибудь микросервис в результате отвалится, но наша задача — этого не допустить. Поэтому в лог выдается дополнительная диагностика. Извлекается диагностика из сокета:
private static String getConnectionInfo(Socket socket, SSLEngine engine) {
var socketInfo = Optional.ofNullable(socket)
.map(s -> String.format(Locale.ROOT, "%s:%d:%s:%d", s.getLocalAddress().getHostAddress(),
s.getLocalPort(), s.getInetAddress().getHostAddress(), s.getPort()))
.orElse("");
var engineInfo = Optional.ofNullable(engine)
.map(s -> String.format(Locale.ROOT, "%s:%d", s.getPeerHost(), s.getPeerPort()))
.orElse("");
return socketInfo + engineInfo;
}
Сначала я кидал сам себе исключение и сохранял stacktrace, чтобы понять, где произошло событие, но это слишком затратный способ, который после получения информации с L3 потерял смысл.
Все без толку
Итак, теперь у нас за месяц в логах с интенсивностью раз в час начинают появляться сообщения о том, что пора менять сертификат. Но кто же их читает? Пришлось принять определенные меры. Зацените этот перечень, чтобы понять глубину проблемы:
На уровне INFO сообщение начинает выдаваться за 90 дней. Letsencrypt такого бы не перенес.
На уровне WARNING предупреждение начинает писаться за 30 дней.
На уровне ERROR в лог пишется за 7 дней.
Думаете это все? Как бы не так.
Последний штрих
Удивительное дело — у нас в стране выросло целое поколение потребителей, которых е… [беспокоит] срок годности продуктов с точностью до дня. В советское время срок годности указывался только на молочных продуктах и других скоропортящихся, где счет идет на часы (торты, сливочная помадка).
Сейчас покупатель берет в руки банку с консервами, которые могут лежать три года, и возмущается: «Да это же просрочка! Срок годности истек неделю назад!»
Любопытно, что на Западе, где потребителей любят больше, чем у нас, пишут «Best before», что в переводе означает «продукт сохраняет все свои наилучшие качества до». Не «годен до», а «прекрасен до» или «наилучш до» (если вы сможете это произнести).
Почему же наши производители загоняют себя в такие прокрустовы рамки? Потому что кто-то так первый раз утвердил, а сегодня любой производитель, который бы решил восстать против потребительского фашизма, был бы объявлен г… [нехорошим человеком] и с… [нехорошим человеком].
А вы можете съесть печенье, просроченное на три дня?
https://tema.livejournal.com/1742206.html Артемий Лебедев
В принципе такая логика для сертификатов тоже имеет право на существование. Клиентам, которые делают у нас на сайте заказы, должно быть до лампочки, что у нас сертификат на внутреннем сервере вчера просрочился.
Поэтому в модуле omni-tls-starter реализованы 3 разных режима работы:
Стандартный режим STRICT. При любой ошибке — разорвать соединение.
Режим ALLOW_EXPIRED. В этом режиме поле notBefore в сертификате X509 получает семантику bestBefore. В логах при этом пишется сообщение не «сертификат истекает через -5 дней», как было бы сделано у многих, а «сертификат такой-то истек 5 дней назад».
И наконец, режим INSECURE. В принципе все программы имеют такой режим, например, в утилите curl есть опция -k, но, в отличие от остальных программ, модуль omni-tls-starter выдает в лог подробную диагностику обо всех ошибках. Идеальный режим для отладки настроек. Своеобразный ssllabs.com для внутренней сети.
Короче
Кто хочет, пользуйтесь. Думаю, многим может оказаться полезной как сама библиотека, так и реализованные в ней идеи.
Вообще, как только эта библиотека у меня появилась, оказалось, что в ней можно много каких интересных дополнительных проверок сделать. В статье рассказано не обо всех. Некоторые из них можно посмотреть в моем репозитории на гитхабе.