Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.25% covered (warning)
79.25%
42 / 53
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SetupSignatureVerifier
79.25% covered (warning)
79.25%
42 / 53
42.86% covered (danger)
42.86%
3 / 7
23.58
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
 verify
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 validateCertificate
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 validateScope
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 validateFileSignature
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 getRootCertificate
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
6.00
 splitCerts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
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\Install;
10
11use OC\IntegrityCheck\Helpers\EnvironmentHelper;
12use OC\IntegrityCheck\Helpers\FileAccessHelper;
13use OCA\Libresign\AppInfo\Application;
14use OCA\Libresign\Exception\InvalidSignatureException;
15use OCA\Libresign\Exception\LibresignException;
16use OCA\Libresign\Vendor\phpseclib4\Crypt\RSA;
17use OCA\Libresign\Vendor\phpseclib4\Crypt\RSA\PublicKey as RSAPublicKey;
18use OCA\Libresign\Vendor\phpseclib4\File\X509;
19
20final class SetupSignatureVerifier {
21    public function __construct(
22        private EnvironmentHelper $environmentHelper,
23        private FileAccessHelper $fileAccessHelper,
24    ) {
25    }
26
27    /**
28     * @param array{hashes: array<string, string>, signature: string, certificate: string} $signatureData
29     */
30    public function verify(array $signatureData, SetupTrustMode $trustMode): X509 {
31        $certificate = $signatureData['certificate'] ?? '';
32        if ($certificate === '') {
33            throw new InvalidSignatureException('Certificate not found in signature data.');
34        }
35
36        $x509 = X509::load($certificate);
37        $this->validateCertificate($x509, $trustMode);
38        $this->validateScope($x509);
39        $this->validateFileSignature($x509, $signatureData['hashes'], $signatureData['signature']);
40
41        return $x509;
42    }
43
44    private function validateCertificate(X509 $x509, SetupTrustMode $trustMode): void {
45        $previousCAs = X509::getCAs();
46        X509::clearCAStore();
47        try {
48            foreach ($this->splitCerts($this->getRootCertificate($trustMode)) as $rootCert) {
49                X509::addCA($rootCert);
50            }
51            if (!$x509->validateSignature()) {
52                throw new InvalidSignatureException('Certificate is not valid.');
53            }
54        } finally {
55            X509::clearCAStore();
56            foreach ($previousCAs as $previousCA) {
57                X509::addCA($previousCA);
58            }
59        }
60    }
61
62    private function validateScope(X509 $x509): void {
63        $subject = $x509->getSubjectDN(X509::DN_OPENSSL);
64        $commonName = is_array($subject) ? ($subject['CN'] ?? '') : '';
65        if ($commonName !== Application::APP_ID && $commonName !== 'core') {
66            throw new InvalidSignatureException(
67                sprintf(
68                    'Certificate is not valid for required scope. (Requested: %s, current: CN=%s)',
69                    Application::APP_ID,
70                    $commonName,
71                ),
72            );
73        }
74    }
75
76    /**
77     * @param array<string, string> $hashes
78     */
79    private function validateFileSignature(X509 $x509, array $hashes, string $signature): void {
80        $publicKey = $x509->getPublicKey();
81        if (!$publicKey instanceof RSAPublicKey) {
82            throw new InvalidSignatureException('Certificate public key is not RSA.');
83        }
84
85        $decodedSignature = base64_decode($signature, true);
86        if ($decodedSignature === false) {
87            throw new InvalidSignatureException('Signature could not get decoded.');
88        }
89
90        $rsa = $publicKey->withPadding(RSA::SIGNATURE_PSS);
91        if (!$rsa->verify(json_encode($hashes), $decodedSignature)) {
92            throw new InvalidSignatureException('Signature could not get verified.');
93        }
94    }
95
96    private function getRootCertificate(SetupTrustMode $trustMode): string {
97        if ($trustMode === SetupTrustMode::Development) {
98            $localCert = __DIR__ . '/../../../build/tools/certificates/local/root.crt';
99            if (!file_exists($localCert)) {
100                throw new LibresignException('Local development root certificate not found at ' . $localCert);
101            }
102            return (string)file_get_contents($localCert);
103        }
104
105        $rootCertificatePath = $this->environmentHelper->getServerRoot() . '/resources/codesigning/root.crt';
106        $rootCertificate = $this->fileAccessHelper->file_get_contents($rootCertificatePath);
107        if (!is_string($rootCertificate)) {
108            throw new LibresignException('Root certificate not found at ' . $rootCertificatePath);
109        }
110        return $rootCertificate;
111    }
112
113    /**
114     * @return list<string>
115     */
116    private function splitCerts(string $cert): array {
117        preg_match_all(
118            '([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})',
119            $cert,
120            $matches,
121        );
122        return $matches[0];
123    }
124}