Commit 9257f15d authored by MozLando's avatar MozLando
Browse files

Merge #6128



6128: Closes #5715: Login storage refactor r=csadilek a=grigoryk
Co-authored-by: default avatarGrisha Kruglov <gkruglov@mozilla.com>
parents b67fa5a6 aa9a36aa
......@@ -5,6 +5,114 @@
package mozilla.components.concept.storage
import kotlinx.coroutines.Deferred
import org.json.JSONObject
/**
* An interface describing a storage layer for logins/passwords.
*/
@SuppressWarnings("TooManyFunctions")
interface LoginsStorage : AutoCloseable {
/**
* Deletes all login records. These deletions will be synced to the server on the next call to sync.
*/
suspend fun wipe()
/**
* Clears out all local state, bringing us back to the state before the first write (or sync).
*/
suspend fun wipeLocal()
/**
* Deletes the password with the given ID.
*
* @return True if the deletion did anything, false otherwise.
*/
suspend fun delete(id: String): Boolean
/**
* Fetches a password from the underlying storage layer by its unique identifier.
*
* @param guid Unique identifier for the desired record.
* @return [Login] record, or `null` if the record does not exist.
*/
suspend fun get(guid: String): Login?
/**
* Marks the login with the given [guid] as `in-use`.
*
* @param guid Unique identifier for the desired record.
*/
suspend fun touch(guid: String)
/**
* Fetches the full list of logins from the underlying storage layer.
*
* @return A list of stored [Login] records.
*/
suspend fun list(): List<Login>
/**
* Inserts the provided login into the database, returning it's id.
*
* This function ignores values in metadata fields (`timesUsed`,
* `timeCreated`, `timeLastUsed`, and `timePasswordChanged`).
*
* If login has an empty id field, then a GUID will be
* generated automatically. The format of generated guids
* are left up to the implementation of LoginsStorage (in
* practice the [DatabaseLoginsStorage] generates 12-character
* base64url (RFC 4648) encoded strings.
*
* This will return an error result if a GUID is provided but
* collides with an existing record, or if the provided record
* is invalid (missing password, origin, or doesn't have exactly
* one of formSubmitURL and httpRealm).
*
* @param login A [Login] record to add.
* @return A `guid` for the created record.
*/
suspend fun add(login: Login): String
/**
* Updates the fields in the provided record.
*
* This will throw if `login.id` does not refer to
* a record that exists in the database, or if the provided record
* is invalid (missing password, origin, or doesn't have exactly
* one of formSubmitURL and httpRealm).
*
* Like `add`, this function will ignore values in metadata
* fields (`timesUsed`, `timeCreated`, `timeLastUsed`, and
* `timePasswordChanged`).
*
* @param login A [Login] record instance to update.
*/
suspend fun update(login: Login)
/**
* Bulk-import of a list of [Login].
* Storage must be empty; implementations expected to throw otherwise.
*
* @param logins A list of [Login] records to be imported.
* @return JSON object with detailed information about imported logins.
*/
suspend fun importLoginsAsync(logins: List<Login>): JSONObject
/**
* Checks if login already exists and is valid. Implementations expected to throw for invalid [login].
*
* @param login A [Login] record to validate.
*/
suspend fun ensureValid(login: Login)
/**
* Fetch the list of logins for some origin from the underlying storage layer.
*
* @param origin A host name used to look up [Login] records.
* @return A list of [Login] objects, representing matching logins.
*/
suspend fun getByBaseDomain(origin: String): List<Login>
}
/**
* Represents a login that can be used by autofill APIs.
......@@ -41,7 +149,31 @@ data class Login(
/**
* The password for this login entry.
*/
val password: String
val password: String,
/**
* Number of times this password has been used.
*/
val timesUsed: Int = 0,
/**
* Time of creation in milliseconds from the unix epoch.
*/
val timeCreated: Long = 0L,
/**
* Time of last use in milliseconds from the unix epoch.
*/
val timeLastUsed: Long = 0L,
/**
* Time of last password change in milliseconds from the unix epoch.
*/
val timePasswordChanged: Long = 0L,
/**
* HTML field associated with the [username].
*/
val usernameField: String? = null,
/**
* HTML field associated with the [password].
*/
val passwordField: String? = null
)
/**
......
......@@ -42,20 +42,6 @@ data class SyncAuthInfo(
val tokenServerUrl: String
)
/**
* An extension of [SyncableStore] that can be locked/unlocked using an encryption key.
*/
interface LockableStore : SyncableStore {
/**
* Executes a [block] while keeping the store in an unlocked state. Store is locked once [block] is finished.
*
* @param encryptionKey Plaintext encryption key used by the underlying storage implementation (e.g. sqlcipher)
* to key the store.
* @param block A lambda to execute while the store is unlocked.
*/
suspend fun <T> unlocked(encryptionKey: String, block: suspend (store: LockableStore) -> T): T
}
/**
* Describes a "sync" entry point for a storage layer.
*/
......@@ -67,9 +53,3 @@ interface SyncableStore {
*/
fun getHandle(): Long
}
/**
* A set of results of running a sync operation for multiple instances of [SyncableStore].
*/
typealias SyncResult = Map<String, StoreSyncStatus>
data class StoreSyncStatus(val status: SyncStatus)
......@@ -8,7 +8,6 @@ import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceType
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider
import mozilla.components.lib.dataprotect.SecureAbove22Preferences
typealias ServerConfig = mozilla.appservices.fxaclient.Config
......@@ -78,9 +77,6 @@ sealed class SyncEngine(val nativeName: String) {
/**
* A 'logins/passwords' engine.
*
* When using this engine, make sure to pass a [SecureAbove22Preferences] instance to
* [GlobalSyncableStoreProvider.configureKeyStorage] with a key storage instance that has a "passwords" key set.
*/
object Passwords : SyncEngine("passwords")
......
......@@ -5,7 +5,6 @@
package mozilla.components.service.fxa.sync
import mozilla.components.concept.sync.SyncableStore
import mozilla.components.lib.dataprotect.SecureAbove22Preferences
import mozilla.components.service.fxa.SyncConfig
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.manager.SyncEnginesStorage
......@@ -78,7 +77,6 @@ interface SyncStatusObserver {
*/
object GlobalSyncableStoreProvider {
private val stores: MutableMap<String, SyncableStore> = mutableMapOf()
private var keyStorage: SecureAbove22Preferences? = null
/**
* Configure an instance of [SyncableStore] for a [SyncEngine] enum.
......@@ -88,20 +86,9 @@ object GlobalSyncableStoreProvider {
stores[storePair.first.nativeName] = storePair.second
}
/**
* Set an instance of [SecureAbove22Preferences] used for accessing an encryption key for [SyncEngine.Passwords].
*
* @param ks An instance of [SecureAbove22Preferences].
*/
fun configureKeyStorage(ks: SecureAbove22Preferences) {
keyStorage = ks
}
internal fun getStore(name: String): SyncableStore? {
return stores[name]
}
internal fun getKeyStorage() = keyStorage
}
/**
......
......@@ -23,7 +23,6 @@ import androidx.work.WorkerParameters
import mozilla.appservices.syncmanager.SyncParams
import mozilla.appservices.syncmanager.SyncServiceStatus
import mozilla.appservices.syncmanager.SyncManager as RustSyncManager
import mozilla.components.concept.sync.LockableStore
import mozilla.components.concept.sync.SyncableStore
import mozilla.components.service.fxa.FxaDeviceSettingsCache
import mozilla.components.service.fxa.SyncAuthInfoCache
......@@ -302,31 +301,7 @@ class WorkManagerSyncWorker(
return Result.success()
}
// We need a password storage encryption key, if password storage is configured to be synced. It needs to be
// unlocked for the duration of a sync.
val passwordStore = syncableStores.entries.find { it.key == SyncEngine.Passwords }?.value as? LockableStore
val passwordsKey = if (passwordStore == null) {
null
} else {
val ks = GlobalSyncableStoreProvider.getKeyStorage()
require(ks != null) {
"GlobalSyncableStoreProvider must be configured with a key storage instance when syncing passwords"
}
ks.getString(SyncEngine.Passwords.nativeName) ?: throw IllegalStateException(
"Key for SyncEngine.Passwords must be present in the key storage if password sync is enabled"
)
}
// Issue tracking moving locking/unlocking of storage layers to RustSyncManager:
// https://github.com/mozilla/application-services/issues/2102
return if (passwordStore != null) {
// We need to keep the store unlocked for the duration of the sync, as well as when we interact with
// setLogins API method.
passwordStore.unlocked(passwordsKey!!) { doSync(syncableStores) }
} else {
doSync(syncableStores)
}
return doSync(syncableStores)
}
@Suppress("LongMethod", "ComplexMethod")
......
......@@ -49,6 +49,8 @@ dependencies {
testImplementation Dependencies.mozilla_sync_logins
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_junit
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito
......
/* 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.async
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.plus
import mozilla.appservices.logins.DatabaseLoginsStorage
import mozilla.appservices.logins.InvalidRecordException
import mozilla.appservices.logins.LoginsStorage
import mozilla.components.concept.sync.SyncAuthInfo
import mozilla.components.concept.sync.SyncStatus
import mozilla.appservices.sync15.SyncTelemetryPing
import mozilla.components.concept.sync.LockableStore
import mozilla.components.support.sync.telemetry.SyncTelemetry
import org.json.JSONObject
/**
* This type contains the set of information required to successfully
* connect to the server and sync.
*/
typealias SyncUnlockInfo = mozilla.appservices.logins.SyncUnlockInfo
/**
* Raw password data that is stored by the storage implementation.
*/
typealias ServerPassword = mozilla.appservices.logins.ServerPassword
/**
* 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 `LoginsStorageException`.
*/
typealias LoginsStorageException = mozilla.appservices.logins.LoginsStorageException
/**
* 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.SyncAuthInvalidException
/**
* This is thrown if `lock()`/`unlock()` pairs don't match up.
*/
typealias MismatchedLockException = mozilla.appservices.logins.MismatchedLockException
/**
* This is thrown if `update()` is performed with a record whose ID
* does not exist.
*/
typealias NoSuchRecordException = mozilla.appservices.logins.NoSuchRecordException
/**
* This is thrown if `add()` is given a record whose `id` is not blank, and
* collides with a record already known to the storage instance.
*
* You can avoid ever worrying about this error by always providing blank
* `id` property when inserting new records.
*/
typealias IdCollisionException = mozilla.appservices.logins.IdCollisionException
/**
* 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.InvalidRecordException
/**
* This error is emitted in two cases:
*
* 1. An incorrect key is used to to open the login database
* 2. The file at the path specified is not a sqlite database.
*
* SQLCipher does not give any way to distinguish between these two cases.
*/
typealias InvalidKeyException = mozilla.appservices.logins.InvalidKeyException
/**
* This error is emitted if a request to a sync server failed.
*/
typealias RequestFailedException = mozilla.appservices.logins.RequestFailedException
/**
* An interface equivalent to the LoginsStorage interface, but where operations are
* asynchronous.
*/
@Suppress("TooManyFunctions")
interface AsyncLoginsStorage : AutoCloseable {
/** Locks the logins storage.
*
* @rejectsWith [MismatchedLockException] if we're already locked
*/
fun lock(): Deferred<Unit>
/** Unlocks the logins storage using the provided key.
*
* @rejectsWith [InvalidKeyException] if the encryption key is wrong, or the db is corrupt
* @rejectsWith [MismatchedLockException] if we're already unlocked
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun unlock(encryptionKey: String): Deferred<Unit>
/**
* Unlock (open) the database, using a byte string as the key.
* This is equivalent to calling unlock() after hex-encoding the bytes (lower
* case hexadecimal characters are used).
*
* @rejectsWith [InvalidKeyException] if the encryption key is wrong, or the db is corrupt
* @rejectsWith [MismatchedLockException] if the database is already unlocked
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun unlock(encryptionKey: ByteArray): Deferred<Unit>
/** Returns `true` if the storage is locked, false otherwise. */
fun isLocked(): Boolean
/**
* Synchronizes the logins storage layer with a remote layer.
*
* @rejectsWith [SyncAuthInvalidException] if authentication needs to be refreshed
* @rejectsWith [RequestFailedException] if there was a network error during connection.
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun sync(syncInfo: SyncUnlockInfo): Deferred<SyncTelemetryPing>
/**
* Delete all login records. These deletions will be synced to the server on the next call to sync.
*
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun wipe(): Deferred<Unit>
/**
* Clear out all local state, bringing us back to the state before the first write (or sync).
*
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun wipeLocal(): Deferred<Unit>
/**
* Deletes the password with the given ID.
*
* Resolves to true if the deletion did anything.
*
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun delete(id: String): Deferred<Boolean>
/**
* Fetches a password from the underlying storage layer by ID.
*
* Resolves to `null` if the record does not exist.
*
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun get(id: String): Deferred<ServerPassword?>
/**
* Marks the login with the given ID as `in-use`.
*
* @rejectsWith [NoSuchRecordException] if the login does not exist.
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun touch(id: String): Deferred<Unit>
/**
* Fetches the full list of passwords from the underlying storage layer.
*
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun list(): Deferred<List<ServerPassword>>
/**
* Inserts the provided login into the database, returning it's id.
*
* This function ignores values in metadata fields (`timesUsed`,
* `timeCreated`, `timeLastUsed`, and `timePasswordChanged`).
*
* If login has an empty id field, then a GUID will be
* generated automatically. The format of generated guids
* are left up to the implementation of LoginsStorage (in
* practice the [DatabaseLoginsStorage] generates 12-character
* base64url (RFC 4648) encoded strings.
*
* This will return an error result if a GUID is provided but
* collides with an existing record, or if the provided record
* is invalid (missing password, hostname, or doesn't have exactly
* one of formSubmitURL and httpRealm).
*
* @rejectsWith [IdCollisionException] if a nonempty id is provided, and
* @rejectsWith [InvalidRecordException] if the record is invalid.
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun add(login: ServerPassword): Deferred<String>
/**
* Updates the fields in the provided record.
*
* This will return an error if `login.id` does not refer to
* a record that exists in the database, or if the provided record
* is invalid (missing password, hostname, or doesn't have exactly
* one of formSubmitURL and httpRealm).
*
* Like `add`, this function will ignore values in metadata
* fields (`timesUsed`, `timeCreated`, `timeLastUsed`, and
* `timePasswordChanged`).
*
* @rejectsWith [NoSuchRecordException] if the login does not exist.
* @rejectsWith [InvalidRecordException] if the update would create an invalid record.
* @rejectsWith [LoginsStorageException] if the storage is locked, and on unexpected
* errors (IO failure, rust panics, etc)
*/
fun update(login: ServerPassword): Deferred<Unit>
/**
* Equivalent to `unlock(encryptionKey)`, but does not throw in the case
* that the database is already unlocked.
*
* @rejectsWith [InvalidKeyException] if the encryption key is wrong, or the db is corrupt
* @rejectsWith [LoginsStorageException] if there was some other error opening the database
*/
fun ensureUnlocked(encryptionKey: String): Deferred<Unit>
/**
* Equivalent to `unlock(encryptionKey)`, but does not throw in the case
* that the database is already unlocked.
*
* @rejectsWith [InvalidKeyException] if the encryption key is wrong, or the db is corrupt
* @rejectsWith [LoginsStorageException] if there was some other error opening the database
*/
fun ensureUnlocked(encryptionKey: ByteArray): Deferred<Unit>
/**
* Equivalent to `lock()`, but does not throw in the case that
* the database is already unlocked. Never rejects.
*/
fun ensureLocked(): Deferred<Unit>
/**
* This should be removed. See: https://github.com/mozilla/application-services/issues/1877
*
* Note: handles do not remain valid after locking / unlocking the logins database.
*
* @return raw internal handle that could be used for referencing underlying logins database.
* Use it with SyncManager.
*/
fun getHandle(): Long
/**
* Bulk-import of a list of [ServerPassword].
* Storage must be empty, otherwise underlying implementation this will throw.
*
* @return number of records that could not be imported.
*
* @rejectsWith [LoginsStorageException] If DB isn't empty during an import; also, on unexpected errors
* (IO failure, rust panics, etc).