From 4b7457c7f712b92f21604d8612ec8ff19df75c81 Mon Sep 17 00:00:00 2001 From: Tad Fisher Date: Sun, 9 Oct 2022 15:10:10 -0700 Subject: Add crypto-hwsecurity library --- crypto-hwsecurity/src/main/AndroidManifest.xml | 1 + .../app/passwordstore/crypto/DeviceIdentifier.kt | 52 ++++++ .../app/passwordstore/crypto/DeviceKeyInfo.kt | 26 +++ .../app/passwordstore/crypto/HWSecurityDevice.kt | 46 ++++++ .../crypto/HWSecurityDeviceHandler.kt | 52 ++++++ .../app/passwordstore/crypto/HWSecurityManager.kt | 182 +++++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 crypto-hwsecurity/src/main/AndroidManifest.xml create mode 100644 crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceIdentifier.kt create mode 100644 crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceKeyInfo.kt create mode 100644 crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDevice.kt create mode 100644 crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDeviceHandler.kt create mode 100644 crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityManager.kt (limited to 'crypto-hwsecurity/src/main') diff --git a/crypto-hwsecurity/src/main/AndroidManifest.xml b/crypto-hwsecurity/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cc947c56 --- /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 00000000..be27f139 --- /dev/null +++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceIdentifier.kt @@ -0,0 +1,52 @@ +@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 00000000..79f8cb06 --- /dev/null +++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/DeviceKeyInfo.kt @@ -0,0 +1,26 @@ +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 00000000..b053cd92 --- /dev/null +++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDevice.kt @@ -0,0 +1,46 @@ +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 00000000..f52c5265 --- /dev/null +++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityDeviceHandler.kt @@ -0,0 +1,52 @@ +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 00000000..a97a2e20 --- /dev/null +++ b/crypto-hwsecurity/src/main/kotlin/app/passwordstore/crypto/HWSecurityManager.kt @@ -0,0 +1,182 @@ +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() + ) +} -- cgit v1.2.3