Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.12% covered (danger)
44.12%
45 / 102
69.57% covered (warning)
69.57%
16 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignEngineHandler
44.12% covered (danger)
44.12%
45 / 102
69.57% covered (warning)
69.57%
16 / 23
304.43
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
 setInputFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getInputFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCertificate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCertificate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readCertificate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setPassword
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setVisibleElements
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getVisibleElements
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSignedContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSignatureParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSignatureParams
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLeafExpiryOverrideInDays
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 generateCertificate
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 savePfx
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 deletePfx
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getPfxOfCurrentSigner
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 updatePassword
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getLastSignedDate
6.25% covered (danger)
6.25%
1 / 16
0.00% covered (danger)
0.00%
0 / 1
35.66
 getFileStream
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
4.05
 getCertificateEngineFactory
n/a
0 / 0
n/a
0 / 0
0
 getCertificateEngine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beforeSign
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\SignEngine;
10
11use InvalidArgumentException;
12use OCA\Libresign\DataObjects\VisibleElementAssoc;
13use OCA\Libresign\Exception\EmptyCertificateException;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Exception\PasswordToSignNotDefinedException;
16use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
17use OCA\Libresign\Handler\CertificateEngine\IEngineHandler;
18use OCA\Libresign\Service\FolderService;
19use OCP\Files\File;
20use OCP\Files\InvalidPathException;
21use OCP\Files\NotFoundException;
22use OCP\Files\NotPermittedException;
23use OCP\IL10N;
24use Psr\Log\LoggerInterface;
25
26abstract class SignEngineHandler implements IPkcs12SignEngineHandler {
27    private File $inputFile;
28    protected string $certificate = '';
29    private string $pfxFilename = 'signature.pfx';
30    private string $password = '';
31    /** @var VisibleElementAssoc[] */
32    private array $visibleElements = [];
33    private array $signatureParams = [];
34
35    public function __construct(
36        private IL10N $l10n,
37        private readonly FolderService $folderService,
38        private LoggerInterface $logger,
39    ) {
40    }
41
42    /**
43     * @return static
44     */
45    #[\Override]
46    public function setInputFile(File $inputFile): self {
47        $this->inputFile = $inputFile;
48        return $this;
49    }
50
51    #[\Override]
52    public function getInputFile(): File {
53        return $this->inputFile;
54    }
55
56    #[\Override]
57    public function setCertificate(string $certificate): self {
58        $this->certificate = $certificate;
59        return $this;
60    }
61
62    #[\Override]
63    public function getCertificate(): string {
64        return $this->certificate;
65    }
66
67    #[\Override]
68    public function readCertificate(): array {
69        return $this->getCertificateEngine()
70            ->readCertificate(
71                $this->getCertificate(),
72                $this->getPassword()
73            );
74    }
75
76    #[\Override]
77    public function setPassword(string $password): self {
78        $this->password = $password;
79        return $this;
80    }
81
82    #[\Override]
83    public function getPassword(): string {
84        return $this->password;
85    }
86
87    /**
88     * @param VisibleElementAssoc[] $visibleElements
89     *
90     * @return static
91     */
92    public function setVisibleElements(array $visibleElements): self {
93        $this->visibleElements = $visibleElements;
94        return $this;
95    }
96
97    /**
98     * @return VisibleElementAssoc[]
99     *
100     * @psalm-return array<VisibleElementAssoc>
101     */
102    public function getVisibleElements(): array {
103        return $this->visibleElements;
104    }
105
106    #[\Override]
107    public function getSignedContent(): string {
108        return $this->sign()->getContent();
109    }
110
111    #[\Override]
112    public function getSignatureParams(): array {
113        return $this->signatureParams;
114    }
115
116    #[\Override]
117    public function setSignatureParams(array $params): self {
118        $this->signatureParams = $params;
119        return $this;
120    }
121
122    public function setLeafExpiryOverrideInDays(?int $days): self {
123        $this->getCertificateEngine()->setLeafExpiryOverrideInDays($days);
124        return $this;
125    }
126
127    /**
128     * Generate certificate
129     *
130     * @param array $user Example: ['host' => '', 'name' => '']
131     * @param string $signPassword Password of signature
132     * @param string $friendlyName Friendly name
133     */
134    public function generateCertificate(array $user, string $signPassword, string $friendlyName): string {
135        try {
136            $content = $this->getCertificateEngine()
137                ->setHosts([$user['host']])
138                ->setCommonName($user['name'])
139                ->setFriendlyName($friendlyName)
140                ->setUID($user['uid'])
141                ->setPassword($signPassword)
142                ->generateCertificate();
143        } catch (LibresignException $e) {
144            throw $e;
145        } catch (EmptyCertificateException) {
146            // TRANSLATORS Error while LibreSign creates a user's signing certificate (PFX): the instance root CA material is missing or empty.
147            throw new LibresignException($this->l10n->t('Empty root certificate data'));
148        } catch (InvalidArgumentException) {
149            // TRANSLATORS Error while LibreSign creates a user's signing certificate: the identity fields or password payload sent to the certificate engine are invalid.
150            throw new LibresignException($this->l10n->t('Invalid data to generate certificate'));
151        } catch (\Throwable) {
152            // TRANSLATORS Error while LibreSign creates a user's signing certificate: the certificate engine failed unexpectedly during generation.
153            throw new LibresignException($this->l10n->t('Failure on generate certificate'));
154        }
155        if (!$content) {
156            // TRANSLATORS Error while LibreSign creates a user's signing certificate: generation finished without returning a PFX/certificate payload.
157            throw new LibresignException($this->l10n->t('Failure to generate certificate'));
158        }
159        $this->setCertificate($content);
160        return $content;
161    }
162
163    public function savePfx(string $uid, string $content): string {
164        $previousUserId = $this->folderService->getUserId();
165        $this->folderService->setUserId($uid);
166
167        try {
168            $folder = $this->folderService->getFolder();
169            $folder->newFile($this->pfxFilename, $content);
170        } catch (NotPermittedException) {
171            // TRANSLATORS Permission error when LibreSign tries to store the user's signing certificate (PFX) in their Nextcloud home folder.
172            throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
173        } finally {
174            $this->folderService->setUserId($previousUserId);
175        }
176
177        return $content;
178    }
179
180    public function deletePfx(string $uid): void {
181        $previousUserId = $this->folderService->getUserId();
182        $this->folderService->setUserId($uid);
183        try {
184            $folder = $this->folderService->getFolder();
185            $file = $folder->get($this->pfxFilename);
186            $file->delete();
187        } catch (NotPermittedException) {
188            // TRANSLATORS Permission error when LibreSign tries to delete the user's signing certificate (PFX) from their Nextcloud home folder.
189            throw new LibresignException($this->l10n->t('You do not have permission for this action.'));
190        } catch (NotFoundException|InvalidPathException) {
191        } finally {
192            $this->folderService->setUserId($previousUserId);
193        }
194    }
195
196    /**
197     * Get content of pfx file
198     */
199    public function getPfxOfCurrentSigner(string $uid): string {
200        $previousUserId = $this->folderService->getUserId();
201        $this->folderService->setUserId($uid);
202        $certificate = '';
203
204        try {
205            $folder = $this->folderService->getFolder();
206            /** @var \OCP\Files\File */
207            $node = $folder->get($this->pfxFilename);
208            $certificate = $node->getContent();
209        } catch (NotFoundException) {
210            // TRANSLATORS Error shown to the signer when LibreSign has no stored signing certificate/password yet; they must create a signing password before signing.
211            throw new PasswordToSignNotDefinedException($this->l10n->t('Password to sign not defined. Create a password to sign.'), 400);
212        } finally {
213            $this->folderService->setUserId($previousUserId);
214        }
215
216        if (empty($certificate)) {
217            // TRANSLATORS Error shown to the signer when LibreSign has no stored signing certificate/password yet; they must create a signing password before signing.
218            throw new PasswordToSignNotDefinedException($this->l10n->t('Password to sign not defined. Create a password to sign.'), 400);
219        }
220        return $certificate;
221    }
222
223    public function updatePassword(string $uid, string $currentPrivateKey, string $newPrivateKey): string {
224        $pfx = $this->getPfxOfCurrentSigner($uid);
225        $content = $this->getCertificateEngine()->updatePassword(
226            $pfx,
227            $currentPrivateKey,
228            $newPrivateKey
229        );
230        return $this->savePfx($uid, $content);
231    }
232
233    #[\Override]
234    public function getLastSignedDate(): \DateTime {
235        $stream = $this->getFileStream();
236
237        $chain = $this->getCertificateChain($stream);
238        if (empty($chain)) {
239            throw new \UnexpectedValueException('Certificate chain is empty.');
240        }
241
242        $last = $chain[array_key_last($chain)];
243        if (!is_array($last) || !isset($last['signingTime']) || !$last['signingTime'] instanceof \DateTime) {
244            $this->logger->error('Invalid signingTime in certificate chain.', ['chain' => $chain]);
245            throw new \UnexpectedValueException('Invalid signingTime in certificate chain.');
246        }
247
248        // Prevent accepting certificates with future signing dates (possible clock issues)
249        $dateTime = new \DateTime('now', new \DateTimeZone('UTC'));
250        if ($last['signingTime'] > $dateTime) {
251            $this->logger->error('We found Marty McFly', [
252                'last_signature' => json_encode($last['signingTime']),
253                'current_date_time' => json_encode($dateTime),
254            ]);
255            throw new \UnexpectedValueException('Invalid signingTime in certificate chain. We found Marty McFly');
256        }
257
258        return $last['signingTime'];
259    }
260
261    /**
262     * @return resource
263     */
264    protected function getFileStream() {
265        $signedFile = $this->getInputFile();
266        $stream = $signedFile->fopen('rb');
267        if ($stream === false) {
268            throw new \RuntimeException('Unable to open the signed file for reading.');
269        }
270        return $stream;
271    }
272
273    abstract protected function getCertificateEngineFactory(): CertificateEngineFactory;
274
275    protected function getCertificateEngine(): IEngineHandler {
276        return $this->getCertificateEngineFactory()->getEngine();
277    }
278
279    protected function beforeSign(): void {
280        $this->getCertificateEngine()->validateRootCertificate();
281    }
282}