diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8f82c..41b85e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,13 @@ Fix for JSON serialization of revocation 1.1.0 Add support for using the cert upload feature to upload auth certs Switch to .NET 8 + +1.1.1 +Allow for manual specification of enrollment term length +Add Lifetime parameter to allow for manual specification of cert validity +Bugfix - Properly handle syncs of 0 records +Allow for manual specification of enrollment term length + +1.1.2 +Add Lifetime parameter to allow for manual specification of cert validity +Bugfix - Properly handle syncs of 0 records diff --git a/README.md b/README.md index 4b14544..782a9e5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Sectigo Certificate Manager Gateway AnyCA Gateway REST Plugin + Sectigo Certificate Manager AnyCA Gateway REST Plugin

@@ -45,10 +45,10 @@ The Sectigo AnyCA Gateway REST plugin extends the capabilities of the Sectigo Ce ## Compatibility -The Sectigo Certificate Manager Gateway AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The Sectigo Certificate Manager AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. ## Support -The Sectigo Certificate Manager Gateway AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. +The Sectigo Certificate Manager AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. > To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. @@ -61,7 +61,7 @@ In addition, for the admin account you plan to use, make sure it has the API adm 1. Install the AnyCA Gateway REST per the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). -2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [Sectigo Certificate Manager Gateway AnyCA Gateway REST plugin](https://github.com/Keyfactor/sectigo-scm-caplugin/releases/latest) from GitHub. +2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [Sectigo Certificate Manager AnyCA Gateway REST plugin](https://github.com/Keyfactor/sectigo-scm-caplugin/releases/latest) from GitHub. 3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: @@ -72,11 +72,11 @@ In addition, for the admin account you plan to use, make sure it has the API adm Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the Sectigo Certificate Manager Gateway AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the Sectigo Certificate Manager AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. -5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the Sectigo Certificate Manager Gateway plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. +5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the Sectigo Certificate Manager plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. ## Configuration @@ -113,6 +113,7 @@ In addition, for the admin account you plan to use, make sure it has the API adm * **MultiDomain** - This flag lets Keyfactor know if the certificate can contain multiple domain names. Depending on the setting, the SAN entries of the request will change to support Sectigo requirements. * **Organization** - If the organization name is provided here, the Sectigo gateway will use that organization name in requests instead of whatever is in the O= field in the request subject. * **Department** - If your Sectigo account is using department-level products, put the appropriate department name here. Previously, this was alternatively supplied in the OU= subject field, which is now deprecated. + * **Lifetime** - OPTIONAL: The term length (in days) to use for enrollment. If not provided, the default is the first value available in the profile definition in your Sectigo account. diff --git a/integration-manifest.json b/integration-manifest.json index aba2711..8665460 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -78,6 +78,10 @@ { "name": "Department", "description": "If your Sectigo account is using department-level products, put the appropriate department name here. Previously, this was alternatively supplied in the OU= subject field, which is now deprecated." + }, + { + "name": "Lifetime", + "description": "OPTIONAL: The term length (in days) to use for enrollment. If not provided, the default is the first value available in the profile definition in your Sectigo account." } ] } diff --git a/sectigo-scm-caplugin/Client/SectigoClient.cs b/sectigo-scm-caplugin/Client/SectigoClient.cs index f7da84f..583a233 100644 --- a/sectigo-scm-caplugin/Client/SectigoClient.cs +++ b/sectigo-scm-caplugin/Client/SectigoClient.cs @@ -8,6 +8,8 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Org.BouncyCastle.Asn1.Ocsp; + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -19,6 +21,8 @@ using System.Threading; using System.Threading.Tasks; +using Error = Keyfactor.Extensions.CAPlugin.Sectigo.API.Error; + namespace Keyfactor.Extensions.CAPlugin.Sectigo.Client { public class SectigoClient @@ -34,7 +38,9 @@ public SectigoClient(HttpClient client) public async Task GetCertificate(int sslId) { - var response = await RestClient.GetAsync($"api/ssl/v1/{sslId}"); + string url = $"api/ssl/v1/{sslId}"; + Logger.LogTrace($"API Request: GET {url}"); + var response = await RestClient.GetAsync(url); return await ProcessResponse(response); } @@ -61,7 +67,10 @@ public async Task CertificateListProducer(BlockingCollection certs, Logger.LogInformation($"Request Certificates at Position {certIndex} with Page Size {pageSize}"); certificatePageToProcess = await PageCertificates(certIndex, pageSize, filter); Logger.LogDebug($"Found {certificatePageToProcess.Count} certificate to process"); - + if (certificatePageToProcess.Count == 0) + { + return; + } //Processing Loop will add and retry adding to queue until all certificates have been processed for a page batchCount = 0; blockedCount = 0; @@ -136,7 +145,7 @@ public async Task CertificateListProducer(BlockingCollection certs, public async Task> PageCertificates(int position = 0, int size = 25, string filter = "") { string filterQueryString = string.IsNullOrEmpty(filter) ? string.Empty : $"&{filter}"; - Logger.LogTrace($"API Request: api/ssl/v1?position={position}&size={size}{filterQueryString}".TrimEnd()); + Logger.LogTrace($"API Request: GET api/ssl/v1?position={position}&size={size}{filterQueryString}".TrimEnd()); var response = await RestClient.GetAsync($"api/ssl/v1?position={position}&size={size}{filterQueryString}".TrimEnd()); return await ProcessResponse>(response); } @@ -148,23 +157,17 @@ public async Task RevokeSslCertificateById(int sslId, int revcode, string reasonCode = revcode, reason = revreason }; + Logger.LogTrace($"API Request: POST api/ssl/v1/revoke/{sslId}\nParameters: {JsonConvert.SerializeObject(data, Formatting.Indented)}"); var response = await RestClient.PostAsJsonAsync($"api/ssl/v1/revoke/{sslId}", data); - if (response.IsSuccessStatusCode) - { - return true; - } - var failedResp = ProcessResponse(response).Result; - return failedResp.IsSuccess;//Should throw an exception with error message from API + var resp = ProcessResponse(response).Result; + + return true;//Should throw an exception with error message from API, should only hit this if success } public async Task ListOrganizations() { + Logger.LogTrace($"API Request: GET api/organization/v1"); var response = await RestClient.GetAsync("api/organization/v1"); - if (response.IsSuccessStatusCode) - { - string responseContent = await response.Content.ReadAsStringAsync(); - Logger.LogTrace($"Raw Response: {responseContent}"); - } var orgsResponse = await ProcessResponse>(response); return new ListOrganizationsResponse { Organizations = orgsResponse }; @@ -172,13 +175,8 @@ public async Task ListOrganizations() public async Task GetOrganizationDetails(int orgId) { + Logger.LogTrace($"API Request: GET api/organization/v1/{orgId}"); var response = await RestClient.GetAsync($"api/organization/v1/{orgId}"); - if (response.IsSuccessStatusCode) - { - string responseContent = await response.Content.ReadAsStringAsync(); - Logger.LogTrace($"Raw Response: {responseContent}"); - } - var orgDetailsResponse = await ProcessResponse(response); return orgDetailsResponse; } @@ -200,6 +198,7 @@ public async Task ListPersons(int orgId) public async Task ListCustomFields() { + Logger.LogTrace($"API Request: GET api/ssl/v1/customFields"); var response = await RestClient.GetAsync("api/ssl/v1/customFields"); return new ListCustomFieldsResponse { CustomFields = await ProcessResponse>(response) }; } @@ -211,13 +210,14 @@ public async Task ListSslProfiles(int? orgId = null) { urlSuffix = $"?organizationId={orgId}"; } - + Logger.LogTrace($"API Request: GET api/ssl/v1/types{urlSuffix}"); var response = await RestClient.GetAsync($"api/ssl/v1/types{urlSuffix}"); return new ListSslProfilesResponse { SslProfiles = await ProcessResponse>(response) }; } public async Task> PagePersons(int orgId, int position = 0, int size = 25) { + Logger.LogTrace($"API Request: GET api/person/v1?position={position}&size={size}&organizationId={orgId}"); var response = await RestClient.GetAsync($"api/person/v1?position={position}&size={size}&organizationId={orgId}"); return await ProcessResponse>(response); } @@ -226,6 +226,7 @@ public async Task Enroll(EnrollRequest request) { try { + Logger.LogTrace($"API Request: POST api/ssl/v1/enroll\nParameters: {JsonConvert.SerializeObject(request, Formatting.Indented)}"); var response = await RestClient.PostAsJsonAsync("api/ssl/v1/enroll", request); var enrollResponse = await ProcessResponse(response); @@ -245,35 +246,14 @@ public async Task Enroll(EnrollRequest request) } } - public async Task Renew(int sslId) - { - try - { - var response = await RestClient.PostAsJsonAsync($"api/ssl/v1/renewById/{sslId}", ""); - var renewResponse = await ProcessResponse(response); - - return renewResponse.sslId; - } - catch (InvalidOperationException invalidOp) - { - throw new Exception($"Invalid Operation. {invalidOp.Message}|{invalidOp.StackTrace}"); - } - catch (HttpRequestException httpEx) - { - throw new Exception($"HttpRequestException. {httpEx.Message}|{httpEx.StackTrace}"); - } - catch (Exception) - { - throw; - } - } - public async Task PickupCertificate(int sslId, string subject) { + Logger.LogTrace($"API Request: GET api/ssl/v1/collect/{sslId}/x509C0"); var response = await RestClient.GetAsync($"api/ssl/v1/collect/{sslId}/x509CO"); - + if (response.IsSuccessStatusCode && response.Content.Headers.ContentLength > 0) { + Logger.LogTrace($"Raw response: {response.Content.ReadAsStringAsync()}"); string pemChain = await response.Content.ReadAsStringAsync(); string[] splitChain = pemChain.Replace("\r\n", string.Empty).Split(new string[] { "-----" }, StringSplitOptions.RemoveEmptyEntries); @@ -284,24 +264,19 @@ public async Task PickupCertificate(int sslId, string subject) //return new X509Certificate2(); } - public async Task Reissue(ReissueRequest request, int sslId) - { - var response = await RestClient.PostAsJsonAsync($"api/ssl/v1/replace/{sslId}", request); - response.EnsureSuccessStatusCode(); - } - #region Static Methods private static async Task ProcessResponse(HttpResponseMessage response) { + string responseContent = await response.Content.ReadAsStringAsync(); + Logger.LogDebug($"Raw API response: {responseContent}"); if (response.IsSuccessStatusCode) { - string responseContent = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject(responseContent); } else { - var error = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var error = JsonConvert.DeserializeObject(responseContent); throw new Exception($"{error.Code} | {error.Description}"); } } diff --git a/sectigo-scm-caplugin/Constants.cs b/sectigo-scm-caplugin/Constants.cs index 014be4c..47f7af0 100644 --- a/sectigo-scm-caplugin/Constants.cs +++ b/sectigo-scm-caplugin/Constants.cs @@ -27,6 +27,7 @@ public class Config public const string MULTIDOMAIN = "MultiDomain"; public const string ORGANIZATION = "Organization"; public const string DEPARTMENT = "Department"; + public const string LIFETIME = "Lifetime"; } //headers for API client diff --git a/sectigo-scm-caplugin/SectigoCAPlugin.cs b/sectigo-scm-caplugin/SectigoCAPlugin.cs index c88038b..dcb46db 100644 --- a/sectigo-scm-caplugin/SectigoCAPlugin.cs +++ b/sectigo-scm-caplugin/SectigoCAPlugin.cs @@ -196,6 +196,26 @@ public async Task Enroll(string csr, string subject, Dictionar _logger.LogTrace($"Found {enrollmentProfile.name} profile for enroll request"); } + int termLength; + var profileTerms = Task.Run(async () => await GetProfileTerms(int.Parse(productInfo.ProductID))).Result; + if (!string.IsNullOrEmpty(productInfo.ProductParameters[Constants.Config.LIFETIME])) + { + var tempTerm = int.Parse(productInfo.ProductParameters[Constants.Config.LIFETIME]); + if (profileTerms.Contains(tempTerm)) + { + termLength = tempTerm; + } + else + { + _logger.LogError($"Specified term length of {tempTerm} does not match available terms for product ID {productInfo.ProductID}. Available terms are {string.Join(",", profileTerms)}"); + throw new Exception($"Specified term length of {tempTerm} does not match available terms for product ID {productInfo.ProductID}"); + } + } + else + { + termLength = profileTerms[0]; + } + int sslId; string priorSn = string.Empty; Certificate newCert = null; @@ -216,7 +236,7 @@ public async Task Enroll(string csr, string subject, Dictionar { csr = csr, orgId = requestOrgId, - term = Task.Run(async () => await GetProfileTerm(int.Parse(productInfo.ProductID))).Result, + term = termLength, certType = enrollmentProfile.id, //External requestor is expected to be an email. Use config to pull the enrollment field or send blank //sectigo will default to the account (API account) making the request. @@ -229,8 +249,6 @@ public async Task Enroll(string csr, string subject, Dictionar }; _logger.LogDebug($"Submit {enrollmentType} request"); - var jsonReq = JsonConvert.SerializeObject(request, Formatting.Indented); - _logger.LogDebug($"Request object: {jsonReq}"); sslId = Task.Run(async () => await client.Enroll(request)).Result; newCert = Task.Run(async () => await client.GetCertificate(sslId)).Result; _logger.LogDebug($"Enrolled for Certificate {newCert.CommonName} (ID: {newCert.Id}) | Status: {newCert.status}. Attempt to Pickup Certificate."); @@ -245,7 +263,7 @@ public async Task Enroll(string csr, string subject, Dictionar catch (HttpRequestException httpEx) { _logger.LogError($"Enrollment Failed due to a HTTP error: {httpEx.Message}"); - throw new Exception(httpEx.Message); + throw; } catch (Exception ex) { @@ -257,7 +275,7 @@ public async Task Enroll(string csr, string subject, Dictionar retError = ex.InnerException.Message; } - throw new Exception(retError); + throw; } } @@ -431,6 +449,13 @@ public Dictionary GetTemplateParameterAnnotations() Hidden = false, DefaultValue = "", Type = "String" + }, + [Constants.Config.LIFETIME] = new PropertyConfigInfo() + { + Comments = "OPTIONAL: The term length (in days) to use for enrollment. If not provided, the default is the first value available in the profile definition in your Sectigo account.", + Hidden = false, + DefaultValue = "", + Type = "String" } }; } @@ -520,7 +545,7 @@ public async Task Synchronize(BlockingCollection blockin _logger.LogError($"Synchronize task failed with the following message: {producerTask.Exception.Flatten().Message}"); throw producerTask.Exception.Flatten(); } - + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Processing record {certToAdd.Id}"); string dbCertId = null; int dbCertStatus = -1; //serial number is blank on certs that have not been issued (awaiting approval) @@ -566,23 +591,26 @@ public async Task Synchronize(BlockingCollection blockin } //Download to get full certdata required for sync process - _logger.LogTrace($"Attempt to Pickup Certificate {certToAdd.CommonName} (ID: {certToAdd.Id})"); + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Attempt to Pickup Certificate {certToAdd.CommonName}"); var certdataApi = Task.Run(async () => await client.PickupCertificate(certToAdd.Id, certToAdd.CommonName)).Result; if (certdataApi != null) certData = Convert.ToBase64String(certdataApi.GetRawCertData()); + if (certToAdd == null || String.IsNullOrEmpty(certToAdd.SerialNumber) || String.IsNullOrEmpty(certToAdd.CommonName) || String.IsNullOrEmpty(certData)) { _logger.LogDebug($"Certificate Data unavailable for {certToAdd.CommonName} (ID: {certToAdd.Id}). Skipping "); continue; } + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Retrieved cert data: {certData}"); string prodId = ""; try { - _logger.LogTrace($"Cert ID: {certToAdd.Id.ToString()}"); - _logger.LogTrace($"Sync ID: {syncReqId.ToString()}"); - _logger.LogTrace($"Product ID: {certToAdd.CertType.id.ToString()}"); + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Cert ID: {certToAdd.Id.ToString()}"); + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Sync ID: {syncReqId.ToString()}"); + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Product ID: {certToAdd.CertType.id.ToString()}"); + _logger.LogTrace($"SYNC TRACE ({certToAdd.Id}): Status: {certToAdd.status}"); prodId = certToAdd.CertType.id.ToString(); } catch { } @@ -674,11 +702,11 @@ private async Task GetOrganizationAsync(string orgName) return orgList.Organizations.Where(x => x.name.ToLower().Equals(orgName.ToLower())).FirstOrDefault(); } - private async Task GetProfileTerm(int profileId) + private async Task> GetProfileTerms(int profileId) { var client = SectigoClient.InitializeClient(_config, _certificateResolver); var profileList = await client.ListSslProfiles(); - return profileList.SslProfiles.Where(x => x.id == profileId).FirstOrDefault().terms[0]; + return profileList.SslProfiles.Where(x => x.id == profileId).FirstOrDefault().terms.ToList(); } private async Task GetProfile(int profileId)