Skip to content

Latest commit

 

History

History
1634 lines (1300 loc) · 51.1 KB

File metadata and controls

1634 lines (1300 loc) · 51.1 KB

Android E2EE Implementation Guide

Version: 1.0
Date: October 8, 2025
Target: Android developers implementing E2EE for cross-platform compatibility with iOS Inviso app
Status: Production-ready specification


Table of Contents

  1. Overview
  2. Critical Requirements
  3. Architecture
  4. Cryptographic Implementation
  5. Key Exchange Protocol
  6. Message Encryption/Decryption
  7. Role Determination
  8. Storage and Lifecycle
  9. Wire Format
  10. Testing and Validation
  11. Security Checklist
  12. Common Pitfalls

1. Overview

This guide describes the exact E2EE implementation used in the iOS Inviso app. Android must implement the identical protocol to ensure cross-platform compatibility.

Security Properties

End-to-End Encrypted: Server cannot read message content
Forward Secrecy: Past messages remain secure if keys compromised
Authenticated Encryption: Messages cannot be forged or tampered
Ephemeral Keys: New keys generated for every connection
Per-Connection Rotation: Keys never reused between connections
No Persistent History: Keys wiped on disconnect

Cryptographic Stack

Component Algorithm Purpose
Key Exchange X25519 ECDH Derive shared secret between peers
Session Key Derivation HKDF-SHA256 Convert shared secret to session key
Message Encryption AES-256-GCM Encrypt message content
Message Key Derivation HKDF-SHA256 Derive per-message keys (forward secrecy)
Authentication GCM Tag 16-byte authentication tag

2. Critical Requirements

⚠️ MUST Follow Exactly

These requirements are non-negotiable for cross-platform compatibility:

  1. Use X25519 for ECDH (Curve25519, 32-byte keys)
  2. Use AES-256-GCM (not CBC, not CTR - must be GCM mode)
  3. Use HKDF-SHA256 (not raw SHA256, must use HKDF)
  4. Use 12-byte nonces for AES-GCM (not 16 bytes)
  5. Use MessageDirection.send for BOTH sender and receiver in HKDF derivation
  6. Derive deterministic UUID from roomId (not random UUIDs)
  7. Use exact wire format (JSON with fields: v, c, n, d, t)
  8. Base64 encode all binary data (publicKey, nonce, ciphertext, tag)
  9. Increment counter monotonically for each sent message
  10. Wipe keys immediately on disconnect

Android Libraries

Use these proven Android cryptography libraries:

dependencies {
    // Tink for modern cryptography (recommended by Google)
    implementation 'com.google.crypto.tink:tink-android:1.10.0'
    
    // OR use Bouncy Castle for X25519 if Tink doesn't cover everything
    implementation 'org.bouncycastle:bcprov-jdk15on:1.70'
    
    // Conscrypt for native crypto performance
    implementation 'org.conscrypt:conscrypt-android:2.5.2'
}

Recommended: Use Tink for AES-GCM and HKDF, Bouncy Castle for X25519.


3. Architecture

3.1 Component Structure

Create these classes in your Android project:

app/src/main/java/com/inviso/chat/encryption/
├── EncryptionModels.kt          // Data classes for wire format
├── EncryptionErrors.kt          // Error types
├── EncryptionKeychain.kt        // Android Keystore wrapper
├── MessageEncryptor.kt          // AES-GCM encryption/decryption
├── KeyExchangeHandler.kt        // X25519 ECDH key exchange
└── EncryptionConstants.kt       // Constants and configuration

3.2 Key Lifecycle Diagram

┌─────────────────────────────────────────────────────────────────┐
│                    SESSION LIFECYCLE                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  User joins room via REST API (no encryption yet)              │
│  └─> WebSocket connection established                          │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │          CONNECTION #1 (First Connection)                │  │
│  ├──────────────────────────────────────────────────────────┤  │
│  │  Step 1: Derive deterministic UUID from roomId          │  │
│  │  Step 2: Generate X25519 keypair, store in Keystore     │  │
│  │  Step 3: Send publicKey via WebSocket signaling         │  │
│  │  Step 4: Receive peer's publicKey                       │  │
│  │  Step 5: Perform ECDH to derive sharedSecret            │  │
│  │  Step 6: Derive sessionKey via HKDF                     │  │
│  │  Step 7: Store sessionKey in Keystore                   │  │
│  │  Step 8: Exchange key_exchange_complete confirmation    │  │
│  │  Step 9: Messages now encrypted with AES-GCM            │  │
│  │  Step 10: On disconnect, DELETE all keys from Keystore  │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │          CONNECTION #2 (Reconnection)                    │  │
│  ├──────────────────────────────────────────────────────────┤  │
│  │  Repeat Steps 1-10 with NEW keypair                     │  │
│  │  Previous sessionKey is DELETED, new one derived        │  │
│  │  Counter resets to 0 (fresh encryption state)           │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  User leaves session → Final cleanup                           │
│  └─> Verify all keys deleted from Keystore                    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

4. Cryptographic Implementation

4.1 EncryptionConstants.kt

package com.inviso.chat.encryption

object EncryptionConstants {
    // Key sizes (bytes)
    const val PUBLIC_KEY_SIZE = 32      // X25519 public key
    const val PRIVATE_KEY_SIZE = 32     // X25519 private key
    const val SESSION_KEY_SIZE = 32     // AES-256 key
    const val NONCE_SIZE = 12           // AES-GCM nonce
    const val TAG_SIZE = 16             // AES-GCM authentication tag
    
    // HKDF info strings (MUST match iOS exactly)
    const val SESSION_KEY_INFO = "inviso-session-v1"
    const val MESSAGE_KEY_INFO_PREFIX = "inviso-msg-v1"
    
    // Protocol version
    const val PROTOCOL_VERSION: UInt = 1u
    
    // Timeouts
    const val KEY_EXCHANGE_TIMEOUT_MS = 10_000L
    const val MAX_COUNTER_GAP = 1000UL
}

4.2 MessageDirection Enum

⚠️ CRITICAL: Both sender and receiver must use .SEND direction.

package com.inviso.chat.encryption

enum class MessageDirection(val value: UByte) {
    SEND(0x01u),
    RECEIVE(0x02u)
}

Important: iOS uses .send for both encryption and decryption. Android MUST do the same.

4.3 EncryptionModels.kt

package com.inviso.chat.encryption

import com.google.gson.annotations.SerializedName
import java.util.*

/**
 * Wire format for encrypted messages sent over WebRTC DataChannel
 * 
 * CRITICAL: All binary data MUST be base64-encoded for JSON transport
 */
data class MessageWireFormat(
    @SerializedName("v") val version: UInt,          // Protocol version (always 1)
    @SerializedName("c") val counter: ULong,         // Message counter (monotonic)
    @SerializedName("n") val nonce: String,          // Base64-encoded nonce (12 bytes)
    @SerializedName("d") val ciphertext: String,     // Base64-encoded ciphertext
    @SerializedName("t") val tag: String             // Base64-encoded GCM tag (16 bytes)
) {
    fun toJson(): String = gson.toJson(this)
    
    fun nonceBytes(): ByteArray = Base64.getDecoder().decode(nonce)
    fun ciphertextBytes(): ByteArray = Base64.getDecoder().decode(ciphertext)
    fun tagBytes(): ByteArray = Base64.getDecoder().decode(tag)
    
    companion object {
        private val gson = com.google.gson.Gson()
        
        fun fromJson(json: String): MessageWireFormat? {
            return try {
                gson.fromJson(json, MessageWireFormat::class.java)
            } catch (e: Exception) {
                null
            }
        }
    }
}

/**
 * In-memory encryption state (per session)
 * DO NOT persist this - recreate on each connection
 */
data class EncryptionState(
    var sendCounter: ULong = 0u,
    var receiveCounter: ULong = 0u,
    var keyExchangeComplete: Boolean = false,
    var keyExchangeStartedAt: Long = System.currentTimeMillis()
)

/**
 * Key exchange WebSocket message
 */
data class KeyExchangeMessage(
    val type: String = "key_exchange",
    val publicKey: String,                // Base64-encoded X25519 public key
    val sessionId: String                 // roomId (for server routing)
)

/**
 * Key exchange completion message
 */
data class KeyExchangeCompleteMessage(
    val type: String = "key_exchange_complete",
    val sessionId: String
)

5. Key Exchange Protocol

5.1 Deterministic UUID Derivation

⚠️ CRITICAL: Both iOS and Android MUST derive the same UUID from roomId.

package com.inviso.chat.encryption

import java.util.UUID

object UUIDDerivation {
    /**
     * Derive deterministic UUID from roomId
     * 
     * CRITICAL: This ensures both iOS and Android use the same UUID
     * for Keystore/Keychain lookups
     * 
     * Algorithm:
     * 1. Take first 32 hex characters from roomId
     * 2. Format as UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
     * 
     * Example:
     *   roomId = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6..."
     *   UUID   = "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
     */
    fun deriveSessionKeyId(roomId: String): UUID {
        // Take first 32 hex characters
        val prefix = roomId.take(32).lowercase()
        
        // Format as UUID string
        val uuidString = buildString {
            append(prefix.substring(0, 8))   // 8 chars
            append("-")
            append(prefix.substring(8, 12))  // 4 chars
            append("-")
            append(prefix.substring(12, 16)) // 4 chars
            append("-")
            append(prefix.substring(16, 20)) // 4 chars
            append("-")
            append(prefix.substring(20, 32)) // 12 chars
        }
        
        return try {
            UUID.fromString(uuidString)
        } catch (e: Exception) {
            // Fallback to random UUID (should never happen)
            UUID.randomUUID()
        }
    }
}

5.2 KeyExchangeHandler.kt

package com.inviso.chat.encryption

import org.bouncycastle.crypto.agreement.X25519Agreement
import org.bouncycastle.crypto.generators.X25519KeyPairGenerator
import org.bouncycastle.crypto.params.X25519KeyGenerationParameters
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
import java.security.SecureRandom
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

class KeyExchangeHandler(
    private val keystoreManager: EncryptionKeychain
) {
    
    /**
     * Generate X25519 keypair for session
     * 
     * @param sessionId Deterministic UUID derived from roomId
     * @return Base64-encoded public key to send to peer
     */
    fun generateKeypair(sessionId: UUID): String {
        // 1. Generate X25519 keypair
        val keyPairGenerator = X25519KeyPairGenerator()
        keyPairGenerator.init(X25519KeyGenerationParameters(SecureRandom()))
        val keyPair = keyPairGenerator.generateKeyPair()
        
        val privateKey = keyPair.private as X25519PrivateKeyParameters
        val publicKey = keyPair.public as X25519PublicKeyParameters
        
        // 2. Validate key sizes
        require(privateKey.encoded.size == EncryptionConstants.PRIVATE_KEY_SIZE) {
            "Invalid private key size: ${privateKey.encoded.size}"
        }
        require(publicKey.encoded.size == EncryptionConstants.PUBLIC_KEY_SIZE) {
            "Invalid public key size: ${publicKey.encoded.size}"
        }
        
        // 3. Store private key in Android Keystore
        keystoreManager.storePrivateKey(
            sessionId = sessionId,
            privateKeyBytes = privateKey.encoded
        )
        
        // 4. Return base64-encoded public key
        val publicKeyBase64 = Base64.getEncoder().encodeToString(publicKey.encoded)
        
        println("[KeyExchange] Generated keypair for session ${sessionId.toString().take(8)}")
        println("[KeyExchange] Public key: ${publicKeyBase64.take(16)}...")
        
        return publicKeyBase64
    }
    
    /**
     * Derive session key from peer's public key using ECDH + HKDF
     * 
     * @param peerPublicKeyBase64 Base64-encoded peer public key
     * @param sessionId Deterministic UUID derived from roomId
     * @return Session key (also stored in Keystore)
     */
    fun deriveSessionKey(
        peerPublicKeyBase64: String,
        sessionId: UUID
    ): ByteArray {
        println("[KeyExchange] Deriving session key for ${sessionId.toString().take(8)}")
        
        // 1. Decode peer public key
        val peerPublicKeyBytes = Base64.getDecoder().decode(peerPublicKeyBase64)
        require(peerPublicKeyBytes.size == EncryptionConstants.PUBLIC_KEY_SIZE) {
            "Invalid peer public key size: ${peerPublicKeyBytes.size}"
        }
        
        val peerPublicKey = X25519PublicKeyParameters(peerPublicKeyBytes, 0)
        
        // 2. Retrieve our private key from Keystore
        val privateKeyBytes = keystoreManager.getPrivateKey(sessionId)
            ?: throw EncryptionException("Private key not found in Keystore")
        
        val privateKey = X25519PrivateKeyParameters(privateKeyBytes, 0)
        
        // 3. Perform ECDH key agreement to derive shared secret
        val agreement = X25519Agreement()
        agreement.init(privateKey)
        
        val sharedSecret = ByteArray(agreement.agreementSize)
        agreement.calculateAgreement(peerPublicKey, sharedSecret, 0)
        
        println("[KeyExchange] Shared secret derived (${sharedSecret.size} bytes)")
        
        // 4. Derive session key from shared secret using HKDF-SHA256
        val sessionKey = hkdfExpand(
            ikm = sharedSecret,
            info = EncryptionConstants.SESSION_KEY_INFO.toByteArray(),
            length = EncryptionConstants.SESSION_KEY_SIZE
        )
        
        // 5. Store session key in Keystore
        keystoreManager.storeSessionKey(
            sessionId = sessionId,
            sessionKeyBytes = sessionKey
        )
        
        // Debug: Print first 8 bytes of session key for verification
        val debugKey = Base64.getEncoder().encodeToString(sessionKey.take(8).toByteArray())
        println("[KeyExchange] Session key derived: $debugKey...")
        
        // 6. Zero out sensitive data
        sharedSecret.fill(0)
        
        return sessionKey
    }
    
    /**
     * HKDF-Expand using SHA-256
     * 
     * CRITICAL: This MUST match iOS implementation exactly
     * 
     * Algorithm:
     * 1. PRK = ikm (we skip HKDF-Extract since we use empty salt)
     * 2. Expand using HMAC-SHA256 to generate output key material
     */
    private fun hkdfExpand(
        ikm: ByteArray,
        info: ByteArray,
        length: Int
    ): ByteArray {
        // HKDF-Extract with empty salt (PRK = HMAC-SHA256("", IKM))
        val prk = hmacSha256(key = ByteArray(0), data = ikm)
        
        // HKDF-Expand
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(prk, "HmacSHA256"))
        
        val hashLen = 32 // SHA-256 output size
        val n = (length + hashLen - 1) / hashLen
        
        val okm = ByteArray(length)
        var t = ByteArray(0)
        
        for (i in 1..n) {
            mac.reset()
            mac.update(t)
            mac.update(info)
            mac.update(i.toByte())
            t = mac.doFinal()
            
            val copyLen = minOf(t.size, length - (i - 1) * hashLen)
            System.arraycopy(t, 0, okm, (i - 1) * hashLen, copyLen)
        }
        
        return okm
    }
    
    private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(key, "HmacSHA256"))
        return mac.doFinal(data)
    }
}

6. Message Encryption/Decryption

6.1 MessageEncryptor.kt

package com.inviso.chat.encryption

import com.google.crypto.tink.subtle.AesGcmJce
import java.security.SecureRandom
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

class MessageEncryptor(
    private val keystoreManager: EncryptionKeychain
) {
    
    /**
     * Encrypt plaintext message
     * 
     * @param plaintext Message to encrypt
     * @param sessionId UUID for Keystore lookup
     * @param counter Message counter (MUST increment for each message)
     * @return Wire format ready for JSON serialization
     */
    fun encrypt(
        plaintext: String,
        sessionId: UUID,
        counter: ULong
    ): MessageWireFormat {
        // 1. Retrieve session key from Keystore
        val sessionKey = keystoreManager.getSessionKey(sessionId)
            ?: throw EncryptionException("Session key not found")
        
        // 2. Derive message-specific key using HKDF ratchet
        // CRITICAL: Use MessageDirection.SEND (same as iOS)
        val messageKey = deriveMessageKey(
            sessionKey = sessionKey,
            counter = counter,
            direction = MessageDirection.SEND
        )
        
        // 3. Generate random 12-byte nonce
        val nonce = ByteArray(EncryptionConstants.NONCE_SIZE)
        SecureRandom().nextBytes(nonce)
        
        // 4. Encrypt with AES-256-GCM
        val aesGcm = AesGcmJce(messageKey)
        val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8)
        
        // AES-GCM produces: ciphertext || tag (last 16 bytes)
        val ciphertextWithTag = aesGcm.encrypt(nonce, plaintextBytes)
        
        // Split ciphertext and tag
        val ciphertext = ciphertextWithTag.copyOfRange(0, ciphertextWithTag.size - 16)
        val tag = ciphertextWithTag.copyOfRange(ciphertextWithTag.size - 16, ciphertextWithTag.size)
        
        // 5. Zero out message key (forward secrecy!)
        messageKey.fill(0)
        
        // 6. Create wire format with base64-encoded fields
        val wireFormat = MessageWireFormat(
            version = EncryptionConstants.PROTOCOL_VERSION,
            counter = counter,
            nonce = Base64.getEncoder().encodeToString(nonce),
            ciphertext = Base64.getEncoder().encodeToString(ciphertext),
            tag = Base64.getEncoder().encodeToString(tag)
        )
        
        println("[Encryptor] Encrypted message (counter=$counter, size=${ciphertext.size} bytes)")
        
        return wireFormat
    }
    
    /**
     * Decrypt received message
     * 
     * @param wireFormat Encrypted wire format from peer
     * @param sessionId UUID for Keystore lookup
     * @return Decrypted plaintext
     */
    fun decrypt(
        wireFormat: MessageWireFormat,
        sessionId: UUID
    ): String {
        // 1. Validate protocol version
        require(wireFormat.version == EncryptionConstants.PROTOCOL_VERSION) {
            "Unsupported protocol version: ${wireFormat.version}"
        }
        
        // 2. Retrieve session key from Keystore
        val sessionKey = keystoreManager.getSessionKey(sessionId)
            ?: throw EncryptionException("Session key not found")
        
        // 3. Decode base64 fields
        val nonce = wireFormat.nonceBytes()
        val ciphertext = wireFormat.ciphertextBytes()
        val tag = wireFormat.tagBytes()
        
        // 4. Validate sizes
        require(nonce.size == EncryptionConstants.NONCE_SIZE) {
            "Invalid nonce size: ${nonce.size}"
        }
        require(tag.size == EncryptionConstants.TAG_SIZE) {
            "Invalid tag size: ${tag.size}"
        }
        
        // 5. Derive same message-specific key using HKDF ratchet
        // CRITICAL: Use MessageDirection.SEND (same as sender!)
        val messageKey = deriveMessageKey(
            sessionKey = sessionKey,
            counter = wireFormat.counter,
            direction = MessageDirection.SEND  // NOT .RECEIVE!
        )
        
        // 6. Reconstruct ciphertext with tag for AES-GCM
        val ciphertextWithTag = ciphertext + tag
        
        // 7. Decrypt with AES-256-GCM (automatically verifies auth tag)
        val aesGcm = AesGcmJce(messageKey)
        val plaintextBytes = try {
            aesGcm.decrypt(nonce, ciphertextWithTag)
        } catch (e: Exception) {
            throw EncryptionException("Decryption failed (authentication error): ${e.message}")
        }
        
        // 8. Zero out message key (forward secrecy!)
        messageKey.fill(0)
        
        // 9. Convert to string
        val plaintext = String(plaintextBytes, Charsets.UTF_8)
        
        println("[Encryptor] Decrypted message (counter=${wireFormat.counter}, size=${plaintextBytes.size} bytes)")
        
        return plaintext
    }
    
    /**
     * Derive per-message key using HKDF ratchet
     * 
     * CRITICAL: This provides forward secrecy within a connection.
     * Each message uses a different key derived from sessionKey + counter.
     * 
     * @param sessionKey Master session key
     * @param counter Message counter
     * @param direction MUST be MessageDirection.SEND for BOTH encryption and decryption
     */
    private fun deriveMessageKey(
        sessionKey: ByteArray,
        counter: ULong,
        direction: MessageDirection
    ): ByteArray {
        // Build info parameter: "inviso-msg-v1" || counter || direction
        val info = buildString {
            append(EncryptionConstants.MESSAGE_KEY_INFO_PREFIX)
            append(counter.toString())
            append(direction.value.toString())
        }.toByteArray()
        
        // Derive message key using HKDF
        return hkdfExpand(
            ikm = sessionKey,
            info = info,
            length = EncryptionConstants.SESSION_KEY_SIZE
        )
    }
    
    private fun hkdfExpand(
        ikm: ByteArray,
        info: ByteArray,
        length: Int
    ): ByteArray {
        val prk = hmacSha256(key = ByteArray(0), data = ikm)
        
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(prk, "HmacSHA256"))
        
        val hashLen = 32
        val n = (length + hashLen - 1) / hashLen
        val okm = ByteArray(length)
        var t = ByteArray(0)
        
        for (i in 1..n) {
            mac.reset()
            mac.update(t)
            mac.update(info)
            mac.update(i.toByte())
            t = mac.doFinal()
            
            val copyLen = minOf(t.size, length - (i - 1) * hashLen)
            System.arraycopy(t, 0, okm, (i - 1) * hashLen, copyLen)
        }
        
        return okm
    }
    
    private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(key, "HmacSHA256"))
        return mac.doFinal(data)
    }
}

7. Role Determination

7.1 Server-Assigned Roles (Recommended)

The signaling server assigns roles when both users join a room.

Server Implementation (Node.js - already done):

// When second user joins room
ws.send(JSON.stringify({
    type: 'room_joined',
    roomId: roomId,
    isInitiator: false  // Second user is responder
}));

// First user gets notified
firstUserWs.send(JSON.stringify({
    type: 'peer_joined',
    peerId: secondUserId,
    isInitiator: true  // First user is initiator
}));

Android Implementation:

// In your WebSocket message handler
when (message.type) {
    "room_joined" -> {
        val isInitiator = message.isInitiator ?: false
        this.serverAssignedIsInitiator = isInitiator
        
        // Start key exchange with assigned role
        startKeyExchange(isInitiator)
    }
    
    "peer_joined" -> {
        val isInitiator = message.isInitiator ?: true
        this.serverAssignedIsInitiator = isInitiator
        
        // Start key exchange with assigned role
        startKeyExchange(isInitiator)
    }
}

7.2 Key Exchange Flow by Role

INITIATOR (first user)                    RESPONDER (second user)
─────────────────────────                 ─────────────────────────
1. room_joined (isInitiator=true)         
2. Generate keypair                       
3. Send key_exchange →                    
                                          4. room_joined (isInitiator=false)
                                          5. Generate keypair
                                          6. Receive key_exchange
                                          7. Derive session key
                                          8. Send key_exchange →
9. Receive key_exchange                   9. Send key_exchange_complete →
10. Derive session key                    10. ✅ Encryption ready (responder)
11. Receive key_exchange_complete         11. Wait for messages
12. ✅ Encryption ready (initiator)
13. Create WebRTC PeerConnection
14. Send WebRTC offer

⚠️ CRITICAL RULES:

  1. BOTH devices generate keypairs and send key_exchange
  2. RESPONDER sends key_exchange_complete after deriving session key
  3. INITIATOR waits for key_exchange_complete before creating PeerConnection
  4. ONLY INITIATOR creates WebRTC PeerConnection and sends offer
  5. Keys are wiped on disconnect and regenerated on reconnection

8. Storage and Lifecycle

8.1 EncryptionKeychain.kt (Android Keystore)

package com.inviso.chat.encryption

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import java.util.*
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

class EncryptionKeychain {
    
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
        load(null)
    }
    
    companion object {
        private const val KEYSTORE_ALIAS_PREFIX = "inviso_encryption_"
        private const val TRANSFORMATION = "AES/GCM/NoPadding"
        private const val GCM_TAG_LENGTH = 128
    }
    
    /**
     * Store private key (X25519) in Keystore
     * 
     * @param sessionId Deterministic UUID from roomId
     * @param privateKeyBytes Raw X25519 private key (32 bytes)
     */
    fun storePrivateKey(sessionId: UUID, privateKeyBytes: ByteArray) {
        val alias = "${KEYSTORE_ALIAS_PREFIX}private_$sessionId"
        
        // Encrypt private key using Android Keystore master key
        val masterKey = getOrCreateMasterKey()
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.ENCRYPT_MODE, masterKey)
        
        val iv = cipher.iv
        val encryptedKey = cipher.doFinal(privateKeyBytes)
        
        // Store encrypted key + IV in SharedPreferences
        val prefs = getSharedPreferences()
        prefs.edit()
            .putString("${alias}_data", Base64.getEncoder().encodeToString(encryptedKey))
            .putString("${alias}_iv", Base64.getEncoder().encodeToString(iv))
            .apply()
        
        println("[Keychain] Stored private key for session ${sessionId.toString().take(8)}")
    }
    
    /**
     * Retrieve private key from Keystore
     */
    fun getPrivateKey(sessionId: UUID): ByteArray? {
        val alias = "${KEYSTORE_ALIAS_PREFIX}private_$sessionId"
        
        val prefs = getSharedPreferences()
        val encryptedKeyBase64 = prefs.getString("${alias}_data", null) ?: return null
        val ivBase64 = prefs.getString("${alias}_iv", null) ?: return null
        
        val encryptedKey = Base64.getDecoder().decode(encryptedKeyBase64)
        val iv = Base64.getDecoder().decode(ivBase64)
        
        // Decrypt using master key
        val masterKey = getOrCreateMasterKey()
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.DECRYPT_MODE, masterKey, GCMParameterSpec(GCM_TAG_LENGTH, iv))
        
        return cipher.doFinal(encryptedKey)
    }
    
    /**
     * Store session key (derived from ECDH) in Keystore
     */
    fun storeSessionKey(sessionId: UUID, sessionKeyBytes: ByteArray) {
        val alias = "${KEYSTORE_ALIAS_PREFIX}session_$sessionId"
        
        val masterKey = getOrCreateMasterKey()
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.ENCRYPT_MODE, masterKey)
        
        val iv = cipher.iv
        val encryptedKey = cipher.doFinal(sessionKeyBytes)
        
        val prefs = getSharedPreferences()
        prefs.edit()
            .putString("${alias}_data", Base64.getEncoder().encodeToString(encryptedKey))
            .putString("${alias}_iv", Base64.getEncoder().encodeToString(iv))
            .apply()
        
        println("[Keychain] Stored session key for session ${sessionId.toString().take(8)}")
    }
    
    /**
     * Retrieve session key from Keystore
     */
    fun getSessionKey(sessionId: UUID): ByteArray? {
        val alias = "${KEYSTORE_ALIAS_PREFIX}session_$sessionId"
        
        val prefs = getSharedPreferences()
        val encryptedKeyBase64 = prefs.getString("${alias}_data", null) ?: return null
        val ivBase64 = prefs.getString("${alias}_iv", null) ?: return null
        
        val encryptedKey = Base64.getDecoder().decode(encryptedKeyBase64)
        val iv = Base64.getDecoder().decode(ivBase64)
        
        val masterKey = getOrCreateMasterKey()
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.DECRYPT_MODE, masterKey, GCMParameterSpec(GCM_TAG_LENGTH, iv))
        
        return cipher.doFinal(encryptedKey)
    }
    
    /**
     * Delete all keys for a session (call on disconnect)
     */
    fun deleteKeys(sessionId: UUID) {
        val privateAlias = "${KEYSTORE_ALIAS_PREFIX}private_$sessionId"
        val sessionAlias = "${KEYSTORE_ALIAS_PREFIX}session_$sessionId"
        
        val prefs = getSharedPreferences()
        prefs.edit()
            .remove("${privateAlias}_data")
            .remove("${privateAlias}_iv")
            .remove("${sessionAlias}_data")
            .remove("${sessionAlias}_iv")
            .apply()
        
        println("[Keychain] Deleted all keys for session ${sessionId.toString().take(8)}")
    }
    
    private fun getOrCreateMasterKey(): SecretKey {
        val alias = "inviso_master_key"
        
        return if (keyStore.containsAlias(alias)) {
            keyStore.getKey(alias, null) as SecretKey
        } else {
            val keyGenerator = KeyGenerator.getInstance(
                KeyProperties.KEY_ALGORITHM_AES,
                "AndroidKeyStore"
            )
            
            val spec = KeyGenParameterSpec.Builder(
                alias,
                KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
            )
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setKeySize(256)
                .setUserAuthenticationRequired(false)
                .build()
            
            keyGenerator.init(spec)
            keyGenerator.generateKey()
        }
    }
    
    private fun getSharedPreferences() =
        android.app.Application().getSharedPreferences(
            "inviso_encryption_prefs",
            android.content.Context.MODE_PRIVATE
        )
}

8.2 Cleanup on Disconnect

// In your ChatManager or WebRTC handler
fun onDisconnect() {
    currentSessionKeyId?.let { sessionId ->
        // Delete all keys from Keystore
        keystoreManager.deleteKeys(sessionId)
        
        // Clear in-memory state
        encryptionStates.remove(roomId)
        currentSessionKeyId = null
        keyExchangeInProgress = false
        isEncryptionReady = false
        
        println("[Cleanup] All encryption keys deleted for session ${sessionId.toString().take(8)}")
    }
}

9. Wire Format

9.1 WebSocket Signaling Messages

Key Exchange:

{
  "type": "key_exchange",
  "publicKey": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2",
  "sessionId": "a1b2c3d4e5f6..."
}

Key Exchange Complete:

{
  "type": "key_exchange_complete",
  "sessionId": "a1b2c3d4e5f6..."
}

9.2 WebRTC DataChannel Encrypted Message

JSON sent over binary DataChannel:

{
  "v": 1,
  "c": 42,
  "n": "MTIzNDU2Nzg5MDEy",
  "d": "ZW5jcnlwdGVkIGRhdGEgaGVyZQ==",
  "t": "YXV0aGVudGljYXRpb24="
}

Field Descriptions:

Field Type Description
v UInt8 Protocol version (always 1)
c UInt64 Message counter (0, 1, 2, ...)
n String Base64-encoded nonce (12 bytes)
d String Base64-encoded ciphertext (variable)
t String Base64-encoded GCM tag (16 bytes)

10. Testing and Validation

10.1 Unit Tests

Create these test cases:

class EncryptionTest {
    
    @Test
    fun testUUIDDerivation() {
        // Test deterministic UUID from roomId
        val roomId = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6..."
        val uuid1 = UUIDDerivation.deriveSessionKeyId(roomId)
        val uuid2 = UUIDDerivation.deriveSessionKeyId(roomId)
        
        assertEquals(uuid1, uuid2) // Same roomId = same UUID
        
        val expectedUuid = "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
        assertEquals(expectedUuid, uuid1.toString())
    }
    
    @Test
    fun testKeyExchange() {
        val keystoreManager = EncryptionKeychain()
        val handler = KeyExchangeHandler(keystoreManager)
        
        val sessionId = UUID.randomUUID()
        
        // Device A generates keypair
        val publicKeyA = handler.generateKeypair(sessionId)
        
        // Device B generates keypair
        val publicKeyB = handler.generateKeypair(sessionId)
        
        // Device A derives session key from B's public key
        val sessionKeyA = handler.deriveSessionKey(publicKeyB, sessionId)
        
        // Device B derives session key from A's public key
        val sessionKeyB = handler.deriveSessionKey(publicKeyA, sessionId)
        
        // Session keys MUST match
        assertArrayEquals(sessionKeyA, sessionKeyB)
    }
    
    @Test
    fun testEncryptDecrypt() {
        val keystoreManager = EncryptionKeychain()
        val encryptor = MessageEncryptor(keystoreManager)
        
        val sessionId = UUID.randomUUID()
        val sessionKey = ByteArray(32).apply { SecureRandom().nextBytes(this) }
        keystoreManager.storeSessionKey(sessionId, sessionKey)
        
        val plaintext = "Hello, World!"
        val counter = 0UL
        
        // Encrypt
        val wireFormat = encryptor.encrypt(plaintext, sessionId, counter)
        
        // Decrypt
        val decrypted = encryptor.decrypt(wireFormat, sessionId)
        
        assertEquals(plaintext, decrypted)
    }
    
    @Test
    fun testMessageDirection() {
        // CRITICAL: Both sender and receiver use .SEND
        val keystoreManager = EncryptionKeychain()
        val encryptor = MessageEncryptor(keystoreManager)
        
        val sessionId = UUID.randomUUID()
        val sessionKey = ByteArray(32).apply { SecureRandom().nextBytes(this) }
        keystoreManager.storeSessionKey(sessionId, sessionKey)
        
        // Sender encrypts with .SEND
        val plaintext = "Test message"
        val wireFormat = encryptor.encrypt(plaintext, sessionId, 0UL)
        
        // Receiver decrypts with .SEND (not .RECEIVE!)
        val decrypted = encryptor.decrypt(wireFormat, sessionId)
        
        assertEquals(plaintext, decrypted)
    }
}

10.2 Cross-Platform Validation

Test with iOS device:

  1. Connect Android device to iOS device in same room
  2. Send message from Android → iOS
  3. Verify iOS can decrypt (check iOS console logs for session key)
  4. Send message from iOS → Android
  5. Verify Android can decrypt (check Android logcat for session key)
  6. Compare session keys (first 8 bytes should match):
    iOS:     IGOQpoLDw1M=
    Android: IGOQpoLDw1M=
    

Debug Logging:

// Add to deriveSessionKey()
val debugKey = Base64.getEncoder().encodeToString(sessionKey.take(8).toByteArray())
println("[KeyExchange] Session key derived: $debugKey...")

11. Security Checklist

Before deploying to production:

  • X25519 ECDH implemented correctly
  • AES-256-GCM used (not CBC, not CTR)
  • HKDF-SHA256 used for key derivation
  • 12-byte nonces generated randomly for each message
  • MessageDirection.SEND used for both encryption and decryption
  • Deterministic UUID derived from roomId (not random)
  • Session keys wiped on disconnect
  • Private keys wiped on disconnect
  • Message keys zeroed after use (forward secrecy)
  • Counter increments monotonically for each sent message
  • Wire format matches iOS exactly (v, c, n, d, t)
  • Base64 encoding for all binary data
  • Cross-platform tested with iOS device
  • Session keys match between iOS and Android
  • No debug logging in production builds
  • Android Keystore used for key storage
  • SharedPreferences encryption enabled

12. Common Pitfalls

❌ Mistake 1: Using Different HKDF Direction

WRONG:

// Sender
val messageKey = deriveMessageKey(sessionKey, counter, MessageDirection.SEND)

// Receiver
val messageKey = deriveMessageKey(sessionKey, counter, MessageDirection.RECEIVE)

CORRECT:

// Sender
val messageKey = deriveMessageKey(sessionKey, counter, MessageDirection.SEND)

// Receiver (SAME DIRECTION!)
val messageKey = deriveMessageKey(sessionKey, counter, MessageDirection.SEND)

❌ Mistake 2: Using Random UUIDs

WRONG:

val sessionId = UUID.randomUUID()  // Different on each device!

CORRECT:

val sessionId = UUIDDerivation.deriveSessionKeyId(roomId)  // Same on both devices

❌ Mistake 3: Not Wiping Keys on Disconnect

WRONG:

fun onDisconnect() {
    isConnected = false
    // Keys remain in Keystore!
}

CORRECT:

fun onDisconnect() {
    isConnected = false
    currentSessionKeyId?.let { 
        keystoreManager.deleteKeys(it)
    }
    encryptionStates.clear()
}

❌ Mistake 4: Using 16-byte Nonces

WRONG:

val nonce = ByteArray(16)  // AES-GCM standard, but not compatible with iOS!

CORRECT:

val nonce = ByteArray(12)  // iOS uses 12 bytes

❌ Mistake 5: Not Base64 Encoding Binary Data

WRONG:

{
  "v": 1,
  "c": 0,
  "n": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
  "d": [65, 66, 67, ...],
  "t": [48, 49, 50, ...]
}

CORRECT:

{
  "v": 1,
  "c": 0,
  "n": "AQIDBAUGBwgJCgsM",
  "d": "QUJD...",
  "t": "MDEy..."
}

❌ Mistake 6: Responder Creating PeerConnection

WRONG:

fun onKeyExchangeComplete() {
    // Both devices create PeerConnection
    createPeerConnection()
}

CORRECT:

fun onKeyExchangeComplete() {
    if (serverAssignedIsInitiator == true) {
        // ONLY initiator creates PeerConnection
        createPeerConnection()
    } else {
        // Responder waits for offer
        println("Responder waiting for WebRTC offer")
    }
}

❌ Mistake 7: Not Handling Key Rotation on Reconnect

WRONG:

fun onReconnect() {
    // Reuse old keys from previous connection
    if (isEncryptionReady) {
        createPeerConnection()
    }
}

CORRECT:

fun onReconnect() {
    // Wipe old keys and regenerate
    cleanupEncryption()
    startKeyExchange(serverAssignedIsInitiator ?: false)
}

13. Final Integration Checklist

Phase 1: Core Encryption Classes

  • Create EncryptionConstants.kt
  • Create EncryptionModels.kt
  • Create EncryptionErrors.kt
  • Create UUIDDerivation.kt
  • Create EncryptionKeychain.kt
  • Create KeyExchangeHandler.kt
  • Create MessageEncryptor.kt

Phase 2: WebSocket Integration

  • Add key_exchange message handling
  • Add key_exchange_complete message handling
  • Add server-assigned role tracking
  • Implement startKeyExchange()
  • Implement handlePeerPublicKey()
  • Implement finalizeKeyExchange()

Phase 3: WebRTC DataChannel Integration

  • Configure DataChannel as binary (not text)
  • Implement encrypt before sending
  • Implement decrypt on receiving
  • Add counter management (sendCounter, receiveCounter)
  • Add encryption state tracking

Phase 4: Lifecycle Management

  • Implement cleanupEncryption() on disconnect
  • Implement cleanupEncryption() on peer leave
  • Add key regeneration on reconnection
  • Verify keys wiped from Keystore

Phase 5: UI Indicators (Optional)

  • Add "E2EE" badge when encryption ready
  • Add "Encrypting..." indicator during key exchange
  • Add connection info card with encryption status
  • Add toggle functionality for connection card

Phase 6: Testing

  • Unit tests for UUID derivation
  • Unit tests for key exchange
  • Unit tests for encrypt/decrypt
  • Cross-platform test with iOS device
  • Verify session keys match
  • Verify messages decrypt correctly
  • Test disconnect/reconnect key rotation

Phase 7: Production Readiness

  • Remove all debug logging
  • Enable ProGuard/R8 obfuscation
  • Test on multiple Android versions (API 26+)
  • Test with different WebRTC scenarios (direct, relay)
  • Security audit (recommended)
  • Performance profiling

14. Quick Reference

Key Sizes (bytes)

  • X25519 Public Key: 32
  • X25519 Private Key: 32
  • Session Key (AES-256): 32
  • Nonce (AES-GCM): 12
  • Tag (AES-GCM): 16

HKDF Info Strings

  • Session key: "inviso-session-v1"
  • Message key: "inviso-msg-v1" + counter + direction

MessageDirection

  • Both sender and receiver use: MessageDirection.SEND

UUID Derivation

  • Take first 32 hex chars from roomId
  • Format as: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Cleanup Triggers

  • WebSocket disconnect
  • Peer leaves room
  • User leaves session
  • App enters background (optional)

Cross-Platform Validation

  • Compare session keys (first 8 bytes, base64-encoded)
  • iOS and Android MUST derive identical keys
  • Test bidirectional message exchange

15. Support and Troubleshooting

Session Keys Don't Match

Symptom: iOS and Android derive different session keys

Causes:

  1. Different UUIDs (not deterministic)
  2. Different HKDF info strings
  3. Different X25519 implementations
  4. Byte order issues (endianness)

Solution:

// Add debug logging
val debugSessionKey = Base64.getEncoder()
    .encodeToString(sessionKey.take(8).toByteArray())
println("[DEBUG] Session key: $debugSessionKey")

// Compare with iOS logs
// iOS: IGOQpoLDw1M=
// Android: IGOQpoLDw1M= (should match)

Decryption Fails with "Authentication Error"

Symptom: javax.crypto.AEADBadTagException

Causes:

  1. Wrong MessageDirection (using .RECEIVE instead of .SEND)
  2. Wrong counter value
  3. Corrupted ciphertext
  4. Wrong session key

Solution:

// Verify message direction
val messageKey = deriveMessageKey(
    sessionKey = sessionKey,
    counter = wireFormat.counter,
    direction = MessageDirection.SEND  // MUST be SEND!
)

Keys Not Wiped on Disconnect

Symptom: Old keys remain in Keystore after disconnect

Solution:

// Add explicit cleanup
override fun onDestroy() {
    super.onDestroy()
    currentSessionKeyId?.let { sessionId ->
        keystoreManager.deleteKeys(sessionId)
    }
}

"Private Key Not Found" Error

Symptom: Can't retrieve private key during key exchange

Causes:

  1. Keystore alias mismatch
  2. Wrong UUID used for lookup
  3. Key expired or deleted

Solution:

// Ensure same UUID used for storage and retrieval
val sessionId = UUIDDerivation.deriveSessionKeyId(roomId)
keystoreManager.storePrivateKey(sessionId, privateKey)
val retrievedKey = keystoreManager.getPrivateKey(sessionId)

16. Contact and Resources

iOS Reference Implementation

  • Location: /Users/benceszilagyi/dev/trackit/Inviso/
  • Key files:
    • Inviso/Services/Encryption/EncryptionModels.swift
    • Inviso/Services/Encryption/KeyExchangeHandler.swift
    • Inviso/Services/Encryption/MessageEncryptor.swift
    • Inviso/Chat/ChatManager.swift (lines 660-850)

Server Reference

  • Location: /Users/benceszilagyi/dev/trackit/chat-server/index.js
  • Key section: handleWebRTCSignaling() (lines 420-480)

Security Documentation

  • /Users/benceszilagyi/dev/trackit/Inviso/encryption.md
  • /Users/benceszilagyi/dev/trackit/Inviso/encryptionimplementation.md

Android Crypto Libraries


17. Version History

Version Date Changes
1.0 Oct 8, 2025 Initial production-ready specification

Appendix A: Complete Android Example

Here's a minimal working example integrating all components:

// ChatManager.kt
class ChatManager(
    private val context: Context,
    private val webSocketClient: WebSocketClient,
    private val webRtcClient: WebRTCClient
) {
    private val keystoreManager = EncryptionKeychain()
    private val keyExchangeHandler = KeyExchangeHandler(keystoreManager)
    private val messageEncryptor = MessageEncryptor(keystoreManager)
    
    private var currentSessionKeyId: UUID? = null
    private var serverAssignedIsInitiator: Boolean? = null
    private var encryptionStates = mutableMapOf<String, EncryptionState>()
    
    var isEncryptionReady = false
    var keyExchangeInProgress = false
    
    fun onWebSocketMessage(message: WebSocketMessage) {
        when (message.type) {
            "room_joined" -> {
                serverAssignedIsInitiator = message.isInitiator
                startKeyExchange(message.isInitiator ?: false)
            }
            
            "key_exchange" -> {
                handlePeerPublicKey(
                    message.publicKey ?: return,
                    message.sessionId ?: return
                )
            }
            
            "key_exchange_complete" -> {
                finalizeKeyExchange(
                    message.sessionId ?: return,
                    isInitiator = true
                )
            }
        }
    }
    
    fun startKeyExchange(isInitiator: Boolean) {
        keyExchangeInProgress = true
        isEncryptionReady = false
        
        // Derive deterministic UUID
        val sessionKeyId = UUIDDerivation.deriveSessionKeyId(roomId)
        currentSessionKeyId = sessionKeyId
        
        // Generate keypair
        val publicKey = keyExchangeHandler.generateKeypair(sessionKeyId)
        
        // Send to peer
        webSocketClient.send(KeyExchangeMessage(
            publicKey = publicKey,
            sessionId = roomId
        ))
        
        // Initialize state
        encryptionStates[roomId] = EncryptionState()
    }
    
    fun handlePeerPublicKey(publicKeyBase64: String, sessionId: String) {
        val sessionKeyId = UUIDDerivation.deriveSessionKeyId(sessionId)
        currentSessionKeyId = sessionKeyId
        
        // Derive session key
        val sessionKey = keyExchangeHandler.deriveSessionKey(
            publicKeyBase64,
            sessionKeyId
        )
        
        // Update state
        val state = encryptionStates[sessionId]!!
        state.keyExchangeComplete = true
        
        // Responder sends completion message
        if (serverAssignedIsInitiator == false) {
            webSocketClient.send(KeyExchangeCompleteMessage(
                sessionId = sessionId
            ))
            finalizeKeyExchange(sessionId, isInitiator = false)
        }
    }
    
    fun finalizeKeyExchange(sessionId: String, isInitiator: Boolean) {
        keyExchangeInProgress = false
        isEncryptionReady = true
        
        // Only initiator creates PeerConnection
        if (isInitiator) {
            webRtcClient.createPeerConnection()
        }
    }
    
    fun sendMessage(text: String) {
        val sessionKeyId = currentSessionKeyId ?: return
        val state = encryptionStates[roomId] ?: return
        
        // Encrypt
        val wireFormat = messageEncryptor.encrypt(
            plaintext = text,
            sessionId = sessionKeyId,
            counter = state.sendCounter
        )
        
        // Increment counter
        state.sendCounter++
        
        // Send over WebRTC DataChannel
        webRtcClient.sendBinary(wireFormat.toJson().toByteArray())
    }
    
    fun onDataChannelMessage(data: ByteArray) {
        val sessionKeyId = currentSessionKeyId ?: return
        val json = String(data, Charsets.UTF_8)
        val wireFormat = MessageWireFormat.fromJson(json) ?: return
        
        // Decrypt
        val plaintext = messageEncryptor.decrypt(
            wireFormat = wireFormat,
            sessionId = sessionKeyId
        )
        
        // Update UI
        displayMessage(plaintext)
    }
    
    fun cleanupEncryption() {
        currentSessionKeyId?.let { sessionId ->
            keystoreManager.deleteKeys(sessionId)
        }
        encryptionStates.clear()
        currentSessionKeyId = null
        keyExchangeInProgress = false
        isEncryptionReady = false
    }
    
    fun onDisconnect() {
        cleanupEncryption()
    }
}

END OF DOCUMENT

This guide provides everything needed to implement iOS-compatible E2EE on Android. Follow it carefully, test thoroughly, and your Android app will seamlessly encrypt/decrypt messages with iOS devices.

Good luck! 🔐