diff --git a/.github/upstream-sync-baseline b/.github/upstream-sync-baseline
new file mode 100644
index 0000000000..b06bbbbb5e
--- /dev/null
+++ b/.github/upstream-sync-baseline
@@ -0,0 +1 @@
+48ce3af5b4cd9b818d44edca4249a15d48e2170f
diff --git a/.github/workflows/upstream-audit.yml b/.github/workflows/upstream-audit.yml
new file mode 100644
index 0000000000..b34258d757
--- /dev/null
+++ b/.github/workflows/upstream-audit.yml
@@ -0,0 +1,48 @@
+name: Audit upstream changes
+
+on:
+ schedule:
+ - cron: "17 6 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ audit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Generate upstream reconciliation report
+ id: audit
+ shell: bash
+ run: scripts/upstream-audit.sh "$RUNNER_TEMP/upstream-audit.md"
+
+ - name: Create or update reconciliation issue
+ if: steps.audit.outputs.has_changes == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPORT: ${{ runner.temp }}/upstream-audit.md
+ shell: bash
+ run: |
+ set -euo pipefail
+ issue_number="$(
+ gh issue list \
+ --state open \
+ --search '"Upstream reconciliation pending" in:title' \
+ --json number,title \
+ --jq '.[] | select(.title == "Upstream reconciliation pending") | .number' \
+ | head -n 1
+ )"
+
+ if [[ -n "$issue_number" ]]; then
+ gh issue edit "$issue_number" --body-file "$REPORT"
+ else
+ gh issue create \
+ --title "Upstream reconciliation pending" \
+ --body-file "$REPORT"
+ fi
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3d606dce1..3181217805 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
android:installLocation="auto">
+
- getPersistentAndDecrypt(ids)
+ getPersistentAndDecrypt(ids, action = "autofill")
}
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
index 1c3e455cdb..ec2977401a 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
@@ -25,6 +25,7 @@ import app.passwordstore.data.crypto.CryptoRepository
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
@@ -53,6 +54,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
@@ -146,6 +148,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
@@ -546,7 +550,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) {
val pinDialog =
PinDialog.newInstance(
title = resources.getString(R.string.pin_new_entry_title),
@@ -556,20 +560,19 @@ 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"
- }
- if (pin.size >= 4) {
- persistentPassphrases.edit {
+ val pin = bundle.getCharArray(PinDialog.PIN_KEY)
+ if (pin != null && pin.size >= 4) {
+ 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)
@@ -581,7 +584,7 @@ open class BasePGPActivity : AppCompatActivity() {
)
}
}
- pin.wipe()
+ pin?.wipe()
}
}
} else {
@@ -612,7 +615,7 @@ open class BasePGPActivity : AppCompatActivity() {
/* Find persistent PGP passphrases with matching key ID, unlock the first one
* with biometrics or after PIN verification */
- protected fun getPersistentAndDecrypt(identifiers: List) {
+ protected fun getPersistentAndDecrypt(identifiers: List, action: String? = null) {
// Detect AES key invalidation due to enrollment of a new fingerprint and emit warning
if (
BiometricAuthenticator.canAuthenticate(this@BasePGPActivity) &&
@@ -637,12 +640,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() &&
@@ -676,13 +685,12 @@ 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)
+ verifyPin(encryptedPins, identifiers, action)
} else {
decrypt(identifiers)
}
@@ -690,83 +698,118 @@ open class BasePGPActivity : AppCompatActivity() {
/* Asks for and verifies the user PIN for unlocking a store entry. */
private fun verifyPin(
- pinEncrypted: CharArray,
- ids: List,
+ encryptedPins: Map,
identifiers: List,
+ action: String?,
isError: Boolean = false,
) {
val pinDialog =
PinDialog.newInstance(
title = resources.getString(R.string.pin_entry_title),
- description = resources.getString(R.string.pin_entry_description),
+ description =
+ when (action) {
+ "autofill" -> resources.getString(R.string.pin_entry_autofill_description)
+ "passkey" -> resources.getString(R.string.pin_entry_passkey_description)
+ else -> resources.getString(R.string.pin_entry_description)
+ },
)
if (isError) pinDialog.setError()
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)
+ } else {
+ val pin =
+ requireNotNull(bundle.getCharArray(PinDialog.PIN_KEY)) { "returned PIN is null" }
+ var pinRetries = 0
+ var pinOk = false
+
+ for ((id, encryptedPin) in encryptedPins) {
+ val 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) {
+ updatePinAttemptCounter(encryptedPins, 0)
+ 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) {
+ val encryptedPinsUpdated = updatePinAttemptCounter(encryptedPins, pinRetries)
+ verifyPin(encryptedPinsUpdated, identifiers, action, isError = true)
+ } else {
+ // Reset only the relevant identities after the retry budget is exhausted.
+ encryptedPins.keys.forEach { id ->
+ cachedPassphrases.remove(id)
+ persistentPassphrases.edit { remove(id) }
+ unlockPins.edit { remove(id) }
}
- verifyPin(pinEncryptedUpdate, ids, identifiers, 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
+ decrypt(identifiers)
+ }
}
- pin.wipe()
}
}
}
+ /** Updates and persists the shared retry counter for the relevant per-PGP-ID PINs. */
+ private fun updatePinAttemptCounter(
+ encryptedPins: Map,
+ attempts: Int,
+ ): Map {
+ val updatedEncryptedPins = mutableMapOf()
+ 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[id] = updated
+ }
+ pin.wipe()
+ } ?: remove(id)
+ }
+ }
+ if (attempts == 0) {
+ persistentPassphrases.edit {
+ putLong(PreferenceKeys.BIOMETRICS_AND_PIN_LAST_USE, Instant.now().toEpochMilli())
+ }
+ }
+ return updatedEncryptedPins
+ }
+
protected fun decrypt(identifiers: List, isError: Boolean = false) {
val passphrases = cachedPassphrases.filterKeys {
identifiers.map { it.toString() }.contains(it)
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
index 5e75aa55d1..48e0854c98 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
@@ -271,7 +271,10 @@ class DecryptActivity : BasePGPActivity() {
entry.extraContent.forEach { (key, value) ->
if (key.contentEquals(PasswordEntry.EXTRA_CONTENT))
- items.add(FieldItem.createFreeformField(getString(R.string.crypto_extra_label), value))
+ if (settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true))
+ items.add(FieldItem.createFreeformField(getString(R.string.crypto_extra_label), value))
+ else
+ items.add(FieldItem.createPasswordField(getString(R.string.crypto_extra_label), value))
}
val adapter =
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
index a02c025262..eed4e0d722 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
@@ -5,7 +5,6 @@
package app.passwordstore.ui.crypto
-import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -496,15 +495,17 @@ class PasswordCreationActivity : BasePGPActivity() {
passwordFile.writeBytes(result.getOrThrow().toByteArray())
}
- // associate the new password name with the last name's timestamp in history
- val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val oldFilePathHash = "${fullPath.trimEnd('/')}/$suggestedName.gpg".base64()
- val timestamp = preference.getString(oldFilePathHash)
- if (timestamp != null) {
- preference.edit {
+ // create/update timestamp on the current password file
+ val preference = getSharedPreferences("recent_password_history", 0)
+ preference.edit {
+ suggestedName?.let { oldFile ->
+ val oldFilePathHash = "${fullPath.trimEnd('/')}/$oldFile.gpg".base64()
remove(oldFilePathHash)
- putString(passwordFile.absolutePathString().base64(), timestamp)
}
+ putString(
+ passwordFile.absolutePathString().base64(),
+ System.currentTimeMillis().toString(),
+ )
}
val returnIntent = Intent()
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PinDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PinDialog.kt
index 01efd64c32..bdd6754bac 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/PinDialog.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/PinDialog.kt
@@ -15,7 +15,6 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import app.passwordstore.R
import app.passwordstore.databinding.DialogPinEntryBinding
-import app.passwordstore.util.extensions.finish
import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.extensions.wipe
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -26,18 +25,26 @@ 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
+ }
+
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setView(binding.root)
- var titleText = requireArguments().getString(TITLE_TEXT_EXTRA)
+ val titleText = requireArguments().getString(TITLE_TEXT_EXTRA)
builder.setTitle(titleText)
- var descriptionText = requireArguments().getString(DESCRIPTION_TEXT_EXTRA)
+ val descriptionText = requireArguments().getString(DESCRIPTION_TEXT_EXTRA)
binding.descriptionText.setText(descriptionText)
builder.setPositiveButton(android.R.string.ok) { _, _ -> setPinAndDismiss() }
+ builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() }
+
val dialog = builder.create()
+ dialog.setCanceledOnTouchOutside(false)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.setOnShowListener {
var pinLength = 0
@@ -58,7 +65,7 @@ class PinDialog : DialogFragment() {
setPinAndDismiss()
return@setOnKeyListener true
}
- false
+ keyCode == KeyEvent.KEYCODE_BACK
}
}
}
@@ -81,12 +88,18 @@ class PinDialog : DialogFragment() {
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
- finish()
+ 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()
}
@@ -98,6 +111,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,
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
index dbdee107d4..ddc301e941 100644
--- a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
+++ b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
@@ -7,7 +7,6 @@ package app.passwordstore.ui.dialogs
import android.app.AlertDialog
import android.app.Dialog
-import android.content.Context
import android.content.SharedPreferences
import android.graphics.Typeface
import android.os.Bundle
@@ -42,7 +41,7 @@ class DicewarePasswordGeneratorDialogFragment : DialogFragment() {
lateinit var prefs: SharedPreferences
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+ prefs = requireContext().getSharedPreferences("PasswordGenerator", 0)
val builder = MaterialAlertDialogBuilder(requireContext())
val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater)
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
index 53a9345263..9fa2402750 100644
--- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
+++ b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
@@ -39,7 +39,7 @@ import reactivecircus.flowbinding.android.widget.checkedChanges
class PasswordGeneratorDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+ val prefs = requireContext().getSharedPreferences("PasswordGenerator", 0)
val builder = MaterialAlertDialogBuilder(requireContext())
val binding = FragmentPwgenBinding.inflate(layoutInflater)
@@ -139,7 +139,7 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
* passwords.
*/
private fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean {
- ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
+ ctx.getSharedPreferences("PasswordGenerator", 0).edit {
for (possibleOption in PasswordOption.entries) {
putBoolean(possibleOption.key, possibleOption in options)
}
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
index dfeb43a691..8f5fb6dbda 100644
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
+++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
@@ -371,8 +371,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name
) {
// save the time when password was used
- val preferences =
- context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val preferences = context.getSharedPreferences("recent_password_history", 0)
preferences.edit {
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
}
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
index ebc02c12f7..067d7a581f 100644
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
+++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
@@ -5,7 +5,6 @@
package app.passwordstore.ui.passwords
import android.content.ComponentName
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
@@ -481,6 +480,13 @@ class PasswordStore : BaseGitActivity() {
if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
else filesToDelete.add(item.file)
}
+ // remove to-be-deleted files from history
+ val preference = getSharedPreferences("recent_password_history", 0)
+ preference.edit {
+ filesToDelete.forEach { file ->
+ remove(file.absolutePath.base64())
+ }
+ }
selectedItems.map { item -> item.file.deleteRecursively() }
refreshPasswordList()
AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
@@ -557,8 +563,7 @@ class PasswordStore : BaseGitActivity() {
// associate the new category with the last category's timestamp in
// history
- val preference =
- getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val preference = getSharedPreferences("recent_password_history", 0)
val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
if (timestamp != null) {
preference.edit {
@@ -657,6 +662,16 @@ class PasswordStore : BaseGitActivity() {
.show()
}
} else {
+ // update timestamp cache with the new file locations
+ val preference = getSharedPreferences("recent_password_history", 0)
+ preference.edit {
+ sourceDestinationMap.forEach { (src, dest) ->
+ val srcPathHash = src.absolutePath.base64()
+ val timestamp = preference.getString(srcPathHash)
+ remove(srcPathHash)
+ putString(dest.absolutePath.base64(), timestamp)
+ }
+ }
AutofillMatcher.updateMatches(this, sourceDestinationMap)
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
index d9fd6c6b90..9c937915e6 100644
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
@@ -12,6 +12,7 @@ import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.widget.CheckBox
+import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
@@ -128,6 +129,58 @@ class PGPKeyListActivity : AppCompatActivity() {
}
}
+ fun onNavigateBack(
+ isSelectingKeys: Boolean,
+ singleSelection: Boolean,
+ selectedKeyIds: Set,
+ ) {
+ val result = Intent()
+ if (isSelectingKeys) {
+ if (selectedKeyIds.isNotEmpty()) {
+ result.putExtra(
+ EXTRA_SELECTED_KEY,
+ selectedKeyIds.joinToString(separator = "\n"),
+ )
+ val gpgIdDest = intent.getStringExtra("SUB_PATH")
+ gpgIdDest?.let { result.putExtra("SUB_PATH", it) }
+ setResult(RESULT_OK, result)
+ finish()
+ } else {
+ val okButtonText =
+ if (singleSelection) resources.getString(R.string.gpg_key_single_select)
+ else resources.getString(R.string.gpg_key_select)
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.no_keys_selected_dialog_title)
+ .setPositiveButton(okButtonText, null)
+ .setNegativeButton(
+ R.string.pgp_key_insecure_passphrase_warning_confirm // continue anyway
+ ) { _, _ ->
+ if (singleSelection && SshKey.pgpLongKeyId != 0L) SshKey.delete()
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ .setCancelable(false)
+ .show()
+ }
+ } else if (isAddingKeys && keysAdded) {
+ setResult(RESULT_OK, result)
+ finish()
+ } else {
+ setResult(RESULT_CANCELED, result)
+ finish()
+ }
+ }
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ object :
+ OnBackPressedCallback(enabled = isSelectingKeys) { // only enable if in key selection mode
+ override fun handleOnBackPressed() {
+ onNavigateBack(isSelectingKeys, singleSelection, selectedKeyIds)
+ }
+ },
+ )
+
setContent {
APSTheme {
Scaffold(
@@ -140,41 +193,7 @@ class PGPKeyListActivity : AppCompatActivity() {
} else stringResource(R.string.activity_label_pgp_key_manager),
navigationIcon = painterResource(R.drawable.ic_arrow_back_24dp),
onNavigationIconClick = {
- val result = Intent()
- if (isSelectingKeys) {
- if (selectedKeyIds.isNotEmpty()) {
- result.putExtra(
- EXTRA_SELECTED_KEY,
- selectedKeyIds.joinToString(separator = "\n"),
- )
- val gpgIdDest = intent.getStringExtra("SUB_PATH")
- gpgIdDest?.let { result.putExtra("SUB_PATH", it) }
- setResult(RESULT_OK, result)
- finish()
- } else {
- val okButtonText =
- if (singleSelection) resources.getString(R.string.gpg_key_single_select)
- else resources.getString(R.string.gpg_key_select)
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.no_keys_selected_dialog_title)
- .setPositiveButton(okButtonText, null)
- .setNegativeButton(
- R.string.pgp_key_insecure_passphrase_warning_confirm // continue anyway
- ) { _, _ ->
- if (singleSelection && SshKey.pgpLongKeyId != 0L) SshKey.delete()
- setResult(RESULT_CANCELED)
- finish()
- }
- .setCancelable(false)
- .show()
- }
- } else if (isAddingKeys && keysAdded) {
- setResult(RESULT_OK, result)
- finish()
- } else {
- setResult(RESULT_CANCELED, result)
- finish()
- }
+ onNavigateBack(isSelectingKeys, singleSelection, selectedKeyIds)
},
backgroundColor = MaterialTheme.colorScheme.surface,
)
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
index b4f7aadffb..74a3c5574e 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
@@ -5,15 +5,14 @@
package app.passwordstore.ui.settings
-import android.content.SharedPreferences
import android.text.InputType
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
-import app.passwordstore.injection.prefs.PGPPassphrases
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.extensions.persistentPassphrases
import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.unlockPins
import app.passwordstore.util.settings.PreferenceKeys
import de.Maxr1998.modernpreferences.PreferenceScreen
import de.Maxr1998.modernpreferences.helpers.editText
@@ -21,12 +20,9 @@ import de.Maxr1998.modernpreferences.helpers.onClick
import de.Maxr1998.modernpreferences.helpers.singleChoice
import de.Maxr1998.modernpreferences.helpers.switch
import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-import javax.inject.Inject
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
- @PGPPassphrases @Inject lateinit var persistentPassphrases: SharedPreferences
-
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
@@ -57,6 +53,7 @@ class PasswordSettings(private val activity: FragmentActivity) : SettingsProvide
initialSelection = "disabled"
onClick {
activity.persistentPassphrases.edit { clear() }
+ activity.unlockPins.edit { clear() }
true
}
}
@@ -68,6 +65,7 @@ class PasswordSettings(private val activity: FragmentActivity) : SettingsProvide
textInputType = InputType.TYPE_CLASS_NUMBER
onClick {
activity.persistentPassphrases.edit { clear() }
+ activity.unlockPins.edit { clear() }
true
}
}
@@ -93,6 +91,11 @@ class PasswordSettings(private val activity: FragmentActivity) : SettingsProvide
titleRes = R.string.pref_clear_clipboard_title
summaryRes = R.string.pref_clear_clipboard_summary
}
+ switch(PreferenceKeys.SHOW_EXTRA_CONTENT) {
+ defaultValue = true
+ titleRes = R.string.show_extra_content_pref_title
+ summaryRes = R.string.show_extra_content_pref_summary
+ }
}
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
index 05bec37281..20cbb8c393 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
@@ -36,6 +36,7 @@ import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.gitSecrets
import app.passwordstore.util.extensions.launchActivity
+import app.passwordstore.util.extensions.passwordHistory
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.extensions.snackbar
import app.passwordstore.util.extensions.unsafeLazy
@@ -352,6 +353,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
}
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ activity.passwordHistory.edit { clear() }
dialogInterface.cancel()
activity.finish()
}
diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt
index c106ec344e..2d9cec39e9 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt
@@ -184,19 +184,27 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) :
matchedFiles: List,
): FillResponse? {
var datasetCount = 0
+ val maxSuggestions =
+ inlineSuggestionsRequest?.maxSuggestionCount
+ ?: InlineSuggestionsRequest.SUGGESTION_COUNT_UNLIMITED
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
+ fun nextImeSpec(): InlinePresentationSpec? =
+ when {
+ datasetCount < maxSuggestions -> imeSpecs.getOrNull(datasetCount) ?: imeSpecs.lastOrNull()
+ else -> null
+ }
return FillResponse.Builder().run {
for (file in matchedFiles) {
- makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
+ makeMatchDataset(context, file, nextImeSpec())?.let {
datasetCount++
addDataset(it)
}
}
- makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ makeGenerateDataset(context, nextImeSpec())?.let {
datasetCount++
addDataset(it)
}
- makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ makeSearchDataset(context, nextImeSpec())?.let {
datasetCount++
addDataset(it)
}
diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
index 79b0963ef8..196de6e5b1 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
@@ -21,11 +21,11 @@ import logcat.logcat
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
private val Context.autofillAppMatches
- get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, 0)
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
private val Context.autofillWebMatches
- get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, 0)
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
return when (formOrigin) {
diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
index dc0fc20657..d347209182 100644
--- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
@@ -53,10 +53,18 @@ val Context.sharedPrefs: SharedPreferences
val Context.persistentPassphrases: SharedPreferences
get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_passphrases", 0)
+/** Get the persistent unlock PINs [SharedPreferences] instance */
+val Context.unlockPins: SharedPreferences
+ get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_unlock_pins", 0)
+
/** Get the persistent Git server secrets [SharedPreferences] instance */
val Context.gitSecrets: SharedPreferences
get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_git_secrets", 0)
+/** Get the persistent pass file timestamps */
+val Context.passwordHistory: SharedPreferences
+ get() = getSharedPreferences("recent_password_history", 0)
+
/** Resolve [attr] from the [Context]'s theme */
fun Context.resolveAttribute(attr: Int): Int {
val typedValue = TypedValue()
diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
index 2f058147fb..122e751d77 100644
--- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
+++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
@@ -181,15 +181,16 @@ object SshKey {
else false
}
- public enum class Type(val value: String) {
+ enum class Type(val value: String) {
Imported("imported"),
KeystoreNative("keystore_native"),
KeystoreWrappedEd25519("keystore_wrapped_eddsa"),
ImportedPGP("imported_pgp");
companion object {
+ private val mapByValue = entries.associateBy { it.value }
- fun fromValue(value: String?): Type? = entries.associateBy { it.value }[value]
+ fun fromValue(value: String?): Type? = mapByValue[value]
}
}
diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
index dcbf70e7c8..9dc4d6bc44 100644
--- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
@@ -31,6 +31,7 @@ fun runMigrations(
filesDirPath: String,
sharedPrefs: SharedPreferences,
gitSettings: GitSettings,
+ persistentPassphrases: SharedPreferences,
context: Context = Application.instance.applicationContext,
runTest: Boolean = false,
) {
@@ -43,7 +44,7 @@ fun runMigrations(
removePersistentCredentialCache(sharedPrefs, gitSettings, context, runTest)
if (!runTest) moveToPasswordGeneratorPrefs(sharedPrefs, context)
deleteKeystoreWrappedEd25519Key(sharedPrefs, context)
- migrateToFastUnlockOptions(sharedPrefs)
+ migrateToFastUnlockOptions(sharedPrefs, persistentPassphrases)
}
private fun deleteKeystoreWrappedEd25519Key(sharedPrefs: SharedPreferences, context: Context) {
@@ -61,7 +62,7 @@ private fun deleteKeystoreWrappedEd25519Key(sharedPrefs: SharedPreferences, cont
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
- context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
+ context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, 0).edit {
clear()
}
@@ -132,8 +133,7 @@ private fun moveToPasswordGeneratorPrefs(sharedPrefs: SharedPreferences, context
// Old, encrypted preferences
val pwgenPrefs = createEncryptedPreferences(context, "pwgen_preferences")
// New destination
- val passwordGeneratorPrefs =
- context.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+ val passwordGeneratorPrefs = context.getSharedPreferences("PasswordGenerator", 0)
val separator =
pwgenPrefs.getString(PreferenceKeys.DICEWARE_SEPARATOR, null)
@@ -295,7 +295,11 @@ private fun createEncryptedPreferences(context: Context, fileName: String): Shar
)
}
-private fun migrateToFastUnlockOptions(sharedPrefs: SharedPreferences) {
+private fun migrateToFastUnlockOptions(
+ sharedPrefs: SharedPreferences,
+ persistentPassphrases: SharedPreferences,
+) {
+ persistentPassphrases.edit { remove("unlock_pin") }
sharedPrefs.edit {
if (sharedPrefs.getBoolean(PreferenceKeys.UNLOCK_PASSWORDS_WITH_PIN, false))
putString(PreferenceKeys.PREF_FAST_UNLOCK_OPTION, "fingerprint")
diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
index 3c5f0b7be0..2baa605ea8 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
@@ -5,7 +5,6 @@
package app.passwordstore.util.settings
-import android.content.Context
import android.content.SharedPreferences
import app.passwordstore.Application
import app.passwordstore.data.password.PasswordItem
@@ -25,8 +24,7 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator)
),
RECENTLY_USED(
Comparator { p1: PasswordItem, p2: PasswordItem ->
- val recentHistory =
- Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val recentHistory = Application.instance.getSharedPreferences("recent_password_history", 0)
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
when {
diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
index c090b7cc0f..80cfad3c5f 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
@@ -73,6 +73,7 @@ object PreferenceKeys {
const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
const val SORT_ORDER = "sort_order"
const val SHOW_PASSWORD = "show_password"
+ const val SHOW_EXTRA_CONTENT = "show_extra_content"
@Deprecated(
message = "Use PREF_FAST_UNLOCK_OPTION instead",
replaceWith = ReplaceWith("PreferenceKeys.PREF_FAST_UNLOCK_OPTION"),
diff --git a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt
index 177ba2b40f..57dd87bee1 100644
--- a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt
+++ b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt
@@ -39,18 +39,16 @@ class ShortcutHandler @Inject constructor(@ApplicationContext val context: Conte
val shortcutManager: ShortcutManager = context.getSystemService() ?: return
val shortcut = buildShortcut(item, intent)
val shortcuts = shortcutManager.dynamicShortcuts
- // If we're above or equal to the maximum shortcuts allowed, drop the last item.
- if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
+ // Drop any stale entry for this exact password, it is about to be re-added at the front.
+ shortcuts.removeAll { it.id == shortcut.id }
+ // Make room for the new shortcut by evicting the least recently used ones.
+ while (shortcuts.size >= MAX_SHORTCUT_COUNT) {
// We'd just do List.removeLast but the Kotlin stdlib extension gets shadowed by the API 35
// JDK implementation
shortcuts.removeAt(shortcuts.lastIndex)
}
- // Reverse the list so we can append our new shortcut at the 'end'.
- shortcuts.reverse()
- shortcuts.add(shortcut)
- // Reverse it again, so the previous items are now in the correct order and our new item
- // is at the front like it's supposed to.
- shortcuts.reverse()
+ // The new shortcut goes at the front
+ shortcuts.add(0, shortcut)
// Write back the new shortcuts.
shortcutManager.dynamicShortcuts = shortcuts.map(::rebuildShortcut)
}
@@ -73,9 +71,9 @@ class ShortcutHandler @Inject constructor(@ApplicationContext val context: Conte
/** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */
private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo {
- return ShortcutInfo.Builder(context, item.fullPathToParent)
+ return ShortcutInfo.Builder(context, item.longName)
.setShortLabel(item.toString())
- .setLongLabel(item.fullPathToParent + item.toString())
+ .setLongLabel("/${item.longName}")
.setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px))
.setIntent(intent)
.build()
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 3c2691cc30..1c74a595e9 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -218,6 +218,8 @@
Commit-Log anzeigen
Zeige das Passwort
Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren.
+ Zusatzangaben anzeigen
+ Soll der Inhalt des Felds mit den Zusatzangaben nach dem Entschlüsseln angezeigt werden?
Schnelles Entsperren von Einträgen
Erstellen
Liste aktualisieren
@@ -489,8 +491,10 @@
PIN festlegen
- Ihr PIN-Code zum schnellen Entsperren muss mindestens vier Ziffern lang sein.
+ Ihre PIN zum schnellen Entsperren muss mindestens vier Ziffern lang sein.
PIN-Verifizierung
- Geben Sie Ihren PIN-Code ein, um den Eintrag anzuzeigen.
+ Geben Sie Ihre PIN ein, um den Eintrag anzuzeigen.
+ Geben Sie Ihre PIN ein, um den Passkey freizuschalten.
+ Geben Sie Ihre PIN ein, um die Zugangsdaten freizuschalten.
Falsche Eingabe
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f6e8515446..b153caf9f2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -225,6 +225,8 @@
Show commit log
Show the password
Control the visibility of the passwords once decrypted. This does not disable copying to clipboard.
+ Show extra content
+ Control the visibility of the extra content once decrypted.
Fast unlocking of entries
Generate
Refresh list
@@ -501,8 +503,10 @@
PIN
Set PIN
- Your PIN code for fast unlocking must be at least 4 digits long
+ Your PIN for fast unlocking must be at least 4 digits long.
PIN verification
- Enter your PIN code to show this entry
+ Enter your PIN to show this entry.
+ Enter your PIN to unlock the passkey.
+ Enter your PIN to unlock the credentials.
Wrong input
diff --git a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt
index c3d83b7a1e..04463d3b70 100644
--- a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt
+++ b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt
@@ -44,6 +44,7 @@ class MigrationsTest {
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
+ assertNull(getString("unlock_pin"))
}
@Test
@@ -60,6 +61,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -83,6 +85,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -106,6 +109,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -122,6 +126,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -136,6 +141,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -150,6 +156,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -164,6 +171,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -177,6 +185,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -193,6 +202,7 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
@@ -211,10 +221,27 @@ class MigrationsTest {
filesDir,
sharedPrefs,
GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
context,
runTest = true,
)
assertFalse { sharedPrefs.contains(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
assertFalse { sharedPrefs.contains(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
}
+
+ @Test
+ fun verifyPersistentUnlockPin() {
+ sharedPrefs.edit {
+ putString("unlock_pin", "abcdefg")
+ }
+ runMigrations(
+ filesDir,
+ sharedPrefs,
+ GitSettings(sharedPrefs, gitSecrets, filesDir),
+ sharedPrefs,
+ context,
+ runTest = true,
+ )
+ checkOldKeysAreRemoved()
+ }
}
diff --git a/autofill-parser/src/main/assets/publicsuffixes b/autofill-parser/src/main/assets/publicsuffixes
index f24281da5e..945457953f 100644
--- a/autofill-parser/src/main/assets/publicsuffixes
+++ b/autofill-parser/src/main/assets/publicsuffixes
@@ -1,4 +1,4 @@
-141697
+141904
119
*.001.test.code-builder-stg.platform.salesforce.com
*.0e.vc
@@ -44,6 +44,7 @@
*.airflow.us-east-2.on.aws
*.airflow.us-west-1.on.aws
*.airflow.us-west-2.on.aws
+*.aivencloud.com
*.alces.network
*.ap-east-1.airflow.amazonaws.com
*.ap-east-1.rds.amazonaws.com
@@ -108,7 +109,7 @@
*.compute.amazonaws.com
*.compute.amazonaws.com.cn
*.compute.estate
-*.cryptonomic.net
+*.cursorusercontent.com
*.customer-oci.com
*.d.crm.dev
*.database.run
@@ -189,7 +190,6 @@
*.oci.customer-oci.com
*.ocp.customer-oci.com
*.ocs.customer-oci.com
-*.on-acorn.io
*.on-k3s.io
*.on-rancher.cloud
*.on-rio.io
@@ -417,7 +417,6 @@ ac.rw
ac.se
ac.sz
ac.th
-ac.tj
ac.tz
ac.ug
ac.uk
@@ -444,7 +443,6 @@ actor
ad
ad.jp
adachi.tokyo.jp
-adaptable.app
adimo.co.uk
adm.br
adm.ec
@@ -469,6 +467,7 @@ aem.network
aem.page
aem.reviews
aero
+aero.in
aero.mv
aerobatic.aero
aeroclub.aero
@@ -531,7 +530,6 @@ airtraffic.aero
aisai.aichi.jp
aisho.shiga.jp
aiven.app
-aivencloud.com
aizubange.fukushima.jp
aizumi.tokushima.jp
aizumisato.fukushima.jp
@@ -595,6 +593,7 @@ alta.no
altervista.org
alto-adige.it
altoadige.it
+alumni.in
alvdal.no
alwaysdata.net
am
@@ -618,6 +617,7 @@ amica
amli.no
amot.no
amplifyapp.com
+ams.scw.site
amsterdam
an.it
analytics
@@ -687,7 +687,6 @@ apartments
api.br
api.gov.uk
api.lp.dev
-api.stdlib.com
apigee.io
app
app-ionos.space
@@ -712,7 +711,6 @@ appwrite.network
aq
aq.it
aquarelle
-aquila.it
ar
ar.it
ar.us
@@ -833,7 +831,7 @@ auction
audi
audible
audio
-audnedaln.no
+audnedal.no
augustow.pl
aukra.no
aure.no
@@ -1118,7 +1116,6 @@ birkenes.no
bitbucket.io
bitter.jp
biz
-biz.at
biz.az
biz.bb
biz.cy
@@ -1592,6 +1589,7 @@ cl.it
claims
clan.rip
claude.app
+claudeusercontent.com
cleaning
clerk.app
clerkstage.app
@@ -1750,6 +1748,8 @@ cockpit.nl-ams.scw.cloud
cockpit.pl-waw.scw.cloud
cocotte.jp
codeberg.page
+codepen.app
+codepen.dev
codes
codespot.com
coffee
@@ -2013,6 +2013,7 @@ cx
cx.ua
cy
cy.eu.org
+cyb.ge
cymru
cyon.link
cyon.site
@@ -2127,14 +2128,13 @@ desi
design
design.aero
det.br
-deta.app
-deta.dev
deus-canvas.com
deuxfleurs.eu
deuxfleurs.page
dev
dev-myqnapcloud.com
dev.br
+dev.cv
dev.project-study.com
development.run
devices.resinstaging.io
@@ -2143,7 +2143,6 @@ df.leg.br
dfirma.pl
dgca.aero
dgn.ec
-dh.bytemark.co.uk
dhl
diadem.cloud
diamonds
@@ -2958,6 +2957,7 @@ fr-par-2.baremetal.scw.cloud
fr.eu.org
fr.it
fra1-de.cloudjiffy.net
+frame.claudeusercontent.com
framer.ai
framer.app
framer.media
@@ -2978,7 +2978,6 @@ freedesktop.org
freemyip.com
freesite.host
freetls.fastly.net
-frei.no
freight.aero
frenchkiss.jp
fresenius
@@ -3204,6 +3203,7 @@ gh.srv.us
gi
gialai.vn
giehtavuoatna.no
+gielda.no
gift
gifts
gifu.gifu.jp
@@ -3501,6 +3501,7 @@ grimstad.no
gripe
griw.gov.pl
grocery
+grok.me
groks-the.info
groks-this.info
grondar.za
@@ -3615,6 +3616,7 @@ hamada.shimane.jp
hamamatsu.shizuoka.jp
hamar.no
hamaroy.no
+hamarøy.no
hamatama.saga.jp
hamatonbetsu.hokkaido.jp
hamburg
@@ -3698,6 +3700,7 @@ hercules-app.com
hercules-dev.com
here
here-for-more.info
+here.now
hermes
herokuapp.com
heroy.more-og-romsdal.no
@@ -3827,6 +3830,7 @@ holy.jp
home-webserver.de
home.arpa
home.dyndns.org
+home64.de
homebuilt.aero
homedepot
homedns.org
@@ -3907,7 +3911,6 @@ hyogo.jp
hypernode.io
hyuga.miyazaki.jp
hyundai
-hzc.io
hábmer.no
hámmárfeasta.no
hápmir.no
@@ -4082,7 +4085,6 @@ inf.mk
inf.ua
infiniti
info
-info.at
info.az
info.bb
info.bd
@@ -4173,6 +4175,8 @@ ipfs.storacha.link
ipfs.w3s.link
ipifony.net
ipiranga
+ipv64.de
+ipv64.net
iq
ir
ir.md
@@ -4593,6 +4597,7 @@ karelia.su
kariwa.niigata.jp
kariya.aichi.jp
karlsoy.no
+karlsøy.no
karmoy.no
karmøy.no
karpacz.pl
@@ -5169,6 +5174,7 @@ living
livorno.it
lk
llc
+llc.ge
llp
ln.cn
lo.it
@@ -5378,7 +5384,6 @@ matsuzaki.shizuoka.jp
matta-varjjat.no
mattel
mayfirst.info
-mayfirst.org
mazowsze.pl
mazury.pl
mb.ca
@@ -5858,6 +5863,7 @@ myfritz.link
myfritz.net
myftp.biz
myftp.org
+mygov.scot
myhome-server.de
myiphost.com
myjino.ru
@@ -6627,7 +6633,6 @@ onagawa.miyagi.jp
oncilla.mythic-beasts.com
ondigitalocean.app
one
-onfabrica.com
ong
ong.br
onga.fukuoka.jp
@@ -6638,6 +6643,8 @@ onion
onjuku.chiba.jp
onl
online
+online-server.cloud
+online.ge
online.th
onna.okinawa.jp
ono.fukui.jp
@@ -7073,6 +7080,7 @@ pl.ua
place
platter-app.dev
play
+playcode.site
playit.plus
playstation
playstation-cloud.com
@@ -7208,7 +7216,6 @@ promo
properties
property
protection
-protonet.io
pru
prudential
pruszkow.pl
@@ -7329,6 +7336,7 @@ rdy.jp
re
re.it
re.kr
+re.no
read
read-books.org
readmyblog.org
@@ -7995,6 +8003,7 @@ scb
scbl.fr-par.scw.cloud
scbl.nl-ams.scw.cloud
scbl.pl-waw.scw.cloud
+sch.ac
sch.ae
sch.bd
sch.id
@@ -8015,6 +8024,7 @@ schokokeks.net
scholarships
school
school.ge
+school.in
school.nz
school.za
schoolbus.jp
@@ -8030,6 +8040,7 @@ scot
scrapper-site.net
scrapping.cc
scrysec.com
+scw.site
sd
sd.cn
sd.us
@@ -8493,6 +8504,7 @@ stord.no
stordal.no
store
store.bb
+store.cv
store.dk
store.nf
store.ro
@@ -8500,7 +8512,6 @@ store.st
store.ve
storebase.store
storfjord.no
-storj.farm
strand.no
stranda.no
strapiapp.com
@@ -8552,10 +8563,13 @@ stuff-4-sale.us
stufftoread.com
style
su
+su.it
sub.jp
subsc-pay.com
subsc-pay.net
sucks
+sud-sardegna.it
+sudsardegna.it
sue.fukuoka.jp
suedtirol.it
suginami.tokyo.jp
@@ -8578,6 +8592,7 @@ sunagawa.hokkaido.jp
sund.no
sunndal.no
sunnyday.jp
+suohkan.no
supabase.co
supabase.in
supabase.net
@@ -8832,6 +8847,7 @@ tirol
tj
tj.cn
tjeldsund.no
+tjielte.no
tjmaxx
tjome.no
tjx
@@ -8859,6 +8875,7 @@ tn
tn.it
tn.oxa.cloud
tn.us
+tnx.ge
to
to.gov.br
to.it
@@ -9064,7 +9081,6 @@ trentinoaltoadige.it
trentinos-tirol.it
trentinostirol.it
trentinosud-tirol.it
-trentinosudtirol.it
trentinosued-tirol.it
trentinosuedtirol.it
trentinosüd-tirol.it
@@ -9183,6 +9199,7 @@ u.se
u2-local.xnbay.com
u2.xnbay.com
ua
+ub.in
ubank
ube.yamaguchi.jp
uber.space
@@ -9199,6 +9216,7 @@ udono.mie.jp
ueda.nagano.jp
ueno.gunma.jp
uenohara.yamanashi.jp
+uenorge.no
ufcfan.org
ug
ug.gov.pl
@@ -9218,12 +9236,12 @@ uk.net
uk.oxa.cloud
uk.primetel.cloud
uk.reclaim.cloud
-uk0.bigv.io
uki.kumamoto.jp
ukiha.fukuoka.jp
ullensaker.no
ullensvang.no
ulsan.kr
+ulstein.no
ulvik.no
um.gov.pl
umaji.kochi.jp
@@ -9350,7 +9368,6 @@ val-d-aosta.it
val-daosta.it
val.run
vald-aosta.it
-valdaosta.it
valer.hedmark.no
valer.ostfold.no
valle-aosta.it
@@ -9399,6 +9416,7 @@ venice.it
vennesla.no
ventures
verbania.it
+verbano-cusio-ossola.it
vercel.app
vercel.dev
vercel.run
@@ -9498,7 +9516,6 @@ vladikavkaz.su
vladimir.ru
vladimir.su
vlog.br
-vm.bytemark.co.uk
vn
vn.ua
voagat.no
@@ -9518,7 +9535,7 @@ vp4.me
vpndns.net
vpnplus.to
vps-host.net
-vps.hrsn.au
+vps.hrsn.net
vps.mcdir.ru
vr.it
vs.it
@@ -9575,10 +9592,12 @@ watch
watches
watson.jp
waw.pl
+waw.scw.site
wazuka.kyoto.jp
we.bs
weather
weatherchannel
+web
web.app
web.bo
web.core.usgovcloudapi.net
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
index cf768f015b..2d0d08c5dd 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
@@ -77,6 +77,7 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH =
"nA0iN59Ie3Ck+fi+wBc8+RoWRPCPkzhbW3gs43ZguoE=", // Original (GitHub release)
"eRN80/UC/Z35sLtql9UY+ig0XwP/8M76QJ9omrs54hQ=", // GoodyOG's OLED fork (GitHub release)
),
+ "io.github.jqssun.helium" to arrayOf("CVVASU1T9IygUbyGw5LEbtspcl2lWSvYrzw9AnsxZrs="),
"net.waterfox.android.release" to
arrayOf(
"8JHKOZi0nhWdI+6VWGmZx10LcCP8+cVBkqgTAKwWbhc=", // GitHub release
@@ -144,6 +145,7 @@ private val BROWSER_MULTI_ORIGIN_METHOD =
"com.vivaldi.browser" to BrowserMultiOriginMethod.Field,
"eu.weblibre.gecko" to BrowserMultiOriginMethod.Field,
"io.github.forkmaintainers.iceraven" to BrowserMultiOriginMethod.WebView,
+ "io.github.jqssun.helium" to BrowserMultiOriginMethod.Field,
"net.waterfox.android.release" to BrowserMultiOriginMethod.Field,
"org.bromite.bromite" to BrowserMultiOriginMethod.Field,
"org.cromite.cromite" to BrowserMultiOriginMethod.Field,
@@ -206,6 +208,7 @@ private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY =
"com.chrome.dev" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"com.microsoft.emmx" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"com.vivaldi.browser" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
+ "io.github.jqssun.helium" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"org.bromite.bromite" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"org.cromite.cromite" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"org.ungoogled.chromium.extensions.stable" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
diff --git a/docs/upstream-reconciliation.md b/docs/upstream-reconciliation.md
new file mode 100644
index 0000000000..493bf9cb4d
--- /dev/null
+++ b/docs/upstream-reconciliation.md
@@ -0,0 +1,51 @@
+# Upstream reconciliation
+
+This repository intentionally diverges from [`agrahn/Android-Password-Store`](https://github.com/agrahn/Android-Password-Store), especially in the passkey implementation. Upstream remains valuable for classic Password Store, Autofill, PGP, Android platform, browser compatibility, and UX fixes, but it must not be merged wholesale.
+
+## Policy
+
+The review unit is an **upstream commit/PR**, not a Git merge. Every upstream change since `.github/upstream-sync-baseline` gets one disposition:
+
+- **adopted** — cherry-picked or ported without semantic changes;
+- **adapted** — the upstream invariant applies, but the implementation is rewritten for this fork;
+- **already solved** — the fork already covers the same bug or invariant;
+- **skipped** — dependency, CI, release, obsolete implementation, or otherwise intentionally not applicable.
+
+Passkey-owned paths are protected by policy. Changes under `passkeys/`, the app passkey provider/injection packages, passkey provider XML, and passkey documentation are never mechanically adopted. Upstream passkey changes are treated as an interoperability/security test corpus: identify the invariant first, then verify or reimplement it in the fork architecture.
+
+## Routine review
+
+1. Run `scripts/upstream-audit.sh`. It fetches `agrahn/Android-Password-Store:develop` and produces a Markdown report for only the commits after the recorded review baseline.
+2. Review the report by area. Pay particular attention to **PASSKEY-PROTECTED**, **AUTOFILL**, and **PGP/CRYPTO** entries.
+3. For a clean classic-app PR, use `scripts/adopt-upstream-pr.sh `. The helper refuses passkey-owned changes unless `--allow-passkeys` is explicitly supplied.
+4. For cross-cutting or security-sensitive changes, port the behaviour manually and preserve the upstream PR/commit in the commit message or PR description.
+5. Run the full repository CI, including passkey compatibility tests.
+6. Once **every** upstream commit in the report has a disposition, run `scripts/mark-upstream-reviewed.sh` and commit the baseline update.
+
+The baseline is deliberately an upstream SHA rather than a merge base. Selective cherry-picks do not create ancestry with upstream, so using the Git merge base would repeatedly report the same historical commits forever.
+
+## Automated audit
+
+`.github/workflows/upstream-audit.yml` runs weekly and can also be started manually. When new upstream commits exist it creates or refreshes one issue named **Upstream reconciliation pending** containing the same categorized report. The workflow never modifies source code or moves the review baseline automatically.
+
+This split is intentional: detection is automated; adoption remains explicit.
+
+## Current reconciliation (2026-08-29)
+
+Reviewed through upstream `48ce3af5b4cd9b818d44edca4249a15d48e2170f` (`develop`). The initial reconciliation ports or adapts the following upstream work while keeping the fork-owned passkey implementation:
+
+| Upstream PR | Disposition | Scope |
+| --- | --- | --- |
+| #936 | adopted | Avoid rebuilding the SSH key type lookup map on every parse |
+| #938, #939, #941, #942 | adopted/adapted | Complete recent-password timestamp lifecycle: create/edit, delete, move, repository reset |
+| #1000 | adapted | Contextual PIN wording for Autofill; no import of upstream's passkey UI |
+| #1007 | adopted | Correct shortcut identity and LRU ordering |
+| #1011 | adapted | Bind fast-unlock PIN state to each PGP ID, including migration/cleanup |
+| #1014 | adopted | Optional concealment of extra password-entry content |
+| #1016 | adopted | Reuse IME inline-presentation specs safely when suggestions outnumber specs |
+| #1022 | adopted | Android 17 local-network permission for SSH |
+| #1026 | adapted | Add Titanium/Helium to classic browser trust detection; passkey caller trust remains independently controlled |
+| #1043 | adopted | Make system Back and toolbar Back consistent in the PGP key chooser |
+| PSL updates | adopted as snapshot | Refresh the current Public Suffix List once instead of replaying generated-data commits |
+
+Dependency-only upstream commits remain owned by Renovate. Upstream CI/release workflow changes are not synchronized because this fork has its own release and validation pipeline.
diff --git a/scripts/adopt-upstream-pr.sh b/scripts/adopt-upstream-pr.sh
new file mode 100755
index 0000000000..6a7813e717
--- /dev/null
+++ b/scripts/adopt-upstream-pr.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/adopt-upstream-pr.sh [--allow-passkeys]
+
+Find the squash/merge commit for an upstream PR on agrahn/Android-Password-Store:develop,
+refuse protected passkey changes by default, then cherry-pick it with provenance.
+EOF
+}
+
+ALLOW_PASSKEYS=false
+if [[ "${1:-}" == "--allow-passkeys" ]]; then
+ ALLOW_PASSKEYS=true
+ shift
+fi
+
+PR="${1:-}"
+if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
+ usage >&2
+ exit 2
+fi
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+
+if ! git diff --quiet || ! git diff --cached --quiet; then
+ echo "Working tree must be clean before adopting an upstream PR." >&2
+ exit 2
+fi
+
+UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-upstream}"
+UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/agrahn/Android-Password-Store.git}"
+UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-develop}"
+
+if git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
+ git remote set-url "$UPSTREAM_REMOTE" "$UPSTREAM_URL"
+else
+ git remote add "$UPSTREAM_REMOTE" "$UPSTREAM_URL"
+fi
+
+git fetch --quiet --no-tags "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"
+UPSTREAM_REF="$UPSTREAM_REMOTE/$UPSTREAM_BRANCH"
+
+COMMIT="$(git log "$UPSTREAM_REF" --format='%H' --grep="#${PR}" -n 1)"
+if [[ -z "$COMMIT" ]]; then
+ echo "Could not find a commit for upstream PR #$PR on $UPSTREAM_REF." >&2
+ exit 3
+fi
+
+mapfile -t FILES < <(git diff-tree --no-commit-id --name-only -r "$COMMIT")
+PROTECTED=()
+for path in "${FILES[@]}"; do
+ case "$path" in
+ passkeys/*|PASSKEYS.md|PasskeyStorage.md|app/src/main/java/app/passwordstore/passkeys/*|app/src/main/java/app/passwordstore/injection/passkeys/*|app/src/main/res/xml/passkey_provider.xml|app/src/main/res/values-v34/bools.xml)
+ PROTECTED+=("$path")
+ ;;
+ esac
+done
+
+if (( ${#PROTECTED[@]} > 0 )) && [[ "$ALLOW_PASSKEYS" != true ]]; then
+ echo "Refusing to cherry-pick upstream PR #$PR because it touches fork-owned passkey code:" >&2
+ printf ' - %s\n' "${PROTECTED[@]}" >&2
+ echo "Review the behavioural invariant and port it manually. Use --allow-passkeys only after an explicit review." >&2
+ exit 4
+fi
+
+echo "Upstream PR #$PR -> $COMMIT"
+printf ' %s\n' "${FILES[@]}"
+git cherry-pick -x "$COMMIT"
diff --git a/scripts/mark-upstream-reviewed.sh b/scripts/mark-upstream-reviewed.sh
new file mode 100755
index 0000000000..95bc0ff618
--- /dev/null
+++ b/scripts/mark-upstream-reviewed.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+
+UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-upstream}"
+UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/agrahn/Android-Password-Store.git}"
+UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-develop}"
+BASELINE_FILE="${UPSTREAM_BASELINE_FILE:-.github/upstream-sync-baseline}"
+
+if ! git diff --quiet || ! git diff --cached --quiet; then
+ echo "Working tree must be clean before moving the upstream review baseline." >&2
+ exit 2
+fi
+
+if git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
+ git remote set-url "$UPSTREAM_REMOTE" "$UPSTREAM_URL"
+else
+ git remote add "$UPSTREAM_REMOTE" "$UPSTREAM_URL"
+fi
+
+git fetch --quiet --no-tags "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"
+UPSTREAM_HEAD="$(git rev-parse "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH")"
+printf '%s\n' "$UPSTREAM_HEAD" > "$BASELINE_FILE"
+
+echo "Updated $BASELINE_FILE to $UPSTREAM_HEAD"
+echo "Commit this only after every upstream change up to that SHA has an explicit disposition."
diff --git a/scripts/upstream-audit.sh b/scripts/upstream-audit.sh
new file mode 100755
index 0000000000..58a7aee12f
--- /dev/null
+++ b/scripts/upstream-audit.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+
+UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-upstream}"
+UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/agrahn/Android-Password-Store.git}"
+UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-develop}"
+BASELINE_FILE="${UPSTREAM_BASELINE_FILE:-.github/upstream-sync-baseline}"
+OUTPUT="${1:-upstream-audit.md}"
+
+if [[ ! -f "$BASELINE_FILE" ]]; then
+ echo "Missing upstream baseline: $BASELINE_FILE" >&2
+ exit 2
+fi
+
+BASELINE="$(tr -d '[:space:]' < "$BASELINE_FILE")"
+if ! [[ "$BASELINE" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "Invalid upstream baseline SHA in $BASELINE_FILE: $BASELINE" >&2
+ exit 2
+fi
+
+if git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
+ git remote set-url "$UPSTREAM_REMOTE" "$UPSTREAM_URL"
+else
+ git remote add "$UPSTREAM_REMOTE" "$UPSTREAM_URL"
+fi
+
+git fetch --quiet --no-tags "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"
+UPSTREAM_REF="$UPSTREAM_REMOTE/$UPSTREAM_BRANCH"
+UPSTREAM_HEAD="$(git rev-parse "$UPSTREAM_REF")"
+
+if ! git cat-file -e "${BASELINE}^{commit}" 2>/dev/null; then
+ echo "Baseline $BASELINE is not available after fetching $UPSTREAM_REF" >&2
+ exit 2
+fi
+if ! git merge-base --is-ancestor "$BASELINE" "$UPSTREAM_HEAD"; then
+ echo "Baseline $BASELINE is not an ancestor of $UPSTREAM_REF ($UPSTREAM_HEAD)." >&2
+ echo "Upstream may have been rebased; review manually before moving the baseline." >&2
+ exit 3
+fi
+
+COUNT="$(git rev-list --count "$BASELINE..$UPSTREAM_HEAD")"
+
+classify_path() {
+ local path="$1"
+ case "$path" in
+ passkeys/*|PASSKEYS.md|PasskeyStorage.md|app/src/main/java/app/passwordstore/passkeys/*|app/src/main/java/app/passwordstore/injection/passkeys/*|app/src/main/res/xml/passkey_provider.xml|app/src/main/res/values-v34/bools.xml)
+ printf 'PASSKEY-PROTECTED'
+ ;;
+ *publicsuffix*)
+ printf 'GENERATED-DATA'
+ ;;
+ autofill-parser/*|app/src/main/java/app/passwordstore/util/autofill/*|app/src/main/java/app/passwordstore/ui/autofill/*)
+ printf 'AUTOFILL'
+ ;;
+ app/src/main/java/app/passwordstore/ui/crypto/*|app/src/main/java/app/passwordstore/ui/pgp/*|crypto/*)
+ printf 'PGP/CRYPTO'
+ ;;
+ gradle/*|gradlew|gradlew.bat|renovate.json|*/build.gradle.kts|build.gradle.kts|settings.gradle.kts)
+ printf 'DEPENDENCY/BUILD'
+ ;;
+ .github/*)
+ printf 'CI/REPOSITORY'
+ ;;
+ *)
+ printf 'CLASSIC-APP'
+ ;;
+ esac
+}
+
+{
+ echo "# Upstream reconciliation report"
+ echo
+ echo "- Fork: \`$(git rev-parse --short=12 HEAD)\`"
+ echo "- Last reviewed upstream: \`$BASELINE\`"
+ echo "- Current upstream \`$UPSTREAM_BRANCH\`: \`$UPSTREAM_HEAD\`"
+ echo "- New upstream commits: **$COUNT**"
+ echo
+
+ if [[ "$COUNT" == "0" ]]; then
+ echo "No upstream changes require review."
+ else
+ echo "## Commits"
+ echo
+ git log --reverse --format='- `%h` %s' "$BASELINE..$UPSTREAM_HEAD"
+ echo
+ echo "## Changed files"
+ echo
+ echo "| Area | Path |"
+ echo "| --- | --- |"
+ while IFS= read -r path; do
+ [[ -z "$path" ]] && continue
+ printf '| %s | `%s` |\n' "$(classify_path "$path")" "$path"
+ done < <(git diff --name-only "$BASELINE..$UPSTREAM_HEAD")
+ echo
+ echo "## Review policy"
+ echo
+ echo "- **PASSKEY-PROTECTED** changes are never adopted mechanically. Review their behavioural/security invariant and reimplement only when it applies to this fork."
+ echo "- Classic app, Autofill, PGP and crypto fixes can normally be selectively cherry-picked or semantically ported."
+ echo "- Dependency/build updates stay under Renovate unless an upstream change carries behaviour not represented by a version bump."
+ echo "- Generated public-suffix data should be refreshed as one current snapshot rather than replaying historical update commits."
+ echo
+ echo "After every commit in this report has a disposition (adopted, adapted, already solved, or intentionally skipped), run \`scripts/mark-upstream-reviewed.sh\` and commit the baseline change."
+ fi
+} > "$OUTPUT"
+
+HAS_CHANGES=false
+if [[ "$COUNT" != "0" ]]; then
+ HAS_CHANGES=true
+fi
+
+if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+ {
+ echo "has_changes=$HAS_CHANGES"
+ echo "commit_count=$COUNT"
+ echo "upstream_head=$UPSTREAM_HEAD"
+ } >> "$GITHUB_OUTPUT"
+fi
+
+cat "$OUTPUT"