Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file
### Fixed

- Autofill fast unlocking setup: PIN setting dialog/biometric prompt were not shown after passphrase verification
- Shortcuts on the home screen (pinned passwords) were pointing to the containing folder, not the actual password
- There is now one fast unlocking PIN per PGP ID to prevent password leakage in case of folders that were initialised with different PGP IDs

## [2.0.0] - 2026-07-26

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The original documentation can be found [here](https://docs.passwordstore.app) a

To activate passkey (Android 14+) and autofill support, go to Settings → Autofill & Passkeys and choose Password Store as your preferred service. For Chrome and Chromium-based browsers, you might additionally need to enable "Autofill using another service" within the browser's own settings.

Utilizing the standard `pass` file structure, passkey data is stored on the first line, followed by optional extra content, as line-oriented plain text secured by PGP encryption. Details on passkey encoding and storage are given [here](PasskeyStorage.md).
Utilising the standard `pass` file structure, passkey data is stored on the first line, followed by optional extra content, as line-oriented plain text secured by PGP encryption. Details on passkey encoding and storage are given in file [`PasskeyStorage.md`](PasskeyStorage.md).

## How-To: Transfer a PGP key to Password Store securely

Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/app/passwordstore/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.view.WindowInsetsController
import androidx.appcompat.app.AppCompatDelegate
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.context.FilesDirPath
import app.passwordstore.injection.prefs.PGPPassphrases
import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.ui.crypto.BasePGPActivity.Companion.cachedPassphrases
import app.passwordstore.util.coroutines.DispatcherProvider
Expand Down Expand Up @@ -47,6 +48,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere

@Inject @SettingsPreferences lateinit var prefs: SharedPreferences
@Inject @FilesDirPath lateinit var filesDirPath: String
@Inject @PGPPassphrases lateinit var persistentPassphrases: SharedPreferences
@Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var gitSettings: GitSettings
@Inject lateinit var proxyUtils: ProxyUtils
Expand All @@ -73,7 +75,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere

prefs.registerOnSharedPreferenceChangeListener(this)
setNightMode()
runMigrations(filesDirPath, prefs, gitSettings)
runMigrations(filesDirPath, prefs, gitSettings, persistentPassphrases)
proxyUtils.setDefaultProxy()
DynamicColors.applyToActivitiesIfAvailable(this)
setupScreenOffHandler()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ class PreferenceModule {
return context.getSharedPreferences("${BuildConfig.APPLICATION_ID}_passphrases", MODE_PRIVATE)
}

@[Provides UnlockPins Reusable]
fun provideUnlockPins(@ApplicationContext context: Context): SharedPreferences {
return context.getSharedPreferences("${BuildConfig.APPLICATION_ID}_unlock_pins", MODE_PRIVATE)
}

@[Provides GitSecrets Reusable]
fun provideGitSecrets(@ApplicationContext context: Context): SharedPreferences {
return context.getSharedPreferences("${BuildConfig.APPLICATION_ID}_git_secrets", MODE_PRIVATE)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/

package app.passwordstore.injection.prefs

import javax.inject.Qualifier

@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class UnlockPins
176 changes: 112 additions & 64 deletions app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.prefs.PGPPassphrases
import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.injection.prefs.UnlockPins
import app.passwordstore.ui.dialogs.PasswordDialog
import app.passwordstore.ui.pgp.PGPKeyListActivity
import app.passwordstore.util.auth.BiometricAuthenticator
Expand Down Expand Up @@ -57,6 +58,7 @@ import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.max
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
Expand Down Expand Up @@ -158,6 +160,8 @@ open class BasePGPActivity : AppCompatActivity() {
*/
@PGPPassphrases @Inject lateinit var persistentPassphrases: SharedPreferences

@UnlockPins @Inject lateinit var unlockPins: SharedPreferences

@Inject lateinit var repository: CryptoRepository
@Inject lateinit var dispatcherProvider: DispatcherProvider

Expand Down Expand Up @@ -554,7 +558,7 @@ open class BasePGPActivity : AppCompatActivity() {
) {
/* Ask user for setting a PIN if not yet existing, encrypt and store it on the
* device, then update passphrase in cache */
if (persistentPassphrases.getString("unlock_pin", null) == null) {
if (unlockPins.getString(id, null) == null) {
fastUnlockingSetupCompletion = CompletableDeferred<Unit>()
val pinDialog =
PinDialog.newInstance(
Expand All @@ -566,15 +570,17 @@ open class BasePGPActivity : AppCompatActivity() {
if (key == PinDialog.PIN_RESULT_KEY) {
val pin = bundle.getCharArray(PinDialog.PIN_KEY)
if (pin != null && pin.size >= 4) {
persistentPassphrases.edit {
unlockPins.edit {
putString(
"unlock_pin", // reset and prepend PIN attempt counter
id, // reset and prepend PIN attempt counter
AESEncryption.encrypt(
charArrayOf('0', ':') + pin,
keyType = KeyType.PERSISTENT,
)
?.concatToString(),
)
}
persistentPassphrases.edit {
putString(
id,
AESEncryption.encrypt(passphrase, keyType = KeyType.PERSISTENT)
Expand Down Expand Up @@ -645,12 +651,18 @@ open class BasePGPActivity : AppCompatActivity() {
if (
biometrics_and_pin_timeout > 0L &&
now - biometrics_and_pin_last_use >= TimeUnit.DAYS.toMillis(biometrics_and_pin_timeout)
)
) {
persistentPassphrases.edit { clear() }
unlockPins.edit { clear() }
}

val persistentIds =
identifiers.map { it.toString() }.filter { persistentPassphrases.contains(it) }
val pinEncrypted = persistentPassphrases.getString("unlock_pin", null)?.toCharArray()
val encryptedPins =
unlockPins
.getAll()
.filterKeys { persistentIds.contains(it) }
.mapValues { (it.value as String).toCharArray() }
if (
!persistentIds.none() &&
identifiers.map { it.toString() }.filter { cachedPassphrases.containsKey(it) }.none() &&
Expand Down Expand Up @@ -684,22 +696,20 @@ open class BasePGPActivity : AppCompatActivity() {
if (result !is BiometricResult.Retry) decrypt(identifiers)
}
} else if (
!persistentIds.none() &&
!encryptedPins.none() &&
identifiers.map { it.toString() }.filter { cachedPassphrases.containsKey(it) }.none() &&
AESEncryption.isHardwareBacked(KeyType.PERSISTENT) &&
settings.getString(PreferenceKeys.PREF_FAST_UNLOCK_OPTION, "disabled") == "PIN" &&
pinEncrypted != null
settings.getString(PreferenceKeys.PREF_FAST_UNLOCK_OPTION, "disabled") == "PIN"
) {
verifyPin(pinEncrypted, persistentIds, identifiers, action)
verifyPin(encryptedPins, identifiers, action)
} else {
decrypt(identifiers)
}
}

/* Asks for and verifies the user PIN for unlocking a store entry. */
private fun verifyPin(
pinEncrypted: CharArray,
ids: List<String>,
encryptedPins: Map<String, CharArray>,
identifiers: List<PGPIdentifier>,
action: String?,
isError: Boolean = false,
Expand All @@ -718,69 +728,107 @@ open class BasePGPActivity : AppCompatActivity() {
pinDialog.show(supportFragmentManager, "PIN_DIALOG")
pinDialog.setFragmentResultListener(PinDialog.PIN_RESULT_KEY) { key, bundle ->
if (key == PinDialog.PIN_RESULT_KEY) {
val pin = requireNotNull(bundle.getCharArray(PinDialog.PIN_KEY)) { "returned PIN is null" }
var (pinRetries, cachedPin) =
AESEncryption.decrypt(pinEncrypted, keyType = KeyType.PERSISTENT)?.let { cached ->
if (cached[1] == ':') {
Pair(cached[0].digitToInt(), cached.filterIndexed { i, _ -> i > 1 }.toCharArray())
} else {
// fix PIN cache that does not have an attempt count prepended (old app version)
persistentPassphrases.edit {
putString(
"unlock_pin",
AESEncryption.encrypt(
charArrayOf('0', ':') + cached,
keyType = KeyType.PERSISTENT,
if (bundle.getBoolean(PinDialog.PIN_CANCEL))
decrypt(identifiers) // decrypt with passphrase verification
else {
val pin =
requireNotNull(bundle.getCharArray(PinDialog.PIN_KEY)) { "returned PIN is null" }
var pinRetries = 0

var pinOk = false
// verify user-entered PIN against cached PINs
for ((id, encryptedPin) in encryptedPins) {
var cachedPin =
AESEncryption.decrypt(encryptedPin, keyType = KeyType.PERSISTENT)?.let { cached ->
cached.copyOfRange(cached.indexOf(':') + 1, cached.size).also {
pinRetries =
max(
pinRetries,
cached.copyOfRange(0, cached.indexOf(':')).concatToString().toIntOrNull()
?: MAX_RETRIES,
)
?.concatToString(),
)
cached?.wipe()
}
}
Pair(0, cached)
pinOk = cachedPin?.let { it.contentEquals(pin) } ?: false
cachedPin?.wipe()
if (pinOk) {
// PIN verifies successfully against one of the cached ones
updatePinAttemptCounter(encryptedPins, 0) // reset attempt counter
// re-encrypt and cache passphrase temporarily for use until screen-off
persistentPassphrases
.getString(id, null)
?.toCharArray()
?.let { passEncrypted ->
AESEncryption.decrypt(passEncrypted, keyType = KeyType.PERSISTENT)
}
?.let { pass ->
AESEncryption.encrypt(pass)?.let {
cachedPassphrases.put(id, it)
}
pass.wipe()
}
break
}
} ?: Pair(MAX_RETRIES, null)
if (cachedPin?.let { it.contentEquals(pin) } ?: false) { // PIN verifies successfully
persistentPassphrases.edit {
putString(
"unlock_pin", // reset to zero and prepend attempt counter
AESEncryption.encrypt(charArrayOf('0', ':') + pin, keyType = KeyType.PERSISTENT)
?.concatToString(),
)
putLong(PreferenceKeys.BIOMETRICS_AND_PIN_LAST_USE, Instant.now().toEpochMilli())
}
ids.forEach { id ->
val passEncrypted = persistentPassphrases.getString(id, null)?.toCharArray()
val pass =
// re-encrypt passphrase for use until screen-off
AESEncryption.encrypt(
// decrypt persistently cached passphrase
AESEncryption.decrypt(passEncrypted, keyType = KeyType.PERSISTENT)
)
pass?.let { cachedPassphrases.put(id, it) }
}
decrypt(identifiers)
} else if (
cachedPin != null && ++pinRetries < MAX_RETRIES
) { // PIN verification failed, try again
val pinEncryptedUpdate =
AESEncryption.encrypt(
charArrayOf(pinRetries.digitToChar(), ':') + cachedPin,
keyType = KeyType.PERSISTENT,
)
pinEncryptedUpdate?.let { // update PIN cache with incremented attempt counter
persistentPassphrases.edit {
putString("unlock_pin", pinEncryptedUpdate.concatToString())

pin.wipe()

if (pinOk) decrypt(identifiers)
else {
if (++pinRetries < MAX_RETRIES) { // try again
val encryptedPinsUpdated = updatePinAttemptCounter(encryptedPins, pinRetries)
verifyPin(encryptedPinsUpdated, identifiers, action, isError = true)
} else {
// reset PIN and cached passphrase(s) to prevent bruteforcing
encryptedPins.keys.forEach { id ->
cachedPassphrases.remove(id)
persistentPassphrases.edit { remove(id) }
unlockPins.edit { remove(id) }
}
decrypt(identifiers)
}
verifyPin(pinEncryptedUpdate, ids, identifiers, action, isError = true)
} ?: throw NullPointerException()
} else { // PIN verification failed, do not try again
persistentPassphrases.edit { clear() } // reset PIN to prevent bruteforcing
decrypt(identifiers) // decrypt with passphrase verification
}
}
pin.wipe()
}
}
}

// updates attempt counter and prepends it to the cached PINs
private fun updatePinAttemptCounter(
encryptedPins: Map<String, CharArray>,
attempts: Int,
): Map<String, CharArray> {
var updatedEncryptedPins = mutableMapOf<String, CharArray>()
unlockPins.edit {
encryptedPins.forEach { id, encryptedPin ->
AESEncryption.decrypt(encryptedPin, keyType = KeyType.PERSISTENT)
?.let { cached ->
cached.copyOfRange(cached.indexOf(':') + 1, cached.size).also { cached.wipe() }
}
?.let { pin ->
AESEncryption.encrypt(
(attempts.toString() + ":").toCharArray() + pin,
keyType = KeyType.PERSISTENT,
)
?.let { updated ->
putString(id, updated.concatToString())
updatedEncryptedPins.put(id, updated)
}
pin?.wipe()
}
?: run {
remove(id)
}
}
}
if (attempts == 0)
persistentPassphrases.edit {
putLong(PreferenceKeys.BIOMETRICS_AND_PIN_LAST_USE, Instant.now().toEpochMilli())
}
return updatedEncryptedPins
}

protected fun decrypt(identifiers: List<PGPIdentifier>, isError: Boolean = false) {
val passphrases = cachedPassphrases.filterKeys {
identifiers.map { it.toString() }.contains(it)
Expand Down
26 changes: 21 additions & 5 deletions app/src/main/java/app/passwordstore/ui/crypto/PinDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ class PinDialog : DialogFragment() {
private val binding by unsafeLazy { DialogPinEntryBinding.inflate(layoutInflater) }
private var isError: Boolean = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isCancelable = false // disable tapping BACK / fragment cancel
}

override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setView(binding.root)
Expand All @@ -35,9 +40,13 @@ class PinDialog : DialogFragment() {
binding.descriptionText.setText(descriptionText)

builder.setPositiveButton(android.R.string.ok) { _, _ -> setPinAndDismiss() }
builder.setNegativeButton(android.R.string.cancel) { d, _ -> d.cancel() }

val dialog = builder.create()

dialog.setCanceledOnTouchOutside(false)

dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.setCancelable(false)
dialog.setOnShowListener {
var pinLength = 0
if (isError) {
Expand All @@ -55,9 +64,9 @@ class PinDialog : DialogFragment() {
setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_ENTER && pinLength >= 4) {
setPinAndDismiss()
return@setOnKeyListener true
return@setOnKeyListener true // return true to consume Enter
}
false
keyCode == KeyEvent.KEYCODE_BACK // return true to consume Back
}
}
}
Expand All @@ -79,12 +88,18 @@ class PinDialog : DialogFragment() {

override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
setFragmentResult(PIN_RESULT_KEY, Bundle())
setFragmentResult(PIN_RESULT_KEY, Bundle().also { it.putBoolean(PIN_CANCEL, true) })
}

private fun setPinAndDismiss() {
val pin = binding.pinEditText.text?.let { CharArray(it.length) { i -> it[i] } }
setFragmentResult(PIN_RESULT_KEY, Bundle().also { it.putCharArray(PIN_KEY, pin) })
setFragmentResult(
PIN_RESULT_KEY,
Bundle().also {
it.putCharArray(PIN_KEY, pin)
it.putBoolean(PIN_CANCEL, false)
},
)
dismissAllowingStateLoss()
}

Expand All @@ -95,6 +110,7 @@ class PinDialog : DialogFragment() {

const val PIN_RESULT_KEY = "pin_result"
const val PIN_KEY = "pin"
const val PIN_CANCEL = "cancel"

fun newInstance(
title: String,
Expand Down
Loading