Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.24% covered (warning)
74.24%
147 / 198
41.67% covered (danger)
41.67%
10 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignSetupService
74.24% covered (warning)
74.24%
147 / 198
41.67% covered (danger)
41.67%
10 / 24
121.52
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
 setArchitecture
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setResource
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getArchitectures
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 setPrivateKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCertificate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrivateKey
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 getCertificate
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 writeAppSignature
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
8.03
 getInstallPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSignatureFileName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setDistro
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLinuxDistributionToDownloadJava
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAppInfoDirectory
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSignatureData
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
5.58
 getHashesOfResource
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 verify
63.89% covered (warning)
63.89%
23 / 36
0.00% covered (danger)
0.00%
0 / 1
11.01
 getFolderIterator
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 generateHashes
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 isExcluded
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 createSignatureData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getDevelopCert
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
2
 arrayToConfigFile
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Install;
10
11use OC\IntegrityCheck\Helpers\FileAccessHelper;
12use OCA\Libresign\AppInfo\Application;
13use OCA\Libresign\Exception\EmptySignatureDataException;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Exception\SignatureDataNotFoundException;
16use OCA\Libresign\Handler\CertificateEngine\CertificateHelper;
17use OCA\Libresign\Vendor\phpseclib4\Crypt\PublicKeyLoader;
18use OCA\Libresign\Vendor\phpseclib4\Crypt\RSA;
19use OCA\Libresign\Vendor\phpseclib4\Crypt\RSA\PrivateKey;
20use OCA\Libresign\Vendor\phpseclib4\File\X509;
21use OCP\App\IAppManager;
22use OCP\Files\NotFoundException;
23use OCP\ITempManager;
24
25class SignSetupService {
26    private array $exclude = [
27        'openssl_config',
28        'cfssl_config',
29        'unauthetnicated',
30    ];
31    private InstallTarget $target;
32    private string $resource;
33    private array $signatureData = [];
34    private ?X509 $signingCertificate = null;
35    private ?PrivateKey $privateKey = null;
36    public function __construct(
37        private FileAccessHelper $fileAccessHelper,
38        private SetupSignatureVerifier $setupSignatureVerifier,
39        private SetupInstallPathResolver $installPathResolver,
40        private IAppManager $appManager,
41        protected ITempManager $tempManager,
42    ) {
43        $this->target = InstallTarget::current();
44    }
45
46    public function setArchitecture(string $architecture): self {
47        $this->target = $this->target->withArchitecture($architecture);
48        return $this;
49    }
50
51    public function setResource(string $resource): self {
52        $this->resource = $resource;
53        return $this;
54    }
55
56    /**
57     * @return array<int|string, string>
58     */
59    public function getArchitectures(): array {
60        $appInfo = $this->appManager->getAppInfo(Application::APP_ID);
61        if (!is_array($appInfo) || !isset($appInfo['dependencies'])) {
62            throw new \Exception('dependencies>architecture not found at info.xml');
63        }
64        $architectures = $appInfo['dependencies']['architecture'] ?? null;
65        if ($architectures === null || $architectures === '' || $architectures === []) {
66            throw new \Exception('dependencies>architecture not found at info.xml');
67        }
68
69        if (is_array($architectures)) {
70            /** @var list<string> $normalized */
71            $normalized = array_values(array_map(static fn (mixed $value): string => (string)$value, $architectures));
72            return $normalized;
73        }
74
75        return [(string)$architectures];
76    }
77
78    public function setPrivateKey(PrivateKey $privateKey): void {
79        $this->privateKey = $privateKey;
80    }
81
82    public function setCertificate(X509 $x509): void {
83        $this->signingCertificate = $x509;
84    }
85
86    private function getPrivateKey(): PrivateKey {
87        if (!$this->privateKey instanceof PrivateKey) {
88            if (file_exists(__DIR__ . '/../../../build/tools/certificates/local/libresign.key')) {
89                $privateKey = file_get_contents(__DIR__ . '/../../../build/tools/certificates/local/libresign.key');
90                $this->privateKey = PublicKeyLoader::loadPrivateKey($privateKey);
91            } else {
92                $this->getDevelopCert();
93            }
94        }
95        if (!$this->privateKey instanceof PrivateKey) {
96            throw new LibresignException('Private key not found');
97        }
98        return $this->privateKey;
99    }
100
101    private function getCertificate(): X509 {
102        if (!$this->signingCertificate instanceof x509) {
103            if (file_exists(__DIR__ . '/../../../build/tools/certificates/local/libresign.crt')) {
104                $x509 = file_get_contents(__DIR__ . '/../../../build/tools/certificates/local/libresign.crt');
105                $this->signingCertificate = X509::load($x509);
106            } else {
107                $this->getDevelopCert();
108            }
109        }
110        if (!$this->signingCertificate instanceof x509) {
111            throw new LibresignException('Certificate not found');
112        }
113        return $this->signingCertificate;
114    }
115
116    /**
117     * Write the signature of the app in the specified folder
118     *
119     * @param string $path
120     * @param X509 $certificate
121     * @param RSA $privateKey
122     * @throws \Exception
123     */
124    public function writeAppSignature() {
125        try {
126            $iterator = $this->getFolderIterator($this->getInstallPath());
127            $hashes = $this->generateHashes($iterator);
128            $signature = $this->createSignatureData($hashes);
129            $this->fileAccessHelper->file_put_contents(
130                $this->getFileName(),
131                json_encode($signature, JSON_PRETTY_PRINT)
132            );
133        } catch (NotFoundException $e) {
134            throw new \Exception(sprintf(
135                "Folder %s not found.\nIs necessary to run this command first: occ libresign:install --%s --architecture=%s",
136                $e->getMessage(),
137                $this->resource,
138                $this->target->architecture(),
139            ));
140        } catch (\Exception $e) {
141            $appInfoDir = $this->getAppInfoDirectory();
142            if (!$this->fileAccessHelper->is_writable($appInfoDir)) {
143                throw new \Exception($appInfoDir . ' is not writable. Original error: ' . $e->getMessage());
144            }
145            throw $e;
146        }
147    }
148
149    public function getInstallPath(): string {
150        return $this->installPathResolver->resolve($this->target, $this->resource);
151    }
152
153    private function getFileName(): string {
154        $appInfoDir = $this->getAppInfoDirectory();
155        return $appInfoDir . '/' . $this->getSignatureFileName();
156    }
157
158    public function getSignatureFileName(): string {
159        $path[] = 'install-' . $this->target->architecture();
160        if ($this->resource === 'java') {
161            $path[] = $this->getLinuxDistributionToDownloadJava();
162        }
163        $path[] = $this->resource . '.json';
164        return implode('-', $path);
165    }
166
167    public function setDistro(string $distro): self {
168        $this->target = $this->target->withDistro($distro);
169        return $this;
170    }
171
172    public function getLinuxDistributionToDownloadJava(): string {
173        return $this->target->distro();
174    }
175
176    protected function getAppInfoDirectory(): string {
177        $appInfoDir = (string)realpath(__DIR__ . '/../../../appinfo');
178        $this->fileAccessHelper->assertDirectoryExists($appInfoDir);
179        return $appInfoDir;
180    }
181
182    private function getSignatureData(SetupTrustMode $trustMode): array {
183        if (!empty($this->signatureData)) {
184            return $this->signatureData;
185        }
186        $filename = $this->getFileName();
187        if (!file_exists($filename)) {
188            throw new SignatureDataNotFoundException('Signature data not found.');
189        }
190        $content = $this->fileAccessHelper->file_get_contents($filename);
191        if (\is_string($content)) {
192            $signatureData = json_decode($content, true);
193        } else {
194            $signatureData = null;
195        }
196        if (!\is_array($signatureData)) {
197            throw new SignatureDataNotFoundException('Signature data not found.');
198        }
199        $this->signatureData = $signatureData;
200        $this->setupSignatureVerifier->verify($signatureData, $trustMode);
201
202        return $this->signatureData;
203    }
204
205    private function getHashesOfResource(SetupTrustMode $trustMode): array {
206        $signatureData = $this->getSignatureData($trustMode);
207        if (count($signatureData['hashes']) === 0) {
208            throw new EmptySignatureDataException('No signature files to ' . $this->resource);
209        }
210        return $signatureData;
211    }
212
213    public function verify(
214        string $architecture,
215        string $resource,
216        SetupTrustMode $trustMode = SetupTrustMode::Production,
217    ): array {
218        $this->signatureData = [];
219        $this->target = $this->target->withArchitecture($architecture);
220        $this->resource = $resource;
221
222        try {
223            $expectedHashes = $this->getHashesOfResource($trustMode);
224            // Compare the list of files which are not identical
225            $currentInstanceHashes = $this->generateHashes($this->getFolderIterator($this->getInstallPath()));
226        } catch (EmptySignatureDataException $th) {
227            return [
228                'EMPTY_SIGNATURE_DATA' => $th->getMessage(),
229            ];
230        } catch (SignatureDataNotFoundException $th) {
231            return [
232                'SIGNATURE_DATA_NOT_FOUND' => $th->getMessage(),
233            ];
234        } catch (\Exception $e) {
235            return [
236                'HASH_FILE_ERROR' => $e->getMessage(),
237            ];
238        }
239
240        $differencesA = array_diff_assoc($expectedHashes['hashes'], $currentInstanceHashes);
241        $differencesB = array_diff_assoc($currentInstanceHashes, $expectedHashes['hashes']);
242        $differences = array_merge($differencesA, $differencesB);
243        $differenceArray = [];
244        foreach ($differences as $filename => $hash) {
245            // Check if file should not exist in the new signature table
246            if (!array_key_exists($filename, $expectedHashes['hashes'])) {
247                $differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
248                $differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
249                continue;
250            }
251
252            // Check if file is missing
253            if (!array_key_exists($filename, $currentInstanceHashes)) {
254                $differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes['hashes'][$filename];
255                $differenceArray['FILE_MISSING'][$filename]['current'] = '';
256                continue;
257            }
258
259            // Check if hash does mismatch
260            if ($expectedHashes['hashes'][$filename] !== $currentInstanceHashes[$filename]) {
261                $differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes['hashes'][$filename];
262                $differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
263                continue;
264            }
265
266            // Should never happen.
267            throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
268        }
269
270        return $differenceArray;
271    }
272
273    /**
274     * Enumerates all files belonging to the folder. Sensible defaults are excluded.
275     *
276     * @param string $folderToIterate
277     * @param string $root
278     * @return \RecursiveIteratorIterator
279     * @throws \Exception
280     */
281    private function getFolderIterator(string $folderToIterate): \RecursiveIteratorIterator {
282        if (!is_dir($folderToIterate)) {
283            throw new NotFoundException($folderToIterate);
284        }
285        $dirItr = new \RecursiveDirectoryIterator(
286            $folderToIterate,
287            \RecursiveDirectoryIterator::SKIP_DOTS
288        );
289
290        return new \RecursiveIteratorIterator(
291            $dirItr,
292            \RecursiveIteratorIterator::SELF_FIRST
293        );
294    }
295
296    /**
297     * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
298     * in the iterator.
299     *
300     * @param \RecursiveIteratorIterator $iterator
301     * @param string $path
302     * @return array Array of hashes.
303     */
304    private function generateHashes(\RecursiveIteratorIterator $iterator): array {
305        $hashes = [];
306
307        $baseDirectoryLength = \strlen($this->getInstallPath());
308        foreach ($iterator as $filename => $data) {
309            /** @var \DirectoryIterator $data */
310            if ($data->isDir()) {
311                continue;
312            }
313
314            $relativeFileName = substr((string)$filename, $baseDirectoryLength);
315            $relativeFileName = ltrim($relativeFileName, '/');
316
317            if ($this->isExcluded($relativeFileName)) {
318                continue;
319            }
320
321            $hashes[$relativeFileName] = hash_file('sha512', $filename);
322        }
323
324        return $hashes;
325    }
326
327    private function isExcluded(string $filename): bool {
328        foreach ($this->exclude as $prefix) {
329            if (str_starts_with($filename, (string)$prefix)) {
330                return true;
331            }
332        }
333        return false;
334    }
335
336    /**
337     * Creates the signature data
338     *
339     * @param array $hashes
340     * @param X509 $certificate
341     * @param RSA $privateKey
342     * @return array
343     */
344    private function createSignatureData(array $hashes): array {
345        ksort($hashes);
346
347        $privateKey = $this->getPrivateKey()
348            ->withPadding(RSA::SIGNATURE_PSS);
349        $signature = $privateKey->sign(json_encode($hashes));
350
351        return [
352            'hashes' => $hashes,
353            'signature' => base64_encode((string)$signature),
354            'certificate' => $this->getCertificate()->toString(),
355        ];
356    }
357
358    public function getDevelopCert(): array {
359        $privateKey = openssl_pkey_new([
360            'private_key_bits' => 2048,
361            'private_key_type' => OPENSSL_KEYTYPE_RSA,
362        ]);
363
364        $csrNames = ['commonName' => 'libresign'];
365
366        $csr = openssl_csr_new($csrNames, $privateKey, ['digest_alg' => 'sha256']);
367        $x509 = openssl_csr_sign($csr, null, $privateKey, $days = 365, [
368            'digest_alg' => 'sha256',
369            'config' => $this->arrayToConfigFile([
370                'v3_user' => [
371                    'keyUsage' => 'digitalSignature',
372                    'extendedKeyUsage' => 'clientAuth',
373                    'authorityInfoAccess' => '@aia_section',
374                ],
375                'aia_section' => [
376                    'caIssuers;URI.0' => 'https://apps.nextcloud.com/apps/libresign',
377                ],
378            ]),
379        ]);
380
381        openssl_x509_export($x509, $rootCertificate);
382        openssl_pkey_export($privateKey, $privateKeyCert);
383
384        $this->privateKey = RSA::loadPrivateKey($privateKeyCert);
385        $this->signingCertificate = X509::load($rootCertificate);
386
387        $rootCertPath = __DIR__ . '/../../../build/tools/certificates/local/';
388        if (!is_dir($rootCertPath)) {
389            mkdir($rootCertPath, 0777, true);
390        }
391        file_put_contents($rootCertPath . '/root.crt', $rootCertificate);
392        file_put_contents($rootCertPath . '/libresign.crt', $rootCertificate);
393        file_put_contents($rootCertPath . '/libresign.key', $privateKeyCert);
394
395        $privateKeyInstance = openssl_pkey_new([
396            'private_key_bits' => 2048,
397            'private_key_type' => OPENSSL_KEYTYPE_RSA,
398        ]);
399        return [
400            'rootCertificate' => $rootCertificate,
401            'privateKeyInstance' => $privateKeyInstance,
402            'privateKeyCert' => $privateKeyCert,
403        ];
404    }
405
406    private function arrayToConfigFile(array $config): string {
407        $temporaryFile = $this->tempManager->getTemporaryFile('.cfg');
408        if (!$temporaryFile) {
409            throw new LibresignException('Failure to create temporary file to OpenSSL .cfg file');
410        }
411        $ini = CertificateHelper::arrayToIni($config);
412        file_put_contents($temporaryFile, $ini);
413        return $temporaryFile;
414    }
415}