diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 5d4d291cde..0e6bb3659b 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -479,7 +479,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); } @@ -487,6 +487,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 */ @@ -505,19 +514,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 19df46f98c..594c5b014c 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Handler\SignEngine; -use DateTime; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; @@ -18,17 +17,21 @@ 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\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 OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCA\Libresign\Vendor\phpseclib3\File\ASN1; use OCP\Files\File; use OCP\IAppConfig; use OCP\IL10N; -use OCP\ITempManager; 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 = ''; @@ -41,11 +44,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); } @@ -110,12 +114,26 @@ public function getCertificateChain($resource): array { $certificateEngine->setPolicyUserIdForValidation($this->policyUserIdForValidation); try { + $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; @@ -131,11 +149,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; } @@ -145,7 +166,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); @@ -297,216 +318,89 @@ 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; + $leaf = &$result['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]; } } - 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($validation['signatureValidation']) && is_array($validation['signatureValidation'])) { + $signatureValidation = $validation['signatureValidation']; + + // Keep legacy OpenSSL result when native validator reports this known false-positive. + if (!$this->isDigestMismatchSignatureValidation($validation)) { + $leaf['signature_validation'] = $signatureValidation; } } - 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']; - } + + if (isset($validation['certificateValidation']) && is_array($validation['certificateValidation'])) { + $leaf['certificate_validation'] = $validation['certificateValidation']; } + + if (!isset($leaf['certificate_validation'])) { + $leaf['certificate_validation'] = [ + 'id' => 3, + 'label' => $this->l10n->t('Certificate issuer is unknown.'), + ]; + } + return $result; } - 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]; - } + /** + * 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; } - return []; + + $signatureValidation = $validation['signatureValidation'] ?? null; + return is_array($signatureValidation) && ($signatureValidation['id'] ?? null) === 3; } - private function popplerUtilsPdfSignFallback($resource): array { - if (!empty($this->signaturesFromPoppler)) { - return $this->signaturesFromPoppler; - } - if (shell_exec('which pdfsig') === null) { - return $this->signaturesFromPoppler; - } + /** + * @param resource $resource + * @return array + */ + private function extractNativeSignatureMetadata($resource): array { rewind($resource); $content = stream_get_contents($resource); - $tempFile = $this->tempManager->getTemporaryFile('file.pdf'); - file_put_contents($tempFile, $content); - - $content = shell_exec('env TZ=UTC pdfsig ' . $tempFile); - if (empty($content)) { - return $this->signaturesFromPoppler; + if (!is_string($content) || $content === '') { + return []; } - $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; - } - $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; - } - } + try { + $signatures = $this->extractNativeSignaturesFromContent($content); + } catch (UnsignedPdfException) { + return []; } - return $this->signaturesFromPoppler; - } + $metadata = []; - 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.' => [ - '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.'), - ], - }; - } + 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, + ]; + } - 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.') - ], - }; + return $metadata; } - private function parseDistinguishedNameWithMultipleValues(string $dn): array { - $result = []; - $pairs = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $dn); - - 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; - } - } - - return $result; + protected function extractNativeSignaturesFromContent(string $content): array { + return $this->pdfSignatureExtractor->extractFromString($content); } private function der2pem($derData) { diff --git a/lib/Service/Signature/PdfSignatureValidationService.php b/lib/Service/Signature/PdfSignatureValidationService.php new file mode 100644 index 0000000000..2a222deb4c --- /dev/null +++ b/lib/Service/Signature/PdfSignatureValidationService.php @@ -0,0 +1,314 @@ +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->validateNativeFromResource($resource); + } catch (UnsignedPdfException) { + return []; + } + + return $this->mapValidationResults($results); + } + + /** + * Validate PDF signatures from binary content. + * + * @param string $pdfContent Binary PDF content + * @return list + */ + public function validateFromString(string $pdfContent): array { + try { + $results = $this->validateNativeFromString($pdfContent); + } catch (UnsignedPdfException) { + return []; + } + + return $this->mapValidationResults($results); + } + + /** + * @param resource $resource + * @return list, certificateValidation: ValidationResult}> + */ + protected function validateNativeFromResource($resource): array { + return $this->validator->validateFromResource($resource); + } + + /** + * @return list, certificateValidation: ValidationResult}> + */ + protected function validateNativeFromString(string $pdfContent): array { + return $this->validator->validateFromString($pdfContent); + } + + /** + * 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/Api/Controller/AccountControllerTest.php b/tests/php/Api/Controller/AccountControllerTest.php index b529035a0f..8040be6fe9 100644 --- a/tests/php/Api/Controller/AccountControllerTest.php +++ b/tests/php/Api/Controller/AccountControllerTest.php @@ -15,9 +15,10 @@ 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\Api\ApiTestCase; +use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCP\IAppConfig; -use OCP\ITempManager; use OCP\L10N\IFactory as IL10NFactory; use Psr\Log\LoggerInterface; @@ -108,11 +109,12 @@ private function getPkcs12HandlerWithStubbedGeneration(): Pkcs12Handler { \OCP\Server::get(CertificateEngineFactory::class), \OCP\Server::get(IL10NFactory::class)->get('libresign'), \OCP\Server::get(FooterHandler::class), - \OCP\Server::get(ITempManager::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(CaIdentifierService::class), \OCP\Server::get(DocMdpHandler::class), \OCP\Server::get(CrlService::class), + \OCP\Server::get(PdfSignatureValidationService::class), + \OCP\Server::get(PdfSignatureExtractor::class), ]) ->onlyMethods(['generateCertificate']) ->getMock(); diff --git a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php index a28b230ecf..2569278191 100644 --- a/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php +++ b/tests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.php @@ -18,12 +18,13 @@ 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 OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; 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\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -35,13 +36,14 @@ 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 IEngineHandler&MockObject $certificateEngine; private CaIdentifierService&MockObject $caIdentifierService; private DocMdpHandler&MockObject $docMdpHandler; private CrlService&MockObject $crlService; + private PdfSignatureValidationService&MockObject $pdfSignatureValidationService; + private PdfSignatureExtractor $pdfSignatureExtractor; #[\Override] public function setUp(): void { @@ -67,11 +69,13 @@ public function setUp(): void { $this->certificateEngineFactory->method('getEngine')->willReturn($this->certificateEngine); $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 { @@ -83,11 +87,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(); @@ -98,11 +103,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, ); } @@ -423,4 +429,148 @@ 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']); + } + + public function testGetCertificateChainPropagatesUnexpectedNativeMetadataExtractionFailureAndResetsPolicyValidationContext(): void { + $this->logger->expects($this->never())->method('warning'); + + $policyCalls = []; + $certificateEngine = $this->createMock(IEngineHandler::class); + $certificateEngine->method('setPolicyUserIdForValidation') + ->willReturnCallback(function (?string $userId) use (&$policyCalls, $certificateEngine) { + $policyCalls[] = $userId; + return $certificateEngine; + }); + + $certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); + $certificateEngineFactory->method('getEngine')->willReturn($certificateEngine); + + $handler = new class($this->folderService, $this->appConfig, $certificateEngineFactory, $this->l10n, $this->footerHandler, $this->logger, $this->caIdentifierService, $this->docMdpHandler, $this->crlService, $this->pdfSignatureValidationService, $this->pdfSignatureExtractor, ) extends Pkcs12Handler { + protected function extractNativeSignaturesFromContent(string $content): array { + throw new \RuntimeException('metadata boom'); + } + }; + + $handler->setPolicyUserIdForValidation('requester'); + $resource = fopen(__DIR__ . '/../../../fixtures/pdfs/small_valid-signed.pdf', 'r'); + $this->assertIsResource($resource); + + $thrown = null; + try { + $handler->getCertificateChain($resource); + $this->fail('Expected RuntimeException to be propagated.'); + } catch (\RuntimeException $exception) { + $thrown = $exception; + } finally { + fclose($resource); + } + + $this->assertInstanceOf(\RuntimeException::class, $thrown); + $this->assertSame('metadata boom', $thrown->getMessage()); + $this->assertSame(['requester', null], $policyCalls); + + $reflection = new \ReflectionProperty(Pkcs12Handler::class, 'policyUserIdForValidation'); + $this->assertNull($reflection->getValue($handler)); + } + } diff --git a/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php b/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php index 05aeaa37a6..04bcb2a024 100644 --- a/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php +++ b/tests/php/Unit/Service/IdentifyMethod/PasswordTest.php @@ -21,9 +21,10 @@ 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 OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor; use OCP\IAppConfig; use OCP\IL10N; -use OCP\ITempManager; use OCP\IUserSession; use OCP\L10N\IFactory as IL10NFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -39,11 +40,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; #[\Override] public function setUp(): void { @@ -53,12 +55,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(); } @@ -81,11 +84,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..137e1e1e9e --- /dev/null +++ b/tests/php/Unit/Service/Signature/PdfSignatureValidationServiceTest.php @@ -0,0 +1,133 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->appConfig->method('getValueString')->willReturn(''); + $this->l10n = $this->createMock(IL10N::class); + $this->l10n + ->method('t') + ->willReturnCallback(static fn (string $text): string => $text); + $this->logger = $this->createMock(LoggerInterface::class); + } + + 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']); + } + + public function testValidateFromStringReturnsEmptyListForUnsignedPdfException(): void { + $this->logger->expects($this->never())->method('warning'); + + $service = new class($this->appConfig, $this->l10n, $this->logger) extends PdfSignatureValidationService { + protected function validateNativeFromString(string $pdfContent): array { + throw new UnsignedPdfException('Unsigned file.'); + } + }; + + $this->assertSame([], $service->validateFromString('%PDF-1.7')); + } + + public function testValidateFromStringPropagatesUnexpectedRuntimeException(): void { + $this->logger->expects($this->never())->method('warning'); + + $service = new class($this->appConfig, $this->l10n, $this->logger) extends PdfSignatureValidationService { + protected function validateNativeFromString(string $pdfContent): array { + throw new \RuntimeException('validator boom'); + } + }; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('validator boom'); + $service->validateFromString('%PDF-1.7'); + } + + 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; + } +}