From 3f9cda2377cce0d71c44379a2fdb2207fccb2c7c Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 30 Jul 2026 23:46:46 +0000 Subject: [PATCH 01/34] fix(passkeys): use WorkManager for passkey auto-sync to survive activity destruction Replace lifecycleScope-based git sync in AppPasskeyProviderActivity with a PasskeySyncWorker (CoroutineWorker) that runs independently of the activity lifecycle. The previous approach launched sync in lifecycleScope and immediately called finish(), which cancelled the coroutine before the git operation could complete. The worker performs git add/commit/pull/push directly using JGit with non-interactive SSH/HTTPS authentication. It gracefully fails when authentication requires user interaction (biometric prompts, host key verification dialogs) and logs the failure. Fixes #98 --- .../passkeys/AppPasskeyProviderActivity.kt | 27 +- .../passkeys/PasskeySyncWorker.kt | 464 ++++++++++++++++++ 2 files changed, 465 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyProviderActivity.kt index 8e0131b40..e74237742 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/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt new file mode 100644 index 000000000..b5e2ff388 --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -0,0 +1,464 @@ +/* + * 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 android.content.SharedPreferences +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.prefs.GitSecrets +import app.passwordstore.passkeys.storage.GitSyncResult +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.crypto.AESEncryption.KeyType +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.util.git.sshj.SshjConfig +import app.passwordstore.util.git.sshj.normalizeForSshj +import app.passwordstore.util.git.sshj.parseSshPublicKey +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.fold +import com.github.michaelbull.result.runCatching +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.io.File +import java.io.IOException +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyStore +import java.security.spec.PKCS8EncodedKeySpec +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.withContext +import logcat.LogPriority +import logcat.logcat +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.FingerprintVerifier +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +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.bouncycastle.jce.provider.BouncyCastleProvider +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.TransportHttp +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS + +class PasskeySyncWorker( + private val appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + private val hiltEntryPoint by unsafeLazy { + EntryPointAccessors.fromApplication(appContext, PasskeySyncWorkerEntryPoint::class.java) + } + + override suspend fun doWork(): androidx.work.ListenableWorker.Result { + return runCatching { + val repository = + PasswordRepository.repository + ?: throw IllegalStateException("Password repository is not initialized") + val git = Git(repository) + val gitSettings = hiltEntryPoint.gitSettings() + val dispatcherProvider = hiltEntryPoint.dispatcherProvider() + val passkeyRepositoryState = hiltEntryPoint.passkeyRepositoryState() + val generationProvider = hiltEntryPoint.generationProvider() + val gitSecrets = hiltEntryPoint.gitSecrets() + val hostKeyFile = File(appContext.filesDir, ".host_key") + val authMode = gitSettings.authMode + val rebase = gitSettings.rebaseOnPull + val useMultiplexing = gitSettings.useMultiplexing + + withContext(dispatcherProvider.io()) { + val oldHead = generationProvider.currentGitHead() + + if (useMultiplexing) { + executeSync(git, gitSettings, hostKeyFile, authMode, rebase, gitSecrets) + } else { + executePull(git, gitSettings, hostKeyFile, authMode, rebase, gitSecrets) + executePush(git, hostKeyFile, authMode, gitSecrets) + } + + 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" } + } + } + .fold( + success = { androidx.work.ListenableWorker.Result.success() }, + failure = { + logcat(LogPriority.WARN) { "Passkey auto-sync failed: ${it.message}" } + androidx.work.ListenableWorker.Result.failure() + }, + ) + } + + private fun executeSync( + git: Git, + gitSettings: GitSettings, + hostKeyFile: File, + authMode: AuthMode, + rebase: Boolean, + gitSecrets: SharedPreferences, + ) { + git.add().addFilepattern(".").call() + val status = git.status().call() + if (status.uncommittedChanges.isNotEmpty()) { + val name = gitSettings.authorName.ifEmpty { "root" } + val email = gitSettings.authorEmail.ifEmpty { "localhost" } + val identity = PersonIdent(name, email) + git.commit().setAll(true).setAuthor(identity).setCommitter(identity).call() + } + configureTransport(git.pull(), hostKeyFile, authMode, gitSecrets) + .setRebase(rebase) + .setRemote("origin") + .call() + configureTransport(git.push().setPushAll(), hostKeyFile, authMode, gitSecrets) + .setRemote("origin") + .call() + } + + private fun executePull( + git: Git, + gitSettings: GitSettings, + hostKeyFile: File, + authMode: AuthMode, + rebase: Boolean, + gitSecrets: SharedPreferences, + ) { + git.add().addFilepattern(".").call() + val status = git.status().call() + if (status.uncommittedChanges.isNotEmpty()) { + val name = gitSettings.authorName.ifEmpty { "root" } + val email = gitSettings.authorEmail.ifEmpty { "localhost" } + val identity = PersonIdent(name, email) + git.commit().setAll(true).setAuthor(identity).setCommitter(identity).call() + } + configureTransport(git.pull(), hostKeyFile, authMode, gitSecrets) + .setRebase(rebase) + .setRemote("origin") + .call() + } + + private fun executePush( + git: Git, + hostKeyFile: File, + authMode: AuthMode, + gitSecrets: SharedPreferences, + ) { + configureTransport(git.push().setPushAll(), hostKeyFile, authMode, gitSecrets) + .setRemote("origin") + .call() + } + + private fun > configureTransport( + command: T, + hostKeyFile: File, + authMode: AuthMode, + gitSecrets: SharedPreferences, + ): T { + command.setTransportConfigCallback { transport: Transport -> + when (transport) { + is SshTransport -> { + transport.sshSessionFactory = + createHeadlessSshSessionFactory(hostKeyFile, authMode, gitSecrets) + } + is TransportHttp -> { + if (authMode == AuthMode.Password) { + val storedPassword = getStoredHttpsPassword(gitSecrets) + if (storedPassword != null) { + transport.credentialsProvider = + object : CredentialsProvider() { + override fun isInteractive() = false + + override fun supports(vararg items: CredentialItem) = items.all { + it is CredentialItem.Username || it is CredentialItem.Password + } + + override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { + for (item in items) { + when (item) { + is CredentialItem.Username -> item.value = uri?.user + is CredentialItem.Password -> item.value = storedPassword.toCharArray() + else -> return false + } + } + return true + } + + override fun reset(uri: URIish?) {} + } + } + } + } + } + command.setTimeout(CONNECT_TIMEOUT) + } + return command + } + + private fun createHeadlessSshSessionFactory( + hostKeyFile: File, + authMode: AuthMode, + gitSecrets: SharedPreferences, + ): org.eclipse.jgit.transport.SshSessionFactory { + return object : org.eclipse.jgit.transport.SshSessionFactory() { + override fun getSession( + uri: URIish?, + credentialsProvider: CredentialsProvider?, + fs: FS?, + tms: Int, + ): org.eclipse.jgit.transport.RemoteSession { + setUpBouncyCastleForSshj() + val ssh = SSHClient(SshjConfig()) + if (hostKeyFile.exists()) { + val hostKeyEntry = hostKeyFile.readText() + ssh.addHostKeyVerifier(FingerprintVerifier.getInstance(hostKeyEntry)) + } else { + throw IOException( + "Host key not verified; cannot perform background sync without prior host key trust" + ) + } + val host = uri?.host ?: throw IOException("No host in URI") + val port = uri.port.takeUnless { it == -1 } ?: 22 + ssh.connect(host, port) + if (!ssh.isConnected) throw IOException("SSH connection failed") + + when (authMode) { + AuthMode.SshKey -> { + if (SshKey.mustAuthenticate) { + throw IOException( + "SSH key requires biometric authentication; cannot perform background sync" + ) + } + val keyProvider = + loadSshKeyNonInteractive(ssh, gitSecrets) + ?: throw IOException("Cannot load SSH key non-interactively") + ssh.auth(uri.user ?: "git", AuthPublickey(keyProvider)) + } + AuthMode.Password -> { + val password = + getStoredHttpsPassword(gitSecrets) + ?: throw IOException("No stored password for SSH password authentication") + ssh.auth( + uri.user ?: "git", + AuthPassword( + object : PasswordFinder { + override fun reqPassword(resource: Resource<*>?) = password.toCharArray() + + override fun shouldRetry(resource: Resource<*>?) = false + } + ), + ) + } + AuthMode.None -> {} + } + + return HeadlessRemoteSession(ssh) + } + + override fun getType(): String = "HeadlessSshSessionFactory" + } + } + + private fun loadSshKeyNonInteractive( + ssh: SSHClient, + gitSecrets: SharedPreferences, + ): KeyProvider? { + return runCatching { + when (SshKey.type) { + SshKey.Type.KeystoreNative -> { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val publicKey = + keyStore.getCertificate("sshkey")?.publicKey + ?: throw NullPointerException("No public key in keystore") + val privateKey = + keyStore.getKey("sshkey", null) + ?: throw NullPointerException("No private key in keystore") + SecurityUtils.setRegisterBouncyCastle(false) + SecurityUtils.setSecurityProvider(null) + ssh.loadKeys(KeyPair(normalizeForSshj(publicKey), privateKey)) + } + SshKey.Type.KeystoreWrappedEd25519 -> { + val publicKeyText = + SshKey.sshPublicKey ?: throw NullPointerException("No public key file") + val publicKey = + parseSshPublicKey(publicKeyText) + ?: throw NullPointerException("Cannot parse public key") + val encrypted = gitSecrets.getString("sshkey", "false:")?.split(":", limit = 2) + val mustAuth = encrypted?.getOrNull(0) == "true" + if (mustAuth) { + throw IOException("SSH key requires authentication; cannot load non-interactively") + } + val privateKeyEncoded = + AESEncryption.decryptToByteArray( + encrypted?.getOrNull(1)?.toCharArray(), + KeyType.PERSISTENT, + ) + val keyFactory = KeyFactory.getInstance("Ed25519", BouncyCastleProvider()) + val privateKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(privateKeyEncoded)) + SecurityUtils.setRegisterBouncyCastle(true) + SecurityUtils.setSecurityProvider(BouncyCastleProvider.PROVIDER_NAME) + ssh.loadKeys(KeyPair(publicKey, privateKey)) + } + SshKey.Type.Imported -> { + val privateKeyFile = File(appContext.filesDir, ".ssh_key") + if (!privateKeyFile.exists()) throw IOException("Imported SSH key file not found") + val storedPassphrase = + gitSecrets.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) + if (storedPassphrase != null && AESEncryption.isHardwareBacked(KeyType.PERSISTENT)) { + val passphrase = + AESEncryption.decrypt( + storedPassphrase.toCharArray(), + keyType = KeyType.PERSISTENT, + ) + ?.concatToString() + if (passphrase != null) { + ssh.loadKeys( + privateKeyFile.absolutePath, + object : PasswordFinder { + override fun reqPassword(resource: Resource<*>?) = passphrase.toCharArray() + + override fun shouldRetry(resource: Resource<*>?) = false + }, + ) + } else { + ssh.loadKeys( + privateKeyFile.absolutePath, + object : PasswordFinder { + override fun reqPassword(resource: Resource<*>?) = charArrayOf() + + override fun shouldRetry(resource: Resource<*>?) = false + }, + ) + } + } else { + ssh.loadKeys( + privateKeyFile.absolutePath, + object : PasswordFinder { + override fun reqPassword(resource: Resource<*>?) = charArrayOf() + + override fun shouldRetry(resource: Resource<*>?) = false + }, + ) + } + } + SshKey.Type.ImportedPGP -> { + throw IOException("PGP-backed SSH key requires interactive authentication") + } + null -> throw IOException("No SSH key configured") + } + } + .fold( + success = { it }, + failure = { + logcat(LogPriority.WARN) { "Failed to load SSH key non-interactively: ${it.message}" } + null + }, + ) + } + + private fun getStoredHttpsPassword(gitSecrets: SharedPreferences): String? { + val encrypted = gitSecrets.getString(PreferenceKeys.HTTPS_PASSWORD, null) ?: return null + return if (AESEncryption.isHardwareBacked(KeyType.PERSISTENT)) { + AESEncryption.decrypt(encrypted.toCharArray(), keyType = KeyType.PERSISTENT)?.concatToString() + } else null + } + + private class HeadlessRemoteSession(private val ssh: SSHClient) : + org.eclipse.jgit.transport.RemoteSession { + private var currentProcess: Session? = null + + override fun exec(command: String?, timeout: Int): Process { + val session = ssh.startSession() + currentProcess = session + val channel = session.exec(command) + return HeadlessProcess(channel, timeout.toLong()) + } + + override fun disconnect() { + currentProcess?.close() + currentProcess = null + ssh.close() + } + } + + private class HeadlessProcess( + 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() = command.outputStream + + override fun getErrorStream() = command.errorStream + + override fun exitValue(): Int = command.exitStatus + + override fun getInputStream() = command.inputStream + } + + companion object { + private const val CONNECT_TIMEOUT = 10 + private const val WORK_NAME = "passkey_auto_sync" + + fun enqueue(context: Context) { + val workRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context) + .enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + workRequest, + ) + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface PasskeySyncWorkerEntryPoint { + fun gitSettings(): GitSettings + + fun dispatcherProvider(): DispatcherProvider + + fun passkeyRepositoryState(): PasskeyRepositoryState + + fun generationProvider(): RepositoryGenerationProvider + + @GitSecrets fun gitSecrets(): SharedPreferences + } +} From a749d0286999798b42609c32dbba6f882141fa21 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 30 Jul 2026 23:56:53 +0000 Subject: [PATCH 02/34] fix(passkeys): add commit message and reduce duplication in PasskeySyncWorker --- .../passkeys/PasskeySyncWorker.kt | 87 ++++++++----------- 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt index b5e2ff388..aaf530835 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -122,6 +122,23 @@ class PasskeySyncWorker( ) } + private fun stageAndCommit(git: Git, gitSettings: GitSettings) { + git.add().addFilepattern(".").call() + val status = git.status().call() + if (status.uncommittedChanges.isNotEmpty()) { + 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 executeSync( git: Git, gitSettings: GitSettings, @@ -130,14 +147,7 @@ class PasskeySyncWorker( rebase: Boolean, gitSecrets: SharedPreferences, ) { - git.add().addFilepattern(".").call() - val status = git.status().call() - if (status.uncommittedChanges.isNotEmpty()) { - val name = gitSettings.authorName.ifEmpty { "root" } - val email = gitSettings.authorEmail.ifEmpty { "localhost" } - val identity = PersonIdent(name, email) - git.commit().setAll(true).setAuthor(identity).setCommitter(identity).call() - } + stageAndCommit(git, gitSettings) configureTransport(git.pull(), hostKeyFile, authMode, gitSecrets) .setRebase(rebase) .setRemote("origin") @@ -155,14 +165,7 @@ class PasskeySyncWorker( rebase: Boolean, gitSecrets: SharedPreferences, ) { - git.add().addFilepattern(".").call() - val status = git.status().call() - if (status.uncommittedChanges.isNotEmpty()) { - val name = gitSettings.authorName.ifEmpty { "root" } - val email = gitSettings.authorEmail.ifEmpty { "localhost" } - val identity = PersonIdent(name, email) - git.commit().setAll(true).setAuthor(identity).setCommitter(identity).call() - } + stageAndCommit(git, gitSettings) configureTransport(git.pull(), hostKeyFile, authMode, gitSecrets) .setRebase(rebase) .setRemote("origin") @@ -333,44 +336,22 @@ class PasskeySyncWorker( SshKey.Type.Imported -> { val privateKeyFile = File(appContext.filesDir, ".ssh_key") if (!privateKeyFile.exists()) throw IOException("Imported SSH key file not found") - val storedPassphrase = - gitSecrets.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) - if (storedPassphrase != null && AESEncryption.isHardwareBacked(KeyType.PERSISTENT)) { - val passphrase = - AESEncryption.decrypt( - storedPassphrase.toCharArray(), - keyType = KeyType.PERSISTENT, - ) - ?.concatToString() - if (passphrase != null) { - ssh.loadKeys( - privateKeyFile.absolutePath, - object : PasswordFinder { - override fun reqPassword(resource: Resource<*>?) = passphrase.toCharArray() - - override fun shouldRetry(resource: Resource<*>?) = false - }, - ) - } else { - ssh.loadKeys( - privateKeyFile.absolutePath, - object : PasswordFinder { - override fun reqPassword(resource: Resource<*>?) = charArrayOf() - - override fun shouldRetry(resource: Resource<*>?) = false - }, - ) - } - } else { - ssh.loadKeys( - privateKeyFile.absolutePath, - object : PasswordFinder { - override fun reqPassword(resource: Resource<*>?) = charArrayOf() + val passphrase = + gitSecrets + .getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) + ?.takeIf { AESEncryption.isHardwareBacked(KeyType.PERSISTENT) } + ?.let { + AESEncryption.decrypt(it.toCharArray(), keyType = KeyType.PERSISTENT) + ?.concatToString() + } + val passwordFinder = + object : PasswordFinder { + override fun reqPassword(resource: Resource<*>?) = + passphrase?.toCharArray() ?: charArrayOf() - override fun shouldRetry(resource: Resource<*>?) = false - }, - ) - } + override fun shouldRetry(resource: Resource<*>?) = false + } + ssh.loadKeys(privateKeyFile.absolutePath, passwordFinder) } SshKey.Type.ImportedPGP -> { throw IOException("PGP-backed SSH key requires interactive authentication") From 4d3213ddd77ee6d04949dc25d43667a88683706a Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 30 Jul 2026 23:59:33 +0000 Subject: [PATCH 03/34] fix(passkeys): cast Key to PrivateKey in PasskeySyncWorker --- .../main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt index aaf530835..35d4b568d 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -40,6 +40,7 @@ import java.io.IOException import java.security.KeyFactory import java.security.KeyPair import java.security.KeyStore +import java.security.PrivateKey import java.security.spec.PKCS8EncodedKeySpec import java.util.concurrent.TimeUnit import kotlinx.coroutines.withContext @@ -305,7 +306,7 @@ class PasskeySyncWorker( keyStore.getCertificate("sshkey")?.publicKey ?: throw NullPointerException("No public key in keystore") val privateKey = - keyStore.getKey("sshkey", null) + keyStore.getKey("sshkey", null) as? PrivateKey ?: throw NullPointerException("No private key in keystore") SecurityUtils.setRegisterBouncyCastle(false) SecurityUtils.setSecurityProvider(null) From 9b80a1a9697a20bf1d11ae3dc82e4154e3dbd1e1 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:24:17 +0200 Subject: [PATCH 04/34] serialize shared git operations --- .../util/git/GitOperationCoordinator.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/util/git/GitOperationCoordinator.kt 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 000000000..671f9132f --- /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() + } + } +} From 39605fdfa39cbcfa2237592fab9fbb484876ad5c Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:25:34 +0200 Subject: [PATCH 05/34] add reusable headless passkey git sync engine --- .../passkeys/PasskeyGitSyncEngine.kt | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt 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 000000000..2a50e75de --- /dev/null +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt @@ -0,0 +1,460 @@ +/* + * 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.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.lib.PersonIdent +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 + +internal class PasskeySyncException( + message: String, + cause: Throwable? = null, + val retryable: Boolean, +) : Exception(message, cause) + +/** Headless, serialized Git sync used by durable passkey background work. */ +@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, +) { + + suspend fun sync(): Result { + if (gitSettings.url == null) { + return Err(PasskeySyncException("Git remote is not configured", retryable = false)) + } + val repository = + PasswordRepository.repository + ?: return Err( + PasskeySyncException("Password repository is not initialized", retryable = false) + ) + + return withContext(dispatcherProvider.io()) { + GitOperationCoordinator.withLock { + try { + executeSync(Git(repository)) + Ok(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: PasskeySyncException) { + Err(e) + } catch (e: Exception) { + Err( + PasskeySyncException( + "Passkey Git sync failed: ${e.message}", + cause = e, + retryable = e.isRetryableTransportFailure(), + ) + ) + } + } + } + } + + private suspend fun executeSync(git: Git) { + try { + val oldHead = generationProvider.currentGitHead() + stageAndCommit(git) + pull(git) + push(git) + val conflicts = git.status().call().conflicting.toList() + val newHead = generationProvider.currentGitHead() + val syncResult = + GitSyncResult( + oldHead = oldHead, + newHead = newHead, + worktreeChanged = oldHead != newHead, + conflicts = conflicts, + ) + passkeyRepositoryState.onGitSyncCompleted(syncResult) + generationProvider.bumpWorktreeGeneration() + } finally { + git.close() + } + } + + 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) { + val command = git.pull().setRemote("origin").setRebase(gitSettings.rebaseOnPull) + val cleanup = configureTransport(command) + 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) + 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<*, *>): () -> Unit { + var sshFactory: HeadlessSshSessionFactory? = null + var credentialsProvider: WipingCredentialsProvider? = null + command.setTimeout(CONNECT_TIMEOUT_SECONDS) + 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 CONNECT_TIMEOUT_SECONDS = 10 + } +} + +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 +} From 98d36d2142cc91d7d6a352369a3980da2c73f165 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:25:53 +0200 Subject: [PATCH 06/34] make passkey sync durable and non-cancelling --- .../passkeys/PasskeySyncWorker.kt | 431 ++---------------- 1 file changed, 37 insertions(+), 394 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt index 35d4b568d..991ff7fad 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -6,426 +6,77 @@ package app.passwordstore.passkeys import android.content.Context -import android.content.SharedPreferences +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.data.repo.PasswordRepository -import app.passwordstore.injection.prefs.GitSecrets -import app.passwordstore.passkeys.storage.GitSyncResult -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.crypto.AESEncryption.KeyType +import app.passwordstore.Application +import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.git.sshj.SshKey -import app.passwordstore.util.git.sshj.SshjConfig -import app.passwordstore.util.git.sshj.normalizeForSshj -import app.passwordstore.util.git.sshj.parseSshPublicKey -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.fold -import com.github.michaelbull.result.runCatching import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent -import java.io.File -import java.io.IOException -import java.security.KeyFactory -import java.security.KeyPair -import java.security.KeyStore -import java.security.PrivateKey -import java.security.spec.PKCS8EncodedKeySpec import java.util.concurrent.TimeUnit -import kotlinx.coroutines.withContext import logcat.LogPriority import logcat.logcat -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.connection.channel.direct.Session -import net.schmizz.sshj.transport.verification.FingerprintVerifier -import net.schmizz.sshj.userauth.keyprovider.KeyProvider -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.bouncycastle.jce.provider.BouncyCastleProvider -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.PersonIdent -import org.eclipse.jgit.transport.CredentialItem -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.SshTransport -import org.eclipse.jgit.transport.Transport -import org.eclipse.jgit.transport.TransportHttp -import org.eclipse.jgit.transport.URIish -import org.eclipse.jgit.util.FS +/** Durable, one-shot passkey Git sync. This is event-driven and does not schedule periodic work. */ class PasskeySyncWorker( - private val appContext: Context, + appContext: Context, params: WorkerParameters, ) : CoroutineWorker(appContext, params) { - private val hiltEntryPoint by unsafeLazy { + private val entryPoint by unsafeLazy { EntryPointAccessors.fromApplication(appContext, PasskeySyncWorkerEntryPoint::class.java) } - override suspend fun doWork(): androidx.work.ListenableWorker.Result { - return runCatching { - val repository = - PasswordRepository.repository - ?: throw IllegalStateException("Password repository is not initialized") - val git = Git(repository) - val gitSettings = hiltEntryPoint.gitSettings() - val dispatcherProvider = hiltEntryPoint.dispatcherProvider() - val passkeyRepositoryState = hiltEntryPoint.passkeyRepositoryState() - val generationProvider = hiltEntryPoint.generationProvider() - val gitSecrets = hiltEntryPoint.gitSecrets() - val hostKeyFile = File(appContext.filesDir, ".host_key") - val authMode = gitSettings.authMode - val rebase = gitSettings.rebaseOnPull - val useMultiplexing = gitSettings.useMultiplexing - - withContext(dispatcherProvider.io()) { - val oldHead = generationProvider.currentGitHead() - - if (useMultiplexing) { - executeSync(git, gitSettings, hostKeyFile, authMode, rebase, gitSecrets) - } else { - executePull(git, gitSettings, hostKeyFile, authMode, rebase, gitSecrets) - executePush(git, hostKeyFile, authMode, gitSecrets) - } - - 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" } - } - } - .fold( - success = { androidx.work.ListenableWorker.Result.success() }, - failure = { - logcat(LogPriority.WARN) { "Passkey auto-sync failed: ${it.message}" } - androidx.work.ListenableWorker.Result.failure() - }, - ) - } - - private fun stageAndCommit(git: Git, gitSettings: GitSettings) { - git.add().addFilepattern(".").call() - val status = git.status().call() - if (status.uncommittedChanges.isNotEmpty()) { - 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 executeSync( - git: Git, - gitSettings: GitSettings, - hostKeyFile: File, - authMode: AuthMode, - rebase: Boolean, - gitSecrets: SharedPreferences, - ) { - stageAndCommit(git, gitSettings) - configureTransport(git.pull(), hostKeyFile, authMode, gitSecrets) - .setRebase(rebase) - .setRemote("origin") - .call() - configureTransport(git.push().setPushAll(), hostKeyFile, authMode, gitSecrets) - .setRemote("origin") - .call() - } - - private fun executePull( - git: Git, - gitSettings: GitSettings, - hostKeyFile: File, - authMode: AuthMode, - rebase: Boolean, - gitSecrets: SharedPreferences, - ) { - stageAndCommit(git, gitSettings) - configureTransport(git.pull(), hostKeyFile, authMode, gitSecrets) - .setRebase(rebase) - .setRemote("origin") - .call() - } - - private fun executePush( - git: Git, - hostKeyFile: File, - authMode: AuthMode, - gitSecrets: SharedPreferences, - ) { - configureTransport(git.push().setPushAll(), hostKeyFile, authMode, gitSecrets) - .setRemote("origin") - .call() - } - - private fun > configureTransport( - command: T, - hostKeyFile: File, - authMode: AuthMode, - gitSecrets: SharedPreferences, - ): T { - command.setTransportConfigCallback { transport: Transport -> - when (transport) { - is SshTransport -> { - transport.sshSessionFactory = - createHeadlessSshSessionFactory(hostKeyFile, authMode, gitSecrets) - } - is TransportHttp -> { - if (authMode == AuthMode.Password) { - val storedPassword = getStoredHttpsPassword(gitSecrets) - if (storedPassword != null) { - transport.credentialsProvider = - object : CredentialsProvider() { - override fun isInteractive() = false - - override fun supports(vararg items: CredentialItem) = items.all { - it is CredentialItem.Username || it is CredentialItem.Password - } - - override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { - for (item in items) { - when (item) { - is CredentialItem.Username -> item.value = uri?.user - is CredentialItem.Password -> item.value = storedPassword.toCharArray() - else -> return false - } - } - return true - } - - override fun reset(uri: URIish?) {} - } - } - } - } - } - command.setTimeout(CONNECT_TIMEOUT) + override suspend fun doWork(): Result { + if (!Application.instance.sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true)) { + return Result.success() } - return command - } - private fun createHeadlessSshSessionFactory( - hostKeyFile: File, - authMode: AuthMode, - gitSecrets: SharedPreferences, - ): org.eclipse.jgit.transport.SshSessionFactory { - return object : org.eclipse.jgit.transport.SshSessionFactory() { - override fun getSession( - uri: URIish?, - credentialsProvider: CredentialsProvider?, - fs: FS?, - tms: Int, - ): org.eclipse.jgit.transport.RemoteSession { - setUpBouncyCastleForSshj() - val ssh = SSHClient(SshjConfig()) - if (hostKeyFile.exists()) { - val hostKeyEntry = hostKeyFile.readText() - ssh.addHostKeyVerifier(FingerprintVerifier.getInstance(hostKeyEntry)) + 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 { - throw IOException( - "Host key not verified; cannot perform background sync without prior host key trust" - ) - } - val host = uri?.host ?: throw IOException("No host in URI") - val port = uri.port.takeUnless { it == -1 } ?: 22 - ssh.connect(host, port) - if (!ssh.isConnected) throw IOException("SSH connection failed") - - when (authMode) { - AuthMode.SshKey -> { - if (SshKey.mustAuthenticate) { - throw IOException( - "SSH key requires biometric authentication; cannot perform background sync" - ) - } - val keyProvider = - loadSshKeyNonInteractive(ssh, gitSecrets) - ?: throw IOException("Cannot load SSH key non-interactively") - ssh.auth(uri.user ?: "git", AuthPublickey(keyProvider)) - } - AuthMode.Password -> { - val password = - getStoredHttpsPassword(gitSecrets) - ?: throw IOException("No stored password for SSH password authentication") - ssh.auth( - uri.user ?: "git", - AuthPassword( - object : PasswordFinder { - override fun reqPassword(resource: Resource<*>?) = password.toCharArray() - - override fun shouldRetry(resource: Resource<*>?) = false - } - ), - ) - } - AuthMode.None -> {} + Result.failure() } - - return HeadlessRemoteSession(ssh) - } - - override fun getType(): String = "HeadlessSshSessionFactory" - } - } - - private fun loadSshKeyNonInteractive( - ssh: SSHClient, - gitSecrets: SharedPreferences, - ): KeyProvider? { - return runCatching { - when (SshKey.type) { - SshKey.Type.KeystoreNative -> { - val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - val publicKey = - keyStore.getCertificate("sshkey")?.publicKey - ?: throw NullPointerException("No public key in keystore") - val privateKey = - keyStore.getKey("sshkey", null) as? PrivateKey - ?: throw NullPointerException("No private key in keystore") - SecurityUtils.setRegisterBouncyCastle(false) - SecurityUtils.setSecurityProvider(null) - ssh.loadKeys(KeyPair(normalizeForSshj(publicKey), privateKey)) - } - SshKey.Type.KeystoreWrappedEd25519 -> { - val publicKeyText = - SshKey.sshPublicKey ?: throw NullPointerException("No public key file") - val publicKey = - parseSshPublicKey(publicKeyText) - ?: throw NullPointerException("Cannot parse public key") - val encrypted = gitSecrets.getString("sshkey", "false:")?.split(":", limit = 2) - val mustAuth = encrypted?.getOrNull(0) == "true" - if (mustAuth) { - throw IOException("SSH key requires authentication; cannot load non-interactively") - } - val privateKeyEncoded = - AESEncryption.decryptToByteArray( - encrypted?.getOrNull(1)?.toCharArray(), - KeyType.PERSISTENT, - ) - val keyFactory = KeyFactory.getInstance("Ed25519", BouncyCastleProvider()) - val privateKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(privateKeyEncoded)) - SecurityUtils.setRegisterBouncyCastle(true) - SecurityUtils.setSecurityProvider(BouncyCastleProvider.PROVIDER_NAME) - ssh.loadKeys(KeyPair(publicKey, privateKey)) - } - SshKey.Type.Imported -> { - val privateKeyFile = File(appContext.filesDir, ".ssh_key") - if (!privateKeyFile.exists()) throw IOException("Imported SSH key file not found") - val passphrase = - gitSecrets - .getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE, null) - ?.takeIf { AESEncryption.isHardwareBacked(KeyType.PERSISTENT) } - ?.let { - AESEncryption.decrypt(it.toCharArray(), keyType = KeyType.PERSISTENT) - ?.concatToString() - } - val passwordFinder = - object : PasswordFinder { - override fun reqPassword(resource: Resource<*>?) = - passphrase?.toCharArray() ?: charArrayOf() - - override fun shouldRetry(resource: Resource<*>?) = false - } - ssh.loadKeys(privateKeyFile.absolutePath, passwordFinder) - } - SshKey.Type.ImportedPGP -> { - throw IOException("PGP-backed SSH key requires interactive authentication") - } - null -> throw IOException("No SSH key configured") - } - } - .fold( - success = { it }, - failure = { - logcat(LogPriority.WARN) { "Failed to load SSH key non-interactively: ${it.message}" } - null - }, - ) - } - - private fun getStoredHttpsPassword(gitSecrets: SharedPreferences): String? { - val encrypted = gitSecrets.getString(PreferenceKeys.HTTPS_PASSWORD, null) ?: return null - return if (AESEncryption.isHardwareBacked(KeyType.PERSISTENT)) { - AESEncryption.decrypt(encrypted.toCharArray(), keyType = KeyType.PERSISTENT)?.concatToString() - } else null - } - - private class HeadlessRemoteSession(private val ssh: SSHClient) : - org.eclipse.jgit.transport.RemoteSession { - private var currentProcess: Session? = null - - override fun exec(command: String?, timeout: Int): Process { - val session = ssh.startSession() - currentProcess = session - val channel = session.exec(command) - return HeadlessProcess(channel, timeout.toLong()) - } - - override fun disconnect() { - currentProcess?.close() - currentProcess = null - ssh.close() - } - } - - private class HeadlessProcess( - 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() = command.outputStream - - override fun getErrorStream() = command.errorStream - - override fun exitValue(): Int = command.exitStatus - - override fun getInputStream() = command.inputStream + }, + ) } companion object { - private const val CONNECT_TIMEOUT = 10 private const val WORK_NAME = "passkey_auto_sync" + private const val MAX_ATTEMPTS = 3 fun enqueue(context: Context) { - val workRequest = OneTimeWorkRequestBuilder().build() - WorkManager.getInstance(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.REPLACE, - workRequest, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request, ) } } @@ -433,14 +84,6 @@ class PasskeySyncWorker( @EntryPoint @InstallIn(SingletonComponent::class) interface PasskeySyncWorkerEntryPoint { - fun gitSettings(): GitSettings - - fun dispatcherProvider(): DispatcherProvider - - fun passkeyRepositoryState(): PasskeyRepositoryState - - fun generationProvider(): RepositoryGenerationProvider - - @GitSecrets fun gitSecrets(): SharedPreferences + fun syncEngine(): PasskeyGitSyncEngine } } From 67b40fae2ad2a216f71afd545d9131a0002ff2c6 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:26:26 +0200 Subject: [PATCH 07/34] serialize foreground and background git access --- .../ui/git/base/BaseGitActivity.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 22bf0b162..bfcfc6da1 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,25 @@ 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) { From 339030ac2a473e80a8f35de71ca8cb03020a4bc5 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:27:49 +0200 Subject: [PATCH 08/34] reopen repository for durable passkey sync --- .../java/app/passwordstore/passkeys/PasskeySyncWorker.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt index 991ff7fad..8e0c77f04 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -15,6 +15,7 @@ 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 @@ -42,6 +43,12 @@ class PasskeySyncWorker( 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" } From 759c8eba22e2b5cf11aadbdd48062ae69bfa17c3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:28:21 +0200 Subject: [PATCH 09/34] test git operation serialization --- .../util/git/GitOperationCoordinatorTest.kt | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt 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 000000000..c0a9a2490 --- /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 { + runCatching { + GitOperationCoordinator.withLock { error("boom") } + } + + var entered = false + withTimeout(1_000) { GitOperationCoordinator.withLock { entered = true } } + assertTrue(entered) + } +} From b40b4259c72abf90b0af2656d8bf1e6ad6a59b69 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:30:20 +0200 Subject: [PATCH 10/34] format passkey sync worker --- .../passkeys/PasskeySyncWorker.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt index 8e0c77f04..73715f719 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -49,21 +49,24 @@ class PasskeySyncWorker( 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() - } - }, - ) + 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 { From bc2dee06839424ef120a94a6391323e38008e91b Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:31:11 +0200 Subject: [PATCH 11/34] format headless passkey git sync engine --- .../java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt index 2a50e75de..9d54252cd 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt @@ -207,7 +207,8 @@ constructor( } else -> { if (gitSettings.authMode == AuthMode.Password) { - credentialsProvider = WipingCredentialsProvider(requireStoredPassword(PreferenceKeys.HTTPS_PASSWORD)) + credentialsProvider = + WipingCredentialsProvider(requireStoredPassword(PreferenceKeys.HTTPS_PASSWORD)) transport.credentialsProvider = credentialsProvider } } From deb0dae633ae9179f018e5f847a48d840e4996af Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:33:09 +0200 Subject: [PATCH 12/34] apply spotless formatting to passkey sync worker --- .../main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt index 73715f719..819fe5f14 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeySyncWorker.kt @@ -74,8 +74,7 @@ class PasskeySyncWorker( private const val MAX_ATTEMPTS = 3 fun enqueue(context: Context) { - val constraints = - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) From 1112caa5a677c6dde784358304dfc50829855701 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:33:42 +0200 Subject: [PATCH 13/34] apply spotless formatting to serialized git operations --- .../java/app/passwordstore/ui/git/base/BaseGitActivity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 bfcfc6da1..c57444530 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 @@ -74,7 +74,9 @@ abstract class BaseGitActivity : AppCompatActivity() { * @param operation The type of git operation to launch */ suspend fun launchGitOperation(operation: GitOp): Result = - GitOperationCoordinator.withLock { launchGitOperationUnlocked(operation) } + GitOperationCoordinator.withLock { + launchGitOperationUnlocked(operation) + } private suspend fun launchGitOperationUnlocked(operation: GitOp): Result { if (gitSettings.url == null) { From 65ca9cebc7b934a5f3f955f52b6953d905b6bbee Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:38:11 +0200 Subject: [PATCH 14/34] fix coroutine lint in coordinator test --- .../app/passwordstore/util/git/GitOperationCoordinatorTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt b/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt index c0a9a2490..8155cbe51 100644 --- a/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt +++ b/app/src/test/java/app/passwordstore/util/git/GitOperationCoordinatorTest.kt @@ -51,9 +51,9 @@ class GitOperationCoordinatorTest { @Test fun `releases lock when operation fails`() = runBlocking { - runCatching { + try { GitOperationCoordinator.withLock { error("boom") } - } + } catch (_: IllegalStateException) {} var entered = false withTimeout(1_000) { GitOperationCoordinator.withLock { entered = true } } From 90c1a93acab7f6bf6b058d22e0c44ed0e7c33865 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:51:08 +0200 Subject: [PATCH 15/34] feat(passkeys): carry changed Git paths through sync result --- .../app/passwordstore/passkeys/storage/GitSyncResult.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 a323df00b..cfc51ba7b 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" } } } From cdfe86f5f5a55e75e031b6ccd4f2c57be45dfdb4 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:51:36 +0200 Subject: [PATCH 16/34] feat(passkeys): add remote refresh contract --- .../passkeys/storage/PasskeyRemoteRefresher.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 passkeys/core/src/main/kotlin/app/passwordstore/passkeys/storage/PasskeyRemoteRefresher.kt 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 000000000..aed168af1 --- /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 +} From 8b4e7d85884c16a6fd721614e8bc01972c06c045 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:52:29 +0200 Subject: [PATCH 17/34] feat(passkeys): support pull-only remote refresh with changed paths --- .../passkeys/PasskeyGitSyncEngine.kt | 84 +++++++++++++++---- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt index 9d54252cd..de1ff484f 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt @@ -10,6 +10,7 @@ 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 @@ -42,7 +43,10 @@ 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 @@ -50,6 +54,7 @@ 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, @@ -57,7 +62,7 @@ internal class PasskeySyncException( val retryable: Boolean, ) : Exception(message, cause) -/** Headless, serialized Git sync used by durable passkey background work. */ +/** Headless, serialized Git sync used by durable passkey background work and miss recovery. */ @Singleton class PasskeyGitSyncEngine @Inject @@ -68,9 +73,16 @@ constructor( private val dispatcherProvider: DispatcherProvider, private val passkeyRepositoryState: PasskeyRepositoryState, private val generationProvider: RepositoryGenerationProvider, -) { +) : PasskeyRemoteRefresher { - suspend fun sync(): Result { + 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)) } @@ -83,7 +95,7 @@ constructor( return withContext(dispatcherProvider.io()) { GitOperationCoordinator.withLock { try { - executeSync(Git(repository)) + operation(Git(repository)) Ok(Unit) } catch (e: CancellationException) { throw e @@ -92,7 +104,7 @@ constructor( } catch (e: Exception) { Err( PasskeySyncException( - "Passkey Git sync failed: ${e.message}", + "Passkey Git operation failed: ${e.message}", cause = e, retryable = e.isRetryableTransportFailure(), ) @@ -108,22 +120,62 @@ constructor( stageAndCommit(git) pull(git) push(git) - val conflicts = git.status().call().conflicting.toList() - val newHead = generationProvider.currentGitHead() - val syncResult = - GitSyncResult( - oldHead = oldHead, - newHead = newHead, - worktreeChanged = oldHead != newHead, - conflicts = conflicts, - ) - passkeyRepositoryState.onGitSyncCompleted(syncResult) - generationProvider.bumpWorktreeGeneration() + completeRepositoryUpdate(git, oldHead) + } finally { + git.close() + } + } + + private suspend fun executeRefresh(git: Git) { + try { + val oldHead = generationProvider.currentGitHead() + pull(git) + 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() From a139c46177ef71c53ee3e35ee39cb4fdf7547bef Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:53:45 +0200 Subject: [PATCH 18/34] perf(passkeys): incrementally reconcile index after Git pull --- .../passkeys/storage/IndexedPasskeyStorage.kt | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) 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 7b193502d..aeed05b2e 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,74 @@ 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 + } + + val repositoryEntries = + delegate.listMetadataWithRefs().getOrElse { error -> + logcat(LogPriority.WARN) { + "Failed scanning changed passkeys for incremental index update: ${error.message}" + } + return@withLock false + } + val changedEntries = + repositoryEntries.filter { entry -> + entry.fileRef?.relativePath in changedPasskeyPaths + } + + // Remove old versions first. Missing paths represent credentials deleted by the pull; + // added/modified paths are inserted again below using their post-pull source version. + metadataIndex.values + .filter { it.fileRef.relativePath in changedPasskeyPaths } + .map { it.metadata } + .forEach(::removeFromIndex) + + for (entry in changedEntries) { + val ref = entry.fileRef ?: continue + val version = entry.sourceVersion ?: continue + val metadata = + if (entry.metadata.userName.isBlank() && entry.metadata.userDisplayName.isBlank()) { + delegate + .loadCredentialMetadata(ref, version) + .fold( + success = { it }, + failure = { error -> + logcat(LogPriority.WARN) { + "Failed to decrypt changed passkey metadata for ${ref.relativePath}: ${error.message}" + } + metadataEnricher?.enrich(entry.metadata) ?: entry.metadata + }, + ) + } else { + entry.metadata + } + indexMetadata(metadata, version, ref) + } + + trackedGeneration = resolveCurrentGeneration() + logcat { + "Incrementally reconciled passkey index: changedPaths=${changedPasskeyPaths.size}, indexed=${changedEntries.size}" + } + true + } + } + override suspend fun listMetadata(rpId: String?): Result, Throwable> { ensureIndexLoaded() @@ -418,8 +486,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() } } } From e276b15e27c3a1cebfbbc875a4ebb90a7f8fdae7 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:54:43 +0200 Subject: [PATCH 19/34] feat(passkeys): refresh remote once on credential lookup miss --- .../PasskeyCredentialProviderService.kt | 196 +++++++++++++----- 1 file changed, 139 insertions(+), 57 deletions(-) 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 03743e1e2..1765eb386 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 @@ -13,6 +13,7 @@ import android.content.Intent import android.graphics.drawable.Icon import android.os.CancellationSignal import android.os.OutcomeReceiver +import android.os.SystemClock import androidx.annotation.RequiresApi import androidx.core.net.toUri import androidx.credentials.exceptions.ClearCredentialException @@ -34,12 +35,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.SupervisorJob +import kotlinx.coroutines.cancel +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 +58,153 @@ 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 lastSuccessfulRefreshElapsedMillis = 0L override fun onCreate() { super.onCreate() logcat { "PasskeyCredentialProviderService created" } } + override fun onDestroy() { + serviceScope.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 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 + 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) + var entries = loadCredentialEntries(queries) - @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}" + if (entries.isEmpty() && queries.isNotEmpty() && remoteRefresher != null) { + logcat { "No local passkey candidates; attempting one remote Git refresh" } + if (refreshRemote()) { + entries = loadCredentialEntries(queries) } + } + + if (cancellationSignal.isCanceled) return@launch + if (entries.isEmpty()) { + callback.onError(GetCredentialUnknownException("No matching passkeys found")) + return@launch + } - val isAutoSelectAllowed = PasskeyProviderUtils.isAutoSelectAllowed(metadata.size) - addAll(metadata.map { meta -> buildCredentialEntry(option, meta, isAutoSelectAllowed) }) + 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")) } } + } - if (entries.isEmpty()) { - callback.onError(GetCredentialUnknownException("No matching passkeys found")) - return + 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) } + } + } - callback.onResult( - BeginGetCredentialResponse( - credentialEntries = entries, - actions = emptyList(), - authenticationActions = emptyList(), - remoteEntry = null, - ) + 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(): Boolean { + val refresher = remoteRefresher ?: return false + return refreshMutex.withLock { + val now = SystemClock.elapsedRealtime() + if (now - lastSuccessfulRefreshElapsedMillis < REMOTE_REFRESH_COOLDOWN_MILLIS) { + return@withLock true + } + refresher + .refresh() + .fold( + success = { + lastSuccessfulRefreshElapsedMillis = SystemClock.elapsedRealtime() + true + }, + failure = { + logcat(LogPriority.WARN) { "Remote passkey refresh failed: $it" } + false + }, + ) } } @@ -230,6 +308,10 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi return Icon.createWithResource(this, applicationInfo.icon) } + private companion object { + const val REMOTE_REFRESH_COOLDOWN_MILLIS = 30_000L + } + public companion object { public const val EXTRA_OPERATION: String = "passkey_operation" public const val EXTRA_CREDENTIAL_ID: String = "passkey_credential_id" From ff73f8b0ab3dd6451bbcac0d8cb0b2965e45ddf3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:54:57 +0200 Subject: [PATCH 20/34] feat(passkeys): expose headless remote refresh to credential provider --- .../AppPasskeyCredentialProviderService.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt index b2f072d08..50f21239d 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,16 @@ 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 +50,7 @@ class AppPasskeyCredentialProviderService : PasskeyCredentialProviderService() { fun passkeyStorage(): PasskeyStorage fun passkeyCryptoHandler(): PasskeyCryptoHandler + + fun passkeyGitSyncEngine(): PasskeyGitSyncEngine } } From baa600879cac3f53616ebb17aef32f3dc1d0674b Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:55:38 +0200 Subject: [PATCH 21/34] fix(passkeys): merge credential provider companion constants --- .../passkeys/provider/PasskeyCredentialProviderService.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 1765eb386..6da4026f6 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 @@ -308,11 +308,8 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi return Icon.createWithResource(this, applicationInfo.icon) } - private companion object { - const val REMOTE_REFRESH_COOLDOWN_MILLIS = 30_000L - } - public companion object { + private const val REMOTE_REFRESH_COOLDOWN_MILLIS = 30_000L public const val EXTRA_OPERATION: String = "passkey_operation" public const val EXTRA_CREDENTIAL_ID: String = "passkey_credential_id" public const val OPERATION_CREATE: String = "create" From 7b3924e7ea371cc7d2dcd681dd521bf1ac2f5659 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:56:55 +0200 Subject: [PATCH 22/34] test(passkeys): cover changed-path sync classification --- .../passkeys/storage/GitSyncResultTest.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/GitSyncResultTest.kt 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 000000000..c5a05d8ea --- /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()) + } +} From 5b62e81546e6fd44d0b8ba2d034c7d06f4c2ae43 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:58:38 +0200 Subject: [PATCH 23/34] fix(passkeys): bound miss refresh latency and initialize cold repository --- .../passkeys/PasskeyGitSyncEngine.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt index de1ff484f..a017705ca 100644 --- a/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt +++ b/app/src/main/java/app/passwordstore/passkeys/PasskeyGitSyncEngine.kt @@ -86,6 +86,9 @@ constructor( 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( @@ -118,7 +121,7 @@ constructor( try { val oldHead = generationProvider.currentGitHead() stageAndCommit(git) - pull(git) + pull(git, BACKGROUND_SYNC_TIMEOUT_SECONDS) push(git) completeRepositoryUpdate(git, oldHead) } finally { @@ -129,7 +132,7 @@ constructor( private suspend fun executeRefresh(git: Git) { try { val oldHead = generationProvider.currentGitHead() - pull(git) + pull(git, REMOTE_REFRESH_TIMEOUT_SECONDS) completeRepositoryUpdate(git, oldHead) } finally { git.close() @@ -193,9 +196,9 @@ constructor( .call() } - private fun pull(git: Git) { + private fun pull(git: Git, timeoutSeconds: Int) { val command = git.pull().setRemote("origin").setRebase(gitSettings.rebaseOnPull) - val cleanup = configureTransport(command) + val cleanup = configureTransport(command, timeoutSeconds) try { val result = command.call() if (!result.isSuccessful) { @@ -212,7 +215,7 @@ constructor( private fun push(git: Git) { val command = git.push().setPushAll().setRemote("origin") - val cleanup = configureTransport(command) + val cleanup = configureTransport(command, BACKGROUND_SYNC_TIMEOUT_SECONDS) try { command.call().forEach { pushResult -> pushResult.remoteUpdates.forEach { update -> @@ -239,10 +242,13 @@ constructor( } } - private fun configureTransport(command: TransportCommand<*, *>): () -> Unit { + private fun configureTransport( + command: TransportCommand<*, *>, + timeoutSeconds: Int, + ): () -> Unit { var sshFactory: HeadlessSshSessionFactory? = null var credentialsProvider: WipingCredentialsProvider? = null - command.setTimeout(CONNECT_TIMEOUT_SECONDS) + command.setTimeout(timeoutSeconds) command.setTransportConfigCallback { transport: Transport -> when (transport) { is SshTransport -> { @@ -305,7 +311,8 @@ constructor( } private companion object { - const val CONNECT_TIMEOUT_SECONDS = 10 + const val BACKGROUND_SYNC_TIMEOUT_SECONDS = 10 + const val REMOTE_REFRESH_TIMEOUT_SECONDS = 3 } } From 58199e775a22a12b5b80a686897539b2bec00c77 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 15:59:19 +0200 Subject: [PATCH 24/34] fix(passkeys): do not suppress first remote refresh after boot --- .../passkeys/provider/PasskeyCredentialProviderService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 6da4026f6..bf90f55d6 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 @@ -190,7 +190,10 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi val refresher = remoteRefresher ?: return false return refreshMutex.withLock { val now = SystemClock.elapsedRealtime() - if (now - lastSuccessfulRefreshElapsedMillis < REMOTE_REFRESH_COOLDOWN_MILLIS) { + if ( + lastSuccessfulRefreshElapsedMillis != 0L && + now - lastSuccessfulRefreshElapsedMillis < REMOTE_REFRESH_COOLDOWN_MILLIS + ) { return@withLock true } refresher From 992e0c9fdf4126aff94204870bcd823317153feb Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:04:14 +0200 Subject: [PATCH 25/34] style(passkeys): format remote refresher provider --- .../passkeys/AppPasskeyCredentialProviderService.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt index 50f21239d..689f8ee73 100644 --- a/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt +++ b/app/src/main/java/app/passwordstore/passkeys/AppPasskeyCredentialProviderService.kt @@ -35,9 +35,7 @@ class AppPasskeyCredentialProviderService : PasskeyCredentialProviderService() { override val remoteRefresher: PasskeyRemoteRefresher? get() = - if ( - Application.instance.sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true) - ) { + if (Application.instance.sharedPrefs.getBoolean(PreferenceKeys.PASSKEY_AUTO_GIT_SYNC, true)) { entryPoint.passkeyGitSyncEngine() } else { null From 1f51be5f0a86533340d033f6ed8f29ebd58bd811 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:05:20 +0200 Subject: [PATCH 26/34] style(passkeys): format incremental index reconciliation --- .../passkeys/storage/IndexedPasskeyStorage.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 aeed05b2e..842d2552f 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 @@ -186,10 +186,9 @@ public class IndexedPasskeyStorage( } return@withLock false } - val changedEntries = - repositoryEntries.filter { entry -> - entry.fileRef?.relativePath in changedPasskeyPaths - } + val changedEntries = repositoryEntries.filter { entry -> + entry.fileRef?.relativePath in changedPasskeyPaths + } // Remove old versions first. Missing paths represent credentials deleted by the pull; // added/modified paths are inserted again below using their post-pull source version. From dbba696ae6dd2020209a0a0a78bc265fbb2ba282 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:06:10 +0200 Subject: [PATCH 27/34] style(passkeys): format miss-recovery coroutine --- .../PasskeyCredentialProviderService.kt | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) 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 bf90f55d6..f7ff077d3 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 @@ -81,51 +81,50 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { - 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 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) - var entries = loadCredentialEntries(queries) + val queries = parseGetQueries(options) + var entries = loadCredentialEntries(queries) - if (entries.isEmpty() && queries.isNotEmpty() && remoteRefresher != null) { - logcat { "No local passkey candidates; attempting one remote Git refresh" } - if (refreshRemote()) { - entries = loadCredentialEntries(queries) - } + if (entries.isEmpty() && queries.isNotEmpty() && remoteRefresher != null) { + logcat { "No local passkey candidates; attempting one remote Git refresh" } + if (refreshRemote()) { + entries = loadCredentialEntries(queries) } + } - if (cancellationSignal.isCanceled) return@launch - if (entries.isEmpty()) { - callback.onError(GetCredentialUnknownException("No matching passkeys found")) - return@launch - } + 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")) - } + ) + } 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() } } @@ -145,7 +144,8 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi logcat(LogPriority.WARN) { "Skipping malformed passkey request: ${e.message}" } return@mapNotNull null } - val rpId = parsedRequest.rpId ?: parsedRequest.allowCredentials.firstNotNullOfOrNull { it.rpId } + val rpId = + parsedRequest.rpId ?: parsedRequest.allowCredentials.firstNotNullOfOrNull { it.rpId } if (rpId == null) { logcat(LogPriority.WARN) { "Skipping passkey option without RP ID" } null From 5e52dd81b4c0f058d8c5b5265712a4c7e96bb795 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:08:38 +0200 Subject: [PATCH 28/34] perf(passkeys): reconcile changed passkey files directly --- .../passkeys/storage/IndexedPasskeyStorage.kt | 111 ++++++++++++------ 1 file changed, 74 insertions(+), 37 deletions(-) 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 842d2552f..624052fa5 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 @@ -179,54 +179,91 @@ public class IndexedPasskeyStorage( return@withLock true } - val repositoryEntries = - delegate.listMetadataWithRefs().getOrElse { error -> - logcat(LogPriority.WARN) { - "Failed scanning changed passkeys for incremental index update: ${error.message}" - } + for (relativePath in changedPasskeyPaths) { + if (!reconcileChangedPasskey(relativePath)) { return@withLock false } - val changedEntries = repositoryEntries.filter { entry -> - entry.fileRef?.relativePath in changedPasskeyPaths - } - - // Remove old versions first. Missing paths represent credentials deleted by the pull; - // added/modified paths are inserted again below using their post-pull source version. - metadataIndex.values - .filter { it.fileRef.relativePath in changedPasskeyPaths } - .map { it.metadata } - .forEach(::removeFromIndex) - - for (entry in changedEntries) { - val ref = entry.fileRef ?: continue - val version = entry.sourceVersion ?: continue - val metadata = - if (entry.metadata.userName.isBlank() && entry.metadata.userDisplayName.isBlank()) { - delegate - .loadCredentialMetadata(ref, version) - .fold( - success = { it }, - failure = { error -> - logcat(LogPriority.WARN) { - "Failed to decrypt changed passkey metadata for ${ref.relativePath}: ${error.message}" - } - metadataEnricher?.enrich(entry.metadata) ?: entry.metadata - }, - ) - } else { - entry.metadata - } - indexMetadata(metadata, version, ref) } trackedGeneration = resolveCurrentGeneration() logcat { - "Incrementally reconciled passkey index: changedPaths=${changedPasskeyPaths.size}, indexed=${changedEntries.size}" + "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.Unstable -> 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() From de40f961b02ab6110ec0b461f795f96d10c7b446 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:10:47 +0200 Subject: [PATCH 29/34] fix(passkeys): handle unavailable source versions during reconciliation --- .../app/passwordstore/passkeys/storage/IndexedPasskeyStorage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 624052fa5..7ad0a7879 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 @@ -228,7 +228,7 @@ public class IndexedPasskeyStorage( when (versionResult) { SourceVersionResult.Missing -> return true is SourceVersionResult.Stable -> versionResult.version - is SourceVersionResult.Unstable -> return false + is SourceVersionResult.Unavailable -> return false } val duplicate = metadataIndex[credentialKey(credentialId)] From 8315770ab6e78751fd6b2c80a58c4049cdfded07 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:12:45 +0200 Subject: [PATCH 30/34] style(passkeys): wrap credential entry loader signature --- .../passkeys/provider/PasskeyCredentialProviderService.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f7ff077d3..d289c1855 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 @@ -155,7 +155,9 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi } } - private suspend fun loadCredentialEntries(queries: List): List { + private suspend fun loadCredentialEntries( + queries: List + ): List { val entries = mutableListOf() for (query in queries) { val metadata = From 9bfa69eae4a063ea44e183452bd623838b1e96a8 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:14:30 +0200 Subject: [PATCH 31/34] test(passkeys): verify direct incremental Git index reconciliation --- .../IndexedPasskeyStorageGitSyncTest.kt | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt 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 000000000..243b3a5d8 --- /dev/null +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt @@ -0,0 +1,181 @@ +/* + * 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()) + } +} From b1bfdaa8cbc55f958585ac9508247eafba8bc760 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:17:30 +0200 Subject: [PATCH 32/34] style(passkeys): format incremental Git reconciliation tests --- .../storage/IndexedPasskeyStorageGitSyncTest.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 index 243b3a5d8..33813c910 100644 --- a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/storage/IndexedPasskeyStorageGitSyncTest.kt @@ -82,17 +82,13 @@ class IndexedPasskeyStorageGitSyncTest { delegate.remoteVersion = version(generation = 1) generationProvider.head = "new" generationProvider.worktreeGeneration = 1 - storage.onGitSyncCompleted( - GitSyncResult("old", "new", true, emptyList(), setOf(changedPath)) - ) + 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)) - ) + storage.onGitSyncCompleted(GitSyncResult("new", "newer", true, emptyList(), setOf(changedPath))) assertTrue(storage.listMetadata("example.com").getOrElse { emptyList() }.isEmpty()) assertEquals(1, delegate.fullScanCount) @@ -131,9 +127,8 @@ class IndexedPasskeyStorageGitSyncTest { var remoteMetadata: PasskeyMetadata? = null var remoteVersion: CredentialSourceVersion? = null - override suspend fun listMetadata( - rpId: String? - ): Result, Throwable> = Ok(emptyList()) + override suspend fun listMetadata(rpId: String?): Result, Throwable> = + Ok(emptyList()) override suspend fun listMetadataWithRefs( rpId: String? From d6a150711905fcf418853b13d0f45f27d85e1e2c Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:22:55 +0200 Subject: [PATCH 33/34] fix(passkeys): avoid coroutine cancel extension conflict --- .../passkeys/provider/PasskeyCredentialProviderService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d289c1855..ef65b678e 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 @@ -43,8 +43,8 @@ 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.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -72,7 +72,7 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi } override fun onDestroy() { - serviceScope.cancel() + serviceScope.coroutineContext[Job]?.cancel() super.onDestroy() } From 910fa685ee27e12115dc125a98da8f09399d20ae Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Fri, 31 Jul 2026 16:24:05 +0200 Subject: [PATCH 34/34] fix(passkeys): coalesce only concurrent remote refreshes --- .../PasskeyCredentialProviderService.kt | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) 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 ef65b678e..9e8b0973b 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 @@ -13,7 +13,6 @@ import android.content.Intent import android.graphics.drawable.Icon import android.os.CancellationSignal import android.os.OutcomeReceiver -import android.os.SystemClock import androidx.annotation.RequiresApi import androidx.core.net.toUri import androidx.credentials.exceptions.ClearCredentialException @@ -64,7 +63,7 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi @Suppress("RawDispatchersUse") private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val refreshMutex = Mutex() - @Volatile private var lastSuccessfulRefreshElapsedMillis = 0L + @Volatile private var refreshGeneration = 0L override fun onCreate() { super.onCreate() @@ -93,11 +92,12 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi } val queries = parseGetQueries(options) + val observedRefreshGeneration = refreshGeneration var entries = loadCredentialEntries(queries) if (entries.isEmpty() && queries.isNotEmpty() && remoteRefresher != null) { logcat { "No local passkey candidates; attempting one remote Git refresh" } - if (refreshRemote()) { + if (refreshRemote(observedRefreshGeneration)) { entries = loadCredentialEntries(queries) } } @@ -188,21 +188,17 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi return entries } - private suspend fun refreshRemote(): Boolean { + private suspend fun refreshRemote(observedGeneration: Long): Boolean { val refresher = remoteRefresher ?: return false return refreshMutex.withLock { - val now = SystemClock.elapsedRealtime() - if ( - lastSuccessfulRefreshElapsedMillis != 0L && - now - lastSuccessfulRefreshElapsedMillis < REMOTE_REFRESH_COOLDOWN_MILLIS - ) { + if (refreshGeneration != observedGeneration) { return@withLock true } refresher .refresh() .fold( success = { - lastSuccessfulRefreshElapsedMillis = SystemClock.elapsedRealtime() + refreshGeneration++ true }, failure = { @@ -314,7 +310,6 @@ public abstract class PasskeyCredentialProviderService : CredentialProviderServi } public companion object { - private const val REMOTE_REFRESH_COOLDOWN_MILLIS = 30_000L public const val EXTRA_OPERATION: String = "passkey_operation" public const val EXTRA_CREDENTIAL_ID: String = "passkey_credential_id" public const val OPERATION_CREATE: String = "create"