Skip to content
This repository was archived by the owner on Oct 15, 2024. It is now read-only.

Commit a716ac9

Browse files
committed
Quick and dirty hardware key import
1 parent 7504013 commit a716ac9

File tree

5 files changed

+150
-9
lines changed

5 files changed

+150
-9
lines changed

app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,26 @@
77
package app.passwordstore.ui.pgp
88

99
import android.os.Bundle
10+
import androidx.activity.ComponentActivity
1011
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
1112
import androidx.appcompat.app.AppCompatActivity
13+
import androidx.lifecycle.lifecycleScope
1214
import app.passwordstore.R
15+
import app.passwordstore.crypto.HWSecurityDeviceHandler
1316
import app.passwordstore.crypto.KeyUtils.tryGetId
1417
import app.passwordstore.crypto.PGPKey
1518
import app.passwordstore.crypto.PGPKeyManager
1619
import app.passwordstore.crypto.errors.KeyAlreadyExistsException
20+
import app.passwordstore.crypto.errors.NoSecretKeyException
1721
import com.github.michaelbull.result.Err
1822
import com.github.michaelbull.result.Ok
1923
import com.github.michaelbull.result.Result
24+
import com.github.michaelbull.result.getOrThrow
2025
import com.github.michaelbull.result.runCatching
2126
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2227
import dagger.hilt.android.AndroidEntryPoint
2328
import javax.inject.Inject
29+
import kotlinx.coroutines.launch
2430
import kotlinx.coroutines.runBlocking
2531

2632
@AndroidEntryPoint
@@ -32,9 +38,10 @@ class PGPKeyImportActivity : AppCompatActivity() {
3238
*/
3339
private var lastBytes: ByteArray? = null
3440
@Inject lateinit var keyManager: PGPKeyManager
41+
@Inject lateinit var deviceHandler: HWSecurityDeviceHandler
3542

3643
private val pgpKeyImportAction =
37-
registerForActivityResult(OpenDocument()) { uri ->
44+
(this as ComponentActivity).registerForActivityResult(OpenDocument()) { uri ->
3845
runCatching {
3946
if (uri == null) {
4047
return@runCatching null
@@ -50,6 +57,7 @@ class PGPKeyImportActivity : AppCompatActivity() {
5057

5158
override fun onCreate(savedInstanceState: Bundle?) {
5259
super.onCreate(savedInstanceState)
60+
5361
pgpKeyImportAction.launch(arrayOf("*/*"))
5462
}
5563

@@ -68,6 +76,16 @@ class PGPKeyImportActivity : AppCompatActivity() {
6876
return key
6977
}
7078

79+
private fun pairDevice(bytes: ByteArray) {
80+
lifecycleScope.launch {
81+
val result = keyManager.addKey(
82+
deviceHandler.pairWithPublicKey(PGPKey(bytes)).getOrThrow(),
83+
replace = true
84+
)
85+
handleImportResult(result)
86+
}
87+
}
88+
7189
private fun handleImportResult(result: Result<PGPKey?, Throwable>) {
7290
when (result) {
7391
is Ok<PGPKey?> -> {
@@ -85,8 +103,8 @@ class PGPKeyImportActivity : AppCompatActivity() {
85103
.setCancelable(false)
86104
.show()
87105
}
88-
is Err<Throwable> -> {
89-
if (result.error is KeyAlreadyExistsException && lastBytes != null) {
106+
is Err<Throwable> -> when {
107+
result.error is KeyAlreadyExistsException && lastBytes != null ->
90108
MaterialAlertDialogBuilder(this)
91109
.setTitle(getString(R.string.pgp_key_import_failed))
92110
.setMessage(getString(R.string.pgp_key_import_failed_replace_message))
@@ -96,14 +114,21 @@ class PGPKeyImportActivity : AppCompatActivity() {
96114
.setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
97115
.setCancelable(false)
98116
.show()
99-
} else {
117+
result.error is NoSecretKeyException && lastBytes != null ->
118+
MaterialAlertDialogBuilder(this)
119+
.setTitle(R.string.pgp_key_import_failed_no_secret)
120+
.setMessage(R.string.pgp_key_import_failed_no_secret_message)
121+
.setPositiveButton(R.string.dialog_yes) { _, _ -> pairDevice(lastBytes!!) }
122+
.setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
123+
.setCancelable(false)
124+
.show()
125+
else ->
100126
MaterialAlertDialogBuilder(this)
101127
.setTitle(getString(R.string.pgp_key_import_failed))
102-
.setMessage(result.error.message)
128+
.setMessage(result.error.message + "\n" + result.error.stackTraceToString())
103129
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
104130
.setCancelable(false)
105131
.show()
106-
}
107132
}
108133
}
109134
}

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@
332332
<string name="select_gpg_key_title">Select\nGPG Key</string>
333333
<string name="select_gpg_key_message">Select a GPG key to initialize your store with</string>
334334
<string name="gpg_key_select">Select key</string>
335+
<string name="pair_hardware_key">Pair hardware key</string>
335336

336337
<!-- SSH port validation -->
337338
<string name="ssh_scheme_needed_title">Potentially incorrect URL</string>
@@ -358,6 +359,8 @@
358359
<string name="password_list_fab_content_description">Create new password or folder</string>
359360
<string name="pgp_key_import_failed">Failed to import PGP key</string>
360361
<string name="pgp_key_import_failed_replace_message">An existing key with this ID was found, do you want to replace it?</string>
362+
<string name="pgp_key_import_failed_no_secret">No secret PGP key</string>
363+
<string name="pgp_key_import_failed_no_secret_message">This is a public key. Would you like to pair a hardware security device?</string>
361364
<string name="pgp_key_import_succeeded">Successfully imported PGP key</string>
362365
<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>
363366
<string name="pref_category_pgp_title">PGP settings</string>

crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ import app.passwordstore.crypto.GpgIdentifier.KeyId
99
import app.passwordstore.crypto.GpgIdentifier.UserId
1010
import com.github.michaelbull.result.get
1111
import com.github.michaelbull.result.runCatching
12+
import java.io.ByteArrayOutputStream
13+
import org.bouncycastle.bcpg.GnuExtendedS2K
14+
import org.bouncycastle.bcpg.S2K
15+
import org.bouncycastle.bcpg.SecretKeyPacket
16+
import org.bouncycastle.bcpg.SecretSubkeyPacket
17+
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags
1218
import org.bouncycastle.openpgp.PGPKeyRing
19+
import org.bouncycastle.openpgp.PGPPublicKey
20+
import org.bouncycastle.openpgp.PGPPublicKeyRing
1321
import org.bouncycastle.openpgp.PGPSecretKey
1422
import org.bouncycastle.openpgp.PGPSecretKeyRing
1523
import org.pgpainless.algorithm.EncryptionPurpose
@@ -37,6 +45,29 @@ public object KeyUtils {
3745
val keyRing = tryParseKeyring(key) ?: return null
3846
return UserId(keyRing.publicKey.userIDs.next())
3947
}
48+
49+
public fun tryCreateStubKey(
50+
publicKey: PGPKey,
51+
serial: ByteArray,
52+
stubFingerprints: List<OpenPgpFingerprint>
53+
): PGPKey? {
54+
val keyRing = tryParseKeyring(publicKey) as? PGPPublicKeyRing ?: return null
55+
val secretKeyRing =
56+
keyRing
57+
.fold(PGPSecretKeyRing(emptyList())) { ring, key ->
58+
PGPSecretKeyRing.insertSecretKey(
59+
ring,
60+
if (stubFingerprints.any { it == OpenPgpFingerprint.parseFromBinary(key.fingerprint) }) {
61+
toCardSecretKey(key, serial)
62+
} else {
63+
toDummySecretKey(key)
64+
}
65+
)
66+
}
67+
68+
return PGPKey(secretKeyRing.encoded)
69+
}
70+
4071
public fun tryGetEncryptionKeyFingerprint(key: PGPKey): OpenPgpFingerprint? {
4172
val keyRing = tryParseKeyring(key) ?: return null
4273
val encryptionSubkey =
@@ -59,3 +90,63 @@ public object KeyUtils {
5990
return info.getSecretKey(encryptionKey.keyID)
6091
}
6192
}
93+
94+
private fun toDummySecretKey(publicKey: PGPPublicKey): PGPSecretKey {
95+
96+
return PGPSecretKey(
97+
if (publicKey.isMasterKey) {
98+
SecretKeyPacket(
99+
publicKey.publicKeyPacket,
100+
SymmetricKeyAlgorithmTags.NULL,
101+
SecretKeyPacket.USAGE_CHECKSUM,
102+
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_NO_PRIVATE_KEY),
103+
byteArrayOf(),
104+
byteArrayOf()
105+
)
106+
} else {
107+
SecretSubkeyPacket(
108+
publicKey.publicKeyPacket,
109+
SymmetricKeyAlgorithmTags.NULL,
110+
SecretKeyPacket.USAGE_CHECKSUM,
111+
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_NO_PRIVATE_KEY),
112+
byteArrayOf(),
113+
byteArrayOf()
114+
)
115+
},
116+
publicKey
117+
)
118+
}
119+
120+
@Suppress("MagicNumber")
121+
private fun toCardSecretKey(publicKey: PGPPublicKey, serial: ByteArray): PGPSecretKey {
122+
return PGPSecretKey(
123+
if (publicKey.isMasterKey) {
124+
SecretKeyPacket(
125+
publicKey.publicKeyPacket,
126+
SymmetricKeyAlgorithmTags.NULL,
127+
SecretKeyPacket.USAGE_CHECKSUM,
128+
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_DIVERT_TO_CARD),
129+
ByteArray(8),
130+
encodeSerial(serial),
131+
)
132+
} else {
133+
SecretSubkeyPacket(
134+
publicKey.publicKeyPacket,
135+
SymmetricKeyAlgorithmTags.NULL,
136+
SecretKeyPacket.USAGE_CHECKSUM,
137+
GnuExtendedS2K(S2K.GNU_PROTECTION_MODE_DIVERT_TO_CARD),
138+
ByteArray(8),
139+
encodeSerial(serial),
140+
)
141+
},
142+
publicKey
143+
)
144+
}
145+
146+
@Suppress("MagicNumber")
147+
private fun encodeSerial(serial: ByteArray): ByteArray {
148+
val out = ByteArrayOutputStream()
149+
out.write(serial.size)
150+
out.write(serial, 0, minOf(16, serial.size))
151+
return out.toByteArray()
152+
}

crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ package app.passwordstore.crypto
99
import androidx.annotation.VisibleForTesting
1010
import app.passwordstore.crypto.KeyUtils.tryGetId
1111
import app.passwordstore.crypto.KeyUtils.tryParseKeyring
12-
import app.passwordstore.crypto.errors.InvalidKeyException
1312
import app.passwordstore.crypto.errors.KeyAlreadyExistsException
1413
import app.passwordstore.crypto.errors.KeyDeletionFailedException
1514
import app.passwordstore.crypto.errors.KeyDirectoryUnavailableException
1615
import app.passwordstore.crypto.errors.KeyNotFoundException
1716
import app.passwordstore.crypto.errors.NoKeysAvailableException
17+
import app.passwordstore.crypto.errors.NoSecretKeyException
1818
import app.passwordstore.util.coroutines.runSuspendCatching
1919
import com.github.michaelbull.result.Result
2020
import com.github.michaelbull.result.unwrap
@@ -40,12 +40,17 @@ constructor(
4040
withContext(dispatcher) {
4141
runSuspendCatching {
4242
if (!keyDirExists()) throw KeyDirectoryUnavailableException
43-
val incomingKeyRing = tryParseKeyring(key) ?: throw InvalidKeyException
43+
val incomingKeyRing = tryParseKeyring(key)
44+
45+
if (incomingKeyRing is PGPPublicKeyRing) {
46+
throw NoSecretKeyException(tryGetId(key)?.toString() ?: "Failed to retrieve key ID")
47+
}
48+
4449
val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION")
4550
if (keyFile.exists()) {
4651
val existingKeyBytes = keyFile.readBytes()
4752
val existingKeyRing =
48-
tryParseKeyring(PGPKey(existingKeyBytes)) ?: throw InvalidKeyException
53+
tryParseKeyring(PGPKey(existingKeyBytes))
4954
when {
5055
existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> {
5156
keyFile.writeBytes(key.contents)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.bouncycastle.bcpg
2+
3+
/**
4+
* Add a constructor for GNU-extended S2K
5+
*
6+
* This extension is documented on GnuPG documentation DETAILS file,
7+
* section "GNU extensions to the S2K algorithm". Its support is
8+
* already present in S2K class but lack for a constructor.
9+
*
10+
* @author Léonard Dallot <[email protected]>
11+
*/
12+
public class GnuExtendedS2K(mode: Int) : S2K(SIMPLE) {
13+
init {
14+
this.type = GNU_DUMMY_S2K
15+
this.protectionMode = mode
16+
}
17+
}

0 commit comments

Comments
 (0)