Перейти к содержимому
getnextpdf.com

Enterprise редакция

Пакетная проверка подписей

NextPDF Enterprise проверяет цифровые подписи сразу во множестве PDF-документов одним вызовом. NextPDF\Enterprise\Signature\BatchSignatureValidator::validate() принимает список документов и возвращает BatchValidationReport. Каждая подпись проходит один и тот же fail-closed конвейер: криптографическую CMS-аутентификацию по подписанному диапазону байтов, проверку цепочки сертификатов с якорями доверия и проверку отзыва по OCSP/CRL. Отчёт содержит детали по каждому документу и каждой подписи — CertChainStatus, RevocationStatus, TimestampStatus, — так что инструменты соответствия могут повторно вывести каждый вердикт из зафиксированных доказательств.

Модель вердикта намеренно строгая. Подпись получает Valid, только когда все доказательства утвердительно установлены. Отсутствие сведений об отзыве даёт Indeterminate, но никогда Valid. Эта страница описывает пакетный оркестратор и его типы результатов. Однодокументная сторона проверки AdES описана в разделе Проверка подписи. Встраивание материала для долгосрочной проверки описано в разделе Архив.

Эта возможность поставляется в составе NextPDF Enterprise (nextpdf/enterprise) и активируется лицензионным конвертом уровня Enterprise. Развёртывание без этого права не загружает классы данной возможности. Сравните редакции и получите лицензию.

Окно терминала
composer require nextpdf/enterprise

Метапакет nextpdf/premium также разрешает пакет Enterprise. Активация использует ваш лицензионный конверт Enterprise; см. Лицензирование и активация. Пакетные типы автозагружаются в пространстве имён NextPDF\Enterprise\Signature. Никакого расширения PHP сверх базового набора движка не требуется.

Один вызов validate() обрабатывает список значений DocumentSignatureInput. Каждый вход несёт идентификатор документа, необработанные байты PDF и необязательные якоря доверия в формате PEM. Валидатор извлекает словари подписей каждого документа и выполняет три стадии для каждой подписи.

Стадия 1 — криптографическая аутентификация. Отсоединённый CMS/PKCS#7-блоб из /Contents проверяется по байтам, покрываемым /ByteRange. Верификатор сам пересчитывает дайджест содержимого и сравнивает его с подписанным атрибутом messageDigest. Он никогда не доверяет дайджесту, предоставленному создателем (RFC 5652 §5.6). Значение подписи должно проверяться, а сертификат подписанта должен быть привязан к CMS. Отсутствующие или искажённые /Contents или /ByteRange, неразбираемый CMS, несовпадение дайджеста или неудачная проверка подписи — всё это приводит к fail-closed. Подпись, проверяемая под SHA-1, считается слабой и никогда не проходит полностью.

Стадия 2 — проверка цепочки и якорение доверия. Цепочка подписанта, восстановленная из CMS, проверяется как предполагаемый путь сертификации. Предоставленные вами trustedCerts являются входными данными якоря доверия в смысле RFC 5280 §6.1.1: конечный элемент цепочки должен соответствовать одному из предоставленных якорей по отпечатку DER SHA-256. Структурно согласованная цепочка, конечный элемент которой не является настроенным якорем, никогда не признаётся доверенной. При отсутствии пригодных якорей сообщается только структурный вердикт, а CertChainStatus::$trusted остаётся false.

Стадия 3 — отзыв. Проверка отзыва выполняется по восстановленной цепочке после аутентификации, отражая модель ETSI EN 319 102-1, где проверка отзыва следует за успешной проверкой пути (пункт 5.2.6.2). OCSP первичен: засчитывается только криптографически проверенный ответ, как Good или Revoked. Путь CRL — резервный и подтверждает свежесть списка. Когда ни один клиент не настроен, статус — unavailable.

Вердикт для каждой подписи — это SignatureValidationStatus. Таксономия отражает модель статусов ETSI EN 319 102-1 (TOTAL-PASSED / TOTAL-FAILED / INDETERMINATE) на уровне отдельной подписи:

ДоказательствоВердикт
Сертификат подтверждённо отозванInvalid (решающее, независимо от других проверок)
CMS-аутентификация не удалась, материал подписанта не восстановленError
CMS-аутентификация не удалась, материал подписанта присутствуетInvalid
Аутентифицировано, но цепочка не проходит проверкуInvalid (или Error при отсутствии цепочки)
Аутентифицировано и цепочка валидна, но нет подтверждённого якоря доверияIndeterminate
Аутентифицировано, цепочка валидна, доверенная, но нет окончательного отсутствия отзываIndeterminate
Всё вышеперечисленное утвердительно установленоValid

Правило окончательного отсутствия отзыва. «Не доказано, что отозван» — не то же самое, что «доказано, что не отозван». Вердикт Valid требует хотя бы одного результата отзыва Good. Проверенный положительный ответ OCSP — окончательная форма: он утверждает собственный статус сертификата подписанта. Криптографически принятый свежий CRL также удовлетворяет условию в этой реализации, но лишь как подтверждение свежести и целостности — путь не разбирает записи по серийным номерам, поэтому не даёт гарантии отзыва по серийному номеру и никогда не даёт положительного вердикта revoked. Настройте OCSP везде, где важно положительное обнаружение отзыва: развёртывание только с CRL не отобразит отозванный сертификат как Invalid. Когда оба результата OCSP и CRL — Unknown или Unavailable, статус отзыва не определён, а вердикт — Indeterminate. Это следует ETSI EN 319 102-1: недоступные сведения о статусе отзыва приводят к INDETERMINATE, но никогда к прохождению (пункт 5.1.3, TRY_LATER). Это ужесточение поведения в 3.1.0 с влиянием на обратную совместимость: более ранние выпуски могли сообщать Valid без окончательного доказательства отзыва. Развёртывания, в которых не настроен клиент OCSP или CRL, теперь часто видят Indeterminate там, где ранее видели Valid.

Две границы честно очерчивают эту возможность. Во-первых, пакетный валидатор не оценивает встроенные токены меток времени: TimestampStatus в пакетных результатах всегда является состоянием отсутствия. Оценка меток времени RFC 3161 относится к однодокументной стороне проверки; см. Проверка подписи. Во-вторых, эта страница — только проверка на чтение. Встраивание материала DSS/VRI для долгосрочной валидности — это возможность Архив.

Несущее решение — это fail-closed производитель вердиктов. Valid создаётся только из утвердительных доказательств по всем трём осям: криптографическая аутентификация, цепочка с якорем доверия и окончательное отсутствие отзыва. Всё неустановленное деградирует до Indeterminate, а не по умолчанию в прохождение, — такова позиция EN 319 102-1 при отсутствии материала об отзыве. Пропускная способность пакета никогда не выкупает строгость обратно: пакетный слой — это оркестрация над тем же проверенным CMS-верификатором, что используется для одного документа, поэтому прогон в 1000 документов применяет идентичную криптографию. Отчёт также отделяет доказательство от вердикта — CertChainStatus и RevocationStatus фиксируют входные данные, на которых основан каждый вердикт, так что аудитор может вывести его позже.

Проектный контекст: Подписание в масштабе, без компромиссов.

Все символы ниже являются публичным API в nextpdf/enterprise 3.1.0.

final class BatchSignatureValidator
{
public function __construct(
?SignatureExtractor $extractor = null,
?CertificateChainValidator $chainValidator = null,
private readonly ?OcspClient $ocspClient = null,
private readonly ?CrlFetcher $crlFetcher = null,
?CmsSignatureDataExtractor $cmsExtractor = null,
private readonly ClockInterface $clock = new SystemClock(),
)
public function validate(array $inputs): BatchValidationReport
}

Бросает или завершается неудачей с: validate() бросает \InvalidArgumentException, если список входов пуст, и \OverflowException, когда пакет превышает 1000 документов. Документ, не являющийся разбираемым PDF, не бросает исключение; он становится результатом уровня документа Error. $clock — это PSR-20 Psr\Clock\ClockInterface, используемый для решения о свежести CRL, поэтому вердикты детерминированы при замороженных тестовых часах.

final readonly class DocumentSignatureInput
{
public string $documentId;
public function __construct(
string $documentId,
public string $pdfData,
public array $trustedCerts = [],
)
}

Бросает или завершается неудачей с: \InvalidArgumentException, если $documentId — пустая строка. $trustedCerts — это список якорных сертификатов доверия в формате PEM.

final readonly class BatchValidationReport
{
public function __construct(
public array $documents,
public int $totalDocuments,
public int $totalSignatures,
public int $totalValid,
public int $totalInvalid,
public float $durationMs,
)
public function allValid(): bool
public function hasDocumentsWithoutSignatures(): bool
public function toJson(?CertPiiGuard $piiGuard = null): string
}

Бросает или завершается неудачей с: toJson() бросает \JsonException, если кодирование не удалось. allValid() возвращает true, только когда есть подписи и ни одна из них не является невалидной. По умолчанию toJson() применяет приватный по умолчанию NextPDF\Enterprise\Signature\Eidas\CertPiiGuard, который маскирует имя подписанта, корневого эмитента, имя TSA и диагностику проблем цепочки; см. Уровни доверия eIDAS для API этого стража.

final readonly class DocumentValidationResult
{
public function __construct(
public string $documentId,
public DocumentValidationStatus $status,
public array $signatures,
public int $validCount,
public int $invalidCount,
)
public function hasSignatures(): bool
public function totalSignatures(): int
}
enum DocumentValidationStatus: string
{
case AllValid = 'all_valid';
case SomeInvalid = 'some_invalid';
case AllInvalid = 'all_invalid';
case NoSignatures = 'no_signatures';
case Error = 'error';
}

Бросает или завершается неудачей с: ничем. Неизменяемый объект-значение и enum с базовым типом.

final readonly class SignatureValidationResult
{
public function __construct(
public SignatureValidationStatus $status,
public CertChainStatus $certChain,
public TimestampStatus $timestamp,
public RevocationStatus $revocation,
public string $signer,
public string $level = '',
public string $subFilter = '',
public string $reason = '',
)
public function isValid(): bool
}
enum SignatureValidationStatus: string
{
case Valid = 'valid';
case Invalid = 'invalid';
case Indeterminate = 'indeterminate';
case Error = 'error';
}

Бросает или завершается неудачей с: ничем. $signer — это субъект сертификата, проверенного CMS, когда аутентификация прошла, иначе пустая строка. $level — это метка, производная от SubFilter (например, B-B для ETSI.CAdES.detached), а не определение соответствия AdES.

final readonly class CertChainStatus
{
public function __construct(
public bool $valid,
public bool $trusted,
public int $chainLength,
public string $rootIssuer,
public array $issues = [],
)
public function hasIssues(): bool
}

Бросает или завершается неудачей с: ничем. $trusted устанавливается только при подтверждённом попадании в членство якоря доверия, но никогда из непустоты списка якорей.

final readonly class RevocationStatus
{
public function __construct(
public RevocationCheckResult $ocspStatus,
public RevocationCheckResult $crlStatus,
public bool $isRevoked,
public ?DateTimeImmutable $revocationDate = null,
)
public static function unavailable(): self
public function hasConclusiveGood(): bool
}
enum RevocationCheckResult: string
{
case Good = 'good';
case Revoked = 'revoked';
case Unknown = 'unknown';
case Unavailable = 'unavailable';
}

Бросает или завершается неудачей с: ничем из показанных членов. Класс также предоставляет статические фабрики с проверкой доказательств (good(), revoked(), fromResults()), которые бросают \InvalidArgumentException, когда заявленный статус противоречит доказательствам OCSP/CRL, — отозванный результат никогда не может быть создан как не-отозванный, и наоборот. hasConclusiveGood() возвращает true, только для не-отозванного статуса, где хотя бы одна проверка — Good.

final readonly class TimestampStatus
{
public function __construct(
public bool $present,
public bool $valid,
public ?DateTimeImmutable $timestampTime = null,
public string $tsaName = '',
public array $issues = [],
)
public static function absent(): self
}

Бросает или завершается неудачей с: ничем. В пакетных результатах это всегда состояние absent(); см. Крайние случаи и подводные камни.

Проверьте один документ и прочитайте отчёт. Этот пример использует неподписанный PDF, поэтому вывод детерминирован.

batch-quick-start.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Signature\BatchSignatureValidator;
use NextPDF\Enterprise\Signature\DocumentSignatureInput;
// A minimal, unsigned PDF: the validator reports it as no_signatures.
$unsigned = "%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
$validator = new BatchSignatureValidator();
try {
$report = $validator->validate([
new DocumentSignatureInput(documentId: 'doc-001', pdfData: $unsigned),
]);
} catch (\InvalidArgumentException $e) {
// Empty input list, or an empty documentId.
echo 'Rejected: ' . $e->getMessage() . "\n";
exit(1);
}
echo 'Documents: ' . $report->totalDocuments . "\n";
echo 'Signatures: ' . $report->totalSignatures . "\n";
foreach ($report->documents as $doc) {
echo $doc->documentId . ': ' . $doc->status->value . "\n";
}
echo 'All valid: ' . ($report->allValid() ? 'yes' : 'no') . "\n";
echo 'Unsigned documents: ' . ($report->hasDocumentsWithoutSignatures() ? 'yes' : 'no') . "\n";

Ожидаемый вывод:

Documents: 1
Signatures: 0
doc-001: no_signatures
All valid: no
Unsigned documents: yes

Обратите внимание, что здесь allValid() сообщает no: он требует хотя бы одной подписи и отсутствия невалидных результатов, поэтому пустой набор подписей никогда не проходит незаметно.

Проверьте каталог подписанных договоров с клиентами отзыва, якорями доверия, разбиением на пакеты и JSON-отчётом с защитой PII.

batch-validate-contracts.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Security\Ltv\CrlFetcher;
use NextPDF\Enterprise\Security\Ltv\OcspClient;
use NextPDF\Enterprise\Security\Ltv\OcspResponseCache;
use NextPDF\Enterprise\Signature\BatchSignatureValidator;
use NextPDF\Enterprise\Signature\DocumentSignatureInput;
use NextPDF\Enterprise\Signature\SignatureValidationStatus;
// Any PSR-18 client works; Guzzle shown here.
$httpClient = new \GuzzleHttp\Client(['timeout' => 10]);
// Revocation clients make a conclusive non-revoked (Good) result reachable.
// Without them, every verdict tops out at Indeterminate. The response cache
// lets repeat signers across the batch resolve without extra network calls.
$validator = new BatchSignatureValidator(
ocspClient: new OcspClient($httpClient, cache: new OcspResponseCache()),
crlFetcher: new CrlFetcher($httpClient),
);
// Trust anchors are an input: the chain terminus must match one of these.
$anchors = [(string) file_get_contents('/etc/nextpdf/trust/enterprise-root.pem')];
$inputs = [];
foreach (glob('/var/contracts/signed/*.pdf') ?: [] as $path) {
$inputs[] = new DocumentSignatureInput(
documentId: basename($path),
pdfData: (string) file_get_contents($path),
trustedCerts: $anchors,
);
}
$exit = 0;
// One call is capped at 1,000 documents; chunk larger runs.
foreach (array_chunk($inputs, 1000) as $batch) {
try {
$report = $validator->validate($batch);
// Signer PII is redacted by default in the serialized report.
file_put_contents('/var/log/nextpdf/batch-report.jsonl', $report->toJson() . PHP_EOL, FILE_APPEND); // one JSON document per line
} catch (\InvalidArgumentException | \OverflowException $e) {
fwrite(STDERR, 'Batch rejected: ' . $e->getMessage() . "\n");
exit(2);
} catch (\JsonException $e) {
fwrite(STDERR, 'Report encoding failed: ' . $e->getMessage() . "\n");
exit(3);
}
foreach ($report->documents as $doc) {
foreach ($doc->signatures as $sig) {
if ($sig->status !== SignatureValidationStatus::Valid) {
$exit = 1;
fwrite(STDERR, sprintf(
"%s: %s (chain trusted: %s, revoked: %s)\n",
$doc->documentId,
$sig->status->value,
$sig->certChain->trusted ? 'yes' : 'no',
$sig->revocation->isRevoked ? 'yes' : 'no',
));
}
}
}
}
exit($exit);

Ожидаемый вывод (stderr, для одного документа, чьи сведения об отзыве были недоступны; остальные строки зависят от ваших входных данных):

contract-0042.pdf: indeterminate (chain trusted: yes, revoked: no)

JSON-отчёт сериализует поля идентичности подписанта через CertPiiGuard по умолчанию, поэтому запись по отдельной подписи выглядит так (выдержка, иллюстративно):

{
"status": "indeterminate",
"signer": "[REDACTED]",
"level": "B-B",
"subFilter": "ETSI.CAdES.detached"
}
  • Пустой список входов бросает \InvalidArgumentException; более 1000 документов в одном вызове бросает \OverflowException. Разбивайте более крупные прогоны, как в промышленном примере.
  • Обновление с более ранних выпусков: без настроенного клиента OCSP или CRL отзыв — unavailable, поэтому ни одна подпись не может достичь Valid. Более ранние выпуски здесь сообщали Valid; 3.1.0 сообщает Indeterminate (см. Концептуальный обзор).
  • Счётчики уровня документа строги: только Valid увеличивает validCount. Invalid, Indeterminate и Error все увеличивают invalidCount. Поэтому документ, чья единственная подпись — Indeterminate, сообщает all_invalid. Ориентируйтесь на status отдельной подписи, когда различие важно.
  • Проверка OCSP выполняется только когда восстановленная цепочка имеет хотя бы два сертификата, потому что запросу нужен эмитент. Цепочка из одного сертификата переходит к пути CRL или unavailable.
  • crlStatus никогда не сообщает revoked в пакетных результатах. Резервный вариант CRL подтверждает только свежесть списка; авторитетный результат отзыва приходит от OCSP.
  • timestamp всегда absent() в пакетных результатах. Пакетный валидатор не оценивает встроенные токены RFC 3161; используйте Проверку подписи для оценки меток времени.
  • signer пуст, когда аутентификация не удалась. Когда установлен, это CN (или O) субъекта сертификата, проверенного CMS, — никогда не строка /Name из словаря подписи без аутентификации.
  • Записи trustedCerts должны быть PEM-сертификатами. Пустой или искажённый список якорей даёт только структурный вердикт цепочки с trusted: false, ограничивая вердикт значением Indeterminate.
  • Байты, не начинающиеся с заголовка PDF, дают статус уровня документа error с нулём подписей — без исключения.
  • toJson() по умолчанию скрывает PII. Передавайте new CertPiiGuard(disclosePii: true), только когда у вас есть задокументированное правовое основание для обработки идентичности подписанта.
  • Fail-closed производитель вердиктов. Valid требует всего: проверенной CMS-аутентификации по дайджесту /ByteRange, валидной цепочки, подтверждённого членства в якоре доверия и окончательного не-отозванного статуса. Каждая неустановленная проверка деградирует вердикт; ничто не переходит по умолчанию в прохождение.
  • Никакого отмывания идентичности. Сообщаемый подписант — это криптографически привязанный субъект сертификата. Запись /Name — управляемые злоумышленником метаданные, и она никогда не выводится как подписант.
  • Слабые алгоритмы никогда не проходят. Проверяемая подпись SHA-1 всё равно сообщается как невалидная; криптографическая валидность под слабым дайджестом не отмывается в полное прохождение.
  • Доверие — это вход, а не вывод. Предоставленные вами якоря сопоставляются с конечным элементом цепочки по отпечатку DER SHA-256 (RFC 5280 §6.1.1). Самосогласованность цепочки или сама по себе непустота списка якорей никогда не устанавливает доверие.
  • Отзыв решающ. Проверенное утверждение об отзыве принудительно даёт Invalid независимо от любой другой проверки; недоступные сведения принудительно дают Indeterminate.
  • Приватность по умолчанию в сериализованном выводе. toJson() маскирует CN подписанта, DN корневого эмитента, имя TSA и диагностику проблем цепочки, если вы не откажетесь, реализуя минимизацию данных по GDPR Article 5(1)(c) на границе сериализации.
  • Детерминированное время. Решение о свежести CRL читает внедрённые часы PSR-20, а не системное настенное время хоста, поэтому вердикты отзыва воспроизводимы под тестом.

NextPDF Enterprise реализует поведение, основанное на ETSI EN 319 102-1 (трёхзначная модель статуса проверки и правило, что недоступные сведения об отзыве дают INDETERMINATE), RFC 5652 §5.6 (пересчёт дайджеста на стороне верификатора) и RFC 5280 §6.1 (якоря доверия как входные данные полагающейся стороны для проверки пути). Поддержка — не соответствие, а соответствие — не сертификация. NextPDF не имеет сертификации и не предоставляет её. Пакетный валидатор не является квалифицированной службой проверки, а его статусы — это инженерные вердикты, согласованные с таксономией EN 319 102-1, а не индикации TOTAL-PASSED/TOTAL-FAILED/INDETERMINATE из полного процесса проверки по пункту 5. В частности, пакетный режим не выполняет доказательство существования или обработку меток времени; это покрывает однодокументная сторона проверки.

Пакетный валидатор не сверяется с политикой режима FIPS, и включение режима FIPS не меняет пакетные вердикты. Его обработка алгоритмов на стороне проверки фиксирована и fail-closed: слабые (SHA-1) подписи никогда не сообщаются как Valid, с режимом FIPS или без него. Политика режима FIPS в Enterprise управляет стороной подписания/генерации, что описано в FIPS 140 — Глубокий справочник. Поддержка FIPS 140 — это заявление о возможности, а не утверждение о валидации или сертификации.

  • validate() бросает \InvalidArgumentException для пустого списка и \OverflowException при более чем 1000 документах. Искажённые документы никогда не бросают исключение; они дают результаты уровня документа error.
  • Valid требует конъюнкции: CMS криптографически проверен, цепочка валидна, членство в якоре доверия подтверждено и RevocationStatus::hasConclusiveGood() равно true.
  • Подтверждённо-отозванный сертификат решающ: вердикт — Invalid независимо от всех прочих доказательств.
  • Обе проверки отзыва Unknown/Unavailable означают Indeterminate, но никогда Valid (ужесточение 3.1.0, влияние на обратную совместимость).
  • Аутентифицированная, валидная по цепочке подпись без подтверждённого якоря доверия — Indeterminate: подлинная, доверие не установлено.
  • signer — это субъект, проверенный CMS, или пустая строка; запись /Name никогда не используется.
  • timestamp всегда является состоянием отсутствия в пакетных результатах.
  • validCount считает только Valid; все остальные статусы считаются в invalidCount, а статус документа агрегируется из этих счётчиков.
  • toJson() применяет приватный по умолчанию CertPiiGuard, если страж не передан явно.
  • Итоги отчёта — точные суммы по результатам отдельных документов; durationMs — измеренное настенное время для пакета.

Модуль Безопасность / Подписание NextPDF Core — это сторона производителя: он создаёт CMS-подписи, применяет метки времени RFC 3161 и проверяет цепочки и отзыв для материала, который встраивает в момент подписания. Core не поставляет оркестратор пакетной проверки на стороне верификации: ни многодокументного отчёта, ни агрегированной таксономии статусов, ни вердиктов отзыва OCSP/CRL для сторонних документов, ни сериализации отчёта с защитой PII. На одном лишь Core вы бы сами извлекали и проверяли каждую подпись и строили собственную отчётность. Однодокументная сторона проверки Enterprise (Проверка подписи) и этот пакетный оркестратор предоставляют этот слой.

Эта страница документирует только внешне наблюдаемое поведение и поддерживаемую поверхность публичного API. Внутренние пути пространств имён, вспомогательные классы, таблицы механизмов, имена файлов runbook и префиксы тикетов выходят за рамки.