summaryrefslogtreecommitdiff
path: root/crypto
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2023-08-10 03:21:47 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2023-08-10 03:31:08 +0530
commit959a56d7ffbc20db60721d7a09f399c8bdefe07e (patch)
tree0d9b14e0bb770fd6da2e04295f2c22c087142439 /crypto
parentefef72b6327c8e683c8844146e23d12104f12dd1 (diff)
refactor: un-flatten module structure
Diffstat (limited to 'crypto')
-rw-r--r--crypto/common/build.gradle.kts7
-rw-r--r--crypto/common/lint-baseline.xml4
-rw-r--r--crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt44
-rw-r--r--crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoOptions.kt8
-rw-r--r--crypto/common/src/main/kotlin/app/passwordstore/crypto/KeyManager.kt41
-rw-r--r--crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt48
-rw-r--r--crypto/pgpainless/build.gradle.kts18
-rw-r--r--crypto/pgpainless/lint-baseline.xml4
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt52
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt21
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt35
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt128
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt12
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt154
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt139
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/CryptoConstants.kt14
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt35
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPIdentifierTest.kt41
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt236
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt148
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/TestUtils.kt32
-rw-r--r--crypto/pgpainless/src/test/resources/aead_pub51
-rw-r--r--crypto/pgpainless/src/test/resources/aead_sec107
-rw-r--r--crypto/pgpainless/src/test/resources/alice_owner@example_com16
-rw-r--r--crypto/pgpainless/src/test/resources/bobby_owner@example_com16
-rw-r--r--crypto/pgpainless/src/test/resources/public_key21
-rw-r--r--crypto/pgpainless/src/test/resources/public_key_multiple_identities51
-rw-r--r--crypto/pgpainless/src/test/resources/secret_key26
-rw-r--r--crypto/pgpainless/src/test/resources/secret_key_multiple_identities93
29 files changed, 1602 insertions, 0 deletions
diff --git a/crypto/common/build.gradle.kts b/crypto/common/build.gradle.kts
new file mode 100644
index 00000000..110664bd
--- /dev/null
+++ b/crypto/common/build.gradle.kts
@@ -0,0 +1,7 @@
+/*
+ * 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.kotlin-jvm-library") }
+
+dependencies { implementation(libs.thirdparty.kotlinResult) }
diff --git a/crypto/common/lint-baseline.xml b/crypto/common/lint-baseline.xml
new file mode 100644
index 00000000..2ed8a3bb
--- /dev/null
+++ b/crypto/common/lint-baseline.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.2.0-alpha14" type="baseline" client="gradle" dependencies="false" name="AGP (8.2.0-alpha14)" variant="all" version="8.2.0-alpha14">
+
+</issues>
diff --git a/crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt b/crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt
new file mode 100644
index 00000000..898cf058
--- /dev/null
+++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.errors.CryptoHandlerException
+import com.github.michaelbull.result.Result
+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> {
+
+ /**
+ * Decrypt the given [ciphertextStream] using a set of potential [keys] and [passphrase], and
+ * writes the resultant plaintext to [outputStream]. The returned [Result] should be checked to
+ * ensure it is **not** an instance of [com.github.michaelbull.result.Err] before the contents of
+ * [outputStream] are used.
+ */
+ public fun decrypt(
+ keys: List<Key>,
+ passphrase: String,
+ ciphertextStream: InputStream,
+ outputStream: OutputStream,
+ options: DecryptOpts,
+ ): Result<Unit, CryptoHandlerException>
+
+ /**
+ * Encrypt the given [plaintextStream] to the provided [keys], and writes the encrypted ciphertext
+ * to [outputStream]. The returned [Result] should be checked to ensure it is **not** an instance
+ * of [com.github.michaelbull.result.Err] before the contents of [outputStream] are used.
+ */
+ public fun encrypt(
+ keys: List<Key>,
+ plaintextStream: InputStream,
+ outputStream: OutputStream,
+ options: EncOpts,
+ ): Result<Unit, CryptoHandlerException>
+
+ /** Given a [fileName], return whether this instance can handle it. */
+ public fun canHandle(fileName: String): Boolean
+}
diff --git a/crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoOptions.kt b/crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoOptions.kt
new file mode 100644
index 00000000..1e60cdba
--- /dev/null
+++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/CryptoOptions.kt
@@ -0,0 +1,8 @@
+package app.passwordstore.crypto
+
+/** Defines the contract for a grab-bag of options for individual cryptographic operations. */
+public interface CryptoOptions {
+
+ /** Returns a [Boolean] indicating if the [option] is enabled for this operation. */
+ public fun isOptionEnabled(option: String): Boolean
+}
diff --git a/crypto/common/src/main/kotlin/app/passwordstore/crypto/KeyManager.kt b/crypto/common/src/main/kotlin/app/passwordstore/crypto/KeyManager.kt
new file mode 100644
index 00000000..c9db3734
--- /dev/null
+++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/KeyManager.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+import com.github.michaelbull.result.Result
+
+/**
+ * [KeyManager] defines a contract for implementing a management system for [Key]s as they would be
+ * used by an implementation of [CryptoHandler] to obtain eligible public or private keys as
+ * required.
+ */
+public interface KeyManager<Key, KeyIdentifier> {
+
+ /**
+ * Inserts a [key] into the store. If the key already exists, this method will return
+ * [app.passwordstore.crypto.errors.KeyAlreadyExistsException] unless [replace] is `true`.
+ */
+ public suspend fun addKey(key: Key, replace: Boolean = false): Result<Key, Throwable>
+
+ /** Finds a key for [identifier] in the store and deletes it. */
+ public suspend fun removeKey(identifier: KeyIdentifier): Result<Unit, Throwable>
+
+ /**
+ * Get a [Key] for the given [id]. The actual semantics of what [id] is are left to individual
+ * implementations to figure out for themselves. For example, in GPG this can be a full
+ * hexadecimal key ID, an email, a short hex key ID, and probably a few more things.
+ */
+ public suspend fun getKeyById(id: KeyIdentifier): Result<Key, Throwable>
+
+ /** Returns all keys currently in the store as a [List]. */
+ public suspend fun getAllKeys(): Result<List<Key>, Throwable>
+
+ /**
+ * Get a stable identifier for the given [key]. The returned key ID should be suitable to be used
+ * as an identifier for the cryptographic identity tied to this key.
+ */
+ public suspend fun getKeyId(key: Key): KeyIdentifier?
+}
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
new file mode 100644
index 00000000..eb64541e
--- /dev/null
+++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt
@@ -0,0 +1,48 @@
+package app.passwordstore.crypto.errors
+
+import app.passwordstore.crypto.KeyManager
+
+public sealed class CryptoException(message: String? = null, cause: Throwable? = null) :
+ Exception(message, cause)
+
+/** Sealed exception types for [KeyManager]. */
+public sealed class KeyManagerException(message: String? = null) : CryptoException(message)
+
+/** Store contains no keys. */
+public data object NoKeysAvailableException : KeyManagerException("No keys were found")
+
+/** Key directory does not exist or cannot be accessed. */
+public data object KeyDirectoryUnavailableException :
+ KeyManagerException("Key directory does not exist")
+
+/** Failed to delete given key. */
+public data object KeyDeletionFailedException : KeyManagerException("Couldn't delete the key file")
+
+/** Failed to parse the key as a known type. */
+public data object InvalidKeyException :
+ KeyManagerException("Given key cannot be parsed as a known key type")
+
+/** Key failed the [app.passwordstore.crypto.KeyUtils.isKeyUsable] test. */
+public data object UnusableKeyException :
+ KeyManagerException("Given key is not usable for encryption - is it using AEAD?")
+
+/** No key matching `keyId` could be found. */
+public class KeyNotFoundException(keyId: String) :
+ KeyManagerException("No key found with id: $keyId")
+
+/** Attempting to add another key for `keyId` without requesting a replace. */
+public class KeyAlreadyExistsException(keyId: String) :
+ KeyManagerException("Pre-existing key was found for $keyId")
+
+/** Sealed exception types for [app.passwordstore.crypto.CryptoHandler]. */
+public sealed class CryptoHandlerException(message: String? = null, cause: Throwable? = null) :
+ CryptoException(message, cause)
+
+/** The passphrase provided for decryption was incorrect. */
+public class IncorrectPassphraseException(cause: Throwable) : CryptoHandlerException(null, cause)
+
+/** No keys were passed to the encrypt/decrypt operation. */
+public data object NoKeysProvidedException : CryptoHandlerException(null, null)
+
+/** An unexpected error that cannot be mapped to a known type. */
+public class UnknownError(cause: Throwable) : CryptoHandlerException(null, cause)
diff --git a/crypto/pgpainless/build.gradle.kts b/crypto/pgpainless/build.gradle.kts
new file mode 100644
index 00000000..29f5bb1f
--- /dev/null
+++ b/crypto/pgpainless/build.gradle.kts
@@ -0,0 +1,18 @@
+/*
+ * 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.kotlin-jvm-library") }
+
+dependencies {
+ api(projects.crypto.common)
+ implementation(projects.coroutineUtils)
+ implementation(libs.androidx.annotation)
+ implementation(libs.dagger.hilt.core)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.thirdparty.kotlinResult)
+ implementation(libs.thirdparty.pgpainless)
+ testImplementation(libs.bundles.testDependencies)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.testing.testparameterinjector)
+}
diff --git a/crypto/pgpainless/lint-baseline.xml b/crypto/pgpainless/lint-baseline.xml
new file mode 100644
index 00000000..2ed8a3bb
--- /dev/null
+++ b/crypto/pgpainless/lint-baseline.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.2.0-alpha14" type="baseline" client="gradle" dependencies="false" name="AGP (8.2.0-alpha14)" variant="all" version="8.2.0-alpha14">
+
+</issues>
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt
new file mode 100644
index 00000000..5b23dc18
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.PGPIdentifier.KeyId
+import app.passwordstore.crypto.PGPIdentifier.UserId
+import com.github.michaelbull.result.get
+import com.github.michaelbull.result.runCatching
+import org.bouncycastle.openpgp.PGPKeyRing
+import org.pgpainless.PGPainless
+import org.pgpainless.key.parsing.KeyRingReader
+
+/** Utility methods to deal with [PGPKey]s. */
+public object KeyUtils {
+ /**
+ * Attempts to parse a [PGPKeyRing] from a given [key]. The key is first tried as a secret key and
+ * then as a public one before the method gives up and returns null.
+ */
+ public fun tryParseKeyring(key: PGPKey): PGPKeyRing? {
+ return runCatching { KeyRingReader.readKeyRing(key.contents.inputStream()) }.get()
+ }
+
+ /** Parses a [PGPKeyRing] from the given [key] and calculates its long key ID */
+ public fun tryGetId(key: PGPKey): KeyId? {
+ val keyRing = tryParseKeyring(key) ?: return null
+ return KeyId(keyRing.publicKey.keyID)
+ }
+
+ /**
+ * Attempts to parse the given [PGPKey] into a [PGPKeyRing] and obtains the [UserId] of the
+ * corresponding public key.
+ */
+ public fun tryGetEmail(key: PGPKey): UserId? {
+ val keyRing = tryParseKeyring(key) ?: return null
+ return UserId(keyRing.publicKey.userIDs.next())
+ }
+
+ /**
+ * Tests if the given [key] can be used for encryption, which is a bare minimum necessity for the
+ * app.
+ */
+ public fun isKeyUsable(key: PGPKey): Boolean {
+ return runCatching {
+ val keyRing = tryParseKeyring(key) ?: return false
+ PGPainless.inspectKeyRing(keyRing).isUsableForEncryption
+ }
+ .get() != null
+ }
+}
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt
new file mode 100644
index 00000000..15ce92f0
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt
@@ -0,0 +1,21 @@
+package app.passwordstore.crypto
+
+/** [CryptoOptions] implementation for PGPainless decrypt operations. */
+public class PGPDecryptOptions
+private constructor(
+ private val values: Map<String, Boolean>,
+) : CryptoOptions {
+
+ override fun isOptionEnabled(option: String): Boolean {
+ return values.getOrDefault(option, false)
+ }
+
+ /** Implementation of a builder pattern for [PGPDecryptOptions]. */
+ public class Builder {
+
+ /** Build the final [PGPDecryptOptions] object. */
+ public fun build(): PGPDecryptOptions {
+ return PGPDecryptOptions(emptyMap())
+ }
+ }
+}
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt
new file mode 100644
index 00000000..90de6b51
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt
@@ -0,0 +1,35 @@
+package app.passwordstore.crypto
+
+/** [CryptoOptions] implementation for PGPainless encrypt operations. */
+public class PGPEncryptOptions
+private constructor(
+ private val values: Map<String, Boolean>,
+) : CryptoOptions {
+
+ internal companion object {
+ const val ASCII_ARMOR = "ASCII_ARMOR"
+ }
+
+ override fun isOptionEnabled(option: String): Boolean {
+ return values.getOrDefault(option, false)
+ }
+
+ /** Implementation of a builder pattern for [PGPEncryptOptions]. */
+ public class Builder {
+ private val optionsMap = mutableMapOf<String, Boolean>()
+
+ /**
+ * Toggle whether the encryption operation output will be ASCII armored or in OpenPGP's binary
+ * format.
+ */
+ public fun withAsciiArmor(enabled: Boolean): Builder {
+ optionsMap[ASCII_ARMOR] = enabled
+ return this
+ }
+
+ /** Build the final [PGPEncryptOptions] object. */
+ public fun build(): PGPEncryptOptions {
+ return PGPEncryptOptions(optionsMap)
+ }
+ }
+}
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt
new file mode 100644
index 00000000..98a1de2e
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+import java.util.Locale
+import java.util.regex.Pattern
+
+/** Supertype for valid identifiers of PGP keys. */
+public sealed class PGPIdentifier {
+
+ /** A [PGPIdentifier] that represents either a long key ID or a fingerprint. */
+ public data class KeyId(val id: Long) : PGPIdentifier() {
+ override fun toString(): String {
+ return convertKeyIdToHex(id)
+ }
+
+ /** Convert a [Long] key ID to a formatted string. */
+ private fun convertKeyIdToHex(keyId: Long): String {
+ return convertKeyIdToHex32bit(keyId shr HEX_32_BIT_COUNT) + convertKeyIdToHex32bit(keyId)
+ }
+
+ /**
+ * Converts [keyId] to an unsigned [Long] then uses [java.lang.Long.toHexString] to convert it
+ * to a lowercase hex ID.
+ */
+ private fun convertKeyIdToHex32bit(keyId: Long): String {
+ var hexString = java.lang.Long.toHexString(keyId and HEX_32_BITMASK).lowercase(Locale.ENGLISH)
+ while (hexString.length < HEX_32_STRING_LENGTH) {
+ hexString = "0$hexString"
+ }
+ return hexString
+ }
+ }
+
+ /**
+ * A [PGPIdentifier] that represents the textual name/email combination corresponding to a key.
+ * Despite the [email] property in this class, the value is not guaranteed to be a valid email.
+ */
+ public data class UserId(val email: String) : PGPIdentifier() {
+ override fun toString(): String {
+ return email
+ }
+ }
+
+ public companion object {
+ private const val HEX_RADIX = 16
+ private const val HEX_32_BIT_COUNT = 32
+ private const val HEX_32_BITMASK = 0xffffffffL
+ private const val HEX_32_STRING_LENGTH = 8
+ private const val TRUNCATED_FINGERPRINT_LENGTH = 16
+
+ /**
+ * Attempts to parse an untyped String identifier into a concrete subtype of [PGPIdentifier].
+ */
+ @Suppress("ReturnCount")
+ public fun fromString(identifier: String): PGPIdentifier? {
+ if (identifier.isEmpty()) return null
+ // Match long key IDs:
+ // FF22334455667788 or 0xFF22334455667788
+ val maybeLongKeyId =
+ identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F\\d]{16}".toRegex()) }
+ if (maybeLongKeyId != null) {
+ val keyId = maybeLongKeyId.toULong(HEX_RADIX)
+ return KeyId(keyId.toLong())
+ }
+
+ // Match fingerprints:
+ // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
+ val maybeFingerprint =
+ identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F\\d]{40}".toRegex()) }
+ if (maybeFingerprint != null) {
+ // Truncating to the long key ID is not a security issue since OpenKeychain only
+ // accepts
+ // non-ambiguous key IDs.
+ val keyId = maybeFingerprint.takeLast(TRUNCATED_FINGERPRINT_LENGTH).toULong(HEX_RADIX)
+ return KeyId(keyId.toLong())
+ }
+
+ return splitUserId(identifier)?.let { UserId(it) }
+ }
+
+ private object UserIdRegex {
+ val PATTERN: Pattern = Pattern.compile("^(.*?)(?: \\((.*)\\))?(?: <(.*)>)?$")
+ const val NAME = 1
+ const val EMAIL = 3
+ }
+
+ private object EmailRegex {
+ val PATTERN: Pattern = Pattern.compile("^<?\"?([^<>\"]*@[^<>\"]*[.]?[^<>\"]*)\"?>?$")
+ const val EMAIL = 1
+ }
+
+ /**
+ * Takes a 'Name (Comment) <Email>' user ID in any of its permutations and attempts to extract
+ * an email from it.
+ */
+ @Suppress("NestedBlockDepth")
+ private fun splitUserId(userId: String): String? {
+ if (userId.isNotEmpty()) {
+ val matcher = UserIdRegex.PATTERN.matcher(userId)
+ if (matcher.matches()) {
+ var name =
+ if (matcher.group(UserIdRegex.NAME)?.isEmpty() == true) null
+ else matcher.group(UserIdRegex.NAME)
+ var email = matcher.group(UserIdRegex.EMAIL)
+ if (email != null && name != null) {
+ val emailMatcher = EmailRegex.PATTERN.matcher(name)
+ if (emailMatcher.matches() && email == emailMatcher.group(EmailRegex.EMAIL)) {
+ email = emailMatcher.group(EmailRegex.EMAIL)
+ name = null
+ }
+ }
+ if (email == null && name != null) {
+ val emailMatcher = EmailRegex.PATTERN.matcher(name)
+ if (emailMatcher.matches()) {
+ email = emailMatcher.group(EmailRegex.EMAIL)
+ }
+ }
+ return email
+ }
+ }
+ return null
+ }
+ }
+}
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt
new file mode 100644
index 00000000..a33655d4
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+/**
+ * A simple value class wrapping over a [ByteArray] that can represent a PGP key. The implementation
+ * details of public/ private parts as well as identities are deferred to [PGPKeyManager].
+ */
+@JvmInline public value class PGPKey(public val contents: ByteArray)
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt
new file mode 100644
index 00000000..aed1acf2
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+@file:Suppress("BlockingMethodInNonBlockingContext")
+
+package app.passwordstore.crypto
+
+import androidx.annotation.VisibleForTesting
+import app.passwordstore.crypto.KeyUtils.isKeyUsable
+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.UnusableKeyException
+import app.passwordstore.util.coroutines.runSuspendCatching
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.unwrap
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import org.bouncycastle.openpgp.PGPPublicKeyRing
+import org.bouncycastle.openpgp.PGPSecretKeyRing
+import org.pgpainless.PGPainless
+import org.pgpainless.util.selection.userid.SelectUserId
+
+public class PGPKeyManager
+@Inject
+constructor(
+ filesDir: String,
+ private val dispatcher: CoroutineDispatcher,
+) : KeyManager<PGPKey, PGPIdentifier> {
+
+ private val keyDir = File(filesDir, KEY_DIR_NAME)
+
+ /** @see KeyManager.addKey */
+ override suspend fun addKey(key: PGPKey, replace: Boolean): Result<PGPKey, Throwable> =
+ withContext(dispatcher) {
+ runSuspendCatching {
+ if (!keyDirExists()) throw KeyDirectoryUnavailableException
+ val incomingKeyRing = tryParseKeyring(key) ?: throw InvalidKeyException
+ if (!isKeyUsable(key)) throw UnusableKeyException
+ val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION")
+ if (keyFile.exists()) {
+ val existingKeyBytes = keyFile.readBytes()
+ val existingKeyRing =
+ tryParseKeyring(PGPKey(existingKeyBytes)) ?: throw InvalidKeyException
+ when {
+ existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> {
+ keyFile.writeBytes(key.contents)
+ return@runSuspendCatching key
+ }
+ existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPPublicKeyRing -> {
+ val updatedPublicKey = PGPainless.mergeCertificate(existingKeyRing, incomingKeyRing)
+ val keyBytes = PGPainless.asciiArmor(updatedPublicKey).encodeToByteArray()
+ keyFile.writeBytes(keyBytes)
+ return@runSuspendCatching key
+ }
+ }
+ // Check for replace flag first and if it is false, throw an error
+ if (!replace)
+ throw KeyAlreadyExistsException(
+ tryGetId(key)?.toString() ?: "Failed to retrieve key ID"
+ )
+ if (!keyFile.delete()) throw KeyDeletionFailedException
+ }
+
+ keyFile.writeBytes(key.contents)
+
+ key
+ }
+ }
+
+ /** @see KeyManager.removeKey */
+ override suspend fun removeKey(identifier: PGPIdentifier): Result<Unit, Throwable> =
+ withContext(dispatcher) {
+ runSuspendCatching {
+ if (!keyDirExists()) throw KeyDirectoryUnavailableException
+ val key = getKeyById(identifier).unwrap()
+ val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION")
+ if (keyFile.exists()) {
+ if (!keyFile.delete()) throw KeyDeletionFailedException
+ }
+ }
+ }
+
+ /** @see KeyManager.getKeyById */
+ override suspend fun getKeyById(id: PGPIdentifier): Result<PGPKey, Throwable> =
+ withContext(dispatcher) {
+ runSuspendCatching {
+ if (!keyDirExists()) throw KeyDirectoryUnavailableException
+ val keyFiles = keyDir.listFiles()
+ if (keyFiles.isNullOrEmpty()) throw NoKeysAvailableException
+ val keys = keyFiles.map { file -> PGPKey(file.readBytes()) }
+
+ val matchResult =
+ when (id) {
+ is PGPIdentifier.KeyId -> {
+ val keyIdMatch =
+ keys
+ .map { key -> key to tryGetId(key) }
+ .firstOrNull { (_, keyId) -> keyId?.id == id.id }
+ keyIdMatch?.first
+ }
+ is PGPIdentifier.UserId -> {
+ val selector = SelectUserId.byEmail(id.email)
+ val userIdMatch =
+ keys
+ .map { key -> key to tryParseKeyring(key) }
+ .firstOrNull { (_, keyRing) -> selector.firstMatch(keyRing) != null }
+ userIdMatch?.first
+ }
+ }
+
+ if (matchResult != null) {
+ return@runSuspendCatching matchResult
+ }
+
+ throw KeyNotFoundException("$id")
+ }
+ }
+
+ /** @see KeyManager.getAllKeys */
+ override suspend fun getAllKeys(): Result<List<PGPKey>, Throwable> =
+ withContext(dispatcher) {
+ runSuspendCatching {
+ if (!keyDirExists()) throw KeyDirectoryUnavailableException
+ val keyFiles = keyDir.listFiles()
+ if (keyFiles.isNullOrEmpty()) return@runSuspendCatching emptyList()
+ keyFiles.map { keyFile -> PGPKey(keyFile.readBytes()) }.toList()
+ }
+ }
+
+ /** @see KeyManager.getKeyById */
+ override suspend fun getKeyId(key: PGPKey): PGPIdentifier? = tryGetId(key)
+
+ /** Checks if [keyDir] exists and attempts to create it if not. */
+ private fun keyDirExists(): Boolean {
+ return keyDir.exists() || keyDir.mkdirs()
+ }
+
+ public companion object {
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal const val KEY_DIR_NAME: String = "keys"
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal const val KEY_EXTENSION: String = "key"
+ }
+}
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt
new file mode 100644
index 00000000..a7087acf
--- /dev/null
+++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.errors.CryptoHandlerException
+import app.passwordstore.crypto.errors.IncorrectPassphraseException
+import app.passwordstore.crypto.errors.NoKeysProvidedException
+import app.passwordstore.crypto.errors.UnknownError
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.runCatching
+import java.io.InputStream
+import java.io.OutputStream
+import javax.inject.Inject
+import org.bouncycastle.openpgp.PGPPublicKeyRing
+import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
+import org.bouncycastle.openpgp.PGPSecretKeyRing
+import org.bouncycastle.openpgp.PGPSecretKeyRingCollection
+import org.bouncycastle.util.io.Streams
+import org.pgpainless.PGPainless
+import org.pgpainless.decryption_verification.ConsumerOptions
+import org.pgpainless.encryption_signing.EncryptionOptions
+import org.pgpainless.encryption_signing.ProducerOptions
+import org.pgpainless.exception.WrongPassphraseException
+import org.pgpainless.key.protection.SecretKeyRingProtector
+import org.pgpainless.util.Passphrase
+
+public class PGPainlessCryptoHandler @Inject constructor() :
+ CryptoHandler<PGPKey, PGPEncryptOptions, PGPDecryptOptions> {
+
+ /**
+ * Decrypts the given [ciphertextStream] using [PGPainless] and writes the decrypted output to
+ * [outputStream]. The provided [passphrase] is wrapped in a [SecretKeyRingProtector] and the
+ * [keys] argument is defensively checked to ensure it has at least one key present.
+ *
+ * @see CryptoHandler.decrypt
+ */
+ public override fun decrypt(
+ keys: List<PGPKey>,
+ passphrase: String,
+ ciphertextStream: InputStream,
+ outputStream: OutputStream,
+ options: PGPDecryptOptions,
+ ): Result<Unit, CryptoHandlerException> =
+ runCatching {
+ if (keys.isEmpty()) {
+ throw NoKeysProvidedException
+ }
+ val keyringCollection =
+ keys
+ .map { key -> PGPainless.readKeyRing().secretKeyRing(key.contents) }
+ .run(::PGPSecretKeyRingCollection)
+ val protector = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(passphrase))
+ val decryptionStream =
+ PGPainless.decryptAndOrVerify()
+ .onInputStream(ciphertextStream)
+ .withOptions(
+ ConsumerOptions()
+ .addDecryptionKeys(keyringCollection, protector)
+ .addDecryptionPassphrase(Passphrase.fromPassword(passphrase))
+ )
+ Streams.pipeAll(decryptionStream, outputStream)
+ decryptionStream.close()
+ keyringCollection.forEach { keyRing ->
+ check(decryptionStream.metadata.isEncryptedFor(keyRing)) {
+ "Stream should be encrypted for ${keyRing.secretKey.keyID} but wasn't"
+ }
+ }
+ return@runCatching
+ }
+ .mapError { error ->
+ when (error) {
+ is WrongPassphraseException -> IncorrectPassphraseException(error)
+ is CryptoHandlerException -> error
+ else -> UnknownError(error)
+ }
+ }
+
+ /**
+ * Encrypts the provided [plaintextStream] and writes the encrypted output to [outputStream]. The
+ * [keys] argument is defensively checked to contain at least one key.
+ *
+ * @see CryptoHandler.encrypt
+ */
+ public override fun encrypt(
+ keys: List<PGPKey>,
+ plaintextStream: InputStream,
+ outputStream: OutputStream,
+ options: PGPEncryptOptions,
+ ): Result<Unit, CryptoHandlerException> =
+ runCatching {
+ if (keys.isEmpty()) {
+ throw NoKeysProvidedException
+ }
+ val publicKeyRings =
+ keys.mapNotNull(KeyUtils::tryParseKeyring).mapNotNull { keyRing ->
+ when (keyRing) {
+ is PGPPublicKeyRing -> keyRing
+ is PGPSecretKeyRing -> PGPainless.extractCertificate(keyRing)
+ else -> null
+ }
+ }
+ require(keys.size == publicKeyRings.size) {
+ "Failed to parse all keys: ${keys.size} keys were provided but only ${publicKeyRings.size} were valid"
+ }
+ if (publicKeyRings.isEmpty()) {
+ throw NoKeysProvidedException
+ }
+ val publicKeyRingCollection = PGPPublicKeyRingCollection(publicKeyRings)
+ val encryptionOptions = EncryptionOptions().addRecipients(publicKeyRingCollection)
+ val producerOptions =
+ ProducerOptions.encrypt(encryptionOptions)
+ .setAsciiArmor(options.isOptionEnabled(PGPEncryptOptions.ASCII_ARMOR))
+ val encryptionStream =
+ PGPainless.encryptAndOrSign().onOutputStream(outputStream).withOptions(producerOptions)
+ Streams.pipeAll(plaintextStream, encryptionStream)
+ encryptionStream.close()
+ val result = encryptionStream.result
+ publicKeyRingCollection.forEach { keyRing ->
+ require(result.isEncryptedFor(keyRing)) {
+ "Stream should be encrypted for ${keyRing.publicKey.keyID} but wasn't"
+ }
+ }
+ }
+ .mapError { error ->
+ when (error) {
+ is CryptoHandlerException -> error
+ else -> UnknownError(error)
+ }
+ }
+
+ /** Runs a naive check on the extension for the given [fileName] to check if it is a PGP file. */
+ public override fun canHandle(fileName: String): Boolean {
+ return fileName.substringAfterLast('.', "") == "gpg"
+ }
+}
diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/CryptoConstants.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/CryptoConstants.kt
new file mode 100644
index 00000000..d827e169
--- /dev/null
+++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/CryptoConstants.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+object CryptoConstants {
+ const val KEY_PASSPHRASE = "hunter2"
+ const val PLAIN_TEXT = "encryption worthy content"
+ const val KEY_NAME = "John Doe"
+ const val KEY_EMAIL = "john.doe@example.com"
+ const val KEY_ID = 0x08edf7567183ce27
+}
diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt
new file mode 100644
index 00000000..a0f84402
--- /dev/null
+++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt
@@ -0,0 +1,35 @@
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.KeyUtils.isKeyUsable
+import app.passwordstore.crypto.KeyUtils.tryGetId
+import app.passwordstore.crypto.KeyUtils.tryParseKeyring
+import app.passwordstore.crypto.TestUtils.AllKeys
+import app.passwordstore.crypto.TestUtils.getArmoredSecretKeyWithMultipleIdentities
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import org.bouncycastle.openpgp.PGPSecretKeyRing
+
+class KeyUtilsTest {
+ @Test
+ fun parseKeyWithMultipleIdentities() {
+ val key = PGPKey(getArmoredSecretKeyWithMultipleIdentities())
+ val keyring = tryParseKeyring(key)
+ assertNotNull(keyring)
+ assertIs<PGPSecretKeyRing>(keyring)
+ val keyId = tryGetId(key)
+ assertNotNull(keyId)
+ assertIs<PGPIdentifier.KeyId>(keyId)
+ assertEquals("b950ae2813841585", keyId.toString())
+ }
+
+ @Test
+ fun isKeyUsable() {
+ val params = AllKeys.entries.map { it to (it != AllKeys.AEAD_PUB && it != AllKeys.AEAD_SEC) }
+ params.forEach { (allKeys, isUsable) ->
+ val key = PGPKey(allKeys.keyMaterial)
+ assertEquals(isUsable, isKeyUsable(key), "${allKeys.name} failed expectation:")
+ }
+ }
+}
diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPIdentifierTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPIdentifierTest.kt
new file mode 100644
index 00000000..efc6e0ba
--- /dev/null
+++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPIdentifierTest.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.crypto
+
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class PGPIdentifierTest {
+
+ @Test
+ fun parseHexKeyIdWithout0xPrefix() {
+ val identifier = PGPIdentifier.fromString("79E8208280490C77")
+ assertNotNull(identifier)
+ assertTrue { identifier is PGPIdentifier.KeyId }
+ }
+
+ @Test
+ fun parseHexKeyId() {
+ val identifier = PGPIdentifier.fromString("0x79E8208280490C77")
+ assertNotNull(identifier)
+ assertTrue { identifier is PGPIdentifier.KeyId }
+ }
+
+ @Test
+ fun parseValidEmail() {
+ val identifier = PGPIdentifier.fromString("john.doe@example.org")
+ assertNotNull(identifier)
+ assertTrue { identifier is PGPIdentifier.UserId }
+ }
+
+ @Test
+ fun parseEmailWithoutTLD() {
+ val identifier = PGPIdentifier.fromString("john.doe@example")
+ assertNotNull(identifier)
+ assertTrue { identifier is PGPIdentifier.UserId }
+ }
+}
diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt
new file mode 100644
index 00000000..85cf8e1b
--- /dev/null
+++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt
@@ -0,0 +1,236 @@
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.KeyUtils.tryGetId
+import app.passwordstore.crypto.PGPIdentifier.KeyId
+import app.passwordstore.crypto.PGPIdentifier.UserId
+import app.passwordstore.crypto.errors.KeyAlreadyExistsException
+import app.passwordstore.crypto.errors.KeyNotFoundException
+import app.passwordstore.crypto.errors.NoKeysAvailableException
+import app.passwordstore.crypto.errors.UnusableKeyException
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.unwrap
+import com.github.michaelbull.result.unwrapError
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+
+class PGPKeyManagerTest {
+
+ @get:Rule val temporaryFolder: TemporaryFolder = TemporaryFolder()
+ private val dispatcher = StandardTestDispatcher()
+ private val scope = TestScope(dispatcher)
+ private val filesDir by unsafeLazy { temporaryFolder.root }
+ private val keysDir by unsafeLazy { File(filesDir, PGPKeyManager.KEY_DIR_NAME) }
+ private val keyManager by unsafeLazy { PGPKeyManager(filesDir.absolutePath, dispatcher) }
+ private val secretKey = PGPKey(TestUtils.getArmoredSecretKey())
+ private val publicKey = PGPKey(TestUtils.getArmoredPublicKey())
+
+ private fun <T> unsafeLazy(initializer: () -> T) =
+ lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() }
+
+ @Test
+ fun addKey() =
+ runTest(dispatcher) {
+ // Check if the key id returned is correct
+ val keyId = keyManager.getKeyId(keyManager.addKey(secretKey).unwrap())
+ assertEquals(KeyId(CryptoConstants.KEY_ID), keyId)
+ // Check if the keys directory have one file
+ assertEquals(1, filesDir.list()?.size)
+ // Check if the file name is correct
+ val keyFile = keysDir.listFiles()?.first()
+ assertEquals(keyFile?.name, "$keyId.${PGPKeyManager.KEY_EXTENSION}")
+ }
+
+ @Test
+ fun addKeyWithoutReplaceFlag() =
+ runTest(dispatcher) {
+ // Check adding the keys twice
+ keyManager.addKey(secretKey, false).unwrap()
+ val error = keyManager.addKey(secretKey, false).unwrapError()
+
+ assertIs<KeyAlreadyExistsException>(error)
+ }
+
+ @Test
+ fun addKeyWithReplaceFlag() =
+ runTest(dispatcher) {
+ // Check adding the keys twice
+ keyManager.addKey(secretKey, true).unwrap()
+ val keyId = keyManager.getKeyId(keyManager.addKey(secretKey, true).unwrap())
+
+ assertEquals(KeyId(CryptoConstants.KEY_ID), keyId)
+ }
+
+ @Test
+ fun addKeyWithUnusableKey() =
+ runTest(dispatcher) {
+ val error = keyManager.addKey(PGPKey(TestUtils.getAEADSecretKey())).unwrapError()
+ assertEquals(UnusableKeyException, error)
+ }
+
+ @Test
+ fun removeKey() =
+ runTest(dispatcher) {
+ // Add key using KeyManager
+ keyManager.addKey(secretKey).unwrap()
+ // Remove key
+ keyManager.removeKey(tryGetId(secretKey)!!).unwrap()
+ // Check that no keys remain
+ val keys = keyManager.getAllKeys().unwrap()
+ assertEquals(0, keys.size)
+ }
+
+ @Test
+ fun getKeyById() =
+ runTest(dispatcher) {
+ // Add key using KeyManager
+ keyManager.addKey(secretKey).unwrap()
+ val keyId = keyManager.getKeyId(secretKey)
+ assertNotNull(keyId)
+ assertEquals(KeyId(CryptoConstants.KEY_ID), keyManager.getKeyId(secretKey))
+ // Check returned key id matches the expected id and the created key id
+ val returnedKey = keyManager.getKeyById(keyId).unwrap()
+ assertEquals(keyManager.getKeyId(secretKey), keyManager.getKeyId(returnedKey))
+ }
+
+ @Test
+ fun getKeyByFullUserId() =
+ runTest(dispatcher) {
+ keyManager.addKey(secretKey).unwrap()
+ val keyId = "${CryptoConstants.KEY_NAME} <${CryptoConstants.KEY_EMAIL}>"
+ val returnedKey = keyManager.getKeyById(UserId(keyId)).unwrap()
+ assertEquals(keyManager.getKeyId(secretKey), keyManager.getKeyId(returnedKey))
+ }
+
+ @Test
+ fun getKeyByEmailUserId() =
+ runTest(dispatcher) {
+ keyManager.addKey(secretKey).unwrap()
+ val keyId = CryptoConstants.KEY_EMAIL
+ val returnedKey = keyManager.getKeyById(UserId(keyId)).unwrap()
+ assertEquals(keyManager.getKeyId(secretKey), keyManager.getKeyId(returnedKey))
+ }
+
+ @Test
+ fun getNonExistentKey() =
+ runTest(dispatcher) {
+ // Add key using KeyManager
+ keyManager.addKey(secretKey).unwrap()
+ val keyId = KeyId(0x08edf7567183ce44)
+ // Check returned key
+ val error = keyManager.getKeyById(keyId).unwrapError()
+ assertIs<KeyNotFoundException>(error)
+ assertEquals("No key found with id: $keyId", error.message)
+ }
+
+ @Test
+ fun findNonExistentKey() =
+ runTest(dispatcher) {
+ // Check returned key
+ val error = keyManager.getKeyById(KeyId(0x08edf7567183ce44)).unwrapError()
+ assertIs<NoKeysAvailableException>(error)
+ assertEquals("No keys were found", error.message)
+ }
+
+ @Test
+ fun getAllKeys() =
+ runTest(dispatcher) {
+ // Check if KeyManager returns no key
+ val noKeyList = keyManager.getAllKeys().unwrap()
+ assertEquals(0, noKeyList.size)
+ // Add key using KeyManager
+ keyManager.addKey(secretKey).unwrap()
+ keyManager.addKey(PGPKey(TestUtils.getArmoredSecretKeyWithMultipleIdentities())).unwrap()
+ // Check if KeyManager returns one key
+ val singleKeyList = keyManager.getAllKeys().unwrap()
+ assertEquals(2, singleKeyList.size)
+ }
+
+ @Test
+ fun getMultipleIdentityKeyWithAllIdentities() =
+ runTest(dispatcher) {
+ val key = PGPKey(TestUtils.getArmoredSecretKeyWithMultipleIdentities())
+ keyManager.addKey(key).unwrap()
+ val johnKey = keyManager.getKeyById(UserId("john@doe.org")).unwrap()
+ val janeKey = keyManager.getKeyById(UserId("jane@doe.org")).unwrap()
+
+ assertContentEquals(johnKey.contents, janeKey.contents)
+ }
+
+ @Test
+ fun replaceSecretKeyWithPublicKey() =
+ runTest(dispatcher) {
+ assertIs<Ok<PGPKey>>(keyManager.addKey(secretKey))
+ assertIs<Err<KeyAlreadyExistsException>>(keyManager.addKey(publicKey))
+ }
+
+ @Test
+ fun replacePublicKeyWithSecretKey() =
+ runTest(dispatcher) {
+ assertIs<Ok<PGPKey>>(keyManager.addKey(publicKey))
+ assertIs<Ok<PGPKey>>(keyManager.addKey(secretKey))
+ }
+
+ @Test
+ fun replacePublicKeyWithPublicKey() =
+ runTest(dispatcher) {
+ assertIs<Ok<PGPKey>>(keyManager.addKey(publicKey))
+ assertIs<Ok<PGPKey>>(keyManager.addKey(publicKey))
+ val allKeys = keyManager.getAllKeys()
+ assertIs<Ok<List<PGPKey>>>(allKeys)
+ assertEquals(1, allKeys.value.size)
+ val key = allKeys.value[0]
+ assertContentEquals(publicKey.contents, key.contents)
+ }
+
+ @Test
+ fun replaceSecretKeyWithSecretKey() =
+ runTest(dispatcher) {
+ assertIs<Ok<PGPKey>>(keyManager.addKey(secretKey))
+ assertIs<Err<KeyAlreadyExistsException>>(keyManager.addKey(secretKey))
+ }
+
+ @Test
+ fun addMultipleKeysWithSameEmail() =
+ runTest(dispatcher) {
+ val alice =
+ PGPKey(this::class.java.classLoader.getResource("alice_owner@example_com")!!.readBytes())
+ val bobby =
+ PGPKey(this::class.java.classLoader.getResource("bobby_owner@example_com")!!.readBytes())
+ assertIs<Ok<PGPKey>>(keyManager.addKey(alice))
+ assertIs<Ok<PGPKey>>(keyManager.addKey(bobby))
+
+ keyManager.getAllKeys().apply {
+ assertIs<Ok<List<PGPKey>>>(this)
+ assertEquals(2, this.value.size)
+ }
+ val longKeyIds =
+ arrayOf(
+ KeyId(-7087927403306410599), // Alice
+ KeyId(-961222705095032109), // Bobby
+ )
+ val userIds =
+ arrayOf(
+ UserId("Alice <owner@example.com>"),
+ UserId("Bobby <owner@example.com>"),
+ )
+
+ for (idCollection in arrayOf(longKeyIds, userIds)) {
+ val alice1 = keyManager.getKeyById(idCollection[0])
+ val bobby1 = keyManager.getKeyById(idCollection[1])
+ assertIs<Ok<PGPKey>>(alice1)
+ assertIs<Ok<PGPKey>>(bobby1)
+ assertNotEquals(alice1.value.contents, bobby1.value.contents)
+ }
+ }
+}
diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt
new file mode 100644
index 00000000..8bf6ba1e
--- /dev/null
+++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+@file:Suppress("JUnitMalformedDeclaration") // The test runner takes care of it
+
+package app.passwordstore.crypto
+
+import app.passwordstore.crypto.CryptoConstants.KEY_PASSPHRASE
+import app.passwordstore.crypto.CryptoConstants.PLAIN_TEXT
+import app.passwordstore.crypto.errors.IncorrectPassphraseException
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.getError
+import com.google.testing.junit.testparameterinjector.TestParameter
+import com.google.testing.junit.testparameterinjector.TestParameterInjector
+import java.io.ByteArrayOutputStream
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+import org.junit.runner.RunWith
+import org.pgpainless.PGPainless
+import org.pgpainless.decryption_verification.MessageInspector
+
+@Suppress("Unused") // Test runner handles it internally
+enum class EncryptionKey(val keySet: List<PGPKey>) {
+ PUBLIC(listOf(PGPKey(TestUtils.getArmoredPublicKey()))),
+ SECRET(listOf(PGPKey(TestUtils.getArmoredSecretKey()))),
+ ALL(listOf(PGPKey(TestUtils.getArmoredPublicKey()), PGPKey(TestUtils.getArmoredSecretKey()))),
+}
+
+@RunWith(TestParameterInjector::class)
+class PGPainlessCryptoHandlerTest {
+
+ private val cryptoHandler = PGPainlessCryptoHandler()
+ private val secretKey = PGPKey(TestUtils.getArmoredSecretKey())
+
+ @Test
+ fun encryptAndDecrypt(@TestParameter encryptionKey: EncryptionKey) {
+ val ciphertextStream = ByteArrayOutputStream()
+ val encryptRes =
+ cryptoHandler.encrypt(
+ encryptionKey.keySet,
+ PLAIN_TEXT.byteInputStream(Charsets.UTF_8),
+ ciphertextStream,
+ PGPEncryptOptions.Builder().build(),
+ )
+ assertIs<Ok<Unit>>(encryptRes)
+ val plaintextStream = ByteArrayOutputStream()
+ val decryptRes =
+ cryptoHandler.decrypt(
+ listOf(secretKey),
+ KEY_PASSPHRASE,
+ ciphertextStream.toByteArray().inputStream(),
+ plaintextStream,
+ PGPDecryptOptions.Builder().build(),
+ )
+ assertIs<Ok<Unit>>(decryptRes)
+ assertEquals(PLAIN_TEXT, plaintextStream.toString(Charsets.UTF_8))
+ }
+
+ @Test
+ fun decryptWithWrongPassphrase(@TestParameter encryptionKey: EncryptionKey) {
+ val ciphertextStream = ByteArrayOutputStream()
+ val encryptRes =
+ cryptoHandler.encrypt(
+ encryptionKey.keySet,
+ PLAIN_TEXT.byteInputStream(Charsets.UTF_8),
+ ciphertextStream,
+ PGPEncryptOptions.Builder().build(),
+ )
+ assertIs<Ok<Unit>>(encryptRes)
+ val plaintextStream = ByteArrayOutputStream()
+ val result =
+ cryptoHandler.decrypt(
+ listOf(secretKey),
+ "very incorrect passphrase",
+ ciphertextStream.toByteArray().inputStream(),
+ plaintextStream,
+ PGPDecryptOptions.Builder().build(),
+ )
+ assertIs<Err<Throwable>>(result)
+ assertIs<IncorrectPassphraseException>(result.getError())
+ }
+
+ @Test
+ fun encryptAsciiArmored(@TestParameter encryptionKey: EncryptionKey) {
+ val ciphertextStream = ByteArrayOutputStream()
+ val encryptRes =
+ cryptoHandler.encrypt(
+ encryptionKey.keySet,
+ PLAIN_TEXT.byteInputStream(Charsets.UTF_8),
+ ciphertextStream,
+ PGPEncryptOptions.Builder().withAsciiArmor(true).build(),
+ )
+ assertIs<Ok<Unit>>(encryptRes)
+ val ciphertext = ciphertextStream.toString(Charsets.UTF_8)
+ assertContains(ciphertext, "Version: PGPainless")
+ assertContains(ciphertext, "-----BEGIN PGP MESSAGE-----")
+ assertContains(ciphertext, "-----END PGP MESSAGE-----")
+ }
+
+ @Test
+ fun encryptMultiple() {
+ val alice =
+ PGPainless.generateKeyRing().modernKeyRing("Alice <owner@example.com>", KEY_PASSPHRASE)
+ val bob = PGPainless.generateKeyRing().modernKeyRing("Bob <owner@example.com>", KEY_PASSPHRASE)
+ val aliceKey = PGPKey(PGPainless.asciiArmor(alice).encodeToByteArray())
+ val bobKey = PGPKey(PGPainless.asciiArmor(bob).encodeToByteArray())
+ val ciphertextStream = ByteArrayOutputStream()
+ val encryptRes =
+ cryptoHandler.encrypt(
+ listOf(aliceKey, bobKey),
+ PLAIN_TEXT.byteInputStream(Charsets.UTF_8),
+ ciphertextStream,
+ PGPEncryptOptions.Builder().withAsciiArmor(true).build(),
+ )
+ assertIs<Ok<Unit>>(encryptRes)
+ val message = ciphertextStream.toByteArray().decodeToString()
+ val info = MessageInspector.determineEncryptionInfoForMessage(message)
+ assertTrue(info.isEncrypted)
+ assertEquals(2, info.keyIds.size)
+ assertFalse(info.isSignedOnly)
+ for (key in listOf(aliceKey, bobKey)) {
+ val ciphertextStreamCopy = message.byteInputStream()
+ val plaintextStream = ByteArrayOutputStream()
+ val res =
+ cryptoHandler.decrypt(
+ listOf(key),
+ KEY_PASSPHRASE,
+ ciphertextStreamCopy,
+ plaintextStream,
+ PGPDecryptOptions.Builder().build(),
+ )
+ assertIs<Ok<Unit>>(res)
+ }
+ }
+
+ @Test
+ fun canHandleFiltersFormats() {
+ assertFalse { cryptoHandler.canHandle("example.com") }
+ assertTrue { cryptoHandler.canHandle("example.com.gpg") }
+ assertFalse { cryptoHandler.canHandle("example.com.asc") }
+ }
+}
diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/TestUtils.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/TestUtils.kt
new file mode 100644
index 00000000..90b98ac9
--- /dev/null
+++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/TestUtils.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+@file:Suppress("RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
+
+package app.passwordstore.crypto
+
+object TestUtils {
+ fun getArmoredSecretKey() = this::class.java.classLoader.getResource("secret_key").readBytes()
+
+ fun getArmoredPublicKey() = this::class.java.classLoader.getResource("public_key").readBytes()
+
+ fun getArmoredSecretKeyWithMultipleIdentities() =
+ this::class.java.classLoader.getResource("secret_key_multiple_identities").readBytes()
+
+ fun getArmoredPublicKeyWithMultipleIdentities() =
+ this::class.java.classLoader.getResource("public_key_multiple_identities").readBytes()
+
+ fun getAEADPublicKey() = this::class.java.classLoader.getResource("aead_pub").readBytes()
+
+ fun getAEADSecretKey() = this::class.java.classLoader.getResource("aead_sec").readBytes()
+
+ enum class AllKeys(val keyMaterial: ByteArray) {
+ ARMORED_SEC(getArmoredSecretKey()),
+ ARMORED_PUB(getArmoredPublicKey()),
+ MULTIPLE_IDENTITIES_SEC(getArmoredSecretKeyWithMultipleIdentities()),
+ MULTIPLE_IDENTITIES_PUB(getArmoredPublicKeyWithMultipleIdentities()),
+ AEAD_SEC(getAEADSecretKey()),
+ AEAD_PUB(getAEADPublicKey()),
+ }
+}
diff --git a/crypto/pgpainless/src/test/resources/aead_pub b/crypto/pgpainless/src/test/resources/aead_pub
new file mode 100644
index 00000000..f6ae1e82
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/aead_pub
@@ -0,0 +1,51 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+=lwhh
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/aead_sec b/crypto/pgpainless/src/test/resources/aead_sec
new file mode 100644
index 00000000..8e13ac0e
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/aead_sec
@@ -0,0 +1,107 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+=m5E4
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/alice_owner@example_com b/crypto/pgpainless/src/test/resources/alice_owner@example_com
new file mode 100644
index 00000000..d2612b0d
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/alice_owner@example_com
@@ -0,0 +1,16 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lIYEY2to0BYJKwYBBAHaRw8BAQdAuI+Z2XyvQv6qBnA06ZAoKArgfMXFN783oYWl
+Vh1DqpT+BwMCSqjdffS3e+X/Kfnv30tVSSrb8j2nX2C+P0ODVvS7xWs8MG8TN33d
+NJXWUkfct513yADC520EL2KpXPU6GThIxsYmxBXPdyBb3CAiQQDJWLQZQWxpY2Ug
+PG93bmVyQGV4YW1wbGUuY29tPoiQBBMWCAA4FiEEMqSK1ESF5td0+gNunaKflIQq
+HZkFAmNraNACGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQnaKflIQqHZk6
+sAD/Xx8MQbtXcKPJi/0UGXkyRHEYbcC+zzhECFalHsQsNh0A/3Naih9zRixXt3v0
+JVCv7fbKaXpfKdGi8tj9muSa+5QBnIsEY2to0BIKKwYBBAGXVQEFAQEHQJqcQkrg
+shQO4tyyshE9Ng74LAdu4zRD/yb9aQet+61BAwEIB/4HAwIEJ6OJJ8OAFf/0IyRK
+Frmj2UHklu1UT1P2JPF5RPzgPlAxZB1eGHFcCQoJX/ro2AsbQ5KZUwraSs1QgX5b
+GKAdcyJqFwtbz+pkTpBvOWS6iHgEGBYIACAWIQQypIrURIXm13T6A26dop+UhCod
+mQUCY2to0AIbDAAKCRCdop+UhCodmYTSAP0U5Q6clPUsFcjIcwKA+x5G1Q+wzODx
+7/pUS2Vg+cKOMAEAuY5wW5k0eCuWMC/uzXy8l2a3BwsMN3nlApuGk0zOcwM=
+=ApnA
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/bobby_owner@example_com b/crypto/pgpainless/src/test/resources/bobby_owner@example_com
new file mode 100644
index 00000000..6bd548e7
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/bobby_owner@example_com
@@ -0,0 +1,16 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lIYEY2tpShYJKwYBBAHaRw8BAQdA0syygn/sjv82T226XDe7ZmsJ897HQ88pruR6
+uMSdtYP+BwMCAaOsMuRJoq3/eHOl9Df1jlr3zfIBdw0hQrmcJ2qVOS4xQGDegjLW
+Bbqnmjw7cCRUN1knjHdMWYwrnm8G9YmhOhhwHwdmhxw/LJOA00SyVLQZQm9iYnkg
+PG93bmVyQGV4YW1wbGUuY29tPoiQBBMWCAA4FiEEPnwcCqistth5tnEB8qkNCuDF
+QtMFAmNraUoCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQ8qkNCuDFQtOx
+NAD+MzrKYoQQxgLWkqf08Jhc58sa+2xeZBI3Sq0o+huMql0A/39hfDlJnzD61gEZ
+vwOeZuTNb+LH23ha2uG8UpoMx4oLnIsEY2tpShIKKwYBBAGXVQEFAQEHQIVF4gjr
+Wpw7KN/IYZQdml9Rn7zlBMsXNIxXhcMVjxlZAwEIB/4HAwJenw1L/ZS8WP+e3uzg
+khxkk1dQ3fZbTaR90z0wzLDGngVJO1J2XmfIPnTeU8conEeak8Yyt8+85QdM9MK0
+ch0MyhEa/8hRgtCL8Fo3XkLKiHgEGBYIACAWIQQ+fBwKqKy22Hm2cQHyqQ0K4MVC
+0wUCY2tpSgIbDAAKCRDyqQ0K4MVC04+aAQDEW/aasrpOYw35DIddH/Wp4tSrWi65
+kv18HvDPl/c6KwEAw6ZxYsfWmxMtzY6efTIzVnvb4T3OZEVWG6XetZoDTAI=
+=OFsg
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/public_key b/crypto/pgpainless/src/test/resources/public_key
new file mode 100644
index 00000000..987bac6f
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/public_key
@@ -0,0 +1,21 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: PGPainless
+Comment: BC98 82EF 93DC 22F8 D7D4 47AD 08ED F756 7183 CE27
+Comment: John Doe <john.doe@example.com>
+
+mDMEYT33+BYJKwYBBAHaRw8BAQdAoofwCvOfKJ4pGxEO4s64wFD+QnePpNY5zXgW
+TTOFb2+0H0pvaG4gRG9lIDxqb2huLmRvZUBleGFtcGxlLmNvbT6IeAQTFgoAIAUC
+YT33+AIbAQUWAgMBAAQLCQgHBRUKCQgLAh4BAhkBAAoJEAjt91Zxg84n5dYA/AiA
+BqBdt2ItWgDPLCNEqt9wIMgRpkDrAMtXXyyLSkWsAQCoowpenGsq5fxhuRcS3w6Q
+s+/Qw1GqnoidxhioR9J+ALg4BGE99/gSCisGAQQBl1UBBQEBB0C7eFVsFUif4q9S
+taBI6JAwsI+hQSAo3I6V4jU3rix8XwMBCAeIdQQYFgoAHQUCYT33+AIbDAUWAgMB
+AAQLCQgHBRUKCQgLAh4BAAoJEAjt91Zxg84nmn4BALmD8WYxTdrJqUZUE1TcFvzG
+5r0//rPM8Vut5X+KwUXjAQDWVP22KaA8VXpevSxkS3n/ti0KjQVKEFzGbmwB2dTT
+CbgzBGE99/gWCSsGAQQB2kcPAQEHQJXfqDjCO9L4qBu62/UPpQ5q0638kG8+AGf/
+hJH2q2BTiNUEGBYKAH0FAmE99/gCGwIFFgIDAQAECwkIBwUVCgkICwIeAV8gBBkW
+CgAGBQJhPff4AAoJEGSLoii3QC8mrhcBALzpJQTHF8cJJRA9+DQ3qZ85Eu217MJi
+x1aYA1i0zyP5AQD/jN/aBsSTqAHF+zU8/ezzHeoilyBYgxLS9Q2qelDeDAAKCRAI
+7fdWcYPOJ7aHAP9EBq0rzV3c6GtVl8bPnk+llpV/1aodxTSnijQtVSMuMAD+JMUD
+Jd2bimlhuVwpu0DFiF7IF64SAxmVifTwsTWYiQs=
+=jGlC
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/public_key_multiple_identities b/crypto/pgpainless/src/test/resources/public_key_multiple_identities
new file mode 100644
index 00000000..2cc896c2
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/public_key_multiple_identities
@@ -0,0 +1,51 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+GorvBkqXcIR7
+=dL2N
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/secret_key b/crypto/pgpainless/src/test/resources/secret_key
new file mode 100644
index 00000000..61334b01
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/secret_key
@@ -0,0 +1,26 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: PGPainless
+Comment: BC98 82EF 93DC 22F8 D7D4 47AD 08ED F756 7183 CE27
+Comment: John Doe <john.doe@example.com>
+
+lIYEYT33+BYJKwYBBAHaRw8BAQdAoofwCvOfKJ4pGxEO4s64wFD+QnePpNY5zXgW
+TTOFb2/+CQMCh3Bp60ThtX9g8u+uxtuLdeeU5UC14Ox4zVD/x2L7sUzN94XVocOn
+WVJTIgeZ1CBhrsSOMg5grj0Zwf1YODlBpZ85V8stPebpjZ2mCZUz1rQfSm9obiBE
+b2UgPGpvaG4uZG9lQGV4YW1wbGUuY29tPoh4BBMWCgAgBQJhPff4AhsBBRYCAwEA
+BAsJCAcFFQoJCAsCHgECGQEACgkQCO33VnGDzifl1gD8CIAGoF23Yi1aAM8sI0Sq
+33AgyBGmQOsAy1dfLItKRawBAKijCl6cayrl/GG5FxLfDpCz79DDUaqeiJ3GGKhH
+0n4AnIsEYT33+BIKKwYBBAGXVQEFAQEHQLt4VWwVSJ/ir1K1oEjokDCwj6FBICjc
+jpXiNTeuLHxfAwEIB/4JAwKHcGnrROG1f2AcnEUWhC2rDrztJB3JK7pe+PVJbMaK
+O2eYKLiBZOT6Dy1rexMi0vS19IMYLf1V2qgsO9phoglOD+m95tr8Ha9FhfbpJjua
+iHUEGBYKAB0FAmE99/gCGwwFFgIDAQAECwkIBwUVCgkICwIeAQAKCRAI7fdWcYPO
+J5p+AQC5g/FmMU3ayalGVBNU3Bb8xua9P/6zzPFbreV/isFF4wEA1lT9timgPFV6
+Xr0sZEt5/7YtCo0FShBcxm5sAdnU0wmchgRhPff4FgkrBgEEAdpHDwEBB0CV36g4
+wjvS+Kgbutv1D6UOatOt/JBvPgBn/4SR9qtgU/4JAwKHcGnrROG1f2A1hnm2UXZL
+Go/tPJo3pJCJDLClIKi7I5RoHruafuQ2ODvznLbCnbuft9B2cA5MZUMFCk6nBvoU
+k6hwGWxOSNJIOmrCx+PMiNUEGBYKAH0FAmE99/gCGwIFFgIDAQAECwkIBwUVCgkI
+CwIeAV8gBBkWCgAGBQJhPff4AAoJEGSLoii3QC8mrhcBALzpJQTHF8cJJRA9+DQ3
+qZ85Eu217MJix1aYA1i0zyP5AQD/jN/aBsSTqAHF+zU8/ezzHeoilyBYgxLS9Q2q
+elDeDAAKCRAI7fdWcYPOJ7aHAP9EBq0rzV3c6GtVl8bPnk+llpV/1aodxTSnijQt
+VSMuMAD+JMUDJd2bimlhuVwpu0DFiF7IF64SAxmVifTwsTWYiQs=
+=/dDf
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/crypto/pgpainless/src/test/resources/secret_key_multiple_identities b/crypto/pgpainless/src/test/resources/secret_key_multiple_identities
new file mode 100644
index 00000000..5da8ac81
--- /dev/null
+++ b/crypto/pgpainless/src/test/resources/secret_key_multiple_identities
@@ -0,0 +1,93 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+hmaBvZKKiR6/QRMyZMQ34dx9GorvBkqXcIR7
+=8IuC
+-----END PGP PRIVATE KEY BLOCK-----