aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md3
-rw-r--r--CHANGELOG.md12
-rw-r--r--CONTRIBUTING.md100
-rw-r--r--README.md15
-rw-r--r--SECURITY.md23
-rw-r--r--TODO.md15
-rw-r--r--app/build.gradle.kts1
-rw-r--r--app/lint.xml2
-rw-r--r--app/src/main/AndroidManifest.xml12
-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/ui/settings/AboutScreen.kt3
-rw-r--r--app/src/main/java/app/passwordstore/util/services/CredentialService.kt134
-rw-r--r--app/src/main/res/values-it/strings.xml1
-rw-r--r--app/src/main/res/values/strings.xml1
-rw-r--r--app/src/main/res/xml/credential_provider.xml10
-rw-r--r--app/version.properties4
-rw-r--r--autofill-parser/src/main/assets/publicsuffixesbin148065 -> 148083 bytes
-rw-r--r--gradle/libs.versions.toml1
20 files changed, 416 insertions, 81 deletions
diff --git a/AGENTS.md b/AGENTS.md
index c26ae1cc..d93040af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,6 @@ minSdk is 31: GrapheneOS runs current Android only, and Android 12 has
and to the GitHub fork (`danixland/HardenedPass`) in one `git push`.
GitHub is a push mirror: never create commits on GitHub (bots, web edits), they would
diverge from the main repository.
-- `upstream` is fetch-only (push URL disabled).
- Work directly on `master`, no PRs.
## Build and test
@@ -205,7 +204,7 @@ manual because bot commits on the GitHub mirror would diverge from the main repo
./gradlew :autofill-parser:test -PslimTests
git commit -am "autofill-parser: update public suffix list" && git push
-Last run: 2026-11-06.
+Last run: 2026-11-09.
## Releases
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d9511c2..6c9c6fee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
+## [Unreleased]
+
+### Added
+
+- About: "Report a security issue" links to the security policy, with the maintainer's OpenPGP key for private reports
+
+## [0.3.4] - 2026-11-09
+
+### Added
+
+- Credential Manager provider on Android 14 and later: apps that save or ask for a password through Android's password manager (e.g. Signal's backup key) can use HardenedPass, with the same matches and entry search as autofill
+
## [0.3.3] - 2026-11-06
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f681cf52..97ea5dde 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,59 +1,101 @@
# Contribution Guidelines
-:tada: First off, thanks for taking the time to contribute! :tada:
-
-This document should be treated as suggestions, and not rules, on how to contribute to Android Password Store. All positive contribution is welcome, so please do not hesitate in pitching forth any ideas you have!
+Thanks for taking the time to contribute! This document is a set of suggestions, not rules,
+on how to contribute to HardenedPass. Ideas of any size are welcome.
## Table of contents
-- [Things to do before you start writing code](#things-to-do-before-you-start-writing-code)
+- [Before you start writing code](#before-you-start-writing-code)
- [Navigating the source code](#navigating-the-source-code)
- [Building the project](#building-the-project)
+- [Checks before pushing](#checks-before-pushing)
+- [Sending changes](#sending-changes)
-# Things to do before you start writing code
+## Before you start writing code
-If you're trying to fix a bug that already has an open issue, it's a good idea to drop a comment mentioning that you're working on a fix. If no open issue exists, please file one with the full details of what the bug is and how you intend to resolve it, and only start implementation once a maintainer has triaged the issue and has signed off on your approach. This allows maintainers to be able to review your pull request faster when it arrives, and saves you redundant effort of having to make potentially large changes in response to code review.
+If you want to fix a bug that already has an open
+[issue](https://github.com/danixland/HardenedPass/issues), comment there to say you are
+working on it. If there is no issue yet, open one with the details of the bug and how you
+intend to fix it, and wait for the maintainer to agree on the approach before you start. That
+makes review faster and saves you from large rework.
-If you want to add a new feature, please file an issue first to gauge maintainer and community interest. Sometimes the feature might need adjustments before it is accepted, or it might not be something we want to add to APS at all. Proposing the feature in detail before working on it ensures that you build exactly what is necessary and not waste time and effort on aspects that would get rejected during review.
+For a new feature, open an issue first too. It might need adjustments, or it might not fit the
+project's goal: a `pass` client that works flawlessly on GrapheneOS, with no Google Play
+Services and no telemetry. Agreeing on the details first means you build only what will be
+accepted.
-## Navigating the source code
+Security issues are the exception: do not open a public issue, follow
+[SECURITY.md](SECURITY.md) instead.
-The source code is split across 12 modules and 1 subproject.
+## Navigating the source code
-- `build-logic` and its modules host the Gradle build logic for the project.
-- `autofill-parser` is the aptly named parser for Android's Autofill structures that also deals with trust and feature detection for browsers.
-- `coroutine-utils` is a helper libraries that allow for effective usage and testing of [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html).
-- `crypto/common` is the foundation of our new, extensible cryptography APIs that adds the ability to introduce new cryptographic backends to APS with minimal effort.
-- `crypto/pgpainless` is the first of our new backends that implements the APIs defined in `crypto-common` to offer PGP cryptography through the [PGPainless](https://gh.pgpainless.org/) library.
-- `format/common` handles parsing the `pass` file format.
-- `passgen/diceware` is our new password generator that implements the [Diceware](https://theworld.com/~reinhold/diceware.html) algorithm.
+The source code is split across 9 modules, plus the `build-logic` included build.
+
+- `build-logic` hosts the Gradle build logic for the project.
+- `autofill-parser` parses Android's Autofill structures, and deals with trust and feature
+ detection for browsers.
+- `coroutine-utils` helps use and test
+ [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html).
+- `crypto/common` defines the cryptography APIs that backends implement.
+- `crypto/pgpainless` implements them with the [PGPainless](https://pgpainless.org/) library.
+- `format/common` parses the `pass` file format.
+- `passgen/diceware` is a password generator implementing the
+ [Diceware](https://theworld.com/~reinhold/diceware.html) algorithm.
- `passgen/random` contains the default password generator.
-- `ui/compose` has the theming code for building UI components in [Jetpack Compose](https://developer.android.com/jetpack/compose).
-- `app` is everything else that constitutes APS.
+- `ui/compose` has the theme and shared components for
+ [Jetpack Compose](https://developer.android.com/jetpack/compose).
+- `app` is everything else: the UI, git sync, the autofill service and the Credential Manager
+ provider.
-In most scenarios, the `app` directory is where you'd be contributing changes to. While most of the code has been rewritten and documented, there are still gnarly "legacy" parts that might be challenging to understand at a glance. Please get in touch via the [Discussions](https://github.com/android-password-store/Android-Password-Store/discussions) page with any questions you have, and we'd love to explain and improve things.
+Most changes land in `app`. Some older parts are hard to follow at a glance: ask on the
+[issue tracker](https://github.com/danixland/HardenedPass/issues) and they will be explained,
+and improved where possible.
-We bundle a [`ignore-revs-file`](https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt) to ensure `git blame` is not affected by noisy changes. To make use of this, run `git config blame.ignoreRevsFile .git-blame-ignore-revs` from inside this repository. GitHub will automatically use this file for the blames it renders on the website.
+A [`ignore-revs-file`](https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt)
+keeps `git blame` clear of noisy changes. To use it, run
+`git config blame.ignoreRevsFile .git-blame-ignore-revs` inside this repository. GitHub uses it
+automatically.
## Source code conventions
-- Unless you're absolutely sure what you're doing, always prefer the [`unsafeLazy`](https://github.com/android-password-store/Android-Password-Store/blob/f870dd49138d63d9803783458abf1356176851db/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt#L68) method over Kotlin's inbuilt `lazy` for lazily evaluated properties.
-- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a ViewModel. Do not add XML layouts, Fragments or view binding.
+- Unless you are sure of what you are doing, prefer the `unsafeLazy` function in
+ `app/src/main/java/app/passwordstore/util/extensions/Extensions.kt` over Kotlin's `lazy` for
+ lazily evaluated properties.
+- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a
+ ViewModel. Do not add XML layouts, Fragments or view binding.
+- The Kotlin package stays `app.passwordstore`, while the application ID is
+ `xyz.danix.hardenedpass`. Do not rename the package.
+- No proprietary dependencies, no Google Play Services, no analytics or crash reporting.
## Building the project
-### Building with Gradle
+You need JDK 17 and the Android SDK. Set `sdk.dir` in `local.properties`, or use
+[Android Studio](https://developer.android.com/studio), which does it for you.
+
+Build a debug APK with:
+
+```shell
+./gradlew :app:assembleDebug
+```
+
+You can find it in `app/build/outputs/apk/debug/`. It installs as "HardenedPass Debug", next to
+the release app.
-This document assumes that you already have an Android development environment ready. If not, refer to Google's documentation on [installing Android Studio](https://developer.android.com/studio/preview). APS will build with all editions of Android Studio, but development typically happens with the Canary channel.
+## Checks before pushing
-Run the following command to generate a debug APK.
+CI runs these, with `CI=1` turning Kotlin warnings into errors. Run the same before you push:
```shell
-./gradlew collectDebugApks
+CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \
+ lint metalavaCheckCompatibilityRelease
```
-You can find the generated APK at `app/outputs`.
+`./gradlew spotlessApply` fixes the formatting. To run most of these checks automatically on
+every push, install a Git pre-push hook with `./gradlew installGitHooks` (it has no effect if
+you set `core.hooksPath`).
-## Pre-push checks
+## Sending changes
-The project enforces code style conventions and library API stability by virtue of a carefully curated Gradle build. To setup a Git pre-push hook to run them automatically, run `./gradlew installGitHooks`.
+Open a pull request on [GitHub](https://github.com/danixland/HardenedPass/pulls). GitHub is a
+mirror of the main repository, so the maintainer applies accepted changes there, with your
+authorship kept, and closes the pull request instead of merging it.
diff --git a/README.md b/README.md
index 70778ee1..7eaf3f25 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,16 @@ Android Password Store authors.
How to install, set up and use the app: <https://danix.xyz/hardenedpass/>.
+New to `pass`? "Your own password store" is a beginner tutorial series, also available in
+[Italian](https://danix.xyz/it/articles/your-own-password-store-part-1/):
+
+1. [Create the store](https://danix.xyz/articles/your-own-password-store-part-1/) with `pass`,
+ GPG and git.
+2. [Choose a git host](https://danix.xyz/articles/your-own-password-store-part-2/), or do it all
+ from the phone with Termux.
+3. [Install HardenedPass](https://danix.xyz/articles/your-own-password-store-part-3/), connect it
+ to the store, and cover daily use and recovery.
+
## Changes from upstream
- Decrypts messages GnuPG 2.3+ writes by default (OCB / LibrePGP AEAD), which upstream rejected
@@ -45,9 +55,14 @@ Services.
Report bugs and feature requests on the
[issue tracker](https://github.com/danixland/HardenedPass/issues).
+Report security issues privately by email instead, as described in
+[SECURITY.md](SECURITY.md).
## Support
+HardenedPass will always be free, but maintaining a software can be costly, so if you found this piece of software useful, you can buy me a coffee.
+All donations will help me maintain HardenedPass in the future.
+
[![Support on Ko-fi](media/kofi-button.png)](https://ko-fi.com/K6Y520C9I6)
## License
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..d8c1f25c
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,23 @@
+# Security policy
+
+## Reporting a vulnerability
+
+Please report security issues privately, not on the public issue tracker.
+
+Email <danix@danix.xyz>, encrypted to the maintainer's OpenPGP key if you can:
+
+ 110E 1B11 1BA2 C3C7 CCD1 27B8 F96B 36CA CC44 17AE
+
+Fetch it with either of these, and check that the fingerprint matches:
+
+ gpg --locate-keys danix@danix.xyz
+ gpg --keyserver hkps://keys.openpgp.org --recv-keys 110E1B111BA2C3C7CCD127B8F96B36CACC4417AE
+
+Include the HardenedPass version (Settings, About), the Android version, and the
+steps to reproduce.
+
+## Supported versions
+
+Only the latest release gets fixes. No app store delivers HardenedPass updates:
+install the newest APK from
+[GitHub Releases](https://github.com/danixland/HardenedPass/releases/latest).
diff --git a/TODO.md b/TODO.md
index 286ffa40..27ddf2da 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,6 +2,18 @@
Open work after 0.2.0. Remove an item in the commit that finishes it.
+## Bugs
+
+- Pull fails on a store made with Create Local Repo once a remote is added in Edit Git server
+ settings: "Could not read an object while parsing commit <id>". Push works, and a cloned store
+ pulls fine (0.3.3, emulator, SSH). Likely cause: `PasswordRepository.addRemote` writes the refspec
+ `+refs/head/*:refs/remotes/$name/*`, with `head` where git uses `heads`, so nothing is fetched.
+ Stores already set up this way keep the bad refspec in `.git/config`, so a fix must repair them
+ too. Not confirmed by reading the store's config.
+- Key import accepts GnuPG stub and smartcard keyrings, whose secret parts live on a card: they
+ pass `KeyUtils.isSecretKey` and only fail at decryption. Refuse them at import with a clear
+ message (check for a secret subkey usable for decryption).
+
## Features and UI
- Push after autofill saves: the "Push after each change" setting skips them, because
@@ -10,3 +22,6 @@ Open work after 0.2.0. Remove an item in the commit that finishes it.
it at runtime, so the QR scanner must be replaced. That alone is not enough: `androidx.biometric`
depends on it too, for a fingerprint dialog unused at minSdk 31, so it must be excluded or
`BiometricPrompt` used from the platform.
+- OpenPGP card support (NFC and USB): requested on the password-store mailing list (2026-11-09).
+ Upstream reached cards through OpenKeychain until PGPainless replaced it in 2.0; PGPainless has
+ no card support, so decryption on the card needs its own OpenPGP card (ISO 7816) layer.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 81414510..b21d0aed 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -60,6 +60,7 @@ dependencies {
implementation(libs.androidx.autofill)
implementation(libs.androidx.biometricKtx)
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.credentials)
implementation(libs.androidx.documentfile)
// FragmentActivity, which BiometricPrompt needs.
implementation(libs.androidx.fragment.ktx)
diff --git a/app/lint.xml b/app/lint.xml
index 42a888eb..6babd470 100644
--- a/app/lint.xml
+++ b/app/lint.xml
@@ -25,6 +25,8 @@
<ignore path="src/main/res/drawable/ic_miscellaneous_services_24px.xml" />
<ignore path="src/main/res/drawable/ic_miscellaneous_services_24px.xml" />
</issue>
+ <!-- The app is a Credential Manager provider (Android 14+), not a client: no Play Services. -->
+ <issue id="CredentialDependency" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<issue id="ReportShortcutUsage" severity="ignore" />
<issue id="MissingTranslation" severity="ignore" />
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0eabbab4..026be6c8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -79,6 +79,18 @@
android:name="android.autofill"
android:resource="@xml/oreo_autofill_service" />
</service>
+ <service
+ android:name=".util.services.CredentialService"
+ android:exported="true"
+ android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
+ tools:targetApi="34">
+ <intent-filter>
+ <action android:name="android.service.credentials.CredentialProviderService" />
+ </intent-filter>
+ <meta-data
+ android:name="android.credentials.provider"
+ android:resource="@xml/credential_provider" />
+ </service>
<!-- ponytail: rotation, resizing and keyboard changes must not cancel a fill in progress;
other recreations (theme, locale) cancel it. -->
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/ui/settings/AboutScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
index 4a366390..6c20d3af 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
@@ -152,6 +152,9 @@ private fun AboutScreen(
LinkRow(stringResource(R.string.about_changelog)) { onOpen(changelog) }
LinkRow(stringResource(R.string.about_source)) { onOpen(REPOSITORY) }
LinkRow(stringResource(R.string.about_issues)) { onOpen("$REPOSITORY/issues") }
+ LinkRow(stringResource(R.string.about_security)) {
+ onOpen("$REPOSITORY/blob/master/SECURITY.md")
+ }
// White in both themes: the logo's dark outline disappears on a dark background, and Ko-fi
// asks that the logo itself stays as it is.
Button(
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)
+ }
+}
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index fed8adec..18065976 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -393,6 +393,7 @@
<string name="about_changelog">Novità</string>
<string name="about_source">Codice sorgente</string>
<string name="about_issues">Segnala un problema</string>
+ <string name="about_security">Segnala un problema di sicurezza</string>
<string name="about_support">Sostieni su Ko-fi</string>
<string name="about_privacy_title">Privacy</string>
<string name="about_privacy">Nessuna telemetria, nessuna analisi, nessun servizio Google. HardenedPass si collega solo al tuo server git, tramite il tuo proxy se ne imposti uno.</string>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a415e95f..ec2396be 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -427,6 +427,7 @@
<string name="about_changelog">What\'s new</string>
<string name="about_source">Source code</string>
<string name="about_issues">Report a problem</string>
+ <string name="about_security">Report a security issue</string>
<string name="about_support">Support on Ko-fi</string>
<string name="about_privacy_title">Privacy</string>
<string name="about_privacy">No telemetry, no analytics, no Google services. HardenedPass connects only to your git server, through your proxy if you set one.</string>
diff --git a/app/src/main/res/xml/credential_provider.xml b/app/src/main/res/xml/credential_provider.xml
new file mode 100644
index 00000000..d8e2d5b8
--- /dev/null
+++ b/app/src/main/res/xml/credential_provider.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8" ?><!--
+ ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ ~ SPDX-License-Identifier: GPL-3.0-only
+ -->
+
+<credential-provider xmlns:android="http://schemas.android.com/apk/res/android">
+ <capabilities>
+ <capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" />
+ </capabilities>
+</credential-provider>
diff --git a/app/version.properties b/app/version.properties
index 5b4f8e0b..cd3fecb2 100644
--- a/app/version.properties
+++ b/app/version.properties
@@ -1,5 +1,5 @@
#
# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
#
-versioning-plugin.versionCode=303
-versioning-plugin.versionName=0.3.3
+versioning-plugin.versionCode=304
+versioning-plugin.versionName=0.3.4
diff --git a/autofill-parser/src/main/assets/publicsuffixes b/autofill-parser/src/main/assets/publicsuffixes
index 84cf47df..0cf30bf1 100644
--- a/autofill-parser/src/main/assets/publicsuffixes
+++ b/autofill-parser/src/main/assets/publicsuffixes
Binary files differ
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 546b844c..03eb4c1b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,7 @@ androidx-annotation = "androidx.annotation:annotation:1.8.2"
androidx-autofill = "androidx.autofill:autofill:1.3.0"
androidx-biometricKtx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
androidx-core-ktx = "androidx.core:core-ktx:1.13.1"
+androidx-credentials = "androidx.credentials:credentials:1.5.0"
androidx-documentfile = "androidx.documentfile:documentfile:1.1.0"
androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.8.4"
androidx-hilt-navigation-compose = "androidx.hilt:hilt-navigation-compose:1.2.0"