💡 Полный рабочий пример доступен на GitHub:
pdf-signing-certificate-checks-python

Введение

Сервис подписывает загруженные PDF‑файлы каждую ночь. Однажды утром сертификат, который он использует, перестаёт быть действительным, но ничего не меняется: задача запускается, файлы записываются, журнал выглядит нормально. Спустя недели кто‑то открывает один из этих документов в Acrobat и видит предупреждающую полосу, потому что подпись, сделанная с просроченным сертификатом, не считается «слабой» подписью — валидаторы помечают её как недействительную. Документы, которые выглядят одобренными, стоят меньше, чем неподписанные, потому что люди им доверяли.

Это отказ имеет название. Проверка действительности сертификата — это поведение GroupDocs.Signature для Python, которое отказывается подписывать, когда срок действия сертификата истёк или ещё не начался. Оно появилось в версии 26.9 вместе с двумя изменениями того же характера: SHA‑256 стал digest‑ом по умолчанию для PDF‑подписей, а SignatureSettings.log_level начал фильтровать сообщения вместо того, чтобы тихо игнорироваться. Каждое из них берёт результат, который раньше происходил незаметно, и выводит его на передний план.

В этой статье сравниваются эти три контроля, как они работают из Python через .NET — что каждый меняет в выводе, когда их следует использовать и какие две детали привязки стоили людям полдня. Все приведённые результаты получены при запуске примера на одностраничном PDF.

Почему это важнее, чем просто заметка о версии

Три изменения имеют общую характеристику, которую стоит назвать: все они превращают ошибку, которую вы бы обнаружили позже, в ошибку, которую вы обнаруживаете сразу.

  • Просроченные сертификаты: вызов подписи завершается ошибкой, пока кто‑то может обновить сертификат, вместо того чтобы создавать документы, которые не проходят проверку после распространения.
  • Digest‑по‑умолчанию: новые подписи используют SHA‑256 без необходимости кого‑то помнить об этом, поэтому «слабый» вариант требует осознанного решения, а не невнимательности.
  • Уровни журналирования: сервис, который настраивает только предупреждения, теперь действительно получает только предупреждения, что делает их читаемыми, а значит — их читают.

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

Предпосылки

Прежде чем начать, убедитесь, что у вас есть:

  • Python 3.9 или новее на 64‑разрядном интерпретаторе — пакет поставляется с включённым .NET‑runtime и не имеет 32‑разрядного колеса.
  • GroupDocs.Signature для Python через .NET 26.10.0, с бесплатной временной лицензией, если хотите снять ограничения оценки.
  • PDF‑файл для подписи и пакет cryptography, если хотите создавать одноразовые тестовые сертификаты, как в примере.

Установка

pip install groupdocs-signature-net cryptography

Управление 1 — Digest, записываемый в подпись

hash_algorithm в DigitalSignOptions выбирает digest. По умолчанию с версии 26.9 используется SHA‑256 в формате adbe.pkcs7.detached, который ожидают текущие валидаторы; до этого новые подписи использовали SHA‑1.

with signature.Signature(source_path) as sign:
    options = DigitalSignOptions()
    options.certificate_stream = io.BytesIO(pfx)
    options.password = PASSWORD
    options.hash_algorithm = HashAlgorithm.SHA512
    options.reason = "Approved"

    result = sign.sign(output_path, options)
    return len(result.succeeded)

Две детали стоит выделить. Сертификат передаётся через certificate_stream как io.BytesIO, а не как путь к файлу — так PKCS#12, построенный в памяти, попадает в библиотеку, не записываясь на диск; пример полагается на это, чтобы вовсе не поставлять закрытый ключ. HashAlgorithm предлагает варианты AUTO, SHA1, SHA256, SHA384 и SHA512; если добавить временную метку, она использует тот же digest, что и подпись.

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

Управление 2 — Останавливает ли просроченный сертификат процесс

Без переопределений подпись сертификатом, срок действия которого истёк (или ещё не начался), вызывает GroupDocsSignatureException и ничего не записывает.

try:
    sign.sign(output_path, options)
    return True
except signature.GroupDocsSignatureException as error:
    print(f"Rejected: {str(error).splitlines()[0]}")
    return False

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

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

settings = signature.SignatureSettings(ConsoleLogger())
settings.log_level = LogLevel.WARNING | LogLevel.ERROR

with signature.Signature(source_path, settings=settings) as sign:
    options.allow_expired = True
    result = sign.sign(output_path, options)

allow_not_yet_valid работает аналогично для сертификата, выданного на более позднюю дату, а два флага независимы: разрешение просроченного сертификата не разрешает ранний. Ранний сертификат обычно указывает на неправильные часы машины, а не на аномалию сертификата; неверные часы делают каждую подпись, созданную этой машиной, сомнительной, поэтому проверяйте их перед тем, как переопределять что‑либо.

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

Управление 3 — Узнает ли кто‑нибудь о проблеме

SignatureSettings.log_level — это значение‑флаг. Пример подписывает один и тот же документ три раза с уровнями LogLevel.NONE, LogLevel.WARNING | LogLevel.ERROR и LogLevel.ALL, подсчитывая, что приходит:

logger = CollectingLogger()
settings = signature.SignatureSettings(logger)
settings.log_level = level

with signature.Signature(source_path, settings=settings) as sign:
    options.allow_expired = True
    sign.sign(output_path, options)

Получаются ноль сообщений, затем одно предупреждение, затем это предупреждение плюс десять трассировок. До версии 26.9 все три строки были бы одинаковыми, потому что уровень принимался и игнорировался — это полезно знать, если вы когда‑нибудь задавали уровень, не видели изменений и решили, что неправильно прочитали свой код.

Две детали привязки стоили мне полдня, поэтому их стоит озвучить явно. SignatureSettings.logger только для чтения, поэтому логгер передаётся в конструктор, а попытка присвоить его позже вызывает AttributeError; log_level задаётся обычным способом после создания. Кроме того, пользовательский логгер не должен наследоваться от groupdocs.signature.logging.ILogger — этот базовый класс оборачивает нативный объект, конструктору которого нужен дескриптор, принадлежащий библиотеке, поэтому наследование приводит к TypeError. Привязка принимает любой обычный объект, реализующий три метода:

class StdlibLogger:
    def error(self, message, exception=None):
        logging.getLogger("groupdocs").error(message, exc_info=exception)

    def warning(self, message, exception=None):
        logging.getLogger("groupdocs").warning(message)

    def trace(self, message):
        logging.getLogger("groupdocs").debug(message)

Методам error и warning следует принимать необязательный параметр exception. Библиотека не всегда передаёт его, а логгер, требующий его, ломается на сообщениях, где он отсутствует.

Сравнение трёх контролей: когда использовать каждый

Управление Лучшее применение Ключевые преимущества Ограничения
hash_algorithm соответствие политике, задающей digest одно назначение; одинаковый размер вывода бессмысленно, если сам сертификат недоверенный
проверка действительности и переопределения любые подписи для сторонних лиц ошибка появляется там, где её можно исправить переопределение создаёт файл, но не делает его надёжным
log_level сервисы с уже загруженными журналами одиннадцать сообщений превращаются в одно фильтрует только журнал, никогда не подавляет исключения

Это не альтернативы — один вызов подписи использует все три контроля. Порядок их рассмотрения соответствует порядку последствий: проверка действительности решает, будет ли файл, digest определяет, что внутри, а уровень журнала решает, кто узнает.

Меняет ли уровень журнала набор получаемых исключений?

Нет. Он определяет, какие сообщения попадают в ваш логгер, и ничего более. Просроченный сертификат всё равно вызывает GroupDocsSignatureException при LogLevel.NONE, а allow_expired всё равно подписывает при LogLevel.ALL; возвращаемые значения и исключения одинаковы для любого уровня. Меняется лишь то, будет ли предупреждение, объясняющее сомнительную подпись, когда‑либо прочитано человеком.

Проверка перемещена в том же направлении

Стоит упомянуть, потому что это вторая часть того же релиза. verify с пустым DigitalVerifyOptions теперь криптографически проверяет каждую цифровую подпись PDF, так что документ, изменённый после подписи, считается недействительным, а не просто «необъяснённым»:

with signature.Signature(signed_path) as sign:
    result = sign.verify(DigitalVerifyOptions())
    return result.is_valid

Всего две строки, и их стоит добавить в любой конвейер, который подписывает и затем хранит файлы. Обратите внимание, чего не гарантирует True: он говорит, что подпись соответствует документу, но не то, что издатель доверенный. Самоподписанные сертификаты примера проходят проверку здесь, но всё равно отклоняются PDF‑читалкой, что отвечает на вопрос о доверии отдельно.

Лучшие практики и советы

  • Оставляйте отказ по умолчанию во всех процессах, подписывающих от имени пользователей, и переопределяйте только для конкретного вызова, а не глобально. Исключение дешево; пакет недействительных подписей — нет.
  • Записывайте текст предупреждения, а не только счётчик. В нём указаны сертификат и дата, что единственное, на что оператор может отреагировать.
  • Проверьте часы перед разрешением сертификата, ещё не ставшего действительным. Обычно сертификат правильный, а часы машины — нет, и это влияет на более чем один вызов подписи.
  • Убирайте трассировки из продакшна. Около десяти сообщений на каждый запуск быстро накапливаются; включайте их только при диагностике и выключайте после.
  • Проверяйте после подписи в любом конвейере, теперь когда проверка криптографическая, так повреждённый вывод будет пойман до того, как получатель его обнаружит.

Заключение

Три контроля, один вызов подписи и единая идея дизайна: рискованный результат теперь требует решения, а безопасный — ничего. Оставляйте проверку действительности, рассматривайте allow_expired как исключение на уровне вызова, которое вы логируете, не трогайте digest, если политика не требует иначе, и задавайте уровень журнала, который делает предупреждения читаемыми.

Запуск примера на вашем собственном PDF займет минуту и выведет точно то, что изменил каждый контроль — шесть подписанных файлов, один преднамеренный отказ и три строки счётчиков сообщений, которые больше не одинаковы.

Дополнительные ресурсы