Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.12% covered (success)
99.12%
112 / 113
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Revoke
99.12% covered (success)
99.12%
112 / 113
75.00% covered (warning)
75.00%
3 / 4
21
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
 configure
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 execute
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
1 / 1
14
 isValidSerial
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3declare(strict_types=1);
4
5/**
6 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
7 * SPDX-License-Identifier: AGPL-3.0-or-later
8 */
9
10namespace OCA\Libresign\Command\Crl;
11
12use OCA\Libresign\Enum\CRLReason;
13use OCA\Libresign\Service\Crl\CrlService;
14use Symfony\Component\Console\Command\Command;
15use Symfony\Component\Console\Input\InputArgument;
16use Symfony\Component\Console\Input\InputInterface;
17use Symfony\Component\Console\Input\InputOption;
18use Symfony\Component\Console\Output\OutputInterface;
19use Symfony\Component\Console\Style\SymfonyStyle;
20
21class Revoke extends Command {
22    public function __construct(
23        private CrlService $crlService,
24    ) {
25        parent::__construct();
26    }
27
28    #[\Override]
29    protected function configure(): void {
30        $validReasons = [];
31        foreach (CRLReason::cases() as $reason) {
32            $validReasons[] = $reason->value . '=' . $reason->getDescription();
33        }
34        $reasonDescription = 'Revocation reason code (' . implode(', ', $validReasons) . ')';
35
36        $this
37            ->setName('libresign:crl:revoke')
38            ->setDescription('Revoke a certificate by serial number')
39            ->addArgument(
40                'serial-number',
41                InputArgument::REQUIRED,
42                'Serial number of the certificate to revoke (decimal or hex format, no 0x prefix)'
43            )
44            ->addOption(
45                'reason',
46                'r',
47                InputOption::VALUE_REQUIRED,
48                $reasonDescription,
49                '0'
50            )
51            ->addOption(
52                'reason-text',
53                't',
54                InputOption::VALUE_REQUIRED,
55                'Optional reason description text'
56            )
57            ->addOption(
58                'dry-run',
59                null,
60                InputOption::VALUE_NONE,
61                'Show what would be done without making changes'
62            );
63    }
64
65    #[\Override]
66    protected function execute(InputInterface $input, OutputInterface $output): int {
67        $io = new SymfonyStyle($input, $output);
68        $isDryRun = $input->getOption('dry-run');
69        $serialNumber = $input->getArgument('serial-number');
70        $reasonCode = (int)$input->getOption('reason');
71        $reasonText = $input->getOption('reason-text');
72
73        if ($isDryRun) {
74            $io->note('Running in DRY-RUN mode - no changes will be made');
75        }
76
77        $io->title('LibreSign CRL Certificate Revocation');
78
79        if (!$this->isValidSerial($serialNumber)) {
80            $io->error("Invalid serial number: {$serialNumber}. Must be in decimal or hex format (no 0x prefix).");
81            return Command::FAILURE;
82        }
83
84        if (!CRLReason::isValid($reasonCode)) {
85            $validCodes = array_map(fn ($case) => $case->value, CRLReason::cases());
86            $io->error("Invalid reason code: {$reasonCode}. Valid codes are: " . implode(', ', $validCodes));
87            return Command::FAILURE;
88        }
89
90        $reason = CRLReason::from($reasonCode);
91        assert($reason instanceof CRLReason);
92        $reasonDescription = $reason->getDescription();
93
94        $io->section('Revocation Details');
95        $io->table(
96            ['Field', 'Value'],
97            [
98                ['Serial Number', $serialNumber],
99                ['Reason Code', $reasonCode],
100                ['Reason', $reasonDescription],
101                ['Description', $reasonText ?? 'N/A'],
102            ]
103        );
104
105        try {
106            $status = $this->crlService->getCertificateStatus($serialNumber);
107
108            $io->section('Current Certificate Status');
109            $io->text("Status: {$status['status']}");
110
111            if ($status['status'] === 'revoked') {
112                $io->warning("Certificate {$serialNumber} is already revoked.");
113                if (isset($status['reason_code'])) {
114                    $currentReason = CRLReason::tryFrom($status['reason_code']);
115                    $currentReasonDescription = $currentReason?->getDescription() ?? 'unknown';
116                    $io->text("Current reason: {$currentReasonDescription} (code: {$status['reason_code']})");
117                }
118                if (isset($status['revoked_at'])) {
119                    $io->text("Revoked at: {$status['revoked_at']}");
120                }
121                return Command::SUCCESS;
122            }
123
124            if ($status['status'] === 'unknown') {
125                $io->error("Certificate {$serialNumber} not found in the database.");
126                return Command::FAILURE;
127            }
128
129        } catch (\Exception $e) {
130            $io->error('Error checking certificate status: ' . $e->getMessage());
131            return Command::FAILURE;
132        }
133
134        if ($isDryRun) {
135            $io->section('Dry Run Results');
136            $io->text("Would revoke certificate with serial number: {$serialNumber}");
137            $io->text("Reason: {$reasonDescription} (code: {$reasonCode})");
138            if ($reasonText) {
139                $io->text("Description: {$reasonText}");
140            }
141            $io->warning('Use --dry-run=false or remove --dry-run to perform actual revocation');
142            return Command::SUCCESS;
143        }
144
145        $io->section('Performing Revocation');
146        try {
147            $success = $this->crlService->revokeCertificate(
148                $serialNumber,
149                $reason,
150                $reasonText,
151                'cli-admin'
152            );
153
154            if ($success) {
155                $io->success("Certificate {$serialNumber} has been revoked successfully.");
156                $io->text("Reason: {$reasonDescription} (code: {$reasonCode})");
157                if ($reasonText) {
158                    $io->text("Description: {$reasonText}");
159                }
160
161                $io->note('The CRL will be regenerated on the next request to include this revocation.');
162            } else {
163                $io->error("Failed to revoke certificate {$serialNumber}");
164                return Command::FAILURE;
165            }
166        } catch (\Exception $e) {
167            $io->error('Error revoking certificate: ' . $e->getMessage());
168            return Command::FAILURE;
169        }
170
171        return Command::SUCCESS;
172    }
173
174    private function isValidSerial(string $serialNumber): bool {
175        $serialNumber = trim($serialNumber);
176
177        if ($serialNumber === '') {
178            return false;
179        }
180
181        if (str_starts_with(strtolower($serialNumber), '0x')) {
182            return false;
183        }
184
185        if (ctype_digit($serialNumber)) {
186            return true;
187        }
188
189        return ctype_xdigit($serialNumber);
190    }
191}