End-to-end encryption (E2EE) ensures only the sender and recipient can read messages — not the server. Understanding the key exchange model, key storage, and rotation is essential for implementing E2EE correctly.
Signal Protocol Fundamentals
Most modern E2EE messaging apps (Signal, WhatsApp, Matrix) use the Signal Protocol. Its key components:
- Identity Key pair (IK): long-term; generated once on device registration
- Signed PreKey pair (SPK): medium-term; rotated every 30–90 days
- One-Time PreKeys (OPK): single-use; uploaded in batches; enables forward secrecy
- Session key: derived per-conversation using X3DH + Double Ratchet
Key Generation on Android
class KeyManager(private val keyStore: AndroidKeyStore) {
// Generate the long-term identity key pair
// Store in Keystore — non-extractable; backed by hardware
fun generateIdentityKeyPair(): KeyPair {
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
kpg.initialize(
KeyGenParameterSpec.Builder("identity_key", KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_AGREE_KEY)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setUserAuthenticationRequired(false)
.build()
)
return kpg.generateKeyPair()
}
// Generate ephemeral PreKeys — can be extracted for transmission to server
fun generatePreKeys(count: Int): List<ECKeyPair> {
return (1..count).map {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val keyPair = kpg.generateKeyPair()
ECKeyPair(publicKey = keyPair.public, privateKey = keyPair.private)
}
}
// Store PreKey private keys encrypted in secure storage
fun storePreKeyPrivate(keyId: Int, privateKey: ByteArray) {
val encrypted = keystoreEncryption.encrypt(privateKey)
securePrefs.putBytes("prekey_$keyId", encrypted)
}
}
X3DH: Initial Key Exchange
When Alice wants to start a conversation with Bob:
// Alice's side — initiating a session
class X3DHInitiator {
fun establishSession(
bobIdentityKey: ECPublicKey, // fetched from server
bobSignedPreKey: ECPublicKey, // fetched from server
bobOneTimePreKey: ECPublicKey? // fetched from server (may be unavailable)
): SessionKeys {
// Generate Alice's ephemeral key
val aliceEphemeral = generateEphemeralKeyPair()
// DH computations
val dh1 = dh(aliceIdentityKey.private, bobSignedPreKey)
val dh2 = dh(aliceEphemeral.private, bobIdentityKey)
val dh3 = dh(aliceEphemeral.private, bobSignedPreKey)
val dh4 = bobOneTimePreKey?.let { dh(aliceEphemeral.private, it) }
// Combine with HKDF
val ikm = dh1 + dh2 + dh3 + (dh4 ?: ByteArray(0))
val masterSecret = hkdf(ikm, info = "X3DH")
return SessionKeys(
rootKey = masterSecret.take(32).toByteArray(),
chainKey = masterSecret.drop(32).toByteArray(),
ephemeralKey = aliceEphemeral.public
)
}
}
Double Ratchet: Per-Message Keys
After X3DH establishes a shared secret, each message uses a new key derived via the Double Ratchet:
class DoubleRatchet(private val sessionState: SessionState) {
fun encrypt(plaintext: ByteArray): EncryptedMessage {
val messageKey = deriveMessageKey(sessionState.chainKey)
sessionState.advanceChainKey()
val ciphertext = aeadEncrypt(messageKey, plaintext)
return EncryptedMessage(
ciphertext = ciphertext,
ratchetKey = sessionState.senderRatchetKey,
messageIndex = sessionState.sendingChainIndex++
)
}
fun decrypt(message: EncryptedMessage): ByteArray {
if (message.ratchetKey != sessionState.receiverRatchetKey) {
// New ratchet step — perform DH ratchet
sessionState.performDHRatchet(message.ratchetKey)
}
val messageKey = deriveMessageKey(sessionState.chainKey, message.messageIndex)
return aeadDecrypt(messageKey, message.ciphertext)
}
}
Key Rotation
class PreKeyRotationManager(
private val keyManager: KeyManager,
private val serverApi: KeyServerApi,
private val workManager: WorkManager
) {
// Schedule rotation every 30 days
fun scheduleRotation() {
val work = PeriodicWorkRequestBuilder<PreKeyRotationWorker>(30, TimeUnit.DAYS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
workManager.enqueueUniquePeriodicWork("prekey_rotation", ExistingPeriodicWorkPolicy.KEEP, work)
}
}
class PreKeyRotationWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// Generate new signed prekey
val newSignedPreKey = keyManager.generateSignedPreKey()
val signature = keyManager.signWithIdentityKey(newSignedPreKey.publicKey.encode())
// Upload to server
serverApi.uploadSignedPreKey(SignedPreKeyUpload(
keyId = newSignedPreKey.id,
publicKey = newSignedPreKey.publicKey.encode(),
signature = signature
))
// Upload batch of one-time prekeys if server is running low
val remainingCount = serverApi.getOneTimePreKeyCount()
if (remainingCount < 20) {
val newPreKeys = keyManager.generatePreKeys(50)
serverApi.uploadOneTimePreKeys(newPreKeys.map { it.toUploadFormat() })
}
return Result.success()
}
}
Key Takeaways
| Concept | Rule |
|---|---|
| Identity key | Long-term; stored in Android Keystore (non-extractable) |
| Signed PreKey | Rotate every 30–90 days; signed by identity key |
| One-Time PreKeys | Batch upload; enables forward secrecy; replenish when < 20 remain |
| Double Ratchet | New key per message — compromise of one key doesn't expose past or future |
| Never extract identity key | Private key stays in Keystore; only public key is shared |
| Key rotation | Use WorkManager; requires network; fail gracefully |