summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/service/sync-logins
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
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')
-rw-r--r--mobile/android/android-components/components/service/sync-logins/README.md76
-rw-r--r--mobile/android/android-components/components/service/sync-logins/build.gradle53
-rw-r--r--mobile/android/android-components/components/service/sync-logins/proguard-rules-consumer.pro1
-rw-r--r--mobile/android/android-components/components/service/sync-logins/proguard-rules.pro25
-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
11 files changed, 772 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/sync-logins/README.md b/mobile/android/android-components/components/service/sync-logins/README.md
new file mode 100644
index 0000000000..8cf3711bb4
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/README.md
@@ -0,0 +1,76 @@
+# [Android Components](../../../README.md) > Service > Firefox Sync - Logins
+
+A library for integrating with Firefox Sync - Logins.
+
+## Before using this component
+Products sending telemetry and using this component *must request* a data-review following [this process](https://wiki.mozilla.org/Firefox/Data_Collection).
+This component provides data collection using the [Glean SDK](https://mozilla.github.io/glean/book/index.html).
+The list of metrics being collected is available in the [metrics documentation](../../support/sync-telemetry/docs/metrics.md).
+
+## Motivation
+
+The **Firefox Sync - Logins Component** provides a way for Android applications to do the following:
+
+* Retrieve the Logins (url / password) data type from [Firefox Sync](https://www.mozilla.org/en-US/firefox/features/sync/)
+
+## Usage
+
+### Before using this component
+
+The `mozilla.appservices.logins` component collects telemetry using the [Glean SDK](https://mozilla.github.io/glean/).
+Applications that send telemetry via Glean *must ensure* they have received appropriate data-review following
+[the Firefox Data Collection process](https://wiki.mozilla.org/Firefox/Data_Collection) before integrating this component.
+
+Details on the metrics collected by the `mozilla.appservices.logins` component are available
+[here](https://github.com/mozilla/application-services/tree/main/docs/metrics/logins/metrics.md)
+
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```
+implementation "org.mozilla.components:service-sync-logins:{latest-version}"
+```
+
+You will also need the Firefox Accounts component to be able to obtain the keys to decrypt the Logins data:
+
+```
+implementation "org.mozilla.components:fxa:{latest-version}
+```
+
+### Known Issues
+
+* Android 6.0 is temporary not supported and will probably crash the application.
+
+## API
+
+This implements the login-related interfaces from `mozilla.components.concept.storage`.
+
+## FAQ
+
+### Which exceptions do I need to handle?
+
+It depends, but probably only `SyncAuthInvalid`, but potentially `IncorrectKey`.
+
+- You need to handle `SyncAuthInvalid`. You can do this by refreshing the FxA authentication (you should only do this once, and not in e.g. a loop). Most/All consumers will need to do this.
+
+- `IncorrectKey`: If you're sure the key you have used is valid, the only way to handle this is likely to delete the file containing the database (as the data is unreadable without the key). On the bright side, for sync users it should all be pulled down on the next sync.
+
+- `NoSuchRecord`, `InvalidRecord` all indicate problems with either your code or the arguments given to various functions. You may trigger and handle these if you like (it may be more convenient in some scenarios), but code that wishes to completely avoid them should be able to.
+
+The errors reported as "raw" `LoginsApiException` are things like Rust panics, errors reported by OpenSSL or SQLcipher, corrupt data on the server (things that are not JSON after decryption), bugs in our code, etc. You don't need to handle these, and it would likely be beneficial (but of course not necessary) to report them via some sort of telemetry, if any is available.
+
+### Can I use an in-memory SQLcipher connection with `DatabaseLoginsStorage`?
+
+Just create a `DatabaseLoginsStorage` with the path `:memory:`, and it will work. You may also use a [SQLite URI filename](https://www.sqlite.org/uri.html) with the parameter `mode=memory`. See https://www.sqlite.org/inmemorydb.html for more options and further information.
+
+### What's `wipeLocal`?
+
+`wipeLocal` deletes all local data from the database, bringing us back to the state prior to the first local write (or sync). That is, it returns it to an empty database.
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/service/sync-logins/build.gradle b/mobile/android/android-components/components/service/sync-logins/build.gradle
new file mode 100644
index 0000000000..6a80afa113
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/build.gradle
@@ -0,0 +1,53 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ lint {
+ warningsAsErrors true
+ abortOnError true
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ consumerProguardFiles 'proguard-rules-consumer.pro'
+ }
+ }
+
+ namespace 'mozilla.components.browser.storage.sync.logins'
+}
+
+dependencies {
+ // Parts of this dependency are typealiase'd or are otherwise part of this module's public API.
+ api (ComponentsDependencies.mozilla_appservices_logins) {
+ // Use our own version of the Glean dependency,
+ // which might be different from the version declared by A-S.
+ exclude group: 'org.mozilla.components', module: 'service-glean'
+ }
+ api ComponentsDependencies.mozilla_appservices_sync15
+
+ // Types defined in concept-sync are part of this module's public API.
+ api project(':concept-sync')
+ api project(':lib-dataprotect')
+
+ implementation project(':concept-storage')
+ implementation project(':support-utils')
+ implementation project(':service-glean')
+
+ implementation ComponentsDependencies.kotlin_coroutines
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/service/sync-logins/proguard-rules-consumer.pro b/mobile/android/android-components/components/service/sync-logins/proguard-rules-consumer.pro
new file mode 100644
index 0000000000..d3456cd17e
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/proguard-rules-consumer.pro
@@ -0,0 +1 @@
+# ProGuard rules for consumers of this library.
diff --git a/mobile/android/android-components/components/service/sync-logins/proguard-rules.pro b/mobile/android/android-components/components/service/sync-logins/proguard-rules.pro
new file mode 100644
index 0000000000..50e2b38a97
--- /dev/null
+++ b/mobile/android/android-components/components/service/sync-logins/proguard-rules.pro
@@ -0,0 +1,25 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /Users/sebastian/Library/Android/sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
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