diff options
author | Harsh Shandilya <me@msfjarvis.dev> | 2023-08-10 03:21:47 +0530 |
---|---|---|
committer | Harsh Shandilya <me@msfjarvis.dev> | 2023-08-10 03:31:08 +0530 |
commit | 959a56d7ffbc20db60721d7a09f399c8bdefe07e (patch) | |
tree | 0d9b14e0bb770fd6da2e04295f2c22c087142439 /crypto | |
parent | efef72b6327c8e683c8844146e23d12104f12dd1 (diff) |
refactor: un-flatten module structure
Diffstat (limited to 'crypto')
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----- |