diff options
author | Harsh Shandilya <me@msfjarvis.dev> | 2021-10-23 17:02:50 +0530 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-10-23 17:02:50 +0530 |
commit | aac74ae4515aa1d746f46287029441f5a945c98e (patch) | |
tree | 9d23e06592ecd884d6b58dd089692d9e4224a3f9 /crypto-pgpainless | |
parent | 21c8653e6815ca34574e783a5ce7ac783b188228 (diff) |
Switch new PGP backend to use PGPainless (#1522)
* crypto-pgpainless: init
* crypto-pgpainless: add an opinionated CryptoHandler impl
* app: migrate to crypto-pgpainless
* crypto-pgp: remove
* github: remove now unused instrumentation tests job
* crypto-common: fixup package names
* wip(crypto-pgpainless): add `PGPKeyPair` and `PGPKeyManager`
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
(cherry picked from commit 02d07e9e797a8600cc8c534a731dfffcc44cfdde)
* crypto-pgpainless: use hex-encoded key IDs
* crypto-pgpainless: replace legacy Gopenpgp-generated key file
* crypto-pgpainless: fix CryptoConstants source set
* crypto-pgpainless: fix tests
* crypto-pgpainless: reinstate PGPKeyManager tests
Co-authored-by: Aditya Wasan <adityawasan55@gmail.com>
Diffstat (limited to 'crypto-pgpainless')
9 files changed, 465 insertions, 0 deletions
diff --git a/crypto-pgpainless/api/crypto-pgpainless.api b/crypto-pgpainless/api/crypto-pgpainless.api new file mode 100644 index 00000000..1c59ca1f --- /dev/null +++ b/crypto-pgpainless/api/crypto-pgpainless.api @@ -0,0 +1,31 @@ +public final class dev/msfjarvis/aps/crypto/PGPKeyManager : dev/msfjarvis/aps/crypto/KeyManager { + public static final field Companion Ldev/msfjarvis/aps/crypto/PGPKeyManager$Companion; + public fun <init> (Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;)V + public synthetic fun addKey (Ldev/msfjarvis/aps/crypto/KeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addKey (Ldev/msfjarvis/aps/crypto/PGPKeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun canHandle (Ljava/lang/String;)Z + public fun getAllKeys (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getKeyById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun makeKey (Ljava/lang/String;)Ldev/msfjarvis/aps/crypto/PGPKeyPair; + public synthetic fun removeKey (Ldev/msfjarvis/aps/crypto/KeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun removeKey (Ldev/msfjarvis/aps/crypto/PGPKeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/msfjarvis/aps/crypto/PGPKeyManager$Companion { + public final fun makeKey (Ljava/lang/String;)Ldev/msfjarvis/aps/crypto/PGPKeyPair; +} + +public final class dev/msfjarvis/aps/crypto/PGPKeyPair : dev/msfjarvis/aps/crypto/KeyPair { + public fun <init> (Lorg/bouncycastle/openpgp/PGPSecretKey;)V + public fun getKeyId ()Ljava/lang/String; + public fun getPrivateKey ()[B + public fun getPublicKey ()[B +} + +public final class dev/msfjarvis/aps/crypto/PGPainlessCryptoHandler : dev/msfjarvis/aps/crypto/CryptoHandler { + public fun <init> ()V + public fun canHandle (Ljava/lang/String;)Z + public fun decrypt (Ljava/lang/String;Ljava/lang/String;Ljava/io/InputStream;Ljava/io/OutputStream;)V + public fun encrypt (Ljava/util/List;Ljava/io/InputStream;Ljava/io/OutputStream;)V +} + diff --git a/crypto-pgpainless/build.gradle.kts b/crypto-pgpainless/build.gradle.kts new file mode 100644 index 00000000..37040e80 --- /dev/null +++ b/crypto-pgpainless/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +plugins { + kotlin("jvm") + `aps-plugin` +} + +dependencies { + api(projects.cryptoCommon) + implementation(libs.androidx.annotation) + implementation(libs.dagger.hilt.core) + implementation(libs.kotlin.coroutines.core) + implementation(libs.thirdparty.kotlinResult) + implementation(libs.thirdparty.pgpainless) + testImplementation(libs.bundles.testDependencies) + testImplementation(libs.kotlin.coroutines.test) +} diff --git a/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPKeyManager.kt b/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPKeyManager.kt new file mode 100644 index 00000000..fd886843 --- /dev/null +++ b/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPKeyManager.kt @@ -0,0 +1,112 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.crypto + +import androidx.annotation.VisibleForTesting +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.pgpainless.PGPainless + +public class PGPKeyManager( + filesDir: String, + private val dispatcher: CoroutineDispatcher, +) : KeyManager<PGPKeyPair> { + + private val keyDir = File(filesDir, KEY_DIR_NAME) + + override suspend fun addKey(key: PGPKeyPair, replace: Boolean): Result<PGPKeyPair, Throwable> = + withContext(dispatcher) { + runCatching { + if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException + val keyFile = File(keyDir, "${key.getKeyId()}.$KEY_EXTENSION") + if (keyFile.exists()) { + // Check for replace flag first and if it is false, throw an error + if (!replace) throw KeyManagerException.KeyAlreadyExistsException(key.getKeyId()) + if (!keyFile.delete()) throw KeyManagerException.KeyDeletionFailedException + } + + keyFile.writeBytes(key.getPrivateKey()) + + key + } + } + + override suspend fun removeKey(key: PGPKeyPair): Result<PGPKeyPair, Throwable> = + withContext(dispatcher) { + runCatching { + if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException + val keyFile = File(keyDir, "${key.getKeyId()}.$KEY_EXTENSION") + if (keyFile.exists()) { + if (!keyFile.delete()) throw KeyManagerException.KeyDeletionFailedException + } + + key + } + } + + override suspend fun getKeyById(id: String): Result<PGPKeyPair, Throwable> = + withContext(dispatcher) { + runCatching { + if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException + val keys = keyDir.listFiles() + if (keys.isNullOrEmpty()) throw KeyManagerException.NoKeysAvailableException + + for (keyFile in keys) { + val secretKeyRing = PGPainless.readKeyRing().secretKeyRing(keyFile.inputStream()) + val secretKey = secretKeyRing.secretKey + val keyPair = PGPKeyPair(secretKey) + if (keyPair.getKeyId() == id) return@runCatching keyPair + } + + throw KeyManagerException.KeyNotFoundException(id) + } + } + + override suspend fun getAllKeys(): Result<List<PGPKeyPair>, Throwable> = + withContext(dispatcher) { + runCatching { + if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException + val keys = keyDir.listFiles() + if (keys.isNullOrEmpty()) return@runCatching listOf() + + keys + .map { keyFile -> + val secretKeyRing = PGPainless.readKeyRing().secretKeyRing(keyFile.inputStream()) + val secretKey = secretKeyRing.secretKey + + PGPKeyPair(secretKey) + } + .toList() + } + } + + override fun canHandle(fileName: String): Boolean { + // TODO: This is a temp hack for now and in future it should check that the GPGKeyManager can + // decrypt the file + return fileName.endsWith(KEY_EXTENSION) + } + + 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" + + @JvmStatic + public fun makeKey(armoredKey: String): PGPKeyPair { + val secretKey = PGPainless.readKeyRing().secretKeyRing(armoredKey).secretKey + return PGPKeyPair(secretKey) + } + } +} diff --git a/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPKeyPair.kt b/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPKeyPair.kt new file mode 100644 index 00000000..03bcf515 --- /dev/null +++ b/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPKeyPair.kt @@ -0,0 +1,31 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.crypto + +import org.bouncycastle.openpgp.PGPSecretKey + +public class PGPKeyPair(private val secretKey: PGPSecretKey) : KeyPair { + + init { + if (secretKey.isPrivateKeyEmpty) throw KeyPairException.PrivateKeyUnavailableException + } + + override fun getPrivateKey(): ByteArray { + return secretKey.encoded + } + override fun getPublicKey(): ByteArray { + return secretKey.publicKey.encoded + } + override fun getKeyId(): String { + var keyId = secretKey.keyID.toString(radix = 16) + if (keyId.length < KEY_ID_LENGTH) keyId = "0$keyId" + return keyId + } + + private companion object { + private const val KEY_ID_LENGTH = 16 + } +} diff --git a/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPainlessCryptoHandler.kt b/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPainlessCryptoHandler.kt new file mode 100644 index 00000000..3276b995 --- /dev/null +++ b/crypto-pgpainless/src/main/kotlin/dev/msfjarvis/aps/crypto/PGPainlessCryptoHandler.kt @@ -0,0 +1,69 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.crypto + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +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.key.protection.PasswordBasedSecretKeyRingProtector +import org.pgpainless.util.Passphrase + +public class PGPainlessCryptoHandler @Inject constructor() : CryptoHandler { + + public override fun decrypt( + privateKey: String, + password: String, + ciphertextStream: InputStream, + outputStream: OutputStream, + ) { + val pgpSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(privateKey) + val keyringCollection = PGPSecretKeyRingCollection(listOf(pgpSecretKeyRing)) + val protector = + PasswordBasedSecretKeyRingProtector.forKey( + pgpSecretKeyRing, + Passphrase.fromPassword(password) + ) + PGPainless.decryptAndOrVerify() + .onInputStream(ciphertextStream) + .withOptions( + ConsumerOptions() + .addDecryptionKeys(keyringCollection, protector) + .addDecryptionPassphrase(Passphrase.fromPassword(password)) + ) + .use { decryptionStream -> decryptionStream.copyTo(outputStream) } + } + + public override fun encrypt( + pubKeys: List<String>, + plaintextStream: InputStream, + outputStream: OutputStream, + ) { + val pubKeysStream = ByteArrayInputStream(pubKeys.joinToString("\n").toByteArray()) + val publicKeyRingCollection = + pubKeysStream.use { + ArmoredInputStream(it).use { armoredInputStream -> + PGPainless.readKeyRing().publicKeyRingCollection(armoredInputStream) + } + } + val encOpt = EncryptionOptions().apply { publicKeyRingCollection.forEach { addRecipient(it) } } + val prodOpt = ProducerOptions.encrypt(encOpt).setAsciiArmor(true) + PGPainless.encryptAndOrSign().onOutputStream(outputStream).withOptions(prodOpt).use { + encryptionStream -> + plaintextStream.copyTo(encryptionStream) + } + } + + public override fun canHandle(fileName: String): Boolean { + return fileName.split('.').lastOrNull() == "gpg" + } +} diff --git a/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/CryptoConstants.kt b/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/CryptoConstants.kt new file mode 100644 index 00000000..fad5308c --- /dev/null +++ b/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/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 dev.msfjarvis.aps.crypto + +internal object CryptoConstants { + internal const val KEY_PASSPHRASE = "hunter2" + internal const val PLAIN_TEXT = "encryption worthy content" + internal const val KEY_NAME = "John Doe" + internal const val KEY_EMAIL = "john.doe@example.com" + internal const val KEY_ID = "08edf7567183ce27" +} diff --git a/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/PGPKeyManagerTest.kt b/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/PGPKeyManagerTest.kt new file mode 100644 index 00000000..12bb85ad --- /dev/null +++ b/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/PGPKeyManagerTest.kt @@ -0,0 +1,139 @@ +package dev.msfjarvis.aps.crypto + +import com.github.michaelbull.result.unwrap +import com.github.michaelbull.result.unwrapError +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +@OptIn(ExperimentalCoroutinesApi::class) +public class PGPKeyManagerTest { + + @get:Rule public val temporaryFolder: TemporaryFolder = TemporaryFolder() + private val filesDir by lazy(LazyThreadSafetyMode.NONE) { temporaryFolder.root } + private val keysDir by lazy(LazyThreadSafetyMode.NONE) { + File(filesDir, PGPKeyManager.KEY_DIR_NAME) + } + private val testCoroutineDispatcher = TestCoroutineDispatcher() + private val keyManager by lazy(LazyThreadSafetyMode.NONE) { + PGPKeyManager(filesDir.absolutePath, testCoroutineDispatcher) + } + private val key = PGPKeyManager.makeKey(getArmoredKey()) + + @Test + public fun testAddingKey() { + runBlockingTest { + // Check if the key id returned is correct + val keyId = keyManager.addKey(key).unwrap().getKeyId() + assertEquals(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 + public fun testAddingKeyWithoutReplaceFlag() { + runBlockingTest { + // Check adding the keys twice + keyManager.addKey(key, false).unwrap() + val error = keyManager.addKey(key, false).unwrapError() + + assertIs<KeyManagerException.KeyAlreadyExistsException>(error) + } + } + + @Test + public fun testAddingKeyWithReplaceFlag() { + runBlockingTest { + // Check adding the keys twice + keyManager.addKey(key, true).unwrap() + val keyId = keyManager.addKey(key, true).unwrap().getKeyId() + + assertEquals(CryptoConstants.KEY_ID, keyId) + } + } + + @Test + public fun testRemovingKey() { + runBlockingTest { + // Add key using KeyManager + keyManager.addKey(key).unwrap() + + // Check if the key id returned is correct + val keyId = keyManager.removeKey(key).unwrap().getKeyId() + assertEquals(CryptoConstants.KEY_ID, keyId) + + // Check if the keys directory have 0 files + val keysDir = File(filesDir, PGPKeyManager.KEY_DIR_NAME) + assertEquals(0, keysDir.list()?.size) + } + } + + @Test + public fun testGetExistingKey() { + runBlockingTest { + // Add key using KeyManager + keyManager.addKey(key).unwrap() + + // Check returned key id matches the expected id and the created key id + val returnedKeyPair = keyManager.getKeyById(key.getKeyId()).unwrap() + assertEquals(CryptoConstants.KEY_ID, key.getKeyId()) + assertEquals(key.getKeyId(), returnedKeyPair.getKeyId()) + } + } + + @Test + public fun testGetNonExistentKey() { + runBlockingTest { + // Add key using KeyManager + keyManager.addKey(key).unwrap() + + val randomKeyId = "0x123456789" + + // Check returned key + val error = keyManager.getKeyById(randomKeyId).unwrapError() + assertIs<KeyManagerException.KeyNotFoundException>(error) + assertEquals("No key found with id: $randomKeyId", error.message) + } + } + + @Test + public fun testFindKeysWithoutAdding() { + runBlockingTest { + // Check returned key + val error = keyManager.getKeyById("0x123456789").unwrapError() + assertIs<KeyManagerException.NoKeysAvailableException>(error) + assertEquals("No keys were found", error.message) + } + } + + @Test + public fun testGettingAllKeys() { + runBlockingTest { + // TODO: Should we check for more than 1 keys? + // Check if KeyManager returns no key + val noKeyList = keyManager.getAllKeys().unwrap() + assertEquals(0, noKeyList.size) + + // Add key using KeyManager + keyManager.addKey(key).unwrap() + + // Check if KeyManager returns one key + val singleKeyList = keyManager.getAllKeys().unwrap() + assertEquals(1, singleKeyList.size) + } + } + + private fun getArmoredKey() = this::class.java.classLoader.getResource("private_key").readText() +} diff --git a/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/PGPKeyPairTest.kt b/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/PGPKeyPairTest.kt new file mode 100644 index 00000000..9fc3ed69 --- /dev/null +++ b/crypto-pgpainless/src/test/kotlin/dev/msfjarvis/aps/crypto/PGPKeyPairTest.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.crypto + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.pgpainless.PGPainless + +public class PGPKeyPairTest { + + @Test + public fun testIfKeyIdIsCorrect() { + val secretKey = PGPainless.readKeyRing().secretKeyRing(getKey()).secretKey + val keyPair = PGPKeyPair(secretKey) + + assertEquals(CryptoConstants.KEY_ID, keyPair.getKeyId()) + } + + private fun getKey(): String = this::class.java.classLoader.getResource("private_key").readText() +} diff --git a/crypto-pgpainless/src/test/resources/private_key b/crypto-pgpainless/src/test/resources/private_key new file mode 100644 index 00000000..61334b01 --- /dev/null +++ b/crypto-pgpainless/src/test/resources/private_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----- |