Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to support importing passwords from Google as CSV #4601

Merged
merged 2 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions autofill/autofill-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ plugins {
id 'com.android.library'
id 'kotlin-android'
id 'com.squareup.anvil'
id 'kotlin-parcelize'
}

apply from: "$rootProject.projectDir/gradle/android-library.gradle"
Expand Down Expand Up @@ -59,6 +60,8 @@ dependencies {

implementation "androidx.datastore:datastore-preferences:_"

implementation "de.siegmar:fastcsv:_"

implementation Square.retrofit2.converter.moshi
implementation "com.squareup.moshi:moshi-kotlin:_"
implementation "com.squareup.moshi:moshi-adapters:_"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ class SecureStoreBackedAutofillStore @Inject constructor(
return matchType
}

private fun LoginCredentials.prepareForReinsertion(): WebsiteLoginDetailsWithCredentials {
private fun LoginCredentials.prepareForBulkInsertion(): WebsiteLoginDetailsWithCredentials {
val loginDetails = WebsiteLoginDetails(
id = id,
domain = domain,
Expand All @@ -287,22 +287,31 @@ class SecureStoreBackedAutofillStore @Inject constructor(

override suspend fun reinsertCredentials(credentials: LoginCredentials) {
withContext(dispatcherProvider.io()) {
secureStorage.addWebsiteLoginDetailsWithCredentials(credentials.prepareForReinsertion())?.also {
secureStorage.addWebsiteLoginDetailsWithCredentials(credentials.prepareForBulkInsertion())?.also {
syncCredentialsListener.onCredentialAdded(it.details.id!!)
}
}
}

override suspend fun reinsertCredentials(credentials: List<LoginCredentials>) {
withContext(dispatcherProvider.io()) {
val mappedCredentials = credentials.map { it.prepareForReinsertion() }
val mappedCredentials = credentials.map { it.prepareForBulkInsertion() }
secureStorage.addWebsiteLoginDetailsWithCredentials(mappedCredentials).also {
val ids = mappedCredentials.mapNotNull { it.details.id }
syncCredentialsListener.onCredentialsAdded(ids)
}
}
}

override suspend fun bulkInsert(credentials: List<LoginCredentials>): List<Long> {
return withContext(dispatcherProvider.io()) {
val mappedCredentials = credentials.map { it.prepareForBulkInsertion() }
return@withContext secureStorage.addWebsiteLoginDetailsWithCredentials(mappedCredentials).also {
syncCredentialsListener.onCredentialsAdded(it)
}
}
}

private fun usernameMatch(
credentials: WebsiteLoginDetailsWithCredentials,
username: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* 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 com.duckduckgo.autofill.impl.importing

import android.os.Parcelable
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult
import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished
import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize

interface CredentialImporter {
suspend fun import(
importList: List<LoginCredentials>,
originalImportListSize: Int,
)

fun getImportStatus(): Flow<ImportResult>

sealed interface ImportResult : Parcelable {

@Parcelize
data object InProgress : ImportResult

@Parcelize
data class Finished(
val savedCredentials: Int,
val numberSkipped: Int,
) : ImportResult
}
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class CredentialImporterImpl @Inject constructor(
private val autofillStore: InternalAutofillStore,
private val dispatchers: DispatcherProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : CredentialImporter {

private val _importStatus = MutableSharedFlow<ImportResult>(replay = 1)

override suspend fun import(
importList: List<LoginCredentials>,
originalImportListSize: Int,
) {
appCoroutineScope.launch(dispatchers.io()) {
doImportCredentials(importList, originalImportListSize)
}
}

private suspend fun doImportCredentials(
importList: List<LoginCredentials>,
originalImportListSize: Int,
) {
var skippedCredentials = originalImportListSize - importList.size

_importStatus.emit(InProgress)

val insertedIds = autofillStore.bulkInsert(importList)

skippedCredentials += (importList.size - insertedIds.size)
_importStatus.emit(Finished(savedCredentials = insertedIds.size, numberSkipped = skippedCredentials))
}

override fun getImportStatus(): Flow<ImportResult> = _importStatus
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* 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 com.duckduckgo.autofill.impl.importing

import android.net.Uri
import android.os.Parcelable
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize

interface CsvCredentialConverter {
suspend fun readCsv(encodedBlob: String): CsvCredentialImportResult
suspend fun readCsv(fileUri: Uri): CsvCredentialImportResult

sealed interface CsvCredentialImportResult : Parcelable {

@Parcelize
data class Success(val numberCredentialsInSource: Int, val loginCredentialsToImport: List<LoginCredentials>) : CsvCredentialImportResult

@Parcelize
data object Error : CsvCredentialImportResult
}
}

@ContributesBinding(AppScope::class)
class GooglePasswordManagerCsvCredentialConverter @Inject constructor(
private val parser: CsvCredentialParser,
private val fileReader: CsvFileReader,
private val credentialValidator: ImportedCredentialValidator,
private val domainNameNormalizer: DomainNameNormalizer,
private val dispatchers: DispatcherProvider,
private val blobDecoder: GooglePasswordBlobDecoder,
private val existingCredentialMatchDetector: ExistingCredentialMatchDetector,
) : CsvCredentialConverter {

override suspend fun readCsv(encodedBlob: String): CsvCredentialImportResult {
return kotlin.runCatching {
withContext(dispatchers.io()) {
val csv = blobDecoder.decode(encodedBlob)
convertToLoginCredentials(csv)
}
}.getOrElse { CsvCredentialImportResult.Error }
}

override suspend fun readCsv(fileUri: Uri): CsvCredentialImportResult {
return kotlin.runCatching {
withContext(dispatchers.io()) {
val csv = fileReader.readCsvFile(fileUri)
convertToLoginCredentials(csv)
}
}.getOrElse { CsvCredentialImportResult.Error }
}

private suspend fun convertToLoginCredentials(csv: String): CsvCredentialImportResult {
return when (val parseResult = parser.parseCsv(csv)) {
is CsvCredentialParser.ParseResult.Success -> {
val toImport = deduplicateAndCleanup(parseResult.credentials)
CsvCredentialImportResult.Success(parseResult.credentials.size, toImport)
}
is CsvCredentialParser.ParseResult.Error -> CsvCredentialImportResult.Error
}
}

private suspend fun deduplicateAndCleanup(allCredentials: List<LoginCredentials>): List<LoginCredentials> {
val dedupedCredentials = allCredentials.distinct()
val validCredentials = dedupedCredentials.filter { credentialValidator.isValid(it) }
val normalizedDomains = domainNameNormalizer.normalizeDomains(validCredentials)
val entriesNotAlreadySaved = filterNewCredentials(normalizedDomains)
return entriesNotAlreadySaved
}

private suspend fun filterNewCredentials(credentials: List<LoginCredentials>): List<LoginCredentials> {
return existingCredentialMatchDetector.filterExistingCredentials(credentials)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* 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 com.duckduckgo.autofill.impl.importing

import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult
import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Error
import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import de.siegmar.fastcsv.reader.CsvReader
import de.siegmar.fastcsv.reader.CsvRow
import javax.inject.Inject
import kotlinx.coroutines.withContext
import timber.log.Timber

interface CsvCredentialParser {
suspend fun parseCsv(csv: String): ParseResult

sealed interface ParseResult {
data class Success(val credentials: List<LoginCredentials>) : ParseResult
data object Error : ParseResult
}
}

@ContributesBinding(AppScope::class)
class GooglePasswordManagerCsvCredentialParser @Inject constructor(
private val dispatchers: DispatcherProvider,
) : CsvCredentialParser {

override suspend fun parseCsv(csv: String): ParseResult {
return kotlin.runCatching {
val credentials = convertToCredentials(csv).also {
Timber.i("Parsed CSV. Found %d credentials", it.size)
}
Success(credentials)
}.onFailure {
Timber.e(it, "Failed to parse CSV")
Error
}.getOrElse {
Error
}
}

/**
* Format of the Google Password Manager CSV is:
* name | url | username | password | note
*/
private suspend fun convertToCredentials(csv: String): List<LoginCredentials> {
return withContext(dispatchers.io()) {
val lines = mutableListOf<CsvRow>()
val iter = CsvReader.builder().build(csv).spliterator()
iter.forEachRemaining { lines.add(it) }
Timber.d("Found %d lines in the CSV", lines.size)

lines.firstOrNull().verifyExpectedFormat()

// drop the header row
val credentialLines = lines.drop(1)

return@withContext credentialLines
.mapNotNull {
if (it.fields.size != EXPECTED_HEADERS_ORDERED.size) {
Timber.w("Line is unexpected format. Expected ${EXPECTED_HEADERS_ORDERED.size} parts, found ${it.fields.size}")
return@mapNotNull null
}

parseToCredential(
domainTitle = it.getField(0).blanksToNull(),
domain = it.getField(1).blanksToNull(),
username = it.getField(2).blanksToNull(),
password = it.getField(3).blanksToNull(),
notes = it.getField(4).blanksToNull(),
)
}
}
}

private fun parseToCredential(
domainTitle: String?,
domain: String?,
username: String?,
password: String?,
notes: String?,
): LoginCredentials {
return LoginCredentials(
domainTitle = domainTitle,
domain = domain,
username = username,
password = password,
notes = notes,
)
}

private fun String?.blanksToNull(): String? {
return if (isNullOrBlank()) null else this
}

private fun CsvRow?.verifyExpectedFormat() {
if (this == null) {
throw IllegalArgumentException("File not recognised as a CSV")
}

val headers = this.fields

if (headers.size != EXPECTED_HEADERS_ORDERED.size) {
throw IllegalArgumentException(
"CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS_ORDERED.size}, found: ${headers.size}",
)
}

headers.forEachIndexed { index, value ->
if (value != EXPECTED_HEADERS_ORDERED[index]) {
throw IllegalArgumentException(
"CSV header does not match expected format. Expected: ${EXPECTED_HEADERS_ORDERED[index]}, found: $value",
)
}
}
}

companion object {
val EXPECTED_HEADERS_ORDERED = listOf(
"name",
"url",
"username",
"password",
"note",
)
}
}
Loading
Loading