Version: 1.0
Date: October 8, 2025
Target: Android developers implementing E2EE for cross-platform compatibility with iOS Inviso app
Status: Production-ready specification
- Overview
- Critical Requirements
- Architecture
- Cryptographic Implementation
- Key Exchange Protocol
- Message Encryption/Decryption
- Role Determination
- Storage and Lifecycle
- Wire Format
- Testing and Validation
- Security Checklist
- Common Pitfalls
This guide describes the exact E2EE implementation used in the iOS Inviso app. Android must implement the identical protocol to ensure cross-platform compatibility.
✅ 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
| 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 |
These requirements are non-negotiable for cross-platform compatibility:
- Use X25519 for ECDH (Curve25519, 32-byte keys)
- Use AES-256-GCM (not CBC, not CTR - must be GCM mode)
- Use HKDF-SHA256 (not raw SHA256, must use HKDF)
- Use 12-byte nonces for AES-GCM (not 16 bytes)
- Use MessageDirection.send for BOTH sender and receiver in HKDF derivation
- Derive deterministic UUID from roomId (not random UUIDs)
- Use exact wire format (JSON with fields: v, c, n, d, t)
- Base64 encode all binary data (publicKey, nonce, ciphertext, tag)
- Increment counter monotonically for each sent message
- Wipe keys immediately on disconnect
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.
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
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────────┘
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
}.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.
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
)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()
}
}
}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)
}
}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)
}
}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)
}
}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
- BOTH devices generate keypairs and send
key_exchange - RESPONDER sends
key_exchange_completeafter deriving session key - INITIATOR waits for
key_exchange_completebefore creating PeerConnection - ONLY INITIATOR creates WebRTC PeerConnection and sends offer
- Keys are wiped on disconnect and regenerated on reconnection
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
)
}// 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)}")
}
}Key Exchange:
{
"type": "key_exchange",
"publicKey": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2",
"sessionId": "a1b2c3d4e5f6..."
}Key Exchange Complete:
{
"type": "key_exchange_complete",
"sessionId": "a1b2c3d4e5f6..."
}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) |
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)
}
}Test with iOS device:
- Connect Android device to iOS device in same room
- Send message from Android → iOS
- Verify iOS can decrypt (check iOS console logs for session key)
- Send message from iOS → Android
- Verify Android can decrypt (check Android logcat for session key)
- 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...")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
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)WRONG:
val sessionId = UUID.randomUUID() // Different on each device!CORRECT:
val sessionId = UUIDDerivation.deriveSessionKeyId(roomId) // Same on both devicesWRONG:
fun onDisconnect() {
isConnected = false
// Keys remain in Keystore!
}CORRECT:
fun onDisconnect() {
isConnected = false
currentSessionKeyId?.let {
keystoreManager.deleteKeys(it)
}
encryptionStates.clear()
}WRONG:
val nonce = ByteArray(16) // AES-GCM standard, but not compatible with iOS!CORRECT:
val nonce = ByteArray(12) // iOS uses 12 bytesWRONG:
{
"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..."
}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")
}
}WRONG:
fun onReconnect() {
// Reuse old keys from previous connection
if (isEncryptionReady) {
createPeerConnection()
}
}CORRECT:
fun onReconnect() {
// Wipe old keys and regenerate
cleanupEncryption()
startKeyExchange(serverAssignedIsInitiator ?: false)
}- Create
EncryptionConstants.kt - Create
EncryptionModels.kt - Create
EncryptionErrors.kt - Create
UUIDDerivation.kt - Create
EncryptionKeychain.kt - Create
KeyExchangeHandler.kt - Create
MessageEncryptor.kt
- Add key_exchange message handling
- Add key_exchange_complete message handling
- Add server-assigned role tracking
- Implement startKeyExchange()
- Implement handlePeerPublicKey()
- Implement finalizeKeyExchange()
- Configure DataChannel as binary (not text)
- Implement encrypt before sending
- Implement decrypt on receiving
- Add counter management (sendCounter, receiveCounter)
- Add encryption state tracking
- Implement cleanupEncryption() on disconnect
- Implement cleanupEncryption() on peer leave
- Add key regeneration on reconnection
- Verify keys wiped from Keystore
- 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
- 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
- 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
- X25519 Public Key: 32
- X25519 Private Key: 32
- Session Key (AES-256): 32
- Nonce (AES-GCM): 12
- Tag (AES-GCM): 16
- Session key:
"inviso-session-v1" - Message key:
"inviso-msg-v1" + counter + direction
- Both sender and receiver use:
MessageDirection.SEND
- Take first 32 hex chars from roomId
- Format as:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- WebSocket disconnect
- Peer leaves room
- User leaves session
- App enters background (optional)
- Compare session keys (first 8 bytes, base64-encoded)
- iOS and Android MUST derive identical keys
- Test bidirectional message exchange
Symptom: iOS and Android derive different session keys
Causes:
- Different UUIDs (not deterministic)
- Different HKDF info strings
- Different X25519 implementations
- 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)Symptom: javax.crypto.AEADBadTagException
Causes:
- Wrong MessageDirection (using .RECEIVE instead of .SEND)
- Wrong counter value
- Corrupted ciphertext
- Wrong session key
Solution:
// Verify message direction
val messageKey = deriveMessageKey(
sessionKey = sessionKey,
counter = wireFormat.counter,
direction = MessageDirection.SEND // MUST be SEND!
)Symptom: Old keys remain in Keystore after disconnect
Solution:
// Add explicit cleanup
override fun onDestroy() {
super.onDestroy()
currentSessionKeyId?.let { sessionId ->
keystoreManager.deleteKeys(sessionId)
}
}Symptom: Can't retrieve private key during key exchange
Causes:
- Keystore alias mismatch
- Wrong UUID used for lookup
- 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)- Location:
/Users/benceszilagyi/dev/trackit/Inviso/ - Key files:
Inviso/Services/Encryption/EncryptionModels.swiftInviso/Services/Encryption/KeyExchangeHandler.swiftInviso/Services/Encryption/MessageEncryptor.swiftInviso/Chat/ChatManager.swift(lines 660-850)
- Location:
/Users/benceszilagyi/dev/trackit/chat-server/index.js - Key section:
handleWebRTCSignaling()(lines 420-480)
/Users/benceszilagyi/dev/trackit/Inviso/encryption.md/Users/benceszilagyi/dev/trackit/Inviso/encryptionimplementation.md
- Tink: https://github.com/google/tink
- Bouncy Castle: https://www.bouncycastle.org/
- Conscrypt: https://github.com/google/conscrypt
| Version | Date | Changes |
|---|---|---|
| 1.0 | Oct 8, 2025 | Initial production-ready specification |
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! 🔐