diff --git a/app/Commands/Authority/Certificate/ImportCommand.php b/app/Commands/Authority/Certificate/ImportCommand.php index a14fa35..4e180f2 100644 --- a/app/Commands/Authority/Certificate/ImportCommand.php +++ b/app/Commands/Authority/Certificate/ImportCommand.php @@ -40,22 +40,25 @@ public function handle(): int try { $config = $this->getCaConfig(); } catch (\RuntimeException $e) { - error($e->getMessage()); + stdErr(fn () => error($e->getMessage())); + return self::FAILURE; } $ca = $config->database()->ca(); - if ($ca->metadata()?->certificate !== null && !$this->option('force')) { - error('A certificate already exists. Use --force to overwrite.'); + if ($ca->metadata()?->certificate !== null && ! $this->option('force')) { + stdErr(fn () => error('A certificate already exists. Use --force to overwrite.')); + return self::FAILURE; } $path = $this->argument('pem'); if ($path) { - if (!file_exists($path)) { - error("File not found: {$path}"); + if (! file_exists($path)) { + stdErr(fn () => error("File not found: {$path}")); + return self::FAILURE; } $pem = file_get_contents($path); @@ -63,8 +66,9 @@ public function handle(): int $pem = file_get_contents('php://stdin'); } - if (!$pem) { - error('No PEM data provided'); + if (! $pem) { + stdErr(fn () => error('No PEM data provided')); + return self::FAILURE; } @@ -73,12 +77,14 @@ public function handle(): int try { $cert = $x509->loadX509($pem); } catch (\Exception $e) { - error('Failed to load certificate: ' . $e->getMessage()); + stdErr(fn () => error('Failed to load certificate: '.$e->getMessage())); + return self::FAILURE; } if ($cert === false) { - error('Failed to parse certificate.'); + stdErr(fn () => error('Failed to parse certificate.')); + return self::FAILURE; } @@ -99,7 +105,8 @@ public function handle(): int $key = $config->database()->keys()->forFingerprint($fingerprint); if ($key === null) { - error("No matching key found in database for fingerprint [{$fingerprint}]."); + stdErr(fn () => error("No matching key found in database for fingerprint [{$fingerprint}].")); + return self::FAILURE; } diff --git a/app/Commands/Authority/Certificate/SelfSignedCertificate.php b/app/Commands/Authority/Certificate/SelfSignedCertificate.php index 0cbf02b..4b04fb9 100644 --- a/app/Commands/Authority/Certificate/SelfSignedCertificate.php +++ b/app/Commands/Authority/Certificate/SelfSignedCertificate.php @@ -7,8 +7,8 @@ use App\Storage\Entities\CaCertificateDetails; use App\Storage\Entities\CaMetadata; use App\Storage\Enums\CaFile; -use App\Support\CertificateFingerprint; use App\Storage\Enums\KeyFile; +use App\Support\CertificateFingerprint; use Carbon\CarbonImmutable; use Illuminate\Console\Scheduling\Schedule; use LaravelZero\Framework\Commands\Command; @@ -46,19 +46,22 @@ public function handle(): int $config = $this->getCaConfig(); } catch (\RuntimeException $e) { error($e->getMessage()); + return self::FAILURE; } $ca = $config->database()->ca(); - if ($ca->metadata()?->certificate !== null && !$this->option('force')) { + if ($ca->metadata()?->certificate !== null && ! $this->option('force')) { error('A certificate already exists. Use --force to overwrite.'); + return self::FAILURE; } $distinguished_name = $this->argument('distinguished_name'); if (! (new X509)->setDN($distinguished_name)) { error('Invalid distinguished name format.'); + return self::FAILURE; } @@ -66,10 +69,11 @@ public function handle(): int if ($serial_number !== null) { if (! ctype_xdigit($serial_number)) { error('Serial number must be a valid hexadecimal string.'); + return self::FAILURE; } - } else if($config->certificationAuthority->randomSerialNumbers) { - $serial_number = new BigInteger(Random::string(20) & ("\x7F" . str_repeat("\xFF", 19)), 256)->toHex(); + } elseif ($config->certificationAuthority->randomSerialNumbers) { + $serial_number = new BigInteger(Random::string(20) & ("\x7F".str_repeat("\xFF", 19)), 256)->toHex(); } else { $existingSerial = $ca->metadata()?->certificate?->serial_number; $serial_number = $existingSerial !== null @@ -84,13 +88,20 @@ public function handle(): int return self::FAILURE; } - $pem = $config->database()->keys()->getFile($key_id, KeyFile::PrivateKey); + $keyEntity = $config->database()->keys()->find($key_id); + if ($keyEntity !== null && ! $keyEntity->private) { + error("Key [{$key_id}] is a public-only key. Self-signed certificate creation requires a private key."); + return self::FAILURE; + } + + $pem = $config->database()->keys()->getFile($key_id, KeyFile::PrivateKey); try { $key = $this->loadPrivateKey($pem); } catch (\Exception $e) { - error('Failed to load private key: ' . $e->getMessage()); + error('Failed to load private key: '.$e->getMessage()); + return self::FAILURE; } @@ -99,6 +110,7 @@ public function handle(): int $path_length_constraint = (int) $path_length_constraint; if ($path_length_constraint < 0) { error('Path length constraint must be greater than or equal to zero.'); + return self::FAILURE; } } @@ -111,7 +123,7 @@ public function handle(): int $rootIssuer->setPrivateKey($key); $rootIssuer->setDN($rootSubject->getDN()); - $validFrom = new CarbonImmutable(); + $validFrom = new CarbonImmutable; $validTo = new CarbonImmutable($this->option('validity')); $x509 = new X509; diff --git a/app/Commands/Authority/Csr/CreateCommand.php b/app/Commands/Authority/Csr/CreateCommand.php index e77067d..c90a53b 100644 --- a/app/Commands/Authority/Csr/CreateCommand.php +++ b/app/Commands/Authority/Csr/CreateCommand.php @@ -40,25 +40,29 @@ public function handle(): int $config = $this->getCaConfig(); } catch (\RuntimeException $e) { error($e->getMessage()); + return self::FAILURE; } $ca = $config->database()->ca(); $hasCertificate = $ca->metadata()?->certificate !== null && $ca->hasFile(CaFile::Certificate); - if ($hasCertificate && !$this->option('ignore-existing-certificate')) { + if ($hasCertificate && ! $this->option('ignore-existing-certificate')) { error('A certificate already exists. Use --ignore-existing-certificate to create a CSR anyway.'); + return self::FAILURE; } - if ($ca->hasFile(CaFile::Csr) && !$this->option('force')) { + if ($ca->hasFile(CaFile::Csr) && ! $this->option('force')) { error('A CSR already exists. Use --force to overwrite.'); + return self::FAILURE; } $distinguished_name = $this->argument('distinguished_name'); if (! (new X509)->setDN($distinguished_name)) { error('Invalid distinguished name format.'); + return self::FAILURE; } @@ -69,12 +73,20 @@ public function handle(): int return self::FAILURE; } + $keyEntity = $config->database()->keys()->find($key_id); + if ($keyEntity !== null && ! $keyEntity->private) { + error("Key [{$key_id}] is a public-only key. CSR creation requires a private key."); + + return self::FAILURE; + } + $pem = $config->database()->keys()->getFile($key_id, KeyFile::PrivateKey); try { $key = $this->loadPrivateKey($pem); } catch (\Exception $e) { - error('Failed to load private key: ' . $e->getMessage()); + error('Failed to load private key: '.$e->getMessage()); + return self::FAILURE; } diff --git a/app/Commands/Certificate/ExistsCommand.php b/app/Commands/Certificate/ExistsCommand.php new file mode 100644 index 0000000..d91a2b2 --- /dev/null +++ b/app/Commands/Certificate/ExistsCommand.php @@ -0,0 +1,45 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + error($e->getMessage()); + + return self::FAILURE; + } + + $has = $config->database()->certificates()->exists($this->argument('id')); + + if ($has) { + info('Certificate exists'); + } else { + error('Certificate does not exist'); + } + + return $has ? self::SUCCESS : self::FAILURE; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Certificate/GetCommand.php b/app/Commands/Certificate/GetCommand.php new file mode 100644 index 0000000..4b49e1c --- /dev/null +++ b/app/Commands/Certificate/GetCommand.php @@ -0,0 +1,54 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + stdErr(fn () => error($e->getMessage())); + + return self::FAILURE; + } + + $id = $this->argument('id') ?? trim(file_get_contents('php://stdin')); + + if (! $id) { + stdErr(fn () => error('No certificate ID provided.')); + + return self::FAILURE; + } + $content = $config->database()->certificates()->getFile($id, CertificateFile::Certificate); + + if ($content === null) { + stdErr(fn () => error("Certificate [{$id}] not found.")); + + return self::FAILURE; + } + + $this->output->write($content); + + return self::SUCCESS; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Certificate/Issue/CsrCommand.php b/app/Commands/Certificate/Issue/CsrCommand.php new file mode 100644 index 0000000..512e831 --- /dev/null +++ b/app/Commands/Certificate/Issue/CsrCommand.php @@ -0,0 +1,114 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + stdErr(fn () => error($e->getMessage())); + + return self::FAILURE; + } + + $caMetadata = $config->database()->ca()->metadata(); + if ($caMetadata?->key_id === null) { + stdErr(fn () => error('CA key is not configured.')); + + return self::FAILURE; + } + + $privateKeyPem = $config->database()->keys()->getFile($caMetadata->key_id, KeyFile::PrivateKey); + if ($privateKeyPem === null) { + stdErr(fn () => error("CA private key [{$caMetadata->key_id}] not found.")); + + return self::FAILURE; + } + + try { + $issuerKey = stdErr(fn () => $this->loadPrivateKey($privateKeyPem)); + } catch (\Exception $e) { + stdErr(fn () => error('Failed to load CA private key: '.$e->getMessage())); + + return self::FAILURE; + } + + $path = $this->argument('pem'); + if ($path) { + if (! file_exists($path)) { + stdErr(fn () => error("File not found: {$path}")); + + return self::FAILURE; + } + $csrPem = file_get_contents($path); + } else { + $csrPem = file_get_contents('php://stdin'); + } + + if (! $csrPem) { + stdErr(fn () => error('No CSR PEM data provided.')); + + return self::FAILURE; + } + + $dnOverride = $this->option('dn'); + + if ($dnOverride === null && ! $this->option('force')) { + $x509 = new X509; + $x509->loadCSR($csrPem); + $csrDn = $x509->getDN(X509::DN_STRING); + + if (! stdErr(fn () => confirm("Issue certificate with DN: {$csrDn}?"))) { + stdErr(fn () => error('Aborted.')); + + return self::FAILURE; + } + } + + $service = new CertificateSigningService($config->database(), $config); + + try { + $certificate = $service->issueFromCsr( + templateName: $this->argument('template'), + csrPem: $csrPem, + issuerKey: $issuerKey, + distinguishedNameOverride: $dnOverride, + serialNumberOverride: $this->option('serial-number'), + ); + } catch (\RuntimeException $e) { + stdErr(fn () => error($e->getMessage())); + + return self::FAILURE; + } + + $this->output->writeln($certificate->id); + + return self::SUCCESS; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Certificate/Issue/KeyCommand.php b/app/Commands/Certificate/Issue/KeyCommand.php new file mode 100644 index 0000000..5860551 --- /dev/null +++ b/app/Commands/Certificate/Issue/KeyCommand.php @@ -0,0 +1,80 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + stdErr(fn () => error($e->getMessage())); + + return self::FAILURE; + } + + $caMetadata = $config->database()->ca()->metadata(); + if ($caMetadata?->key_id === null) { + stdErr(fn () => error('CA key is not configured.')); + + return self::FAILURE; + } + + $privateKeyPem = $config->database()->keys()->getFile($caMetadata->key_id, KeyFile::PrivateKey); + if ($privateKeyPem === null) { + stdErr(fn () => error("CA private key [{$caMetadata->key_id}] not found.")); + + return self::FAILURE; + } + + try { + $issuerKey = stdErr(fn () => $this->loadPrivateKey($privateKeyPem)); + } catch (\Exception $e) { + stdErr(fn () => error('Failed to load CA private key: '.$e->getMessage())); + + return self::FAILURE; + } + + $service = new CertificateSigningService($config->database(), $config); + + try { + $certificate = $service->issueFromKeyId( + templateName: $this->argument('template'), + keyId: $this->argument('key_id'), + issuerKey: $issuerKey, + distinguishedName: $this->argument('distinguished_name'), + serialNumberOverride: $this->option('serial-number'), + ); + } catch (\RuntimeException $e) { + stdErr(fn () => error($e->getMessage())); + + return self::FAILURE; + } + + $this->output->writeln($certificate->id); + + return self::SUCCESS; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Certificate/ListCommand.php b/app/Commands/Certificate/ListCommand.php new file mode 100644 index 0000000..3dcbb95 --- /dev/null +++ b/app/Commands/Certificate/ListCommand.php @@ -0,0 +1,59 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + error($e->getMessage()); + + return self::FAILURE; + } + + $certificates = $config->database()->certificates()->all(); + + if ($certificates->isEmpty()) { + error('No certificates found.'); + + return self::FAILURE; + } + + table( + headers: ['ID', 'Seq', 'Common Name', 'Type', 'Serial Number', 'Not Before', 'Not After', 'Revoked At'], + rows: $certificates->map(fn ($cert) => [ + $cert->id, + $cert->sequence, + $cert->commonName, + $cert->type, + $cert->serialNumber, + $cert->notBefore->toDateTimeString(), + $cert->notAfter->toDateTimeString(), + $cert->revokedAt?->toDateTimeString() ?? '', + ])->toArray(), + ); + + return self::SUCCESS; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Key/Get/PrivateCommand.php b/app/Commands/Key/Get/PrivateCommand.php index 24d0d32..a2b6b66 100644 --- a/app/Commands/Key/Get/PrivateCommand.php +++ b/app/Commands/Key/Get/PrivateCommand.php @@ -38,11 +38,19 @@ public function handle(): int $config = $this->getCaConfig(); } catch (\RuntimeException $e) { stdErr(fn () => error($e->getMessage())); + return self::FAILURE; } $repository = $config->database()->keys(); + $keyEntity = $repository->find($this->argument('id')); + if ($keyEntity !== null && ! $keyEntity->private) { + stdErr(fn () => error('Key ['.$this->argument('id').'] is a public-only key and has no private key.')); + + return self::FAILURE; + } + $content = $repository->getFile($this->argument('id'), KeyFile::PrivateKey); if ($content === null) { diff --git a/app/Commands/Key/Import/PublicCommand.php b/app/Commands/Key/Import/PublicCommand.php new file mode 100644 index 0000000..65733fd --- /dev/null +++ b/app/Commands/Key/Import/PublicCommand.php @@ -0,0 +1,94 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + stdErr(fn () => error($e->getMessage())); + + return self::FAILURE; + } + + $id = $this->argument('id'); + + if ($config->database()->keys()->exists($id)) { + stdErr(fn () => error("Key with id {$id} already exists")); + + return self::FAILURE; + } + + $path = $this->argument('pem'); + + if ($path) { + if (! file_exists($path)) { + stdErr(fn () => error("File not found: {$path}")); + + return self::FAILURE; + } + $pem = file_get_contents($path); + } else { + $pem = file_get_contents('php://stdin'); + } + + if (! $pem) { + stdErr(fn () => error('No PEM data provided')); + + return self::FAILURE; + } + + try { + $publicKey = RSA::loadPublicKey($pem); + } catch (\Exception $e) { + stdErr(fn () => error('Failed to load public key: '.$e->getMessage())); + + return self::FAILURE; + } + + if (! $publicKey instanceof RSA\PublicKey) { + stdErr(fn () => error('Only RSA public keys are supported.')); + + return self::FAILURE; + } + + $fingerprint = $publicKey->getFingerprint(); + $keySize = $publicKey->getLength(); + + $entity = new Key( + id: $id, + size: $keySize, + fingerprint: $fingerprint, + createdAt: now()->toImmutable(), + private: false, + ); + + $config->database()->keys()->save($entity); + $config->database()->keys()->putFile($id, KeyFile::PublicKey, $publicKey->toString('PKCS8')); + + return self::SUCCESS; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Key/ImportCommand.php b/app/Commands/Key/ImportCommand.php index aded52f..9c763ec 100644 --- a/app/Commands/Key/ImportCommand.php +++ b/app/Commands/Key/ImportCommand.php @@ -38,22 +38,25 @@ public function handle(): int try { $config = $this->getCaConfig(); } catch (\RuntimeException $e) { - error($e->getMessage()); + stdErr(fn () => error($e->getMessage())); + return self::FAILURE; } $id = $this->argument('id'); if ($config->database()->keys()->exists($id)) { - error("Key with id {$id} already exists"); + stdErr(fn () => error("Key with id {$id} already exists")); + return self::FAILURE; } $path = $this->argument('pem'); if ($path) { - if (!file_exists($path)) { - error("File not found: {$path}"); + if (! file_exists($path)) { + stdErr(fn () => error("File not found: {$path}")); + return self::FAILURE; } $pem = file_get_contents($path); @@ -61,15 +64,17 @@ public function handle(): int $pem = file_get_contents('php://stdin'); } - if (!$pem) { - error('No PEM data provided'); + if (! $pem) { + stdErr(fn () => error('No PEM data provided')); + return self::FAILURE; } try { - $private_key = $this->loadPrivateKey($pem, $password); + $private_key = stdErr(fn () => $this->loadPrivateKey($pem, $password)); } catch (\Exception $e) { - error('Failed to load private key: ' . $e->getMessage()); + stdErr(fn () => error('Failed to load private key: '.$e->getMessage())); + return self::FAILURE; } diff --git a/app/Commands/Key/ListCommand.php b/app/Commands/Key/ListCommand.php new file mode 100644 index 0000000..e7f474a --- /dev/null +++ b/app/Commands/Key/ListCommand.php @@ -0,0 +1,56 @@ +getCaConfig(); + } catch (\RuntimeException $e) { + error($e->getMessage()); + + return self::FAILURE; + } + + $keys = $config->database()->keys()->all(); + + if ($keys->isEmpty()) { + error('No keys found.'); + + return self::FAILURE; + } + + table( + headers: ['ID', 'Size', 'Fingerprint', 'Private', 'Created At'], + rows: $keys->map(fn ($key) => [ + $key->id, + $key->size ?? '', + $key->fingerprint, + $key->private ? 'yes' : 'no', + $key->createdAt->toDateTimeString(), + ])->toArray(), + ); + + return self::SUCCESS; + } + + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/app/Commands/Key/Password/UpdateCommand.php b/app/Commands/Key/Password/UpdateCommand.php index d21165b..027d790 100644 --- a/app/Commands/Key/Password/UpdateCommand.php +++ b/app/Commands/Key/Password/UpdateCommand.php @@ -8,7 +8,8 @@ use Illuminate\Console\Scheduling\Schedule; use LaravelZero\Framework\Commands\Command; -use function Laravel\Prompts\{error, password}; +use function Laravel\Prompts\error; +use function Laravel\Prompts\password; class UpdateCommand extends Command { @@ -38,6 +39,7 @@ public function handle(): int $config = $this->getCaConfig(); } catch (\RuntimeException $e) { error($e->getMessage()); + return self::FAILURE; } @@ -45,6 +47,14 @@ public function handle(): int if (! $config->database()->keys()->exists($id)) { error("Key [{$id}] does not exist."); + + return self::FAILURE; + } + + $keyEntity = $config->database()->keys()->find($id); + if ($keyEntity !== null && ! $keyEntity->private) { + error("Key [{$id}] is a public-only key and has no private key."); + return self::FAILURE; } @@ -53,7 +63,8 @@ public function handle(): int try { $privateKey = $this->loadPrivateKey($pem); } catch (\Exception $e) { - error('Failed to load private key: ' . $e->getMessage()); + error('Failed to load private key: '.$e->getMessage()); + return self::FAILURE; } @@ -66,6 +77,7 @@ public function handle(): int $newPassword = password(label: 'Enter new password for private key', required: true); if ($newPassword !== password(label: 'Confirm new password', required: true)) { error('Passwords do not match'); + return self::FAILURE; } } diff --git a/app/Services/CertificateSigningService.php b/app/Services/CertificateSigningService.php new file mode 100644 index 0000000..854229b --- /dev/null +++ b/app/Services/CertificateSigningService.php @@ -0,0 +1,288 @@ +loadCSR($csrPem); + + if (! $x509->validateSignature()) { + throw new RuntimeException('CSR signature is invalid.'); + } + + $subjectKey = $x509->getPublicKey(); + if (! $subjectKey instanceof RSA\PublicKey) { + throw new RuntimeException('Only RSA keys are supported.'); + } + + $dn = $distinguishedNameOverride ?? $x509->getDN(X509::DN_STRING); + + $keyId = $this->importPublicKeyFromCsr($subjectKey); + + return $this->sign( + templateName: $templateName, + subjectKey: $subjectKey, + issuerKey: $issuerKey, + distinguishedName: $dn, + keyId: $keyId, + csrPem: $csrPem, + serialNumberOverride: $serialNumberOverride, + ); + } + + public function issueFromKeyId( + string $templateName, + string $keyId, + RSA\PrivateKey $issuerKey, + string $distinguishedName, + ?string $serialNumberOverride = null, + ): Certificate { + $keyEntity = $this->database->keys()->find($keyId); + if ($keyEntity === null) { + throw new RuntimeException("Key [{$keyId}] does not exist."); + } + + $publicKeyPem = $this->database->keys()->getFile($keyId, KeyFile::PublicKey); + if ($publicKeyPem === null) { + throw new RuntimeException("Public key file not found for key [{$keyId}]."); + } + + $subjectKey = RSA::loadPublicKey($publicKeyPem); + if (! $subjectKey instanceof RSA\PublicKey) { + throw new RuntimeException('Only RSA keys are supported.'); + } + + return $this->sign( + templateName: $templateName, + subjectKey: $subjectKey, + issuerKey: $issuerKey, + distinguishedName: $distinguishedName, + keyId: $keyId, + serialNumberOverride: $serialNumberOverride, + ); + } + + private function sign( + string $templateName, + RSA\PublicKey $subjectKey, + RSA\PrivateKey $issuerKey, + string $distinguishedName, + ?string $keyId = null, + ?string $csrPem = null, + ?string $serialNumberOverride = null, + ): Certificate { + $template = $this->config->certificateTemplates[$templateName] ?? null; + if ($template === null) { + throw new RuntimeException("Template [{$templateName}] not found."); + } + + $caMetadata = $this->database->ca()->metadata(); + if ($caMetadata?->certificate === null) { + throw new RuntimeException('CA certificate does not exist. Create or import a CA certificate first.'); + } + + $caCertDetails = $caMetadata->certificate; + + if ($caCertDetails->valid_to->isPast()) { + throw new RuntimeException('CA certificate has expired.'); + } + + if ($template->ca && $caCertDetails->path_length_constraint !== null && $caCertDetails->path_length_constraint === 0) { + throw new RuntimeException('CA pathLengthConstraint is 0 — cannot issue subordinate CA certificates.'); + } + + if (! (new X509)->setDN($distinguishedName)) { + throw new RuntimeException('Invalid distinguished name format.'); + } + + $sequence = $this->getNextSequence($caMetadata); + $serialNumber = $this->generateSerialNumber($sequence, $serialNumberOverride); + + $issuer = $this->buildIssuerX509($issuerKey); + + $subject = new X509; + $subject->setDN($distinguishedName); + $subject->setPublicKey($subjectKey); + + $x509 = new X509; + $x509->setSerialNumber($serialNumber, 16); + + $this->applyTemplateExtensions($x509, $template, $caCertDetails->path_length_constraint); + + $validFrom = new CarbonImmutable; + $validTo = new CarbonImmutable($template->validity); + + $x509->setStartDate($validFrom); + $x509->setEndDate($validTo); + + $result = $x509->sign($issuer, $subject); + $certPem = $x509->saveX509($result); + + $id = $sequence.'-'.$serialNumber; + + $dnParts = (new X509)->loadX509($certPem); + $loadedX509 = new X509; + $loadedX509->loadX509($certPem); + $commonName = ''; + $dn = $loadedX509->getDN(X509::DN_STRING); + if (preg_match('/CN=([^,\/]+)/', $dn, $matches)) { + $commonName = trim($matches[1]); + } + + $certificate = new Certificate( + id: $id, + keyId: $keyId, + commonName: $commonName, + type: $template->ca ? 'ca' : 'end-entity', + serialNumber: $serialNumber, + notBefore: $validFrom, + notAfter: $validTo, + sequence: $sequence, + ); + + $this->database->certificates()->save($certificate); + $this->database->certificates()->putFile($id, CertificateFile::Certificate, $certPem); + + if ($csrPem !== null) { + $this->database->certificates()->putFile($id, CertificateFile::Request, $csrPem); + } + + $this->database->ca()->saveMetadata(new CaMetadata( + key_id: $caMetadata->key_id, + certificate: $caMetadata->certificate, + last_issued_sequence: $sequence, + )); + + return $certificate; + } + + private function getNextSequence(CaMetadata $metadata): int + { + return ($metadata->last_issued_sequence ?? 0) + 1; + } + + private function generateSerialNumber(int $sequence, ?string $override = null): string + { + if ($override !== null) { + if (! ctype_xdigit($override)) { + throw new RuntimeException('Serial number must be a valid hexadecimal string.'); + } + + return $override; + } + + if ($this->config->certificationAuthority->randomSerialNumbers) { + return (new BigInteger(Random::string(20) & ("\x7F".str_repeat("\xFF", 19)), 256))->toHex(); + } + + return (new BigInteger($sequence))->toHex(); + } + + private function buildIssuerX509(RSA\PrivateKey $key): X509 + { + $caCertPem = $this->database->ca()->getFile(CaFile::Certificate); + if ($caCertPem === null) { + throw new RuntimeException('CA certificate file not found.'); + } + + $issuer = new X509; + $issuer->loadX509($caCertPem); + $issuer->setPrivateKey($key); + + return $issuer; + } + + private function applyTemplateExtensions(X509 $x509, CertificateTemplateConfig $template, ?int $caPathLengthConstraint): void + { + $caConfig = $this->config->certificationAuthority; + + if (count($caConfig->crlDistributionPoints) > 0) { + $x509->setExtensionValue('id-ce-cRLDistributionPoints', [ + [ + 'distributionPoint' => [ + 'fullName' => array_map(fn ($x) => ['uniformResourceIdentifier' => $x], $caConfig->crlDistributionPoints), + ], + ], + ]); + } + + if (count($caConfig->certificateDistributionPoints) > 0) { + $x509->setExtensionValue('id-pe-authorityInfoAccess', array_map(fn ($x) => [ + 'accessMethod' => 'id-ad-caIssuers', + 'accessLocation' => [ + 'uniformResourceIdentifier' => $x, + ], + ], $caConfig->certificateDistributionPoints)); + } + + if ($template->ca) { + $x509->makeCA(); + $subordinateConstraint = null; + if ($template->pathLengthConstraint !== null) { + $subordinateConstraint = $template->pathLengthConstraint; + } elseif ($caPathLengthConstraint !== null && $caPathLengthConstraint > 0) { + $subordinateConstraint = $caPathLengthConstraint - 1; + } + if ($subordinateConstraint !== null) { + $x509->setExtensionValue('id-ce-basicConstraints', [ + 'cA' => true, + 'pathLenConstraint' => $subordinateConstraint, + ], true); + } + } + } + + private function importPublicKeyFromCsr(RSA\PublicKey $key): string + { + $fingerprint = $key->getFingerprint(); + + $existing = $this->database->keys()->forFingerprint($fingerprint); + if ($existing !== null) { + return $existing->id; + } + + $id = 'csr-'.substr(hash('sha256', $key->toString('PKCS8')), 0, 16); + + $entity = new Key( + id: $id, + size: $key->getLength(), + fingerprint: $fingerprint, + createdAt: now()->toImmutable(), + private: false, + ); + + $this->database->keys()->save($entity); + $this->database->keys()->putFile($id, KeyFile::PublicKey, $key->toString('PKCS8')); + + return $id; + } +} diff --git a/app/Storage/Entities/CaMetadata.php b/app/Storage/Entities/CaMetadata.php index 2612818..893f2dc 100644 --- a/app/Storage/Entities/CaMetadata.php +++ b/app/Storage/Entities/CaMetadata.php @@ -9,6 +9,7 @@ class CaMetadata extends SingletonEntity public function __construct( public readonly ?string $key_id = null, public readonly ?CaCertificateDetails $certificate = null, + public readonly ?int $last_issued_sequence = null, ) {} public static function fromArray(array $data): static @@ -16,14 +17,16 @@ public static function fromArray(array $data): static return new static( key_id: $data['key_id'] ?? null, certificate: isset($data['certificate']) ? CaCertificateDetails::fromArray($data['certificate']) : null, + last_issued_sequence: $data['last_issued_sequence'] ?? null, ); } public function toArray(): array { return [ - 'key_id' => $this->key_id, 'certificate' => $this->certificate?->toArray(), + 'key_id' => $this->key_id, + 'last_issued_sequence' => $this->last_issued_sequence, ]; } } diff --git a/app/Storage/Entities/Certificate.php b/app/Storage/Entities/Certificate.php index 76e5f94..0ac4515 100644 --- a/app/Storage/Entities/Certificate.php +++ b/app/Storage/Entities/Certificate.php @@ -15,6 +15,7 @@ public function __construct( public readonly string $serialNumber, public readonly CarbonImmutable $notBefore, public readonly CarbonImmutable $notAfter, + public readonly int $sequence = 0, public readonly array $subjectAltNames = [], public readonly array $extensions = [], public readonly ?CarbonImmutable $revokedAt = null, @@ -32,6 +33,7 @@ public static function fromArray(string $id, array $data): static serialNumber: $data['serial_number'], notBefore: CarbonImmutable::parse($data['not_before']), notAfter: CarbonImmutable::parse($data['not_after']), + sequence: $data['sequence'] ?? 0, subjectAltNames: $data['subject_alt_names'] ?? [], extensions: $data['extensions'] ?? [], revokedAt: isset($data['revoked_at']) ? CarbonImmutable::parse($data['revoked_at']) : null, @@ -47,6 +49,7 @@ public function toArray(): array 'not_after' => $this->notAfter->toIso8601String(), 'not_before' => $this->notBefore->toIso8601String(), 'revoked_at' => $this->revokedAt?->toIso8601String(), + 'sequence' => $this->sequence, 'serial_number' => $this->serialNumber, 'subject_alt_names' => $this->subjectAltNames, 'type' => $this->type, diff --git a/app/Storage/Entities/Key.php b/app/Storage/Entities/Key.php index 5c03041..4a65d83 100644 --- a/app/Storage/Entities/Key.php +++ b/app/Storage/Entities/Key.php @@ -12,6 +12,7 @@ public function __construct( public readonly ?int $size, public readonly string $fingerprint, public readonly CarbonImmutable $createdAt, + public readonly bool $private = true, ) { parent::__construct($id); } @@ -23,15 +24,17 @@ public static function fromArray(string $id, array $data): static size: $data['size'] ?? null, fingerprint: $data['fingerprint'], createdAt: CarbonImmutable::parse($data['created_at']), + private: $data['private'] ?? true, ); } public function toArray(): array { - return [ - 'size' => $this->size, - 'fingerprint' => $this->fingerprint, + return array_filter([ 'created_at' => $this->createdAt->toIso8601String(), - ]; + 'fingerprint' => $this->fingerprint, + 'private' => $this->private ? null : false, + 'size' => $this->size, + ], fn ($v) => $v !== null); } } diff --git a/workdir/configure.sh b/workdir/configure.sh index 5118d05..177c95f 100644 --- a/workdir/configure.sh +++ b/workdir/configure.sh @@ -58,6 +58,29 @@ ensure_csr_for_ca() { fi } +ensure_signed_ca_certificate() { + local ca_file="$1" + local signing_ca_file="$2" + local template="$3" + + printf "[%s] Checking if CA has a certificate: " "$ca_file" + if ! ../php-ca authority:certificate:exists --ca="$ca_file" --quiet; then + echo "no" + printf "[%s] Issuing certificate signed by [%s] using template [%s]: " "$ca_file" "$signing_ca_file" "$template" + if ! ../php-ca authority:csr:get --ca="$ca_file" \ + | ../php-ca certificate:issue:csr "$template" --ca="$signing_ca_file" --force \ + | ../php-ca certificate:get --ca="$signing_ca_file" \ + | ../php-ca authority:certificate:import --ca="$ca_file"; then + echo "failed" + return 1 + else + echo "done" + fi + else + echo "yes" + fi +} + ensure_key php-pki-config.json 1024 || exit 1 ensure_self_signed_ca php-pki-config.json "CN=Test CA" "+1 month" || exit 1 @@ -66,6 +89,8 @@ ensure_self_signed_ca root-ca.json "C=WW, O=PHP PKI CA Project, CN=My Root CA" " ensure_key sub-ca.json 2048 || exit 1 ensure_csr_for_ca sub-ca.json "C=WW, O=PHP PKI CA Project, CN=My Sub CA" || exit 1 +ensure_signed_ca_certificate sub-ca.json root-ca.json subordinary-ca || exit 1 ensure_key int-ca.json 1024 || exit 1 ensure_csr_for_ca int-ca.json "C=WW, O=PHP PKI CA Project, CN=My Intermediate CA" || exit 1 +ensure_signed_ca_certificate int-ca.json sub-ca.json intermediate-ca || exit 1