Skip to content

Commit

Permalink
Add ability to support importing passwords from Google as CSV
Browse files Browse the repository at this point in the history
  • Loading branch information
CDRussell committed May 29, 2024
1 parent f9bd90b commit 4091171
Show file tree
Hide file tree
Showing 23 changed files with 734 additions and 0 deletions.
2 changes: 2 additions & 0 deletions autofill/autofill-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ dependencies {
implementation AndroidX.fragment.ktx
implementation "androidx.webkit:webkit:_"

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
@@ -0,0 +1,43 @@
/*
* 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.content.Context
import android.net.Uri
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import java.io.BufferedReader
import java.io.InputStreamReader
import javax.inject.Inject

interface CsvFileReader {
fun readCsvFile(fileUri: Uri): String
}

@ContributesBinding(AppScope::class)
class ContentResolverFileReader @Inject constructor(
private val context: Context,
) : CsvFileReader {

override fun readCsvFile(fileUri: Uri): String {
return context.contentResolver.openInputStream(fileUri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
} ?: ""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 com.duckduckgo.autofill.api.domain.app.LoginCredentials
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 javax.inject.Inject
import kotlinx.coroutines.withContext

interface CsvPasswordImporter {
suspend fun importCsv(fileUri: Uri): List<Long>
}

@ContributesBinding(AppScope::class)
class GooglePasswordManagerCsvPasswordImporter @Inject constructor(
private val parser: CsvPasswordParser,
private val fileReader: CsvFileReader,
private val credentialValidator: ImportedPasswordValidator,
private val existingPasswordMatchDetector: ExistingPasswordMatchDetector,
private val domainNameNormalizer: DomainNameNormalizer,
private val dispatchers: DispatcherProvider,
private val autofillStore: InternalAutofillStore,
) : CsvPasswordImporter {

override suspend fun importCsv(fileUri: Uri): List<Long> {
return kotlin.runCatching {
withContext(dispatchers.io()) {
val csv = fileReader.readCsvFile(fileUri)
val allPasswords = parser.parseCsv(csv)
val dedupedPasswords = allPasswords.distinct()
val validPasswords = filterValidPasswords(dedupedPasswords)
val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords)
savePasswords(normalizedDomains)
}
}.getOrElse { emptyList() }
}

private suspend fun savePasswords(passwords: List<LoginCredentials>): List<Long> {
val savedCredentialIds = mutableListOf<Long>()
passwords.forEach {
if (!existingPasswordMatchDetector.alreadyExists(it)) {
val insertedId = autofillStore.saveCredentials(it.domain!!, it)?.id

if (insertedId != null) {
savedCredentialIds.add(insertedId)
}
}
}
return savedCredentialIds
}

private fun filterValidPasswords(passwords: List<LoginCredentials>): List<LoginCredentials> {
return passwords.filter { it.isValid() }
}

private fun LoginCredentials.isValid(): Boolean = credentialValidator.isValid(this)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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.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 CsvPasswordParser {
suspend fun parseCsv(csv: String): List<LoginCredentials>
}

@ContributesBinding(AppScope::class)
class GooglePasswordManagerCsvPasswordParser @Inject constructor(
private val dispatchers: DispatcherProvider,
) : CsvPasswordParser {

// private val csvFormat by lazy {
// CSVFormat.Builder.create(CSVFormat.DEFAULT).build()
// }

override suspend fun parseCsv(csv: String): List<LoginCredentials> {
return kotlin.runCatching {
convertToPasswordList(csv).also {
Timber.i("Parsed CSV. Found %d passwords", it.size)
}
}.onFailure {
Timber.e("Failed to parse CSV: %s", it.message)
}.getOrElse {
emptyList()
}
}

/**
* Format of the Google Password Manager CSV is:
* name | url | username | password | note
*/
private suspend fun convertToPasswordList(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 passwordsLines = lines.drop(1)

Timber.v("About to parse %d passwords", passwordsLines.size)
return@withContext passwordsLines
.mapNotNull {
if (it.fields.size != EXPECTED_HEADERS.size) {
Timber.w("CSV line does not match expected format. Expected ${EXPECTED_HEADERS.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.size) {
throw IllegalArgumentException(
"CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS.size}, found: ${headers.size}",
)
}

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

companion object {
val EXPECTED_HEADERS = listOf(
"name",
"url",
"username",
"password",
"note",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface DomainNameNormalizer {
suspend fun normalizeDomains(unnormalized: List<LoginCredentials>): List<LoginCredentials>
}

@ContributesBinding(AppScope::class)
class DefaultDomainNameNormalizer @Inject constructor(
private val urlMatcher: AutofillUrlMatcher,
) : DomainNameNormalizer {
override suspend fun normalizeDomains(unnormalized: List<LoginCredentials>): List<LoginCredentials> {
return unnormalized.map {
val currentDomain = it.domain ?: return@map null
val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain)
it.copy(domain = normalizedDomain)
}.filterNotNull()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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.store.InternalAutofillStore
import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.flow.firstOrNull

interface ExistingPasswordMatchDetector {
suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean
}

@ContributesBinding(AppScope::class)
class DefaultExistingPasswordMatchDetector @Inject constructor(
private val urlMatcher: AutofillUrlMatcher,
private val autofillStore: InternalAutofillStore,
) : ExistingPasswordMatchDetector {

override suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean {
val credentials = autofillStore.getAllCredentials().firstOrNull() ?: return false

return credentials.any { existing ->
existing.domain == newCredentials.domain &&
existing.username == newCredentials.username &&
existing.password == newCredentials.password &&
existing.domainTitle == newCredentials.domainTitle &&
existing.notes == newCredentials.notes
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface ImportedPasswordValidator {
fun isValid(loginCredentials: LoginCredentials): Boolean
}

@ContributesBinding(AppScope::class)
class DefaultImportedPasswordValidator @Inject constructor() : ImportedPasswordValidator {

override fun isValid(loginCredentials: LoginCredentials): Boolean {
return with(loginCredentials) {
domain.isNullOrBlank().not() && password.isNullOrBlank().not()
}
}
}
Loading

0 comments on commit 4091171

Please sign in to comment.