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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.NFC" />
<!-- Required by Autofill to verify the certificate hashes of packages -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
Expand All @@ -20,6 +21,9 @@
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />

<application
android:name=".Application"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ import app.passwordstore.crypto.PGPKeyManager
import app.passwordstore.crypto.PGPainlessCryptoHandler
import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.crypto.OpenPgpNfcCard
import app.passwordstore.util.crypto.OpenPgpSmartcardDecryptor
import app.passwordstore.util.crypto.OpenPgpSmartcardStore
import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.filterOk
import com.github.michaelbull.result.get
import com.github.michaelbull.result.getError
import com.github.michaelbull.result.getOrThrow
import com.github.michaelbull.result.map
import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.runCatching
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
Expand All @@ -45,6 +49,8 @@ class CryptoRepository
constructor(
private val pgpKeyManager: PGPKeyManager,
private val pgpCryptoHandler: PGPainlessCryptoHandler,
private val smartcardDecryptor: OpenPgpSmartcardDecryptor,
private val smartcardStore: OpenPgpSmartcardStore,
private val dispatcherProvider: DispatcherProvider,
@SettingsPreferences private val settings: SharedPreferences,
) {
Expand All @@ -61,14 +67,41 @@ constructor(

fun hasDecKey(id: PGPIdentifier): Boolean {
val key = pgpKeyManager.getKeyById(id).get()
return key != null && KeyUtils.hasDecKey(key)
return key != null && (KeyUtils.hasDecKey(key) || isSmartcardBacked(key))
}

fun hasOnlyStubDecKey(id: PGPIdentifier): Boolean {
val key = pgpKeyManager.getKeyById(id).get()
return key != null && KeyUtils.hasOnlyStubDecKeys(key)
}

fun isSmartcardBacked(id: PGPIdentifier): Boolean {
val key = pgpKeyManager.getKeyById(id).get()
return key != null && isSmartcardBacked(key)
}

private fun isSmartcardBacked(key: PGPKey): Boolean {
val primaryKeyId = KeyUtils.tryGetKeyId(key) ?: return false
return smartcardStore.hasAssociation(primaryKeyId)
}

fun hasAuthKey(id: PGPIdentifier): Boolean {
val key = pgpKeyManager.getKeyById(id).get()
return key != null && KeyUtils.hasAuthKey(key)
}

fun hasPrivateAuthKey(id: PGPIdentifier): Boolean {
val key = pgpKeyManager.getKeyById(id).get()
return key != null && KeyUtils.hasPrivateAuthKey(key)
}

/**
* Whether [id] can be used as an SSH authentication key: either it can sign locally (has a
* private authentication subkey) or its authentication is delegated to an associated smartcard.
*/
fun canUseForSshAuth(id: PGPIdentifier): Boolean =
hasPrivateAuthKey(id) || (isSmartcardBacked(id) && hasAuthKey(id))

fun isPasswordProtected(identifiers: List<PGPIdentifier>, anySubkey: Boolean = false): Boolean {
val keys = identifiers.map { pgpKeyManager.getKeyById(it) }.filterOk()
return pgpCryptoHandler.isPassphraseProtected(keys, anySubkey)
Expand Down Expand Up @@ -167,6 +200,28 @@ constructor(
}
}

fun decryptWithSmartcard(
pin: CharArray,
identities: List<PGPIdentifier>,
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<PGPIdentifier>,
message: ByteArrayInputStream,
Expand Down
40 changes: 33 additions & 7 deletions app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -440,11 +440,26 @@ open class BasePGPActivity : AppCompatActivity() {
return gpgIdentifiers
}

private fun getEmailsFromIdentifiers(identifiers: List<PGPIdentifier>): 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<PGPIdentifier>): 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<PGPIdentifier>): Boolean = identifiers.any {
repository.hasOnlyStubDecKey(it) || repository.isSmartcardBacked(it)
}

@Suppress("ReturnCount")
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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()) {
Expand Down
123 changes: 122 additions & 1 deletion app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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) {
Expand All @@ -84,6 +97,7 @@ class DecryptActivity : BasePGPActivity() {
}

override fun onDestroy() {
OpenPgpNfcCard.disableReaderMode(this)
encryptedEntryChars?.wipe()
itemsAdapter?.clearItems()
super.onDestroy()
Expand All @@ -94,6 +108,10 @@ class DecryptActivity : BasePGPActivity() {
identifiers: List<PGPIdentifier>,
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)
Expand Down Expand Up @@ -147,6 +165,109 @@ class DecryptActivity : BasePGPActivity() {
}
}

private suspend fun decryptWithSmartcard(
passphrases: Map<String, CharArray?>,
identifiers: List<PGPIdentifier>,
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
Expand Down
Loading