From ac434626d012ec9326f5bc42bb4aeb9ea3c7dc70 Mon Sep 17 00:00:00 2001 From: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:02:21 -0400 Subject: [PATCH 1/3] Release: 1.0.0 Initial release --------- Co-authored-by: Keyfactor Signed-off-by: Morgan Gangwere --- .gitignore | 2 +- CHANGELOG.md | 12 +- README.md | 61 +- aws-pca-caplugin/AWSPCACAPlugin.cs | 295 +++-- aws-pca-caplugin/AWSPCACAPlugin.csproj | 3 +- aws-pca-caplugin/CHANGELOG.md | 9 - aws-pca-caplugin/Client/ACMPCAClient.cs | 1352 +++++++++++++---------- aws-pca-caplugin/Constants.cs | 2 + aws-pca-caplugin/Models/Models.cs | 9 +- docsource/configuration.md | 55 +- integration-manifest.json | 8 + 11 files changed, 1097 insertions(+), 711 deletions(-) delete mode 100644 aws-pca-caplugin/CHANGELOG.md diff --git a/.gitignore b/.gitignore index 7d8da62..43d9deb 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,4 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd diff --git a/CHANGELOG.md b/CHANGELOG.md index d1bd073..2395c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,3 @@ -v.1.0.2 -- Warning: enrollment field/template parameter with the name "CN DCV Email (admin@boingy.com)" has been renamed to "CN DCV Email" to make it compatible with the REST gateway. "Aplicant Pgone (+nn.nnnnnnnn)" has also been renamed to "Applicant Phone". -- Updated dependencies. -- Added support for default values via enrollment parameters configured in the AnyGateway REST certificate template. -- Fixed issue with non-ASCII characters breaking the gateway. - -v1.0.1 -- Fixed issue with SANs not being read correctly. - v1.0 -- Initial Release. \ No newline at end of file + +- Initial Release. diff --git a/README.md b/README.md index 940709f..6a2f36e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and * **Gateway Registration** - TODO Gateway Registration is a required section + Download the **PCA root certificate** from AWS and have it ready to import into the Gateway **in `.pem` format**. * **CA Connection** @@ -98,6 +98,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and * **IAMUserAccessKey** - IAM user access key (secret). * **IAMUserAccessSecret** - IAM user access secret (secret). * **ExternalId** - Optional sts:ExternalId to supply on AssumeRole calls. + * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. 2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The AWSPCA CA Gateway plugin supports the following product IDs: @@ -107,12 +108,22 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and 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: + + * **LifetimeDays** - OPTIONAL: The number of days of validity to use when requesting certs. If not provided, default is 365 + * **SigningAlgorithm** - Required: AWS ACM PCA certificate signature algorithm to use when issuing certificates. Value is an AWS PCA SigningAlgorithm enum name (case-insensitive), e.g. SHA256WITHRSA, SHA384WITHRSA, SHA256WITHECDSA. If omitted, the plugin selects a default compatible with the CA key algorithm. + ## Authentication (Access Key + Secret) The CAPlugin currently supports **one** authentication method: **AWS Access Key ID + Secret Access Key**. **OAuth** and **Default SDK authentication** will be enabled in later updates. There is functionality present via the **Keyfactor AWS Authentication** library, but these alternate methods are currently ***untested***. +### Known Issues + +At present, a fresh install of Keyfactor Command 24.4 used in conjuction with Keyfactor Gateway REST 25.4.0.0 is confirmed as working. A fresh install of Command 25.3 used with REST 25.4.0.0 is also confirmed as working. +Latest version of Command 25.4 may run into issues, investigation into compatibility issues is ongoing. + ### What you need ready Before configuring the CAPlugin, have the following prepared: @@ -121,7 +132,7 @@ Before configuring the CAPlugin, have the following prepared: - **Access Key ID** (example format: `AKIAIOSFODNN7EXAMPLE`) - **Secret Access Key** (example format: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) -#### 2) A target IAM Role the connector will run as (recommended) +#### 2) A target IAM Role the Gateway will run as (recommended) Example: - `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole` @@ -292,6 +303,52 @@ The following examples are intended as **copy/adapt templates**. } ``` +--- + +## Signing algorithm selection (ACM PCA) + +The connector supports an optional **template / product parameter** named `SigningAlgorithm` that controls the **certificate signature algorithm** +passed to AWS ACM PCA `IssueCertificate`. + +- If **not set**, the plugin will **auto-select** a compatible default based on the CA `KeyAlgorithm` returned by + `DescribeCertificateAuthority`. +- If **set**, the plugin validates the value and **rejects incompatible combinations** before calling AWS. + +### Where to configure + +Set `SigningAlgorithm` on the **AnyGateway template** (product parameters), alongside `LifetimeDays`. + +### Valid `SigningAlgorithm` values (AWS PCA) + +- RSA family: `SHA256WITHRSA`, `SHA384WITHRSA`, `SHA512WITHRSA` +- ECDSA family: `SHA256WITHECDSA`, `SHA384WITHECDSA`, `SHA512WITHECDSA` +- SM2: `SM3WITHSM2` +- ML-DSA (post-quantum): `ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87` + +### Allowed CA key algorithm <-> signing algorithm combinations + +The CA key algorithm is the PCA CA **KeyAlgorithm** (not the subject key in the CSR). The signing algorithm must match the CA key family. + +| CA KeyAlgorithm | Allowed SigningAlgorithm values | +|---|---| +| `RSA_2048`, `RSA_3072`, `RSA_4096` | `SHA256WITHRSA`, `SHA384WITHRSA`, `SHA512WITHRSA` | +| `EC_prime256v1`, `EC_secp384r1`, `EC_secp521r1` | `SHA256WITHECDSA`, `SHA384WITHECDSA`, `SHA512WITHECDSA` | +| `SM2` | `SM3WITHSM2` | +| `ML_DSA_44` | `ML_DSA_44` | +| `ML_DSA_65` | `ML_DSA_65` | +| `ML_DSA_87` | `ML_DSA_87` | + +### Auto-selection defaults + +When `SigningAlgorithm` is omitted, the plugin selects: + +- RSA CAs -> `SHA256WITHRSA` +- EC P-256 -> `SHA256WITHECDSA` +- EC P-384 -> `SHA384WITHECDSA` +- EC P-521 -> `SHA512WITHECDSA` +- SM2 -> `SM3WITHSM2` +- ML-DSA -> exact-match (`ML_DSA_44/65/87`) + ## License diff --git a/aws-pca-caplugin/AWSPCACAPlugin.cs b/aws-pca-caplugin/AWSPCACAPlugin.cs index 7670a51..2201fc9 100644 --- a/aws-pca-caplugin/AWSPCACAPlugin.cs +++ b/aws-pca-caplugin/AWSPCACAPlugin.cs @@ -5,11 +5,6 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. -using System.Collections.Concurrent; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.RegularExpressions; using Amazon.ACMPCA; using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.AWS.Client; @@ -19,6 +14,12 @@ using Keyfactor.PKI.Enums.EJBCA; using Keyfactor.PKI.PEM; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.RegularExpressions; +using static Org.BouncyCastle.Math.EC.ECCurve; namespace Keyfactor.Extensions.CAPlugin.AWS; @@ -33,15 +34,23 @@ public AWSPCACAPlugin() } private IAwsPcaClient AwsClient { get; set; } - + private bool _enabled = false; //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { Logger.MethodEntry(LogLevel.Debug); _certificateDataReader = certificateDataReader; + var _enabled = bool.Parse(GetRequiredString(configProvider, "Enabled")); + if (!_enabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation and AWS PCA Client creation..."); + Logger.MethodExit(); + return; + } AwsClient = new AwsPcaClient(configProvider); Logger.MethodExit(LogLevel.Debug); + } //done @@ -78,10 +87,12 @@ public async Task GetSingleRecord(string caRequestID) throw; } } - - //done - public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, - bool fullSync, CancellationToken cancelToken) + // done + public async Task Synchronize( + BlockingCollection blockingBuffer, + DateTime? lastSync, + bool fullSync, + CancellationToken cancelToken) { Logger.MethodEntry(); Logger.LogTrace( @@ -105,6 +116,7 @@ public async Task Synchronize(BlockingCollection blockin var caRequestId = SafeRequestIdFromArn(audit.certificateArn); if (string.IsNullOrWhiteSpace(caRequestId) && !string.IsNullOrWhiteSpace(audit.certificateSerial)) + { try { caRequestId = await _certificateDataReader @@ -116,6 +128,7 @@ public async Task Synchronize(BlockingCollection blockin Logger.LogTrace( $"Could not map serial to requestId. serial={audit.certificateSerial}. {ex.Message}"); } + } if (string.IsNullOrWhiteSpace(caRequestId)) { @@ -151,7 +164,8 @@ public async Task Synchronize(BlockingCollection blockin { var exp = _certificateDataReader.GetExpirationDateByRequestId(caRequestId); if (exp.HasValue && exp.Value.ToUniversalTime() <= DateTime.UtcNow && - newStatus == (int)EndEntityStatus.GENERATED) newStatus = (int)EndEntityStatus.HISTORICAL; + newStatus == (int)EndEntityStatus.GENERATED) + newStatus = (int)EndEntityStatus.HISTORICAL; } catch (Exception ex) { @@ -175,6 +189,7 @@ public async Task Synchronize(BlockingCollection blockin } if (exists) + { try { var oldStatus = await _certificateDataReader.GetStatusByRequestID(caRequestId) @@ -191,65 +206,92 @@ public async Task Synchronize(BlockingCollection blockin Logger.LogWarning( $"GetStatusByRequestID failed for {caRequestId}: {ex.Message}. Proceeding to emit."); } + } } - // If we only need to update status (revoked/historical) and you don't want to fetch the cert again: - // - For revoked/historical, Keyfactor usually still accepts just status update, but depending on your pipeline, - // you may want to always include Certificate for GENERATED. - // Here: fetch cert for GENERATED/HISTORICAL; skip fetch for REVOKED if ARN missing. - string? certB64 = null; + // Fetch certificate material when needed: + // - GENERATED/HISTORICAL: must fetch (otherwise Keyfactor can't ingest new cert) + // - REVOKED: best-effort fetch if ARN exists (so revoked updates can include PEM); otherwise emit status-only + string? certPayload = null; string? productId = null; - if (newStatus == (int)EndEntityStatus.GENERATED || newStatus == (int)EndEntityStatus.HISTORICAL) - { - if (string.IsNullOrWhiteSpace(audit.certificateArn)) - { - Logger.LogTrace($"Skipping {caRequestId}: no certificateArn to retrieve certificate."); - continue; - } + bool mustFetchForIngest = + newStatus == (int)EndEntityStatus.GENERATED || + newStatus == (int)EndEntityStatus.HISTORICAL; + + bool bestEffortFetch = + newStatus == (int)EndEntityStatus.REVOKED; - var certResp = await AwsClient.SubmitGetCertificateByArnAsync(audit.certificateArn, cancelToken) - .ConfigureAwait(false); + bool shouldAttemptFetch = mustFetchForIngest || bestEffortFetch; - if (certResp?.Status == (int)EndEntityStatus.INPROCESS) + if (shouldAttemptFetch) + { + if (string.IsNullOrWhiteSpace(audit.certificateArn)) { - // Emit in-process update without cert payload - blockingBuffer.Add(new AnyCAPluginCertificate + if (mustFetchForIngest) { - CARequestID = caRequestId, - Status = (int)EndEntityStatus.INPROCESS, - ProductID = certResp.CertificateType - }, cancelToken); + Logger.LogTrace($"Skipping {caRequestId}: no certificateArn to retrieve certificate."); + continue; // can't ingest a new/active cert without payload + } - continue; + // REVOKED (best-effort): status-only update + Logger.LogTrace($"Revoked status-only for {caRequestId}: no certificateArn present."); } - - if (certResp?.RegistrationError != null) + else { - Logger.LogTrace( - $"Skipping {caRequestId}: GetCertificate error: {certResp.RegistrationError.Description}"); - continue; - } + var certResp = await AwsClient + .SubmitGetCertificateByArnAsync(audit.certificateArn, cancelToken) + .ConfigureAwait(false); - certB64 = certResp.Certificate; - productId = certResp.CertificateType; + if (certResp?.Status == (int)EndEntityStatus.INPROCESS) + { + // Emit in-process update without cert payload + blockingBuffer.Add(new AnyCAPluginCertificate + { + CARequestID = caRequestId, + Status = (int)EndEntityStatus.INPROCESS, + ProductID = certResp.CertificateType + }, cancelToken); + continue; + } - if (string.IsNullOrWhiteSpace(certB64)) - { - Logger.LogTrace($"Skipping {caRequestId}: unable to obtain end-entity certificate payload."); - continue; + if (certResp?.RegistrationError != null) + { + if (mustFetchForIngest) + { + Logger.LogTrace( + $"Skipping {caRequestId}: GetCertificate error: {certResp.RegistrationError.Description}"); + continue; + } + + // REVOKED best-effort: proceed status-only + Logger.LogTrace( + $"Revoked status-only for {caRequestId}: GetCertificate error: {certResp.RegistrationError.Description}"); + } + else + { + certPayload = certResp?.Certificate; + productId = certResp?.CertificateType; + + if (mustFetchForIngest && string.IsNullOrWhiteSpace(certPayload)) + { + Logger.LogTrace($"Skipping {caRequestId}: unable to obtain end-entity certificate payload."); + continue; + } + } } } var finalsubmit = new AnyCAPluginCertificate { CARequestID = caRequestId, - Certificate = - GetEndEntityCertificate(certB64), // null is OK for REVOKED updates if your pipeline accepts it + // For REVOKED: this will now be populated when ARN exists + GetCertificate succeeds; otherwise null (status-only). + Certificate = GetEndEntityCertificate(certPayload), Status = newStatus, ProductID = productId }; + // Emit to buffer as AnyGateway expects. blockingBuffer.Add(finalsubmit, cancelToken); } @@ -358,65 +400,75 @@ public async Task Enroll( parsed > 0) days = parsed; + + // Optional signing algorithm override (template parameter) + string? signingAlgorithm = null; + if (productInfo.ProductParameters != null && + productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.SigningAlgorithm, + out var algoStr) && + !string.IsNullOrWhiteSpace(algoStr)) + signingAlgorithm = algoStr.Trim(); + // Normalize CSR to PEM (keeps your existing behavior) csr = PemUtilities.DERToPEM(PemUtilities.PEMToDER(csr), PemUtilities.PemObjectType.CertRequest); switch (enrollmentType) { case EnrollmentType.New: - { - return await IssueAndFetchAsync( - csr, - productInfo.ProductID, - days, - "Certificate Issued") - .ConfigureAwait(false); - } - - case EnrollmentType.RenewOrReissue: - { - if (productInfo.ProductParameters == null || - !TryGetProductParam(productInfo.ProductParameters, "PriorCertSN", out var priorSn) || - string.IsNullOrWhiteSpace(priorSn)) - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = - "Renew/Reissue requires ProductParameters['PriorCertSN'] (hex serial number)." - }; - - string priorRequestId; - try { - priorRequestId = await _certificateDataReader - .GetRequestIDBySerialNumber(priorSn) + return await IssueAndFetchAsync( + csr, + productInfo.ProductID, + days, + signingAlgorithm, + "Certificate Issued") .ConfigureAwait(false); } - catch (Exception ex) + + case EnrollmentType.RenewOrReissue: { - return new EnrollmentResult + if (productInfo.ProductParameters == null || + !TryGetProductParam(productInfo.ProductParameters, "PriorCertSN", out var priorSn) || + string.IsNullOrWhiteSpace(priorSn)) + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = + "Renew/Reissue requires ProductParameters['PriorCertSN'] (hex serial number)." + }; + + string priorRequestId; + try { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"Could not resolve PriorCertSN to request id: {ex.Message}" - }; - } + priorRequestId = await _certificateDataReader + .GetRequestIDBySerialNumber(priorSn) + .ConfigureAwait(false); + } + catch (Exception ex) + { + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"Could not resolve PriorCertSN to request id: {ex.Message}" + }; + } - var expiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); - var isRenewal = expiration.HasValue && expiration.Value.ToUniversalTime() <= DateTime.UtcNow; - - var msg = isRenewal ? "Certificate Renewed" : "Certificate Reissued"; - var token = BuildIdempotencyToken(isRenewal ? "renew" : "reissue", priorRequestId, csr); - - // Still "IssueCertificate" under the hood; PCA doesn't have first-class renew/reissue. - return await IssueAndFetchAsync( - csr, - productInfo.ProductID, - days, - msg, - // Optional: stable-ish idempotency (helps avoid duplicates if caller retries quickly) - token) - .ConfigureAwait(false); - } + var expiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); + var isRenewal = expiration.HasValue && expiration.Value.ToUniversalTime() <= DateTime.UtcNow; + + var msg = isRenewal ? "Certificate Renewed" : "Certificate Reissued"; + var token = BuildIdempotencyToken(isRenewal ? "renew" : "reissue", priorRequestId, csr); + + // Still "IssueCertificate" under the hood; PCA doesn't have first-class renew/reissue. + return await IssueAndFetchAsync( + csr, + productInfo.ProductID, + days, + msg, + // Optional: stable-ish idempotency (helps avoid duplicates if caller retries quickly) + token) + .ConfigureAwait(false); + } default: return new EnrollmentResult @@ -445,6 +497,12 @@ public async Task Enroll( public async Task Ping() { Logger.MethodEntry(); + if (!_enabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation and AWS PCA Client creation..."); + Logger.MethodExit(); + return; + } try { Logger.LogInformation("Ping request received"); @@ -460,12 +518,27 @@ public async Task Ping() } //do - public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) + public Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + try + { + if (!(bool)connectionInfo["Enabled"]) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping validation..."); + Logger.MethodExit(LogLevel.Trace); + return Task.CompletedTask; + } + } + catch (Exception ex) + { + Logger.LogError($"Exception: {LogHandler.FlattenException(ex)}"); + } + + return Task.CompletedTask; } //do - public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, + public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { var certType = Constants.GetTemplateTypes().Find(x => @@ -474,6 +547,7 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + return Task.CompletedTask; } //done @@ -610,6 +684,13 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = "", Type = "String" + }, + [Constants.Enabled] = new() + { + Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" } }; } @@ -627,6 +708,15 @@ public Dictionary GetTemplateParameterAnnotations() Hidden = false, DefaultValue = 365, Type = "Number" + }, + // this is used to passdown csr details/prefill. will be overridden by commmand if not present. + [EnrollmentConfigConstants.SigningAlgorithm] = new() + { + Comments = + "Required: AWS ACM PCA certificate signature algorithm to use when issuing certificates. Value is an AWS PCA SigningAlgorithm enum name (case-insensitive), e.g. SHA256WITHRSA, SHA384WITHRSA, SHA256WITHECDSA. If omitted, the plugin selects a default compatible with the CA key algorithm.", + Hidden = false, + DefaultValue = "SHA256WITHRSA", + Type = "String" } }; } @@ -641,6 +731,7 @@ private async Task IssueAndFetchAsync( string csrPem, string productId, int validityDays, + string? signingAlgorithm, string statusMessageOnSuccess, string? idempotencyToken = null) { @@ -650,6 +741,7 @@ private async Task IssueAndFetchAsync( CsrPem = csrPem, ProductId = productId, ValidityDays = validityDays, + SigningAlgorithm = signingAlgorithm, IdempotencyToken = idempotencyToken ?? Guid.NewGuid().ToString("N") }; @@ -1069,6 +1161,17 @@ private string PreparePemTextFromApi(string? base64) return text; } + private static string GetRequiredString(IAnyCAPluginConfigProvider provider, string key) + { + if (!provider.CAConnectionData.TryGetValue(key, out var obj) || obj == null) + throw new InvalidOperationException($"Missing required configuration value '{key}'."); + + var str = obj.ToString(); + if (string.IsNullOrWhiteSpace(str)) + throw new InvalidOperationException($"Configuration value '{key}' is empty."); + + return str!; + } #endregion } \ No newline at end of file diff --git a/aws-pca-caplugin/AWSPCACAPlugin.csproj b/aws-pca-caplugin/AWSPCACAPlugin.csproj index c98f713..ae30f76 100644 --- a/aws-pca-caplugin/AWSPCACAPlugin.csproj +++ b/aws-pca-caplugin/AWSPCACAPlugin.csproj @@ -13,7 +13,8 @@ - + diff --git a/aws-pca-caplugin/CHANGELOG.md b/aws-pca-caplugin/CHANGELOG.md deleted file mode 100644 index 737b421..0000000 --- a/aws-pca-caplugin/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -v1.1.0 -- Support for custom fields in enrollment -- Support for returning CNAME tokens from enrollment call - -v1.0.1 -- Fixed issue with SANs not being read correctly. - -v1.0 -- Initial Release. \ No newline at end of file diff --git a/aws-pca-caplugin/Client/ACMPCAClient.cs b/aws-pca-caplugin/Client/ACMPCAClient.cs index e87897c..008ddb6 100644 --- a/aws-pca-caplugin/Client/ACMPCAClient.cs +++ b/aws-pca-caplugin/Client/ACMPCAClient.cs @@ -1,588 +1,764 @@ -// Copyright 2022 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 -// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions -// and limitations under the License. - -using System.Security.Cryptography.X509Certificates; -using System.Text; -using Amazon; -using Amazon.ACMPCA; -using Amazon.ACMPCA.Model; -using Amazon.Runtime; -using Amazon.S3; -using Amazon.S3.Model; -using Keyfactor.AnyGateway.Extensions; -using Keyfactor.Extensions.CAPlugin.AWS.Interfaces; -using Keyfactor.Extensions.CAPlugin.AWS.Models; -using Keyfactor.Logging; -using Keyfactor.PKI.Enums.EJBCA; -using Keyfactor.PKI.PEM; -using Keyfactor.PKI.X509; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using IssueCertificateRequest = Keyfactor.Extensions.CAPlugin.AWS.Models.IssueCertificateRequest; -using IssueCertificateResponse = Keyfactor.Extensions.CAPlugin.AWS.Models.IssueCertificateResponse; -using SigningAlgorithm = Amazon.ACMPCA.SigningAlgorithm; - -namespace Keyfactor.Extensions.CAPlugin.AWS.Client; - -/// -/// CSC-style client wrapper for AWS ACM PCA. -/// - Pulls config from IAnyCAPluginConfigProvider -/// - Returns response DTOs with RegistrationError instead of throwing for normal failure paths -/// - Normalizes all certificate statuses to Keyfactor's EndEntityStatus numeric values -/// -public sealed class AwsPcaClient : IAwsPcaClient -{ - private const string ENHANCED_KEY_USAGE_OID = "2.5.29.37"; - private const string SERVER_AUTH_OID = "1.3.6.1.5.5.7.3.1"; - private const string CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2"; - private readonly AWSCredentials AwsCredentials; - private readonly string CaArn; - private readonly ILogger Logger; - - private readonly IAmazonACMPCA PcaClient; - private readonly RegionEndpoint Region; - private readonly string RoleArn; - private readonly string S3Bucket; - private IAmazonS3? S3Client; - - public AwsPcaClient(IAnyCAPluginConfigProvider configProvider) - { - Logger = LogHandler.GetClassLogger(); - - if (configProvider?.CAConnectionData == null) - throw new ArgumentNullException(nameof(configProvider), - "Config provider and CAConnectionData are required."); - - CaArn = GetRequiredString(configProvider, ConfigKeys.CaArn); - S3Bucket = GetRequiredString(configProvider, ConfigKeys.S3Bucket); - - RoleArn = GetRequiredString(configProvider, ConfigKeys.RoleArn); - var regionName = GetRequiredString(configProvider, ConfigKeys.Region); - Region = RegionEndpoint.GetBySystemName(regionName); - - // Resolve AWS credentials using Keyfactor.Extensions.Aws.Auth if available, - // otherwise fall back to basic creds (legacy) or SDK default resolution. - AwsCredentials = AwsAuthLibraryShim.ResolveCredentials( - RoleArn, - regionName, - configProvider.CAConnectionData, - Logger); - - PcaClient = new AmazonACMPCAClient(AwsCredentials, Region); - } - - public async Task PingAsync(CancellationToken cancellationToken = default) - { - Logger.MethodEntry(); - - try - { - _ = await PcaClient.DescribeCertificateAuthorityAsync( - new DescribeCertificateAuthorityRequest { CertificateAuthorityArn = CaArn }, - cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - Logger.LogError($"There was an error contacting AWS PCA: {e.Message}."); - throw new Exception($"Error attempting to ping AWS PCA: {e.Message}.", e); - } - finally - { - Logger.MethodExit(); - } - } - - public async Task SubmitIssueCertificateAsync( - IssueCertificateRequest request, - CancellationToken cancellationToken = default) - { - Logger.MethodEntry(LogLevel.Debug); - - if (request == null) - return new IssueCertificateResponse - { - RegistrationError = new RegistrationError - { - Description = "Request was null.", - ErrorCode = "InvalidRequest" - } - }; - - if (string.IsNullOrWhiteSpace(request.CsrPem)) - return new IssueCertificateResponse - { - RegistrationError = new RegistrationError - { - Description = "CSR PEM is required.", - ErrorCode = "InvalidRequest" - } - }; - - try - { - var csrBytes = PemUtilities.DERToPEM(PemUtilities.PEMToDER(request.CsrPem), - PemUtilities.PemObjectType.CertRequest); - - var issueReq = new Amazon.ACMPCA.Model.IssueCertificateRequest - { - CertificateAuthorityArn = CaArn, - Csr = new MemoryStream(Encoding.ASCII.GetBytes(csrBytes)), - SigningAlgorithm = SigningAlgorithm.SHA256WITHRSA, - IdempotencyToken = request.IdempotencyToken ?? Guid.NewGuid().ToString("N"), - Validity = new Validity - { - Type = ValidityPeriodType.DAYS, - Value = request.ValidityDays.HasValue && request.ValidityDays.Value > 0 - ? request.ValidityDays.Value - : 365 - } - }; - - var resp = await PcaClient.IssueCertificateAsync(issueReq, cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(resp?.CertificateArn)) - return new IssueCertificateResponse - { - RegistrationError = new RegistrationError - { - Description = "IssueCertificate succeeded but CertificateArn was empty.", - ErrorCode = "IssueFailed" - } - }; - - return new IssueCertificateResponse - { - Result = new IssueCertificateResult - { - CertificateArn = resp.CertificateArn, - CertificateId = SafeRequestIdFromArn(resp.CertificateArn) - } - }; - } - catch (Exception ex) - { - return new IssueCertificateResponse { RegistrationError = ToRegistrationError(ex) }; - } - finally - { - Logger.MethodExit(LogLevel.Debug); - } - } - - public Task SubmitGetCertificateAsync( - string certificateId, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(certificateId)) - return Task.FromResult(new CertificateResponse - { - Status = (int)EndEntityStatus.FAILED, - RegistrationError = new RegistrationError - { - Description = "certificateId is required.", - ErrorCode = "InvalidRequest" - } - }); - - var arn = $"{CaArn}/certificate/{certificateId}"; - return SubmitGetCertificateByArnAsync(arn, cancellationToken); - } - - public async Task SubmitGetCertificateByArnAsync( - string certificateArn, - CancellationToken cancellationToken = default) - { - Logger.MethodEntry(LogLevel.Debug); - - if (string.IsNullOrWhiteSpace(certificateArn)) - return new CertificateResponse - { - Status = (int)EndEntityStatus.FAILED, - RegistrationError = new RegistrationError - { - Description = "certificateArn is required.", - ErrorCode = "InvalidRequest" - } - }; - - try - { - var req = new GetCertificateRequest - { - CertificateArn = certificateArn, - CertificateAuthorityArn = CaArn - }; - - // bounded retry for PCA's eventual issuance - const int maxAttempts = 30; - var delay = TimeSpan.FromSeconds(1); - - GetCertificateResponse? resp = null; - Exception? lastError = null; - - for (var attempt = 1; attempt <= maxAttempts; attempt++) - try - { - resp = await PcaClient.GetCertificateAsync(req, cancellationToken).ConfigureAwait(false); - lastError = null; - break; - } - catch (RequestInProgressException rip) - { - lastError = rip; - if (attempt < maxAttempts) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - } - - if (resp == null) - { - // still in progress after retries - var status = lastError != null - ? AwsPcaStatusMapper.FromGetCertificateException(lastError) - : (int)EndEntityStatus.INPROCESS; - - return new CertificateResponse - { - Uuid = SafeRequestIdFromArn(certificateArn), - Status = status, - RegistrationError = lastError != null ? ToRegistrationError(lastError) : null - }; - } - - if (string.IsNullOrWhiteSpace(resp.Certificate)) - return new CertificateResponse - { - Uuid = SafeRequestIdFromArn(certificateArn), - Status = (int)EndEntityStatus.FAILED, - RegistrationError = new RegistrationError - { - Description = $"Certificate with ARN {certificateArn} not found or empty.", - ErrorCode = "NotFound" - } - }; - - // Parse cert so we can infer "product" from EKU and use real notBefore as order date - var x509 = CertificateConverterFactory.FromPEM(resp.Certificate).ToX509Certificate2(); - - var product = - InferAndValidateTemplateTypeKey( - x509); // e.g. EndEntityServerAuth / EndEntityClientAuth / EndEntity / Unknown - - // Return leaf as base64 DER (no PEM headers), plus chain PEM if you want it for upstream extraction. - var leafBase64 = resp.Certificate; - var pemChain = resp.CertificateChain; - - return new CertificateResponse - { - Uuid = SafeRequestIdFromArn(certificateArn), - - // IMPORTANT: Keyfactor-friendly base64 DER - Certificate = leafBase64, - - // Keep chain PEM for any downstream consumers that want it - CertificatePemChain = pemChain, - - Status = AwsPcaStatusMapper.FromGetCertificateSuccess(), - - // Use the certificate’s validity start as “submission/order” timestamp (like the old code) - OrderDate = x509.NotBefore.ToUniversalTime(), - - // Mirror old "ProductID" concept through your model field - CertificateType = product - }; - } - catch (Exception ex) - { - return new CertificateResponse - { - Uuid = SafeRequestIdFromArn(certificateArn), - Status = AwsPcaStatusMapper.FromGetCertificateException(ex), - RegistrationError = ToRegistrationError(ex) - }; - } - finally - { - Logger.MethodExit(LogLevel.Debug); - } - } - - public async Task SubmitRevokeCertificateAsync( - string certificateId, - RevocationReason revocationReason, - CancellationToken cancellationToken = default) - { - Logger.MethodEntry(LogLevel.Debug); - - if (string.IsNullOrWhiteSpace(certificateId)) - return new RevokeResponse - { - Status = (int)EndEntityStatus.FAILED, - RegistrationError = new RegistrationError - { - Description = "certificateId is required. For AWS PCA this should be the hex serial number.", - ErrorCode = "InvalidRequest" - } - }; - - try - { - var revokeReq = new RevokeCertificateRequest - { - CertificateAuthorityArn = CaArn, - CertificateSerial = certificateId, - RevocationReason = revocationReason - }; - - _ = await PcaClient.RevokeCertificateAsync(revokeReq, cancellationToken) - .ConfigureAwait(false); - - return new RevokeResponse - { - RevokeSuccess = true, - Status = (int)EndEntityStatus.REVOKED - }; - } - catch (Exception ex) - { - return new RevokeResponse - { - RevokeSuccess = false, - Status = (int)EndEntityStatus.FAILED, - RegistrationError = ToRegistrationError(ex) - }; - } - finally - { - Logger.MethodExit(LogLevel.Debug); - } - } - - - public async Task SubmitAuditReportAsync(CancellationToken cancellationToken = default) - { - Logger.MethodEntry(LogLevel.Debug); - - try - { - var createReq = new CreateCertificateAuthorityAuditReportRequest - { - CertificateAuthorityArn = CaArn, - S3BucketName = S3Bucket, - AuditReportResponseFormat = AuditReportResponseFormat.JSON - }; - - var createResp = await PcaClient - .CreateCertificateAuthorityAuditReportAsync(createReq, cancellationToken) - .ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(createResp?.S3Key)) - return new AuditReportResponse - { - RegistrationError = new RegistrationError - { - Description = "Audit report request succeeded but no S3Key was returned.", - ErrorCode = "AuditReportFailed" - } - }; - - var json = await DownloadS3ObjectAsStringAsync(S3Bucket, createResp.S3Key, cancellationToken) - .ConfigureAwait(false); - - var parsed = JsonConvert.DeserializeObject>(json) ?? - new List(); - return new AuditReportResponse { Result = parsed }; - } - catch (Exception ex) - { - return new AuditReportResponse { RegistrationError = ToRegistrationError(ex) }; - } - finally - { - Logger.MethodExit(LogLevel.Debug); - } - } - - // ------------------------- - // Internals - // ------------------------- - - private async Task DownloadS3ObjectAsStringAsync( - string bucket, - string key, - CancellationToken cancellationToken) - { - var s3 = await GetOrCreateS3ClientAsync(cancellationToken).ConfigureAwait(false); - - using var response = await s3.GetObjectAsync(new GetObjectRequest - { - BucketName = bucket, - Key = key - }, cancellationToken).ConfigureAwait(false); - - using var reader = new StreamReader(response.ResponseStream); - return await reader.ReadToEndAsync().ConfigureAwait(false); - } - - private async Task GetOrCreateS3ClientAsync(CancellationToken cancellationToken) - { - if (S3Client != null) return S3Client; - - var creds = AwsCredentials; - - // bucket-region discovery (best-effort) - var region = Region; - try - { - using var probe = new AmazonS3Client(creds, RegionEndpoint.USEast1); - var loc = await probe.GetBucketLocationAsync(new GetBucketLocationRequest - { - BucketName = S3Bucket - }, cancellationToken).ConfigureAwait(false); - - var sys = NormalizeS3LocationConstraint(loc.Location); - region = RegionEndpoint.GetBySystemName(sys); - } - catch (Exception ex) - { - Logger.LogWarning( - $"Could not determine S3 bucket region for {S3Bucket}. Falling back to PCA region {Region.SystemName}. Details: {ex.Message}"); - } - - S3Client = new AmazonS3Client(creds, region); - return S3Client; - } - - private static string NormalizeS3LocationConstraint(string? location) - { - if (string.IsNullOrWhiteSpace(location)) return "us-east-1"; - if (string.Equals(location, "EU", StringComparison.OrdinalIgnoreCase)) return "eu-west-1"; - return location; - } - - private static string GetRequiredString(IAnyCAPluginConfigProvider provider, string key) - { - if (!provider.CAConnectionData.TryGetValue(key, out var obj) || obj == null) - throw new InvalidOperationException($"Missing required configuration value '{key}'."); - - var str = obj.ToString(); - if (string.IsNullOrWhiteSpace(str)) - throw new InvalidOperationException($"Configuration value '{key}' is empty."); - - return str!; - } - - private static string SafeRequestIdFromArn(string certArn) - { - var parts = certArn.Split(new[] { "/certificate/" }, StringSplitOptions.RemoveEmptyEntries); - return parts.Length == 2 ? parts[1] : certArn; - } - - private static RegistrationError ToRegistrationError(Exception ex) - { - var err = new RegistrationError - { - ExceptionType = ex.GetType().FullName, - Description = ex.Message, - Raw = ex.ToString() - }; - - if (ex is AmazonServiceException ase) - { - err.ErrorCode = ase.ErrorCode; - err.HttpStatusCode = (int)ase.StatusCode; - err.AwsRequestId = ase.RequestId; - } - - return err; - } - - private static byte[] PemBlockToDer(string pem, string label) - { - // Accept either CSR PEM already, or base64 CSR without headers. - if (!pem.Contains("-----BEGIN", StringComparison.OrdinalIgnoreCase)) - pem = $"-----BEGIN {label}-----{Wrap64(pem)}-----END {label}-----"; - - var begin = $"-----BEGIN {label}-----"; - var end = $"-----END {label}-----"; - - var start = pem.IndexOf(begin, StringComparison.OrdinalIgnoreCase); - if (start < 0) throw new FormatException($"PEM does not contain '{begin}'."); - - start += begin.Length; - var stop = pem.IndexOf(end, start, StringComparison.OrdinalIgnoreCase); - if (stop < 0) throw new FormatException($"PEM does not contain '{end}'."); - - var b64 = pem.Substring(start, stop - start) - .Replace("", "") - .Replace("", "") - .Trim(); - - return Convert.FromBase64String(b64); - } - - private static string Wrap64(string b64) - { - b64 = b64.Replace("", "").Replace("", "").Trim(); - var sb = new StringBuilder(); - for (var i = 0; i < b64.Length; i += 64) - sb.AppendLine(b64.Substring(i, Math.Min(64, b64.Length - i))); - return sb.ToString().TrimEnd(); - } - - /// - /// Infers one of the supported template type keys: - /// EndEntity, EndEntityClientAuth, EndEntityServerAuth. - /// Returns "Unknown" if certificate parsing/inspection fails. - /// - public static string InferTemplateTypeKey(X509Certificate2 cert) - { - try - { - bool server = false, client = false; - - // Find EKU extension (do not rely on indexer throwing) - var eku = cert.Extensions - .OfType() - .FirstOrDefault(x => string.Equals(x.Oid?.Value, ENHANCED_KEY_USAGE_OID, StringComparison.Ordinal)); - - if (eku != null) - foreach (var usage in eku.EnhancedKeyUsages) - if (string.Equals(usage.Value, SERVER_AUTH_OID, StringComparison.Ordinal)) - server = true; - else if (string.Equals(usage.Value, CLIENT_AUTH_OID, StringComparison.Ordinal)) - client = true; - - // Map to your known keys - if (server && !client) return "EndEntityServerAuth"; - if (client && !server) return "EndEntityClientAuth"; - - // both or neither -> generic end entity template - return "EndEntity"; - } - catch - { - return "Unknown"; - } - } - - /// - /// Optional strict validator: ensure the inferred type is one of Constants.TemplateARNs.Keys - /// - public static string InferAndValidateTemplateTypeKey(X509Certificate2 cert) - { - var key = InferTemplateTypeKey(cert); - return Constants.TemplateARNs.ContainsKey(key) ? key : "Unknown"; - } - - private static class ConfigKeys - { - public const string CaArn = "CAArn"; - public const string RoleArn = "RoleArn"; - public const string Region = "Region"; - public const string S3Bucket = "S3Bucket"; - } -} \ No newline at end of file +// Copyright 2022 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 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Amazon; +using Amazon.ACMPCA; +using Amazon.ACMPCA.Model; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.AWS.Interfaces; +using Keyfactor.Extensions.CAPlugin.AWS.Models; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.PKI.PEM; +using Keyfactor.PKI.X509; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using IssueCertificateRequest = Keyfactor.Extensions.CAPlugin.AWS.Models.IssueCertificateRequest; +using IssueCertificateResponse = Keyfactor.Extensions.CAPlugin.AWS.Models.IssueCertificateResponse; +using SigningAlgorithm = Amazon.ACMPCA.SigningAlgorithm; + +namespace Keyfactor.Extensions.CAPlugin.AWS.Client; + +/// +/// CSC-style client wrapper for AWS ACM PCA. +/// - Pulls config from IAnyCAPluginConfigProvider +/// - Returns response DTOs with RegistrationError instead of throwing for normal failure paths +/// - Normalizes all certificate statuses to Keyfactor's EndEntityStatus numeric values +/// +public sealed class AwsPcaClient : IAwsPcaClient +{ + private const string ENHANCED_KEY_USAGE_OID = "2.5.29.37"; + private const string SERVER_AUTH_OID = "1.3.6.1.5.5.7.3.1"; + private const string CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2"; + + private readonly SemaphoreSlim _caInfoLock = new(1, 1); + private readonly AWSCredentials AwsCredentials; + private readonly string CaArn; + private readonly ILogger Logger; + + private readonly IAmazonACMPCA PcaClient; + private readonly RegionEndpoint Region; + private readonly string RoleArn; + private readonly string S3Bucket; + private string? _caKeyAlgorithmName; + private IAmazonS3? S3Client; + + public AwsPcaClient(IAnyCAPluginConfigProvider configProvider) + { + Logger = LogHandler.GetClassLogger(); + + if (configProvider?.CAConnectionData == null) + throw new ArgumentNullException(nameof(configProvider), + "Config provider and CAConnectionData are required."); + + + var enabled = bool.Parse(GetRequiredString(configProvider, "Enabled")); + if (!enabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation and AWS PCA Client creation..."); + Logger.MethodExit(); + return; + } + + CaArn = GetRequiredString(configProvider, ConfigKeys.CaArn); + S3Bucket = GetRequiredString(configProvider, ConfigKeys.S3Bucket); + + RoleArn = GetRequiredString(configProvider, ConfigKeys.RoleArn); + var regionName = GetRequiredString(configProvider, ConfigKeys.Region); + Region = RegionEndpoint.GetBySystemName(regionName); + + // Resolve AWS credentials using Keyfactor.Extensions.Aws.Auth if available, + // otherwise fall back to basic creds (legacy) or SDK default resolution. + AwsCredentials = AwsAuthLibraryShim.ResolveCredentials( + RoleArn, + regionName, + configProvider.CAConnectionData, + Logger); + + PcaClient = new AmazonACMPCAClient(AwsCredentials, Region); + } + + public async Task PingAsync(CancellationToken cancellationToken = default) + { + Logger.MethodEntry(); + + try + { + _ = await PcaClient.DescribeCertificateAuthorityAsync( + new DescribeCertificateAuthorityRequest { CertificateAuthorityArn = CaArn }, + cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + Logger.LogError($"There was an error contacting AWS PCA: {e.Message}."); + throw new Exception($"Error attempting to ping AWS PCA: {e.Message}.", e); + } + finally + { + Logger.MethodExit(); + } + } + + public async Task SubmitIssueCertificateAsync( + IssueCertificateRequest request, + CancellationToken cancellationToken = default) + { + Logger.MethodEntry(LogLevel.Debug); + + if (request == null) + return new IssueCertificateResponse + { + RegistrationError = new RegistrationError + { + Description = "Request was null.", + ErrorCode = "InvalidRequest" + } + }; + + if (string.IsNullOrWhiteSpace(request.CsrPem)) + return new IssueCertificateResponse + { + RegistrationError = new RegistrationError + { + Description = "CSR PEM is required.", + ErrorCode = "InvalidRequest" + } + }; + + try + { + var csrBytes = PemUtilities.DERToPEM(PemUtilities.PEMToDER(request.CsrPem), + PemUtilities.PemObjectType.CertRequest); + + + var signingAlgoRes = await ResolveSigningAlgorithmAsync(request.SigningAlgorithm, cancellationToken) + .ConfigureAwait(false); + if (signingAlgoRes.Error != null) + return new IssueCertificateResponse { RegistrationError = signingAlgoRes.Error }; + + var signingAlgorithm = signingAlgoRes.Value!; + var issueReq = new Amazon.ACMPCA.Model.IssueCertificateRequest + { + CertificateAuthorityArn = CaArn, + Csr = new MemoryStream(Encoding.ASCII.GetBytes(csrBytes)), + SigningAlgorithm = signingAlgorithm, + IdempotencyToken = request.IdempotencyToken ?? Guid.NewGuid().ToString("N"), + Validity = new Validity + { + Type = ValidityPeriodType.DAYS, + Value = request.ValidityDays.HasValue && request.ValidityDays.Value > 0 + ? request.ValidityDays.Value + : 365 + } + }; + + var resp = await PcaClient.IssueCertificateAsync(issueReq, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(resp?.CertificateArn)) + return new IssueCertificateResponse + { + RegistrationError = new RegistrationError + { + Description = "IssueCertificate succeeded but CertificateArn was empty.", + ErrorCode = "IssueFailed" + } + }; + + return new IssueCertificateResponse + { + Result = new IssueCertificateResult + { + CertificateArn = resp.CertificateArn, + CertificateId = SafeRequestIdFromArn(resp.CertificateArn) + } + }; + } + catch (Exception ex) + { + return new IssueCertificateResponse { RegistrationError = ToRegistrationError(ex) }; + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } + } + + public Task SubmitGetCertificateAsync( + string certificateId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(certificateId)) + return Task.FromResult(new CertificateResponse + { + Status = (int)EndEntityStatus.FAILED, + RegistrationError = new RegistrationError + { + Description = "certificateId is required.", + ErrorCode = "InvalidRequest" + } + }); + + var arn = $"{CaArn}/certificate/{certificateId}"; + return SubmitGetCertificateByArnAsync(arn, cancellationToken); + } + + public async Task SubmitGetCertificateByArnAsync( + string certificateArn, + CancellationToken cancellationToken = default) + { + Logger.MethodEntry(LogLevel.Debug); + + if (string.IsNullOrWhiteSpace(certificateArn)) + return new CertificateResponse + { + Status = (int)EndEntityStatus.FAILED, + RegistrationError = new RegistrationError + { + Description = "certificateArn is required.", + ErrorCode = "InvalidRequest" + } + }; + + try + { + var req = new GetCertificateRequest + { + CertificateArn = certificateArn, + CertificateAuthorityArn = CaArn + }; + + // bounded retry for PCA's eventual issuance + const int maxAttempts = 30; + var delay = TimeSpan.FromSeconds(1); + + GetCertificateResponse? resp = null; + Exception? lastError = null; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + try + { + resp = await PcaClient.GetCertificateAsync(req, cancellationToken).ConfigureAwait(false); + lastError = null; + break; + } + catch (RequestInProgressException rip) + { + lastError = rip; + if (attempt < maxAttempts) + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + if (resp == null) + { + // still in progress after retries + var status = lastError != null + ? AwsPcaStatusMapper.FromGetCertificateException(lastError) + : (int)EndEntityStatus.INPROCESS; + + return new CertificateResponse + { + Uuid = SafeRequestIdFromArn(certificateArn), + Status = status, + RegistrationError = lastError != null ? ToRegistrationError(lastError) : null + }; + } + + if (string.IsNullOrWhiteSpace(resp.Certificate)) + return new CertificateResponse + { + Uuid = SafeRequestIdFromArn(certificateArn), + Status = (int)EndEntityStatus.FAILED, + RegistrationError = new RegistrationError + { + Description = $"Certificate with ARN {certificateArn} not found or empty.", + ErrorCode = "NotFound" + } + }; + + // Parse cert so we can infer "product" from EKU and use real notBefore as order date + var x509 = CertificateConverterFactory.FromPEM(resp.Certificate).ToX509Certificate2(); + + var product = + InferAndValidateTemplateTypeKey( + x509); // e.g. EndEntityServerAuth / EndEntityClientAuth / EndEntity / Unknown + + // Return leaf as base64 DER (no PEM headers), plus chain PEM if you want it for upstream extraction. + var leafBase64 = resp.Certificate; + var pemChain = resp.CertificateChain; + + return new CertificateResponse + { + Uuid = SafeRequestIdFromArn(certificateArn), + + // IMPORTANT: Keyfactor-friendly base64 DER + Certificate = leafBase64, + + // Keep chain PEM for any downstream consumers that want it + CertificatePemChain = pemChain, + + Status = AwsPcaStatusMapper.FromGetCertificateSuccess(), + + // Use the certificate’s validity start as “submission/order” timestamp (like the old code) + OrderDate = x509.NotBefore.ToUniversalTime(), + + // Mirror old "ProductID" concept through your model field + CertificateType = product + }; + } + catch (Exception ex) + { + return new CertificateResponse + { + Uuid = SafeRequestIdFromArn(certificateArn), + Status = AwsPcaStatusMapper.FromGetCertificateException(ex), + RegistrationError = ToRegistrationError(ex) + }; + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } + } + + public async Task SubmitRevokeCertificateAsync( + string certificateId, + RevocationReason revocationReason, + CancellationToken cancellationToken = default) + { + Logger.MethodEntry(LogLevel.Debug); + + if (string.IsNullOrWhiteSpace(certificateId)) + return new RevokeResponse + { + Status = (int)EndEntityStatus.FAILED, + RegistrationError = new RegistrationError + { + Description = "certificateId is required. For AWS PCA this should be the hex serial number.", + ErrorCode = "InvalidRequest" + } + }; + + try + { + var revokeReq = new RevokeCertificateRequest + { + CertificateAuthorityArn = CaArn, + CertificateSerial = certificateId, + RevocationReason = revocationReason + }; + + _ = await PcaClient.RevokeCertificateAsync(revokeReq, cancellationToken) + .ConfigureAwait(false); + + return new RevokeResponse + { + RevokeSuccess = true, + Status = (int)EndEntityStatus.REVOKED + }; + } + catch (Exception ex) + { + return new RevokeResponse + { + RevokeSuccess = false, + Status = (int)EndEntityStatus.FAILED, + RegistrationError = ToRegistrationError(ex) + }; + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } + } + + + public async Task SubmitAuditReportAsync(CancellationToken cancellationToken = default) + { + Logger.MethodEntry(LogLevel.Debug); + + try + { + var createReq = new CreateCertificateAuthorityAuditReportRequest + { + CertificateAuthorityArn = CaArn, + S3BucketName = S3Bucket, + AuditReportResponseFormat = AuditReportResponseFormat.JSON + }; + + var createResp = await PcaClient + .CreateCertificateAuthorityAuditReportAsync(createReq, cancellationToken) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(createResp?.S3Key)) + return new AuditReportResponse + { + RegistrationError = new RegistrationError + { + Description = "Audit report request succeeded but no S3Key was returned.", + ErrorCode = "AuditReportFailed" + } + }; + + var json = await DownloadS3ObjectAsStringAsync(S3Bucket, createResp.S3Key, cancellationToken) + .ConfigureAwait(false); + + var parsed = JsonConvert.DeserializeObject>(json) ?? + new List(); + return new AuditReportResponse { Result = parsed }; + } + catch (Exception ex) + { + return new AuditReportResponse { RegistrationError = ToRegistrationError(ex) }; + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } + } + + // ------------------------- + // Internals + // ------------------------- + + private async Task DownloadS3ObjectAsStringAsync( + string bucket, + string key, + CancellationToken cancellationToken) + { + var s3 = await GetOrCreateS3ClientAsync(cancellationToken).ConfigureAwait(false); + + using var response = await s3.GetObjectAsync(new GetObjectRequest + { + BucketName = bucket, + Key = key + }, cancellationToken).ConfigureAwait(false); + + using var reader = new StreamReader(response.ResponseStream); + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + + private async Task GetOrCreateS3ClientAsync(CancellationToken cancellationToken) + { + if (S3Client != null) return S3Client; + + var creds = AwsCredentials; + + // bucket-region discovery (best-effort) + var region = Region; + try + { + using var probe = new AmazonS3Client(creds, RegionEndpoint.USEast1); + var loc = await probe.GetBucketLocationAsync(new GetBucketLocationRequest + { + BucketName = S3Bucket + }, cancellationToken).ConfigureAwait(false); + + var sys = NormalizeS3LocationConstraint(loc.Location); + region = RegionEndpoint.GetBySystemName(sys); + } + catch (Exception ex) + { + Logger.LogWarning( + $"Could not determine S3 bucket region for {S3Bucket}. Falling back to PCA region {Region.SystemName}. Details: {ex.Message}"); + } + + S3Client = new AmazonS3Client(creds, region); + return S3Client; + } + + private static string NormalizeS3LocationConstraint(string? location) + { + if (string.IsNullOrWhiteSpace(location)) return "us-east-1"; + if (string.Equals(location, "EU", StringComparison.OrdinalIgnoreCase)) return "eu-west-1"; + return location; + } + + private static string GetRequiredString(IAnyCAPluginConfigProvider provider, string key) + { + if (!provider.CAConnectionData.TryGetValue(key, out var obj) || obj == null) + throw new InvalidOperationException($"Missing required configuration value '{key}'."); + + var str = obj.ToString(); + if (string.IsNullOrWhiteSpace(str)) + throw new InvalidOperationException($"Configuration value '{key}' is empty."); + + return str!; + } + + private static string SafeRequestIdFromArn(string certArn) + { + var parts = certArn.Split(new[] { "/certificate/" }, StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 2 ? parts[1] : certArn; + } + + private static RegistrationError ToRegistrationError(Exception ex) + { + var err = new RegistrationError + { + ExceptionType = ex.GetType().FullName, + Description = ex.Message, + Raw = ex.ToString() + }; + + if (ex is AmazonServiceException ase) + { + err.ErrorCode = ase.ErrorCode; + err.HttpStatusCode = (int)ase.StatusCode; + err.AwsRequestId = ase.RequestId; + } + + return err; + } + + private static byte[] PemBlockToDer(string pem, string label) + { + // Accept either CSR PEM already, or base64 CSR without headers. + if (!pem.Contains("-----BEGIN", StringComparison.OrdinalIgnoreCase)) + pem = $"-----BEGIN {label}-----{Wrap64(pem)}-----END {label}-----"; + + var begin = $"-----BEGIN {label}-----"; + var end = $"-----END {label}-----"; + + var start = pem.IndexOf(begin, StringComparison.OrdinalIgnoreCase); + if (start < 0) throw new FormatException($"PEM does not contain '{begin}'."); + + start += begin.Length; + var stop = pem.IndexOf(end, start, StringComparison.OrdinalIgnoreCase); + if (stop < 0) throw new FormatException($"PEM does not contain '{end}'."); + + var b64 = pem.Substring(start, stop - start) + .Replace("", "") + .Replace("", "") + .Trim(); + + return Convert.FromBase64String(b64); + } + + private static string Wrap64(string b64) + { + b64 = b64.Replace("", "").Replace("", "").Trim(); + var sb = new StringBuilder(); + for (var i = 0; i < b64.Length; i += 64) + sb.AppendLine(b64.Substring(i, Math.Min(64, b64.Length - i))); + return sb.ToString().TrimEnd(); + } + + /// + /// Infers one of the supported template type keys: + /// EndEntity, EndEntityClientAuth, EndEntityServerAuth. + /// Returns "Unknown" if certificate parsing/inspection fails. + /// + public static string InferTemplateTypeKey(X509Certificate2 cert) + { + try + { + bool server = false, client = false; + + // Find EKU extension (do not rely on indexer throwing) + var eku = cert.Extensions + .OfType() + .FirstOrDefault(x => string.Equals(x.Oid?.Value, ENHANCED_KEY_USAGE_OID, StringComparison.Ordinal)); + + if (eku != null) + foreach (var usage in eku.EnhancedKeyUsages) + if (string.Equals(usage.Value, SERVER_AUTH_OID, StringComparison.Ordinal)) + server = true; + else if (string.Equals(usage.Value, CLIENT_AUTH_OID, StringComparison.Ordinal)) + client = true; + + // Map to your known keys + if (server && !client) return "EndEntityServerAuth"; + if (client && !server) return "EndEntityClientAuth"; + + // both or neither -> generic end entity template + return "EndEntity"; + } + catch + { + return "Unknown"; + } + } + + /// + /// Optional strict validator: ensure the inferred type is one of Constants.TemplateARNs.Keys + /// + public static string InferAndValidateTemplateTypeKey(X509Certificate2 cert) + { + var key = InferTemplateTypeKey(cert); + return Constants.TemplateARNs.ContainsKey(key) ? key : "Unknown"; + } + + + private async Task<(string? Value, RegistrationError? Error)> GetCaKeyAlgorithmAsync( + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_caKeyAlgorithmName)) + return (_caKeyAlgorithmName, null); + + await _caInfoLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!string.IsNullOrWhiteSpace(_caKeyAlgorithmName)) + return (_caKeyAlgorithmName, null); + + try + { + var resp = await PcaClient.DescribeCertificateAuthorityAsync( + new DescribeCertificateAuthorityRequest { CertificateAuthorityArn = CaArn }, + cancellationToken).ConfigureAwait(false); + + // KeyAlgorithm is represented as a string in the service model; some SDK surfaces expose it as a string. + // Use reflection to remain tolerant across AWSSDK.ACMPCA versions. + var cfg = resp?.CertificateAuthority?.CertificateAuthorityConfiguration; + if (cfg == null) + return (null, + new RegistrationError + { + ErrorCode = "AwsDescribeCaFailed", + Description = "DescribeCertificateAuthority returned no configuration." + }); + + var prop = cfg.GetType().GetProperty("KeyAlgorithm"); + var val = prop?.GetValue(cfg)?.ToString(); + if (string.IsNullOrWhiteSpace(val)) + return (null, + new RegistrationError + { + ErrorCode = "AwsDescribeCaFailed", + Description = "Unable to read CA KeyAlgorithm from DescribeCertificateAuthority response." + }); + + _caKeyAlgorithmName = val.Trim(); + return (_caKeyAlgorithmName, null); + } + catch (Exception ex) + { + return (null, + new RegistrationError + { ErrorCode = "AwsDescribeCaFailed", Description = "Failed to query CA KeyAlgorithm." }); + } + } + finally + { + _caInfoLock.Release(); + } + } + + private static bool IsKnownSigningAlgorithm(SigningAlgorithm alg) + { + var v = alg.Value; + return v == SigningAlgorithm.SHA256WITHRSA.Value + || v == SigningAlgorithm.SHA384WITHRSA.Value + || v == SigningAlgorithm.SHA512WITHRSA.Value + || v == SigningAlgorithm.SHA256WITHECDSA.Value + || v == SigningAlgorithm.SHA384WITHECDSA.Value + || v == SigningAlgorithm.SHA512WITHECDSA.Value + || v == SigningAlgorithm.SM3WITHSM2.Value + || v == SigningAlgorithm.ML_DSA_44.Value + || v == SigningAlgorithm.ML_DSA_65.Value + || v == SigningAlgorithm.ML_DSA_87.Value; + } + + private static bool IsSigningAlgorithmCompatible(string caKeyAlgorithmName, SigningAlgorithm signingAlgorithm) + { + var ka = caKeyAlgorithmName.Trim(); + var sig = signingAlgorithm.Value; + + if (ka.StartsWith("RSA_", StringComparison.OrdinalIgnoreCase)) + return sig.EndsWith("WITHRSA", StringComparison.OrdinalIgnoreCase); + + if (ka.StartsWith("EC_", StringComparison.OrdinalIgnoreCase)) + return sig.EndsWith("WITHECDSA", StringComparison.OrdinalIgnoreCase); + + if (ka.Equals("SM2", StringComparison.OrdinalIgnoreCase)) + return sig.Equals(SigningAlgorithm.SM3WITHSM2.Value, StringComparison.OrdinalIgnoreCase); + + if (ka.StartsWith("ML_DSA_", StringComparison.OrdinalIgnoreCase)) + return sig.Equals(ka, StringComparison.OrdinalIgnoreCase); + + return false; + } + + private static SigningAlgorithm DefaultSigningAlgorithmForCaKey(string caKeyAlgorithmName) + { + var ka = caKeyAlgorithmName.Trim(); + + if (ka.StartsWith("RSA_", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.SHA256WITHRSA; + + if (ka.StartsWith("EC_", StringComparison.OrdinalIgnoreCase)) + { + if (ka.Equals("EC_secp384r1", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.SHA384WITHECDSA; + + if (ka.Equals("EC_secp521r1", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.SHA512WITHECDSA; + + return SigningAlgorithm.SHA256WITHECDSA; + } + + if (ka.Equals("SM2", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.SM3WITHSM2; + + if (ka.Equals("ML_DSA_44", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.ML_DSA_44; + if (ka.Equals("ML_DSA_65", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.ML_DSA_65; + if (ka.Equals("ML_DSA_87", StringComparison.OrdinalIgnoreCase)) + return SigningAlgorithm.ML_DSA_87; + + // Fallback + return SigningAlgorithm.SHA256WITHRSA; + } + + private async Task<(SigningAlgorithm? Value, RegistrationError? Error)> ResolveSigningAlgorithmAsync( + string? requestedSigningAlgorithm, + CancellationToken cancellationToken) + { + var caKeyRes = await GetCaKeyAlgorithmAsync(cancellationToken).ConfigureAwait(false); + if (caKeyRes.Error != null) + return (null, caKeyRes.Error); + + var caKey = caKeyRes.Value!; + if (!string.IsNullOrWhiteSpace(requestedSigningAlgorithm)) + { + var resolved = SigningAlgorithm.FindValue(requestedSigningAlgorithm.Trim()); + if (!IsKnownSigningAlgorithm(resolved)) + return (null, new RegistrationError + { + ErrorCode = "InvalidConfiguration", + Description = + $"SigningAlgorithm '{requestedSigningAlgorithm}' is not a supported AWS ACM PCA SigningAlgorithm value." + }); + + if (!IsSigningAlgorithmCompatible(caKey, resolved)) + return (null, new RegistrationError + { + ErrorCode = "InvalidConfiguration", + Description = + $"SigningAlgorithm '{requestedSigningAlgorithm}' is not compatible with CA KeyAlgorithm '{caKey}'." + }); + + return (resolved, null); + } + + return (DefaultSigningAlgorithmForCaKey(caKey), null); + } + + private static class ConfigKeys + { + public const string CaArn = "CAArn"; + public const string RoleArn = "RoleArn"; + public const string Region = "Region"; + public const string S3Bucket = "S3Bucket"; + } +} diff --git a/aws-pca-caplugin/Constants.cs b/aws-pca-caplugin/Constants.cs index 0f89caa..69b76f3 100644 --- a/aws-pca-caplugin/Constants.cs +++ b/aws-pca-caplugin/Constants.cs @@ -61,6 +61,7 @@ public static class Constants public static string IAM_USER_ACCESS_KEY = "IAMUserAccessKey"; public static string IAM_USER_ACCESS_SECRET = "IAMUserAccessSecret"; public static string EXTERNAL_ID = "ExternalId"; + public static string Enabled = "Enabled"; public static List GetTemplateTypes() @@ -72,4 +73,5 @@ public static List GetTemplateTypes() public class EnrollmentConfigConstants { public const string LifetimeDays = "LifetimeDays"; + public const string SigningAlgorithm = "SigningAlgorithm"; } \ No newline at end of file diff --git a/aws-pca-caplugin/Models/Models.cs b/aws-pca-caplugin/Models/Models.cs index 4d9e918..ae1e199 100644 --- a/aws-pca-caplugin/Models/Models.cs +++ b/aws-pca-caplugin/Models/Models.cs @@ -42,12 +42,19 @@ public sealed class IssueCertificateRequest /// Optional desired term in days. If null, defaults to 365. public int? ValidityDays { get; set; } + /// + /// Optional override for ACM PCA IssueCertificate.SigningAlgorithm. + /// If null/empty, the client auto-selects a compatible default based on the CA KeyAlgorithm. + /// + public string? SigningAlgorithm { get; set; } + /// Optional idempotency token. public string? IdempotencyToken { get; set; } } /// -/// Mirrors the CSC plugin expectations: contains the certificate payload + a Keyfactor-accepted numeric status. +/// Response wrapper that carries the issued certificate payload and status.: contains the certificate payload + a +/// Keyfactor-accepted numeric status. /// public sealed class CertificateResponse { diff --git a/docsource/configuration.md b/docsource/configuration.md index 9bcf63d..f15dd50 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -5,12 +5,19 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c ## Requirements This integration is tested and confirmed as working for Anygateway REST 24.4 and above. Notice: Keyfactor Anygateway REST 24.4 requires the use of .Net 8. +## Gateway Registration +Download the **PCA root certificate** from AWS and have it ready to import into the Gateway **in `.pem` format**. ## Authentication (Access Key + Secret) The CAPlugin currently supports **one** authentication method: **AWS Access Key ID + Secret Access Key**. **OAuth** and **Default SDK authentication** will be enabled in later updates. There is functionality present via the **Keyfactor AWS Authentication** library, but these alternate methods are currently ***untested***. +### Known Issues + +At present, a fresh install of Keyfactor Command 24.4 used in conjuction with Keyfactor Gateway REST 25.4.0.0 is confirmed as working. A fresh install of Command 25.3 used with REST 25.4.0.0 is also confirmed as working. +Latest version of Command 25.4 may run into issues, investigation into compatibility issues is ongoing. + ### What you need ready Before configuring the CAPlugin, have the following prepared: @@ -19,7 +26,7 @@ Before configuring the CAPlugin, have the following prepared: - **Access Key ID** (example format: `AKIAIOSFODNN7EXAMPLE`) - **Secret Access Key** (example format: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) -#### 2) A target IAM Role the connector will run as (recommended) +#### 2) A target IAM Role the Gateway will run as (recommended) Example: - `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole` @@ -190,7 +197,49 @@ The following examples are intended as **copy/adapt templates**. } ``` -## Gateway Registration +--- + +## Signing algorithm selection (ACM PCA) + +The connector supports an optional **template / product parameter** named `SigningAlgorithm` that controls the **certificate signature algorithm** +passed to AWS ACM PCA `IssueCertificate`. + +- If **not set**, the plugin will **auto-select** a compatible default based on the CA `KeyAlgorithm` returned by + `DescribeCertificateAuthority`. +- If **set**, the plugin validates the value and **rejects incompatible combinations** before calling AWS. + +### Where to configure + +Set `SigningAlgorithm` on the **AnyGateway template** (product parameters), alongside `LifetimeDays`. + +### Valid `SigningAlgorithm` values (AWS PCA) + +- RSA family: `SHA256WITHRSA`, `SHA384WITHRSA`, `SHA512WITHRSA` +- ECDSA family: `SHA256WITHECDSA`, `SHA384WITHECDSA`, `SHA512WITHECDSA` +- SM2: `SM3WITHSM2` +- ML-DSA (post-quantum): `ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87` + +### Allowed CA key algorithm <-> signing algorithm combinations + +The CA key algorithm is the PCA CA **KeyAlgorithm** (not the subject key in the CSR). The signing algorithm must match the CA key family. + +| CA KeyAlgorithm | Allowed SigningAlgorithm values | +|---|---| +| `RSA_2048`, `RSA_3072`, `RSA_4096` | `SHA256WITHRSA`, `SHA384WITHRSA`, `SHA512WITHRSA` | +| `EC_prime256v1`, `EC_secp384r1`, `EC_secp521r1` | `SHA256WITHECDSA`, `SHA384WITHECDSA`, `SHA512WITHECDSA` | +| `SM2` | `SM3WITHSM2` | +| `ML_DSA_44` | `ML_DSA_44` | +| `ML_DSA_65` | `ML_DSA_65` | +| `ML_DSA_87` | `ML_DSA_87` | + +### Auto-selection defaults + +When `SigningAlgorithm` is omitted, the plugin selects: -TODO Gateway Registration is a required section +- RSA CAs -> `SHA256WITHRSA` +- EC P-256 -> `SHA256WITHECDSA` +- EC P-384 -> `SHA384WITHECDSA` +- EC P-521 -> `SHA512WITHECDSA` +- SM2 -> `SM3WITHSM2` +- ML-DSA -> exact-match (`ML_DSA_44/65/87`) diff --git a/integration-manifest.json b/integration-manifest.json index ae02785..515032c 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -76,12 +76,20 @@ { "name": "ExternalId", "description": "Optional sts:ExternalId to supply on AssumeRole calls." + }, + { + "name": "Enabled", + "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." } ], "enrollment_config": [ { "name": "LifetimeDays", "description": "OPTIONAL: The number of days of validity to use when requesting certs. If not provided, default is 365" + }, + { + "name": "SigningAlgorithm", + "description": "Required: AWS ACM PCA certificate signature algorithm to use when issuing certificates. Value is an AWS PCA SigningAlgorithm enum name (case-insensitive), e.g. SHA256WITHRSA, SHA384WITHRSA, SHA256WITHECDSA. If omitted, the plugin selects a default compatible with the CA key algorithm." } ], "product_ids": [ From 94c8bda595c0e5bbf04cb12b8947bf55fd55b09d Mon Sep 17 00:00:00 2001 From: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:17:34 -0400 Subject: [PATCH 2/3] release 1.0.1 --- .gitignore | 3 + CHANGELOG.md | 5 ++ README.md | 20 ++--- aws-pca-caplugin/AWSPCACAPlugin.cs | 98 ++++++++++++------------- aws-pca-caplugin/AWSPCACAPlugin.csproj | 27 ++++--- aws-pca-caplugin/Client/ACMPCAClient.cs | 28 ++++++- aws-pca-caplugin/Constants.cs | 4 + docsource/configuration.md | 5 -- integration-manifest.json | 3 +- 9 files changed, 108 insertions(+), 85 deletions(-) diff --git a/.gitignore b/.gitignore index 43d9deb..50b55db 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,6 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +/.claude +/issue.txt +/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2395c25..f5e356a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v.1.0.1 + +- Added support for the AWS Code Signing Template +- Fixed template passthrough to final certificate + v1.0 - Initial Release. diff --git a/README.md b/README.md index 6a2f36e..b8f5bbb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- AWSPCA CA Gateway AnyCA Gateway REST Plugin + AWSPCA CAPlugin AnyCA Gateway REST Plugin

@@ -38,10 +38,10 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c ## Compatibility -The AWSPCA CA Gateway AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.4.0 and later. +The AWSPCA CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.4.0 and later. ## Support -The AWSPCA CA 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 AWSPCA CAPlugin 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. @@ -53,7 +53,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and 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 [AWSPCA CA Gateway AnyCA Gateway REST plugin](https://github.com/Keyfactor/aws-pca-caplugin/releases/latest) from GitHub. +2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [AWSPCA CAPlugin AnyCA Gateway REST plugin](https://github.com/Keyfactor/aws-pca-caplugin/releases/latest) from GitHub. 3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: @@ -64,11 +64,11 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the AWSPCA CA 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 AWSPCA CAPlugin 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 AWSPCA CA 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 AWSPCA CAPlugin plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. ## Configuration @@ -100,11 +100,12 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and * **ExternalId** - Optional sts:ExternalId to supply on AssumeRole calls. * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. -2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The AWSPCA CA Gateway plugin supports the following product IDs: +2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The AWSPCA CAPlugin plugin supports the following product IDs: * **EndEntity** * **EndEntityClientAuth** * **EndEntityServerAuth** + * **CodeSigning** 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. @@ -119,11 +120,6 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and The CAPlugin currently supports **one** authentication method: **AWS Access Key ID + Secret Access Key**. **OAuth** and **Default SDK authentication** will be enabled in later updates. There is functionality present via the **Keyfactor AWS Authentication** library, but these alternate methods are currently ***untested***. -### Known Issues - -At present, a fresh install of Keyfactor Command 24.4 used in conjuction with Keyfactor Gateway REST 25.4.0.0 is confirmed as working. A fresh install of Command 25.3 used with REST 25.4.0.0 is also confirmed as working. -Latest version of Command 25.4 may run into issues, investigation into compatibility issues is ongoing. - ### What you need ready Before configuring the CAPlugin, have the following prepared: diff --git a/aws-pca-caplugin/AWSPCACAPlugin.cs b/aws-pca-caplugin/AWSPCACAPlugin.cs index 2201fc9..6a70311 100644 --- a/aws-pca-caplugin/AWSPCACAPlugin.cs +++ b/aws-pca-caplugin/AWSPCACAPlugin.cs @@ -415,60 +415,60 @@ public async Task Enroll( switch (enrollmentType) { case EnrollmentType.New: - { - return await IssueAndFetchAsync( - csr, - productInfo.ProductID, - days, - signingAlgorithm, - "Certificate Issued") - .ConfigureAwait(false); - } + { + return await IssueAndFetchAsync( + csr, + productInfo.ProductID, + days, + signingAlgorithm, + "Certificate Issued") + .ConfigureAwait(false); + } case EnrollmentType.RenewOrReissue: - { - if (productInfo.ProductParameters == null || - !TryGetProductParam(productInfo.ProductParameters, "PriorCertSN", out var priorSn) || - string.IsNullOrWhiteSpace(priorSn)) - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = - "Renew/Reissue requires ProductParameters['PriorCertSN'] (hex serial number)." - }; - - string priorRequestId; - try - { - priorRequestId = await _certificateDataReader - .GetRequestIDBySerialNumber(priorSn) - .ConfigureAwait(false); - } - catch (Exception ex) + { + if (productInfo.ProductParameters == null || + !TryGetProductParam(productInfo.ProductParameters, "PriorCertSN", out var priorSn) || + string.IsNullOrWhiteSpace(priorSn)) + return new EnrollmentResult { - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"Could not resolve PriorCertSN to request id: {ex.Message}" - }; - } - - var expiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); - var isRenewal = expiration.HasValue && expiration.Value.ToUniversalTime() <= DateTime.UtcNow; + Status = (int)EndEntityStatus.FAILED, + StatusMessage = + "Renew/Reissue requires ProductParameters['PriorCertSN'] (hex serial number)." + }; - var msg = isRenewal ? "Certificate Renewed" : "Certificate Reissued"; - var token = BuildIdempotencyToken(isRenewal ? "renew" : "reissue", priorRequestId, csr); - - // Still "IssueCertificate" under the hood; PCA doesn't have first-class renew/reissue. - return await IssueAndFetchAsync( - csr, - productInfo.ProductID, - days, - msg, - // Optional: stable-ish idempotency (helps avoid duplicates if caller retries quickly) - token) + string priorRequestId; + try + { + priorRequestId = await _certificateDataReader + .GetRequestIDBySerialNumber(priorSn) .ConfigureAwait(false); } + catch (Exception ex) + { + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"Could not resolve PriorCertSN to request id: {ex.Message}" + }; + } + + var expiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); + var isRenewal = expiration.HasValue && expiration.Value.ToUniversalTime() <= DateTime.UtcNow; + + var msg = isRenewal ? "Certificate Renewed" : "Certificate Reissued"; + var token = BuildIdempotencyToken(isRenewal ? "renew" : "reissue", priorRequestId, csr); + + // Still "IssueCertificate" under the hood; PCA doesn't have first-class renew/reissue. + return await IssueAndFetchAsync( + csr, + productInfo.ProductID, + days, + msg, + // Optional: stable-ish idempotency (helps avoid duplicates if caller retries quickly) + token) + .ConfigureAwait(false); + } default: return new EnrollmentResult @@ -685,7 +685,7 @@ public Dictionary GetCAConnectorAnnotations() DefaultValue = "", Type = "String" }, - [Constants.Enabled] = new() + [Constants.Enabled] = new () { Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", Hidden = false, diff --git a/aws-pca-caplugin/AWSPCACAPlugin.csproj b/aws-pca-caplugin/AWSPCACAPlugin.csproj index ae30f76..a53434c 100644 --- a/aws-pca-caplugin/AWSPCACAPlugin.csproj +++ b/aws-pca-caplugin/AWSPCACAPlugin.csproj @@ -3,7 +3,7 @@ true - net8.0 + net8.0;net10.0 Keyfactor.Extensions.CAPlugin.AWS true enable @@ -13,16 +13,11 @@ - + - - - - - + @@ -31,13 +26,17 @@ + - - - - - + + + + + + + - + + \ No newline at end of file diff --git a/aws-pca-caplugin/Client/ACMPCAClient.cs b/aws-pca-caplugin/Client/ACMPCAClient.cs index 008ddb6..455ee19 100644 --- a/aws-pca-caplugin/Client/ACMPCAClient.cs +++ b/aws-pca-caplugin/Client/ACMPCAClient.cs @@ -39,6 +39,7 @@ public sealed class AwsPcaClient : IAwsPcaClient private const string ENHANCED_KEY_USAGE_OID = "2.5.29.37"; private const string SERVER_AUTH_OID = "1.3.6.1.5.5.7.3.1"; private const string CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2"; + private const string CODE_SIGNING_OID = "1.3.6.1.5.5.7.3.3"; private readonly SemaphoreSlim _caInfoLock = new(1, 1); private readonly AWSCredentials AwsCredentials; @@ -60,7 +61,6 @@ public AwsPcaClient(IAnyCAPluginConfigProvider configProvider) throw new ArgumentNullException(nameof(configProvider), "Config provider and CAConnectionData are required."); - var enabled = bool.Parse(GetRequiredString(configProvider, "Enabled")); if (!enabled) { @@ -145,12 +145,29 @@ public async Task SubmitIssueCertificateAsync( if (signingAlgoRes.Error != null) return new IssueCertificateResponse { RegistrationError = signingAlgoRes.Error }; + // Map the requested ProductId to its AWS PCA template ARN. Without this, + // PCA falls back to EndEntityCertificate/V1 and every cert gets the default + // Server+Client Auth EKU combination regardless of the selected product. + if (string.IsNullOrWhiteSpace(request.ProductId) || + !Constants.TemplateARNs.TryGetValue(request.ProductId, out var templateArn)) + return new IssueCertificateResponse + { + RegistrationError = new RegistrationError + { + Description = string.IsNullOrWhiteSpace(request.ProductId) + ? "ProductId is required to resolve the AWS PCA template ARN." + : $"Unsupported ProductId '{request.ProductId}'. Supported: {string.Join(", ", Constants.TemplateARNs.Keys)}", + ErrorCode = "InvalidRequest" + } + }; + var signingAlgorithm = signingAlgoRes.Value!; var issueReq = new Amazon.ACMPCA.Model.IssueCertificateRequest { CertificateAuthorityArn = CaArn, Csr = new MemoryStream(Encoding.ASCII.GetBytes(csrBytes)), SigningAlgorithm = signingAlgorithm, + TemplateArn = templateArn, IdempotencyToken = request.IdempotencyToken ?? Guid.NewGuid().ToString("N"), Validity = new Validity { @@ -554,14 +571,14 @@ private static string Wrap64(string b64) ///

/// Infers one of the supported template type keys: - /// EndEntity, EndEntityClientAuth, EndEntityServerAuth. + /// EndEntity, EndEntityClientAuth, EndEntityServerAuth, CodeSigning. /// Returns "Unknown" if certificate parsing/inspection fails. /// public static string InferTemplateTypeKey(X509Certificate2 cert) { try { - bool server = false, client = false; + bool server = false, client = false, codeSigning = false; // Find EKU extension (do not rely on indexer throwing) var eku = cert.Extensions @@ -574,8 +591,11 @@ public static string InferTemplateTypeKey(X509Certificate2 cert) server = true; else if (string.Equals(usage.Value, CLIENT_AUTH_OID, StringComparison.Ordinal)) client = true; + else if (string.Equals(usage.Value, CODE_SIGNING_OID, StringComparison.Ordinal)) + codeSigning = true; // Map to your known keys + if (codeSigning && !server && !client) return "CodeSigning"; if (server && !client) return "EndEntityServerAuth"; if (client && !server) return "EndEntityClientAuth"; @@ -761,4 +781,4 @@ private static class ConfigKeys public const string Region = "Region"; public const string S3Bucket = "S3Bucket"; } -} +} \ No newline at end of file diff --git a/aws-pca-caplugin/Constants.cs b/aws-pca-caplugin/Constants.cs index 69b76f3..b0f0ac0 100644 --- a/aws-pca-caplugin/Constants.cs +++ b/aws-pca-caplugin/Constants.cs @@ -22,6 +22,10 @@ public static class Constants { "EndEntityServerAuth", "arn:aws:acm-pca:::template/EndEntityServerAuthCertificate/V1" + }, + { + "CodeSigning", + "arn:aws:acm-pca:::template/CodeSigningCertificate/V1" } }; diff --git a/docsource/configuration.md b/docsource/configuration.md index f15dd50..30fee0d 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -13,11 +13,6 @@ Download the **PCA root certificate** from AWS and have it ready to import into The CAPlugin currently supports **one** authentication method: **AWS Access Key ID + Secret Access Key**. **OAuth** and **Default SDK authentication** will be enabled in later updates. There is functionality present via the **Keyfactor AWS Authentication** library, but these alternate methods are currently ***untested***. -### Known Issues - -At present, a fresh install of Keyfactor Command 24.4 used in conjuction with Keyfactor Gateway REST 25.4.0.0 is confirmed as working. A fresh install of Command 25.3 used with REST 25.4.0.0 is also confirmed as working. -Latest version of Command 25.4 may run into issues, investigation into compatibility issues is ongoing. - ### What you need ready Before configuring the CAPlugin, have the following prepared: diff --git a/integration-manifest.json b/integration-manifest.json index 515032c..ee8c3d8 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -95,7 +95,8 @@ "product_ids": [ "EndEntity", "EndEntityClientAuth", - "EndEntityServerAuth" + "EndEntityServerAuth", + "CodeSigning" ] } } From 170fa1425e52616f544cbde53ef7b66a20360df9 Mon Sep 17 00:00:00 2001 From: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:59:56 -0400 Subject: [PATCH 3/3] Auth support for all auth methods. (#5) * Auth support for all auth methods. * docs: auto-generate README and documentation [skip ci] * Fixed build. --------- Co-authored-by: github-actions[bot] --- .../keyfactor-bootstrap-workflow-v3.yml | 11 +- CHANGELOG.md | 4 + README.md | 263 ++++++++++++------ aws-pca-caplugin/AWSPCACAPlugin.cs | 109 ++++---- aws-pca-caplugin/AWSPCACAPlugin.csproj | 24 +- .../AWSPCACAPlugin.csproj.user.example | 22 ++ aws-pca-caplugin/Client/ACMPCAClient.cs | 37 ++- docsource/configuration.md | 210 ++++++++++---- 8 files changed, 456 insertions(+), 224 deletions(-) create mode 100644 aws-pca-caplugin/AWSPCACAPlugin.csproj.user.example diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml index 042ba5a..b8c72b2 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -11,10 +11,9 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v3.1.2 + uses: keyfactor/actions/.github/workflows/starter.yml@v5 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 }} + token: ${{ secrets.V2BUILDTOKEN}} # REQUIRED + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} # Only required for golang builds + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} # Only required for golang builds + scan_token: ${{ secrets.SAST_TOKEN }} # REQUIRED diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e356a..0be19fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v.1.0.2 + +- Added support for all auth methods currently supported by the Keyfactor AWS library. + v.1.0.1 - Added support for the AWS Code Signing Template diff --git a/README.md b/README.md index 647e905..59e9a7d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- This integration allows for the Synchronization, Enrollment, and Revocation of certificates from the AWS ACM PCA. This is the AnyGateway REST version. ## Compatibility @@ -41,7 +40,7 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c The AWSPCA CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.4.0 and later. ## Support -The AWSPCA CAPlugin 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 AWSPCA CAPlugin 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. @@ -55,16 +54,16 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [AWSPCA CAPlugin AnyCA Gateway REST plugin](https://github.com/Keyfactor/aws-pca-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 `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 AWSPCA CAPlugin 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 AWSPCA CAPlugin AnyCA Gateway REST plugin DLLs (`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. @@ -82,23 +81,23 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and Populate using the configuration fields collected in the [requirements](#requirements) section. - * **RoleArn** - Destination Role ARN to use for AWS auth. Supports the [profile] prefix when using Default SDK auth, e.g. [myprofile]arn:aws:iam::123456789012:role/MyRole. - * **Region** - AWS Region (single region only, e.g. us-east-1). - * **CAArn** - AWS ACM PCA Certificate Authority ARN. Example: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - * **S3Bucket** - S3 bucket name used for PCA audit reports (inventory). The AWS identity in the Role context must have read/write permissions to this bucket. - * **UseDefaultSdkAuth** - Use AWS SDK default credential inference (supports EC2 instance role, environment variables, shared credentials, etc.). If RoleArn is prefixed with [profile], that profile is prioritized. - * **DefaultSdkAssumeRole** - If UseDefaultSdkAuth is true, setting this to true will perform AssumeRole into RoleArn using the inferred SDK credentials. - * **UseOAuth** - Use OAuth OIDC authentication to obtain a token, then AssumeRole into RoleArn. - * **OAuthScope** - OAuth scope to request. - * **OAuthGrantType** - OAuth grant type to request (commonly client_credentials). - * **OAuthUrl** - OAuth token endpoint URL. - * **OAuthClientId** - OAuth client id (secret). - * **OAuthClientSecret** - OAuth client secret (secret). - * **UseIAM** - Use IAM user access key/secret to AssumeRole into RoleArn. - * **IAMUserAccessKey** - IAM user access key (secret). - * **IAMUserAccessSecret** - IAM user access secret (secret). - * **ExternalId** - Optional sts:ExternalId to supply on AssumeRole calls. - * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. + * **RoleArn** - Destination Role ARN to use for AWS auth. Supports the [profile] prefix when using Default SDK auth, e.g. [myprofile]arn:aws:iam::123456789012:role/MyRole. + * **Region** - AWS Region (single region only, e.g. us-east-1). + * **CAArn** - AWS ACM PCA Certificate Authority ARN. Example: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + * **S3Bucket** - S3 bucket name used for PCA audit reports (inventory). The AWS identity in the Role context must have read/write permissions to this bucket. + * **UseDefaultSdkAuth** - Use AWS SDK default credential inference (supports EC2 instance role, environment variables, shared credentials, etc.). If RoleArn is prefixed with [profile], that profile is prioritized. + * **DefaultSdkAssumeRole** - If UseDefaultSdkAuth is true, setting this to true will perform AssumeRole into RoleArn using the inferred SDK credentials. + * **UseOAuth** - Use OAuth OIDC authentication to obtain a token, then AssumeRole into RoleArn. + * **OAuthScope** - OAuth scope to request. + * **OAuthGrantType** - OAuth grant type to request (commonly client_credentials). + * **OAuthUrl** - OAuth token endpoint URL. + * **OAuthClientId** - OAuth client id (secret). + * **OAuthClientSecret** - OAuth client secret (secret). + * **UseIAM** - Use IAM user access key/secret to AssumeRole into RoleArn. + * **IAMUserAccessKey** - IAM user access key (secret). + * **IAMUserAccessSecret** - IAM user access secret (secret). + * **ExternalId** - Optional sts:ExternalId to supply on AssumeRole calls. + * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. 2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The AWSPCA CAPlugin plugin supports the following product IDs: @@ -111,14 +110,12 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and 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: - * **LifetimeDays** - OPTIONAL: The number of days of validity to use when requesting certs. If not provided, default is 365 - * **SigningAlgorithm** - Required: AWS ACM PCA certificate signature algorithm to use when issuing certificates. Value is an AWS PCA SigningAlgorithm enum name (case-insensitive), e.g. SHA256WITHRSA, SHA384WITHRSA, SHA256WITHECDSA. If omitted, the plugin selects a default compatible with the CA key algorithm. - + * **LifetimeDays** - OPTIONAL: The number of days of validity to use when requesting certs. If not provided, default is 365 + * **SigningAlgorithm** - Required: AWS ACM PCA certificate signature algorithm to use when issuing certificates. Value is an AWS PCA SigningAlgorithm enum name (case-insensitive), e.g. SHA256WITHRSA, SHA384WITHRSA, SHA256WITHECDSA. If omitted, the plugin selects a default compatible with the CA key algorithm. -## Authentication (Access Key + Secret) +## Authentication -The CAPlugin currently supports **one** authentication method: **AWS Access Key ID + Secret Access Key**. -**OAuth** and **Default SDK authentication** will be enabled in later updates. There is functionality present via the **Keyfactor AWS Authentication** library, but these alternate methods are currently ***untested***. +The CAPlugin authenticates to AWS through the **Keyfactor AWS Authentication** library, which supports several methods. All of the methods below are supported and validated against ACM PCA. Choose **one** method per CA connection by setting the corresponding toggles/fields in **CAConnection**. ### Known Issues @@ -127,83 +124,116 @@ Latest version of Command 25.4 may run into issues, investigation into compatibi ### What you need ready -Before configuring the CAPlugin, have the following prepared: +> **Key concept — the *destination* identity.** Whichever identity the plugin ends up operating as — the assumed `RoleArn`, or the *originating* identity when no Assume Role is performed — is the **destination**, and it is what must hold the ACM PCA + S3 audit-report permissions (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)). For every Assume-Role method the **originating** identity additionally needs `sts:AssumeRole` on the target role, and the target role's **trust policy** must allow that originating principal (see [Example trust policies](#example-trust-policies)). + +### Common requirements (all methods) + +- **Region** — the AWS region for service endpoints; must match your **ACM Private CA** region (e.g. `us-east-2`). +- **CAArn** — the Certificate Authority ARN, e.g. `arn:aws:acm-pca:::certificate-authority/`. +- **S3Bucket** — bucket name (not ARN) for ACM PCA audit reports, e.g. `keyfactor-acmpca-audit-reports`. The destination identity needs `s3:ListBucket`/`s3:GetBucketLocation` on the bucket and `s3:GetObject`/`s3:PutObject` on `/*`, and ACM PCA itself must be allowed to write to it (see [Example policy for bucket](#example-policy-for-bucket)). +- **PCA Root Cert** — download the PCA root certificate from AWS in `.pem` format to upload under **Gateway Registration**. +- **Scan interval** — ACM PCA limits audit-report generation to roughly **once per 30 minutes** per CA. Set the CA's `ServiceSettings` full-scan interval to **≥ 30 minutes** to avoid throttling. + +### Method configuration + +For every method, also set the common `Region`, `CAArn`, and `S3Bucket`, and upload the Root CA under **Gateway Registration**. Set all auth toggles you are **not** using to `false`. + +#### IAM User (Access Key + Secret) +- `UseIAM` = `true` +- `IAMUserAccessKey` (e.g. `AKIAIOSFODNN7EXAMPLE`), `IAMUserAccessSecret` (e.g. `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) +- `RoleArn` — the destination role to assume (e.g. `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole`) + +**AWS setup:** the IAM user needs `sts:AssumeRole` on `RoleArn`; `RoleArn`'s trust policy must allow the IAM user (see [Assume-Role trust](#example-trust-policies)); `RoleArn` holds the [PCA + S3 permissions](#example-iam-policies-for-the-destination-identity). + +#### IAM User + ExternalId +As **IAM User**, plus set `ExternalId`. The target role's trust policy must include a matching `sts:ExternalId` condition (see [Assume-Role with ExternalId](#example-trust-policies)). Use this when the role owner requires a shared secret to prevent the confused-deputy problem. + +#### Default SDK (no assume) +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `false` (`RoleArn` is ignored) + +The Gateway host's **ambient AWS identity** is the destination and must hold the PCA + S3 permissions directly. The AWS SDK resolves this from, in order: `AWS_*` environment variables, the shared credentials file `[default]` profile, or an EC2 instance role. On a Windows AnyCA Gateway host the service runs as `NETWORK SERVICE`, so provide credentials via the machine-level env var `AWS_SHARED_CREDENTIALS_FILE` (pointing at a credentials file readable by the service) or the service account's `%USERPROFILE%\.aws\credentials`. + +#### Default SDK + Assume Role +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `true`, `RoleArn` = destination role + +The ambient identity (resolved as above) is the **originating** account and performs `sts:AssumeRole` into `RoleArn`. The ambient identity needs `sts:AssumeRole`; `RoleArn`'s trust allows it and holds the PCA + S3 permissions. + +#### Credential Profile (± Assume Role) +Prefix the `RoleArn` with a `[profile-name]`, e.g. `[myprofile]arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole`, and set `UseDefaultSdkAuth` = `true`. The named profile must exist in the shared AWS credentials file **on the Gateway host** (same location rules as Default SDK above). + +- **No assume** (`DefaultSdkAssumeRole` = `false`): the profile identity is the destination and needs the PCA + S3 permissions directly. +- **With assume** (`DefaultSdkAssumeRole` = `true`): the profile is the originating identity and assumes `RoleArn` (which holds the permissions and trusts the profile identity). + +#### OAuth (OIDC federation) +- `UseOAuth` = `true` +- `OAuthClientId`, `OAuthClientSecret`, `OAuthUrl` (the token endpoint), `OAuthScope`, `OAuthGrantType` (`client_credentials`) +- `RoleArn` = destination role (federated web-identity role) -#### 1) IAMUserAccessKey and IAMUserAccessSecret -- **Access Key ID** (example format: `AKIAIOSFODNN7EXAMPLE`) -- **Secret Access Key** (example format: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) +The plugin requests an OAuth token (`client_credentials` grant, client id/secret sent as an HTTP **Basic** `Authorization` header) and calls `sts:AssumeRoleWithWebIdentity` on `RoleArn`. -#### 2) A target IAM Role the Gateway will run as (recommended) -Example: -- `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole` +**AWS setup:** create an IAM **OIDC identity provider** for the token issuer, and set `RoleArn`'s trust policy to allow `sts:AssumeRoleWithWebIdentity` for that provider with the appropriate `aud` (and optionally `sub`) conditions (see [OAuth/web-identity trust](#example-trust-policies)). -**Role expectations:** -- The role must have permissions for: - - **ACM PCA operations** (Issue/Get/Revoke/Describe + CA certificate chain retrieval) - - **Audit report creation & status polling** (Create/Describe audit reports) - - **S3 bucket access** to read/write audit report objects +> OAuth notes: the OIDC provider must accept the client id/secret as a **Basic auth header** (not POST body); **DPoP must be disabled** on the app; and the token lifetime must be **≤ 12 hours** — STS `AssumeRoleWithWebIdentity` caps `DurationSeconds` at 43200, and the plugin derives the session duration from the token's `expires_in`. -#### 3) Permissions on the assumed role -The assumed role must have permissions for the AWS services the Gateway needs. This typically includes: -- `acm-pca:*` actions required for enrollment + revocation workflows -- Audit report actions (`acm-pca:CreateCertificateAuthorityAuditReport`, `acm-pca:DescribeCertificateAuthorityAuditReport`) -- S3 bucket and object access for the audit report destination bucket +### Setting up AWS Authentication (Examples) -**See the example IAM policies below in this README section** +> [!NOTE] +> Several different options are offered for authenticating with AWS. +> Documentation for how these options work is maintained in the [aws-auth-library](https://github.com/Keyfactor/aws-auth-library) repository. -#### 4) Region -Know the **AWS region** the connector should use (for service endpoints), e.g.: -- `us-east-1` +The following examples show potential configurations for Roles in AWS with different selected authentication methods. Your configuration steps may differ depending on the specific requirements of your use case. In every case the **destination identity** must hold the ACM Private CA + S3 audit-report permissions (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)), and every Assume-Role method additionally requires the *originating* identity to have `sts:AssumeRole` and the target role's **trust policy** to allow that principal (see [Example trust policies](#example-trust-policies)). -> Region must match the region of your **ACM Private CA**. +
+Host instance credentials using Default SDK and Assume Role -#### 5) CA ARN -Have the **Certificate Authority ARN** for the PCA you want to integrate with. +Select the `Use Default SDK Auth` option (`UseDefaultSdkAuth=true`) to have the plugin load the Gateway host's ambient AWS credentials. If the Gateway runs on an EC2 instance, this is the IAM Role assigned to the instance; on a non-EC2 Windows host it is whatever the service account resolves from `AWS_SHARED_CREDENTIALS_FILE` or `%USERPROFILE%\.aws\credentials` (the AnyCA Gateway service runs as `NETWORK SERVICE`). -Example format: -- `arn:aws:acm-pca:::certificate-authority/` +If that ambient identity is itself the Destination account identity to use with ACM Private CA, no additional Role needs to be configured. If it is only to be used initially and a separate `RoleArn` is designated as the Destination account, also select `Assume new Role using Default SDK Auth` (`DefaultSdkAssumeRole=true`). -Example: -- `arn:aws:acm-pca:us-east-2:123456789012:certificate-authority/11111111-2222-3333-4444-555555555555` +**AWS Setup** +_Note: to use instance credentials, the CAPlugin's Gateway host must be running inside an EC2 instance._ +1. Assign or note the existing IAM Role assigned to the EC2 instance (or the credentials configured for the service account). +2. If Assume Role is used, ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up on the Destination role that allows the ambient identity to assume it. +3. Verify the permissions match the requirements for accessing ACM Private CA (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)). -#### 6) S3 Bucket -Choose an S3 bucket to store / retrieve ACM PCA audit reports. +
-You should have: -- **Bucket name** (example: `keyfactor-acmpca-audit-reports`, not the full bucket ARN!) +
+OAuth OIDC Identity Provider (Okta example) +Select the `Use OAuth` option (`UseOAuth=true`) for a CA connection to use an OAuth Identity Provider, and supply `OAuthClientId`, `OAuthClientSecret`, `OAuthUrl` (token endpoint), `OAuthScope`, and `OAuthGrantType` (`client_credentials`). -> The role needs `s3:ListBucket` / `s3:GetBucketLocation` at the bucket ARN, and `s3:GetObject` / `s3:PutObject` on the object ARN pattern. +**AWS Setup** +1. A 3rd party [Identity Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) — an IAM **OIDC identity provider** for the token issuer — needs to be set up in AWS. +2. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) (the `RoleArn`) needs to be created for use with your Identity Provider. +3. Ensure the [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role with the Identity Provider, allowing `sts:AssumeRoleWithWebIdentity` with the appropriate `aud` (and optionally `sub`) conditions (see [OAuth / web-identity trust](#example-trust-policies)). +4. Verify the permissions match the requirements for accessing ACM Private CA. -#### 7) PCA Root Cert -Download the **PCA root certificate** from AWS and have it ready to import into the Gateway **in `.pem` format**. +**OKTA Setup** +1. Ensure your Authorization Server is set up in Okta. +2. Ensure the appropriate scopes are set up in Okta. +3. Set up an Okta App (client-credentials / API Services type). The app must send the client id/secret as a **Basic auth header** (not POST body), have **DPoP disabled**, and issue tokens with a lifetime **≤ 12 hours** (STS caps `DurationSeconds` at 43200). -### Enabling all this in the Gateway Configuration Portal +
-#### 1) Register the Gateway CA and upload the Root CA certificate -1. Navigate to **Gateway Registration**. -2. Upload the **Root CA Certificate** you downloaded earlier (PEM). +
+IAM User credentials to Assume Role -#### 2) Configure the CA connection settings -1. Navigate to **CAConnection**. -2. Populate: - - `RoleArn` (example: `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole`) - - `Region` (example: `us-east-2`) - - `CAArn` (example: `arn:aws:acm-pca:us-east-2:123456789012:certificate-authority/11111111-2222-3333-4444-555555555555`) - - `S3Bucket` (example: `keyfactor-acmpca-audit-reports`) - - `IAMUserAccessKey` (example: `AKIA...`) - - `IAMUserAccessSecret` (example: `wJalrXU...`) +Select the `Use IAM` option (`UseIAM=true`) for a CA connection to use an IAM User credential, supplying `IAMUserAccessKey` and `IAMUserAccessSecret`. -3. Set these auth toggles: - - `UseDefaultSdkAuth` = `false` - - `UseOAuth` = `false` - - `UseIAM` = `true` +**AWS Setup** +1. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) to Assume with your IAM User needs to be created (set as `RoleArn`). +2. Ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role that allows the IAM User (see [Assume-Role trust](#example-trust-policies)). If using the ExternalId modifier, add a matching `sts:ExternalId` condition. +3. AWS does not support programmatic access for AWS SSO accounts. The account used here must be a standard AWS IAM User with an Access Key credential type. +4. Verify the permissions match the requirements for accessing ACM Private CA (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)). + +
--- -### Example IAM policies for the assumed role +### Example IAM policies for the destination identity -The following examples are intended as **copy/adapt templates**. +These permissions belong on the **destination identity** (the assumed `RoleArn`, or the ambient/profile identity when no Assume Role is performed). The following examples are intended as **copy/adapt templates**. #### Example 1: Minimal PCA issuance/retrieval/revocation @@ -278,6 +308,70 @@ The following examples are intended as **copy/adapt templates**. ``` --- +### Example trust policies + +Every Assume-Role method requires a **trust policy** on the destination role that allows the originating principal to assume it. (Default SDK *no-assume* and Credential Profile *no-assume* need no trust policy — the identity is used directly.) + +#### Assume Role — IAM User, Default SDK + Assume, or Credential Profile + Assume + +`<>` is the IAM user ARN (IAM User method) or the ambient/profile identity ARN (Default SDK / Credential Profile + Assume). + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +#### Assume Role with ExternalId + +Add a matching `sts:ExternalId` condition; the CAConnection `ExternalId` value must equal it. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "<>" } } + } + ] +} +``` + +#### OAuth — web-identity trust + +`RoleArn` for the OAuth method must trust the OIDC provider you registered for your token issuer. Condition on the token `aud` (audience) and optionally `sub` (for `client_credentials`, `sub` is typically the client id). + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam:::oidc-provider//" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "/:aud": "<>", + "/:sub": "<>" + } + } + } + ] +} +``` + +--- + ### Example policy for bucket ```json { @@ -350,11 +444,10 @@ When `SigningAlgorithm` is omitted, the plugin selects: - SM2 -> `SM3WITHSM2` - ML-DSA -> exact-match (`ML_DSA_44/65/87`) - ## 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). diff --git a/aws-pca-caplugin/AWSPCACAPlugin.cs b/aws-pca-caplugin/AWSPCACAPlugin.cs index 5de07a7..ae21afa 100644 --- a/aws-pca-caplugin/AWSPCACAPlugin.cs +++ b/aws-pca-caplugin/AWSPCACAPlugin.cs @@ -95,8 +95,8 @@ public async Task Synchronize( CancellationToken cancelToken) { Logger.MethodEntry(); - Logger.LogTrace( - $"Synchronize started. fullSync={fullSync}, lastSync={lastSync?.ToUniversalTime().ToString("O") ?? "null"}"); + Logger.LogInformation( + $"AWS PCA Synchronize started. fullSync={fullSync}, lastSync={lastSync?.ToUniversalTime().ToString("O") ?? "(none)"}"); try { @@ -105,7 +105,8 @@ public async Task Synchronize( throw new Exception($"AWS audit report failed: {report.RegistrationError.Description}"); var items = report?.Result ?? new List(); - Logger.LogDebug($"Sync found {items.Count} audit records."); + Logger.LogInformation($"AWS PCA Synchronize: retrieved {items.Count} audit record(s) from the CA."); + int emitted = 0; foreach (var audit in items) { @@ -252,6 +253,7 @@ public async Task Synchronize( Status = (int)EndEntityStatus.INPROCESS, ProductID = certResp.CertificateType }, cancelToken); + emitted++; continue; } @@ -294,9 +296,12 @@ public async Task Synchronize( // Emit to buffer as AnyGateway expects. blockingBuffer.Add(finalsubmit, cancelToken); + emitted++; } blockingBuffer.CompleteAdding(); + Logger.LogInformation( + $"AWS PCA Synchronize complete: scanned {items.Count} audit record(s), emitted {emitted} certificate(s) to Keyfactor (added/updated/status-changed). fullSync={fullSync}."); Logger.MethodExit(); } catch (Exception e) @@ -341,14 +346,14 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r return (int)EndEntityStatus.FAILED; } - // Prefer the normalized numeric status from the client response if present. - if (resp != null && resp.Status > 0) - return resp.Status; - - // Fallback if Status wasn't implemented on response - return resp?.RevokeSuccess == true - ? (int)EndEntityStatus.REVOKED - : (int)EndEntityStatus.FAILED; + // Prefer the normalized numeric status from the client response if present; + // otherwise fall back to RevokeSuccess. + var revokedStatus = (resp != null && resp.Status > 0) + ? resp.Status + : (resp?.RevokeSuccess == true ? (int)EndEntityStatus.REVOKED : (int)EndEntityStatus.FAILED); + Logger.LogInformation( + $"AWS PCA Revoke completed: serial={serial}, reason={awsReason}, resultStatus={revokedStatus}."); + return revokedStatus; } catch (Exception ex) { @@ -440,59 +445,35 @@ public async Task Enroll( string priorRequestId; try { - return await IssueAndFetchAsync( - csr, - productInfo.ProductID, - days, - signingAlgorithm, - "Certificate Issued") + priorRequestId = await _certificateDataReader + .GetRequestIDBySerialNumber(priorSn) .ConfigureAwait(false); } - - case EnrollmentType.RenewOrReissue: + catch (Exception ex) { - if (productInfo.ProductParameters == null || - !TryGetProductParam(productInfo.ProductParameters, "PriorCertSN", out var priorSn) || - string.IsNullOrWhiteSpace(priorSn)) - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = - "Renew/Reissue requires ProductParameters['PriorCertSN'] (hex serial number)." - }; - - string priorRequestId; - try - { - priorRequestId = await _certificateDataReader - .GetRequestIDBySerialNumber(priorSn) - .ConfigureAwait(false); - } - catch (Exception ex) + return new EnrollmentResult { - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"Could not resolve PriorCertSN to request id: {ex.Message}" - }; - } + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"Could not resolve PriorCertSN to request id: {ex.Message}" + }; + } - var expiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); - var isRenewal = expiration.HasValue && expiration.Value.ToUniversalTime() <= DateTime.UtcNow; + var expiration = _certificateDataReader.GetExpirationDateByRequestId(priorRequestId); + var isRenewal = expiration.HasValue && expiration.Value.ToUniversalTime() <= DateTime.UtcNow; - var msg = isRenewal ? "Certificate Renewed" : "Certificate Reissued"; - var token = BuildIdempotencyToken(isRenewal ? "renew" : "reissue", priorRequestId, csr); + var msg = isRenewal ? "Certificate Renewed" : "Certificate Reissued"; + var token = BuildIdempotencyToken(isRenewal ? "renew" : "reissue", priorRequestId, csr); - // Still "IssueCertificate" under the hood; PCA doesn't have first-class renew/reissue. - return await IssueAndFetchAsync( - csr, - productInfo.ProductID, - days, - msg, - // Optional: stable-ish idempotency (helps avoid duplicates if caller retries quickly) - token) - .ConfigureAwait(false); - } + // Still "IssueCertificate" under the hood; PCA doesn't have first-class renew/reissue. + return await IssueAndFetchAsync( + csr, + productInfo.ProductID, + days, + msg, + // Optional: stable-ish idempotency (helps avoid duplicates if caller retries quickly) + token) + .ConfigureAwait(false); + } default: return new EnrollmentResult @@ -529,8 +510,9 @@ public async Task Ping() } try { - Logger.LogInformation("Ping request received"); + Logger.LogInformation("AWS PCA Ping request received; contacting AWS..."); await AwsClient.PingAsync().ConfigureAwait(false); + Logger.LogInformation("AWS PCA Ping succeeded (AWS reachable)."); } catch (Exception e) { @@ -769,16 +751,23 @@ private async Task IssueAndFetchAsync( IdempotencyToken = idempotencyToken ?? Guid.NewGuid().ToString("N") }; + Logger.LogInformation( + $"AWS PCA IssueCertificate requested: productId={productId}, validityDays={validityDays}, signingAlgorithm={signingAlgorithm ?? "(auto)"}."); + var issueResp = await AwsClient.SubmitIssueCertificateAsync(issueReq).ConfigureAwait(false); if (issueResp?.RegistrationError != null || issueResp?.Result == null || string.IsNullOrWhiteSpace(issueResp.Result.CertificateArn)) + { + var failMsg = issueResp?.RegistrationError?.Description ?? "AWS PCA IssueCertificate failed."; + Logger.LogError($"AWS PCA IssueCertificate failed (productId={productId}): {failMsg}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, - StatusMessage = issueResp?.RegistrationError?.Description ?? "AWS PCA IssueCertificate failed." + StatusMessage = failMsg }; + } var certArn = issueResp.Result.CertificateArn; var caRequestId = issueResp.Result.CertificateId ?? SafeRequestIdFromArn(certArn) ?? certArn; @@ -811,6 +800,8 @@ private async Task IssueAndFetchAsync( StatusMessage = "AWS PCA returned an empty certificate." }; + Logger.LogInformation( + $"AWS PCA enrollment succeeded ({statusMessageOnSuccess}): productId={productId}, caRequestId={caRequestId}, certificateArn={certArn}, validityDays={validityDays}, status={certResp.Status}."); return new EnrollmentResult { CARequestID = caRequestId, diff --git a/aws-pca-caplugin/AWSPCACAPlugin.csproj b/aws-pca-caplugin/AWSPCACAPlugin.csproj index a53434c..b5796b4 100644 --- a/aws-pca-caplugin/AWSPCACAPlugin.csproj +++ b/aws-pca-caplugin/AWSPCACAPlugin.csproj @@ -12,13 +12,19 @@ - - + + + - - Always @@ -26,17 +32,15 @@ - - + - - + + - - + \ No newline at end of file diff --git a/aws-pca-caplugin/AWSPCACAPlugin.csproj.user.example b/aws-pca-caplugin/AWSPCACAPlugin.csproj.user.example new file mode 100644 index 0000000..188e87b --- /dev/null +++ b/aws-pca-caplugin/AWSPCACAPlugin.csproj.user.example @@ -0,0 +1,22 @@ + + + + + C:\path\to\your\push-script.ps1 + + diff --git a/aws-pca-caplugin/Client/ACMPCAClient.cs b/aws-pca-caplugin/Client/ACMPCAClient.cs index 8f350d6..ac26eab 100644 --- a/aws-pca-caplugin/Client/ACMPCAClient.cs +++ b/aws-pca-caplugin/Client/ACMPCAClient.cs @@ -453,14 +453,37 @@ private async Task DownloadS3ObjectAsStringAsync( { var s3 = await GetOrCreateS3ClientAsync(cancellationToken).ConfigureAwait(false); - using var response = await s3.GetObjectAsync(new GetObjectRequest + // ACM PCA audit-report generation is asynchronous: CreateCertificateAuthorityAuditReport + // returns the S3 key before AWS has finished writing the object. Downloading immediately + // races the report generation and fails with NoSuchKey ("The specified key does not + // exist"). Poll for the object with backoff until it appears (or we time out). + var deadline = DateTime.UtcNow.AddMinutes(3); + var delay = TimeSpan.FromSeconds(2); + while (true) { - BucketName = bucket, - Key = key - }, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + try + { + using var response = await s3.GetObjectAsync(new GetObjectRequest + { + BucketName = bucket, + Key = key + }, cancellationToken).ConfigureAwait(false); - using var reader = new StreamReader(response.ResponseStream); - return await reader.ReadToEndAsync().ConfigureAwait(false); + using var reader = new StreamReader(response.ResponseStream); + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + catch (AmazonS3Exception ex) when ( + (ex.ErrorCode == "NoSuchKey" || ex.StatusCode == System.Net.HttpStatusCode.NotFound) + && DateTime.UtcNow < deadline) + { + Logger.LogDebug( + $"Audit report not yet available in S3 (bucket={bucket}, key={key}); " + + $"retrying in {delay.TotalSeconds:0}s."); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 1.5, 15)); + } + } } private async Task GetOrCreateS3ClientAsync(CancellationToken cancellationToken) @@ -781,4 +804,4 @@ private static class ConfigKeys public const string Region = "Region"; public const string S3Bucket = "S3Bucket"; } -} +} \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index f15dd50..580cfc6 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -8,10 +8,9 @@ This integration is tested and confirmed as working for Anygateway REST 24.4 and ## Gateway Registration Download the **PCA root certificate** from AWS and have it ready to import into the Gateway **in `.pem` format**. -## Authentication (Access Key + Secret) +## Authentication -The CAPlugin currently supports **one** authentication method: **AWS Access Key ID + Secret Access Key**. -**OAuth** and **Default SDK authentication** will be enabled in later updates. There is functionality present via the **Keyfactor AWS Authentication** library, but these alternate methods are currently ***untested***. +The CAPlugin authenticates to AWS through the **Keyfactor AWS Authentication** library, which supports several methods. All of the methods below are supported and validated against ACM PCA. Choose **one** method per CA connection by setting the corresponding toggles/fields in **CAConnection**. ### Known Issues @@ -20,83 +19,116 @@ Latest version of Command 25.4 may run into issues, investigation into compatibi ### What you need ready -Before configuring the CAPlugin, have the following prepared: +> **Key concept — the *destination* identity.** Whichever identity the plugin ends up operating as — the assumed `RoleArn`, or the *originating* identity when no Assume Role is performed — is the **destination**, and it is what must hold the ACM PCA + S3 audit-report permissions (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)). For every Assume-Role method the **originating** identity additionally needs `sts:AssumeRole` on the target role, and the target role's **trust policy** must allow that originating principal (see [Example trust policies](#example-trust-policies)). -#### 1) IAMUserAccessKey and IAMUserAccessSecret -- **Access Key ID** (example format: `AKIAIOSFODNN7EXAMPLE`) -- **Secret Access Key** (example format: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) +### Common requirements (all methods) -#### 2) A target IAM Role the Gateway will run as (recommended) -Example: -- `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole` +- **Region** — the AWS region for service endpoints; must match your **ACM Private CA** region (e.g. `us-east-2`). +- **CAArn** — the Certificate Authority ARN, e.g. `arn:aws:acm-pca:::certificate-authority/`. +- **S3Bucket** — bucket name (not ARN) for ACM PCA audit reports, e.g. `keyfactor-acmpca-audit-reports`. The destination identity needs `s3:ListBucket`/`s3:GetBucketLocation` on the bucket and `s3:GetObject`/`s3:PutObject` on `/*`, and ACM PCA itself must be allowed to write to it (see [Example policy for bucket](#example-policy-for-bucket)). +- **PCA Root Cert** — download the PCA root certificate from AWS in `.pem` format to upload under **Gateway Registration**. +- **Scan interval** — ACM PCA limits audit-report generation to roughly **once per 30 minutes** per CA. Set the CA's `ServiceSettings` full-scan interval to **≥ 30 minutes** to avoid throttling. -**Role expectations:** -- The role must have permissions for: - - **ACM PCA operations** (Issue/Get/Revoke/Describe + CA certificate chain retrieval) - - **Audit report creation & status polling** (Create/Describe audit reports) - - **S3 bucket access** to read/write audit report objects +### Method configuration -#### 3) Permissions on the assumed role -The assumed role must have permissions for the AWS services the Gateway needs. This typically includes: -- `acm-pca:*` actions required for enrollment + revocation workflows -- Audit report actions (`acm-pca:CreateCertificateAuthorityAuditReport`, `acm-pca:DescribeCertificateAuthorityAuditReport`) -- S3 bucket and object access for the audit report destination bucket +For every method, also set the common `Region`, `CAArn`, and `S3Bucket`, and upload the Root CA under **Gateway Registration**. Set all auth toggles you are **not** using to `false`. -**See the example IAM policies below in this README section** +#### IAM User (Access Key + Secret) +- `UseIAM` = `true` +- `IAMUserAccessKey` (e.g. `AKIAIOSFODNN7EXAMPLE`), `IAMUserAccessSecret` (e.g. `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) +- `RoleArn` — the destination role to assume (e.g. `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole`) -#### 4) Region -Know the **AWS region** the connector should use (for service endpoints), e.g.: -- `us-east-1` +**AWS setup:** the IAM user needs `sts:AssumeRole` on `RoleArn`; `RoleArn`'s trust policy must allow the IAM user (see [Assume-Role trust](#example-trust-policies)); `RoleArn` holds the [PCA + S3 permissions](#example-iam-policies-for-the-destination-identity). -> Region must match the region of your **ACM Private CA**. +#### IAM User + ExternalId +As **IAM User**, plus set `ExternalId`. The target role's trust policy must include a matching `sts:ExternalId` condition (see [Assume-Role with ExternalId](#example-trust-policies)). Use this when the role owner requires a shared secret to prevent the confused-deputy problem. -#### 5) CA ARN -Have the **Certificate Authority ARN** for the PCA you want to integrate with. +#### Default SDK (no assume) +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `false` (`RoleArn` is ignored) -Example format: -- `arn:aws:acm-pca:::certificate-authority/` +The Gateway host's **ambient AWS identity** is the destination and must hold the PCA + S3 permissions directly. The AWS SDK resolves this from, in order: `AWS_*` environment variables, the shared credentials file `[default]` profile, or an EC2 instance role. On a Windows AnyCA Gateway host the service runs as `NETWORK SERVICE`, so provide credentials via the machine-level env var `AWS_SHARED_CREDENTIALS_FILE` (pointing at a credentials file readable by the service) or the service account's `%USERPROFILE%\.aws\credentials`. -Example: -- `arn:aws:acm-pca:us-east-2:123456789012:certificate-authority/11111111-2222-3333-4444-555555555555` +#### Default SDK + Assume Role +- `UseDefaultSdkAuth` = `true`, `DefaultSdkAssumeRole` = `true`, `RoleArn` = destination role -#### 6) S3 Bucket -Choose an S3 bucket to store / retrieve ACM PCA audit reports. +The ambient identity (resolved as above) is the **originating** account and performs `sts:AssumeRole` into `RoleArn`. The ambient identity needs `sts:AssumeRole`; `RoleArn`'s trust allows it and holds the PCA + S3 permissions. -You should have: -- **Bucket name** (example: `keyfactor-acmpca-audit-reports`, not the full bucket ARN!) +#### Credential Profile (± Assume Role) +Prefix the `RoleArn` with a `[profile-name]`, e.g. `[myprofile]arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole`, and set `UseDefaultSdkAuth` = `true`. The named profile must exist in the shared AWS credentials file **on the Gateway host** (same location rules as Default SDK above). +- **No assume** (`DefaultSdkAssumeRole` = `false`): the profile identity is the destination and needs the PCA + S3 permissions directly. +- **With assume** (`DefaultSdkAssumeRole` = `true`): the profile is the originating identity and assumes `RoleArn` (which holds the permissions and trusts the profile identity). -> The role needs `s3:ListBucket` / `s3:GetBucketLocation` at the bucket ARN, and `s3:GetObject` / `s3:PutObject` on the object ARN pattern. +#### OAuth (OIDC federation) +- `UseOAuth` = `true` +- `OAuthClientId`, `OAuthClientSecret`, `OAuthUrl` (the token endpoint), `OAuthScope`, `OAuthGrantType` (`client_credentials`) +- `RoleArn` = destination role (federated web-identity role) -#### 7) PCA Root Cert -Download the **PCA root certificate** from AWS and have it ready to import into the Gateway **in `.pem` format**. +The plugin requests an OAuth token (`client_credentials` grant, client id/secret sent as an HTTP **Basic** `Authorization` header) and calls `sts:AssumeRoleWithWebIdentity` on `RoleArn`. + +**AWS setup:** create an IAM **OIDC identity provider** for the token issuer, and set `RoleArn`'s trust policy to allow `sts:AssumeRoleWithWebIdentity` for that provider with the appropriate `aud` (and optionally `sub`) conditions (see [OAuth/web-identity trust](#example-trust-policies)). + +> OAuth notes: the OIDC provider must accept the client id/secret as a **Basic auth header** (not POST body); **DPoP must be disabled** on the app; and the token lifetime must be **≤ 12 hours** — STS `AssumeRoleWithWebIdentity` caps `DurationSeconds` at 43200, and the plugin derives the session duration from the token's `expires_in`. + +### Setting up AWS Authentication (Examples) + +> [!NOTE] +> Several different options are offered for authenticating with AWS. +> Documentation for how these options work is maintained in the [aws-auth-library](https://github.com/Keyfactor/aws-auth-library) repository. + +The following examples show potential configurations for Roles in AWS with different selected authentication methods. Your configuration steps may differ depending on the specific requirements of your use case. In every case the **destination identity** must hold the ACM Private CA + S3 audit-report permissions (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)), and every Assume-Role method additionally requires the *originating* identity to have `sts:AssumeRole` and the target role's **trust policy** to allow that principal (see [Example trust policies](#example-trust-policies)). + +
+Host instance credentials using Default SDK and Assume Role + +Select the `Use Default SDK Auth` option (`UseDefaultSdkAuth=true`) to have the plugin load the Gateway host's ambient AWS credentials. If the Gateway runs on an EC2 instance, this is the IAM Role assigned to the instance; on a non-EC2 Windows host it is whatever the service account resolves from `AWS_SHARED_CREDENTIALS_FILE` or `%USERPROFILE%\.aws\credentials` (the AnyCA Gateway service runs as `NETWORK SERVICE`). + +If that ambient identity is itself the Destination account identity to use with ACM Private CA, no additional Role needs to be configured. If it is only to be used initially and a separate `RoleArn` is designated as the Destination account, also select `Assume new Role using Default SDK Auth` (`DefaultSdkAssumeRole=true`). + +**AWS Setup** +_Note: to use instance credentials, the CAPlugin's Gateway host must be running inside an EC2 instance._ +1. Assign or note the existing IAM Role assigned to the EC2 instance (or the credentials configured for the service account). +2. If Assume Role is used, ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up on the Destination role that allows the ambient identity to assume it. +3. Verify the permissions match the requirements for accessing ACM Private CA (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)). + +
+ +
+OAuth OIDC Identity Provider (Okta example) + +Select the `Use OAuth` option (`UseOAuth=true`) for a CA connection to use an OAuth Identity Provider, and supply `OAuthClientId`, `OAuthClientSecret`, `OAuthUrl` (token endpoint), `OAuthScope`, and `OAuthGrantType` (`client_credentials`). + +**AWS Setup** +1. A 3rd party [Identity Provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) — an IAM **OIDC identity provider** for the token issuer — needs to be set up in AWS. +2. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) (the `RoleArn`) needs to be created for use with your Identity Provider. +3. Ensure the [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role with the Identity Provider, allowing `sts:AssumeRoleWithWebIdentity` with the appropriate `aud` (and optionally `sub`) conditions (see [OAuth / web-identity trust](#example-trust-policies)). +4. Verify the permissions match the requirements for accessing ACM Private CA. + +**OKTA Setup** +1. Ensure your Authorization Server is set up in Okta. +2. Ensure the appropriate scopes are set up in Okta. +3. Set up an Okta App (client-credentials / API Services type). The app must send the client id/secret as a **Basic auth header** (not POST body), have **DPoP disabled**, and issue tokens with a lifetime **≤ 12 hours** (STS caps `DurationSeconds` at 43200). + +
-### Enabling all this in the Gateway Configuration Portal +
+IAM User credentials to Assume Role -#### 1) Register the Gateway CA and upload the Root CA certificate -1. Navigate to **Gateway Registration**. -2. Upload the **Root CA Certificate** you downloaded earlier (PEM). +Select the `Use IAM` option (`UseIAM=true`) for a CA connection to use an IAM User credential, supplying `IAMUserAccessKey` and `IAMUserAccessSecret`. -#### 2) Configure the CA connection settings -1. Navigate to **CAConnection**. -2. Populate: - - `RoleArn` (example: `arn:aws:iam::123456789012:role/Keyfactor-AnyGateway-AcmPcaRole`) - - `Region` (example: `us-east-2`) - - `CAArn` (example: `arn:aws:acm-pca:us-east-2:123456789012:certificate-authority/11111111-2222-3333-4444-555555555555`) - - `S3Bucket` (example: `keyfactor-acmpca-audit-reports`) - - `IAMUserAccessKey` (example: `AKIA...`) - - `IAMUserAccessSecret` (example: `wJalrXU...`) +**AWS Setup** +1. An [AWS Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html) to Assume with your IAM User needs to be created (set as `RoleArn`). +2. Ensure a [Trust Relationship](https://docs.aws.amazon.com/directoryservice/latest/admin-guide/edit_trust.html) is set up for that role that allows the IAM User (see [Assume-Role trust](#example-trust-policies)). If using the ExternalId modifier, add a matching `sts:ExternalId` condition. +3. AWS does not support programmatic access for AWS SSO accounts. The account used here must be a standard AWS IAM User with an Access Key credential type. +4. Verify the permissions match the requirements for accessing ACM Private CA (see [Example IAM policies](#example-iam-policies-for-the-destination-identity)). -3. Set these auth toggles: - - `UseDefaultSdkAuth` = `false` - - `UseOAuth` = `false` - - `UseIAM` = `true` +
--- -### Example IAM policies for the assumed role +### Example IAM policies for the destination identity -The following examples are intended as **copy/adapt templates**. +These permissions belong on the **destination identity** (the assumed `RoleArn`, or the ambient/profile identity when no Assume Role is performed). The following examples are intended as **copy/adapt templates**. #### Example 1: Minimal PCA issuance/retrieval/revocation @@ -171,6 +203,70 @@ The following examples are intended as **copy/adapt templates**. ``` --- +### Example trust policies + +Every Assume-Role method requires a **trust policy** on the destination role that allows the originating principal to assume it. (Default SDK *no-assume* and Credential Profile *no-assume* need no trust policy — the identity is used directly.) + +#### Assume Role — IAM User, Default SDK + Assume, or Credential Profile + Assume + +`<>` is the IAM user ARN (IAM User method) or the ambient/profile identity ARN (Default SDK / Credential Profile + Assume). + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +#### Assume Role with ExternalId + +Add a matching `sts:ExternalId` condition; the CAConnection `ExternalId` value must equal it. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "<>" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "<>" } } + } + ] +} +``` + +#### OAuth — web-identity trust + +`RoleArn` for the OAuth method must trust the OIDC provider you registered for your token issuer. Condition on the token `aud` (audience) and optionally `sub` (for `client_credentials`, `sub` is typically the client id). + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam:::oidc-provider//" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "/:aud": "<>", + "/:sub": "<>" + } + } + } + ] +} +``` + +--- + ### Example policy for bucket ```json {