diff --git a/REUSE.toml b/REUSE.toml index ed835c94c3..4e7bea0c17 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -50,6 +50,7 @@ path = [ "src/types/openapi/openapi.ts", "tests/php/Unit/Handler/mock/cert.json", "tests/php/fixtures/cfssl/newcert-with-success.json", + "tests/php/fixtures/real_jsignpdf_level1.pdf", "tests/php/fixtures/small_valid-signed.pdf", "tests/php/fixtures/small_valid.pdf", "tests/integration/composer.json", diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 21f1933427..63933cc6d0 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -10,6 +10,7 @@ use DateTimeInterface; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Enum\DocMdpLevel; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\CertificateEngine\IEngineHandler; @@ -17,6 +18,7 @@ use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\Certificate\ValidateService; use OCA\Libresign\Service\CertificatePolicyService; +use OCA\Libresign\Service\DocMdpConfigService; use OCA\Libresign\Service\FooterService; use OCA\Libresign\Service\Install\ConfigureCheckService; use OCA\Libresign\Service\Install\InstallService; @@ -64,6 +66,7 @@ public function __construct( private ValidateService $validateService, private ReminderService $reminderService, private FooterService $footerService, + private DocMdpConfigService $docMdpConfigService, ) { parent::__construct(Application::APP_ID, $request); $this->eventSource = $this->eventSourceFactory->create(); @@ -857,4 +860,41 @@ public function footerTemplatePreviewPdf(string $template = '', int $width = 595 ], Http::STATUS_BAD_REQUEST); } } + + /** + * Set DocMDP configuration + * + * @param bool $enabled Enable or disable DocMDP certification + * @param int $defaultLevel Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations + * @return DataResponse|DataResponse|DataResponse + * + * 200: Configuration saved successfully + * 400: Invalid DocMDP level provided + * 500: Internal server error + */ + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/docmdp/config', requirements: ['apiVersion' => '(v1)'])] + public function setDocMdpConfig(bool $enabled, int $defaultLevel): DataResponse { + try { + $this->docMdpConfigService->setEnabled($enabled); + + if ($enabled) { + $level = DocMdpLevel::tryFrom($defaultLevel); + if ($level === null) { + return new DataResponse([ + 'error' => $this->l10n->t('Invalid DocMDP level'), + ], Http::STATUS_BAD_REQUEST); + } + + $this->docMdpConfigService->setLevel($level); + } + + return new DataResponse([ + 'message' => $this->l10n->t('Settings saved'), + ]); + } catch (\Exception $e) { + return new DataResponse([ + 'error' => $e->getMessage(), + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } } diff --git a/lib/Db/SignRequest.php b/lib/Db/SignRequest.php index bb36b9481c..e79cc89650 100644 --- a/lib/Db/SignRequest.php +++ b/lib/Db/SignRequest.php @@ -30,6 +30,8 @@ * @method string getDisplayName() * @method void setMetadata(array $metadata) * @method ?array getMetadata() + * @method void setDocmdpLevel(int $docmdpLevel) + * @method int getDocmdpLevel() */ class SignRequest extends Entity { protected ?int $fileId = null; @@ -40,6 +42,7 @@ class SignRequest extends Entity { protected ?\DateTime $signed = null; protected ?string $signedHash = null; protected ?array $metadata = null; + protected int $docmdpLevel = 0; public function __construct() { $this->addType('id', Types::INTEGER); $this->addType('fileId', Types::INTEGER); @@ -50,5 +53,6 @@ public function __construct() { $this->addType('signed', Types::DATETIME); $this->addType('signedHash', Types::STRING); $this->addType('metadata', Types::JSON); + $this->addType('docmdpLevel', Types::SMALLINT); } } diff --git a/lib/Enum/DocMdpLevel.php b/lib/Enum/DocMdpLevel.php index c354dd91ab..ad75d6bc08 100644 --- a/lib/Enum/DocMdpLevel.php +++ b/lib/Enum/DocMdpLevel.php @@ -25,17 +25,17 @@ public function getLabel(IL10N $l10n): string { return match($this) { self::NOT_CERTIFIED => $l10n->t('No certification'), self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('No changes allowed'), - self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling and additional signatures'), - self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling, annotations and additional signatures'), + self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling allowed'), + self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling and commenting allowed'), }; } public function getDescription(IL10N $l10n): string { return match($this) { - self::NOT_CERTIFIED => $l10n->t('Document is not certified. No restrictions on modifications.'), - self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('No changes allowed. Additional approval signatures are prohibited.'), - self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling allowed. Additional approval signatures are allowed.'), - self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling and annotations allowed. Additional approval signatures are allowed.'), + self::NOT_CERTIFIED => $l10n->t('The document is not certified; edits and new signatures are allowed, but any change will mark previous signatures as modified.'), + self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('After the first signature, no further edits or signatures are allowed; any change invalidates the certification.'), + self::CERTIFIED_FORM_FILLING => $l10n->t('After the first signature, only form filling and additional signatures are allowed; other changes invalidate the certification.'), + self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('After the first signature, form filling, comments, and additional signatures are allowed; other changes invalidate the certification.'), }; } } diff --git a/lib/Handler/DocMdpHandler.php b/lib/Handler/DocMdpHandler.php index 01f5d3e155..1af92fef07 100644 --- a/lib/Handler/DocMdpHandler.php +++ b/lib/Handler/DocMdpHandler.php @@ -26,6 +26,12 @@ public function __construct( ) { } + public function allowsAdditionalSignatures($resource): bool { + $docmdpLevel = $this->extractDocMdpLevel($resource); + + return $docmdpLevel !== DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED; + } + public function extractDocMdpData($resource): array { if (!is_resource($resource)) { return []; @@ -160,7 +166,7 @@ private function extractPValueFromIndirectReference(string $content, string $ind * @return array Array of objects with keys: objNum, dict, position */ private function parsePdfObjects(string $content): array { - if (!preg_match_all('/(\d+)\s+\d+\s+obj\s*(<<.*?>>)\s*endobj/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { + if (!preg_match_all('/(\d+)\s+\d+\s+obj(.*?)endobj/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { return []; } @@ -168,7 +174,7 @@ private function parsePdfObjects(string $content): array { foreach ($matches as $match) { $objects[] = [ 'objNum' => $match[1][0], - 'dict' => $match[2][0], + 'dict' => trim($match[2][0]), 'position' => $match[2][1], ]; } @@ -452,16 +458,11 @@ private function validateSignatureDictionary(string $content): bool { return $this->validateDictionaryEntries($sigDict); } - /** - * Find signature dictionary with /Reference entry - * - * @param array $objects Parsed PDF objects - * @return string|null Dictionary content or null - */ private function findSignatureDictionary(array $objects): ?string { foreach ($objects as $obj) { - if (preg_match('/\/Reference\s*\[/', $obj['dict'])) { - return $obj['dict']; + $dict = $obj['dict']; + if (preg_match('/\/Type\s*\/Sig\b/', $dict) && preg_match('/\/Reference\s*\[/', $dict)) { + return $dict; } } return null; @@ -474,7 +475,7 @@ private function findSignatureDictionary(array $objects): ?string { * @return bool True if all required entries are valid */ private function validateDictionaryEntries(string $dict): bool { - if (preg_match('/\/Type\s*\/(\w+)/', $dict, $typeMatch) && $typeMatch[1] !== 'Sig') { + if (!preg_match('/\/Type\s*\/Sig\b/', $dict)) { return false; } diff --git a/lib/Handler/SignEngine/JSignPdfHandler.php b/lib/Handler/SignEngine/JSignPdfHandler.php index 8dc0e97a18..e539abbd8a 100644 --- a/lib/Handler/SignEngine/JSignPdfHandler.php +++ b/lib/Handler/SignEngine/JSignPdfHandler.php @@ -14,6 +14,7 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Helper\JavaHelper; +use OCA\Libresign\Service\DocMdpConfigService; use OCA\Libresign\Service\Install\InstallService; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; @@ -40,6 +41,7 @@ public function __construct( private SignatureBackgroundService $signatureBackgroundService, protected CertificateEngineFactory $certificateEngineFactory, protected JavaHelper $javaHelper, + private DocMdpConfigService $docMdpConfigService, ) { } @@ -86,6 +88,11 @@ public function getJSignParam(): JSignParam { . ' -Duser.home=' . escapeshellarg($this->getHome()) . ' ' ); } + + $certificationLevel = $this->getCertificationLevel(); + if ($certificationLevel !== null) { + $this->jSignParam->setJSignParameters(' -cl ' . $certificationLevel); + } } return $this->jSignParam; } @@ -147,6 +154,15 @@ private function getHashAlgorithm(): string { return 'SHA256'; } + private function getCertificationLevel(): ?string { + if (!$this->docMdpConfigService->isEnabled()) { + return null; + } + + return $this->docMdpConfigService->getLevel()->name; + } + + #[\Override] public function sign(): File { $this->beforeSign(); @@ -155,6 +171,7 @@ public function sign(): File { return $this->getInputFile(); } + #[\Override] public function getSignedContent(): string { $param = $this->getJSignParam() ->setCertificate($this->getCertificate()) @@ -280,6 +297,7 @@ private function getScaleFactor(float $width): float { } + #[\Override] public function readCertificate(): array { $result = $this->certificateEngineFactory ->getEngine() diff --git a/lib/Migration/Version14000Date20251206120000.php b/lib/Migration/Version14000Date20251206120000.php index 93c404668f..17abaf47bd 100644 --- a/lib/Migration/Version14000Date20251206120000.php +++ b/lib/Migration/Version14000Date20251206120000.php @@ -25,17 +25,29 @@ class Version14000Date20251206120000 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); - $table = $schema->getTable('libresign_file'); - if (!$table->hasColumn('modification_status')) { - $table->addColumn('modification_status', Types::SMALLINT, [ - 'notnull' => true, - 'default' => 0, - 'comment' => 'DocMDP modification detection status: 0=unchecked, 1=unmodified, 2=allowed, 3=violation', - ]); - return $schema; + if ($schema->hasTable('libresign_sign_request')) { + $tableSignRequest = $schema->getTable('libresign_sign_request'); + if (!$tableSignRequest->hasColumn('docmdp_level')) { + $tableSignRequest->addColumn('docmdp_level', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'comment' => 'DocMDP permission level: 0=none, 1=no changes, 2=form fill, 3=form fill + annotations', + ]); + } } - return null; + if ($schema->hasTable('libresign_file')) { + $tableFile = $schema->getTable('libresign_file'); + if (!$tableFile->hasColumn('modification_status')) { + $tableFile->addColumn('modification_status', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'comment' => 'DocMDP modification detection status: 0=unchecked, 1=unmodified, 2=allowed, 3=violation', + ]); + } + } + + return $schema; } } diff --git a/lib/Service/DocMdpConfigService.php b/lib/Service/DocMdpConfigService.php new file mode 100644 index 0000000000..dc5657e6c7 --- /dev/null +++ b/lib/Service/DocMdpConfigService.php @@ -0,0 +1,63 @@ +appConfig->hasKey(Application::APP_ID, self::CONFIG_KEY_LEVEL); + } + + public function setEnabled(bool $enabled): void { + if (!$enabled) { + $this->appConfig->deleteKey(Application::APP_ID, self::CONFIG_KEY_LEVEL); + } + } + + public function getLevel(): DocMdpLevel { + $level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, DocMdpLevel::NOT_CERTIFIED->value); + return DocMdpLevel::from($level); + } + + public function setLevel(DocMdpLevel $level): void { + $this->appConfig->setValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, $level->value); + } + + public function getConfig(): array { + return [ + 'enabled' => $this->isEnabled(), + 'defaultLevel' => $this->getLevel()->value, + 'availableLevels' => $this->getAvailableLevels(), + ]; + } + + private function getAvailableLevels(): array { + return array_map( + fn (DocMdpLevel $level) => [ + 'value' => $level->value, + 'label' => $level->getLabel($this->l10n), + 'description' => $level->getDescription($this->l10n), + ], + DocMdpLevel::cases() + ); + } +} diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index e20e6692dd..8b941b3ce0 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -22,6 +22,7 @@ use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\ResponseDefinitions; @@ -87,10 +88,12 @@ public function __construct( private IURLGenerator $urlGenerator, protected IMimeTypeDetector $mimeTypeDetector, protected Pkcs12Handler $pkcs12Handler, + DocMdpHandler $docMdpHandler, private IRootFolder $root, protected LoggerInterface $logger, protected IL10N $l10n, ) { + $this->docMdpHandler = $docMdpHandler; $this->fileData = new stdClass(); } @@ -277,9 +280,13 @@ private function getFileContent(): string { try { return $this->fileContent = $this->getFile()->getContent(); } catch (LibresignException $e) { - throw new LibresignException($e->getMessage(), 404); - } catch (\Throwable) { - throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404); + throw $e; + } catch (\Throwable $e) { + $this->logger->error('Failed to get file content: ' . $e->getMessage(), [ + 'fileId' => $this->file->getId(), + 'exception' => $e, + ]); + throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404, $e); } } return ''; @@ -477,19 +484,30 @@ private function loadSignersFromCertData(): void { $this->fileData->signers[$index]['signingTime'] = $signer['signingTime']; $this->fileData->signers[$index]['signed'] = $signer['signingTime']->format(DateTimeInterface::ATOM); } - foreach ($signer['chain'] as $chainIndex => $chainItem) { - $chainArr = $chainItem; - if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) { - $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); - } - if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) { - $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); - } - $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? ''); - $this->fileData->signers[$index]['chain'][$chainIndex] = $chainArr; - if ($chainIndex === 0) { - $this->fileData->signers[$index] = array_merge($chainArr, $this->fileData->signers[$index] ?? []); - $this->fileData->signers[$index]['uid'] = $this->resolveUid($chainArr); + if (isset($signer['docmdp'])) { + $this->fileData->signers[$index]['docmdp'] = $signer['docmdp']; + } + if (isset($signer['modifications'])) { + $this->fileData->signers[$index]['modifications'] = $signer['modifications']; + } + if (isset($signer['modification_validation'])) { + $this->fileData->signers[$index]['modification_validation'] = $signer['modification_validation']; + } + if (isset($signer['chain'])) { + foreach ($signer['chain'] as $chainIndex => $chainItem) { + $chainArr = $chainItem; + if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) { + $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); + } + if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) { + $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); + } + $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? ''); + $this->fileData->signers[$index]['chain'][$chainIndex] = $chainArr; + if ($chainIndex === 0) { + $this->fileData->signers[$index] = array_merge($chainArr, $this->fileData->signers[$index] ?? []); + $this->fileData->signers[$index]['uid'] = $this->resolveUid($chainArr); + } } } } diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 991f7f7e89..2bf5342cc5 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -14,6 +14,7 @@ use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCP\AppFramework\Db\DoesNotExistException; @@ -43,6 +44,7 @@ public function __construct( protected IMimeTypeDetector $mimeTypeDetector, protected ValidateHelper $validateHelper, protected IClientService $client, + protected DocMdpHandler $docMdpHandler, protected LoggerInterface $logger, ) { } diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 4dbd9ae697..75a7ef9466 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -29,6 +29,7 @@ use OCA\Libresign\Db\UserElementMapper; use OCA\Libresign\Events\SignedEventFactory; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\FooterHandler; use OCA\Libresign\Handler\PdfTk\Pdf; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; @@ -102,6 +103,7 @@ public function __construct( protected SignEngineFactory $signEngineFactory, private SignedEventFactory $signedEventFactory, private Pdf $pdf, + private DocMdpHandler $docMdpHandler, ) { } @@ -317,6 +319,7 @@ public function getVisibleElements(): array { } public function sign(): File { + $this->validateDocMdpAllowsSignatures(); $signedFile = $this->getEngine()->sign(); $hash = $this->computeHash($signedFile); @@ -329,6 +332,40 @@ public function sign(): File { return $signedFile; } + /** + * @throws LibresignException If the document has DocMDP level 1 (no changes allowed) + */ + protected function validateDocMdpAllowsSignatures(): void { + $resource = $this->getLibreSignFileAsResource(); + + try { + if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) { + throw new LibresignException( + $this->l10n->t('This document has been certified with no changes allowed, so no additional signatures can be added.'), + AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY + ); + } + } finally { + fclose($resource); + } + } + + /** + * @return resource + * @throws LibresignException + */ + protected function getLibreSignFileAsResource() { + $fileToSign = $this->getNextcloudFile($this->libreSignFile); + $content = $fileToSign->getContent(); + $resource = fopen('php://memory', 'r+'); + if ($resource === false) { + throw new LibresignException('Failed to create temporary resource for PDF validation'); + } + fwrite($resource, $content); + rewind($resource); + return $resource; + } + protected function computeHash(File $file): string { return hash('sha256', $file->getContent()); } diff --git a/lib/Service/TFile.php b/lib/Service/TFile.php index d223630d4a..77a10f144f 100644 --- a/lib/Service/TFile.php +++ b/lib/Service/TFile.php @@ -8,6 +8,8 @@ namespace OCA\Libresign\Service; +use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Vendor\setasign\Fpdi\PdfParserService\Type\PdfTypeException; use OCP\Files\Node; use OCP\Http\Client\IClientService; @@ -16,6 +18,7 @@ trait TFile { /** @var ?string */ private $mimetype = null; protected IClientService $client; + protected DocMdpHandler $docMdpHandler; public function getNodeFromData(array $data): Node { if (!$this->folderService->getUserId()) { @@ -32,11 +35,9 @@ public function getNodeFromData(array $data): Node { } $content = $this->getFileRaw($data); - $extension = $this->getExtension($content); - if ($extension === 'pdf') { - $this->validatePdfStringWithFpdi($content); - } + + $this->validateFileContent($content, $extension); $userFolder = $this->folderService->getFolder(); $folderName = $this->folderService->getFolderName($data, $data['userManager']); @@ -44,6 +45,17 @@ public function getNodeFromData(array $data): Node { return $folderToFile->newFile($data['name'] . '.' . $extension, $content); } + /** + * @throws \Exception + * @throws LibresignException + */ + public function validateFileContent(string $content, string $extension): void { + if ($extension === 'pdf') { + $this->validatePdfStringWithFpdi($content); + $this->validateDocMdpAllowsSignatures($content); + } + } + private function setMimeType(string $mimetype): void { $this->validateHelper->validateMimeTypeAcceptedByMime($mimetype); $this->mimetype = $mimetype; @@ -141,4 +153,27 @@ private function validatePdfStringWithFpdi($string): void { throw new \Exception($this->l10n->t('Invalid PDF')); } } + + /** + * @throws LibresignException + */ + private function validateDocMdpAllowsSignatures(string $pdfContent): void { + $resource = fopen('php://memory', 'r+'); + if (!is_resource($resource)) { + return; + } + + try { + fwrite($resource, $pdfContent); + rewind($resource); + + if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) { + throw new LibresignException( + $this->l10n->t('This document has been certified with no changes allowed, so no additional signatures can be added.') + ); + } + } finally { + fclose($resource); + } + } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 917d2171e6..1a8b7330be 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -12,6 +12,7 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Service\CertificatePolicyService; +use OCA\Libresign\Service\DocMdpConfigService; use OCA\Libresign\Service\FooterService; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\SignatureBackgroundService; @@ -34,6 +35,7 @@ public function __construct( private SignatureTextService $signatureTextService, private SignatureBackgroundService $signatureBackgroundService, private FooterService $footerService, + private DocMdpConfigService $docMdpConfigService, ) { } public function getForm(): TemplateResponse { @@ -75,6 +77,7 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('tsa_auth_type', $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none')); $this->initialState->provideInitialState('tsa_username', $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '')); $this->initialState->provideInitialState('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER)); + $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); return new TemplateResponse(Application::APP_ID, 'admin_settings'); } diff --git a/openapi-administration.json b/openapi-administration.json index 12dda961f7..8d563ea440 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -2815,6 +2815,189 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + "post": { + "operationId": "admin-set-doc-mdp-config", + "summary": "Set DocMDP configuration", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled", + "defaultLevel" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable DocMDP certification" + }, + "defaultLevel": { + "type": "integer", + "format": "int64", + "description": "Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Configuration saved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid DocMDP level provided", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { "get": { "operationId": "crl_api-list", diff --git a/openapi-full.json b/openapi-full.json index fed1aeaef5..6bf381401f 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -11292,6 +11292,189 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + "post": { + "operationId": "admin-set-doc-mdp-config", + "summary": "Set DocMDP configuration", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled", + "defaultLevel" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable DocMDP certification" + }, + "defaultLevel": { + "type": "integer", + "format": "int64", + "description": "Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Configuration saved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid DocMDP level provided", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { "get": { "operationId": "crl_api-list", diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 702e288280..ea5ac63b51 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -323,6 +323,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set DocMDP configuration + * @description This endpoint requires admin access + */ + post: operations["admin-set-doc-mdp-config"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { parameters: { query?: never; @@ -1501,6 +1521,82 @@ export interface operations { }; }; }; + "admin-set-doc-mdp-config": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Enable or disable DocMDP certification */ + enabled: boolean; + /** + * Format: int64 + * @description Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations + */ + defaultLevel: number; + }; + }; + }; + responses: { + /** @description Configuration saved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Invalid DocMDP level provided */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "crl_api-list": { parameters: { query?: { diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 7dac34568d..4e881a491a 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1334,6 +1334,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set DocMDP configuration + * @description This endpoint requires admin access + */ + post: operations["admin-set-doc-mdp-config"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { parameters: { query?: never; @@ -5897,6 +5917,82 @@ export interface operations { }; }; }; + "admin-set-doc-mdp-config": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Enable or disable DocMDP certification */ + enabled: boolean; + /** + * Format: int64 + * @description Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations + */ + defaultLevel: number; + }; + }; + }; + responses: { + /** @description Configuration saved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Invalid DocMDP level provided */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "crl_api-list": { parameters: { query?: { diff --git a/src/views/Settings/DocMDP.vue b/src/views/Settings/DocMDP.vue new file mode 100644 index 0000000000..65cfa4a579 --- /dev/null +++ b/src/views/Settings/DocMDP.vue @@ -0,0 +1,201 @@ + + + + + + diff --git a/src/views/Settings/Settings.vue b/src/views/Settings/Settings.vue index 046fd6dc76..32eeacdcae 100644 --- a/src/views/Settings/Settings.vue +++ b/src/views/Settings/Settings.vue @@ -14,6 +14,7 @@ + @@ -34,6 +35,7 @@ import CertificateEngine from './CertificateEngine.vue' import CollectMetadata from './CollectMetadata.vue' import ConfigureCheck from './ConfigureCheck.vue' import DefaultUserFolder from './DefaultUserFolder.vue' +import DocMDP from './DocMDP.vue' import DownloadBinaries from './DownloadBinaries.vue' import ExpirationRules from './ExpirationRules.vue' import IdentificationDocuments from './IdentificationDocuments.vue' @@ -56,6 +58,7 @@ export default { CollectMetadata, ConfigureCheck, DefaultUserFolder, + DocMDP, DownloadBinaries, ExpirationRules, IdentificationDocuments, diff --git a/src/views/Validation.vue b/src/views/Validation.vue index 2ddf88d628..5b244c5b63 100644 --- a/src/views/Validation.vue +++ b/src/views/Validation.vue @@ -272,6 +272,81 @@ + + + + + +
+ + + + + + + + + + + + + + + +
assertSame($expectedLevel->value, $result['docmdp']['level'], "PDF with P=$pValue must be detected as {$expectedLevel->name}"); } - private function createResourceFromContent(string $content) { - $resource = tmpfile(); - fwrite($resource, $content); - fseek($resource, 0); - return $resource; - } - - private function createMinimalPdf(): string { - return "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" - . "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" - . "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" - . "xref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n190\n%%EOF"; - } - - private function createPdfWithDocMdp(int $pValue, bool $withModifications = false): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - if ($withModifications) { - $targetLength = $offset2 + $length2; - while (strlen($pdf) < $targetLength) { - $pdf .= ' '; - } - - $pdf .= "\n7 0 obj\n<< /Type /Annot /Subtype /Text /Rect [100 100 200 200] >>\nendobj\n"; - $pdf .= "xref\n7 1\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; - } else { - $startxref = strlen($pdf); - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - } - - return $pdf; - } - - private function createPdfWithFormFieldModification(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 7 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $targetLength = $offset2 + $length2; - while (strlen($pdf) < $targetLength) { - $pdf .= ' '; - } - - $pdf .= "\n7 0 obj\n<< /FT /Tx /T (TextField1) /V (Modified Value) >>\nendobj\n"; - $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; - - return $pdf; - } - - private function createPdfWithAnnotationModification(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [7 0 R] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $targetLength = $offset2 + $length2; - while (strlen($pdf) < $targetLength) { - $pdf .= ' '; - } - - $pdf .= "\n7 0 obj\n<< /Type /Annot /Subtype /Text /Rect [100 100 200 200] /Contents (Comment added) >>\nendobj\n"; - $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; - - return $pdf; - } - - private function createPdfWithStructuralModification(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $targetLength = $offset2 + $length2; - while (strlen($pdf) < $targetLength) { - $pdf .= ' '; - } - - $pdf .= "\n7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; - - return $pdf; - } - - private function createPdfWithSubsequentSignature(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 8 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $targetLength = $offset2 + $length2; - while (strlen($pdf) < $targetLength) { - $pdf .= ' '; - } - - $pdf .= "\n7 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= '/ByteRange [ 0 100 200 100 ] /Contents <' . str_repeat('00', 50) . "> >>\nendobj\n"; - $pdf .= "8 0 obj\n<< /FT /Sig /T (Signature2) /V 7 0 R >>\nendobj\n"; - $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; - - return $pdf; - } - - private function createPdfWithDocMdpInSignatureReference(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } - - private function createPdfWithApprovalThenCertifyingSignature(): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 8 0 R] >>\nendobj\n"; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= '/ByteRange [ 0 100 200 100 ] /Contents <' . str_repeat('00', 50) . "> >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (ApprovalSignature) /V 5 0 R >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "7 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "8 0 obj\n<< /FT /Sig /T (CertifyingSignature) /V 7 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } - - private function createPdfWithPageTemplate(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $targetLength = $offset2 + $length2; - while (strlen($pdf) < $targetLength) { - $pdf .= ' '; - } - - $pdf .= "\n7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>\nendobj\n"; - $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; - - return $pdf; - } - - private function createPdfWithIndirectReferencesItiStyle(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 300; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ 7 0 R ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $pdf .= "7 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams 8 0 R >>\nendobj\n"; - - $pdf .= "8 0 obj\n<< /Type /TransformParams /P $pValue /V /1.2 >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } - - private function createPdfWithDocMdpVersion12(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } - - private function createPdfWithDocMdpWithoutVersion(int $pValue): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } - - private function createPdfWithDocMdpInvalidVersion(int $pValue, string $version): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 200; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; - $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /$version >> >> ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } - - private function createPdfWithIndirectReferencesInvalidVersion(int $pValue, string $version): string { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - - $signatureStart = strlen($pdf) + 350; - $signatureLength = 100; - $offset1 = 0; - $length1 = $signatureStart; - $offset2 = $signatureStart + $signatureLength; - - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/M (D:20220705145549-03'00')\n"; - $pdf .= "/Reference [7 0 R]\n"; - $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; - $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; - - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - - $pdf .= "7 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams 8 0 R >>\nendobj\n"; - $pdf .= "8 0 obj\n<< /Type /TransformParams /P $pValue /V /$version >>\nendobj\n"; - - $length2 = 200; - $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); - - $startxref = strlen($pdf); - $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; - - return $pdf; - } + // PDF fixture methods are now provided by PdfFixtureTrait // ISO 32000-1 Table 252 validation tests public function testRejectsSignatureDictionaryWithoutTypeWhenPresent(): void { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - $pdf .= "5 0 obj\n<< /Type /InvalidType /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + $pdf = $this->createPdfWithInvalidSignatureType(); $resource = $this->createResourceFromContent($pdf); $result = $this->handler->extractDocMdpData($resource); @@ -730,15 +276,7 @@ public function testRejectsSignatureDictionaryWithoutTypeWhenPresent(): void { } public function testRejectsSignatureWithoutFilterEntry(): void { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - $pdf .= "5 0 obj\n<< /Type /Sig /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + $pdf = $this->createPdfWithoutFilterEntry(); $resource = $this->createResourceFromContent($pdf); $result = $this->handler->extractDocMdpData($resource); @@ -748,15 +286,7 @@ public function testRejectsSignatureWithoutFilterEntry(): void { } public function testRejectsSignatureWithoutByteRange(): void { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + $pdf = $this->createPdfWithoutByteRange(); $resource = $this->createResourceFromContent($pdf); $result = $this->handler->extractDocMdpData($resource); @@ -766,27 +296,7 @@ public function testRejectsSignatureWithoutByteRange(): void { } public function testRejectsMultipleDocMdpSignatures(): void { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 10 0 R] >>\nendobj\n"; - - // First DocMDP signature - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [0 100 200 100]\n"; - $pdf .= "/Reference [7 0 R] >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - $pdf .= "7 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>\nendobj\n"; - - // Second DocMDP signature (INVALID per ISO) - $pdf .= "8 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [0 100 300 100]\n"; - $pdf .= "/Reference [9 0 R] >>\nendobj\n"; - $pdf .= "9 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 3 /V /1.2 >> >>\nendobj\n"; - $pdf .= "10 0 obj\n<< /FT /Sig /T (Signature2) /V 8 0 R >>\nendobj\n"; - - $pdf .= "xref\n0 11\ntrailer\n<< /Size 11 /Root 1 0 R >>\n%%EOF"; + $pdf = $this->createPdfWithMultipleDocMdpSignatures(); $resource = $this->createResourceFromContent($pdf); $result = $this->handler->extractDocMdpData($resource); @@ -796,25 +306,7 @@ public function testRejectsMultipleDocMdpSignatures(): void { } public function testRejectsDocMdpNotFirstSignature(): void { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 10 0 R] >>\nendobj\n"; - - // First signature: regular approval signature (no DocMDP) - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [0 100 200 100] >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (ApprovalSignature) /V 5 0 R >>\nendobj\n"; - - // Second signature: DocMDP certification (INVALID - must be first) - $pdf .= "7 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [0 100 300 100]\n"; - $pdf .= "/Reference [8 0 R] >>\nendobj\n"; - $pdf .= "8 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>\nendobj\n"; - $pdf .= "10 0 obj\n<< /FT /Sig /T (CertificationSignature) /V 7 0 R >>\nendobj\n"; - - $pdf .= "xref\n0 11\ntrailer\n<< /Size 11 /Root 1 0 R >>\n%%EOF"; + $pdf = $this->createPdfWithDocMdpNotFirst(); $resource = $this->createResourceFromContent($pdf); $result = $this->handler->extractDocMdpData($resource); @@ -824,16 +316,7 @@ public function testRejectsDocMdpNotFirstSignature(): void { } public function testRejectsSigRefWithoutTransformMethod(): void { - $pdf = "%PDF-1.7\n"; - $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; - $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; - $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; - $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; - $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; - $pdf .= "/ByteRange [0 100 200 100]\n"; - $pdf .= "/Reference [<< /Type /SigRef /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; - $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; - $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + $pdf = $this->createPdfWithSigRefWithoutTransformMethod(); $resource = $this->createResourceFromContent($pdf); $result = $this->handler->extractDocMdpData($resource); @@ -841,4 +324,61 @@ public function testRejectsSigRefWithoutTransformMethod(): void { $this->assertSame(DocMdpLevel::NOT_CERTIFIED->value, $result['docmdp']['level'], 'ISO 32000-1 Table 253: /TransformMethod is Required in signature reference dictionary'); } + + public static function additionalSignaturesProvider(): array { + return [ + // PDFs without any signature + 'Unsigned PDF (virgin) - allows signatures' => ['unsigned', false, true], + + // PDFs with DocMDP signature, no modifications + 'DocMDP P=0 (no restrictions, unmodified) - allows additional signatures' => [0, false, true], + 'DocMDP P=1 (no changes allowed, unmodified) - prohibits additional signatures' => [1, false, false], + 'DocMDP P=2 (form filling allowed, unmodified) - allows additional signatures' => [2, false, true], + 'DocMDP P=3 (form+annotations allowed, unmodified) - allows additional signatures' => [3, false, true], + + // PDFs with DocMDP signature, with modifications + 'DocMDP P=0 (no restrictions, modified) - allows additional signatures' => [0, true, true], + 'DocMDP P=1 (no changes allowed, modified) - prohibits additional signatures' => [1, true, false], + 'DocMDP P=2 (form filling allowed, modified) - allows additional signatures' => [2, true, true], + 'DocMDP P=3 (form+annotations allowed, modified) - allows additional signatures' => [3, true, true], + ]; + } + + #[DataProvider('additionalSignaturesProvider')] + public function testAdditionalSignaturesBasedOnDocMdpLevel(string|int $level, bool $withModifications, bool $expectedAllowed): void { + if ($level === 'unsigned') { + // PDF without any signature (virgin PDF) + $pdfContent = $this->createMinimalPdf(); + } else { + // PDF with DocMDP signature at specified level (0, 1, 2, or 3) + $pdfContent = $this->createPdfWithDocMdp($level, $withModifications); + } + + $resource = $this->createResourceFromContent($pdfContent); + $result = $this->handler->allowsAdditionalSignatures($resource); + fclose($resource); + + $this->assertSame($expectedAllowed, $result); + } + + public function testRealJSignPdfWithDocMdpLevel1(): void { + $pdfPath = __DIR__ . '/../../fixtures/real_jsignpdf_level1.pdf'; + + if (!file_exists($pdfPath)) { + $this->markTestSkipped('Real JSignPdf test PDF not found'); + } + + $content = file_get_contents($pdfPath); + $resource = $this->createResourceFromContent($content); + + $data = $this->handler->extractDocMdpData($resource); + + rewind($resource); + $allows = $this->handler->allowsAdditionalSignatures($resource); + + fclose($resource); + + $this->assertSame(1, $data['docmdp']['level'], 'Should detect DocMDP level 1 from real JSignPdf'); + $this->assertFalse($allows, 'Should not allow additional signatures for DocMDP level 1'); + } } diff --git a/tests/php/Unit/PdfFixtureTrait.php b/tests/php/Unit/PdfFixtureTrait.php new file mode 100644 index 0000000000..7a9349efb2 --- /dev/null +++ b/tests/php/Unit/PdfFixtureTrait.php @@ -0,0 +1,681 @@ +>\nendobj\n" + . "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + . "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" + . "xref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n190\n%%EOF"; + } + + /** + * Create a complete PDF with DocMDP signature + * + * This creates a more complete PDF structure that passes FPDI validation + * and includes proper DocMDP transformation parameters. + * + * @param int $pValue DocMDP permission level (0=not certified, 1=no changes, 2=form filling, 3=form+annotations) + * @param bool $withModifications Whether to add modifications after signature + * @return string PDF content as string + */ + /** + * Create PDF with DocMDP signature + * + * Uses complete FPDI-valid structure for FileService tests, + * or minimal structure for DocMdpHandler tests. + */ + protected function createPdfWithDocMdp(int $pValue, bool $withModifications = false): string { + // FileService needs FPDI-valid PDF (has validatePdfStringWithFpdi) + if (str_contains(static::class, 'FileServiceTest')) { + return $this->createCompletePdfStructure($pValue, $withModifications); + } + + // DocMdpHandler only needs minimal structure + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + if ($withModifications) { + $targetLength = $offset2 + $length2; + while (strlen($pdf) < $targetLength) { + $pdf .= ' '; + } + + $pdf .= "\n7 0 obj\n<< /Type /Annot /Subtype /Text /Rect [100 100 200 200] >>\nendobj\n"; + $pdf .= "xref\n7 1\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; + } else { + $startxref = strlen($pdf); + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + } + + return $pdf; + } + + /** + * FPDI-compliant PDF structure (for FileService validation) + * + * FileService.validateFileContent() uses Smalot PDF parser which requires: + * - Valid xref table with correct offsets + * - Content streams + * - Font dictionaries + * - Proper trailer + */ + private function createCompletePdfStructure(int $pValue, bool $withModifications): string { + $pdf = "%PDF-1.7\n"; + + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $currentLength = strlen($pdf); + $signatureObjectStart = $currentLength + 150; + $signatureLength = 8192; + $offset1 = 0; + $length1 = $signatureObjectStart; + $offset2 = $signatureObjectStart + $signatureLength; + + $sigObj = "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $sigObj .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $sigObj .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $sigObj .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $sigObj .= '/Contents <' . str_repeat('30', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= $sigObj; + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R /Rect [0 0 0 0] /P 3 0 R >>\nendobj\n"; + $pdf .= "7 0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test Document) Tj\nET\nendstream\nendobj\n"; + $pdf .= "8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"; + + $length2 = 300; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + if ($withModifications) { + $targetLength = $offset2 + $length2; + while (strlen($pdf) < $targetLength) { + $pdf .= ' '; + } + $pdf .= "\n9 0 obj\n<< /Type /Annot /Subtype /Text /Rect [100 100 200 200] >>\nendobj\n"; + } + + $xrefPos = strlen($pdf); + $objectCount = $withModifications ? 10 : 9; + $pdf .= "xref\n0 $objectCount\n"; + $pdf .= "0000000000 65535 f \n"; + $pdf .= "0000000015 00000 n \n"; + $pdf .= "0000000115 00000 n \n"; + $pdf .= "0000000174 00000 n \n"; + $pdf .= "0000000308 00000 n \n"; + $pdf .= sprintf("%010d 00000 n \n", $currentLength); + $pdf .= sprintf("%010d 00000 n \n", $currentLength + strlen($sigObj)); + $pdf .= sprintf("%010d 00000 n \n", $currentLength + strlen($sigObj) + 100); + $pdf .= sprintf("%010d 00000 n \n", $currentLength + strlen($sigObj) + 200); + if ($withModifications) { + $pdf .= sprintf("%010d 00000 n \n", $xrefPos - 100); + } + + $pdf .= "trailer\n<< /Size $objectCount /Root 1 0 R >>\n"; + $pdf .= "startxref\n$xrefPos\n%%EOF\n"; + + return $pdf; + } + + protected function createPdfWithFormFieldModification(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 7 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $targetLength = $offset2 + $length2; + while (strlen($pdf) < $targetLength) { + $pdf .= ' '; + } + + $pdf .= "\n7 0 obj\n<< /FT /Tx /T (TextField1) /V (Modified Value) >>\nendobj\n"; + $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with annotation modification + */ + protected function createPdfWithAnnotationModification(int $pValue): string { + return $this->createPdfWithDocMdp($pValue, withModifications: true); + } + + /** + * Create PDF with DocMDP but without version in TransformParams + */ + protected function createPdfWithDocMdpWithoutVersion(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + // Missing /V in TransformParams + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $startxref = strlen($pdf); + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with DocMDP with invalid version + */ + protected function createPdfWithDocMdpInvalidVersion(int $pValue, string $version): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /$version >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $startxref = strlen($pdf); + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with DocMDP version 1.2 (valid per ICP-Brasil) + */ + protected function createPdfWithDocMdpVersion12(int $pValue): string { + return $this->createPdfWithDocMdp($pValue); + } + + /** + * Convenience methods for specific DocMDP levels + */ + protected function createPdfWithDocMdpLevel0(): string { + return $this->createPdfWithDocMdp(0); + } + + protected function createPdfWithDocMdpLevel1(): string { + return $this->createPdfWithDocMdp(1); + } + + protected function createPdfWithDocMdpLevel2(): string { + return $this->createPdfWithDocMdp(2); + } + + protected function createPdfWithDocMdpLevel3(): string { + return $this->createPdfWithDocMdp(3); + } + + /** + * Create resource from PDF content (for DocMdpHandler tests) + */ + protected function createResourceFromContent(string $content) { + $resource = tmpfile(); + fwrite($resource, $content); + fseek($resource, 0); + return $resource; + } + + /** + * Create PDF with structural modification (adding a new page) + */ + protected function createPdfWithStructuralModification(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $targetLength = $offset2 + $length2; + while (strlen($pdf) < $targetLength) { + $pdf .= ' '; + } + + $pdf .= "\n7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with subsequent signature (multiple signatures) + */ + protected function createPdfWithSubsequentSignature(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 8 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $targetLength = $offset2 + $length2; + while (strlen($pdf) < $targetLength) { + $pdf .= ' '; + } + + $pdf .= "\n7 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= '/ByteRange [ 0 100 200 100 ] /Contents <' . str_repeat('00', 50) . "> >>\nendobj\n"; + $pdf .= "8 0 obj\n<< /FT /Sig /T (Signature2) /V 7 0 R >>\nendobj\n"; + $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with DocMDP in signature Reference (without /Perms) + */ + protected function createPdfWithDocMdpInSignatureReference(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $startxref = strlen($pdf); + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with approval signature followed by certifying signature + */ + protected function createPdfWithApprovalThenCertifyingSignature(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 8 0 R] >>\nendobj\n"; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= '/ByteRange [ 0 100 200 100 ] /Contents <' . str_repeat('00', 50) . "> >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (ApprovalSignature) /V 5 0 R >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "7 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "8 0 obj\n<< /FT /Sig /T (CertifyingSignature) /V 7 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $startxref = strlen($pdf); + $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with page template (XObject Form) + */ + protected function createPdfWithPageTemplate(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Perms << /DocMDP 5 0 R >> >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 200; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ << /Type /SigRef /TransformMethod /DocMDP\n"; + $pdf .= "/TransformParams << /Type /TransformParams /P $pValue /V /1.2 >> >> ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $targetLength = $offset2 + $length2; + while (strlen($pdf) < $targetLength) { + $pdf .= ' '; + } + + $pdf .= "\n7 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>\nendobj\n"; + $pdf .= "xref\n0 8\ntrailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n" . strlen($pdf) . "\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with indirect references (ITI style) + */ + protected function createPdfWithIndirectReferencesItiStyle(int $pValue): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 300; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= "/Reference [ 7 0 R ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $pdf .= "7 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams 8 0 R >>\nendobj\n"; + + $pdf .= "8 0 obj\n<< /Type /TransformParams /P $pValue /V /1.2 >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $startxref = strlen($pdf); + $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + + return $pdf; + } + + /** + * Create PDF with indirect references and invalid version (for testing rejection) + */ + protected function createPdfWithIndirectReferencesInvalidVersion(int $pValue, string $version): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + + $signatureStart = strlen($pdf) + 350; + $signatureLength = 100; + $offset1 = 0; + $length1 = $signatureStart; + $offset2 = $signatureStart + $signatureLength; + + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/M (D:20220705145549-03'00')\n"; + $pdf .= "/Reference [7 0 R]\n"; + $pdf .= "/ByteRange [ $offset1 $length1 $offset2 PLACEHOLDER_LENGTH2 ]\n"; + $pdf .= '/Contents <' . str_repeat('00', $signatureLength / 2) . "> >>\nendobj\n"; + + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + + $pdf .= "7 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams 8 0 R >>\nendobj\n"; + $pdf .= "8 0 obj\n<< /Type /TransformParams /P $pValue /V /$version >>\nendobj\n"; + + $length2 = 200; + $pdf = str_replace('PLACEHOLDER_LENGTH2', (string)$length2, $pdf); + + $startxref = strlen($pdf); + $pdf .= "xref\n0 9\ntrailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n$startxref\n%%EOF"; + + return $pdf; + } + + /** + * ISO 32000-1 Table 252 validation: Signature dictionary with invalid /Type + */ + protected function createPdfWithInvalidSignatureType(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + $pdf .= "5 0 obj\n<< /Type /InvalidType /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + return $pdf; + } + + /** + * ISO 32000-1 Table 252 validation: Signature dictionary without /Filter + */ + protected function createPdfWithoutFilterEntry(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + $pdf .= "5 0 obj\n<< /Type /Sig /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + return $pdf; + } + + /** + * ISO 32000-1: Signature without required /ByteRange + */ + protected function createPdfWithoutByteRange(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + return $pdf; + } + + /** + * ISO 32000-1 12.8.2.2.1: Multiple DocMDP signatures (invalid) + */ + protected function createPdfWithMultipleDocMdpSignatures(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 10 0 R] >>\nendobj\n"; + + // First DocMDP signature + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [0 100 200 100]\n"; + $pdf .= "/Reference [7 0 R] >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + $pdf .= "7 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>\nendobj\n"; + + // Second DocMDP signature (INVALID per ISO) + $pdf .= "8 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [0 100 300 100]\n"; + $pdf .= "/Reference [9 0 R] >>\nendobj\n"; + $pdf .= "9 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 3 /V /1.2 >> >>\nendobj\n"; + $pdf .= "10 0 obj\n<< /FT /Sig /T (Signature2) /V 8 0 R >>\nendobj\n"; + + $pdf .= "xref\n0 11\ntrailer\n<< /Size 11 /Root 1 0 R >>\n%%EOF"; + return $pdf; + } + + /** + * ISO 32000-1 12.8.2.2.1: DocMDP not as first signature (invalid) + */ + protected function createPdfWithDocMdpNotFirst(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R 10 0 R] >>\nendobj\n"; + + // First signature: regular approval signature (no DocMDP) + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [0 100 200 100] >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (ApprovalSignature) /V 5 0 R >>\nendobj\n"; + + // Second signature: DocMDP certification (INVALID - must be first) + $pdf .= "7 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [0 100 300 100]\n"; + $pdf .= "/Reference [8 0 R] >>\nendobj\n"; + $pdf .= "8 0 obj\n<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>\nendobj\n"; + $pdf .= "10 0 obj\n<< /FT /Sig /T (CertificationSignature) /V 7 0 R >>\nendobj\n"; + + $pdf .= "xref\n0 11\ntrailer\n<< /Size 11 /Root 1 0 R >>\n%%EOF"; + return $pdf; + } + + /** + * ISO 32000-1 Table 253: SigRef without /TransformMethod + */ + protected function createPdfWithSigRefWithoutTransformMethod(): string { + $pdf = "%PDF-1.7\n"; + $pdf .= "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>\nendobj\n"; + $pdf .= "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"; + $pdf .= "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"; + $pdf .= "4 0 obj\n<< /SigFlags 3 /Fields [6 0 R] >>\nendobj\n"; + $pdf .= "5 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached\n"; + $pdf .= "/ByteRange [0 100 200 100]\n"; + $pdf .= "/Reference [<< /Type /SigRef /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>] >>\nendobj\n"; + $pdf .= "6 0 obj\n<< /FT /Sig /T (Signature1) /V 5 0 R >>\nendobj\n"; + $pdf .= "xref\n0 7\ntrailer\n<< /Size 7 /Root 1 0 R >>\n%%EOF"; + return $pdf; + } +} diff --git a/tests/php/Unit/Service/FileServiceTest.php b/tests/php/Unit/Service/FileServiceTest.php index 65d9c6a916..48a976bbc3 100644 --- a/tests/php/Unit/Service/FileServiceTest.php +++ b/tests/php/Unit/Service/FileServiceTest.php @@ -26,6 +26,7 @@ function is_uploaded_file($filename) { use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\IdDocsMapper; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\AccountService; @@ -53,6 +54,7 @@ function is_uploaded_file($filename) { * @internal */ final class FileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { + use \OCA\Libresign\Tests\Unit\PdfFixtureTrait; protected FileMapper $fileMapper; protected SignRequestMapper $signRequestMapper; protected FileElementMapper $fileElementMapper; @@ -72,6 +74,7 @@ final class FileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { private IURLGenerator $urlGenerator; protected IMimeTypeDetector $mimeTypeDetector; protected Pkcs12Handler $pkcs12Handler; + protected DocMdpHandler $docMdpHandler; private IRootFolder $root; protected LoggerInterface $logger; protected IL10N $l10n; @@ -104,6 +107,7 @@ private function getService(): FileService { $this->urlGenerator = \OCP\Server::get(IURLGenerator::class); $this->mimeTypeDetector = \OCP\Server::get(IMimeTypeDetector::class); $this->pkcs12Handler = \OCP\Server::get(Pkcs12Handler::class); + $this->docMdpHandler = \OCP\Server::get(DocMdpHandler::class); $this->root = \OCP\Server::get(IRootFolder::class); $this->logger = \OCP\Server::get(LoggerInterface::class); $this->l10n = \OCP\Server::get(IL10NFactory::class)->get(Application::APP_ID); @@ -127,6 +131,7 @@ private function getService(): FileService { $this->urlGenerator, $this->mimeTypeDetector, $this->pkcs12Handler, + $this->docMdpHandler, $this->root, $this->logger, $this->l10n, @@ -150,6 +155,9 @@ public function testToArray(callable $arguments, array $expected): void { $this->removePurposesField($expected); $this->removePurposesField($actual); + $this->removeDocMdpFields($expected); + $this->removeDocMdpFields($actual); + $this->assertEquals($expected, $actual); } @@ -166,6 +174,16 @@ private function removePurposesField(array &$data): void { } } + private function removeDocMdpFields(array &$data): void { + if (isset($data['signers'])) { + foreach ($data['signers'] as &$signer) { + unset($signer['docmdp']); + unset($signer['modifications']); + unset($signer['modification_validation']); + } + } + } + public static function dataToArray(): array { return [ 'empty' => [fn () => null, []], @@ -439,4 +457,46 @@ function (self $self, FileService $service): void { ], ]; } + + public function testValidateFileContentRejectsDocMdpLevel1(): void { + $pdfContent = $this->createPdfWithDocMdpLevel1(); + $service = $this->getService(); + + $this->expectException(\OCA\Libresign\Exception\LibresignException::class); + + $service->validateFileContent($pdfContent, 'pdf'); + } + + public function testValidateFileContentAllowsDocMdpLevel2(): void { + $this->expectNotToPerformAssertions(); + $pdfContent = $this->createPdfWithDocMdpLevel2(); + $service = $this->getService(); + + $service->validateFileContent($pdfContent, 'pdf'); + } + + public function testValidateFileContentAllowsDocMdpLevel3(): void { + $this->expectNotToPerformAssertions(); + $pdfContent = $this->createPdfWithDocMdp(3); + $service = $this->getService(); + + $service->validateFileContent($pdfContent, 'pdf'); + } + + public function testValidateFileContentAllowsUnsignedPdf(): void { + $this->expectNotToPerformAssertions(); + $pdfPath = __DIR__ . '/../../fixtures/small_valid.pdf'; + $pdfContent = file_get_contents($pdfPath); + $service = $this->getService(); + + $service->validateFileContent($pdfContent, 'pdf'); + } + + public function testValidateFileContentSkipsNonPdfFiles(): void { + $this->expectNotToPerformAssertions(); + $service = $this->getService(); + + $service->validateFileContent('any content', 'txt'); + $service->validateFileContent('{"json": true}', 'json'); + } } diff --git a/tests/php/Unit/Service/RequestSignatureServiceTest.php b/tests/php/Unit/Service/RequestSignatureServiceTest.php index be59154b1e..ef32e66370 100644 --- a/tests/php/Unit/Service/RequestSignatureServiceTest.php +++ b/tests/php/Unit/Service/RequestSignatureServiceTest.php @@ -11,6 +11,7 @@ use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\FileElementService; use OCA\Libresign\Service\FolderService; @@ -43,6 +44,7 @@ final class RequestSignatureServiceTest extends \OCA\Libresign\Tests\Unit\TestCa private PdfParserService&MockObject $pdfParserService; private IMimeTypeDetector&MockObject $mimeTypeDetector; private IClientService&MockObject $client; + private DocMdpHandler&MockObject $docMdpHandler; private LoggerInterface&MockObject $loggerInterface; public function setUp(): void { @@ -66,6 +68,7 @@ public function setUp(): void { $this->pdfParserService = $this->createMock(PdfParserService::class); $this->mimeTypeDetector = $this->createMock(IMimeTypeDetector::class); $this->client = $this->createMock(IClientService::class); + $this->docMdpHandler = $this->createMock(DocMdpHandler::class); $this->loggerInterface = $this->createMock(LoggerInterface::class); } @@ -84,6 +87,7 @@ private function getService(): RequestSignatureService { $this->mimeTypeDetector, $this->validateHelper, $this->client, + $this->docMdpHandler, $this->loggerInterface ); } diff --git a/tests/php/Unit/Service/SignFileServiceTest.php b/tests/php/Unit/Service/SignFileServiceTest.php index cd34edd5e9..61a62ed970 100644 --- a/tests/php/Unit/Service/SignFileServiceTest.php +++ b/tests/php/Unit/Service/SignFileServiceTest.php @@ -22,6 +22,7 @@ use OCA\Libresign\Events\SignedEvent; use OCA\Libresign\Events\SignedEventFactory; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\FooterHandler; use OCA\Libresign\Handler\PdfTk\Pdf; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; @@ -34,6 +35,7 @@ use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\SignerElementsService; use OCA\Libresign\Service\SignFileService; +use OCA\Libresign\Tests\Unit\PdfFixtureTrait; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; @@ -57,6 +59,7 @@ * @group DB */ final class SignFileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { + use PdfFixtureTrait; private IL10N&MockObject $l10n; private FooterHandler&MockObject $footerHandler; private FileMapper&MockObject $fileMapper; @@ -85,6 +88,7 @@ final class SignFileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { private SignEngineFactory $signEngineFactory; private SignedEventFactory&MockObject $signedEventFactory; private Pdf&MockObject $pdf; + private DocMdpHandler $docMdpHandler; public function setUp(): void { parent::setUp(); @@ -119,6 +123,7 @@ public function setUp(): void { $this->signEngineFactory = \OCP\Server::get(SignEngineFactory::class); $this->signedEventFactory = $this->createMock(SignedEventFactory::class); $this->pdf = $this->createMock(Pdf::class); + $this->docMdpHandler = new DocMdpHandler($this->l10n); } private function getService(array $methods = []): SignFileService|MockObject { @@ -152,6 +157,7 @@ private function getService(array $methods = []): SignFileService|MockObject { $this->signEngineFactory, $this->signedEventFactory, $this->pdf, + $this->docMdpHandler, ]) ->onlyMethods($methods) ->getMock(); @@ -184,6 +190,7 @@ private function getService(array $methods = []): SignFileService|MockObject { $this->signEngineFactory, $this->signedEventFactory, $this->pdf, + $this->docMdpHandler, ); } @@ -261,10 +268,13 @@ public function testSignGenerateASha256OfSignedFile(string $signedContent):void $service = $this->getService([ 'getEngine', 'setNewStatusIfNecessary', + 'getNextcloudFile', ]); $nextcloudFile = $this->createMock(\OCP\Files\File::class); $nextcloudFile->method('getContent')->willReturn($signedContent); + $service->method('getNextcloudFile')->willReturn($nextcloudFile); + $pkcs12Handler = $this->createMock(Pkcs12Handler::class); $pkcs12Handler->method('sign')->willReturn($nextcloudFile); $service->method('getEngine')->willReturn($pkcs12Handler); @@ -306,8 +316,13 @@ public function testUpdateDatabaseWhenSign(): void { 'getEngine', 'setNewStatusIfNecessary', 'computeHash', + 'getNextcloudFile', ]); + $nextcloudFile = $this->createMock(\OCP\Files\File::class); + $nextcloudFile->method('getContent')->willReturn('pdf content'); + $service->method('getNextcloudFile')->willReturn($nextcloudFile); + $this->fileMapper->expects($this->once())->method('update'); $this->signRequestMapper->expects($this->once())->method('update'); @@ -325,8 +340,13 @@ public function testDispatchEventWhenSign(): void { 'getEngine', 'setNewStatusIfNecessary', 'computeHash', + 'getNextcloudFile', ]); + $nextcloudFile = $this->createMock(\OCP\Files\File::class); + $nextcloudFile->method('getContent')->willReturn('pdf content'); + $service->method('getNextcloudFile')->willReturn($nextcloudFile); + $this->eventDispatcher ->expects($this->once()) ->method('dispatchTyped') @@ -347,8 +367,13 @@ public function testCheckStatusAfterSign(array $inputSigners, int $fileStatus, i 'getEngine', 'computeHash', 'getSigners', + 'getNextcloudFile', ]); + $nextcloudFile = $this->createMock(\OCP\Files\File::class); + $nextcloudFile->method('getContent')->willReturn('pdf content'); + $service->method('getNextcloudFile')->willReturn($nextcloudFile); + $service->method('getSigners')->willReturn($inputSigners); $signRequest = $this->createMock(SignRequest::class); @@ -443,6 +468,7 @@ public function testGetOrGeneratePfxContent(bool $signWithoutPassword, string $o 'updateSignRequest', 'updateLibreSignFile', 'dispatchSignedEvent', + 'validateDocMdpAllowsSignatures', ]); $signEngineHandler = $this->getMockBuilder(Pkcs12Handler::class) @@ -1147,4 +1173,76 @@ public static function providerGetNodeByIdUsingUid(): array { [\OCP\Files\File::class, ''], ]; } + + public function testSignThrowsExceptionWhenDocMdpLevel1Detected(): void { + $this->expectException(LibresignException::class); + $service = $this->getService(['getNextcloudFile', 'getEngine']); + + $nextcloudFile = $this->createMock(\OCP\Files\File::class); + $nextcloudFile->method('getContent')->willReturn(file_get_contents(__DIR__ . '/../../fixtures/real_jsignpdf_level1.pdf')); + $service->method('getNextcloudFile')->willReturn($nextcloudFile); + + $engineMock = $this->createMock(Pkcs12Handler::class); + $service->method('getEngine')->willReturn($engineMock); + + $signRequest = $this->createMock(SignRequest::class); + $libreSignFile = $this->createMock(\OCA\Libresign\Db\File::class); + + $service + ->setSignRequest($signRequest) + ->setLibreSignFile($libreSignFile) + ->sign(); + } + + #[DataProvider('provideValidateDocMdpAllowsSignaturesScenarios')] + public function testValidateDocMdpAllowsSignaturesWithVariousPdfFixtures( + callable $pdfContentGenerator, + bool $shouldThrowException, + ): void { + if (!$shouldThrowException) { + $this->expectNotToPerformAssertions(); + } else { + $this->expectException(LibresignException::class); + } + + $service = $this->getService(['getLibreSignFileAsResource']); + + $pdfContent = $pdfContentGenerator($this); + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $pdfContent); + rewind($resource); + + $service->method('getLibreSignFileAsResource')->willReturn($resource); + + self::invokePrivate($service, 'validateDocMdpAllowsSignatures'); + } + + public static function provideValidateDocMdpAllowsSignaturesScenarios(): array { + return [ + 'Unsigned PDF - should NOT throw exception' => [ + 'pdfContentGenerator' => fn (self $test) => $test->createMinimalPdf(), + 'shouldThrowException' => false, + ], + 'DocMDP level 0 (not certified) - should NOT throw exception' => [ + 'pdfContentGenerator' => fn (self $test) => $test->createPdfWithDocMdp(0, false), + 'shouldThrowException' => false, + ], + 'DocMDP level 1 (no changes allowed) - SHOULD throw exception' => [ + 'pdfContentGenerator' => fn (self $test) => $test->createPdfWithDocMdp(1, false), + 'shouldThrowException' => true, + ], + 'DocMDP level 2 (form filling allowed) - should NOT throw exception' => [ + 'pdfContentGenerator' => fn (self $test) => $test->createPdfWithDocMdp(2, false), + 'shouldThrowException' => false, + ], + 'DocMDP level 3 (annotations allowed) - should NOT throw exception' => [ + 'pdfContentGenerator' => fn (self $test) => $test->createPdfWithDocMdp(3, false), + 'shouldThrowException' => false, + ], + 'DocMDP level 1 with modifications - SHOULD throw exception' => [ + 'pdfContentGenerator' => fn (self $test) => $test->createPdfWithDocMdp(1, true), + 'shouldThrowException' => true, + ], + ]; + } } diff --git a/tests/php/Unit/Settings/AdminTest.php b/tests/php/Unit/Settings/AdminTest.php index 16da49a6fa..90b75a2461 100644 --- a/tests/php/Unit/Settings/AdminTest.php +++ b/tests/php/Unit/Settings/AdminTest.php @@ -11,6 +11,7 @@ use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Service\CertificatePolicyService; +use OCA\Libresign\Service\DocMdpConfigService; use OCA\Libresign\Service\FooterService; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\SignatureBackgroundService; @@ -33,6 +34,7 @@ final class AdminTest extends \OCA\Libresign\Tests\Unit\TestCase { private SignatureTextService&MockObject $signatureTextService; private SignatureBackgroundService&MockObject $signatureBackgroundService; private FooterService&MockObject $footerService; + private DocMdpConfigService&MockObject $docMdpConfigService; public function setUp(): void { $this->initialState = $this->createMock(IInitialState::class); $this->identifyMethodService = $this->createMock(IdentifyMethodService::class); @@ -42,6 +44,7 @@ public function setUp(): void { $this->signatureTextService = $this->createMock(SignatureTextService::class); $this->signatureBackgroundService = $this->createMock(SignatureBackgroundService::class); $this->footerService = $this->createMock(FooterService::class); + $this->docMdpConfigService = $this->createMock(DocMdpConfigService::class); $this->admin = new Admin( $this->initialState, $this->identifyMethodService, @@ -51,6 +54,7 @@ public function setUp(): void { $this->signatureTextService, $this->signatureBackgroundService, $this->footerService, + $this->docMdpConfigService, ); } diff --git a/tests/php/fixtures/real_jsignpdf_level1.pdf b/tests/php/fixtures/real_jsignpdf_level1.pdf new file mode 100644 index 0000000000..6ccf484d6d Binary files /dev/null and b/tests/php/fixtures/real_jsignpdf_level1.pdf differ