diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestEndpoint.inc new file mode 100644 index 00000000..355f3cd3 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/system/certificate/signing_request'; + $this->model_name = 'CertificateSigningRequest'; + $this->request_method_options = ['POST']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc index ab3581f3..b059e78c 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc @@ -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; @@ -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)], @@ -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. diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc index c35e933d..15d68913 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc @@ -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.', ); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequest.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequest.inc new file mode 100644 index 00000000..2b66001e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequest.inc @@ -0,0 +1,304 @@ +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']); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestTestCase.inc new file mode 100644 index 00000000..6b82dfe2 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestTestCase.inc @@ -0,0 +1,54 @@ +create(apply: true); + + # Ensure the CSR was created + $this->assert_str_contains($csr->csr->value, '-----BEGIN CERTIFICATE REQUEST-----'); + file_put_contents('/tmp/testca.csr', $csr->csr->value); + + # Generate a CA and sign the CSR + new Command('openssl genpkey -algorithm RSA -out /tmp/testca.key'); + new Command('openssl req -new -x509 -key /tmp/testca.key -out /tmp/testca.crt -days 3650 -subj "/CN=Test CA"'); + new Command( + 'openssl x509 -req -in /tmp/testca.csr -CA /tmp/testca.crt -CAkey /tmp/testca.key -out /tmp/testca.crt -days 3650', + ); + + # Ensure we can complete the CSR by updating the Certificate object's 'crt' field with the signed certificate + $cert = new Certificate(id: $csr->id); + $cert->crt->value = file_get_contents('/tmp/testca.crt'); + $cert->update(apply: true); + + # Ensure the pending CSR was removed + $this->assert_is_empty($cert->csr->value); + + # Remove the test files + new Command('rm /tmp/testca.*'); + } +}