Траст-менеджер здорового человека

ae098a9d5bcb2779b28e3c156061c226.png

Если у вас есть какой-никакой бэкенд, какая-никакая инфраструктура, то наверняка вам приходится возиться с 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 потерял смысл.

Все без толку

Итак, теперь у нас за месяц в логах с интенсивностью раз в час начинают появляться сообщения о том, что пора менять сертификат. Но кто же их читает? Пришлось принять определенные меры. Зацените этот перечень, чтобы понять глубину проблемы:

  1. На уровне INFO сообщение начинает выдаваться за 90 дней. Letsencrypt такого бы не перенес.

  2. На уровне WARNING предупреждение начинает писаться за 30 дней.

  3. На уровне ERROR в лог пишется за 7 дней.

  4. Думаете это все? Как бы не так.

Последний штрих

Удивительное дело — у нас в стране выросло целое поколение потребителей, которых е… [беспокоит] срок годности продуктов с точностью до дня. В советское время срок годности указывался только на молочных продуктах и других скоропортящихся, где счет идет на часы (торты, сливочная помадка).

Сейчас покупатель берет в руки банку с консервами, которые могут лежать три года, и возмущается: «Да это же просрочка! Срок годности истек неделю назад!»

Любопытно, что на Западе, где потребителей любят больше, чем у нас, пишут «Best before», что в переводе означает «продукт сохраняет все свои наилучшие качества до». Не «годен до», а «прекрасен до» или «наилучш до» (если вы сможете это произнести).

Почему же наши производители загоняют себя в такие прокрустовы рамки? Потому что кто-то так первый раз утвердил, а сегодня любой производитель, который бы решил восстать против потребительского фашизма, был бы объявлен г… [нехорошим человеком] и с… [нехорошим человеком].

А вы можете съесть печенье, просроченное на три дня?

https://tema.livejournal.com/1742206.html Артемий Лебедев

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

Поэтому в модуле omni-tls-starter реализованы 3 разных режима работы:

  1. Стандартный режим STRICT. При любой ошибке — разорвать соединение.

  2. Режим ALLOW_EXPIRED. В этом режиме поле notBefore в сертификате X509 получает семантику bestBefore. В логах при этом пишется сообщение не «сертификат истекает через -5 дней», как было бы сделано у многих, а «сертификат такой-то истек 5 дней назад».

  3. И наконец, режим INSECURE. В принципе все программы имеют такой режим, например, в утилите curl есть опция -k, но, в отличие от остальных программ, модуль omni-tls-starter выдает в лог подробную диагностику обо всех ошибках. Идеальный режим для отладки настроек. Своеобразный ssllabs.com для внутренней сети.

Короче

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

Вообще, как только эта библиотека у меня появилась, оказалось, что в ней можно много каких интересных дополнительных проверок сделать. В статье рассказано не обо всех. Некоторые из них можно посмотреть в моем репозитории на гитхабе.

© Habrahabr.ru