diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt index b2f072d081..689f8ee73c 100644 --- a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt +++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt @@ -6,9 +6,13 @@ package app.passwordstore.passkeys import androidx.annotation.RequiresApi +import app.passwordstore.Application import app.passwordstore.passkeys.crypto.PasskeyCryptoHandler import app.passwordstore.passkeys.provider.PasskeyCredentialProviderService +import app.passwordstore.passkeys.storage.PasskeyRemoteRefresher import app.passwordstore.passkeys.storage.PasskeyStorage +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -29,6 +33,14 @@ class AppPasskeyCredentialProviderService : PasskeyCredentialProviderService() { override val providerActivity: Class get() = AppPasskeyProviderActivity::class.java + override val remoteRefresher: PasskeyRemoteRefresher? + get() = + if (Application.instance.sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true)) { + entryPoint.passkeyGitSyncEngine() + } else { + null + } + @EntryPoint @InstallIn(SingletonComponent::class) interface PasskeysEntryPoint { @@ -36,5 +48,7 @@ class AppPasskeyCredentialProviderService : PasskeyCredentialProviderService() { fun passkeyStorage(): PasskeyStorage fun passkeyCryptoHandler(): PasskeyCryptoHandler + + fun passkeyGitSyncEngine(): PasskeyGitSyncEngine } } diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt index 8e0131b406..e74237742f 100644 --- a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt +++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt @@ -33,7 +33,6 @@ import app.passwordstore.passkeys.provider.PasskeyAuthenticator import app.passwordstore.passkeys.provider.PasskeyCredentialProviderService import app.passwordstore.passkeys.provider.PasskeyProviderUtils import app.passwordstore.passkeys.provider.caller.WebAuthnCallerVerifier -import app.passwordstore.passkeys.storage.GitSyncResult import app.passwordstore.passkeys.storage.MissingRecipientKeyException import app.passwordstore.passkeys.storage.PasskeyRepositoryState import app.passwordstore.passkeys.storage.PasskeyStorage @@ -44,7 +43,6 @@ import app.passwordstore.passkeys.storage.SignatureCounterPolicy import app.passwordstore.passkeys.storage.SignatureCounterTransaction import app.passwordstore.passkeys.storage.SourceVersionResult import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.git.base.BaseGitActivity.GitOp import app.passwordstore.ui.pgp.PGPKeyListActivity import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.settings.PreferenceKeys @@ -80,30 +78,7 @@ class AppPasskeyProviderActivity : BaseGitActivity() { if (!sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true)) return if (gitSettings.url == null) return if (PasswordRepository.repository == null) return - lifecycleScope.launch(dispatcherProvider.io()) { - try { - val oldHead = generationProvider.currentGitHead() - launchGitOperation(GitOp.SYNC) - .fold( - success = { - val newHead = generationProvider.currentGitHead() - val syncResult = - GitSyncResult( - oldHead = oldHead, - newHead = newHead, - worktreeChanged = oldHead != newHead, - conflicts = emptyList(), - ) - passkeyRepositoryState.onGitSyncCompleted(syncResult) - generationProvider.bumpWorktreeGeneration() - logcat { "Passkey auto-sync completed" } - }, - failure = { logcat(LogPriority.WARN) { "Passkey auto-sync failed: $it" } }, - ) - } catch (e: Exception) { - logcat(LogPriority.WARN) { "Passkey auto-sync crashed: $e" } - } - } + PasskeySyncWorker.enqueue(this) } @RequiresApi(34) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt new file mode 100644 index 0000000000..a017705ca9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt @@ -0,0 +1,520 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys + +import android.content.SharedPreferences +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.context.FilesDirPath +import app.passwordstore.injection.prefs.GitSecrets +import app.passwordstore.passkeys.storage.GitSyncResult +import app.passwordstore.passkeys.storage.PasskeyRemoteRefresher +import app.passwordstore.passkeys.storage.PasskeyRepositoryState +import app.passwordstore.passkeys.storage.RepositoryGenerationProvider +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.crypto.AESEncryption +import app.passwordstore.util.git.GitOperationCoordinator +import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.util.git.sshj.SshjConfig +import app.passwordstore.util.git.sshj.setUpBouncyCastleForSshj +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.FingerprintVerifier +import net.schmizz.sshj.userauth.method.AuthPassword +import net.schmizz.sshj.userauth.method.AuthPublickey +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.diff.DiffEntry +import org.eclipse.jgit.diff.DiffFormatter +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.revwalk.RevWalk +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.RemoteRefUpdate +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS +import org.eclipse.jgit.util.io.DisabledOutputStream + +internal class PasskeySyncException( + message: String, + cause: Throwable? = null, + val retryable: Boolean, +) : Exception(message, cause) + +/** Headless, serialized Git sync used by durable passkey background work and miss recovery. */ +@Singleton +class PasskeyGitSyncEngine +@Inject +constructor( + private val gitSettings: GitSettings, + @GitSecrets private val gitSecrets: SharedPreferences, + @FilesDirPath private val filesDirPath: String, + private val dispatcherProvider: DispatcherProvider, + private val passkeyRepositoryState: PasskeyRepositoryState, + private val generationProvider: RepositoryGenerationProvider, +) : PasskeyRemoteRefresher { + + suspend fun sync(): Result = executeGitOperation(::executeSync) + + /** Pull-only refresh used when Credential Manager has no matching local candidate. */ + override suspend fun refresh(): Result = executeGitOperation(::executeRefresh) + + private suspend fun executeGitOperation( + operation: suspend (Git) -> Unit + ): Result { + if (gitSettings.url == null) { + return Err(PasskeySyncException("Git remote is not configured", retryable = false)) + } + if (PasswordRepository.repository == null) { + PasswordRepository.initialize() + } + val repository = + PasswordRepository.repository + ?: return Err( + PasskeySyncException("Password repository is not initialized", retryable = false) + ) + + return withContext(dispatcherProvider.io()) { + GitOperationCoordinator.withLock { + try { + operation(Git(repository)) + Ok(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: PasskeySyncException) { + Err(e) + } catch (e: Exception) { + Err( + PasskeySyncException( + "Passkey Git operation failed: ${e.message}", + cause = e, + retryable = e.isRetryableTransportFailure(), + ) + ) + } + } + } + } + + private suspend fun executeSync(git: Git) { + try { + val oldHead = generationProvider.currentGitHead() + stageAndCommit(git) + pull(git, BACKGROUND_SYNC_TIMEOUT_SECONDS) + push(git) + completeRepositoryUpdate(git, oldHead) + } finally { + git.close() + } + } + + private suspend fun executeRefresh(git: Git) { + try { + val oldHead = generationProvider.currentGitHead() + pull(git, REMOTE_REFRESH_TIMEOUT_SECONDS) + completeRepositoryUpdate(git, oldHead) + } finally { + git.close() + } + } + + private suspend fun completeRepositoryUpdate(git: Git, oldHead: String?) { + val conflicts = git.status().call().conflicting.toList() + val newHead = generationProvider.currentGitHead() + val changedPaths = changedPaths(git, oldHead, newHead) + if (oldHead != newHead) { + generationProvider.bumpWorktreeGeneration() + } + val syncResult = + GitSyncResult( + oldHead = oldHead, + newHead = newHead, + worktreeChanged = oldHead != newHead, + conflicts = conflicts, + changedPaths = changedPaths, + ) + passkeyRepositoryState.onGitSyncCompleted(syncResult) + } + + private fun changedPaths(git: Git, oldHead: String?, newHead: String?): Set { + if (oldHead == null || newHead == null || oldHead == newHead) return emptySet() + val repository = git.repository + val oldId = repository.resolve(oldHead) ?: return emptySet() + val newId = repository.resolve(newHead) ?: return emptySet() + val paths = linkedSetOf() + + RevWalk(repository).use { walk -> + val oldTree = walk.parseCommit(oldId).tree + val newTree = walk.parseCommit(newId).tree + DiffFormatter(DisabledOutputStream.INSTANCE).use { formatter -> + formatter.setRepository(repository) + formatter.setDetectRenames(true) + formatter.scan(oldTree, newTree).forEach { entry -> + if (entry.oldPath != DiffEntry.DEV_NULL) paths.add(entry.oldPath) + if (entry.newPath != DiffEntry.DEV_NULL) paths.add(entry.newPath) + } + } + } + return paths + } + + private fun stageAndCommit(git: Git) { + git.add().addFilepattern(".").call() + val status = git.status().call() + if (status.uncommittedChanges.isEmpty()) return + + val name = gitSettings.authorName.ifEmpty { "root" } + val email = gitSettings.authorEmail.ifEmpty { "localhost" } + val identity = PersonIdent(name, email) + git + .commit() + .setAll(true) + .setMessage("[Android Password Store] Sync") + .setAuthor(identity) + .setCommitter(identity) + .call() + } + + private fun pull(git: Git, timeoutSeconds: Int) { + val command = git.pull().setRemote("origin").setRebase(gitSettings.rebaseOnPull) + val cleanup = configureTransport(command, timeoutSeconds) + try { + val result = command.call() + if (!result.isSuccessful) { + val detail = + result.rebaseResult?.status?.name + ?: result.mergeResult?.mergeStatus?.name + ?: "unknown status" + throw PasskeySyncException("Git pull failed: $detail", retryable = false) + } + } finally { + cleanup() + } + } + + private fun push(git: Git) { + val command = git.push().setPushAll().setRemote("origin") + val cleanup = configureTransport(command, BACKGROUND_SYNC_TIMEOUT_SECONDS) + try { + command.call().forEach { pushResult -> + pushResult.remoteUpdates.forEach { update -> + when (update.status) { + RemoteRefUpdate.Status.OK, + RemoteRefUpdate.Status.UP_TO_DATE -> {} + RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD, + RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, + RemoteRefUpdate.Status.NOT_ATTEMPTED -> + throw PasskeySyncException( + "Git push failed: ${update.status}${update.message?.let { ": $it" } ?: ""}", + retryable = true, + ) + else -> + throw PasskeySyncException( + "Git push failed: ${update.status}${update.message?.let { ": $it" } ?: ""}", + retryable = false, + ) + } + } + } + } finally { + cleanup() + } + } + + private fun configureTransport( + command: TransportCommand<*, *>, + timeoutSeconds: Int, + ): () -> Unit { + var sshFactory: HeadlessSshSessionFactory? = null + var credentialsProvider: WipingCredentialsProvider? = null + command.setTimeout(timeoutSeconds) + command.setTransportConfigCallback { transport: Transport -> + when (transport) { + is SshTransport -> { + sshFactory = + HeadlessSshSessionFactory( + authMode = gitSettings.authMode, + hostKeyFile = File(filesDirPath, ".host_key"), + passwordLoader = { requireStoredPassword(PreferenceKeys.HTTPS_PASSWORD) }, + sshPassphraseLoader = { + loadStoredPassword(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + }, + ) + transport.sshSessionFactory = sshFactory + } + else -> { + if (gitSettings.authMode == AuthMode.Password) { + credentialsProvider = + WipingCredentialsProvider(requireStoredPassword(PreferenceKeys.HTTPS_PASSWORD)) + transport.credentialsProvider = credentialsProvider + } + } + } + } + return { + sshFactory?.close() + credentialsProvider?.close() + } + } + + private fun requireStoredPassword(key: String): CharArray { + return loadStoredPassword(key) + ?: throw PasskeySyncException( + "Stored Git credential is unavailable; interactive sync is required", + retryable = false, + ) + } + + private fun loadStoredPassword(key: String): CharArray? { + val encrypted = gitSecrets.getString(key, null)?.toCharArray() ?: return null + return try { + AESEncryption.decrypt(encrypted, keyType = AESEncryption.KeyType.PERSISTENT) + } finally { + encrypted.fill('\u0000') + } + } + + private fun Throwable.isRetryableTransportFailure(): Boolean { + var current: Throwable? = this + while (current != null) { + if ( + current is IOException || + current is org.eclipse.jgit.api.errors.TransportException || + current is org.eclipse.jgit.errors.TransportException + ) { + return true + } + current = current.cause + } + return false + } + + private companion object { + const val BACKGROUND_SYNC_TIMEOUT_SECONDS = 10 + const val REMOTE_REFRESH_TIMEOUT_SECONDS = 3 + } +} + +private class WipingCredentialsProvider(private val password: CharArray) : CredentialsProvider() { + override fun isInteractive(): Boolean = false + + override fun supports(vararg items: CredentialItem): Boolean = items.all { + it is CredentialItem.Username || it is CredentialItem.Password + } + + override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { + items.forEach { item -> + when (item) { + is CredentialItem.Username -> item.value = uri?.user + is CredentialItem.Password -> item.value = password.copyOf() + else -> return false + } + } + return true + } + + override fun reset(uri: URIish?) {} + + fun close() { + password.fill('\u0000') + } +} + +private class HeadlessSshSessionFactory( + private val authMode: AuthMode, + private val hostKeyFile: File, + private val passwordLoader: () -> CharArray, + private val sshPassphraseLoader: () -> CharArray?, +) : org.eclipse.jgit.transport.SshSessionFactory() { + + private var currentSession: HeadlessSshSession? = null + + override fun getSession( + uri: URIish, + credentialsProvider: CredentialsProvider?, + fs: FS?, + tms: Int, + ): org.eclipse.jgit.transport.RemoteSession { + return currentSession + ?: HeadlessSshSession( + uri = uri, + authMode = authMode, + hostKeyFile = hostKeyFile, + passwordLoader = passwordLoader, + sshPassphraseLoader = sshPassphraseLoader, + ) + .connect() + .also { currentSession = it } + } + + override fun getType(): String = "HeadlessSshSessionFactory" + + fun close() { + currentSession?.close() + currentSession = null + } +} + +private class HeadlessSshSession( + uri: URIish, + private val authMode: AuthMode, + private val hostKeyFile: File, + private val passwordLoader: () -> CharArray, + private val sshPassphraseLoader: () -> CharArray?, +) : org.eclipse.jgit.transport.RemoteSession { + + private val uri = normalizeUri(uri) + private lateinit var ssh: SSHClient + private var currentSession: Session? = null + + fun connect(): HeadlessSshSession { + if (!hostKeyFile.exists()) { + throw PasskeySyncException( + "SSH host key is not trusted yet; interactive sync is required", + retryable = false, + ) + } + + setUpBouncyCastleForSshj() + ssh = SSHClient(SshjConfig()) + try { + ssh.addHostKeyVerifier(FingerprintVerifier.getInstance(hostKeyFile.readText())) + ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) + if (!ssh.isConnected) throw IOException("SSH connection failed") + val user = uri.user ?: "git" + + when (authMode) { + AuthMode.Password -> { + val password = passwordLoader() + try { + ssh.auth(user, AuthPassword(StaticPasswordFinder(password))) + } finally { + password.fill('\u0000') + } + } + AuthMode.SshKey -> authenticateWithSshKey(user) + AuthMode.None -> + throw PasskeySyncException( + "Headless SSH requires a configured authentication mode", + retryable = false, + ) + } + return this + } catch (e: Exception) { + ssh.close() + throw e + } + } + + private fun authenticateWithSshKey(user: String) { + if (!SshKey.exists) { + throw PasskeySyncException("SSH key is not configured", retryable = false) + } + if (SshKey.mustAuthenticate || SshKey.type == SshKey.Type.ImportedPGP) { + throw PasskeySyncException( + "SSH key requires interactive authentication", + retryable = false, + ) + } + + val passphrase = sshPassphraseLoader() ?: charArrayOf() + try { + val keyProvider = + try { + SshKey.provide(ssh, StaticPasswordFinder(passphrase)) + } catch (e: Exception) { + throw PasskeySyncException( + "SSH key cannot be unlocked non-interactively", + cause = e, + retryable = false, + ) + } ?: throw PasskeySyncException("SSH key is not configured", retryable = false) + try { + ssh.auth(user, AuthPublickey(keyProvider)) + } catch (e: Exception) { + throw PasskeySyncException( + "SSH authentication failed; interactive sync may be required", + cause = e, + retryable = false, + ) + } + } finally { + passphrase.fill('\u0000') + } + } + + override fun exec(commandName: String?, timeout: Int): Process { + currentSession?.close() + val session = ssh.startSession() + currentSession = session + return HeadlessSshProcess(session.exec(commandName), timeout.toLong()) + } + + override fun disconnect() { + currentSession?.close() + currentSession = null + } + + fun close() { + disconnect() + if (::ssh.isInitialized) ssh.close() + } + + private companion object { + fun normalizeUri(uri: URIish): URIish { + if (!uri.host.contains('@')) return uri + val userPlusHost = "${uri.user}@${uri.host}" + return uri + .setUser(userPlusHost.substringBeforeLast('@')) + .setHost(userPlusHost.substringAfterLast('@')) + } + } +} + +private class StaticPasswordFinder(private val password: CharArray) : PasswordFinder { + override fun reqPassword(resource: Resource<*>?): CharArray = password.copyOf() + + override fun shouldRetry(resource: Resource<*>?): Boolean = false +} + +private class HeadlessSshProcess( + private val command: Session.Command, + private val timeout: Long, +) : Process() { + override fun waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } + + override fun destroy() = command.close() + + override fun getOutputStream(): OutputStream = command.outputStream + + override fun getErrorStream(): InputStream = command.errorStream + + override fun exitValue(): Int = command.exitStatus + + override fun getInputStream(): InputStream = command.inputStream +} diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt new file mode 100644 index 0000000000..819fe5f145 --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import app.passwordstore.Application +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.fold +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.TimeUnit +import logcat.LogPriority +import logcat.logcat + +/** Durable, one-shot passkey Git sync. This is event-driven and does not schedule periodic work. */ +class PasskeySyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + private val entryPoint by unsafeLazy { + EntryPointAccessors.fromApplication(appContext, PasskeySyncWorkerEntryPoint::class.java) + } + + override suspend fun doWork(): Result { + if (!Application.instance.sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true)) { + return Result.success() + } + + // WorkManager may restart the application process before running queued work. Reopen the + // repository in that case so the sync remains durable across process death. + if (PasswordRepository.repository == null) { + PasswordRepository.initialize() + } + + return entryPoint + .syncEngine() + .sync() + .fold( + success = { + logcat { "Passkey auto-sync completed" } + Result.success() + }, + failure = { error -> + logcat(LogPriority.WARN) { "Passkey auto-sync failed: $error" } + val retryable = (error as? PasskeySyncException)?.retryable ?: true + if (retryable && runAttemptCount + 1 < MAX_ATTEMPTS) { + Result.retry() + } else { + Result.failure() + } + }, + ) + } + + companion object { + private const val WORK_NAME = "passkey_auto_sync" + private const val MAX_ATTEMPTS = 3 + + fun enqueue(context: Context) { + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(context.applicationContext) + .enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request, + ) + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface PasskeySyncWorkerEntryPoint { + fun syncEngine(): PasskeyGitSyncEngine + } +} diff --git a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt index 22bf0b162b..c57444530b 100644 --- a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt @@ -12,6 +12,7 @@ import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.injection.prefs.GitSecrets import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.ErrorMessages +import app.passwordstore.util.git.GitOperationCoordinator import app.passwordstore.util.git.operation.BreakOutOfDetached import app.passwordstore.util.git.operation.CloneOperation import app.passwordstore.util.git.operation.GcOperation @@ -66,18 +67,27 @@ abstract class BaseGitActivity : AppCompatActivity() { protected var remoteBranch = "" /** - * Attempt to launch the requested Git operation. + * Attempt to launch the requested Git operation. All repository-mutating operations are + * serialized with background passkey sync so JGit never operates on the shared worktree from two + * callers at once. * * @param operation The type of git operation to launch */ - suspend fun launchGitOperation(operation: GitOp): Result { + suspend fun launchGitOperation(operation: GitOp): Result = + GitOperationCoordinator.withLock { + launchGitOperationUnlocked(operation) + } + + private suspend fun launchGitOperationUnlocked(operation: GitOp): Result { if (gitSettings.url == null) { return Err(IllegalStateException("Git url is not set!")) } if (operation == GitOp.SYNC && !gitSettings.useMultiplexing) { - // If the server does not support multiple SSH channels per connection, we cannot run - // a sync operation without reconnecting and thus break sync into its two parts. - return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } + // Keep the lock across both operations so a background worker cannot interleave between + // pull and push when the server requires the reconnecting fallback. + return launchGitOperationUnlocked(GitOp.PULL).andThen { + launchGitOperationUnlocked(GitOp.PUSH) + } } val op = when (operation) { diff --git a/app/src/main/java/app/passwordstore/util/git/GitOperationCoordinator.kt b/app/src/main/java/app/passwordstore/util/git/GitOperationCoordinator.kt new file mode 100644 index 0000000000..671f9132f6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/GitOperationCoordinator.kt @@ -0,0 +1,22 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import kotlinx.coroutines.sync.Mutex + +/** Serializes Git operations that mutate the shared password-store repository. */ +object GitOperationCoordinator { + private val mutex = Mutex() + + suspend fun withLock(block: suspend () -> T): T { + mutex.lock() + return try { + block() + } finally { + mutex.unlock() + } + } +} diff --git a/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt b/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt new file mode 100644 index 0000000000..8155cbe515 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +class GitOperationCoordinatorTest { + + @Test + fun `serializes concurrent git operations`() = runBlocking { + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + val secondEntered = AtomicBoolean(false) + + val first = + launch(Dispatchers.Default) { + GitOperationCoordinator.withLock { + firstEntered.complete(Unit) + releaseFirst.await() + } + } + + firstEntered.await() + + val second = + launch(Dispatchers.Default) { + secondStarted.complete(Unit) + GitOperationCoordinator.withLock { secondEntered.set(true) } + } + + secondStarted.await() + assertFalse(secondEntered.get()) + + releaseFirst.complete(Unit) + withTimeout(1_000) { joinAll(first, second) } + assertTrue(secondEntered.get()) + } + + @Test + fun `releases lock when operation fails`() = runBlocking { + try { + GitOperationCoordinator.withLock { error("boom") } + } catch (_: IllegalStateException) {} + + var entered = false + withTimeout(1_000) { GitOperationCoordinator.withLock { entered = true } } + assertTrue(entered) + } +} diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/GitSyncResult.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/GitSyncResult.kt index a323df00bd..cfc51ba7b1 100644 --- a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/GitSyncResult.kt +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/GitSyncResult.kt @@ -10,6 +10,7 @@ public data class GitSyncResult( val newHead: String?, val worktreeChanged: Boolean, val conflicts: List, + val changedPaths: Set = emptySet(), ) { public val headChanged: Boolean @@ -19,8 +20,13 @@ public data class GitSyncResult( get() = conflicts.isNotEmpty() public fun affectsPasskeys(passkeyDirectory: String = "fido2"): Boolean { + val passkeyPrefix = "$passkeyDirectory/" + if (changedPaths.isNotEmpty()) { + return changedPaths.any { it.startsWith(passkeyPrefix) || it == ".gpg-id" } || + conflicts.any { it.startsWith(passkeyPrefix) || it == ".gpg-id" } + } return headChanged || worktreeChanged || - conflicts.any { it.startsWith(passkeyDirectory) || it == ".gpg-id" } + conflicts.any { it.startsWith(passkeyPrefix) || it == ".gpg-id" } } } diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt index 7b193502d8..7ad0a78790 100644 --- a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt @@ -160,6 +160,110 @@ public class IndexedPasskeyStorage( } } + private suspend fun reconcileGitChanges(changedPaths: Set): Boolean { + if (generationProvider == null || !indexLoaded) return false + if (changedPaths.any { it == ".gpg-id" }) return false + + val changedPasskeyPaths = + changedPaths + .asSequence() + .filter { it.startsWith("fido2/") && it.endsWith(".gpg") } + .map { it.removePrefix("fido2/") } + .toSet() + + return indexLoadMutex.withLock { + if (!indexLoaded) return@withLock false + + if (changedPasskeyPaths.isEmpty()) { + trackedGeneration = resolveCurrentGeneration() + return@withLock true + } + + for (relativePath in changedPasskeyPaths) { + if (!reconcileChangedPasskey(relativePath)) { + return@withLock false + } + } + + trackedGeneration = resolveCurrentGeneration() + logcat { + "Incrementally reconciled passkey index: changedPaths=${changedPasskeyPaths.size}" + } + true + } + } + + private suspend fun reconcileChangedPasskey(relativePath: String): Boolean { + val pathSegments = relativePath.split('/') + if (pathSegments.size != 2) return false + + val rpDirectory = pathSegments[0] + val fileName = pathSegments[1] + if (!fileName.endsWith(".gpg")) return false + val credentialId = hexToBytes(fileName.removeSuffix(".gpg")) ?: return false + + val provisionalRef = + try { + PasskeyFileRef( + canonicalRpId = rpDirectory, + credentialId = credentialId, + relativePath = relativePath, + ) + } catch (e: IllegalArgumentException) { + logcat(LogPriority.WARN) { "Rejected changed passkey path $relativePath: ${e.message}" } + return false + } + + val existingAtPath = metadataIndex.values.filter { it.fileRef.relativePath == relativePath } + existingAtPath.map { it.metadata }.forEach(::removeFromIndex) + + val versionResult = + delegate.resolveSourceVersionExact(provisionalRef).getOrElse { error -> + logcat(LogPriority.WARN) { + "Failed resolving changed passkey $relativePath: ${error.message}" + } + return false + } + val version = + when (versionResult) { + SourceVersionResult.Missing -> return true + is SourceVersionResult.Stable -> versionResult.version + is SourceVersionResult.Unavailable -> return false + } + + val duplicate = metadataIndex[credentialKey(credentialId)] + if (duplicate != null && duplicate.fileRef.relativePath != relativePath) { + logcat(LogPriority.WARN) { "Duplicate credential ID introduced by $relativePath" } + return false + } + + val metadata = + delegate.loadCredentialMetadata(provisionalRef, version).getOrElse { error -> + logcat(LogPriority.WARN) { + "Failed loading changed passkey metadata for $relativePath: ${error.message}" + } + return false + } + + if (!metadata.credentialId.contentEquals(credentialId)) { + logcat(LogPriority.WARN) { "Credential ID does not match changed path $relativePath" } + return false + } + if (sanitizeRpId(metadata.rpId) != rpDirectory) { + logcat(LogPriority.WARN) { "RP ID does not match changed path $relativePath" } + return false + } + + val canonicalRef = + PasskeyFileRef( + canonicalRpId = metadata.rpId, + credentialId = credentialId.copyOf(), + relativePath = relativePath, + ) + indexMetadata(metadata, version, canonicalRef) + return true + } + override suspend fun listMetadata(rpId: String?): Result, Throwable> { ensureIndexLoaded() @@ -418,8 +522,15 @@ public class IndexedPasskeyStorage( if (syncResult.headChanged && syncResult.newHead != null && hasRemoteConfigured) { repositoryBackedUp = true } + + if (syncResult.changedPaths.isNotEmpty() && reconcileGitChanges(syncResult.changedPaths)) { + return + } + if (syncResult.affectsPasskeys()) { invalidate(InvalidationReason.GIT_SYNC_COMPLETED) + } else if (indexLoaded) { + indexLoadMutex.withLock { trackedGeneration = resolveCurrentGeneration() } } } diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyRemoteRefresher.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyRemoteRefresher.kt new file mode 100644 index 0000000000..aed168af16 --- /dev/null +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyRemoteRefresher.kt @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys.storage + +import com.github.michaelbull.result.Result + +public fun interface PasskeyRemoteRefresher { + public suspend fun refresh(): Result +} diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/GitSyncResultTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/GitSyncResultTest.kt new file mode 100644 index 0000000000..c5a05d8ea5 --- /dev/null +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/GitSyncResultTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys.storage + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GitSyncResultTest { + + @Test + fun `known unrelated Git changes do not invalidate passkey index`() { + val result = + GitSyncResult( + oldHead = "old", + newHead = "new", + worktreeChanged = true, + conflicts = emptyList(), + changedPaths = setOf("passwords/example.gpg"), + ) + + assertFalse(result.affectsPasskeys()) + } + + @Test + fun `changed passkey file affects passkey index`() { + val result = + GitSyncResult( + oldHead = "old", + newHead = "new", + worktreeChanged = true, + conflicts = emptyList(), + changedPaths = setOf("fido2/example.com/abcd.gpg"), + ) + + assertTrue(result.affectsPasskeys()) + } + + @Test + fun `gpg id change affects passkey index`() { + val result = + GitSyncResult( + oldHead = "old", + newHead = "new", + worktreeChanged = true, + conflicts = emptyList(), + changedPaths = setOf(".gpg-id"), + ) + + assertTrue(result.affectsPasskeys()) + } +} diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt new file mode 100644 index 0000000000..33813c910f --- /dev/null +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package app.passwordstore.passkeys.storage + +import app.passwordstore.passkeys.model.PasskeyCredential +import app.passwordstore.passkeys.model.PasskeyMetadata +import app.passwordstore.passkeys.model.SensitivePasskeyCredential +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.getOrElse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant +import kotlinx.coroutines.runBlocking + +class IndexedPasskeyStorageGitSyncTest { + + @Test + fun `Git sync reconciles changed passkey directly without rescanning index`() = runBlocking { + val delegate = TrackingStorage() + val generationProvider = FakeGenerationProvider() + val storage = IndexedPasskeyStorage(delegate, generationProvider) + + assertTrue(storage.listMetadata().isOk) + assertEquals(1, delegate.fullScanCount) + + val credentialId = byteArrayOf(0x01, 0x02) + val relativePath = "example.com/0102.gpg" + delegate.remoteMetadata = + PasskeyMetadata( + credentialId = credentialId, + rpId = "example.com", + userName = "alice", + userDisplayName = "Alice", + createdAt = Instant.fromEpochMilliseconds(1_000), + ) + delegate.remoteVersion = version(generation = 1) + generationProvider.head = "new" + generationProvider.worktreeGeneration = 1 + + storage.onGitSyncCompleted( + GitSyncResult( + oldHead = "old", + newHead = "new", + worktreeChanged = true, + conflicts = emptyList(), + changedPaths = setOf("fido2/$relativePath"), + ) + ) + + val metadata = storage.listMetadata("example.com").getOrElse { emptyList() } + assertEquals(1, metadata.size) + assertTrue(metadata.single().credentialId.contentEquals(credentialId)) + assertEquals(1, delegate.fullScanCount) + assertEquals(1, delegate.exactMetadataLoadCount) + } + + @Test + fun `Git deletion removes passkey directly without rescanning index`() = runBlocking { + val delegate = TrackingStorage() + val generationProvider = FakeGenerationProvider() + val storage = IndexedPasskeyStorage(delegate, generationProvider) + val credentialId = byteArrayOf(0x01, 0x02) + val changedPath = "fido2/example.com/0102.gpg" + + assertTrue(storage.listMetadata().isOk) + delegate.remoteMetadata = + PasskeyMetadata( + credentialId = credentialId, + rpId = "example.com", + userName = "alice", + userDisplayName = "Alice", + createdAt = Instant.fromEpochMilliseconds(1_000), + ) + delegate.remoteVersion = version(generation = 1) + generationProvider.head = "new" + generationProvider.worktreeGeneration = 1 + storage.onGitSyncCompleted(GitSyncResult("old", "new", true, emptyList(), setOf(changedPath))) + + delegate.remoteMetadata = null + delegate.remoteVersion = null + generationProvider.head = "newer" + generationProvider.worktreeGeneration = 2 + storage.onGitSyncCompleted(GitSyncResult("new", "newer", true, emptyList(), setOf(changedPath))) + + assertTrue(storage.listMetadata("example.com").getOrElse { emptyList() }.isEmpty()) + assertEquals(1, delegate.fullScanCount) + } + + private fun version(generation: Long): CredentialSourceVersion { + return CredentialSourceVersion( + repositoryGeneration = RepositoryGeneration("repo", null, generation), + canonicalPath = "/repo/fido2/example.com/0102.gpg", + fileSize = 128, + modifiedAtMillis = generation, + ciphertextDigest = byteArrayOf(generation.toByte()), + ) + } + + private class FakeGenerationProvider : RepositoryGenerationProvider { + var head: String? = "old" + var worktreeGeneration: Long = 0 + + override suspend fun currentGitHead(): String? = head + + override fun currentWorktreeGeneration(): Long = worktreeGeneration + + override fun bumpWorktreeGeneration() { + worktreeGeneration++ + } + + override fun repositoryIdentity(): String = "repo" + + override fun isInMergeOrRebaseState(): Boolean = false + } + + private class TrackingStorage : PasskeyStorage { + var fullScanCount = 0 + var exactMetadataLoadCount = 0 + var remoteMetadata: PasskeyMetadata? = null + var remoteVersion: CredentialSourceVersion? = null + + override suspend fun listMetadata(rpId: String?): Result, Throwable> = + Ok(emptyList()) + + override suspend fun listMetadataWithRefs( + rpId: String? + ): Result, Throwable> { + fullScanCount++ + return Ok(emptyList()) + } + + override suspend fun resolveSourceVersionExact( + ref: PasskeyFileRef + ): Result { + val version = remoteVersion + return if (version == null) { + Ok(SourceVersionResult.Missing) + } else { + Ok(SourceVersionResult.Stable(version)) + } + } + + override suspend fun loadCredentialMetadata( + ref: PasskeyFileRef, + expectedVersion: CredentialSourceVersion?, + ): Result { + exactMetadataLoadCount++ + return remoteMetadata?.let(::Ok) ?: Err(IllegalArgumentException("Credential not found")) + } + + override suspend fun loadForSigning( + credentialId: ByteArray + ): Result = Err(UnsupportedOperationException()) + + override suspend fun saveCredential( + credential: PasskeyCredential, + privateKey: ByteArray, + ): Result = Err(UnsupportedOperationException()) + + override suspend fun deleteCredential(credentialId: ByteArray): Result = + Err(UnsupportedOperationException()) + + override suspend fun updateSignCount( + credentialId: ByteArray, + newSignCount: ULong, + ): Result = Err(UnsupportedOperationException()) + } +} diff --git a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt index 03743e1e24..9e8b0973ba 100644 --- a/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt +++ b/passkeys/provider/src/main/kotlin/app/passwordstore/passkeys/provider/PasskeyCredentialProviderService.kt @@ -34,12 +34,20 @@ import androidx.credentials.provider.PublicKeyCredentialEntry import app.passwordstore.passkeys.crypto.PasskeyCryptoHandler import app.passwordstore.passkeys.model.PasskeyMetadata import app.passwordstore.passkeys.storage.InvalidationReason +import app.passwordstore.passkeys.storage.PasskeyRemoteRefresher import app.passwordstore.passkeys.storage.PasskeyRepositoryState import app.passwordstore.passkeys.storage.PasskeyStorage import com.github.michaelbull.result.fold import java.time.Instant +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import logcat.LogPriority import logcat.logcat @@ -49,84 +57,155 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi protected abstract val passkeyStorage: PasskeyStorage protected abstract val cryptoHandler: PasskeyCryptoHandler protected abstract val providerActivity: Class + protected open val remoteRefresher: PasskeyRemoteRefresher? + get() = null + + @Suppress("RawDispatchersUse") + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val refreshMutex = Mutex() + @Volatile private var refreshGeneration = 0L override fun onCreate() { super.onCreate() logcat { "PasskeyCredentialProviderService created" } } + override fun onDestroy() { + serviceScope.coroutineContext[Job]?.cancel() + super.onDestroy() + } + final override fun onBeginGetCredentialRequest( request: BeginGetCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { - try { - val options = - request.beginGetCredentialOptions.filterIsInstance() - if (options.isEmpty()) { - callback.onError(GetCredentialUnknownException("No passkey options available")) - return - } + val job = serviceScope.launch { + try { + val options = + request.beginGetCredentialOptions.filterIsInstance() + if (options.isEmpty()) { + if (!cancellationSignal.isCanceled) { + callback.onError(GetCredentialUnknownException("No passkey options available")) + } + return@launch + } + + val queries = parseGetQueries(options) + val observedRefreshGeneration = refreshGeneration + var entries = loadCredentialEntries(queries) - val entries = - mutableListOf().apply { - for (option in options) { - val parsedRequest = - PasskeyProviderUtils.json.decodeFromString(option.requestJson) - val rpId = - parsedRequest.rpId ?: parsedRequest.allowCredentials.firstNotNullOfOrNull { it.rpId } - if (rpId == null) { - logcat(LogPriority.WARN) { "Skipping passkey option without RP ID" } - continue - } - - @Suppress("RawDispatchersUse") - val metadata = - runBlocking(Dispatchers.IO) { - passkeyStorage - .listMetadata(rpId) - .fold( - success = { - PasskeyProviderUtils.selectCredentialsByMetadata( - it, - parsedRequest.allowCredentials, - ) - .map { metadata -> - PasskeyProviderUtils.loadStoredIdentity(passkeyStorage, metadata) - } - }, - failure = { - logcat(LogPriority.ERROR) { "Failed loading passkeys for $rpId: $it" } - emptyList() - }, - ) - } - - logcat { - "Passkey candidates: rpId=$rpId, allowCredentials=${parsedRequest.allowCredentials.size}, matches=${metadata.size}" - } - - val isAutoSelectAllowed = PasskeyProviderUtils.isAutoSelectAllowed(metadata.size) - addAll(metadata.map { meta -> buildCredentialEntry(option, meta, isAutoSelectAllowed) }) + if (entries.isEmpty() && queries.isNotEmpty() && remoteRefresher != null) { + logcat { "No local passkey candidates; attempting one remote Git refresh" } + if (refreshRemote(observedRefreshGeneration)) { + entries = loadCredentialEntries(queries) } } - if (entries.isEmpty()) { - callback.onError(GetCredentialUnknownException("No matching passkeys found")) - return - } + if (cancellationSignal.isCanceled) return@launch + if (entries.isEmpty()) { + callback.onError(GetCredentialUnknownException("No matching passkeys found")) + return@launch + } - callback.onResult( - BeginGetCredentialResponse( - credentialEntries = entries, - actions = emptyList(), - authenticationActions = emptyList(), - remoteEntry = null, + callback.onResult( + BeginGetCredentialResponse( + credentialEntries = entries, + actions = emptyList(), + authenticationActions = emptyList(), + remoteEntry = null, + ) ) + } catch (_: CancellationException) { + // Credential Manager no longer needs this response. + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Unable to build get-credential response: $e" } + if (!cancellationSignal.isCanceled) { + callback.onError(GetCredentialUnknownException(e.message ?: "Unknown passkey error")) + } + } + } + + cancellationSignal.setOnCancelListener { job.cancel() } + } + + private data class GetQuery( + val option: BeginGetPublicKeyCredentialOption, + val request: WebAuthnGetRequest, + val rpId: String, + ) + + private fun parseGetQueries(options: List): List { + return options.mapNotNull { option -> + val parsedRequest = + try { + PasskeyProviderUtils.json.decodeFromString(option.requestJson) + } catch (e: Exception) { + logcat(LogPriority.WARN) { "Skipping malformed passkey request: ${e.message}" } + return@mapNotNull null + } + val rpId = + parsedRequest.rpId ?: parsedRequest.allowCredentials.firstNotNullOfOrNull { it.rpId } + if (rpId == null) { + logcat(LogPriority.WARN) { "Skipping passkey option without RP ID" } + null + } else { + GetQuery(option, parsedRequest, rpId) + } + } + } + + private suspend fun loadCredentialEntries( + queries: List + ): List { + val entries = mutableListOf() + for (query in queries) { + val metadata = + passkeyStorage + .listMetadata(query.rpId) + .fold( + success = { + PasskeyProviderUtils.selectCredentialsByMetadata(it, query.request.allowCredentials) + .map { metadata -> + PasskeyProviderUtils.loadStoredIdentity(passkeyStorage, metadata) + } + }, + failure = { + logcat(LogPriority.ERROR) { "Failed loading passkeys for ${query.rpId}: $it" } + emptyList() + }, + ) + + logcat { + "Passkey candidates: rpId=${query.rpId}, allowCredentials=${query.request.allowCredentials.size}, matches=${metadata.size}" + } + + val isAutoSelectAllowed = PasskeyProviderUtils.isAutoSelectAllowed(metadata.size) + entries.addAll( + metadata.map { meta -> buildCredentialEntry(query.option, meta, isAutoSelectAllowed) } ) - } catch (e: Exception) { - logcat(LogPriority.ERROR) { "Unable to build get-credential response: $e" } - callback.onError(GetCredentialUnknownException(e.message ?: "Unknown passkey error")) + } + return entries + } + + private suspend fun refreshRemote(observedGeneration: Long): Boolean { + val refresher = remoteRefresher ?: return false + return refreshMutex.withLock { + if (refreshGeneration != observedGeneration) { + return@withLock true + } + refresher + .refresh() + .fold( + success = { + refreshGeneration++ + true + }, + failure = { + logcat(LogPriority.WARN) { "Remote passkey refresh failed: $it" } + false + }, + ) } }