From 3177706ee39df56c7a0761eef108c5367c92a433 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:10 -0300 Subject: [PATCH 01/79] refactor(validation): expose PDF validator package data Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PdfSignatureValidationService.php | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/lib/Service/Signature/PdfSignatureValidationService.php b/lib/Service/Signature/PdfSignatureValidationService.php index e27b077b85..2acb4bbf9b 100644 --- a/lib/Service/Signature/PdfSignatureValidationService.php +++ b/lib/Service/Signature/PdfSignatureValidationService.php @@ -128,14 +128,27 @@ private function mapValidationResults(array $results): array { $mapped = []; foreach ($results as $result) { + $signature = $result['signature'] ?? null; $sigValidation = $result['signatureValidation'] ?? null; $certValidation = $result['certificateValidation'] ?? null; - if (!$sigValidation instanceof ValidationResult || !$certValidation instanceof ValidationResult) { + if ( + !$signature instanceof ExtractedSignature + || !$sigValidation instanceof ValidationResult + || !$certValidation instanceof ValidationResult + ) { continue; } + $certificates = $result['certificates'] ?? []; + if (!is_array($certificates)) { + $certificates = []; + } + $mapped[] = [ + 'signature' => $signature, + 'certificates' => array_values($certificates), + 'timestamp' => $result['timestamp'] ?? null, 'signatureValidation' => $this->mapSignatureValidation($sigValidation), 'certificateValidation' => $this->mapCertificateValidation($certValidation), 'raw' => [ @@ -160,28 +173,28 @@ private function mapSignatureValidation(ValidationResult $result): array { 'id' => 2, // TRANSLATORS User-facing status when signature cryptographic validation fails. 'label' => $this->l10n->t('Signature is invalid.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], ValidationState::DIGEST_MISMATCH => [ 'id' => 3, // TRANSLATORS User-facing status when signed digest does not match PDF content. 'label' => $this->l10n->t('Digest mismatch.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], ValidationState::NOT_VERIFIED => [ 'id' => 5, // TRANSLATORS User-facing status when validation could not be fully completed. 'label' => $this->l10n->t('Signature has not yet been verified.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], default => [ 'id' => 6, // TRANSLATORS Generic fallback status for unexpected signature validation failures. 'label' => $this->l10n->t('Unknown validation failure.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], }; @@ -199,47 +212,60 @@ private function mapCertificateValidation(ValidationResult $result): array { 'id' => 2, // TRANSLATORS User-facing status when issuing CA is known but not trusted. 'label' => $this->l10n->t("Certificate issuer isn't trusted."), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], ValidationState::CERT_ISSUER_UNKNOWN => [ 'id' => 3, // TRANSLATORS User-facing status when certificate issuer cannot be identified/trusted. 'label' => $this->l10n->t('Certificate issuer is unknown.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], ValidationState::CERT_REVOKED => [ 'id' => 4, // TRANSLATORS User-facing status when certificate is revoked. 'label' => $this->l10n->t('Certificate has been revoked.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], ValidationState::CERT_EXPIRED => [ 'id' => 5, // TRANSLATORS User-facing status when certificate is expired. 'label' => $this->l10n->t('Certificate has expired.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], ValidationState::CERT_NOT_VERIFIED => [ 'id' => 6, // TRANSLATORS User-facing status when certificate validation could not be completed. 'label' => $this->l10n->t('Certificate has not yet been verified.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], default => [ 'id' => 7, // TRANSLATORS Generic fallback status for unexpected certificate validation failures. 'label' => $this->l10n->t('Unknown issue with certificate or corrupted data.'), - 'reason' => $this->translateKnownReason($result->reason), + 'reason' => $this->translateValidationReason($result), 'isValid' => false, ], }; } + private function translateValidationReason(ValidationResult $result): ?string { + if ($result->reasonCode !== null) { + return match ($result->reasonCode) { + ValidationReason::NO_BYTE_RANGE => $this->l10n->t('No ByteRange in signature'), + ValidationReason::DIGEST_MISMATCH => $this->l10n->t('PDF content hash does not match signed digest'), + ValidationReason::NO_BINARY_SIGNATURE => $this->l10n->t('No binary signature'), + ValidationReason::SIGNATURE_CERTIFICATE_MISMATCH => $this->l10n->t('Signature does not match certificate'), + }; + } + + return $this->translateKnownReason($result->reason); + } + private function translateKnownReason(?string $reason): ?string { if ($reason === null || $reason === '') { return $reason; From 8796a04483135dbeb20be708c021c60491ed3746 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:18 -0300 Subject: [PATCH 02/79] refactor(signing): remove legacy PDF signature parsing Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Handler/SignEngine/Pkcs12Handler.php | 188 ++++++++++++----------- 1 file changed, 97 insertions(+), 91 deletions(-) diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index 4a5b13ed05..6a9900c384 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -18,8 +18,8 @@ use OCA\Libresign\Service\Crl\CrlService; use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\Signature\PdfSignatureValidationService; -use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException; -use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; +use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ExtractedSignature; +use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\TimestampToken; use OCA\Libresign\Vendor\phpseclib4\Exception\UnexpectedValueException; use OCA\Libresign\Vendor\phpseclib4\File\ASN1; use OCP\Files\File; @@ -46,7 +46,6 @@ public function __construct( private DocMdpHandler $docMdpHandler, private CrlService $crlService, private PdfSignatureValidationService $pdfSignatureValidationService, - private PdfSignatureExtractor $pdfSignatureExtractor, ) { parent::__construct($l10n, $folderService, $logger); } @@ -126,35 +125,49 @@ public function getCertificateChain($resource): array { return $certificates; } - private function processSignature($resource, ?string $signature, array $metadata = [], array $validation = []): array { - $result = []; - - if (!$signature) { - $result['chain'][0]['signature_validation'] = [ - 'id' => 3, - // TRANSLATORS Status label on LibreSign's public/document validation UI when the PDF signature hash does not match the document bytes (tamper or corrupt signature). - 'label' => $this->l10n->t('Digest mismatch.'), - ]; - return $result; + private function processSignature( + $resource, + ExtractedSignature $signature, + array $validation = [], + ): array { + $binarySignature = $signature->binarySignature; + if ($binarySignature === null || $binarySignature === '') { + return []; } + $result = []; + try { - $decoded = ASN1::decodeBER($signature); + $decoded = ASN1::decodeBER($binarySignature); } catch (UnexpectedValueException) { - return []; + $decoded = null; + } + + $result = $this->extractSigningTime($decoded, $result); + + $timestamp = $validation['timestamp'] ?? null; + if ($timestamp instanceof TimestampToken) { + $result['timestamp'] = $this->mapTimestampToken($timestamp); } - $result = $this->extractTimestampData($decoded, $result); - $chain = $this->extractCertificateChain($signature); + $pemCertificates = $validation['certificates'] ?? []; + if (!is_array($pemCertificates)) { + $pemCertificates = []; + } + + $chain = $this->extractCertificateChain($pemCertificates); if (!empty($chain)) { $result['chain'] = $this->orderCertificates($chain); - $result = $this->enrichLeafWithNativeData($result, $metadata, $validation); + $result = $this->enrichLeafWithNativeData( + $result, + $signature, + $validation, + ); } $result = $this->extractDocMdpData($resource, $result); - $result = $this->applyLibreSignRootCAFlag($result); - return $result; + return $this->applyLibreSignRootCAFlag($result); } private function applyLibreSignRootCAFlag(array $signer): array { @@ -187,55 +200,72 @@ private function extractDocMdpData($resource, array $result): array { return array_merge($result, $docMdpData); } - private function extractTimestampData(?array $decoded, array $result): array { + private function extractSigningTime(?array $decoded, array $result): array { if ($decoded === null) { return $result; } $tsa = new TSA(); - - $timestampData = $tsa->extract($decoded); - if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) { - $result['timestamp'] = $timestampData; + $signingTime = $tsa->getSigninTime($decoded); + if ($signingTime instanceof \DateTime) { + $result['signingTime'] = $signingTime; } - if (!isset($result['signingTime']) || !$result['signingTime'] instanceof \DateTime) { - $result['signingTime'] = $tsa->getSigninTime($decoded); - } return $result; } - private function extractCertificateChain(string $signature): array { - $pkcs7PemSignature = $this->der2pem($signature); - $pemCertificates = []; + private function mapTimestampToken(TimestampToken $timestamp): array { + $result = [ + 'genTime' => $timestamp->generatedAt, + 'policy' => $timestamp->policyOid, + 'serialNumber' => $timestamp->serialNumber, + ]; - if (!openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) { - return []; + $commonName = $timestamp->certificateSubject['CN'] ?? null; + if (is_string($commonName) && $commonName !== '') { + $result['tsaName'] = $commonName; } + return array_filter( + $result, + static fn (mixed $value): bool => $value !== null && $value !== '', + ); + } + + /** + * @param list $pemCertificates + */ + private function extractCertificateChain(array $pemCertificates): array { $chain = []; $isLibreSignRootCA = false; $certificateEngine = $this->getCertificateEngine(); foreach ($pemCertificates as $index => $pemCertificate) { + if (!is_string($pemCertificate) || $pemCertificate === '') { + continue; + } + $parsed = $certificateEngine->parseCertificate($pemCertificate); - if ($parsed) { - $parsed['signature_validation'] = [ - 'id' => 1, - // TRANSLATORS Status label on LibreSign signature validation when the cryptographic PDF signature checks out successfully. - 'label' => $this->l10n->t('Signature is valid.'), - ]; - if (!$isLibreSignRootCA) { - $isLibreSignRootCA = $this->isLibreSignRootCA($pemCertificate, $parsed); - } - $parsed['isLibreSignRootCA'] = $isLibreSignRootCA; - $chain[$index] = $parsed; + if (!$parsed) { + continue; } + + if (!$isLibreSignRootCA) { + $isLibreSignRootCA = $this->isLibreSignRootCA( + $pemCertificate, + $parsed, + ); + } + + $parsed['isLibreSignRootCA'] = $isLibreSignRootCA; + $chain[$index] = $parsed; } + if ($isLibreSignRootCA || $this->isLibreSignFile) { foreach ($chain as &$cert) { $cert['isLibreSignRootCA'] = true; } + unset($cert); } return $chain; @@ -302,24 +332,40 @@ private function getRootCertificatePem(): string { return $this->rootCertificatePem; } - private function enrichLeafWithNativeData(array $result, array $metadata, array $validation): array { + private function enrichLeafWithNativeData( + array $result, + ExtractedSignature $signature, + array $validation, + ): array { if (empty($result['chain'])) { return $result; } $leaf = &$result['chain'][0]; + $metadata = $signature->metadata; - foreach (['field', 'range', 'signature_type', 'signing_hash_algorithm', 'covers_entire_document'] as $key) { - if (array_key_exists($key, $metadata)) { - $leaf[$key] = $metadata[$key]; - } + $leaf['field'] = $metadata->field; + $leaf['range'] = $metadata->range; + $leaf['signature_type'] = $metadata->signatureType; + $leaf['signing_hash_algorithm'] = $signature->hashAlgorithm; + $leaf['covers_entire_document'] = $metadata->coversEntireDocument; + + if ($metadata->documentModificationState !== null) { + $leaf['document_modification_state'] + = $metadata->documentModificationState->value; } - if (isset($validation['signatureValidation']) && is_array($validation['signatureValidation'])) { + if ( + isset($validation['signatureValidation']) + && is_array($validation['signatureValidation']) + ) { $leaf['signature_validation'] = $validation['signatureValidation']; } - if (isset($validation['certificateValidation']) && is_array($validation['certificateValidation'])) { + if ( + isset($validation['certificateValidation']) + && is_array($validation['certificateValidation']) + ) { $leaf['certificate_validation'] = $validation['certificateValidation']; } @@ -334,46 +380,6 @@ private function enrichLeafWithNativeData(array $result, array $metadata, array return $result; } - /** - * @param resource $resource - * @return array - */ - private function extractNativeSignatureMetadata($resource): array { - rewind($resource); - $content = stream_get_contents($resource); - if (!is_string($content) || $content === '') { - return []; - } - - try { - $signatures = $this->extractNativeSignaturesFromContent($content); - } catch (UnsignedPdfException) { - return []; - } - $metadata = []; - - foreach ($signatures as $index => $signature) { - $metadata[$index] = [ - 'field' => $signature->metadata->field, - 'range' => $signature->metadata->range, - 'signature_type' => $signature->metadata->signatureType, - 'covers_entire_document' => $signature->metadata->coversEntireDocument, - ]; - } - - return $metadata; - } - - protected function extractNativeSignaturesFromContent(string $content): array { - return $this->pdfSignatureExtractor->extractFromString($content); - } - - private function der2pem($derData) { - $pem = chunk_split(base64_encode((string)$derData), 64, "\n"); - $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n"; - return $pem; - } - private function getHandler(): SignEngineHandler { $sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); $property = lcfirst($sign_engine) . 'Handler'; From 26b01526ef146eb47665226fa6392fe3cedbd38c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:28 -0300 Subject: [PATCH 03/79] feat(validation): expose PDF modification state on signers Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/File/CertificateSignersMergeService.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/Service/File/CertificateSignersMergeService.php b/lib/Service/File/CertificateSignersMergeService.php index 64812f4f3f..c0190290b8 100644 --- a/lib/Service/File/CertificateSignersMergeService.php +++ b/lib/Service/File/CertificateSignersMergeService.php @@ -406,6 +406,14 @@ private function enrichSignerWithCertificateValidation(\stdClass $signer, array if (isset($endEntityCert['covers_entire_document']) && !isset($signer->covers_entire_document)) { $signer->covers_entire_document = $endEntityCert['covers_entire_document']; } + + if ( + isset($endEntityCert['document_modification_state']) + && !isset($signer->document_modification_state) + ) { + $signer->document_modification_state + = $endEntityCert['document_modification_state']; + } } /** From 3e3b0a62a6fdb5a29dca0f00c933cecf3eaafaeb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:35 -0300 Subject: [PATCH 04/79] feat(api): add PDF modification state to signer response Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c593db46e5..5ca9ce1ba9 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -178,6 +178,7 @@ * sign_request_uuid?: string, * hash_algorithm?: string, * covers_entire_document?: bool, + * document_modification_state?: 'unchanged'|'unsigned_content'|'trailing_data'|'invalid_byte_range'|'invalid_eof_boundary', * me: bool, * status: 0|1|2, * signingOrder?: non-negative-int, From c6e7f6e56e37287340424bc96b42cebc39ba5084 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:44 -0300 Subject: [PATCH 05/79] feat(validation): show PDF modification states Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 37 +++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index 3dc14507f4..ab4b73ebe7 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -98,12 +98,12 @@ {{ getSignatureValidationMessage(signer) }} - + @@ -321,6 +321,7 @@ type SignerModel = { signature_validation?: ValidationState certificate_validation?: ValidationState covers_entire_document?: boolean + document_modification_state?: 'unchanged' | 'unsigned_content' | 'trailing_data' | 'invalid_byte_range' | 'invalid_eof_boundary' crl_validation?: string crl_revoked_at?: string docmdp?: SignerDocMdp @@ -409,6 +410,7 @@ function isRevokedBeforeSigning(signer: SignerModel) { function hasValidationIssues(signer: SignerModel) { return signer.signature_validation?.id !== 1 || signer.certificate_validation?.id !== 1 + || hasDocumentModificationWarning(signer) || isRevokedBeforeSigning(signer) } @@ -432,8 +434,10 @@ function getValidityStatus(signer: SignerModel) { } function hasValidationStatus(signer: SignerModel) { - return !!(signer.signature_validation || signer.certificate_validation || signer.crl_validation - || signer.covers_entire_document === false + return !!(signer.signature_validation + || signer.certificate_validation + || signer.crl_validation + || signer.document_modification_state || (signer.valid_from && signer.valid_to && signer.signed)) } @@ -444,9 +448,28 @@ function getSignatureValidationMessage(signer: SignerModel) { return signer.signature_validation?.message || t('libresign', 'Document integrity check failed') } -function getSignatureCoverageMessage() { - // TRANSLATORS Warning shown when extra bytes exist outside the PDF signature ByteRange. - return t('libresign', 'The signature does not cover the entire document') +function hasDocumentModificationWarning(signer: SignerModel) { + return !!signer.document_modification_state + && signer.document_modification_state !== 'unchanged' +} + +function getDocumentModificationMessage(signer: SignerModel) { + switch (signer.document_modification_state) { + case 'unsigned_content': + // TRANSLATORS Warning shown when content exists after the latest PDF signature. + return t('libresign', 'The document contains unsigned content after the latest signature') + case 'trailing_data': + // TRANSLATORS Warning shown when unexpected bytes exist after the final PDF EOF marker. + return t('libresign', 'Unexpected data was found after the final PDF end marker') + case 'invalid_byte_range': + // TRANSLATORS Warning shown when a PDF signature ByteRange is structurally invalid. + return t('libresign', 'The signature ByteRange is invalid') + case 'invalid_eof_boundary': + // TRANSLATORS Warning shown when signed PDF data does not end at a valid EOF boundary. + return t('libresign', 'The signed content does not end at a valid PDF end marker') + default: + return '' + } } function getCertificateTrustMessage(signer: SignerModel) { From 1fdbccdaa55f916de1763cfdf04e91fe4c9dc32a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:52 -0300 Subject: [PATCH 06/79] test(validation): cover structured PDF validation reasons Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PdfSignatureValidationServiceTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php index 137e1e1e9e..d3ec4f0bf7 100644 --- a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php +++ b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php @@ -11,6 +11,7 @@ use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCA\Libresign\Tests\Unit\TestCase; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException; +use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationReason; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationResult; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState; use OCP\IAppConfig; @@ -72,6 +73,24 @@ public function testMapReasonUsesDictionaryForKnownReason(): void { $this->assertSame('PDF content hash does not match signed digest', $result['reason']); } + public function testMapReasonUsesStructuredReasonCode(): void { + $service = $this->newServiceWithoutConstructor(); + $result = $this->invokePrivateMethod( + $service, + 'mapSignatureValidation', + new ValidationResult( + ValidationState::DIGEST_MISMATCH, + 'this text must not be used', + ValidationReason::DIGEST_MISMATCH, + ) + ); + + $this->assertSame( + 'PDF content hash does not match signed digest', + $result['reason'], + ); + } + public function testMapReasonKeepsUnknownReasonUntouched(): void { $service = $this->newServiceWithoutConstructor(); $result = $this->invokePrivateMethod( From d5947dcb702f9918de8c8bbe28dc12c57d288b1b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:22:58 -0300 Subject: [PATCH 07/79] test(validation): cover PDF modification state propagation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/File/CertificateSignersMergeServiceTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php index e7ad50ab76..4121b3d022 100644 --- a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php +++ b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php @@ -163,6 +163,7 @@ public function testMergePromotesLeafCertValidityDatesToSignerRoot(): void { 'chain' => [[ 'subject' => ['CN' => 'Signer User'], 'covers_entire_document' => false, + 'document_modification_state' => 'unsigned_content', 'validFrom_time_t' => 1769644731, 'validTo_time_t' => 1769731131, ]], @@ -185,6 +186,10 @@ public function testMergePromotesLeafCertValidityDatesToSignerRoot(): void { $this->assertSame('2026-01-28T23:58:51+00:00', $signer->chain[0]['valid_from']); $this->assertSame('2026-01-29T23:58:51+00:00', $signer->chain[0]['valid_to']); $this->assertFalse($signer->covers_entire_document); + $this->assertSame( + 'unsigned_content', + $signer->document_modification_state, + ); } public function testMergeDoesNotExportTopLevelTsaWithTimestampData(): void { From fb1094f8b0eda0ce0c7a3a6548dc923e36da667d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:23:04 -0300 Subject: [PATCH 08/79] test(signing): update PDF validation integration tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Handler/SignEngine/Pkcs12HandlerTest.php | 92 +++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php index 8e7d07921e..63feb63214 100644 --- a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php +++ b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php @@ -19,7 +19,6 @@ use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCA\Libresign\Tests\Fixtures\PdfFixtureCatalog; -use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IAppConfig; @@ -41,8 +40,8 @@ final class Pkcs12HandlerTest extends \OCA\Libresign\Tests\Unit\TestCase { private DocMdpHandler&MockObject $docMdpHandler; private CrlService&MockObject $crlService; private PdfSignatureValidationService&MockObject $pdfSignatureValidationService; - private PdfSignatureExtractor $pdfSignatureExtractor; private array $nativeValidation = []; + private ?\Throwable $nativeValidationException = null; private int $nativeValidationCalls = 0; #[\Override] @@ -58,11 +57,25 @@ public function setUp(): void { $this->crlService = $this->createMock(CrlService::class); $this->pdfSignatureValidationService = $this->createMock(PdfSignatureValidationService::class); $this->pdfSignatureValidationService->method('validateFromResource') - ->willReturnCallback(function (): array { + ->willReturnCallback(function ($resource): array { $this->nativeValidationCalls++; - return $this->nativeValidation; + + if ($this->nativeValidationException !== null) { + throw $this->nativeValidationException; + } + + if ($this->nativeValidation !== []) { + return $this->nativeValidation; + } + + $service = new PdfSignatureValidationService( + $this->appConfig, + $this->l10n, + $this->logger, + ); + + return $service->validateFromResource($resource); }); - $this->pdfSignatureExtractor = new PdfSignatureExtractor(); } private function getHandler(array $methods = []): Pkcs12Handler|MockObject { @@ -79,7 +92,6 @@ private function getHandler(array $methods = []): Pkcs12Handler|MockObject { $this->docMdpHandler, $this->crlService, $this->pdfSignatureValidationService, - $this->pdfSignatureExtractor, ]) ->onlyMethods($methods) ->getMock(); @@ -95,7 +107,6 @@ private function getHandler(array $methods = []): Pkcs12Handler|MockObject { $this->docMdpHandler, $this->crlService, $this->pdfSignatureValidationService, - $this->pdfSignatureExtractor, ); } @@ -416,29 +427,28 @@ public function testDocMdpPdfsExtraction(): void { } } - public function testPackageExtractorParsesFieldAndRange(): void { - $content = file_get_contents(__DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf'); - $this->assertIsString($content); - - $signatures = $this->pdfSignatureExtractor->extractFromString($content); - $this->assertCount(1, $signatures); + public function testGetCertificateChainProvidesNativePackageShape(): void { + $fixtureResource = fopen( + __DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', + 'r', + ); + $this->assertIsResource($fixtureResource); - $metadata = $signatures[0]->metadata; - $this->assertSame('Signature1', $metadata->field); - $this->assertSame([ - 'offset1' => 0, - 'length1' => 1311, - 'offset2' => 31313, - 'length2' => 32829, - ], $metadata->range); - } + $service = new PdfSignatureValidationService( + $this->appConfig, + $this->l10n, + $this->logger, + ); + $this->nativeValidation = $service->validateFromResource($fixtureResource); + fclose($fixtureResource); - public function testGetCertificateChainProvidesNativePackageShape(): void { - $this->nativeValidation = [ - [ - 'signatureValidation' => ['id' => 1, 'label' => 'Signature is valid.'], - 'certificateValidation' => ['id' => 3, 'label' => 'Certificate issuer is unknown.'], - ], + $this->nativeValidation[0]['signatureValidation'] = [ + 'id' => 1, + 'label' => 'Signature is valid.', + ]; + $this->nativeValidation[0]['certificateValidation'] = [ + 'id' => 3, + 'label' => 'Certificate issuer is unknown.', ]; $handler = $this->getHandler(); @@ -492,14 +502,24 @@ public function testGetCertificateChainUsesNativeValidationServiceForEachSignatu } public function testGetCertificateChainUsesNativeDigestMismatchValidation(): void { - $this->nativeValidation = [ - [ - 'signatureValidation' => [ - 'id' => 3, - 'label' => 'Digest mismatch.', - 'reason' => 'PDF content hash does not match signed digest', - ], - ], + $fixtureResource = fopen( + __DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', + 'r', + ); + $this->assertIsResource($fixtureResource); + + $service = new PdfSignatureValidationService( + $this->appConfig, + $this->l10n, + $this->logger, + ); + $this->nativeValidation = $service->validateFromResource($fixtureResource); + fclose($fixtureResource); + + $this->nativeValidation[0]['signatureValidation'] = [ + 'id' => 3, + 'label' => 'Digest mismatch.', + 'reason' => 'PDF content hash does not match signed digest', ]; $handler = $this->getHandler(); From f6b8293ba79c17ba02250821c5180a3f27f8f58f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:23:11 -0300 Subject: [PATCH 09/79] test(validation): cover PDF modification warnings Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../validation/SignerDetails.spec.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/tests/components/validation/SignerDetails.spec.ts b/src/tests/components/validation/SignerDetails.spec.ts index 5eaf8018d4..61666da7a3 100644 --- a/src/tests/components/validation/SignerDetails.spec.ts +++ b/src/tests/components/validation/SignerDetails.spec.ts @@ -547,9 +547,22 @@ describe('SignerDetails.vue - Business Logic', () => { }) }) - describe('getSignatureCoverageMessage method', () => { - it('reports bytes outside the signed ByteRange', () => { - expect(wrapper.vm.getSignatureCoverageMessage()).toBe('The signature does not cover the entire document') + describe('document modification messages', () => { + it.each([ + ['unsigned_content', 'The document contains unsigned content after the latest signature'], + ['trailing_data', 'Unexpected data was found after the final PDF end marker'], + ['invalid_byte_range', 'The signature ByteRange is invalid'], + ['invalid_eof_boundary', 'The signed content does not end at a valid PDF end marker'], + ])('maps %s to the expected warning', (state, expected) => { + expect(wrapper.vm.getDocumentModificationMessage({ + document_modification_state: state, + })).toBe(expected) + }) + + it('does not warn when the document is unchanged', () => { + expect(wrapper.vm.hasDocumentModificationWarning({ + document_modification_state: 'unchanged', + })).toBe(false) }) }) From 4984fbeb8dbe05cb84ec0a1237b1fdbaaae80875 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:23:31 -0300 Subject: [PATCH 10/79] chore(openapi): regenerate signer validation models Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 10 ++++++++++ openapi.json | 10 ++++++++++ src/types/openapi/openapi-full.ts | 2 ++ src/types/openapi/openapi.ts | 2 ++ 4 files changed, 24 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index aa616ec7c0..5edc01c32b 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2486,6 +2486,16 @@ "covers_entire_document": { "type": "boolean" }, + "document_modification_state": { + "type": "string", + "enum": [ + "unchanged", + "unsigned_content", + "trailing_data", + "invalid_byte_range", + "invalid_eof_boundary" + ] + }, "me": { "type": "boolean" }, diff --git a/openapi.json b/openapi.json index df13aa512b..02c03d1729 100644 --- a/openapi.json +++ b/openapi.json @@ -1887,6 +1887,16 @@ "covers_entire_document": { "type": "boolean" }, + "document_modification_state": { + "type": "string", + "enum": [ + "unchanged", + "unsigned_content", + "trailing_data", + "invalid_byte_range", + "invalid_eof_boundary" + ] + }, "me": { "type": "boolean" }, diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 51d9ae4d38..ad062c0c4a 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2229,6 +2229,8 @@ export type components = { sign_request_uuid?: string; hash_algorithm?: string; covers_entire_document?: boolean; + /** @enum {string} */ + document_modification_state?: "unchanged" | "unsigned_content" | "trailing_data" | "invalid_byte_range" | "invalid_eof_boundary"; me: boolean; /** * Format: int64 diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2da73b0bfc..a3dbf4509c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1564,6 +1564,8 @@ export type components = { sign_request_uuid?: string; hash_algorithm?: string; covers_entire_document?: boolean; + /** @enum {string} */ + document_modification_state?: "unchanged" | "unsigned_content" | "trailing_data" | "invalid_byte_range" | "invalid_eof_boundary"; me: boolean; /** * Format: int64 From d0f5582ecf51b7963dddaf27d435129a55f9dc5f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:25:07 -0300 Subject: [PATCH 11/79] test(signing): expect package certificate validation state Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php index 63feb63214..d2a091d31f 100644 --- a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php +++ b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php @@ -498,7 +498,7 @@ public function testGetCertificateChainUsesNativeValidationServiceForEachSignatu $this->assertSame(1, $this->nativeValidationCalls); $this->assertNotEmpty($result); $this->assertSame(1, $result[0]['chain'][0]['signature_validation']['id']); - $this->assertSame(3, $result[0]['chain'][0]['certificate_validation']['id']); + $this->assertSame(2, $result[0]['chain'][0]['certificate_validation']['id']); } public function testGetCertificateChainUsesNativeDigestMismatchValidation(): void { From a6cd4a814068840a9523c5e0002618903174d2e1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:25:27 -0300 Subject: [PATCH 12/79] fix(validation): expose PDF modification helpers Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index ab4b73ebe7..2d78f39d92 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -593,7 +593,8 @@ defineExpose({ getValidityStatus, hasValidationStatus, getSignatureValidationMessage, - getSignatureCoverageMessage, + hasDocumentModificationWarning, + getDocumentModificationMessage, getCertificateTrustMessage, getValidityStatusAtSigning, getCrlValidationIconPath, From 69d8201a30654ed881e9a0310adf29e2ecfca4b6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:52:51 -0300 Subject: [PATCH 13/79] fix(validation): update PDF validator result types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PdfSignatureValidationService.php | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/Service/Signature/PdfSignatureValidationService.php b/lib/Service/Signature/PdfSignatureValidationService.php index 2acb4bbf9b..07bc8f47ee 100644 --- a/lib/Service/Signature/PdfSignatureValidationService.php +++ b/lib/Service/Signature/PdfSignatureValidationService.php @@ -75,7 +75,17 @@ public function setTrustedRoots(array $certificates): void { * Validate PDF signatures from file resource. * * @param resource $resource PDF file resource - * @return list + * @return list, + * timestamp: ?TimestampToken, + * signatureValidation: array, + * certificateValidation: array, + * raw: array{ + * signature: ValidationResult, + * certificate: ValidationResult, + * }, + * }> */ public function validateFromResource($resource): array { try { @@ -91,7 +101,17 @@ public function validateFromResource($resource): array { * Validate PDF signatures from binary content. * * @param string $pdfContent Binary PDF content - * @return list + * @return list, + * timestamp: ?TimestampToken, + * signatureValidation: array, + * certificateValidation: array, + * raw: array{ + * signature: ValidationResult, + * certificate: ValidationResult, + * }, + * }> */ public function validateFromString(string $pdfContent): array { try { @@ -121,8 +141,24 @@ protected function validateNativeFromString(string $pdfContent): array { /** * Map validation results from PdfSignatureValidator to LibreSign format. * - * @param list $results Results from PdfSignatureValidator - * @return list + * @param list, + * certificateValidation: ValidationResult, + * timestamp: ?TimestampToken, + * }> $results Results from PdfSignatureValidator + * @return list, + * timestamp: ?TimestampToken, + * signatureValidation: array, + * certificateValidation: array, + * raw: array{ + * signature: ValidationResult, + * certificate: ValidationResult, + * }, + * }> */ private function mapValidationResults(array $results): array { $mapped = []; @@ -140,15 +176,10 @@ private function mapValidationResults(array $results): array { continue; } - $certificates = $result['certificates'] ?? []; - if (!is_array($certificates)) { - $certificates = []; - } - $mapped[] = [ 'signature' => $signature, - 'certificates' => array_values($certificates), - 'timestamp' => $result['timestamp'] ?? null, + 'certificates' => $result['certificates'], + 'timestamp' => $result['timestamp'], 'signatureValidation' => $this->mapSignatureValidation($sigValidation), 'certificateValidation' => $this->mapCertificateValidation($certValidation), 'raw' => [ From 8dbac50719524efecdff33b861aed852536b3a02 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:53:00 -0300 Subject: [PATCH 14/79] fix(validation): preserve PDF modification state types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/components/validation/SignerDetails.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/components/validation/SignerDetails.spec.ts b/src/tests/components/validation/SignerDetails.spec.ts index 61666da7a3..90c0bc664a 100644 --- a/src/tests/components/validation/SignerDetails.spec.ts +++ b/src/tests/components/validation/SignerDetails.spec.ts @@ -553,7 +553,7 @@ describe('SignerDetails.vue - Business Logic', () => { ['trailing_data', 'Unexpected data was found after the final PDF end marker'], ['invalid_byte_range', 'The signature ByteRange is invalid'], ['invalid_eof_boundary', 'The signed content does not end at a valid PDF end marker'], - ])('maps %s to the expected warning', (state, expected) => { + ] as const)('maps %s to the expected warning', (state, expected) => { expect(wrapper.vm.getDocumentModificationMessage({ document_modification_state: state, })).toBe(expected) From 3391657fad7f1060f693c1783843d7f2a9317386 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:09:46 -0300 Subject: [PATCH 15/79] fix(validation): preserve TSA certificate hints Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Handler/SignEngine/Pkcs12Handler.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index 6a9900c384..63456be171 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -219,16 +219,19 @@ private function mapTimestampToken(TimestampToken $timestamp): array { 'genTime' => $timestamp->generatedAt, 'policy' => $timestamp->policyOid, 'serialNumber' => $timestamp->serialNumber, + 'cnHints' => $timestamp->certificateSubject, ]; - $commonName = $timestamp->certificateSubject['CN'] ?? null; + $commonName = $timestamp->certificateSubject['commonName'] ?? null; if (is_string($commonName) && $commonName !== '') { $result['tsaName'] = $commonName; } return array_filter( $result, - static fn (mixed $value): bool => $value !== null && $value !== '', + static fn (mixed $value): bool => $value !== null + && $value !== '' + && $value !== [], ); } From 4ae38d067a4399b76c0be0f32a66ab307f9653c9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:11:29 -0300 Subject: [PATCH 16/79] fix(validation): isolate scoped vendor result types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PdfSignatureValidationService.php | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/Service/Signature/PdfSignatureValidationService.php b/lib/Service/Signature/PdfSignatureValidationService.php index 07bc8f47ee..dc891b1432 100644 --- a/lib/Service/Signature/PdfSignatureValidationService.php +++ b/lib/Service/Signature/PdfSignatureValidationService.php @@ -128,14 +128,36 @@ public function validateFromString(string $pdfContent): array { * @return list, certificateValidation: ValidationResult, timestamp: TimestampToken|null}> */ protected function validateNativeFromResource($resource): array { - return $this->validator->validateFromResource($resource); + /** @psalm-suppress UndefinedDocblockClass Vendor PHPDoc is not rewritten by PHP-Scoper. */ + $results = $this->validator->validateFromResource($resource); + + /** @var list, + * certificateValidation: ValidationResult, + * timestamp: ?TimestampToken, + * }> $results + */ + return $results; } /** * @return list, certificateValidation: ValidationResult, timestamp: TimestampToken|null}> */ protected function validateNativeFromString(string $pdfContent): array { - return $this->validator->validateFromString($pdfContent); + /** @psalm-suppress UndefinedDocblockClass Vendor PHPDoc is not rewritten by PHP-Scoper. */ + $results = $this->validator->validateFromString($pdfContent); + + /** @var list, + * certificateValidation: ValidationResult, + * timestamp: ?TimestampToken, + * }> $results + */ + return $results; } /** From 1946e6920f92f4315de72cf6f297a9e2212dbb77 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:26:58 -0300 Subject: [PATCH 17/79] refactor(validation): use validator result types directly Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PdfSignatureValidationService.php | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/lib/Service/Signature/PdfSignatureValidationService.php b/lib/Service/Signature/PdfSignatureValidationService.php index dc891b1432..07bc8f47ee 100644 --- a/lib/Service/Signature/PdfSignatureValidationService.php +++ b/lib/Service/Signature/PdfSignatureValidationService.php @@ -128,36 +128,14 @@ public function validateFromString(string $pdfContent): array { * @return list, certificateValidation: ValidationResult, timestamp: TimestampToken|null}> */ protected function validateNativeFromResource($resource): array { - /** @psalm-suppress UndefinedDocblockClass Vendor PHPDoc is not rewritten by PHP-Scoper. */ - $results = $this->validator->validateFromResource($resource); - - /** @var list, - * certificateValidation: ValidationResult, - * timestamp: ?TimestampToken, - * }> $results - */ - return $results; + return $this->validator->validateFromResource($resource); } /** * @return list, certificateValidation: ValidationResult, timestamp: TimestampToken|null}> */ protected function validateNativeFromString(string $pdfContent): array { - /** @psalm-suppress UndefinedDocblockClass Vendor PHPDoc is not rewritten by PHP-Scoper. */ - $results = $this->validator->validateFromString($pdfContent); - - /** @var list, - * certificateValidation: ValidationResult, - * timestamp: ?TimestampToken, - * }> $results - */ - return $results; + return $this->validator->validateFromString($pdfContent); } /** From fc6c24d431cc022b7b798837f282b9b8dae3a17c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:29:26 -0300 Subject: [PATCH 18/79] test(validation): preserve TSA certificate hints Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Handler/SignEngine/Pkcs12HandlerTest.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php index d2a091d31f..df462584fd 100644 --- a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php +++ b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php @@ -19,6 +19,7 @@ use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCA\Libresign\Tests\Fixtures\PdfFixtureCatalog; +use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\TimestampToken; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IAppConfig; @@ -487,6 +488,57 @@ public function testGetCertificateChainProvidesNativePackageShape(): void { $this->assertIsBool($leaf['covers_entire_document']); } + public function testGetCertificateChainMapsNativeTimestampData(): void { + $fixtureResource = fopen( + __DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', + 'r', + ); + $this->assertIsResource($fixtureResource); + + $service = new PdfSignatureValidationService( + $this->appConfig, + $this->l10n, + $this->logger, + ); + $this->nativeValidation = $service->validateFromResource($fixtureResource); + fclose($fixtureResource); + + $this->assertNotEmpty($this->nativeValidation); + + $generatedAt = new \DateTimeImmutable('2026-09-04T12:00:00+00:00'); + $this->nativeValidation[0]['timestamp'] = new TimestampToken( + $generatedAt, + '1.2.3.4', + '123456', + [ + 'commonName' => 'LibreSign Local TSA', + 'organizationName' => 'LibreCode', + ], + ); + + $resource = fopen( + __DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', + 'r', + ); + $this->assertIsResource($resource); + + $result = $this->getHandler()->getCertificateChain($resource); + fclose($resource); + + $this->assertNotEmpty($result); + $this->assertArrayHasKey('timestamp', $result[0]); + + $timestamp = $result[0]['timestamp']; + $this->assertSame($generatedAt, $timestamp['genTime']); + $this->assertSame('1.2.3.4', $timestamp['policy']); + $this->assertSame('123456', $timestamp['serialNumber']); + $this->assertSame('LibreSign Local TSA', $timestamp['tsaName']); + $this->assertSame([ + 'commonName' => 'LibreSign Local TSA', + 'organizationName' => 'LibreCode', + ], $timestamp['cnHints']); + } + public function testGetCertificateChainUsesNativeValidationServiceForEachSignature(): void { $handler = $this->getHandler(); $resource = fopen(__DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', 'r'); From f9f16f93903416ba46e278f25766a08fbc452d74 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:40:01 -0300 Subject: [PATCH 19/79] test(validation): cover complete validator results Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Handler/SignEngine/Pkcs12Handler.php | 6 +--- .../PdfSignatureValidationServiceTest.php | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index 63456be171..e1e7810ad6 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -220,13 +220,9 @@ private function mapTimestampToken(TimestampToken $timestamp): array { 'policy' => $timestamp->policyOid, 'serialNumber' => $timestamp->serialNumber, 'cnHints' => $timestamp->certificateSubject, + 'tsaName' => $timestamp->certificateSubject['commonName'] ?? null, ]; - $commonName = $timestamp->certificateSubject['commonName'] ?? null; - if (is_string($commonName) && $commonName !== '') { - $result['tsaName'] = $commonName; - } - return array_filter( $result, static fn (mixed $value): bool => $value !== null diff --git a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php index d3ec4f0bf7..c4e299ced0 100644 --- a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php +++ b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php @@ -102,6 +102,36 @@ public function testMapReasonKeepsUnknownReasonUntouched(): void { $this->assertSame('custom runtime detail', $result['reason']); } + public function testValidateFromStringPreservesAllValidationResults(): void { + $pdfContent = file_get_contents( + __DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf' + ); + $this->assertIsString($pdfContent); + + $service = new class($this->appConfig, $this->l10n, $this->logger) extends PdfSignatureValidationService { + protected function validateNativeFromString(string $pdfContent): array { + $results = parent::validateNativeFromString($pdfContent); + $this->assertNativeResultAvailable($results); + + return [$results[0], $results[0]]; + } + + private function assertNativeResultAvailable(array $results): void { + if ($results === []) { + throw new \RuntimeException('Expected signed PDF fixture.'); + } + } + }; + + $result = $service->validateFromString($pdfContent); + + $this->assertCount(2, $result); + $this->assertSame( + $result[0]['signature'], + $result[1]['signature'], + ); + } + public function testValidateFromStringReturnsEmptyListForUnsignedPdfException(): void { $this->logger->expects($this->never())->method('warning'); From baf94ee41e83dd49fa89d2d1640445e8b183f44d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:40:16 -0300 Subject: [PATCH 20/79] test(validation): cover certificate display name priority Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../CertificateSignersMergeServiceTest.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php index 4121b3d022..b5ffabd589 100644 --- a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php +++ b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php @@ -154,6 +154,36 @@ public static function providerCertificateInfoMatching(): array { ]; } + public function testMergePrefersCertificateNameOverSubjectCommonName(): void { + $fileData = new \stdClass(); + $fileData->signers = []; + + $certData = [[ + 'uid' => 'email:signer@example.com', + 'chain' => [[ + 'name' => 'Certificate Name', + 'subject' => [ + 'CN' => 'Subject Common Name', + ], + ]], + ]]; + + $this->getService()->merge( + $fileData, + $certData, + 'example.com', + 'Signed', + fn (array $cert, string $host): ?string => null, + fn (string $method, string $value): string => $method . ':' . $value, + fn (string $accountId): ?string => null, + ); + + $this->assertSame( + 'Certificate Name', + $fileData->signers[0]->chain[0]['displayName'], + ); + } + public function testMergePromotesLeafCertValidityDatesToSignerRoot(): void { $fileData = new \stdClass(); $fileData->signers = []; From e1510bef39c1acc6bb2c1478880b335579e16cef Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:04:50 -0300 Subject: [PATCH 21/79] test(validation): cover remaining mutation cases Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Handler/SignEngine/Pkcs12Handler.php | 7 ++- .../CertificateSignersMergeServiceTest.php | 42 ++++++++++++++ .../PdfSignatureValidationServiceTest.php | 56 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index e1e7810ad6..501ebd3c44 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -317,13 +317,16 @@ private function getRootCertificatePem(): string { return $this->rootCertificatePem; } $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path'); + $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem'; + if (empty($configPath) || !is_dir($configPath) - || !is_readable($configPath . DIRECTORY_SEPARATOR . 'ca.pem') + || !is_readable($caPemPath) ) { return ''; } - $rootCertificatePem = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem'); + + $rootCertificatePem = file_get_contents($caPemPath); if ($rootCertificatePem === false) { return ''; } diff --git a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php index b5ffabd589..f158c031b2 100644 --- a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php +++ b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php @@ -184,6 +184,48 @@ public function testMergePrefersCertificateNameOverSubjectCommonName(): void { ); } + public function testSingleContractSignerRequiresNumericSignRequestId(): void { + $service = $this->getService(); + $method = new \ReflectionMethod( + CertificateSignersMergeService::class, + 'getSingleContractSignerIndex', + ); + + $signers = [ + (object)[ + 'signRequestId' => 42, + ], + (object)[ + 'signRequestId' => 'not-numeric', + ], + ]; + + $this->assertSame( + 0, + $method->invoke($service, $signers), + ); + } + + public function testCertificateRootCaFlagIsPromotedToSigner(): void { + $service = $this->getService(); + $method = new \ReflectionMethod( + CertificateSignersMergeService::class, + 'enrichSignerWithCertificateValidation', + ); + + $signer = new \stdClass(); + + $method->invoke( + $service, + $signer, + [ + 'isLibreSignRootCA' => true, + ], + ); + + $this->assertTrue($signer->isLibreSignRootCA); + } + public function testMergePromotesLeafCertValidityDatesToSignerRoot(): void { $fileData = new \stdClass(); $fileData->signers = []; diff --git a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php index c4e299ced0..65aeeac047 100644 --- a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php +++ b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Tests\Unit\Service\Signature; +use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCA\Libresign\Tests\Unit\TestCase; use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException; @@ -132,6 +133,61 @@ private function assertNativeResultAvailable(array $results): void { ); } + public function testLoadsLibreSignCaCertificateFromConfiguredDirectory(): void { + $configPath = sys_get_temp_dir() + . DIRECTORY_SEPARATOR + . 'libresign-validator-ca-' + . bin2hex(random_bytes(8)); + + mkdir($configPath, 0700, true); + + $certificate = 'TEST_CA_CERTIFICATE'; + $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem'; + file_put_contents($caPemPath, $certificate); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig + ->method('getValueString') + ->willReturnCallback( + static function ( + string $appId, + string $key, + string $default = '', + ) use ($configPath): string { + if ($appId !== Application::APP_ID) { + return $default; + } + + return match ($key) { + 'config_path' => $configPath, + 'libresign_ca_certificate' => '', + default => $default, + }; + }, + ); + + try { + $service = new PdfSignatureValidationService( + $appConfig, + $this->l10n, + $this->logger, + ); + + $property = new \ReflectionProperty( + PdfSignatureValidationService::class, + 'libresignCaCertificate', + ); + + $this->assertSame( + $certificate, + $property->getValue($service), + ); + } finally { + @unlink($caPemPath); + @rmdir($configPath); + } + } + public function testValidateFromStringReturnsEmptyListForUnsignedPdfException(): void { $this->logger->expects($this->never())->method('warning'); From 9bef52daf9b9303ff10fc5d53c2164e82cccda4d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:40:15 -0300 Subject: [PATCH 22/79] fix(validation): wrap long detail text Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 49 ++++++++++++++------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index 2d78f39d92..b068dd9bcb 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -545,18 +545,24 @@ function hasDocMdpInfo(signer: SignerModel) { function getModificationStatusIcon(signer: SignerModel) { if (!signer.modification_validation) return undefined const status = signer.modification_validation.status - if (status === MODIFICATION_UNMODIFIED || status === MODIFICATION_ALLOWED) { + if (status === MODIFICATION_UNMODIFIED) { return mdiCheckCircle } - return mdiAlertCircle + if (status === MODIFICATION_ALLOWED) { + return mdiAlertCircle + } + return mdiCancel } function getModificationStatusClass(signer: SignerModel) { if (!signer.modification_validation) return '' const status = signer.modification_validation.status - if (status === MODIFICATION_UNMODIFIED || status === MODIFICATION_ALLOWED) { + if (status === MODIFICATION_UNMODIFIED) { return 'icon-success' } + if (status === MODIFICATION_ALLOWED) { + return 'icon-warning' + } return 'icon-error' } @@ -610,30 +616,41 @@ defineExpose({ From 21f1fbfe2603b30cf55d5e43c8ec3b1a461c32fb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:43:41 -0300 Subject: [PATCH 54/79] fix(validation): use plain glyphs for signer status badges Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 57 +++++++++++++-------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index 838e99db39..eca8edfd59 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -256,21 +256,7 @@ import { computed, ref } from 'vue' import CertificateChain from './CertificateChain.vue' import SignerTimestamp from './SignerTimestamp.vue' -import { - mdiAlertCircle, - mdiAlertCircleOutline, - mdiCancel, - mdiCheckCircle, - mdiCloseCircle, - mdiHelpCircle, - mdiInformationOutline, - mdiKey, - mdiShieldAlert, - mdiShieldCheck, - mdiShieldOff, - mdiUnfoldLessHorizontal, - mdiUnfoldMoreHorizontal, -} from '@mdi/js' +import { mdiAlertCircleOutline, mdiHelpCircle, mdiInformationOutline, mdiKey, mdiShieldAlert, mdiShieldCheck, mdiShieldOff, mdiUnfoldLessHorizontal, mdiUnfoldMoreHorizontal, mdiCheck, mdiAlert, mdiClose } from '@mdi/js' type ValidationState = { @@ -424,14 +410,14 @@ function getIconValidityPath(signer: SignerModel) { } if (signer.modification_validation?.status === MODIFICATION_VIOLATION || isRevokedBeforeSigning(signer)) { - return mdiCloseCircle + return mdiClose } if (hasDocumentModificationWarning(signer) || (signer.certificate_validation !== undefined && signer.certificate_validation.id !== 1)) { - return mdiAlertCircle + return mdiAlert } - return mdiCheckCircle + return mdiCheck } function getSignerValidationClass(signer: SignerModel) { @@ -574,12 +560,12 @@ function getModificationStatusIcon(signer: SignerModel) { if (!signer.modification_validation) return undefined const status = signer.modification_validation.status if (status === MODIFICATION_UNMODIFIED) { - return mdiCheckCircle + return mdiCheck } if (status === MODIFICATION_ALLOWED) { - return mdiAlertCircle + return mdiAlert } - return mdiCancel + return mdiClose } function getModificationStatusClass(signer: SignerModel) { @@ -675,12 +661,39 @@ defineExpose({ line-height: 1.4; } .signer-validation-icon { - display: flex; + display: inline-flex; align-items: center; justify-content: center; width: 44px; + min-width: 44px; height: 44px; + min-height: 44px; flex: 0 0 44px; + border-radius: 50%; + box-sizing: border-box; + background-color: transparent; + color: inherit; +} + +.signer-validation-icon :deep(.material-design-icon), +.signer-validation-icon :deep(svg) { + color: inherit !important; + fill: currentColor !important; +} + +.icon-success.signer-validation-icon { + background-color: rgba(var(--color-success-rgb), 0.18); + color: var(--color-success); +} + +.icon-warning.signer-validation-icon { + background-color: rgba(var(--color-warning-rgb), 0.18); + color: var(--color-warning); +} + +.icon-error.signer-validation-icon { + background-color: rgba(var(--color-error-rgb), 0.16); + color: var(--color-error); } .icon-success { From 13e7e97502ed146977311dd33b6fa6d6aa679a26 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:48:00 -0300 Subject: [PATCH 55/79] fix(validation): restore required status icons Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 32 ++++++++++++--------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index eca8edfd59..5fdc0185da 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -39,7 +39,7 @@