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"