From 294dee6b13d7a1f7b10d82f57b08627cf8abbd4f Mon Sep 17 00:00:00 2001 From: Emanuel Valasiadis Date: Tue, 21 Jul 2026 14:08:31 +0900 Subject: [PATCH 01/14] Add PGP smartcard support via NFC Smartcard-backed PGP decryption and SSH/commit signing over NFC: OpenPGP card communication, PIN prompt with retry-counter handling, commit signing via OpenPgpCommitSigner, and a modernized PGP key list view. Includes required manifest, settings, and string changes. --- app/src/main/AndroidManifest.xml | 4 + .../data/crypto/CryptoRepository.kt | 59 +- .../ui/crypto/BasePGPActivity.kt | 73 ++- .../ui/crypto/DecryptActivity.kt | 197 +++++++ .../ui/crypto/PasswordCreationActivity.kt | 118 ++-- .../ui/dialogs/AddPgpKeyBottomSheet.kt | 8 + .../ui/dialogs/PasswordDialog.kt | 19 +- .../ui/git/config/GitConfigActivity.kt | 28 + .../passwordstore/ui/git/log/GitLogAdapter.kt | 17 + .../ui/passwords/PasswordStore.kt | 108 +++- .../ui/pgp/PGPKeyImportActivity.kt | 212 +++++++- .../app/passwordstore/ui/pgp/PGPKeyList.kt | 45 +- .../ui/pgp/PGPKeyListActivity.kt | 61 +++ .../util/crypto/OpenPgpCardPrompt.kt | 452 ++++++++++++++++ .../util/crypto/OpenPgpNfcCard.kt | 506 ++++++++++++++++++ .../util/crypto/OpenPgpSmartcardDecryptor.kt | 174 ++++++ .../util/crypto/OpenPgpSmartcardStore.kt | 51 ++ .../util/extensions/AndroidExtensions.kt | 16 + .../passwordstore/util/git/ErrorMessages.kt | 4 +- .../util/git/GitCommandExecutor.kt | 22 + .../app/passwordstore/util/git/GitCommit.kt | 2 + .../app/passwordstore/util/git/GitLogModel.kt | 10 +- .../util/git/OpenPgpCommitSigner.kt | 481 +++++++++++++++++ .../util/settings/GitSettings.kt | 6 + .../util/settings/PreferenceKeys.kt | 1 + .../util/shortcuts/ShortcutHandler.kt | 25 + .../res/drawable/ic_hardware_key_24dp.xml | 13 + .../main/res/drawable/ic_lock_closed_24px.xml | 14 + .../res/drawable/ic_software_key_24dp.xml | 22 + .../main/res/layout/activity_git_config.xml | 18 + app/src/main/res/layout/add_pgp_key_sheet.xml | 16 + .../main/res/layout/git_log_row_layout.xml | 19 +- app/src/main/res/values/colors_material3.xml | 2 + app/src/main/res/values/strings.xml | 64 +++ .../app/passwordstore/crypto/KeyUtils.kt | 136 +++-- 35 files changed, 2870 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt create mode 100644 app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt create mode 100644 app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt create mode 100644 app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardStore.kt create mode 100644 app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt create mode 100644 app/src/main/res/drawable/ic_hardware_key_24dp.xml create mode 100644 app/src/main/res/drawable/ic_lock_closed_24px.xml create mode 100644 app/src/main/res/drawable/ic_software_key_24dp.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4189c6a4c2..bb384b7d21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + + , anySubkey: Boolean = false): Boolean { val keys = identifiers.map { pgpKeyManager.getKeyById(it) }.filterOk() return pgpCryptoHandler.isPassphraseProtected(keys, anySubkey) @@ -167,6 +200,30 @@ constructor( } } + fun decryptWithSmartcard( + pin: CharArray, + identities: List, + encryptedMessage: ByteArrayInputStream, + message: ByteArrayOutputStream, + card: OpenPgpNfcCard, + ) = + identities.mapUntil({ it.second.isOk }) { id -> + encryptedMessage.reset() + message.reset() + val result = + runCatching { + val key = pgpKeyManager.getKeyById(id).getOrThrow() + val primaryKeyId = KeyUtils.tryGetKeyId(key) + val cardFingerprints = + primaryKeyId?.let { smartcardStore.getFingerprints(it) }.orEmpty() + smartcardDecryptor.decrypt(key, pin, encryptedMessage, message, card, cardFingerprints) + message + } + .mapError { app.passwordstore.crypto.errors.UnknownError(it.message, it) } + result.getError()?.let { logcat { it.asLog() } } + Pair(id.toString(), result) + } + fun encrypt( identities: List, message: ByteArrayInputStream, 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 e20d7df8f4..1f21f57536 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -440,11 +440,26 @@ open class BasePGPActivity : AppCompatActivity() { return gpgIdentifiers } - private fun getEmailsFromIdentifiers(identifiers: List): String? { - val emails = identifiers.map { repository.getEmailFromKeyId(it) }.filterNotNull().distinct() - if (emails.isEmpty()) return null - val label = if (emails.size > 1) R.string.pgp_id_label_plural else R.string.pgp_id_label - return "${getString(label)} ${emails.joinToString(", ")}" + /** + * Builds a short label naming the key(s) a passphrase/PIN is being requested for, so the prompt + * makes clear which key is being unlocked. Shows the key's user ID exactly as the key list does, + * falling back to the email and then the key ID so the label is never empty for a known key. + */ + protected fun getIdentityLabelForIdentifiers(identifiers: List): String? { + if (identifiers.isEmpty()) return null + return identifiers + .map { id -> + repository.getUserIdFromKeyId(id)?.takeIf { it.isNotBlank() && it != "null" } + ?: repository.getEmailFromKeyId(id) + ?: repository.getLongKeyIdFromKeyId(id) + ?: id.toString() + } + .distinct() + .joinToString(", ") + } + + protected fun needsSmartcardPin(identifiers: List): Boolean = identifiers.any { + repository.hasOnlyStubDecKey(it) || repository.isSmartcardBacked(it) } @Suppress("ReturnCount") @@ -471,8 +486,23 @@ open class BasePGPActivity : AppCompatActivity() { private suspend fun askPassphrase(isError: Boolean, identifiers: List) { if (++retries > MAX_RETRIES) finish() + val needsSmartcardPin = needsSmartcardPin(identifiers) val dialog = - PasswordDialog.newInstance(getEmailsFromIdentifiers(identifiers), cacheOptionVisible = true) + if (needsSmartcardPin) { + PasswordDialog.newInstance( + getIdentityLabelForIdentifiers(identifiers), + cacheOptionVisible = true, + titleRes = R.string.openpgp_card_pin_title, + hintRes = R.string.openpgp_card_pin_hint, + errorRes = R.string.openpgp_card_wrong_pin, + cacheLabelRes = R.string.cache_openpgp_card_pin_until_screen_off, + ) + } else { + PasswordDialog.newInstance( + getIdentityLabelForIdentifiers(identifiers), + cacheOptionVisible = true, + ) + } if (isError) dialog.setError() dialog.show(supportFragmentManager, "PASSWORD_DIALOG") dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> @@ -484,6 +514,27 @@ open class BasePGPActivity : AppCompatActivity() { var cacheEnabled = bundle.getBoolean(PasswordDialog.PASSWORD_CACHE_KEY) lifecycleScope.launch(dispatcherProvider.main()) { decryptWithPassphrase(mapOf("" to passphrase), identifiers) { id -> // onSuccess + if (needsSmartcardPin) { + runCatching { + val isHardwareBacked = AESEncryption.isHardwareBacked() + val encryptedPin = AESEncryption.encrypt(passphrase) + if (isHardwareBacked && cacheEnabled && encryptedPin != null) { + cachedPassphrases.put(id, encryptedPin) + } else { + cachedPassphrases[id]?.wipe() + cachedPassphrases.remove(id) + } + settings.edit { + putBoolean( + PreferenceKeys.CACHE_PASSPHRASE, + isHardwareBacked && cacheEnabled && encryptedPin != null, + ) + } + } + .onErr { e -> logcat { e.asLog() } } + passphrase.wipe() + return@decryptWithPassphrase + } var fastUnlockingSetupCompletion: CompletableDeferred? = null runCatching { // update temporary passphrase cache @@ -786,7 +837,15 @@ open class BasePGPActivity : AppCompatActivity() { identifiers.map { it.toString() }.contains(it) } lifecycleScope.launch(dispatcherProvider.main()) { - if (!repository.isPasswordProtected(identifiers) && !isError) { + if (needsSmartcardPin(identifiers)) { + // Smartcard PIN entry and retries are handled inline by the smartcard decrypt flow; just + // pass any cached (e.g. biometric-unlocked) PIN through for the first attempt. + val decryptedCachedPins = passphrases.mapValues { + AESEncryption.decrypt(it.value) ?: charArrayOf() + } + decryptWithPassphrase(decryptedCachedPins, identifiers) + decryptedCachedPins.values.forEach { it.wipe() } + } else if (!repository.isPasswordProtected(identifiers) && !isError) { // try passphraseless decryption first decryptWithPassphrase(mapOf("" to null), identifiers) } else if (!isError && !passphrases.isEmpty()) { 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 10510f4597..348e417b2f 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View +import android.widget.Toast import androidx.core.content.edit import androidx.lifecycle.lifecycleScope import app.passwordstore.R @@ -25,6 +26,8 @@ import app.passwordstore.injection.prefs.PasswordHistory import app.passwordstore.ui.adapters.FieldItemAdapter import app.passwordstore.util.crypto.AESEncryption import app.passwordstore.util.crypto.AESEncryption.KeyType +import app.passwordstore.util.crypto.OpenPgpCardPrompt +import app.passwordstore.util.crypto.OpenPgpNfcCard import app.passwordstore.util.extensions.base64 import app.passwordstore.util.extensions.enableEdgeToEdgeView import app.passwordstore.util.extensions.getString @@ -33,9 +36,12 @@ import app.passwordstore.util.extensions.toCharArray import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.extensions.wipe import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler import com.github.michaelbull.result.get import com.github.michaelbull.result.getError import com.github.michaelbull.result.getOrThrow +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayOutputStream import java.io.File @@ -50,6 +56,7 @@ import kotlinx.coroutines.withContext class DecryptActivity : BasePGPActivity() { @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var shortcutHandler: ShortcutHandler @CredentialUsernames @Inject lateinit var credentialUsernames: SharedPreferences @PasswordHistory @Inject lateinit var passwordHistory: SharedPreferences @@ -65,6 +72,14 @@ class DecryptActivity : BasePGPActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // The entry may have been deleted since a launcher shortcut was created for it; bail out + // gracefully (and prune the stale shortcut) instead of crashing when we try to read the file. + if (!File(fullPath).exists()) { + Toast.makeText(this, R.string.password_no_longer_exists, Toast.LENGTH_LONG).show() + shortcutHandler.pruneDynamicShortcuts() + finish() + return + } supportActionBar?.setDisplayHomeAsUpEnabled(true) title = name with(binding) { @@ -84,6 +99,7 @@ class DecryptActivity : BasePGPActivity() { } override fun onDestroy() { + OpenPgpNfcCard.disableReaderMode(this) encryptedEntryChars?.wipe() itemsAdapter?.clearItems() super.onDestroy() @@ -94,6 +110,10 @@ class DecryptActivity : BasePGPActivity() { identifiers: List, onSuccess: suspend (String) -> Unit, ) { + if (identifiers.any { repository.hasOnlyStubDecKey(it) || repository.isSmartcardBacked(it) }) { + decryptWithSmartcard(passphrases, identifiers, onSuccess) + return + } val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() } val outputStream = ByteArrayOutputStream() val results = repository.decrypt(passphrases, identifiers, message, outputStream) @@ -147,6 +167,183 @@ class DecryptActivity : BasePGPActivity() { } } + private suspend fun decryptWithSmartcard( + passphrases: Map, + identifiers: List, + onSuccess: suspend (String) -> Unit, + ) { + val messageBytes = withContext(dispatcherProvider.io()) { File(fullPath).readBytes() } + val outputStream = ByteArrayOutputStream() + // Same modern smartcard UX as commit signing: one persistent reader, a reused present/hold-card + // dialog, the card op run on the card's own thread, inline PIN entry with retries (so reader + // mode stays on across wrong PINs and never triggers the NDEF-URL popup), and reader mode + // released only once the card is physically removed. + val prompt = OpenPgpCardPrompt(this, R.string.openpgp_nfc_decrypt_title, dispatcherProvider) + val reader = prompt.createReader() + if (reader == null) { + showSmartcardError(getString(R.string.openpgp_nfc_unavailable)) + return + } + // Namespaced so the decryption PIN cache is kept separate from the signing PIN cache. + val cacheKey = "decrypt:${identifiers.firstOrNull()}" + val presentMessage = getString(R.string.openpgp_nfc_tap_card) + // Seed the PIN from a caller-provided (e.g. biometric-unlocked) value, then the screen-off + // cache; otherwise prompt inline below. + var pin: CharArray? = + passphrases.values.firstOrNull()?.takeIf { it.isNotEmpty() } ?: prompt.readCachedPin(cacheKey) + var pinFromCache = pin != null + var cachePin = false + var pinErrorMessage: String? = null + var cardMessage = presentMessage + var readerHandedOff = false + try { + while (true) { + if (pin == null) { + prompt.dismissDialog() + val entry = + prompt.askSecret( + titleRes = R.string.openpgp_card_pin_title, + hintRes = R.string.openpgp_card_pin_hint, + showCacheOption = true, + errorMessage = pinErrorMessage, + minLength = OpenPgpCardPrompt.MIN_PIN_LENGTH, + identityLabel = getIdentityLabelForIdentifiers(identifiers), + ) + ?: run { + finish() + return + } + pin = entry.secret + cachePin = entry.cache + pinFromCache = false + pinErrorMessage = null + cardMessage = presentMessage + } + val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } + val attempt = + prompt.attempt(reader, cardMessage) { card -> + val results = + repository.decryptWithSmartcard( + currentPin, + identifiers, + messageBytes.inputStream(), + outputStream, + card, + ) + // Surface a decryption failure (wrong PIN, transceive error, …) as a thrown exception + // so the prompt can classify it. + results.last().second.getError()?.let { throw it } + results + } + when (attempt) { + is OpenPgpCardPrompt.Attempt.Success -> { + // Cache the PIN only now that decryption actually succeeded. + if (!pinFromCache) prompt.storeCachedPin(cacheKey, currentPin, cachePin) + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, reader) + prompt.dismissDialog() + val lastResult = attempt.value.last() + val decryptedEntryBytes = lastResult.second.getOrThrow().toByteArray() + lastResult.second.getOrThrow().wipe() + val decryptedEntryChars = decryptedEntryBytes.toCharArray() + decryptedEntryBytes.wipe() + val entry = passwordEntryFactory.create(decryptedEntryChars) + encryptedEntryChars = AESEncryption.encrypt(decryptedEntryChars) + decryptedEntryChars.wipe() + entry.clearExtraChars() + createPasswordUI(entry) + onSuccess(lastResult.first) + return + } + OpenPgpCardPrompt.Attempt.Cancelled -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(null, reader) + finish() + return + } + is OpenPgpCardPrompt.Attempt.Error -> { + val error = attempt.error + if (OpenPgpCardPrompt.isSmartcardPinFailure(error)) { + // A rejected PIN must never be kept in the cache; re-prompt inline with the card's + // own + // remaining-attempts count, keeping reader mode on the whole time. + prompt.clearCachedPin(cacheKey) + pin?.wipe() + pin = null + pinFromCache = false + // Prefer the count the card volunteered in 63 Cx; otherwise ask it directly with a + // non-destructive status check, so we learn the real state even after a status word + // that omits it (69 82, a 6A 80 length/format rejection, …) — in particular whether + // the card is now blocked. + val remaining = + OpenPgpCardPrompt.smartcardPinRetriesRemaining(error) + ?: withContext(dispatcherProvider.io()) { + runCatching { attempt.card?.readUserPinRetries() }.get() + } + if (remaining == 0) { + // Blocked: report in a dialog, hold reader mode until the card is lifted, and stop + // asking for a PIN. + prompt.dismissDialog() + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, reader) + showSmartcardError(getString(R.string.openpgp_card_pin_blocked)) + return + } + runCatching { attempt.card?.close() } + pinErrorMessage = + if (remaining != null) { + resources.getQuantityString( + R.plurals.openpgp_card_wrong_pin_remaining, + remaining, + remaining, + ) + } else { + getString(R.string.openpgp_card_wrong_pin) + } + continue + } + // A transient NFC/card hiccup (tag lost, transient 6A 80, …): ask the user to present + // the card again. + if (OpenPgpCardPrompt.isRetryableCardError(error)) { + runCatching { attempt.card?.close() } + cardMessage = getString(R.string.openpgp_nfc_card_comm_failed) + continue + } + // Any other terminal error: hold reader mode until the card is lifted, then report it. + prompt.dismissDialog() + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, reader) + showSmartcardError(friendlySmartcardError(error)) + return + } + } + } + } finally { + pin?.wipe() + prompt.dismissDialog() + if (!readerHandedOff) prompt.releaseReaderWhenCardRemoved(null, reader) + } + } + + private fun showSmartcardError(message: String) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.openpgp_nfc_decrypt_failed_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + // Reader mode is disabled by the removal watcher once the card is lifted; just finish. + finish() + } + .setCancelable(false) + .show() + } + + private fun friendlySmartcardError(error: Throwable?): String = + if (OpenPgpCardPrompt.isSmartcardPinFailure(error)) { + resources.getString(R.string.openpgp_card_wrong_pin) + } else { + error?.message ?: resources.getString(R.string.password_decryption_unknown_error) + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.pgp_handler, menu) return true 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 d4916bea15..3044cd25af 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -41,6 +41,7 @@ import app.passwordstore.ui.folderselect.SelectFolderActivity import app.passwordstore.ui.passwords.PasswordStore import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.crypto.AESEncryption +import app.passwordstore.util.crypto.OpenPgpCardPrompt import app.passwordstore.util.extensions.asLog import app.passwordstore.util.extensions.base64 import app.passwordstore.util.extensions.commitChange @@ -52,6 +53,7 @@ import app.passwordstore.util.extensions.toByteArray import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.extensions.wipe +import app.passwordstore.util.git.ErrorMessages import app.passwordstore.util.settings.DirectoryStructure import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.getOrThrow @@ -84,6 +86,7 @@ import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat +import org.eclipse.jgit.api.errors.CanceledException @AndroidEntryPoint class PasswordCreationActivity : BasePGPActivity() { @@ -512,20 +515,26 @@ class PasswordCreationActivity : BasePGPActivity() { return@runCatching } + val passwordFileExisted = passwordFile.exists() + val previousPasswordBytes = + withContext(dispatcherProvider.io()) { + if (passwordFileExisted) passwordFile.toFile().readBytes() else null + } + val newFilePathHash = passwordFile.absolutePathString().base64() + val oldFilePathHash = suggestedName?.let { oldFile -> + "${fullPath.trimEnd('/')}/$oldFile.gpg".base64() + } + val previousNewHistory = passwordHistory.getString(newFilePathHash, null) + val previousOldHistory = oldFilePathHash?.let { passwordHistory.getString(it, null) } + withContext(dispatcherProvider.io()) { passwordFile.writeBytes(result.getOrThrow().toByteArray()) } // create/update timestamp on the current password file passwordHistory.edit { - suggestedName?.let { oldFile -> - val oldFilePathHash = "${fullPath.trimEnd('/')}/$oldFile.gpg".base64() - remove(oldFilePathHash) - } - putString( - passwordFile.absolutePathString().base64(), - System.currentTimeMillis().toString(), - ) + oldFilePathHash?.let(::remove) + putString(newFilePathHash, System.currentTimeMillis().toString()) } val returnIntent = Intent() @@ -553,50 +562,71 @@ class PasswordCreationActivity : BasePGPActivity() { entry.clear() } - editPass?.wipe() - editUsername?.wipe() - editExtra?.wipe() - val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - - lifecycleScope.launch { - commitChange( - resources.getString( - commitMessageRes, - PasswordRepository.getLongName(fullPath, repoPath, editName), - ) + commitChange( + resources.getString( + commitMessageRes, + PasswordRepository.getLongName(fullPath, repoPath, editName), ) - .onOk { - setResult(RESULT_OK, returnIntent) - - val dialog = - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - var messageText = + ) + .onOk { + editPass?.wipe() + editUsername?.wipe() + editExtra?.wipe() + setResult(RESULT_OK, returnIntent) + val dialog = + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + var messageText = + getString( + R.string.password_creation_file_encryption_succeeded_ids_message, + succeededUserEmails.joinToString(), + ) + if (!failedUserEmails.isEmpty()) { + dialog.setTitle(R.string.password_creation_file_encryption_partial_success_title) + messageText += getString( - R.string.password_creation_file_encryption_succeeded_ids_message, - succeededUserEmails.joinToString(), + R.string.password_creation_file_encryption_failed_ids_message, + failedUserEmails.joinToString(), ) - if (!failedUserEmails.isEmpty()) { - dialog.setTitle(R.string.password_creation_file_encryption_partial_success_title) - messageText += - getString( - R.string.password_creation_file_encryption_failed_ids_message, - failedUserEmails.joinToString(), - ) + } else { + val title = + if (editing) + getString(R.string.password_creation_edit_file_encryption_success_title) + else getString(R.string.password_creation_new_file_encryption_success_title) + dialog.setTitle(title) + } + dialog.setMessage(messageText) + dialog.show() + } + .onErr { e -> + logcat(ERROR) { e.asLog("Failed to commit password changes") } + withContext(dispatcherProvider.io()) { + if (passwordFileExisted && previousPasswordBytes != null) { + passwordFile.writeBytes(previousPasswordBytes) } else { - val title = - if (editing) - getString(R.string.password_creation_edit_file_encryption_success_title) - else getString(R.string.password_creation_new_file_encryption_success_title) - dialog.setTitle(title) + passwordFile.toFile().delete() } - dialog.setMessage(messageText) - dialog.show() } - } + passwordHistory.edit { + if (previousNewHistory == null) remove(newFilePathHash) + else putString(newFilePathHash, previousNewHistory) + oldFilePathHash?.let { key -> + if (previousOldHistory == null) remove(key) + else putString(key, previousOldHistory) + } + } + // Don't nag with a bar when the user cancelled signing, or when the failure was + // already shown in a dialog (e.g. a blocked smartcard PIN). + if ( + !OpenPgpCardPrompt.isHandled(e) && + generateSequence(e) { it.cause }.none { it is CanceledException } + ) { + snackbar(message = ErrorMessages[e]) + } + } } .onErr { e -> logcat(ERROR) { e.asLog() } diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/AddPgpKeyBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/AddPgpKeyBottomSheet.kt index 5cc378df80..aac81d97d4 100644 --- a/app/src/main/java/app/passwordstore/ui/dialogs/AddPgpKeyBottomSheet.kt +++ b/app/src/main/java/app/passwordstore/ui/dialogs/AddPgpKeyBottomSheet.kt @@ -17,6 +17,7 @@ import androidx.core.view.updateLayoutParams import androidx.fragment.app.setFragmentResult import app.passwordstore.R import app.passwordstore.ui.pgp.PGPKeyListActivity.Companion.ACTION_IMPORT_FILE +import app.passwordstore.ui.pgp.PGPKeyListActivity.Companion.ACTION_IMPORT_NFC import app.passwordstore.ui.pgp.PGPKeyListActivity.Companion.ACTION_KEY import app.passwordstore.ui.pgp.PGPKeyListActivity.Companion.ACTION_NEW_PGP_KEY import app.passwordstore.ui.pgp.PGPKeyListActivity.Companion.PGP_KEY_ADD_REQUEST_KEY @@ -81,6 +82,13 @@ class AddPgpKeyBottomSheet : BottomSheetDialogFragment() { ) dismiss() } + dialog.findViewById(R.id.import_key_nfc)?.setOnClickListener { + setFragmentResult( + PGP_KEY_ADD_REQUEST_KEY, + Bundle().also { it.putString(ACTION_KEY, ACTION_IMPORT_NFC) }, + ) + dismiss() + } } } ) diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt index 0b9e9550c1..fca66a6763 100644 --- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt +++ b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.View import android.view.WindowManager +import androidx.annotation.StringRes import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment import androidx.fragment.app.setFragmentResult @@ -36,7 +37,8 @@ class PasswordDialog : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val builder = MaterialAlertDialogBuilder(requireContext()) builder.setView(binding.root) - builder.setTitle(R.string.ssh_keygen_passphrase) + builder.setTitle(requireArguments().getInt(TITLE_RES_EXTRA)) + binding.passwordField.setHint(requireArguments().getInt(HINT_RES_EXTRA)) userIds = requireArguments().getString(USER_IDS_EXTRA) userIds?.let { @@ -49,6 +51,7 @@ class PasswordDialog : DialogFragment() { cacheEnabledChecked = requireContext().sharedPrefs.getBoolean(PreferenceKeys.CACHE_PASSPHRASE, false) binding.cacheEnabled.apply { + setText(requireArguments().getInt(CACHE_LABEL_RES_EXTRA)) isChecked = cacheEnabledChecked setOnCheckedChangeListener { _, isChecked -> cacheEnabledChecked = isChecked } } @@ -57,7 +60,7 @@ class PasswordDialog : DialogFragment() { dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) dialog.setOnShowListener { if (isError) { - binding.passwordField.error = getString(R.string.pgp_wrong_passphrase_input) + binding.passwordField.error = getString(requireArguments().getInt(ERROR_RES_EXTRA)) } binding.passwordEditText.apply { doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } @@ -105,6 +108,10 @@ class PasswordDialog : DialogFragment() { private const val USER_IDS_EXTRA = "user_ids" private const val CACHE_OPTION_EXTRA = "cache_option" + private const val TITLE_RES_EXTRA = "title_res" + private const val HINT_RES_EXTRA = "hint_res" + private const val ERROR_RES_EXTRA = "error_res" + private const val CACHE_LABEL_RES_EXTRA = "cache_label_res" private const val ON_CANCEL_FINISH = "finish_option" const val PASSWORD_RESULT_KEY = "password_result" @@ -115,6 +122,10 @@ class PasswordDialog : DialogFragment() { userIds: String? = null, cacheOptionVisible: Boolean = false, onCancelFinish: Boolean = true, + @StringRes titleRes: Int = R.string.ssh_keygen_passphrase, + @StringRes hintRes: Int = R.string.ssh_keygen_passphrase, + @StringRes errorRes: Int = R.string.pgp_wrong_passphrase_input, + @StringRes cacheLabelRes: Int = R.string.cache_password_until_screen_off, ): PasswordDialog { val extras = Bundle().also { @@ -122,6 +133,10 @@ class PasswordDialog : DialogFragment() { putString(USER_IDS_EXTRA, userIds) putBoolean(CACHE_OPTION_EXTRA, cacheOptionVisible) putBoolean(ON_CANCEL_FINISH, onCancelFinish) + putInt(TITLE_RES_EXTRA, titleRes) + putInt(HINT_RES_EXTRA, hintRes) + putInt(ERROR_RES_EXTRA, errorRes) + putInt(CACHE_LABEL_RES_EXTRA, cacheLabelRes) } } val fragment = PasswordDialog() diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt index 4b170e21b9..8775b09158 100644 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt @@ -48,6 +48,7 @@ class GitConfigActivity : BaseGitActivity() { if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus() else binding.gitUserName.setText(gitSettings.authorName) binding.gitUserEmail.setText(gitSettings.authorEmail) + binding.signCommits.isChecked = gitSettings.signCommits setupTools() binding.saveButton.setOnClickListener { val email = binding.gitUserEmail.text.toString().trim() @@ -60,6 +61,7 @@ class GitConfigActivity : BaseGitActivity() { } else { gitSettings.authorEmail = email gitSettings.authorName = name + gitSettings.signCommits = binding.signCommits.isChecked Snackbar.make( binding.root, getString(R.string.git_server_config_save_success), @@ -97,6 +99,9 @@ class GitConfigActivity : BaseGitActivity() { binding.gitResetToRemote.alpha = if (binding.gitResetToRemote.isEnabled) 1.0f else 0.5f binding.gitGc.isEnabled = binding.gitResetToRemote.isEnabled binding.gitGc.alpha = if (binding.gitGc.isEnabled) 1.0f else 0.5f + updateRemoveLockButton(repo) + } else { + updateRemoveLockButton(null) } binding.gitLog.setOnClickListener { runCatching { launchActivity(GitLogActivity::class.java) } @@ -113,6 +118,25 @@ class GitConfigActivity : BaseGitActivity() { ) } } + binding.gitRemoveLock.setOnClickListener { removeLockFile() } + } + + private fun updateRemoveLockButton(repo: Repository? = PasswordRepository.repository) { + val canRemoveLock = repo?.directory?.resolve(GIT_INDEX_LOCK)?.isFile == true + binding.gitRemoveLock.isEnabled = canRemoveLock + binding.gitRemoveLock.alpha = if (canRemoveLock) 1.0f else 0.5f + } + + private fun removeLockFile() { + val lockFile = PasswordRepository.repository?.directory?.resolve(GIT_INDEX_LOCK) + val messageRes = + when { + lockFile == null || !lockFile.isFile -> R.string.git_remove_lock_file_missing + lockFile.delete() -> R.string.git_remove_lock_file_success + else -> R.string.git_remove_lock_file_failed + } + Snackbar.make(binding.root, getString(messageRes), Snackbar.LENGTH_SHORT).show() + updateRemoveLockButton() } private fun resetToRemote() { @@ -181,4 +205,8 @@ class GitConfigActivity : BaseGitActivity() { getString(R.string.git_head_missing) } } + + companion object { + private const val GIT_INDEX_LOCK = "index.lock" + } } diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt index 044a75f148..92dc27b692 100644 --- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt +++ b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt @@ -7,7 +7,9 @@ package app.passwordstore.ui.git.log import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import app.passwordstore.R import app.passwordstore.databinding.GitLogRowLayoutBinding import app.passwordstore.util.git.GitCommit import app.passwordstore.util.git.GitLogModel @@ -60,6 +62,21 @@ class GitLogAdapter : RecyclerView.Adapter() { gitLogRowMessage.text = commit.shortMessage gitLogRowHash.text = shortHash(commit.hash) gitLogRowTime.text = stringFrom(commit.time) + gitLogRowSignature.setImageResource( + if (commit.isSigned) R.drawable.ic_lock_closed_24px else R.drawable.ic_lock_open_24px + ) + gitLogRowSignature.setColorFilter( + ContextCompat.getColor( + root.context, + if (commit.isSigned) R.color.git_commit_signature_signed + else R.color.git_commit_signature_unsigned, + ) + ) + gitLogRowSignature.contentDescription = + root.context.getString( + if (commit.isSigned) R.string.git_commit_signature_signed + else R.string.git_commit_signature_unsigned + ) } } } 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 4815338c50..0df3f83a8c 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -40,6 +40,7 @@ import app.passwordstore.ui.onboarding.activity.OnboardingActivity import app.passwordstore.ui.pgp.PGPKeyListActivity import app.passwordstore.ui.settings.SettingsActivity import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.crypto.OpenPgpCardPrompt import app.passwordstore.util.extensions.base64 import app.passwordstore.util.extensions.commitChange import app.passwordstore.util.extensions.contains @@ -49,7 +50,9 @@ import app.passwordstore.util.extensions.isInsideRepository import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.extensions.listFilesRecursively import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.git.ErrorMessages import app.passwordstore.util.settings.AuthMode import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.shortcuts.ShortcutHandler @@ -57,8 +60,10 @@ import app.passwordstore.util.viewmodel.FilterMode import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel import com.github.michaelbull.result.fold import com.github.michaelbull.result.onErr +import com.github.michaelbull.result.onOk import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint @@ -72,7 +77,12 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR import logcat.LogPriority.INFO +import logcat.asLog import logcat.logcat +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.ResetCommand.ResetType +import org.eclipse.jgit.api.errors.CanceledException +import org.eclipse.jgit.errors.LockFailedException const val PASSWORD_FRAGMENT_TAG = "PasswordsList" @@ -492,29 +502,65 @@ class PasswordStore : BaseGitActivity() { if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively()) else filesToDelete.add(item.file) } - // remove to-be-deleted files from history - passwordHistory.edit { - filesToDelete.forEach { file -> - remove(file.absolutePath.base64()) - } - } - // remove cached passkey hex ID (filename without extension) <--> webauthn username - // associations - credentialUsernames.edit { - filesToDelete.forEach { file -> - val fileBasename = Paths.get(file.absolutePath).nameWithoutExtension - if (fileBasename.matches("[a-fA-F0-9]{64}".toRegex())) remove(fileBasename) - } - } - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) val fmt = selectedItems.joinToString(separator = ", ") { item -> item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) } lifecycleScope.launch { - commitChange(getString(R.string.git_commit_remove_text, fmt)) + withContext(dispatcherProvider.io()) { + selectedItems.forEach { item -> item.file.deleteRecursively() } + } + refreshPasswordList() + commitChange(resources.getString(R.string.git_commit_remove_text, fmt)) + .onOk { + // The deletion is committed (and signed, if requested): finalise the bookkeeping. + // remove to-be-deleted files from history + passwordHistory.edit { + filesToDelete.forEach { file -> remove(file.absolutePath.base64()) } + } + // remove cached passkey hex ID (filename without extension) <--> webauthn username + // associations + credentialUsernames.edit { + filesToDelete.forEach { file -> + val fileBasename = Paths.get(file.absolutePath).nameWithoutExtension + if (fileBasename.matches("[a-fA-F0-9]{64}".toRegex())) remove(fileBasename) + } + } + AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) + shortcutHandler.pruneDynamicShortcuts() + snackbar( + message = resources.getQuantityString(R.plurals.password_delete_success, size) + ) + } + .onErr { e -> + // The commit (or its signature) did not go through, e.g. the user cancelled the + // signing prompt. Undo the on-disk deletion by restoring the working tree from HEAD + // so the entry is only ever removed once it has actually been committed. Guard the + // restore so a failure (e.g. a stale index.lock) reports an error instead of + // crashing. + logcat(ERROR) { "Aborting deletion; restoring working tree from HEAD\n${e.asLog()}" } + val restored = + withContext(dispatcherProvider.io()) { + try { + PasswordRepository.repository?.let { repo -> + Git(repo).reset().setMode(ResetType.HARD).call() + } + true + } catch (t: Throwable) { + logcat(ERROR) { t.asLog() } + false + } + } + refreshPasswordList() + // Don't nag with a bar when the user cancelled, or when the failure was already shown + // in a dialog (e.g. a blocked smartcard PIN). + if (!isCancellation(e) && !OpenPgpCardPrompt.isHandled(e)) { + val message = + if (isGitLockError(e) || !restored) getString(R.string.git_index_locked_error) + else ErrorMessages[e] + snackbar(message = message, length = Snackbar.LENGTH_LONG) + } + } updateFabSync() } } @@ -522,6 +568,32 @@ class PasswordStore : BaseGitActivity() { .show() } + /** Whether [error] (or a cause) is a user cancellation, e.g. dismissing the signing prompt. */ + private fun isCancellation(error: Throwable?): Boolean { + var cause = error + while (cause != null) { + if (cause is CanceledException) return true + cause = cause.cause + } + return false + } + + /** + * Whether [error] is a Git index-lock failure, usually a stale `index.lock` from an interrupted + * operation. The lock is never removed automatically; it can be cleared from Git configuration. + */ + private fun isGitLockError(error: Throwable?): Boolean { + var cause = error + while (cause != null) { + if (cause is LockFailedException) return true + val message = cause.message.orEmpty() + if (message.contains("index.lock", ignoreCase = true)) return true + if (message.contains("Cannot lock", ignoreCase = true)) return true + cause = cause.cause + } + return false + } + fun movePasswords(values: List) { val intent = Intent(this, SelectFolderActivity::class.java) val fileLocations = values.map { it.file.absolutePath }.toTypedArray() diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt index a561810b4a..2a0c90e0a8 100644 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt @@ -13,6 +13,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import app.passwordstore.R +import app.passwordstore.crypto.KeyUtils.containsAnyFingerprint import app.passwordstore.crypto.KeyUtils.isCertificateOrKey import app.passwordstore.crypto.KeyUtils.parseAllCertificatesOrKeys import app.passwordstore.crypto.KeyUtils.tryGetKeyId @@ -24,19 +25,26 @@ import app.passwordstore.crypto.errors.UnusableKeyException import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.ui.dialogs.TextInputDialog import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.crypto.OpenPgpCardInfo +import app.passwordstore.util.crypto.OpenPgpNfcCard +import app.passwordstore.util.crypto.OpenPgpSmartcardStore import app.passwordstore.util.extensions.snackbar import com.github.michaelbull.result.Result import com.github.michaelbull.result.get import com.github.michaelbull.result.getError import com.github.michaelbull.result.getOrThrow import com.github.michaelbull.result.onErr +import com.github.michaelbull.result.onOk import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.net.URL import javax.inject.Inject +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat @@ -47,6 +55,7 @@ class PGPKeyImportActivity : AppCompatActivity() { @Inject lateinit var pgpKeyManager: PGPKeyManager @Inject lateinit var repository: CryptoRepository @Inject lateinit var dispatcherProvider: DispatcherProvider + @Inject lateinit var smartcardStore: OpenPgpSmartcardStore private val MAX_RETRIES = 3 private var retries = 0 @@ -57,6 +66,7 @@ class PGPKeyImportActivity : AppCompatActivity() { private val importedKeyIds = mutableListOf() /** Keys that ultimately failed to import along with the reason. */ private val importFailures = mutableListOf>() + private var pendingSmartcardInfo: OpenPgpCardInfo? = null private val pgpKeyImportAction = registerForActivityResult(GetContent()) { uri -> @@ -70,7 +80,7 @@ class PGPKeyImportActivity : AppCompatActivity() { ?: throw IllegalStateException("Failed to open selected file") val bytes = keyInputStream.use { `is` -> `is`.readBytes() } if (isCertificateOrKey(PGPKey(bytes))) { - importAllKeys(bytes) + importAllKeys(bytes, pendingSmartcardInfo?.fingerprints) } else { // incoming material may be a symmetrically encrypted key backup lifecycleScope.launch(dispatcherProvider.main()) { askBackupCode(bytes, isError = false) } @@ -80,6 +90,10 @@ class PGPKeyImportActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (intent.getBooleanExtra(EXTRA_IMPORT_FROM_NFC, false)) { + importFromNfc() + return + } runCatching { pgpKeyImportAction.launch("*/*") } .onErr { e -> logcat(ERROR) { e.asLog() } @@ -87,18 +101,167 @@ class PGPKeyImportActivity : AppCompatActivity() { } } + override fun onDestroy() { + OpenPgpNfcCard.disableReaderMode(this) + super.onDestroy() + } + + private fun importFromNfc() { + val progressDialog = + MaterialAlertDialogBuilder(this) + .setTitle(R.string.openpgp_nfc_setup_title) + .setMessage(R.string.openpgp_nfc_tap_card) + .setNegativeButton(R.string.dialog_cancel, null) + .setCancelable(true) + .show() + val cancelSignal = CompletableDeferred() + var canceled = false + fun cancelNfcDialog() { + if (cancelSignal.complete(Unit)) { + canceled = true + OpenPgpNfcCard.disableReaderMode(this) + progressDialog.dismiss() + setResult(RESULT_CANCELED) + finish() + } + } + progressDialog.setCanceledOnTouchOutside(true) + progressDialog.setOnCancelListener { cancelNfcDialog() } + progressDialog.getButton(android.app.AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + cancelNfcDialog() + } + lifecycleScope.launch(dispatcherProvider.main()) { + while (!canceled) { + progressDialog.setTitle(R.string.openpgp_nfc_setup_title) + progressDialog.setMessage(getString(R.string.openpgp_nfc_tap_card)) + runCatching { + val card = + OpenPgpNfcCard.waitForCardOrNull( + this@PGPKeyImportActivity, + cancelSignal, + disableReaderModeOnError = false, + disableReaderModeOnClose = false, + onCardDetected = { + progressDialog.setTitle(R.string.openpgp_nfc_hold_card_title) + progressDialog.setMessage(getString(R.string.openpgp_nfc_hold_card)) + }, + ) ?: return@launch + card.use { it.readCardInfo() } + } + .onOk { cardInfo -> + progressDialog.dismiss() + setupSmartcardKey(cardInfo) + return@launch + } + .onErr { e -> + if (OpenPgpNfcCard.isTransceiveFailure(e)) { + logcat(ERROR) { e.asLog() } + } else { + progressDialog.dismiss() + logcat(ERROR) { e.asLog() } + showNfcErrorDialog(e.message ?: getString(R.string.pgp_key_import_failed)) + return@launch + } + } + } + } + } + + private suspend fun setupSmartcardKey(cardInfo: OpenPgpCardInfo) { + if (cardInfo.fingerprints.isEmpty()) { + showNfcErrorDialog(getString(R.string.openpgp_nfc_no_fingerprints)) + return + } + + val localKey = + withContext(dispatcherProvider.io()) { + pgpKeyManager.getAllKeys().get()?.firstOrNull { + containsAnyFingerprint(it, cardInfo.fingerprints) + } + } + + if (localKey != null) { + associateSmartcardKey(localKey, cardInfo) + return + } + + if (cardInfo.url.isNullOrBlank()) { + showNfcSetupDialog(cardInfo) + return + } + + val downloadResult = runCatching { + withContext(dispatcherProvider.io()) { + URL(cardInfo.url).openStream().use { it.readBytes() } + } + } + val bytes = downloadResult.get() + if (bytes == null) { + showNfcSetupDialog(cardInfo) + return + } + if (!isCertificateOrKey(PGPKey(bytes))) { + showNfcErrorDialog(getString(R.string.openpgp_nfc_url_no_openpgp_key)) + return + } + pendingSmartcardInfo = cardInfo + importAllKeys(bytes, cardInfo.fingerprints) + } + + private fun showNfcSetupDialog(cardInfo: OpenPgpCardInfo) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.openpgp_nfc_setup_detected_title) + .setMessage( + if (cardInfo.url.isNullOrBlank()) R.string.openpgp_nfc_setup_detected_no_url_message + else R.string.openpgp_nfc_setup_detected_fetch_failed_message + ) + .setPositiveButton(R.string.bottom_sheet_import_pgp_key) { _, _ -> + pendingSmartcardInfo = cardInfo + pgpKeyImportAction.launch("*/*") + } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> + OpenPgpNfcCard.disableReaderMode(this) + setResult(RESULT_CANCELED) + finish() + } + .setCancelable(false) + .show() + } + + private fun showNfcErrorDialog(message: String) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.openpgp_nfc_setup_failed_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + OpenPgpNfcCard.disableReaderMode(this) + setResult(RESULT_CANCELED) + finish() + } + .setCancelable(false) + .show() + } + /** * Splits [bytes] into one [PGPKey] per certificate/key block found and processes them * sequentially. A multi-key armored file (e.g. produced by `gpg --export A B C`) yields several * blocks; each is imported via [pgpKeyManager] independently, so partial failures * (already-exists, unusable) are reported per key without aborting the rest. */ - private fun importAllKeys(bytes: ByteArray) { + private fun importAllKeys(bytes: ByteArray, matchingFingerprints: List? = null) { pendingImports.clear() importedKeyIds.clear() importFailures.clear() - parseAllCertificatesOrKeys(PGPKey(bytes)).forEach { - pendingImports.add(PGPKey(it.getEncoded())) + parseAllCertificatesOrKeys(PGPKey(bytes)) + .filter { cert -> + matchingFingerprints == null || + containsAnyFingerprint(PGPKey(cert.getEncoded()), matchingFingerprints) + } + .forEach { + pendingImports.add(PGPKey(it.getEncoded())) + } + if (pendingImports.isEmpty() && matchingFingerprints != null) { + showNfcErrorDialog(getString(R.string.openpgp_nfc_fingerprint_mismatch)) + return } processNextImport() } @@ -115,7 +278,12 @@ class PGPKeyImportActivity : AppCompatActivity() { private fun handleSingleImportResult(result: Result, sourceKey: PGPKey) { if (result.isOk) { - result.get()?.let { tryGetKeyId(it)?.let(importedKeyIds::add) } + result.get()?.let { + tryGetKeyId(it)?.let(importedKeyIds::add) + pendingSmartcardInfo?.let { cardInfo -> + associateSmartcardKey(it, cardInfo, showDialog = false) + } + } processNextImport() return } @@ -128,7 +296,12 @@ class PGPKeyImportActivity : AppCompatActivity() { .setPositiveButton(R.string.dialog_yes) { _, _ -> val retry = runCatching { addKeyOrThrow(sourceKey, replace = true) } if (retry.isOk) { - retry.get()?.let { tryGetKeyId(it)?.let(importedKeyIds::add) } + retry.get()?.let { + tryGetKeyId(it)?.let(importedKeyIds::add) + pendingSmartcardInfo?.let { cardInfo -> + associateSmartcardKey(it, cardInfo, showDialog = false) + } + } } else { importFailures.add(sourceKey to (retry.getError() ?: error)) } @@ -152,6 +325,29 @@ class PGPKeyImportActivity : AppCompatActivity() { return stored } + private fun associateSmartcardKey( + key: PGPKey, + cardInfo: OpenPgpCardInfo, + showDialog: Boolean = true, + ) { + if (!containsAnyFingerprint(key, cardInfo.fingerprints)) { + showNfcErrorDialog(getString(R.string.openpgp_nfc_fingerprint_mismatch)) + return + } + val keyId = + tryGetKeyId(key) + ?: run { + showNfcErrorDialog(getString(R.string.pgp_key_import_failed)) + return + } + smartcardStore.associate(keyId, cardInfo.fingerprints, cardInfo.url) + if (showDialog) { + importedKeyIds.clear() + importedKeyIds.add(keyId) + showImportSummary() + } + } + private suspend fun askBackupCode(bytes: ByteArray, isError: Boolean) { if (++retries > MAX_RETRIES) finish() val dialog = TextInputDialog.newInstance(getString(R.string.pgp_key_backupcode_title)) @@ -251,4 +447,8 @@ class PGPKeyImportActivity : AppCompatActivity() { } .show() } + + companion object { + const val EXTRA_IMPORT_FROM_NFC = "app.passwordstore.extra.IMPORT_FROM_NFC" + } } diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt index 66eb4a05e5..d8a163f0b6 100644 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt @@ -8,6 +8,7 @@ package app.passwordstore.ui.pgp import android.annotation.SuppressLint import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -36,11 +38,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.PGPIdentifier.KeyId @@ -53,6 +57,9 @@ import app.passwordstore.util.git.sshj.SshKey import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +/** Opacity applied to key rows that are not selectable in the current context. */ +private const val DISABLED_KEY_ALPHA = 0.38f + @Composable fun KeyList( identifiers: ImmutableList>, @@ -62,8 +69,11 @@ fun KeyList( onExportItemClick: (identifier: PGPIdentifier) -> Unit, onExportPublicClick: (identifier: PGPIdentifier) -> Unit, modifier: Modifier = Modifier, + isStubKey: (identifier: PGPIdentifier) -> Boolean = { false }, + onKeyInfoClick: (identifier: PGPIdentifier) -> Unit = {}, onKeySelected: ((identifier: PGPIdentifier, isSelected: Boolean) -> Unit)? = null, singleSelection: Boolean = false, + isKeyEnabled: (identifier: PGPIdentifier) -> Boolean = { true }, ) { var selectedId by remember { mutableStateOf(if (singleSelection) KeyId(SshKey.pgpLongKeyId) else KeyId(0L)) @@ -86,11 +96,14 @@ fun KeyList( KeyItem( identifier = identifier, isSecretKey = isSecretKey, + isStubKey = isStubKey, + onKeyInfoClick = onKeyInfoClick, onChangePassphraseClick = onChangePassphraseClick, onDeleteItemClick = onDeleteItemClick, onExportItemClick = onExportItemClick, onExportPublicClick = onExportPublicClick, onKeySelected = onKeySelected, + isKeyEnabled = isKeyEnabled, /* For single key selection mode: The next two arguments serve to * detect key selection and to recompose whole list of keys */ selectedId = selectedId, @@ -111,6 +124,8 @@ fun KeyList( private fun KeyItem( identifier: Pair, isSecretKey: (identifier: PGPIdentifier) -> Boolean, + isStubKey: (identifier: PGPIdentifier) -> Boolean, + onKeyInfoClick: (identifier: PGPIdentifier) -> Unit, onChangePassphraseClick: (identifier: PGPIdentifier) -> Unit, onDeleteItemClick: (identifier: PGPIdentifier) -> Unit, onExportItemClick: (identifier: PGPIdentifier) -> Unit, @@ -119,9 +134,13 @@ private fun KeyItem( onKeySelected: ((identifier: PGPIdentifier, isSelected: Boolean) -> Unit)? = null, selectedId: KeyId, onSelectedChange: ((KeyId, Boolean) -> Unit)? = null, + isKeyEnabled: (identifier: PGPIdentifier) -> Boolean = { true }, ) { var isDeleting by remember { mutableStateOf(false) } var keyId = identifier.first ?: throw NullPointerException() + // Keys that cannot serve the current purpose (e.g. a public-only key when selecting an SSH + // authentication key) are shown greyed out and are not selectable. + val enabled = isKeyEnabled(keyId) DeleteConfirmationDialog( isDeleting = isDeleting, isSecretKey = isSecretKey(keyId), @@ -135,9 +154,8 @@ private fun KeyItem( Row( modifier = modifier - .padding(horizontal = SpacingLarge, vertical = SpacingSmall) .fillMaxWidth() - .conditional(onKeySelected != null) { + .conditional(enabled && onKeySelected != null) { toggleable( value = checked, // set value to the current checked status onValueChange = { @@ -146,10 +164,31 @@ private fun KeyItem( checked = it }, ) - }, + } + // Outside selection mode, tapping a key opens its info dialog. + .conditional(onKeySelected == null) { clickable { onKeyInfoClick(keyId) } } + .conditional(!enabled) { alpha(DISABLED_KEY_ALPHA) } + .padding(horizontal = SpacingLarge, vertical = SpacingSmall), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + if (isStubKey(keyId)) { + Icon( + painter = painterResource(id = R.drawable.ic_hardware_key_24dp), + contentDescription = stringResource(R.string.pgp_key_hardware_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(SpacingSmall)) + } else if (isSecretKey(keyId)) { + Icon( + painter = painterResource(id = R.drawable.ic_software_key_24dp), + contentDescription = stringResource(R.string.pgp_key_software_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(SpacingSmall)) + } Text( text = identifier.second.toString(), modifier = Modifier.weight(1f), 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..7323f5e40b 100644 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt @@ -39,10 +39,12 @@ import app.passwordstore.ui.APSAppBar import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.dialogs.AddPgpKeyBottomSheet import app.passwordstore.ui.dialogs.PasswordDialog +import app.passwordstore.ui.pgp.PGPKeyImportActivity.Companion.EXTRA_IMPORT_FROM_NFC import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.wipe import app.passwordstore.util.git.sshj.SshKey import app.passwordstore.util.viewmodel.PGPKeyListViewModel +import com.github.michaelbull.result.get import com.github.michaelbull.result.getOrThrow import com.github.michaelbull.result.onErr import com.github.michaelbull.result.onOk @@ -121,6 +123,12 @@ class PGPKeyListActivity : AppCompatActivity() { keyAction.launch(Intent(this, PGPKeyImportActivity::class.java)) isAddingKeys = true } + ACTION_IMPORT_NFC -> { + keyAction.launch( + Intent(this, PGPKeyImportActivity::class.java).putExtra(EXTRA_IMPORT_FROM_NFC, true) + ) + isAddingKeys = true + } ACTION_NEW_PGP_KEY -> { keyAction.launch(Intent(this, PGPKeyCreationActivity::class.java)) isAddingKeys = true @@ -195,6 +203,8 @@ class PGPKeyListActivity : AppCompatActivity() { KeyList( identifiers = viewModel.keys, // Pair isSecretKey = ::isSecretKey, + isStubKey = ::isStubKey, + onKeyInfoClick = ::showKeyInfo, onChangePassphraseClick = ::changeKeyPassphrase, onDeleteItemClick = ::deleteKey, onExportItemClick = ::exportKey, @@ -213,6 +223,10 @@ class PGPKeyListActivity : AppCompatActivity() { } } else null, singleSelection = singleSelection, + // Selecting an SSH authentication key (single-selection mode): grey out keys that can't + // authenticate — public-only keys, and stubs without an associated smartcard. + isKeyEnabled = + if (singleSelection) cryptoRepository::canUseForSshAuth else { { true } }, ) } } @@ -222,6 +236,52 @@ class PGPKeyListActivity : AppCompatActivity() { private fun isSecretKey(identifier: PGPIdentifier): Boolean = cryptoRepository.isSecretKey(identifier) + /** A key whose private material lives on hardware (a smartcard) or was otherwise stripped. */ + private fun isStubKey(identifier: PGPIdentifier): Boolean = + cryptoRepository.isSmartcardBacked(identifier) || + cryptoRepository.hasOnlyStubDecKey(identifier) + + private fun showKeyInfo(identifier: PGPIdentifier) { + val fingerprint = + pgpKeyManager.getKeyById(identifier).get()?.let { key -> + KeyUtils.tryGetFingerprints(key).firstOrNull()?.let(::formatFingerprint) + } + val type = + when { + // Both a registered smartcard and a bare stub mean the private key lives on hardware; + // mirror isStubKey() so the info label matches the hardware icon shown in the list. + cryptoRepository.isSmartcardBacked(identifier) || + cryptoRepository.hasOnlyStubDecKey(identifier) -> + getString(R.string.pgp_key_info_type_hardware) + cryptoRepository.isSecretKey(identifier) -> getString(R.string.pgp_key_info_type_secret) + else -> getString(R.string.pgp_key_info_type_public) + } + val message = buildString { + cryptoRepository.getUserIdFromKeyId(identifier)?.takeIf { it != "null" }?.let { + appendLine(getString(R.string.pgp_key_info_user_id, it)) + } + cryptoRepository.getEmailFromKeyId(identifier)?.let { + appendLine(getString(R.string.pgp_key_info_email, it)) + } + cryptoRepository.getLongKeyIdFromKeyId(identifier)?.let { + appendLine(getString(R.string.pgp_key_info_key_id, it)) + } + fingerprint?.let { appendLine(getString(R.string.pgp_key_info_fingerprint, it)) } + append(getString(R.string.pgp_key_info_type, type)) + } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.pgp_key_info_title) + .setMessage(message) + .setPositiveButton(R.string.dialog_ok, null) + .show() + } + + private fun formatFingerprint(fingerprint: ByteArray): String = + fingerprint + .joinToString(separator = "") { "%02X".format(it.toInt() and 0xFF) } + .chunked(4) + .joinToString(separator = " ") + private fun changeKeyPassphrase(identifier: PGPIdentifier) { val intent = Intent(this, PGPKeyChangePassphraseActivity::class.java) intent.putExtra(PGPKeyChangePassphraseActivity.EXTRA_SELECTED_IDENTIFIER, identifier.toString()) @@ -372,6 +432,7 @@ class PGPKeyListActivity : AppCompatActivity() { const val PGP_KEY_ADD_REQUEST_KEY = "add_pgp_key" const val ACTION_KEY = "action" const val ACTION_IMPORT_FILE = "from_file" + const val ACTION_IMPORT_NFC = "from_nfc" const val ACTION_NEW_PGP_KEY = "generate_new" fun newIntent( diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt new file mode 100644 index 0000000000..1496db774a --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt @@ -0,0 +1,452 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.crypto + +import android.text.InputType +import android.view.View +import android.view.WindowManager +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.databinding.DialogPasswordEntryBinding +import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.hideKeyboard +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.wipe +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onErr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext +import logcat.asLog +import logcat.logcat + +/** + * Drives the shared OpenPGP smartcard UX for a single card operation (commit signing, decryption, + * …). It keeps NFC reader mode enabled for the whole operation via a single [CardReader], shows a + * reusable "present card" / "keep the card on the phone" dialog, runs the card operation on the + * card's own thread right after applet selection (so it can't race the NFC presence check), and — + * on success — keeps reader mode on until the card is physically removed so the platform never + * dispatches the card's NDEF URL. + * + * All UI-touching members are `suspend` so callers on the main thread (e.g. decryption) don't block + * it; callers running off the main thread (e.g. commit signing) can wrap them in `runBlocking`. + */ +class OpenPgpCardPrompt( + private val activity: FragmentActivity, + @StringRes private val titleRes: Int, + private val dispatcherProvider: DispatcherProvider, +) { + + private val cardDialog = AtomicReference(null) + private val cardDialogCancel = AtomicReference?>(null) + + /** Outcome of a single [attempt]. */ + sealed interface Attempt { + /** [card] is left open so reader mode can be released once it is physically removed. */ + class Success(val value: T, val card: OpenPgpNfcCard) : Attempt + + data object Cancelled : Attempt + + /** + * [card] is the connected card when the failure happened after connecting (else null); it is + * left open so the caller can hold reader mode until the card is physically removed (terminal + * failure) or close it to allow the user to present it again (retry). + */ + class Error(val error: Throwable, val card: OpenPgpNfcCard?) : Attempt + } + + /** Enables reader mode for the operation. Returns `null` when NFC is unavailable or disabled. */ + suspend fun createReader(): CardReader? = + withContext(dispatcherProvider.main()) { CardReader.create(activity) } + + /** + * Shows (or, on a retry, reuses and re-labels with [message]) the card dialog, awaits a tap on + * the already-open [reader], and runs [block] on the connected card on the same thread, + * immediately after applet selection. The dialog stays on screen for the whole exchange and for + * the next attempt; the caller dismisses it via [dismissDialog] when the operation ends. + */ + suspend fun attempt( + reader: CardReader, + message: String, + block: (OpenPgpNfcCard) -> T, + ): Attempt = coroutineScope { + val cancel = CompletableDeferred() + cardDialogCancel.set(cancel) + withContext(dispatcherProvider.main()) { showOrUpdateDialog(message) } + val attemptJob = + async(dispatcherProvider.io()) { + val card = reader.awaitCard { + activity.runOnUiThread { + cardDialog.get()?.let { dialog -> + dialog.setTitle(R.string.openpgp_nfc_hold_card_title) + dialog.setMessage(activity.getString(R.string.openpgp_nfc_hold_card)) + } + } + } + try { + Attempt.Success(block(card), card) + } catch (e: Throwable) { + if (e is CancellationException) { + runCatching { card.close() } + throw e + } + // Leave the card open; the caller closes it (retry) or holds reader mode until it is + // removed (terminal failure). + Attempt.Error(e, card) + } + } + try { + select> { + attemptJob.onAwait { it } + cancel.onAwait { + attemptJob.cancel() + Attempt.Cancelled + } + } + } catch (e: Throwable) { + // The card wait itself failed (no card connected). + Attempt.Error(e, null) + } + } + + /** Creates the card dialog, or just re-labels it if it is already showing. Main thread. */ + private fun showOrUpdateDialog(message: String) { + // Collapse the soft keyboard left over from PIN entry so it doesn't cover the card prompt or + // the + // status bar, and keep the card dialog from resurrecting it. + activity.hideKeyboard() + val existing = cardDialog.get() + if (existing != null && existing.isShowing) { + existing.setTitle(titleRes) + existing.setMessage(message) + return + } + val dialog = + MaterialAlertDialogBuilder(activity) + .setTitle(titleRes) + .setMessage(message) + .setNegativeButton(R.string.dialog_cancel) { _, _ -> + cardDialogCancel.get()?.complete(Unit) + } + .setOnCancelListener { cardDialogCancel.get()?.complete(Unit) } + .setCancelable(true) + .show() + dialog.setCanceledOnTouchOutside(true) + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + cardDialog.set(dialog) + } + + suspend fun dismissDialog() { + val dialog = cardDialog.getAndSet(null) ?: return + withContext(dispatcherProvider.main()) { dialog.dismiss() } + } + + class SecretEntry(val secret: CharArray, val cache: Boolean) + + /** + * Prompts the user for a card PIN (or passphrase). When [showCacheOption] is true the dialog + * offers a "keep until screen-off" checkbox; the caller decides whether to actually cache via + * [storeCachedPin]. [errorMessage] is reported inline on the field (e.g. "Wrong PIN, N tries + * left"). When [minLength] is > 0 the confirm button stays disabled until at least that many + * characters are entered (the OpenPGP card spec mandates a 6-character minimum PIN). Returns + * `null` if the user cancels. + */ + suspend fun askSecret( + @StringRes titleRes: Int, + @StringRes hintRes: Int, + showCacheOption: Boolean = false, + errorMessage: String? = null, + minLength: Int = 0, + identityLabel: String? = null, + ): SecretEntry? { + if (activity.isFinishing || activity.isDestroyed) return null + val showCache = showCacheOption && AESEncryption.isHardwareBacked() + val cacheDefault = + showCache && activity.sharedPrefs.getBoolean(PreferenceKeys.CACHE_PASSPHRASE, false) + val result = CompletableDeferred() + withContext(dispatcherProvider.main()) { + try { + val binding = DialogPasswordEntryBinding.inflate(activity.layoutInflater) + binding.passwordField.setHint(hintRes) + // Tell the user which key/card this PIN unlocks. + identityLabel?.let { + binding.userIdList.text = it + binding.userIdList.visibility = View.VISIBLE + } + binding.passwordEditText.inputType = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_PASSWORD or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + if (showCache) { + binding.cacheEnabled.visibility = View.VISIBLE + binding.cacheEnabled.setText(R.string.cache_openpgp_card_pin_until_screen_off) + binding.cacheEnabled.isChecked = cacheDefault + } + val dialog = + MaterialAlertDialogBuilder(activity) + .setTitle(titleRes) + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + val text = binding.passwordEditText.text + val secret = + text?.let { CharArray(it.length) { index -> it[index] } } ?: charArrayOf() + text?.clear() + result.complete(SecretEntry(secret, showCache && binding.cacheEnabled.isChecked)) + } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> result.complete(null) } + .setOnCancelListener { result.complete(null) } + .show() + // The error and the min-length hint share the caption area below the field, and the error + // (e.g. "Wrong PIN, N left") takes priority; only fall back to the hint when there is none. + when { + errorMessage != null -> binding.passwordField.error = errorMessage + minLength > 0 -> + binding.passwordField.helperText = + activity.resources.getQuantityString( + R.plurals.openpgp_card_pin_min_length, + minLength, + minLength, + ) + } + if (minLength > 0) { + // Enforce the minimum PIN length by keeping the confirm button disabled until enough + // characters are entered. + val okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + val updateOkEnabled = { + okButton.isEnabled = (binding.passwordEditText.text?.length ?: 0) >= minLength + } + updateOkEnabled() + binding.passwordEditText.doAfterTextChanged { updateOkEnabled() } + } + dialog.window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } catch (t: Throwable) { + logcat { t.asLog() } + result.complete(null) + } + } + return result.await() + } + + /** Reads and decrypts a screen-off-cached PIN for [cacheKey], or null if none. */ + fun readCachedPin(cacheKey: String): CharArray? { + val encrypted = BasePGPActivity.cachedPassphrases[cacheKey] ?: return null + return AESEncryption.decrypt(encrypted) + } + + fun clearCachedPin(cacheKey: String) { + BasePGPActivity.cachedPassphrases[cacheKey]?.wipe() + BasePGPActivity.cachedPassphrases.remove(cacheKey) + } + + /** Caches [pin] (AES-encrypted, until screen-off) under [cacheKey] when [cache] is set. */ + fun storeCachedPin(cacheKey: String, pin: CharArray, cache: Boolean) { + runCatching { + val hardwareBacked = AESEncryption.isHardwareBacked() + val encryptedPin = if (cache) AESEncryption.encrypt(pin) else null + if (hardwareBacked && cache && encryptedPin != null) { + BasePGPActivity.cachedPassphrases[cacheKey]?.wipe() + BasePGPActivity.cachedPassphrases[cacheKey] = encryptedPin + } else { + clearCachedPin(cacheKey) + } + activity.sharedPrefs.edit { + putBoolean( + PreferenceKeys.CACHE_PASSPHRASE, + hardwareBacked && cache && encryptedPin != null, + ) + } + } + .onErr { e -> logcat { e.asLog() } } + } + + /** Shows a simple informational dialog (used for terminal card errors, e.g. a blocked PIN). */ + suspend fun showError(@StringRes titleRes: Int, message: String) { + withContext(dispatcherProvider.main()) { + MaterialAlertDialogBuilder(activity) + .setTitle(titleRes) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .setCancelable(true) + .show() + } + } + + /** + * Keeps reader mode enabled until [card] is lifted (or a timeout elapses), then disables it, so + * the platform never dispatches the still-present card's NDEF URL after an operation ends — + * whether it succeeded or failed — for instance while a result dialog is still on screen. When + * [card] is null (e.g. the card was never connected), reader mode is disabled right away. Runs + * off the calling thread on the activity scope so it does not delay the operation. + */ + fun releaseReaderWhenCardRemoved(card: OpenPgpNfcCard?, reader: CardReader) { + activity.lifecycleScope.launch { + if (card != null) { + withContext(dispatcherProvider.io()) { + try { + val deadline = System.currentTimeMillis() + READER_MODE_RELEASE_TIMEOUT_MS + // Actively probe the card; two consecutive misses mean it has left the field (a single + // miss can be a transient transceive glitch while it is still present). Only pace the + // "still present" case with the interval so removal is detected in ~2 probe timeouts. + var consecutiveMisses = 0 + while (consecutiveMisses < 2 && System.currentTimeMillis() < deadline) { + if (card.isPresent()) { + consecutiveMisses = 0 + delay(READER_MODE_POLL_INTERVAL_MS) + } else { + consecutiveMisses++ + } + } + } finally { + runCatching { card.close() } + } + } + } + reader.close() + } + } + + /** + * Shows a modal "remove your card" dialog and **suspends** until [card] is physically lifted (or a + * timeout elapses), then dismisses the dialog and releases [reader]. + * + * Unlike [releaseReaderWhenCardRemoved] this blocks the caller. NFC reader mode is only active + * while the hosting activity is resumed, so when an operation would otherwise let its activity + * pause/finish right after the card exchange (e.g. an SSH authentication during a git push, whose + * activity moves on once auth succeeds), holding the caller here keeps the activity foreground — + * and the card in reader mode — until the user removes it, so the platform never dispatches the + * still-present card's NDEF URL. + */ + suspend fun awaitCardRemoval(card: OpenPgpNfcCard, reader: CardReader) { + val dialog = + withContext(dispatcherProvider.main()) { + if (activity.isFinishing || activity.isDestroyed) return@withContext null + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.openpgp_nfc_remove_card_title) + .setMessage(R.string.openpgp_nfc_remove_card_message) + .setCancelable(false) + .show() + } + try { + withContext(dispatcherProvider.io()) { + val deadline = System.currentTimeMillis() + READER_MODE_REMOVAL_TIMEOUT_MS + var consecutiveMisses = 0 + while (consecutiveMisses < 2 && System.currentTimeMillis() < deadline) { + if (card.isPresent()) { + consecutiveMisses = 0 + delay(READER_MODE_POLL_INTERVAL_MS) + } else { + consecutiveMisses++ + } + } + } + } finally { + runCatching { card.close() } + withContext(dispatcherProvider.main()) { runCatching { dialog?.dismiss() } } + reader.close() + } + } + + companion object { + /** The minimum PW1 (user/signing) PIN length mandated by the OpenPGP Card specification. */ + const val MIN_PIN_LENGTH = 6 + + private const val READER_MODE_RELEASE_TIMEOUT_MS = 30_000L + // Longer cap for the interactive "remove your card" wait, which depends on the user reacting. + private const val READER_MODE_REMOVAL_TIMEOUT_MS = 60_000L + private const val READER_MODE_POLL_INTERVAL_MS = 300L + private val PIN_FAILURE_REGEX = Regex("""63 c[0-9a-f]""", RegexOption.IGNORE_CASE) + + /** + * Whether [error] is a smartcard PIN rejection, recognised whether it arrives as a structured + * [OpenPgpCardStatusException] or only in a wrapped message. + */ + fun isSmartcardPinFailure(error: Throwable?): Boolean { + var cause = error + while (cause != null) { + // A PIN the card rejected for its length/format is also a (recoverable) PIN problem. + if (cause is SmartcardPinFormatException) return true + if (cause is OpenPgpCardStatusException && cause.isAuthenticationFailure) return true + val message = cause.message.orEmpty() + if (message.contains("69 82", ignoreCase = true)) return true + if (message.contains("69 83", ignoreCase = true)) return true + if (PIN_FAILURE_REGEX.containsMatchIn(message)) return true + cause = cause.cause + } + return false + } + + /** The card-reported number of PIN attempts still available, or null if the card didn't say. */ + fun smartcardPinRetriesRemaining(error: Throwable?): Int? { + var cause = error + while (cause != null) { + if (cause is OpenPgpCardStatusException) { + cause.retriesRemaining?.let { + return it + } + } + cause = cause.cause + } + return null + } + + /** + * Whether [error] is a transient NFC/card *transport* problem (tag lost mid-exchange, a + * malformed/short response, a transceive glitch), for which the user should simply present the + * card again. + * + * Crucially, an [OpenPgpCardStatusException] is *not* retryable even though it extends + * [IOException]: the card answered with a status word, so it was read just fine — that's a card + * error to report (or, if it's a PIN rejection, to re-prompt for), never a "couldn't read the + * card". Only a plain transport [IOException] (no card status word anywhere in the chain) + * counts. + */ + fun isRetryableCardError(error: Throwable?): Boolean { + var cause = error + var transportFailure = false + while (cause != null) { + // The card responded — whatever the status word, this was not a failed read. + if (cause is OpenPgpCardStatusException) return false + if (cause is IOException) transportFailure = true + cause = cause.cause + } + return transportFailure + } + + /** Whether [error] (or a cause) is an already-reported smartcard failure (see below). */ + fun isHandled(error: Throwable?): Boolean { + var cause = error + while (cause != null) { + if (cause is SmartcardOperationHandledException) return true + cause = cause.cause + } + return false + } + } +} + +/** + * Thrown when a smartcard operation (e.g. commit signing) has already reported its failure to the + * user via a dialog, so callers should not additionally surface it (e.g. as a snackbar). + */ +class SmartcardOperationHandledException(message: String? = null) : Exception(message) diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt new file mode 100644 index 0000000000..0d5d282664 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt @@ -0,0 +1,506 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.crypto + +import android.app.Activity +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.Bundle +import app.passwordstore.R +import com.github.michaelbull.result.get +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.suspendCancellableCoroutine +import logcat.LogPriority.WARN +import logcat.asLog +import logcat.logcat + +class OpenPgpNfcCard( + private val isoDep: IsoDep, + private val onClose: () -> Unit = {}, +) : AutoCloseable { + + fun selectOpenPgpApplet() { + transceive(SELECT_OPENPGP) + } + + fun verifyUserPin(pin: CharArray) { + verifyPin(pin, reference = 0x82) + } + + fun verifySignaturePin(pin: CharArray) { + verifyPin(pin, reference = 0x81) + } + + /** Remaining PW1 verification attempts as seen through the decryption (0x82) slot. */ + fun readUserPinRetries(): Int? = readPinRetries(reference = 0x82) + + /** Remaining PW1 verification attempts as seen through the signature (0x81) slot. */ + fun readSignaturePinRetries(): Int? = readPinRetries(reference = 0x81) + + /** + * Asks the card how many verification attempts are left for the password [reference], *without* + * consuming one. Per the OpenPGP Card spec a VERIFY with an empty data field (a Case-1 APDU) is a + * pure status check: the card answers `63 Cx` (x tries left), `69 83` (blocked → 0), or `90 00` + * (already verified this session). Returns null when the card doesn't report a usable count. + */ + private fun readPinRetries(reference: Int): Int? = + try { + transceive(byteArrayOf(0x00, 0x20, 0x00, reference.toByte())) + null // 90 00: already verified this session; no counter reported. + } catch (e: OpenPgpCardStatusException) { + e.retriesRemaining + } catch (e: IOException) { + null // A transport problem while probing shouldn't mask the original failure. + } + + private fun verifyPin(pin: CharArray, reference: Int) { + val pinBytes = pin.concatToString().toByteArray(Charsets.UTF_8) + try { + transceive( + byteArrayOf(0x00, 0x20, 0x00, reference.toByte(), pinBytes.size.toByte()) + pinBytes + ) + } catch (e: OpenPgpCardStatusException) { + // A VERIFY carries only the PIN in its data field, so a "wrong data / wrong length" rejection + // (67 xx / 6A 80) means the PIN didn't fit the card's PW length bounds — surface it as a + // recoverable, re-promptable error rather than the raw status word. + if (e.isDataFieldRejection) throw SmartcardPinFormatException(e.sw1, e.sw2) + throw e + } finally { + pinBytes.fill(0) + } + } + + fun decipher(ciphertext: ByteArray): ByteArray { + val payload = byteArrayOf(0x00) + ciphertext + return transceiveData(0x2A, 0x80, 0x86, payload, expectedLength = ciphertext.size) + } + + fun computeDigitalSignature(digestInfo: ByteArray, expectedLength: Int): ByteArray { + return transceiveData(0x2A, 0x9E, 0x9A, digestInfo, expectedLength) + } + + /** + * Runs INTERNAL AUTHENTICATE (INS 0x88) with the card's Authentication key over [input] and + * returns the raw signature. Unlike PSO:CDS (used for OpenPGP signatures), this uses the + * Authentication key slot and requires PW1 verified in mode 0x82 (see [verifyUserPin]). Used for + * SSH public-key authentication. + */ + fun internalAuthenticate(input: ByteArray): ByteArray { + // Le = 0 → request up to 256 bytes; longer responses (e.g. RSA) are pulled in via 61xx chaining. + return transceiveData(0x88, 0x00, 0x00, input, expectedLength = 0) + } + + fun readCardInfo(): OpenPgpCardInfo { + val applicationData = transceive(GET_APPLICATION_RELATED_DATA) + val fingerprints = findTlv(applicationData, 0xC5)?.let(::parseFingerprints).orEmpty() + val url = runCatching { transceive(GET_URL).toString(Charsets.UTF_8).trim() }.get() + return OpenPgpCardInfo(fingerprints = fingerprints, url = url?.takeIf { it.isNotBlank() }) + } + + /** + * Whether the card is still within the reader field. Actively probes with a benign read command + * rather than trusting [IsoDep.isConnected], whose cached presence state can stay `true` after + * the card has physically left the field. Uses a short transceive timeout so a removed card is + * reported quickly instead of blocking for the (long) signing timeout before throwing. + */ + fun isPresent(): Boolean = + runCatching { + isoDep.timeout = PRESENCE_PROBE_TIMEOUT_MS + isoDep.transceive(GET_APPLICATION_RELATED_DATA) + true + } + .getOr(false) + + override fun close() { + runCatching { isoDep.close() } + onClose() + } + + private fun transceive(command: ByteArray): ByteArray { + val response = isoDep.transceive(command) + if (response.size < 2) throw IOException("Malformed NFC response") + val sw1 = response[response.size - 2].toInt() and 0xff + val sw2 = response[response.size - 1].toInt() and 0xff + val data = response.copyOf(response.size - 2) + if (sw1 == 0x90 && sw2 == 0x00) return data + if (sw1 == 0x61) + return data + transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, sw2.toByte())) + if (sw1 == 0x6C) return transceive(command.copyOf(command.size - 1) + sw2.toByte()) + throw OpenPgpCardStatusException(sw1, sw2) + } + + private fun transceiveData( + ins: Int, + p1: Int, + p2: Int, + payload: ByteArray, + expectedLength: Int, + ): ByteArray { + return if (payload.size <= MAX_APDU_NC) { + transceiveShort(ins, p1, p2, payload, expectedLength) + } else { + transceiveChained(ins, p1, p2, payload, expectedLength) + } + } + + private fun transceiveShort( + ins: Int, + p1: Int, + p2: Int, + payload: ByteArray, + expectedLength: Int, + ): ByteArray { + val command = + byteArrayOf(0x00, ins.toByte(), p1.toByte(), p2.toByte(), payload.size.toByte()) + + payload + + encodeShortLe(expectedLength) + return transceive(command) + } + + private fun transceiveChained( + ins: Int, + p1: Int, + p2: Int, + payload: ByteArray, + expectedLength: Int, + ): ByteArray { + val chunkSize = (isoDep.maxTransceiveLength - 6).coerceIn(1, MAX_APDU_NC) + var offset = 0 + var response = byteArrayOf() + while (offset < payload.size) { + val end = minOf(offset + chunkSize, payload.size) + val chunk = payload.copyOfRange(offset, end) + val isLast = end == payload.size + val cla = if (isLast) 0x00 else 0x10 + val command = + byteArrayOf(cla.toByte(), ins.toByte(), p1.toByte(), p2.toByte(), chunk.size.toByte()) + + chunk + + if (isLast) encodeShortLe(expectedLength) else byteArrayOf() + response = transceive(command) + offset = end + } + return response + } + + @Suppress("unused") + private fun transceiveExtended(ins: Int, p1: Int, p2: Int, payload: ByteArray): ByteArray { + val lc = payload.size + val command = + byteArrayOf( + 0x00, + ins.toByte(), + p1.toByte(), + p2.toByte(), + 0x00, + ((lc ushr 8) and 0xff).toByte(), + (lc and 0xff).toByte(), + ) + payload + byteArrayOf(0x00, 0x00) + return transceive(command) + } + + companion object { + private const val MAX_APDU_NC = 254 + + // Short transceive timeout used only for presence probing, so a removed card fails fast instead + // of waiting out the multi-second signing timeout. + private const val PRESENCE_PROBE_TIMEOUT_MS = 200 + + private fun encodeShortLe(expectedLength: Int): ByteArray = + byteArrayOf(if (expectedLength >= 256) 0x00 else expectedLength.toByte()) + + private val OPENPGP_AID = byteArrayOf(0xD2.toByte(), 0x76, 0x00, 0x01, 0x24, 0x01) + private val SELECT_OPENPGP = + byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, OPENPGP_AID.size.toByte()) + + OPENPGP_AID + + byteArrayOf(0x00) + private val GET_APPLICATION_RELATED_DATA = byteArrayOf(0x00, 0xCA.toByte(), 0x00, 0x6E, 0x00) + private val GET_URL = byteArrayOf(0x00, 0xCA.toByte(), 0x5F, 0x50, 0x00) + + private fun parseFingerprints(value: ByteArray): List = + value + .asSequence() + .chunked(20) + .map { it.toByteArray() } + .filter { fingerprint -> fingerprint.any { it != 0.toByte() } } + .toList() + + private fun findTlv(data: ByteArray, expectedTag: Int): ByteArray? { + var offset = 0 + while (offset < data.size) { + val (tag, tagEnd) = readTag(data, offset) + val (length, valueOffset) = readLength(data, tagEnd) + val valueEnd = valueOffset + length + if (valueEnd > data.size) return null + val value = data.copyOfRange(valueOffset, valueEnd) + if (tag == expectedTag) return value + if (tag == 0x6E || tag == 0x73) + findTlv(value, expectedTag)?.let { + return it + } + offset = valueEnd + } + return null + } + + private fun readTag(data: ByteArray, offset: Int): Pair { + var cursor = offset + var tag = data[cursor++].toInt() and 0xff + if (tag and 0x1f == 0x1f) { + do { + val next = data[cursor++].toInt() and 0xff + tag = (tag shl 8) or next + } while (next and 0x80 == 0x80 && cursor < data.size) + } + return tag to cursor + } + + private fun readLength(data: ByteArray, offset: Int): Pair { + var cursor = offset + val first = data[cursor++].toInt() and 0xff + if (first and 0x80 == 0) return first to cursor + val count = first and 0x7f + var length = 0 + repeat(count) { length = (length shl 8) or (data[cursor++].toInt() and 0xff) } + return length to cursor + } + + fun disableReaderMode(activity: Activity) { + val adapter = NfcAdapter.getDefaultAdapter(activity) ?: return + try { + adapter.disableReaderMode(activity) + } catch (e: IllegalStateException) { + // NfcAdapter.disableReaderMode throws "activity is already destroyed" when it runs as an + // onDestroy() cleanup (the platform has already torn down the activity's NFC state, so + // reader mode is gone anyway). Swallow it so finishing the activity never crashes. + logcat(WARN) { e.asLog() } + } + } + + suspend fun waitForCard( + activity: Activity, + disableReaderModeOnError: Boolean = true, + disableReaderModeOnClose: Boolean = true, + onCardDetected: () -> Unit = {}, + ): OpenPgpNfcCard = suspendCancellableCoroutine { continuation -> + val adapter = NfcAdapter.getDefaultAdapter(activity) + if (adapter == null || !adapter.isEnabled) { + continuation.resumeWithException( + IOException(activity.getString(R.string.openpgp_nfc_unavailable)) + ) + return@suspendCancellableCoroutine + } + val completed = AtomicBoolean(false) + + val callback = NfcAdapter.ReaderCallback { tag: Tag -> + if (!completed.compareAndSet(false, true)) return@ReaderCallback + try { + val isoDep = + IsoDep.get(tag) + ?: throw IOException(activity.getString(R.string.openpgp_nfc_not_iso_dep)) + activity.runOnUiThread { onCardDetected() } + isoDep.connect() + isoDep.timeout = 30_000 + val card = + OpenPgpNfcCard(isoDep) { + if (disableReaderModeOnClose) { + activity.runOnUiThread { disableReaderMode(activity) } + } + } + card.selectOpenPgpApplet() + if (continuation.isActive) { + continuation.resume(card) + } else { + card.close() + } + } catch (e: Throwable) { + if (disableReaderModeOnError) { + activity.runOnUiThread { disableReaderMode(activity) } + } + if (continuation.isActive) { + continuation.resumeWithException(e) + } + } + } + + adapter.enableReaderMode( + activity, + callback, + NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or + NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, + Bundle().apply { putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 500) }, + ) + continuation.invokeOnCancellation { + if (completed.compareAndSet(false, true)) disableReaderMode(activity) + } + } + + suspend fun waitForCardOrNull( + activity: Activity, + cancelSignal: Deferred, + disableReaderModeOnError: Boolean = true, + disableReaderModeOnClose: Boolean = true, + onCardDetected: () -> Unit = {}, + ): OpenPgpNfcCard? = coroutineScope { + val wait = async { + waitForCard( + activity, + disableReaderModeOnError, + disableReaderModeOnClose, + onCardDetected, + ) + } + try { + select { + wait.onAwait { it } + cancelSignal.onAwait { + wait.cancel() + disableReaderMode(activity) + null + } + } + } finally { + if (!wait.isCompleted) wait.cancel() + } + } + + fun isTransceiveFailure(error: Throwable?): Boolean { + var cause = error + while (cause != null) { + if (cause is IOException && cause.message?.contains("Transceive failed") == true) { + return true + } + cause = cause.cause + } + return false + } + } +} + +open class OpenPgpCardStatusException(val sw1: Int, val sw2: Int) : + IOException( + "OpenPGP card returned ${sw1.toString(16).padStart(2, '0')} " + + sw2.toString(16).padStart(2, '0') + ) { + + val isAuthenticationFailure: Boolean + // 69 82: security status not satisfied, 69 83: authentication method blocked, + // 63 Cx: verification failed with x retries remaining. + get() = sw1 == 0x69 && (sw2 == 0x82 || sw2 == 0x83) || sw1 == 0x63 && sw2 in 0xC0..0xCF + + /** + * Whether the card rejected the command *data field* itself — `67 xx` (wrong length) or `6A 80` + * (incorrect parameters in the data field). For a PIN VERIFY, whose data field is only the PIN, + * this means the PIN did not fit the card's PW length bounds (too long or too short). + */ + val isDataFieldRejection: Boolean + get() = sw1 == 0x67 || (sw1 == 0x6A && sw2 == 0x80) + + /** + * Number of PIN attempts the card reports as still remaining, or `null` when the status word does + * not carry that information. `63 Cx` encodes the remaining tries in its low nibble; `69 83` + * (authentication method blocked) means none are left. + */ + val retriesRemaining: Int? + get() = + when { + sw1 == 0x63 && sw2 in 0xC0..0xCF -> sw2 and 0x0F + sw1 == 0x69 && sw2 == 0x83 -> 0 + else -> null + } +} + +/** + * A PIN VERIFY the card rejected because the PIN did not fit its configured PW length bounds (the + * OpenPGP Card spec defines a per-card min of 6 and a max in the PW Status Bytes). Some cards (e.g. + * YubiKey) answer an over-long PIN with `6A 80` rather than a normal `63 Cx` wrong-PIN status. This + * rejection does **not** decrement the retry counter, so it is recoverable: the user can simply + * re-enter a PIN of acceptable length. + */ +class SmartcardPinFormatException(sw1: Int, sw2: Int) : OpenPgpCardStatusException(sw1, sw2) + +data class OpenPgpCardInfo(val fingerprints: List, val url: String?) + +/** + * Keeps NFC reader mode enabled for the whole duration of a multi-step card operation (such as + * commit signing with PIN retries), so the platform never falls back to dispatching the card's NDEF + * URL between taps. Enable reader mode once via [create], await successive card presentations with + * [awaitCard], and [close] it exactly once (which disables reader mode) when finished. + */ +class CardReader +private constructor(private val activity: Activity, private val adapter: NfcAdapter) : + AutoCloseable { + + private val tags = Channel(Channel.UNLIMITED) + private val closed = AtomicBoolean(false) + private val callback = NfcAdapter.ReaderCallback { tag -> tags.trySend(tag) } + + init { + adapter.enableReaderMode( + activity, + callback, + NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or + NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, + Bundle().apply { putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 500) }, + ) + } + + /** + * Suspends until an OpenPGP card is presented and its applet selected, transparently skipping + * past transient tag glitches (a card lifted mid-connect, a stale buffered tag, a non-ISO-DEP + * tag). [onCardDetected] is invoked once a card has connected. Throws + * [OpenPgpCardStatusException] only when the card actively rejects the applet selection. + */ + suspend fun awaitCard(onCardDetected: () -> Unit): OpenPgpNfcCard { + while (true) { + val tag = tags.receive() + val isoDep = IsoDep.get(tag) ?: continue + try { + isoDep.connect() + isoDep.timeout = 30_000 + onCardDetected() + val card = OpenPgpNfcCard(isoDep) + card.selectOpenPgpApplet() + return card + } catch (e: OpenPgpCardStatusException) { + runCatching { isoDep.close() } + throw e + } catch (e: Throwable) { + // Tag lost or a transient transport error: wait for the next presentation. + runCatching { isoDep.close() } + } + } + } + + override fun close() { + if (closed.compareAndSet(false, true)) { + tags.close() + runCatching { adapter.disableReaderMode(activity) } + } + } + + companion object { + fun create(activity: Activity): CardReader? { + val adapter = NfcAdapter.getDefaultAdapter(activity) ?: return null + if (!adapter.isEnabled) return null + return CardReader(activity, adapter) + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt new file mode 100644 index 0000000000..5c360eac29 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt @@ -0,0 +1,174 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + +package app.passwordstore.util.crypto + +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPKey +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import org.bouncycastle.bcpg.AEADEncDataPacket +import org.bouncycastle.bcpg.PublicKeyAlgorithmTags +import org.bouncycastle.bcpg.SymmetricEncIntegrityPacket +import org.bouncycastle.openpgp.PGPCompressedData +import org.bouncycastle.openpgp.PGPEncryptedDataList +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPLiteralData +import org.bouncycastle.openpgp.PGPObjectFactory +import org.bouncycastle.openpgp.PGPOnePassSignatureList +import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData +import org.bouncycastle.openpgp.PGPSessionKey +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.operator.AbstractPublicKeyDataDecryptorFactory +import org.bouncycastle.openpgp.operator.PGPDataDecryptor +import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JceSessionKeyDataDecryptorFactoryBuilder +import org.bouncycastle.util.io.Streams + +class OpenPgpSmartcardDecryptor @Inject constructor() { + + fun decrypt( + key: PGPKey, + pin: CharArray, + ciphertextStream: InputStream, + outputStream: OutputStream, + card: OpenPgpNfcCard, + cardFingerprints: List, + ) { + val cert = KeyUtils.tryParseCertificateOrKey(key) ?: throw PGPException("Invalid PGP key") + val keyIds = keyIdsMatchingCard(cert, cardFingerprints) + card.verifyUserPin(pin) + + val decoder = PGPUtil.getDecoderStream(ciphertextStream) + val objectFactory = PGPObjectFactory(decoder, JcaKeyFingerprintCalculator()) + val encryptedDataList = + generateSequence { objectFactory.nextObject() } + .filterIsInstance() + .firstOrNull() ?: throw PGPException("No encrypted OpenPGP data found") + + val encryptedDataPackets = + encryptedDataList.asSequence().filterIsInstance().toList() + val explicitMatches = encryptedDataPackets.filter { keyIds.contains(it.keyIdentifier.keyId) } + val anonymousMatches = encryptedDataPackets.filter { + it.keyIdentifier.isWildcard || it.keyIdentifier.keyId == 0L + } + val candidates = (explicitMatches + anonymousMatches).distinct() + if (candidates.isEmpty()) throw PGPException("Message is not encrypted to this OpenPGP card") + + val decryptorFactory = OpenPgpCardDecryptorFactory(card) + var firstFailure: Exception? = null + val (encryptedData, sessionKey) = + candidates.firstNotNullOfOrNull { candidate -> + try { + candidate to candidate.getSessionKey(decryptorFactory) + } catch (e: Throwable) { + if (OpenPgpNfcCard.isTransceiveFailure(e)) throw e + if (e.isCardAuthenticationFailure()) throw e + if (firstFailure == null) firstFailure = e as? Exception + null + } + } + ?: throw PGPException( + "Message is not encrypted to this OpenPGP card", + firstFailure, + ) + + encryptedData.getDataStream(JceSessionKeyDataDecryptorFactoryBuilder().build(sessionKey)).use { + cleartext -> + pipeLiteralData(cleartext, outputStream) + } + + if (encryptedData.isIntegrityProtected && !encryptedData.verify()) { + throw PGPException("OpenPGP message integrity check failed") + } + } + + private fun Throwable.isCardAuthenticationFailure(): Boolean = + this is OpenPgpCardStatusException && isAuthenticationFailure || + cause?.isCardAuthenticationFailure() == true + + private fun keyIdsMatchingCard( + cert: org.bouncycastle.openpgp.api.OpenPGPCertificate, + cardFingerprints: List, + ): Set { + if (cardFingerprints.isEmpty()) return cert.getAllKeyIdentifiers().map { it.getKeyId() }.toSet() + val matchingKeyIds = + cert + .getAllKeyIdentifiers() + .filter { keyIdentifier -> + val fingerprint = keyIdentifier.getFingerprint() ?: return@filter false + cardFingerprints.any { it.contentEquals(fingerprint) } + } + .map { it.getKeyId() } + .toSet() + if (matchingKeyIds.isEmpty()) { + throw PGPException("The selected OpenPGP card does not match this key") + } + return matchingKeyIds + } + + private fun pipeLiteralData(inputStream: InputStream, outputStream: OutputStream) { + var current = PGPObjectFactory(inputStream, JcaKeyFingerprintCalculator()).nextObject() + while (current != null) { + when (current) { + is PGPCompressedData -> { + pipeLiteralData(current.dataStream, outputStream) + return + } + is PGPLiteralData -> { + current.inputStream.use { Streams.pipeAll(it, outputStream) } + return + } + is PGPOnePassSignatureList -> { + current = PGPObjectFactory(inputStream, JcaKeyFingerprintCalculator()).nextObject() + } + else -> throw PGPException("Unsupported OpenPGP cleartext packet") + } + } + throw PGPException("No literal OpenPGP data found") + } + + private class OpenPgpCardDecryptorFactory(private val card: OpenPgpNfcCard) : + AbstractPublicKeyDataDecryptorFactory() { + + private val contentDecryptorFactory = BcPublicKeyDataDecryptorFactory(null) + + override fun recoverSessionData( + keyAlgorithm: Int, + secKeyData: Array, + pkeskVersion: Int, + ): ByteArray { + if ( + keyAlgorithm != PublicKeyAlgorithmTags.RSA_ENCRYPT && + keyAlgorithm != PublicKeyAlgorithmTags.RSA_GENERAL + ) { + throw PGPException("NFC OpenPGP decryption currently supports RSA card subkeys only") + } + val mpi = secKeyData.firstOrNull() ?: throw PGPException("Missing encrypted session key") + if (mpi.size <= 2) throw PGPException("Malformed RSA session key") + return card.decipher(mpi.copyOfRange(2, mpi.size)) + } + + override fun createDataDecryptor( + withIntegrityPacket: Boolean, + encAlgorithm: Int, + key: ByteArray, + ): PGPDataDecryptor = + contentDecryptorFactory.createDataDecryptor(withIntegrityPacket, encAlgorithm, key) + + override fun createDataDecryptor( + aeadEncDataPacket: AEADEncDataPacket, + sessionKey: PGPSessionKey, + ): PGPDataDecryptor = contentDecryptorFactory.createDataDecryptor(aeadEncDataPacket, sessionKey) + + override fun createDataDecryptor( + seipd: SymmetricEncIntegrityPacket, + sessionKey: PGPSessionKey, + ): PGPDataDecryptor = contentDecryptorFactory.createDataDecryptor(seipd, sessionKey) + } +} diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardStore.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardStore.kt new file mode 100644 index 0000000000..926c70954e --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardStore.kt @@ -0,0 +1,51 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.crypto + +import android.content.SharedPreferences +import androidx.core.content.edit +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.injection.prefs.SettingsPreferences +import javax.inject.Inject +import org.bouncycastle.util.encoders.Hex + +class OpenPgpSmartcardStore +@Inject +constructor(@SettingsPreferences private val preferences: SharedPreferences) { + + fun associate(primaryKeyId: PGPIdentifier.KeyId, fingerprints: List, url: String?) { + preferences.edit { + putString( + fingerprintKey(primaryKeyId), + fingerprints.joinToString("\n") { Hex.toHexString(it) }, + ) + putString(urlKey(primaryKeyId), url.orEmpty()) + } + } + + fun hasAssociation(primaryKeyId: PGPIdentifier.KeyId): Boolean = + !preferences.getString(fingerprintKey(primaryKeyId), null).isNullOrBlank() + + fun getFingerprints(primaryKeyId: PGPIdentifier.KeyId): List = + preferences + .getString(fingerprintKey(primaryKeyId), null) + ?.lineSequence() + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { Hex.decode(it) } + ?.toList() + .orEmpty() + + private fun fingerprintKey(primaryKeyId: PGPIdentifier.KeyId) = + "$PREFERENCE_PREFIX.${primaryKeyId.id}.fingerprints" + + private fun urlKey(primaryKeyId: PGPIdentifier.KeyId) = + "$PREFERENCE_PREFIX.${primaryKeyId.id}.url" + + companion object { + private const val PREFERENCE_PREFIX = "openpgp_smartcard" + } +} 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 db0100f827..0d3d319dad 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -20,6 +20,7 @@ import android.os.Build import android.util.TypedValue import android.view.View import android.view.autofill.AutofillManager +import android.view.inputmethod.InputMethodManager import androidx.activity.ComponentActivity import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat @@ -38,6 +39,18 @@ import logcat.logcat val Context.autofillManager: AutofillManager? get() = getSystemService() +/** + * Hides the soft keyboard and clears the focused view, so a focused text field cannot resurface the + * keyboard over a dialog, snackbar, or the status bar once overlaying dialogs close. Must be called + * on the main thread. + */ +fun FragmentActivity.hideKeyboard() { + val imm = getSystemService() ?: return + val focus = currentFocus + imm.hideSoftInputFromWindow((focus ?: window.decorView).windowToken, 0) + focus?.clearFocus() +} + /** Get an instance of [ClipboardManager] */ val Context.clipboard get() = getSystemService() @@ -115,6 +128,9 @@ fun FragmentActivity.snackbar( message: String, length: Int = Snackbar.LENGTH_SHORT, ): Snackbar { + // Collapse the soft keyboard so the status bar isn't hidden behind it (a snackbar shown while the + // keyboard is up would otherwise sit under it). + hideKeyboard() val snackbar = Snackbar.make(view, message, length) snackbar.anchorView = findViewById(R.id.fab) snackbar.show() diff --git a/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt b/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt index 8fcc96beae..de81117526 100644 --- a/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt +++ b/app/src/main/java/app/passwordstore/util/git/ErrorMessages.kt @@ -51,8 +51,8 @@ object ErrorMessages { if (throwable == null) return resources.getString(R.string.git_unknown_error) return when (val rootCause = rootCause(throwable)) { is GitException -> rootCause.message - is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) - else -> throwable.message ?: resources.getString(R.string.git_unknown_error) + is UnknownHostException -> resources.getString(R.string.git_unknown_host, rootCause.message) + else -> rootCause.message ?: resources.getString(R.string.git_unknown_error) } } diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt index a73263bf83..6b908c8343 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt @@ -8,7 +8,10 @@ package app.passwordstore.util.git import android.widget.Toast import androidx.fragment.app.FragmentActivity import app.passwordstore.R +import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.crypto.OpenPgpSmartcardStore +import app.passwordstore.util.extensions.hideKeyboard import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.git.GitException.PullException @@ -45,6 +48,9 @@ class GitCommandExecutor( suspend fun execute(): Result { val gitSettings = hiltEntryPoint.gitSettings() val dispatcherProvider = hiltEntryPoint.dispatcherProvider() + // Collapse any keyboard left focused by an entry form so it can't overlap the status snackbar + // or the dialogs shown while the operation runs (or the error/success UI when it finishes). + activity.hideKeyboard() val snackbar = activity.snackbar( message = activity.resources.getString(R.string.git_operation_running), @@ -66,6 +72,18 @@ class GitCommandExecutor( val name = gitSettings.authorName.ifEmpty { "root" } val email = gitSettings.authorEmail.ifEmpty { "localhost" } val identity = PersonIdent(name, email) + if (gitSettings.signCommits) { + command + .setSign(true) + .setGpgSigner( + OpenPgpCommitSigner( + activity, + hiltEntryPoint.pgpKeyManager(), + hiltEntryPoint.smartcardStore(), + dispatcherProvider, + ) + ) + } command.setAuthor(identity).setCommitter(identity).call() } } @@ -133,5 +151,9 @@ class GitCommandExecutor( fun gitSettings(): GitSettings fun dispatcherProvider(): DispatcherProvider + + fun pgpKeyManager(): PGPKeyManager + + fun smartcardStore(): OpenPgpSmartcardStore } } diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommit.kt b/app/src/main/java/app/passwordstore/util/git/GitCommit.kt index d99b289c0f..14be417247 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitCommit.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitCommit.kt @@ -14,10 +14,12 @@ import java.time.Instant * @property shortMessage the commit's short message (i.e. title line). * @property authorName name of the commit's author without email address. * @property time time when the commit was created. + * @property isSigned whether the commit contains an embedded OpenPGP signature. */ data class GitCommit( val hash: String, val shortMessage: String, val authorName: String, val time: Instant, + val isSigned: Boolean, ) diff --git a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt index 75437b3659..ecad47ef22 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt @@ -47,7 +47,15 @@ class GitLogModel { // user experience. private val cache: MutableList by unsafeLazy { commits() - .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } + .map { + GitCommit( + it.hash, + it.shortMessage, + it.authorIdent.name, + it.time, + it.getRawGpgSignature() != null, + ) + } .toMutableList() } val size = cache.size diff --git a/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt new file mode 100644 index 0000000000..901fd66027 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt @@ -0,0 +1,481 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git + +import android.widget.Button +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.crypto.CardReader +import app.passwordstore.util.crypto.OpenPgpCardPrompt +import app.passwordstore.util.crypto.OpenPgpNfcCard +import app.passwordstore.util.crypto.OpenPgpSmartcardStore +import app.passwordstore.util.crypto.SmartcardOperationHandledException +import app.passwordstore.util.extensions.hideKeyboard +import app.passwordstore.util.extensions.wipe +import com.github.michaelbull.result.get +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import com.google.android.material.R as materialR +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.runBlocking +import logcat.asLog +import logcat.logcat +import org.bouncycastle.asn1.DERNull +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.bouncycastle.asn1.x509.DigestInfo +import org.bouncycastle.bcpg.ArmoredOutputStream +import org.bouncycastle.bcpg.HashAlgorithmTags +import org.bouncycastle.bcpg.PublicKeyAlgorithmTags +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPPrivateKey +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSecretKey +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +import org.bouncycastle.openpgp.PGPSignature +import org.bouncycastle.openpgp.PGPSignatureGenerator +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.operator.PGPContentSigner +import org.bouncycastle.openpgp.operator.PGPContentSignerBuilder +import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder +import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder +import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.eclipse.jgit.api.errors.CanceledException +import org.eclipse.jgit.lib.CommitBuilder +import org.eclipse.jgit.lib.GpgSignature +import org.eclipse.jgit.lib.GpgSigner +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.transport.CredentialsProvider + +class OpenPgpCommitSigner( + private val activity: FragmentActivity, + private val keyManager: PGPKeyManager, + private val smartcardStore: OpenPgpSmartcardStore, + private val dispatcherProvider: DispatcherProvider, +) : GpgSigner() { + + override fun sign( + commit: CommitBuilder, + gpgSigningKey: String?, + committer: PersonIdent, + credentialsProvider: CredentialsProvider?, + ) { + val signingKey = resolveSigningKey(gpgSigningKey) + val primaryKeyId = + KeyUtils.tryGetKeyId(signingKey) + ?: throw PGPException("Cannot determine OpenPGP signing key ID") + val payload = commit.build() + val signature = + if (smartcardStore.hasAssociation(primaryKeyId)) { + signWithSmartcard(signingKey, primaryKeyId, payload) + } else { + signWithSecretKey(signingKey, payload) + } + // A null signature means the user chose to proceed with an unsigned commit. + if (signature != null) { + commit.setGpgSignature(GpgSignature(signature)) + } + } + + override fun canLocateSigningKey( + gpgSigningKey: String?, + committer: PersonIdent, + credentialsProvider: CredentialsProvider?, + ): Boolean = runCatching { resolveSigningKey(gpgSigningKey) }.isOk + + private fun resolveSigningKey(gpgSigningKey: String?): PGPKey { + val identifiers = + gpgSigningKey?.let(PGPIdentifier::fromString)?.let(::listOf) ?: rootGpgIdentifiers() + identifiers.forEach { identifier -> + keyManager.getKeyById(identifier).get()?.let { + return it + } + } + throw PGPException("No OpenPGP key from .gpg-id is available for Git commit signing") + } + + private fun rootGpgIdentifiers(): List { + val gpgIdFile = PasswordRepository.getRepositoryDirectory().resolve(".gpg-id") + if (!gpgIdFile.isFile) throw PGPException("No root .gpg-id found for Git commit signing") + return gpgIdFile + .readLines() + .map { it.substringBefore('#').substringBefore('!').trim() } + .filter { it.isNotEmpty() && it != "gpg-id" } + .mapNotNull(PGPIdentifier::fromString) + } + + private fun signWithSecretKey(key: PGPKey, payload: ByteArray): ByteArray { + val secretKey = findSecretSigningKey(key) + if (secretKey.isPrivateKeyEmpty) { + throw PGPException("Git commit signing key is a smartcard stub without a card association") + } + val passphrase = + runBlocking { + OpenPgpCardPrompt(activity, R.string.git_signing_passphrase_title, dispatcherProvider) + .askSecret( + titleRes = R.string.git_signing_passphrase_title, + hintRes = R.string.ssh_keygen_passphrase, + identityLabel = identityLabel(key), + ) + } + ?.secret ?: throw CanceledException(activity.getString(R.string.dialog_cancel)) + try { + val decryptor = + BcPBESecretKeyDecryptorBuilder(BcPGPDigestCalculatorProvider()).build(passphrase) + val privateKey = secretKey.extractPrivateKey(decryptor) + return buildDetachedSignature( + secretKey.publicKey, + BcPGPContentSignerBuilder(secretKey.publicKey.algorithm, HashAlgorithmTags.SHA256), + privateKey, + payload, + ) + } finally { + passphrase.wipe() + } + } + + /** + * Signs [payload] with the OpenPGP smartcard associated with [primaryKeyId]. + * + * Returns `null` when the user explicitly opts to proceed with an unsigned commit, in which case + * the caller must not attach a signature to the commit. + */ + private fun signWithSmartcard( + key: PGPKey, + primaryKeyId: PGPIdentifier.KeyId, + payload: ByteArray, + ): ByteArray? { + // Collapse (and unfocus) the entry form's keyboard up front so it can't linger behind the + // signing dialogs or resurface over the status/error UI when they close. + activity.runOnUiThread { activity.hideKeyboard() } + when (confirmSmartcardSigning()) { + SigningChoice.CANCEL -> throw CanceledException(activity.getString(R.string.dialog_cancel)) + SigningChoice.SKIP -> return null + SigningChoice.SIGN -> {} + } + // The shared prompt keeps reader mode enabled for the whole operation, shows the reused + // present/hold-card dialog, and runs the card exchange on the card's own thread. Any smartcard + // failure is reported to the user in a dialog (never a snackbar) by the outer catch below. + val prompt = OpenPgpCardPrompt(activity, R.string.git_signing_card_title, dispatcherProvider) + var reader: CardReader? = null + var pin: CharArray? = null + // Reader mode is released via the removal watcher (which disables it once the card leaves) on + // every terminal outcome — success or failure — so the finally only closes it if we exit + // unexpectedly. + var readerHandedOff = false + try { + // Namespaced so the signing PIN cache is kept separate from the decryption PIN cache. + val cacheKey = "sign:$primaryKeyId" + val cardFingerprints = smartcardStore.getFingerprints(primaryKeyId) + val publicKey = findCardSigningKey(key, cardFingerprints) + val activeReader = + runBlocking { prompt.createReader() } + ?: throw IOException(activity.getString(R.string.openpgp_nfc_unavailable)) + reader = activeReader + val presentMessage = activity.getString(R.string.git_signing_tap_card) + var pinFromCache = false + var cachePin = false + var pinErrorMessage: String? = null + var cardMessage = presentMessage + prompt.readCachedPin(cacheKey)?.let { + pin = it + pinFromCache = true + } + while (true) { + if (pin == null) { + // Take the card dialog down while the PIN dialog is up so they don't stack. + runBlocking { prompt.dismissDialog() } + val entry = + runBlocking { + prompt.askSecret( + titleRes = R.string.git_signing_card_pin_title, + hintRes = R.string.openpgp_card_pin_hint, + showCacheOption = true, + errorMessage = pinErrorMessage, + minLength = OpenPgpCardPrompt.MIN_PIN_LENGTH, + identityLabel = identityLabel(key), + ) + } ?: throw CanceledException(activity.getString(R.string.dialog_cancel)) + pin = entry.secret + cachePin = entry.cache + pinFromCache = false + pinErrorMessage = null + cardMessage = presentMessage + } + val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } + // The whole card exchange (applet select → verify → sign) runs on a single thread with no + // hop, so a genuine wrong PIN reliably comes back as a card status word (e.g. 63 Cx) rather + // than a transceive error caused by racing the NFC presence check. + val attempt = runBlocking { + prompt.attempt(activeReader, cardMessage) { card -> + card.verifySignaturePin(currentPin) + val privateKey = PGPPrivateKey(publicKey.keyID, publicKey.publicKeyPacket, null) + buildDetachedSignature( + publicKey, + CardContentSignerBuilder(publicKey, card), + privateKey, + payload, + ) + } + } + when (attempt) { + is OpenPgpCardPrompt.Attempt.Success -> { + runBlocking { prompt.dismissDialog() } + // Cache the PIN only now that the whole signing operation has succeeded, so a rejected + // PIN is never persisted. + if (!pinFromCache) prompt.storeCachedPin(cacheKey, currentPin, cachePin) + // Keep reader mode on until the card is physically lifted so the platform never + // dispatches its NDEF URL while it is still present (e.g. while the success dialog is + // up). + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) + return attempt.value + } + OpenPgpCardPrompt.Attempt.Cancelled -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(null, activeReader) + throw CanceledException(activity.getString(R.string.dialog_cancel)) + } + is OpenPgpCardPrompt.Attempt.Error -> { + val e = attempt.error + if (OpenPgpCardPrompt.isSmartcardPinFailure(e)) { + // A rejected PIN must never be kept in the cache. + prompt.clearCachedPin(cacheKey) + pin?.wipe() + pin = null + pinFromCache = false + // Trust the card's own retry counter rather than tracking attempts in the app; if it + // didn't put the count in 63 Cx, ask it directly with a non-destructive status check + // so we learn the real state (in particular whether the card is now blocked) even + // after a status word that omits it (69 82, a 6A 80 length/format rejection, …). + val remaining = + OpenPgpCardPrompt.smartcardPinRetriesRemaining(e) + ?: runCatching { attempt.card?.readSignaturePinRetries() }.get() + if (remaining == 0) { + // Blocked: hold reader mode until the card is lifted, then abort — the outer catch + // reports it in a dialog. Stop asking for a PIN. + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) + throw PGPException(activity.getString(R.string.openpgp_card_pin_blocked)) + } + runCatching { attempt.card?.close() } + pinErrorMessage = + if (remaining != null) { + activity.resources.getQuantityString( + R.plurals.openpgp_card_wrong_pin_remaining, + remaining, + remaining, + ) + } else { + activity.getString(R.string.openpgp_card_wrong_pin) + } + continue + } + // Any other NFC/card hiccup (tag lost mid-exchange, transient 6A 80, …) never reaches + // the card's PIN counter: let the user present the card again. + if (OpenPgpCardPrompt.isRetryableCardError(e)) { + runCatching { attempt.card?.close() } + cardMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed) + continue + } + // Any other terminal error: hold reader mode until the card is lifted, then propagate. + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) + throw e + } + } + } + } catch (e: Throwable) { + // Cancellation and already-reported failures propagate untouched; every other smartcard + // failure is reported in a dialog (never a snackbar), then marked handled. + if (e is CanceledException || OpenPgpCardPrompt.isHandled(e)) throw e + runBlocking { + prompt.dismissDialog() + prompt.showError( + R.string.error, + e.message ?: activity.getString(R.string.password_decryption_unknown_error), + ) + } + throw SmartcardOperationHandledException(e.message) + } finally { + pin?.wipe() + runBlocking { prompt.dismissDialog() } + if (!readerHandedOff && reader != null) prompt.releaseReaderWhenCardRemoved(null, reader) + } + } + + private enum class SigningChoice { + SIGN, + SKIP, + CANCEL, + } + + private fun confirmSmartcardSigning(): SigningChoice { + if (activity.isFinishing || activity.isDestroyed) return SigningChoice.CANCEL + val choice = AtomicReference(SigningChoice.CANCEL) + val latch = CountDownLatch(1) + val error = AtomicReference(null) + activity.runOnUiThread { + try { + val dialog = + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.git_signing_card_title) + .setMessage(R.string.git_signing_confirm_message) + .setPositiveButton(R.string.git_signing_confirm_positive) { _, _ -> + choice.set(SigningChoice.SIGN) + latch.countDown() + } + .setNegativeButton(R.string.git_signing_confirm_unsigned) { _, _ -> + choice.set(SigningChoice.SKIP) + latch.countDown() + } + .setOnCancelListener { latch.countDown() } + .setCancelable(true) + .show() + dialog.setCanceledOnTouchOutside(true) + // Keep "Commit without signing" available but visually understated so it does not + // invite accidental taps over the primary "Sign" action. + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.let(::deemphasizeButton) + } catch (t: Throwable) { + error.set(t) + latch.countDown() + } + } + latch.await() + error.get()?.let { logcat { it.asLog() } } + return choice.get() + } + + private fun deemphasizeButton(button: Button) { + button.setTextColor( + MaterialColors.getColor(button, materialR.attr.colorOnSurfaceVariant) + ) + } + + /** Short label naming the signing key, so the passphrase/PIN prompt shows which key it unlocks. */ + private fun identityLabel(key: PGPKey): String? = + KeyUtils.tryGetUserId(key)?.toString()?.takeIf { it.isNotBlank() && it != "null" } + ?: KeyUtils.tryGetKeyId(key)?.toString() + + private fun findSecretSigningKey(key: PGPKey): PGPSecretKey { + val rings = + PGPSecretKeyRingCollection( + PGPUtil.getDecoderStream(key.contents.inputStream()), + JcaKeyFingerprintCalculator(), + ) + return rings.keyRings + .asSequence() + .flatMap { it.secretKeys.asSequence() } + .firstOrNull { it.isSigningKey } + ?: throw PGPException("No signing-capable OpenPGP secret key found") + } + + private fun publicKeys(key: PGPKey): Sequence = + runCatching { + PGPSecretKeyRingCollection( + PGPUtil.getDecoderStream(key.contents.inputStream()), + JcaKeyFingerprintCalculator(), + ) + .keyRings + .asSequence() + .flatMap { it.secretKeys.asSequence() } + .map { it.publicKey } + } + .getOrElse { + PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(key.contents.inputStream()), + JcaKeyFingerprintCalculator(), + ) + .keyRings + .asSequence() + .flatMap { it.publicKeys.asSequence() } + } + + private fun findCardSigningKey(key: PGPKey, cardFingerprints: List): PGPPublicKey { + return publicKeys(key).firstOrNull { publicKey -> + publicKey.algorithm in RSA_SIGNING_ALGORITHMS && + cardFingerprints.any { it.contentEquals(publicKey.fingerprint) } + } ?: throw PGPException("No RSA signing key matching this OpenPGP card was found") + } + + private fun buildDetachedSignature( + publicKey: PGPPublicKey, + signerBuilder: PGPContentSignerBuilder, + privateKey: PGPPrivateKey, + payload: ByteArray, + ): ByteArray { + val generator = PGPSignatureGenerator(signerBuilder, publicKey) + generator.init(PGPSignature.BINARY_DOCUMENT, privateKey) + generator.update(payload) + val out = ByteArrayOutputStream() + ArmoredOutputStream(out).use { armored -> generator.generate().encode(armored) } + return out.toByteArray() + } + + // The PIN is verified explicitly (see signWithSmartcard) before this builder runs, so it only has + // to compute the signature. + private class CardContentSignerBuilder( + private val publicKey: PGPPublicKey, + private val card: OpenPgpNfcCard, + ) : PGPContentSignerBuilder { + + override fun build(signatureType: Int, privateKey: PGPPrivateKey): PGPContentSigner { + if (publicKey.algorithm !in RSA_SIGNING_ALGORITHMS) { + throw PGPException("NFC OpenPGP commit signing currently supports RSA card keys only") + } + val digestCalculator = BcPGPDigestCalculatorProvider().get(HashAlgorithmTags.SHA256) + return object : PGPContentSigner { + override fun getOutputStream(): OutputStream = digestCalculator.outputStream + + override fun getSignature(): ByteArray { + val digestInfo = + DigestInfo( + AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256, DERNull.INSTANCE), + digestCalculator.digest, + ) + .encoded + return card.computeDigitalSignature( + digestInfo, + expectedLength = (publicKey.bitStrength + 7) / 8, + ) + } + + override fun getDigest(): ByteArray = digestCalculator.digest + + override fun getType(): Int = signatureType + + override fun getHashAlgorithm(): Int = HashAlgorithmTags.SHA256 + + override fun getKeyAlgorithm(): Int = publicKey.algorithm + + override fun getKeyID(): Long = publicKey.keyID + } + } + } + + companion object { + // RSA_SIGN is a legacy OpenPGP algorithm id that BouncyCastle deprecates in favour of + // RSA_GENERAL, but keys carrying the old tag still exist and must be recognized here. + @Suppress("DEPRECATION") + private val RSA_SIGNING_ALGORITHMS = + setOf(PublicKeyAlgorithmTags.RSA_GENERAL, PublicKeyAlgorithmTags.RSA_SIGN) + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index a74d4e1793..d27ab309b7 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -89,6 +89,12 @@ constructor( settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) } } + var signCommits + get() = settings.getBoolean(PreferenceKeys.GIT_CONFIG_SIGN_COMMITS, false) + set(value) { + settings.edit { putBoolean(PreferenceKeys.GIT_CONFIG_SIGN_COMMITS, value) } + } + var useMultiplexing get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) set(value) { 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 1bf927cd6d..2712535d0e 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -27,6 +27,7 @@ object PreferenceKeys { const val GIT_CONFIG = "git_config" const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" + const val GIT_CONFIG_SIGN_COMMITS = "git_config_sign_commits" @Deprecated(message = "We're removing support for external storage") const val GIT_EXTERNAL = "git_external" 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..8b26c30bc3 100644 --- a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt +++ b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt @@ -13,8 +13,12 @@ import android.graphics.drawable.Icon import androidx.core.content.getSystemService import app.passwordstore.R import app.passwordstore.data.password.PasswordItem +import app.passwordstore.ui.crypto.BasePGPActivity +import com.github.michaelbull.result.onErr +import com.github.michaelbull.result.runCatching import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File import javax.inject.Inject import logcat.logcat @@ -71,6 +75,27 @@ class ShortcutHandler @Inject constructor(@ApplicationContext val context: Conte shortcutManager.requestPinShortcut(shortcut, null) } + /** + * Removes dynamic shortcuts whose target password file no longer exists (e.g. after the entry was + * deleted or moved), so that long-pressing the launcher icon never offers passwords that would + * fail to decrypt. + */ + fun pruneDynamicShortcuts() { + runCatching { + val shortcutManager: ShortcutManager = context.getSystemService() ?: return + val staleIds = + shortcutManager.dynamicShortcuts.mapNotNull { shortcut -> + val path = shortcut.intent?.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + if (path != null && !File(path).exists()) shortcut.id else null + } + if (staleIds.isNotEmpty()) { + logcat { "Pruning ${staleIds.size} stale dynamic shortcut(s)" } + shortcutManager.removeDynamicShortcuts(staleIds) + } + } + .onErr { logcat { "Failed to prune dynamic shortcuts: ${it.message}" } } + } + /** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */ private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo { return ShortcutInfo.Builder(context, item.fullPathToParent) diff --git a/app/src/main/res/drawable/ic_hardware_key_24dp.xml b/app/src/main/res/drawable/ic_hardware_key_24dp.xml new file mode 100644 index 0000000000..c1456323f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_hardware_key_24dp.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_lock_closed_24px.xml b/app/src/main/res/drawable/ic_lock_closed_24px.xml new file mode 100644 index 0000000000..495872299d --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_closed_24px.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_software_key_24dp.xml b/app/src/main/res/drawable/ic_software_key_24dp.xml new file mode 100644 index 0000000000..0721c5e347 --- /dev/null +++ b/app/src/main/res/drawable/ic_software_key_24dp.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/activity_git_config.xml b/app/src/main/res/layout/activity_git_config.xml index 965c36d15c..1fbb0783c9 100644 --- a/app/src/main/res/layout/activity_git_config.xml +++ b/app/src/main/res/layout/activity_git_config.xml @@ -61,6 +61,16 @@ android:layout_marginTop="8dp" android:text="@string/crypto_save" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/sign_commits" /> + + + + diff --git a/app/src/main/res/layout/add_pgp_key_sheet.xml b/app/src/main/res/layout/add_pgp_key_sheet.xml index 94f21ac589..8c75408ab0 100644 --- a/app/src/main/res/layout/add_pgp_key_sheet.xml +++ b/app/src/main/res/layout/add_pgp_key_sheet.xml @@ -41,4 +41,20 @@ app:layout_constraintTop_toBottomOf="@id/import_key" app:rippleColor="?attr/colorSecondary" /> + + diff --git a/app/src/main/res/layout/git_log_row_layout.xml b/app/src/main/res/layout/git_log_row_layout.xml index 40df042464..502899dadf 100644 --- a/app/src/main/res/layout/git_log_row_layout.xml +++ b/app/src/main/res/layout/git_log_row_layout.xml @@ -25,19 +25,34 @@ + + diff --git a/app/src/main/res/values/colors_material3.xml b/app/src/main/res/values/colors_material3.xml index 92494730e9..63a0a22c96 100644 --- a/app/src/main/res/values/colors_material3.xml +++ b/app/src/main/res/values/colors_material3.xml @@ -61,4 +61,6 @@ #668eacbb + #2E7D32 + #8A8A8A diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2676a38afe..5085567a26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,10 @@ Are you sure you want to delete the selected item? Are you sure you want to delete the %d selected items? + + Successfully deleted password item. + Successfully deleted password items. + Deleting… Move Edit @@ -48,6 +52,17 @@ Renamed %1$s to %2$s Multiple items moved to %1$s PGP IDs in %1$s initialised + Sign Git commits with the store PGP key + PGP signing passphrase + OpenPGP card signing PIN + Sign with OpenPGP card + Sign this commit with the store OpenPGP key? + Sign + Don\'t sign + Present your OpenPGP smartcard to sign the commit. + Couldn\'t read the card. Present it again and hold it still until the operation finishes. + Signed commit + Unsigned commit Copied to clipboard. @@ -320,6 +335,7 @@ Create new password Restore backup/import from file Create new key + Set up NFC smartcard Enable debug logging (requires app restart). Debug logging If the autofill function cannot retrieve the username from the contents of a password file, the value specified here will be used. @@ -355,6 +371,7 @@ Decryption failed. No suitable decryption key found Unknown decryption error + This password no longer exists. Unknown error @@ -412,6 +429,37 @@ Failed to import PGP key An existing key with the following ID was found:\n\n\t%1$s\n\nDo you want to replace it? The given key is not usable for encryption. Please ensure that a subkey for encryption exists and that it has not expired or been revoked. + Present your OpenPGP smartcard. + Smartcard detected + Keep the smartcard on your phone. Do not move it until this operation finishes. + NFC is unavailable or disabled. + This NFC tag is not an ISO-DEP smartcard. + NFC smartcard setup + Smartcard detected + The app can talk to this OpenPGP card over NFC, but the card does not provide a public-key URL. Import the matching public key or GnuPG stub key file next; the app will verify it against the card fingerprints. + The app can talk to this OpenPGP card over NFC, but could not download the public key from the card URL. Import the matching public key or GnuPG stub key file next; the app will verify it against the card fingerprints. + NFC smartcard unavailable + The OpenPGP card did not report any key fingerprints. + The card URL did not return transferable OpenPGP key data. + The imported key does not match the fingerprints reported by the OpenPGP card. + OpenPGP card PIN + PIN + + At least %d character + At least %d characters + + Wrong OpenPGP card PIN + + Wrong PIN. %d attempt remaining before the card is blocked. + Wrong PIN. %d attempts remaining before the card is blocked. + + The OpenPGP card signing PIN is blocked. Reset it with an external tool before signing again. + Keep PIN until screen-off + Decrypt with OpenPGP card + SSH authentication with OpenPGP card + Remove your card + Authentication succeeded. Lift your OpenPGP card off the phone to continue. + OpenPGP card decryption failed Key already exists (replacement skipped) PGP key successfully imported @@ -452,6 +500,11 @@ PGP settings Some error occurred. Run garbage collection job + Remove stale Git lock file + Removed stale Git lock file. + No stale Git lock file was found. + Failed to remove stale Git lock file. + A previous Git operation left a lock file behind. Remove the stale Git lock file from the Git configuration, then try again. PGP key manager Select one or more PGP keys Choose a PGP key @@ -465,6 +518,17 @@ Repeat new passphrase Remote branch name Add a key using the “+” button below + Hardware-backed key + Software-backed key + Key information + User ID: %1$s + Email: %1$s + Key ID: %1$s + Fingerprint: %1$s + Type: %1$s + Software-backed + Public key + Hardware-backed (smartcard) No key selected No keys available No PGP keys have been added to the app yet. Import a key file or create a new key in the PGP key manager. diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt index c0230d4aaf..b9d6e9241f 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -24,6 +24,13 @@ import org.pgpainless.key.info.KeyRingInfo /** Utility methods to deal with [PGPKey]s. */ public object KeyUtils { + /** + * Key-usage flags that make a (sub)key usable for authentication, in preference order: a dedicated + * Authentication subkey first, then a Signing subkey, then the primary Certification key. + */ + private val AUTH_CAPABILITY_RANKING = + listOf(KeyFlags.AUTHENTICATION, KeyFlags.SIGN_DATA, KeyFlags.CERTIFY_OTHER) + /** * Attempts to parse an [OpenPGPCertificate] from a given [PGPKey]. The key is first tried as a * secret keyring and then as a public one before the method gives up and returns null. @@ -72,6 +79,19 @@ public object KeyUtils { public fun tryGetKeyId(cert: OpenPGPCertificate): KeyId = cert.getPrimaryKey().getKeyIdentifier().getKeyId().let { KeyId(it) } + /** Returns all fingerprints present in [key], including subkeys. */ + public fun tryGetFingerprints(key: PGPKey): List = + tryParseCertificateOrKey(key)?.getAllKeyIdentifiers()?.mapNotNull { it.getFingerprint() } + ?: emptyList() + + /** Returns true if [key] contains any of [fingerprints]. */ + public fun containsAnyFingerprint(key: PGPKey, fingerprints: List): Boolean { + val keyFingerprints = tryGetFingerprints(key) + return keyFingerprints.any { keyFingerprint -> + fingerprints.any { fingerprint -> keyFingerprint.contentEquals(fingerprint) } + } + } + /** * Queries all secret subkey IDs of a given [OpenPGPCertificate] along with their usages (C,E,S,A) * and whether the private key was stripped @@ -114,14 +134,15 @@ public object KeyUtils { /** Tests if the given [PGPKey] content is a PGP certificate or key at all */ public fun isCertificateOrKey(key: PGPKey): Boolean = tryParseCertificateOrKey(key) != null - /** Tests if the given [PGPKey] provides any secret non-stripped subkey */ + /** Tests if the given [PGPKey] provides any secret subkey, including smartcard stubs. */ public fun isSecretKey(key: PGPKey): Boolean = tryParseCertificateOrKey(key)?.let { isSecretKey(it) } ?: false - /** Tests if the given [OpenPGPCertificate] provides any secret non-stripped subkey */ + /** + * Tests if the given [OpenPGPCertificate] provides any secret subkey, including smartcard stubs. + */ public fun isSecretKey(cert: OpenPGPCertificate): Boolean = - cert is OpenPGPKey && - cert.getSecretKeys().values.any { !it.getPGPSecretKey().isPrivateKeyEmpty() } + cert is OpenPGPKey && cert.getSecretKeys().values.any() /** * Tests if the given [PGPKey] can be used for encryption, which is a bare minimum necessity for @@ -137,70 +158,91 @@ public object KeyUtils { public fun isKeyUsable(cert: OpenPGPCertificate): Boolean = KeyRingInfo(cert).isUsableForEncryption - /** Tests if the given [PGPKey] provides a decryption-capable secret subkey */ + /** Tests if the given [PGPKey] provides a decryption-capable secret subkey. */ public fun hasDecKey(key: PGPKey): Boolean = tryParseCertificateOrKey(key)?.let { hasDecKey(it) } ?: false - /** Tests if the given [OpenPGPCertificate] provides a decryption-capable secret subkey */ + /** Tests if the given [OpenPGPCertificate] provides a decryption-capable secret subkey. */ public fun hasDecKey(cert: OpenPGPCertificate): Boolean = - cert is OpenPGPKey && - cert.getSecretKeys().values.any { - it.isEncryptionKey() && !it.getPGPSecretKey().isPrivateKeyEmpty() - } + cert is OpenPGPKey && cert.getSecretKeys().values.any { it.isEncryptionKey() } + + /** Tests if the given [PGPKey] provides only card-backed decryption stubs. */ + public fun hasOnlyStubDecKeys(key: PGPKey): Boolean = + tryParseCertificateOrKey(key)?.let { hasOnlyStubDecKeys(it) } ?: false - /** Tests if the given [PGPKey] provides an authentication-capable secret subkey */ + /** Tests if the given [OpenPGPCertificate] provides only card-backed decryption stubs. */ + public fun hasOnlyStubDecKeys(cert: OpenPGPCertificate): Boolean = + cert is OpenPGPKey && + cert.getSecretKeys().values.any { it.isEncryptionKey() } && + cert + .getSecretKeys() + .values + .filter { it.isEncryptionKey() } + .all { it.getPGPSecretKey().isPrivateKeyEmpty() } + + /** Tests if the given [PGPKey] provides an authentication-capable (sub)key. */ public fun hasAuthKey(key: PGPKey): Boolean = tryParseCertificateOrKey(key)?.let { hasAuthKey(it) } ?: false - /** Tests if the given [OpenPGPCertificate] provides an authentication-capable secret subkey */ - public fun hasAuthKey(cert: OpenPGPCertificate): Boolean { + /** + * Tests if the given [OpenPGPCertificate] provides an authentication-capable (sub)key. + * + * This inspects the certificate's *public* component keys, so it works for a public-only + * certificate and for a smartcard-backed key whose private half lives on the card (a stub with + * empty private material) — in both cases the key is still authentication-capable (the card, or a + * private key held elsewhere, does the actual signing). + */ + public fun hasAuthKey(cert: OpenPGPCertificate): Boolean = + AUTH_CAPABILITY_RANKING.any { flag -> + cert.getComponentKeysWithFlag(Date(), flag).isNotEmpty() + } + + /** + * Tests if the given [PGPKey] provides an authentication-capable secret subkey whose private key + * is present locally (i.e. can sign without a smartcard). Used to decide whether a key can be used + * for SSH authentication on its own. + */ + public fun hasPrivateAuthKey(key: PGPKey): Boolean = + tryParseCertificateOrKey(key)?.let { hasPrivateAuthKey(it) } ?: false + + /** @see hasPrivateAuthKey */ + public fun hasPrivateAuthKey(cert: OpenPGPCertificate): Boolean { if (cert !is OpenPGPKey) return false - val authFlags = listOf(KeyFlags.AUTHENTICATION, KeyFlags.SIGN_DATA, KeyFlags.CERTIFY_OTHER) val subkeys = cert.getSecretKeys().values - val authKeys = - authFlags - .map { flag -> - subkeys - .filter { it.hasKeyFlags(Date(), flag) && !it.getPGPSecretKey().isPrivateKeyEmpty() } - .firstOrNull() - } - .filterNotNull() - return authKeys.isNotEmpty() + return AUTH_CAPABILITY_RANKING.any { flag -> + subkeys.any { it.hasKeyFlags(Date(), flag) && !it.getPGPSecretKey().isPrivateKeyEmpty() } + } } /** - * Parse the public part of the first authentication-capable subkey from [OpenPGPCertificate] or + * Parse the public part of the first authentication-capable (sub)key from [OpenPGPCertificate] or * null if none was found */ public fun extractPublicAuthKey(key: PGPKey): PublicKey? = tryParseCertificateOrKey(key)?.let { extractPublicAuthKey(it) } ?: null /** - * Parse the public part of the first authentication-capable subkey from [OpenPGPCertificate] or - * null if none was found, the returned key format is java.security.PublicKey, as used by sshj + * Parse the public part of the first authentication-capable (sub)key from [OpenPGPCertificate] or + * null if none was found, the returned key format is java.security.PublicKey, as used by sshj. + * + * Only the public half is needed here (it becomes the SSH public key), so this reads the + * certificate's *public* component keys. That makes it work for public-only certificates and for + * smartcard-backed stubs (empty private material) alike — the private authentication operation + * happens later, on the card. */ public fun extractPublicAuthKey(cert: OpenPGPCertificate): PublicKey? { - if (cert !is OpenPGPKey) return null - - /* A and S subkeys as well as the primary C key are equally suitable for authentication; - * we pick the first subkey matching one of the capabilities in the given ranking order: */ - val authFlags = listOf(KeyFlags.AUTHENTICATION, KeyFlags.SIGN_DATA, KeyFlags.CERTIFY_OTHER) - val subkeys = - cert.getSecretKeys().values.sortedByDescending { it.getCreationTime() } // newest first - val authKeys = - authFlags - .map { flag -> - subkeys - .filter { it.hasKeyFlags(Date(), flag) && !it.getPGPSecretKey().isPrivateKeyEmpty() } - .firstOrNull() - } - .filterNotNull() - - return if (authKeys.isEmpty()) null - else - JcaPGPKeyConverter() - .setProvider(BouncyCastleProvider()) - .getPublicKey(authKeys.first().getPGPSecretKey().getPublicKey()) + // A and S subkeys as well as the primary C key are equally suitable for authentication; pick the + // newest key matching one of the capabilities in the given ranking order. + val authKey = + AUTH_CAPABILITY_RANKING.firstNotNullOfOrNull { flag -> + cert + .getComponentKeysWithFlag(Date(), flag) + .maxByOrNull { it.getCreationTime() } // newest first + } ?: return null + + return JcaPGPKeyConverter() + .setProvider(BouncyCastleProvider()) + .getPublicKey(authKey.getPGPPublicKey()) } public fun extractPublicKeyData(key: PGPKey): ByteArray? = From f552aae07d78e8f088917dee4153d0924409cd99 Mon Sep 17 00:00:00 2001 From: Emanuel Valasiadis Date: Tue, 21 Jul 2026 14:42:13 +0900 Subject: [PATCH 02/14] Add PGP-based SSH authentication Authenticate git SSH sessions with a PGP authentication subkey on an OpenPGP smartcard (PgpCardSshAuthMethod). --- .../util/git/sshj/PgpCardSshAuthMethod.kt | 239 ++++++++++++++++++ .../util/git/sshj/SshjSessionFactory.kt | 33 ++- 2 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt new file mode 100644 index 0000000000..3f1cdf5219 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt @@ -0,0 +1,239 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.git.sshj + +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier.KeyId +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.crypto.CardReader +import app.passwordstore.util.crypto.OpenPgpCardPrompt +import app.passwordstore.util.extensions.wipe +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import java.io.IOException +import java.math.BigInteger +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer.PlainBuffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.common.SSHPacket +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import net.schmizz.sshj.userauth.method.AuthPublickey + +/** + * A [KeyProvider] for a smartcard-backed PGP authentication key. Only the public half is known to + * the app; the private authentication operation happens on the card (see [CardSshAuthPublickey]), + * so [getPrivate] is never called and returns null. + */ +class PgpCardSshKeyProvider(private val publicKey: PublicKey) : KeyProvider { + override fun getPublic(): PublicKey = publicKey + + override fun getPrivate(): PrivateKey? = null + + override fun getType(): KeyType = KeyType.fromKey(publicKey) +} + +/** + * [AuthPublickey] that produces the authentication signature on an OpenPGP smartcard instead of with + * a local private key. It overrides [putSig] to assemble the exact data SSH signs — `string(session + * id) ‖ request-so-far` — hand it to [signer], and append the resulting SSH signature blob. Host-key + * verification and everything else stay on sshj's default path. + */ +class CardSshAuthPublickey( + keyProvider: KeyProvider, + private val signer: CardSshSigner, +) : AuthPublickey(keyProvider) { + + public override fun putSig(reqData: SSHPacket): SSHPacket { + val sessionId = params.transport.sessionID + val dataToSign = PlainBuffer().putString(sessionId).putBuffer(reqData).compactData + val (algorithmName, signature) = signer.sign(dataToSign) + // SSH signature field: string( string(algorithm) ‖ string(signature) ). + val signatureBlob = PlainBuffer().putString(algorithmName).putString(signature).compactData + reqData.putString(signatureBlob) + return reqData + } +} + +/** + * Drives an OpenPGP smartcard over NFC to answer an SSH public-key authentication challenge with + * INTERNAL AUTHENTICATE (PW1 mode 0x82). Mirrors the commit-signing card flow: one reader kept + * enabled for the operation, a reused present-card dialog, inline PIN entry with the card's own + * retry counter, and reader mode released only once the card is physically removed. + */ +class CardSshSigner( + private val activity: FragmentActivity, + private val dispatcherProvider: DispatcherProvider, + private val primaryKeyId: KeyId, + private val publicKey: PublicKey, +) { + + /** + * Signs [dataToSign] on the card and returns the SSH `(algorithm-name, signature-blob)` pair. The + * algorithm name matches the public-key algorithm sshj advertises for Ed25519/ECDSA keys. + */ + fun sign(dataToSign: ByteArray): Pair { + val keyType = KeyType.fromKey(publicKey) + val cardInput: ByteArray + val encode: (ByteArray) -> ByteArray + when (keyType) { + // Ed25519 signs the message directly and the raw 64-byte R‖S is the SSH signature as-is. + KeyType.ED25519 -> { + cardInput = dataToSign + encode = { raw -> raw } + } + // ECDSA signs a hash of the message; the card returns raw r‖s which SSH wants as two mpints. + KeyType.ECDSA256 -> { + cardInput = digest("SHA-256", dataToSign) + encode = { raw -> encodeEcdsaSignature(raw, fieldBytes = 32) } + } + KeyType.ECDSA384 -> { + cardInput = digest("SHA-384", dataToSign) + encode = { raw -> encodeEcdsaSignature(raw, fieldBytes = 48) } + } + KeyType.ECDSA521 -> { + cardInput = digest("SHA-512", dataToSign) + encode = { raw -> encodeEcdsaSignature(raw, fieldBytes = 66) } + } + else -> + throw SSHException( + "Smartcard-based SSH authentication is not yet supported for $keyType keys" + ) + } + return keyType.toString() to encode(driveCard(cardInput)) + } + + private fun driveCard(input: ByteArray): ByteArray { + val prompt = OpenPgpCardPrompt(activity, R.string.openpgp_nfc_ssh_title, dispatcherProvider) + var reader: CardReader? = null + var pin: CharArray? = null + var readerHandedOff = false + try { + val cacheKey = "ssh:${primaryKeyId.id}" + val activeReader = + runBlocking { prompt.createReader() } + ?: throw IOException(activity.getString(R.string.openpgp_nfc_unavailable)) + reader = activeReader + val presentMessage = activity.getString(R.string.openpgp_nfc_tap_card) + var pinFromCache = false + var cachePin = false + var pinErrorMessage: String? = null + var cardMessage = presentMessage + prompt.readCachedPin(cacheKey)?.let { + pin = it + pinFromCache = true + } + while (true) { + if (pin == null) { + runBlocking { prompt.dismissDialog() } + val entry = + runBlocking { + prompt.askSecret( + titleRes = R.string.openpgp_card_pin_title, + hintRes = R.string.openpgp_card_pin_hint, + showCacheOption = true, + errorMessage = pinErrorMessage, + minLength = OpenPgpCardPrompt.MIN_PIN_LENGTH, + ) + } ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + pin = entry.secret + cachePin = entry.cache + pinFromCache = false + pinErrorMessage = null + cardMessage = presentMessage + } + val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } + // PW1 in mode 0x82 authorises INTERNAL AUTHENTICATE (the auth key slot), unlike PSO:CDS + // (mode 0x81) used for commit signing. + val attempt = + runBlocking { + prompt.attempt(activeReader, cardMessage) { card -> + card.verifyUserPin(currentPin) + card.internalAuthenticate(input) + } + } + when (attempt) { + is OpenPgpCardPrompt.Attempt.Success -> { + runBlocking { prompt.dismissDialog() } + if (!pinFromCache) prompt.storeCachedPin(cacheKey, currentPin, cachePin) + readerHandedOff = true + val signature = attempt.value + // Block until the card is lifted so reader mode stays up (keeping the activity + // foreground) and the platform never dispatches the card's NDEF URL once the git push + // proceeds and this activity moves on. + runBlocking { prompt.awaitCardRemoval(attempt.card, activeReader) } + return signature + } + OpenPgpCardPrompt.Attempt.Cancelled -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(null, activeReader) + throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } + is OpenPgpCardPrompt.Attempt.Error -> { + val e = attempt.error + if (OpenPgpCardPrompt.isSmartcardPinFailure(e)) { + prompt.clearCachedPin(cacheKey) + pin?.wipe() + pin = null + pinFromCache = false + val remaining = + OpenPgpCardPrompt.smartcardPinRetriesRemaining(e) + ?: runCatching { attempt.card?.readUserPinRetries() }.get() + if (remaining == 0) { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) + throw SSHException(activity.getString(R.string.openpgp_card_pin_blocked)) + } + runCatching { attempt.card?.close() } + pinErrorMessage = + if (remaining != null) { + activity.resources.getQuantityString( + R.plurals.openpgp_card_wrong_pin_remaining, + remaining, + remaining, + ) + } else { + activity.getString(R.string.openpgp_card_wrong_pin) + } + } else if (OpenPgpCardPrompt.isRetryableCardError(e)) { + // Keep the PIN, ask the user to present the card again. + runCatching { attempt.card?.close() } + cardMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed) + } else { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) + throw SSHException(e?.message ?: "OpenPGP card authentication failed") + } + } + } + } + } finally { + pin?.wipe() + runBlocking { prompt.dismissDialog() } + if (!readerHandedOff && reader != null) prompt.releaseReaderWhenCardRemoved(null, reader) + } + } + + private fun digest(algorithm: String, data: ByteArray): ByteArray = + MessageDigest.getInstance(algorithm).digest(data) + + /** + * Converts the card's raw ECDSA signature (r‖s, each left-padded to [fieldBytes]) into the SSH + * signature blob `string(mpint r) ‖ string(mpint s)`. + */ + private fun encodeEcdsaSignature(raw: ByteArray, fieldBytes: Int): ByteArray { + if (raw.size != fieldBytes * 2) { + throw SSHException("Unexpected ECDSA signature length ${raw.size} (expected ${fieldBytes * 2})") + } + val r = BigInteger(1, raw.copyOfRange(0, fieldBytes)) + val s = BigInteger(1, raw.copyOfRange(fieldBytes, raw.size)) + return PlainBuffer().putMPInt(r).putMPInt(s).compactData + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 932183ee7c..9966a83aac 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -8,6 +8,8 @@ import android.util.Base64 import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier.KeyId +import app.passwordstore.ui.git.base.BaseGitActivity import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.getString import app.passwordstore.util.git.operation.CredentialFinder @@ -201,19 +203,36 @@ private class SshjSession( ssh.auth(username, passwordAuth) } is SshAuthMethod.SshKey -> { - val pubkeyAuth = - AuthPublickey( - SshKey.provide( - ssh, - CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider), - ) - ) + val pubkeyAuth = cardBackedAuth() ?: localKeyAuth(authMethod) ssh.auth(username, pubkeyAuth) } } return this } + /** + * When the selected PGP SSH key lives on a smartcard, authenticate by delegating the signature to + * the card over NFC (INTERNAL AUTHENTICATE) instead of unlocking a local private key. Returns null + * for every other key so the normal local-key path is used untouched. + */ + private fun cardBackedAuth(): AuthPublickey? { + if (SshKey.type != SshKey.Type.ImportedPGP) return null + val pgpKeyId = SshKey.pgpLongKeyId + if (pgpKeyId == 0L) return null + val repository = (callingActivity as? BaseGitActivity)?.repository ?: return null + if (!repository.isSmartcardBacked(KeyId(pgpKeyId))) return null + val publicKey = + SshKey.sshPublicKey?.let { parseSshPublicKey(it) } + ?: throw IOException("Missing SSH public key for card-backed authentication") + val signer = CardSshSigner(callingActivity, dispatcherProvider, KeyId(pgpKeyId), publicKey) + return CardSshAuthPublickey(PgpCardSshKeyProvider(publicKey), signer) + } + + private fun localKeyAuth(authMethod: SshAuthMethod): AuthPublickey = + AuthPublickey( + SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider)) + ) + override fun exec(commandName: String?, timeout: Int): Process { if (currentCommand != null) { logcat(WARN) { "Killing old command" } From 072e7d20e7ce5c95cd27df2889dc16838ba8d05f Mon Sep 17 00:00:00 2001 From: Emanuel Valasiadis Date: Tue, 21 Jul 2026 14:42:13 +0900 Subject: [PATCH 03/14] Document transferring a PGP key from an OpenPGP smartcard --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2e5c727a2d..dcc4e3b0cd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ Utilizing the standard `pass` file structure, passkey data is stored on the firs ## How-To: Transfer a PGP key to Password Store securely +### From an OpenPGP smartcard + +1. Go to `Settings > PGP settings > Key manager > +` and select `Set up NFC smartcard` +2. Present your smartcard behind the phone on the NFC sensor and hold it there + ### From GPG keyring ````bash gpg --armor --gen-random 1 24 # generate a strong random password; use it in the next step From b475f446a4bfe53357cdfd1437e47a0655dae105 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Sat, 18 Jul 2026 04:08:43 -0600 Subject: [PATCH 04/14] Add RSA support for smartcard-backed SSH authentication Previously the OpenPGP card SSH auth path handled only Ed25519 and ECDSA keys and rejected RSA. Adding RSA required two changes: - Algorithm negotiation. sshj advertises RSA keys under a negotiated algorithm name (rsa-sha2-512, rsa-sha2-256 or ssh-rsa) that differs from the key's base type, and the name in the signature blob must match the one in the request. The old override signed with KeyType.toString(), which is always ssh-rsa (SHA-1) and mismatches what putPubKey advertised. Because the card replaces both putPubKey and putSig, CardSshAuthPublickey now tracks its own copy of sshj's KeyAlgorithm queue (peeked for the current algorithm, dropped in shouldRetry) so the advertised algorithm, the signed algorithm and the retry fallback stay in lockstep. - Card signing input. For RSA the signer builds a PKCS#1 v1.5 DigestInfo (DER hash prefix plus digest, hash chosen from the negotiated algorithm) and hands it to INTERNAL AUTHENTICATE; the card applies the PKCS#1 padding and modular exponentiation and returns the raw modulus-sized signature, which is used directly as the SSH rsa_signature_blob. --- .../util/git/sshj/PgpCardSshAuthMethod.kt | 166 ++++++++++++++++-- 1 file changed, 149 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt index 3f1cdf5219..1a0e412bc3 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt @@ -13,6 +13,7 @@ import app.passwordstore.util.crypto.OpenPgpCardPrompt import app.passwordstore.util.extensions.wipe import com.github.michaelbull.result.get import com.github.michaelbull.result.runCatching +import com.hierynomus.sshj.key.KeyAlgorithm import java.io.IOException import java.math.BigInteger import java.security.MessageDigest @@ -24,6 +25,7 @@ import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.KeyType import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.common.SSHPacket +import net.schmizz.sshj.userauth.UserAuthException import net.schmizz.sshj.userauth.keyprovider.KeyProvider import net.schmizz.sshj.userauth.method.AuthPublickey @@ -41,25 +43,63 @@ class PgpCardSshKeyProvider(private val publicKey: PublicKey) : KeyProvider { } /** - * [AuthPublickey] that produces the authentication signature on an OpenPGP smartcard instead of with - * a local private key. It overrides [putSig] to assemble the exact data SSH signs — `string(session - * id) ‖ request-so-far` — hand it to [signer], and append the resulting SSH signature blob. Host-key - * verification and everything else stay on sshj's default path. + * [AuthPublickey] that produces the authentication signature on an OpenPGP smartcard instead of + * with a local private key. It overrides [putPubKey]/[putSig] to advertise and sign with the + * *negotiated* public-key algorithm rather than the key's base type: for RSA these differ + * (`rsa-sha2-512` / `rsa-sha2-256` / `ssh-rsa`), and the algorithm name in the request must match + * the one in the signature blob. The exact data SSH signs -- `string(session id) || + * request-so-far` -- is handed to [signer], which drives the card. Host-key verification and + * everything else stay on sshj's default path. + * + * sshj's [net.schmizz.sshj.userauth.method.KeyedAuthMethod] keeps its chosen [KeyAlgorithm] queue + * private and drops the head on [shouldRetry] to fall back to the next algorithm. Because the card + * replaces both [putPubKey] and [putSig], this class tracks the same queue itself so the advertised + * key algorithm, the signed algorithm, and the retry fallback stay in lockstep. */ class CardSshAuthPublickey( - keyProvider: KeyProvider, + private val keyProvider: KeyProvider, private val signer: CardSshSigner, ) : AuthPublickey(keyProvider) { + private var algorithms: MutableList? = null + + private fun currentAlgorithm(): KeyAlgorithm { + val queue = + algorithms + ?: params.transport + .getClientKeyAlgorithms(KeyType.fromKey(keyProvider.public)) + .toMutableList() + .also { algorithms = it } + return queue.firstOrNull() + ?: throw UserAuthException( + "No key algorithm configured for ${KeyType.fromKey(keyProvider.public)}" + ) + } + + public override fun putPubKey(reqData: SSHPacket): SSHPacket { + // Public key as 2 strings: [ negotiated key algorithm | key blob ], as sshj's putPubKey does. + reqData + .putString(currentAlgorithm().keyAlgorithm) + .putString(PlainBuffer().putPublicKey(keyProvider.public).compactData) + return reqData + } + public override fun putSig(reqData: SSHPacket): SSHPacket { + val algorithmName = currentAlgorithm().keyAlgorithm val sessionId = params.transport.sessionID val dataToSign = PlainBuffer().putString(sessionId).putBuffer(reqData).compactData - val (algorithmName, signature) = signer.sign(dataToSign) + val signature = signer.sign(dataToSign, algorithmName) // SSH signature field: string( string(algorithm) ‖ string(signature) ). val signatureBlob = PlainBuffer().putString(algorithmName).putString(signature).compactData reqData.putString(signatureBlob) return reqData } + + override fun shouldRetry(): Boolean { + val queue = algorithms ?: return false + if (queue.isNotEmpty()) queue.removeAt(0) + return queue.isNotEmpty() + } } /** @@ -76,10 +116,12 @@ class CardSshSigner( ) { /** - * Signs [dataToSign] on the card and returns the SSH `(algorithm-name, signature-blob)` pair. The - * algorithm name matches the public-key algorithm sshj advertises for Ed25519/ECDSA keys. + * Signs [dataToSign] on the card and returns the SSH signature blob. [sshAlgorithmName] is the + * public-key algorithm the auth request advertised: for Ed25519/ECDSA it equals the key type, but + * for RSA it is the negotiated `rsa-sha2-512` / `rsa-sha2-256` / `ssh-rsa`, which selects the + * hash the card signs over. */ - fun sign(dataToSign: ByteArray): Pair { + fun sign(dataToSign: ByteArray, sshAlgorithmName: String): ByteArray { val keyType = KeyType.fromKey(publicKey) val cardInput: ByteArray val encode: (ByteArray) -> ByteArray @@ -102,12 +144,18 @@ class CardSshSigner( cardInput = digest("SHA-512", dataToSign) encode = { raw -> encodeEcdsaSignature(raw, fieldBytes = 66) } } + // RSA: the card wraps the DigestInfo we supply in PKCS#1 v1.5 padding and returns the raw + // modulus-sized signature, which is exactly the SSH rsa_signature_blob (string(s), no mpint). + KeyType.RSA -> { + cardInput = pkcs1DigestInfo(sshAlgorithmName, dataToSign) + encode = { raw -> raw } + } else -> throw SSHException( "Smartcard-based SSH authentication is not yet supported for $keyType keys" ) } - return keyType.toString() to encode(driveCard(cardInput)) + return encode(driveCard(cardInput)) } private fun driveCard(input: ByteArray): ByteArray { @@ -152,13 +200,12 @@ class CardSshSigner( val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } // PW1 in mode 0x82 authorises INTERNAL AUTHENTICATE (the auth key slot), unlike PSO:CDS // (mode 0x81) used for commit signing. - val attempt = - runBlocking { - prompt.attempt(activeReader, cardMessage) { card -> - card.verifyUserPin(currentPin) - card.internalAuthenticate(input) - } + val attempt = runBlocking { + prompt.attempt(activeReader, cardMessage) { card -> + card.verifyUserPin(currentPin) + card.internalAuthenticate(input) } + } when (attempt) { is OpenPgpCardPrompt.Attempt.Success -> { runBlocking { prompt.dismissDialog() } @@ -230,10 +277,95 @@ class CardSshSigner( */ private fun encodeEcdsaSignature(raw: ByteArray, fieldBytes: Int): ByteArray { if (raw.size != fieldBytes * 2) { - throw SSHException("Unexpected ECDSA signature length ${raw.size} (expected ${fieldBytes * 2})") + throw SSHException( + "Unexpected ECDSA signature length ${raw.size} (expected ${fieldBytes * 2})" + ) } val r = BigInteger(1, raw.copyOfRange(0, fieldBytes)) val s = BigInteger(1, raw.copyOfRange(fieldBytes, raw.size)) return PlainBuffer().putMPInt(r).putMPInt(s).compactData } + + /** + * Builds the PKCS#1 v1.5 DigestInfo (ASN.1 DER `hash-algorithm identifier || digest`) that an + * OpenPGP card expects as the INTERNAL AUTHENTICATE input for an RSA key; the card supplies the + * surrounding PKCS#1 padding frame and does the modular exponentiation. The hash follows the SSH + * signature algorithm: `rsa-sha2-512` -> SHA-512, `rsa-sha2-256` -> SHA-256, `ssh-rsa` -> SHA-1. + */ + private fun pkcs1DigestInfo(sshAlgorithmName: String, dataToSign: ByteArray): ByteArray { + val (jcaDigest, digestInfoPrefix) = + when (sshAlgorithmName) { + "rsa-sha2-512" -> "SHA-512" to SHA512_DIGEST_INFO_PREFIX + "rsa-sha2-256" -> "SHA-256" to SHA256_DIGEST_INFO_PREFIX + "ssh-rsa" -> "SHA-1" to SHA1_DIGEST_INFO_PREFIX + else -> throw SSHException("Unsupported RSA SSH signature algorithm $sshAlgorithmName") + } + return digestInfoPrefix + digest(jcaDigest, dataToSign) + } + + private companion object { + // DigestInfo DER prefixes from RFC 8017 sec. 9.2 (EMSA-PKCS1-v1_5), prepended to the raw hash. + val SHA1_DIGEST_INFO_PREFIX = + byteArrayOf( + 0x30, + 0x21, + 0x30, + 0x09, + 0x06, + 0x05, + 0x2b, + 0x0e, + 0x03, + 0x02, + 0x1a, + 0x05, + 0x00, + 0x04, + 0x14, + ) + val SHA256_DIGEST_INFO_PREFIX = + byteArrayOf( + 0x30, + 0x31, + 0x30, + 0x0d, + 0x06, + 0x09, + 0x60, + 0x86.toByte(), + 0x48, + 0x01, + 0x65, + 0x03, + 0x04, + 0x02, + 0x01, + 0x05, + 0x00, + 0x04, + 0x20, + ) + val SHA512_DIGEST_INFO_PREFIX = + byteArrayOf( + 0x30, + 0x51, + 0x30, + 0x0d, + 0x06, + 0x09, + 0x60, + 0x86.toByte(), + 0x48, + 0x01, + 0x65, + 0x03, + 0x04, + 0x02, + 0x03, + 0x05, + 0x00, + 0x04, + 0x40, + ) + } } From 0675c973df26b81af015ec9883b32aaad9988ef3 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:02:00 +0900 Subject: [PATCH 05/14] Avoid leaking the OpenPGP card PIN into an immutable String verifyPin encoded the PIN with pin.concatToString().toByteArray(), which materializes the PIN as an immutable String. Strings cannot be zeroed and linger on the heap until garbage collection (and may be paged out or captured in a heap dump), defeating the finally-block that wipes the byte copy. Encode the CharArray straight to a wipeable ByteArray through a CharBuffer and zero the encoder's backing array, so no unwipeable copy of the PIN is created. --- .../util/crypto/OpenPgpNfcCard.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt index 0d5d282664..bee13529df 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt @@ -15,6 +15,7 @@ import com.github.michaelbull.result.get import com.github.michaelbull.result.getOr import com.github.michaelbull.result.runCatching import java.io.IOException +import java.nio.CharBuffer import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -68,7 +69,10 @@ class OpenPgpNfcCard( } private fun verifyPin(pin: CharArray, reference: Int) { - val pinBytes = pin.concatToString().toByteArray(Charsets.UTF_8) + // Encode the PIN straight from the CharArray to a wipeable ByteArray. Going through + // String.toByteArray() would leave the PIN in an immutable String that cannot be zeroed and + // lingers on the heap until garbage collection. + val pinBytes = charArrayToUtf8Bytes(pin) try { transceive( byteArrayOf(0x00, 0x20, 0x00, reference.toByte(), pinBytes.size.toByte()) + pinBytes @@ -84,6 +88,19 @@ class OpenPgpNfcCard( } } + /** + * Encodes [chars] as UTF-8 without ever materializing the secret in an immutable String. The + * encoder's backing array is wiped before returning, so the only surviving copy is the + * caller-owned result, which the caller can zero once it is done. + */ + private fun charArrayToUtf8Bytes(chars: CharArray): ByteArray { + val byteBuffer = Charsets.UTF_8.encode(CharBuffer.wrap(chars)) + val bytes = ByteArray(byteBuffer.remaining()) + byteBuffer.get(bytes) + if (byteBuffer.hasArray()) byteBuffer.array().fill(0) + return bytes + } + fun decipher(ciphertext: ByteArray): ByteArray { val payload = byteArrayOf(0x00) + ciphertext return transceiveData(0x2A, 0x80, 0x86, payload, expectedLength = ciphertext.size) From 847267eefa71d30d0696fad8a7f9476165ed8708 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:02:37 +0900 Subject: [PATCH 06/14] Restrict OpenPGP card-URL key fetch to HTTPS with a size cap The public key advertised by the card's URL data object was fetched with URL(url).openStream().readBytes(), accepting any scheme and reading the stream unbounded. The URL originates from the card and can be attacker-influenced, so a plain-HTTP URL is MITM-able, a file:// URL would read local files, and an unbounded read lets a hostile endpoint exhaust memory. Only honor HTTPS URLs and cap the download at 1 MiB (public keys are a few KiB at most). Non-HTTPS or oversized responses fall back to manual import. --- .../ui/pgp/PGPKeyImportActivity.kt | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt index 2a0c90e0a8..40deb4f762 100644 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt @@ -40,6 +40,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.IOException import java.net.URL import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred @@ -191,9 +192,7 @@ class PGPKeyImportActivity : AppCompatActivity() { } val downloadResult = runCatching { - withContext(dispatcherProvider.io()) { - URL(cardInfo.url).openStream().use { it.readBytes() } - } + withContext(dispatcherProvider.io()) { downloadKeyFromUrl(cardInfo.url) } } val bytes = downloadResult.get() if (bytes == null) { @@ -208,6 +207,32 @@ class PGPKeyImportActivity : AppCompatActivity() { importAllKeys(bytes, cardInfo.fingerprints) } + /** + * Fetches the public key advertised by the card's URL data object. The URL comes from the card + * (potentially attacker-controlled), so only HTTPS is honored -- a plain-HTTP URL is trivially + * MITM-able and schemes such as file:// would read local files -- and the download is capped so a + * hostile endpoint cannot exhaust memory. A non-HTTPS URL simply falls back to manual import. + */ + private fun downloadKeyFromUrl(url: String): ByteArray { + val parsed = URL(url) + if (!parsed.protocol.equals("https", ignoreCase = true)) { + throw IOException("Refusing to fetch OpenPGP key over non-HTTPS URL") + } + parsed.openStream().use { stream -> + val buffer = ByteArray(MAX_KEY_DOWNLOAD_BYTES) + var total = 0 + while (total < buffer.size) { + val read = stream.read(buffer, total, buffer.size - total) + if (read == -1) break + total += read + } + if (total == buffer.size && stream.read() != -1) { + throw IOException("OpenPGP key at URL exceeds $MAX_KEY_DOWNLOAD_BYTES bytes") + } + return buffer.copyOf(total) + } + } + private fun showNfcSetupDialog(cardInfo: OpenPgpCardInfo) { MaterialAlertDialogBuilder(this) .setTitle(R.string.openpgp_nfc_setup_detected_title) @@ -450,5 +475,8 @@ class PGPKeyImportActivity : AppCompatActivity() { companion object { const val EXTRA_IMPORT_FROM_NFC = "app.passwordstore.extra.IMPORT_FROM_NFC" + + // OpenPGP public keys are a few KiB at most; cap the card-URL download well above that. + private const val MAX_KEY_DOWNLOAD_BYTES = 1 * 1024 * 1024 } } From 044df7a131ed6a9b66cf64bc04997fb47ec9c663 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:02:53 +0900 Subject: [PATCH 07/14] Reject smartcard decryption of non-integrity-protected messages The card decryptor only verified the integrity packet when one was present and otherwise returned the plaintext silently. A message with no MDC/SEIPD (legacy SED) is unauthenticated and malleable, and the app's main PGPainless path already refuses such messages by default. Reject messages without integrity protection before decrypting, and document that the in-memory output buffer is discarded by the caller when verify() fails after the streamed plaintext has been written. --- .../util/crypto/OpenPgpSmartcardDecryptor.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt index 5c360eac29..6d7fad61e5 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt @@ -78,12 +78,21 @@ class OpenPgpSmartcardDecryptor @Inject constructor() { firstFailure, ) + // Reject messages that carry no integrity protection (legacy SED packets) outright, matching + // the default policy of the app's main PGPainless decryption path. Without an MDC/SEIPD the + // plaintext is unauthenticated and malleable. + if (!encryptedData.isIntegrityProtected) { + throw PGPException("Refusing to decrypt OpenPGP message without integrity protection") + } + + // Streaming decryption necessarily writes the plaintext before verify() can run; [outputStream] + // is an in-memory buffer the caller must (and does) discard when this method throws. encryptedData.getDataStream(JceSessionKeyDataDecryptorFactoryBuilder().build(sessionKey)).use { cleartext -> pipeLiteralData(cleartext, outputStream) } - if (encryptedData.isIntegrityProtected && !encryptedData.verify()) { + if (!encryptedData.verify()) { throw PGPException("OpenPGP message integrity check failed") } } From c4210c5435e3315cbf8b1b0d9e7592439e08c0d1 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:03:15 +0900 Subject: [PATCH 08/14] Cap the number of card decipher attempts per message Every public-key encrypted session key packet that matches (including every wildcard-recipient packet, which always matches) drives a decipher operation on the card. A crafted message could enqueue arbitrarily many such packets and push a large amount of work onto the card. Bound the attempts to 16. --- .../util/crypto/OpenPgpSmartcardDecryptor.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt index 6d7fad61e5..471c4831b9 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt @@ -57,7 +57,10 @@ class OpenPgpSmartcardDecryptor @Inject constructor() { val anonymousMatches = encryptedDataPackets.filter { it.keyIdentifier.isWildcard || it.keyIdentifier.keyId == 0L } - val candidates = (explicitMatches + anonymousMatches).distinct() + // Each candidate triggers a card decipher operation. Wildcard-recipient packets (key id 0) all + // match, so a crafted message could enqueue arbitrarily many; cap the attempts to bound the + // work a hostile message can push onto the card. + val candidates = (explicitMatches + anonymousMatches).distinct().take(MAX_DECRYPT_CANDIDATES) if (candidates.isEmpty()) throw PGPException("Message is not encrypted to this OpenPGP card") val decryptorFactory = OpenPgpCardDecryptorFactory(card) @@ -180,4 +183,9 @@ class OpenPgpSmartcardDecryptor @Inject constructor() { sessionKey: PGPSessionKey, ): PGPDataDecryptor = contentDecryptorFactory.createDataDecryptor(seipd, sessionKey) } + + private companion object { + // Upper bound on the number of PKESK packets we will try to decrypt on the card per message. + private const val MAX_DECRYPT_CANDIDATES = 16 + } } From 345861d2b12b34535badab2c3bdd6fbed35d300d Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:03:24 +0900 Subject: [PATCH 09/14] Remove unused extended-APDU transceive helper transceiveExtended was never called (it was annotated @Suppress("unused")); the short-APDU and command-chaining paths cover every card exchange. Drop the dead code. --- .../passwordstore/util/crypto/OpenPgpNfcCard.kt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt index bee13529df..ced152e082 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt @@ -213,22 +213,6 @@ class OpenPgpNfcCard( return response } - @Suppress("unused") - private fun transceiveExtended(ins: Int, p1: Int, p2: Int, payload: ByteArray): ByteArray { - val lc = payload.size - val command = - byteArrayOf( - 0x00, - ins.toByte(), - p1.toByte(), - p2.toByte(), - 0x00, - ((lc ushr 8) and 0xff).toByte(), - (lc and 0xff).toByte(), - ) + payload + byteArrayOf(0x00, 0x00) - return transceive(command) - } - companion object { private const val MAX_APDU_NC = 254 From 07b5d50865214e2fdab6ee9eec1bd6876a77703a Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:04:39 +0900 Subject: [PATCH 10/14] Drop the unreachable smartcard branch in askPassphrase BasePGPActivity.decrypt() routes any smartcard-backed or stub-decryption-key identifiers straight to decryptWithPassphrase (which handles PIN entry inline) and only falls through to askPassphrase when needsSmartcardPin is false. So the needsSmartcardPin branches inside askPassphrase -- the card-PIN PasswordDialog variant and its dedicated caching block -- can never run. Remove them, which in turn makes the title/hint/error/cache-label parameters added to PasswordDialog unused, so revert PasswordDialog to its single hardcoded passphrase configuration. --- .../ui/crypto/BasePGPActivity.kt | 41 ++----------------- .../ui/dialogs/PasswordDialog.kt | 19 +-------- 2 files changed, 6 insertions(+), 54 deletions(-) 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 1f21f57536..a922e1ab44 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -486,23 +486,11 @@ open class BasePGPActivity : AppCompatActivity() { private suspend fun askPassphrase(isError: Boolean, identifiers: List) { if (++retries > MAX_RETRIES) finish() - val needsSmartcardPin = needsSmartcardPin(identifiers) val dialog = - if (needsSmartcardPin) { - PasswordDialog.newInstance( - getIdentityLabelForIdentifiers(identifiers), - cacheOptionVisible = true, - titleRes = R.string.openpgp_card_pin_title, - hintRes = R.string.openpgp_card_pin_hint, - errorRes = R.string.openpgp_card_wrong_pin, - cacheLabelRes = R.string.cache_openpgp_card_pin_until_screen_off, - ) - } else { - PasswordDialog.newInstance( - getIdentityLabelForIdentifiers(identifiers), - cacheOptionVisible = true, - ) - } + PasswordDialog.newInstance( + getIdentityLabelForIdentifiers(identifiers), + cacheOptionVisible = true, + ) if (isError) dialog.setError() dialog.show(supportFragmentManager, "PASSWORD_DIALOG") dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> @@ -514,27 +502,6 @@ open class BasePGPActivity : AppCompatActivity() { var cacheEnabled = bundle.getBoolean(PasswordDialog.PASSWORD_CACHE_KEY) lifecycleScope.launch(dispatcherProvider.main()) { decryptWithPassphrase(mapOf("" to passphrase), identifiers) { id -> // onSuccess - if (needsSmartcardPin) { - runCatching { - val isHardwareBacked = AESEncryption.isHardwareBacked() - val encryptedPin = AESEncryption.encrypt(passphrase) - if (isHardwareBacked && cacheEnabled && encryptedPin != null) { - cachedPassphrases.put(id, encryptedPin) - } else { - cachedPassphrases[id]?.wipe() - cachedPassphrases.remove(id) - } - settings.edit { - putBoolean( - PreferenceKeys.CACHE_PASSPHRASE, - isHardwareBacked && cacheEnabled && encryptedPin != null, - ) - } - } - .onErr { e -> logcat { e.asLog() } } - passphrase.wipe() - return@decryptWithPassphrase - } var fastUnlockingSetupCompletion: CompletableDeferred? = null runCatching { // update temporary passphrase cache diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt index fca66a6763..0b9e9550c1 100644 --- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt +++ b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordDialog.kt @@ -11,7 +11,6 @@ import android.os.Bundle import android.view.KeyEvent import android.view.View import android.view.WindowManager -import androidx.annotation.StringRes import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment import androidx.fragment.app.setFragmentResult @@ -37,8 +36,7 @@ class PasswordDialog : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val builder = MaterialAlertDialogBuilder(requireContext()) builder.setView(binding.root) - builder.setTitle(requireArguments().getInt(TITLE_RES_EXTRA)) - binding.passwordField.setHint(requireArguments().getInt(HINT_RES_EXTRA)) + builder.setTitle(R.string.ssh_keygen_passphrase) userIds = requireArguments().getString(USER_IDS_EXTRA) userIds?.let { @@ -51,7 +49,6 @@ class PasswordDialog : DialogFragment() { cacheEnabledChecked = requireContext().sharedPrefs.getBoolean(PreferenceKeys.CACHE_PASSPHRASE, false) binding.cacheEnabled.apply { - setText(requireArguments().getInt(CACHE_LABEL_RES_EXTRA)) isChecked = cacheEnabledChecked setOnCheckedChangeListener { _, isChecked -> cacheEnabledChecked = isChecked } } @@ -60,7 +57,7 @@ class PasswordDialog : DialogFragment() { dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) dialog.setOnShowListener { if (isError) { - binding.passwordField.error = getString(requireArguments().getInt(ERROR_RES_EXTRA)) + binding.passwordField.error = getString(R.string.pgp_wrong_passphrase_input) } binding.passwordEditText.apply { doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } @@ -108,10 +105,6 @@ class PasswordDialog : DialogFragment() { private const val USER_IDS_EXTRA = "user_ids" private const val CACHE_OPTION_EXTRA = "cache_option" - private const val TITLE_RES_EXTRA = "title_res" - private const val HINT_RES_EXTRA = "hint_res" - private const val ERROR_RES_EXTRA = "error_res" - private const val CACHE_LABEL_RES_EXTRA = "cache_label_res" private const val ON_CANCEL_FINISH = "finish_option" const val PASSWORD_RESULT_KEY = "password_result" @@ -122,10 +115,6 @@ class PasswordDialog : DialogFragment() { userIds: String? = null, cacheOptionVisible: Boolean = false, onCancelFinish: Boolean = true, - @StringRes titleRes: Int = R.string.ssh_keygen_passphrase, - @StringRes hintRes: Int = R.string.ssh_keygen_passphrase, - @StringRes errorRes: Int = R.string.pgp_wrong_passphrase_input, - @StringRes cacheLabelRes: Int = R.string.cache_password_until_screen_off, ): PasswordDialog { val extras = Bundle().also { @@ -133,10 +122,6 @@ class PasswordDialog : DialogFragment() { putString(USER_IDS_EXTRA, userIds) putBoolean(CACHE_OPTION_EXTRA, cacheOptionVisible) putBoolean(ON_CANCEL_FINISH, onCancelFinish) - putInt(TITLE_RES_EXTRA, titleRes) - putInt(HINT_RES_EXTRA, hintRes) - putInt(ERROR_RES_EXTRA, errorRes) - putInt(CACHE_LABEL_RES_EXTRA, cacheLabelRes) } } val fragment = PasswordDialog() From 17cee84882c1041e91b5c29361feff0389daffd2 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:11:48 +0900 Subject: [PATCH 11/14] Unify the smartcard PIN-and-retry loop into OpenPgpCardPrompt.runWithPin Decryption, commit signing and SSH authentication each carried a near-identical ~100-line state machine: create a reader, seed the PIN from cache, then loop prompting for the PIN, running the card operation, and on a rejected PIN wipe it, consult the card's remaining-attempts counter and either re-prompt or report the card blocked, with transient transport errors re-presenting the card. Three copies of this security-sensitive logic meant a fix to one could miss the others. Extract it into a single suspend helper, OpenPgpCardPrompt.runWithPin, returning a CardOutcome (Success/Cancelled/Blocked/Failed) with any open card handed back. Each call site now supplies only the PIN slot (PinMode), the prompt strings, the card operation, and how to map each outcome to its own reader-release and user-facing behaviour (which genuinely differ: decrypt shows a dialog, signing rethrows to its reporting catch, SSH throws SSHException and blocks on card removal). The PIN lifecycle -- seeding, wiping, and cache-only-on-success -- is owned entirely by the helper. --- .../ui/crypto/DecryptActivity.kt | 194 ++++++------------ .../util/crypto/OpenPgpCardPrompt.kt | 135 ++++++++++++ .../util/git/OpenPgpCommitSigner.kt | 147 ++++--------- .../util/git/sshj/PgpCardSshAuthMethod.kt | 130 ++++-------- 4 files changed, 274 insertions(+), 332 deletions(-) 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 348e417b2f..bf2242fffa 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -37,10 +37,8 @@ import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.extensions.wipe import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.shortcuts.ShortcutHandler -import com.github.michaelbull.result.get import com.github.michaelbull.result.getError import com.github.michaelbull.result.getOrThrow -import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayOutputStream @@ -174,152 +172,78 @@ class DecryptActivity : BasePGPActivity() { ) { val messageBytes = withContext(dispatcherProvider.io()) { File(fullPath).readBytes() } val outputStream = ByteArrayOutputStream() - // Same modern smartcard UX as commit signing: one persistent reader, a reused present/hold-card - // dialog, the card op run on the card's own thread, inline PIN entry with retries (so reader - // mode stays on across wrong PINs and never triggers the NDEF-URL popup), and reader mode - // released only once the card is physically removed. + // Modern smartcard UX: one persistent reader, a reused present/hold-card dialog, the card + // operation run on the card's own thread, inline PIN entry with retries (so reader mode stays + // on across wrong PINs and never triggers the NDEF-URL popup), and reader mode released only + // once the card is physically removed. The shared loop lives in OpenPgpCardPrompt.runWithPin. val prompt = OpenPgpCardPrompt(this, R.string.openpgp_nfc_decrypt_title, dispatcherProvider) val reader = prompt.createReader() if (reader == null) { showSmartcardError(getString(R.string.openpgp_nfc_unavailable)) return } - // Namespaced so the decryption PIN cache is kept separate from the signing PIN cache. - val cacheKey = "decrypt:${identifiers.firstOrNull()}" - val presentMessage = getString(R.string.openpgp_nfc_tap_card) - // Seed the PIN from a caller-provided (e.g. biometric-unlocked) value, then the screen-off - // cache; otherwise prompt inline below. - var pin: CharArray? = - passphrases.values.firstOrNull()?.takeIf { it.isNotEmpty() } ?: prompt.readCachedPin(cacheKey) - var pinFromCache = pin != null - var cachePin = false - var pinErrorMessage: String? = null - var cardMessage = presentMessage var readerHandedOff = false try { - while (true) { - if (pin == null) { - prompt.dismissDialog() - val entry = - prompt.askSecret( - titleRes = R.string.openpgp_card_pin_title, - hintRes = R.string.openpgp_card_pin_hint, - showCacheOption = true, - errorMessage = pinErrorMessage, - minLength = OpenPgpCardPrompt.MIN_PIN_LENGTH, - identityLabel = getIdentityLabelForIdentifiers(identifiers), + val outcome = + prompt.runWithPin( + reader = reader, + // Namespaced so the decryption PIN cache is kept separate from the signing PIN cache. + cacheKey = "decrypt:${identifiers.firstOrNull()}", + pinTitleRes = R.string.openpgp_card_pin_title, + pinHintRes = R.string.openpgp_card_pin_hint, + identityLabel = getIdentityLabelForIdentifiers(identifiers), + pinMode = OpenPgpCardPrompt.PinMode.USER, + presentMessage = getString(R.string.openpgp_nfc_tap_card), + commFailedMessage = getString(R.string.openpgp_nfc_card_comm_failed), + // Seed the PIN from a caller-provided (e.g. biometric-unlocked) value. + seedPin = passphrases.values.firstOrNull()?.takeIf { it.isNotEmpty() }, + ) { card, currentPin -> + val results = + repository.decryptWithSmartcard( + currentPin, + identifiers, + messageBytes.inputStream(), + outputStream, + card, ) - ?: run { - finish() - return - } - pin = entry.secret - cachePin = entry.cache - pinFromCache = false - pinErrorMessage = null - cardMessage = presentMessage + // Surface a decryption failure (wrong PIN, transceive error, ...) as a thrown exception + // so the prompt can classify it. + results.last().second.getError()?.let { throw it } + results } - val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } - val attempt = - prompt.attempt(reader, cardMessage) { card -> - val results = - repository.decryptWithSmartcard( - currentPin, - identifiers, - messageBytes.inputStream(), - outputStream, - card, - ) - // Surface a decryption failure (wrong PIN, transceive error, …) as a thrown exception - // so the prompt can classify it. - results.last().second.getError()?.let { throw it } - results - } - when (attempt) { - is OpenPgpCardPrompt.Attempt.Success -> { - // Cache the PIN only now that decryption actually succeeded. - if (!pinFromCache) prompt.storeCachedPin(cacheKey, currentPin, cachePin) - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, reader) - prompt.dismissDialog() - val lastResult = attempt.value.last() - val decryptedEntryBytes = lastResult.second.getOrThrow().toByteArray() - lastResult.second.getOrThrow().wipe() - val decryptedEntryChars = decryptedEntryBytes.toCharArray() - decryptedEntryBytes.wipe() - val entry = passwordEntryFactory.create(decryptedEntryChars) - encryptedEntryChars = AESEncryption.encrypt(decryptedEntryChars) - decryptedEntryChars.wipe() - entry.clearExtraChars() - createPasswordUI(entry) - onSuccess(lastResult.first) - return - } - OpenPgpCardPrompt.Attempt.Cancelled -> { - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(null, reader) - finish() - return - } - is OpenPgpCardPrompt.Attempt.Error -> { - val error = attempt.error - if (OpenPgpCardPrompt.isSmartcardPinFailure(error)) { - // A rejected PIN must never be kept in the cache; re-prompt inline with the card's - // own - // remaining-attempts count, keeping reader mode on the whole time. - prompt.clearCachedPin(cacheKey) - pin?.wipe() - pin = null - pinFromCache = false - // Prefer the count the card volunteered in 63 Cx; otherwise ask it directly with a - // non-destructive status check, so we learn the real state even after a status word - // that omits it (69 82, a 6A 80 length/format rejection, …) — in particular whether - // the card is now blocked. - val remaining = - OpenPgpCardPrompt.smartcardPinRetriesRemaining(error) - ?: withContext(dispatcherProvider.io()) { - runCatching { attempt.card?.readUserPinRetries() }.get() - } - if (remaining == 0) { - // Blocked: report in a dialog, hold reader mode until the card is lifted, and stop - // asking for a PIN. - prompt.dismissDialog() - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, reader) - showSmartcardError(getString(R.string.openpgp_card_pin_blocked)) - return - } - runCatching { attempt.card?.close() } - pinErrorMessage = - if (remaining != null) { - resources.getQuantityString( - R.plurals.openpgp_card_wrong_pin_remaining, - remaining, - remaining, - ) - } else { - getString(R.string.openpgp_card_wrong_pin) - } - continue - } - // A transient NFC/card hiccup (tag lost, transient 6A 80, …): ask the user to present - // the card again. - if (OpenPgpCardPrompt.isRetryableCardError(error)) { - runCatching { attempt.card?.close() } - cardMessage = getString(R.string.openpgp_nfc_card_comm_failed) - continue - } - // Any other terminal error: hold reader mode until the card is lifted, then report it. - prompt.dismissDialog() - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, reader) - showSmartcardError(friendlySmartcardError(error)) - return - } + when (outcome) { + is OpenPgpCardPrompt.CardOutcome.Success -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, reader) + val lastResult = outcome.value.last() + val decryptedEntryBytes = lastResult.second.getOrThrow().toByteArray() + lastResult.second.getOrThrow().wipe() + val decryptedEntryChars = decryptedEntryBytes.toCharArray() + decryptedEntryBytes.wipe() + val entry = passwordEntryFactory.create(decryptedEntryChars) + encryptedEntryChars = AESEncryption.encrypt(decryptedEntryChars) + decryptedEntryChars.wipe() + entry.clearExtraChars() + createPasswordUI(entry) + onSuccess(lastResult.first) + } + OpenPgpCardPrompt.CardOutcome.Cancelled -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(null, reader) + finish() + } + is OpenPgpCardPrompt.CardOutcome.Blocked -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, reader) + showSmartcardError(getString(R.string.openpgp_card_pin_blocked)) + } + is OpenPgpCardPrompt.CardOutcome.Failed -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, reader) + showSmartcardError(friendlySmartcardError(outcome.error)) } } } finally { - pin?.wipe() prompt.dismissDialog() if (!readerHandedOff) prompt.releaseReaderWhenCardRemoved(null, reader) } diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt index 1496db774a..126bb4a43c 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt @@ -22,6 +22,7 @@ import app.passwordstore.util.extensions.hideKeyboard import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.extensions.wipe import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.get import com.github.michaelbull.result.onErr import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -127,6 +128,140 @@ class OpenPgpCardPrompt( } } + /** Which PW1 access slot a wrong-PIN retry counter should be read from. */ + enum class PinMode { + /** PW1 mode 0x82 (decryption / INTERNAL AUTHENTICATE). */ + USER, + /** PW1 mode 0x81 (PSO:CDS commit signing). */ + SIGNATURE, + } + + /** Terminal outcome of [runWithPin]. Any [card] handed back is left open for the caller. */ + sealed interface CardOutcome { + class Success(val value: T, val card: OpenPgpNfcCard) : CardOutcome + + data object Cancelled : CardOutcome + + class Blocked(val card: OpenPgpNfcCard?) : CardOutcome + + class Failed(val error: Throwable, val card: OpenPgpNfcCard?) : CardOutcome + } + + /** + * Runs a full smartcard PIN-and-retry session against the already-open [reader], shared by + * decryption, commit signing and SSH authentication. + * + * The PIN is seeded from [seedPin] (e.g. a biometric-unlocked value) or the screen-off cache + * under [cacheKey], otherwise the user is prompted (with the OpenPGP-mandated [MIN_PIN_LENGTH] + * minimum). [block] verifies the PIN and performs the card operation via [attempt]. On a rejected + * PIN the PIN is wiped and dropped from the cache, and the card's own remaining-attempts counter + * is consulted -- [pinMode] selects the slot -- to either re-prompt inline or report the card as + * [CardOutcome.Blocked]. A transient transport error re-presents the card with + * [commFailedMessage]. The PIN is cached only once [block] fully succeeds, so a rejected PIN is + * never persisted. + * + * The present-card dialog is dismissed on success and the PIN is always wiped before returning. + * Reader-mode release and turning each [CardOutcome] into a user-facing action stay with the + * caller, since those differ per operation. + */ + suspend fun runWithPin( + reader: CardReader, + cacheKey: String, + @StringRes pinTitleRes: Int, + @StringRes pinHintRes: Int, + identityLabel: String?, + pinMode: PinMode, + presentMessage: String, + commFailedMessage: String, + seedPin: CharArray? = null, + block: (OpenPgpNfcCard, CharArray) -> T, + ): CardOutcome { + var pin: CharArray? = seedPin?.takeIf { it.isNotEmpty() } ?: readCachedPin(cacheKey) + var pinFromCache = pin != null + var cachePin = false + var pinErrorMessage: String? = null + var cardMessage = presentMessage + try { + while (true) { + if (pin == null) { + // Take the card dialog down while the PIN dialog is up so they don't stack. + dismissDialog() + val entry = + askSecret( + titleRes = pinTitleRes, + hintRes = pinHintRes, + showCacheOption = true, + errorMessage = pinErrorMessage, + minLength = MIN_PIN_LENGTH, + identityLabel = identityLabel, + ) ?: return CardOutcome.Cancelled + pin = entry.secret + cachePin = entry.cache + pinFromCache = false + pinErrorMessage = null + cardMessage = presentMessage + } + val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } + when (val attempt = attempt(reader, cardMessage) { card -> block(card, currentPin) }) { + is Attempt.Success -> { + dismissDialog() + // Cache the PIN only now that the whole operation has succeeded. + if (!pinFromCache) storeCachedPin(cacheKey, currentPin, cachePin) + return CardOutcome.Success(attempt.value, attempt.card) + } + Attempt.Cancelled -> return CardOutcome.Cancelled + is Attempt.Error -> { + val e = attempt.error + if (isSmartcardPinFailure(e)) { + // A rejected PIN must never be kept in the cache. + clearCachedPin(cacheKey) + pin?.wipe() + pin = null + pinFromCache = false + // Trust the card's own retry counter; if the status word omitted it, ask the card + // directly with a non-destructive status check so we learn whether it is now blocked. + val remaining = + smartcardPinRetriesRemaining(e) + ?: withContext(dispatcherProvider.io()) { + runCatching { readPinRetries(attempt.card, pinMode) }.get() + } + if (remaining == 0) return CardOutcome.Blocked(attempt.card) + runCatching { attempt.card?.close() } + pinErrorMessage = wrongPinMessage(remaining) + continue + } + // Any transient NFC/card hiccup never reaches the PIN counter: re-present the card. + if (isRetryableCardError(e)) { + runCatching { attempt.card?.close() } + cardMessage = commFailedMessage + continue + } + return CardOutcome.Failed(e, attempt.card) + } + } + } + } finally { + pin?.wipe() + } + } + + private fun readPinRetries(card: OpenPgpNfcCard?, pinMode: PinMode): Int? = + when (pinMode) { + PinMode.USER -> card?.readUserPinRetries() + PinMode.SIGNATURE -> card?.readSignaturePinRetries() + } + + private fun wrongPinMessage(remaining: Int?): String = + if (remaining != null) { + activity.resources.getQuantityString( + R.plurals.openpgp_card_wrong_pin_remaining, + remaining, + remaining, + ) + } else { + activity.getString(R.string.openpgp_card_wrong_pin) + } + /** Creates the card dialog, or just re-labels it if it is already showing. Main thread. */ private fun showOrUpdateDialog(message: String) { // Collapse the soft keyboard left over from PIN entry so it doesn't cover the card prompt or diff --git a/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt index 901fd66027..5b51980496 100644 --- a/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt +++ b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt @@ -172,11 +172,11 @@ class OpenPgpCommitSigner( SigningChoice.SIGN -> {} } // The shared prompt keeps reader mode enabled for the whole operation, shows the reused - // present/hold-card dialog, and runs the card exchange on the card's own thread. Any smartcard - // failure is reported to the user in a dialog (never a snackbar) by the outer catch below. + // present/hold-card dialog, and runs the card exchange on the card's own thread via + // OpenPgpCardPrompt.runWithPin. Any smartcard failure is reported to the user in a dialog + // (never a snackbar) by the outer catch below. val prompt = OpenPgpCardPrompt(activity, R.string.git_signing_card_title, dispatcherProvider) var reader: CardReader? = null - var pin: CharArray? = null // Reader mode is released via the removal watcher (which disables it once the card leaves) on // every terminal outcome — success or failure — so the finally only closes it if we exit // unexpectedly. @@ -190,42 +190,21 @@ class OpenPgpCommitSigner( runBlocking { prompt.createReader() } ?: throw IOException(activity.getString(R.string.openpgp_nfc_unavailable)) reader = activeReader - val presentMessage = activity.getString(R.string.git_signing_tap_card) - var pinFromCache = false - var cachePin = false - var pinErrorMessage: String? = null - var cardMessage = presentMessage - prompt.readCachedPin(cacheKey)?.let { - pin = it - pinFromCache = true - } - while (true) { - if (pin == null) { - // Take the card dialog down while the PIN dialog is up so they don't stack. - runBlocking { prompt.dismissDialog() } - val entry = - runBlocking { - prompt.askSecret( - titleRes = R.string.git_signing_card_pin_title, - hintRes = R.string.openpgp_card_pin_hint, - showCacheOption = true, - errorMessage = pinErrorMessage, - minLength = OpenPgpCardPrompt.MIN_PIN_LENGTH, - identityLabel = identityLabel(key), - ) - } ?: throw CanceledException(activity.getString(R.string.dialog_cancel)) - pin = entry.secret - cachePin = entry.cache - pinFromCache = false - pinErrorMessage = null - cardMessage = presentMessage - } - val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } - // The whole card exchange (applet select → verify → sign) runs on a single thread with no - // hop, so a genuine wrong PIN reliably comes back as a card status word (e.g. 63 Cx) rather - // than a transceive error caused by racing the NFC presence check. - val attempt = runBlocking { - prompt.attempt(activeReader, cardMessage) { card -> + val outcome = + runBlocking { + prompt.runWithPin( + reader = activeReader, + cacheKey = cacheKey, + pinTitleRes = R.string.git_signing_card_pin_title, + pinHintRes = R.string.openpgp_card_pin_hint, + identityLabel = identityLabel(key), + pinMode = OpenPgpCardPrompt.PinMode.SIGNATURE, + presentMessage = activity.getString(R.string.git_signing_tap_card), + commFailedMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed), + ) { card, currentPin -> + // The whole card exchange (applet select -> verify -> sign) runs on a single thread + // with no hop, so a genuine wrong PIN reliably comes back as a card status word (e.g. + // 63 Cx) rather than a transceive error caused by racing the NFC presence check. card.verifySignaturePin(currentPin) val privateKey = PGPPrivateKey(publicKey.keyID, publicKey.publicKeyPacket, null) buildDetachedSignature( @@ -236,71 +215,30 @@ class OpenPgpCommitSigner( ) } } - when (attempt) { - is OpenPgpCardPrompt.Attempt.Success -> { - runBlocking { prompt.dismissDialog() } - // Cache the PIN only now that the whole signing operation has succeeded, so a rejected - // PIN is never persisted. - if (!pinFromCache) prompt.storeCachedPin(cacheKey, currentPin, cachePin) - // Keep reader mode on until the card is physically lifted so the platform never - // dispatches its NDEF URL while it is still present (e.g. while the success dialog is - // up). - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) - return attempt.value - } - OpenPgpCardPrompt.Attempt.Cancelled -> { - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(null, activeReader) - throw CanceledException(activity.getString(R.string.dialog_cancel)) - } - is OpenPgpCardPrompt.Attempt.Error -> { - val e = attempt.error - if (OpenPgpCardPrompt.isSmartcardPinFailure(e)) { - // A rejected PIN must never be kept in the cache. - prompt.clearCachedPin(cacheKey) - pin?.wipe() - pin = null - pinFromCache = false - // Trust the card's own retry counter rather than tracking attempts in the app; if it - // didn't put the count in 63 Cx, ask it directly with a non-destructive status check - // so we learn the real state (in particular whether the card is now blocked) even - // after a status word that omits it (69 82, a 6A 80 length/format rejection, …). - val remaining = - OpenPgpCardPrompt.smartcardPinRetriesRemaining(e) - ?: runCatching { attempt.card?.readSignaturePinRetries() }.get() - if (remaining == 0) { - // Blocked: hold reader mode until the card is lifted, then abort — the outer catch - // reports it in a dialog. Stop asking for a PIN. - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) - throw PGPException(activity.getString(R.string.openpgp_card_pin_blocked)) - } - runCatching { attempt.card?.close() } - pinErrorMessage = - if (remaining != null) { - activity.resources.getQuantityString( - R.plurals.openpgp_card_wrong_pin_remaining, - remaining, - remaining, - ) - } else { - activity.getString(R.string.openpgp_card_wrong_pin) - } - continue - } - // Any other NFC/card hiccup (tag lost mid-exchange, transient 6A 80, …) never reaches - // the card's PIN counter: let the user present the card again. - if (OpenPgpCardPrompt.isRetryableCardError(e)) { - runCatching { attempt.card?.close() } - cardMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed) - continue - } - // Any other terminal error: hold reader mode until the card is lifted, then propagate. - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) - throw e - } + when (outcome) { + is OpenPgpCardPrompt.CardOutcome.Success -> { + // Keep reader mode on until the card is physically lifted, so the platform never + // dispatches its NDEF URL while it is still present (e.g. while the success dialog is + // up). + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, activeReader) + return outcome.value + } + OpenPgpCardPrompt.CardOutcome.Cancelled -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(null, activeReader) + throw CanceledException(activity.getString(R.string.dialog_cancel)) + } + is OpenPgpCardPrompt.CardOutcome.Blocked -> { + // Hold reader mode until the card is lifted, then abort - the outer catch reports it. + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, activeReader) + throw PGPException(activity.getString(R.string.openpgp_card_pin_blocked)) + } + is OpenPgpCardPrompt.CardOutcome.Failed -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, activeReader) + throw outcome.error } } } catch (e: Throwable) { @@ -316,7 +254,6 @@ class OpenPgpCommitSigner( } throw SmartcardOperationHandledException(e.message) } finally { - pin?.wipe() runBlocking { prompt.dismissDialog() } if (!readerHandedOff && reader != null) prompt.releaseReaderWhenCardRemoved(null, reader) } diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt index 1a0e412bc3..1a257dd38c 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt @@ -10,9 +10,6 @@ import app.passwordstore.crypto.PGPIdentifier.KeyId import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.crypto.CardReader import app.passwordstore.util.crypto.OpenPgpCardPrompt -import app.passwordstore.util.extensions.wipe -import com.github.michaelbull.result.get -import com.github.michaelbull.result.runCatching import com.hierynomus.sshj.key.KeyAlgorithm import java.io.IOException import java.math.BigInteger @@ -161,108 +158,57 @@ class CardSshSigner( private fun driveCard(input: ByteArray): ByteArray { val prompt = OpenPgpCardPrompt(activity, R.string.openpgp_nfc_ssh_title, dispatcherProvider) var reader: CardReader? = null - var pin: CharArray? = null var readerHandedOff = false try { - val cacheKey = "ssh:${primaryKeyId.id}" val activeReader = runBlocking { prompt.createReader() } ?: throw IOException(activity.getString(R.string.openpgp_nfc_unavailable)) reader = activeReader - val presentMessage = activity.getString(R.string.openpgp_nfc_tap_card) - var pinFromCache = false - var cachePin = false - var pinErrorMessage: String? = null - var cardMessage = presentMessage - prompt.readCachedPin(cacheKey)?.let { - pin = it - pinFromCache = true - } - while (true) { - if (pin == null) { - runBlocking { prompt.dismissDialog() } - val entry = - runBlocking { - prompt.askSecret( - titleRes = R.string.openpgp_card_pin_title, - hintRes = R.string.openpgp_card_pin_hint, - showCacheOption = true, - errorMessage = pinErrorMessage, - minLength = OpenPgpCardPrompt.MIN_PIN_LENGTH, - ) - } ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - pin = entry.secret - cachePin = entry.cache - pinFromCache = false - pinErrorMessage = null - cardMessage = presentMessage - } - val currentPin = requireNotNull(pin) { "PIN must be set before contacting the card" } - // PW1 in mode 0x82 authorises INTERNAL AUTHENTICATE (the auth key slot), unlike PSO:CDS - // (mode 0x81) used for commit signing. - val attempt = runBlocking { - prompt.attempt(activeReader, cardMessage) { card -> + val outcome = + runBlocking { + prompt.runWithPin( + reader = activeReader, + cacheKey = "ssh:${primaryKeyId.id}", + pinTitleRes = R.string.openpgp_card_pin_title, + pinHintRes = R.string.openpgp_card_pin_hint, + identityLabel = null, + // PW1 in mode 0x82 authorises INTERNAL AUTHENTICATE (the auth key slot), unlike PSO:CDS + // (mode 0x81) used for commit signing. + pinMode = OpenPgpCardPrompt.PinMode.USER, + presentMessage = activity.getString(R.string.openpgp_nfc_tap_card), + commFailedMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed), + ) { card, currentPin -> card.verifyUserPin(currentPin) card.internalAuthenticate(input) } } - when (attempt) { - is OpenPgpCardPrompt.Attempt.Success -> { - runBlocking { prompt.dismissDialog() } - if (!pinFromCache) prompt.storeCachedPin(cacheKey, currentPin, cachePin) - readerHandedOff = true - val signature = attempt.value - // Block until the card is lifted so reader mode stays up (keeping the activity - // foreground) and the platform never dispatches the card's NDEF URL once the git push - // proceeds and this activity moves on. - runBlocking { prompt.awaitCardRemoval(attempt.card, activeReader) } - return signature - } - OpenPgpCardPrompt.Attempt.Cancelled -> { - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(null, activeReader) - throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - } - is OpenPgpCardPrompt.Attempt.Error -> { - val e = attempt.error - if (OpenPgpCardPrompt.isSmartcardPinFailure(e)) { - prompt.clearCachedPin(cacheKey) - pin?.wipe() - pin = null - pinFromCache = false - val remaining = - OpenPgpCardPrompt.smartcardPinRetriesRemaining(e) - ?: runCatching { attempt.card?.readUserPinRetries() }.get() - if (remaining == 0) { - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) - throw SSHException(activity.getString(R.string.openpgp_card_pin_blocked)) - } - runCatching { attempt.card?.close() } - pinErrorMessage = - if (remaining != null) { - activity.resources.getQuantityString( - R.plurals.openpgp_card_wrong_pin_remaining, - remaining, - remaining, - ) - } else { - activity.getString(R.string.openpgp_card_wrong_pin) - } - } else if (OpenPgpCardPrompt.isRetryableCardError(e)) { - // Keep the PIN, ask the user to present the card again. - runCatching { attempt.card?.close() } - cardMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed) - } else { - readerHandedOff = true - prompt.releaseReaderWhenCardRemoved(attempt.card, activeReader) - throw SSHException(e?.message ?: "OpenPGP card authentication failed") - } - } + when (outcome) { + is OpenPgpCardPrompt.CardOutcome.Success -> { + readerHandedOff = true + val signature = outcome.value + // Block until the card is lifted so reader mode stays up (keeping the activity + // foreground) and the platform never dispatches the card's NDEF URL once the git push + // proceeds and this activity moves on. + runBlocking { prompt.awaitCardRemoval(outcome.card, activeReader) } + return signature + } + OpenPgpCardPrompt.CardOutcome.Cancelled -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(null, activeReader) + throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } + is OpenPgpCardPrompt.CardOutcome.Blocked -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, activeReader) + throw SSHException(activity.getString(R.string.openpgp_card_pin_blocked)) + } + is OpenPgpCardPrompt.CardOutcome.Failed -> { + readerHandedOff = true + prompt.releaseReaderWhenCardRemoved(outcome.card, activeReader) + throw SSHException(outcome.error.message ?: "OpenPGP card authentication failed") } } } finally { - pin?.wipe() runBlocking { prompt.dismissDialog() } if (!readerHandedOff && reader != null) prompt.releaseReaderWhenCardRemoved(null, reader) } From 2a280160b994438990955890e44d3dd0e60a3b29 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:13:07 +0900 Subject: [PATCH 12/14] Build the SSH RSA DigestInfo with BouncyCastle instead of hardcoded prefixes CardSshSigner carried three hand-written RFC 8017 DigestInfo DER prefixes for SHA-1/256/512, while OpenPgpCommitSigner already builds its DigestInfo through BouncyCastle's DigestInfo/AlgorithmIdentifier. Emit the DER the same way here, mapping each SSH RSA algorithm to its digest OID, and drop the prefix tables. --- .../util/git/sshj/PgpCardSshAuthMethod.kt | 84 +++---------------- 1 file changed, 13 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt index 1a257dd38c..6e799bb5e7 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt @@ -25,6 +25,11 @@ import net.schmizz.sshj.common.SSHPacket import net.schmizz.sshj.userauth.UserAuthException import net.schmizz.sshj.userauth.keyprovider.KeyProvider import net.schmizz.sshj.userauth.method.AuthPublickey +import org.bouncycastle.asn1.DERNull +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers +import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.bouncycastle.asn1.x509.DigestInfo /** * A [KeyProvider] for a smartcard-backed PGP authentication key. Only the public half is known to @@ -239,79 +244,16 @@ class CardSshSigner( * signature algorithm: `rsa-sha2-512` -> SHA-512, `rsa-sha2-256` -> SHA-256, `ssh-rsa` -> SHA-1. */ private fun pkcs1DigestInfo(sshAlgorithmName: String, dataToSign: ByteArray): ByteArray { - val (jcaDigest, digestInfoPrefix) = + // Let BouncyCastle emit the RFC 8017 sec. 9.2 (EMSA-PKCS1-v1_5) DigestInfo DER rather than + // carrying hardcoded per-hash prefixes; OpenPgpCommitSigner builds its DigestInfo the same way. + val (jcaDigest, oid) = when (sshAlgorithmName) { - "rsa-sha2-512" -> "SHA-512" to SHA512_DIGEST_INFO_PREFIX - "rsa-sha2-256" -> "SHA-256" to SHA256_DIGEST_INFO_PREFIX - "ssh-rsa" -> "SHA-1" to SHA1_DIGEST_INFO_PREFIX + "rsa-sha2-512" -> "SHA-512" to NISTObjectIdentifiers.id_sha512 + "rsa-sha2-256" -> "SHA-256" to NISTObjectIdentifiers.id_sha256 + "ssh-rsa" -> "SHA-1" to OIWObjectIdentifiers.idSHA1 else -> throw SSHException("Unsupported RSA SSH signature algorithm $sshAlgorithmName") } - return digestInfoPrefix + digest(jcaDigest, dataToSign) - } - - private companion object { - // DigestInfo DER prefixes from RFC 8017 sec. 9.2 (EMSA-PKCS1-v1_5), prepended to the raw hash. - val SHA1_DIGEST_INFO_PREFIX = - byteArrayOf( - 0x30, - 0x21, - 0x30, - 0x09, - 0x06, - 0x05, - 0x2b, - 0x0e, - 0x03, - 0x02, - 0x1a, - 0x05, - 0x00, - 0x04, - 0x14, - ) - val SHA256_DIGEST_INFO_PREFIX = - byteArrayOf( - 0x30, - 0x31, - 0x30, - 0x0d, - 0x06, - 0x09, - 0x60, - 0x86.toByte(), - 0x48, - 0x01, - 0x65, - 0x03, - 0x04, - 0x02, - 0x01, - 0x05, - 0x00, - 0x04, - 0x20, - ) - val SHA512_DIGEST_INFO_PREFIX = - byteArrayOf( - 0x30, - 0x51, - 0x30, - 0x0d, - 0x06, - 0x09, - 0x60, - 0x86.toByte(), - 0x48, - 0x01, - 0x65, - 0x03, - 0x04, - 0x02, - 0x03, - 0x05, - 0x00, - 0x04, - 0x40, - ) + return DigestInfo(AlgorithmIdentifier(oid, DERNull.INSTANCE), digest(jcaDigest, dataToSign)) + .encoded } } From 81c743aa177c067697cd6dfe9da4b1bf268027d0 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 15:13:31 +0900 Subject: [PATCH 13/14] Document that per-operation PIN caching is intentional The decrypt/sign/ssh cache keys deliberately stay separate even though a card's PW1 signing and decryption/auth slots are usually the same physical PIN. Note why, so the apparent duplicate prompting is not "fixed" by assuming the slots share a secret, which is not guaranteed across cards. --- .../java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt index 126bb4a43c..693366bfe5 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt @@ -383,6 +383,12 @@ class OpenPgpCardPrompt( return result.await() } + // Callers namespace [cacheKey] per operation ("decrypt:", "sign:", "ssh:"). On most cards the + // decryption/auth (PW1 mode 0x82) and signing (PW1 mode 0x81) PINs are the same physical PW1, so + // a user may be prompted -- and the PIN cached -- separately per operation. This is deliberate: + // it keeps the caches independent and avoids assuming the slots share a secret, which is not + // guaranteed across cards. + /** Reads and decrypts a screen-off-cached PIN for [cacheKey], or null if none. */ fun readCachedPin(cacheKey: String): CharArray? { val encrypted = BasePGPActivity.cachedPassphrases[cacheKey] ?: return null From f19f76fe2eb4ef2c9f02078fc4648bc3d64edc01 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Tue, 21 Jul 2026 18:29:50 +0900 Subject: [PATCH 14/14] Reformat touched files with ktfmt to satisfy spotlessCheck --- .../data/crypto/CryptoRepository.kt | 22 ++-- .../ui/pgp/PGPKeyImportActivity.kt | 26 ++--- .../ui/pgp/PGPKeyListActivity.kt | 17 ++- .../util/crypto/OpenPgpCardPrompt.kt | 32 +++--- .../util/crypto/OpenPgpNfcCard.kt | 16 +-- .../util/git/OpenPgpCommitSigner.kt | 108 +++++++++--------- .../util/git/sshj/PgpCardSshAuthMethod.kt | 39 +++---- .../util/git/sshj/SshjSessionFactory.kt | 9 +- .../util/shortcuts/ShortcutHandler.kt | 18 +-- .../app/passwordstore/crypto/KeyUtils.kt | 25 ++-- 10 files changed, 158 insertions(+), 154 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt index f96cdb30b4..27f9df07ba 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -96,8 +96,8 @@ constructor( } /** - * Whether [id] can be used as an SSH authentication key: either it can sign locally (has a private - * authentication subkey) or its authentication is delegated to an associated smartcard. + * Whether [id] can be used as an SSH authentication key: either it can sign locally (has a + * private authentication subkey) or its authentication is delegated to an associated smartcard. */ fun canUseForSshAuth(id: PGPIdentifier): Boolean = hasPrivateAuthKey(id) || (isSmartcardBacked(id) && hasAuthKey(id)) @@ -210,16 +210,14 @@ constructor( identities.mapUntil({ it.second.isOk }) { id -> encryptedMessage.reset() message.reset() - val result = - runCatching { - val key = pgpKeyManager.getKeyById(id).getOrThrow() - val primaryKeyId = KeyUtils.tryGetKeyId(key) - val cardFingerprints = - primaryKeyId?.let { smartcardStore.getFingerprints(it) }.orEmpty() - smartcardDecryptor.decrypt(key, pin, encryptedMessage, message, card, cardFingerprints) - message - } - .mapError { app.passwordstore.crypto.errors.UnknownError(it.message, it) } + val result = runCatching { + val key = pgpKeyManager.getKeyById(id).getOrThrow() + val primaryKeyId = KeyUtils.tryGetKeyId(key) + val cardFingerprints = primaryKeyId?.let { smartcardStore.getFingerprints(it) }.orEmpty() + smartcardDecryptor.decrypt(key, pin, encryptedMessage, message, card, cardFingerprints) + message + } + .mapError { app.passwordstore.crypto.errors.UnknownError(it.message, it) } result.getError()?.let { logcat { it.asLog() } } Pair(id.toString(), result) } diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt index 40deb4f762..ecd52c63a0 100644 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt @@ -136,19 +136,19 @@ class PGPKeyImportActivity : AppCompatActivity() { progressDialog.setTitle(R.string.openpgp_nfc_setup_title) progressDialog.setMessage(getString(R.string.openpgp_nfc_tap_card)) runCatching { - val card = - OpenPgpNfcCard.waitForCardOrNull( - this@PGPKeyImportActivity, - cancelSignal, - disableReaderModeOnError = false, - disableReaderModeOnClose = false, - onCardDetected = { - progressDialog.setTitle(R.string.openpgp_nfc_hold_card_title) - progressDialog.setMessage(getString(R.string.openpgp_nfc_hold_card)) - }, - ) ?: return@launch - card.use { it.readCardInfo() } - } + val card = + OpenPgpNfcCard.waitForCardOrNull( + this@PGPKeyImportActivity, + cancelSignal, + disableReaderModeOnError = false, + disableReaderModeOnClose = false, + onCardDetected = { + progressDialog.setTitle(R.string.openpgp_nfc_hold_card_title) + progressDialog.setMessage(getString(R.string.openpgp_nfc_hold_card)) + }, + ) ?: return@launch + card.use { it.readCardInfo() } + } .onOk { cardInfo -> progressDialog.dismiss() setupSmartcardKey(cardInfo) 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 7323f5e40b..bd4e8beb4e 100644 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt @@ -226,7 +226,10 @@ class PGPKeyListActivity : AppCompatActivity() { // Selecting an SSH authentication key (single-selection mode): grey out keys that can't // authenticate — public-only keys, and stubs without an associated smartcard. isKeyEnabled = - if (singleSelection) cryptoRepository::canUseForSshAuth else { { true } }, + if (singleSelection) cryptoRepository::canUseForSshAuth + else { + { true } + }, ) } } @@ -238,8 +241,7 @@ class PGPKeyListActivity : AppCompatActivity() { /** A key whose private material lives on hardware (a smartcard) or was otherwise stripped. */ private fun isStubKey(identifier: PGPIdentifier): Boolean = - cryptoRepository.isSmartcardBacked(identifier) || - cryptoRepository.hasOnlyStubDecKey(identifier) + cryptoRepository.isSmartcardBacked(identifier) || cryptoRepository.hasOnlyStubDecKey(identifier) private fun showKeyInfo(identifier: PGPIdentifier) { val fingerprint = @@ -257,9 +259,12 @@ class PGPKeyListActivity : AppCompatActivity() { else -> getString(R.string.pgp_key_info_type_public) } val message = buildString { - cryptoRepository.getUserIdFromKeyId(identifier)?.takeIf { it != "null" }?.let { - appendLine(getString(R.string.pgp_key_info_user_id, it)) - } + cryptoRepository + .getUserIdFromKeyId(identifier) + ?.takeIf { it != "null" } + ?.let { + appendLine(getString(R.string.pgp_key_info_user_id, it)) + } cryptoRepository.getEmailFromKeyId(identifier)?.let { appendLine(getString(R.string.pgp_key_info_email, it)) } diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt index 693366bfe5..f4ec7ae1d2 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt @@ -403,21 +403,21 @@ class OpenPgpCardPrompt( /** Caches [pin] (AES-encrypted, until screen-off) under [cacheKey] when [cache] is set. */ fun storeCachedPin(cacheKey: String, pin: CharArray, cache: Boolean) { runCatching { - val hardwareBacked = AESEncryption.isHardwareBacked() - val encryptedPin = if (cache) AESEncryption.encrypt(pin) else null - if (hardwareBacked && cache && encryptedPin != null) { - BasePGPActivity.cachedPassphrases[cacheKey]?.wipe() - BasePGPActivity.cachedPassphrases[cacheKey] = encryptedPin - } else { - clearCachedPin(cacheKey) - } - activity.sharedPrefs.edit { - putBoolean( - PreferenceKeys.CACHE_PASSPHRASE, - hardwareBacked && cache && encryptedPin != null, - ) - } + val hardwareBacked = AESEncryption.isHardwareBacked() + val encryptedPin = if (cache) AESEncryption.encrypt(pin) else null + if (hardwareBacked && cache && encryptedPin != null) { + BasePGPActivity.cachedPassphrases[cacheKey]?.wipe() + BasePGPActivity.cachedPassphrases[cacheKey] = encryptedPin + } else { + clearCachedPin(cacheKey) } + activity.sharedPrefs.edit { + putBoolean( + PreferenceKeys.CACHE_PASSPHRASE, + hardwareBacked && cache && encryptedPin != null, + ) + } + } .onErr { e -> logcat { e.asLog() } } } @@ -468,8 +468,8 @@ class OpenPgpCardPrompt( } /** - * Shows a modal "remove your card" dialog and **suspends** until [card] is physically lifted (or a - * timeout elapses), then dismisses the dialog and releases [reader]. + * Shows a modal "remove your card" dialog and **suspends** until [card] is physically lifted (or + * a timeout elapses), then dismisses the dialog and releases [reader]. * * Unlike [releaseReaderWhenCardRemoved] this blocks the caller. NFC reader mode is only active * while the hosting activity is resumed, so when an operation would otherwise let its activity diff --git a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt index ced152e082..bfbf79dea9 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt @@ -117,7 +117,8 @@ class OpenPgpNfcCard( * SSH public-key authentication. */ fun internalAuthenticate(input: ByteArray): ByteArray { - // Le = 0 → request up to 256 bytes; longer responses (e.g. RSA) are pulled in via 61xx chaining. + // Le = 0 → request up to 256 bytes; longer responses (e.g. RSA) are pulled in via 61xx + // chaining. return transceiveData(0x88, 0x00, 0x00, input, expectedLength = 0) } @@ -134,13 +135,12 @@ class OpenPgpNfcCard( * the card has physically left the field. Uses a short transceive timeout so a removed card is * reported quickly instead of blocking for the (long) signing timeout before throwing. */ - fun isPresent(): Boolean = - runCatching { - isoDep.timeout = PRESENCE_PROBE_TIMEOUT_MS - isoDep.transceive(GET_APPLICATION_RELATED_DATA) - true - } - .getOr(false) + fun isPresent(): Boolean = runCatching { + isoDep.timeout = PRESENCE_PROBE_TIMEOUT_MS + isoDep.transceive(GET_APPLICATION_RELATED_DATA) + true + } + .getOr(false) override fun close() { runCatching { isoDep.close() } diff --git a/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt index 5b51980496..0d6b2e6ed0 100644 --- a/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt +++ b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt @@ -129,13 +129,13 @@ class OpenPgpCommitSigner( } val passphrase = runBlocking { - OpenPgpCardPrompt(activity, R.string.git_signing_passphrase_title, dispatcherProvider) - .askSecret( - titleRes = R.string.git_signing_passphrase_title, - hintRes = R.string.ssh_keygen_passphrase, - identityLabel = identityLabel(key), - ) - } + OpenPgpCardPrompt(activity, R.string.git_signing_passphrase_title, dispatcherProvider) + .askSecret( + titleRes = R.string.git_signing_passphrase_title, + hintRes = R.string.ssh_keygen_passphrase, + identityLabel = identityLabel(key), + ) + } ?.secret ?: throw CanceledException(activity.getString(R.string.dialog_cancel)) try { val decryptor = @@ -190,31 +190,30 @@ class OpenPgpCommitSigner( runBlocking { prompt.createReader() } ?: throw IOException(activity.getString(R.string.openpgp_nfc_unavailable)) reader = activeReader - val outcome = - runBlocking { - prompt.runWithPin( - reader = activeReader, - cacheKey = cacheKey, - pinTitleRes = R.string.git_signing_card_pin_title, - pinHintRes = R.string.openpgp_card_pin_hint, - identityLabel = identityLabel(key), - pinMode = OpenPgpCardPrompt.PinMode.SIGNATURE, - presentMessage = activity.getString(R.string.git_signing_tap_card), - commFailedMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed), - ) { card, currentPin -> - // The whole card exchange (applet select -> verify -> sign) runs on a single thread - // with no hop, so a genuine wrong PIN reliably comes back as a card status word (e.g. - // 63 Cx) rather than a transceive error caused by racing the NFC presence check. - card.verifySignaturePin(currentPin) - val privateKey = PGPPrivateKey(publicKey.keyID, publicKey.publicKeyPacket, null) - buildDetachedSignature( - publicKey, - CardContentSignerBuilder(publicKey, card), - privateKey, - payload, - ) - } + val outcome = runBlocking { + prompt.runWithPin( + reader = activeReader, + cacheKey = cacheKey, + pinTitleRes = R.string.git_signing_card_pin_title, + pinHintRes = R.string.openpgp_card_pin_hint, + identityLabel = identityLabel(key), + pinMode = OpenPgpCardPrompt.PinMode.SIGNATURE, + presentMessage = activity.getString(R.string.git_signing_tap_card), + commFailedMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed), + ) { card, currentPin -> + // The whole card exchange (applet select -> verify -> sign) runs on a single thread + // with no hop, so a genuine wrong PIN reliably comes back as a card status word (e.g. + // 63 Cx) rather than a transceive error caused by racing the NFC presence check. + card.verifySignaturePin(currentPin) + val privateKey = PGPPrivateKey(publicKey.keyID, publicKey.publicKeyPacket, null) + buildDetachedSignature( + publicKey, + CardContentSignerBuilder(publicKey, card), + privateKey, + payload, + ) } + } when (outcome) { is OpenPgpCardPrompt.CardOutcome.Success -> { // Keep reader mode on until the card is physically lifted, so the platform never @@ -302,12 +301,12 @@ class OpenPgpCommitSigner( } private fun deemphasizeButton(button: Button) { - button.setTextColor( - MaterialColors.getColor(button, materialR.attr.colorOnSurfaceVariant) - ) + button.setTextColor(MaterialColors.getColor(button, materialR.attr.colorOnSurfaceVariant)) } - /** Short label naming the signing key, so the passphrase/PIN prompt shows which key it unlocks. */ + /** + * Short label naming the signing key, so the passphrase/PIN prompt shows which key it unlocks. + */ private fun identityLabel(key: PGPKey): String? = KeyUtils.tryGetUserId(key)?.toString()?.takeIf { it.isNotBlank() && it != "null" } ?: KeyUtils.tryGetKeyId(key)?.toString() @@ -325,26 +324,25 @@ class OpenPgpCommitSigner( ?: throw PGPException("No signing-capable OpenPGP secret key found") } - private fun publicKeys(key: PGPKey): Sequence = - runCatching { - PGPSecretKeyRingCollection( - PGPUtil.getDecoderStream(key.contents.inputStream()), - JcaKeyFingerprintCalculator(), - ) - .keyRings - .asSequence() - .flatMap { it.secretKeys.asSequence() } - .map { it.publicKey } - } - .getOrElse { - PGPPublicKeyRingCollection( - PGPUtil.getDecoderStream(key.contents.inputStream()), - JcaKeyFingerprintCalculator(), - ) - .keyRings - .asSequence() - .flatMap { it.publicKeys.asSequence() } - } + private fun publicKeys(key: PGPKey): Sequence = runCatching { + PGPSecretKeyRingCollection( + PGPUtil.getDecoderStream(key.contents.inputStream()), + JcaKeyFingerprintCalculator(), + ) + .keyRings + .asSequence() + .flatMap { it.secretKeys.asSequence() } + .map { it.publicKey } + } + .getOrElse { + PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(key.contents.inputStream()), + JcaKeyFingerprintCalculator(), + ) + .keyRings + .asSequence() + .flatMap { it.publicKeys.asSequence() } + } private fun findCardSigningKey(key: PGPKey, cardFingerprints: List): PGPPublicKey { return publicKeys(key).firstOrNull { publicKey -> diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt index 6e799bb5e7..e40e108932 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt @@ -49,9 +49,9 @@ class PgpCardSshKeyProvider(private val publicKey: PublicKey) : KeyProvider { * with a local private key. It overrides [putPubKey]/[putSig] to advertise and sign with the * *negotiated* public-key algorithm rather than the key's base type: for RSA these differ * (`rsa-sha2-512` / `rsa-sha2-256` / `ssh-rsa`), and the algorithm name in the request must match - * the one in the signature blob. The exact data SSH signs -- `string(session id) || - * request-so-far` -- is handed to [signer], which drives the card. Host-key verification and - * everything else stay on sshj's default path. + * the one in the signature blob. The exact data SSH signs -- `string(session id) || request-so-far` + * -- is handed to [signer], which drives the card. Host-key verification and everything else stay + * on sshj's default path. * * sshj's [net.schmizz.sshj.userauth.method.KeyedAuthMethod] keeps its chosen [KeyAlgorithm] queue * private and drops the head on [shouldRetry] to fall back to the next algorithm. Because the card @@ -169,24 +169,23 @@ class CardSshSigner( runBlocking { prompt.createReader() } ?: throw IOException(activity.getString(R.string.openpgp_nfc_unavailable)) reader = activeReader - val outcome = - runBlocking { - prompt.runWithPin( - reader = activeReader, - cacheKey = "ssh:${primaryKeyId.id}", - pinTitleRes = R.string.openpgp_card_pin_title, - pinHintRes = R.string.openpgp_card_pin_hint, - identityLabel = null, - // PW1 in mode 0x82 authorises INTERNAL AUTHENTICATE (the auth key slot), unlike PSO:CDS - // (mode 0x81) used for commit signing. - pinMode = OpenPgpCardPrompt.PinMode.USER, - presentMessage = activity.getString(R.string.openpgp_nfc_tap_card), - commFailedMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed), - ) { card, currentPin -> - card.verifyUserPin(currentPin) - card.internalAuthenticate(input) - } + val outcome = runBlocking { + prompt.runWithPin( + reader = activeReader, + cacheKey = "ssh:${primaryKeyId.id}", + pinTitleRes = R.string.openpgp_card_pin_title, + pinHintRes = R.string.openpgp_card_pin_hint, + identityLabel = null, + // PW1 in mode 0x82 authorises INTERNAL AUTHENTICATE (the auth key slot), unlike PSO:CDS + // (mode 0x81) used for commit signing. + pinMode = OpenPgpCardPrompt.PinMode.USER, + presentMessage = activity.getString(R.string.openpgp_nfc_tap_card), + commFailedMessage = activity.getString(R.string.openpgp_nfc_card_comm_failed), + ) { card, currentPin -> + card.verifyUserPin(currentPin) + card.internalAuthenticate(input) } + } when (outcome) { is OpenPgpCardPrompt.CardOutcome.Success -> { readerHandedOff = true diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 9966a83aac..d09eda2029 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -212,8 +212,8 @@ private class SshjSession( /** * When the selected PGP SSH key lives on a smartcard, authenticate by delegating the signature to - * the card over NFC (INTERNAL AUTHENTICATE) instead of unlocking a local private key. Returns null - * for every other key so the normal local-key path is used untouched. + * the card over NFC (INTERNAL AUTHENTICATE) instead of unlocking a local private key. Returns + * null for every other key so the normal local-key path is used untouched. */ private fun cardBackedAuth(): AuthPublickey? { if (SshKey.type != SshKey.Type.ImportedPGP) return null @@ -230,7 +230,10 @@ private class SshjSession( private fun localKeyAuth(authMethod: SshAuthMethod): AuthPublickey = AuthPublickey( - SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider)) + SshKey.provide( + ssh, + CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider), + ) ) override fun exec(commandName: String?, timeout: Int): Process { 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 8b26c30bc3..a015a73dba 100644 --- a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt +++ b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt @@ -82,17 +82,17 @@ class ShortcutHandler @Inject constructor(@ApplicationContext val context: Conte */ fun pruneDynamicShortcuts() { runCatching { - val shortcutManager: ShortcutManager = context.getSystemService() ?: return - val staleIds = - shortcutManager.dynamicShortcuts.mapNotNull { shortcut -> - val path = shortcut.intent?.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) - if (path != null && !File(path).exists()) shortcut.id else null - } - if (staleIds.isNotEmpty()) { - logcat { "Pruning ${staleIds.size} stale dynamic shortcut(s)" } - shortcutManager.removeDynamicShortcuts(staleIds) + val shortcutManager: ShortcutManager = context.getSystemService() ?: return + val staleIds = + shortcutManager.dynamicShortcuts.mapNotNull { shortcut -> + val path = shortcut.intent?.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + if (path != null && !File(path).exists()) shortcut.id else null } + if (staleIds.isNotEmpty()) { + logcat { "Pruning ${staleIds.size} stale dynamic shortcut(s)" } + shortcutManager.removeDynamicShortcuts(staleIds) } + } .onErr { logcat { "Failed to prune dynamic shortcuts: ${it.message}" } } } diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt index b9d6e9241f..ea49b745c4 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -25,8 +25,9 @@ import org.pgpainless.key.info.KeyRingInfo public object KeyUtils { /** - * Key-usage flags that make a (sub)key usable for authentication, in preference order: a dedicated - * Authentication subkey first, then a Signing subkey, then the primary Certification key. + * Key-usage flags that make a (sub)key usable for authentication, in preference order: a + * dedicated Authentication subkey first, then a Signing subkey, then the primary Certification + * key. */ private val AUTH_CAPABILITY_RANKING = listOf(KeyFlags.AUTHENTICATION, KeyFlags.SIGN_DATA, KeyFlags.CERTIFY_OTHER) @@ -192,15 +193,14 @@ public object KeyUtils { * empty private material) — in both cases the key is still authentication-capable (the card, or a * private key held elsewhere, does the actual signing). */ - public fun hasAuthKey(cert: OpenPGPCertificate): Boolean = - AUTH_CAPABILITY_RANKING.any { flag -> - cert.getComponentKeysWithFlag(Date(), flag).isNotEmpty() - } + public fun hasAuthKey(cert: OpenPGPCertificate): Boolean = AUTH_CAPABILITY_RANKING.any { flag -> + cert.getComponentKeysWithFlag(Date(), flag).isNotEmpty() + } /** * Tests if the given [PGPKey] provides an authentication-capable secret subkey whose private key - * is present locally (i.e. can sign without a smartcard). Used to decide whether a key can be used - * for SSH authentication on its own. + * is present locally (i.e. can sign without a smartcard). Used to decide whether a key can be + * used for SSH authentication on its own. */ public fun hasPrivateAuthKey(key: PGPKey): Boolean = tryParseCertificateOrKey(key)?.let { hasPrivateAuthKey(it) } ?: false @@ -231,13 +231,14 @@ public object KeyUtils { * happens later, on the card. */ public fun extractPublicAuthKey(cert: OpenPGPCertificate): PublicKey? { - // A and S subkeys as well as the primary C key are equally suitable for authentication; pick the + // A and S subkeys as well as the primary C key are equally suitable for authentication; pick + // the // newest key matching one of the capabilities in the given ranking order. val authKey = AUTH_CAPABILITY_RANKING.firstNotNullOfOrNull { flag -> - cert - .getComponentKeysWithFlag(Date(), flag) - .maxByOrNull { it.getCreationTime() } // newest first + cert.getComponentKeysWithFlag(Date(), flag).maxByOrNull { + it.getCreationTime() + } // newest first } ?: return null return JcaPGPKeyConverter()