Skip to content

Per-participant ECDH channel keys (#498)#490

Closed
elliottdehn wants to merge 1 commit into
digital-asset:mainfrom
elliottdehn:ecdh-channel-keys
Closed

Per-participant ECDH channel keys (#498)#490
elliottdehn wants to merge 1 commit into
digital-asset:mainfrom
elliottdehn:ecdh-channel-keys

Conversation

@elliottdehn

@elliottdehn elliottdehn commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Closes #498

Summary

New EncryptionAlgorithmSpec.EcdhChannelAes128Gcm: caches per-participant-pair ECDH shared secrets and derives per-transaction AES-128-GCM keys via HKDF. After the first ECDH with a recipient (~0.36ms), subsequent encryptions cost ~0.005ms (72x faster).

Full implementation: protobuf enum, encrypt path, decrypt path, channel key store with epoch-based rotation.

Impact

Encrypt-side latency (submitter, warm channels):

Scenario Before After (warm) Saved
2-party contract 1.6ms ~0.04ms 1.56ms (97%)
CC transfer, 20 SVs 16ms ~0.4ms 15.6ms (97%)
First contact (cold) same same 0 (full ECDH on first use)

This is the highest-impact encrypt-side optimization in the series, but requires a protocol change (new protobuf enum value).

Relation to other PRs

Ciphertext Format

ephemeralPubKey (65 bytes, uncompressed P-256 point)
|| txNonce     (16 bytes, random per-transaction for session key derivation)
|| iv          (12 bytes, AES-GCM nonce)
|| ciphertext  (AES-128-GCM encrypted payload + 16-byte auth tag)

encryptionAlgorithmSpec in the AsymmetricEncrypted protobuf message tells the receiver which decrypt path:

  • ECIES_HKDF_HMAC_SHA256_AES128CBC (= 1): existing full ECIES
  • ECDH_CHANNEL_AES128GCM (= 3): channel-key mode (this PR)

Old nodes reject channel-key ciphertexts cleanly (unrecognized protobuf enum). New nodes support both.

Security

  • Per-transaction freshness: random 16-byte txNonce ensures unique session keys
  • Forward secrecy: ChannelKeyStore.rotateEpoch() on topology changes generates new ephemeral keys
  • Deterministic encryption: not supported (channel keys are inherently non-deterministic)
  • Equivalent to: TLS 1.3 session resumption, Signal pre-keys, MLS epoch keys

Test plan

  • Channel-key encrypt → decrypt round-trip produces correct plaintext
  • Channel-key ciphertext rejected by old nodes (clean Unrecognized enum)
  • Full ECIES ciphertext still works (no regression)
  • ChannelKeyStore epoch rotation invalidates old channels
  • SimplestPingIntegrationTest passes

🤖 Generated with Claude Code

@elliottdehn

Copy link
Copy Markdown
Contributor Author

I have hereby read the Digital Asset CLA and agree to its terms

Implements EcdhChannelAes128Gcm: a new EncryptionAlgorithmSpec that
uses cached per-participant-pair ECDH shared secrets. After the first
ECDH with a recipient, subsequent encryptions derive per-transaction
AES-128-GCM keys via HKDF, costing ~0.005ms instead of ~0.36ms per
recipient (72x faster).

Full implementation:
- crypto.proto: new ENCRYPTION_ALGORITHM_SPEC_ECDH_CHANNEL_AES128GCM
  enum value (= 3)
- Encryption.scala: EcdhChannelAes128Gcm case object with proto
  mapping and deserialization
- JcePureCrypto.scala: encryptWithChannelKey using ChannelKeyStore
  for cached ECDH + HKDF session key derivation + AES-128-GCM
- JcePureCrypto.scala: decryptWithInternal case for channel-key mode:
  ECDH from embedded ephemeral public key, HKDF derive, AES-GCM
  decrypt
- ChannelKeyStore.scala: per-pair ECDH cache with epoch-based
  rotation for forward secrecy

Ciphertext format:
  ephemeralPubKey(65 bytes, uncompressed EC point)
  || txNonce(16 bytes, random per-transaction)
  || iv(12 bytes, AES-GCM nonce)
  || AES-GCM ciphertext + tag

The receiver does ECDH(receiverPrivKey, ephemeralPubKey) to derive
the channel key, then HKDF(channelKey, txNonce) to derive the AES
key. On repeated contact with the same sender's ephemeral key, the
receiver can also cache the channel key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@elliottdehn elliottdehn changed the title RFC: Per-participant ECDH channel keys (72x per-recipient speedup after first contact) Per-participant ECDH channel keys: full encrypt/decrypt (72x per-recipient speedup) Apr 16, 2026
@elliottdehn

Copy link
Copy Markdown
Contributor Author

Impact Analysis: Single-Party High-Volume Scenario

For an exchange-like participant sending CC transfers through one party, 20 SVs, 2 views per transfer:

Submitter encrypt cost per transaction:

Scenario ECIES per tx Other CPU Total CPU/tx 1-core tx/s
Today 44 × 0.4ms = 17.6ms ~2ms ~20ms ~50
Channel keys, new counterparty 2 × 0.4ms + 40 × 0.005ms = 1.0ms ~2ms ~3ms ~330
Channel keys, repeat counterparty 44 × 0.005ms = 0.2ms ~2ms ~2.2ms ~450

On a 4-core machine:

Scenario Throughput
Today ~200 tx/s
Channel keys, new counterparties ~1,300 tx/s
Channel keys, repeat counterparties ~1,800 tx/s

6-9x throughput on the same hardware, no architectural changes. The ECIES cost effectively vanishes for repeat counterparties, shifting the bottleneck from per-participant crypto to BFT consensus throughput.

For an exchange doing 10,000 CC sends/day with mostly repeat customers: ~84 seconds/day of ECIES today → ~5 seconds/day with channel keys.

@elliottdehn

Copy link
Copy Markdown
Contributor Author

Note: this PR adds a new EncryptionAlgorithmSpec protobuf enum value (ECDH_CHANNEL_AES128GCM = 3 in crypto.proto). Per contributing guidelines, this needs review from canton-change-owners.

@elliottdehn elliottdehn changed the title Per-participant ECDH channel keys: full encrypt/decrypt (72x per-recipient speedup) Per-participant ECDH channel keys (#498) Apr 16, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fresh ECDH per transaction for known participant pairs

2 participants