[BWA-16] Import Google Authenticator exports via scanner (#101)

This commit is contained in:
Patrick Honkonen
2024-05-28 13:01:07 -04:00
committed by GitHub
parent a40f3b91db
commit 00b35bd3ab
17 changed files with 296 additions and 61 deletions

View File

@@ -1,3 +1,5 @@
import com.google.protobuf.gradle.proto
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.crashlytics)
@@ -7,6 +9,7 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlinx.kover)
alias(libs.plugins.ksp)
alias(libs.plugins.google.protobuf)
alias(libs.plugins.google.services)
alias(libs.plugins.sonarqube)
}
@@ -65,6 +68,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1,LICENSE*.md}"
}
}
sourceSets {
getByName("main") {
proto {
srcDir("src/main/proto")
}
}
}
}
dependencies {
@@ -103,10 +113,12 @@ dependencies {
implementation(libs.google.firebase.crashlytics)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.google.guava)
implementation(libs.google.protobuf.javalite)
implementation(libs.jakewharton.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.square.okhttp)
implementation(libs.square.okhttp.logging)
implementation(libs.square.retrofit)
@@ -129,6 +141,19 @@ dependencies {
androidTestImplementation(libs.bundles.tests.instrumented)
}
protobuf {
protoc {
artifact = libs.google.protobuf.protoc.get().toString()
}
generateProtoTasks {
this.all().forEach { task ->
task.builtins.create("java") {
option("lite")
}
}
}
}
sonar {
properties {
property("sonar.projectKey", "bitwarden_authenticator-android")
@@ -140,7 +165,10 @@ sonar {
}
tasks {
withType<Test> {
useJUnitPlatform()
}
getByName("sonar") {
dependsOn("check")
}
}
}

View File

@@ -53,6 +53,12 @@
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
################################################################################
# Google Protobuf generated files
################################################################################
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
################################################################################
# JNA
################################################################################

View File

@@ -31,8 +31,14 @@ interface TotpCodeManager {
const val PERIOD_PARAM = "period"
const val SECRET_PARAM = "secret"
const val ISSUER_PARAM = "issuer"
/**
* URI query parameter containing export data from Google Authenticator.
*/
const val DATA_PARAM = "data"
const val TOTP_CODE_PREFIX = "otpauth://totp"
const val STEAM_CODE_PREFIX = "steam://"
const val GOOGLE_EXPORT_PREFIX = "otpauth-migration://"
const val TOTP_DIGITS_DEFAULT = 6
const val STEAM_DIGITS_DEFAULT = 5
const val PERIOD_SECONDS_DEFAULT = 30

View File

@@ -72,6 +72,11 @@ interface AuthenticatorRepository {
*/
suspend fun createItem(item: AuthenticatorItemEntity): CreateItemResult
/**
* Attempt to add provided [items].
*/
suspend fun addItems(vararg items: AuthenticatorItemEntity): CreateItemResult
/**
* Attempt to delete a cipher.
*/

View File

@@ -204,6 +204,15 @@ class AuthenticatorRepositoryImpl @Inject constructor(
}
}
override suspend fun addItems(vararg items: AuthenticatorItemEntity): CreateItemResult {
return try {
authenticatorDiskSource.saveItem(*items)
CreateItemResult.Success
} catch (e: Exception) {
CreateItemResult.Error
}
}
override suspend fun hardDeleteItem(itemId: String): DeleteItemResult {
return try {
authenticatorDiskSource.deleteItem(itemId)

View File

@@ -6,9 +6,14 @@ package com.bitwarden.authenticator.data.authenticator.repository.model
sealed class TotpCodeResult {
/**
* Code has been successfully added.
* Code containing an OTP URI has been successfully scanned.
*/
data class Success(val code: String) : TotpCodeResult()
data class TotpCodeScan(val code: String) : TotpCodeResult()
/**
* Code containing exported data from Google Authenticator was scanned.
*/
data class GoogleExportScan(val data: String) : TotpCodeResult()
/**
* There was an error scanning the code.

View File

@@ -0,0 +1,22 @@
package com.bitwarden.authenticator.data.platform.manager
/**
* An interface for encoding and decoding data.
*/
interface BitwardenEncodingManager {
/**
* Decodes '%'-escaped octets in the given string.
*/
fun uriDecode(value: String): String
/**
* Decodes the specified [value], and returns the resulting [ByteArray].
*/
fun base64Decode(value: String): ByteArray
/**
* Encodes the specified [byteArray], and returns the encoded String.
*/
fun base32Encode(byteArray: ByteArray): String
}

View File

@@ -0,0 +1,16 @@
package com.bitwarden.authenticator.data.platform.manager
import android.net.Uri
import com.google.common.io.BaseEncoding
/**
* Default implementation of [BitwardenEncodingManager].
*/
class BitwardenEncodingManagerImpl : BitwardenEncodingManager {
override fun uriDecode(value: String): String = Uri.decode(value)
override fun base64Decode(value: String): ByteArray = BaseEncoding.base64().decode(value)
override fun base32Encode(byteArray: ByteArray): String =
BaseEncoding.base32().encode(byteArray)
}

View File

@@ -5,6 +5,8 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.Authentica
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManagerImpl
import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManager
import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManagerImpl
import com.bitwarden.authenticator.data.platform.manager.CrashLogsManager
import com.bitwarden.authenticator.data.platform.manager.CrashLogsManagerImpl
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
@@ -67,4 +69,8 @@ object PlatformManagerModule {
fun provideImportManager(
authenticatorDiskSource: AuthenticatorDiskSource,
): ImportManager = ImportManagerImpl(authenticatorDiskSource)
@Provides
@Singleton
fun provideEncodingManager(): BitwardenEncodingManager = BitwardenEncodingManagerImpl()
}

View File

@@ -87,7 +87,7 @@ class EditItemViewModel @Inject constructor(
it.copy(
dialog = EditItemState.DialogState.Generic(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(R.string.name),
message = R.string.validation_field_required.asText(R.string.name.asText()),
)
)
}
@@ -97,7 +97,7 @@ class EditItemViewModel @Inject constructor(
it.copy(
dialog = EditItemState.DialogState.Generic(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(R.string.key),
message = R.string.validation_field_required.asText(R.string.key.asText()),
)
)
}

View File

@@ -7,12 +7,15 @@ import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult
import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult
import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManager
import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager
import com.bitwarden.authenticator.data.platform.manager.imports.model.GoogleAuthenticatorProtos
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem
@@ -39,6 +42,7 @@ import javax.inject.Inject
class ItemListingViewModel @Inject constructor(
private val authenticatorRepository: AuthenticatorRepository,
private val clipboardManager: BitwardenClipboardManager,
private val encodingManager: BitwardenEncodingManager,
settingsRepository: SettingsRepository,
) : BaseViewModel<ItemListingState, ItemListingEvent, ItemListingAction>(
initialState = ItemListingState(
@@ -261,32 +265,120 @@ class ItemListingViewModel @Inject constructor(
viewModelScope.launch {
when (val totpResult = action.totpResult) {
TotpCodeResult.CodeScanningError -> {
sendAction(
action = ItemListingAction.Internal.CreateItemResultReceive(
result = CreateItemResult.Error,
),
)
handleCodeScanningErrorReceive()
}
is TotpCodeResult.Success -> {
is TotpCodeResult.TotpCodeScan -> {
handleTotpCodeScanReceive(totpResult)
}
val item = totpResult.code.toAuthenticatorEntityOrNull()
?: run {
sendAction(
action = ItemListingAction.Internal.CreateItemResultReceive(
result = CreateItemResult.Error,
),
)
return@launch
}
val result = authenticatorRepository.createItem(item)
sendAction(ItemListingAction.Internal.CreateItemResultReceive(result))
is TotpCodeResult.GoogleExportScan -> {
handleGoogleExportScan(totpResult)
}
}
}
}
private suspend fun handleTotpCodeScanReceive(
totpResult: TotpCodeResult.TotpCodeScan,
) {
val item = totpResult.code.toAuthenticatorEntityOrNull()
?: run {
handleCodeScanningErrorReceive()
return
}
val result = authenticatorRepository.createItem(item)
sendAction(ItemListingAction.Internal.CreateItemResultReceive(result))
}
private suspend fun handleGoogleExportScan(
totpResult: TotpCodeResult.GoogleExportScan,
) {
val base64EncodedMigrationData = encodingManager.uriDecode(
value = totpResult.data,
)
val decodedMigrationData = encodingManager.base64Decode(
value = base64EncodedMigrationData,
)
val payload = GoogleAuthenticatorProtos.MigrationPayload
.parseFrom(decodedMigrationData)
val entries = payload
.otpParametersList
.mapNotNull { otpParam ->
val secret = encodingManager.base32Encode(
byteArray = otpParam.secret.toByteArray()
)
// Google Authenticator only supports TOTP and HOTP codes. We do not support HOTP
// codes so we skip over codes that are not TOTP.
val type = when (otpParam.type) {
GoogleAuthenticatorProtos.MigrationPayload.OtpType.OTP_TOTP -> {
AuthenticatorItemType.TOTP
}
else -> return@mapNotNull null
}
// Google Authenticator does not always provide a valid digits value so we double
// check it and fallback to the default value if it is not within our valid range.
val digits = if (otpParam.digits in 5..10) {
otpParam.digits
} else {
TotpCodeManager.TOTP_DIGITS_DEFAULT
}
// Google Authenticator only supports SHA1 algorithms.
val algorithm = AuthenticatorItemAlgorithm.SHA1
// Google Authenticator ignores period so we always set it to our default.
val period = TotpCodeManager.PERIOD_SECONDS_DEFAULT
val accountName: String = when {
otpParam.issuer.isNullOrEmpty().not() &&
otpParam.name.startsWith("${otpParam.issuer}:") -> {
otpParam.name.replace("${otpParam.issuer}:", "")
}
else -> otpParam.name
}
// If the issuer is not provided fallback to the token name since issuer is required
// in our database
val issuer = when {
otpParam.issuer.isNullOrEmpty() -> otpParam.name
else -> otpParam.issuer
}
AuthenticatorItemEntity(
id = UUID.randomUUID().toString(),
key = secret,
type = type,
algorithm = algorithm,
period = period,
digits = digits,
issuer = issuer,
accountName = accountName,
userId = null,
favorite = false,
)
}
val result = authenticatorRepository.addItems(*entries.toTypedArray())
sendAction(ItemListingAction.Internal.CreateItemResultReceive(result))
}
private suspend fun handleCodeScanningErrorReceive() {
sendAction(
action = ItemListingAction.Internal.CreateItemResultReceive(
result = CreateItemResult.Error,
),
)
}
private fun handleAlertThresholdSecondsReceive(
action: ItemListingAction.Internal.AlertThresholdSecondsReceive,
) {

View File

@@ -43,17 +43,22 @@ class QrCodeScanViewModel @Inject constructor(
)
}
// For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters
private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) {
var result: TotpCodeResult = TotpCodeResult.Success(action.qrCode)
val scannedCode = action.qrCode
if (scannedCode.isBlank() || !scannedCode.startsWith(TotpCodeManager.TOTP_CODE_PREFIX)) {
if (scannedCode.startsWith(TotpCodeManager.TOTP_CODE_PREFIX)) {
handleTotpUriReceive(scannedCode)
} else if (scannedCode.startsWith(TotpCodeManager.GOOGLE_EXPORT_PREFIX)) {
handleGoogleExportUriReceive(scannedCode)
} else {
authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError)
sendEvent(QrCodeScanEvent.NavigateBack)
return
}
}
// For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters
private fun handleTotpUriReceive(scannedCode: String) {
var result: TotpCodeResult = TotpCodeResult.TotpCodeScan(scannedCode)
val scannedCodeUri = Uri.parse(scannedCode)
val secretValue = scannedCodeUri
.getQueryParameter(TotpCodeManager.SECRET_PARAM)
@@ -70,7 +75,18 @@ class QrCodeScanViewModel @Inject constructor(
if (!areParametersValid(scannedCode, values)) {
result = TotpCodeResult.CodeScanningError
}
authenticatorRepository.emitTotpCodeResult(result)
sendEvent(QrCodeScanEvent.NavigateBack)
}
private fun handleGoogleExportUriReceive(scannedCode: String) {
val uri = Uri.parse(scannedCode)
val encodedData = uri.getQueryParameter(TotpCodeManager.DATA_PARAM)
val result: TotpCodeResult = if (encodedData.isNullOrEmpty()) {
TotpCodeResult.CodeScanningError
} else {
TotpCodeResult.GoogleExportScan(encodedData)
}
authenticatorRepository.emitTotpCodeResult(result)
sendEvent(QrCodeScanEvent.NavigateBack)
}

View File

@@ -31,7 +31,7 @@ class QrCodeAnalyzerImpl : QrCodeAnalyzer {
val source = PlanarYUVLuminanceSource(
image.planes[0].buffer.toByteArray(),
image.width,
image.planes[0].rowStride,
image.height,
0,
0,
@@ -39,24 +39,22 @@ class QrCodeAnalyzerImpl : QrCodeAnalyzer {
image.height,
false,
)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
try {
val result = MultiFormatReader()
.apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to arrayListOf(
BarcodeFormat.QR_CODE,
),
),
)
}
.decode(binaryBitmap)
val result = MultiFormatReader().decode(
/* image = */ binaryBitmap,
/* hints = */
mapOf(
DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE),
DecodeHintType.ALSO_INVERTED to true
),
)
qrCodeRead = true
onQrCodeScanned(result.text)
} catch (e: NotFoundException) {
return
} catch (ignored: NotFoundException) {
} finally {
image.close()
}

View File

@@ -0,0 +1,33 @@
syntax = "proto3";
option java_package = "com.bitwarden.authenticator.data.platform.manager.imports.model";
option java_outer_classname = "GoogleAuthenticatorProtos";
message MigrationPayload {
enum Algorithm {
ALGO_INVALID = 0;
ALGO_SHA1 = 1;
}
enum OtpType {
OTP_INVALID = 0;
OTP_HOTP = 1;
OTP_TOTP = 2;
}
message OtpParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
int32 digits = 5;
OtpType type = 6;
int64 counter = 7;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}

View File

@@ -1,16 +0,0 @@
package com.bitwarden.android.authenticator
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -1,11 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.google.protobuf) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlinx.kover) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.sonarqube) apply false
}

View File

@@ -32,6 +32,9 @@ espresso = "3.5.1"
fastlaneScreengrab = "2.1.1"
firebaseBom = "33.0.0"
glide = "1.0.0-beta01"
googleGuava = "33.0.0-android"
googleProtoBufJava = "3.25.1"
googleProtoBufPlugin = "0.9.4"
googleServices = "4.4.1"
hilt = "2.51.1"
junit5 = "5.10.2"
@@ -94,20 +97,24 @@ androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", vers
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" }
bitwarden-sdk = { module = "com.bitwarden:sdk-android", version.ref = "bitwardenSdk" }
bumptech-glide = { module = "com.github.bumptech.glide:compose", version.ref = "glide" }
fastlane-screengrab = { module = "tools.fastlane:screengrab", version.ref = "fastlaneScreengrab"}
fastlane-screengrab = { module = "tools.fastlane:screengrab", version.ref = "fastlaneScreengrab" }
google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
google-firebase-cloud-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" }
google-guava = { module = "com.google.guava:guava", version.ref = "googleGuava" }
google-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
google-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
google-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
google-protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "googleProtoBufJava" }
# Included so that Renovate tracks updates since protoc is not referenced directly in `dependency {}` blocks.
google-protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "googleProtoBufJava" }
jakewharton-retrofit-kotlinx-serialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerialization" }
junit-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" }
junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
robolectric-robolectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" }
@@ -121,6 +128,7 @@ zxing-zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics" }
google-protobuf = { id = "com.google.protobuf", version.ref = "googleProtoBufPlugin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }