Canton KMS Driver Developer Guide

Important

Daml Enterprise license required

Note

This is an Initial Availability feature. Please contact your account manager or DA support for further guidance.

Introduction

The Canton protocol relies on a number of cryptographic operations such as asymmetric encryption and digital signatures. To maximize the operational security of a Canton node the corresponding private keys should not be stored or processed in cleartext. A Key Management System (KMS) or Hardware Security Module (HSM) allows us to perform such cryptographic operations where the private key resides securely inside the KMS/HSM. All nodes in Canton can make use of a KMS.

AWS KMS and Google Cloud KMS are supported as of Canton v2.7. To broaden the support of other KMSs and HSMs, Canton v2.9 introduces a plugin approach, called KMS Drivers, which allows the implementation of custom integrations. This document explains the APIs that must be implemented for a KMS Driver, provides a guide on the implementation, and describes how to configure Canton to run with a KMS driver. An implementation needs to be developed for the JVM, currently only Scala is supported.

KMS Driver API

The two main APIs that are required for a KMS Driver are:

  1. Driver Factory: Implements how a driver is instantiated and the main entry point for Canton to load a driver.
  2. KMS Driver: The actual KMS driver API that offers cryptographic operations based on the KMS.

The stable APIs are versioned with a single major version number. A breaking change to either the factory or driver APIs will result in a new major version of those APIs. The current and only version is v1, which is part of the module path for the respective API interfaces.

KMS Driver Factory API v1

The factory consists of two interfaces: a generic v1 DriverFactory and a specific v1 KmsDriverFactory.

The v1.DriverFactory defines the following aspects for a generic driver:

  • The type of the driver
  • A name that uniquely identifies the driver
  • The version of the API the driver implements and optional build information (driver version number or commit hash)
  • A driver-specific configuration object with configuration parser and writer
  • A create method that instantiates a driver with that factory

Concretely the interface is defined as the following in Scala:

// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates.
// Proprietary code. All rights reserved.

package com.digitalasset.canton.driver.api.v1

import com.digitalasset.canton.driver.api
import org.slf4j.Logger
import pureconfig.{ConfigReader, ConfigWriter}

import scala.concurrent.ExecutionContext

/** The corresponding factory for an implementation of a [[Driver]] that can instantiate a new driver. */
trait DriverFactory extends api.DriverFactory {

  /** The name of the driver that is instantiated by an implementation of the driver factory. */
  def name: String

  /** The version of the driver API this factory is implemented against. */
  def version: Int

  /** Optional information for the build of the driver factory, e.g., git commit hash. */
  def buildInfo: Option[String]

  /** The driver-specific configuration type. */
  type ConfigType

  /** The parser to load the driver-specific configuration. */
  def configReader: ConfigReader[ConfigType]

  /** The configuration writer for the driver-specific configuration.
    *
    * @param confidential If the flag is true, the config writer should omit any sensitive configuration items, such as credentials.
    */
  def configWriter(confidential: Boolean): ConfigWriter[ConfigType]

  /** The creation method of a driver by this factory.
    * If the creation of the driver fails this method should throw an exception.
    *
    * @param config The driver-specific configuration.
    * @param loggerFactory A logger factory that should be used by the driver to create a logger for a particular class.
    * @param executionContext The execution context that should be used by the driver.
    *
    * @return A new instance of [[Driver]].
    */
  def create(
      config: ConfigType,
      loggerFactory: Class[_] => Logger,
      executionContext: ExecutionContext,
  ): Driver
}

A specialization of the generic DriverFactory is the v1.KmsDriverFactory which defines the driver type to be a KmsDriver and the API version to be 1.

// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates.
// Proprietary code. All rights reserved.

package com.digitalasset.canton.crypto.kms.driver.api.v1

import com.digitalasset.canton.crypto.kms.driver.api
import com.digitalasset.canton.driver.api.v1

trait KmsDriverFactory extends api.KmsDriverFactory with v1.DriverFactory {

  override val version: Int = 1

  override type Driver <: KmsDriver

}

Driver Configuration Reading & Parsing

Canton uses pureconfig configuration library to read its configuration. Given that the configuration of the driver can be embedded inside a Canton configuration file, the factory of the driver needs to specify the type of configuration, as well as configuration readers and writers. A ConfigType can be a case class or as basic as a Map[String, String]. In the latter case, the config reader and writer can be defined as:

  • val configReader = pureconfig.ConfigReader.mapReader[String]
  • val configWriter = pureconfig.ConfigWriter.mapWriter[String]

KMS Driver API v1

The main part of the API is the v1.KmsDriver API that defines the following operations for a KMS Driver:

  • Key Generation: Asymmetric signing and encryption key pairs as well as symmetric encryption keys
  • Supported Key and Algorithm Specifications: The specs that are supported by the driver, for example, RSA 2048 keys and RSA OAEP SHA256 asymmetric encryption.
  • Signing: Sign data with a key from the KMS and the specified algorithm.
  • Asymmetric Decryption: Decrypt a ciphertext with a private key from the KMS and the specified algorithm.
  • Symmetric Encryption and Decryption: Encrypt or decrypt with a symmetric encryption key from the KMS. The default symmetric encryption algorithm of the KMS is used.
  • Key Management: Get the public key of a private key stored in the KMS, check if a key exists, and delete a key.
  • Health: Return the health of the KMS Driver instance.

The API is designed as an asynchronous API using Futures. An OpenTelemetry trace context is passed in from Canton into the KMS driver operations to be able to link a Canton request to operations in the KMS. The driver is responsible for propagating the trace context into the KMS.

Concretely the Scala interface is defined as the following:

// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates.
// Proprietary code. All rights reserved.

package com.digitalasset.canton.crypto.kms.driver.api.v1

import com.digitalasset.canton.crypto.kms.driver.api
import io.opentelemetry.context.Context

import scala.concurrent.Future

/** The interface for a pluggable KMS implementation, that is, a KMS Driver.
  *
  * Cryptographic operations are async, i.e., they return a Future.
  * In case of failures, the future must be failed with a [[KmsDriverException]].
  *
  * Each KMS operation takes an OpenTelemetry [[io.opentelemetry.context.Context]] as a trace context that can optionally be propagated to the external KMS.
  */
trait KmsDriver extends api.KmsDriver with AutoCloseable {

  /** Returns the current health of the driver.
    *
    * If the driver reports itself as unhealthy, Canton will close the current driver instance and create a new one to recover from the unhealthy state.
    * Transient failures should be reported by throwing an [[com.digitalasset.canton.crypto.kms.driver.api.v1.KmsDriverException]] with `retryable` true on driver operations.
    *
    * @return A future that completes with the driver's health.
    */
  def health: Future[KmsDriverHealth]

  /** The supported signing key specifications by the driver */
  def supportedSigningKeySpecs: Set[SigningKeySpec]

  /** The supported signing algorithm specifications by the driver */
  def supportedSigningAlgoSpecs: Set[SigningAlgoSpec]

  /** The supported encryption key specifications by the driver */
  def supportedEncryptionKeySpecs: Set[EncryptionKeySpec]

  /** The supported encryption algorithm specifications by the driver */
  def supportedEncryptionAlgoSpecs: Set[EncryptionAlgoSpec]

  /** Generate a new signing key pair.
    *
    * @param signingKeySpec The key specification for the new signing key pair. The caller ensures it is a [[supportedSigningKeySpecs]].
    * @param keyName An optional descriptive name for the key pair, max 300 characters long.
    *
    * @return A future that completes with the unique KMS key identifier, max 300 characters long.
    */
  def generateSigningKeyPair(
      signingKeySpec: SigningKeySpec,
      keyName: Option[String],
  )(traceContext: Context): Future[String]

  /** Generate a new asymmetric encryption key pair.
    *
    * @param encryptionKeySpec The key specification of the new encryption key pair. The caller ensures it is a [[supportedEncryptionKeySpecs]].
    * @param keyName An optional descriptive name for the key pair, max 300 characters long.
    *
    * @return A future that completes with the unique KMS key identifier, max 300 characters long.
    */
  def generateEncryptionKeyPair(
      encryptionKeySpec: EncryptionKeySpec,
      keyName: Option[String],
  )(traceContext: Context): Future[String]

  /** Generate a new symmetric encryption key.
    * The default symmetric key specification of the KMS is used.
    *
    * @param keyName An optional descriptive name for the symmetric key, max 300 characters long.
    *
    * @return A future that completes with the unique KMS key identifier, max 300 characters long.
    */
  def generateSymmetricKey(keyName: Option[String])(traceContext: Context): Future[String]

  /** Sign the given data using the private key identified by the keyId with the given signing algorithm specification.
    * If the `algoSpec` is not compatible with the key spec of `keyId` then this method must fail with a non-retryable exception.
    *
    * @param data The data to be signed with the specified signature algorithm. The upper bound of the data size is 4kb.
    * @param keyId The identifier of the private signing key.
    * @param algoSpec The signature algorithm specification. The caller ensures it is a [[supportedSigningAlgoSpecs]].
    *
    * @return A future that completes with the signature.
    */
  def sign(data: Array[Byte], keyId: String, algoSpec: SigningAlgoSpec)(
      traceContext: Context
  ): Future[Array[Byte]]

  /** Asymmetrically decrypt the given ciphertext using the private key identified by the keyId with the given asymmetric encryption algorithm specification.
    * If the `algoSpec` is not compatible with the key spec of `keyId` then this method must fail with a non-retryable exception.
    *
    * @param ciphertext The asymmetrically encrypted ciphertext that needs to be decrypted. The length of the ciphertext depends on the parameters of the asymmetric encryption algorithm. Implementations may assume that the length of the ciphertext is at most 6144 bytes in any case.
    * @param keyId The identifier of the private encryption key to perform the asymmetric decryption with.
    * @param algoSpec The asymmetric encryption algorithm specification. The caller ensures it is a [[supportedEncryptionAlgoSpecs]].
    *
    * @return A future that completes with the plaintext.
    */
  def decryptAsymmetric(
      ciphertext: Array[Byte],
      keyId: String,
      algoSpec: EncryptionAlgoSpec,
  )(traceContext: Context): Future[Array[Byte]]

  /** Symmetrically encrypt the given plaintext using the symmetric encryption key identified by the keyId.
    * The same/default symmetric encryption algorithm of the KMS must be used for both symmetric encryption and decryption.
    *
    * @param data The plaintext to symmetrically encrypt. The upper bound of the data size is 4kb.
    * @param keyId The identifier of the symmetric encryption key.
    *
    * @return A future that completes with the ciphertext.
    */
  def encryptSymmetric(data: Array[Byte], keyId: String)(traceContext: Context): Future[Array[Byte]]

  /** Symmetrically decrypt the given ciphertext using the symmetric encryption key identified by the keyId.
    * The same/default symmetric encryption algorithm of the KMS must be used for both symmetric encryption and decryption.
    *
    * @param ciphertext The ciphertext to symmetrically decrypt. The upper bound of the ciphertext size is 6144 bytes.
    * @param keyId The identifier of the symmetric encryption key.
    *
    * @return A future that completes with the plaintext.
    */
  def decryptSymmetric(ciphertext: Array[Byte], keyId: String)(
      traceContext: Context
  ): Future[Array[Byte]]

  /** Exports a public key from the KMS for the given key pair identified by keyId.
    *
    * @param keyId The identifier of the key pair.
    *
    * @return A future that completes with the exported [[PublicKey]]
    */
  def getPublicKey(keyId: String)(traceContext: Context): Future[PublicKey]

  /** Asserts that the key given by its identifier exists and is active.
    *
    * @param keyId The identifier of the key to be checked.
    *
    * @return A future that completes successfully if the key exists and is active. Otherwise the future must have been failed.
    */
  def keyExistsAndIsActive(keyId: String)(traceContext: Context): Future[Unit]

  /** Deletes a key given by its identifier from the KMS.
    *
    * @param keyId The identifier of the key to be deleted.
    *
    * @return A future that completes when the key has been deleted or the deletion of the key has been scheduled.
    */
  def deleteKey(keyId: String)(traceContext: Context): Future[Unit]

}

/** A public key exported from the KMS.
  *
  * @param key The DER-encoded X.509 public key (SubjectPublicKeyInfo)
  * @param spec The key specification of the key pair
  */
final case class PublicKey(key: Array[Byte], spec: KeySpec)

Error Handling and Health

In case the driver experiences an error the Future of the operation should be failed with a KmsDriverException. When the exception’s flag retryable is true the caller side, i.e., Canton, performs a retry with exponential backoff. This behavior is suitable for transient errors, such as network issues, resource exhaustion etc.

In case of permanent errors, a non-retryable exception should be thrown, which either fails the current operation from where the cryptographic operation is called or causes a fatal error in the Canton node.

The driver should report its health through the health method. A Canton node periodically queries the health of the driver and reports it as part of the node’s overall health.

Develop and Test a KMS Driver

Set Up API Dependency

The Canton KMS Driver API is published as an artifact on Digital Asset’s JFrog Artifactory:

https://digitalasset.jfrog.io/ui/repos/tree/General/canton-kms-driver-api

You must have a Canton enterprise license and account to access the artifact. You may need to configure your build system to authenticate with a personal access token towards JFrog Artifactory.

In your build system of choice, you need to depend on the API as a regular Maven-style artifact with:

  • organization: com.digitalasset.canton
  • artifact: kms-driver-api
  • version: the Canton release version, e.g., 2.9.0

Implement the API and Build the Driver

Implement the v1.KmsDriverFactory by specifying the driver’s name, configuration type with readers/writers, and the create method to instantiate a driver by creating a new class instance of your KmsDriver implementation. Specify the fully qualified name of the factory class in the file:

src/main/resources/META-INF/services/com.digitalasset.canton.crypto.kms.driver.api.v1.KmsDriverFactory

and in the following file for the base driver factory (without v1):

src/main/resources/META-INF/services/com.digitalasset.canton.crypto.kms.driver.api.KmsDriverFactory

The major part of the implementation is the v1.KmsDriver that is specific to the KMS/HSM to be integrated with. The supported key and algorithm specifications can be defined statically depending on the capabilities of the underlying KMS/HSM. To ensure the best compatibility with other Canton nodes, all currently specified key and algorithm specifications should be supported.

Any credentials required by the underlying KMS/HSM can either be passed through the Canton configuration file as part of the driver-specific configuration, where secrets can be resolved from the environment, or retrieved by the driver directly from the environment or any other driver-specific means.

Bundle your driver into a self-contained jar, that is, with all required libraries included in the jar. That way you only need a single driver jar when starting Canton with your KMS Driver.

KMS Driver Testing

The reusable test suite for KMS Drivers is published at canton-kms-driver-testing. Configure your build system to depend on this maven artifact in the test scope of your project (e.g. for sbt append % Test to limit the dependency to the test scope).

KmsDriverTest

The main part of the test suite is the KmsDriverTest that tests the functionality of a driver against the KmsDriver API.

In the simplest form the specific driver test class extends the KmsDriverTest and allows the generation of new keys as part of the test:

class AwsKmsDriverTest extends KmsDriverTest {

  override protected def newKmsDriver(): KmsDriver = {
    val awsKms = AwsKms
      .create(KmsConfig.Aws.awsTestConfig, timeouts, loggerFactory)
      .valueOrFail("failed to create AWS KMS")

    new AwsKmsDriver(awsKms)
  }

  "AWS KMS Driver" must {
    behave like kmsDriver(allowKeyGeneration = true)
  }

}

Generating new keys can be expensive when running tests during development, in particular when using cloud-based KMSs. To mitigate this, the test suite can also be configured to use predefined keys to test most parts (except key generation) of the KMS Driver API:

class AwsKmsDriverWithPredefinedKeysTest extends KmsDriverTest {

  override val predefinedSigningKeys: Map[SigningKeySpec, String] =
    Map(
      SigningKeySpec.EcP256 -> "alias/canton-kms-test-signing-key",
      SigningKeySpec.EcP384 -> "alias/canton-kms-test-signing-key-P384",
    )

  override val predefinedEncryptionKeys: Map[EncryptionKeySpec, String] =
    Map(EncryptionKeySpec.Rsa2048 -> "alias/canton-kms-test-asymmetric-key")

  override val predefinedSymmetricKey: Option[String] = Some("alias/canton-kms-test-key")

  override protected def newKmsDriver(): KmsDriver = {
    val awsKms = AwsKms
      .create(KmsConfig.Aws.awsTestConfig, timeouts, loggerFactory)
      .valueOrFail("failed to create AWS KMS")

    new AwsKmsDriver(awsKms)
  }

  "AWS KMS Driver" must {
    behave like kmsDriver(allowKeyGeneration = false)
  }

}

For each supported signing/encryption key specification an existing key alias/ID can be configured as part of the predefined keys maps. When running the test suite the generation of new keys is not allowed.

KmsDriverFactoryTest

The test suite for the KMS driver factory is structured similarly to the above:

class AwsKmsDriverFactoryTest extends KmsDriverFactoryTest {

  override type Factory = AwsKmsDriverFactory

  override protected lazy val factory: AwsKmsDriverFactory = {
    new AwsKmsDriverFactory
  }

  override protected lazy val config: AwsKmsDriverFactory#ConfigType = {
    val aws = KmsConfig.Aws.awsTestConfig
    AwsKmsDriverConfig(region = aws.region, multiRegionKey = aws.multiRegionKey)
  }

  "AWS KMS Driver Factory" must {
    behave like kmsDriverFactory()
  }
}

The KmsDriverFactory can write the driver-specific configuration with a confidential flag being true, which means any sensitive information in the configuration such as credentials should be omitted from the written configuration. A specific test case should be added if your driver-specific configuration contains any confidential information, asserting that the sensitive information is omitted.

Run Canton with a KMS Driver

Configure Canton to run with a KMS driver, for example, for a participant participant1:

canton.participants.participant1.crypto.provider = kms
canton.participants.participant1.crypto.kms {
  type = driver
  name = "aws-kms"
	config = {
	  region = us-east-1
	  multi-region-key = false
	  audit-logging = true
	}
}

Run Canton with your driver jar on its class path:

java -cp driver.jar:canton.jar com.digitalasset.canton.CantonEnterpriseApp -c canton.conf # further canton arguments

Where canton.jar depends on the Canton version, e.g., lib/canton-enterprise-2.9.0.jar. The canton.conf is a configuration file that needs to configure at least one of the nodes to use the driver KMS as outlined above. Run a ping for example with participant1.health.ping(participant1) to validate that the participant can use the configured KMS and driver.