Skip to content

Commit

Permalink
feat: add extracerts to generated cert
Browse files Browse the repository at this point in the history
Signed-off-by: Vitor Mattos <[email protected]>
  • Loading branch information
vitormattos committed Jan 21, 2025
1 parent d7ef072 commit 86eda40
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 46 deletions.
4 changes: 4 additions & 0 deletions lib/Handler/CertificateEngine/CfsslHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public function generateCertificate(): string {
$certKeys['private_key'],
[
'friendly_name' => $this->getFriendlyName(),
'extracerts' => [
$certKeys['certificate'],
$certKeys['certificate_request'],
],
],
);
}
Expand Down
49 changes: 37 additions & 12 deletions lib/Handler/CertificateEngine/OpenSslHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,23 @@ public function generateCertificate(): string {
]);
$temporaryFile = $this->tempManager->getTemporaryFile('.cfg');
// More information about x509v3: https://www.openssl.org/docs/manmaster/man5/x509v3_config.html
file_put_contents($temporaryFile, <<<CONFIG
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment, keyCertSign
extendedKeyUsage = clientAuth, emailProtection
subjectAltName = {$this->getSubjectAltNames()}
authorityKeyIdentifier = keyid
subjectKeyIdentifier = hash
# certificatePolicies = <policyOID> CPS: http://url/with/policy/informations.pdf
CONFIG);
$config = [
'v3_req' => [
'basicConstraints' => 'CA:FALSE',
'keyUsage' => 'digitalSignature, keyEncipherment, keyCertSign',
'extendedKeyUsage' => 'clientAuth, emailProtection',
'subjectAltName' => $this->getSubjectAltNames(),
'authorityKeyIdentifier' => 'keyid',
'subjectKeyIdentifier' => 'hash',
// @todo Implement a feature to define this PDF at Administration Settings
// 'certificatePolicies' => '<policyOID> CPS: http://url/with/policy/informations.pdf',
]
];
if (empty($config['v3_req']['subjectAltName'])) {
unset($config['v3_req']['subjectAltName']);
}
$config = $this->arrayToIni($config);
file_put_contents($temporaryFile, $config);
$csr = openssl_csr_new($this->getCsrNames(), $privateKey);
$x509 = openssl_csr_sign($csr, $rootCertificate, $rootPrivateKey, $this->expirity(), [
'config' => $temporaryFile,
Expand All @@ -93,15 +100,33 @@ public function generateCertificate(): string {
$privateKey,
[
'friendly_name' => $this->getFriendlyName(),
'extracerts' => [
$x509,
$rootCertificate,
],
],
);
}

private function arrayToIni(array $config) {
$fileContent = '';
foreach ($config as $i => $v) {
if (is_array($v)) {
$fileContent .= "\n[$i]\n" . $this->arrayToIni($v);
} else {
$fileContent .= "$i = " . (str_contains($v, "\n") ? '"' . $v . '"' : $v) . "\n";
}
}
return $fileContent;
}

private function getSubjectAltNames(): string {
$hosts = $this->getHosts();
$altNames = [];
foreach ($hosts as $email) {
$altNames[] = 'email:' . $email;
foreach ($hosts as $host) {
if (filter_var($host, FILTER_VALIDATE_EMAIL)) {
$altNames[] = 'email:' . $host;
}
}
return implode(', ', $altNames);
}
Expand Down
74 changes: 55 additions & 19 deletions lib/Handler/CertificateEngine/OrderCertificatesTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,73 @@

namespace OCA\Libresign\Handler\CertificateEngine;

use InvalidArgumentException;

trait OrderCertificatesTrait {
public function orderCertificates(array $certificates): array {
$ordered = [];
$map = [];
$this->validateCertificateStructure($certificates);
$remainingCerts = [];

$tree = current($certificates);
// Get the root certificate.
$rootCert = null;
foreach ($certificates as $cert) {
if ($tree['subject'] === $cert['issuer']) {
$tree = $cert;
if (!$this->arrayDiffCanonicalized($cert['subject'], $cert['issuer'])) {
$rootCert = $cert;
}
$map[$cert['name']] = $cert;
$remainingCerts[$cert['name']] = $cert;
}

if (!$tree) {
if ($rootCert) {
unset($remainingCerts[$rootCert['name']]);
$ordered = [$rootCert];
} else {
return $certificates;
}
unset($map[$tree['name']]);
$ordered[] = $tree;

$current = $tree;
while (!empty($map) && $current) {
if ($current['subject'] === $tree['issuer']) {
$ordered[] = $current;
$tree = $current;
unset($map[$current['name']]);
$current = reset($map);
continue;


while (!empty($remainingCerts)) {
$found = false;
foreach ($remainingCerts as $name => $cert) {
$last = end($ordered);
if (!$this->arrayDiffCanonicalized($last['subject'], $cert['issuer'])) {
$ordered[] = $cert;
unset($remainingCerts[$name]);
$found = true;
break;
}
}

if (!$found) {
throw new InvalidArgumentException('Certificate chain is incomplete or invalid. Certificates: ' . json_encode($certificates));
}
$current = next($map);
}

return $ordered;
}

private function validateCertificateStructure(array $certificates): void {
if (empty($certificates)) {
throw new InvalidArgumentException('Certificate list cannot be empty');
}

foreach ($certificates as $cert) {
if (!isset($cert['subject'], $cert['issuer'], $cert['name'])) {
throw new InvalidArgumentException(
'Invalid certificate structure. Certificate must have "subject", "issuer", and "name".'
);
}
}

$names = array_column($certificates, 'name');
if (count($names) !== count(array_unique($names))) {
throw new InvalidArgumentException('Duplicate certificate names detected');
}
}

private function arrayDiffCanonicalized(array $array1, array $array2): array {
sort($array1);
sort($array2);

return array_diff($array1, $array2);
}
}
134 changes: 121 additions & 13 deletions tests/Unit/Handler/OpenSslHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/

use bovigo\vfs\vfsStream;
use OCA\Libresign\Exception\EmptyCertificateException;
use OCA\Libresign\Exception\InvalidPasswordException;
use OCA\Libresign\Handler\CertificateEngine\OpenSslHandler;
use OCP\Files\AppData\IAppDataFactory;
use OCP\IAppConfig;
Expand All @@ -27,48 +29,123 @@ public function setUp(): void {
$this->appDataFactory = \OCP\Server::get(IAppDataFactory::class);
$this->dateTimeFormatter = \OCP\Server::get(IDateTimeFormatter::class);
$this->tempManager = \OCP\Server::get(ITempManager::class);

// The storage can't be modified when create a new instance to
// don't lost the root cert
vfsStream::setup('certificate');
}

private function getInstance(): OpenSslHandler {
$this->openSslHandler = new OpenSslHandler(
$this->config,
$this->appConfig,
$this->appDataFactory,
$this->dateTimeFormatter,
$this->tempManager,
);
vfsStream::setup('certificate');
$this->openSslHandler->setConfigPath('vfs://certificate/');
return $this->openSslHandler;
}

public function testEmptyCertificate(): void {
$signerInstance = $this->getInstance();

// Test invalid password
$this->expectException(EmptyCertificateException::class);
$signerInstance->readCertificate('', '');
}

public function testInvalidPassword(): void {
// Create root cert
$rootInstance = $this->getInstance();
$rootInstance->generateRootCert('', []);

// Create signer cert
$signerInstance = $this->getInstance();
$signerInstance->setHosts(['[email protected]']);
$signerInstance->setPassword('right password');
$certificateContent = $signerInstance->generateCertificate();

// Test invalid password
$this->expectException(InvalidPasswordException::class);
$signerInstance->readCertificate($certificateContent, 'invalid password');
}

/**
* @dataProvider dataReadCertificate
*/
public function testReadCertificate(string $commonName, string $signerName, array $hosts, string $password, array $csrNames): void {
public function testReadCertificate(
string $commonName,
string $signerName,
array $hosts,
string $password,
array $csrNames,
array $root,
): void {
// Create root cert
$rootInstance = $this->getInstance();
if (isset($root['CN'])) {
$rootInstance->setCommonName($root['CN']);
}
if (isset($root['C'])) {
$rootInstance->setCountry($root['C']);
}
if (isset($root['ST'])) {
$rootInstance->setState($root['ST']);
}
if (isset($root['O'])) {
$rootInstance->setOrganization($root['O']);
}
if (isset($root['OU'])) {
$rootInstance->setOrganizationalUnit($root['OU']);
}
$rootInstance->generateRootCert($commonName, $root);

// Create signer cert
$signerInstance = $this->getInstance();
$signerInstance->setHosts($hosts);
$signerInstance->setPassword($password);
$signerInstance->setFriendlyName($signerName);
if (isset($csrNames['CN'])) {
$signerInstance->setCommonName($csrNames['CN']);
}
if (isset($csrNames['C'])) {
$this->openSslHandler->setCountry($csrNames['C']);
$signerInstance->setCountry($csrNames['C']);
}
if (isset($csrNames['ST'])) {
$this->openSslHandler->setState($csrNames['ST']);
$signerInstance->setState($csrNames['ST']);
}
if (isset($csrNames['O'])) {
$this->openSslHandler->setOrganization($csrNames['O']);
$signerInstance->setOrganization($csrNames['O']);
}
if (isset($csrNames['OU'])) {
$this->openSslHandler->setOrganizationalUnit($csrNames['OU']);
$signerInstance->setOrganizationalUnit($csrNames['OU']);
}
$this->openSslHandler->generateRootCert($commonName, $csrNames);
$certificateContent = $signerInstance->generateCertificate();

// Parse signer cert
$parsed = $signerInstance->readCertificate($certificateContent, $password);

$this->openSslHandler->setHosts($hosts);
$this->openSslHandler->setPassword($password);
$this->openSslHandler->setFriendlyName($signerName);
$certificateContent = $this->openSslHandler->generateCertificate();
$parsed = $this->openSslHandler->readCertificate($certificateContent, $password);
// Test total elements of extracerts
// The correct content is: cert signer, intermediate certs (if have), root cert
$this->assertArrayHasKey('extracerts', $parsed);
$this->assertCount(2, $parsed['extracerts']);

// Test name
$name = $this->csrArrayToString($csrNames);
$this->assertEquals($parsed['name'], $name);

$this->assertJsonStringEqualsJsonString(
json_encode($csrNames),
json_encode($parsed['subject'])
);

// Test subject
$this->assertEquals($csrNames, $parsed['subject']);

// Test issuer ony if was defined root distinguished names
if (count($root) === count($parsed['issuer'])) {
$this->assertEquals($root, $parsed['issuer']);
}
}

private function csrArrayToString(array $csr): string {
Expand All @@ -91,6 +168,28 @@ public static function dataReadCertificate(): array {
'ST' => 'Some-State',
'O' => 'Organization Name',
],
[],
],
[
'common name',
'Signer Name',
['account:test'],
'password',
[
'C' => 'CT',
'ST' => 'Some-State',
'O' => 'Organization Name',
'OU' => 'Organization Unit',
'CN' => 'Common Name',
],
[
'C' => 'RT',
'ST' => 'Root-State',
'O' => 'Root Organization Name',
'OU' => 'Root Organization Unit',
'CN' => 'Root Common Name',
'UID' => 'account:test'
],
],
[
'common name',
Expand All @@ -102,6 +201,15 @@ public static function dataReadCertificate(): array {
'ST' => 'Some-State',
'O' => 'Organization Name',
'OU' => 'Organization Unit',
'CN' => 'Common Name',
],
[
'C' => 'RT',
'ST' => 'Root-State',
'O' => 'Root Organization Name',
'OU' => 'Root Organization Unit',
'CN' => 'Root Common Name',
'UID' => 'email:[email protected]'
],
],
];
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/features/file/validate.feature
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Feature: validate
Then the response should be a JSON array with the following mandatory values
| key | value |
| (jq).ocs.data.signers[0].me | false |
| (jq).ocs.data.signers[0].uid | account:signer1 |
| (jq).ocs.data.signers[0].identifyMethods | [{"method": "account","value": "signer1","mandatory": 1}] |
| (jq).ocs.data.signers[0].subject | /C=BR/ST=State of Company/L=City Name/O=Organization/OU=Organization Unit/UID=account:signer1/CN=signer1-displayname |
| (jq).ocs.data.signers[0].signature_validation | {"id":1,"label":"Signature is valid."} |
| (jq).ocs.data.signers[0].signature_validation | {"id":1,"label":"Signature is valid."} |
| (jq).ocs.data.signers[0].hash_algorithm | RSA-SHA1 |

0 comments on commit 86eda40

Please sign in to comment.