Quick and dirty hardware key import

This commit is contained in:
Tad Fisher 2022-10-09 16:11:28 -07:00
parent 75040136ae
commit a716ac9514
No known key found for this signature in database
GPG key ID: 3A7425F7E7B22251
5 changed files with 150 additions and 9 deletions

View file

@ -7,20 +7,26 @@
package app.passwordstore.ui.pgp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import app.passwordstore.R
import app.passwordstore.crypto.HWSecurityDeviceHandler
import app.passwordstore.crypto.KeyUtils.tryGetId
import app.passwordstore.crypto.PGPKey
import app.passwordstore.crypto.PGPKeyManager
import app.passwordstore.crypto.errors.KeyAlreadyExistsException
import app.passwordstore.crypto.errors.NoSecretKeyException
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.getOrThrow
import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@AndroidEntryPoint
@ -32,9 +38,10 @@ class PGPKeyImportActivity : AppCompatActivity() {
*/
private var lastBytes: ByteArray? = null
@Inject lateinit var keyManager: PGPKeyManager
@Inject lateinit var deviceHandler: HWSecurityDeviceHandler
private val pgpKeyImportAction =
registerForActivityResult(OpenDocument()) { uri ->
(this as ComponentActivity).registerForActivityResult(OpenDocument()) { uri ->
runCatching {
if (uri == null) {
return@runCatching null
@ -50,6 +57,7 @@ class PGPKeyImportActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pgpKeyImportAction.launch(arrayOf("*/*"))
}
@ -68,6 +76,16 @@ class PGPKeyImportActivity : AppCompatActivity() {
return key
}
private fun pairDevice(bytes: ByteArray) {
lifecycleScope.launch {
val result = keyManager.addKey(
deviceHandler.pairWithPublicKey(PGPKey(bytes)).getOrThrow(),
replace = true
)
handleImportResult(result)
}
}
private fun handleImportResult(result: Result<PGPKey?, Throwable>) {
when (result) {
is Ok<PGPKey?> -> {
@ -85,8 +103,8 @@ class PGPKeyImportActivity : AppCompatActivity() {
.setCancelable(false)
.show()
}
is Err<Throwable> -> {
if (result.error is KeyAlreadyExistsException && lastBytes != null) {
is Err<Throwable> -> when {
result.error is KeyAlreadyExistsException && lastBytes != null ->
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.pgp_key_import_failed))
.setMessage(getString(R.string.pgp_key_import_failed_replace_message))
@ -96,14 +114,21 @@ class PGPKeyImportActivity : AppCompatActivity() {
.setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
.setCancelable(false)
.show()
} else {
result.error is NoSecretKeyException && lastBytes != null ->
MaterialAlertDialogBuilder(this)
.setTitle(R.string.pgp_key_import_failed_no_secret)
.setMessage(R.string.pgp_key_import_failed_no_secret_message)
.setPositiveButton(R.string.dialog_yes) { _, _ -> pairDevice(lastBytes!!) }
.setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
.setCancelable(false)
.show()
else ->
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.pgp_key_import_failed))
.setMessage(result.error.message)
.setMessage(result.error.message + "\n" + result.error.stackTraceToString())
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.setCancelable(false)
.show()
}
}
}
}

View file

@ -332,6 +332,7 @@
<string name="select_gpg_key_title">Select\nGPG Key</string>
<string name="select_gpg_key_message">Select a GPG key to initialize your store with</string>
<string name="gpg_key_select">Select key</string>
<string name="pair_hardware_key">Pair hardware key</string>
<!-- SSH port validation -->
<string name="ssh_scheme_needed_title">Potentially incorrect URL</string>
@ -358,6 +359,8 @@
<string name="password_list_fab_content_description">Create new password or folder</string>
<string name="pgp_key_import_failed">Failed to import PGP key</string>
<string name="pgp_key_import_failed_replace_message">An existing key with this ID was found, do you want to replace it?</string>
<string name="pgp_key_import_failed_no_secret">No secret PGP key</string>
<string name="pgp_key_import_failed_no_secret_message">This is a public key. Would you like to pair a hardware security device?</string>
<string name="pgp_key_import_succeeded">Successfully imported PGP key</string>
<string name="pgp_key_import_succeeded_message">The key ID of the imported key is given below, please review it for correctness:\n%1$s</string>
<string name="pref_category_pgp_title">PGP settings</string>

View file

@ -9,7 +9,15 @@ import app.passwordstore.crypto.GpgIdentifier.KeyId
import app.passwordstore.crypto.GpgIdentifier.UserId
import com.github.michaelbull.result.get
import com.github.michaelbull.result.runCatching
import java.io.ByteArrayOutputStream
import org.bouncycastle.bcpg.GnuExtendedS2K
import org.bouncycastle.bcpg.S2K
import org.bouncycastle.bcpg.SecretKeyPacket
import org.bouncycastle.bcpg.SecretSubkeyPacket
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags
import org.bouncycastle.openpgp.PGPKeyRing
import org.bouncycastle.openpgp.PGPPublicKey
import org.bouncycastle.openpgp.PGPPublicKeyRing
import org.bouncycastle.openpgp.PGPSecretKey
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.pgpainless.algorithm.EncryptionPurpose
@ -37,6 +45,29 @@ public object KeyUtils {
val keyRing = tryParseKeyring(key) ?: return null
return UserId(keyRing.publicKey.userIDs.next())
}
public fun tryCreateStubKey(
publicKey: PGPKey,
serial: ByteArray,
stubFingerprints: List<OpenPgpFingerprint>
): PGPKey? {
val keyRing = tryParseKeyring(publicKey) as? PGPPublicKeyRing ?: return null
val secretKeyRing =
keyRing
.fold(PGPSecretKeyRing(emptyList())) { ring, key ->
PGPSecretKeyRing.insertSecretKey(
ring,
if (stubFingerprints.any { it == OpenPgpFingerprint.parseFromBinary(key.fingerprint) }) {
toCardSecretKey(key, serial)
} else {
toDummySecretKey(key)
}
)
}
return PGPKey(secretKeyRing.encoded)
}
public fun tryGetEncryptionKeyFingerprint(key: PGPKey): OpenPgpFingerprint? {
val keyRing = tryParseKeyring(key) ?: return null
val encryptionSubkey =
@ -59,3 +90,63 @@ public object KeyUtils {
return info.getSecretKey(encryptionKey.keyID)
}
}
private fun toDummySecretKey(publicKey: PGPPublicKey): PGPSecretKey {
return PGPSecretKey(
if (publicKey.isMasterKey) {
SecretKeyPacket(
publicKey.publicKeyPacket,
SymmetricKeyAlgorithmTags.NULL,
SecretKeyPacket.USAGE_CHECKSUM,
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_NO_PRIVATE_KEY),
byteArrayOf(),
byteArrayOf()
)
} else {
SecretSubkeyPacket(
publicKey.publicKeyPacket,
SymmetricKeyAlgorithmTags.NULL,
SecretKeyPacket.USAGE_CHECKSUM,
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_NO_PRIVATE_KEY),
byteArrayOf(),
byteArrayOf()
)
},
publicKey
)
}
@Suppress("MagicNumber")
private fun toCardSecretKey(publicKey: PGPPublicKey, serial: ByteArray): PGPSecretKey {
return PGPSecretKey(
if (publicKey.isMasterKey) {
SecretKeyPacket(
publicKey.publicKeyPacket,
SymmetricKeyAlgorithmTags.NULL,
SecretKeyPacket.USAGE_CHECKSUM,
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_DIVERT_TO_CARD),
ByteArray(8),
encodeSerial(serial),
)
} else {
SecretSubkeyPacket(
publicKey.publicKeyPacket,
SymmetricKeyAlgorithmTags.NULL,
SecretKeyPacket.USAGE_CHECKSUM,
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_DIVERT_TO_CARD),
ByteArray(8),
encodeSerial(serial),
)
},
publicKey
)
}
@Suppress("MagicNumber")
private fun encodeSerial(serial: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
out.write(serial.size)
out.write(serial, 0, minOf(16, serial.size))
return out.toByteArray()
}

View file

@ -9,12 +9,12 @@ package app.passwordstore.crypto
import androidx.annotation.VisibleForTesting
import app.passwordstore.crypto.KeyUtils.tryGetId
import app.passwordstore.crypto.KeyUtils.tryParseKeyring
import app.passwordstore.crypto.errors.InvalidKeyException
import app.passwordstore.crypto.errors.KeyAlreadyExistsException
import app.passwordstore.crypto.errors.KeyDeletionFailedException
import app.passwordstore.crypto.errors.KeyDirectoryUnavailableException
import app.passwordstore.crypto.errors.KeyNotFoundException
import app.passwordstore.crypto.errors.NoKeysAvailableException
import app.passwordstore.crypto.errors.NoSecretKeyException
import app.passwordstore.util.coroutines.runSuspendCatching
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.unwrap
@ -40,12 +40,17 @@ constructor(
withContext(dispatcher) {
runSuspendCatching {
if (!keyDirExists()) throw KeyDirectoryUnavailableException
val incomingKeyRing = tryParseKeyring(key) ?: throw InvalidKeyException
val incomingKeyRing = tryParseKeyring(key)
if (incomingKeyRing is PGPPublicKeyRing) {
throw NoSecretKeyException(tryGetId(key)?.toString() ?: "Failed to retrieve key ID")
}
val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION")
if (keyFile.exists()) {
val existingKeyBytes = keyFile.readBytes()
val existingKeyRing =
tryParseKeyring(PGPKey(existingKeyBytes)) ?: throw InvalidKeyException
tryParseKeyring(PGPKey(existingKeyBytes))
when {
existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> {
keyFile.writeBytes(key.contents)

View file

@ -0,0 +1,17 @@
package org.bouncycastle.bcpg
/**
* Add a constructor for GNU-extended S2K
*
* This extension is documented on GnuPG documentation DETAILS file,
* section "GNU extensions to the S2K algorithm". Its support is
* already present in S2K class but lack for a constructor.
*
* @author Léonard Dallot <leonard.dallot@taztag.com>
*/
public class GnuExtendedS2K(mode: Int) : S2K(SIMPLE) {
init {
this.type = GNU_DUMMY_S2K
this.protectionMode = mode
}
}