From a1e7e92d6d25622bc16cfd8a7f730161c0b9d7df Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 16 May 2024 15:30:48 -0400 Subject: [PATCH] [BWA-14] Support importing LastPass exports (#98) --- .../disk/entity/AuthenticatorItemAlgorithm.kt | 8 +- .../disk/entity/AuthenticatorItemType.kt | 6 ++ .../manager/imports/ImportManagerImpl.kt | 18 +++-- .../manager/imports/model/ImportFileFormat.kt | 1 + .../imports/model/LastPassJsonExport.kt | 54 ++++++++++++++ .../imports/parsers/BitwardenExportParser.kt | 26 +++++-- .../imports/parsers/LastPassExportParser.kt | 74 +++++++++++++++++++ .../imports/parsers/TwoFasExportParser.kt | 35 +++++---- .../platform/util/ImportFormatExtensions.kt | 1 + .../main/res/values/strings_non_localized.xml | 1 + 10 files changed, 195 insertions(+), 29 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt index 4ebfb7023c..5a36af1017 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemAlgorithm.kt @@ -18,5 +18,11 @@ enum class AuthenticatorItemAlgorithm { /** * Authenticator item verification code uses SHA512 hash. */ - SHA512 + SHA512, + ; + + companion object { + fun fromStringOrNull(value: String): AuthenticatorItemAlgorithm? = + entries.find { it.name.equals(value, ignoreCase = true) } + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt index 1d04fa2fb1..7e8fd89fd1 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemType.kt @@ -14,4 +14,10 @@ enum class AuthenticatorItemType { * Steam's implementation of a one time password. */ STEAM, + ; + + companion object { + fun fromStringOrNull(value: String): AuthenticatorItemType? = + entries.find { it.name.equals(value, ignoreCase = true) } + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt index 701cf3d35e..76414fa251 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/ImportManagerImpl.kt @@ -5,6 +5,7 @@ import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDat import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat import com.bitwarden.authenticator.data.platform.manager.imports.parsers.BitwardenExportParser import com.bitwarden.authenticator.data.platform.manager.imports.parsers.ExportParser +import com.bitwarden.authenticator.data.platform.manager.imports.parsers.LastPassExportParser import com.bitwarden.authenticator.data.platform.manager.imports.parsers.TwoFasExportParser /** @@ -22,13 +23,18 @@ class ImportManagerImpl( val parser: ExportParser = when (importFileFormat) { ImportFileFormat.BITWARDEN_JSON -> BitwardenExportParser(importFileFormat) ImportFileFormat.TWO_FAS_JSON -> TwoFasExportParser() + ImportFileFormat.LAST_PASS_JSON -> LastPassExportParser() } - return parser.parse(byteArray) - .map { authenticatorDiskSource.saveItem(*it.toTypedArray()) } - .fold( - onSuccess = { ImportDataResult.Success }, - onFailure = { ImportDataResult.Error } - ) + return try { + parser.parse(byteArray) + .mapCatching { authenticatorDiskSource.saveItem(*it.toTypedArray()) } + .fold( + onSuccess = { ImportDataResult.Success }, + onFailure = { ImportDataResult.Error } + ) + } catch (e: Throwable) { + ImportDataResult.Error + } } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt index f4c005f23e..58b792d1db 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/ImportFileFormat.kt @@ -8,4 +8,5 @@ enum class ImportFileFormat( ) { BITWARDEN_JSON("application/json"), TWO_FAS_JSON("*/*"), + LAST_PASS_JSON("application/json") } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt new file mode 100644 index 0000000000..23f3aed329 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/model/LastPassJsonExport.kt @@ -0,0 +1,54 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LastPassJsonExport( + val deviceId: String, + val deviceSecret: String, + val localDeviceId: String, + val deviceName: String, + val version: Int, + val accounts: List, + val folders: List, +) { + @Serializable + data class Account( + @SerialName("accountID") + val accountId: String, + val issuerName: String, + val originalIssuerName: String, + val userName: String, + val originalUserName: String, + val pushNotification: Boolean, + val secret: String, + val timeStep: Int, + val digits: Int, + val creationTimestamp: Long, + val isFavorite: Boolean, + val algorithm: String, + val folderData: FolderData?, + val backupInfo: BackupInfo?, + ) { + @Serializable + data class FolderData( + val folderId: String, + val position: Int, + ) + + @Serializable + data class BackupInfo( + val creationDate: String, + val deviceOs: String, + val appVersion: String, + ) + } + + @Serializable + data class Folder( + val id: Int, + val name: String, + val isOpened: Boolean, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt index 8e487cf914..07aa5ce7ed 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/BitwardenExportParser.kt @@ -10,9 +10,11 @@ import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFil import com.bitwarden.authenticator.data.platform.util.asFailure import com.bitwarden.authenticator.data.platform.util.asSuccess import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import java.io.ByteArrayInputStream +import java.io.IOException class BitwardenExportParser( private val fileFormat: ImportFileFormat, @@ -32,14 +34,22 @@ class BitwardenExportParser( explicitNulls = false } - return importJson - .decodeFromStream(ByteArrayInputStream(byteArray)) - .asSuccess() - .map { exportData -> - exportData - .items - .toAuthenticatorItemEntities() - } + return try { + importJson + .decodeFromStream(ByteArrayInputStream(byteArray)) + .asSuccess() + .mapCatching { exportData -> + exportData + .items + .toAuthenticatorItemEntities() + } + } catch (e: SerializationException) { + e.asFailure() + } catch (e: IllegalArgumentException) { + e.asFailure() + } catch (e: IOException) { + e.asFailure() + } } private fun List.toAuthenticatorItemEntities() = diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt new file mode 100644 index 0000000000..ca18357e13 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/LastPassExportParser.kt @@ -0,0 +1,74 @@ +package com.bitwarden.authenticator.data.platform.manager.imports.parsers + +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.platform.manager.imports.model.LastPassJsonExport +import com.bitwarden.authenticator.data.platform.util.asFailure +import com.bitwarden.authenticator.data.platform.util.asSuccess +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.ByteArrayInputStream +import java.io.IOException +import java.util.UUID + +/** + * An [ExportParser] responsible for transforming LastPass export files into Bitwarden Authenticator + * items. + */ +class LastPassExportParser : ExportParser { + + @OptIn(ExperimentalSerializationApi::class) + override fun parse(byteArray: ByteArray): Result> { + val importJson = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + return try { + importJson + .decodeFromStream(ByteArrayInputStream(byteArray)) + .asSuccess() + .mapCatching { exportData -> + exportData + .accounts + .toAuthenticatorItemEntities() + } + } catch (e: SerializationException) { + e.asFailure() + } catch (e: IllegalArgumentException) { + e.asFailure() + } catch (e: IOException) { + e.asFailure() + } + } + + private fun List.toAuthenticatorItemEntities() = + map { it.toAuthenticatorItemEntity() } + + private fun LastPassJsonExport.Account.toAuthenticatorItemEntity(): AuthenticatorItemEntity { + + // Lastpass only supports TOTP codes. + val type = AuthenticatorItemType.TOTP + + val algorithmEnum = AuthenticatorItemAlgorithm + .fromStringOrNull(algorithm) + ?: throw IllegalArgumentException("Unsupported algorithm.") + + return AuthenticatorItemEntity( + id = UUID.randomUUID().toString(), + key = secret, + type = type, + algorithm = algorithmEnum, + period = timeStep, + digits = digits, + issuer = originalIssuerName, + userId = null, + accountName = originalUserName, + favorite = isFavorite, + ) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt index f0d6352414..e86b614d03 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/imports/parsers/TwoFasExportParser.kt @@ -4,17 +4,20 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.Aut 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.platform.manager.imports.model.TwoFasJsonExport +import com.bitwarden.authenticator.data.platform.util.asFailure import com.bitwarden.authenticator.data.platform.util.asSuccess import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import java.io.ByteArrayInputStream +import java.io.IOException import java.util.UUID private const val TOKEN_TYPE_HOTP = "HOTP" /** - * A [ExportParser] responsible for transforming 2FAS export files into Bitwarden Authenticator + * An [ExportParser] responsible for transforming 2FAS export files into Bitwarden Authenticator * items. */ class TwoFasExportParser : ExportParser { @@ -30,14 +33,22 @@ class TwoFasExportParser : ExportParser { explicitNulls = false } - return importJson - .decodeFromStream(ByteArrayInputStream(byteArray)) - .asSuccess() - .map { exportData -> - exportData - .services - .toAuthenticatorItemEntities() - } + return try { + importJson + .decodeFromStream(ByteArrayInputStream(byteArray)) + .asSuccess() + .mapCatching { exportData -> + exportData + .services + .toAuthenticatorItemEntities() + } + } catch (e: SerializationException) { + e.asFailure() + } catch (e: IllegalArgumentException) { + e.asFailure() + } catch (e: IOException) { + e.asFailure() + } } private fun List.toAuthenticatorItemEntities() = @@ -51,11 +62,7 @@ class TwoFasExportParser : ExportParser { if (tokenType.equals(other = TOKEN_TYPE_HOTP, ignoreCase = true)) { null } else { - AuthenticatorItemType - .entries - .find { entry -> - entry.name.equals(other = tokenType, ignoreCase = true) - } + AuthenticatorItemType.fromStringOrNull(tokenType) } } ?: throw IllegalArgumentException("Unsupported OTP type: ${otp.tokenType}.") diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt index c6924d55c8..9474b8cd70 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ImportFormatExtensions.kt @@ -12,4 +12,5 @@ val ImportFileFormat.displayLabel: Text get() = when (this) { ImportFileFormat.BITWARDEN_JSON -> R.string.import_format_label_bitwarden_json.asText() ImportFileFormat.TWO_FAS_JSON -> R.string.import_format_label_2fas_json.asText() + ImportFileFormat.LAST_PASS_JSON -> R.string.import_format_label_lastpass_json.asText() } diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 7cd0993a2b..f23f86965d 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -4,4 +4,5 @@ .csv Bitwarden (.json) 2FAS (.2fas) + LastPass (.json)