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

Support OpenPGP hardware keys #2170

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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 @@ -50,6 +50,7 @@

<activity
android:name=".ui.crypto.DecryptActivity"
android:configChanges="keyboard|keyboardHidden"
android:exported="true" />

<activity
Expand Down Expand Up @@ -93,6 +94,7 @@
android:name=".ui.settings.SettingsActivity"
android:exported="false"
android:label="@string/action_settings"
android:configChanges="keyboard|keyboardHidden"
android:parentActivityName=".ui.passwords.PasswordStore" />

<activity
Expand Down Expand Up @@ -136,6 +138,7 @@
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.autofill.AutofillDecryptActivity"
android:configChanges="keyboard|keyboardHidden"
android:exported="false"
android:theme="@style/NoBackgroundThemeM3" />
<activity
Expand All @@ -162,6 +165,7 @@
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.pgp.PGPKeyImportActivity"
android:configChanges="keyboard|keyboardHidden"
android:theme="@style/NoBackgroundThemeM3" />
<activity
android:name=".ui.pgp.PGPKeyListActivity"
Expand Down
14 changes: 11 additions & 3 deletions app/src/main/java/app/passwordstore/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ package app.passwordstore
import android.content.SharedPreferences
import android.os.Build
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate.*
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode
import app.passwordstore.crypto.HWSecurityManager
import app.passwordstore.injection.context.FilesDirPath
import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.util.extensions.getString
Expand Down Expand Up @@ -42,16 +47,18 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
@Inject lateinit var proxyUtils: ProxyUtils
@Inject lateinit var gitSettings: GitSettings
@Inject lateinit var features: Features
@Inject lateinit var deviceManager: HWSecurityManager

override fun onCreate() {
super.onCreate()
instance = this
LeakCanary.config =
LeakCanary.config.copy(eventListeners = LeakCanary.config.eventListeners + SentryLeakUploader)
if (

val enableLogging =
BuildConfig.ENABLE_DEBUG_FEATURES ||
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)
) {
if (enableLogging) {
LogcatLogger.install(AndroidLogcatLogger(DEBUG))
AppWatcher.manualInstall(this)
setVmPolicy()
Expand All @@ -62,6 +69,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
runMigrations(filesDirPath, prefs, gitSettings)
proxyUtils.setDefaultProxy()
DynamicColors.applyToActivitiesIfAvailable(this)
deviceManager.init(enableLogging)
Sentry.configureScope { scope ->
val user = User()
user.data =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -50,7 +54,10 @@ constructor(
): Result<Unit, CryptoHandlerException> {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
67 changes: 47 additions & 20 deletions app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -50,6 +57,7 @@ class PGPKeyImportActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

pgpKeyImportAction.launch(arrayOf("*/*"))
}

Expand All @@ -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<PGPKey?, Throwable>) {
when (result) {
is Ok<PGPKey?> -> {
Expand All @@ -89,26 +108,34 @@ class PGPKeyImportActivity : AppCompatActivity() {
.setCancelable(false)
.show()
}
is Err<Throwable> -> {
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<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))
.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()
}
}
}
}
}
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,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>
Expand All @@ -360,6 +361,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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import java.io.InputStream
import java.io.OutputStream

/** Generic interface to implement cryptographic operations on top of. */
public interface CryptoHandler<Key, EncOpts : CryptoOptions, DecryptOpts : CryptoOptions> {
public interface CryptoHandler<
Key,
EncOpts : CryptoOptions,
DecryptOpts : CryptoOptions,
EncryptedSessionKey,
DecryptedSessionKey,
> {

/**
* Decrypt the given [ciphertextStream] using a set of potential [keys] and [passphrase], and
Expand All @@ -25,6 +31,7 @@ public interface CryptoHandler<Key, EncOpts : CryptoOptions, DecryptOpts : Crypt
ciphertextStream: InputStream,
outputStream: OutputStream,
options: DecryptOpts,
onDecryptSessionKey: (EncryptedSessionKey) -> DecryptedSessionKey,
): Result<Unit, CryptoHandlerException>

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package app.passwordstore.crypto

import app.passwordstore.crypto.errors.DeviceHandlerException
import com.github.michaelbull.result.Result

public interface DeviceHandler<Key, EncryptedSessionKey, DecryptedSessionKey> {
public suspend fun pairWithPublicKey(publicKey: Key): Result<Key, DeviceHandlerException>

public suspend fun decryptSessionKey(
encryptedSessionKey: EncryptedSessionKey
): Result<DecryptedSessionKey, DeviceHandlerException>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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) :
Expand All @@ -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)
Expand All @@ -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<KeySpecific>) :
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()
Loading