Fix E2EE encryption surviving app restarts

Six interconnected fixes to make Olm/Megolm state persist correctly
across restarts:

- Fix OlmAccount/OlmSession unpickle argument order (data, key) — was
  reversed, causing INVALID_BASE64 on every restore
- Fix pickle byte encoding: pickle() returns ByteArray, not StringBuffer;
  encode/decode with ISO_8859_1
- Track publishedOtkCount in SharedPreferences to avoid evicting existing
  OTKs on restore (only generate MAX - published new keys)
- Delay startSync() until after uploadKeys() on fresh login to avoid
  concurrent OlmAccount access (race condition causing BAD_MESSAGE_KEY_ID)
- Persist encryptedRooms to SharedPreferences so rooms stay encrypted
  across restarts
- Check m.room.encryption in both state and timeline events (server
  returns it in timeline for rooms where encryption was enabled early)

Also adds SAS device verification UI, DM naming improvements, and
updates state.md with full architecture and debugging notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-05-17 15:21:09 -03:00
parent b30e83898c
commit 3747172488
8 changed files with 781 additions and 58 deletions

View file

@ -2,6 +2,7 @@ package com.ltadeu6.matrix.matrix
import android.content.Context
import android.util.Base64
import android.util.Log
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@ -41,8 +42,13 @@ class CryptoService(
init {
OlmManager() // loads the native libolm .so
olmAccount = restoreAccount() ?: OlmAccount().also { persistAccount(it) }
val restored = restoreAccount()
olmAccount = restored ?: OlmAccount().also { persistAccount(it) }
Log.i(TAG, "CryptoService init — restored=${restored != null} curve25519=${curve25519Key.take(8)}")
restoreInboundSessions()
restoreOutboundSessions()
restoreOlmSessions()
Log.i(TAG, "CryptoService init — inbound=${inboundSessions.size} outbound=${outboundSessions.size} olm=${olmSessions.size}")
}
val curve25519Key: String
@ -101,23 +107,53 @@ class CryptoService(
/** Call after a successful `/keys/upload` to mark one-time keys as consumed. */
fun onKeysUploaded() {
val justPublished = olmAccount.oneTimeKeys()["curve25519"]?.size ?: 0
olmAccount.markOneTimeKeysAsPublished()
publishedOtkCount += justPublished
persistAccount(olmAccount)
}
/**
* Builds a payload with only one-time keys (no device_keys).
* Use this for OTK replenishment so the server does not see device_keys as changed,
* which would trigger device_lists.changed events and break cross-signing trust.
*/
fun buildOtkOnlyUploadPayload(): JsonObject {
val needed = maxOf(0, MAX_ONE_TIME_KEYS - publishedOtkCount)
if (needed > 0) olmAccount.generateOneTimeKeys(needed)
val otks = olmAccount.oneTimeKeys()
val curve25519Otks: Map<String, String> = otks["curve25519"] ?: emptyMap()
val oneTimeKeys = buildJsonObject {
curve25519Otks.forEach { (keyId, keyValue) ->
val keyObj = buildJsonObject { put("key", keyValue) }
val sig = olmAccount.signMessage(keyObj.toCanonicalJson())
put("signed_curve25519:$keyId", buildJsonObject {
put("key", keyValue)
put("signatures", buildJsonObject {
put(userId, buildJsonObject { put("ed25519:$deviceId", sig) })
})
})
}
}
return buildJsonObject { put("one_time_keys", oneTimeKeys) }
}
// -------------------------------------------------------------------------
// To-device event processing (receive incoming room keys)
// -------------------------------------------------------------------------
fun processToDeviceEvents(events: List<JsonObject>) {
fun processToDeviceEvents(events: List<JsonObject>): Set<String> {
val newRooms = mutableSetOf<String>()
for (event in events) {
try { processToDeviceEvent(event) } catch (_: Exception) {}
try { processToDeviceEvent(event, newRooms) } catch (_: Exception) {}
}
return newRooms
}
private fun processToDeviceEvent(event: JsonObject) {
private fun processToDeviceEvent(event: JsonObject, newRooms: MutableSet<String>) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return
val content = event["content"]?.jsonObject ?: return
Log.d(TAG, "to-device type=$type")
when (type) {
"m.room.encrypted" -> {
@ -126,12 +162,18 @@ class CryptoService(
val innerType = (inner["type"] as? JsonPrimitive)?.content
val innerContent = inner["content"]?.jsonObject ?: return
when (innerType) {
"m.room_key" -> handleRoomKey(innerContent)
"m.forwarded_room_key" -> handleForwardedRoomKey(innerContent)
"m.room_key" -> handleRoomKey(innerContent)?.let {
Log.i(TAG, "m.room_key stored — room=$it")
newRooms.add(it)
}
"m.forwarded_room_key" -> handleForwardedRoomKey(innerContent)?.let {
Log.i(TAG, "m.forwarded_room_key stored — room=$it")
newRooms.add(it)
}
}
}
"m.room_key" -> handleRoomKey(content)
"m.forwarded_room_key" -> handleForwardedRoomKey(content)
"m.room_key" -> handleRoomKey(content)?.let { newRooms.add(it) }
"m.forwarded_room_key" -> handleForwardedRoomKey(content)?.let { newRooms.add(it) }
}
}
@ -139,18 +181,24 @@ class CryptoService(
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.olm.v1.curve25519-aes-sha2") return null
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content ?: return null
val ciphertext = content["ciphertext"]?.jsonObject ?: return null
val ourMsg = ciphertext[curve25519Key]?.jsonObject ?: return null
val myCurve = curve25519Key
Log.d(TAG, "olm decrypt — our curve25519=${myCurve.take(8)} ciphertext keys=${ciphertext.keys.map { it.take(8) }}")
val ourMsg = ciphertext[myCurve]?.jsonObject ?: run {
Log.e(TAG, "olm decrypt — our key ${myCurve.take(8)} NOT in ciphertext — key mismatch!")
return null
}
val type = (ourMsg["type"] as? JsonPrimitive)?.content?.toIntOrNull() ?: return null
val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null
// Try existing sessions first (type 1 — regular message)
if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) {
olmSessions[senderKey]?.forEach { session ->
return try {
try {
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
session.decryptMessage(olmMsg)
} catch (_: Exception) { null }
return session.decryptMessage(olmMsg).also { persistOlmSessions(senderKey) }
} catch (_: Exception) { /* try next session */ }
}
Log.w(TAG, "olm type-1 all sessions failed for ${senderKey.take(8)}")
}
// Pre-key message (type 0) — create a new inbound Olm session
@ -159,48 +207,77 @@ class CryptoService(
val session = OlmSession()
session.initInboundSessionFrom(olmAccount, senderKey, body)
olmAccount.removeOneTimeKeys(session)
if (publishedOtkCount > 0) publishedOtkCount--
persistAccount(olmAccount)
olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session)
persistOlmSessions(senderKey)
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
session.decryptMessage(olmMsg)
} catch (_: Exception) { null }
session.decryptMessage(olmMsg).also {
Log.i(TAG, "olm PRE_KEY ok — new inbound session for ${senderKey.take(8)}")
persistOlmSessions(senderKey)
}
} catch (e: Exception) {
Log.e(TAG, "olm PRE_KEY failed for ${senderKey.take(8)}: $e")
null
}
}
return null
}
private fun handleRoomKey(content: JsonObject) {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
if (sessionId in inboundSessions) return
try {
/** Returns the room ID if a new session was stored, null otherwise. */
private fun handleRoomKey(content: JsonObject): String? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null
if (sessionId in inboundSessions) return null
return try {
val session = OlmInboundGroupSession(sessionKey)
inboundSessions[sessionId] = session
persistInboundSession(sessionId, session)
} catch (_: Exception) {}
roomId
} catch (e: Exception) {
Log.e(TAG, "handleRoomKey — OlmInboundGroupSession create failed id=${sessionId.take(8)}: $e")
null
}
}
/**
* Handles `m.forwarded_room_key` a key shared by another of our own devices in response
* to an `m.room_key_request`. Uses [OlmInboundGroupSession.importSession] because the
* forwarded key is in exported (message-index-aware) format.
*/
private fun handleForwardedRoomKey(content: JsonObject) {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
if (sessionId in inboundSessions) return
try {
/** Returns the room ID if a new session was stored, null otherwise. */
private fun handleForwardedRoomKey(content: JsonObject): String? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null
if (sessionId in inboundSessions) return null
return try {
val session = OlmInboundGroupSession.importSession(sessionKey)
inboundSessions[sessionId] = session
persistInboundSession(sessionId, session)
} catch (_: Exception) {}
roomId
} catch (e: Exception) {
Log.e(TAG, "handleForwardedRoomKey — importSession failed id=${sessionId.take(8)}: $e")
null
}
}
// -------------------------------------------------------------------------
// Room message decryption / encryption
// -------------------------------------------------------------------------
/** Exports the inbound Megolm session at its first known index, for key forwarding. */
fun exportInboundSession(sessionId: String): String? {
val session = inboundSessions[sessionId] ?: run {
Log.w(TAG, "exportInboundSession — session not found: ${sessionId.take(8)}")
return null
}
return try {
session.export(session.getFirstKnownIndex())
} catch (e: Exception) {
Log.e(TAG, "exportInboundSession failed: $e")
null
}
}
fun decryptRoomEventPayload(content: JsonObject): JsonObject? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
@ -209,7 +286,10 @@ class CryptoService(
return try {
val result = session.decryptMessage(ciphertext)
json.parseToJsonElement(result.mDecryptedMessage).jsonObject
} catch (_: Exception) { null }
} catch (e: Exception) {
Log.w(TAG, "megolm decrypt failed id=${sessionId.take(8)}: $e")
null
}
}
fun decryptRoomEvent(content: JsonObject): String? =
@ -227,6 +307,8 @@ class CryptoService(
put("session_id", session.sessionIdentifier())
put("ciphertext", session.encryptMessage(plaintext))
put("device_id", deviceId)
}.also {
persistOutboundSession(roomId, session)
}
} catch (_: Exception) { null }
}
@ -250,7 +332,10 @@ class CryptoService(
}
}
Pair(sessionId, sessionKey)
} catch (_: Exception) { null }
} catch (e: Exception) {
Log.e(TAG, "initOutboundSession failed: $e")
null
}
}
/**
@ -283,15 +368,22 @@ class CryptoService(
// -------------------------------------------------------------------------
private fun persistAccount(account: OlmAccount) {
val pickled = account.pickle(PICKLE_KEY, StringBuffer())
prefs.edit().putString(KEY_ACCOUNT, Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
val pickled = account.pickle(PICKLE_KEY, StringBuffer()) as ByteArray
prefs.edit().putString(KEY_ACCOUNT, String(pickled, Charsets.ISO_8859_1)).apply()
}
private fun restoreAccount(): OlmAccount? {
val stored = prefs.getString(KEY_ACCOUNT, null) ?: return null
val stored = prefs.getString(KEY_ACCOUNT, null) ?: run {
Log.e(TAG, "restoreAccount — nothing in prefs (key=$KEY_ACCOUNT)")
return null
}
return try {
OlmAccount().also { it.unpickle(PICKLE_KEY, Base64.decode(stored, Base64.NO_WRAP)) }
} catch (_: Exception) { null }
OlmAccount().also { it.unpickle(stored.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY) }
.also { Log.i(TAG, "restoreAccount — unpickle ok") }
} catch (e: Exception) {
Log.e(TAG, "restoreAccount — unpickle FAILED: $e")
null
}
}
private fun persistInboundSession(sessionId: String, session: OlmInboundGroupSession) {
@ -314,15 +406,83 @@ class CryptoService(
private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) {
try {
val pickled = session.pickle(PICKLE_KEY, StringBuffer())
prefs.edit().putString("outbound_$roomId", Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
val pickled = session.pickle(PICKLE_KEY, StringBuffer()) as ByteArray
prefs.edit().putString("outbound_$roomId", String(pickled, Charsets.ISO_8859_1)).apply()
val ids = prefs.getStringSet(KEY_OUTBOUND_IDS, emptySet())!!.toMutableSet()
ids.add(roomId)
prefs.edit().putStringSet(KEY_OUTBOUND_IDS, ids).apply()
} catch (_: Exception) {}
}
private fun restoreOutboundSessions() {
val ids = prefs.getStringSet(KEY_OUTBOUND_IDS, emptySet()) ?: return
for (roomId in ids) {
val stored = prefs.getString("outbound_$roomId", null) ?: continue
try {
outboundSessions[roomId] = OlmOutboundGroupSession().also {
it.unpickle(stored.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY)
}
} catch (_: Exception) {}
}
}
private fun persistOlmSessions(senderKey: String) {
val sessions = olmSessions[senderKey].orEmpty()
if (sessions.isEmpty()) return
try {
val pickles = sessions.mapNotNull { session ->
runCatching {
String(session.pickle(PICKLE_KEY, StringBuffer()) as ByteArray, Charsets.ISO_8859_1)
}.getOrNull()
}.toSet()
if (pickles.isEmpty()) return
val prefKey = olmSessionPrefKey(senderKey)
val senderKeys = prefs.getStringSet(KEY_OLM_SENDER_KEYS, emptySet())!!.toMutableSet()
senderKeys.add(senderKey)
prefs.edit()
.putStringSet(prefKey, pickles)
.putStringSet(KEY_OLM_SENDER_KEYS, senderKeys)
.apply()
} catch (_: Exception) {}
}
private fun restoreOlmSessions() {
val senderKeys = prefs.getStringSet(KEY_OLM_SENDER_KEYS, emptySet()) ?: return
for (senderKey in senderKeys) {
val pickles = prefs.getStringSet(olmSessionPrefKey(senderKey), emptySet()).orEmpty()
val restored = pickles.mapNotNull { pickled ->
runCatching {
OlmSession().also {
it.unpickle(pickled.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY)
}
}.getOrNull()
}.toMutableList()
if (restored.isNotEmpty()) {
olmSessions[senderKey] = restored
}
}
}
private fun olmSessionPrefKey(senderKey: String): String {
val encoded = Base64.encodeToString(
senderKey.toByteArray(Charsets.UTF_8),
Base64.NO_WRAP or Base64.URL_SAFE
)
return "olm_sessions_$encoded"
}
private var publishedOtkCount: Int
get() = prefs.getInt(KEY_OTK_COUNT, 0)
set(v) { prefs.edit().putInt(KEY_OTK_COUNT, v).apply() }
companion object {
private const val MAX_ONE_TIME_KEYS = 10
private const val TAG = "MatrixCrypto"
private const val MAX_ONE_TIME_KEYS = 50
private const val KEY_ACCOUNT = "olm_account"
private const val KEY_INBOUND_IDS = "inbound_ids"
private const val KEY_OUTBOUND_IDS = "outbound_ids"
private const val KEY_OLM_SENDER_KEYS = "olm_sender_keys"
private const val KEY_OTK_COUNT = "published_otk_count"
private val PICKLE_KEY = "matrix_olm_pickle".toByteArray()
}
}

View file

@ -183,3 +183,18 @@ data class Message(
val isEdited: Boolean = false,
val reactions: Map<String, Int> = emptyMap()
)
data class OwnDevice(
val userId: String,
val deviceId: String,
val displayName: String,
val isCurrent: Boolean,
val isVerified: Boolean
)
data class DeviceSyncState(
val isLoaded: Boolean = false,
val isCurrentDeviceVerified: Boolean = false,
val availableTargets: List<OwnDevice> = emptyList(),
val error: String? = null
)

View file

@ -21,6 +21,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
@ -29,6 +30,7 @@ import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
sealed class AuthState {
object LoggedOut : AuthState()
object Loading : AuthState()
@ -57,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private val _pendingSsoToken = MutableStateFlow<String?>(null)
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
val deviceSyncState: StateFlow<DeviceSyncState> = _deviceSyncState.asStateFlow()
private var accessToken: String? = null
private var currentUserId: String? = null
private var syncJob: Job? = null
@ -107,6 +112,7 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.onVerified = {
requestedSessions.clear()
sharedSessionRooms.clear()
scope.launch { refreshDeviceSyncState() }
startSync()
}
@ -114,6 +120,8 @@ class MatrixSession private constructor(private val appContext: Context) {
val savedHomeserver = prefs.getString("homeserver_url", null)
val savedUserId = prefs.getString("user_id", null)
val savedDeviceId = prefs.getString("device_id", null)
encryptedRooms.addAll(prefs.getStringSet("encrypted_room_ids", emptySet()) ?: emptySet())
if (savedToken != null && savedHomeserver != null && savedUserId != null) {
accessToken = savedToken
currentUserId = savedUserId
@ -125,7 +133,10 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.userId = savedUserId
verificationService.deviceId = savedDeviceId
verificationService.cryptoService = crypto
scope.launch { uploadKeys() }
scope.launch {
uploadOtks()
refreshDeviceSyncState()
}
}
startSync()
}
@ -186,6 +197,7 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.userId = ""
verificationService.deviceId = ""
verificationService.cryptoService = null
_deviceSyncState.value = DeviceSyncState()
_authState.value = AuthState.LoggedOut
_syncState.value = SyncState()
}
@ -301,6 +313,28 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
suspend fun startSelfVerification(): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
return runCatching {
refreshDeviceSyncState(token, matrixApi)
val state = _deviceSyncState.value
if (state.isCurrentDeviceVerified) {
throw Exception("This device is already synced")
}
val target = pickPreferredVerificationTarget(state.availableTargets)
?: throw Exception("No other device available for verification")
val started = verificationService.requestVerification(
toUser = target.userId,
toDevice = target.deviceId,
deviceLabel = target.displayName
)
if (!started) {
throw Exception("Another verification is already in progress")
}
}
}
// -------------------------------------------------------------------------
// Login flow
// -------------------------------------------------------------------------
@ -320,8 +354,11 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.userId = response.userId
verificationService.deviceId = response.deviceId
verificationService.cryptoService = crypto
scope.launch { uploadKeys() }
startSync()
scope.launch {
uploadKeys()
refreshDeviceSyncState()
startSync()
}
}
// -------------------------------------------------------------------------
@ -339,6 +376,17 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
private suspend fun uploadOtks() {
val crypto = cryptoService ?: return
val token = accessToken ?: return
val matrixApi = api ?: return
runCatching {
val payload = crypto.buildOtkOnlyUploadPayload()
matrixApi.uploadKeys("Bearer $token", payload)
crypto.onKeysUploaded()
}
}
private suspend fun sendTimelineEvent(
roomId: String,
eventType: String,
@ -393,6 +441,7 @@ class MatrixSession private constructor(private val appContext: Context) {
val key = "${ms.roomId}:${ms.sessionId}"
if (key in requestedSessions) continue
requestedSessions.add(key)
Log.i(TAG, "sending key request — room=${ms.roomId.take(20)} session=${ms.sessionId.take(8)} senderKey=${ms.senderKey.take(8)}")
val requestId = "${System.currentTimeMillis()}_${ms.sessionId.take(8)}"
val body = buildJsonObject {
@ -431,6 +480,7 @@ class MatrixSession private constructor(private val appContext: Context) {
val auth = "Bearer $token"
val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return
Log.i(TAG, "shareGroupSession — room=${roomId.take(20)} session=${sessionId.take(8)}")
// 1. Joined members
val members = runCatching { matrixApi.getJoinedMembers(auth, roomId) }
@ -523,6 +573,98 @@ class MatrixSession private constructor(private val appContext: Context) {
runCatching { matrixApi.sendToDevice(auth, "m.room.encrypted", txnId, toDeviceBody) }
}
// -------------------------------------------------------------------------
// Room key request response (answer key requests from our own devices)
// -------------------------------------------------------------------------
/**
* Responds to `m.room_key_request` to-device events from our own devices.
* Exports the inbound Megolm session and sends `m.forwarded_room_key` back via a fresh
* Olm PRE_KEY channel so the requester can decrypt our messages.
*/
private suspend fun handleRoomKeyRequest(event: JsonObject, auth: String, matrixApi: MatrixApi) {
val content = event["content"]?.jsonObject ?: return
if ((content["action"] as? JsonPrimitive)?.content != "request") return
val sender = (event["sender"] as? JsonPrimitive)?.content ?: return
if (sender != currentUserId) return // only serve our own devices
val requestingDeviceId = (content["requesting_device_id"] as? JsonPrimitive)?.content ?: return
val body = content["body"]?.jsonObject ?: return
if ((body["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
val roomId = (body["room_id"] as? JsonPrimitive)?.content ?: return
val sessionId = (body["session_id"] as? JsonPrimitive)?.content ?: return
val crypto = cryptoService ?: return
Log.i(TAG, "key request from device=$requestingDeviceId room=${roomId.take(20)} session=${sessionId.take(8)}")
val exportedKey = crypto.exportInboundSession(sessionId) ?: run {
Log.w(TAG, "key request — session ${sessionId.take(8)} not in inboundSessions, cannot respond")
return
}
// Look up the requesting device's keys
val queryBody = buildJsonObject {
put("device_keys", buildJsonObject {
put(sender, JsonArray(listOf(JsonPrimitive(requestingDeviceId))))
})
}
val queryResp = runCatching { matrixApi.queryKeys(auth, queryBody) }.getOrNull() ?: return
val devInfo = queryResp["device_keys"]?.jsonObject
?.get(sender)?.jsonObject
?.get(requestingDeviceId)?.jsonObject ?: return
val keys = devInfo["keys"]?.jsonObject ?: return
val theirCurve25519 = (keys["curve25519:$requestingDeviceId"] as? JsonPrimitive)?.content ?: return
val theirEd25519 = (keys["ed25519:$requestingDeviceId"] as? JsonPrimitive)?.content ?: return
// Claim an OTK so we establish a fresh Olm session (PRE_KEY)
val claimBody = buildJsonObject {
put("one_time_keys", buildJsonObject {
put(sender, buildJsonObject { put(requestingDeviceId, "signed_curve25519") })
})
}
val claimResp = runCatching { matrixApi.claimKeys(auth, claimBody) }.getOrNull() ?: return
val otkKey = claimResp["one_time_keys"]?.jsonObject
?.get(sender)?.jsonObject
?.get(requestingDeviceId)?.jsonObject
?.values?.firstOrNull()?.jsonObject
?.let { (it["key"] as? JsonPrimitive)?.content } ?: return
val devId = prefs.getString("device_id", "") ?: return
val uid = currentUserId ?: return
val payload = buildJsonObject {
put("type", "m.forwarded_room_key")
put("content", buildJsonObject {
put("algorithm", "m.megolm.v1.aes-sha2")
put("room_id", roomId)
put("session_id", sessionId)
put("session_key", exportedKey)
put("sender_key", crypto.curve25519Key)
put("sender_claimed_ed25519_key", crypto.ed25519Key)
put("forwarding_curve25519_key_chain", JsonArray(emptyList()))
})
put("sender", uid)
put("sender_device", devId)
put("keys", buildJsonObject { put("ed25519", crypto.ed25519Key) })
put("recipient", sender)
put("recipient_keys", buildJsonObject { put("ed25519", theirEd25519) })
}.toString()
val ciphertext = crypto.encryptOlmPayload(payload, theirCurve25519, otkKey) ?: return
val encContent = buildJsonObject {
put("algorithm", "m.olm.v1.curve25519-aes-sha2")
put("sender_key", crypto.curve25519Key)
put("ciphertext", ciphertext)
}
val txnId = "${System.currentTimeMillis()}_keyreply_${sessionId.take(8)}"
val toDeviceBody = buildJsonObject {
put("messages", buildJsonObject {
put(sender, buildJsonObject { put(requestingDeviceId, encContent) })
})
}
runCatching { matrixApi.sendToDevice(auth, "m.room.encrypted", txnId, toDeviceBody) }
.onSuccess { Log.i(TAG, "forwarded_room_key sent to device=$requestingDeviceId session=${sessionId.take(8)}") }
.onFailure { Log.e(TAG, "failed to send forwarded_room_key: $it") }
}
// -------------------------------------------------------------------------
// Sync loop
// -------------------------------------------------------------------------
@ -545,26 +687,54 @@ class MatrixSession private constructor(private val appContext: Context) {
since = response.nextBatch
// Feed to-device events so incoming room keys are available before decryption
var newKeyRooms: Set<String> = emptySet()
response.toDevice?.events?.let { events ->
cryptoService?.processToDeviceEvents(events)
newKeyRooms = cryptoService?.processToDeviceEvents(events) ?: emptySet()
events.forEach { event ->
val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach
if (type.startsWith("m.key.verification.")) {
verificationService.processToDeviceEvent(event)
when {
type.startsWith("m.key.verification.") ->
verificationService.processToDeviceEvent(event)
type == "m.room_key_request" ->
scope.launch { handleRoomKeyRequest(event, "Bearer $token", matrixApi) }
}
}
}
val missing = processSyncResponse(response)
// Re-decrypt cached events for rooms where new keys just arrived
if (newKeyRooms.isNotEmpty()) {
val current = _syncState.value
val rooms = current.rooms.toMutableMap()
val messages = current.messages.toMutableMap()
val extraMissing = mutableListOf<MissingSession>()
for (roomId in newKeyRooms) {
if (roomId in roomEventCache) {
rebuildRoomMessages(roomId, rooms, messages, extraMissing)
}
}
_syncState.value = SyncState(rooms = rooms, messages = messages)
if (extraMissing.isNotEmpty()) {
sendKeyRequests(extraMissing, "Bearer $token", matrixApi)
}
}
// Request keys from our other devices for any event we couldn't decrypt
if (missing.isNotEmpty()) {
sendKeyRequests(missing, "Bearer $token", matrixApi)
}
// Replenish OTKs if the server reports running low (without re-uploading device keys)
val otkCount = response.deviceOneTimeKeysCount["signed_curve25519"] ?: -1
if (otkCount in 0..9) {
scope.launch { uploadOtks() }
}
if (isFirstSync) {
isFirstSync = false
refreshDirectRoomTargets(token, matrixApi)
refreshDeviceSyncState(token, matrixApi)
fetchMissingRoomNames(token, matrixApi)
}
} catch (e: CancellationException) {
@ -626,6 +796,75 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
private suspend fun refreshDeviceSyncState() {
val token = accessToken ?: return
val matrixApi = api ?: return
refreshDeviceSyncState(token, matrixApi)
}
private suspend fun refreshDeviceSyncState(token: String, matrixApi: MatrixApi) {
val userId = currentUserId ?: return
val currentDeviceId = prefs.getString("device_id", null) ?: return
val request = buildJsonObject {
put("device_keys", buildJsonObject {
put(userId, JsonArray(emptyList()))
})
}
val response = runCatching { matrixApi.queryKeys("Bearer $token", request) }.getOrNull()
if (response == null) {
_deviceSyncState.value = _deviceSyncState.value.copy(
isLoaded = true,
error = "failed to load device sync status"
)
return
}
val selfSigningKeyId = response["self_signing_keys"]?.jsonObject
?.get(userId)?.jsonObject
?.get("keys")?.jsonObject
?.keys?.firstOrNull()
val devicesJson = response["device_keys"]?.jsonObject
?.get(userId)?.jsonObject
?: JsonObject(emptyMap())
val devices = devicesJson.map { (deviceId, rawDevice) ->
val device = rawDevice.jsonObject
val signatures = device["signatures"]?.jsonObject?.get(userId)?.jsonObject
val isVerified = selfSigningKeyId != null && signatures?.containsKey(selfSigningKeyId) == true
val displayName = (device["unsigned"]?.jsonObject?.get("device_display_name") as? JsonPrimitive)?.content
?.takeIf { it.isNotBlank() }
?: deviceId
OwnDevice(
userId = userId,
deviceId = deviceId,
displayName = displayName,
isCurrent = deviceId == currentDeviceId,
isVerified = isVerified
)
}
_deviceSyncState.value = DeviceSyncState(
isLoaded = true,
isCurrentDeviceVerified = devices.firstOrNull { it.isCurrent }?.isVerified == true,
availableTargets = devices
.filterNot { it.isCurrent }
.sortedWith(
compareBy<OwnDevice> {
when {
it.displayName.contains("element", ignoreCase = true) -> 0
else -> 1
}
}.thenBy { it.displayName.lowercase() }
),
error = null
)
}
private fun pickPreferredVerificationTarget(targets: List<OwnDevice>): OwnDevice? {
return targets.firstOrNull()
}
private fun prefersMemberName(roomId: String): Boolean {
return directRoomTargets.containsKey(roomId)
}
@ -658,9 +897,14 @@ class MatrixSession private constructor(private val appContext: Context) {
}
// Track encryption state
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
.any { it.type == "m.room.encryption" }
if (roomIsEncrypted) encryptedRooms.add(roomId)
val stateEvents = roomData.state?.events ?: emptyList()
val roomIsEncrypted = stateEvents.any { it.type == "m.room.encryption" }
|| (roomData.timeline?.events ?: emptyList()).any { it.type == "m.room.encryption" }
if (roomIsEncrypted && encryptedRooms.add(roomId)) {
val saved = prefs.getStringSet("encrypted_room_ids", emptySet())!!.toMutableSet()
saved.add(roomId)
prefs.edit().putStringSet("encrypted_room_ids", saved).apply()
}
roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
rooms[roomId] = Room(
id = roomId,
@ -919,6 +1163,7 @@ class MatrixSession private constructor(private val appContext: Context) {
}
companion object {
private const val TAG = "MatrixCrypto"
@Volatile private var instance: MatrixSession? = null
fun getInstance(context: Context): MatrixSession =

View file

@ -20,6 +20,7 @@ data class SasEmoji(val emoji: String, val name: String)
sealed class VerificationState {
object Idle : VerificationState()
data class Requesting(val toUser: String, val toDevice: String, val txnId: String, val deviceLabel: String) : VerificationState()
data class Requested(val fromUser: String, val fromDevice: String, val txnId: String) : VerificationState()
data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState()
object Done : VerificationState()
@ -62,6 +63,9 @@ class VerificationService(private val scope: CoroutineScope) {
private var selectedMacMethod: String? = null
private var hasConfirmedLocally: Boolean = false
private var hasVerifiedTheirMac: Boolean = false
private var initiatedLocally: Boolean = false
private var sentStartCanonicalJson: String? = null
private var expectedCommitment: String? = null
fun processToDeviceEvent(event: JsonObject) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return
@ -71,7 +75,9 @@ class VerificationService(private val scope: CoroutineScope) {
when (type) {
"m.key.verification.request" -> handleRequest(sender, txnId, content)
"m.key.verification.ready" -> handleReady(sender, txnId, content)
"m.key.verification.start" -> handleStart(sender, txnId, content)
"m.key.verification.accept" -> handleAccept(txnId, content)
"m.key.verification.key" -> handleKey(txnId, content)
"m.key.verification.mac" -> handleMac(txnId, content)
"m.key.verification.done" -> handleDone(txnId)
@ -79,9 +85,32 @@ class VerificationService(private val scope: CoroutineScope) {
}
}
fun requestVerification(toUser: String, toDevice: String, deviceLabel: String): Boolean {
if (_state.value !is VerificationState.Idle && _state.value !is VerificationState.Cancelled) return false
cleanup()
val txnId = "txn_${System.currentTimeMillis()}"
currentTxnId = txnId
theirUserId = toUser
theirDeviceId = toDevice
initiatedLocally = true
_state.value = VerificationState.Requesting(toUser, toDevice, txnId, deviceLabel)
val content = buildJsonObject {
put("from_device", deviceId)
put("transaction_id", txnId)
put("methods", buildJsonArray { add(JsonPrimitive("m.sas.v1")) })
put("timestamp", System.currentTimeMillis())
}
scope.launch {
onSend(toUser, toDevice, "m.key.verification.request", "${txnId}_request", content)
}
return true
}
private fun handleRequest(sender: String, txnId: String, content: JsonObject) {
if (_state.value !is VerificationState.Idle) return
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
initiatedLocally = false
theirUserId = sender
theirDeviceId = fromDevice
currentTxnId = txnId
@ -102,6 +131,43 @@ class VerificationService(private val scope: CoroutineScope) {
}
}
private fun handleReady(sender: String, txnId: String, content: JsonObject) {
val state = _state.value as? VerificationState.Requesting ?: return
if (!initiatedLocally || txnId != currentTxnId || sender != state.toUser) return
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
theirDeviceId = fromDevice
theirUserId = sender
val sas = OlmSAS()
olmSas = sas
ourPublicKey = sas.publicKey
val startContent = buildJsonObject {
put("from_device", deviceId)
put("transaction_id", txnId)
put("method", "m.sas.v1")
put("key_agreement_protocols", buildJsonArray {
add(JsonPrimitive("curve25519-hkdf-sha256"))
})
put("hashes", buildJsonArray {
add(JsonPrimitive("sha256"))
})
put("message_authentication_codes", buildJsonArray {
add(JsonPrimitive("hkdf-hmac-sha256.v2"))
add(JsonPrimitive("hkdf-hmac-sha256"))
})
put("short_authentication_string", buildJsonArray {
add(JsonPrimitive("decimal"))
add(JsonPrimitive("emoji"))
})
}
sentStartCanonicalJson = startContent.toCanonicalJson()
scope.launch {
onSend(sender, fromDevice, "m.key.verification.start", "${txnId}_start", startContent)
}
}
private fun handleStart(sender: String, txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return
val method = (content["method"] as? JsonPrimitive)?.content
@ -160,17 +226,65 @@ class VerificationService(private val scope: CoroutineScope) {
}
}
private fun handleAccept(txnId: String, content: JsonObject) {
if (!initiatedLocally || txnId != currentTxnId) return
val method = (content["method"] as? JsonPrimitive)?.content
if (method != "m.sas.v1") {
sendCancel(txnId, "m.unknown_method", "Unsupported method")
return
}
selectedMacMethod = (content["message_authentication_code"] as? JsonPrimitive)?.content
?: run {
sendCancel(txnId, "m.unknown_method", "Missing MAC method")
return
}
expectedCommitment = (content["commitment"] as? JsonPrimitive)?.content
val toUser = theirUserId ?: return
val toDevice = theirDeviceId ?: return
val key = ourPublicKey ?: return
val keyContent = buildJsonObject {
put("transaction_id", txnId)
put("key", key)
}
scope.launch {
onSend(toUser, toDevice, "m.key.verification.key", "${txnId}_key", keyContent)
}
}
private fun handleKey(txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return
val sas = olmSas ?: return
val theirKey = (content["key"] as? JsonPrimitive)?.content ?: return
if (initiatedLocally) {
val commitment = expectedCommitment
val startCanonical = sentStartCanonicalJson
if (commitment != null && startCanonical != null) {
val sha256 = MessageDigest.getInstance("SHA-256")
val computed = Base64.encodeToString(
sha256.digest((theirKey + startCanonical).toByteArray(Charsets.UTF_8)),
Base64.NO_WRAP or Base64.NO_PADDING
)
if (computed != commitment) {
sendCancel(txnId, "m.mismatched_commitment", "Commitment mismatch")
return
}
}
}
sas.setTheirPublicKey(theirKey)
val info = "MATRIX_KEY_VERIFICATION_SAS" +
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
"|${userId}|${deviceId}|${ourPublicKey}" +
"|${txnId}"
val info = if (initiatedLocally) {
"MATRIX_KEY_VERIFICATION_SAS" +
"|${userId}|${deviceId}|${ourPublicKey}" +
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
"|${txnId}"
} else {
"MATRIX_KEY_VERIFICATION_SAS" +
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
"|${userId}|${deviceId}|${ourPublicKey}" +
"|${txnId}"
}
val sasBytes = sas.generateShortCode(info, 6)
val emojis = sasBytes.toSasEmojis()
@ -337,6 +451,9 @@ class VerificationService(private val scope: CoroutineScope) {
selectedMacMethod = null
hasConfirmedLocally = false
hasVerifiedTheirMac = false
initiatedLocally = false
sentStartCanonicalJson = null
expectedCommitment = null
}
}

View file

@ -56,6 +56,7 @@ fun RoomListScreen(
val session = MatrixSession.getInstance(context)
val syncState by session.syncState.collectAsState()
val authState by session.authState.collectAsState()
val deviceSyncState by session.deviceSyncState.collectAsState()
val verificationState by session.verificationService.state.collectAsState()
val scope = rememberCoroutineScope()
@ -132,8 +133,64 @@ fun RoomListScreen(
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
)
if (deviceSyncState.isLoaded &&
!deviceSyncState.isCurrentDeviceVerified &&
deviceSyncState.availableTargets.isNotEmpty() &&
verificationState is VerificationState.Idle
) {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = " [!] device not synced yet",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen)
)
Text(
text = " accept it on ${deviceSyncState.availableTargets.first().displayName}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixGreenDim)
)
}
Button(
onClick = {
scope.launch {
session.startSelfVerification()
.onSuccess {
actionError = null
onOpenVerification()
}
.onFailure { actionError = it.message ?: "failed to start verification" }
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MatrixGreen,
contentColor = MatrixBackground
)
) {
Text("[sync]", fontFamily = TerminalFont, fontSize = 12.sp)
}
}
}
// Verification banner
when (val vs = verificationState) {
is VerificationState.Requesting -> {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenVerification() },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = " [!] verification requested — open ${vs.deviceLabel}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen)
)
}
}
is VerificationState.Requested -> {
Spacer(Modifier.height(8.dp))
Row(

View file

@ -64,6 +64,29 @@ fun VerificationScreen(onDone: () -> Unit) {
)
}
is VerificationState.Requesting -> {
Text(
text = " verification request sent to",
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(4.dp))
Text(
text = " ${s.deviceLabel}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
)
Text(
text = " accept it there to continue",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
)
Spacer(Modifier.height(24.dp))
OutlinedButton(
onClick = { session.verificationService.cancel(); onDone() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MatrixGreenDim)
) {
Text("[cancel]", fontFamily = TerminalFont)
}
}
is VerificationState.Requested -> {
Text(
text = " verification request from",