summaryrefslogtreecommitdiffstats
path: root/app/src/main/java
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 09:50:08 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 09:50:08 +0100
commitadb1e3feebe790e56751e6909b2111a432ac87f0 (patch)
tree0cc6a4bc64e1703d1158449983c4a1d8e6424a35 /app/src/main/java
parent645fd6a64780ff4c4a74ecc2e4c6d3d43bcd472d (diff)
downloadHardenedPass-adb1e3feebe790e56751e6909b2111a432ac87f0.tar.gz
HardenedPass-adb1e3feebe790e56751e6909b2111a432ac87f0.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt69
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt79
-rw-r--r--app/src/main/java/app/passwordstore/util/services/CredentialService.kt134
4 files changed, 246 insertions, 48 deletions
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. <danix@danix.xyz>
+ * 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<BeginCreateCredentialResponse, CreateCredentialException>,
+ ) {
+ 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<BeginGetCredentialResponse, GetCredentialException>,
+ ) {
+ val origin = appOrigin(request.callingAppInfo)
+ val option = request.beginGetCredentialOptions.filterIsInstance<BeginGetPasswordOption>()
+ 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<Void?, ClearCredentialException>,
+ ) {
+ // Nothing is signed in: there is no state to clear.
+ callback.onResult(null)
+ }
+}