[BWA-14] Support importing LastPass exports (#98)

This commit is contained in:
Patrick Honkonen
2024-05-16 15:30:48 -04:00
committed by GitHub
parent 15251c840c
commit a1e7e92d6d
10 changed files with 195 additions and 29 deletions

View File

@@ -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) }
}
}

View File

@@ -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) }
}
}

View File

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

View File

@@ -8,4 +8,5 @@ enum class ImportFileFormat(
) {
BITWARDEN_JSON("application/json"),
TWO_FAS_JSON("*/*"),
LAST_PASS_JSON("application/json")
}

View File

@@ -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<Account>,
val folders: List<Folder>,
) {
@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,
)
}

View File

@@ -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<ExportJsonData>(ByteArrayInputStream(byteArray))
.asSuccess()
.map { exportData ->
exportData
.items
.toAuthenticatorItemEntities()
}
return try {
importJson
.decodeFromStream<ExportJsonData>(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<ExportJsonData.ExportItem>.toAuthenticatorItemEntities() =

View File

@@ -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<List<AuthenticatorItemEntity>> {
val importJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
return try {
importJson
.decodeFromStream<LastPassJsonExport>(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<LastPassJsonExport.Account>.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,
)
}
}

View File

@@ -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<TwoFasJsonExport>(ByteArrayInputStream(byteArray))
.asSuccess()
.map { exportData ->
exportData
.services
.toAuthenticatorItemEntities()
}
return try {
importJson
.decodeFromStream<TwoFasJsonExport>(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<TwoFasJsonExport.Service>.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}.")

View File

@@ -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()
}

View File

@@ -4,4 +4,5 @@
<string name="export_format_label_csv">.csv</string>
<string name="import_format_label_bitwarden_json">Bitwarden (.json)</string>
<string name="import_format_label_2fas_json">2FAS (.2fas)</string>
<string name="import_format_label_lastpass_json">LastPass (.json)</string>
</resources>