Store session data in a secure way (#98)

* Replace SessionData DataStore with an encrypted SQLite DB.

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2023-03-02 16:48:54 +01:00 committed by GitHub
parent 381bd3fd3f
commit 6677f80abe
38 changed files with 600 additions and 199 deletions

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.sessionstorage
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.session.SessionData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DatabaseSessionStore @Inject constructor(
private val database: SessionDatabase,
) : SessionStore {
override fun isLoggedIn(): Flow<Boolean> {
return database.sessionDataQueries.selectFirst().asFlow().mapToOneOrNull().map { it != null }
}
override suspend fun storeData(sessionData: SessionData) {
database.sessionDataQueries.insertSessionData(sessionData)
}
override suspend fun getLatestSession(): SessionData? {
return database.sessionDataQueries.selectFirst()
.executeAsOneOrNull()
}
override suspend fun getSession(sessionId: String): SessionData? {
return database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
}
override suspend fun removeSession(sessionId: String) {
database.sessionDataQueries.removeSession(sessionId)
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.sessionstorage
import io.element.android.libraries.matrix.session.SessionData
import kotlinx.coroutines.flow.Flow
interface SessionStore {
fun isLoggedIn(): Flow<Boolean>
suspend fun storeData(session: SessionData)
suspend fun getSession(sessionId: String): SessionData?
suspend fun getLatestSession(): SessionData?
suspend fun removeSession(sessionId: String)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.sessionstorage.di
import android.content.Context
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.sessionstorage.SessionDatabase
import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
@Module
@ContributesTo(AppScope::class)
object SessionStorageModule {
@Provides
@SingleIn(AppScope::class)
fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase {
val name = "session_database"
val secretFile = context.getDatabasePath("$name.key")
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile, name)
val driver = SqlCipherDriverFactory(passphraseProvider)
.create(SessionDatabase.Schema, "$name.db", context)
return SessionDatabase(driver)
}
}

View file

@ -0,0 +1,21 @@
CREATE TABLE SessionData (
userId TEXT NOT NULL PRIMARY KEY,
deviceId TEXT NOT NULL,
accessToken TEXT NOT NULL,
refreshToken TEXT,
homeserverUrl TEXT NOT NULL,
isSoftLogout INTEGER AS Boolean NOT NULL DEFAULT 0,
slidingSyncProxy TEXT
);
selectFirst:
SELECT * FROM SessionData LIMIT 1;
selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;
insertSessionData:
INSERT INTO SessionData(userId, deviceId, accessToken, refreshToken, homeserverUrl, isSoftLogout, slidingSyncProxy) VALUES ?;
removeSession:
DELETE FROM SessionData WHERE userId = ?;

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.sessionstorage
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import io.element.android.libraries.matrix.session.SessionData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseSessionStoreTests {
private lateinit var database: SessionDatabase
private lateinit var databaseSessionStore: DatabaseSessionStore
private val aSessionData = SessionData(
userId = "userId",
deviceId = "deviceId",
accessToken = "accessToken",
refreshToken = "refreshToken",
homeserverUrl = "homeserverUrl",
isSoftLogout = false,
slidingSyncProxy = null
)
@Before
fun setup() {
// Initialise in memory SQLite driver
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
SessionDatabase.Schema.create(driver)
database = SessionDatabase(driver)
databaseSessionStore = DatabaseSessionStore(database)
}
@Test
fun `storeData persists the SessionData into the DB`() = runTest {
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull()
databaseSessionStore.storeData(aSessionData)
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData)
}
@Test
fun `isLoggedIn emits true while there are sessions in the DB`() = runTest {
databaseSessionStore.isLoggedIn().test {
assertThat(awaitItem()).isFalse()
database.sessionDataQueries.insertSessionData(aSessionData)
assertThat(awaitItem()).isTrue()
database.sessionDataQueries.removeSession(aSessionData.userId)
assertThat(awaitItem()).isFalse()
}
}
@Test
fun `getLatestSession gets the first session in the DB`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId"))
val latestSession = databaseSessionStore.getLatestSession()
assertThat(latestSession).isEqualTo(aSessionData)
}
@Test
fun `getSession returns a matching session in DB if exists`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId"))
val foundSession = databaseSessionStore.getSession(aSessionData.userId)
assertThat(foundSession).isEqualTo(aSessionData)
}
@Test
fun `getSession returns null if a no matching session exists in DB`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId"))
val foundSession = databaseSessionStore.getSession(aSessionData.userId)
assertThat(foundSession).isNull()
}
@Test
fun `removeSession removes the associated session in DB`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
}
}