All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.net.folivo.trixnity.client.key.KeyBackupService.kt Maven / Gradle / Ivy

There is a newer version: 4.7.1
Show newest version
package net.folivo.trixnity.client.key

import arrow.core.flatMap
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.serialization.encodeToString
import net.folivo.trixnity.client.CurrentSyncState
import net.folivo.trixnity.client.store.AccountStore
import net.folivo.trixnity.client.store.KeyStore
import net.folivo.trixnity.client.store.OlmCryptoStore
import net.folivo.trixnity.client.store.StoredSecret
import net.folivo.trixnity.client.utils.RetryLoopFlowState.PAUSE
import net.folivo.trixnity.client.utils.RetryLoopFlowState.RUN
import net.folivo.trixnity.client.utils.retryLoopWhenSyncIs
import net.folivo.trixnity.client.utils.retryWhen
import net.folivo.trixnity.clientserverapi.client.MatrixClientServerApiClient
import net.folivo.trixnity.clientserverapi.client.SyncState
import net.folivo.trixnity.clientserverapi.model.keys.GetRoomKeysBackupVersionResponse
import net.folivo.trixnity.clientserverapi.model.keys.SetRoomKeyBackupVersionRequest
import net.folivo.trixnity.core.ErrorResponse
import net.folivo.trixnity.core.EventHandler
import net.folivo.trixnity.core.MatrixServerException
import net.folivo.trixnity.core.UserInfo
import net.folivo.trixnity.core.model.RoomId
import net.folivo.trixnity.core.model.events.ClientEvent.GlobalAccountDataEvent
import net.folivo.trixnity.core.model.events.m.MegolmBackupV1EventContent
import net.folivo.trixnity.core.model.keys.*
import net.folivo.trixnity.core.model.keys.RoomKeyBackupSessionData.EncryptedRoomKeyBackupV1SessionData
import net.folivo.trixnity.core.model.keys.RoomKeyBackupSessionData.EncryptedRoomKeyBackupV1SessionData.RoomKeyBackupV1SessionData
import net.folivo.trixnity.crypto.SecretType
import net.folivo.trixnity.crypto.key.encryptSecret
import net.folivo.trixnity.crypto.olm.StoredInboundMegolmSession
import net.folivo.trixnity.crypto.sign.SignService
import net.folivo.trixnity.crypto.sign.SignWith
import net.folivo.trixnity.crypto.sign.signatures
import net.folivo.trixnity.olm.*
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds

private val log = KotlinLogging.logger {}

interface KeyBackupService {
    /**
     * This is the active key backup version.
     * Is null, when the backup algorithm is not supported or there is no existing backup.
     */
    val version: StateFlow

    suspend fun loadMegolmSession(
        roomId: RoomId,
        sessionId: String,
    )

    suspend fun keyBackupCanBeTrusted(keyBackupVersion: GetRoomKeysBackupVersionResponse, privateKey: String): Boolean

    suspend fun bootstrapRoomKeyBackup(
        key: ByteArray,
        keyId: String,
        masterSigningPrivateKey: String,
        masterSigningPublicKey: String
    ): Result
}

class KeyBackupServiceImpl(
    userInfo: UserInfo,
    private val accountStore: AccountStore,
    private val olmCryptoStore: OlmCryptoStore,
    private val keyStore: KeyStore,
    private val api: MatrixClientServerApiClient,
    private val signService: SignService,
    private val currentSyncState: CurrentSyncState,
    private val scope: CoroutineScope,
) : KeyBackupService, EventHandler {
    private val ownUserId = userInfo.userId
    private val ownDeviceId = userInfo.deviceId
    private val currentBackupVersion = MutableStateFlow(null)

    /**
     * This is the active key backup version.
     * Is null, when the backup algorithm is not supported or there is no existing backup.
     */
    override val version = currentBackupVersion.asStateFlow()

    override fun startInCoroutineScope(scope: CoroutineScope) {
        // we use UNDISPATCHED because we want to ensure, that collect is called immediately
        scope.launch(start = CoroutineStart.UNDISPATCHED) { setAndSignNewKeyBackupVersion() }
        scope.launch(start = CoroutineStart.UNDISPATCHED) { uploadRoomKeyBackup() }
    }

    internal suspend fun setAndSignNewKeyBackupVersion() {
        currentSyncState.retryLoopWhenSyncIs(
            SyncState.RUNNING,
            onError = { log.warn(it) { "failed get (and sign) current room key version" } },
            onCancel = { log.info { "stop get current room key version, because job was cancelled" } },
        ) {
            keyStore.getSecretsFlow().mapNotNull { it[SecretType.M_MEGOLM_BACKUP_V1] }
                .distinctUntilChanged()
                // TODO should use the version from secret, when MSC2474 is merged
                .collectLatest { updateKeyBackupVersion(it.decryptedPrivateKey) }
        }
    }

    private suspend fun updateKeyBackupVersion(privateKey: String?) {
        log.debug { "check key backup version" }
        val currentVersion = api.key.getRoomKeysVersion().getOrThrow().let { currentVersion ->
            if (currentVersion is GetRoomKeysBackupVersionResponse.V1) {
                val deviceSignature =
                    signService.signatures(currentVersion.authData)[ownUserId]?.find { it.keyId == ownDeviceId }
                if (privateKey != null && keyBackupCanBeTrusted(currentVersion, privateKey)) {
                    if (deviceSignature != null &&
                        currentVersion.authData.signatures[ownUserId]?.none { it == deviceSignature } == true
                    ) {
                        log.info { "sign key backup" }
                        api.key.setRoomKeysVersion(
                            SetRoomKeyBackupVersionRequest.V1(
                                authData = with(currentVersion.authData) {
                                    val ownUsersSignatures = signatures[ownUserId].orEmpty()
                                        .filterNot { it.keyId == ownDeviceId } + deviceSignature
                                    copy(signatures = signatures + (ownUserId to Keys(ownUsersSignatures.toSet())))
                                },
                                version = currentVersion.version
                            )
                        ).getOrThrow()
                    }
                    currentVersion
                } else {
                    // TODO should we mark all known keys as not backed up?
                    log.info { "reset key backup and remove own signature from it" }
                    // when the private key does not match it's likely, that the key backup has been changed
                    if (currentVersion.authData.signatures[ownUserId]?.any { it.keyId == ownDeviceId } == true)
                        api.key.setRoomKeysVersion(
                            SetRoomKeyBackupVersionRequest.V1(
                                authData = with(currentVersion.authData) {
                                    val ownUsersSignatures =
                                        signatures[ownUserId].orEmpty()
                                            .filterNot { it.keyId == ownDeviceId }
                                            .toSet()
                                    copy(signatures = signatures + (ownUserId to Keys(ownUsersSignatures)))
                                },
                                version = currentVersion.version
                            )
                        ).getOrThrow()
                    keyStore.updateSecrets { it - SecretType.M_MEGOLM_BACKUP_V1 }
                    null
                }
            } else {
                log.warn { "unsupported key backup version" }
                null
            }
        }
        currentBackupVersion.value = currentVersion
    }


    private val currentlyLoadingMegolmSessions = MutableStateFlow>>(setOf())

    override suspend fun loadMegolmSession(
        roomId: RoomId,
        sessionId: String,
    ): Unit = coroutineScope {
        val runningKey = Pair(roomId, sessionId)
        if (currentlyLoadingMegolmSessions.getAndUpdate { it + runningKey }.contains(runningKey).not()) {
            scope.launch {
                coroutineContext.job.invokeOnCompletion {
                    currentlyLoadingMegolmSessions.update { it - runningKey }
                }
                retryWhen(
                    combine(version, currentSyncState) { currentVersion, currentSyncState ->
                        currentVersion != null && currentSyncState == SyncState.RUNNING
                    }.map { if (it) RUN else PAUSE },
                    scheduleBase = 1.seconds,
                    scheduleLimit = 6.hours,
                    onError = {
                        if (it is MatrixServerException
                            && it.statusCode == HttpStatusCode.NotFound
                            && it.errorResponse is ErrorResponse.NotFound
                        ) log.trace(it) { "megolm session from key backup not found on server" }
                        else log.warn(it) { "failed load megolm session from key backup" }

                    },
                    onCancel = { log.trace { "stop load megolm session from key backup, because job was cancelled" } },
                ) {
                    val version = version.value?.version
                    if (version != null) {
                        log.debug { "try to find key backup for roomId=$roomId, sessionId=$sessionId, version=$version" }
                        val encryptedSessionData =
                            api.key.getRoomKeys(version, roomId, sessionId).getOrThrow().sessionData
                        require(encryptedSessionData is EncryptedRoomKeyBackupV1SessionData)
                        val privateKey = keyStore.getSecrets()[SecretType.M_MEGOLM_BACKUP_V1]?.decryptedPrivateKey
                        val decryptedJson = freeAfter(OlmPkDecryption.create(privateKey)) {
                            it.decrypt(
                                with(encryptedSessionData) {
                                    OlmPkMessage(
                                        cipherText = ciphertext,
                                        mac = mac,
                                        ephemeralKey = ephemeral
                                    )
                                }
                            )
                        }
                        val data = api.json.decodeFromString(decryptedJson)
                        val (firstKnownIndex, pickledSession) =
                            freeAfter(OlmInboundGroupSession.import(data.sessionKey)) {
                                it.firstKnownIndex to it.pickle(checkNotNull(accountStore.getAccount()?.olmPickleKey))
                            }
                        val senderSigningKey = Key.Ed25519Key(
                            null,
                            data.senderClaimedKeys[KeyAlgorithm.Ed25519.name]
                                ?: throw IllegalArgumentException("sender claimed key should not be empty")
                        )
                        olmCryptoStore.updateInboundMegolmSession(sessionId, roomId) {
                            if (it != null && it.firstKnownIndex <= firstKnownIndex) it
                            else StoredInboundMegolmSession(
                                senderKey = data.senderKey,
                                sessionId = sessionId,
                                roomId = roomId,
                                firstKnownIndex = firstKnownIndex,
                                isTrusted = false, // because it comes from backup
                                hasBeenBackedUp = true, // because it comes from backup
                                senderSigningKey = senderSigningKey,
                                forwardingCurve25519KeyChain = data.forwardingKeyChain,
                                pickled = pickledSession
                            )
                        }
                    }
                }
                log.debug { "found key backup for roomId=$roomId, sessionId=$sessionId" }
            }
        }
        currentlyLoadingMegolmSessions.first { it.contains(runningKey).not() }
    }

    override suspend fun keyBackupCanBeTrusted(
        keyBackupVersion: GetRoomKeysBackupVersionResponse,
        privateKey: String,
    ): Boolean {
        val generatedPublicKey = try {
            freeAfter(OlmPkDecryption.create(privateKey)) { it.publicKey }
        } catch (error: Exception) {
            log.warn(error) { "could not generate public key from private backup key" }
            return false
        }
        if (keyBackupVersion !is GetRoomKeysBackupVersionResponse.V1) {
            log.warn { "current room key backup version does not match v1 or there was no backup" }
            return false
        }
        val originalPublicKey = keyBackupVersion.authData.publicKey.value
        if (originalPublicKey != generatedPublicKey) {
            log.warn { "key backup private key does not match public key (expected: $originalPublicKey was: $generatedPublicKey" }
            return false
        }
//    if ( // TODO this is only relevant, when we want to use the key backup without private key
//        keyBackupVersion.authData.signatures[ownUserId]?.none {
//            it.keyId?.let { keyId ->
//                val keyTrustLevel = keyStore.getDeviceKey(ownUserId, keyId)?.trustLevel
//                    ?: keyStore.getCrossSigningKey(ownUserId, keyId)?.trustLevel
//                keyTrustLevel == KeySignatureTrustLevel.Valid(true)
//                        || keyTrustLevel == KeySignatureTrustLevel.CrossSigned(true)
//                        || keyTrustLevel == KeySignatureTrustLevel.NotAllDeviceKeysCrossSigned(true)
//            } == true
//        } == true
//    ) {
//        log.warn { "key backup cannot be trusted, because it is not signed by any trusted key" }
//        return false
//    }
        return true
    }

    @OptIn(FlowPreview::class)
    internal suspend fun uploadRoomKeyBackup() {
        currentSyncState.retryLoopWhenSyncIs(
            SyncState.RUNNING,
            onError = { log.warn(it) { "failed upload room key backup" } },
            onCancel = { log.debug { "stop upload room key backup, because job was cancelled" } },
        ) {
            olmCryptoStore.notBackedUpInboundMegolmSessions.debounce(1.seconds)
                .onEach { notBackedUpInboundMegolmSessions ->
                    val version = version.value
                    if (version != null && notBackedUpInboundMegolmSessions.isNotEmpty()) {
                        log.debug { "upload room keys to key backup" }
                        api.key.setRoomKeys(version.version, RoomsKeyBackup(
                            notBackedUpInboundMegolmSessions.values.groupBy { it.roomId }
                                .mapValues { roomEntries ->
                                    RoomKeyBackup(roomEntries.value.associate { session ->
                                        val encryptedRoomKeyBackupV1SessionData =
                                            freeAfter(OlmPkEncryption.create(version.authData.publicKey.value)) { pke ->
                                                val sessionKey = freeAfter(
                                                    OlmInboundGroupSession.unpickle(
                                                        checkNotNull(accountStore.getAccount()?.olmPickleKey),
                                                        session.pickled
                                                    )
                                                ) { it.export(it.firstKnownIndex) }
                                                pke.encrypt(
                                                    api.json.encodeToString(
                                                        RoomKeyBackupV1SessionData(
                                                            session.senderKey,
                                                            session.forwardingCurve25519KeyChain,
                                                            session.senderSigningKey.let { mapOf(it.algorithm.name to it.value) },
                                                            sessionKey
                                                        )
                                                    )
                                                ).run {
                                                    EncryptedRoomKeyBackupV1SessionData(
                                                        ciphertext = cipherText,
                                                        mac = mac,
                                                        ephemeral = ephemeralKey
                                                    )
                                                }
                                            }
                                        session.sessionId to RoomKeyBackupData(
                                            firstMessageIndex = session.firstKnownIndex,
                                            forwardedCount = session.forwardingCurve25519KeyChain.size,
                                            isVerified = session.isTrusted,
                                            sessionData = encryptedRoomKeyBackupV1SessionData
                                        )
                                    })
                                }
                        )).onFailure {
                            if (it is MatrixServerException) {
                                val errorResponse = it.errorResponse
                                if (errorResponse is ErrorResponse.WrongRoomKeysVersion) {
                                    log.info { "key backup version is outdated" }
                                    updateKeyBackupVersion(keyStore.getSecrets()[SecretType.M_MEGOLM_BACKUP_V1]?.decryptedPrivateKey)
                                }
                            }
                        }.getOrThrow()
                        notBackedUpInboundMegolmSessions.values.forEach {
                            olmCryptoStore.updateInboundMegolmSession(it.sessionId, it.roomId) { session ->
                                session?.copy(hasBeenBackedUp = true)
                            }
                        }
                    }
                }.collect()
        }
    }

    override suspend fun bootstrapRoomKeyBackup(
        key: ByteArray,
        keyId: String,
        masterSigningPrivateKey: String,
        masterSigningPublicKey: String,
    ): Result {
        val (keyBackupPrivateKey, keyBackupPublicKey) = freeAfter(OlmPkDecryption.create(null)) { it.privateKey to it.publicKey }
        return api.key.setRoomKeysVersion(
            SetRoomKeyBackupVersionRequest.V1(
                authData = with(
                    RoomKeyBackupAuthData.RoomKeyBackupV1AuthData(Key.Curve25519Key(null, keyBackupPublicKey))
                ) {
                    val ownDeviceSignature = signService.signatures(this)[ownUserId]
                        ?.firstOrNull()
                    val ownUsersSignature =
                        signService.signatures(
                            this,
                            SignWith.PrivateKey(masterSigningPrivateKey, masterSigningPublicKey)
                        )[ownUserId]
                            ?.firstOrNull()
                    requireNotNull(ownUsersSignature)
                    requireNotNull(ownDeviceSignature)
                    copy(signatures = signatures + (ownUserId to keysOf(ownDeviceSignature, ownUsersSignature)))
                },
                version = null // create new version
            )
        ).flatMap {
            val encryptedBackupKey = MegolmBackupV1EventContent(
                encryptSecret(key, keyId, SecretType.M_MEGOLM_BACKUP_V1.id, keyBackupPrivateKey, api.json)
            )
            keyStore.updateSecrets {
                it + (SecretType.M_MEGOLM_BACKUP_V1 to StoredSecret(
                    GlobalAccountDataEvent(encryptedBackupKey),
                    keyBackupPrivateKey
                ))
            }
            api.user.setAccountData(encryptedBackupKey, ownUserId)
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy