feat: implement crl

Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
This commit is contained in:
Vitor Mattos 2025-10-20 23:16:37 -03:00
parent 81dee74894
commit 701fbb5e3c
No known key found for this signature in database
GPG key ID: 6FECE2AD4809003A
25 changed files with 2322 additions and 15 deletions

View file

@ -25,7 +25,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
* [Donate with GitHub Sponsor: ![Donate using GitHub Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](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
View 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
View 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
View 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;
}
}

View 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
View 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
View 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
View 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
View 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;
}
}

View file

@ -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());
}
}
}

View file

@ -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;
}

View file

@ -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');
}
}

View file

@ -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;
}
}

View 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
View 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;
}
}
}

View 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'
);
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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();
}
}

View 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());
}
}

View file

@ -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;

View 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',
};
}
}