Skip to content

Commit

Permalink
Add ability to support importing passwords from Google as CSV (#4601)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/608920331025315/1207415218447811/f

### Description
Adds ability to import passwords exported as a CSV from Google Password
Manager (GPM). This functionality is used by the higher up branches in
the stack to provide a guided way to import from GPM, but can be tested
directly from autofill dev settings and choosing to import a CSV file.

Test CSV files are available in [Example CSV files for
testing](https://app.asana.com/0/1208737781360301/1208737781360301/f)

### Steps to test this PR

#### Happy path: all unique
- [x] Fresh install `internal` build type, and launch autofill dev
settings
- [x] Choose `Import CSV`
- [x] Choose CSV file containing only unique entries and verify all are
imported (e.g., choose `example_csv_20.csv` and make sure 20 are
imported)

#### Happy path: importing with some duplicates in the list
- [x] Delete all saved credentials
- [x] Choose `Import CSV` again, and this time choose file containing
duplicates
- [x] verify correct amount imported (e.g., choose
`dupes_20_total_17_unique.csv` and make sure 17 are imported)

#### Happy path: importing with a duplicate already saved in the DB
- [x] Add a credential that matches what is in the CSV you will export
- [x] Import the CSV
- [x] Verify the correct amount imported and the already-saved one
wasn't duplicated

#### Non-CSV files
- [x] Choose `Import CSV`
- [x] Pick a file that isn't a password CSV export
- [x] Make sure it handles this gracefully
  • Loading branch information
CDRussell authored Nov 13, 2024
1 parent 4c51a0a commit c5a59ad
Show file tree
Hide file tree
Showing 40 changed files with 1,496 additions and 13 deletions.
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

0 comments on commit c5a59ad

Please sign in to comment.