Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions composer/composer/installed.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand All @@ -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": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@
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;
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationResult;
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState;
/**
* Complete PDF signature validator.
*
* @psalm-type PdfSignatureValidationResult = array{
* signature: ExtractedSignature,
* signatureValidation: ValidationResult,
* certificates: list<string>,
* certificateValidation: ValidationResult,
* timestamp: ?TimestampToken,
* }
* @internal
*/
final class PdfSignatureValidator
Expand All @@ -22,18 +31,20 @@ final class PdfSignatureValidator
private CertificateExtractor $certificateExtractor;
private CmsTimestampExtractor $cmsTimestampExtractor;
private PdfSignatureExtractor $extractor;
private PdfDocumentModificationAnalyzer $documentModificationAnalyzer;
/** @var list<string> */
private array $trustedRoots = [];
/**
* @param list<string>|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);
}
Expand Down Expand Up @@ -63,7 +74,7 @@ public function getTrustedRoots() : array
/**
* @param resource $resource
* @param list<string>|null $trustedRoots
* @return list<array{signature:ExtractedSignature,signatureValidation:ValidationResult,certificates:list<string>,certificateValidation:ValidationResult,timestamp:?TimestampToken}>
* @return list<PdfSignatureValidationResult>
* @throws UnsignedPdfException
*/
public function validateFromResource($resource, ?array $trustedRoots = null) : array
Expand All @@ -74,26 +85,42 @@ public function validateFromResource($resource, ?array $trustedRoots = null) : a
}
/**
* @param list<string>|null $trustedRoots
* @return list<array{signature:ExtractedSignature,signatureValidation:ValidationResult,certificates:list<string>,certificateValidation:ValidationResult,timestamp:?TimestampToken}>
* @return list<PdfSignatureValidationResult>
* @throws UnsignedPdfException
*/
public function validateFromString(string $pdfContent, ?array $trustedRoots = null) : array
{
$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<string> $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<string> $certificates
* @param list<string>|null $trustedRoots
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
declare (strict_types=1);
namespace OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Tests\Unit\Parser;

use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationReason;
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState;
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator;
use OCA\Libresign\Vendor\PHPUnit\Framework\TestCase;
use RuntimeException;
/** @internal */
final class PdfSignatureStructureValidationTest extends TestCase
{
private PdfSignatureValidator $validator;
protected function setUp() : void
{
$this->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;
}
}