💡 Полный рабочий пример доступен на 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 займет минуту и выведет точно то, что изменил каждый контроль — шесть подписанных файлов, один преднамеренный отказ и три строки счётчиков сообщений, которые больше не одинаковы.