From cba0bb432dc5244e091bd7bf85cc23361d30a37d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 21 Jul 2026 09:24:44 -0400 Subject: [PATCH 1/6] ADO 84076: Apply template parameter defaults when Command omits them Enrollment against a template that was added but never saved arrives with the plugin's declared template parameters absent from ProductParameters, because Command does not populate a template's parameter collection with the annotation defaults until the template is saved. Previously this failed enrollment (ValidityPeriod/ValidityUnits threw ArgumentException; RenewalDays returned a failure result), matching the "given key was not present in the dictionary" class of bug reported in 81803. Add RequestManager.ResolveTemplateParameter, which returns the value supplied by Command or falls back to the DefaultValue declared in GetTemplateParameterAnnotations(). Use it for ValidityPeriod, ValidityUnits, and RenewalDays so enrollment succeeds against an unsaved template using the same defaults the annotations advertise. Only a parameter with neither a supplied value nor a declared default remains an error. --- HydrantCAProxy/HydrantIdCAPlugin.cs | 12 ++++--- HydrantCAProxy/RequestManager.cs | 53 +++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index cc7b611..6c7f86e 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -590,17 +590,21 @@ await flow.StepAsync("SubmitEnrollment", async () => var expiration = previousX509.NotAfter; var now = DateTime.UtcNow; - if (!productInfo.ProductParameters.ContainsKey("RenewalDays")) + // Resolve RenewalDays from the supplied parameters, falling back to the + // annotation default when Command has not yet populated it (unsaved + // template — ADO 81803). Only fail if there is no default either. + var renewalDays = RequestManager.ResolveTemplateParameter(productInfo, HydrantIdCAPluginConfig.EnrollmentParametersConstants.RenewalDays); + if (string.IsNullOrWhiteSpace(renewalDays)) { - flow.Fail("ValidateRenewalDays", "RenewalDays not found in ProductParameters"); + flow.Fail("ValidateRenewalDays", "RenewalDays not supplied and no annotation default is defined"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Renewal failed: RenewalDays not found in product parameters." + StatusMessage = "Renewal failed: RenewalDays not found in product parameters and no default is defined." }; } - var isRenewal = (expiration - now).TotalDays <= Convert.ToInt16(productInfo.ProductParameters["RenewalDays"]); + var isRenewal = (expiration - now).TotalDays <= Convert.ToInt16(renewalDays); _logger.LogTrace("Enroll: expiration={Expiration}, now={Now}, isRenewal={IsRenewal}", expiration, now, isRenewal); flow.Step("DetermineRenewOrReissue", isRenewal ? "Renewal" : "Re-Issue"); diff --git a/HydrantCAProxy/RequestManager.cs b/HydrantCAProxy/RequestManager.cs index 6a94b75..f2fe8fa 100644 --- a/HydrantCAProxy/RequestManager.cs +++ b/HydrantCAProxy/RequestManager.cs @@ -21,6 +21,7 @@ using LogHandler = Keyfactor.Logging.LogHandler; using Keyfactor.AnyGateway.Extensions; using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.Extensions.CAPlugin.HydrantId; using System.Linq; namespace Keyfactor.HydrantId @@ -29,6 +30,41 @@ public class RequestManager { private static readonly ILogger Log = LogHandler.GetClassLogger(); + // Default values for the template parameters declared in + // GetTemplateParameterAnnotations(). Keyfactor Command does not populate a + // template's parameter collection with these annotation defaults until the + // template has been saved, so an enrollment against a template that was added + // but never saved arrives with the keys absent. Falling back to these defaults + // keeps enrollment working in that state instead of throwing + // ("The given key ... was not present in the dictionary"). See ADO 81803 / 84076. + private static readonly Dictionary TemplateParameterDefaults = + HydrantIdCAPluginConfig.GetTemplateParameterAnnotations() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.DefaultValue); + + /// + /// Returns the enrollment parameter value supplied by Command, falling back to the + /// default declared in + /// when the key is absent or blank. Returns null only when neither a supplied value + /// nor an annotation default exists. + /// + public static string ResolveTemplateParameter(EnrollmentProductInfo productInfo, string key) + { + if (productInfo?.ProductParameters != null && + productInfo.ProductParameters.TryGetValue(key, out var supplied) && + !string.IsNullOrWhiteSpace(supplied)) + { + return supplied; + } + + if (TemplateParameterDefaults.TryGetValue(key, out var def) && def != null) + { + Log.LogTrace("ResolveTemplateParameter: '{Key}' not supplied by Command; using annotation default '{Default}'", key, def); + return Convert.ToString(def); + } + + return null; + } + public int GetMapReturnStatus(RevocationStatusEnum hydrantIdStatus) { try @@ -160,22 +196,25 @@ public CertRequestBody GetEnrollmentRequest(Guid? policyId, EnrollmentProductInf if (productInfo == null) throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); - if (productInfo.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null."); if (string.IsNullOrEmpty(csr)) throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - if (!productInfo.ProductParameters.ContainsKey("ValidityPeriod")) - throw new ArgumentException("ValidityPeriod is required in ProductParameters.", nameof(productInfo)); - if (!productInfo.ProductParameters.ContainsKey("ValidityUnits")) - throw new ArgumentException("ValidityUnits is required in ProductParameters.", nameof(productInfo)); + // Resolve validity from the supplied parameters, falling back to annotation + // defaults when Command has not yet populated them (unsaved template — ADO 81803). + var validityPeriod = ResolveTemplateParameter(productInfo, HydrantIdCAPluginConfig.EnrollmentParametersConstants.ValidityPeriod); + var validityUnits = ResolveTemplateParameter(productInfo, HydrantIdCAPluginConfig.EnrollmentParametersConstants.ValidityUnits); + + if (string.IsNullOrWhiteSpace(validityPeriod)) + throw new ArgumentException("ValidityPeriod was not supplied and no annotation default is defined.", nameof(productInfo)); + if (string.IsNullOrWhiteSpace(validityUnits)) + throw new ArgumentException("ValidityUnits was not supplied and no annotation default is defined.", nameof(productInfo)); var request = new CertRequestBody { Policy = policyId, Csr = csr, DnComponents = GetDnComponentsRequest(csr), - Validity = GetValidity(productInfo.ProductParameters["ValidityPeriod"], Convert.ToInt16(productInfo.ProductParameters["ValidityUnits"])) + Validity = GetValidity(validityPeriod, Convert.ToInt16(validityUnits)) }; if (san != null && san.Count > 0) From 5bad9f5c96991acf2a58070884ed1028cc991fb7 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 21 Jul 2026 09:31:34 -0400 Subject: [PATCH 2/6] Target 26.2 gateway framework and align IAnyCAPlugin to 3.3.0 Update gateway_framework to 26.2 in integration-manifest.json and bump Keyfactor.AnyGateway.IAnyCAPlugin from 3.0.0 to 3.3.0, matching the versions used by the cscglobal and sslstore plugins on the 26.2 framework. --- HydrantCAProxy/HydrantIdCAPlugin.csproj | 2 +- integration-manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/HydrantCAProxy/HydrantIdCAPlugin.csproj b/HydrantCAProxy/HydrantIdCAPlugin.csproj index fef6cf3..d2ea4cd 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.csproj +++ b/HydrantCAProxy/HydrantIdCAPlugin.csproj @@ -8,7 +8,7 @@ HydrantIdCAPlugin - + diff --git a/integration-manifest.json b/integration-manifest.json index c8b6e47..3f9f33b 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -9,7 +9,7 @@ "support_level": "kf-supported", "link_github": true, "update_catalog": true, - "gateway_framework": "24.2", + "gateway_framework": "26.2", "about": { "carest": { "ca_plugin_config": [ From e1eba78d7d4ba0e43fcd61f564691338cdec02c1 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 21 Jul 2026 13:39:00 +0000 Subject: [PATCH 3/6] Update generated docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2fa1217..025d663 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The HID Global HydrantId AnyCA Gateway REST plugin extends the capabilities of H ## Compatibility -The HID Global AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. +The HID Global AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2 and later. ## Support The HID Global 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. From 9aae56e1a9ea35bf7fd584ce44e36c53d0a9b79b Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 21 Jul 2026 09:41:04 -0400 Subject: [PATCH 4/6] Upgrade starter workflow to starter.yml@v5 Bump the Keyfactor bootstrap workflow from starter.yml@v3 to @v5, matching the cscglobal and sslstore plugins. Adds the Command connection inputs (command_token_url, command_hostname, command_base_api_path) and the entra / command client secrets required by v5, and drops the obsolete APPROVE_README_PUSH secret. --- .github/workflows/keyfactor-starter-workflow.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml index 64919a4..0f3d3ae 100644 --- a/.github/workflows/keyfactor-starter-workflow.yml +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -11,10 +11,17 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v3 + uses: keyfactor/actions/.github/workflows/starter.yml@v5 + with: + command_token_url: ${{ vars.COMMAND_TOKEN_URL }} + command_hostname: ${{ vars.COMMAND_HOSTNAME }} + command_base_api_path: ${{ vars.COMMAND_API_PATH }} secrets: token: ${{ secrets.V2BUILDTOKEN}} - APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} scan_token: ${{ secrets.SAST_TOKEN }} + entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} + entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} + command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} + command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} From 761ac289f6b52a339cd4b8030de3e756264f1422 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 14:26:46 +0000 Subject: [PATCH 5/6] docs: auto-generate README and documentation [skip ci] --- README.md | 80 +++++++++++++++++++++++++++---------------------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 025d663..124566b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- The HID Global HydrantId AnyCA Gateway REST plugin extends the capabilities of HydrantId Certificate Authority Service to Keyfactor Command via the Keyfactor AnyCA Gateway. This plugin leverages the HydrantId REST API with Hawk authentication to provide comprehensive certificate lifecycle management. The plugin represents a fully featured AnyCA Plugin with the following capabilities: * **CA Sync**: @@ -55,7 +54,7 @@ The HID Global HydrantId AnyCA Gateway REST plugin extends the capabilities of H The HID Global AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2 and later. ## Support -The HID Global 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 HID Global AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. 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. @@ -175,16 +174,17 @@ The plugin supports the following standard CRL revocation reasons: 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [HID Global AnyCA Gateway REST plugin](https://github.com/Keyfactor/hydrantid-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net6.0` or `net8.0` or `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the HID Global 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 HID Global AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -197,17 +197,17 @@ The plugin supports the following standard CRL revocation reasons: * **Gateway Registration** ### CA Connection Configuration - + When registering the HydrantId CA in the AnyCA Gateway, you'll need to provide the following configuration parameters: - + | Parameter | Description | Required | Example | |-----------|-------------|----------|---------| | **HydrantIdBaseUrl** | Full URL to the HydrantId API endpoint | Yes | `https://acm.hydrantid.com` or `https://acm-stage.hydrantid.com` | | **HydrantIdAuthId** | API Authentication ID provided by HydrantId | Yes | `your-auth-id` | | **HydrantIdAuthKey** | API Authentication Key provided by HydrantId | Yes | `your-secret-auth-key` | - + ### Gateway Registration Notes - + - Each defined Certificate Authority in the AnyCA Gateway REST can support one HydrantId API endpoint - If you have multiple HydrantId environments or accounts, you must define multiple Certificate Authorities in the AnyCA Gateway - Each CA configuration will manifest in Command as a separate CA entry @@ -218,75 +218,74 @@ The plugin supports the following standard CRL revocation reasons: - Certificate status mapping - End-entity certificate extraction from PEM chains - Enrollment completion polling (30-second timeout) - + ### Security Considerations - + 1. **Credential Storage**: Store API credentials securely and restrict access to the Gateway configuration 2. **Secret Management**: Consider using a secrets management system for AuthKey storage 3. **Network Security**: Ensure TLS/SSL is properly configured for all API communications 4. **Least Privilege**: Request API credentials with minimal required permissions 5. **Audit Logging**: Enable comprehensive logging in both the Gateway and HydrantId for security monitoring 6. **Credential Rotation**: Regularly rotate API credentials according to your security policy - + **CA Connection** - + Populate using the configuration fields collected in the [requirements](#requirements) section. - + * **HydrantIdBaseUrl** - The base URL for the HydrantId API endpoint. For example, `https://acm.hydrantid.com` or `https://acm-stage.hydrantid.com`. * **HydrantIdAuthId** - The API Authentication ID provided by HydrantId for API access. * **HydrantIdAuthKey** - The API Authentication Key (secret) provided by HydrantId for API access. - + 2. **Certificate Template Configuration** - + After adding the CA to the Gateway, configure each certificate template: - + 1. Navigate to the Templates/Products section for the newly added CA 2. For each template (policy) discovered from HydrantId, configure: - **ValidityPeriod**: Select `Days`, `Months`, or `Years` - **ValidityUnits**: Enter the numeric value (e.g., `365` for one year in days) - **RenewalDays**: Enter the renewal window in days (e.g., `30`) - + Example configurations: - **1-Year Certificate (Days)**: ValidityPeriod=`Days`, ValidityUnits=`365`, RenewalDays=`30` - **2-Year Certificate (Years)**: ValidityPeriod=`Years`, ValidityUnits=`2`, RenewalDays=`60` - **6-Month Certificate (Months)**: ValidityPeriod=`Months`, ValidityUnits=`6`, RenewalDays=`30` - + 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. * **CA Connection** Populate using the configuration fields collected in the [requirements](#requirements) section. - * **HydrantIdBaseUrl** - The Base URL For the HydrantId Endpoint similar to https://acm-stage.hydrantid.com. Get this from HydrantId. - * **HydrantIdAuthId** - The AuthId Obtained from HydrantId. - * **HydrantIdAuthKey** - The AuthKey Obtained from HydrantId. - * **Enabled** - Flag to Enable or Disable the CA connector. + * **HydrantIdBaseUrl** - The Base URL For the HydrantId Endpoint similar to https://acm-stage.hydrantid.com. Get this from HydrantId. + * **HydrantIdAuthId** - The AuthId Obtained from HydrantId. + * **HydrantIdAuthKey** - The AuthKey Obtained from HydrantId. + * **Enabled** - Flag to Enable or Disable the CA connector. 2. ### Template (Product) Configuration - Each certificate template (policy) discovered from HydrantId requires configuration for enrollment: + Each certificate template (policy) discovered from HydrantId requires configuration for enrollment: - | Parameter | Description | Required | Example | - |-----------|-------------|----------|---------| - | **ValidityPeriod** | Time unit for certificate lifetime | Yes | `Days`, `Months`, or `Years` | - | **ValidityUnits** | Numeric value for the validity period | Yes | `365` (for 1 year in days), `12` (for 1 year in months), `2` (for 2 years) | - | **RenewalDays** | Days before expiration to trigger renewal | Yes | `30` (renew within 30 days of expiration) | + | Parameter | Description | Required | Example | + |-----------|-------------|----------|---------| + | **ValidityPeriod** | Time unit for certificate lifetime | Yes | `Days`, `Months`, or `Years` | + | **ValidityUnits** | Numeric value for the validity period | Yes | `365` (for 1 year in days), `12` (for 1 year in months), `2` (for 2 years) | + | **RenewalDays** | Days before expiration to trigger renewal | Yes | `30` (renew within 30 days of expiration) | - **Important Notes:** - - Template names (Product IDs) are automatically discovered from HydrantId using the GET /api/v2/policies endpoint - - The ValidityPeriod and ValidityUnits combine to determine the certificate lifetime - - RenewalDays determines the behavior for certificate renewal: - - Within window: Performs a renewal operation (maintains certificate lineage) - - Outside window: Performs a re-issue operation (new certificate enrollment) + **Important Notes:** + - Template names (Product IDs) are automatically discovered from HydrantId using the GET /api/v2/policies endpoint + - The ValidityPeriod and ValidityUnits combine to determine the certificate lifetime + - RenewalDays determines the behavior for certificate renewal: + - Within window: Performs a renewal operation (maintains certificate lineage) + - Outside window: Performs a re-issue operation (new certificate enrollment) 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. 4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: - * **ValidityPeriod** - The desired lifetime time period could be Days, Months or Years. - * **ValidityUnits** - The desired lifetime time value some number indicating days, months or years. - * **RenewalDays** - The window that determines whether it is a renewal vs a re-issue. - + * **ValidityPeriod** - The desired lifetime time period could be Days, Months or Years. + * **ValidityUnits** - The desired lifetime time value some number indicating days, months or years. + * **RenewalDays** - The window that determines whether it is a renewal vs a re-issue. ## Installation @@ -308,11 +307,10 @@ The plugin supports the following standard CRL revocation reasons: 5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the HID Global HydrantId plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. - ## License Apache License 2.0, see [LICENSE](LICENSE). ## Related Integrations -See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). From 0ba55c275eb114c32230e247198af8926b30bffe Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 21 Jul 2026 10:31:51 -0400 Subject: [PATCH 6/6] Add unit test project with coverage for RequestManager Add HydrantCAProxy.Tests (xUnit) and wire it into the solution. Covers the ADO 84076 template-parameter-default fix (ResolveTemplateParameter and its wiring through GetEnrollmentRequest) plus the previously untested RequestManager surface: revocation-reason mapping (including reason 0), status mapping, renewal/enrollment request building, SAN construction, certificate list requests, and enrollment result mapping. 38 tests. --- .../HydrantCAProxy.Tests.csproj | 28 ++ HydrantCAProxy.Tests/RequestManagerTests.cs | 345 ++++++++++++++++++ HydrantIdCAProxy.sln | 40 ++ 3 files changed, 413 insertions(+) create mode 100644 HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj create mode 100644 HydrantCAProxy.Tests/RequestManagerTests.cs diff --git a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj new file mode 100644 index 0000000..6c8eb2b --- /dev/null +++ b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + disable + disable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/HydrantCAProxy.Tests/RequestManagerTests.cs b/HydrantCAProxy.Tests/RequestManagerTests.cs new file mode 100644 index 0000000..509df49 --- /dev/null +++ b/HydrantCAProxy.Tests/RequestManagerTests.cs @@ -0,0 +1,345 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + +using System; +using System.Collections.Generic; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.HydrantId; +using Keyfactor.HydrantId; +using Keyfactor.HydrantId.Client.Models; +using Keyfactor.HydrantId.Client.Models.Enums; +using Keyfactor.HydrantId.Exceptions; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; + +namespace HydrantCAProxy.Tests +{ + public class RequestManagerTests + { + private readonly RequestManager _sut = new RequestManager(); + + // A valid PEM CSR (CN=unit.test.hydrantid.local) used to exercise the + // enrollment/DN-parsing paths without contacting a live CA. + private const string SampleCsr = + "-----BEGIN CERTIFICATE REQUEST-----\n" + + "MIICyDCCAbACAQAwgYIxCzAJBgNVBAYTAlVTMQ0wCwYDVQQIDARPaGlvMRUwEwYD\n" + + "VQQHDAxJbmRlcGVuZGVuY2UxEjAQBgNVBAoMCUtleWZhY3RvcjEVMBMGA1UECwwM\n" + + "SW50ZWdyYXRpb25zMSIwIAYDVQQDDBl1bml0LnRlc3QuaHlkcmFudGlkLmxvY2Fs\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7HMrgfnq6o9t+7NAI4wZ\n" + + "XmiIY3lQcuEA2drbwDqx1HW78xbs6ajhIO8A68RpHUjdBfgOl+3zwCcjgbi8+whI\n" + + "OHubyMsonPCvCoKVUNv1CBclDcKEf+zAFuc7TWeL8n9aZNIeI/mLqhDxt2ZPIPuC\n" + + "tNh1wZToQ5gf4u/LQXSksLwbiITeBsATEKGNMsTERM7gYuldPQFS3bTof7LGRPWT\n" + + "shwNiBv6dw5QIgmXOBSJWdT0NfWVNudTF1wxV+41E/mvQCM+66Onw+ialH1nRefh\n" + + "LCiWIT48LLHLrYN045QorzqbDPzk8itpka+6JA04rlNKcSOBurAypkWBvhnU9N8F\n" + + "pQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBADO6dln9VOVkCG5qTBuifSxrGgDt\n" + + "IoQFIHxtMVhMI2CiPPeDDfJpPDX7CoHKRGKelilwxnWlOfzupv1Qb/02YXXq/F/Z\n" + + "twSyVAIbisuzL6RLIGox3GSkwlM0JTiyjASUJyVextRvxlmMRWTdc4z2v7Wxgmbf\n" + + "k8wZ7VrUYofBmAj9S3ozilPWRKspl/BZrm+4IIoufa2BKfMnGQGbsad22mrpkRtG\n" + + "1gm6iZDzaVTSC3iO5+CA/ZNwRT2ShIAHAbZTUSf62n5+nfs8Wki67i96hQqX7qIT\n" + + "MRXVBIV6K2c9Ls9aEh5qnPR8wre/VMaufCliSb0Q4X50Tal8kJZbS6/ZfJo=\n" + + "-----END CERTIFICATE REQUEST-----"; + + private static EnrollmentProductInfo ProductInfo(Dictionary parameters) => + new EnrollmentProductInfo { ProductID = "test-policy", ProductParameters = parameters }; + + // --------------------------------------------------------------------- + // ResolveTemplateParameter — the ADO 84076 / 81803 fix. + // Command does not populate a template's parameter collection with the + // annotation defaults until the template is saved. The resolver must fall + // back to the declared DefaultValue so enrollment still works. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("ValidityPeriod", "Years")] + [InlineData("ValidityUnits", "1")] + [InlineData("RenewalDays", "30")] + public void ResolveTemplateParameter_KeyMissing_ReturnsAnnotationDefault(string key, string expected) + { + // Unsaved template: Command supplies an empty parameter collection. + var productInfo = ProductInfo(new Dictionary()); + + var result = RequestManager.ResolveTemplateParameter(productInfo, key); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ResolveTemplateParameter_BlankValue_ReturnsAnnotationDefault(string blank) + { + var productInfo = ProductInfo(new Dictionary { ["ValidityPeriod"] = blank }); + + var result = RequestManager.ResolveTemplateParameter(productInfo, "ValidityPeriod"); + + Assert.Equal("Years", result); + } + + [Fact] + public void ResolveTemplateParameter_ValueSupplied_ReturnsSuppliedValue() + { + var productInfo = ProductInfo(new Dictionary { ["ValidityPeriod"] = "Days" }); + + var result = RequestManager.ResolveTemplateParameter(productInfo, "ValidityPeriod"); + + Assert.Equal("Days", result); + } + + [Fact] + public void ResolveTemplateParameter_NullProductParameters_ReturnsAnnotationDefault() + { + var productInfo = ProductInfo(null); + + var result = RequestManager.ResolveTemplateParameter(productInfo, "RenewalDays"); + + Assert.Equal("30", result); + } + + [Fact] + public void ResolveTemplateParameter_NullProductInfo_ReturnsAnnotationDefault() + { + var result = RequestManager.ResolveTemplateParameter(null, "ValidityUnits"); + + Assert.Equal("1", result); + } + + [Fact] + public void ResolveTemplateParameter_UnknownKeyWithNoDefault_ReturnsNull() + { + var productInfo = ProductInfo(new Dictionary()); + + var result = RequestManager.ResolveTemplateParameter(productInfo, "NotADeclaredParameter"); + + Assert.Null(result); + } + + // --------------------------------------------------------------------- + // GetEnrollmentRequest — proves the resolver is actually wired into + // enrollment: an unsaved template (no parameters) still produces a valid + // request using the annotation defaults instead of throwing. + // --------------------------------------------------------------------- + + [Fact] + public void GetEnrollmentRequest_UnsavedTemplate_UsesDefaultValidity() + { + var productInfo = ProductInfo(new Dictionary()); + + var request = _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, null); + + // Defaults are ValidityPeriod=Years, ValidityUnits=1. + Assert.NotNull(request); + Assert.Equal(1, request.Validity.Years); + Assert.Null(request.Validity.Months); + Assert.Null(request.Validity.Days); + } + + [Fact] + public void GetEnrollmentRequest_SuppliedValidity_HonorsSuppliedValues() + { + var productInfo = ProductInfo(new Dictionary + { + ["ValidityPeriod"] = "Months", + ["ValidityUnits"] = "6" + }); + + var request = _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, null); + + Assert.Equal(6, request.Validity.Months); + Assert.Null(request.Validity.Years); + } + + [Fact] + public void GetEnrollmentRequest_NullCsr_Throws() + { + var productInfo = ProductInfo(new Dictionary()); + + Assert.Throws(() => _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, null, null)); + } + + [Fact] + public void GetEnrollmentRequest_NullProductInfo_Throws() + { + Assert.Throws(() => _sut.GetEnrollmentRequest(Guid.NewGuid(), null, SampleCsr, null)); + } + + [Fact] + public void GetEnrollmentRequest_WithSans_PopulatesSubjectAltNames() + { + var productInfo = ProductInfo(new Dictionary()); + var sans = new Dictionary { ["dnsname"] = new[] { "a.example.com", "b.example.com" } }; + + var request = _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, sans); + + Assert.NotNull(request.SubjectAltNames); + Assert.Equal(2, request.SubjectAltNames.Dnsname.Count); + } + + // --------------------------------------------------------------------- + // GetMapRevokeReasons — revocation reason mapping (incl. ADO 86120 + // reason 0 = Unspecified). + // --------------------------------------------------------------------- + + [Theory] + [InlineData(0u, RevocationReasons.Unspecified)] + [InlineData(1u, RevocationReasons.KeyCompromise)] + [InlineData(3u, RevocationReasons.AffiliationChanged)] + [InlineData(4u, RevocationReasons.Superseded)] + [InlineData(5u, RevocationReasons.CessationOfOperation)] + public void GetMapRevokeReasons_SupportedReason_MapsCorrectly(uint input, RevocationReasons expected) + { + Assert.Equal(expected, _sut.GetMapRevokeReasons(input)); + } + + [Theory] + [InlineData(2u)] // certificateHold — not supported + [InlineData(6u)] + [InlineData(99u)] + public void GetMapRevokeReasons_UnsupportedReason_Throws(uint input) + { + Assert.Throws(() => _sut.GetMapRevokeReasons(input)); + } + + [Fact] + public void GetRevokeRequest_SetsReason() + { + var result = _sut.GetRevokeRequest(RevocationReasons.KeyCompromise); + + Assert.Equal(RevocationReasons.KeyCompromise, result.Reason); + } + + // --------------------------------------------------------------------- + // GetMapReturnStatus — HydrantId status -> Keyfactor EndEntityStatus. + // --------------------------------------------------------------------- + + [Theory] + [InlineData(RevocationStatusEnum.Valid, EndEntityStatus.GENERATED)] + [InlineData(RevocationStatusEnum.Pending, EndEntityStatus.INPROCESS)] + [InlineData(RevocationStatusEnum.Revoked, EndEntityStatus.REVOKED)] + [InlineData(RevocationStatusEnum.Failed, EndEntityStatus.FAILED)] + [InlineData(RevocationStatusEnum.Expired, EndEntityStatus.FAILED)] // default branch + public void GetMapReturnStatus_MapsToExpectedEndEntityStatus(RevocationStatusEnum input, EndEntityStatus expected) + { + Assert.Equal((int)expected, _sut.GetMapReturnStatus(input)); + } + + // --------------------------------------------------------------------- + // GetRenewalRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewalRequest_WithCsr_SetsCsrAndReuseFlag() + { + var result = _sut.GetRenewalRequest("some-csr", reuseCsr: false); + + Assert.Equal("some-csr", result.Csr); + Assert.False(result.ReuseCsr); + } + + [Fact] + public void GetRenewalRequest_ReuseCsrWithoutCsr_DoesNotThrow() + { + var result = _sut.GetRenewalRequest(null, reuseCsr: true); + + Assert.True(result.ReuseCsr); + } + + [Fact] + public void GetRenewalRequest_NoCsrAndNoReuse_Throws() + { + Assert.Throws(() => _sut.GetRenewalRequest(null, reuseCsr: false)); + } + + // --------------------------------------------------------------------- + // GetSansRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetSansRequest_Null_ReturnsEmptySans() + { + var result = _sut.GetSansRequest(null); + + Assert.NotNull(result); + } + + [Fact] + public void GetSansRequest_AllTypes_Populated() + { + var sans = new Dictionary + { + ["dnsname"] = new[] { "example.com" }, + ["ipaddress"] = new[] { "10.0.0.1", "10.0.0.2" }, + ["rfc822name"] = new[] { "user@example.com" }, + ["upn"] = new[] { "user@corp.local" } + }; + + var result = _sut.GetSansRequest(sans); + + Assert.Single(result.Dnsname); + Assert.Equal(2, result.Ipaddress.Count); + Assert.Single(result.Rfc822Name); + Assert.Single(result.Upn); + } + + // --------------------------------------------------------------------- + // GetCertificatesListRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetCertificatesListRequest_SetsOffsetAndLimit() + { + var result = _sut.GetCertificatesListRequest(offset: 100, limit: 50); + + Assert.Equal(100, result.Offset); + Assert.Equal(50, result.Limit); + Assert.True(result.Expired); + } + + // --------------------------------------------------------------------- + // GetEnrollmentResult + // --------------------------------------------------------------------- + + [Fact] + public void GetEnrollmentResult_NullEnrollmentResult_ReturnsFailed() + { + var result = _sut.GetEnrollmentResult(null, new AnyCAPluginCertificate { Certificate = "cert" }); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_MissingId_ReturnsFailed() + { + var cert = new Certificate { Id = null }; + + var result = _sut.GetEnrollmentResult(cert, new AnyCAPluginCertificate { Certificate = "cert" }); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_MissingCertificateContent_ReturnsFailed() + { + var cert = new Certificate { Id = Guid.NewGuid() }; + + var result = _sut.GetEnrollmentResult(cert, new AnyCAPluginCertificate { Certificate = "" }); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_Valid_ReturnsGenerated() + { + var id = Guid.NewGuid(); + var cert = new Certificate { Id = id }; + var pluginCert = new AnyCAPluginCertificate { Certificate = "BASE64CERT" }; + + var result = _sut.GetEnrollmentResult(cert, pluginCert); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal(id.ToString(), result.CARequestID); + Assert.Equal("BASE64CERT", result.Certificate); + } + } +} diff --git a/HydrantIdCAProxy.sln b/HydrantIdCAProxy.sln index ed1d538..96b4291 100644 --- a/HydrantIdCAProxy.sln +++ b/HydrantIdCAProxy.sln @@ -14,19 +14,59 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\..\README.md = ..\..\README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HydrantCAProxy.Tests", "HydrantCAProxy.Tests\HydrantCAProxy.Tests.csproj", "{5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HydrantCAProxy", "HydrantCAProxy", "{3E513467-2F3E-ABB0-F7F2-0CA04438089D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Prerelease|Any CPU = Prerelease|Any CPU + Prerelease|x64 = Prerelease|x64 + Prerelease|x86 = Prerelease|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|Any CPU.Build.0 = Debug|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|x64.ActiveCfg = Debug|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|x64.Build.0 = Debug|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|x86.ActiveCfg = Debug|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|x86.Build.0 = Debug|Any CPU {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|Any CPU.ActiveCfg = Release|Any CPU {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|Any CPU.Build.0 = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|x64.ActiveCfg = Prerelease|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|x64.Build.0 = Prerelease|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|x86.ActiveCfg = Prerelease|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|x86.Build.0 = Prerelease|Any CPU {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|Any CPU.ActiveCfg = Release|Any CPU {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|Any CPU.Build.0 = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|x64.ActiveCfg = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|x64.Build.0 = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|x86.ActiveCfg = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|x86.Build.0 = Release|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Debug|x64.Build.0 = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Debug|x86.Build.0 = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Prerelease|Any CPU.ActiveCfg = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Prerelease|Any CPU.Build.0 = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Prerelease|x64.ActiveCfg = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Prerelease|x64.Build.0 = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Prerelease|x86.ActiveCfg = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Prerelease|x86.Build.0 = Debug|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Release|Any CPU.Build.0 = Release|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Release|x64.ActiveCfg = Release|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Release|x64.Build.0 = Release|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Release|x86.ActiveCfg = Release|Any CPU + {5DAFF413-A3CE-4451-BCEE-0AD9C6C09D41}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE