androidengineers.Book a session

Designing a Messaging App

E2E Encryption & Key Rotation

article25 minHard

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:

  1. Identity Key pair (IK): long-term; generated once on device registration
  2. Signed PreKey pair (SPK): medium-term; rotated every 30–90 days
  3. One-Time PreKeys (OPK): single-use; uploaded in batches; enables forward secrecy
  4. 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

ConceptRule
Identity keyLong-term; stored in Android Keystore (non-extractable)
Signed PreKeyRotate every 30–90 days; signed by identity key
One-Time PreKeysBatch upload; enables forward secrecy; replenish when < 20 remain
Double RatchetNew key per message — compromise of one key doesn't expose past or future
Never extract identity keyPrivate key stays in Keystore; only public key is shared
Key rotationUse WorkManager; requires network; fail gracefully

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
E2E Encryption & Key Rotation | Android System Design | Android Engineers