summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/service/sync-logins/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/service/sync-logins/src
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/service/sync-logins/src')
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml5
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt46
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt69
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt129
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt280
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt87
-rw-r--r--mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties1
7 files changed, 617 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..816719811c
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt
new file mode 100644
index 0000000000..96baa7d646
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.sync.logins
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.async
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.storage.Login
+import mozilla.components.concept.storage.LoginEntry
+import mozilla.components.concept.storage.LoginValidationDelegate
+import mozilla.components.concept.storage.LoginValidationDelegate.Result
+import mozilla.components.concept.storage.LoginsStorage
+import mozilla.components.support.base.log.logger.Logger
+
+/**
+ * A delegate that will check against [storage] to see if a given Login can be persisted, and return
+ * information about why it can or cannot.
+ */
+class DefaultLoginValidationDelegate(
+ private val storage: Lazy<LoginsStorage>,
+ private val scope: CoroutineScope = CoroutineScope(IO),
+ private val crashReporting: CrashReporting? = null,
+) : LoginValidationDelegate {
+ private val logger = Logger("DefaultAddonUpdater")
+
+ /**
+ * Compares a [Login] to a passed in list of potential dupes [Login] or queries underlying
+ * storage for potential dupes list of [Login] to determine if it should be updated or created.
+ */
+ override fun shouldUpdateOrCreateAsync(entry: LoginEntry): Deferred<Result> {
+ return scope.async {
+ val foundLogin = try {
+ storage.value.findLoginToUpdate(entry)
+ } catch (e: LoginsApiException) {
+ logger.warn("Failure in shouldUpdateOrCreateAsync: $e")
+ crashReporting?.submitCaughtException(e)
+ null
+ }
+ if (foundLogin == null) Result.CanBeCreated else Result.CanBeUpdated(foundLogin)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt
new file mode 100644
index 0000000000..892930e28d
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.sync.logins
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import mozilla.components.concept.storage.Login
+import mozilla.components.concept.storage.LoginEntry
+import mozilla.components.concept.storage.LoginStorageDelegate
+import mozilla.components.concept.storage.LoginsStorage
+
+/**
+ * [LoginStorageDelegate] implementation.
+ *
+ * An abstraction that handles the persistence and retrieval of [LoginEntry]s so that Gecko doesn't
+ * have to.
+ *
+ * In order to use this class, attach it to the active [GeckoRuntime] as its `loginStorageDelegate`.
+ * It is not designed to work with other engines.
+ *
+ * This class is part of a complex flow integrating Gecko and Application Services code, which is
+ * described here:
+ *
+ * - GV finds something on a page that it believes could be autofilled
+ * - GV calls [onLoginFetch]
+ * - We retrieve all [Login]s with matching domains (if any) from [loginStorage]
+ * - We return these [Login]s to GV
+ * - GV autofills one of the returned [Login]s into the page
+ * - GV calls [onLoginUsed] to let us know which [Login] was used
+ * - User submits their credentials
+ * - GV detects something that looks like a login submission
+ * - ([GeckoLoginStorageDelegate] is not involved with this step)
+ * `SaveLoginDialogFragment` is shown to the user, who decides whether or not
+ * to save the [LoginEntry] and gives them a chance to manually adjust the
+ * username/password fields.
+ * - `SaveLoginDialogFragment` uses `DefaultLoginValidationDelegate` to determine
+ * what the result of the operation will be: saving a new login,
+ * updating an existing login, or filling in a blank username.
+ * - If the user accepts: GV calls [onLoginSave] with the [LoginEntry]
+ */
+class GeckoLoginStorageDelegate(
+ private val loginStorage: Lazy<LoginsStorage>,
+ private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
+) : LoginStorageDelegate {
+
+ override fun onLoginUsed(login: Login) {
+ scope.launch {
+ loginStorage.value.touch(login.guid)
+ }
+ }
+
+ override fun onLoginFetch(domain: String): Deferred<List<Login>> {
+ return scope.async {
+ loginStorage.value.getByBaseDomain(domain)
+ }
+ }
+
+ @Synchronized
+ override fun onLoginSave(login: LoginEntry) {
+ scope.launch {
+ loginStorage.value.addOrUpdate(login)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt
new file mode 100644
index 0000000000..a16b45cac2
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.sync.logins
+
+import android.content.Context
+import android.content.SharedPreferences
+import mozilla.appservices.logins.KeyRegenerationEventReason
+import mozilla.appservices.logins.checkCanary
+import mozilla.appservices.logins.createCanary
+import mozilla.appservices.logins.decryptFields
+import mozilla.appservices.logins.recordKeyRegenerationEvent
+import mozilla.components.concept.storage.EncryptedLogin
+import mozilla.components.concept.storage.KeyGenerationReason
+import mozilla.components.concept.storage.KeyManager
+import mozilla.components.concept.storage.Login
+import mozilla.components.concept.storage.ManagedKey
+import mozilla.components.lib.dataprotect.SecureAbove22Preferences
+
+/**
+ * A class that knows how to encrypt & decrypt strings, backed by application-services' logins lib.
+ * Used for protecting usernames/passwords at rest.
+ *
+ * This class manages creation and storage of the encryption key.
+ * It also keeps track of abnormal events, such as managed key going missing or getting corrupted.
+ *
+ * @param context [Context] used for obtaining [SharedPreferences] for managing internal prefs.
+ * @param securePrefs A [SecureAbove22Preferences] instance used for storing the managed key.
+ */
+class LoginsCrypto(
+ private val context: Context,
+ private val securePrefs: SecureAbove22Preferences,
+ private val storage: SyncableLoginsStorage,
+) : KeyManager() {
+ private val plaintextPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
+
+ override suspend fun recoverFromKeyLoss(reason: KeyGenerationReason.RecoveryNeeded) {
+ val telemetryEventReason = when (reason) {
+ is KeyGenerationReason.RecoveryNeeded.Lost -> KeyRegenerationEventReason.Lost
+ is KeyGenerationReason.RecoveryNeeded.Corrupt -> KeyRegenerationEventReason.Corrupt
+ is KeyGenerationReason.RecoveryNeeded.AbnormalState -> KeyRegenerationEventReason.Other
+ }
+ recordKeyRegenerationEvent(telemetryEventReason)
+ storage.conn.getStorage().wipeLocal()
+ }
+
+ override fun getStoredCanary(): String? {
+ return plaintextPrefs.getString(CANARY_PHRASE_CIPHERTEXT_KEY, null)
+ }
+
+ override fun getStoredKey(): String? {
+ return securePrefs.getString(LOGINS_KEY)
+ }
+
+ override fun storeKeyAndCanary(key: String) {
+ // To consider: should this be a non-destructive operation, just in case?
+ // e.g. if we thought we lost the key, but actually did not, that would let us recover data later on.
+ // otherwise, if we mess up and override a perfectly good key, the data is gone for good.
+ securePrefs.putString(LOGINS_KEY, key)
+ // To detect key corruption or absence, use the newly generated key to encrypt a known string.
+ // See isKeyValid below.
+ plaintextPrefs
+ .edit()
+ .putString(CANARY_PHRASE_CIPHERTEXT_KEY, createCanary(CANARY_PHRASE_PLAINTEXT, key))
+ .apply()
+ }
+
+ override fun createKey(): String {
+ return mozilla.appservices.logins.createKey()
+ }
+
+ override fun isKeyRecoveryNeeded(rawKey: String, canary: String): KeyGenerationReason.RecoveryNeeded? {
+ return try {
+ if (checkCanary(canary, CANARY_PHRASE_PLAINTEXT, rawKey)) {
+ null
+ } else {
+ // A bad key should trigger a IncorrectKey, but check this branch just in case.
+ KeyGenerationReason.RecoveryNeeded.Corrupt
+ }
+ } catch (e: IncorrectKey) {
+ KeyGenerationReason.RecoveryNeeded.Corrupt
+ }
+ }
+
+ /**
+ * Decrypts ciphertext fields within [login], producing a plaintext [Login].
+ */
+ suspend fun decryptLogin(login: EncryptedLogin): Login {
+ return decryptLogin(login, getOrGenerateKey())
+ }
+
+ /**
+ * Decrypts ciphertext fields within [login], producing a plaintext [Login].
+ *
+ * This version inputs a ManagedKey. Use this for operations that
+ * decrypt multiple logins to avoid constructing the key multiple times.
+ */
+ fun decryptLogin(login: EncryptedLogin, key: ManagedKey): Login {
+ val secFields = decryptFields(login.secFields, key.key)
+ // Note: The autofill code catches errors on decryptFields and returns
+ // null, but it's not as easy to recover in this case since the code
+ // almost certainly going to need to a [Login], so we just throw in
+ // that case. Decryption errors shouldn't be happen as long as the
+ // canary checking code below is working correctly
+
+ return Login(
+ guid = login.guid,
+ origin = login.origin,
+ username = secFields.username,
+ password = secFields.password,
+ formActionOrigin = login.formActionOrigin,
+ httpRealm = login.httpRealm,
+ usernameField = login.usernameField,
+ passwordField = login.passwordField,
+ timesUsed = login.timesUsed,
+ timeCreated = login.timeCreated,
+ timeLastUsed = login.timeLastUsed,
+ timePasswordChanged = login.timePasswordChanged,
+ )
+ }
+
+ companion object {
+ const val PREFS_NAME = "loginsCrypto"
+ const val LOGINS_KEY = "loginsKey"
+ const val CANARY_PHRASE_CIPHERTEXT_KEY = "canaryPhrase"
+ const val CANARY_PHRASE_PLAINTEXT = "a string for checking validity of the key"
+ }
+}
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt
new file mode 100644
index 0000000000..8eafd25aab
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt
@@ -0,0 +1,280 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.sync.logins
+
+import android.content.Context
+import androidx.annotation.GuardedBy
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import mozilla.appservices.logins.DatabaseLoginsStorage
+import mozilla.components.concept.storage.EncryptedLogin
+import mozilla.components.concept.storage.Login
+import mozilla.components.concept.storage.LoginEntry
+import mozilla.components.concept.storage.LoginsStorage
+import mozilla.components.concept.sync.SyncableStore
+import mozilla.components.lib.dataprotect.SecureAbove22Preferences
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.utils.logElapsedTime
+import java.io.Closeable
+
+// Current database
+const val DB_NAME = "logins2.sqlite"
+
+// Name of our preferences file
+const val PREFS_NAME = "logins"
+
+// SQLCipher migration status.
+// - 0 / unset: We haven't done the SQLCipher migration
+// - 1: We performed v1 of the SQLCipher migration
+//
+// We no longer migrate SQLCipher, if the user hasn't
+// successfully migrated - we delete the DB
+const val SQL_CIPHER_MIGRATION = "sql-cipher-migration"
+
+/**
+ * The telemetry ping from a successful sync
+ */
+typealias SyncTelemetryPing = mozilla.appservices.sync15.SyncTelemetryPing
+
+/**
+ * The base class of all errors emitted by logins storage.
+ *
+ * Concrete instances of this class are thrown for operations which are
+ * not expected to be handled in a meaningful way by the application.
+ *
+ * For example, caught Rust panics, SQL errors, failure to generate secure
+ * random numbers, etc. are all examples of things which will result in a
+ * concrete `LoginsApiException`.
+ */
+typealias LoginsApiException = mozilla.appservices.logins.LoginsApiException
+
+/**
+ * This indicates that the authentication information (e.g. the [SyncUnlockInfo])
+ * provided to [AsyncLoginsStorage.sync] is invalid. This often indicates that it's
+ * stale and should be refreshed with FxA (however, care should be taken not to
+ * get into a loop refreshing this information).
+ */
+typealias SyncAuthInvalidException = mozilla.appservices.logins.LoginsApiException.SyncAuthInvalid
+
+/**
+ * This is thrown if `update()` is performed with a record whose GUID
+ * does not exist.
+ */
+typealias NoSuchRecordException = mozilla.appservices.logins.LoginsApiException.NoSuchRecord
+
+/**
+ * This is thrown on attempts to insert or update a record so that it
+ * is no longer valid, where "invalid" is defined as such:
+ *
+ * - A record with a blank `password` is invalid.
+ * - A record with a blank `hostname` is invalid.
+ * - A record that doesn't have a `formSubmitURL` nor a `httpRealm` is invalid.
+ * - A record that has both a `formSubmitURL` and a `httpRealm` is invalid.
+ */
+typealias InvalidRecordException = mozilla.appservices.logins.LoginsApiException.InvalidRecord
+
+/**
+ * Error encrypting/decrypting logins data
+ */
+typealias IncorrectKey = mozilla.appservices.logins.LoginsApiException.IncorrectKey
+
+/**
+ * Implements [LoginsStorage] and [SyncableStore] using the application-services logins library.
+ *
+ * Synchronization is handled via the SyncManager by calling [registerWithSyncManager]
+ */
+class SyncableLoginsStorage(
+ private val context: Context,
+ private val securePrefs: Lazy<SecureAbove22Preferences>,
+) : LoginsStorage, SyncableStore, AutoCloseable {
+ private val plaintextPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
+ private val logger = Logger("SyncableLoginsStorage")
+ private val coroutineContext by lazy { Dispatchers.IO }
+ val crypto by lazy { LoginsCrypto(context, securePrefs.value, this) }
+
+ internal val conn by lazy {
+ // We do not migrate SQLCipher anymore, we should delete it if it exists
+ runBlocking(coroutineContext) {
+ deleteSQLCipherDBIfNeeded()
+ }
+ LoginStorageConnection.init(dbPath = context.getDatabasePath(DB_NAME).absolutePath)
+ LoginStorageConnection
+ }
+
+ /**
+ * "Warms up" this storage layer by establishing the database connection.
+ */
+ suspend fun warmUp() = withContext(coroutineContext) {
+ logElapsedTime(logger, "Warming up storage") { conn }
+ Unit
+ }
+
+ /**
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(LoginsApiException::class)
+ override suspend fun wipeLocal() = withContext(coroutineContext) {
+ conn.getStorage().wipeLocal()
+ }
+
+ /**
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(LoginsApiException::class)
+ override suspend fun delete(guid: String): Boolean = withContext(coroutineContext) {
+ conn.getStorage().delete(guid)
+ }
+
+ /**
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(LoginsApiException::class)
+ override suspend fun get(guid: String): Login? = withContext(coroutineContext) {
+ conn.getStorage().get(guid)?.toEncryptedLogin()?.let { crypto.decryptLogin(it) }
+ }
+
+ /**
+ * @throws [NoSuchRecordException] if the login does not exist.
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(NoSuchRecordException::class, LoginsApiException::class)
+ override suspend fun touch(guid: String) = withContext(coroutineContext) {
+ conn.getStorage().touch(guid)
+ }
+
+ /**
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(LoginsApiException::class)
+ override suspend fun list(): List<Login> = withContext(coroutineContext) {
+ val key = crypto.getOrGenerateKey()
+ conn.getStorage().list().map { crypto.decryptLogin(it.toEncryptedLogin(), key) }
+ }
+
+ /**
+ * @throws [InvalidRecordException] if the record is invalid.
+ * @throws [IncorrectKey] if the encryption key can't decrypt the login
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(IncorrectKey::class, InvalidRecordException::class, LoginsApiException::class)
+ override suspend fun add(entry: LoginEntry) = withContext(coroutineContext) {
+ conn.getStorage().add(entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin()
+ }
+
+ /**
+ * @throws [NoSuchRecordException] if the login does not exist.
+ * @throws [IncorrectKey] if the encryption key can't decrypt the login
+ * @throws [InvalidRecordException] if the update would create an invalid record.
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(
+ IncorrectKey::class,
+ NoSuchRecordException::class,
+ InvalidRecordException::class,
+ LoginsApiException::class,
+ )
+ override suspend fun update(guid: String, entry: LoginEntry) = withContext(coroutineContext) {
+ conn.getStorage().update(guid, entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin()
+ }
+
+ /**
+ * @throws [InvalidRecordException] if the update would create an invalid record.
+ * @throws [IncorrectKey] if the encryption key can't decrypt the login
+ * @throws [LoginsApiException] if the storage is locked, and on unexpected
+ * errors (IO failure, rust panics, etc)
+ */
+ @Throws(IncorrectKey::class, InvalidRecordException::class, LoginsApiException::class)
+ override suspend fun addOrUpdate(entry: LoginEntry) = withContext(coroutineContext) {
+ conn.getStorage().addOrUpdate(entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin()
+ }
+
+ override fun registerWithSyncManager() {
+ conn.getStorage().registerWithSyncManager()
+ }
+
+ /**
+ * @throws [LoginsApiException] On unexpected errors (IO failure, rust panics, etc)
+ */
+ @Throws(LoginsApiException::class)
+ override suspend fun getByBaseDomain(origin: String): List<Login> = withContext(coroutineContext) {
+ val key = crypto.getOrGenerateKey()
+ conn.getStorage().getByBaseDomain(origin).map { crypto.decryptLogin(it.toEncryptedLogin(), key) }
+ }
+
+ /**
+ * @throws [IncorrectKey] if the encryption key can't decrypt the login
+ * @throws [LoginsApiException] On unexpected errors (IO failure, rust panics, etc)
+ */
+ @Throws(LoginsApiException::class)
+ override suspend fun findLoginToUpdate(entry: LoginEntry): Login? = withContext(coroutineContext) {
+ conn.getStorage().findLoginToUpdate(entry.toLoginEntry(), crypto.getOrGenerateKey().key)?.toLogin()
+ }
+
+ /**
+ * @throws [IncorrectKey] if the encryption key can't decrypt the login
+ */
+ override suspend fun decryptLogin(login: EncryptedLogin) = crypto.decryptLogin(login)
+
+ override fun close() {
+ coroutineContext.cancel()
+ conn.close()
+ }
+
+ /*
+ * We not longer migrate SQLCipher, we delete the DB, key and any
+ * associated prefs
+ */
+ private suspend fun deleteSQLCipherDBIfNeeded() {
+ // Older database that was encrypted using SQLCipher
+ val dbNameSqlCipher = "logins.sqlite"
+ // Prefs key that we stored the old SQLCipher encryption key
+ val encryptionKeySqlCipher = "passwords"
+
+ val version = plaintextPrefs.getInt(SQL_CIPHER_MIGRATION, 0)
+ if (version == 0) {
+ // Older database that was encrypted using SQLCipher, majority of clients won't
+ // have anything to actually delete
+ securePrefs.value.remove(encryptionKeySqlCipher)
+ val file = context.getDatabasePath(dbNameSqlCipher)
+ file.delete()
+ }
+ plaintextPrefs.edit().putInt(SQL_CIPHER_MIGRATION, 1).apply()
+ }
+}
+
+/**
+ * A singleton wrapping a [LoginsStorage] connection.
+ */
+internal object LoginStorageConnection : Closeable {
+ @GuardedBy("this")
+ private var storage: DatabaseLoginsStorage? = null
+
+ internal fun init(dbPath: String = DB_NAME) = synchronized(this) {
+ if (storage == null) {
+ storage = DatabaseLoginsStorage(dbPath)
+ }
+ storage
+ }
+
+ internal fun getStorage(): DatabaseLoginsStorage = synchronized(this) {
+ check(storage != null) { "must call init first" }
+ return storage!!
+ }
+
+ override fun close() = synchronized(this) {
+ check(storage != null) { "must call init first" }
+ storage!!.close()
+ storage = null
+ }
+}
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt
new file mode 100644
index 0000000000..7141610251
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.sync.logins
+
+import mozilla.components.concept.storage.EncryptedLogin
+import mozilla.components.concept.storage.Login
+import mozilla.components.concept.storage.LoginEntry
+
+// Convert between application-services data classes and the ones in concept.storage.
+
+/**
+ * Convert A-S EncryptedLogin into A-C [EncryptedLogin].
+ */
+fun mozilla.appservices.logins.EncryptedLogin.toEncryptedLogin() = EncryptedLogin(
+ guid = record.id,
+ origin = fields.origin,
+ formActionOrigin = fields.formActionOrigin,
+ httpRealm = fields.httpRealm,
+ usernameField = fields.usernameField,
+ passwordField = fields.passwordField,
+ timesUsed = record.timesUsed,
+ timeCreated = record.timeCreated,
+ timeLastUsed = record.timeLastUsed,
+ timePasswordChanged = record.timePasswordChanged,
+ secFields = secFields,
+)
+
+/**
+ * Convert A-S Login into A-C [Login].
+ */
+fun mozilla.appservices.logins.Login.toLogin() = Login(
+ guid = record.id,
+ origin = fields.origin,
+ username = secFields.username,
+ password = secFields.password,
+ formActionOrigin = fields.formActionOrigin,
+ httpRealm = fields.httpRealm,
+ usernameField = fields.usernameField,
+ passwordField = fields.passwordField,
+ timesUsed = record.timesUsed,
+ timeCreated = record.timeCreated,
+ timeLastUsed = record.timeLastUsed,
+ timePasswordChanged = record.timePasswordChanged,
+)
+
+/**
+ * Convert A-C [LoginEntry] into A-S LoginEntry.
+ */
+fun LoginEntry.toLoginEntry() = mozilla.appservices.logins.LoginEntry(
+ fields = mozilla.appservices.logins.LoginFields(
+ origin = origin,
+ formActionOrigin = formActionOrigin,
+ httpRealm = httpRealm,
+ usernameField = usernameField,
+ passwordField = passwordField,
+ ),
+ secFields = mozilla.appservices.logins.SecureLoginFields(
+ username = username,
+ password = password,
+ ),
+)
+
+/**
+ * Convert A-C [Login] into A-S Login.
+ */
+fun Login.toLogin() = mozilla.appservices.logins.Login(
+ record = mozilla.appservices.logins.RecordFields(
+ id = guid,
+ timesUsed = timesUsed,
+ timeCreated = timeCreated,
+ timeLastUsed = timeLastUsed,
+ timePasswordChanged = timePasswordChanged,
+ ),
+ fields = mozilla.appservices.logins.LoginFields(
+ origin = origin,
+ formActionOrigin = formActionOrigin,
+ httpRealm = httpRealm,
+ usernameField = usernameField,
+ passwordField = passwordField,
+ ),
+ secFields = mozilla.appservices.logins.SecureLoginFields(
+ username = username,
+ password = password,
+ ),
+)
diff --git a/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28