Skip to content
Merged
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
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ https://libresign.coop
<command>OCA\Libresign\Command\Crl\Stats</command>
<command>OCA\Libresign\Command\Crl\Cleanup</command>
<command>OCA\Libresign\Command\Crl\Revoke</command>
<command>OCA\Libresign\Command\SignatureElements\Cleanup</command>
<command>OCA\Libresign\Command\Developer\Reset</command>
<command>OCA\Libresign\Command\Developer\SignSetup</command>
<command>OCA\Libresign\Command\Install</command>
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default [
// TODO: upstream
'openapi-*.json',
// StrykerJS sandbox
'.stryker-tmp/*'
'.stryker-tmp/*',
],
},

Expand Down
89 changes: 89 additions & 0 deletions lib/Command/SignatureElements/Cleanup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Command\SignatureElements;

use OCA\Libresign\Service\EmailSignerElementsService;
use OCA\Libresign\Service\FolderService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\File;
use OCP\Files\Folder;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/** Explicit retention for guest images, including keys orphaned by secret rotation. */
class Cleanup extends Command {
public function __construct(
private FolderService $folderService,
private ITimeFactory $timeFactory,
) {
parent::__construct();
}

#[\Override]
protected function configure(): void {
$this->setName('libresign:signature-elements:cleanup')
->setDescription('Purge old saved email signatures and anonymous session images, even when their policy is disabled')
->addOption('days', null, InputOption::VALUE_REQUIRED, 'Delete folders whose images have not changed in this many days (required)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'List eligible folders without deleting them');
}

#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$days = filter_var($input->getOption('days'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($days === false || $days === null) {
$output->writeln('<error>Specify a positive number of days with --days.</error>');
return self::INVALID;
}
$cutoff = $this->timeFactory->getDateTime()->modify('-' . $days . ' days')->getTimestamp();
$dryRun = (bool)$input->getOption('dry-run');
$count = 0;
foreach ($this->folderService->getUnauthenticatedFolder()->getDirectoryListing() as $folder) {
if (!$folder instanceof Folder) {
continue;
}
if ($folder->getName() === EmailSignerElementsService::STORAGE_FOLDER) {
foreach ($folder->getDirectoryListing() as $saved) {
if ($saved instanceof Folder && preg_match('/^[a-f0-9]{64}$/D', $saved->getName())
&& $this->purgeFolder($saved, '/^(signature|initial)\.png$/D', $cutoff, $dryRun, $output)) {
$count++;
}
}
} elseif (preg_match('/^[a-zA-Z0-9,-]{16,128}$/D', $folder->getName())
&& $this->purgeFolder($folder, '/^(signature|initial)_[0-9]+\.png$/D', $cutoff, $dryRun, $output)) {
$count++;
}
}
$output->writeln(($dryRun ? 'Would delete ' : 'Deleted ') . $count . ' signature folders.');
return self::SUCCESS;
}

private function purgeFolder(Folder $folder, string $pattern, int $cutoff, bool $dryRun, OutputInterface $output): bool {
if ($folder->getMTime() >= $cutoff) {
return false;
}
$files = $folder->getDirectoryListing();
if ($files === []) {
return false;
}
foreach ($files as $file) {
// The unauthenticated container can also contain documents. Only purge
// folders holding exclusively signature images with the expected names.
if (!$file instanceof File || !preg_match($pattern, $file->getName()) || $file->getMTime() >= $cutoff) {
return false;
}
}
$output->writeln($folder->getName());
if (!$dryRun) {
$folder->delete();
}
return true;
}
}
10 changes: 10 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\SignRequest;
use OCA\Libresign\Db\SignRequestMapper;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
Expand All @@ -19,6 +20,7 @@
use OCA\Libresign\Middleware\Attribute\RequireSetupOk;
use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid;
use OCA\Libresign\Service\AccountService;
use OCA\Libresign\Service\EmailSignerElementsService;
use OCA\Libresign\Service\File\FileListService;
use OCA\Libresign\Service\FileService;
use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService;
Expand Down Expand Up @@ -81,6 +83,7 @@ public function __construct(
private SigningRequestValidator $signingRequestValidator,
private IEventDispatcher $eventDispatcher,
private IURLGenerator $urlGenerator,
private EmailSignerElementsService $emailSignerElementsService,
) {
parent::__construct(
request: $request,
Expand Down Expand Up @@ -443,6 +446,13 @@ private function provideSignerSignatues(): void {
$signatures = $this->signerElementsService->getUserElements($this->userSession->getUser()->getUID());
} else {
$signatures = $this->signerElementsService->getElementsFromSessionAsArray();
$signRequest = $this->getSignRequestEntity();
if ($signRequest instanceof SignRequest) {
$this->initialState->provideInitialState(
'email_signer_signature',
$this->emailSignerElementsService->getSignerState($signRequest),
);
}
}
$this->initialState->provideInitialState('user_signatures', $signatures);
}
Expand Down
47 changes: 42 additions & 5 deletions lib/Controller/SignFileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use OCA\Libresign\Middleware\Attribute\RequireSigner;
use OCA\Libresign\ResponseDefinitions;
use OCA\Libresign\Service\AsyncSigningService;
use OCA\Libresign\Service\EmailSignerElementsService;
use OCA\Libresign\Service\File\SettingsLoader;
use OCA\Libresign\Service\FileService;
use OCA\Libresign\Service\IdentifyMethodService;
Expand Down Expand Up @@ -76,6 +77,7 @@ public function __construct(
private SignerIpGeolocationPolicyService $signerIpGeolocationPolicyService,
private SigningErrorHandler $errorHandler,
private SignatureRejectionService $signatureRejectionService,
private EmailSignerElementsService $emailSignerElementsService,
private LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
Expand Down Expand Up @@ -118,6 +120,7 @@ public function signByFileId(int $fileId, string $method, array $elements = [],
* @param string $token Token, commonly send by email
* @param bool $async Execute signing asynchronously when possible
* @param LibresignSignerDeviceGeolocation $deviceGeolocation Device-reported geolocation metadata submitted by the signing client
* @param bool $saveSignature Explicitly save the external email signer's visible signature for future documents
* @psalm-param array<string, mixed> $deviceGeolocation
* @return DataResponse<Http::STATUS_OK, LibresignSignActionResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignSignActionErrorResponse, array{}>
*
Expand All @@ -131,8 +134,8 @@ public function signByFileId(int $fileId, string $method, array $elements = [],
#[PublicPage]
#[OpenAPI(tags: ['signing'])]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/sign/uuid/{uuid}', requirements: ['apiVersion' => '(v1)'])]
public function signBySignerUuid(string $uuid, string $method, array $elements = [], string $identifyValue = '', string $token = '', bool $async = false, array $deviceGeolocation = []): DataResponse {
return $this->sign($method, $elements, $identifyValue, $token, null, $uuid, $async, $deviceGeolocation);
public function signBySignerUuid(string $uuid, string $method, array $elements = [], string $identifyValue = '', string $token = '', bool $async = false, array $deviceGeolocation = [], bool $saveSignature = false): DataResponse {
return $this->sign($method, $elements, $identifyValue, $token, null, $uuid, $async, $deviceGeolocation, $saveSignature);
}

/**
Expand All @@ -147,6 +150,7 @@ public function sign(
?string $signRequestUuid = null,
bool $async = false,
array $deviceGeolocation = [],
bool $saveSignature = false,
): DataResponse {
try {
$user = $this->userSession->getUser();
Expand Down Expand Up @@ -194,6 +198,18 @@ public function sign(
);
}

$signature = null;
$signatureSaveFailed = false;
if ($saveSignature && !$user instanceof IUser) {
try {
$signature = $this->emailSignerElementsService->prepareSignature($signRequest, $token, $elements, true);
$signatureSaveFailed = $signature === null;
} catch (\Throwable $e) {
$this->logger->warning('Unable to prepare signature elements of email signer', ['exception' => $e]);
$signatureSaveFailed = true;
}
}

$this->signFileService->prepareForSigning(
$libreSignFile,
$signRequest,
Expand All @@ -206,16 +222,37 @@ public function sign(
);

if ($async && $this->workerConfiguration->isAsyncSigningEnabled()) {
return $this->signAsync($libreSignFile, $signRequest, $user, $userIdentifier, $method, $token, $elements, $metadata);
$response = $this->signAsync($libreSignFile, $signRequest, $user, $userIdentifier, $method, $token, $elements, $metadata);
} else {
$response = $this->signSync($libreSignFile, $elements, $metadata);
}

return $this->signSync($libreSignFile, $elements, $metadata);
if ($response->getStatus() === Http::STATUS_OK && $saveSignature && !$user instanceof IUser) {
if ($signature !== null && !$this->saveEmailSignerElements($signature)) {
$signatureSaveFailed = true;
}
$data = $response->getData();
$data['signatureSaveStatus'] = $signatureSaveFailed ? 'failed' : 'saved';
$response->setData($data);
}
return $response;
} catch (\Throwable $e) {
$data = $this->errorHandler->handleException($e);
return new DataResponse($data, Http::STATUS_UNPROCESSABLE_ENTITY);
}
}

/** @param array{key: string, elements: array<string, string>} $signature */
private function saveEmailSignerElements(array $signature): bool {
try {
$this->emailSignerElementsService->saveSignature($signature);
return true;
} catch (\Throwable $e) {
// A failed optional save must never undo successful document signing.
$this->logger->warning('Unable to save signature elements of email signer', ['exception' => $e]);
return false;
}
}

/**
* Execute asynchronous signing using background job
*
Expand Down
81 changes: 80 additions & 1 deletion lib/Controller/SignatureElementsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
namespace OCA\Libresign\Controller;

use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Db\SignRequest;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid;
use OCA\Libresign\Service\AccountService;
use OCA\Libresign\Service\EmailSignerElementsService;
use OCA\Libresign\Service\IdentifyMethod\AbstractIdentifyMethod;
use OCA\Libresign\Service\SessionService;
use OCA\Libresign\Service\SignatureTextService;
use OCA\Libresign\Service\SignerElementsService;
Expand All @@ -19,7 +23,9 @@
use OCA\Libresign\Service\Validation\VisibleElementValidator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
Expand All @@ -34,6 +40,7 @@
use OCP\IUser;
use OCP\IUserSession;
use OCP\Preview\IMimeIconProvider;
use Psr\Log\LoggerInterface;

/**
* @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions
Expand All @@ -56,6 +63,8 @@ public function __construct(
protected IMimeIconProvider $mimeIconProvider,
protected IURLGenerator $urlGenerator,
private VisibleElementValidator $visibleElementValidator,
private EmailSignerElementsService $emailSignerElementsService,
private LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
}
Expand Down Expand Up @@ -151,6 +160,69 @@ public function getSignatureElements(): DataResponse {
}
}

/**
* Use the saved signature elements of a signer identified by email
*
* The saved elements are copied into the current signing session only
* after the signer satisfies the confirm method configured for the Email
* identification factor.
*
* @param string $token Verification code, required when the confirm method is the email token
* @return DataResponse<Http::STATUS_OK, LibresignUserElementsResponse, array{}>|DataResponse<Http::STATUS_UNPROCESSABLE_ENTITY, LibresignMessageResponse, array{}>
*
* 200: OK
* 422: The saved elements cannot be used
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[AnonRateLimit(limit: 10, period: 60)]
#[BruteForceProtection(action: 'libresign_email_signer_elements')]
#[RequestHeader(name: 'libresign-sign-request-uuid', description: 'The UUID of the sign request, used to identify the request', indirect: true)]
#[RequireSignRequestUuid]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/signature/elements/saved', requirements: ['apiVersion' => '(v1)'])]
public function useSavedSignatureElements(string $token = ''): DataResponse {
$signRequest = $this->getSignRequestEntity();
if ($this->userSession->getUser() instanceof IUser || !$signRequest instanceof SignRequest) {
return new DataResponse(
[
// TRANSLATORS Error shown when a signer tries to reuse a saved signature on a request that was not sent by email.
'message' => $this->l10n->t('Saved signatures are only available to signers identified by email'),
],
Http::STATUS_UNPROCESSABLE_ENTITY
);
}
try {
$elements = $this->emailSignerElementsService->unlock($signRequest, $token);
} catch (LibresignException $e) {
$message = $e->getMessage();
$decoded = json_decode($message, true);
if (is_array($decoded) && isset($decoded['errors'][0]['message'])) {
$message = (string)$decoded['errors'][0]['message'];
}
$response = new DataResponse(
[
'message' => $message,
],
Http::STATUS_UNPROCESSABLE_ENTITY
);
if ($e->getCode() === AbstractIdentifyMethod::CODE_INVALID_TOKEN) {
$response->throttle();
}
return $response;
} catch (\Exception $e) {
// Unexpected failures must not be reported as invalid credentials.
$this->logger->error('Unable to load saved email signature elements', ['exception' => $e]);
throw $e;
}
return new DataResponse(
[
'elements' => $elements,
],
Http::STATUS_OK
);
}

/**
* Get preview of signature elements of
*
Expand Down Expand Up @@ -305,19 +377,26 @@ public function patchSignatureElement(int $nodeId, string $type = '', array $fil
#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/signature/elements/{nodeId}', requirements: ['apiVersion' => '(v1)'])]
public function deleteSignatureElement(int $nodeId): DataResponse {
try {
if (!$this->userSession->getUser() instanceof IUser) {
// Keep the session element available for retry when forgetting its copy fails.
$this->emailSignerElementsService->forgetSessionElement($nodeId);
}
$this->accountService->deleteSignatureElement(
user: $this->userSession->getUser(),
nodeId: $nodeId,
sessionId: $this->sessionService->getSessionId(),
);
} catch (\Throwable) {
} catch (DoesNotExistException|\OCP\Files\NotFoundException) {
return new DataResponse(
[
// TRANSLATORS Error shown when deleting a visible signature element that cannot be found.
'message' => $this->l10n->t('Element not found')
],
Http::STATUS_NOT_FOUND
);
} catch (\Exception $e) {
$this->logger->error('Unable to delete signature element', ['exception' => $e]);
throw $e;
}
return new DataResponse(
[
Expand Down
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@
* message?: string,
* file?: LibresignFileUuidReference,
* job?: LibresignSigningJob,
* signatureSaveStatus?: 'saved'|'failed',
* }
* @psalm-type LibresignSignActionErrorResponse = array{
* action: int,
Expand Down
Loading
Loading