• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 19072335922

04 Nov 2025 02:39PM UTC coverage: 39.625%. First build
19072335922

Pull #5731

github

web-flow
Merge 904f89e48 into e77ceb5b6
Pull Request #5731: feat: add multiple ou

34 of 97 new or added lines in 9 files covered. (35.05%)

4570 of 11533 relevant lines covered (39.63%)

2.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.67
/lib/Handler/CertificateEngine/CfsslHandler.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Handler\CertificateEngine;
10

11
use GuzzleHttp\Client;
12
use GuzzleHttp\Exception\ConnectException;
13
use GuzzleHttp\Exception\RequestException;
14
use OC\SystemConfig;
15
use OCA\Libresign\AppInfo\Application;
16
use OCA\Libresign\Exception\LibresignException;
17
use OCA\Libresign\Handler\CfsslServerHandler;
18
use OCA\Libresign\Helper\ConfigureCheckHelper;
19
use OCA\Libresign\Service\CertificatePolicyService;
20
use OCA\Libresign\Service\Install\InstallService;
21
use OCP\Files\AppData\IAppDataFactory;
22
use OCP\IAppConfig;
23
use OCP\IConfig;
24
use OCP\IDateTimeFormatter;
25
use OCP\ITempManager;
26
use OCP\IURLGenerator;
27

28
/**
29
 * Class CfsslHandler
30
 *
31
 * @package OCA\Libresign\Handler
32
 *
33
 * @method CfsslHandler setClient(Client $client)
34
 */
35
class CfsslHandler extends AEngineHandler implements IEngineHandler {
36
        public const CFSSL_URI = 'http://127.0.0.1:8888/api/v1/cfssl/';
37

38
        /** @var Client */
39
        protected $client;
40
        protected $cfsslUri;
41
        private string $binary = '';
42

43
        public function __construct(
44
                protected IConfig $config,
45
                protected IAppConfig $appConfig,
46
                private SystemConfig $systemConfig,
47
                protected IAppDataFactory $appDataFactory,
48
                protected IDateTimeFormatter $dateTimeFormatter,
49
                protected ITempManager $tempManager,
50
                protected CfsslServerHandler $cfsslServerHandler,
51
                protected CertificatePolicyService $certificatePolicyService,
52
                protected IURLGenerator $urlGenerator,
53
        ) {
54
                parent::__construct($config, $appConfig, $appDataFactory, $dateTimeFormatter, $tempManager, $certificatePolicyService, $urlGenerator);
55✔
55

56
                $this->cfsslServerHandler->configCallback(fn () => $this->getConfigPath());
55✔
57
        }
58

59
        #[\Override]
60
        public function generateRootCert(
61
                string $commonName,
62
                array $names = [],
63
        ): void {
64
                $this->cfsslServerHandler->createConfigServer(
×
65
                        $commonName,
×
66
                        $names,
×
67
                        $this->getCaExpiryInDays(),
×
68
                        $this->getCrlDistributionUrl(),
×
69
                );
×
70

71
                $this->gencert();
×
72

73
                $this->stopIfRunning();
×
74

75
                for ($i = 1; $i <= 4; $i++) {
×
76
                        if ($this->isUp()) {
×
77
                                break;
×
78
                        }
79
                        sleep(2);
×
80
                }
81
        }
82

83
        #[\Override]
84
        public function generateCertificate(): string {
85
                $certKeys = $this->newCert();
×
86
                return parent::exportToPkcs12(
×
87
                        $certKeys['certificate'],
×
88
                        $certKeys['private_key'],
×
89
                        [
×
90
                                'friendly_name' => $this->getFriendlyName(),
×
91
                                'extracerts' => [
×
92
                                        $certKeys['certificate'],
×
93
                                        $certKeys['certificate_request'],
×
94
                                ],
×
95
                        ],
×
96
                );
×
97
        }
98

99
        #[\Override]
100
        public function isSetupOk(): bool {
101
                $configPath = $this->getConfigPath();
×
102
                $certificate = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
×
103
                $privateKey = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
×
104
                if (!$certificate || !$privateKey) {
×
105
                        return false;
×
106
                }
107
                try {
108
                        $this->getClient();
×
109
                        return true;
×
110
                } catch (\Throwable) {
×
111
                }
112
                return false;
×
113
        }
114

115
        #[\Override]
116
        protected function getConfigureCheckResourceName(): string {
117
                return 'cfssl-configure';
×
118
        }
119

120
        #[\Override]
121
        protected function getCertificateRegenerationTip(): string {
122
                return 'Consider regenerating the root certificate with: occ libresign:configure:cfssl --cn="Your CA Name"';
×
123
        }
124

125
        #[\Override]
126
        protected function getEngineSpecificChecks(): array {
127
                return $this->checkBinaries();
×
128
        }
129

130
        #[\Override]
131
        protected function getSetupSuccessMessage(): string {
132
                return 'Root certificate config files found.';
×
133
        }
134

135
        #[\Override]
136
        protected function getSetupErrorMessage(): string {
137
                return 'CFSSL (root certificate) not configured.';
×
138
        }
139

140
        #[\Override]
141
        protected function getSetupErrorTip(): string {
142
                return 'Run occ libresign:configure:cfssl --help';
×
143
        }
144

145
        #[\Override]
146
        public function toArray(): array {
147
                $return = parent::toArray();
×
148
                if (!empty($return['configPath'])) {
×
149
                        $return['cfsslUri'] = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_uri');
×
150
                }
151
                return $return;
×
152
        }
153

154
        public function getCommonName(): string {
155
                $uid = $this->getUID();
×
156
                if (!$uid) {
×
157
                        return $this->commonName;
×
158
                }
159
                return $uid . ', ' . $this->commonName;
×
160
        }
161

162
        private function newCert(): array {
163
                $json = [
×
164
                        'json' => [
×
165
                                'profile' => 'client',
×
166
                                'request' => [
×
167
                                        'hosts' => $this->getHosts(),
×
168
                                        'CN' => $this->getCommonName(),
×
169
                                        'key' => [
×
170
                                                'algo' => 'rsa',
×
171
                                                'size' => 2048,
×
172
                                        ],
×
173
                                        'names' => [],
×
174
                                        'crl_url' => $this->getCrlDistributionUrl(),
×
175
                                ],
×
176
                        ],
×
177
                ];
×
178

179
                $names = $this->getNames();
×
NEW
180
                foreach ($names as $key => $value) {
×
NEW
181
                        if (!empty($value) && is_array($value)) {
×
NEW
182
                                $names[$key] = implode(', ', $value);
×
183
                        }
184
                }
185
                if (!empty($names)) {
×
186
                        $json['json']['request']['names'][] = $names;
×
187
                }
188

189
                try {
190
                        $response = $this->getClient()
×
191
                                ->request('post',
×
192
                                        'newcert',
×
193
                                        $json
×
194
                                )
×
195
                        ;
×
196
                } catch (RequestException|ConnectException $th) {
×
197
                        if ($th->getHandlerContext() && $th->getHandlerContext()['error']) {
×
198
                                throw new \Exception($th->getHandlerContext()['error'], 1);
×
199
                        }
200
                        throw new LibresignException($th->getMessage(), 500);
×
201
                }
202

203
                $responseDecoded = json_decode((string)$response->getBody(), true);
×
204
                if (!isset($responseDecoded['success']) || !$responseDecoded['success']) {
×
205
                        throw new LibresignException('Error while generating certificate keys!', 500);
×
206
                }
207

208
                return $responseDecoded['result'];
×
209
        }
210

211
        private function gencert(): void {
212
                $binary = $this->getBinary();
×
213
                $configPath = $this->getConfigPath();
×
214
                $csrFile = $configPath . '/csr_server.json';
×
215

216
                $cmd = escapeshellcmd($binary) . ' gencert -initca ' . escapeshellarg($csrFile);
×
217
                $output = shell_exec($cmd);
×
218

219
                if (!$output) {
×
220
                        throw new \RuntimeException('cfssl without output.');
×
221
                }
222

223
                $json = json_decode($output, true);
×
224
                if (!$json || !isset($json['cert'], $json['key'], $json['csr'])) {
×
225
                        throw new \RuntimeException('Error generating CA: invalid cfssl output.');
×
226
                }
227

228
                file_put_contents($configPath . '/ca.pem', $json['cert']);
×
229
                file_put_contents($configPath . '/ca-key.pem', $json['key']);
×
230
                file_put_contents($configPath . '/ca.csr', $json['csr']);
×
231
        }
232

233
        private function getClient(): Client {
234
                if (!$this->client) {
×
235
                        $this->setClient(new Client(['base_uri' => $this->getCfsslUri()]));
×
236
                }
237
                $this->wakeUp();
×
238
                return $this->client;
×
239
        }
240

241
        private function isUp(): bool {
242
                try {
243
                        $client = $this->getClient();
×
244
                        if (!$this->portOpen()) {
×
245
                                throw new LibresignException('CFSSL server is down', 500);
×
246
                        }
247
                        $response = $client
×
248
                                ->request('get',
×
249
                                        'health',
×
250
                                        [
×
251
                                                'base_uri' => $this->getCfsslUri()
×
252
                                        ]
×
253
                                )
×
254
                        ;
×
255
                } catch (RequestException|ConnectException $th) {
×
256
                        switch ($th->getCode()) {
×
257
                                case 404:
×
258
                                        throw new \Exception('Endpoint /health of CFSSL server not found. Maybe you are using incompatible version of CFSSL server. Use latests version.', 1);
×
259
                                default:
260
                                        if ($th->getHandlerContext() && $th->getHandlerContext()['error']) {
×
261
                                                throw new \Exception($th->getHandlerContext()['error'], 1);
×
262
                                        }
263
                                        throw new LibresignException($th->getMessage(), 500);
×
264
                        }
265
                }
266

267
                $responseDecoded = json_decode((string)$response->getBody(), true);
×
268
                if (!isset($responseDecoded['success']) || !$responseDecoded['success']) {
×
269
                        throw new LibresignException('Error while check cfssl API health!', 500);
×
270
                }
271

272
                if (empty($responseDecoded['result']) || empty($responseDecoded['result']['healthy'])) {
×
273
                        return false;
×
274
                }
275

276
                return (bool)$responseDecoded['result']['healthy'];
×
277
        }
278

279
        private function wakeUp(): void {
280
                if ($this->portOpen()) {
×
281
                        return;
×
282
                }
283
                $binary = $this->getBinary();
×
284
                $configPath = $this->getConfigPath();
×
285
                if (!$configPath) {
×
286
                        throw new LibresignException('CFSSL not configured.');
×
287
                }
288
                $this->cfsslServerHandler->updateExpirity($this->getCaExpiryInDays());
×
289
                $cmd = 'nohup ' . $binary . ' serve -address=127.0.0.1 '
×
290
                        . '-ca-key ' . $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem '
×
291
                        . '-ca ' . $configPath . DIRECTORY_SEPARATOR . 'ca.pem '
×
292
                        . '-config ' . $configPath . DIRECTORY_SEPARATOR . 'config_server.json > /dev/null 2>&1 & echo $!';
×
293
                shell_exec($cmd);
×
294
                $loops = 0;
×
295
                while (!$this->portOpen() && $loops <= 4) {
×
296
                        sleep(1);
×
297
                        $loops++;
×
298
                }
299
        }
300

301
        private function portOpen(): bool {
302
                $host = parse_url($this->getCfsslUri(), PHP_URL_HOST);
×
303
                $port = parse_url($this->getCfsslUri(), PHP_URL_PORT);
×
304

305
                set_error_handler(function (): void { });
×
306
                $socket = fsockopen($host, $port, $errno, $errstr, 0.1);
×
307
                restore_error_handler();
×
308
                if (!$socket || $errno || $errstr) {
×
309
                        return false;
×
310
                }
311
                fclose($socket);
×
312
                return true;
×
313
        }
314

315
        private function getServerPid(): int {
316
                $cmd = 'ps -eo pid,command|';
×
317
                $cmd .= 'grep "cfssl.*serve.*-address"|'
×
318
                        . 'grep -v grep|'
×
319
                        . 'grep -v defunct|'
×
320
                        . 'sed -e "s/^[[:space:]]*//"|cut -d" " -f1';
×
321
                $output = shell_exec($cmd);
×
322
                if (!is_string($output)) {
×
323
                        return 0;
×
324
                }
325
                $pid = trim($output);
×
326
                return (int)$pid;
×
327
        }
328

329
        /**
330
         * Parse command
331
         *
332
         * Have commands that need to be executed as sudo otherwise don't will work,
333
         * by example the command runuser or kill. To prevent error when run in a
334
         * GitHub Actions, these commands are executed prefixed by sudo when exists
335
         * an environment called GITHUB_ACTIONS.
336
         */
337
        private function parseCommand(string $command): string {
338
                if (getenv('GITHUB_ACTIONS') !== false) {
×
339
                        $command = 'sudo ' . $command;
×
340
                }
341
                return $command;
×
342
        }
343

344
        private function stopIfRunning(): void {
345
                $pid = $this->getServerPid();
×
346
                if ($pid > 0) {
×
347
                        exec($this->parseCommand('kill -9 ' . $pid));
×
348
                }
349
        }
350

351
        private function getBinary(): string {
352
                if ($this->binary) {
×
353
                        return $this->binary;
×
354
                }
355

356
                if (PHP_OS_FAMILY === 'Windows') {
×
357
                        throw new LibresignException('Incompatible with Windows');
×
358
                }
359

360
                if ($this->appConfig->hasKey(Application::APP_ID, 'cfssl_bin')) {
×
361
                        $binary = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_bin');
×
362
                        if (!file_exists($binary)) {
×
363
                                $this->appConfig->deleteKey(Application::APP_ID, 'cfssl_bin');
×
364
                        }
365
                        return $binary;
×
366
                }
367
                throw new LibresignException('Binary of CFSSL not found. Install binaries.');
×
368
        }
369

370
        private function getCfsslUri(): string {
371
                if ($this->cfsslUri) {
×
372
                        return $this->cfsslUri;
×
373
                }
374

375
                if ($uri = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_uri')) {
×
376
                        return $uri;
×
377
                }
378
                $this->appConfig->deleteKey(Application::APP_ID, 'cfssl_uri');
×
379

380
                $this->cfsslUri = self::CFSSL_URI;
×
381
                return $this->cfsslUri;
×
382
        }
383

384
        public function setCfsslUri($uri): void {
385
                if ($uri) {
×
386
                        $this->appConfig->setValueString(Application::APP_ID, 'cfssl_uri', $uri);
×
387
                } else {
388
                        $this->appConfig->deleteKey(Application::APP_ID, 'cfssl_uri');
×
389
                }
390
                $this->cfsslUri = $uri;
×
391
        }
392

393
        private function checkBinaries(): array {
394
                if (PHP_OS_FAMILY === 'Windows') {
×
395
                        return [
×
396
                                (new ConfigureCheckHelper())
×
397
                                        ->setErrorMessage('CFSSL is incompatible with Windows')
×
398
                                        ->setResource('cfssl'),
×
399
                        ];
×
400
                }
401
                $binary = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_bin');
×
402
                if (!$binary) {
×
403
                        return [
×
404
                                (new ConfigureCheckHelper())
×
405
                                        ->setErrorMessage('CFSSL not installed.')
×
406
                                        ->setResource('cfssl')
×
407
                                        ->setTip('Run occ libresign:install --cfssl'),
×
408
                        ];
×
409
                }
410

411
                if (!file_exists($binary)) {
×
412
                        return [
×
413
                                (new ConfigureCheckHelper())
×
414
                                        ->setErrorMessage('CFSSL not found.')
×
415
                                        ->setResource('cfssl')
×
416
                                        ->setTip('Run occ libresign:install --cfssl'),
×
417
                        ];
×
418
                }
419
                $version = shell_exec("$binary version");
×
420
                if (!is_string($version) || empty($version)) {
×
421
                        return [
×
422
                                (new ConfigureCheckHelper())
×
423
                                        ->setErrorMessage(sprintf(
×
424
                                                'Failed to run the command "%s" with user %s',
×
425
                                                "$binary version",
×
426
                                                get_current_user()
×
427
                                        ))
×
428
                                        ->setResource('cfssl')
×
429
                                        ->setTip('Run occ libresign:install --cfssl')
×
430
                        ];
×
431
                }
432
                preg_match_all('/: (?<version>.*)/', $version, $matches);
×
433
                if (!$matches || !isset($matches['version']) || count($matches['version']) !== 2) {
×
434
                        return [
×
435
                                (new ConfigureCheckHelper())
×
436
                                        ->setErrorMessage(sprintf(
×
437
                                                'Failed to identify cfssl version with command %s',
×
438
                                                "$binary version"
×
439
                                        ))
×
440
                                        ->setResource('cfssl')
×
441
                                        ->setTip('Run occ libresign:install --cfssl')
×
442
                        ];
×
443
                }
444
                if (!str_contains($matches['version'][0], InstallService::CFSSL_VERSION)) {
×
445
                        return [
×
446
                                (new ConfigureCheckHelper())
×
447
                                        ->setErrorMessage(sprintf(
×
448
                                                'Invalid version. Expected: %s, actual: %s',
×
449
                                                InstallService::CFSSL_VERSION,
×
450
                                                $matches['version'][0]
×
451
                                        ))
×
452
                                        ->setResource('cfssl')
×
453
                                        ->setTip('Run occ libresign:install --cfssl')
×
454
                        ];
×
455
                }
456
                $return = [];
×
457
                $return[] = (new ConfigureCheckHelper())
×
458
                        ->setSuccessMessage('CFSSL binary path: ' . $binary)
×
459
                        ->setResource('cfssl');
×
460
                $return[] = (new ConfigureCheckHelper())
×
461
                        ->setSuccessMessage('CFSSL version: ' . $matches['version'][0])
×
462
                        ->setResource('cfssl');
×
463
                $return[] = (new ConfigureCheckHelper())
×
464
                        ->setSuccessMessage('Runtime: ' . $matches['version'][1])
×
465
                        ->setResource('cfssl');
×
466
                return $return;
×
467
        }
468

469
        #[\Override]
470
        public function generateCrlDer(array $revokedCertificates): string {
471
                try {
472
                        $queryParams = [];
×
473
                        $queryParams['expiry'] = '168h'; // 7 days * 24 hours
×
474

475
                        $response = $this->getClient()->request('GET', 'crl', [
×
476
                                'query' => $queryParams
×
477
                        ]);
×
478

479
                        $responseData = json_decode((string)$response->getBody(), true);
×
480

481
                        if (!isset($responseData['success']) || !$responseData['success']) {
×
482
                                $errorMessage = isset($responseData['errors'])
×
483
                                        ? implode(', ', array_column($responseData['errors'], 'message'))
×
484
                                        : 'Unknown CFSSL error';
×
485
                                throw new \RuntimeException('CFSSL CRL generation failed: ' . $errorMessage);
×
486
                        }
487

488
                        if (isset($responseData['result']) && is_string($responseData['result'])) {
×
489
                                return $responseData['result'];
×
490
                        }
491

492
                        throw new \RuntimeException('No CRL data returned from CFSSL');
×
493

494
                } catch (RequestException|ConnectException $e) {
×
495
                        throw new \RuntimeException('Failed to communicate with CFSSL server: ' . $e->getMessage());
×
496
                } catch (\Throwable $e) {
×
497
                        throw new \RuntimeException('CFSSL CRL generation error: ' . $e->getMessage());
×
498
                }
499
        }
500

501
        /**
502
         * Get Authority Key Identifier from certificate (needed for CFSSL revocation)
503
         *
504
         * @param string $certificatePem PEM encoded certificate
505
         * @return string Authority Key Identifier in lowercase without colons
506
         */
507
        public function getAuthorityKeyId(string $certificatePem): string {
508
                $cert = openssl_x509_read($certificatePem);
×
509
                if (!$cert) {
×
510
                        throw new \RuntimeException('Invalid certificate format');
×
511
                }
512

513
                $parsed = openssl_x509_parse($cert);
×
514
                if (!$parsed || !isset($parsed['extensions']['authorityKeyIdentifier'])) {
×
515
                        throw new \RuntimeException('Certificate does not contain Authority Key Identifier');
×
516
                }
517

518
                $authKeyId = $parsed['extensions']['authorityKeyIdentifier'];
×
519

520
                if (preg_match('/keyid:([A-Fa-f0-9:]+)/', $authKeyId, $matches)) {
×
521
                        return strtolower(str_replace(':', '', $matches[1]));
×
522
                }
523

524
                throw new \RuntimeException('Could not parse Authority Key Identifier');
×
525
        }
526

527
        /**
528
         * Revoke a certificate using CFSSL API
529
         *
530
         * @param string $serialNumber Certificate serial number in decimal format
531
         * @param string $authorityKeyId Authority key identifier (lowercase, no colons)
532
         * @param string $reason CRLReason description string (e.g., 'superseded', 'keyCompromise')
533
         */
534
        public function revokeCertificate(string $serialNumber, string $authorityKeyId, string $reason): bool {
535
                try {
536
                        $json = [
×
537
                                'json' => [
×
538
                                        'serial' => $serialNumber,
×
539
                                        'authority_key_id' => $authorityKeyId,
×
540
                                        'reason' => $reason,
×
541
                                ],
×
542
                        ];
×
543

544
                        $response = $this->getClient()->request('POST', 'revoke', $json);
×
545

546
                        $responseData = json_decode((string)$response->getBody(), true);
×
547

548
                        if (!isset($responseData['success'])) {
×
549
                                $errorMessage = isset($responseData['errors'])
×
550
                                        ? implode(', ', array_column($responseData['errors'], 'message'))
×
551
                                        : 'Unknown CFSSL error';
×
552
                                throw new \RuntimeException('CFSSL revocation failed: ' . $errorMessage);
×
553
                        }
554

555
                        return $responseData['success'];
×
556

557
                } catch (RequestException|ConnectException $e) {
×
558
                        throw new \RuntimeException('Failed to communicate with CFSSL server: ' . $e->getMessage());
×
559
                } catch (\Throwable $e) {
×
560
                        throw new \RuntimeException('CFSSL certificate revocation error: ' . $e->getMessage());
×
561
                }
562
        }
563
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc