Merge pull request #2893 from element-hq/feature/valere/add_device_keys_to_rs

Add public device keys to rageshakes
This commit is contained in:
Valere 2024-05-24 16:13:37 +02:00 committed by GitHub
commit a52e316fa7
6 changed files with 255 additions and 2 deletions

1
changelog.d/2893.misc Normal file
View file

@ -0,0 +1 @@
BugReporting | Add public device keys to rageshakes

View file

@ -36,7 +36,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SdkMetadata import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -79,6 +81,7 @@ class DefaultBugReporter @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val bugReporterUrlProvider: BugReporterUrlProvider, private val bugReporterUrlProvider: BugReporterUrlProvider,
private val sdkMetadata: SdkMetadata, private val sdkMetadata: SdkMetadata,
private val matrixClientProvider: MatrixClientProvider,
) : BugReporter { ) : BugReporter {
companion object { companion object {
// filenames // filenames
@ -145,7 +148,7 @@ class DefaultBugReporter @Inject constructor(
val sessionData = sessionStore.getLatestSession() val sessionData = sessionStore.getLatestSession()
val deviceId = sessionData?.deviceId ?: "undefined" val deviceId = sessionData?.deviceId ?: "undefined"
val userId = sessionData?.userId ?: "undefined" val userId = sessionData?.userId?.let { UserId(it) }
if (!isCancelled) { if (!isCancelled) {
// build the multi part request // build the multi part request
@ -153,9 +156,20 @@ class DefaultBugReporter @Inject constructor(
.addFormDataPart("text", bugDescription) .addFormDataPart("text", bugDescription)
.addFormDataPart("app", context.getString(R.string.bug_report_app_name)) .addFormDataPart("app", context.getString(R.string.bug_report_app_name))
.addFormDataPart("user_agent", userAgentProvider.provide()) .addFormDataPart("user_agent", userAgentProvider.provide())
.addFormDataPart("user_id", userId) .addFormDataPart("user_id", userId?.toString() ?: "undefined")
.addFormDataPart("can_contact", canContact.toString()) .addFormDataPart("can_contact", canContact.toString())
.addFormDataPart("device_id", deviceId) .addFormDataPart("device_id", deviceId)
.apply {
userId?.let {
matrixClientProvider.getOrNull(it)?.let { client ->
val curveKey = client.encryptionService().deviceCurve25519()
val edKey = client.encryptionService().deviceEd25519()
if (curveKey != null && edKey != null) {
addFormDataPart("device_keys", "curve25519:$curveKey, ed25519:$edKey")
}
}
}
}
.addFormDataPart("device", Build.MODEL.trim()) .addFormDataPart("device", Build.MODEL.trim())
.addFormDataPart("locale", Locale.getDefault().toString()) .addFormDataPart("locale", Locale.getDefault().toString())
.addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha)

View file

@ -20,16 +20,25 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.network.useragent.DefaultUserAgentProvider import io.element.android.libraries.network.useragent.DefaultUserAgentProvider
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.MultipartReader
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okio.buffer
import okio.source
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@ -84,6 +93,202 @@ class DefaultBugReporterTest {
assertThat(onUploadSucceedCalled).isTrue() assertThat(onUploadSucceedCalled).isTrue()
} }
@Test
fun `test sendBugReport form data`() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse()
.setResponseCode(200)
)
server.start()
val mockSessionStore = InMemorySessionStore().apply {
storeData(mockSessionData("@foo:eample.com", "ABCDEFGH"))
}
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY")
val sut = DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = mockSessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
)
val progressValues = mutableListOf<Int>()
sut.sendBugReport(
withDevicesLogs = true,
withCrashLogs = true,
withScreenshot = true,
theBugDescription = "a bug occurred",
canContact = true,
listener = object : BugReporterListener {
override fun onUploadCancelled() {}
override fun onUploadFailed(reason: String?) {}
override fun onProgress(progress: Int) {
progressValues.add(progress)
}
override fun onUploadSucceed() {}
},
)
val request = server.takeRequest()
val foundValues = collectValuesFromFormData(request)
assertThat(foundValues["app"]).isEqualTo("element-x-android")
assertThat(foundValues["can_contact"]).isEqualTo("true")
assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH")
assertThat(foundValues["sdk_sha"]).isEqualTo("123456789")
assertThat(foundValues["user_id"]).isEqualTo("@foo:eample.com")
assertThat(foundValues["text"]).isEqualTo("a bug occurred")
assertThat(foundValues["device_keys"]).isEqualTo("curve25519:CURVECURVECURVE, ed25519:EDKEYEDKEYEDKY")
// device_key now added given they are not null
assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 1)
server.shutdown()
}
@Test
fun `test sendBugReport should not report device_keys if not known`() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse()
.setResponseCode(200)
)
server.start()
val mockSessionStore = InMemorySessionStore().apply {
storeData(mockSessionData("@foo:eample.com", "ABCDEFGH"))
}
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
fakeEncryptionService.givenDeviceKeys(null, null)
val sut = DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = mockSessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
)
sut.sendBugReport(
withDevicesLogs = true,
withCrashLogs = true,
withScreenshot = true,
theBugDescription = "a bug occurred",
canContact = true,
listener = null
)
val request = server.takeRequest()
val foundValues = collectValuesFromFormData(request)
assertThat(foundValues["device_keys"]).isNull()
server.shutdown()
}
@Test
fun `test sendBugReport no client provider no session data`() = runTest {
val server = MockWebServer()
server.enqueue(
MockResponse()
.setResponseCode(200)
)
server.start()
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService()
fakeEncryptionService.givenDeviceKeys(null, null)
val sut = DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore("I did crash", true),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = InMemorySessionStore(),
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) })
)
sut.sendBugReport(
withDevicesLogs = true,
withCrashLogs = true,
withScreenshot = true,
theBugDescription = "a bug occurred",
canContact = true,
listener = null
)
val request = server.takeRequest()
val foundValues = collectValuesFromFormData(request)
println("## FOUND VALUES $foundValues")
assertThat(foundValues["device_keys"]).isNull()
assertThat(foundValues["device_id"]).isEqualTo("undefined")
assertThat(foundValues["user_id"]).isEqualTo("undefined")
assertThat(foundValues["label"]).isEqualTo("crash")
}
private fun collectValuesFromFormData(request: RecordedRequest): HashMap<String, String> {
val boundary = request.headers["Content-Type"]!!.split("=").last()
val foundValues = HashMap<String, String>()
request.body.inputStream().source().buffer().use {
val multipartReader = MultipartReader(it, boundary)
// Just use simple parsing to detect basic properties
val regex = "form-data; name=\"(\\w*)\".*".toRegex()
multipartReader.use {
var part = multipartReader.nextPart()
while (part != null) {
part.headers["Content-Disposition"]?.let { contentDisposition ->
regex.find(contentDisposition)?.groupValues?.get(1)?.let { name ->
foundValues.put(name, part!!.body.readUtf8())
}
}
part = multipartReader.nextPart()
}
}
}
return foundValues
}
private fun mockSessionData(userId: String, deviceId: String) = SessionData(
userId = userId,
deviceId = deviceId,
homeserverUrl = "example.com",
accessToken = "AA",
isTokenValid = true,
loginType = LoginType.DIRECT,
loginTimestamp = null,
oidcData = null,
refreshToken = null,
slidingSyncProxy = null,
passphrase = null
)
@Test @Test
fun `test sendBugReport error`() = runTest { fun `test sendBugReport error`() = runTest {
val server = MockWebServer() val server = MockWebServer()
@ -150,6 +355,7 @@ class DefaultBugReporterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") }, bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"), sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider()
) )
} }

View file

@ -50,4 +50,16 @@ interface EncryptionService {
* Wait for backup upload steady state. * Wait for backup upload steady state.
*/ */
fun waitForBackupUploadSteadyState(): Flow<BackupUploadState> fun waitForBackupUploadSteadyState(): Flow<BackupUploadState>
/**
* Get the public curve25519 key of our own device in base64. This is usually what is
* called the identity key of the device.
*/
suspend fun deviceCurve25519(): String?
/**
* Get the public ed25519 key of our own device. This is usually what is
* called the fingerprint of the device.
*/
suspend fun deviceEd25519(): String?
} }

View file

@ -190,4 +190,12 @@ internal class RustEncryptionService(
it.mapRecoveryException() it.mapRecoveryException()
} }
} }
override suspend fun deviceCurve25519(): String? {
return service.curve25519Key()
}
override suspend fun deviceEd25519(): String? {
return service.ed25519Key()
}
} }

View file

@ -39,6 +39,9 @@ class FakeEncryptionService : EncryptionService {
private var enableBackupsFailure: Exception? = null private var enableBackupsFailure: Exception? = null
private var curve25519: String? = null
private var ed25519: String? = null
fun givenEnableBackupsFailure(exception: Exception?) { fun givenEnableBackupsFailure(exception: Exception?) {
enableBackupsFailure = exception enableBackupsFailure = exception
} }
@ -94,6 +97,15 @@ class FakeEncryptionService : EncryptionService {
return waitForBackupUploadSteadyStateFlow return waitForBackupUploadSteadyStateFlow
} }
fun givenDeviceKeys(curve25519: String?, ed25519: String?) {
this.curve25519 = curve25519
this.ed25519 = ed25519
}
override suspend fun deviceCurve25519(): String? = curve25519
override suspend fun deviceEd25519(): String? = ed25519
suspend fun emitBackupState(state: BackupState) { suspend fun emitBackupState(state: BackupState) {
backupStateStateFlow.emit(state) backupStateStateFlow.emit(state)
} }