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 Nov 11, 2024
1 parent 5256082 commit c6c32a2
Show file tree
Hide file tree
Showing 24 changed files with 789 additions and 0 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
@@ -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,85 @@
/*
* 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.CsvPasswordImporter.ParseResult
import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult.Success
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 CsvPasswordImporter {
suspend fun readCsv(blob: String): ParseResult
suspend fun readCsv(fileUri: Uri): ParseResult

sealed interface ParseResult : Parcelable {
@Parcelize
data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List<LoginCredentials>) : ParseResult

@Parcelize
data object Error : ParseResult
}
}

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

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

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

private suspend fun convertToLoginCredentials(csv: String): Success {
val allPasswords = parser.parseCsv(csv)
val dedupedPasswords = allPasswords.distinct()
val validPasswords = filterValidPasswords(dedupedPasswords)
val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords)
return Success(allPasswords.size, normalizedDomains)
}

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
}
}
}
Loading

0 comments on commit c6c32a2

Please sign in to comment.