From adb1e3feebe790e56751e6909b2111a432ac87f0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 09:50:08 +0100 Subject: app: add a Credential Manager provider for passwords Apps that save or request a password through Android's Credential Manager (e.g. Signal's backup key) found no provider: HardenedPass only implemented an autofill service, which Credential Manager does not route requests to even when it is the preferred service. CredentialService (Android 14+) handles password requests keyed by the calling app's package. Saving opens AutofillSaveActivity prefilled from the request and records the autofill match; getting offers the matched entries, decrypted by AutofillDecryptActivity, plus a search action through the autofill picker. Browser requests for a website are not served: autofill covers them. androidx.credentials brings no Play Services; lint's CredentialDependency check is ignored for that reason. Co-Authored-By: Claude Opus 5.5 --- .../ui/autofill/AutofillDecryptActivity.kt | 69 +++++++---- .../ui/autofill/AutofillFilterView.kt | 12 +- .../ui/autofill/AutofillSaveActivity.kt | 79 ++++++++---- .../util/services/CredentialService.kt | 134 +++++++++++++++++++++ 4 files changed, 246 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/util/services/CredentialService.kt (limited to 'app/src/main/java') diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 3b62de4c..38c86d86 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.IntentSender import android.content.SharedPreferences +import android.os.Build import android.os.Bundle import android.view.WindowManager import android.view.autofill.AutofillManager @@ -23,6 +24,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.core.content.edit +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import app.passwordstore.Application.Companion.screenWasOff @@ -81,7 +85,8 @@ class AutofillDecryptActivity : FragmentActivity() { private lateinit var directoryStructure: DirectoryStructure private lateinit var filePath: String - private lateinit var clientState: Bundle + /** The autofill request's state; null when Credential Manager asked. */ + private var clientState: Bundle? = null private lateinit var action: AutofillAction private var clearCache = true @@ -124,13 +129,15 @@ class AutofillDecryptActivity : FragmentActivity() { finish() return } - clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } + val fromCredentialManager = + Build.VERSION.SDK_INT >= 34 && + PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) != null + clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) + if (clientState == null && !fromCredentialManager) { + logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true) action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match directoryStructure = AutofillPreferences.directoryStructure(this) @@ -278,26 +285,34 @@ class AutofillDecryptActivity : FragmentActivity() { askPassphrase(identifiers, wrong = true) return } - if (credentials == null) { - setResult(RESULT_CANCELED) - } else { - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this@AutofillDecryptActivity, - credentials, - clientState, - action, - ) - withContext(dispatcherProvider.main()) { - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }, - ) - } + val result = credentials?.let(::resultIntent) + withContext(dispatcherProvider.main()) { + if (result == null) setResult(RESULT_CANCELED) else setResult(RESULT_OK, result) } withContext(dispatcherProvider.main()) { finish() } } + /** The fill dataset, or the password for Credential Manager; null for an entry without one. */ + private fun resultIntent(credentials: Credentials): Intent? { + val state = clientState + if (state != null) { + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset(this, credentials, state, action) + return Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + } + if (Build.VERSION.SDK_INT < 34) return null + val password = credentials.password?.takeUnless { it.isEmpty() } ?: return null + // PasswordCredential rejects an empty id: fall back to the entry's name. + val id = + credentials.username?.takeUnless { it.isEmpty() } ?: File(filePath).nameWithoutExtension + return Intent().also { + PendingIntentHandler.setGetCredentialResponse( + it, + GetCredentialResponse(PasswordCredential(id, password)), + ) + } + } + private suspend fun decryptCredential( file: File, password: String, @@ -357,6 +372,12 @@ class AutofillDecryptActivity : FragmentActivity() { } } + /** Decrypts [file] for a Credential Manager request, which the system adds to the intent. */ + fun makeCredentialIntent(file: File, context: Context): Intent = + Intent(context, AutofillDecryptActivity::class.java) + .putExtra(EXTRA_SEARCH_ACTION, false) + .putExtra(EXTRA_FILE_PATH, file.absolutePath) + fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { val intent = Intent(context, AutofillDecryptActivity::class.java).apply { diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt index 474005b5..799015cb 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -8,6 +8,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.os.Build import android.os.Bundle import android.view.autofill.AutofillManager import androidx.activity.compose.setContent @@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.passwordstore.R @@ -58,6 +60,11 @@ class AutofillFilterView : FragmentActivity() { "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" private var matchAndDecryptFileRequestCode = 1 + /** The picker for a Credential Manager request, which the system adds to the intent. */ + fun makeCredentialIntent(context: Context, formOrigin: FormOrigin.App): Intent = + Intent(context, AutofillFilterView::class.java) + .putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) + fun makeMatchAndDecryptFileIntentSender( context: Context, formOrigin: FormOrigin, @@ -107,7 +114,10 @@ class AutofillFilterView : FragmentActivity() { params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() window.attributes = params - if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { + val fromCredentialManager = + Build.VERSION.SDK_INT >= 34 && + PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) != null + if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true && !fromCredentialManager) { logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } finish() return diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt index 2ed0adef..d32df6ca 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -8,11 +8,15 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.os.Build import android.os.Bundle import android.view.WindowManager import android.view.autofill.AutofillManager import androidx.activity.compose.setContent import androidx.core.os.bundleOf +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePasswordResponse +import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.ui.compose.theme.APSTheme @@ -60,6 +64,24 @@ class AutofillSaveActivity : FragmentActivity() { credentials: Credentials?, formOrigin: FormOrigin, ): IntentSender { + val intent = + Intent(context, AutofillSaveActivity::class.java) + .putExtras(saveExtras(context, credentials, formOrigin)) + return PendingIntent.getActivity( + context, + saveRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + .intentSender + } + + /** The editor's prefill: folder and name from [formOrigin] and the username. */ + private fun saveExtras( + context: Context, + credentials: Credentials?, + formOrigin: FormOrigin, + ): Bundle { val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) // Prevent directory traversals val sanitizedIdentifier = @@ -76,28 +98,14 @@ class AutofillSaveActivity : FragmentActivity() { username = credentials?.username, identifier = identifier, ) - val intent = - Intent(context, AutofillSaveActivity::class.java).apply { - putExtras( - bundleOf( - EXTRA_FOLDER_NAME to folderName, - EXTRA_NAME to fileName, - EXTRA_PASSWORD to credentials?.password, - EXTRA_SHOULD_MATCH_APP to - formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, - EXTRA_SHOULD_MATCH_WEB to - formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, - EXTRA_GENERATE_PASSWORD to (credentials == null), - ) - ) - } - return PendingIntent.getActivity( - context, - saveRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - .intentSender + return bundleOf( + EXTRA_FOLDER_NAME to folderName, + EXTRA_NAME to fileName, + EXTRA_PASSWORD to credentials?.password, + EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, + EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, + EXTRA_GENERATE_PASSWORD to (credentials == null), + ) } } @@ -117,8 +125,29 @@ class AutofillSaveActivity : FragmentActivity() { @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + /** Set when Credential Manager asked to save a password, which then answers it. */ + private var fromCredentialManager = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= 34) { + PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let { request -> + val create = + request.callingRequest as? CreatePasswordRequest + ?: run { + finish() + return + } + fromCredentialManager = true + intent.putExtras( + saveExtras( + this, + Credentials(create.id, create.password, null), + FormOrigin.App(request.callingAppInfo.packageName), + ) + ) + } + } // The whole window is the editor: keep it out of screenshots and recents. window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) val initial = @@ -149,7 +178,11 @@ class AutofillSaveActivity : FragmentActivity() { private fun finishSaved(saved: SaveResult.Saved) { formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) } val resultIntent = - if (generate) { + if (fromCredentialManager && Build.VERSION.SDK_INT >= 34) { + Intent().also { + PendingIntentHandler.setCreateCredentialResponse(it, CreatePasswordResponse()) + } + } else if (generate) { val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { diff --git a/app/src/main/java/app/passwordstore/util/services/CredentialService.kt b/app/src/main/java/app/passwordstore/util/services/CredentialService.kt new file mode 100644 index 00000000..97b2666f --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/services/CredentialService.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.services + +import android.app.PendingIntent +import android.content.Intent +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.provider.Action +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.PasswordCredentialEntry +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import app.passwordstore.R +import app.passwordstore.ui.autofill.AutofillDecryptActivity +import app.passwordstore.ui.autofill.AutofillFilterView +import app.passwordstore.ui.autofill.AutofillSaveActivity +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.makeFillMatchMetadata +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.michaelbull.result.getOrElse +import logcat.LogPriority.WARN +import logcat.logcat + +/** + * Credential Manager provider (Android 14+): apps that save or ask for a password through + * `CredentialManager` (e.g. Signal's backup key) reach the store here. It reuses the autofill + * matches, picker, decryption and save activities, keyed by the calling app's package. + */ +@RequiresApi(34) +class CredentialService : CredentialProviderService() { + + private var requestCode = 1 + + /** + * The calling app, or null for a browser asking for a website: its origin would need a + * privileged-browser allowlist, and autofill already covers browsers. + */ + private fun appOrigin(info: CallingAppInfo?): FormOrigin.App? = + info?.takeUnless { it.isOriginPopulated() }?.let { FormOrigin.App(it.packageName) } + + private fun pendingIntent(intent: Intent): PendingIntent = + PendingIntent.getActivity( + this, + requestCode++, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + val origin = appOrigin(request.callingAppInfo) + if (request !is BeginCreatePasswordCredentialRequest || origin == null) { + callback.onResult(BeginCreateCredentialResponse()) + return + } + // The username and password arrive in the activity's intent once the user picks this entry. + val entry = + CreateEntry.Builder( + applicationInfo.loadLabel(packageManager), + pendingIntent(Intent(this, AutofillSaveActivity::class.java)), + ) + .build() + callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry))) + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + val origin = appOrigin(request.callingAppInfo) + val option = request.beginGetCredentialOptions.filterIsInstance() + if (origin == null || option.isEmpty()) { + callback.onResult(BeginGetCredentialResponse()) + return + } + // ponytail: a changed publisher gets no entries at all; autofill shows a warning dataset + // instead. Offer AutofillPublisherChangedActivity here if this ever bites. + val files = + AutofillMatcher.getMatchesFor(this, origin).getOrElse { + logcat(WARN) { it.message.orEmpty() } + callback.onResult(BeginGetCredentialResponse()) + return + } + val entries = + option.flatMap { opt -> + files.map { file -> + val metadata = makeFillMatchMetadata(this, file) + PasswordCredentialEntry.Builder( + this, + metadata.subtitle ?: metadata.title, + pendingIntent(AutofillDecryptActivity.makeCredentialIntent(file, this)), + opt, + ) + .setDisplayName(metadata.title) + .build() + } + } + val search = + Action( + getString(R.string.oreo_autofill_search_in_store), + pendingIntent(AutofillFilterView.makeCredentialIntent(this, origin)), + ) + callback.onResult( + BeginGetCredentialResponse(credentialEntries = entries, actions = listOf(search)) + ) + } + + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + // Nothing is signed in: there is no state to clear. + callback.onResult(null) + } +} -- cgit v1.2.3