Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
99 / 103
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailSignerElementsService
96.12% covered (success)
96.12%
99 / 103
76.92% covered (warning)
76.92%
10 / 13
47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSignerState
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 unlock
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 prepareSignature
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 saveSignature
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 forgetSessionElement
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 getConfirmedIdentifyMethod
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getEmailIdentifyMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getConfirmMethod
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 getKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStoredFolder
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getStoredElement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\Db\SignRequest;
12use OCA\Libresign\Exception\LibresignException;
13use OCA\Libresign\ResponseDefinitions;
14use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
15use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\ISignatureMethod;
16use OCA\Libresign\Service\Policy\PolicyService;
17use OCA\Libresign\Service\Policy\Provider\EmailSignerSignature\EmailSignerSignaturePolicy;
18use OCP\Files\File;
19use OCP\Files\Folder;
20use OCP\Files\NotFoundException;
21use OCP\IConfig;
22use OCP\IL10N;
23
24/**
25 * Keeps the visible signature of signers identified only by email across
26 * documents. Saved images are released only after the signer satisfies the
27 * confirm method configured for the Email identification factor.
28 *
29 * @psalm-import-type LibresignUserElement from ResponseDefinitions
30 */
31class EmailSignerElementsService {
32    public const STORAGE_FOLDER = 'email_signer_elements';
33    private const TYPES = ['signature', 'initial'];
34    /** Strongest method first: when several are enabled, the strongest one is required */
35    private const CONFIRM_METHODS = [
36        ISignatureMethod::SIGNATURE_METHOD_EMAIL_TOKEN,
37        ISignatureMethod::SIGNATURE_METHOD_CLICK_TO_SIGN,
38    ];
39
40    public function __construct(
41        private PolicyService $policyService,
42        private IdentifyMethodService $identifyMethodService,
43        private SignerElementsService $signerElementsService,
44        private SessionService $sessionService,
45        private FolderService $folderService,
46        private IConfig $config,
47        private IL10N $l10n,
48    ) {
49    }
50
51    public function isEnabled(): bool {
52        return $this->policyService->resolve(EmailSignerSignaturePolicy::KEY)->getEffectiveValueAsBool(false);
53    }
54
55    /**
56     * State shown to the signing page, null when the feature does not apply
57     *
58     * This describes eligibility only, never whether a saved image exists.
59     *
60     * @return array{confirmMethod: string}|null
61     */
62    public function getSignerState(SignRequest $signRequest): ?array {
63        if (!$this->isEnabled()) {
64            return null;
65        }
66        $identifyMethod = $this->getEmailIdentifyMethod($signRequest);
67        if (!$identifyMethod) {
68            return null;
69        }
70        $confirmMethod = $this->getConfirmMethod($identifyMethod);
71        if ($confirmMethod === null) {
72            return null;
73        }
74        return [
75            'confirmMethod' => $confirmMethod,
76        ];
77    }
78
79    /**
80     * Copy the saved elements into the current signing session
81     *
82     * @return LibresignUserElement[]
83     * @throws LibresignException
84     */
85    public function unlock(SignRequest $signRequest, string $token): array {
86        $identifyMethod = $this->getConfirmedIdentifyMethod($signRequest, $token);
87        $identifyMethod->validateToIdentify();
88        $key = $this->getKey($identifyMethod);
89        $folder = $this->getStoredFolder($key);
90        if (!$folder) {
91            // TRANSLATORS Error shown when a signer identified by email asks to reuse a saved signature but none was saved yet.
92            throw new LibresignException($this->l10n->t('No saved signature found'));
93        }
94        foreach (self::TYPES as $type) {
95            $element = $this->getStoredElement($folder, $type);
96            if ($element) {
97                $this->signerElementsService->replaceSessionElement($type, $element->getContent());
98            }
99        }
100        $this->sessionService->setEmailSignatureKey($key);
101        return $this->signerElementsService->getElementsFromSessionAsArray();
102    }
103
104    /**
105     * Capture confirmed session elements before signing can change request state.
106     *
107     * @param array<array-key, mixed> $elements Elements submitted to sign
108     * @return array{key: string, elements: array<string, string>}|null
109     */
110    public function prepareSignature(SignRequest $signRequest, string $token, array $elements, bool $saveSignature = false): ?array {
111        if (!$saveSignature) {
112            return null;
113        }
114        try {
115            $identifyMethod = $this->getConfirmedIdentifyMethod($signRequest, $token);
116        } catch (LibresignException) {
117            return null;
118        }
119        $profileNodeIds = array_map('intval', array_column(array_filter($elements, 'is_array'), 'profileNodeId'));
120        $toSave = [];
121        foreach ($this->signerElementsService->getElementsFromSession() as $element) {
122            if (!$element instanceof File || !in_array($element->getId(), $profileNodeIds, true)) {
123                continue;
124            }
125            $type = $this->signerElementsService->getSessionElementType($element);
126            if (in_array($type, self::TYPES, true)) {
127                $toSave[$type] = $element->getContent();
128            }
129        }
130        if (empty($toSave)) {
131            return null;
132        }
133        return ['key' => $this->getKey($identifyMethod), 'elements' => $toSave];
134    }
135
136    /**
137     * Persist the server-side snapshot after signing succeeds or is queued.
138     * Credentials and session files are deliberately not read again here.
139     *
140     * @param array{key: string, elements: array<string, string>} $signature
141     */
142    public function saveSignature(array $signature): void {
143        $key = $signature['key'];
144        $folder = $this->folderService->getUnauthenticatedFolder()->getOrCreateFolder(self::STORAGE_FOLDER)->getOrCreateFolder($key);
145        foreach ($signature['elements'] as $type => $content) {
146            $element = $this->getStoredElement($folder, $type);
147            if ($element) {
148                $element->putContent($content);
149                continue;
150            }
151            $folder->newFile($type . '.png', $content);
152        }
153        $this->sessionService->setEmailSignatureKey($key);
154    }
155
156    /**
157     * Forget the saved copy of a session element the signer is deleting
158     */
159    public function forgetSessionElement(int $nodeId): void {
160        $key = $this->sessionService->getEmailSignatureKey();
161        if ($key === '') {
162            return;
163        }
164        $folder = $this->getStoredFolder($key);
165        if (!$folder) {
166            return;
167        }
168        $element = current(array_filter(
169            $this->signerElementsService->getElementsFromSession(),
170            static fn ($element): bool => $element instanceof File && $element->getId() === $nodeId,
171        ));
172        if (!$element instanceof File) {
173            return;
174        }
175        $saved = $this->getStoredElement($folder, $this->signerElementsService->getSessionElementType($element));
176        if ($saved) {
177            $saved->delete();
178        }
179        if (empty($folder->getDirectoryListing())) {
180            $folder->delete();
181        }
182    }
183
184    /**
185     * @throws LibresignException
186     */
187    private function getConfirmedIdentifyMethod(SignRequest $signRequest, string $token): IIdentifyMethod {
188        if (!$this->isEnabled()) {
189            // TRANSLATORS Error shown when a signer identified by email tries to reuse a saved signature but the administrator disabled the feature.
190            throw new LibresignException($this->l10n->t('Saved signatures are disabled'));
191        }
192        $identifyMethod = $this->getEmailIdentifyMethod($signRequest);
193        $confirmMethod = $identifyMethod ? $this->getConfirmMethod($identifyMethod) : null;
194        if (!$identifyMethod || $confirmMethod === null) {
195            // TRANSLATORS Error shown when a signer tries to reuse a saved signature on a request that was not sent by email.
196            throw new LibresignException($this->l10n->t('Saved signatures are only available to signers identified by email'));
197        }
198        $signatureMethod = $identifyMethod->getSignatureMethods()[$confirmMethod];
199        $signatureMethod->setEntity($identifyMethod->getEntity());
200        $signatureMethod->setCodeSentByUser($token);
201        $signatureMethod->validateToSign();
202        return $identifyMethod;
203    }
204
205    private function getEmailIdentifyMethod(SignRequest $signRequest): ?IIdentifyMethod {
206        $methods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
207        if (count($methods) !== 1 || count($methods[IdentifyMethodService::IDENTIFY_EMAIL] ?? []) !== 1) {
208            return null;
209        }
210        return $methods[IdentifyMethodService::IDENTIFY_EMAIL][0] ?? null;
211    }
212
213    /**
214     * The confirm method configured in the Email identification factor
215     */
216    private function getConfirmMethod(IIdentifyMethod $identifyMethod): ?string {
217        $settings = $identifyMethod->getSettings();
218        if (($settings['enabled'] ?? false) !== true) {
219            return null;
220        }
221        $enabled = array_keys(array_filter(
222            $identifyMethod->getSignatureMethods(),
223            static fn ($signatureMethod): bool => $signatureMethod->isEnabled(),
224        ));
225        foreach (self::CONFIRM_METHODS as $method) {
226            if (in_array($method, $enabled, true)) {
227                return $method;
228            }
229        }
230        return null;
231    }
232
233    private function getKey(IIdentifyMethod $identifyMethod): string {
234        $email = strtolower(trim($identifyMethod->getEntity()->getIdentifierValue()));
235        return hash_hmac('sha256', $email, $this->config->getSystemValueString('secret'));
236    }
237
238    private function getStoredFolder(string $key): ?Folder {
239        try {
240            $folder = $this->folderService->getUnauthenticatedFolder()->get(self::STORAGE_FOLDER . '/' . $key);
241            return $folder instanceof Folder ? $folder : null;
242        } catch (NotFoundException) {
243            return null;
244        }
245    }
246
247    private function getStoredElement(Folder $folder, string $type): ?File {
248        try {
249            $element = $folder->get($type . '.png');
250            return $element instanceof File ? $element : null;
251        } catch (NotFoundException) {
252            return null;
253        }
254    }
255}