From 804ba624a59125f2e067b701845d6f0e244d41a9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:49:31 -0300 Subject: [PATCH] chore: update pdf signature validator to 0.5.2 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- composer.json | 2 +- composer.lock | 14 +-- composer/composer/installed.json | 14 +-- .../src/Model/ValidationReason.php | 3 + .../PdfDocumentModificationAnalyzer.php | 18 +++- .../src/Parser/PdfSignatureValidator.php | 41 ++++++-- .../PdfSignatureStructureValidationTest.php | 96 +++++++++++++++++++ 7 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 composer/libresign/pdf-signature-validator/tests/Unit/Parser/PdfSignatureStructureValidationTest.php diff --git a/composer.json b/composer.json index 842f409..c56f35f 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "endroid/qr-code": "^6.0.9", "jsignpdf/jsignpdf-php": "^3.0.0", - "libresign/pdf-signature-validator": "^0.5.1", + "libresign/pdf-signature-validator": "^0.5.2", "libresign/whatosami": "^0.0.2", "mikehaertl/php-pdftk": "^0.14.3", "mpdf/mpdf": "^8.2.6", diff --git a/composer.lock b/composer.lock index 5a06e63..7ca1539 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "071eda5ad765ff4362a2ac2544f745d6", + "content-hash": "e9bb695ee1772d60ac7cc561496652aa", "packages": [ { "name": "bacon/bacon-qr-code", @@ -301,16 +301,16 @@ }, { "name": "libresign/pdf-signature-validator", - "version": "v0.5.1", + "version": "v0.5.2", "source": { "type": "git", "url": "https://github.com/LibreSign/pdf-signature-validator.git", - "reference": "5e109f2c6ccb1344c9df7773ffb107f637b3dc81" + "reference": "50deeb2e38d41e4dcec760d4c9195c5583543769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/LibreSign/pdf-signature-validator/zipball/5e109f2c6ccb1344c9df7773ffb107f637b3dc81", - "reference": "5e109f2c6ccb1344c9df7773ffb107f637b3dc81", + "url": "https://api.github.com/repos/LibreSign/pdf-signature-validator/zipball/50deeb2e38d41e4dcec760d4c9195c5583543769", + "reference": "50deeb2e38d41e4dcec760d4c9195c5583543769", "shasum": "" }, "require": { @@ -340,7 +340,7 @@ "description": "High-quality PDF signature extraction and validation primitives for LibreSign and external consumers.", "support": { "issues": "https://github.com/LibreSign/pdf-signature-validator/issues", - "source": "https://github.com/LibreSign/pdf-signature-validator/tree/v0.5.1" + "source": "https://github.com/LibreSign/pdf-signature-validator/tree/v0.5.2" }, "funding": [ { @@ -348,7 +348,7 @@ "type": "github" } ], - "time": "2026-09-04T17:18:31+00:00" + "time": "2026-09-04T22:21:17+00:00" }, { "name": "libresign/whatosami", diff --git a/composer/composer/installed.json b/composer/composer/installed.json index 100acb0..156e014 100644 --- a/composer/composer/installed.json +++ b/composer/composer/installed.json @@ -310,17 +310,17 @@ }, { "name": "libresign\/pdf-signature-validator", - "version": "v0.5.1", - "version_normalized": "0.5.1.0", + "version": "v0.5.2", + "version_normalized": "0.5.2.0", "source": { "type": "git", "url": "https:\/\/github.com\/LibreSign\/pdf-signature-validator.git", - "reference": "5e109f2c6ccb1344c9df7773ffb107f637b3dc81" + "reference": "50deeb2e38d41e4dcec760d4c9195c5583543769" }, "dist": { "type": "zip", - "url": "https:\/\/api.github.com\/repos\/LibreSign\/pdf-signature-validator\/zipball\/5e109f2c6ccb1344c9df7773ffb107f637b3dc81", - "reference": "5e109f2c6ccb1344c9df7773ffb107f637b3dc81", + "url": "https:\/\/api.github.com\/repos\/LibreSign\/pdf-signature-validator\/zipball\/50deeb2e38d41e4dcec760d4c9195c5583543769", + "reference": "50deeb2e38d41e4dcec760d4c9195c5583543769", "shasum": "" }, "require": { @@ -331,7 +331,7 @@ "bamarni\/composer-bin-plugin": "^1.8", "roave\/security-advisories": "dev-latest" }, - "time": "2026-09-04T17:18:31+00:00", + "time": "2026-09-04T22:21:17+00:00", "type": "library", "extra": { "bamarni-bin": { @@ -352,7 +352,7 @@ "description": "High-quality PDF signature extraction and validation primitives for LibreSign and external consumers.", "support": { "issues": "https:\/\/github.com\/LibreSign\/pdf-signature-validator\/issues", - "source": "https:\/\/github.com\/LibreSign\/pdf-signature-validator\/tree\/v0.5.1" + "source": "https:\/\/github.com\/LibreSign\/pdf-signature-validator\/tree\/v0.5.2" }, "funding": [ { diff --git a/composer/libresign/pdf-signature-validator/src/Model/ValidationReason.php b/composer/libresign/pdf-signature-validator/src/Model/ValidationReason.php index 47ace63..c3691ef 100644 --- a/composer/libresign/pdf-signature-validator/src/Model/ValidationReason.php +++ b/composer/libresign/pdf-signature-validator/src/Model/ValidationReason.php @@ -12,4 +12,7 @@ enum ValidationReason : string case NO_BYTE_RANGE = 'no_byte_range'; case NO_BINARY_SIGNATURE = 'no_binary_signature'; case SIGNATURE_CERTIFICATE_MISMATCH = 'signature_certificate_mismatch'; + case INVALID_BYTE_RANGE = 'invalid_byte_range'; + case INVALID_EOF_BOUNDARY = 'invalid_eof_boundary'; + case UNSUPPORTED_SUBFILTER = 'unsupported_subfilter'; } diff --git a/composer/libresign/pdf-signature-validator/src/Parser/PdfDocumentModificationAnalyzer.php b/composer/libresign/pdf-signature-validator/src/Parser/PdfDocumentModificationAnalyzer.php index 71b97c5..1e9d4af 100644 --- a/composer/libresign/pdf-signature-validator/src/Parser/PdfDocumentModificationAnalyzer.php +++ b/composer/libresign/pdf-signature-validator/src/Parser/PdfDocumentModificationAnalyzer.php @@ -55,7 +55,7 @@ private function findLastSignatureIndex(array $signatures) : ?int } return $lastSignatureIndex; } - private function detectDocumentModificationState(SignatureMetadata $metadata, string $content) : DocumentModificationState + public function detectStructuralIssue(SignatureMetadata $metadata, string $content) : ?DocumentModificationState { $range = $metadata->range; if (!$this->isValidByteRange($range, $metadata->contentsOffset, $content)) { @@ -64,10 +64,22 @@ private function detectDocumentModificationState(SignatureMetadata $metadata, st if ($range === null) { return DocumentModificationState::INVALID_BYTE_RANGE; } - $signedEnd = $range['length2']; - if (!$this->endsAtSignedEofBoundary($content, $signedEnd)) { + if (!$this->endsAtSignedEofBoundary($content, $range['length2'])) { return DocumentModificationState::INVALID_EOF_BOUNDARY; } + return null; + } + private function detectDocumentModificationState(SignatureMetadata $metadata, string $content) : DocumentModificationState + { + $structuralIssue = $this->detectStructuralIssue($metadata, $content); + if ($structuralIssue instanceof DocumentModificationState) { + return $structuralIssue; + } + $range = $metadata->range; + if ($range === null) { + return DocumentModificationState::INVALID_BYTE_RANGE; + } + $signedEnd = $range['length2']; $unsignedContent = \substr($content, $signedEnd); if ($this->isOptionalEol($unsignedContent)) { return DocumentModificationState::UNCHANGED; diff --git a/composer/libresign/pdf-signature-validator/src/Parser/PdfSignatureValidator.php b/composer/libresign/pdf-signature-validator/src/Parser/PdfSignatureValidator.php index 18f8cfc..4d0b920 100644 --- a/composer/libresign/pdf-signature-validator/src/Parser/PdfSignatureValidator.php +++ b/composer/libresign/pdf-signature-validator/src/Parser/PdfSignatureValidator.php @@ -6,6 +6,7 @@ namespace OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException; +use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\DocumentModificationState; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ExtractedSignature; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\TimestampToken; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationReason; @@ -13,6 +14,14 @@ use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState; /** * Complete PDF signature validator. + * + * @psalm-type PdfSignatureValidationResult = array{ + * signature: ExtractedSignature, + * signatureValidation: ValidationResult, + * certificates: list, + * certificateValidation: ValidationResult, + * timestamp: ?TimestampToken, + * } * @internal */ final class PdfSignatureValidator @@ -22,18 +31,20 @@ final class PdfSignatureValidator private CertificateExtractor $certificateExtractor; private CmsTimestampExtractor $cmsTimestampExtractor; private PdfSignatureExtractor $extractor; + private PdfDocumentModificationAnalyzer $documentModificationAnalyzer; /** @var list */ private array $trustedRoots = []; /** * @param list|null $trustedRoots Optional trusted root certificates (PEM) */ - public function __construct(?SignatureValidator $signatureValidator = null, ?CertificateValidator $certificateValidator = null, ?CertificateExtractor $certificateExtractor = null, ?CmsTimestampExtractor $cmsTimestampExtractor = null, ?PdfSignatureExtractor $extractor = null, ?array $trustedRoots = null) + public function __construct(?SignatureValidator $signatureValidator = null, ?CertificateValidator $certificateValidator = null, ?CertificateExtractor $certificateExtractor = null, ?CmsTimestampExtractor $cmsTimestampExtractor = null, ?PdfSignatureExtractor $extractor = null, ?array $trustedRoots = null, ?PdfDocumentModificationAnalyzer $documentModificationAnalyzer = null) { $this->signatureValidator = $signatureValidator ?? new SignatureValidator(); $this->certificateValidator = $certificateValidator ?? new CertificateValidator(); $this->certificateExtractor = $certificateExtractor ?? new CertificateExtractor(); $this->cmsTimestampExtractor = $cmsTimestampExtractor ?? new CmsTimestampExtractor(); $this->extractor = $extractor ?? new PdfSignatureExtractor(); + $this->documentModificationAnalyzer = $documentModificationAnalyzer ?? new PdfDocumentModificationAnalyzer(); if ($trustedRoots !== null && $trustedRoots !== []) { $this->setTrustedRoots($trustedRoots); } @@ -63,7 +74,7 @@ public function getTrustedRoots() : array /** * @param resource $resource * @param list|null $trustedRoots - * @return list,certificateValidation:ValidationResult,timestamp:?TimestampToken}> + * @return list * @throws UnsignedPdfException */ public function validateFromResource($resource, ?array $trustedRoots = null) : array @@ -74,7 +85,7 @@ public function validateFromResource($resource, ?array $trustedRoots = null) : a } /** * @param list|null $trustedRoots - * @return list,certificateValidation:ValidationResult,timestamp:?TimestampToken}> + * @return list * @throws UnsignedPdfException */ public function validateFromString(string $pdfContent, ?array $trustedRoots = null) : array @@ -82,18 +93,34 @@ public function validateFromString(string $pdfContent, ?array $trustedRoots = nu $signatures = $this->extractor->extractFromString($pdfContent); $results = []; foreach ($signatures as $signature) { - if ($signature->binarySignature === null || $signature->binarySignature === '') { + $binarySignature = $signature->binarySignature; + if ($binarySignature === null || $binarySignature === '') { $results[] = ['signature' => $signature, 'signatureValidation' => new ValidationResult(ValidationState::NOT_VERIFIED, 'No binary signature', ValidationReason::NO_BINARY_SIGNATURE), 'certificates' => [], 'certificateValidation' => new ValidationResult(ValidationState::CERT_NOT_VERIFIED, 'No binary signature', ValidationReason::NO_BINARY_SIGNATURE), 'timestamp' => null]; continue; } - $signatureValidation = $this->signatureValidator->verifyDetachedCmsSignature($pdfContent, $signature->binarySignature, $signature->metadata->range); + $signatureValidation = $this->validateSignature($signature, $binarySignature, $pdfContent); /** @var list $certificates */ - $certificates = $this->certificateExtractor->extractCertificates($signature->binarySignature); + $certificates = $this->certificateExtractor->extractCertificates($binarySignature); $certValidation = $this->validateCertificateChain($certificates, $trustedRoots); - $results[] = ['signature' => $signature, 'signatureValidation' => $signatureValidation, 'certificates' => $certificates, 'certificateValidation' => $certValidation, 'timestamp' => $this->cmsTimestampExtractor->extract($signature->binarySignature)]; + $results[] = ['signature' => $signature, 'signatureValidation' => $signatureValidation, 'certificates' => $certificates, 'certificateValidation' => $certValidation, 'timestamp' => $this->cmsTimestampExtractor->extract($binarySignature)]; } return $results; } + private function validateSignature(ExtractedSignature $signature, string $binarySignature, string $pdfContent) : ValidationResult + { + $structuralIssue = $this->documentModificationAnalyzer->detectStructuralIssue($signature->metadata, $pdfContent); + if ($structuralIssue === DocumentModificationState::INVALID_BYTE_RANGE) { + return new ValidationResult(ValidationState::NOT_VERIFIED, 'Invalid PDF signature ByteRange', ValidationReason::INVALID_BYTE_RANGE); + } + if ($structuralIssue === DocumentModificationState::INVALID_EOF_BOUNDARY) { + return new ValidationResult(ValidationState::NOT_VERIFIED, 'Invalid signed PDF revision EOF boundary', ValidationReason::INVALID_EOF_BOUNDARY); + } + $subFilter = $signature->metadata->signatureType; + if (!\in_array($subFilter, ['adbe.pkcs7.detached', 'ETSI.CAdES.detached'], \true)) { + return new ValidationResult(ValidationState::NOT_VERIFIED, $subFilter === null ? 'PDF signature SubFilter is missing' : 'Unsupported PDF signature SubFilter: ' . $subFilter, ValidationReason::UNSUPPORTED_SUBFILTER); + } + return $this->signatureValidator->verifyDetachedCmsSignature($pdfContent, $binarySignature, $signature->metadata->range); + } /** * @param list $certificates * @param list|null $trustedRoots diff --git a/composer/libresign/pdf-signature-validator/tests/Unit/Parser/PdfSignatureStructureValidationTest.php b/composer/libresign/pdf-signature-validator/tests/Unit/Parser/PdfSignatureStructureValidationTest.php new file mode 100644 index 0000000..92401f3 --- /dev/null +++ b/composer/libresign/pdf-signature-validator/tests/Unit/Parser/PdfSignatureStructureValidationTest.php @@ -0,0 +1,96 @@ +validator = new PdfSignatureValidator(); + } + public function testRejectsStructurallyInvalidByteRangeBeforeCmsValidation() : void + { + $content = $this->signedPdfContent(); + $content = \preg_replace('/\\/ByteRange\\s*\\[\\s*0\\b/', '/ByteRange [1', $content, 1, $count); + $this->assertSame(1, $count); + $this->assertIsString($content); + $result = $this->validator->validateFromString($content); + $this->assertSame(ValidationState::NOT_VERIFIED, $result[0]['signatureValidation']->state); + $this->assertSame(ValidationReason::INVALID_BYTE_RANGE, $result[0]['signatureValidation']->reasonCode); + } + public function testRejectsSignedRevisionWithoutValidEofBoundary() : void + { + $content = $this->signedPdfContent(); + $this->assertSame(1, \preg_match('/\\/ByteRange\\s*\\[\\s*\\d+\\s+\\d+\\s+(\\d+)\\s+(\\d+)\\s*\\]/', $content, $matches)); + $signedEnd = (int) $matches[1] + (int) $matches[2]; + $signedRevision = \substr($content, 0, $signedEnd); + $eofOffset = \strrpos($signedRevision, '%%EOF'); + $this->assertNotFalse($eofOffset); + $content[$eofOffset + 2] = 'X'; + $result = $this->validator->validateFromString($content); + $this->assertSame(ValidationState::NOT_VERIFIED, $result[0]['signatureValidation']->state); + $this->assertSame(ValidationReason::INVALID_EOF_BOUNDARY, $result[0]['signatureValidation']->reasonCode); + } + public function testDoesNotValidateRfc3161AsDetachedCms() : void + { + $content = $this->replaceSubFilter($this->signedPdfContent(), 'ETSI.RFC3161'); + $this->assertUnsupportedSubFilter($content); + } + public function testDoesNotValidatePkcs7Sha1AsDetachedCms() : void + { + $content = $this->replaceSubFilter($this->signedPdfContent(), 'adbe.pkcs7.sha1'); + $this->assertUnsupportedSubFilter($content); + } + public function testDoesNotAssumeDetachedCmsWhenSubFilterIsMissing() : void + { + $content = $this->signedPdfContent(); + $content = \preg_replace_callback('/\\/SubFilter\\s*\\/[A-Za-z0-9.\\-_]+/', static fn(array $matches): string => \str_repeat(' ', \strlen($matches[0])), $content, 1, $count); + $this->assertSame(1, $count); + $this->assertIsString($content); + $this->assertUnsupportedSubFilter($content); + } + public function testStructuralFailureTakesPrecedenceOverUnsupportedSubFilter() : void + { + $content = $this->replaceSubFilter($this->signedPdfContent(), 'ETSI.RFC3161'); + $content = \preg_replace('/\\/ByteRange\\s*\\[\\s*0\\b/', '/ByteRange [1', $content, 1, $count); + $this->assertSame(1, $count); + $this->assertIsString($content); + $result = $this->validator->validateFromString($content); + $this->assertSame(ValidationReason::INVALID_BYTE_RANGE, $result[0]['signatureValidation']->reasonCode); + } + private function assertUnsupportedSubFilter(string $content) : void + { + $result = $this->validator->validateFromString($content); + $this->assertSame(ValidationState::NOT_VERIFIED, $result[0]['signatureValidation']->state); + $this->assertSame(ValidationReason::UNSUPPORTED_SUBFILTER, $result[0]['signatureValidation']->reasonCode); + } + private function replaceSubFilter(string $content, string $subFilter) : string + { + $updated = \preg_replace_callback('/\\/SubFilter\\s*\\/[A-Za-z0-9.\\-_]+/', static function (array $matches) use($subFilter) : string { + $replacement = '/SubFilter /' . $subFilter; + if (\strlen($replacement) > \strlen($matches[0])) { + throw new RuntimeException('Replacement SubFilter must not exceed the original length'); + } + return \str_pad($replacement, \strlen($matches[0])); + }, $content, 1, $count); + $this->assertSame(1, $count); + $this->assertIsString($updated); + return $updated; + } + private function signedPdfContent() : string + { + $content = \file_get_contents(__DIR__ . '/../../Fixtures/pdfs/small_valid-signed.pdf'); + $this->assertIsString($content); + return $content; + } +}