Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
81.82% |
153 / 187 |
|
46.67% |
7 / 15 |
CRAP | |
0.00% |
0 / 1 |
| PdfSignatureValidationService | |
81.82% |
153 / 187 |
|
46.67% |
7 / 15 |
36.78 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| loadLibreSignCaCertificate | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
6.07 | |||
| addTrustedRoot | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setTrustedRoots | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| validateFromResource | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| validateFromString | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| validateNativeFromResource | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validateNativeFromString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| mapValidationResults | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
5 | |||
| mapSignatureValidation | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
1 | |||
| mapCertificateValidation | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
1 | |||
| translateValidationReason | |
88.00% |
22 / 25 |
|
0.00% |
0 / 1 |
2.01 | |||
| translateKnownReason | |
31.43% |
11 / 35 |
|
0.00% |
0 / 1 |
13.06 | |||
| isLibreSignCaLoaded | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getLibreSignCaCertificate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Service\Signature; |
| 10 | |
| 11 | use OCA\Libresign\AppInfo\Application; |
| 12 | use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException; |
| 13 | use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ExtractedSignature; |
| 14 | use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationReason; |
| 15 | use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationResult; |
| 16 | use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState; |
| 17 | use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator; |
| 18 | use OCP\IAppConfig; |
| 19 | use OCP\IL10N; |
| 20 | use Psr\Log\LoggerInterface; |
| 21 | |
| 22 | /** |
| 23 | * Validate PDF signatures using the pdf-signature-validator package. |
| 24 | * |
| 25 | * @psalm-import-type PdfSignatureValidationResult from \OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator |
| 26 | * @psalm-import-type MappedPdfValidationResult from PdfSignatureValidationDefinitions |
| 27 | */ |
| 28 | class PdfSignatureValidationService { |
| 29 | private PdfSignatureValidator $validator; |
| 30 | private string $libresignCaCertificate = ''; |
| 31 | |
| 32 | public function __construct( |
| 33 | private IAppConfig $appConfig, |
| 34 | private IL10N $l10n, |
| 35 | private LoggerInterface $logger, |
| 36 | ) { |
| 37 | $this->validator = new PdfSignatureValidator(); |
| 38 | $this->loadLibreSignCaCertificate(); |
| 39 | } |
| 40 | |
| 41 | private function loadLibreSignCaCertificate(): void { |
| 42 | $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path'); |
| 43 | if (!empty($configPath) && is_dir($configPath)) { |
| 44 | $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem'; |
| 45 | if (is_readable($caPemPath)) { |
| 46 | $cert = @file_get_contents($caPemPath); |
| 47 | if ($cert !== false) { |
| 48 | $this->libresignCaCertificate = $cert; |
| 49 | $this->validator->addTrustedRoot($cert); |
| 50 | return; |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | $alternateConfig = $this->appConfig->getValueString( |
| 56 | Application::APP_ID, |
| 57 | 'libresign_ca_certificate' |
| 58 | ); |
| 59 | if (!empty($alternateConfig)) { |
| 60 | $this->libresignCaCertificate = $alternateConfig; |
| 61 | $this->validator->addTrustedRoot($alternateConfig); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | public function addTrustedRoot(string $certificatePem): void { |
| 66 | $this->validator->addTrustedRoot($certificatePem); |
| 67 | } |
| 68 | |
| 69 | public function setTrustedRoots(array $certificates): void { |
| 70 | $this->validator->setTrustedRoots($certificates); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * Validate PDF signatures from file resource. |
| 75 | * |
| 76 | * @param resource $resource PDF file resource |
| 77 | * @return list<MappedPdfValidationResult> |
| 78 | */ |
| 79 | public function validateFromResource($resource): array { |
| 80 | try { |
| 81 | $results = $this->validateNativeFromResource($resource); |
| 82 | } catch (UnsignedPdfException) { |
| 83 | return []; |
| 84 | } |
| 85 | |
| 86 | return $this->mapValidationResults($results); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Validate PDF signatures from binary content. |
| 91 | * |
| 92 | * @param string $pdfContent Binary PDF content |
| 93 | * @return list<MappedPdfValidationResult> |
| 94 | */ |
| 95 | public function validateFromString(string $pdfContent): array { |
| 96 | try { |
| 97 | $results = $this->validateNativeFromString($pdfContent); |
| 98 | } catch (UnsignedPdfException) { |
| 99 | return []; |
| 100 | } |
| 101 | |
| 102 | return $this->mapValidationResults($results); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * @param resource $resource |
| 107 | * @return list<PdfSignatureValidationResult> |
| 108 | */ |
| 109 | protected function validateNativeFromResource($resource): array { |
| 110 | return $this->validator->validateFromResource($resource); |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * @return list<PdfSignatureValidationResult> |
| 115 | */ |
| 116 | protected function validateNativeFromString(string $pdfContent): array { |
| 117 | return $this->validator->validateFromString($pdfContent); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Map validation results from PdfSignatureValidator to LibreSign format. |
| 122 | * |
| 123 | * @param list<PdfSignatureValidationResult> $results Results from PdfSignatureValidator |
| 124 | * @return list<MappedPdfValidationResult> |
| 125 | */ |
| 126 | private function mapValidationResults(array $results): array { |
| 127 | $mapped = []; |
| 128 | |
| 129 | foreach ($results as $result) { |
| 130 | $signature = $result['signature'] ?? null; |
| 131 | $sigValidation = $result['signatureValidation'] ?? null; |
| 132 | $certValidation = $result['certificateValidation'] ?? null; |
| 133 | |
| 134 | if ( |
| 135 | !$signature instanceof ExtractedSignature |
| 136 | || !$sigValidation instanceof ValidationResult |
| 137 | || !$certValidation instanceof ValidationResult |
| 138 | ) { |
| 139 | continue; |
| 140 | } |
| 141 | |
| 142 | $mapped[] = [ |
| 143 | 'signature' => $signature, |
| 144 | 'certificates' => $result['certificates'], |
| 145 | 'timestamp' => $result['timestamp'], |
| 146 | 'signatureValidation' => $this->mapSignatureValidation($sigValidation), |
| 147 | 'certificateValidation' => $this->mapCertificateValidation($certValidation), |
| 148 | 'raw' => [ |
| 149 | 'signature' => $sigValidation, |
| 150 | 'certificate' => $certValidation, |
| 151 | ], |
| 152 | ]; |
| 153 | } |
| 154 | |
| 155 | return $mapped; |
| 156 | } |
| 157 | |
| 158 | private function mapSignatureValidation(ValidationResult $result): array { |
| 159 | return match ($result->state) { |
| 160 | ValidationState::SIGNATURE_VALID => [ |
| 161 | 'id' => 1, |
| 162 | // TRANSLATORS User-facing status when signature cryptographic validation succeeds. |
| 163 | 'label' => $this->l10n->t('Signature is valid.'), |
| 164 | 'isValid' => true, |
| 165 | ], |
| 166 | ValidationState::SIGNATURE_INVALID => [ |
| 167 | 'id' => 2, |
| 168 | // TRANSLATORS User-facing status when signature cryptographic validation fails. |
| 169 | 'label' => $this->l10n->t('Signature is invalid.'), |
| 170 | 'reason' => $this->translateValidationReason($result), |
| 171 | 'isValid' => false, |
| 172 | ], |
| 173 | ValidationState::DIGEST_MISMATCH => [ |
| 174 | 'id' => 3, |
| 175 | // TRANSLATORS User-facing status when signed digest does not match PDF content. |
| 176 | 'label' => $this->l10n->t('Digest mismatch.'), |
| 177 | 'reason' => $this->translateValidationReason($result), |
| 178 | 'isValid' => false, |
| 179 | ], |
| 180 | ValidationState::NOT_VERIFIED => [ |
| 181 | 'id' => 5, |
| 182 | // TRANSLATORS User-facing status when validation could not be fully completed. |
| 183 | 'label' => $this->l10n->t('Signature has not yet been verified.'), |
| 184 | 'reason' => $this->translateValidationReason($result), |
| 185 | 'isValid' => false, |
| 186 | ], |
| 187 | default => [ |
| 188 | 'id' => 6, |
| 189 | // TRANSLATORS Generic fallback status for unexpected signature validation failures. |
| 190 | 'label' => $this->l10n->t('Unknown validation failure.'), |
| 191 | 'reason' => $this->translateValidationReason($result), |
| 192 | 'isValid' => false, |
| 193 | ], |
| 194 | }; |
| 195 | } |
| 196 | |
| 197 | private function mapCertificateValidation(ValidationResult $result): array { |
| 198 | return match ($result->state) { |
| 199 | ValidationState::CERT_TRUSTED => [ |
| 200 | 'id' => 1, |
| 201 | // TRANSLATORS User-facing status when certificate is trusted. |
| 202 | 'label' => $this->l10n->t('Certificate is trusted.'), |
| 203 | 'isValid' => true, |
| 204 | ], |
| 205 | ValidationState::CERT_ISSUER_NOT_TRUSTED => [ |
| 206 | 'id' => 2, |
| 207 | // TRANSLATORS User-facing status when issuing CA is known but not trusted. |
| 208 | 'label' => $this->l10n->t("Certificate issuer isn't trusted."), |
| 209 | 'reason' => $this->translateValidationReason($result), |
| 210 | 'isValid' => false, |
| 211 | ], |
| 212 | ValidationState::CERT_ISSUER_UNKNOWN => [ |
| 213 | 'id' => 3, |
| 214 | // TRANSLATORS User-facing status when certificate issuer cannot be identified/trusted. |
| 215 | 'label' => $this->l10n->t('Certificate issuer is unknown.'), |
| 216 | 'reason' => $this->translateValidationReason($result), |
| 217 | 'isValid' => false, |
| 218 | ], |
| 219 | ValidationState::CERT_REVOKED => [ |
| 220 | 'id' => 4, |
| 221 | // TRANSLATORS User-facing status when certificate is revoked. |
| 222 | 'label' => $this->l10n->t('Certificate has been revoked.'), |
| 223 | 'reason' => $this->translateValidationReason($result), |
| 224 | 'isValid' => false, |
| 225 | ], |
| 226 | ValidationState::CERT_EXPIRED => [ |
| 227 | 'id' => 5, |
| 228 | // TRANSLATORS User-facing status when certificate is expired. |
| 229 | 'label' => $this->l10n->t('Certificate has expired.'), |
| 230 | 'reason' => $this->translateValidationReason($result), |
| 231 | 'isValid' => false, |
| 232 | ], |
| 233 | ValidationState::CERT_NOT_VERIFIED => [ |
| 234 | 'id' => 6, |
| 235 | // TRANSLATORS User-facing status when certificate validation could not be completed. |
| 236 | 'label' => $this->l10n->t('Certificate has not yet been verified.'), |
| 237 | 'reason' => $this->translateValidationReason($result), |
| 238 | 'isValid' => false, |
| 239 | ], |
| 240 | default => [ |
| 241 | 'id' => 7, |
| 242 | // TRANSLATORS Generic fallback status for unexpected certificate validation failures. |
| 243 | 'label' => $this->l10n->t('Unknown issue with certificate or corrupted data.'), |
| 244 | 'reason' => $this->translateValidationReason($result), |
| 245 | 'isValid' => false, |
| 246 | ], |
| 247 | }; |
| 248 | } |
| 249 | |
| 250 | private function translateValidationReason(ValidationResult $result): ?string { |
| 251 | if ($result->reasonCode !== null) { |
| 252 | return match ($result->reasonCode) { |
| 253 | ValidationReason::NO_BYTE_RANGE => ( |
| 254 | // TRANSLATORS PDF digital signature validation. ByteRange is a PDF signature structure that defines exactly which byte ranges are protected by the cryptographic signature. This result means that structure is missing, so LibreSign cannot determine which document content was signed. Keep the user-facing text simple. |
| 255 | $this->l10n->t('The signature does not contain the information needed to verify the document') |
| 256 | ), |
| 257 | ValidationReason::DIGEST_MISMATCH => ( |
| 258 | // TRANSLATORS PDF digital signature validation. LibreSign recalculated the cryptographic digest from the signed PDF byte ranges and it does not match the digest stored in the signature. This can indicate changed content or inconsistent signature data. Keep the user-facing text simple. |
| 259 | $this->l10n->t('The document content does not match the signed content') |
| 260 | ), |
| 261 | ValidationReason::NO_BINARY_SIGNATURE => ( |
| 262 | // TRANSLATORS PDF digital signature validation. The PDF contains signature metadata, but the CMS/PKCS#7 binary signature payload required for cryptographic verification is missing. CMS/PKCS#7 is the standard container commonly used for PDF digital signatures. |
| 263 | $this->l10n->t('The digital signature data is missing') |
| 264 | ), |
| 265 | ValidationReason::SIGNATURE_CERTIFICATE_MISMATCH => ( |
| 266 | // TRANSLATORS PDF digital signature validation. LibreSign verified the signature bytes against the signer's X.509 certificate, but the cryptographic verification failed. The certificate and the digital signature do not match as expected. |
| 267 | $this->l10n->t('The signature could not be verified with its certificate') |
| 268 | ), |
| 269 | ValidationReason::INVALID_BYTE_RANGE => ( |
| 270 | // TRANSLATORS PDF digital signature validation. ByteRange defines which byte ranges are protected by the digital signature. The structure exists but its offsets or lengths are invalid, so cryptographic verification cannot safely continue. Keep the user-facing text simple. |
| 271 | $this->l10n->t('The signature contains an invalid ByteRange') |
| 272 | ), |
| 273 | ValidationReason::INVALID_EOF_BOUNDARY => ( |
| 274 | // TRANSLATORS PDF digital signature validation. The signed PDF revision does not end at a valid PDF end-of-file boundary. This means the signed revision structure is invalid. Keep the user-facing text simple. |
| 275 | $this->l10n->t('The signed PDF revision has an invalid end-of-file boundary') |
| 276 | ), |
| 277 | ValidationReason::UNSUPPORTED_SUBFILTER => ( |
| 278 | // TRANSLATORS PDF digital signature validation. SubFilter identifies the PDF signature format. This result means LibreSign does not support cryptographic validation for that format. |
| 279 | $this->l10n->t('The PDF signature format is not supported') |
| 280 | ), |
| 281 | }; |
| 282 | } |
| 283 | |
| 284 | return $this->translateKnownReason($result->reason); |
| 285 | } |
| 286 | |
| 287 | private function translateKnownReason(?string $reason): ?string { |
| 288 | if ($reason === null || $reason === '') { |
| 289 | return $reason; |
| 290 | } |
| 291 | |
| 292 | if (preg_match('/^Intermediate certificate at position (\d+) is not signed by issuer$/', $reason, $matches) === 1) { |
| 293 | // TRANSLATORS %s is the numeric position of an intermediate certificate in the chain. |
| 294 | return $this->l10n->t( |
| 295 | 'Intermediate certificate at position %s is not signed by issuer', |
| 296 | [$matches[1]] |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | $prefix = 'Certificate validation failed: '; |
| 301 | if (str_starts_with($reason, $prefix)) { |
| 302 | $detail = substr($reason, strlen($prefix)); |
| 303 | $translatedDetail = $this->translateKnownReason($detail) ?? $detail; |
| 304 | // TRANSLATORS %s is a translated certificate validation detail message. |
| 305 | return $this->l10n->t('Certificate validation failed: %s', [$translatedDetail]); |
| 306 | } |
| 307 | |
| 308 | return match ($reason) { |
| 309 | // TRANSLATORS Technical term from PDF signatures. Keep "ByteRange" unchanged. |
| 310 | 'No ByteRange in signature' => $this->l10n->t('No ByteRange in signature'), |
| 311 | // TRANSLATORS Technical message for digest/hash mismatch in PDF signature verification. |
| 312 | 'PDF content hash does not match signed digest' => $this->l10n->t('PDF content hash does not match signed digest'), |
| 313 | // TRANSLATORS Certificate/public-key verification failed for signature bytes. |
| 314 | 'Signature does not match certificate' => $this->l10n->t('Signature does not match certificate'), |
| 315 | // TRANSLATORS X.509 certificate parsing failure. |
| 316 | 'Failed to parse certificate' => $this->l10n->t('Failed to parse certificate'), |
| 317 | // TRANSLATORS Signature timestamp is outside certificate validity window. |
| 318 | 'Certificate was not valid at time of signature' => $this->l10n->t('Certificate was not valid at time of signature'), |
| 319 | // TRANSLATORS Certificate validity date has ended. |
| 320 | 'Certificate has expired' => $this->l10n->t('Certificate has expired'), |
| 321 | // TRANSLATORS No certificates were found in provided certificate chain. |
| 322 | 'Empty certificate chain' => $this->l10n->t('Empty certificate chain'), |
| 323 | // TRANSLATORS Certificate does not provide a serial number field. |
| 324 | 'Certificate has no serial number' => $this->l10n->t('Certificate has no serial number'), |
| 325 | // TRANSLATORS CRL means Certificate Revocation List; keep acronym CRL unchanged. |
| 326 | 'Certificate found in CRL' => $this->l10n->t('Certificate found in CRL'), |
| 327 | // TRANSLATORS Certificate structure/content is invalid. |
| 328 | 'Invalid certificate' => $this->l10n->t('Invalid certificate'), |
| 329 | // TRANSLATORS CA means Certificate Authority; keep acronym CA unchanged. |
| 330 | 'Leaf certificate is marked as CA' => $this->l10n->t('Leaf certificate is marked as CA'), |
| 331 | // TRANSLATORS Certificate signature chain validation failed. |
| 332 | 'Certificate signature validation failed' => $this->l10n->t('Certificate signature validation failed'), |
| 333 | // TRANSLATORS Self-signed certificate is not present in trusted roots list. |
| 334 | 'Self-signed certificate not in trusted roots' => $this->l10n->t('Self-signed certificate not in trusted roots'), |
| 335 | // TRANSLATORS Root certificate must be self-signed to be considered a trust anchor. |
| 336 | 'Root certificate is not self-signed' => $this->l10n->t('Root certificate is not self-signed'), |
| 337 | // TRANSLATORS Root certificate is not present in configured trusted certificate list. |
| 338 | 'Root certificate is not in trusted list' => $this->l10n->t('Root certificate is not in trusted list'), |
| 339 | // TRANSLATORS Signature container has no binary signature payload. |
| 340 | 'No binary signature' => $this->l10n->t('No binary signature'), |
| 341 | // TRANSLATORS Signature payload has no embedded certificates. |
| 342 | 'No certificates in signature' => $this->l10n->t('No certificates in signature'), |
| 343 | // TRANSLATORS Certificate used for signing is expired. |
| 344 | 'Signing certificate has expired' => $this->l10n->t('Signing certificate has expired'), |
| 345 | // TRANSLATORS Certificate used for signing is revoked. |
| 346 | 'Signing certificate has been revoked' => $this->l10n->t('Signing certificate has been revoked'), |
| 347 | // TRANSLATORS Signature verification could not be fully completed. |
| 348 | 'Signature verification incomplete' => $this->l10n->t('Signature verification incomplete'), |
| 349 | default => $reason, |
| 350 | }; |
| 351 | } |
| 352 | |
| 353 | public function isLibreSignCaLoaded(): bool { |
| 354 | return !empty($this->libresignCaCertificate); |
| 355 | } |
| 356 | |
| 357 | public function getLibreSignCaCertificate(): string { |
| 358 | return $this->libresignCaCertificate; |
| 359 | } |
| 360 | } |