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 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,28 @@ 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..a922e1ab44 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") @@ -472,7 +487,10 @@ open class BasePGPActivity : AppCompatActivity() { if (++retries > MAX_RETRIES) finish() val dialog = - PasswordDialog.newInstance(getEmailsFromIdentifiers(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 -> @@ -786,7 +804,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..bf2242fffa 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,10 @@ 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 com.github.michaelbull.result.get +import app.passwordstore.util.shortcuts.ShortcutHandler import com.github.michaelbull.result.getError import com.github.michaelbull.result.getOrThrow +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayOutputStream import java.io.File @@ -50,6 +54,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 +70,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 +97,7 @@ class DecryptActivity : BasePGPActivity() { } override fun onDestroy() { + OpenPgpNfcCard.disableReaderMode(this) encryptedEntryChars?.wipe() itemsAdapter?.clearItems() super.onDestroy() @@ -94,6 +108,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 +165,109 @@ 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() + // 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 + } + var readerHandedOff = false + try { + 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, + ) + // 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 (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 { + 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/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..ecd52c63a0 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,27 @@ 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.io.IOException +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 +56,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 +67,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 +81,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 +91,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 +102,191 @@ 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()) { downloadKeyFromUrl(cardInfo.url) } + } + 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) + } + + /** + * 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) + .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 +303,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 +321,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 +350,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 +472,11 @@ class PGPKeyImportActivity : AppCompatActivity() { } .show() } + + 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 + } } 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..bd4e8beb4e 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,13 @@ 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 +239,54 @@ 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 +437,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..f4ec7ae1d2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpCardPrompt.kt @@ -0,0 +1,593 @@ +/* + * 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.get +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) + } + } + + /** 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 + // 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() + } + + // 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 + 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..bfbf79dea9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpNfcCard.kt @@ -0,0 +1,507 @@ +/* + * 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.nio.CharBuffer +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) { + // 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 + ) + } 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) + } + } + + /** + * 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) + } + + 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 + } + + 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..471c4831b9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/OpenPgpSmartcardDecryptor.kt @@ -0,0 +1,191 @@ +/* + * 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 + } + // 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) + 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, + ) + + // 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.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) + } + + 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 + } +} 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..0d6b2e6ed0 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/OpenPgpCommitSigner.kt @@ -0,0 +1,416 @@ +/* + * 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 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 + // 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 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 + // 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) { + // 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 { + 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/git/sshj/PgpCardSshAuthMethod.kt b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt new file mode 100644 index 0000000000..e40e108932 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/git/sshj/PgpCardSshAuthMethod.kt @@ -0,0 +1,258 @@ +/* + * 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 com.hierynomus.sshj.key.KeyAlgorithm +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.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 + * 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 [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( + 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 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() + } +} + +/** + * 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 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, sshAlgorithmName: String): ByteArray { + 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) } + } + // 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 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 readerHandedOff = false + try { + val activeReader = + 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) + } + } + 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 { + 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 + } + + /** + * 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 { + // 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 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 DigestInfo(AlgorithmIdentifier(oid, DERNull.INSTANCE), digest(jcaDigest, dataToSign)) + .encoded + } +} 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..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 @@ -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,39 @@ 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" } 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..a015a73dba 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..ea49b745c4 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,14 @@ 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 +80,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 +135,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 +159,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? =