diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 62e1f8575b..d3c0fcbe4c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -57,6 +57,7 @@ dependencies {
coreLibraryDesugaring(libs.android.desugarJdkLibs)
implementation(projects.autofillParser)
implementation(projects.coroutineUtils)
+ implementation(projects.cryptoHwsecurity)
implementation(projects.cryptoPgpainless)
implementation(projects.formatCommonImpl)
implementation(projects.passgen.diceware)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e187240d8c..0a5192e287 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,6 +50,7 @@
val user = User()
user.data =
diff --git a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt
index f3a82f39d6..5227bcb779 100644
--- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt
+++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt
@@ -7,6 +7,7 @@ package app.passwordstore.data.crypto
import android.content.SharedPreferences
import app.passwordstore.crypto.GpgIdentifier
+import app.passwordstore.crypto.HWSecurityDeviceHandler
import app.passwordstore.crypto.PGPDecryptOptions
import app.passwordstore.crypto.PGPEncryptOptions
import app.passwordstore.crypto.PGPKeyManager
@@ -16,11 +17,13 @@ import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.getAll
+import com.github.michaelbull.result.getOrThrow
import com.github.michaelbull.result.unwrap
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class CryptoRepository
@@ -29,6 +32,7 @@ constructor(
private val pgpKeyManager: PGPKeyManager,
private val pgpCryptoHandler: PGPainlessCryptoHandler,
@SettingsPreferences private val settings: SharedPreferences,
+ private val deviceHandler: HWSecurityDeviceHandler,
) {
suspend fun decrypt(
@@ -50,7 +54,10 @@ constructor(
): Result {
val decryptionOptions = PGPDecryptOptions.Builder().build()
val keys = pgpKeyManager.getAllKeys().unwrap()
- return pgpCryptoHandler.decrypt(keys, password, message, out, decryptionOptions)
+ return pgpCryptoHandler.decrypt(keys, password, message, out, decryptionOptions) {
+ encryptedSessionKey ->
+ runBlocking { deviceHandler.decryptSessionKey(encryptedSessionKey).getOrThrow() }
+ }
}
private suspend fun encryptPgp(
diff --git a/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt b/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt
index 5a863d8d88..aa06be881b 100644
--- a/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt
+++ b/app/src/main/java/app/passwordstore/injection/crypto/CryptoHandlerModule.kt
@@ -5,14 +5,31 @@
package app.passwordstore.injection.crypto
+import android.app.Activity
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.crypto.HWSecurityDeviceHandler
+import app.passwordstore.crypto.HWSecurityManager
import app.passwordstore.crypto.PGPainlessCryptoHandler
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
+import dagger.hilt.android.components.ActivityComponent
+import dagger.hilt.android.scopes.ActivityScoped
@Module
-@InstallIn(SingletonComponent::class)
+@InstallIn(ActivityComponent::class)
object CryptoHandlerModule {
+
+ @Provides
+ @ActivityScoped
+ fun provideDeviceHandler(
+ activity: Activity,
+ deviceManager: HWSecurityManager
+ ): HWSecurityDeviceHandler =
+ HWSecurityDeviceHandler(
+ deviceManager = deviceManager,
+ fragmentManager = (activity as FragmentActivity).supportFragmentManager
+ )
+
@Provides fun providePgpCryptoHandler() = PGPainlessCryptoHandler()
}
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 0b3fdd4e6a..b8f7100f04 100644
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt
@@ -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,17 @@ 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) {
when (result) {
is Ok -> {
@@ -89,26 +108,34 @@ class PGPKeyImportActivity : AppCompatActivity() {
.setCancelable(false)
.show()
}
- is Err -> {
- if (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))
- .setPositiveButton(R.string.dialog_yes) { _, _ ->
- handleImportResult(runCatching { importKey(lastBytes!!, replace = true) })
- }
- .setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
- .setCancelable(false)
- .show()
- } else {
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_failed))
- .setMessage(result.error.message)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .setCancelable(false)
- .show()
+ is Err ->
+ 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))
+ .setPositiveButton(R.string.dialog_yes) { _, _ ->
+ handleImportResult(runCatching { importKey(lastBytes!!, replace = true) })
+ }
+ .setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
+ .setCancelable(false)
+ .show()
+ 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 + "\n" + result.error.stackTraceToString())
+ .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
+ .setCancelable(false)
+ .show()
}
- }
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3840c0bc64..8ac284d10e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -334,6 +334,7 @@
Select\nGPG Key
Select a GPG key to initialize your store with
Select key
+ Pair hardware key
Potentially incorrect URL
@@ -360,6 +361,8 @@
Create new password or folder
Failed to import PGP key
An existing key with this ID was found, do you want to replace it?
+ No secret PGP key
+ This is a public key. Would you like to pair a hardware security device?
Successfully imported PGP key
The key ID of the imported key is given below, please review it for correctness:\n%1$s
PGP settings
diff --git a/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt b/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt
index 898cf0585b..afb8cbd0a6 100644
--- a/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt
+++ b/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt
@@ -11,7 +11,13 @@ import java.io.InputStream
import java.io.OutputStream
/** Generic interface to implement cryptographic operations on top of. */
-public interface CryptoHandler {
+public interface CryptoHandler<
+ Key,
+ EncOpts : CryptoOptions,
+ DecryptOpts : CryptoOptions,
+ EncryptedSessionKey,
+ DecryptedSessionKey,
+> {
/**
* Decrypt the given [ciphertextStream] using a set of potential [keys] and [passphrase], and
@@ -25,6 +31,7 @@ public interface CryptoHandler DecryptedSessionKey,
): Result
/**
diff --git a/crypto-common/src/main/kotlin/app/passwordstore/crypto/DeviceHandler.kt b/crypto-common/src/main/kotlin/app/passwordstore/crypto/DeviceHandler.kt
new file mode 100644
index 0000000000..74eb0cfa50
--- /dev/null
+++ b/crypto-common/src/main/kotlin/app/passwordstore/crypto/DeviceHandler.kt
@@ -0,0 +1,12 @@
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.errors.DeviceHandlerException
+import com.github.michaelbull.result.Result
+
+public interface DeviceHandler {
+ public suspend fun pairWithPublicKey(publicKey: Key): Result
+
+ public suspend fun decryptSessionKey(
+ encryptedSessionKey: EncryptedSessionKey
+ ): Result
+}
diff --git a/crypto-common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt b/crypto-common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt
index 81bdf95f84..56d6b77031 100644
--- a/crypto-common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt
+++ b/crypto-common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt
@@ -6,7 +6,8 @@ public sealed class CryptoException(message: String? = null, cause: Throwable? =
Exception(message, cause)
/** Sealed exception types for [KeyManager]. */
-public sealed class KeyManagerException(message: String? = null) : CryptoException(message)
+public sealed class KeyManagerException(message: String? = null, cause: Throwable? = null) :
+ CryptoException(message, cause)
/** Store contains no keys. */
public object NoKeysAvailableException : KeyManagerException("No keys were found")
@@ -19,8 +20,8 @@ public object KeyDirectoryUnavailableException :
public object KeyDeletionFailedException : KeyManagerException("Couldn't delete the key file")
/** Failed to parse the key as a known type. */
-public object InvalidKeyException :
- KeyManagerException("Given key cannot be parsed as a known key type")
+public class InvalidKeyException(cause: Throwable? = null) :
+ KeyManagerException("Given key cannot be parsed as a known key type", cause)
/** No key matching `keyId` could be found. */
public class KeyNotFoundException(keyId: String) :
@@ -30,6 +31,9 @@ public class KeyNotFoundException(keyId: String) :
public class KeyAlreadyExistsException(keyId: String) :
KeyManagerException("Pre-existing key was found for $keyId")
+public class NoSecretKeyException(keyId: String) :
+ KeyManagerException("No secret keys found for $keyId")
+
/** Sealed exception types for [app.passwordstore.crypto.CryptoHandler]. */
public sealed class CryptoHandlerException(message: String? = null, cause: Throwable? = null) :
CryptoException(message, cause)
@@ -42,3 +46,33 @@ public class NoKeysProvided(message: String?) : CryptoHandlerException(message,
/** An unexpected error that cannot be mapped to a known type. */
public class UnknownError(cause: Throwable) : CryptoHandlerException(null, cause)
+
+public class KeySpecific(public val key: Any, cause: Throwable?) :
+ CryptoHandlerException(key.toString(), cause)
+
+/** Wrapper containing possibly multiple child exceptions via [suppressedExceptions]. */
+public class MultipleKeySpecific(message: String?, public val errors: List) :
+ CryptoHandlerException(message) {
+ init {
+ for (error in errors) {
+ addSuppressed(error)
+ }
+ }
+}
+
+/** Sealed exception types for [app.passwordstore.crypto.DeviceHandler]. */
+public sealed class DeviceHandlerException(message: String? = null, cause: Throwable? = null) :
+ CryptoHandlerException(message, cause)
+
+/** The device crypto operation was canceled by the user. */
+public class DeviceOperationCanceled(message: String) : DeviceHandlerException(message, null)
+
+/** The device crypto operation failed. */
+public class DeviceOperationFailed(message: String?, cause: Throwable? = null) :
+ DeviceHandlerException(message, cause)
+
+/** The device's key fingerprint doesn't match the fingerprint we are trying to pair it to. */
+public class DeviceFingerprintMismatch(
+ public val publicFingerprint: String,
+ public val deviceFingerprint: String,
+) : DeviceHandlerException()
diff --git a/crypto-hwsecurity/api/crypto-hwsecurity.api b/crypto-hwsecurity/api/crypto-hwsecurity.api
new file mode 100644
index 0000000000..cba8a0ea0a
--- /dev/null
+++ b/crypto-hwsecurity/api/crypto-hwsecurity.api
@@ -0,0 +1,64 @@
+public final class app/passwordstore/crypto/DeviceIdentifier {
+ public static final synthetic fun box-impl ([B)Lapp/passwordstore/crypto/DeviceIdentifier;
+ public static fun constructor-impl ([B)[B
+ public fun equals (Ljava/lang/Object;)Z
+ public static fun equals-impl ([BLjava/lang/Object;)Z
+ public static final fun equals-impl0 ([B[B)Z
+ public static final fun getManufacturer-impl ([B)I
+ public static final fun getOpenPgpVersion-impl ([B)Ljava/lang/String;
+ public static final fun getSerialNumber-impl ([B)[B
+ public fun hashCode ()I
+ public static fun hashCode-impl ([B)I
+ public fun toString ()Ljava/lang/String;
+ public static fun toString-impl ([B)Ljava/lang/String;
+ public final synthetic fun unbox-impl ()[B
+}
+
+public final class app/passwordstore/crypto/DeviceIdentifierKt {
+ public static final fun getManufacturerName-0zlKB64 ([B)Ljava/lang/String;
+}
+
+public final class app/passwordstore/crypto/DeviceKeyInfo {
+ public fun (Lorg/pgpainless/algorithm/PublicKeyAlgorithm;Lorg/pgpainless/key/OpenPgpFingerprint;)V
+ public final fun component1 ()Lorg/pgpainless/algorithm/PublicKeyAlgorithm;
+ public final fun component2 ()Lorg/pgpainless/key/OpenPgpFingerprint;
+ public final fun copy (Lorg/pgpainless/algorithm/PublicKeyAlgorithm;Lorg/pgpainless/key/OpenPgpFingerprint;)Lapp/passwordstore/crypto/DeviceKeyInfo;
+ public static synthetic fun copy$default (Lapp/passwordstore/crypto/DeviceKeyInfo;Lorg/pgpainless/algorithm/PublicKeyAlgorithm;Lorg/pgpainless/key/OpenPgpFingerprint;ILjava/lang/Object;)Lapp/passwordstore/crypto/DeviceKeyInfo;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getAlgorithm ()Lorg/pgpainless/algorithm/PublicKeyAlgorithm;
+ public final fun getFingerprint ()Lorg/pgpainless/key/OpenPgpFingerprint;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class app/passwordstore/crypto/HWSecurityDevice {
+ public synthetic fun ([BLjava/lang/String;Lapp/passwordstore/crypto/DeviceKeyInfo;Lapp/passwordstore/crypto/DeviceKeyInfo;Lapp/passwordstore/crypto/DeviceKeyInfo;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun getAuthKeyInfo ()Lapp/passwordstore/crypto/DeviceKeyInfo;
+ public final fun getEncryptKeyInfo ()Lapp/passwordstore/crypto/DeviceKeyInfo;
+ public final fun getId-z5xZLwU ()[B
+ public final fun getName ()Ljava/lang/String;
+ public final fun getSignKeyInfo ()Lapp/passwordstore/crypto/DeviceKeyInfo;
+}
+
+public final class app/passwordstore/crypto/HWSecurityDeviceHandler : app/passwordstore/crypto/DeviceHandler {
+ public fun (Lapp/passwordstore/crypto/HWSecurityManager;Landroidx/fragment/app/FragmentManager;)V
+ public fun decryptSessionKey (Lapp/passwordstore/crypto/PGPEncryptedSessionKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public synthetic fun decryptSessionKey (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public synthetic fun pairWithPublicKey (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public fun pairWithPublicKey-P2gA-3I ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class app/passwordstore/crypto/HWSecurityException : org/pgpainless/decryption_verification/HardwareSecurity$HardwareSecurityException {
+ public fun (Ljava/lang/String;)V
+ public fun getMessage ()Ljava/lang/String;
+}
+
+public final class app/passwordstore/crypto/HWSecurityManager {
+ public fun (Landroid/app/Application;)V
+ public final fun decryptSessionKey (Landroidx/fragment/app/FragmentManager;Lapp/passwordstore/crypto/PGPEncryptedSessionKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public final fun init (Z)V
+ public static synthetic fun init$default (Lapp/passwordstore/crypto/HWSecurityManager;ZILjava/lang/Object;)V
+ public final fun isHardwareAvailable ()Z
+ public final fun readDevice (Landroidx/fragment/app/FragmentManager;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
diff --git a/crypto-hwsecurity/build.gradle.kts b/crypto-hwsecurity/build.gradle.kts
new file mode 100644
index 0000000000..6f9e4ebc36
--- /dev/null
+++ b/crypto-hwsecurity/build.gradle.kts
@@ -0,0 +1,27 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+plugins {
+ id("com.github.android-password-store.android-library")
+ id("com.github.android-password-store.kotlin-android")
+ id("com.github.android-password-store.kotlin-library")
+}
+
+android {
+ namespace = "app.passwordstore.crypto.hwsecurity"
+}
+
+dependencies {
+ implementation(projects.cryptoPgpainless)
+ implementation(libs.androidx.activity.ktx)
+ implementation(libs.androidx.annotation)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.material)
+ implementation(libs.aps.hwsecurity.openpgp)
+ implementation(libs.aps.hwsecurity.ui)
+ implementation(libs.dagger.hilt.android)
+ implementation(libs.kotlin.coroutines.android)
+ implementation(libs.thirdparty.kotlinResult)
+}
diff --git a/crypto-hwsecurity/src/main/AndroidManifest.xml b/crypto-hwsecurity/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/crypto-hwsecurity/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceIdentifier.kt b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceIdentifier.kt
new file mode 100644
index 0000000000..c3300b10b3
--- /dev/null
+++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceIdentifier.kt
@@ -0,0 +1,56 @@
+@file:Suppress("MagicNumber")
+
+package app.passwordstore.crypto
+
+@JvmInline
+public value class DeviceIdentifier(private val aid: ByteArray) {
+ init {
+ require(aid.size == 16) { "Invalid device application identifier" }
+ }
+
+ public val openPgpVersion: String
+ get() = "${aid[6]}.${aid[7]}"
+
+ public val manufacturer: Int
+ get() = ((aid[8].toInt() and 0xff) shl 8) or (aid[9].toInt() and 0xff)
+
+ public val serialNumber: ByteArray
+ get() = aid.sliceArray(10..13)
+}
+
+// https://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob;f=scd/app-openpgp.c;hb=HEAD#l292
+public val DeviceIdentifier.manufacturerName: String
+ get() =
+ when (manufacturer) {
+ 0x0001 -> "PPC Card Systems"
+ 0x0002 -> "Prism"
+ 0x0003 -> "OpenFortress"
+ 0x0004 -> "Wewid"
+ 0x0005 -> "ZeitControl"
+ 0x0006 -> "Yubico"
+ 0x0007 -> "OpenKMS"
+ 0x0008 -> "LogoEmail"
+ 0x0009 -> "Fidesmo"
+ 0x000A -> "VivoKey"
+ 0x000B -> "Feitian Technologies"
+ 0x000D -> "Dangerous Things"
+ 0x000E -> "Excelsecu"
+ 0x000F -> "Nitrokey"
+ 0x002A -> "Magrathea"
+ 0x0042 -> "GnuPG e.V."
+ 0x1337 -> "Warsaw Hackerspace"
+ 0x2342 -> "warpzone"
+ 0x4354 -> "Confidential Technologies"
+ 0x5343 -> "SSE Carte à puce"
+ 0x5443 -> "TIF-IT e.V."
+ 0x63AF -> "Trustica"
+ 0xBA53 -> "c-base e.V."
+ 0xBD0E -> "Paranoidlabs"
+ 0xCA05 -> "Atos CardOS"
+ 0xF1D0 -> "CanoKeys"
+ 0xF517 -> "FSIJ"
+ 0xF5EC -> "F-Secure"
+ 0x0000,
+ 0xFFFF -> "test card"
+ else -> "unknown"
+ }
diff --git a/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceKeyInfo.kt b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceKeyInfo.kt
new file mode 100644
index 0000000000..c08b43f3ec
--- /dev/null
+++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceKeyInfo.kt
@@ -0,0 +1,27 @@
+package app.passwordstore.crypto
+
+import org.pgpainless.algorithm.PublicKeyAlgorithm
+import org.pgpainless.key.OpenPgpFingerprint
+
+public data class DeviceKeyInfo(
+ public val algorithm: PublicKeyAlgorithm,
+ public val fingerprint: OpenPgpFingerprint
+) {
+ override fun toString(): String = "${algorithm.displayName()} ${fingerprint.prettyPrint()}"
+}
+
+@Suppress("DEPRECATION")
+private fun PublicKeyAlgorithm.displayName(): String =
+ when (this) {
+ PublicKeyAlgorithm.RSA_GENERAL -> "RSA"
+ PublicKeyAlgorithm.RSA_ENCRYPT -> "RSA (encrypt-only, deprecated)"
+ PublicKeyAlgorithm.RSA_SIGN -> "RSA (sign-only, deprecated)"
+ PublicKeyAlgorithm.ELGAMAL_ENCRYPT -> "ElGamal"
+ PublicKeyAlgorithm.DSA -> "DSA"
+ PublicKeyAlgorithm.EC -> "EC (deprecated)"
+ PublicKeyAlgorithm.ECDH -> "ECDH"
+ PublicKeyAlgorithm.ECDSA -> "ECDSA"
+ PublicKeyAlgorithm.ELGAMAL_GENERAL -> "ElGamal (general, deprecated)"
+ PublicKeyAlgorithm.DIFFIE_HELLMAN -> "Diffie-Hellman"
+ PublicKeyAlgorithm.EDDSA -> "EDDSA"
+ }
diff --git a/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDevice.kt b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDevice.kt
new file mode 100644
index 0000000000..f647fcdc63
--- /dev/null
+++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDevice.kt
@@ -0,0 +1,45 @@
+package app.passwordstore.crypto
+
+import de.cotech.hw.openpgp.OpenPgpSecurityKey
+import de.cotech.hw.openpgp.internal.openpgp.EcKeyFormat
+import de.cotech.hw.openpgp.internal.openpgp.KeyFormat
+import de.cotech.hw.openpgp.internal.openpgp.RsaKeyFormat
+import org.pgpainless.algorithm.PublicKeyAlgorithm
+import org.pgpainless.key.OpenPgpFingerprint
+
+public class HWSecurityDevice(
+ public val id: DeviceIdentifier,
+ public val name: String,
+ public val encryptKeyInfo: DeviceKeyInfo?,
+ public val signKeyInfo: DeviceKeyInfo?,
+ public val authKeyInfo: DeviceKeyInfo?,
+)
+
+internal fun OpenPgpSecurityKey.toDevice(): HWSecurityDevice =
+ with(openPgpAppletConnection.openPgpCapabilities) {
+ HWSecurityDevice(
+ id = DeviceIdentifier(aid),
+ name = securityKeyName,
+ encryptKeyInfo = keyInfo(encryptKeyFormat, fingerprintEncrypt),
+ signKeyInfo = keyInfo(signKeyFormat, fingerprintSign),
+ authKeyInfo = keyInfo(authKeyFormat, fingerprintAuth)
+ )
+ }
+
+internal fun keyInfo(format: KeyFormat?, fingerprint: ByteArray?): DeviceKeyInfo? {
+ if (format == null || fingerprint == null) return null
+ return DeviceKeyInfo(format.toKeyAlgorithm(), OpenPgpFingerprint.parseFromBinary(fingerprint))
+}
+
+internal fun KeyFormat.toKeyAlgorithm(): PublicKeyAlgorithm =
+ when (this) {
+ is RsaKeyFormat -> PublicKeyAlgorithm.RSA_GENERAL
+ is EcKeyFormat ->
+ when (val id = algorithmId()) {
+ PublicKeyAlgorithm.ECDH.algorithmId -> PublicKeyAlgorithm.ECDH
+ PublicKeyAlgorithm.ECDSA.algorithmId -> PublicKeyAlgorithm.ECDSA
+ PublicKeyAlgorithm.EDDSA.algorithmId -> PublicKeyAlgorithm.EDDSA
+ else -> throw IllegalArgumentException("Unknown EC algorithm ID: $id")
+ }
+ else -> throw IllegalArgumentException("Unknown key format")
+ }
diff --git a/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDeviceHandler.kt b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDeviceHandler.kt
new file mode 100644
index 0000000000..9c4c06d88e
--- /dev/null
+++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDeviceHandler.kt
@@ -0,0 +1,54 @@
+package app.passwordstore.crypto
+
+import androidx.fragment.app.FragmentManager
+import app.passwordstore.crypto.errors.DeviceFingerprintMismatch
+import app.passwordstore.crypto.errors.DeviceHandlerException
+import app.passwordstore.crypto.errors.DeviceOperationFailed
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.runCatching
+import org.bouncycastle.openpgp.PGPSessionKey
+
+public class HWSecurityDeviceHandler(
+ private val deviceManager: HWSecurityManager,
+ private val fragmentManager: FragmentManager,
+) : DeviceHandler {
+
+ override suspend fun pairWithPublicKey(
+ publicKey: PGPKey
+ ): Result =
+ runCatching {
+ val publicFingerprint =
+ KeyUtils.tryGetEncryptionKeyFingerprint(publicKey)
+ ?: throw DeviceOperationFailed("Failed to get encryption key fingerprint")
+ val device = deviceManager.readDevice(fragmentManager)
+ if (publicFingerprint != device.encryptKeyInfo?.fingerprint) {
+ throw DeviceFingerprintMismatch(
+ publicFingerprint.toString(),
+ device.encryptKeyInfo?.fingerprint?.toString() ?: "Missing encryption key"
+ )
+ }
+ KeyUtils.tryCreateStubKey(
+ publicKey,
+ device.id.serialNumber,
+ listOfNotNull(
+ device.encryptKeyInfo.fingerprint,
+ device.signKeyInfo?.fingerprint,
+ device.authKeyInfo?.fingerprint
+ )
+ )
+ ?: throw DeviceOperationFailed("Failed to create stub secret key")
+ }
+ .mapError { error ->
+ when (error) {
+ is DeviceHandlerException -> error
+ else -> DeviceOperationFailed("Failed to pair device", error)
+ }
+ }
+
+ override suspend fun decryptSessionKey(
+ encryptedSessionKey: PGPEncryptedSessionKey
+ ): Result =
+ runCatching { deviceManager.decryptSessionKey(fragmentManager, encryptedSessionKey) }
+ .mapError { error -> DeviceOperationFailed("Failed to decrypt session key", error) }
+}
diff --git a/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityManager.kt b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityManager.kt
new file mode 100644
index 0000000000..f41ddb1d3b
--- /dev/null
+++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityManager.kt
@@ -0,0 +1,177 @@
+package app.passwordstore.crypto
+
+import android.app.Application
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import de.cotech.hw.SecurityKeyManager
+import de.cotech.hw.SecurityKeyManagerConfig
+import de.cotech.hw.openpgp.OpenPgpSecurityKey
+import de.cotech.hw.openpgp.OpenPgpSecurityKeyDialogFragment
+import de.cotech.hw.openpgp.internal.operations.PsoDecryptOp
+import de.cotech.hw.secrets.ByteSecret
+import de.cotech.hw.secrets.PinProvider
+import de.cotech.hw.ui.SecurityKeyDialogInterface
+import de.cotech.hw.ui.SecurityKeyDialogInterface.SecurityKeyDialogCallback
+import de.cotech.hw.ui.SecurityKeyDialogOptions
+import de.cotech.hw.ui.SecurityKeyDialogOptions.PinMode
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.completeWith
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.bouncycastle.bcpg.ECDHPublicBCPGKey
+import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags
+import org.bouncycastle.openpgp.PGPSessionKey
+import org.pgpainless.algorithm.PublicKeyAlgorithm
+import org.pgpainless.decryption_verification.HardwareSecurity.HardwareSecurityException
+
+@Singleton
+public class HWSecurityManager
+@Inject
+constructor(
+ private val application: Application,
+) {
+
+ private val securityKeyManager: SecurityKeyManager by lazy { SecurityKeyManager.getInstance() }
+
+ public fun init(enableLogging: Boolean = false) {
+ securityKeyManager.init(
+ application,
+ SecurityKeyManagerConfig.Builder().setEnableDebugLogging(enableLogging).build()
+ )
+ }
+
+ public fun isHardwareAvailable(): Boolean {
+ return securityKeyManager.isNfcHardwareAvailable || securityKeyManager.isUsbHostModeAvailable
+ }
+
+ private suspend fun withOpenDevice(
+ fragmentManager: FragmentManager,
+ pinMode: PinMode,
+ block: suspend (OpenPgpSecurityKey, PinProvider?) -> T
+ ): T =
+ withContext(Dispatchers.Main) {
+ val fragment =
+ OpenPgpSecurityKeyDialogFragment.newInstance(
+ SecurityKeyDialogOptions.builder()
+ .setPinMode(pinMode)
+ .setFormFactor(SecurityKeyDialogOptions.FormFactor.SECURITY_KEY)
+ .setPreventScreenshots(false) // TODO
+ .build()
+ )
+
+ val deferred = CompletableDeferred()
+
+ fragment.setSecurityKeyDialogCallback(
+ object : SecurityKeyDialogCallback {
+ private var result: Result = Result.failure(CancellationException())
+
+ override fun onSecurityKeyDialogDiscovered(
+ dialogInterface: SecurityKeyDialogInterface,
+ securityKey: OpenPgpSecurityKey,
+ pinProvider: PinProvider?
+ ) {
+ fragment.lifecycleScope.launch {
+ fragment.repeatOnLifecycle(Lifecycle.State.CREATED) {
+ runCatching {
+ fragment.postProgressMessage("Decrypting password entry")
+ result = Result.success(block(securityKey, pinProvider))
+ fragment.successAndDismiss()
+ }
+ .onFailure { e ->
+ when (e) {
+ is IOException -> fragment.postError(e)
+ else -> {
+ result = Result.failure(e)
+ fragment.dismiss()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onSecurityKeyDialogCancel() {
+ deferred.cancel()
+ }
+
+ override fun onSecurityKeyDialogDismiss() {
+ deferred.completeWith(result)
+ }
+ }
+ )
+
+ fragment.show(fragmentManager)
+
+ val value = deferred.await()
+ // HWSecurity doesn't clean up fast enough for LeakCanary's liking.
+ securityKeyManager.clearConnectedSecurityKeys()
+ value
+ }
+
+ public suspend fun readDevice(fragmentManager: FragmentManager): HWSecurityDevice =
+ withOpenDevice(fragmentManager, PinMode.NO_PIN_INPUT) { securityKey, _ ->
+ securityKey.toDevice()
+ }
+
+ public suspend fun decryptSessionKey(
+ fragmentManager: FragmentManager,
+ encryptedSessionKey: PGPEncryptedSessionKey
+ ): PGPSessionKey =
+ withOpenDevice(fragmentManager, PinMode.PIN_INPUT) { securityKey, pinProvider ->
+ val pin =
+ pinProvider?.getPin(securityKey.openPgpInstanceAid)
+ ?: throw HWSecurityException("PIN required for decryption")
+
+ val contents =
+ withContext(Dispatchers.IO) {
+ when (val a = encryptedSessionKey.algorithm) {
+ PublicKeyAlgorithm.RSA_GENERAL ->
+ decryptSessionKeyRsa(encryptedSessionKey, securityKey, pin)
+ PublicKeyAlgorithm.ECDH -> decryptSessionKeyEcdh(encryptedSessionKey, securityKey, pin)
+ else -> throw HWSecurityException("Unsupported encryption algorithm: ${a.name}")
+ }
+ }
+
+ PGPSessionKey(encryptedSessionKey.algorithm.algorithmId, contents)
+ }
+}
+
+public class HWSecurityException(override val message: String) : HardwareSecurityException()
+
+private fun decryptSessionKeyRsa(
+ encryptedSessionKey: PGPEncryptedSessionKey,
+ securityKey: OpenPgpSecurityKey,
+ pin: ByteSecret,
+): ByteArray {
+ return PsoDecryptOp.create(securityKey.openPgpAppletConnection)
+ .verifyAndDecryptSessionKey(pin, encryptedSessionKey.contents, 0, null)
+}
+
+@Suppress("MagicNumber")
+private fun decryptSessionKeyEcdh(
+ encryptedSessionKey: PGPEncryptedSessionKey,
+ securityKey: OpenPgpSecurityKey,
+ pin: ByteSecret,
+): ByteArray {
+ val key =
+ encryptedSessionKey.publicKey.publicKeyPacket.key.run {
+ this as? ECDHPublicBCPGKey
+ ?: throw HWSecurityException("Expected ECDHPublicBCPGKey but got ${this::class.simpleName}")
+ }
+ val symmetricKeySize =
+ when (val id = key.symmetricKeyAlgorithm.toInt()) {
+ SymmetricKeyAlgorithmTags.AES_128 -> 128
+ SymmetricKeyAlgorithmTags.AES_192 -> 192
+ SymmetricKeyAlgorithmTags.AES_256 -> 256
+ else -> throw HWSecurityException("Unexpected symmetric key algorithm: $id")
+ }
+ return PsoDecryptOp.create(securityKey.openPgpAppletConnection)
+ .verifyAndDecryptSessionKey(pin, encryptedSessionKey.contents, symmetricKeySize, byteArrayOf())
+}
diff --git a/crypto-pgpainless/build.gradle.kts b/crypto-pgpainless/build.gradle.kts
index 56c0c76219..8df98c7b4f 100644
--- a/crypto-pgpainless/build.gradle.kts
+++ b/crypto-pgpainless/build.gradle.kts
@@ -16,7 +16,7 @@ dependencies {
implementation(libs.dagger.hilt.core)
implementation(libs.kotlin.coroutines.core)
implementation(libs.thirdparty.kotlinResult)
- implementation(libs.thirdparty.pgpainless)
+ api(libs.thirdparty.pgpainless)
testImplementation(libs.bundles.testDependencies)
testImplementation(libs.kotlin.coroutines.test)
testImplementation(libs.testing.testparameterinjector)
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 47c06c4ffb..640c5e418f 100644
--- a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt
+++ b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt
@@ -9,7 +9,20 @@ 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
+import org.pgpainless.key.OpenPgpFingerprint
+import org.pgpainless.key.info.KeyRingInfo
import org.pgpainless.key.parsing.KeyRingReader
/** Utility methods to deal with [PGPKey]s. */
@@ -32,4 +45,107 @@ public object KeyUtils {
val keyRing = tryParseKeyring(key) ?: return null
return UserId(keyRing.publicKey.userIDs.next())
}
+
+ public fun tryCreateStubKey(
+ publicKey: PGPKey,
+ serial: ByteArray,
+ stubFingerprints: List
+ ): 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 =
+ KeyRingInfo(keyRing).getEncryptionSubkeys(EncryptionPurpose.ANY).lastOrNull()
+ return encryptionSubkey?.let(OpenPgpFingerprint::of)
+ }
+
+ public fun tryGetEncryptionKey(key: PGPKey): PGPSecretKey? {
+ val keyRing = tryParseKeyring(key) as? PGPSecretKeyRing ?: return null
+ return tryGetEncryptionKey(keyRing)
+ }
+
+ public fun tryGetEncryptionKey(keyRing: PGPSecretKeyRing): PGPSecretKey? {
+ val info = KeyRingInfo(keyRing)
+ return tryGetEncryptionKey(info)
+ }
+
+ private fun tryGetEncryptionKey(info: KeyRingInfo): PGPSecretKey? {
+ val encryptionKey = info.getEncryptionSubkeys(EncryptionPurpose.ANY).lastOrNull() ?: return null
+ 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()
}
diff --git a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt
index be2ec47417..49a3d3194a 100644
--- a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt
+++ b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt
@@ -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,16 @@ 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
+ val existingKeyRing = tryParseKeyring(PGPKey(existingKeyBytes))
when {
existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> {
keyFile.writeBytes(key.contents)
diff --git a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPSessionKey.kt b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPSessionKey.kt
new file mode 100644
index 0000000000..fd1ba4027b
--- /dev/null
+++ b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPSessionKey.kt
@@ -0,0 +1,14 @@
+package app.passwordstore.crypto
+
+import org.bouncycastle.openpgp.PGPPublicKey
+import org.bouncycastle.openpgp.PGPSessionKey
+import org.pgpainless.algorithm.PublicKeyAlgorithm
+
+public class PGPEncryptedSessionKey(
+ public val publicKey: PGPPublicKey,
+ public val algorithm: PublicKeyAlgorithm,
+ public val contents: ByteArray
+)
+
+public fun PGPSessionKey(algorithm: PublicKeyAlgorithm, sessionKey: ByteArray): PGPSessionKey =
+ PGPSessionKey(algorithm.algorithmId, sessionKey)
diff --git a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt
index e1084decdc..e0b14367b6 100644
--- a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt
+++ b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt
@@ -15,20 +15,32 @@ import com.github.michaelbull.result.runCatching
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
+import org.bouncycastle.CachingPublicKeyDataDecryptorFactory
import org.bouncycastle.openpgp.PGPPublicKeyRing
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection
+import org.bouncycastle.openpgp.PGPSessionKey
+import org.gnupg.GnuPGDummyKeyUtil
import org.pgpainless.PGPainless
+import org.pgpainless.algorithm.PublicKeyAlgorithm
import org.pgpainless.decryption_verification.ConsumerOptions
+import org.pgpainless.decryption_verification.HardwareSecurity.HardwareDataDecryptorFactory
import org.pgpainless.encryption_signing.EncryptionOptions
import org.pgpainless.encryption_signing.ProducerOptions
import org.pgpainless.exception.WrongPassphraseException
+import org.pgpainless.key.SubkeyIdentifier
import org.pgpainless.key.protection.SecretKeyRingProtector
import org.pgpainless.util.Passphrase
public class PGPainlessCryptoHandler @Inject constructor() :
- CryptoHandler {
+ CryptoHandler<
+ PGPKey,
+ PGPEncryptOptions,
+ PGPDecryptOptions,
+ PGPEncryptedSessionKey,
+ PGPSessionKey,
+ > {
public override fun decrypt(
keys: List,
@@ -36,6 +48,7 @@ public class PGPainlessCryptoHandler @Inject constructor() :
ciphertextStream: InputStream,
outputStream: OutputStream,
options: PGPDecryptOptions,
+ onDecryptSessionKey: (PGPEncryptedSessionKey) -> PGPSessionKey,
): Result =
runCatching {
if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption")
@@ -44,18 +57,47 @@ public class PGPainlessCryptoHandler @Inject constructor() :
.map { key -> PGPainless.readKeyRing().secretKeyRing(key.contents) }
.run(::PGPSecretKeyRingCollection)
val protector = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(passphrase))
+ val hardwareBackedKeys =
+ keyringCollection.mapNotNull { keyring ->
+ keyring?.takeIf {
+ it.publicKey.keyID in
+ GnuPGDummyKeyUtil.getIdsOfKeysWithGnuPGS2KDivertedToCard(keyring)
+ .map(SubkeyIdentifier::getKeyId)
+ }
+ }
PGPainless.decryptAndOrVerify()
.onInputStream(ciphertextStream)
.withOptions(
- ConsumerOptions()
- .addDecryptionKeys(keyringCollection, protector)
- .addDecryptionPassphrase(Passphrase.fromPassword(passphrase))
+ ConsumerOptions().apply {
+ for (key in hardwareBackedKeys) {
+ addCustomDecryptorFactory(
+ CachingPublicKeyDataDecryptorFactory(
+ HardwareDataDecryptorFactory(SubkeyIdentifier(key)) {
+ _,
+ keyAlgorithm,
+ secKeyData ->
+ onDecryptSessionKey(
+ PGPEncryptedSessionKey(
+ key.publicKey,
+ PublicKeyAlgorithm.requireFromId(keyAlgorithm),
+ secKeyData
+ )
+ )
+ .key
+ }
+ )
+ )
+ }
+ addDecryptionKeys(keyringCollection, protector)
+ addDecryptionPassphrase(Passphrase.fromPassword(passphrase))
+ }
)
.use { decryptionStream -> decryptionStream.copyTo(outputStream) }
return@runCatching
}
.mapError { error ->
when (error) {
+ is CryptoHandlerException -> error
is WrongPassphraseException -> IncorrectPassphraseException(error)
else -> UnknownError(error)
}
diff --git a/crypto-pgpainless/src/main/kotlin/org/bouncycastle/CachingPublicKeyDataDecryptorFactory.kt b/crypto-pgpainless/src/main/kotlin/org/bouncycastle/CachingPublicKeyDataDecryptorFactory.kt
new file mode 100644
index 0000000000..090020e773
--- /dev/null
+++ b/crypto-pgpainless/src/main/kotlin/org/bouncycastle/CachingPublicKeyDataDecryptorFactory.kt
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2022 Paul Schaub
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package org.bouncycastle
+
+import org.bouncycastle.openpgp.PGPException
+import org.bouncycastle.openpgp.operator.PublicKeyDataDecryptorFactory
+import org.bouncycastle.util.encoders.Base64
+import org.pgpainless.decryption_verification.CustomPublicKeyDataDecryptorFactory
+
+/**
+ * Implementation of the [PublicKeyDataDecryptorFactory] which caches decrypted session keys. That
+ * way, if a message needs to be decrypted multiple times, expensive private key operations can be
+ * omitted.
+ *
+ * This implementation changes the behavior or [.recoverSessionData] to first return any cache hits.
+ * If no hit is found, the method call is delegated to the underlying
+ * [PublicKeyDataDecryptorFactory]. The result of that is then placed in the cache and returned.
+ *
+ * TODO: Do we also cache invalid session keys?
+ */
+public class CachingPublicKeyDataDecryptorFactory(
+ private val factory: CustomPublicKeyDataDecryptorFactory
+) : CustomPublicKeyDataDecryptorFactory by factory {
+
+ private val cachedSessionKeys: MutableMap = mutableMapOf()
+
+ @Throws(PGPException::class)
+ override fun recoverSessionData(keyAlgorithm: Int, secKeyData: Array): ByteArray {
+ return cachedSessionKeys
+ .getOrPut(cacheKey(secKeyData)) { factory.recoverSessionData(keyAlgorithm, secKeyData) }
+ .copy()
+ }
+
+ public fun clear() {
+ cachedSessionKeys.clear()
+ }
+
+ private companion object {
+ fun cacheKey(secKeyData: Array): String {
+ return Base64.toBase64String(secKeyData[0])
+ }
+
+ private fun ByteArray.copy(): ByteArray {
+ val copy = ByteArray(size)
+ System.arraycopy(this, 0, copy, 0, copy.size)
+ return copy
+ }
+ }
+}
diff --git a/crypto-pgpainless/src/main/kotlin/org/bouncycastle/bcpg/GnuExtendedS2K.kt b/crypto-pgpainless/src/main/kotlin/org/bouncycastle/bcpg/GnuExtendedS2K.kt
new file mode 100644
index 0000000000..234ca5c9f8
--- /dev/null
+++ b/crypto-pgpainless/src/main/kotlin/org/bouncycastle/bcpg/GnuExtendedS2K.kt
@@ -0,0 +1,16 @@
+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
+ */
+public class GnuExtendedS2K(mode: Int) : S2K(SIMPLE) {
+ init {
+ this.type = GNU_DUMMY_S2K
+ this.protectionMode = mode
+ }
+}
diff --git a/detekt-baselines/app.xml b/detekt-baselines/app.xml
index 2b6993f817..c4269062cd 100644
--- a/detekt-baselines/app.xml
+++ b/detekt-baselines/app.xml
@@ -96,6 +96,5 @@
UseCheckOrError:SshKey.kt$SshKey$throw IllegalStateException("SSH key does not exist in Keystore")
UseCheckOrError:SshKeyGenActivity.kt$SshKeyGenActivity$throw IllegalStateException("Impossible key type selection")
UtilityClassWithPublicConstructor:AutofillMatcher.kt$AutofillMatcher
- WildcardImport:Application.kt$import androidx.appcompat.app.AppCompatDelegate.*
diff --git a/detekt-baselines/crypto-common.xml b/detekt-baselines/crypto-common.xml
new file mode 100644
index 0000000000..d0f2e97fff
--- /dev/null
+++ b/detekt-baselines/crypto-common.xml
@@ -0,0 +1,7 @@
+
+
+
+
+ LongParameterList:CryptoHandler.kt$CryptoHandler$( keys: List<Key>, passphrase: String, ciphertextStream: InputStream, outputStream: OutputStream, options: DecryptOpts, onDecryptSessionKey: (EncryptedSessionKey) -> DecryptedSessionKey, )
+
+
diff --git a/detekt-baselines/crypto-pgpainless.xml b/detekt-baselines/crypto-pgpainless.xml
index c3e6912dc8..f9db469512 100644
--- a/detekt-baselines/crypto-pgpainless.xml
+++ b/detekt-baselines/crypto-pgpainless.xml
@@ -1,7 +1,9 @@
-
+
-
+
+ ForbiddenComment:CachingPublicKeyDataDecryptorFactory.kt$CachingPublicKeyDataDecryptorFactory$* Implementation of the [PublicKeyDataDecryptorFactory] which caches decrypted session keys. That * way, if a message needs to be decrypted multiple times, expensive private key operations can be * omitted. * * This implementation changes the behavior or [.recoverSessionData] to first return any cache hits. * If no hit is found, the method call is delegated to the underlying * [PublicKeyDataDecryptorFactory]. The result of that is then placed in the cache and returned. * * TODO: Do we also cache invalid session keys?
ForbiddenComment:PGPKeyManager.kt$PGPKeyManager$// TODO: This is a temp hack for now and in future it should check that the GPGKeyManager can
+ MatchingDeclarationName:PGPSessionKey.kt$PGPEncryptedSessionKey
diff --git a/gradle.properties b/gradle.properties
index c592e29bf0..55f81c5189 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -11,6 +11,9 @@ org.gradle.vfs.watch=true
# Enable experimental configuration caching
org.gradle.unsafe.configuration-cache=true
+# FIXME Turn cache errors into warnings; can be removed when no
+# longer building dependencies from source
+org.gradle.unsafe.configuration-cache-problems=warn
# Enable Kotlin incremental compilation
kotlin.incremental=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 13b6233e5a..9c5b32cd89 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -7,6 +7,7 @@ composeCompiler = "1.4.0-alpha02"
coroutines = "1.6.4"
flowbinding = "1.2.0"
hilt = "2.44.2"
+hwsecurity = "4.4.0"
kotlin = "1.7.21"
leakcanary = "2.10"
lifecycle = "2.6.0-alpha03"
@@ -34,6 +35,8 @@ androidx-recyclerview = "androidx.recyclerview:recyclerview:1.3.0-rc01"
androidx-recyclerviewSelection = "androidx.recyclerview:recyclerview-selection:1.2.0-alpha01"
androidx-security = "androidx.security:security-crypto:1.1.0-alpha03"
androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"
+aps-hwsecurity-openpgp = { module = "com.github.android-password-store.hwsecurity:hwsecurity-openpgp", version.ref = "hwsecurity" }
+aps-hwsecurity-ui = { module = "com.github.android-password-store.hwsecurity:hwsecurity-ui", version.ref = "hwsecurity" }
aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.2.3"
aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1"
build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e051f05315..00976ee4d4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -4,6 +4,8 @@
*/
@file:Suppress("UnstableApiUsage")
+import me.champeau.gradle.igp.gitRepositories
+
rootProject.name = "APS"
// Plugin repositories
@@ -41,6 +43,8 @@ pluginManagement {
includeModule("com.github.ben-manes", "gradle-versions-plugin")
includeModule("com.gradle", "gradle-enterprise-gradle-plugin")
includeModule("com.gradle.enterprise", "com.gradle.enterprise.gradle.plugin")
+ includeModule("me.champeau.includegit", "me.champeau.includegit.gradle.plugin")
+ includeModule("me.champeau.gradle.includegit", "plugin")
includeModule("me.tylerbwong.gradle.metalava", "plugin")
}
}
@@ -52,7 +56,10 @@ pluginManagement {
}
}
-plugins { id("com.gradle.enterprise") version "3.12" }
+plugins {
+ id("com.gradle.enterprise") version "3.12"
+ id("me.champeau.includegit") version "0.1.5"
+}
gradleEnterprise {
buildScan {
@@ -159,6 +166,35 @@ dependencyResolutionManagement {
}
}
+gitRepositories {
+ checkoutsDirectory.set(rootProject.projectDir.resolve("build/checkouts"))
+ include("hwsecurity") {
+ uri.set("https://github.com/android-password-store/hwsecurity.git")
+ branch.set("main")
+ includeBuild {
+ dependencySubstitution {
+ for (module in
+ listOf(
+ "core",
+ "intent-usb",
+ "intent-nfc",
+ "provider",
+ "fido",
+ "fido2",
+ "openpgp",
+ "piv",
+ "sshj",
+ "ssh",
+ "ui",
+ )) {
+ substitute(module("com.github.android-password-store.hwsecurity:hwsecurity-$module"))
+ .using(project(":hwsecurity:$module"))
+ }
+ }
+ }
+ }
+}
+
// Experimental features
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
@@ -175,6 +211,8 @@ include("coroutine-utils-testing")
include("crypto-common")
+include("crypto-hwsecurity")
+
include("crypto-pgpainless")
include("format-common")