From 3578cc782670bd2ec2587e22460ab0a9f0e4d6c5 Mon Sep 17 00:00:00 2001 From: Sam Eiderman Date: Sun, 3 May 2026 10:10:23 +0300 Subject: [PATCH] Allow GCP KMS keys to use a custom cryptoKey version Until now, every GCP KMS operation hardcoded cryptoKey version "1". This matches Canton-generated keys (Canton always creates a new cryptoKey rather than adding a version to an existing one), but it prevents using cryptoKeys whose key material was imported into GCP KMS, since each import creates a new cryptoKey version and the version that holds the imported material is typically not "1" -- and can be relevant even for asymmetric keys. Add a new optional `key-version-overrides` field to `KmsConfig.Gcp`, mapping a cryptoKey id to the cryptoKey version Canton should use for that key. Keys not listed continue to default to "1", so existing configurations are unchanged. The lookup happens at every call site that previously used the hardcoded version, via a single `cryptoKeyVersionName` helper. Symmetric encrypt/decrypt paths are unchanged: GCP picks the primary version on encrypt and recovers it from the ciphertext on decrypt, so no per-key version is needed there. Includes documentation for the new field in the GCP KMS config reference and the external-key-storage how-to, plus an UNRELEASED.md entry. --- UNRELEASED.md | 7 ++ .../canton/config/KmsConfig.scala | 9 ++ .../canton/crypto/kms/gcp/GcpKms.scala | 93 +++++++------------ .../kms/configuration/kms_gcp_config.rst | 12 +++ .../secure/kms/mode/external_key_storage.rst | 8 ++ 5 files changed, 71 insertions(+), 58 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 31f8cde112..8c58195d27 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -79,6 +79,13 @@ Three gauge metrics are available under `daml.participant.api.indexer` to monito - `.replication.connection-pool.connection.client-connection-check-interval` is introduced that allows configuring the PostgreSQL-specific `client_connection_check_interval` parameter for DB locked connections. This is a safety mechanism to prevent hanging connections in case of network issues. The default value is 5 seconds. +- GCP KMS configuration gains a new `key-version-overrides` map that lets operators specify a custom + cryptoKey version per cryptoKey id (e.g. `key-version-overrides = { "my-imported-key" = "3" }`). + Previously, Canton always used version `"1"`. Keys not listed in the map continue to default to + version `"1"`, so existing configurations are unchanged. This is useful for cryptoKeys whose key + material was [imported into GCP KMS](https://cloud.google.com/kms/docs/importing-a-key), where each + import creates a new cryptoKey version and multiple versions can be relevant even for asymmetric + keys. ### Preview Features - preview feature diff --git a/community/base/src/main/scala/com/digitalasset/canton/config/KmsConfig.scala b/community/base/src/main/scala/com/digitalasset/canton/config/KmsConfig.scala index 2d0dff0311..39e2f71140 100644 --- a/community/base/src/main/scala/com/digitalasset/canton/config/KmsConfig.scala +++ b/community/base/src/main/scala/com/digitalasset/canton/config/KmsConfig.scala @@ -107,6 +107,14 @@ object KmsConfig { * retry configuration * @param endpointOverride * the [optional] endpoint for a proxy to be used by the KMS client. + * @param keyVersionOverrides + * per-cryptoKey overrides for the cryptoKey version that Canton should use when interacting + * with that key. Each map entry is ` -> `. Keys not listed default to + * version `"1"`, which matches how Canton creates new cryptoKeys (it never adds a new + * version to an existing one). This is intended for cryptoKeys whose key material was + * [imported into GCP KMS](https://cloud.google.com/kms/docs/importing-a-key), where each + * import creates a new cryptoKey version and multiple versions can be relevant even for + * asymmetric keys. */ final case class Gcp( locationId: String, @@ -115,6 +123,7 @@ object KmsConfig { auditLogging: Boolean = false, override val retries: RetryConfig = RetryConfig(), endpointOverride: Option[String] = None, + keyVersionOverrides: Map[String, String] = Map.empty, ) extends KmsConfig object Gcp { diff --git a/community/base/src/main/scala/com/digitalasset/canton/crypto/kms/gcp/GcpKms.scala b/community/base/src/main/scala/com/digitalasset/canton/crypto/kms/gcp/GcpKms.scala index 802b9fceba..f95dbd00f5 100644 --- a/community/base/src/main/scala/com/digitalasset/canton/crypto/kms/gcp/GcpKms.scala +++ b/community/base/src/main/scala/com/digitalasset/canton/crypto/kms/gcp/GcpKms.scala @@ -63,10 +63,27 @@ class GcpKms( private lazy val loggerKms = new GcpRequestResponseLogger(config.auditLogging, loggerFactory) - /* Identifies the version for all asymmetric GCP keys. Canton always opts to generate a new keys - * rather than adding a new version for that key. - */ - private val gcpKeyversion = "1" + /** Resolves the GCP cryptoKey version Canton should use for the given [[KmsKeyId]]. If the + * key id is listed in [[KmsConfig.Gcp.keyVersionOverrides]], its mapped version is returned; + * otherwise [[GcpKms.defaultGcpKeyVersion]] (`"1"`) is returned, matching Canton's behavior + * of never adding a new version to a cryptoKey it created. + */ + private def keyVersionFor(keyId: KmsKeyId): String = + config.keyVersionOverrides.getOrElse(keyId.unwrap, GcpKms.defaultGcpKeyVersion) + + private def cryptoKeyVersionName(keyId: KmsKeyId): gcp.CryptoKeyVersionName = + gcp.CryptoKeyVersionName.of( + config.projectId, + config.locationId, + config.keyRingId, + keyId.unwrap, + keyVersionFor(keyId), + ) + + private def cryptoKeyName(keyId: KmsKeyId): gcp.CryptoKeyName = + // Symmetric keys: GCP picks the primary version on encrypt and recovers it from the + // ciphertext on decrypt, so no per-key version is needed here. + gcp.CryptoKeyName.of(config.projectId, config.locationId, config.keyRingId, keyId.unwrap) private val errorMessagesToRetry = Set( @@ -260,14 +277,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, gcp.PublicKey] = { - val keyVersionName = - gcp.CryptoKeyVersionName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - gcpKeyversion, - ) + val keyVersionName = cryptoKeyVersionName(keyId) loggerKms.withLogging[gcp.PublicKey]( loggerKms.getPublicKeyRequestMsg(keyId.unwrap), publicKey => loggerKms.getPublicKeyResponseMsg(keyId.unwrap, publicKey.getAlgorithm.name), @@ -411,13 +421,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, ByteString6144] = { - val keyName = - gcp.CryptoKeyName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - ) + val keyName = cryptoKeyName(keyId) val encryptionAlgorithm = CryptoKeyVersionAlgorithm.GOOGLE_SYMMETRIC_ENCRYPTION for { dataEnc <- loggerKms.withLogging[ByteString]( @@ -446,13 +450,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, ByteString4096] = { - val keyName = - gcp.CryptoKeyName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - ) + val keyName = cryptoKeyName(keyId) val encryptionAlgorithm = CryptoKeyVersionAlgorithm.GOOGLE_SYMMETRIC_ENCRYPTION for { dataPlain <- loggerKms.withLogging[ByteString]( @@ -483,14 +481,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, ByteString190] = { - val keyName = - gcp.CryptoKeyVersionName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - gcpKeyversion, - ) + val keyName = cryptoKeyVersionName(keyId) for { encryptionAlgorithm <- convertToGcpAsymmetricEncryptionSpec(encryptionAlgorithmSpec) .leftMap(err => KmsDecryptError(keyId, err)) @@ -547,14 +538,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, ByteString] = { - val keyVersionName = - gcp.CryptoKeyVersionName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - gcpKeyversion, - ) + val keyVersionName = cryptoKeyVersionName(keyId) signingAlgorithmSpec match { case SigningAlgorithmSpec.EcDsaSha256 => signingKeySpec match { @@ -603,14 +587,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, Unit] = { - val keyVersionName = - gcp.CryptoKeyVersionName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - gcpKeyversion, - ) + val keyVersionName = cryptoKeyVersionName(keyId) loggerKms.withLogging[Unit]( loggerKms.deleteKeyRequestMsg(keyId.unwrap), _ => loggerKms.deleteKeyResponseMsg(keyId.unwrap), @@ -630,14 +607,7 @@ class GcpKms( ec: ExecutionContext, tc: TraceContext, ): EitherT[FutureUnlessShutdown, KmsError, gcp.CryptoKeyVersion] = { - val keyVersionName = - gcp.CryptoKeyVersionName.of( - config.projectId, - config.locationId, - config.keyRingId, - keyId.unwrap, - gcpKeyversion, - ) + val keyVersionName = cryptoKeyVersionName(keyId) loggerKms.withLogging[gcp.CryptoKeyVersion]( loggerKms.retrieveKeyMetadataRequestMsg(keyId.unwrap), keyMetadata => @@ -662,6 +632,13 @@ class GcpKms( object GcpKms extends Kms.SupportedSchemes { + /** Default cryptoKey version used by Canton when no override is specified for a given key + * via [[KmsConfig.Gcp.keyVersionOverrides]]. Canton creates new cryptoKeys in GCP KMS + * rather than adding a new version to an existing one, so version `"1"` is always correct + * for Canton-generated keys. + */ + private[gcp] val defaultGcpKeyVersion: String = "1" + val supportedSigningKeySpecs: NonEmpty[Set[SigningKeySpec]] = NonEmpty.mk( Set, diff --git a/docs-open/src/sphinx/participant/howtos/secure/kms/configuration/kms_gcp_config.rst b/docs-open/src/sphinx/participant/howtos/secure/kms/configuration/kms_gcp_config.rst index f184011a52..cd28394ed3 100644 --- a/docs-open/src/sphinx/participant/howtos/secure/kms/configuration/kms_gcp_config.rst +++ b/docs-open/src/sphinx/participant/howtos/secure/kms/configuration/kms_gcp_config.rst @@ -21,6 +21,18 @@ node's configuration file. A KMS for GCP is configured in the following way: - ``project-id`` specifies which project are we binding to. - ``key-ring-id`` specifies the keyring to use. Multi region keys are enabled for an entire keyring. Therefore, the KMS operator is responsible for setting the keyring correctly depending on the systems' needs. - ``audit-logging`` flag that enables logging of every call made to the GCP KMS +- ``key-version-overrides`` (optional) maps a GCP cryptoKey id to the cryptoKey version Canton + should use for that key. Keys not listed default to version ``"1"``, which matches Canton's + behavior of creating a new cryptoKey rather than adding a version to an existing one. This + is intended for cryptoKeys whose key material was + `imported into GCP KMS `_, where each + import creates a new cryptoKey version and multiple versions can be relevant even for + asymmetric keys. Example:: + + key-version-overrides = { + "my-imported-signing-key" = "3" + "my-imported-encryption-key" = "2" + } Configure GCP credentials and permissions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs-open/src/sphinx/participant/howtos/secure/kms/mode/external_key_storage.rst b/docs-open/src/sphinx/participant/howtos/secure/kms/mode/external_key_storage.rst index 7eecca9c05..e0ea75c85f 100644 --- a/docs-open/src/sphinx/participant/howtos/secure/kms/mode/external_key_storage.rst +++ b/docs-open/src/sphinx/participant/howtos/secure/kms/mode/external_key_storage.rst @@ -76,3 +76,11 @@ where `xyzKmsKeyId` is the KMS key identifier for a specific key (e.g. `KMS Key .. note:: When using `AWS cross account keys `_ the key ID can't be used, use the key `ARN` instead. + +.. note:: + For GCP KMS, the key identifier is the cryptoKey id (e.g. ``my-signing-key``). By + default, Canton uses version ``"1"`` of the cryptoKey. If you registered a cryptoKey + whose key material was `imported into GCP KMS `_ + and want to target a specific cryptoKey version, set + :ref:`\`\`key-version-overrides\`\` ` on the GCP KMS config, mapping the + cryptoKey id to the desired version.