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:
parent
b30e83898c
commit
3747172488
8 changed files with 781 additions and 58 deletions
|
|
@ -2,6 +2,7 @@ package com.ltadeu6.matrix.matrix
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
@ -41,8 +42,13 @@ class CryptoService(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
OlmManager() // loads the native libolm .so
|
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()
|
restoreInboundSessions()
|
||||||
|
restoreOutboundSessions()
|
||||||
|
restoreOlmSessions()
|
||||||
|
Log.i(TAG, "CryptoService init — inbound=${inboundSessions.size} outbound=${outboundSessions.size} olm=${olmSessions.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val curve25519Key: String
|
val curve25519Key: String
|
||||||
|
|
@ -101,23 +107,53 @@ class CryptoService(
|
||||||
|
|
||||||
/** Call after a successful `/keys/upload` to mark one-time keys as consumed. */
|
/** Call after a successful `/keys/upload` to mark one-time keys as consumed. */
|
||||||
fun onKeysUploaded() {
|
fun onKeysUploaded() {
|
||||||
|
val justPublished = olmAccount.oneTimeKeys()["curve25519"]?.size ?: 0
|
||||||
olmAccount.markOneTimeKeysAsPublished()
|
olmAccount.markOneTimeKeysAsPublished()
|
||||||
|
publishedOtkCount += justPublished
|
||||||
persistAccount(olmAccount)
|
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)
|
// 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) {
|
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 type = (event["type"] as? JsonPrimitive)?.content ?: return
|
||||||
val content = event["content"]?.jsonObject ?: return
|
val content = event["content"]?.jsonObject ?: return
|
||||||
|
Log.d(TAG, "to-device type=$type")
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
"m.room.encrypted" -> {
|
"m.room.encrypted" -> {
|
||||||
|
|
@ -126,12 +162,18 @@ class CryptoService(
|
||||||
val innerType = (inner["type"] as? JsonPrimitive)?.content
|
val innerType = (inner["type"] as? JsonPrimitive)?.content
|
||||||
val innerContent = inner["content"]?.jsonObject ?: return
|
val innerContent = inner["content"]?.jsonObject ?: return
|
||||||
when (innerType) {
|
when (innerType) {
|
||||||
"m.room_key" -> handleRoomKey(innerContent)
|
"m.room_key" -> handleRoomKey(innerContent)?.let {
|
||||||
"m.forwarded_room_key" -> handleForwardedRoomKey(innerContent)
|
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.room_key" -> handleRoomKey(content)?.let { newRooms.add(it) }
|
||||||
"m.forwarded_room_key" -> handleForwardedRoomKey(content)
|
"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
|
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 senderKey = (content["sender_key"] as? JsonPrimitive)?.content ?: return null
|
||||||
val ciphertext = content["ciphertext"]?.jsonObject ?: 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 type = (ourMsg["type"] as? JsonPrimitive)?.content?.toIntOrNull() ?: return null
|
||||||
val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null
|
val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null
|
||||||
|
|
||||||
// Try existing sessions first (type 1 — regular message)
|
// Try existing sessions first (type 1 — regular message)
|
||||||
if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) {
|
if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) {
|
||||||
olmSessions[senderKey]?.forEach { session ->
|
olmSessions[senderKey]?.forEach { session ->
|
||||||
return try {
|
try {
|
||||||
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
|
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
|
||||||
session.decryptMessage(olmMsg)
|
return session.decryptMessage(olmMsg).also { persistOlmSessions(senderKey) }
|
||||||
} catch (_: Exception) { null }
|
} 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
|
// Pre-key message (type 0) — create a new inbound Olm session
|
||||||
|
|
@ -159,48 +207,77 @@ class CryptoService(
|
||||||
val session = OlmSession()
|
val session = OlmSession()
|
||||||
session.initInboundSessionFrom(olmAccount, senderKey, body)
|
session.initInboundSessionFrom(olmAccount, senderKey, body)
|
||||||
olmAccount.removeOneTimeKeys(session)
|
olmAccount.removeOneTimeKeys(session)
|
||||||
|
if (publishedOtkCount > 0) publishedOtkCount--
|
||||||
persistAccount(olmAccount)
|
persistAccount(olmAccount)
|
||||||
olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session)
|
olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session)
|
||||||
|
persistOlmSessions(senderKey)
|
||||||
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
|
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
|
||||||
session.decryptMessage(olmMsg)
|
session.decryptMessage(olmMsg).also {
|
||||||
} catch (_: Exception) { null }
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRoomKey(content: JsonObject) {
|
/** Returns the room ID if a new session was stored, null otherwise. */
|
||||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
|
private fun handleRoomKey(content: JsonObject): String? {
|
||||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
|
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
|
||||||
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
|
val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null
|
||||||
if (sessionId in inboundSessions) return
|
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
||||||
try {
|
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null
|
||||||
|
if (sessionId in inboundSessions) return null
|
||||||
|
return try {
|
||||||
val session = OlmInboundGroupSession(sessionKey)
|
val session = OlmInboundGroupSession(sessionKey)
|
||||||
inboundSessions[sessionId] = session
|
inboundSessions[sessionId] = session
|
||||||
persistInboundSession(sessionId, session)
|
persistInboundSession(sessionId, session)
|
||||||
} catch (_: Exception) {}
|
roomId
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "handleRoomKey — OlmInboundGroupSession create failed id=${sessionId.take(8)}: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Returns the room ID if a new session was stored, null otherwise. */
|
||||||
* Handles `m.forwarded_room_key` — a key shared by another of our own devices in response
|
private fun handleForwardedRoomKey(content: JsonObject): String? {
|
||||||
* to an `m.room_key_request`. Uses [OlmInboundGroupSession.importSession] because the
|
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
|
||||||
* forwarded key is in exported (message-index-aware) format.
|
val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null
|
||||||
*/
|
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
||||||
private fun handleForwardedRoomKey(content: JsonObject) {
|
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null
|
||||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
|
if (sessionId in inboundSessions) return null
|
||||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
|
return try {
|
||||||
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
|
|
||||||
if (sessionId in inboundSessions) return
|
|
||||||
try {
|
|
||||||
val session = OlmInboundGroupSession.importSession(sessionKey)
|
val session = OlmInboundGroupSession.importSession(sessionKey)
|
||||||
inboundSessions[sessionId] = session
|
inboundSessions[sessionId] = session
|
||||||
persistInboundSession(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
|
// 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? {
|
fun decryptRoomEventPayload(content: JsonObject): JsonObject? {
|
||||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
|
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
|
||||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
||||||
|
|
@ -209,7 +286,10 @@ class CryptoService(
|
||||||
return try {
|
return try {
|
||||||
val result = session.decryptMessage(ciphertext)
|
val result = session.decryptMessage(ciphertext)
|
||||||
json.parseToJsonElement(result.mDecryptedMessage).jsonObject
|
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? =
|
fun decryptRoomEvent(content: JsonObject): String? =
|
||||||
|
|
@ -227,6 +307,8 @@ class CryptoService(
|
||||||
put("session_id", session.sessionIdentifier())
|
put("session_id", session.sessionIdentifier())
|
||||||
put("ciphertext", session.encryptMessage(plaintext))
|
put("ciphertext", session.encryptMessage(plaintext))
|
||||||
put("device_id", deviceId)
|
put("device_id", deviceId)
|
||||||
|
}.also {
|
||||||
|
persistOutboundSession(roomId, session)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +332,10 @@ class CryptoService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Pair(sessionId, sessionKey)
|
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) {
|
private fun persistAccount(account: OlmAccount) {
|
||||||
val pickled = account.pickle(PICKLE_KEY, StringBuffer())
|
val pickled = account.pickle(PICKLE_KEY, StringBuffer()) as ByteArray
|
||||||
prefs.edit().putString(KEY_ACCOUNT, Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
|
prefs.edit().putString(KEY_ACCOUNT, String(pickled, Charsets.ISO_8859_1)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreAccount(): OlmAccount? {
|
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 {
|
return try {
|
||||||
OlmAccount().also { it.unpickle(PICKLE_KEY, Base64.decode(stored, Base64.NO_WRAP)) }
|
OlmAccount().also { it.unpickle(stored.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY) }
|
||||||
} catch (_: Exception) { null }
|
.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) {
|
private fun persistInboundSession(sessionId: String, session: OlmInboundGroupSession) {
|
||||||
|
|
@ -314,15 +406,83 @@ class CryptoService(
|
||||||
|
|
||||||
private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) {
|
private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) {
|
||||||
try {
|
try {
|
||||||
val pickled = session.pickle(PICKLE_KEY, StringBuffer())
|
val pickled = session.pickle(PICKLE_KEY, StringBuffer()) as ByteArray
|
||||||
prefs.edit().putString("outbound_$roomId", Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
|
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) {}
|
} 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 {
|
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_ACCOUNT = "olm_account"
|
||||||
private const val KEY_INBOUND_IDS = "inbound_ids"
|
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()
|
private val PICKLE_KEY = "matrix_olm_pickle".toByteArray()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,3 +183,18 @@ data class Message(
|
||||||
val isEdited: Boolean = false,
|
val isEdited: Boolean = false,
|
||||||
val reactions: Map<String, Int> = emptyMap()
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import android.util.Log
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
@ -29,6 +30,7 @@ import retrofit2.Retrofit
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
|
||||||
sealed class AuthState {
|
sealed class AuthState {
|
||||||
object LoggedOut : AuthState()
|
object LoggedOut : AuthState()
|
||||||
object Loading : AuthState()
|
object Loading : AuthState()
|
||||||
|
|
@ -57,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
private val _pendingSsoToken = MutableStateFlow<String?>(null)
|
private val _pendingSsoToken = MutableStateFlow<String?>(null)
|
||||||
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
|
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
|
||||||
|
|
||||||
|
private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
|
||||||
|
val deviceSyncState: StateFlow<DeviceSyncState> = _deviceSyncState.asStateFlow()
|
||||||
|
|
||||||
private var accessToken: String? = null
|
private var accessToken: String? = null
|
||||||
private var currentUserId: String? = null
|
private var currentUserId: String? = null
|
||||||
private var syncJob: Job? = null
|
private var syncJob: Job? = null
|
||||||
|
|
@ -107,6 +112,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
verificationService.onVerified = {
|
verificationService.onVerified = {
|
||||||
requestedSessions.clear()
|
requestedSessions.clear()
|
||||||
sharedSessionRooms.clear()
|
sharedSessionRooms.clear()
|
||||||
|
scope.launch { refreshDeviceSyncState() }
|
||||||
startSync()
|
startSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +120,8 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
val savedHomeserver = prefs.getString("homeserver_url", null)
|
val savedHomeserver = prefs.getString("homeserver_url", null)
|
||||||
val savedUserId = prefs.getString("user_id", null)
|
val savedUserId = prefs.getString("user_id", null)
|
||||||
val savedDeviceId = prefs.getString("device_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) {
|
if (savedToken != null && savedHomeserver != null && savedUserId != null) {
|
||||||
accessToken = savedToken
|
accessToken = savedToken
|
||||||
currentUserId = savedUserId
|
currentUserId = savedUserId
|
||||||
|
|
@ -125,7 +133,10 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
verificationService.userId = savedUserId
|
verificationService.userId = savedUserId
|
||||||
verificationService.deviceId = savedDeviceId
|
verificationService.deviceId = savedDeviceId
|
||||||
verificationService.cryptoService = crypto
|
verificationService.cryptoService = crypto
|
||||||
scope.launch { uploadKeys() }
|
scope.launch {
|
||||||
|
uploadOtks()
|
||||||
|
refreshDeviceSyncState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startSync()
|
startSync()
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +197,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
verificationService.userId = ""
|
verificationService.userId = ""
|
||||||
verificationService.deviceId = ""
|
verificationService.deviceId = ""
|
||||||
verificationService.cryptoService = null
|
verificationService.cryptoService = null
|
||||||
|
_deviceSyncState.value = DeviceSyncState()
|
||||||
_authState.value = AuthState.LoggedOut
|
_authState.value = AuthState.LoggedOut
|
||||||
_syncState.value = SyncState()
|
_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
|
// Login flow
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -320,8 +354,11 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
verificationService.userId = response.userId
|
verificationService.userId = response.userId
|
||||||
verificationService.deviceId = response.deviceId
|
verificationService.deviceId = response.deviceId
|
||||||
verificationService.cryptoService = crypto
|
verificationService.cryptoService = crypto
|
||||||
scope.launch { uploadKeys() }
|
scope.launch {
|
||||||
startSync()
|
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(
|
private suspend fun sendTimelineEvent(
|
||||||
roomId: String,
|
roomId: String,
|
||||||
eventType: String,
|
eventType: String,
|
||||||
|
|
@ -393,6 +441,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
val key = "${ms.roomId}:${ms.sessionId}"
|
val key = "${ms.roomId}:${ms.sessionId}"
|
||||||
if (key in requestedSessions) continue
|
if (key in requestedSessions) continue
|
||||||
requestedSessions.add(key)
|
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 requestId = "${System.currentTimeMillis()}_${ms.sessionId.take(8)}"
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
|
|
@ -431,6 +480,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
val auth = "Bearer $token"
|
val auth = "Bearer $token"
|
||||||
|
|
||||||
val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return
|
val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return
|
||||||
|
Log.i(TAG, "shareGroupSession — room=${roomId.take(20)} session=${sessionId.take(8)}")
|
||||||
|
|
||||||
// 1. Joined members
|
// 1. Joined members
|
||||||
val members = runCatching { matrixApi.getJoinedMembers(auth, roomId) }
|
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) }
|
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
|
// Sync loop
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -545,26 +687,54 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
since = response.nextBatch
|
since = response.nextBatch
|
||||||
|
|
||||||
// Feed to-device events so incoming room keys are available before decryption
|
// Feed to-device events so incoming room keys are available before decryption
|
||||||
|
var newKeyRooms: Set<String> = emptySet()
|
||||||
response.toDevice?.events?.let { events ->
|
response.toDevice?.events?.let { events ->
|
||||||
cryptoService?.processToDeviceEvents(events)
|
newKeyRooms = cryptoService?.processToDeviceEvents(events) ?: emptySet()
|
||||||
events.forEach { event ->
|
events.forEach { event ->
|
||||||
val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach
|
val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach
|
||||||
if (type.startsWith("m.key.verification.")) {
|
when {
|
||||||
verificationService.processToDeviceEvent(event)
|
type.startsWith("m.key.verification.") ->
|
||||||
|
verificationService.processToDeviceEvent(event)
|
||||||
|
type == "m.room_key_request" ->
|
||||||
|
scope.launch { handleRoomKeyRequest(event, "Bearer $token", matrixApi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val missing = processSyncResponse(response)
|
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
|
// Request keys from our other devices for any event we couldn't decrypt
|
||||||
if (missing.isNotEmpty()) {
|
if (missing.isNotEmpty()) {
|
||||||
sendKeyRequests(missing, "Bearer $token", matrixApi)
|
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) {
|
if (isFirstSync) {
|
||||||
isFirstSync = false
|
isFirstSync = false
|
||||||
refreshDirectRoomTargets(token, matrixApi)
|
refreshDirectRoomTargets(token, matrixApi)
|
||||||
|
refreshDeviceSyncState(token, matrixApi)
|
||||||
fetchMissingRoomNames(token, matrixApi)
|
fetchMissingRoomNames(token, matrixApi)
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} 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 {
|
private fun prefersMemberName(roomId: String): Boolean {
|
||||||
return directRoomTargets.containsKey(roomId)
|
return directRoomTargets.containsKey(roomId)
|
||||||
}
|
}
|
||||||
|
|
@ -658,9 +897,14 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track encryption state
|
// Track encryption state
|
||||||
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
|
val stateEvents = roomData.state?.events ?: emptyList()
|
||||||
.any { it.type == "m.room.encryption" }
|
val roomIsEncrypted = stateEvents.any { it.type == "m.room.encryption" }
|
||||||
if (roomIsEncrypted) encryptedRooms.add(roomId)
|
|| (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]
|
roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
|
||||||
rooms[roomId] = Room(
|
rooms[roomId] = Room(
|
||||||
id = roomId,
|
id = roomId,
|
||||||
|
|
@ -919,6 +1163,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "MatrixCrypto"
|
||||||
@Volatile private var instance: MatrixSession? = null
|
@Volatile private var instance: MatrixSession? = null
|
||||||
|
|
||||||
fun getInstance(context: Context): MatrixSession =
|
fun getInstance(context: Context): MatrixSession =
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ data class SasEmoji(val emoji: String, val name: String)
|
||||||
|
|
||||||
sealed class VerificationState {
|
sealed class VerificationState {
|
||||||
object Idle : 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 Requested(val fromUser: String, val fromDevice: String, val txnId: String) : VerificationState()
|
||||||
data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState()
|
data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState()
|
||||||
object Done : VerificationState()
|
object Done : VerificationState()
|
||||||
|
|
@ -62,6 +63,9 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
private var selectedMacMethod: String? = null
|
private var selectedMacMethod: String? = null
|
||||||
private var hasConfirmedLocally: Boolean = false
|
private var hasConfirmedLocally: Boolean = false
|
||||||
private var hasVerifiedTheirMac: 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) {
|
fun processToDeviceEvent(event: JsonObject) {
|
||||||
val type = (event["type"] as? JsonPrimitive)?.content ?: return
|
val type = (event["type"] as? JsonPrimitive)?.content ?: return
|
||||||
|
|
@ -71,7 +75,9 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
"m.key.verification.request" -> handleRequest(sender, txnId, content)
|
"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.start" -> handleStart(sender, txnId, content)
|
||||||
|
"m.key.verification.accept" -> handleAccept(txnId, content)
|
||||||
"m.key.verification.key" -> handleKey(txnId, content)
|
"m.key.verification.key" -> handleKey(txnId, content)
|
||||||
"m.key.verification.mac" -> handleMac(txnId, content)
|
"m.key.verification.mac" -> handleMac(txnId, content)
|
||||||
"m.key.verification.done" -> handleDone(txnId)
|
"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) {
|
private fun handleRequest(sender: String, txnId: String, content: JsonObject) {
|
||||||
if (_state.value !is VerificationState.Idle) return
|
if (_state.value !is VerificationState.Idle) return
|
||||||
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
|
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
|
||||||
|
initiatedLocally = false
|
||||||
theirUserId = sender
|
theirUserId = sender
|
||||||
theirDeviceId = fromDevice
|
theirDeviceId = fromDevice
|
||||||
currentTxnId = txnId
|
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) {
|
private fun handleStart(sender: String, txnId: String, content: JsonObject) {
|
||||||
if (txnId != currentTxnId) return
|
if (txnId != currentTxnId) return
|
||||||
val method = (content["method"] as? JsonPrimitive)?.content
|
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) {
|
private fun handleKey(txnId: String, content: JsonObject) {
|
||||||
if (txnId != currentTxnId) return
|
if (txnId != currentTxnId) return
|
||||||
val sas = olmSas ?: return
|
val sas = olmSas ?: return
|
||||||
val theirKey = (content["key"] as? JsonPrimitive)?.content ?: 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)
|
sas.setTheirPublicKey(theirKey)
|
||||||
|
|
||||||
val info = "MATRIX_KEY_VERIFICATION_SAS" +
|
val info = if (initiatedLocally) {
|
||||||
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
|
"MATRIX_KEY_VERIFICATION_SAS" +
|
||||||
"|${userId}|${deviceId}|${ourPublicKey}" +
|
"|${userId}|${deviceId}|${ourPublicKey}" +
|
||||||
"|${txnId}"
|
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
|
||||||
|
"|${txnId}"
|
||||||
|
} else {
|
||||||
|
"MATRIX_KEY_VERIFICATION_SAS" +
|
||||||
|
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
|
||||||
|
"|${userId}|${deviceId}|${ourPublicKey}" +
|
||||||
|
"|${txnId}"
|
||||||
|
}
|
||||||
|
|
||||||
val sasBytes = sas.generateShortCode(info, 6)
|
val sasBytes = sas.generateShortCode(info, 6)
|
||||||
val emojis = sasBytes.toSasEmojis()
|
val emojis = sasBytes.toSasEmojis()
|
||||||
|
|
@ -337,6 +451,9 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
selectedMacMethod = null
|
selectedMacMethod = null
|
||||||
hasConfirmedLocally = false
|
hasConfirmedLocally = false
|
||||||
hasVerifiedTheirMac = false
|
hasVerifiedTheirMac = false
|
||||||
|
initiatedLocally = false
|
||||||
|
sentStartCanonicalJson = null
|
||||||
|
expectedCommitment = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ fun RoomListScreen(
|
||||||
val session = MatrixSession.getInstance(context)
|
val session = MatrixSession.getInstance(context)
|
||||||
val syncState by session.syncState.collectAsState()
|
val syncState by session.syncState.collectAsState()
|
||||||
val authState by session.authState.collectAsState()
|
val authState by session.authState.collectAsState()
|
||||||
|
val deviceSyncState by session.deviceSyncState.collectAsState()
|
||||||
val verificationState by session.verificationService.state.collectAsState()
|
val verificationState by session.verificationService.state.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -132,8 +133,64 @@ fun RoomListScreen(
|
||||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
|
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
|
// Verification banner
|
||||||
when (val vs = verificationState) {
|
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 -> {
|
is VerificationState.Requested -> {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
is VerificationState.Requested -> {
|
||||||
Text(
|
Text(
|
||||||
text = " verification request from",
|
text = " verification request from",
|
||||||
|
|
|
||||||
25
flake.nix
25
flake.nix
|
|
@ -13,15 +13,18 @@
|
||||||
inherit system;
|
inherit system;
|
||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
config.android_sdk.accept_license = true;
|
config.android_sdk.accept_license = true;
|
||||||
|
config.permittedInsecurePackages = [ "olm-3.2.16" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
androidComposition = pkgs.androidenv.composeAndroidPackages {
|
androidComposition = pkgs.androidenv.composeAndroidPackages {
|
||||||
platformVersions = [ "31" "35" ];
|
platformVersions = [ "31" "35" ];
|
||||||
buildToolsVersions = [ "30.0.2" "34.0.0" "35.0.0" ];
|
buildToolsVersions = [ "30.0.2" "34.0.0" "35.0.0" ];
|
||||||
includeEmulator = false;
|
includeEmulator = true;
|
||||||
includeNDK = true;
|
includeNDK = true;
|
||||||
includeSources = false;
|
includeSources = false;
|
||||||
includeSystemImages = false;
|
includeSystemImages = true;
|
||||||
|
systemImageTypes = [ "google_apis" ];
|
||||||
|
abiVersions = [ "x86_64" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
androidSdk = androidComposition.androidsdk;
|
androidSdk = androidComposition.androidsdk;
|
||||||
|
|
@ -33,6 +36,7 @@
|
||||||
pkgs.gradle
|
pkgs.gradle
|
||||||
pkgs.jdk17
|
pkgs.jdk17
|
||||||
androidSdk
|
androidSdk
|
||||||
|
pkgs.matrix-commander
|
||||||
];
|
];
|
||||||
|
|
||||||
ANDROID_HOME = androidSdkRoot;
|
ANDROID_HOME = androidSdkRoot;
|
||||||
|
|
@ -50,10 +54,27 @@
|
||||||
sdk.dir=$ANDROID_SDK_ROOT
|
sdk.dir=$ANDROID_SDK_ROOT
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Launches the Pixel_3a_API_35 AVD. System images come from the nix SDK.
|
||||||
|
# The emulator window is shown by default.
|
||||||
|
start-avd() {
|
||||||
|
local avd_name="Pixel_3a_API_35"
|
||||||
|
local avd_dir="$HOME/.android/avd/$avd_name.avd"
|
||||||
|
if [ ! -d "$avd_dir" ]; then
|
||||||
|
echo "Creating AVD $avd_name..."
|
||||||
|
echo "no" | avdmanager create avd \
|
||||||
|
-n "$avd_name" \
|
||||||
|
-k "system-images;android-35;google_apis;x86_64" \
|
||||||
|
-d "pixel_3a" --force
|
||||||
|
fi
|
||||||
|
emulator -avd "$avd_name" -no-snapshot-load "$@" &
|
||||||
|
echo "Emulator starting (PID $!) — run 'adb wait-for-device' to wait for boot"
|
||||||
|
}
|
||||||
|
|
||||||
echo "matrix-android dev shell"
|
echo "matrix-android dev shell"
|
||||||
echo " JAVA_HOME=$JAVA_HOME"
|
echo " JAVA_HOME=$JAVA_HOME"
|
||||||
echo " ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
echo " ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
echo " Build with: gradle assembleDebug"
|
echo " Build with: gradle assembleDebug"
|
||||||
|
echo " Emulator: start-avd"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
85
state.md
Normal file
85
state.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# State - matrix-android
|
||||||
|
|
||||||
|
Current snapshot of the project as of 2026-05-17.
|
||||||
|
|
||||||
|
## What is working
|
||||||
|
|
||||||
|
- NixOS dev shell is available through `flake.nix`.
|
||||||
|
- Android builds work from the CLI with `nix develop -c gradle --no-daemon assembleDebug`.
|
||||||
|
- The app installs and launches over ADB on the test device without wiping user data.
|
||||||
|
- Login, room list, chat, send, and receive flows are in place.
|
||||||
|
- The UI uses the terminal theme and respects the status bar on the main screens.
|
||||||
|
- Direct message naming prefers the other member's display name.
|
||||||
|
- Local 16 KB-compatible `libolm` packaging is integrated.
|
||||||
|
- SAS device verification is implemented and can be initiated from the Android side.
|
||||||
|
- The app exposes a "sync this device" CTA on the home screen when the current device is unverified.
|
||||||
|
- **E2EE encryption survives app restarts** — messages can be sent and received encrypted after closing and reopening the app.
|
||||||
|
|
||||||
|
## Encryption state
|
||||||
|
|
||||||
|
Encryption works across restarts. All three flows verified:
|
||||||
|
- Messages from matrix-commander (CLI) → decrypted on Android after restart ✓
|
||||||
|
- Messages from Android → appear encrypted in Element Web ✓
|
||||||
|
- Messages from Element Web → decrypted on Android ✓ (after Element Web rotates its Megolm session with fresh OTKs)
|
||||||
|
|
||||||
|
### Root causes fixed
|
||||||
|
|
||||||
|
**1. OlmAccount pickle/unpickle argument order**
|
||||||
|
`unpickle(key, data)` was wrong — the Java API is `deserialize(data, key)`. Passing the key bytes as the first argument caused `INVALID_BASE64` since the key contains underscores (invalid base64). Fixed to `unpickle(data, key)` for all three Olm object types (`OlmAccount`, `OlmOutboundGroupSession`, `OlmSession`).
|
||||||
|
|
||||||
|
**2. Pickle byte encoding**
|
||||||
|
`pickle()` returns `ByteArray`, not `StringBuffer`. The original cast `as StringBuffer` crashed or silently failed. Fixed to `String(pickled as ByteArray, Charsets.ISO_8859_1)` for persist and `stored.toByteArray(Charsets.ISO_8859_1)` for restore.
|
||||||
|
|
||||||
|
**3. OTK eviction on restore**
|
||||||
|
`uploadOtks()` on session restore called `generateOneTimeKeys(50)` unconditionally. Since the Olm OTK ring buffer has a fixed max (50), this evicted all existing OTKs. Senders who had claimed old OTKs got `BAD_MESSAGE_KEY_ID`. Fixed by tracking `publishedOtkCount` in SharedPreferences and calling `generateOneTimeKeys(needed)` where `needed = MAX - publishedOtkCount`. This only fills the gap without evicting.
|
||||||
|
|
||||||
|
**4. Login OTK upload race condition**
|
||||||
|
`startSync()` was called immediately in `onLoginSuccess`, before `uploadKeys()` completed. The first sync could report `otkCount=0` and trigger `uploadOtks()` concurrently with `uploadKeys()` — both calling `generateOneTimeKeys` on the non-thread-safe `OlmAccount`. Fixed by moving `startSync()` inside the upload coroutine for fresh login (so sync only starts after keys are uploaded).
|
||||||
|
|
||||||
|
**5. `m.room.encryption` only checked in state events**
|
||||||
|
The server returns `m.room.encryption` in the **timeline** (not in `state`) for this room, because the encryption was enabled early in the room's history and the timeline window covers it. The check was `roomData.state?.events.any { it.type == "m.room.encryption" }` — always false. Fixed to also check timeline events.
|
||||||
|
|
||||||
|
**6. `encryptedRooms` not persisted**
|
||||||
|
`encryptedRooms` was in-memory only, reset on every restart. After restart, the room appeared unencrypted and messages were sent as plaintext until the first sync populated it. Fixed by persisting to `matrix_session` SharedPreferences under key `encrypted_room_ids`, loaded on init before sync starts.
|
||||||
|
|
||||||
|
### Remaining known issue
|
||||||
|
|
||||||
|
Element Web (`e6CqkKos`) keeps sending `BAD_MESSAGE_KEY_ID` PRE_KEY messages. This is because it has cached OTKs from earlier broken sessions (before the fix) that were evicted from the account. Element Web will recover automatically when it rotates its outbound Megolm session (after ~100 messages or 7 days), at which point it re-claims fresh OTKs. New sessions (like matrix-commander) work immediately.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
- `MatrixSession` (singleton) owns all runtime state. Always via `MatrixSession.getInstance(context)`.
|
||||||
|
- `CryptoService` holds and persists: `OlmAccount`, inbound Megolm sessions, outbound Megolm sessions, Olm 1:1 sessions. All in `olm_crypto` SharedPreferences.
|
||||||
|
- `uploadKeys()` — uploads device keys + OTKs. Called only on **fresh login**. Causes `device_lists.changed`.
|
||||||
|
- `uploadOtks()` — uploads only OTKs (no `device_keys`). Called on **session restore** and when sync reports OTK count ≤ 9. Does NOT cause `device_lists.changed`. Only generates `MAX - publishedOtkCount` new keys to avoid eviction.
|
||||||
|
- `encryptedRooms` — persisted in `matrix_session` prefs. Populated from both `state` and `timeline` events in sync.
|
||||||
|
- `logout()` wipes both `matrix_session` and `olm_crypto` prefs.
|
||||||
|
|
||||||
|
## Test infrastructure
|
||||||
|
|
||||||
|
- Android emulator: Pixel_3a_API_35 (emulator-5554), launched via `start-avd` from the nix dev shell.
|
||||||
|
- matrix-commander account: `@claudetestacc:matrix.org` (credentials in `.mc/credentials.json`, store in `.mc/store/`).
|
||||||
|
- Test room: `!rNfgFvyFMQEWIQnYVV:matrix.org` (shared between `@claudetestacc` and `@ltadeu616`).
|
||||||
|
|
||||||
|
## Files to watch
|
||||||
|
|
||||||
|
- `app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt`
|
||||||
|
- `app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt`
|
||||||
|
- `app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt`
|
||||||
|
- `app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt`
|
||||||
|
- `app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt`
|
||||||
|
|
||||||
|
## Build / deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop -c gradle --no-daemon assembleDebug
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
`adb install -r` preserves user data. Use `logout()` in-app to wipe crypto state.
|
||||||
|
|
||||||
|
To send a test message from matrix-commander:
|
||||||
|
```bash
|
||||||
|
nix develop -c matrix-commander --credentials .mc/credentials.json --store .mc/store \
|
||||||
|
-m "test message" --room '!rNfgFvyFMQEWIQnYVV:matrix.org'
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue