Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions lib/Handler/SignEngine/JSignPdf/JSignPdfHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,12 @@ private function replacePdfVersion(string $content, string $newVersion): string
}

private function getCertificationLevel(): ?string {
if (!$this->docMdpConfigService->isEnabled()) {
return null;
$level = $this->getDocMdpLevel();
if ($level === null && $this->docMdpConfigService->isEnabled()) {
$level = $this->docMdpConfigService->getLevel();
}

return $this->docMdpConfigService->getLevel()->name;
return $level !== null && $level->isCertifying() ? $level->name : null;
}

#[\Override]
Expand Down
11 changes: 7 additions & 4 deletions lib/Handler/SignEngine/PhpNativeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -249,15 +249,18 @@ private function getTsaSettings(): array {
}

private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel {
if (!$this->docMdpConfigService->isEnabled()) {
$level = $this->getDocMdpLevel();
if ($level === null && $this->docMdpConfigService->isEnabled()) {
$level = $this->docMdpConfigService->getLevel();
}
if ($level === null || !$level->isCertifying()) {
return null;
}

// DocMDP values mirror CertificationLevel: 1=NoChanges, 2=FormFilling, 3=FormFillAndAnnotations
$level = $this->docMdpConfigService->getLevel()->value;
// Only certify on invisible signatures or on the first visible element
if ($noVisibleElements || !$this->hasExistingSignatures($this->getInputFile()->getContent())) {
return CertificationLevel::fromInt($level);
// DocMDP values mirror CertificationLevel: 1=NoChanges, 2=FormFilling, 3=FormFillAndAnnotations
return CertificationLevel::fromInt($level->value);
}

return null;
Expand Down
1 change: 1 addition & 0 deletions lib/Handler/SignEngine/Pkcs12Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ public function sign(): File {
->setPassword($this->getPassword())
->setSignatureParams($this->getSignatureParams())
->setVisibleElements($this->getVisibleElements())
->setDocMdpLevel($this->getDocMdpLevel())
->getSignedContent();
$this->getInputFile()->putContent($signedContent);
return $this->getInputFile();
Expand Down
15 changes: 15 additions & 0 deletions lib/Handler/SignEngine/SignEngineHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use InvalidArgumentException;
use OCA\Libresign\DataObjects\VisibleElementAssoc;
use OCA\Libresign\Enum\DocMdpLevel;
use OCA\Libresign\Exception\EmptyCertificateException;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Exception\PasswordToSignNotDefinedException;
Expand All @@ -31,6 +32,7 @@ abstract class SignEngineHandler implements IPkcs12SignEngineHandler {
/** @var VisibleElementAssoc[] */
private array $visibleElements = [];
private array $signatureParams = [];
private ?DocMdpLevel $docMdpLevel = null;

public function __construct(
private IL10N $l10n,
Expand Down Expand Up @@ -119,6 +121,19 @@ public function setSignatureParams(array $params): self {
return $this;
}

/**
* The certification level to sign with, used as is. Without one, the
* current level applies.
*/
public function setDocMdpLevel(?DocMdpLevel $docMdpLevel): self {
$this->docMdpLevel = $docMdpLevel;
return $this;
}

public function getDocMdpLevel(): ?DocMdpLevel {
return $this->docMdpLevel;
}

public function setLeafExpiryOverrideInDays(?int $days): self {
$this->getCertificateEngine()->setLeafExpiryOverrideInDays($days);
return $this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Enum\DocMdpLevel;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Service\Policy\AbstractFilePolicyApplier;
use OCA\Libresign\Service\Policy\Provider\DocMdp\DocMdpPolicy;
use OCP\AppFramework\Http;
use OCP\IUser;

class DocMdpFilePolicyApplier extends AbstractFilePolicyApplier {
Expand All @@ -31,6 +33,11 @@ public function apply(FileEntity $file, array $data): void {
#[\Override]
public function sync(FileEntity $file, array $data): void {
$requestOverrides = $this->getOverrides($data);
if ($this->isPolicySnapshotFrozen($file, DocMdpPolicy::KEY)) {
$this->assertFrozenLevelIsKept($file, $requestOverrides);
return;
}

$activeContext = $this->extractActiveContext($data);
$resolvedPolicy = $activeContext === null
? $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides)
Expand All @@ -55,4 +62,25 @@ public function supportsCoreFlowSync(): bool {
private function getOverrides(array $data): array {
return $this->extractSinglePolicyOverride($data, DocMdpPolicy::KEY);
}

/**
* A frozen request keeps the certification level it was sent with. A client
* may still send that same level along an unrelated update, which changes
* nothing.
*
* @param array<string, mixed> $requestOverrides
*/
private function assertFrozenLevelIsKept(FileEntity $file, array $requestOverrides): void {
$submittedLevel = $requestOverrides[DocMdpPolicy::KEY] ?? null;
if ($submittedLevel === null || (is_numeric($submittedLevel) && (int)$submittedLevel === $file->getDocmdpLevel())) {
return;
}

throw new LibresignException(
// TRANSLATORS Error shown when someone tries to change the PDF certification (DocMDP level) of a document after the signing flow already started.
$this->l10n?->t('The PDF certification cannot be changed after the signing flow has started.')
?? 'The PDF certification cannot be changed after the signing flow has started.',
Http::STATUS_UNPROCESSABLE_ENTITY,
);
}
}
20 changes: 19 additions & 1 deletion lib/Service/SignFileService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
use OCA\Libresign\Db\SignRequestMapper;
use OCA\Libresign\Db\UserElementMapper;
use OCA\Libresign\Enum\DocMdpLevel;
use OCA\Libresign\Enum\FileStatus;
use OCA\Libresign\Enum\IdentifyMethodRequirement;
use OCA\Libresign\Enum\SignRequestStatus;
Expand All @@ -48,6 +49,7 @@
use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken;
use OCA\Libresign\Service\Policy\PolicyService;
use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy;
use OCA\Libresign\Service\Policy\Provider\DocMdp\DocMdpPolicy;
use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy;
use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue;
use OCA\Libresign\Service\SignRequest\SignRequestService;
Expand Down Expand Up @@ -1248,7 +1250,8 @@ private function configureEngine(): void {
$this->restoreMissingVisibleElementImages();
$this->engine
->setVisibleElements($this->getVisibleElements())
->setSignatureParams($this->getSignatureParams());
->setSignatureParams($this->getSignatureParams())
->setDocMdpLevel($this->getDocMdpLevel());
}
}

Expand Down Expand Up @@ -1494,6 +1497,21 @@ private function getFooterPolicyValue(): string {
return $this->footerPolicyValue;
}

/**
* Once frozen, the request certifies with the level it stores, whatever the
* current level. Without a stored level, the current one applies.
*/
private function getDocMdpLevel(): ?DocMdpLevel {
$policySnapshot = ($this->libreSignFile->getMetadata() ?? [])['policy_snapshot'] ?? null;
if (!is_array($policySnapshot) || !array_key_exists(DocMdpPolicy::KEY, $policySnapshot)) {
return null;
}

return FileStatusService::isPolicySnapshotFrozen($this->libreSignFile, $this->fileMapper->findEnvelope($this->libreSignFile))
? $this->libreSignFile->getDocmdpLevelEnum()
: null;
}

protected function getSignedFile(): ?File {
$nodeId = $this->libreSignFile->getSignedNodeId();
if (!$nodeId) {
Expand Down
142 changes: 142 additions & 0 deletions tests/php/Api/Controller/RequestSignatureControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\SignRequestMapper;
use OCA\Libresign\Enum\DocMdpLevel;
use OCA\Libresign\Enum\FileStatus;
use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue;
use OCA\Libresign\Service\RequestSignatureService;
Expand Down Expand Up @@ -357,4 +358,145 @@ public function testUuidPatchRejectsChangedFrozenFooterWithoutMutatingSnapshot()
$this->assertSame('The footer cannot be changed after the signing flow has started.', $body['ocs']['data']['message']);
$this->assertSame($before['policy_snapshot'], $fileMapper->getById($file->getId())->getMetadata()['policy_snapshot']);
}

/**
* @runInSeparateProcess
*/
public function testUuidPatchKeepsTheFrozenDocMdpAfterTheAdminChangesIt(): void {
$user = $this->createAccount('allowrequestsign', 'password', 'testGroup');
$appConfig = $this->getMockAppConfig();
$appConfig->setValueString(Application::APP_ID, 'groups_request_sign', '{"allowGroups":["admin","testGroup"],"denyGroups":[]}');
$appConfig->setValueBool(Application::APP_ID, 'notifyUnsignedUser', false);
$appConfig->setValueInt(Application::APP_ID, 'docmdp_level', DocMdpLevel::CERTIFIED_FORM_FILLING->value);
$signers = [[
'identifyMethods' => [[
'method' => 'email',
'requirement' => 'optional',
'value' => 'external-signer@test.coop',
]],
]];
$file = $this->requestSignFile([
'file' => ['base64' => base64_encode(file_get_contents(__DIR__ . '/../../fixtures/pdfs/small_valid.pdf'))],
'name' => 'sent',
'signers' => $signers,
'userManager' => $user,
]);
$fileMapper = \OCP\Server::get(FileMapper::class);
$before = $fileMapper->getById($file->getId());
$this->assertSame(DocMdpLevel::CERTIFIED_FORM_FILLING->value, $before->getMetadata()['policy_snapshot']['docmdp']['effectiveValue']);

$appConfig->setValueInt(Application::APP_ID, 'docmdp_level', DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value);
$this->request
->withMethod('PATCH')
->withPath('/api/v1/request-signature')
->withRequestHeader([
'Authorization' => 'Basic ' . base64_encode('allowrequestsign:password'),
'Content-Type' => 'application/json',
])
->withRequestBody([
'uuid' => $file->getUuid(),
'name' => 'renamed',
'signers' => $signers,
])
->expectStatus(200);

$this->assertRequest();
$after = $fileMapper->getById($file->getId());
$this->assertSame(DocMdpLevel::CERTIFIED_FORM_FILLING, $after->getDocmdpLevelEnum());
$this->assertSame($before->getMetadata()['policy_snapshot']['docmdp'], $after->getMetadata()['policy_snapshot']['docmdp']);
}

/**
* @runInSeparateProcess
*/
public function testUuidPatchRejectsAnotherDocMdpOnceTheRequestWasSent(): void {
$user = $this->createAccount('allowrequestsign', 'password', 'testGroup');
$appConfig = $this->getMockAppConfig();
$appConfig->setValueString(Application::APP_ID, 'groups_request_sign', '{"allowGroups":["admin","testGroup"],"denyGroups":[]}');
$appConfig->setValueBool(Application::APP_ID, 'notifyUnsignedUser', false);
$appConfig->setValueInt(Application::APP_ID, 'docmdp_level', DocMdpLevel::CERTIFIED_FORM_FILLING->value);
$appConfig->setValueString(Application::APP_ID, 'docmdp_level.allow_child_override', '1');
$signers = [[
'identifyMethods' => [[
'method' => 'email',
'requirement' => 'optional',
'value' => 'external-signer@test.coop',
]],
]];
$file = $this->requestSignFile([
'file' => ['base64' => base64_encode(file_get_contents(__DIR__ . '/../../fixtures/pdfs/small_valid.pdf'))],
'name' => 'sent',
'signers' => $signers,
'userManager' => $user,
]);
$fileMapper = \OCP\Server::get(FileMapper::class);
$before = $fileMapper->getById($file->getId())->getMetadata();

$this->request
->withMethod('PATCH')
->withPath('/api/v1/request-signature')
->withRequestHeader([
'Authorization' => 'Basic ' . base64_encode('allowrequestsign:password'),
'Content-Type' => 'application/json',
])
->withRequestBody([
'uuid' => $file->getUuid(),
'signers' => $signers,
'policy' => ['overrides' => ['docmdp' => DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value]],
])
->expectStatus(422);

$response = $this->assertRequest();
$body = json_decode($response->getBody()->getContents(), true);
$this->assertSame('The PDF certification cannot be changed after the signing flow has started.', $body['ocs']['data']['message']);
$this->assertSame($before['policy_snapshot'], $fileMapper->getById($file->getId())->getMetadata()['policy_snapshot']);
}

/**
* @runInSeparateProcess
*/
public function testUuidPatchRefreshesDraftDocMdpBeforeFreezing(): void {
$user = $this->createAccount('allowrequestsign', 'password', 'testGroup');
$appConfig = $this->getMockAppConfig();
$appConfig->setValueString(Application::APP_ID, 'groups_request_sign', '{"allowGroups":["admin","testGroup"],"denyGroups":[]}');
$appConfig->setValueBool(Application::APP_ID, 'notifyUnsignedUser', false);
$appConfig->setValueInt(Application::APP_ID, 'docmdp_level', DocMdpLevel::CERTIFIED_FORM_FILLING->value);

$file = $this->requestSignFile([
'file' => ['base64' => base64_encode(file_get_contents(__DIR__ . '/../../fixtures/pdfs/small_valid.pdf'))],
'name' => 'draft',
'status' => FileStatus::DRAFT->value,
'signers' => [],
'userManager' => $user,
]);
$fileMapper = \OCP\Server::get(FileMapper::class);
$this->assertSame(DocMdpLevel::CERTIFIED_FORM_FILLING, $fileMapper->getById($file->getId())->getDocmdpLevelEnum());

$appConfig->setValueInt(Application::APP_ID, 'docmdp_level', DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value);
$this->request
->withMethod('PATCH')
->withPath('/api/v1/request-signature')
->withRequestHeader([
'Authorization' => 'Basic ' . base64_encode('allowrequestsign:password'),
'Content-Type' => 'application/json',
])
->withRequestBody([
'uuid' => $file->getUuid(),
'status' => FileStatus::ABLE_TO_SIGN->value,
'signers' => [[
'identifyMethods' => [[
'method' => 'email',
'requirement' => 'optional',
'value' => 'external-signer@test.coop',
]],
]],
])
->expectStatus(200);

$this->assertRequest();
$sent = $fileMapper->getById($file->getId());
$this->assertSame(DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS, $sent->getDocmdpLevelEnum());
$this->assertSame(DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value, $sent->getMetadata()['policy_snapshot']['docmdp']['effectiveValue']);
$this->assertArrayHasKey('policy_snapshot_frozen_at', $sent->getMetadata());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,27 @@ public static function providerTsaParameters(): array {
];
}

#[DataProvider('providerTheGivenLevelWinsOverTheCurrentLevel')]
public function testTheGivenLevelWinsOverTheCurrentLevel(?DocMdpLevel $givenLevel, DocMdpLevel $currentLevel, ?string $expected): void {
$docMdpConfigService = $this->createMock(DocMdpConfigService::class);
$docMdpConfigService->method('isEnabled')->willReturn($currentLevel->isCertifying());
$docMdpConfigService->method('getLevel')->willReturn($currentLevel);
$jSignPdfHandler = $this->getInstance();
$this->setDocMdpConfigService($jSignPdfHandler, $docMdpConfigService);
$jSignPdfHandler->setDocMdpLevel($givenLevel);

$this->assertSame($expected, self::invokePrivate($jSignPdfHandler, 'getCertificationLevel'));
}

public static function providerTheGivenLevelWinsOverTheCurrentLevel(): array {
return [
'another certifying level' => [DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS, DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED, DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->name],
'not certified while the current level certifies' => [DocMdpLevel::NOT_CERTIFIED, DocMdpLevel::CERTIFIED_FORM_FILLING, null],
'certified while the current level does not' => [DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED, DocMdpLevel::NOT_CERTIFIED, DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED->name],
'no level given falls back to the current level' => [null, DocMdpLevel::CERTIFIED_FORM_FILLING, DocMdpLevel::CERTIFIED_FORM_FILLING->name],
];
}

#[DataProvider('providerCertificationLevelWithoutVisibleElements')]
public function testCertificationLevelWithoutVisibleElements(bool $docMdpEnabled, array $visibleElements, string $pdfContent, array $tsaSettings, array $expectedParameters): void {
if (self::$certificateEngineFactory === null || empty(self::$certificateContent)) {
Expand Down
20 changes: 20 additions & 0 deletions tests/php/Unit/Handler/SignEngine/PhpNativeHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,26 @@ public static function providerResolveCertificationLevel(): array {
];
}

#[DataProvider('providerTheGivenLevelWinsOverTheCurrentLevel')]
public function testTheGivenLevelWinsOverTheCurrentLevel(?DocMdpLevel $givenLevel, DocMdpLevel $currentLevel, ?CertificationLevel $expected): void {
$this->docMdpConfigService->method('isEnabled')->willReturn($currentLevel->isCertifying());
$this->docMdpConfigService->method('getLevel')->willReturn($currentLevel);

$handler = $this->getHandler();
$handler->setDocMdpLevel($givenLevel);

$this->assertSame($expected, $this->callPrivateMethod($handler, 'resolveCertificationLevel', true));
}

public static function providerTheGivenLevelWinsOverTheCurrentLevel(): array {
return [
'another certifying level' => [DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS, DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED, CertificationLevel::FormFillSignaturesAndAnnotations],
'not certified while the current level certifies' => [DocMdpLevel::NOT_CERTIFIED, DocMdpLevel::CERTIFIED_FORM_FILLING, null],
'certified while the current level does not' => [DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED, DocMdpLevel::NOT_CERTIFIED, CertificationLevel::NoChangesAllowed],
'no level given falls back to the current level' => [null, DocMdpLevel::CERTIFIED_FORM_FILLING, CertificationLevel::FormFillAndSignatures],
];
}

public function testBuildAppearanceForElementSetsSignatureImageInGraphicAndDescriptionMode(): void {
$imagePath = realpath(__DIR__ . '/../../../../../img/app-dark.png');
$this->assertNotFalse($imagePath, 'Test image must exist');
Expand Down
Loading
Loading