Skip to content

Commit

Permalink
feat: allow CSRs to be created #250
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredhendrickson13 committed Sep 27, 2024
1 parent 4ef1476 commit d1701f9
Show file tree
Hide file tree
Showing 5 changed files with 398 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace RESTAPI\Endpoints;

require_once 'RESTAPI/autoloader.inc';

use RESTAPI\Core\Endpoint;

/**
* Defines an Endpoint for interacting with a singular CertificateSigningRequest object at
* /api/v2/system/certificate/signing_request.
*/
class SystemCertificateSigningRequestEndpoint extends Endpoint {
public function __construct() {
# Set Endpoint attributes
$this->url = '/api/v2/system/certificate/signing_request';
$this->model_name = 'CertificateSigningRequest';
$this->request_method_options = ['POST'];

# Construct the parent Endpoint object
parent::__construct();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Certificate extends Model {
public StringField $descr;
public UIDField $refid;
public StringField $type;
public Base64Field $csr;
public Base64Field $crt;
public Base64Field $prv;

Expand All @@ -43,6 +44,12 @@ class Certificate extends Model {
'services on this system. Use `user` when this certificate is intended to be assigned to a user for ' .
'authentication purposes.',
);
$this->csr = new Base64Field(
default: null,
allow_null: true,
read_only: true,
help_text: 'The X509 certificate signing request string if this certificate is pending an external signature.',
);
$this->crt = new Base64Field(
required: true,
validators: [new X509Validator(allow_crt: true)],
Expand Down Expand Up @@ -76,6 +83,15 @@ class Certificate extends Model {
return $prv;
}

/**
* Extends the default _update() method to ensure any `csr` value is removed before updating a Certificate.
*/
public function _update(): void {
# Remove the `csr` field value before updating the Certificate.
$this->csr->value = null;
parent::_update();
}

/**
* Deletes this Certificate object from configuration.
* @throws ForbiddenError When the Certificate cannot be deleted because it is in use.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class CertificateGenerate extends Model {
default: null,
allow_null: true,
read_only: true,
write_only: true,
sensitive: true,
help_text: 'The X509 private key string.',
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
<?php

namespace RESTAPI\Models;

use RESTAPI\Core\Model;
use RESTAPI\Fields\Base64Field;
use RESTAPI\Fields\ForeignModelField;
use RESTAPI\Fields\IntegerField;
use RESTAPI\Fields\StringField;
use RESTAPI\Fields\UIDField;
use RESTAPI\Responses\ServerError;
use RESTAPI\Validators\EmailAddressValidator;
use RESTAPI\Validators\HostnameValidator;
use RESTAPI\Validators\IPAddressValidator;
use RESTAPI\Validators\RegexValidator;
use RESTAPI\Validators\URLValidator;

/**
* Defines a Model for generating new CSRs.
*/
class CertificateSigningRequest extends Model {
public StringField $descr;
public UIDField $refid;
public IntegerField $serial;
public StringField $keytype;
public IntegerField $keylen;
public StringField $ecname;
public StringField $digest_alg;
public IntegerField $lifetime;
public StringField $dn_commonname;
public StringField $dn_country;
public StringField $dn_state;
public StringField $dn_city;
public StringField $dn_organization;
public StringField $dn_organizationalunit;
public StringField $type;
public StringField $dn_dns_sans;
public StringField $dn_email_sans;
public StringField $dn_ip_sans;
public StringField $dn_uri_sans;
public Base64Field $csr;
public Base64Field $prv;

public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) {
# Set model attributes
$this->config_path = 'cert';
$this->many = true;
$this->always_apply = true;

# Set model fields
$this->descr = new StringField(
required: true,
validators: [new RegexValidator(pattern: "/[\?\>\<\&\/\\\"\']/", invert: true)],
help_text: 'The descriptive name for this certificate.',
);
$this->refid = new UIDField(
help_text: 'The unique ID assigned to this certificate for internal system use. This value is ' .
'generated by this system and cannot be changed.',
);
$this->keytype = new StringField(
required: true,
choices: ['RSA', 'ECDSA'],
representation_only: true,
help_text: 'The type of key pair to generate.',
);
$this->keylen = new IntegerField(
required: true,
choices: [1024, 2048, 3072, 4096, 6144, 7680, 8192, 15360, 16384],
representation_only: true,
conditions: ['keytype' => 'RSA'],
help_text: 'The length of the RSA key pair to generate.',
);
$this->ecname = new StringField(
required: true,
choices_callable: 'get_ecname_choices',
representation_only: true,
conditions: ['keytype' => 'ECDSA'],
help_text: 'The name of the elliptic curve to use for the ECDSA key pair.',
);
$this->digest_alg = new StringField(
required: true,
choices_callable: 'get_digest_alg_choices',
representation_only: true,
help_text: 'The digest method used when the certificate is signed.',
);
$this->lifetime = new IntegerField(
default: 3650,
representation_only: true,
minimum: 1,
maximum: 12000,
help_text: 'The number of days the certificate is valid for.',
);
$this->dn_commonname = new StringField(
required: true,
representation_only: true,
help_text: 'The common name of the certificate.',
);
$this->dn_country = new StringField(
default: null,
choices_callable: 'get_country_choices',
allow_null: true,
representation_only: true,
help_text: 'The country of the certificate.',
);
$this->dn_state = new StringField(
default: null,
allow_null: true,
representation_only: true,
help_text: 'The state/province of the certificate.',
);
$this->dn_city = new StringField(
default: null,
allow_null: true,
representation_only: true,
help_text: 'The city of the certificate.',
);
$this->dn_organization = new StringField(
default: null,
allow_null: true,
representation_only: true,
help_text: 'The organization of the certificate.',
);
$this->dn_organizationalunit = new StringField(
default: null,
allow_null: true,
representation_only: true,
help_text: 'The organizational unit of the certificate.',
);
$this->type = new StringField(
default: 'user',
choices: ['server', 'user'],
help_text: 'The type of certificate to generate.',
);
$this->dn_dns_sans = new StringField(
default: [],
allow_empty: true,
representation_only: true,
many: true,
validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)],
help_text: 'The DNS Subject Alternative Names (SANs) for the certificate.',
);
$this->dn_email_sans = new StringField(
default: [],
allow_empty: true,
representation_only: true,
many: true,
validators: [new EmailAddressValidator()],
help_text: 'The Email Subject Alternative Names (SANs) for the certificate.',
);
$this->dn_ip_sans = new StringField(
default: [],
allow_empty: true,
representation_only: true,
many: true,
validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)],
help_text: 'The IP Subject Alternative Names (SANs) for the certificate.',
);
$this->dn_uri_sans = new StringField(
default: [],
allow_empty: true,
representation_only: true,
many: true,
validators: [new URLValidator()],
help_text: 'The URI Subject Alternative Names (SANs) for the certificate.',
);
$this->csr = new Base64Field(
default: null,
allow_null: true,
read_only: true,
help_text: 'The X509 certificate signing request string. You will need to provide this to a ' .
'certificate authority to sign the certificate.',
);
$this->prv = new Base64Field(
default: null,
allow_null: true,
read_only: true,
write_only: true,
sensitive: true,
help_text: 'The X509 private key string.',
);

parent::__construct($id, $parent_id, $data, ...$options);
}

/**
* Returns a list of available elliptic curve names for ECDSA key pairs.
* @returns array The list of available elliptic curve names.
*/
public static function get_ecname_choices(): array {
# Obtain the available curve list from pfSense's built-in cert_build_curve_list function
return array_keys(cert_build_curve_list());
}

/**
* Returns a list of available digest algorithms for signing certificates.
* @returns array The list of available digest algorithms.
*/
public static function get_digest_alg_choices(): array {
# Obtain the available digest algorithms from pfSense's built-in $openssl_digest_algs global
global $openssl_digest_algs;
return $openssl_digest_algs;
}

/**
* Returns a list of available country codes for the certificate.
* @returns array The list of available country codes.
*/
public static function get_country_choices(): array {
# Obtain the available country codes from pfSense's built-in get_cert_country_codes function
return array_keys(get_cert_country_codes());
}

/**
* Extends the default _create method to ensure the certificate is generated before it is written to config.
*/
protected function _create(): void {
# Generate the certificate
$this->generate_cert();

# Call the parent _create method to write the certificate to config
parent::_create();
}

/**
* Converts this Certificate object's DN values into a X509 DN array.
* @returns array The X509 DN array.
*/
private function to_x509_dn(): array {
# Define static DN values
$dn = ['commonName' => $this->dn_commonname->value, 'subjectAltName' => []];

# Add countryName if it was given
if ($this->dn_country->value) {
$dn['countryName'] = $this->dn_country->value;
}
# Add stateOrProvinceName if it was given
if ($this->dn_state->value) {
$dn['stateOrProvinceName'] = $this->dn_state->value;
}
# Add localityName if it was given
if ($this->dn_city->value) {
$dn['localityName'] = $this->dn_city->value;
}
# Add organizationName if it was given
if ($this->dn_organization->value) {
$dn['organizationName'] = $this->dn_organization->value;
}
# Add organizationalUnitName if it was given
if ($this->dn_organizationalunit->value) {
$dn['organizationalUnitName'] = $this->dn_organizationalunit->value;
}

# Loop through the SAN fields and add them to the subjectAltName array accordingly
foreach ($this->dn_dns_sans->value as $san) {
$dn['subjectAltName'][] = "DNS:$san";
}
foreach ($this->dn_email_sans->value as $san) {
$dn['subjectAltName'][] = "email:$san";
}
foreach ($this->dn_ip_sans->value as $san) {
$dn['subjectAltName'][] = "IP:$san";
}
foreach ($this->dn_uri_sans->value as $san) {
$dn['subjectAltName'][] = "URI:$san";
}

# Piece together the subjectAltName array into a comma-separated string
$dn['subjectAltName'] = implode(',', $dn['subjectAltName']);

return $dn;
}

/**
* Generates a new CSR and key using the requested parameters. This populate the `csr` and `prv` fields.
* @throws ServerError When the CSR and key fails to be generated.
*/
private function generate_cert(): void {
# Define a placeholder for csr_generate() to populate
$csr = [];

# Generate the CSR and key pair
$success = csr_generate(
cert: $csr,
keylen: $this->keylen->value,
dn: $this->to_x509_dn(),
type: $this->type->value,
digest_alg: $this->digest_alg->value,
keytype: $this->keytype->value,
ecname: $this->ecname->value,
);

# Throw a server error if the CSR and key fails to be generated
if (!$success) {
throw new ServerError(
message: 'Failed to generate the certificate signing request for unknown reason.',
response_id: 'CERTIFICATE_SIGNING_REQUEST_GENERATE_FAILED',
);
}

# Populate the `csr` and `prv` fields with the generated values
$this->csr->from_internal($csr['csr']);
$this->prv->from_internal($csr['prv']);
}
}
Loading

0 comments on commit d1701f9

Please sign in to comment.