diff --git a/README.md b/README.md
index 939e79a57..c82a021ed 100644
--- a/README.md
+++ b/README.md
@@ -698,7 +698,8 @@ There is no limit to API calls at this time but is important to note that pfSens
* [SYSTEM/CERTIFICATE](#systemcertificate)
1. [Read System Certificates](#1-read-system-certificates)
1. [Create System Certificates](#2-create-system-certificates)
- 1. [Delete System Certificates](#3-delete-system-certificates)
+ 1. [Update System Certificates](#3-update-system-certificates)
+ 1. [Delete System Certificates](#4-delete-system-certificates)
* [SYSTEM/HOSTNAME](#systemhostname)
1. [Read System Hostname](#1-read-system-hostname)
1. [Update System Hostname](#2-update-system-hostname)
@@ -5196,7 +5197,48 @@ URL: https://{{$hostname}}/api/v1/system/certificate
-### 3. Delete System Certificates
+### 3. Update System Certificates
+
+
+Update an installed SSL certificate.
+Dependent services are NOT restarted.
+
+_Requires at least one of the following privileges:_ [`page-all`, `page-system-certmanager`]
+
+
+***Endpoint:***
+
+```bash
+Method: PUT
+URL: https://{{$hostname}}/api/v1/system/certificate
+```
+
+
+
+***Fields:***
+
+| Key | Type | Description |
+| --- | ------|-------------|
+| refid | string | Specify the refid of the certificate to change (required if `descr` is not defined) |
+| descr | string | Specify the description of the certificate to delete (required if `refid` is not defined) _Note: if multiple certificates exist with the same name, only the first matching certificate will be updated_ _Note: `descr` can be changed when `refid` is specified_|
+| crt | string | Specify the Base64 encoded PEM certificate to import. This field is required when `method` is set to `existing`. _Note: previous releases referred to the `crt` field as `cert`. Both `crt` and `cert` can be used interchangeably._ |
+| prv | string | Specify the corresponding Base64 encoded certificate key. This field is required when `method` is set to `existing`. _Note: previous releases referred to the `prv` field as `key`. Both `prv` and `key` can be used interchangeably._ |
+
+
+
+***Example Request:***
+
+```js
+{
+ "descr": "my already existing certificate",
+ "crt": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNZRENDQWNtZ0F3SUJBZ0lVSUt0U1pUYVRZSWhncytNNllsUUxrZFZ0Tm1nd0RRWUpLb1pJaHZjTkFRRUwKQlFBd1FqRUxNQWtHQTFVRUJoTUNXRmd4RlRBVEJnTlZCQWNNREVSbFptRjFiSFFnUTJsMGVURWNNQm9HQTFVRQpDZ3dUUkdWbVlYVnNkQ0JEYjIxd1lXNTVJRXgwWkRBZUZ3MHlNakF6TWpreE5ESTVNRGRhRncweU16QXpNamt4Ck5ESTVNRGRhTUVJeEN6QUpCZ05WQkFZVEFsaFlNUlV3RXdZRFZRUUhEQXhFWldaaGRXeDBJRU5wZEhreEhEQWEKQmdOVkJBb01FMFJsWm1GMWJIUWdRMjl0Y0dGdWVTQk1kR1F3Z1o4d0RRWUpLb1pJaHZjTkFRRUJCUUFEZ1kwQQpNSUdKQW9HQkFLTjlnaGlHT29rakp1aGs3VlZHYnVWdWg4MUxwVUFYNjEzRzRUZWlFMXJOUVl4U0NWSmdCaXpQCkxZK2hidmRjWUxGdGk4Rm1EM1hpM2J1aUxmdmY3UW5VOWljbmxwVTU4bU00VEEvR1orMisyZXpJTUhCdVowek8KZVRQSXhpOEMzYmZtb1VzME9JdEc0SGNlcWlpK09taXIrY3VlNk5xcVNJQUJuRzFoWHpENUFnTUJBQUdqVXpCUgpNQjBHQTFVZERnUVdCQlJMWFRWa01sQUtKbktaNTJRNmVIWmVRb3pNbHpBZkJnTlZIU01FR0RBV2dCUkxYVFZrCk1sQUtKbktaNTJRNmVIWmVRb3pNbHpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUEKQTRHQkFKRjd6MHBtQlhkQ0xJOWo5c2dFWCs3MWwyRGtYVEttZ2thS05ZN011RFBVQ1RNdUpjZ2MzbDdyd25BbwpRV3FpQ3o4VVZYdENER2xKVzZGYXJUWU5VSTlyaHAxR3hsbDQ3ckt5eXVCVUlLQmkwSFpPa3Y4dnBGM0lqcldECmlqRE5DbFZHQWlDSUU4STFIZDExT3cyY2pNODVCcFpDVFZycXJVaUJFSEswWWtpSwotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==",
+ "prv": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUNkZ0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQW1Bd2dnSmNBZ0VBQW9HQkFLTjlnaGlHT29rakp1aGsKN1ZWR2J1VnVoODFMcFVBWDYxM0c0VGVpRTFyTlFZeFNDVkpnQml6UExZK2hidmRjWUxGdGk4Rm1EM1hpM2J1aQpMZnZmN1FuVTlpY25scFU1OG1NNFRBL0daKzIrMmV6SU1IQnVaMHpPZVRQSXhpOEMzYmZtb1VzME9JdEc0SGNlCnFpaStPbWlyK2N1ZTZOcXFTSUFCbkcxaFh6RDVBZ01CQUFFQ2dZQnI1U0dkYzhCZnp0NFhrcnY2Z2pBZnBERmwKY0IzUHpibGNPeXRaSHRKdEkzYTExMUlsbGcrZE5PRlpuKzF1dS8xb091WjNyUlpZODI3b0xLRHlVQmJMVHpEcwpHVzcwU09JZElwTXQxRmtWNmJZbDBpTXllaU1TUFdOeVl3YjA4Yko3ejZobzgxZnR1NGU4Y2FVS2lDdnZOK1lvCmtmVjk3RHEvNUV1NjVkSGZyUUpCQU5GbSt6dzVWNU83QmVhNXdiNXFSc0daODdBUDMxTU1TNWR3d21nM0VzVmoKekc0UnNMMXNTTjZ3SnM3LytaRERhcDVXS2M0T1FEZDA2YjVQTW5tM3dFTUNRUURIM3c3SXhneVRpaWNSUnpQegpBam5oZU5JL0h2NmloR0craFBMMnIxaFRTUFlWTDhRb2xHLzhhM3JGZW5iNm5rMjlyWks0eXdaK2x1R3hqbFRUCm02UVRBa0VBbmxMOW04QkRUZ2cyNHdjSnpMMnY5OHM5NjUxa25mY0s1RnEyTW5PSmRzTUpHeU8yL05GMW15R1cKaGlZVi9IVTBGTGxTN0Yvci84SWV4T3crWHJjbTN3SkFWZDJiSVdBTUtScFIvRmRGbHlHZXNpSFEyVE04bTU4Wgp5dHFjOHFPVDQzdlYxSFpINUZNWTVTMWJlaGxKb2hOK1BIMmtLZVYyN2MxdU9uUjJOczZIcHdKQU8zdyt4ZnJoCk1ETlJ0TlZPQmZDc0p6aVV1ejRTZXY1K1pMVEphRWpmNkJUSUhVVHl2MGJzRUt3SGNtV3FQc0lFcFFvUkZnSlQKbElmelJSQXdBRWpDNVE9PQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg=="
+}
+```
+
+
+
+### 4. Delete System Certificates
Delete an existing certificate.
diff --git a/pfSense-pkg-API/files/etc/inc/api/endpoints/APISystemCertificate.inc b/pfSense-pkg-API/files/etc/inc/api/endpoints/APISystemCertificate.inc
index 1231511da..35f88d431 100644
--- a/pfSense-pkg-API/files/etc/inc/api/endpoints/APISystemCertificate.inc
+++ b/pfSense-pkg-API/files/etc/inc/api/endpoints/APISystemCertificate.inc
@@ -28,6 +28,10 @@ class APISystemCertificate extends APIEndpoint {
return (new APISystemCertificateCreate())->call();
}
+ protected function put() {
+ return (new APISystemCertificateUpdate())->call();
+ }
+
protected function delete() {
return (new APISystemCertificateDelete())->call();
}
diff --git a/pfSense-pkg-API/files/etc/inc/api/models/APISystemCertificateUpdate.inc b/pfSense-pkg-API/files/etc/inc/api/models/APISystemCertificateUpdate.inc
new file mode 100644
index 000000000..f44645055
--- /dev/null
+++ b/pfSense-pkg-API/files/etc/inc/api/models/APISystemCertificateUpdate.inc
@@ -0,0 +1,152 @@
+privileges = ["page-all", "page-system-certmanager"];
+ $this->change_note = "Modified system certificate address via API";
+ }
+
+ public function action() {
+ $this->config["cert"][$this->id] = $this->validated_data;
+ $this->write_config();
+ mark_subsystem_dirty("cert");
+
+ # Only reload the firewall filter if a false value was not passed in
+ # TODO: This condition applies the changes by default to stay backwards compatible with v1.3.0
+ # TODO: this should be refactored in a future release to not apply by default
+ #if ($this->initial_data["apply"] !== false) {
+ # APIFirewallApplyCreate::apply();
+ #}
+ return APIResponse\get(0, $this->config["cert"][$this->id]);
+ }
+
+ private function __validate_id() {
+ # Validate optional 'refid' field containing the refid of an existing certificate. If refid is not passed, find the cert through 'descr' field
+ if (isset($this->initial_data["refid"])) {
+ # Loop through each cert and check for a match
+ foreach ($this->config["cert"] as $id=>$cert) {
+ # Check if the field 'refid' matches the certificate's refid
+ if ($this->initial_data["refid"] === $cert["refid"]) {
+ $this->id = $id;
+ $this->validated_data = $cert;
+ break;
+ }
+ }
+ # If we did not find an ID in the loop, return a not found error
+ if (is_null($this->id)) {
+ $this->errors[] = APIResponse\get(1009);
+ }
+ }
+ }
+
+ private function __validate_descr() {
+ # Validate the required 'descr' field
+ if (isset($this->initial_data['descr'])) {
+ # Ensure description does not contain invalid characters
+ if (preg_match("/[\?\>\<\&\/\\\"\']/", $this->initial_data['descr'])) {
+ $this->errors[] = APIResponse\get(1037);
+ } else {
+ # Match certificate by 'descr' field only when refid is not set
+ if (isset($this->initial_data["refid"])) {
+ $this->validated_data["descr"] = $this->initial_data['descr'];
+ } else {
+ # Loop through each cert and check for a match
+ foreach ($this->config["cert"] as $id=>$cert) {
+ # Check if the field 'descr' matches the certificate's description
+ if ($this->initial_data["descr"] === $cert["descr"]) {
+ $this->id = $id;
+ $this->validated_data["descr"] = $this->initial_data['descr'];
+ break;
+ }
+ }
+ # If we did not find an ID in the loop, return a not found error
+ if (is_null($this->id)) {
+ $this->errors[] = APIResponse\get(1009);
+ }
+ }
+ }
+ } else {
+ $this->errors[] = APIResponse\get(1002);
+ }
+ }
+
+ private function __validate_crt() {
+ # Check for our required 'crt' field
+ if (isset($this->initial_data["crt"]) or isset($this->initial_data["cert"])) {
+ # Convert 'cert' to 'crt' to remain backwards compatible
+ if (isset($this->initial_data["cert"])) {
+ $this->initial_data["crt"] = $this->initial_data["cert"];
+ }
+
+ # Decode certificate from base64 format
+ $crt = base64_decode($this->initial_data["crt"]);
+
+ # Check if our certificate is valid
+ if (!strstr($crt, "BEGIN CERTIFICATE") || !strstr($crt, "END CERTIFICATE")) {
+ $this->errors[] = APIResponse\get(1003);
+ }
+ else {
+ # Certificate must be stored base64-encoded
+ $this->validated_data["crt"] = base64_encode($crt);
+ }
+ } else {
+ $this->errors[] = APIResponse\get(1003);
+ }
+ }
+
+ private function __validate_prv() {
+ # Check for our optional 'prv' field
+ if (isset($this->initial_data["prv"]) or isset($this->initial_data["key"])) {
+ # Convert 'key' to 'prv' to remain backwards compatible
+ if (isset($this->initial_data["key"])) {
+ $this->initial_data["prv"] = $this->initial_data["key"];
+ }
+
+ # Decode certificate from base64 format
+ $crt = base64_decode($this->initial_data["crt"]);
+ $key = base64_decode($this->initial_data["prv"]);
+
+ # Check if this private key is encrypted
+ if (strstr($key, "ENCRYPTED")) {
+ $this->errors[] = APIResponse\get(1036);
+ }
+ # Check if our certificate and key matches
+ elseif (cert_get_publickey($crt, false) != cert_get_publickey($key, false, 'prv')) {
+ $this->errors[] = APIResponse\get(1049);
+ }
+ else {
+ # key must be stored base64-encoded
+ $this->validated_data["prv"] = base64_encode($key);
+ }
+ } else {
+
+ }
+ }
+
+ public function validate_payload() {
+ $this->__validate_id();
+ $this->__validate_descr();
+ $this->__validate_crt();
+ $this->__validate_prv();
+ }
+
+}
diff --git a/tests/test_api_v1_system_certificate.py b/tests/test_api_v1_system_certificate.py
index 66669fcce..e93910b1c 100644
--- a/tests/test_api_v1_system_certificate.py
+++ b/tests/test_api_v1_system_certificate.py
@@ -231,6 +231,22 @@ class APIE2ETestSystemCertificate(e2e_test_framework.APIE2ETest):
"payload": {"method": "internal", "descr": "TestCA", "keytype": "ECDSA", "ecname": "prime256v1", "digest_alg": "sha256", "lifetime": 365, "dn_commonname": "test.example.com", "dn_country": "US", "type": "user", "altnames": [{"email": "#@!INVALIDEMAIL!@#"}]}
}
]
+ put_tests = [
+ {
+ "name": "Check updating a non-existing certificate",
+ "status": 400,
+ "return": 1009,
+ "payload": {"descr": "INVALID"}
+ },
+ {
+ "name": "Update an existing certificate",
+ "payload": {
+ "descr": "Unit Test",
+ "crt": crt,
+ "prv": prv
+ }
+ },
+ ]
delete_tests = [
{
"name": "Delete certificate",