diff --git a/composer.json b/composer.json index 085c2a5985..062fe1a108 100644 --- a/composer.json +++ b/composer.json @@ -63,6 +63,7 @@ "require": { "cweagans/composer-patches": "^2.0", "jeidison/signer-php": "^1.0", + "libresign/pdf-signature-validator": "^0.2.0", "phpseclib/phpseclib": "^3.0" } } diff --git a/composer.lock b/composer.lock index d4a85967d6..46b5460282 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": "683fc6c9ae20a480af7b531acfea05bb", + "content-hash": "a0127fa8dfa35f6e73a6f5cd728c9b40", "packages": [ { "name": "cweagans/composer-configurable-plugin", @@ -198,6 +198,57 @@ }, "time": "2026-03-17T00:40:40+00:00" }, + { + "name": "libresign/pdf-signature-validator", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/LibreSign/pdf-signature-validator.git", + "reference": "8288a4648c8d738fe538f182bb8a9c6a123feb1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/LibreSign/pdf-signature-validator/zipball/8288a4648c8d738fe538f182bb8a9c6a123feb1a", + "reference": "8288a4648c8d738fe538f182bb8a9c6a123feb1a", + "shasum": "" + }, + "require": { + "php": "^8.2", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + } + }, + "autoload": { + "psr-4": { + "LibreSign\\PdfSignatureValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "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.2.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/libresign", + "type": "github" + } + ], + "time": "2026-04-24T14:34:32+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index ecc3d707a5..ba5b4932f2 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -457,7 +457,7 @@ private function fetchPreview( bool $forceIcon, string $mode, bool $mimeFallback = false, - ) : Http\Response { + ): Http\Response { if (!($node instanceof File) || (!$forceIcon && !$this->preview->isAvailable($node))) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -465,6 +465,15 @@ private function fetchPreview( return new DataResponse([], Http::STATUS_FORBIDDEN); } + // Avoid expensive external preview generators for PDFs when a mime fallback is explicitly requested. + if ($mimeFallback && $node->getMimeType() === 'application/pdf') { + $mimeFallbackResponse = $this->getMimeFallbackResponse($node->getMimeType()); + if ($mimeFallbackResponse !== null) { + /** @var Http\RedirectResponse $mimeFallbackResponse */ + return $mimeFallbackResponse; + } + } + $storage = $node->getStorage(); if ($storage->instanceOfStorage(SharedStorage::class)) { /** @var SharedStorage $storage */ @@ -483,19 +492,43 @@ private function fetchPreview( $response->cacheFor(3600 * 24, false, true); return $response; } catch (NotFoundException) { - // If we have no preview enabled, we can redirect to the mime icon if any - if ($mimeFallback) { - if ($url = $this->mimeIconProvider->getMimeIconUrl($node->getMimeType())) { - return new RedirectResponse($url); - } + $mimeFallbackResponse = $mimeFallback ? $this->getMimeFallbackResponse($node->getMimeType()) : null; + if ($mimeFallbackResponse !== null) { + /** @var Http\RedirectResponse $mimeFallbackResponse */ + return $mimeFallbackResponse; } return new DataResponse([], Http::STATUS_NOT_FOUND); } catch (\InvalidArgumentException) { return new DataResponse([], Http::STATUS_BAD_REQUEST); + } catch (\Throwable $e) { + $this->logger->warning('Failed to generate LibreSign thumbnail preview', [ + 'nodeId' => $node->getId(), + 'mimeType' => $node->getMimeType(), + 'exception' => $e, + ]); + + $mimeFallbackResponse = $mimeFallback ? $this->getMimeFallbackResponse($node->getMimeType()) : null; + if ($mimeFallbackResponse !== null) { + /** @var Http\RedirectResponse $mimeFallbackResponse */ + return $mimeFallbackResponse; + } + + return new DataResponse([], Http::STATUS_NOT_FOUND); } } + private function getMimeFallbackResponse(string $mimeType): ?\OCP\AppFramework\Http\RedirectResponse { + $url = $this->mimeIconProvider->getMimeIconUrl($mimeType); + if ($url) { + /** @var \OCP\AppFramework\Http\RedirectResponse $response */ + $response = new RedirectResponse($url, Http::STATUS_SEE_OTHER); + return $response; + } + + return null; + } + /** * Send a file * diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index c9335f3682..f4b0f2499c 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -8,7 +8,10 @@ namespace OCA\Libresign\Handler\SignEngine; -use DateTime; +use LibreSign\PdfSignatureValidator\Model\ValidationReason; +use LibreSign\PdfSignatureValidator\Model\ValidationResult; +use LibreSign\PdfSignatureValidator\Model\ValidationState; +use LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; @@ -18,17 +21,16 @@ use OCA\Libresign\Service\CaIdentifierService; use OCA\Libresign\Service\Crl\CrlService; use OCA\Libresign\Service\FolderService; +use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCP\Files\File; use OCP\IAppConfig; use OCP\IL10N; -use OCP\ITempManager; use phpseclib3\File\ASN1; use Psr\Log\LoggerInterface; class Pkcs12Handler extends SignEngineHandler { use OrderCertificatesTrait; protected string $certificate = ''; - private array $signaturesFromPoppler = []; private ?JSignPdfHandler $jSignPdfHandler = null; private ?PhpNativeHandler $phpNativeHandler = null; private string $rootCertificatePem = ''; @@ -40,11 +42,12 @@ public function __construct( protected CertificateEngineFactory $certificateEngineFactory, private IL10N $l10n, private FooterHandler $footerHandler, - private ITempManager $tempManager, private LoggerInterface $logger, private CaIdentifierService $caIdentifierService, private DocMdpHandler $docMdpHandler, private CrlService $crlService, + private PdfSignatureValidationService $pdfSignatureValidationService, + private PdfSignatureExtractor $pdfSignatureExtractor, ) { parent::__construct($l10n, $folderService, $logger); } @@ -92,13 +95,26 @@ public function setIsLibreSignFile(): void { #[\Override] public function getCertificateChain($resource): array { $certificates = []; + $nativeMetadata = array_values($this->extractNativeSignatureMetadata($resource)); + rewind($resource); + $nativeValidation = array_values($this->pdfSignatureValidationService->validateFromResource($resource)); + $index = 0; foreach ($this->getSignatures($resource) as $signature) { + $metadata = $nativeMetadata[$index] ?? []; + $validation = $nativeValidation[$index] ?? []; + $index++; + if (!$signature) { continue; } - $result = $this->processSignature($resource, $signature); + $result = $this->processSignature( + $resource, + $signature, + $metadata, + $validation + ); if (empty($result['chain'])) { continue; @@ -109,11 +125,14 @@ public function getCertificateChain($resource): array { return $certificates; } - private function processSignature($resource, ?string $signature): array { + private function processSignature($resource, ?string $signature, array $metadata = [], array $validation = []): array { $result = []; if (!$signature) { - $result['chain'][0]['signature_validation'] = $this->getReadableSigState('Digest Mismatch.'); + $result['chain'][0]['signature_validation'] = [ + 'id' => 3, + 'label' => $this->l10n->t('Digest mismatch.'), + ]; return $result; } @@ -123,7 +142,7 @@ private function processSignature($resource, ?string $signature): array { $chain = $this->extractCertificateChain($signature); if (!empty($chain)) { $result['chain'] = $this->orderCertificates($chain); - $result = $this->enrichLeafWithPopplerData($resource, $result); + $result = $this->enrichLeafWithNativeData($result, $metadata, $validation); } $result = $this->extractDocMdpData($resource, $result); @@ -275,218 +294,85 @@ private function getRootCertificatePem(): string { return $this->rootCertificatePem; } - private function enrichLeafWithPopplerData($resource, array $result): array { + private function enrichLeafWithNativeData(array $result, array $metadata, array $validation): array { if (empty($result['chain'])) { return $result; } - $popplerOnlyFields = ['field', 'range', 'certificate_validation']; - if (!isset($result['chain'][0]['subject'])) { - return $result; - } - $needPoppler = false; - foreach ($popplerOnlyFields as $field) { - if (empty($result['chain'][0][$field])) { - $needPoppler = true; - break; - } - } - if (!isset($result['chain'][0]['signature_validation']) || $result['chain'][0]['signature_validation']['id'] !== 1) { - $needPoppler = true; - } - if (!$needPoppler) { - return $result; - } - $popplerChain = $this->chainFromPoppler($result['chain'][0]['subject'], $resource); - if (empty($popplerChain)) { - return $result; - } - foreach ($popplerOnlyFields as $field) { - if (isset($popplerChain[$field])) { - $result['chain'][0][$field] = $popplerChain[$field]; - } - } - if (!isset($result['chain'][0]['signature_validation']) || $result['chain'][0]['signature_validation']['id'] !== 1) { - if (isset($popplerChain['signature_validation'])) { - $result['chain'][0]['signature_validation'] = $popplerChain['signature_validation']; - } - } - return $result; - } + $leaf = &$result['chain'][0]; - private function chainFromPoppler(array $subject, $resource): array { - $fromFallback = $this->popplerUtilsPdfSignFallback($resource); - foreach ($fromFallback as $popplerSig) { - if (!isset($popplerSig['chain'][0]['subject'])) { - continue; - } - if ($popplerSig['chain'][0]['subject'] == $subject) { - return $popplerSig['chain'][0]; + foreach (['field', 'range', 'signature_type', 'signing_hash_algorithm', 'covers_entire_document'] as $key) { + if (array_key_exists($key, $metadata)) { + $leaf[$key] = $metadata[$key]; } } - return []; - } - private function popplerUtilsPdfSignFallback($resource): array { - if (!empty($this->signaturesFromPoppler)) { - return $this->signaturesFromPoppler; - } - if (shell_exec('which pdfsig') === null) { - return $this->signaturesFromPoppler; - } - rewind($resource); - $content = stream_get_contents($resource); - $tempFile = $this->tempManager->getTemporaryFile('file.pdf'); - file_put_contents($tempFile, $content); + if (isset($validation['signatureValidation']) && is_array($validation['signatureValidation'])) { + $signatureValidation = $validation['signatureValidation']; - $content = shell_exec('env TZ=UTC pdfsig ' . $tempFile); - if (empty($content)) { - return $this->signaturesFromPoppler; - } - $lines = explode("\n", $content); - - $lastSignature = 0; - foreach ($lines as $item) { - $isFirstLevel = preg_match('/^Signature\s#(\d)/', $item, $match); - if ($isFirstLevel) { - $lastSignature = (int)$match[1] - 1; - $this->signaturesFromPoppler[$lastSignature] = []; - continue; + // Keep legacy OpenSSL result when native validator reports this known false-positive. + if (!$this->isDigestMismatchSignatureValidation($validation)) { + $leaf['signature_validation'] = $signatureValidation; } + } - $match = []; - $isSecondLevel = preg_match('/^\s+-\s(?.+):\s(?.*)/', $item, $match); - if ($isSecondLevel) { - switch ((string)$match['key']) { - case 'Signing Time': - $this->signaturesFromPoppler[$lastSignature]['signingTime'] = DateTime::createFromFormat('M d Y H:i:s', $match['value'], new \DateTimeZone('UTC')); - break; - case 'Signer full Distinguished Name': - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['subject'] = $this->parseDistinguishedNameWithMultipleValues($match['value']); - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['name'] = $match['value']; - break; - case 'Signing Hash Algorithm': - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['signatureTypeSN'] = $match['value']; - break; - case 'Signature Validation': - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['signature_validation'] = $this->getReadableSigState($match['value']); - break; - case 'Certificate Validation': - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['certificate_validation'] = $this->getReadableCertState($match['value']); - break; - case 'Signed Ranges': - if (preg_match('/\[(\d+) - (\d+)\], \[(\d+) - (\d+)\]/', $match['value'], $ranges)) { - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['range'] = [ - 'offset1' => (int)$ranges[1], - 'length1' => (int)$ranges[2], - 'offset2' => (int)$ranges[3], - 'length2' => (int)$ranges[4], - ]; - } - break; - case 'Signature Field Name': - $this->signaturesFromPoppler[$lastSignature]['chain'][0]['field'] = $match['value']; - break; - case 'Signature Validation': - case 'Signature Type': - case 'Total document signed': - case 'Not total document signed': - default: - break; - } - } + if (isset($validation['certificateValidation']) && is_array($validation['certificateValidation'])) { + $leaf['certificate_validation'] = $validation['certificateValidation']; } - return $this->signaturesFromPoppler; - } - private function getReadableSigState(string $status) { - return match ($status) { - 'Signature is Valid.' => [ - 'id' => 1, - 'label' => $this->l10n->t('Signature is valid.'), - ], - 'Signature is Invalid.' => [ - 'id' => 2, - 'label' => $this->l10n->t('Signature is invalid.'), - ], - 'Digest Mismatch.' => [ + if (!isset($leaf['certificate_validation'])) { + $leaf['certificate_validation'] = [ 'id' => 3, - 'label' => $this->l10n->t('Digest mismatch.'), - ], - "Document isn't signed or corrupted data." => [ - 'id' => 4, - 'label' => $this->l10n->t("Document isn't signed or corrupted data."), - ], - 'Signature has not yet been verified.' => [ - 'id' => 5, - 'label' => $this->l10n->t('Signature has not yet been verified.'), - ], - default => [ - 'id' => 6, - 'label' => $this->l10n->t('Unknown validation failure.'), - ], - }; + 'label' => $this->l10n->t('Certificate issuer is unknown.'), + ]; + } + + return $result; } + /** + * signer engines can produce signatures that the native validator currently flags as digest mismatch. + * In this case we preserve the legacy validation computed from the PKCS#7 signature. + */ + private function isDigestMismatchSignatureValidation(array $validation): bool { + $rawSignatureValidation = $validation['raw']['signature'] ?? null; + if ($rawSignatureValidation instanceof ValidationResult) { + return $rawSignatureValidation->reasonCode === ValidationReason::DIGEST_MISMATCH + || $rawSignatureValidation->state === ValidationState::DIGEST_MISMATCH; + } - private function getReadableCertState(string $status) { - return match ($status) { - 'Certificate is Trusted.' => [ - 'id' => 1, - 'label' => $this->l10n->t('Certificate is trusted.'), - ], - "Certificate issuer isn't Trusted." => [ - 'id' => 2, - 'label' => $this->l10n->t("Certificate issuer isn't trusted."), - ], - 'Certificate issuer is unknown.' => [ - 'id' => 3, - 'label' => $this->l10n->t('Certificate issuer is unknown.'), - ], - 'Certificate has been Revoked.' => [ - 'id' => 4, - 'label' => $this->l10n->t('Certificate has been revoked.'), - ], - 'Certificate has Expired' => [ - 'id' => 5, - 'label' => $this->l10n->t('Certificate has expired'), - ], - 'Certificate has not yet been verified.' => [ - 'id' => 6, - 'label' => $this->l10n->t('Certificate has not yet been verified.'), - ], - default => [ - 'id' => 7, - 'label' => $this->l10n->t('Unknown issue with Certificate or corrupted data.') - ], - }; + $signatureValidation = $validation['signatureValidation'] ?? null; + return is_array($signatureValidation) && ($signatureValidation['id'] ?? null) === 3; } + /** + * @param resource $resource + * @return array + */ + private function extractNativeSignatureMetadata($resource): array { + rewind($resource); + $content = stream_get_contents($resource); + if (!is_string($content) || $content === '') { + return []; + } - private function parseDistinguishedNameWithMultipleValues(string $dn): array { - $result = []; - $pairs = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $dn); + try { + $signatures = $this->pdfSignatureExtractor->extractFromString($content); + } catch (\Throwable) { + return []; + } + $metadata = []; - foreach ($pairs as $pair) { - [$key, $value] = explode('=', $pair, 2); - if (empty($key) || empty($value)) { - return $result; - } - $key = trim($key); - $value = trim($value); - $value = trim($value, '"'); - - if (!isset($result[$key])) { - $result[$key] = $value; - } else { - if (!is_array($result[$key])) { - $result[$key] = [$result[$key]]; - } - $result[$key][] = $value; - } + 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 $result; + return $metadata; } private function der2pem($derData) { diff --git a/lib/Service/Install/ConfigureCheckService.php b/lib/Service/Install/ConfigureCheckService.php index d4bf39a243..442c81fb78 100644 --- a/lib/Service/Install/ConfigureCheckService.php +++ b/lib/Service/Install/ConfigureCheckService.php @@ -74,47 +74,7 @@ public function checkSign(): array { } public function checkPoppler(): array { - $return = $this->checkPdfSig(); - $return = array_merge($return, $this->checkPdfinfo()); - return $return; - } - - public function checkPdfSig(): array { - if (!empty($this->result['poppler'])) { - return $this->result['poppler']; - } - // The output of this command go to STDERR and exec get the STDOUT - // With 2>&1 the STRERR is redirected to STDOUT - exec('pdfsig -v 2>&1', $version, $retval); - if ($retval !== 0) { - return $this->result['poppler'] = [ - (new ConfigureCheckHelper()) - ->setInfoMessage('Poppler utils not installed') - ->setResource('pdfsig') - ->setTip('Install the package poppler-utils at your operational system to be possible get more details about validation of signatures.'), - ]; - } - if (!$version) { - return $this->result['poppler'] = [ - (new ConfigureCheckHelper()) - ->setErrorMessage('Fail to retrieve pdfsig version') - ->setResource('pdfsig') - ->setTip("The command executed by PHP haven't any output."), - ]; - } - $returnValue = preg_match('/pdfsig version (?.*)/', implode(PHP_EOL, $version), $matches); - if ($returnValue !== 1) { - return $this->result['poppler'] = [ - (new ConfigureCheckHelper()) - ->setErrorMessage('Fail to retrieve pdfsig version') - ->setResource('pdfsig') - ->setTip("This is a poppler-utils dependency and wasn't possible to parse the output of command pdfsig -v"), - ]; - } - return $this->result['poppler'] = [(new ConfigureCheckHelper()) - ->setSuccessMessage('pdfsig version: ' . $matches['version']) - ->setResource('pdfsig') - ]; + return $this->checkPdfinfo(); } public function checkPdfinfo(): array { diff --git a/lib/Service/Signature/PdfSignatureValidationService.php b/lib/Service/Signature/PdfSignatureValidationService.php new file mode 100644 index 0000000000..ddabf3c65c --- /dev/null +++ b/lib/Service/Signature/PdfSignatureValidationService.php @@ -0,0 +1,304 @@ +validator = new PdfSignatureValidator(); + $this->loadLibreSignCaCertificate(); + } + + private function loadLibreSignCaCertificate(): void { + $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path'); + if (!empty($configPath) && is_dir($configPath)) { + $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem'; + if (is_readable($caPemPath)) { + $cert = @file_get_contents($caPemPath); + if ($cert !== false) { + $this->libresignCaCertificate = $cert; + $this->validator->addTrustedRoot($cert); + return; + } + } + } + + $alternateConfig = $this->appConfig->getValueString( + Application::APP_ID, + 'libresign_ca_certificate' + ); + if (!empty($alternateConfig)) { + $this->libresignCaCertificate = $alternateConfig; + $this->validator->addTrustedRoot($alternateConfig); + } + } + + public function addTrustedRoot(string $certificatePem): void { + $this->validator->addTrustedRoot($certificatePem); + } + + public function setTrustedRoots(array $certificates): void { + $this->validator->setTrustedRoots($certificates); + } + + /** + * Validate PDF signatures from file resource. + * + * @param resource $resource PDF file resource + * @return list + */ + public function validateFromResource($resource): array { + try { + $results = $this->validator->validateFromResource($resource); + return $this->mapValidationResults($results); + } catch (\Throwable $e) { + $this->logger->warning('PDF signature validation failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + return []; + } + } + + /** + * Validate PDF signatures from binary content. + * + * @param string $pdfContent Binary PDF content + * @return list + */ + public function validateFromString(string $pdfContent): array { + try { + $results = $this->validator->validateFromString($pdfContent); + return $this->mapValidationResults($results); + } catch (\Throwable $e) { + $this->logger->warning('PDF signature validation failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + return []; + } + } + + /** + * Map validation results from PdfSignatureValidator to LibreSign format. + * + * @param list $results Results from PdfSignatureValidator + * @return list + */ + private function mapValidationResults(array $results): array { + $mapped = []; + + foreach ($results as $result) { + $sigValidation = $result['signatureValidation'] ?? null; + $certValidation = $result['certificateValidation'] ?? null; + + if (!$sigValidation instanceof ValidationResult || !$certValidation instanceof ValidationResult) { + continue; + } + + $mapped[] = [ + 'signatureValidation' => $this->mapSignatureValidation($sigValidation), + 'certificateValidation' => $this->mapCertificateValidation($certValidation), + 'raw' => [ + 'signature' => $sigValidation, + 'certificate' => $certValidation, + ], + ]; + } + + return $mapped; + } + + private function mapSignatureValidation(ValidationResult $result): array { + return match ($result->state) { + ValidationState::SIGNATURE_VALID => [ + 'id' => 1, + // TRANSLATORS User-facing status when signature cryptographic validation succeeds. + 'label' => $this->l10n->t('Signature is valid.'), + 'isValid' => true, + ], + ValidationState::SIGNATURE_INVALID => [ + 'id' => 2, + // TRANSLATORS User-facing status when signature cryptographic validation fails. + 'label' => $this->l10n->t('Signature is invalid.'), + 'reason' => $this->translateKnownReason($result->reason), + '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), + '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), + '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), + 'isValid' => false, + ], + }; + } + + private function mapCertificateValidation(ValidationResult $result): array { + return match ($result->state) { + ValidationState::CERT_TRUSTED => [ + 'id' => 1, + // TRANSLATORS User-facing status when certificate is trusted. + 'label' => $this->l10n->t('Certificate is trusted.'), + 'isValid' => true, + ], + ValidationState::CERT_ISSUER_NOT_TRUSTED => [ + '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), + '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), + '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), + '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), + '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), + '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), + 'isValid' => false, + ], + }; + } + + private function translateKnownReason(?string $reason): ?string { + if ($reason === null || $reason === '') { + return $reason; + } + + if (preg_match('/^Intermediate certificate at position (\d+) is not signed by issuer$/', $reason, $matches) === 1) { + // TRANSLATORS %s is the numeric position of an intermediate certificate in the chain. + return $this->l10n->t( + 'Intermediate certificate at position %s is not signed by issuer', + [$matches[1]] + ); + } + + $prefix = 'Certificate validation failed: '; + if (str_starts_with($reason, $prefix)) { + $detail = substr($reason, strlen($prefix)); + $translatedDetail = $this->translateKnownReason($detail) ?? $detail; + // TRANSLATORS %s is a translated certificate validation detail message. + return $this->l10n->t('Certificate validation failed: %s', [$translatedDetail]); + } + + return match ($reason) { + // TRANSLATORS Technical term from PDF signatures. Keep "ByteRange" unchanged. + 'No ByteRange in signature' => $this->l10n->t('No ByteRange in signature'), + // TRANSLATORS Technical message for digest/hash mismatch in PDF signature verification. + 'PDF content hash does not match signed digest' => $this->l10n->t('PDF content hash does not match signed digest'), + // TRANSLATORS Certificate/public-key verification failed for signature bytes. + 'Signature does not match certificate' => $this->l10n->t('Signature does not match certificate'), + // TRANSLATORS X.509 certificate parsing failure. + 'Failed to parse certificate' => $this->l10n->t('Failed to parse certificate'), + // TRANSLATORS Signature timestamp is outside certificate validity window. + 'Certificate was not valid at time of signature' => $this->l10n->t('Certificate was not valid at time of signature'), + // TRANSLATORS Certificate validity date has ended. + 'Certificate has expired' => $this->l10n->t('Certificate has expired'), + // TRANSLATORS No certificates were found in provided certificate chain. + 'Empty certificate chain' => $this->l10n->t('Empty certificate chain'), + // TRANSLATORS Certificate does not provide a serial number field. + 'Certificate has no serial number' => $this->l10n->t('Certificate has no serial number'), + // TRANSLATORS CRL means Certificate Revocation List; keep acronym CRL unchanged. + 'Certificate found in CRL' => $this->l10n->t('Certificate found in CRL'), + // TRANSLATORS Certificate structure/content is invalid. + 'Invalid certificate' => $this->l10n->t('Invalid certificate'), + // TRANSLATORS CA means Certificate Authority; keep acronym CA unchanged. + 'Leaf certificate is marked as CA' => $this->l10n->t('Leaf certificate is marked as CA'), + // TRANSLATORS Certificate signature chain validation failed. + 'Certificate signature validation failed' => $this->l10n->t('Certificate signature validation failed'), + // TRANSLATORS Self-signed certificate is not present in trusted roots list. + 'Self-signed certificate not in trusted roots' => $this->l10n->t('Self-signed certificate not in trusted roots'), + // TRANSLATORS Root certificate must be self-signed to be considered a trust anchor. + 'Root certificate is not self-signed' => $this->l10n->t('Root certificate is not self-signed'), + // TRANSLATORS Root certificate is not present in configured trusted certificate list. + 'Root certificate is not in trusted list' => $this->l10n->t('Root certificate is not in trusted list'), + // TRANSLATORS Signature container has no binary signature payload. + 'No binary signature' => $this->l10n->t('No binary signature'), + // TRANSLATORS Signature payload has no embedded certificates. + 'No certificates in signature' => $this->l10n->t('No certificates in signature'), + // TRANSLATORS Certificate used for signing is expired. + 'Signing certificate has expired' => $this->l10n->t('Signing certificate has expired'), + // TRANSLATORS Certificate used for signing is revoked. + 'Signing certificate has been revoked' => $this->l10n->t('Signing certificate has been revoked'), + // TRANSLATORS Signature verification could not be fully completed. + 'Signature verification incomplete' => $this->l10n->t('Signature verification incomplete'), + default => $reason, + }; + } + + public function isLibreSignCaLoaded(): bool { + return !empty($this->libresignCaCertificate); + } + + public function getLibreSignCaCertificate(): string { + return $this->libresignCaCertificate; + } +} diff --git a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php index 0d867d6193..c2507d3aef 100644 --- a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php +++ b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php @@ -9,6 +9,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +use LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\DocMdpHandler; @@ -17,12 +18,12 @@ use OCA\Libresign\Service\CaIdentifierService; use OCA\Libresign\Service\Crl\CrlService; use OCA\Libresign\Service\FolderService; +use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCA\Libresign\Tests\Fixtures\PdfFixtureCatalog; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IAppConfig; use OCP\IL10N; -use OCP\ITempManager; use OCP\L10N\IFactory as IL10NFactory; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -33,12 +34,13 @@ final class Pkcs12HandlerTest extends \OCA\Libresign\Tests\Unit\TestCase { private IAppConfig $appConfig; private IL10N $l10n; private FooterHandler&MockObject $footerHandler; - private ITempManager $tempManager; private LoggerInterface&MockObject $logger; private CertificateEngineFactory&MockObject $certificateEngineFactory; private CaIdentifierService&MockObject $caIdentifierService; private DocMdpHandler&MockObject $docMdpHandler; private CrlService&MockObject $crlService; + private PdfSignatureValidationService&MockObject $pdfSignatureValidationService; + private PdfSignatureExtractor $pdfSignatureExtractor; public function setUp(): void { $this->folderService = $this->createMock(FolderService::class); @@ -46,11 +48,13 @@ public function setUp(): void { $this->certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); $this->l10n = \OCP\Server::get(IL10NFactory::class)->get(Application::APP_ID); $this->footerHandler = $this->createMock(FooterHandler::class); - $this->tempManager = \OCP\Server::get(ITempManager::class); $this->logger = $this->createMock(LoggerInterface::class); $this->caIdentifierService = $this->createMock(CaIdentifierService::class); $this->docMdpHandler = $this->createMock(DocMdpHandler::class); $this->crlService = $this->createMock(CrlService::class); + $this->pdfSignatureValidationService = $this->createMock(PdfSignatureValidationService::class); + $this->pdfSignatureValidationService->method('validateFromResource')->willReturn([]); + $this->pdfSignatureExtractor = new PdfSignatureExtractor(); } private function getHandler(array $methods = []): Pkcs12Handler|MockObject { @@ -62,11 +66,12 @@ private function getHandler(array $methods = []): Pkcs12Handler|MockObject { $this->certificateEngineFactory, $this->l10n, $this->footerHandler, - $this->tempManager, $this->logger, $this->caIdentifierService, $this->docMdpHandler, $this->crlService, + $this->pdfSignatureValidationService, + $this->pdfSignatureExtractor, ]) ->onlyMethods($methods) ->getMock(); @@ -77,11 +82,12 @@ private function getHandler(array $methods = []): Pkcs12Handler|MockObject { $this->certificateEngineFactory, $this->l10n, $this->footerHandler, - $this->tempManager, $this->logger, $this->caIdentifierService, $this->docMdpHandler, $this->crlService, + $this->pdfSignatureValidationService, + $this->pdfSignatureExtractor, ); } @@ -380,4 +386,106 @@ 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); + + $metadata = $signatures[0]->metadata; + $this->assertSame('Signature1', $metadata->field); + $this->assertSame([ + 'offset1' => 0, + 'length1' => 1311, + 'offset2' => 31313, + 'length2' => 32829, + ], $metadata->range); + } + + public function testGetCertificateChainProvidesNativePackageShape(): void { + $this->pdfSignatureValidationService->method('validateFromResource') + ->willReturn([ + [ + 'signatureValidation' => ['id' => 1, 'label' => 'Signature is valid.'], + 'certificateValidation' => ['id' => 3, 'label' => 'Certificate issuer is unknown.'], + ], + ]); + + $handler = $this->getHandler(); + $resource = fopen(__DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', 'r'); + $this->assertIsResource($resource); + + $result = $handler->getCertificateChain($resource); + fclose($resource); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('signingTime', $result[0]); + $this->assertInstanceOf(\DateTime::class, $result[0]['signingTime']); + + $this->assertArrayHasKey('chain', $result[0]); + $this->assertNotEmpty($result[0]['chain']); + + $leaf = $result[0]['chain'][0]; + $this->assertArrayHasKey('field', $leaf); + $this->assertEquals('Signature1', $leaf['field']); + $this->assertArrayHasKey('range', $leaf); + $this->assertSame([ + 'offset1' => 0, + 'length1' => 1311, + 'offset2' => 31313, + 'length2' => 32829, + ], $leaf['range']); + + $this->assertArrayHasKey('signature_validation', $leaf); + $this->assertEquals(1, $leaf['signature_validation']['id']); + + $this->assertArrayHasKey('certificate_validation', $leaf); + $this->assertSame(3, $leaf['certificate_validation']['id']); + $this->assertArrayHasKey('signature_type', $leaf); + $this->assertNotEmpty($leaf['signature_type']); + $this->assertArrayHasKey('covers_entire_document', $leaf); + $this->assertIsBool($leaf['covers_entire_document']); + } + + public function testGetCertificateChainUsesNativeValidationServiceForEachSignature(): void { + $this->pdfSignatureValidationService->expects($this->once()) + ->method('validateFromResource'); + + $handler = $this->getHandler(); + $resource = fopen(__DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', 'r'); + $this->assertIsResource($resource); + + $result = $handler->getCertificateChain($resource); + fclose($resource); + + $this->assertNotEmpty($result); + $this->assertSame(1, $result[0]['chain'][0]['signature_validation']['id']); + $this->assertSame(3, $result[0]['chain'][0]['certificate_validation']['id']); + } + + public function testGetCertificateChainDoesNotOverrideLegacySignatureValidationOnDigestMismatch(): void { + $this->pdfSignatureValidationService->method('validateFromResource') + ->willReturn([ + [ + 'signatureValidation' => [ + 'id' => 3, + 'label' => 'Digest mismatch.', + 'reason' => 'PDF content hash does not match signed digest', + ], + ], + ]); + + $handler = $this->getHandler(); + $resource = fopen(__DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', 'r'); + $this->assertIsResource($resource); + + $result = $handler->getCertificateChain($resource); + fclose($resource); + + $this->assertNotEmpty($result); + $this->assertSame(1, $result[0]['chain'][0]['signature_validation']['id']); + $this->assertSame('Signature is valid.', $result[0]['chain'][0]['signature_validation']['label']); + } + } diff --git a/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php b/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php index f335561a32..4cd30cc43c 100644 --- a/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php +++ b/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Tests\Unit\Service\IdentifyMethod; +use LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Enum\CrlValidationStatus; use OCA\Libresign\Exception\LibresignException; @@ -20,9 +21,9 @@ use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\IdentifyMethod\IdentifyService; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\Password; +use OCA\Libresign\Service\Signature\PdfSignatureValidationService; use OCP\IAppConfig; use OCP\IL10N; -use OCP\ITempManager; use OCP\IUserSession; use OCP\L10N\IFactory as IL10NFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -38,11 +39,12 @@ final class PasswordTest extends \OCA\Libresign\Tests\Unit\TestCase { private CertificateEngineFactory&MockObject $certificateEngineFactory; private IL10N $l10n; private FooterHandler&MockObject $footerHandler; - private ITempManager $tempManager; private LoggerInterface&MockObject $logger; private CaIdentifierService&MockObject $caIdentifierService; private DocMdpHandler&MockObject $docMdpHandler; private CrlService&MockObject $crlService; + private PdfSignatureValidationService&MockObject $pdfSignatureValidationService; + private PdfSignatureExtractor $pdfSignatureExtractor; public function setUp(): void { $this->identifyService = $this->createMock(IdentifyService::class); @@ -51,12 +53,13 @@ public function setUp(): void { $this->certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); $this->l10n = \OCP\Server::get(IL10NFactory::class)->get(Application::APP_ID); $this->footerHandler = $this->createMock(FooterHandler::class); - $this->tempManager = \OCP\Server::get(ITempManager::class); $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->caIdentifierService = $this->createMock(CaIdentifierService::class); $this->docMdpHandler = $this->createMock(DocMdpHandler::class); $this->crlService = $this->createMock(CrlService::class); + $this->pdfSignatureValidationService = $this->createMock(PdfSignatureValidationService::class); + $this->pdfSignatureExtractor = new PdfSignatureExtractor(); $this->pkcs12Handler = $this->getPkcs12Instance(); } @@ -79,11 +82,12 @@ private function getPkcs12Instance(array $methods = []) { $this->certificateEngineFactory, $this->l10n, $this->footerHandler, - $this->tempManager, $this->logger, $this->caIdentifierService, $this->docMdpHandler, $this->crlService, + $this->pdfSignatureValidationService, + $this->pdfSignatureExtractor, ]) ->onlyMethods($methods) ->getMock(); diff --git a/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php new file mode 100644 index 0000000000..6101860832 --- /dev/null +++ b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php @@ -0,0 +1,99 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n + ->method('t') + ->willReturnCallback(static fn (string $text): string => $text); + } + + public function testMapSignatureValidationWithEnumState(): void { + $service = $this->newServiceWithoutConstructor(); + $result = $this->invokePrivateMethod( + $service, + 'mapSignatureValidation', + new ValidationResult(ValidationState::DIGEST_MISMATCH, 'hash mismatch') + ); + + $this->assertSame(3, $result['id']); + $this->assertSame('Digest mismatch.', $result['label']); + $this->assertSame('hash mismatch', $result['reason']); + $this->assertFalse($result['isValid']); + } + + public function testMapCertificateValidationWithEnumState(): void { + $service = $this->newServiceWithoutConstructor(); + $result = $this->invokePrivateMethod( + $service, + 'mapCertificateValidation', + new ValidationResult(ValidationState::CERT_TRUSTED) + ); + + $this->assertSame(1, $result['id']); + $this->assertSame('Certificate is trusted.', $result['label']); + $this->assertTrue($result['isValid']); + } + + public function testMapReasonUsesDictionaryForKnownReason(): void { + $service = $this->newServiceWithoutConstructor(); + $result = $this->invokePrivateMethod( + $service, + 'mapSignatureValidation', + new ValidationResult(ValidationState::DIGEST_MISMATCH, 'PDF content hash does not match signed digest') + ); + + $this->assertSame('PDF content hash does not match signed digest', $result['reason']); + } + + public function testMapReasonKeepsUnknownReasonUntouched(): void { + $service = $this->newServiceWithoutConstructor(); + $result = $this->invokePrivateMethod( + $service, + 'mapSignatureValidation', + new ValidationResult(ValidationState::DIGEST_MISMATCH, 'custom runtime detail') + ); + + $this->assertSame('custom runtime detail', $result['reason']); + } + + private function newServiceWithoutConstructor(): PdfSignatureValidationService { + $reflection = new \ReflectionClass(PdfSignatureValidationService::class); + /** @var PdfSignatureValidationService $service */ + $service = $reflection->newInstanceWithoutConstructor(); + + $l10nProperty = $reflection->getProperty('l10n'); + $l10nProperty->setValue($service, $this->l10n); + + return $service; + } + + /** + * @return array + */ + private function invokePrivateMethod(PdfSignatureValidationService $service, string $method, ValidationResult $result): array { + $reflection = new \ReflectionClass($service); + $target = $reflection->getMethod($method); + /** @var array $mapped */ + $mapped = $target->invoke($service, $result); + return $mapped; + } +}