Skip to content

Secure Messaging

This recipe follows the key exchange pattern used in HPKE (RFC 9180) and similar protocols like Signal and TLS 1.3. Two parties establish a shared secret via ECDH, derive an encryption key with HKDF, then communicate using AES-GCM.

Alice sends an encrypted message to Bob:

// --- Both parties generate ECDH key pairs and exchange public keys ---
val ecdh = provider.get(ECDH)
val aliceKeyPair = ecdh.keyPairGenerator(EC.Curve.P256).generateKey()
val bobKeyPair = ecdh.keyPairGenerator(EC.Curve.P256).generateKey()

// --- Alice: compute shared secret using her private key and Bob's public key ---
val aliceSharedSecret = aliceKeyPair.privateKey
    .sharedSecretGenerator()
    .generateSharedSecretToByteArray(bobKeyPair.publicKey)

// --- Alice: derive an AES-256 key from the shared secret via HKDF ---
val hkdf = provider.get(HKDF)
val salt = ByteArray(32) // in practice, use a random or agreed-upon salt
val derivedKeyBytes = hkdf.secretDerivation(
    digest = SHA256,
    outputSize = 256.bits,
    salt = salt,
    info = "messaging-key".encodeToByteArray()
).deriveSecretToByteArray(aliceSharedSecret)

// --- Alice: import the derived bytes as an AES-GCM key and encrypt ---
val aesGcm = provider.get(AES.GCM)
val encryptionKey = aesGcm.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, derivedKeyBytes)
val ciphertext = encryptionKey.cipher().encrypt(plaintext = "Hello, Bob!".encodeToByteArray())

Bob derives the same shared secret and key, then decrypts:

// --- Bob: same shared secret via his private key + Alice's public key ---
val bobSharedSecret = bobKeyPair.privateKey
    .sharedSecretGenerator()
    .generateSharedSecretToByteArray(aliceKeyPair.publicKey)

// --- Bob: same HKDF parameters produce the same key ---
val bobDerivedKeyBytes = provider.get(HKDF).secretDerivation(
    digest = SHA256,
    outputSize = 256.bits,
    salt = salt,
    info = "messaging-key".encodeToByteArray()
).deriveSecretToByteArray(bobSharedSecret)

// --- Bob: decrypt ---
val decryptionKey = provider.get(AES.GCM)
    .keyDecoder()
    .decodeFromByteArray(AES.Key.Format.RAW, bobDerivedKeyBytes)
val plaintext = decryptionKey.cipher().decrypt(ciphertext = ciphertext)
println(plaintext.decodeToString()) // "Hello, Bob!"

The raw ECDH shared secret should never be used directly as an encryption key. Always pass it through a key derivation function like HKDF first.