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.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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue