Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ class SynchronizerSnapshotSyncCryptoApi(
MemberType,
AsymmetricEncrypted[M],
]] = {
def encryptFor(keys: Map[Member, EncryptionPublicKey])(
def encryptForMember(keys: Map[Member, EncryptionPublicKey])(
member: MemberType
): Either[(MemberType, SyncCryptoError), (MemberType, AsymmetricEncrypted[M])] = keys
.get(member)
Expand All @@ -611,18 +611,45 @@ class SynchronizerSnapshotSyncCryptoApi(
Seq.empty,
)
)
.flatMap(k =>
(if (deterministicEncryption) pureCrypto.encryptDeterministicWith(message, k)
else pureCrypto.encryptWith(message, k))
.bimap(error => member -> SyncCryptoEncryptionError(error), member -> _)
)
.flatMap { k =>
if (deterministicEncryption) {
pureCrypto.encryptDeterministicWith(message, k)
.bimap(error => member -> SyncCryptoEncryptionError(error), member -> _)
} else {
// Try precomputed ECDH cache first (moves ECDH off the critical path)
val result = (pureCrypto, crypto.pureCrypto) match {
case (_, jce: com.digitalasset.canton.crypto.provider.jce.JcePureCrypto)
if jce.ecdhPrecomputeCache != null =>
jce.ecdhPrecomputeCache.consume(k.fingerprint) match {
case Some(precomputed) =>
jce.encryptWithPrecomputedEcdh(message, k, precomputed,
com.digitalasset.canton.crypto.provider.jce.JceSecureRandom.random.get())
case None =>
pureCrypto.encryptWith(message, k)
}
case _ =>
pureCrypto.encryptWith(message, k)
}
result.bimap(error => member -> SyncCryptoEncryptionError(error), member -> _)
}
}

EitherT(
ipsSnapshot
.encryptionKey(members)
.map { keys =>
// Register keys for background precomputation
(pureCrypto, crypto.pureCrypto) match {
case (_, jce: com.digitalasset.canton.crypto.provider.jce.JcePureCrypto)
if jce.ecdhPrecomputeCache != null =>
jce.ecdhPrecomputeCache.registerRecipients(
keys.values.toSeq,
pk => jce.javaPublicKeyForPrecompute(pk),
)
case _ => // no precompute cache available
}
members
.traverse(encryptFor(keys))
.traverse(encryptForMember(keys))
.map(_.toMap)
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package com.digitalasset.canton.crypto.provider.jce

import com.digitalasset.canton.crypto.{EncryptionPublicKey, Fingerprint}
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.tracing.TraceContext
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
import org.bouncycastle.crypto.params.*
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey
import org.bouncycastle.jce.ECNamedCurveTable

import java.math.BigInteger
import java.security.{PublicKey as JPublicKey, SecureRandom}
import java.util.concurrent.{ConcurrentHashMap, Executors, TimeUnit}
import scala.jdk.CollectionConverters.*

/** Precomputes ECDH shared secrets with known participants in the background.
* Each entry is consumed exactly once (forward secrecy) and automatically refilled.
*
* This moves the expensive ECDH key agreement (~0.36ms per recipient) off the
* transaction critical path. When encryptFor is called, the shared secret is
* already computed — only KDF + AES remains (~0.03ms).
*/
class EcdhPrecomputeCache(
override val loggerFactory: NamedLoggerFactory
) extends NamedLogging
with AutoCloseable {

private val ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1")
private val ecDomain =
new ECDomainParameters(ecSpec.getCurve, ecSpec.getG, ecSpec.getN, ecSpec.getH)

/** Precomputed ECDH result: ephemeral keypair + shared secret for a specific recipient */
case class PrecomputedEcdh(
ephemeralKeyPair: AsymmetricCipherKeyPair,
sharedSecret: BigInteger,
)

// One precomputed entry per recipient public key fingerprint.
// ConcurrentHashMap for thread-safe consume-and-refill.
private val cache = new ConcurrentHashMap[Fingerprint, PrecomputedEcdh]()

// Known recipient public keys for background refill
private val knownRecipients = new ConcurrentHashMap[Fingerprint, ECPublicKeyParameters]()

private val executor = Executors.newSingleThreadScheduledExecutor(r => {
val t = new Thread(r, "ecdh-precompute")
t.setDaemon(true)
t
})

/** Register recipient public keys for background precomputation.
* Call this on topology changes or participant connection.
*/
def registerRecipients(
publicKeys: Seq[EncryptionPublicKey],
javaKeyLookup: EncryptionPublicKey => Option[JPublicKey],
)(implicit traceContext: TraceContext): Unit = {
var newCount = 0
publicKeys.foreach { pk =>
if (!knownRecipients.containsKey(pk.fingerprint)) {
javaKeyLookup(pk).foreach {
case bcKey: BCECPublicKey =>
val params = new ECPublicKeyParameters(bcKey.engineGetQ(), ecDomain)
knownRecipients.put(pk.fingerprint, params)
newCount += 1
case jcaKey: java.security.interfaces.ECPublicKey =>
val point =
ecSpec.getCurve.createPoint(jcaKey.getW.getAffineX, jcaKey.getW.getAffineY)
val params = new ECPublicKeyParameters(point, ecDomain)
knownRecipients.put(pk.fingerprint, params)
newCount += 1
case _ => // unsupported key type, skip
}
}
}
if (newCount > 0) {
logger.debug(s"Registered $newCount new recipients for ECDH precomputation")
// Immediately precompute for new recipients
executor.submit(new Runnable { def run(): Unit = refillCache()(traceContext) })
}
}

/** Consume a precomputed ECDH result for the given recipient.
* Returns None if no precomputed result is available (caller should fall back to full ECIES).
* The entry is removed from the cache (one-use for forward secrecy) and a background refill
* is triggered.
*/
def consume(fingerprint: Fingerprint)(implicit traceContext: TraceContext): Option[PrecomputedEcdh] = {
val result = Option(cache.remove(fingerprint))
if (result.isDefined) {
// Schedule background refill for this fingerprint
executor.submit(new Runnable {
def run(): Unit = precomputeFor(fingerprint)(traceContext)
})
}
result
}

/** Precompute ECDH for all known recipients that are missing from the cache. */
private def refillCache()(implicit traceContext: TraceContext): Unit =
knownRecipients.asScala.foreach { case (fingerprint, _) =>
if (!cache.containsKey(fingerprint)) {
precomputeFor(fingerprint)
}
}

/** Precompute ECDH for a single recipient. */
private def precomputeFor(fingerprint: Fingerprint)(implicit traceContext: TraceContext): Unit =
Option(knownRecipients.get(fingerprint)).foreach { recipientParams =>
try {
val random = JceSecureRandom.random.get()
val kpg = new ECKeyPairGenerator()
kpg.init(new ECKeyGenerationParameters(ecDomain, random))
val ephemeral = kpg.generateKeyPair()

val agree = new ECDHBasicAgreement()
agree.init(ephemeral.getPrivate)
val sharedSecret = agree.calculateAgreement(recipientParams)

cache.put(fingerprint, PrecomputedEcdh(ephemeral, sharedSecret))
} catch {
case e: Exception =>
logger.warn(s"Failed to precompute ECDH for $fingerprint: ${e.getMessage}")
}
}

/** Start periodic background refill. */
def startPeriodicRefill(intervalMs: Long = 100): Unit =
executor.scheduleAtFixedRate(
() => refillCache()(TraceContext.empty),
0,
intervalMs,
TimeUnit.MILLISECONDS,
)

/** Number of precomputed entries currently available. */
def size: Int = cache.size()

override def close(): Unit = {
executor.shutdown()
executor.awaitTermination(5, TimeUnit.SECONDS)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ class JcePureCrypto(
with ShowUtil
with NamedLogging {

/** Precomputed ECDH cache for background key agreement.
* Moves the expensive ECDH operation (~0.36ms per recipient) off the transaction critical path.
*/
val ecdhPrecomputeCache: EcdhPrecomputeCache = {
val cache = new EcdhPrecomputeCache(loggerFactory)
cache.startPeriodicRefill(intervalMs = 50)
cache
}

/** Expose public key lookup for the precompute cache registration. */
private[jce] def javaPublicKeyForPrecompute(
publicKey: EncryptionPublicKey
): Option[JPublicKey] =
toJavaPublicKey(
publicKey,
{ case k: JPublicKey => Right(k) },
(_: String) => (),
).toOption

// Caches for the java key conversion results
private val javaPublicKeyCache: Cache[Fingerprint, Either[KeyParseAndValidateError, JPublicKey]] =
publicKeyConversionCacheConfig
Expand Down Expand Up @@ -543,6 +562,88 @@ class JcePureCrypto(
publicKey.fingerprint,
)

/** Encrypt using a precomputed ECDH shared secret from the EcdhPrecomputeCache.
* Uses BouncyCastle's IESEngine directly to inject the precomputed values.
* Output format matches the standard Cipher-based ECIES output so decrypt is unchanged.
*/
private[jce] def encryptWithPrecomputedEcdh[M <: HasToByteString](
message: M,
publicKey: EncryptionPublicKey,
precomputed: EcdhPrecomputeCache#PrecomputedEcdh,
random: SecureRandom,
): Either[EncryptionError, AsymmetricEncrypted[M]] = {
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.engines.{AESEngine, IESEngine}
import org.bouncycastle.crypto.generators.KDF2BytesGenerator
import org.bouncycastle.crypto.macs.HMac
import org.bouncycastle.crypto.modes.CBCBlockCipher
import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher
import org.bouncycastle.crypto.params.*

val messageBytes = message.toByteString.toByteArray
val ivSize = EciesHmacSha256Aes128CbcParams.ivSizeForAesCbcInBytes

for {
recipientParams <- toJavaPublicKey(
publicKey,
{ case k: ECPublicKey => Right(k) },
EncryptionError.InvalidEncryptionKey.apply,
).flatMap { jcaKey =>
Either
.catchOnly[Throwable] {
val ecSpec = org.bouncycastle.jce.ECNamedCurveTable.getParameterSpec("secp256r1")
val domain = new ECDomainParameters(ecSpec.getCurve, ecSpec.getG, ecSpec.getN, ecSpec.getH)
val point = jcaKey match {
case bc: org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey =>
bc.engineGetQ()
case other =>
ecSpec.getCurve.createPoint(other.getW.getAffineX, other.getW.getAffineY)
}
new ECPublicKeyParameters(point, domain)
}
.leftMap(err =>
EncryptionError.InvalidEncryptionKey(ThrowableUtil.messageWithStacktrace(err))
)
}
ciphertext <- Either
.catchOnly[Throwable] {
val iv = new Array[Byte](ivSize)
random.nextBytes(iv)

val iesEngine = new IESEngine(
new org.bouncycastle.crypto.agreement.ECDHBasicAgreement(),
new KDF2BytesGenerator(new SHA256Digest()),
new HMac(new SHA256Digest()),
new PaddedBufferedBlockCipher(CBCBlockCipher.newInstance(AESEngine.newInstance())),
)

val iesParams = new IESWithCipherParameters(
Array.emptyByteArray,
Array.emptyByteArray,
512, // macKeySizeInBits
128, // cipherKeySizeInBits
)

iesEngine.init(
true,
precomputed.ephemeralKeyPair.getPrivate,
recipientParams,
new ParametersWithIV(iesParams, iv),
)

val engineOutput = iesEngine.processBlock(messageBytes, 0, messageBytes.length)
iv ++ engineOutput
}
.leftMap(err =>
EncryptionError.FailedToEncrypt(ThrowableUtil.messageWithStacktrace(err))
)
} yield new AsymmetricEncrypted[M](
ByteString.copyFrom(ciphertext),
EncryptionAlgorithmSpec.EciesHkdfHmacSha256Aes128Cbc,
publicKey.fingerprint,
)
}

private def encryptWithRSAOaepSha256[M <: HasToByteString](
message: M,
publicKey: EncryptionPublicKey,
Expand Down
Loading