mirror of
https://github.com/LibreSign/libresign.git
synced 2025-12-18 05:20:45 +01:00
feat: implement crl
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
This commit is contained in:
parent
81dee74894
commit
701fbb5e3c
25 changed files with 2322 additions and 15 deletions
|
|
@ -25,7 +25,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
|
|||
|
||||
* [Donate with GitHub Sponsor: ](https://github.com/sponsors/libresign)
|
||||
]]></description>
|
||||
<version>13.0.0-dev.0</version>
|
||||
<version>13.0.0-dev.1</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@librecode.coop" homepage="https://librecode.coop">LibreCode</author>
|
||||
<documentation>
|
||||
|
|
@ -63,6 +63,9 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
|
|||
<command>OCA\Libresign\Command\Configure\Check</command>
|
||||
<command>OCA\Libresign\Command\Configure\Cfssl</command>
|
||||
<command>OCA\Libresign\Command\Configure\OpenSsl</command>
|
||||
<command>OCA\Libresign\Command\Crl\Stats</command>
|
||||
<command>OCA\Libresign\Command\Crl\Cleanup</command>
|
||||
<command>OCA\Libresign\Command\Crl\Revoke</command>
|
||||
<command>OCA\Libresign\Command\Developer\Reset</command>
|
||||
<command>OCA\Libresign\Command\Developer\SignSetup</command>
|
||||
<command>OCA\Libresign\Command\Install</command>
|
||||
|
|
|
|||
131
lib/Command/Crl/Cleanup.php
Normal file
131
lib/Command/Crl/Cleanup.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Command\Crl;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Libresign\Service\CrlService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class Cleanup extends Command {
|
||||
public function __construct(
|
||||
private CrlService $crlService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('libresign:crl:cleanup')
|
||||
->setDescription('Clean up expired certificates from the CRL database')
|
||||
->addOption(
|
||||
'period',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Clean up expired certificates older than specified period (e.g., "1 year", "6 months")',
|
||||
'1 year'
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show what would be cleaned without making changes'
|
||||
);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$isDryRun = $input->getOption('dry-run');
|
||||
$period = $input->getOption('period');
|
||||
|
||||
if ($isDryRun) {
|
||||
$io->note('Running in DRY-RUN mode - no changes will be made');
|
||||
}
|
||||
|
||||
$io->title('LibreSign CRL Cleanup');
|
||||
|
||||
try {
|
||||
$cleanupDate = new DateTime();
|
||||
$cleanupDate->modify("-{$period}");
|
||||
} catch (\Exception $e) {
|
||||
$io->error("Invalid period format: {$period}. Use formats like '1 year', '6 months', '30 days'");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->section('Cleanup Configuration');
|
||||
$io->text("Period: {$period}");
|
||||
$io->text('Cleanup date: ' . $cleanupDate->format('Y-m-d H:i:s'));
|
||||
|
||||
$stats = $this->crlService->getStatistics();
|
||||
|
||||
$totalCertificates = array_sum($stats);
|
||||
$validCertificates = $stats['issued'] ?? 0;
|
||||
$revokedCertificates = $stats['revoked'] ?? 0;
|
||||
$expiredCertificates = $stats['expired'] ?? 0;
|
||||
|
||||
$io->section('Current Statistics');
|
||||
$io->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total Certificates', $totalCertificates],
|
||||
['Valid Certificates', $validCertificates],
|
||||
['Revoked Certificates', $revokedCertificates],
|
||||
['Expired Certificates', $expiredCertificates],
|
||||
]
|
||||
);
|
||||
|
||||
if ($isDryRun) {
|
||||
$io->section('Dry Run Results');
|
||||
$io->text('Would clean up expired certificates older than ' . $cleanupDate->format('Y-m-d H:i:s'));
|
||||
$io->warning('Use --dry-run=false or remove --dry-run to perform actual cleanup');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section('Performing Cleanup');
|
||||
try {
|
||||
$deletedCount = $this->crlService->cleanupExpiredCertificates($cleanupDate);
|
||||
|
||||
if ($deletedCount > 0) {
|
||||
$io->success("Successfully cleaned up {$deletedCount} expired certificate(s)");
|
||||
} else {
|
||||
$io->info('No expired certificates found for cleanup');
|
||||
}
|
||||
|
||||
$newStats = $this->crlService->getStatistics();
|
||||
|
||||
$newTotalCertificates = array_sum($newStats);
|
||||
$newValidCertificates = $newStats['issued'] ?? 0;
|
||||
$newRevokedCertificates = $newStats['revoked'] ?? 0;
|
||||
$newExpiredCertificates = $newStats['expired'] ?? 0;
|
||||
|
||||
$io->section('Updated Statistics');
|
||||
$io->table(
|
||||
['Metric', 'Before', 'After', 'Change'],
|
||||
[
|
||||
['Total Certificates', $totalCertificates, $newTotalCertificates, $newTotalCertificates - $totalCertificates],
|
||||
['Valid Certificates', $validCertificates, $newValidCertificates, $newValidCertificates - $validCertificates],
|
||||
['Revoked Certificates', $revokedCertificates, $newRevokedCertificates, $newRevokedCertificates - $revokedCertificates],
|
||||
['Expired Certificates', $expiredCertificates, $newExpiredCertificates, $newExpiredCertificates - $expiredCertificates],
|
||||
]
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$io->error('Error during cleanup: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
172
lib/Command/Crl/Revoke.php
Normal file
172
lib/Command/Crl/Revoke.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Command\Crl;
|
||||
|
||||
use OCA\Libresign\Enum\CRLReason;
|
||||
use OCA\Libresign\Service\CrlService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class Revoke extends Command {
|
||||
public function __construct(
|
||||
private CrlService $crlService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$validReasons = [];
|
||||
foreach (CRLReason::cases() as $reason) {
|
||||
$validReasons[] = $reason->value . '=' . $reason->getDescription();
|
||||
}
|
||||
$reasonDescription = 'Revocation reason code (' . implode(', ', $validReasons) . ')';
|
||||
|
||||
$this
|
||||
->setName('libresign:crl:revoke')
|
||||
->setDescription('Revoke a certificate by serial number')
|
||||
->addArgument(
|
||||
'serial-number',
|
||||
InputArgument::REQUIRED,
|
||||
'Serial number of the certificate to revoke'
|
||||
)
|
||||
->addOption(
|
||||
'reason',
|
||||
'r',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
$reasonDescription,
|
||||
'0'
|
||||
)
|
||||
->addOption(
|
||||
'reason-text',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Optional reason description text'
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show what would be done without making changes'
|
||||
);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$isDryRun = $input->getOption('dry-run');
|
||||
$serialNumber = $input->getArgument('serial-number');
|
||||
$reasonCode = (int)$input->getOption('reason');
|
||||
$reasonText = $input->getOption('reason-text');
|
||||
|
||||
if ($isDryRun) {
|
||||
$io->note('Running in DRY-RUN mode - no changes will be made');
|
||||
}
|
||||
|
||||
$io->title('LibreSign CRL Certificate Revocation');
|
||||
|
||||
if (!is_numeric($serialNumber) || (int)$serialNumber <= 0) {
|
||||
$io->error("Invalid serial number: {$serialNumber}. Must be a positive integer.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!CRLReason::isValid($reasonCode)) {
|
||||
$validCodes = array_map(fn ($case) => $case->value, CRLReason::cases());
|
||||
$io->error("Invalid reason code: {$reasonCode}. Valid codes are: " . implode(', ', $validCodes));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$reason = CRLReason::from($reasonCode);
|
||||
$reasonDescription = $reason->getDescription();
|
||||
|
||||
$io->section('Revocation Details');
|
||||
$io->table(
|
||||
['Field', 'Value'],
|
||||
[
|
||||
['Serial Number', $serialNumber],
|
||||
['Reason Code', $reasonCode],
|
||||
['Reason', $reasonDescription],
|
||||
['Description', $reasonText ?? 'N/A'],
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
$status = $this->crlService->getCertificateStatus((int)$serialNumber);
|
||||
|
||||
$io->section('Current Certificate Status');
|
||||
$io->text("Status: {$status['status']}");
|
||||
|
||||
if ($status['status'] === 'revoked') {
|
||||
$io->warning("Certificate {$serialNumber} is already revoked.");
|
||||
if (isset($status['reason_code'])) {
|
||||
$currentReason = CRLReason::tryFrom($status['reason_code']);
|
||||
$currentReasonDescription = $currentReason?->getDescription() ?? 'unknown';
|
||||
$io->text("Current reason: {$currentReasonDescription} (code: {$status['reason_code']})");
|
||||
}
|
||||
if (isset($status['revoked_at'])) {
|
||||
$io->text("Revoked at: {$status['revoked_at']}");
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'unknown') {
|
||||
$io->error("Certificate {$serialNumber} not found in the database.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$io->error('Error checking certificate status: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$io->section('Dry Run Results');
|
||||
$io->text("Would revoke certificate with serial number: {$serialNumber}");
|
||||
$io->text("Reason: {$reasonDescription} (code: {$reasonCode})");
|
||||
if ($reasonText) {
|
||||
$io->text("Description: {$reasonText}");
|
||||
}
|
||||
$io->warning('Use --dry-run=false or remove --dry-run to perform actual revocation');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section('Performing Revocation');
|
||||
try {
|
||||
$success = $this->crlService->revokeCertificate(
|
||||
(int)$serialNumber,
|
||||
$reasonCode,
|
||||
$reasonText,
|
||||
'cli-admin'
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
$io->success("Certificate {$serialNumber} has been revoked successfully.");
|
||||
$io->text("Reason: {$reasonDescription} (code: {$reasonCode})");
|
||||
if ($reasonText) {
|
||||
$io->text("Description: {$reasonText}");
|
||||
}
|
||||
|
||||
$io->note('The CRL will be regenerated on the next request to include this revocation.');
|
||||
} else {
|
||||
$io->error("Failed to revoke certificate {$serialNumber}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$io->error('Error revoking certificate: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
91
lib/Command/Crl/Stats.php
Normal file
91
lib/Command/Crl/Stats.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Command\Crl;
|
||||
|
||||
use OCA\Libresign\Service\CrlService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class Stats extends Command {
|
||||
public function __construct(
|
||||
private CrlService $crlService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('libresign:crl:stats')
|
||||
->setDescription('Display Certificate Revocation List statistics');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('LibreSign CRL Statistics');
|
||||
|
||||
$stats = $this->crlService->getStatistics();
|
||||
$revocationStats = $this->crlService->getRevocationStatistics();
|
||||
|
||||
$totalCertificates = array_sum($stats);
|
||||
$validCertificates = $stats['issued'] ?? 0;
|
||||
$revokedCertificates = $stats['revoked'] ?? 0;
|
||||
$expiredCertificates = $stats['expired'] ?? 0;
|
||||
|
||||
$io->section('Database Statistics');
|
||||
$io->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total Certificates', $totalCertificates],
|
||||
['Valid Certificates', $validCertificates],
|
||||
['Revoked Certificates', $revokedCertificates],
|
||||
['Expired Certificates', $expiredCertificates],
|
||||
]
|
||||
);
|
||||
|
||||
if (!empty($revocationStats)) {
|
||||
$io->section('Revocation Statistics');
|
||||
$revocationTable = [];
|
||||
foreach ($revocationStats as $stat) {
|
||||
$revocationTable[] = [
|
||||
$stat['reason_description'] ?? 'Unknown',
|
||||
$stat['count']
|
||||
];
|
||||
}
|
||||
$io->table(['Revocation Reason', 'Count'], $revocationTable);
|
||||
}
|
||||
|
||||
$recentRevoked = $this->crlService->getRevokedCertificates();
|
||||
if (!empty($recentRevoked)) {
|
||||
$io->section('Recent Revocations (Last 10)');
|
||||
$recentTable = [];
|
||||
$count = 0;
|
||||
foreach (array_reverse($recentRevoked) as $cert) {
|
||||
if ($count >= 10) {
|
||||
break;
|
||||
}
|
||||
$recentTable[] = [
|
||||
$cert['serial_number'],
|
||||
$cert['reason_description'] ?? 'N/A',
|
||||
$cert['revoked_at'] ?? 'N/A',
|
||||
$cert['revoked_by'] ?? 'N/A'
|
||||
];
|
||||
$count++;
|
||||
}
|
||||
$io->table(['Serial Number', 'Reason', 'Revoked At', 'Revoked By'], $recentTable);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
88
lib/Controller/CrlController.php
Normal file
88
lib/Controller/CrlController.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Controller;
|
||||
|
||||
use OCA\Libresign\Service\CrlService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class CrlController extends Controller {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private CrlService $crlService,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Certificate Revocation List in DER format (RFC 5280 compliant)
|
||||
*
|
||||
* @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string, message: string}, array{}>
|
||||
*
|
||||
* 200: CRL retrieved successfully in DER format
|
||||
* 500: Failed to generate CRL
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/crl')]
|
||||
public function getRevocationList(): DataDownloadResponse|DataResponse {
|
||||
try {
|
||||
$crlDer = $this->crlService->generateCrlDer();
|
||||
|
||||
return new DataDownloadResponse(
|
||||
$crlDer,
|
||||
'crl.crl',
|
||||
'application/pkix-crl'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to generate CRL', ['exception' => $e]);
|
||||
|
||||
return new DataResponse([
|
||||
'error' => 'CRL generation failed',
|
||||
'message' => $e->getMessage()
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check certificate revocation status
|
||||
*
|
||||
* @param string $serialNumber Certificate serial number to check
|
||||
* @return DataResponse<Http::STATUS_OK, array{serial_number: string, status: string, checked_at: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string, message: string}, array{}>
|
||||
*
|
||||
* 200: Certificate status retrieved successfully
|
||||
* 400: Invalid serial number format
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[PublicPage]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/crl/check/{serialNumber}')]
|
||||
public function checkCertificateStatus(string $serialNumber): DataResponse {
|
||||
if (!is_numeric($serialNumber) || (int)$serialNumber <= 0) {
|
||||
return new DataResponse(
|
||||
['error' => 'Invalid serial number', 'message' => 'Serial number must be a positive integer'],
|
||||
Http::STATUS_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return new DataResponse($this->crlService->getCertificateStatusResponse((int)$serialNumber));
|
||||
}
|
||||
}
|
||||
89
lib/Db/Crl.php
Normal file
89
lib/Db/Crl.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Db;
|
||||
|
||||
use OCA\Libresign\Enum\CRLStatus;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
use OCP\DB\Types;
|
||||
|
||||
/**
|
||||
* @method void setId(int $id)
|
||||
* @method int getId()
|
||||
* @method void setSerialNumber(int $serialNumber)
|
||||
* @method int getSerialNumber()
|
||||
* @method void setOwner(string $owner)
|
||||
* @method string getOwner()
|
||||
* @method void setReasonCode(?int $reasonCode)
|
||||
* @method ?int getReasonCode()
|
||||
* @method void setRevokedBy(?string $revokedBy)
|
||||
* @method ?string getRevokedBy()
|
||||
* @method void setRevokedAt(?\DateTime $revokedAt)
|
||||
* @method ?\DateTime getRevokedAt()
|
||||
* @method void setInvalidityDate(?\DateTime $invalidityDate)
|
||||
* @method ?\DateTime getInvalidityDate()
|
||||
* @method void setCrlNumber(?int $crlNumber)
|
||||
* @method ?int getCrlNumber()
|
||||
* @method void setIssuedAt(\DateTime $issuedAt)
|
||||
* @method \DateTime getIssuedAt()
|
||||
* @method void setValidTo(?\DateTime $validTo)
|
||||
* @method ?\DateTime getValidTo()
|
||||
* @method void setComment(?string $comment)
|
||||
* @method ?string getComment()
|
||||
*/
|
||||
class Crl extends Entity {
|
||||
protected int $serialNumber = 0;
|
||||
protected string $owner = '';
|
||||
protected string $status = 'issued';
|
||||
protected ?int $reasonCode = null;
|
||||
protected ?string $revokedBy = null;
|
||||
protected ?\DateTime $revokedAt = null;
|
||||
protected ?\DateTime $invalidityDate = null;
|
||||
protected ?int $crlNumber = null;
|
||||
protected ?\DateTime $issuedAt = null;
|
||||
protected ?\DateTime $validTo = null;
|
||||
protected ?string $comment = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', Types::BIGINT);
|
||||
$this->addType('serialNumber', Types::BIGINT);
|
||||
$this->addType('status', Types::STRING);
|
||||
$this->addType('reasonCode', Types::SMALLINT);
|
||||
$this->addType('crlNumber', Types::BIGINT);
|
||||
$this->addType('revokedAt', Types::DATETIME);
|
||||
$this->addType('invalidityDate', Types::DATETIME);
|
||||
$this->addType('issuedAt', Types::DATETIME);
|
||||
$this->addType('validTo', Types::DATETIME);
|
||||
$this->addType('comment', Types::STRING);
|
||||
}
|
||||
|
||||
public function getStatus(): string {
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(CRLStatus|string $status): void {
|
||||
$value = $status instanceof CRLStatus ? $status->value : $status;
|
||||
$this->setter('status', [$value]);
|
||||
}
|
||||
|
||||
public function isRevoked(): bool {
|
||||
return CRLStatus::from($this->status) === CRLStatus::REVOKED;
|
||||
}
|
||||
|
||||
public function isExpired(): bool {
|
||||
if ($this->validTo === null) {
|
||||
return false;
|
||||
}
|
||||
return $this->validTo < new \DateTime();
|
||||
}
|
||||
|
||||
public function isValid(): bool {
|
||||
return !$this->isRevoked() && !$this->isExpired();
|
||||
}
|
||||
}
|
||||
174
lib/Db/CrlMapper.php
Normal file
174
lib/Db/CrlMapper.php
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Libresign\Db;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Libresign\Enum\CRLReason;
|
||||
use OCA\Libresign\Enum\CRLStatus;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<Crl>
|
||||
*/
|
||||
class CrlMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'libresign_crl');
|
||||
}
|
||||
|
||||
public function findBySerialNumber(int $serialNumber): Crl {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('serial_number', $qb->createNamedParameter($serialNumber, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
/** @var Crl */
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function createCertificate(
|
||||
int $serialNumber,
|
||||
string $owner,
|
||||
DateTime $issuedAt,
|
||||
?DateTime $validTo = null,
|
||||
): Crl {
|
||||
$certificate = new Crl();
|
||||
$certificate->setSerialNumber($serialNumber);
|
||||
$certificate->setOwner($owner);
|
||||
$certificate->setStatus(CRLStatus::ISSUED);
|
||||
$certificate->setIssuedAt($issuedAt);
|
||||
$certificate->setValidTo($validTo);
|
||||
|
||||
/** @var Crl */
|
||||
return $this->insert($certificate);
|
||||
}
|
||||
|
||||
public function revokeCertificate(
|
||||
int $serialNumber,
|
||||
CRLReason $reason = CRLReason::UNSPECIFIED,
|
||||
?string $comment = null,
|
||||
?string $revokedBy = null,
|
||||
?DateTime $invalidityDate = null,
|
||||
?int $crlNumber = null,
|
||||
): Crl {
|
||||
$certificate = $this->findBySerialNumber($serialNumber);
|
||||
|
||||
if (CRLStatus::from($certificate->getStatus()) !== CRLStatus::ISSUED) {
|
||||
throw new \InvalidArgumentException('Certificate is not in issued status');
|
||||
}
|
||||
|
||||
$certificate->setStatus(CRLStatus::REVOKED);
|
||||
$certificate->setReasonCode($reason->value);
|
||||
$certificate->setComment($comment);
|
||||
$certificate->setRevokedBy($revokedBy);
|
||||
$certificate->setRevokedAt(new DateTime());
|
||||
$certificate->setInvalidityDate($invalidityDate);
|
||||
$certificate->setCrlNumber($crlNumber);
|
||||
|
||||
/** @var Crl */
|
||||
return $this->update($certificate);
|
||||
}
|
||||
|
||||
public function getRevokedCertificates(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('status', $qb->createNamedParameter(CRLStatus::REVOKED->value)))
|
||||
->orderBy('revoked_at', 'DESC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function isInvalidAt(int $serialNumber, ?DateTime $checkDate = null): bool {
|
||||
$checkDate = $checkDate ?? new DateTime();
|
||||
|
||||
try {
|
||||
$certificate = $this->findBySerialNumber($serialNumber);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($certificate->isRevoked()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($certificate->getInvalidityDate() && $certificate->getInvalidityDate() <= $checkDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getNextCrlNumber(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$result = $qb->select($qb->func()->max('crl_number'))
|
||||
->from($this->getTableName())
|
||||
->executeQuery();
|
||||
|
||||
$maxCrlNumber = $result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
return ($maxCrlNumber ?? 0) + 1;
|
||||
}
|
||||
|
||||
public function cleanupExpiredCertificates(?DateTime $before = null): int {
|
||||
$before = $before ?? new DateTime('-1 year');
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
return $qb->delete($this->getTableName())
|
||||
->where($qb->expr()->isNotNull('valid_to'))
|
||||
->andWhere($qb->expr()->lt('valid_to', $qb->createNamedParameter($before, 'datetime')))
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
public function getStatistics(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$result = $qb->select('status', $qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->groupBy('status')
|
||||
->executeQuery();
|
||||
|
||||
$stats = [];
|
||||
while ($row = $result->fetch()) {
|
||||
$stats[$row['status']] = (int)$row['count'];
|
||||
}
|
||||
|
||||
$result->closeCursor();
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public function getRevocationStatistics(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$result = $qb->select('reason_code', $qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('status', $qb->createNamedParameter(CRLStatus::REVOKED->value)))
|
||||
->andWhere($qb->expr()->isNotNull('reason_code'))
|
||||
->groupBy('reason_code')
|
||||
->executeQuery();
|
||||
|
||||
$stats = [];
|
||||
while ($row = $result->fetch()) {
|
||||
$reasonCode = (int)$row['reason_code'];
|
||||
$reason = CRLReason::tryFrom($reasonCode);
|
||||
$stats[$reasonCode] = [
|
||||
'code' => $reasonCode,
|
||||
'description' => $reason?->getDescription() ?? 'unknown',
|
||||
'count' => (int)$row['count'],
|
||||
];
|
||||
}
|
||||
|
||||
$result->closeCursor();
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
45
lib/Enum/CRLReason.php
Normal file
45
lib/Enum/CRLReason.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Enum;
|
||||
|
||||
/**
|
||||
* RFC 5280 CRLReason codes
|
||||
*/
|
||||
enum CRLReason: int {
|
||||
case UNSPECIFIED = 0;
|
||||
case KEY_COMPROMISE = 1;
|
||||
case CA_COMPROMISE = 2;
|
||||
case AFFILIATION_CHANGED = 3;
|
||||
case SUPERSEDED = 4;
|
||||
case CESSATION_OF_OPERATION = 5;
|
||||
case CERTIFICATE_HOLD = 6;
|
||||
case REMOVE_FROM_CRL = 8;
|
||||
case PRIVILEGE_WITHDRAWN = 9;
|
||||
case AA_COMPROMISE = 10;
|
||||
|
||||
public function getDescription(): string {
|
||||
return match ($this) {
|
||||
self::UNSPECIFIED => 'unspecified',
|
||||
self::KEY_COMPROMISE => 'keyCompromise',
|
||||
self::CA_COMPROMISE => 'cACompromise',
|
||||
self::AFFILIATION_CHANGED => 'affiliationChanged',
|
||||
self::SUPERSEDED => 'superseded',
|
||||
self::CESSATION_OF_OPERATION => 'cessationOfOperation',
|
||||
self::CERTIFICATE_HOLD => 'certificateHold',
|
||||
self::REMOVE_FROM_CRL => 'removeFromCRL',
|
||||
self::PRIVILEGE_WITHDRAWN => 'privilegeWithdrawn',
|
||||
self::AA_COMPROMISE => 'aACompromise',
|
||||
};
|
||||
}
|
||||
|
||||
public static function isValid(int $code): bool {
|
||||
return self::tryFrom($code) !== null;
|
||||
}
|
||||
}
|
||||
26
lib/Enum/CRLStatus.php
Normal file
26
lib/Enum/CRLStatus.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Enum;
|
||||
|
||||
/**
|
||||
* Certificate status in CRL
|
||||
*/
|
||||
enum CRLStatus: string {
|
||||
case ISSUED = 'issued';
|
||||
case REVOKED = 'revoked';
|
||||
|
||||
public function isRevoked(): bool {
|
||||
return $this === self::REVOKED;
|
||||
}
|
||||
|
||||
public function isIssued(): bool {
|
||||
return $this === self::ISSUED;
|
||||
}
|
||||
}
|
||||
|
|
@ -352,7 +352,6 @@ class CfsslHandler extends AEngineHandler implements IEngineHandler {
|
|||
if ($uri = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_uri')) {
|
||||
return $uri;
|
||||
}
|
||||
// In case config is an empty string
|
||||
$this->appConfig->deleteKey(Application::APP_ID, 'cfssl_uri');
|
||||
|
||||
$this->cfsslUri = self::CFSSL_URI;
|
||||
|
|
@ -443,4 +442,99 @@ class CfsslHandler extends AEngineHandler implements IEngineHandler {
|
|||
->setResource('cfssl');
|
||||
return $return;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function generateCrlDer(array $revokedCertificates): string {
|
||||
try {
|
||||
$queryParams = [];
|
||||
$queryParams['expiry'] = '168h'; // 7 days * 24 hours
|
||||
|
||||
$response = $this->getClient()->request('GET', 'crl', [
|
||||
'query' => $queryParams
|
||||
]);
|
||||
|
||||
$responseData = json_decode((string)$response->getBody(), true);
|
||||
|
||||
if (!isset($responseData['success']) || !$responseData['success']) {
|
||||
$errorMessage = isset($responseData['errors'])
|
||||
? implode(', ', array_column($responseData['errors'], 'message'))
|
||||
: 'Unknown CFSSL error';
|
||||
throw new \RuntimeException('CFSSL CRL generation failed: ' . $errorMessage);
|
||||
}
|
||||
|
||||
if (isset($responseData['result']) && is_string($responseData['result'])) {
|
||||
return $responseData['result'];
|
||||
}
|
||||
|
||||
throw new \RuntimeException('No CRL data returned from CFSSL');
|
||||
|
||||
} catch (RequestException|ConnectException $e) {
|
||||
throw new \RuntimeException('Failed to communicate with CFSSL server: ' . $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('CFSSL CRL generation error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Authority Key Identifier from certificate (needed for CFSSL revocation)
|
||||
*
|
||||
* @param string $certificatePem PEM encoded certificate
|
||||
* @return string Authority Key Identifier in lowercase without colons
|
||||
*/
|
||||
public function getAuthorityKeyId(string $certificatePem): string {
|
||||
$cert = openssl_x509_read($certificatePem);
|
||||
if (!$cert) {
|
||||
throw new \RuntimeException('Invalid certificate format');
|
||||
}
|
||||
|
||||
$parsed = openssl_x509_parse($cert);
|
||||
if (!$parsed || !isset($parsed['extensions']['authorityKeyIdentifier'])) {
|
||||
throw new \RuntimeException('Certificate does not contain Authority Key Identifier');
|
||||
}
|
||||
|
||||
$authKeyId = $parsed['extensions']['authorityKeyIdentifier'];
|
||||
|
||||
if (preg_match('/keyid:([A-Fa-f0-9:]+)/', $authKeyId, $matches)) {
|
||||
return strtolower(str_replace(':', '', $matches[1]));
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Could not parse Authority Key Identifier');
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a certificate using CFSSL API
|
||||
*
|
||||
* @param string $serialNumber Certificate serial number in decimal format
|
||||
* @param string $authorityKeyId Authority key identifier (lowercase, no colons)
|
||||
* @param string $reason CRLReason description string (e.g., 'superseded', 'keyCompromise')
|
||||
*/
|
||||
public function revokeCertificate(string $serialNumber, string $authorityKeyId, string $reason): bool {
|
||||
try {
|
||||
$json = [
|
||||
'json' => [
|
||||
'serial' => $serialNumber,
|
||||
'authority_key_id' => $authorityKeyId,
|
||||
'reason' => $reason,
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->getClient()->request('POST', 'revoke', $json);
|
||||
|
||||
$responseData = json_decode((string)$response->getBody(), true);
|
||||
|
||||
if (!isset($responseData['success'])) {
|
||||
$errorMessage = isset($responseData['errors'])
|
||||
? implode(', ', array_column($responseData['errors'], 'message'))
|
||||
: 'Unknown CFSSL error';
|
||||
throw new \RuntimeException('CFSSL revocation failed: ' . $errorMessage);
|
||||
}
|
||||
|
||||
return $responseData['success'];
|
||||
|
||||
} catch (RequestException|ConnectException $e) {
|
||||
throw new \RuntimeException('Failed to communicate with CFSSL server: ' . $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('CFSSL certificate revocation error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,4 +56,12 @@ interface IEngineHandler {
|
|||
public function configureCheck(): array;
|
||||
|
||||
public function toArray(): array;
|
||||
|
||||
/**
|
||||
* Generate Certificate Revocation List in DER format
|
||||
* @param array $revokedCertificates Array of revoked certificate entities
|
||||
* @return string DER-encoded CRL data
|
||||
* @throws \RuntimeException If CRL generation is not supported or fails
|
||||
*/
|
||||
public function generateCrlDer(array $revokedCertificates): string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,4 +31,9 @@ class NoneHandler extends AEngineHandler implements IEngineHandler {
|
|||
public function configureCheck(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function generateCrlDer(array $revokedCertificates): string {
|
||||
throw new \RuntimeException('CRL generation is not supported by None handler');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ namespace OCA\Libresign\Handler\CertificateEngine;
|
|||
use OCA\Libresign\Exception\LibresignException;
|
||||
use OCA\Libresign\Helper\ConfigureCheckHelper;
|
||||
use OCA\Libresign\Service\CertificatePolicyService;
|
||||
use OCA\Libresign\Service\SerialNumberService;
|
||||
use OCP\Files\AppData\IAppDataFactory;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDateTimeFormatter;
|
||||
use OCP\ITempManager;
|
||||
use OCP\IURLGenerator;
|
||||
|
||||
/**
|
||||
* Class FileMapper
|
||||
|
|
@ -32,10 +34,14 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
protected IDateTimeFormatter $dateTimeFormatter,
|
||||
protected ITempManager $tempManager,
|
||||
protected CertificatePolicyService $certificatePolicyService,
|
||||
protected IURLGenerator $urlGenerator,
|
||||
protected SerialNumberService $serialNumberService,
|
||||
) {
|
||||
parent::__construct($config, $appConfig, $appDataFactory, $dateTimeFormatter, $tempManager, $certificatePolicyService);
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[\Override]
|
||||
public function generateRootCert(
|
||||
string $commonName,
|
||||
|
|
@ -49,7 +55,10 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
$csr = openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
|
||||
$options = $this->getRootCertOptions();
|
||||
|
||||
$serialNumber = random_int(1000000, 2147483647);
|
||||
$serialNumber = $this->serialNumberService->generateUniqueSerial(
|
||||
$commonName,
|
||||
new \DateTime('+5 years')
|
||||
);
|
||||
|
||||
$x509 = openssl_csr_sign($csr, null, $privateKey, $days = 365 * 5, $options, $serialNumber);
|
||||
|
||||
|
|
@ -105,7 +114,10 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
throw new LibresignException('OpenSSL error: ' . $message);
|
||||
}
|
||||
|
||||
$serialNumber = random_int(1000000, 2147483647);
|
||||
$serialNumber = $this->serialNumberService->generateUniqueSerial(
|
||||
$this->getCommonName(),
|
||||
new \DateTime('+' . $this->expirity() . ' days')
|
||||
);
|
||||
$options = $this->getLeafCertOptions();
|
||||
|
||||
$x509 = openssl_csr_sign($csr, $rootCertificate, $rootPrivateKey, $this->expirity(), $options, $serialNumber);
|
||||
|
|
@ -142,6 +154,23 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
*/
|
||||
private function buildCaCertificateConfig(): array {
|
||||
$config = [
|
||||
'ca' => [
|
||||
'default_ca' => 'CA_default'
|
||||
],
|
||||
'CA_default' => [
|
||||
'default_crl_days' => 7,
|
||||
'default_md' => 'sha256',
|
||||
'preserve' => 'no',
|
||||
'policy' => 'policy_anything'
|
||||
],
|
||||
'policy_anything' => [
|
||||
'countryName' => 'optional',
|
||||
'stateOrProvinceName' => 'optional',
|
||||
'organizationName' => 'optional',
|
||||
'organizationalUnitName' => 'optional',
|
||||
'commonName' => 'supplied',
|
||||
'emailAddress' => 'optional'
|
||||
],
|
||||
'v3_ca' => [
|
||||
'basicConstraints' => 'critical, CA:TRUE',
|
||||
'keyUsage' => 'critical, digitalSignature, keyCertSign',
|
||||
|
|
@ -149,7 +178,13 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
'subjectAltName' => $this->getSubjectAltNames(),
|
||||
'authorityKeyIdentifier' => 'keyid',
|
||||
'subjectKeyIdentifier' => 'hash',
|
||||
'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
|
||||
],
|
||||
'crl_ext' => [
|
||||
'issuerAltName' => 'issuer:copy',
|
||||
'authorityKeyIdentifier' => 'keyid:always',
|
||||
'subjectKeyIdentifier' => 'hash'
|
||||
]
|
||||
];
|
||||
|
||||
$this->addCaPolicies($config);
|
||||
|
|
@ -166,6 +201,7 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
'subjectAltName' => $this->getSubjectAltNames(),
|
||||
'authorityKeyIdentifier' => 'keyid:always,issuer:always',
|
||||
'subjectKeyIdentifier' => 'hash',
|
||||
'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
|
||||
],
|
||||
];
|
||||
|
||||
|
|
@ -174,6 +210,10 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
return $config;
|
||||
}
|
||||
|
||||
private function getCrlDistributionUrl(): string {
|
||||
return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList');
|
||||
}
|
||||
|
||||
private function addCaPolicies(array &$config): void {
|
||||
$oid = $this->certificatePolicyService->getOid();
|
||||
$cps = $this->certificatePolicyService->getCps();
|
||||
|
|
@ -295,4 +335,140 @@ class OpenSslHandler extends AEngineHandler implements IEngineHandler {
|
|||
->setResource('openssl-configure')
|
||||
->setTip('Run occ libresign:configure:openssl --help')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CRL in DER format using OpenSSL commands
|
||||
* This is OpenSSL-specific logic that belongs in the OpenSSL handler
|
||||
*/
|
||||
#[\Override]
|
||||
public function generateCrlDer(array $revokedCertificates): string {
|
||||
$configPath = $this->getConfigPath();
|
||||
$caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
|
||||
$caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
|
||||
$crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
|
||||
|
||||
if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
|
||||
throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
|
||||
}
|
||||
|
||||
if ($this->isCrlUpToDate($crlDerPath, $revokedCertificates)) {
|
||||
$content = file_get_contents($crlDerPath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException('Failed to read existing CRL file');
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
$crlConfigPath = $this->createCrlConfig($revokedCertificates);
|
||||
$crlPemPath = $configPath . DIRECTORY_SEPARATOR . 'crl.pem';
|
||||
|
||||
try {
|
||||
$command = sprintf(
|
||||
'openssl ca -gencrl -out %s -config %s -cert %s -keyfile %s',
|
||||
escapeshellarg($crlPemPath),
|
||||
escapeshellarg($crlConfigPath),
|
||||
escapeshellarg($caCertPath),
|
||||
escapeshellarg($caKeyPath)
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$returnCode = 0;
|
||||
exec($command . ' 2>&1', $output, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
throw new \RuntimeException('Failed to generate CRL: ' . implode("\n", $output));
|
||||
}
|
||||
|
||||
$convertCommand = sprintf(
|
||||
'openssl crl -in %s -outform DER -out %s',
|
||||
escapeshellarg($crlPemPath),
|
||||
escapeshellarg($crlDerPath)
|
||||
);
|
||||
|
||||
exec($convertCommand . ' 2>&1', $output, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
throw new \RuntimeException('Failed to convert CRL to DER format: ' . implode("\n", $output));
|
||||
}
|
||||
|
||||
$derContent = file_get_contents($crlDerPath);
|
||||
if ($derContent === false) {
|
||||
throw new \RuntimeException('Failed to read generated CRL DER file');
|
||||
}
|
||||
|
||||
return $derContent;
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate CRL: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function isCrlUpToDate(string $crlDerPath, array $revokedCertificates): bool {
|
||||
if (!file_exists($crlDerPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$crlAge = time() - filemtime($crlDerPath);
|
||||
if ($crlAge > 86400) { // 24 hours
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function createCrlConfig(array $revokedCertificates): string {
|
||||
$configPath = $this->getConfigPath();
|
||||
$indexFile = $configPath . DIRECTORY_SEPARATOR . 'index.txt';
|
||||
$crlNumberFile = $configPath . DIRECTORY_SEPARATOR . 'crlnumber';
|
||||
$configFile = $configPath . DIRECTORY_SEPARATOR . 'crl.conf';
|
||||
|
||||
$existingContent = file_exists($indexFile) ? file_get_contents($indexFile) : '';
|
||||
$existingSerials = [];
|
||||
|
||||
if ($existingContent) {
|
||||
foreach (explode("\n", trim($existingContent)) as $line) {
|
||||
if (preg_match('/^R\t.*\t.*\t([A-F0-9]+)\t/', $line, $matches)) {
|
||||
$existingSerials[] = $matches[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$newContent = '';
|
||||
foreach ($revokedCertificates as $cert) {
|
||||
$serialHex = strtoupper(dechex($cert->getSerialNumber()));
|
||||
|
||||
if (in_array($serialHex, $existingSerials)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$revokedAt = new \DateTime($cert->getRevokedAt()->format('Y-m-d H:i:s'));
|
||||
$reasonCode = $cert->getReasonCode() ?? 0;
|
||||
$newContent .= sprintf(
|
||||
"R\t%s\t%s,%02d\t%s\tunknown\t/CN=%s\n",
|
||||
$cert->getValidTo() ? $cert->getValidTo()->format('ymdHis\Z') : '501231235959Z',
|
||||
$revokedAt->format('ymdHis\Z'),
|
||||
$reasonCode,
|
||||
$serialHex,
|
||||
$cert->getOwner()
|
||||
);
|
||||
}
|
||||
|
||||
file_put_contents($indexFile, $existingContent . $newContent);
|
||||
|
||||
if (!file_exists($crlNumberFile)) {
|
||||
file_put_contents($crlNumberFile, "01\n");
|
||||
}
|
||||
|
||||
$crlConfig = $this->buildCaCertificateConfig();
|
||||
|
||||
$crlConfig['CA_default']['dir'] = dirname($indexFile);
|
||||
$crlConfig['CA_default']['database'] = $indexFile;
|
||||
$crlConfig['CA_default']['crlnumber'] = $crlNumberFile;
|
||||
|
||||
$configContent = CertificateHelper::arrayToIni($crlConfig);
|
||||
file_put_contents($configFile, $configContent);
|
||||
|
||||
return $configFile;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
97
lib/Migration/Version13000Date20250117140000.php
Normal file
97
lib/Migration/Version13000Date20250117140000.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Creates CRL (Certificate Revocation List) table for unique serial number management
|
||||
* and RFC-compliant certificate revocation tracking
|
||||
*/
|
||||
class Version13000Date20250117140000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
#[\Override]
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('libresign_crl')) {
|
||||
$table = $schema->createTable('libresign_crl');
|
||||
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
|
||||
$table->addColumn('serial_number', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
]);
|
||||
|
||||
$table->addColumn('owner', Types::STRING, [
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('revoked_by', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
|
||||
$table->addColumn('reason_code', Types::SMALLINT, [
|
||||
'notnull' => false,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
|
||||
$table->addColumn('revoked_at', Types::DATETIME, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->addColumn('invalidity_date', Types::DATETIME, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->addColumn('issued_at', Types::DATETIME, [
|
||||
]);
|
||||
|
||||
$table->addColumn('valid_to', Types::DATETIME, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->addColumn('status', Types::STRING, [
|
||||
'length' => 32,
|
||||
'default' => 'issued',
|
||||
]);
|
||||
|
||||
$table->addColumn('crl_number', Types::BIGINT, [
|
||||
'notnull' => false,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
|
||||
$table->addColumn('comment', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['serial_number'], 'libresign_crl_serial_uk');
|
||||
$table->addIndex(['status'], 'libresign_crl_status_idx');
|
||||
$table->addIndex(['valid_to'], 'libresign_crl_valid_to_idx');
|
||||
$table->addIndex(['reason_code'], 'libresign_crl_reason_code_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
181
lib/Service/CrlService.php
Normal file
181
lib/Service/CrlService.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Service;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Libresign\Db\CrlMapper;
|
||||
use OCA\Libresign\Enum\CRLReason;
|
||||
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* RFC 5280 compliant CRL management
|
||||
*/
|
||||
class CrlService {
|
||||
|
||||
public function __construct(
|
||||
private CrlMapper $crlMapper,
|
||||
private LoggerInterface $logger,
|
||||
private CertificateEngineFactory $certificateEngineFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
private static function isValidReasonCode(int $reasonCode): bool {
|
||||
return CRLReason::isValid($reasonCode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function revokeCertificate(
|
||||
int $serialNumber,
|
||||
int $reasonCode = CRLReason::UNSPECIFIED->value,
|
||||
?string $reasonText = null,
|
||||
?string $revokedBy = null,
|
||||
?DateTime $invalidityDate = null,
|
||||
): bool {
|
||||
if (!self::isValidReasonCode($reasonCode)) {
|
||||
throw new \InvalidArgumentException("Invalid CRLReason code: {$reasonCode}");
|
||||
}
|
||||
|
||||
$reason = CRLReason::from($reasonCode);
|
||||
$crlNumber = $this->getNextCrlNumber();
|
||||
|
||||
try {
|
||||
$this->crlMapper->revokeCertificate(
|
||||
$serialNumber,
|
||||
$reason,
|
||||
$reasonText,
|
||||
$revokedBy,
|
||||
$invalidityDate,
|
||||
$crlNumber
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getCertificateStatus(int $serialNumber, ?DateTime $checkDate = null): array {
|
||||
try {
|
||||
$certificate = $this->crlMapper->findBySerialNumber($serialNumber);
|
||||
|
||||
if ($certificate->isRevoked()) {
|
||||
return [
|
||||
'status' => 'revoked',
|
||||
'reason_code' => $certificate->getReasonCode(),
|
||||
'revoked_at' => $certificate->getRevokedAt()?->format('Y-m-d\TH:i:s\Z'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($certificate->isExpired()) {
|
||||
return [
|
||||
'status' => 'expired',
|
||||
'valid_to' => $certificate->getValidTo()?->format('Y-m-d\TH:i:s\Z'),
|
||||
];
|
||||
}
|
||||
|
||||
return ['status' => 'valid'];
|
||||
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
|
||||
return ['status' => 'unknown'];
|
||||
}
|
||||
}
|
||||
|
||||
public function getCertificateStatusResponse(int $serialNumber): array {
|
||||
$statusInfo = $this->getCertificateStatus($serialNumber);
|
||||
|
||||
$response = [
|
||||
'serial_number' => (string)$serialNumber,
|
||||
'status' => $statusInfo['status'],
|
||||
'checked_at' => (new \DateTime())->format('Y-m-d\TH:i:s\Z'),
|
||||
];
|
||||
|
||||
if ($statusInfo['status'] === 'revoked') {
|
||||
if (isset($statusInfo['reason_code'])) {
|
||||
$response['reason_code'] = $statusInfo['reason_code'];
|
||||
}
|
||||
if (isset($statusInfo['revoked_at'])) {
|
||||
$response['revoked_at'] = $statusInfo['revoked_at'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($statusInfo['status'] === 'expired') {
|
||||
if (isset($statusInfo['valid_to'])) {
|
||||
$response['valid_to'] = $statusInfo['valid_to'];
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function isInvalidAt(int $serialNumber, ?DateTime $checkDate = null): bool {
|
||||
return $this->crlMapper->isInvalidAt($serialNumber, $checkDate);
|
||||
}
|
||||
|
||||
public function getRevokedCertificates(): array {
|
||||
$certificates = $this->crlMapper->getRevokedCertificates();
|
||||
|
||||
$result = [];
|
||||
foreach ($certificates as $certificate) {
|
||||
$result[] = [
|
||||
'serial_number' => $certificate->getSerialNumber(),
|
||||
'owner' => $certificate->getOwner(),
|
||||
'reason_code' => $certificate->getReasonCode(),
|
||||
'reason_description' => $certificate->getReasonCode() ? CRLReason::from($certificate->getReasonCode())->getDescription() : null,
|
||||
'revoked_by' => $certificate->getRevokedBy(),
|
||||
'revoked_at' => $certificate->getRevokedAt()?->format('Y-m-d H:i:s'),
|
||||
'invalidity_date' => $certificate->getInvalidityDate()?->format('Y-m-d H:i:s'),
|
||||
'crl_number' => $certificate->getCrlNumber(),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getNextCrlNumber(): int {
|
||||
return $this->crlMapper->getNextCrlNumber();
|
||||
}
|
||||
|
||||
public function cleanupExpiredCertificates(?DateTime $before = null): int {
|
||||
return $this->crlMapper->cleanupExpiredCertificates($before);
|
||||
}
|
||||
|
||||
public function getStatistics(): array {
|
||||
return $this->crlMapper->getStatistics();
|
||||
}
|
||||
|
||||
public function getRevocationStatistics(): array {
|
||||
return $this->crlMapper->getRevocationStatistics();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function generateCrlDer(): string {
|
||||
try {
|
||||
$revokedCertificates = $this->crlMapper->getRevokedCertificates();
|
||||
|
||||
$engine = $this->certificateEngineFactory->getEngine();
|
||||
|
||||
if (method_exists($engine, 'generateCrlDer')) {
|
||||
return $engine->generateCrlDer($revokedCertificates);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Current certificate engine does not support CRL generation');
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to generate CRL', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
53
lib/Service/SerialNumberService.php
Normal file
53
lib/Service/SerialNumberService.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Service;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Libresign\Db\CrlMapper;
|
||||
use OCP\DB\Exception as DBException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SerialNumberService {
|
||||
private const MAX_RETRY_ATTEMPTS = 10;
|
||||
private const MIN_SERIAL = 1000000;
|
||||
private const MAX_32BIT_SERIAL = 2147483647;
|
||||
|
||||
public function __construct(
|
||||
private CrlMapper $crlMapper,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function generateUniqueSerial(?string $certificateOwner = null, ?DateTime $expiresAt = null): int {
|
||||
for ($attempts = 0; $attempts < self::MAX_RETRY_ATTEMPTS; $attempts++) {
|
||||
$serial = random_int(self::MIN_SERIAL, self::MAX_32BIT_SERIAL);
|
||||
|
||||
try {
|
||||
$this->crlMapper->createCertificate(
|
||||
$serial,
|
||||
$certificateOwner ?? 'Unknown',
|
||||
new DateTime(),
|
||||
$expiresAt
|
||||
);
|
||||
return $serial;
|
||||
|
||||
} catch (DBException $e) {
|
||||
if ($e->getReason() === DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Failed to generate unique serial number after ' . self::MAX_RETRY_ATTEMPTS . ' attempts'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1168,6 +1168,138 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/index.php/apps/libresign/crl": {
|
||||
"get": {
|
||||
"operationId": "crl-get-revocation-list",
|
||||
"summary": "Get Certificate Revocation List in DER format (RFC 5280 compliant)",
|
||||
"tags": [
|
||||
"crl"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "CRL retrieved successfully in DER format",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to generate CRL",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/index.php/apps/libresign/crl/check/{serialNumber}": {
|
||||
"get": {
|
||||
"operationId": "crl-check-certificate-status",
|
||||
"summary": "Check certificate revocation status",
|
||||
"tags": [
|
||||
"crl"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "serialNumber",
|
||||
"in": "path",
|
||||
"description": "Certificate serial number to check",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Certificate status retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"serial_number",
|
||||
"status",
|
||||
"checked_at"
|
||||
],
|
||||
"properties": {
|
||||
"serial_number": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid serial number format",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/index.php/apps/libresign/develop/pdf": {
|
||||
"get": {
|
||||
"operationId": "develop-pdf",
|
||||
|
|
|
|||
132
openapi.json
132
openapi.json
|
|
@ -1018,6 +1018,138 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/index.php/apps/libresign/crl": {
|
||||
"get": {
|
||||
"operationId": "crl-get-revocation-list",
|
||||
"summary": "Get Certificate Revocation List in DER format (RFC 5280 compliant)",
|
||||
"tags": [
|
||||
"crl"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "CRL retrieved successfully in DER format",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to generate CRL",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/index.php/apps/libresign/crl/check/{serialNumber}": {
|
||||
"get": {
|
||||
"operationId": "crl-check-certificate-status",
|
||||
"summary": "Check certificate revocation status",
|
||||
"tags": [
|
||||
"crl"
|
||||
],
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "serialNumber",
|
||||
"in": "path",
|
||||
"description": "Certificate serial number to check",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Certificate status retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"serial_number",
|
||||
"status",
|
||||
"checked_at"
|
||||
],
|
||||
"properties": {
|
||||
"serial_number": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"checked_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid serial number format",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/index.php/apps/libresign/develop/pdf": {
|
||||
"get": {
|
||||
"operationId": "develop-pdf",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,40 @@ export type paths = {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/index.php/apps/libresign/crl": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Certificate Revocation List in DER format (RFC 5280 compliant) */
|
||||
get: operations["crl-get-revocation-list"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/index.php/apps/libresign/crl/check/{serialNumber}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Check certificate revocation status */
|
||||
get: operations["crl-check-certificate-status"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/index.php/apps/libresign/develop/pdf": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1617,6 +1651,77 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
"crl-get-revocation-list": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description CRL retrieved successfully in DER format */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
/** @description Failed to generate CRL */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"crl-check-certificate-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Certificate serial number to check */
|
||||
serialNumber: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Certificate status retrieved successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
serial_number: string;
|
||||
status: string;
|
||||
checked_at: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invalid serial number format */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"develop-pdf": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,40 @@ export type paths = {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/index.php/apps/libresign/crl": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Certificate Revocation List in DER format (RFC 5280 compliant) */
|
||||
get: operations["crl-get-revocation-list"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/index.php/apps/libresign/crl/check/{serialNumber}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Check certificate revocation status */
|
||||
get: operations["crl-check-certificate-status"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/index.php/apps/libresign/develop/pdf": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1266,6 +1300,77 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
"crl-get-revocation-list": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description CRL retrieved successfully in DER format */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
/** @description Failed to generate CRL */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"crl-check-certificate-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Certificate serial number to check */
|
||||
serialNumber: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Certificate status retrieved successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
serial_number: string;
|
||||
status: string;
|
||||
checked_at: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invalid serial number format */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
error: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"develop-pdf": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -57,10 +57,11 @@ class ApiTestCase extends TestCase {
|
|||
$this->request = new \OCA\Libresign\Tests\Api\ApiRequester();
|
||||
}
|
||||
|
||||
private function removeBasePath(array $data): array {
|
||||
protected function removeBasePath(array $data): array {
|
||||
$cleaned = [];
|
||||
foreach ($data['paths'] as $key => $value) {
|
||||
$key = preg_replace('~^/ocs/v2.php/apps/libresign~', '', (string)$key);
|
||||
$key = preg_replace('~^/index.php/apps/libresign~', '', (string)$key);
|
||||
$cleaned[$key] = $value;
|
||||
}
|
||||
$data['paths'] = $cleaned;
|
||||
|
|
|
|||
103
tests/php/Api/Controller/CrlControllerTest.php
Normal file
103
tests/php/Api/Controller/CrlControllerTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Libresign\Tests\Api\Controller;
|
||||
|
||||
use ByJG\ApiTools\OpenApi\OpenApiSchema;
|
||||
use OCA\Libresign\AppInfo\Application;
|
||||
use OCA\Libresign\Tests\Api\ApiTestCase;
|
||||
|
||||
/**
|
||||
* @group DB
|
||||
*/
|
||||
class CrlControllerTest extends ApiTestCase {
|
||||
private const VALID_CERT_SERIAL = '123456';
|
||||
|
||||
public function setUp(): void {
|
||||
$data = json_decode(file_get_contents('openapi-full.json'), true);
|
||||
$data['servers'][] = ['url' => '/index.php/apps/libresign'];
|
||||
$data = $this->removeBasePath($data);
|
||||
/** @var OpenApiSchema */
|
||||
$schema = \ByJG\ApiTools\Base\Schema::getInstance($data);
|
||||
$this->setSchema($schema);
|
||||
|
||||
// Optmize loading time
|
||||
$systemConfig = \OCP\Server::get(\OC\SystemConfig::class);
|
||||
$systemConfig->setValue('auth.bruteforce.protection.enabled', false);
|
||||
|
||||
// Reset settings
|
||||
$this->getMockAppConfig()->setValueBool(Application::APP_ID, 'identification_documents', false);
|
||||
|
||||
$this->request = new \OCA\Libresign\Tests\Api\ApiRequester();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testGetRevocationListReturnsValidResponse(): void {
|
||||
$this->request
|
||||
->withMethod('GET')
|
||||
->withPath('/crl');
|
||||
|
||||
$this->assertRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testCheckCertificateStatusWithValidSerial(): void {
|
||||
$this->request
|
||||
->withMethod('GET')
|
||||
->withPath('/crl/check/' . self::VALID_CERT_SERIAL);
|
||||
|
||||
$this->assertRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testCheckCertificateStatusWithInvalidSerial(): void {
|
||||
$this->request
|
||||
->withMethod('GET')
|
||||
->withPath('/crl/check/invalid')
|
||||
->assertResponseCode(400);
|
||||
|
||||
$this->assertRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testCheckCertificateStatusWithNegativeSerial(): void {
|
||||
$this->request
|
||||
->withMethod('GET')
|
||||
->withPath('/crl/check/-123')
|
||||
->assertResponseCode(400);
|
||||
|
||||
$this->assertRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testCheckCertificateStatusWithZeroSerial(): void {
|
||||
$this->request
|
||||
->withMethod('GET')
|
||||
->withPath('/crl/check/0')
|
||||
->assertResponseCode(400);
|
||||
|
||||
$this->assertRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testCrlEndpointsArePublic(): void {
|
||||
$this->request
|
||||
->withMethod('GET')
|
||||
->withPath('/crl');
|
||||
|
||||
$this->assertRequest();
|
||||
}
|
||||
}
|
||||
53
tests/php/Unit/Db/CrlMapperTest.php
Normal file
53
tests/php/Unit/Db/CrlMapperTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Tests\Unit\Db;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Libresign\Db\Crl;
|
||||
use OCA\Libresign\Enum\CRLReason;
|
||||
use OCA\Libresign\Enum\CRLStatus;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CrlMapperTest extends TestCase {
|
||||
public function testEntityBasicFunctionality(): void {
|
||||
$certificate = new Crl();
|
||||
$certificate->setSerialNumber(123456);
|
||||
$certificate->setOwner('test-owner');
|
||||
$certificate->setStatus(CRLStatus::ISSUED);
|
||||
$certificate->setIssuedAt(new DateTime());
|
||||
|
||||
$this->assertEquals(123456, $certificate->getSerialNumber());
|
||||
$this->assertEquals('test-owner', $certificate->getOwner());
|
||||
$this->assertEquals(CRLStatus::ISSUED, $certificate->getStatus());
|
||||
$this->assertFalse($certificate->isRevoked());
|
||||
$this->assertTrue($certificate->isValid());
|
||||
}
|
||||
|
||||
public function testCrlRevocation(): void {
|
||||
$certificate = new Crl();
|
||||
$certificate->setStatus(CRLStatus::REVOKED);
|
||||
$certificate->setReasonCode(CRLReason::KEY_COMPROMISE->value);
|
||||
$certificate->setRevokedAt(new DateTime());
|
||||
|
||||
$this->assertTrue($certificate->isRevoked());
|
||||
$this->assertFalse($certificate->isValid());
|
||||
$this->assertEquals(CRLReason::KEY_COMPROMISE->value, $certificate->getReasonCode());
|
||||
}
|
||||
|
||||
public function testCrlExpiration(): void {
|
||||
$certificate = new Crl();
|
||||
$certificate->setStatus(CRLStatus::ISSUED);
|
||||
$certificate->setValidTo(new DateTime('-1 day')); // Expired yesterday
|
||||
|
||||
$this->assertFalse($certificate->isRevoked());
|
||||
$this->assertTrue($certificate->isExpired());
|
||||
$this->assertFalse($certificate->isValid());
|
||||
}
|
||||
}
|
||||
|
|
@ -11,11 +11,13 @@ use OCA\Libresign\Exception\InvalidPasswordException;
|
|||
use OCA\Libresign\Exception\LibresignException;
|
||||
use OCA\Libresign\Handler\CertificateEngine\OpenSslHandler;
|
||||
use OCA\Libresign\Service\CertificatePolicyService;
|
||||
use OCA\Libresign\Service\SerialNumberService;
|
||||
use OCP\Files\AppData\IAppDataFactory;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDateTimeFormatter;
|
||||
use OCP\ITempManager;
|
||||
use OCP\IURLGenerator;
|
||||
|
||||
final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
||||
private IConfig $config;
|
||||
|
|
@ -25,6 +27,8 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
private ITempManager $tempManager;
|
||||
private OpenSslHandler $openSslHandler;
|
||||
protected CertificatePolicyService $certificatePolicyService;
|
||||
private SerialNumberService $serialNumberService;
|
||||
private IURLGenerator $urlGenerator;
|
||||
private string $tempDir;
|
||||
public function setUp(): void {
|
||||
$this->config = \OCP\Server::get(IConfig::class);
|
||||
|
|
@ -32,21 +36,23 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
$this->appDataFactory = \OCP\Server::get(IAppDataFactory::class);
|
||||
$this->dateTimeFormatter = \OCP\Server::get(IDateTimeFormatter::class);
|
||||
$this->tempManager = \OCP\Server::get(ITempManager::class);
|
||||
$this->certificatePolicyService = \OCP\Server::get(certificatePolicyService::class);
|
||||
$this->certificatePolicyService = \OCP\Server::get(CertificatePolicyService::class);
|
||||
$this->serialNumberService = \OCP\Server::get(SerialNumberService::class);
|
||||
$this->urlGenerator = \OCP\Server::get(IURLGenerator::class);
|
||||
$this->tempDir = $this->tempManager->getTemporaryFolder('certificate');
|
||||
}
|
||||
|
||||
private function getInstance(): OpenSslHandler {
|
||||
$this->openSslHandler = new OpenSslHandler(
|
||||
private function getInstance() {
|
||||
return new OpenSslHandler(
|
||||
$this->config,
|
||||
$this->appConfig,
|
||||
$this->appDataFactory,
|
||||
$this->dateTimeFormatter,
|
||||
$this->tempManager,
|
||||
$this->certificatePolicyService,
|
||||
$this->urlGenerator,
|
||||
$this->serialNumberService,
|
||||
);
|
||||
$this->openSslHandler->setConfigPath($this->tempDir);
|
||||
return $this->openSslHandler;
|
||||
}
|
||||
|
||||
public function testEmptyCertificate(): void {
|
||||
|
|
@ -60,10 +66,11 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
public function testInvalidPassword(): void {
|
||||
// Create root cert
|
||||
$rootInstance = $this->getInstance();
|
||||
$rootInstance->generateRootCert('', []);
|
||||
$rootInstance->generateRootCert('Test Root CA', []);
|
||||
|
||||
// Create signer cert
|
||||
$signerInstance = $this->getInstance();
|
||||
$signerInstance->setCommonName('Test User');
|
||||
$signerInstance->setHosts(['user@email.tld']);
|
||||
$signerInstance->setPassword('right password');
|
||||
$certificateContent = $signerInstance->generateCertificate();
|
||||
|
|
@ -76,7 +83,7 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
public function testMaxLengthOfDistinguishedNamesWithSuccess(): void {
|
||||
// Create root cert
|
||||
$rootInstance = $this->getInstance();
|
||||
$rootInstance->generateRootCert('', []);
|
||||
$rootInstance->generateRootCert('Test Root CA', []);
|
||||
|
||||
// Create signer cert
|
||||
$signerInstance = $this->getInstance();
|
||||
|
|
@ -91,7 +98,7 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
public function testBiggerThanMaxLengthOfDistinguishedNamesWithError(): void {
|
||||
// Create root cert
|
||||
$rootInstance = $this->getInstance();
|
||||
$rootInstance->generateRootCert('', []);
|
||||
$rootInstance->generateRootCert('Test Root CA', []);
|
||||
|
||||
// Create signer cert
|
||||
$signerInstance = $this->getInstance();
|
||||
|
|
@ -139,6 +146,9 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
$signerInstance->setFriendlyName($signerName);
|
||||
if (isset($csrNames['CN'])) {
|
||||
$signerInstance->setCommonName($csrNames['CN']);
|
||||
} else {
|
||||
$signerInstance->setCommonName($signerName);
|
||||
$csrNames['CN'] = $signerName; // Add to expected values for comparison
|
||||
}
|
||||
if (isset($csrNames['C'])) {
|
||||
$signerInstance->setCountry($csrNames['C']);
|
||||
|
|
@ -248,7 +258,7 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
|
||||
public function testSerialNumberGeneration(): void {
|
||||
$rootInstance = $this->getInstance();
|
||||
$rootInstance->generateRootCert('', []);
|
||||
$rootInstance->generateRootCert('Test Root CA', []);
|
||||
|
||||
$signerInstance = $this->getInstance();
|
||||
$signerInstance->setCommonName('Test User');
|
||||
|
|
@ -275,7 +285,7 @@ final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|||
|
||||
public function testUniqueSerialNumbers(): void {
|
||||
$rootInstance = $this->getInstance();
|
||||
$rootInstance->generateRootCert('', []);
|
||||
$rootInstance->generateRootCert('Test Root CA', []);
|
||||
|
||||
$serialNumbers = [];
|
||||
$numCertificates = 3;
|
||||
|
|
|
|||
233
tests/php/Unit/Service/CrlServiceTest.php
Normal file
233
tests/php/Unit/Service/CrlServiceTest.php
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Libresign\Tests\Unit\Service;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Libresign\Db\Crl;
|
||||
use OCA\Libresign\Db\CrlMapper;
|
||||
use OCA\Libresign\Enum\CRLReason;
|
||||
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
|
||||
use OCA\Libresign\Handler\CertificateEngine\IEngineHandler;
|
||||
use OCA\Libresign\Service\CrlService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class CrlServiceTest extends TestCase {
|
||||
private CrlService $service;
|
||||
private CrlMapper&MockObject $crlMapper;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private CertificateEngineFactory&MockObject $certificateEngineFactory;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->crlMapper = $this->createMock(CrlMapper::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->certificateEngineFactory = $this->createMock(CertificateEngineFactory::class);
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$this->service = new CrlService($this->crlMapper, $this->logger, $this->certificateEngineFactory);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function testRevokeCertificateWithValidReasonCode(): void {
|
||||
$serialNumber = 123456;
|
||||
$reasonCode = 1; // keyCompromise
|
||||
$reasonText = 'Certificate compromised';
|
||||
$revokedBy = 'admin';
|
||||
|
||||
// Mock the dependencies for successful revocation
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('getNextCrlNumber')
|
||||
->willReturn(5);
|
||||
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('revokeCertificate')
|
||||
->with(
|
||||
123456,
|
||||
CRLReason::KEY_COMPROMISE,
|
||||
$reasonText,
|
||||
$revokedBy,
|
||||
null,
|
||||
5
|
||||
);
|
||||
|
||||
$result = $this->service->revokeCertificate($serialNumber, $reasonCode, $reasonText, $revokedBy);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testRevokeCertificateWithInvalidReasonCode(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid CRLReason code: 99');
|
||||
|
||||
$this->service->revokeCertificate(123456, 99, 'Test', 'admin');
|
||||
}
|
||||
|
||||
public function testGenerateCrlDerReturnsValidBinaryData(): void {
|
||||
// Mock revoked certificates data
|
||||
$revokedCertificates = [
|
||||
$this->createRevokedCertificateMock(123456, 'user1@example.com', 1),
|
||||
$this->createRevokedCertificateMock(789012, 'user2@example.com', 2),
|
||||
];
|
||||
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('getRevokedCertificates')
|
||||
->willReturn($revokedCertificates);
|
||||
|
||||
// Mock the certificate engine
|
||||
$mockEngine = $this->createMock(IEngineHandler::class);
|
||||
$mockCrlDer = "\x30\x82\x01\x00"; // Valid DER sequence
|
||||
$mockEngine->expects($this->once())
|
||||
->method('generateCrlDer')
|
||||
->with($revokedCertificates)
|
||||
->willReturn($mockCrlDer);
|
||||
|
||||
$this->certificateEngineFactory->expects($this->once())
|
||||
->method('getEngine')
|
||||
->willReturn($mockEngine);
|
||||
|
||||
$result = $this->service->generateCrlDer();
|
||||
|
||||
$this->assertIsString($result);
|
||||
$this->assertNotEmpty($result);
|
||||
// Basic DER structure should start with SEQUENCE tag (0x30)
|
||||
$this->assertEquals(0x30, ord($result[0]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function testRevokeCertificateSuccess(): void {
|
||||
$serialNumber = 123456;
|
||||
$reasonCode = 1; // keyCompromise
|
||||
$reasonText = 'Test revocation';
|
||||
$revokedBy = 'admin';
|
||||
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('getNextCrlNumber')
|
||||
->willReturn(5);
|
||||
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('revokeCertificate')
|
||||
->with(
|
||||
123456,
|
||||
CRLReason::KEY_COMPROMISE,
|
||||
$reasonText,
|
||||
$revokedBy,
|
||||
null,
|
||||
5
|
||||
);
|
||||
|
||||
$result = $this->service->revokeCertificate($serialNumber, $reasonCode, $reasonText, $revokedBy);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testRevokeCertificateInvalidReasonCode(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid CRLReason code: 99');
|
||||
|
||||
$this->service->revokeCertificate(123456, 99, 'Test', 'admin');
|
||||
}
|
||||
|
||||
public function testGetRevokedCertificatesReturnsFormattedArray(): void {
|
||||
$mock1 = $this->createRevokedCertificateMock(123456, 'user1@example.com', 1);
|
||||
$mock2 = $this->createRevokedCertificateMock(789012, 'user2@example.com', 2);
|
||||
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('getRevokedCertificates')
|
||||
->willReturn([$mock1, $mock2]);
|
||||
|
||||
$result = $this->service->getRevokedCertificates();
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertCount(2, $result);
|
||||
|
||||
// Test first certificate
|
||||
$this->assertArrayHasKey('serial_number', $result[0]);
|
||||
$this->assertArrayHasKey('owner', $result[0]);
|
||||
$this->assertArrayHasKey('reason_code', $result[0]);
|
||||
$this->assertEquals(123456, $result[0]['serial_number']);
|
||||
$this->assertEquals('user1@example.com', $result[0]['owner']);
|
||||
$this->assertEquals(1, $result[0]['reason_code']);
|
||||
}
|
||||
|
||||
public function testGetStatisticsReturnsMapperData(): void {
|
||||
$expectedStats = [
|
||||
'total' => 100,
|
||||
'active' => 85,
|
||||
'revoked' => 10,
|
||||
'expired' => 5,
|
||||
];
|
||||
|
||||
$this->crlMapper->expects($this->once())
|
||||
->method('getStatistics')
|
||||
->willReturn($expectedStats);
|
||||
|
||||
$result = $this->service->getStatistics();
|
||||
|
||||
$this->assertEquals($expectedStats, $result);
|
||||
}
|
||||
|
||||
private function createMockEntity(int $serialNumber, string $owner): MockObject {
|
||||
$entity = $this->createMock(Crl::class);
|
||||
$entity->method('isRevoked')->willReturn(false);
|
||||
$entity->method('isExpired')->willReturn(false);
|
||||
$entity->method('isValid')->willReturn(true);
|
||||
$entity->method('getStatus')->willReturn(\OCA\Libresign\Enum\CRLStatus::ISSUED);
|
||||
return $entity;
|
||||
}
|
||||
|
||||
private function createMockRevokedEntity(int $serialNumber, string $owner, int $reasonCode): array {
|
||||
return [
|
||||
'serial_number' => $serialNumber,
|
||||
'owner' => $owner,
|
||||
'reason_code' => $reasonCode,
|
||||
'revoked_at' => '2025-01-17 10:00:00',
|
||||
'revoked_by' => 'admin',
|
||||
'crl_number' => 1,
|
||||
'invalidity_date' => null,
|
||||
'reason_description' => $this->getReasonDescription($reasonCode),
|
||||
];
|
||||
}
|
||||
|
||||
private function createRevokedCertificateMock(int $serialNumber, string $owner, int $reasonCode): MockObject {
|
||||
$mock = $this->getMockBuilder(Crl::class)
|
||||
->disableOriginalConstructor()
|
||||
->addMethods(['getSerialNumber', 'getOwner', 'getReasonCode', 'getRevokedBy', 'getRevokedAt', 'getInvalidityDate', 'getCrlNumber'])
|
||||
->getMock();
|
||||
|
||||
$mock->method('getSerialNumber')->willReturn($serialNumber);
|
||||
$mock->method('getOwner')->willReturn($owner);
|
||||
$mock->method('getReasonCode')->willReturn($reasonCode);
|
||||
$mock->method('getRevokedBy')->willReturn('admin');
|
||||
$mock->method('getRevokedAt')->willReturn(new DateTime('2025-01-17 10:00:00'));
|
||||
$mock->method('getInvalidityDate')->willReturn(null);
|
||||
$mock->method('getCrlNumber')->willReturn(1);
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
private function getReasonDescription(int $reasonCode): string {
|
||||
return match($reasonCode) {
|
||||
0 => 'unspecified',
|
||||
1 => 'keyCompromise',
|
||||
2 => 'caCompromise',
|
||||
3 => 'affiliationChanged',
|
||||
4 => 'superseded',
|
||||
5 => 'cessationOfOperation',
|
||||
6 => 'certificateHold',
|
||||
8 => 'removeFromCRL',
|
||||
9 => 'privilegeWithdrawn',
|
||||
10 => 'aACompromise',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue