diff options
18 files changed, 673 insertions, 8 deletions
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 994a5ca0..ffe8acae 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,13 +2,9 @@ on: [pull_request] name: Check pull request jobs: - test-pr: + unit-tests: runs-on: ubuntu-latest steps: - - #- name: Auto-cancel redundant workflow run - # uses: technote-space/auto-cancel-redundant-workflow@f9dfa1c127a520e4d71b92892850f861fb861206 - - name: Check if relevant files have changed uses: actions/github-script@a3e7071a34d7e1f219a8a4de9a5e0a34d1ee1293 id: service-changed @@ -43,8 +39,6 @@ jobs: uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f with: fetch-depth: 0 - #with: - # ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: Copy CI gradle.properties if: ${{ steps.service-changed.outputs.result == 'true' }} @@ -62,3 +56,81 @@ jobs: with: name: Test report path: app/build/reports + + run-screenshot-tests: + runs-on: macOS-latest + steps: + - name: Check if relevant files have changed + uses: actions/github-script@a3e7071a34d7e1f219a8a4de9a5e0a34d1ee1293 + id: service-changed + with: + result-encoding: string + script: | + const result = await github.pulls.listFiles({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + pull_number: context.payload.number, + per_page: 100 + }) + const files = result.data.filter(file => + // We wanna run this if the PR workflow is modified + (file.filename.endsWith(".yml") && !file.filename.endsWith("pull_request.yml")) || + // Changes in Markdown files don't need tests + file.filename.endsWith(".md") || + // Changes to fastlane metadata aren't covered by tests + file.filename.startsWith("fastlane/") + ) + // If filtered file count and source file count is equal, it means all files + // in this PR are skip-worthy. + return files.length != result.data.length + + - uses: actions/cache@c64c572235d810460d0d6876e9c705ad5002b353 + if: ${{ steps.service-changed.outputs.result == 'true' }} + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-23 + + - uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2 + if: ${{ steps.service-changed.outputs.result == 'true' }} + with: + java-version: '11' + + - name: Checkout repository + if: ${{ steps.service-changed.outputs.result == 'true' }} + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + with: + fetch-depth: 0 + + - name: Copy CI gradle.properties + if: ${{ steps.service-changed.outputs.result == 'true' }} + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Create AVD and generate snapshot for caching + uses: reactivecircus/android-emulator-runner@5de26e4bd23bf523e8a4b7f077df8bfb8e52b50e + if: ${{ steps.avd-cache.outputs.cache-hit != 'true' }} + with: + api-level: 23 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching" + + - name: Run screenshot tests + uses: reactivecircus/android-emulator-runner@5de26e4bd23bf523e8a4b7f077df8bfb8e52b50e + if: ${{ steps.service-changed.outputs.result == 'true' }} + with: + api-level: 23 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew connectedCheck -PslimTests + + - name: (Fail-only) upload test report + if: failure() + uses: actions/upload-artifact@27121b0bdffd731efa15d66772be8dc71245d074 + with: + name: Test report + path: app/build/reports diff --git a/buildSrc/src/main/java/BaseProjectConfig.kt b/buildSrc/src/main/java/BaseProjectConfig.kt index f3a33713..f9687c3c 100644 --- a/buildSrc/src/main/java/BaseProjectConfig.kt +++ b/buildSrc/src/main/java/BaseProjectConfig.kt @@ -121,6 +121,7 @@ internal fun TestedExtension.configureCommonAndroidOptions() { sourceSets { named("main") { java.srcDirs("src/main/kotlin") } named("test") { java.srcDirs("src/test/kotlin") } + named("androidTest") { java.srcDirs("src/androidTest/kotlin") } } packagingOptions { @@ -128,6 +129,8 @@ internal fun TestedExtension.configureCommonAndroidOptions() { resources.excludes.add("**/*.txt") resources.excludes.add("**/*.kotlin_module") resources.excludes.add("**/plugin.properties") + resources.excludes.add("**/META-INF/AL2.0") + resources.excludes.add("**/META-INF/LGPL2.1") } compileOptions { diff --git a/buildSrc/src/main/java/SlimTests.kt b/buildSrc/src/main/java/SlimTests.kt index e06c7c8c..4057032b 100644 --- a/buildSrc/src/main/java/SlimTests.kt +++ b/buildSrc/src/main/java/SlimTests.kt @@ -21,7 +21,10 @@ internal fun Project.configureSlimTests() { if (providers.gradleProperty(SLIM_TESTS_PROPERTY).forUseAtConfigurationTime().isPresent) { // disable unit test tasks on the release build type for Android Library projects extensions.findByType<LibraryAndroidComponentsExtension>()?.run { - beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { it.enableUnitTest = false } + beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { + it.enableUnitTest = false + it.enableAndroidTest = false + } } // disable unit test tasks on the release build type and free flavor for Android Application @@ -30,6 +33,7 @@ internal fun Project.configureSlimTests() { beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { it.enableUnitTest = false } beforeVariants(selector().withFlavor(FlavorDimensions.FREE to ProductFlavors.NON_FREE)) { it.enableUnitTest = false + it.enableAndroidTest = false } } } diff --git a/crypto-common/api/crypto-common.api b/crypto-common/api/crypto-common.api index 7493379c..6468ea9b 100644 --- a/crypto-common/api/crypto-common.api +++ b/crypto-common/api/crypto-common.api @@ -1,6 +1,63 @@ +public abstract class dev/msfjarvis/aps/data/crypto/CryptoException : java/lang/Exception { + public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public abstract interface class dev/msfjarvis/aps/data/crypto/CryptoHandler { public abstract fun canHandle (Ljava/lang/String;)Z public abstract fun decrypt (Ljava/lang/String;[B[B)[B public abstract fun encrypt (Ljava/lang/String;[B)[B } +public abstract interface class dev/msfjarvis/aps/data/crypto/KeyManager { + public abstract fun addKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun canHandle (Ljava/lang/String;)Z + public abstract fun getAllKeys (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getKeyById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun removeKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/msfjarvis/aps/data/crypto/KeyManager$DefaultImpls { + public static synthetic fun addKey$default (Ldev/msfjarvis/aps/data/crypto/KeyManager;Ldev/msfjarvis/aps/data/crypto/KeyPair;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public abstract class dev/msfjarvis/aps/data/crypto/KeyManagerException : dev/msfjarvis/aps/data/crypto/CryptoException { + public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyAlreadyExistsException : dev/msfjarvis/aps/data/crypto/KeyManagerException { + public fun <init> (Ljava/lang/String;)V +} + +public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDeletionFailedException : dev/msfjarvis/aps/data/crypto/KeyManagerException { + public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDeletionFailedException; +} + +public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDirectoryUnavailableException : dev/msfjarvis/aps/data/crypto/KeyManagerException { + public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDirectoryUnavailableException; +} + +public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyNotFoundException : dev/msfjarvis/aps/data/crypto/KeyManagerException { + public fun <init> (Ljava/lang/String;)V +} + +public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$NoKeysAvailableException : dev/msfjarvis/aps/data/crypto/KeyManagerException { + public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyManagerException$NoKeysAvailableException; +} + +public abstract interface class dev/msfjarvis/aps/data/crypto/KeyPair { + public abstract fun getKeyId ()Ljava/lang/String; + public abstract fun getPrivateKey ()[B + public abstract fun getPublicKey ()[B +} + +public abstract class dev/msfjarvis/aps/data/crypto/KeyPairException : dev/msfjarvis/aps/data/crypto/CryptoException { + public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class dev/msfjarvis/aps/data/crypto/KeyPairException$PrivateKeyUnavailableException : dev/msfjarvis/aps/data/crypto/KeyPairException { + public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyPairException$PrivateKeyUnavailableException; +} + diff --git a/crypto-common/build.gradle.kts b/crypto-common/build.gradle.kts index c1f3eef8..44323900 100644 --- a/crypto-common/build.gradle.kts +++ b/crypto-common/build.gradle.kts @@ -6,3 +6,5 @@ plugins { kotlin("jvm") `aps-plugin` } + +dependencies { implementation(libs.thirdparty.kotlinResult) } diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt new file mode 100644 index 00000000..6a73d381 --- /dev/null +++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt @@ -0,0 +1,19 @@ +package dev.msfjarvis.aps.data.crypto + +public sealed class CryptoException(message: String? = null) : Exception(message) + +public sealed class KeyPairException(message: String? = null) : CryptoException(message) { + public object PrivateKeyUnavailableException : + KeyPairException("Key object does not have a private sub-key") +} + +public sealed class KeyManagerException(message: String? = null) : CryptoException(message) { + public object NoKeysAvailableException : KeyManagerException("No keys were found") + public object KeyDirectoryUnavailableException : + KeyManagerException("Key directory does not exist") + public object KeyDeletionFailedException : KeyManagerException("Couldn't delete the key file") + public class KeyNotFoundException(keyId: String) : + KeyManagerException("No key found with id: $keyId") + public class KeyAlreadyExistsException(keyId: String) : + KeyManagerException("Pre-existing key was found for $keyId but 'replace' is set to false") +} diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt new file mode 100644 index 00000000..b5ba881e --- /dev/null +++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.data.crypto + +import com.github.michaelbull.result.Result + +public interface KeyManager<T : KeyPair> { + + public suspend fun addKey(key: T, replace: Boolean = false): Result<T, Throwable> + public suspend fun removeKey(key: T): Result<T, Throwable> + public suspend fun getKeyById(id: String): Result<T, Throwable> + public suspend fun getAllKeys(): Result<List<T>, Throwable> + + /** Given a [fileName], return whether this instance can handle it. */ + public fun canHandle(fileName: String): Boolean +} diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.kt new file mode 100644 index 00000000..e2362612 --- /dev/null +++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.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.data.crypto + +/** Defines expectations for a keypair used in public key cryptography. */ +public interface KeyPair { + + public fun getPrivateKey(): ByteArray + public fun getPublicKey(): ByteArray + public fun getKeyId(): String +} diff --git a/crypto-pgp/api/crypto-pgp.api b/crypto-pgp/api/crypto-pgp.api index 2164360c..c9b2dde7 100644 --- a/crypto-pgp/api/crypto-pgp.api +++ b/crypto-pgp/api/crypto-pgp.api @@ -1,3 +1,21 @@ +public final class dev/msfjarvis/aps/data/crypto/GPGKeyManager : dev/msfjarvis/aps/data/crypto/KeyManager { + public fun <init> (Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;)V + public fun addKey (Ldev/msfjarvis/aps/data/crypto/GPGKeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun addKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;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 fun removeKey (Ldev/msfjarvis/aps/data/crypto/GPGKeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun removeKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/msfjarvis/aps/data/crypto/GPGKeyPair : dev/msfjarvis/aps/data/crypto/KeyPair { + public fun <init> (Lcom/proton/Gopenpgp/crypto/Key;)V + public fun getKeyId ()Ljava/lang/String; + public fun getPrivateKey ()[B + public fun getPublicKey ()[B +} + public final class dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler : dev/msfjarvis/aps/data/crypto/CryptoHandler { public fun <init> ()V public fun canHandle (Ljava/lang/String;)Z diff --git a/crypto-pgp/build.gradle.kts b/crypto-pgp/build.gradle.kts index 493062b6..95542b1c 100644 --- a/crypto-pgp/build.gradle.kts +++ b/crypto-pgp/build.gradle.kts @@ -2,14 +2,28 @@ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. * SPDX-License-Identifier: GPL-3.0-only */ + plugins { id("com.android.library") kotlin("android") `aps-plugin` } +android { + defaultConfig { + testApplicationId = "dev.msfjarvis.aps.cryptopgp.test" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } +} + dependencies { api(projects.cryptoCommon) + implementation(libs.androidx.annotation) implementation(libs.aps.gopenpgp) implementation(libs.dagger.hilt.core) + implementation(libs.kotlin.coroutines.core) + implementation(libs.thirdparty.kotlinResult) + androidTestImplementation(libs.bundles.testDependencies) + androidTestImplementation(libs.kotlin.coroutines.test) + androidTestImplementation(libs.bundles.androidTestDependencies) } diff --git a/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt new file mode 100644 index 00000000..80a13eb5 --- /dev/null +++ b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt @@ -0,0 +1,165 @@ +package dev.msfjarvis.aps.crypto + +import androidx.test.platform.app.InstrumentationRegistry +import com.github.michaelbull.result.unwrap +import com.github.michaelbull.result.unwrapError +import com.proton.Gopenpgp.crypto.Key +import dev.msfjarvis.aps.crypto.utils.CryptoConstants +import dev.msfjarvis.aps.cryptopgp.test.R +import dev.msfjarvis.aps.data.crypto.GPGKeyManager +import dev.msfjarvis.aps.data.crypto.GPGKeyPair +import dev.msfjarvis.aps.data.crypto.KeyManagerException +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.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +public class GPGKeyManagerTest { + + private val testCoroutineDispatcher = TestCoroutineDispatcher() + private lateinit var gpgKeyManager: GPGKeyManager + private lateinit var key: GPGKeyPair + + @Before + public fun setup() { + gpgKeyManager = GPGKeyManager(getFilesDir().absolutePath, testCoroutineDispatcher) + key = GPGKeyPair(Key(getKey())) + } + + @After + public fun tearDown() { + val filesDir = getFilesDir() + val keysDir = File(filesDir, GPGKeyManager.KEY_DIR_NAME) + + keysDir.deleteRecursively() + } + + @Test + public fun testAddingKey() { + runBlockingTest { + // Check if the key id returned is correct + val keyId = gpgKeyManager.addKey(key).unwrap().getKeyId() + assertEquals(CryptoConstants.KEY_ID, keyId) + + // Check if the keys directory have one file + val keysDir = File(getFilesDir(), GPGKeyManager.KEY_DIR_NAME) + assertEquals(1, keysDir.list()?.size) + + // Check if the file name is correct + val keyFile = keysDir.listFiles()?.first() + assertEquals(keyFile?.name, "$keyId.${GPGKeyManager.KEY_EXTENSION}") + } + } + + @Test + public fun testAddingKeyWithoutReplaceFlag() { + runBlockingTest { + // Check adding the keys twice + gpgKeyManager.addKey(key, false).unwrap() + val error = gpgKeyManager.addKey(key, false).unwrapError() + + assertIs<KeyManagerException.KeyAlreadyExistsException>(error) + } + } + + @Test + public fun testAddingKeyWithReplaceFlag() { + runBlockingTest { + // Check adding the keys twice + gpgKeyManager.addKey(key, true).unwrap() + val keyId = gpgKeyManager.addKey(key, true).unwrap().getKeyId() + + assertEquals(CryptoConstants.KEY_ID, keyId) + } + } + + @Test + public fun testRemovingKey() { + runBlockingTest { + // Add key using KeyManager + gpgKeyManager.addKey(key).unwrap() + + // Check if the key id returned is correct + val keyId = gpgKeyManager.removeKey(key).unwrap().getKeyId() + assertEquals(CryptoConstants.KEY_ID, keyId) + + // Check if the keys directory have 0 files + val keysDir = File(getFilesDir(), GPGKeyManager.KEY_DIR_NAME) + assertEquals(0, keysDir.list()?.size) + } + } + + @Test + public fun testGetExistingKey() { + runBlockingTest { + // Add key using KeyManager + gpgKeyManager.addKey(key).unwrap() + + // Check returned key id matches the expected id and the created key id + val returnedKeyPair = gpgKeyManager.getKeyById(key.getKeyId()).unwrap() + assertEquals(CryptoConstants.KEY_ID, key.getKeyId()) + assertEquals(key.getKeyId(), returnedKeyPair.getKeyId()) + } + } + + @Test + public fun testGetNonExistentKey() { + runBlockingTest { + // Add key using KeyManager + gpgKeyManager.addKey(key).unwrap() + + val randomKeyId = "0x123456789" + + // Check returned key + val error = gpgKeyManager.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 = gpgKeyManager.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 = gpgKeyManager.getAllKeys().unwrap() + assertEquals(0, noKeyList.size) + + // Add key using KeyManager + gpgKeyManager.addKey(key).unwrap() + + // Check if KeyManager returns one key + val singleKeyList = gpgKeyManager.getAllKeys().unwrap() + assertEquals(1, singleKeyList.size) + } + } + + private companion object { + + fun getFilesDir(): File = InstrumentationRegistry.getInstrumentation().context.filesDir + + fun getKey(): String = + InstrumentationRegistry.getInstrumentation() + .context + .resources + .openRawResource(R.raw.private_key) + .readBytes() + .decodeToString() + } +} diff --git a/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt new file mode 100644 index 00000000..2340d9a5 --- /dev/null +++ b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt @@ -0,0 +1,52 @@ +/* + * 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.test.platform.app.InstrumentationRegistry +import com.proton.Gopenpgp.crypto.Key +import dev.msfjarvis.aps.crypto.utils.CryptoConstants +import dev.msfjarvis.aps.cryptopgp.test.R +import dev.msfjarvis.aps.data.crypto.GPGKeyPair +import dev.msfjarvis.aps.data.crypto.KeyPairException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.junit.Test + +public class GPGKeyPairTest { + + @Test + public fun testIfKeyIdIsCorrect() { + val gpgKey = Key(getKey()) + val keyPair = GPGKeyPair(gpgKey) + + assertEquals(CryptoConstants.KEY_ID, keyPair.getKeyId()) + } + + @Test + public fun testBuildingKeyPairWithoutPrivateKey() { + assertFailsWith<KeyPairException.PrivateKeyUnavailableException>( + "GPGKeyPair does not have a private sub key" + ) { + // Get public key object from private key + val gpgKey = Key(getKey()).toPublic() + // Try creating a KeyPair from public key + val keyPair = GPGKeyPair(gpgKey) + + keyPair.getPrivateKey() + } + } + + private companion object { + + fun getKey(): String = + InstrumentationRegistry.getInstrumentation() + .context + .resources + .openRawResource(R.raw.private_key) + .readBytes() + .decodeToString() + } +} diff --git a/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/CryptoConstants.kt b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/CryptoConstants.kt new file mode 100644 index 00000000..873f7105 --- /dev/null +++ b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/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.utils + +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 = "04ace699d5b15b7e" +} diff --git a/crypto-pgp/src/androidTest/res/raw/private_key b/crypto-pgp/src/androidTest/res/raw/private_key new file mode 100644 index 00000000..5a4f436c --- /dev/null +++ b/crypto-pgp/src/androidTest/res/raw/private_key @@ -0,0 +1,18 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GopenPGP 2.1.9 +Comment: https://gopenpgp.org + +xYYEYN+EThYJKwYBBAHaRw8BAQdAh0d9GdVyJV6KbFynPz3sHkdi5RDnKYs+l0x0 +rEOEthX+CQMIfg7BTvTTe7pgvNERA1vLXRjSxXyi7tfSV13JRnrapp7YtNUSHLVS +PqbaLBd6+EXx7dJ9mUSUSWVga5mdtLZ/k6e+6dsygeHiJuwxfGbHnc0fSm9obiBE +b2UgPGpvaG4uZG9lQGV4YW1wbGUuY29tPsKIBBMWCAA6BQJg34ROCRAErOaZ1bFb +fhYhBJQ0DPsSHC5XfslyQwSs5pnVsVt+AhsDAh4BAhkBAwsJBwIVCAIiAQAAtgwB +AOa3rnipQPsxgxvOP1V+2kD6ssiwt6BZRWwPcyfeX1h4AP9ozBYr+PSmNbam9bnq +wgXwuQhPJeWTSgILMaiasugGCMeLBGDfhE4SCisGAQQBl1UBBQEBB0ClFQJX/L2G +EX9ucC5mvwj3X/7aDXDFAmIpQeWYSS1negMBCgn+CQMIF1uko+Ym3thgoDWUgM5e +MNmDG3rYkTa7h6mlhhrsYtE/GN78EJHP1ygFzOczU/YdbxSRTZCu697uPCZLWURV +1+b66KLTMNHNaAkoFb2JC8J4BBgWCAAqBQJg34ROCRAErOaZ1bFbfhYhBJQ0DPsS +HC5XfslyQwSs5pnVsVt+AhsMAAB1CgEApNcEivCSp0f8CnV4UCoSRRRekIbP1Ub2 +GJx6iRJR8xwA/jicDxdnl/Umfd3mWjGk04R47whiDOXdwjBmC1KVBaMH +=Sfsa +-----END PGP PRIVATE KEY BLOCK-----
\ No newline at end of file diff --git a/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt new file mode 100644 index 00000000..478d2700 --- /dev/null +++ b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt @@ -0,0 +1,95 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.data.crypto + +import androidx.annotation.VisibleForTesting +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import com.proton.Gopenpgp.crypto.Crypto +import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +public class GPGKeyManager(filesDir: String, private val dispatcher: CoroutineDispatcher) : + KeyManager<GPGKeyPair> { + + private val keyDir = File(filesDir, KEY_DIR_NAME) + + override suspend fun addKey(key: GPGKeyPair, replace: Boolean): Result<GPGKeyPair, 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: GPGKeyPair): Result<GPGKeyPair, 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<GPGKeyPair, Throwable> = + withContext(dispatcher) { + runCatching { + if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException + val keys = keyDir.listFiles() + if (keys.isNullOrEmpty()) throw KeyManagerException.NoKeysAvailableException + + for (keyFile in keys) { + val keyPair = GPGKeyPair(Crypto.newKeyFromArmored(keyFile.readText())) + if (keyPair.getKeyId() == id) return@runCatching keyPair + } + + throw KeyManagerException.KeyNotFoundException(id) + } + } + + override suspend fun getAllKeys(): Result<List<GPGKeyPair>, Throwable> = + withContext(dispatcher) { + runCatching { + if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException + val keys = keyDir.listFiles() + if (keys.isNullOrEmpty()) return@runCatching listOf() + + keys.map { GPGKeyPair(Crypto.newKeyFromArmored(it.readText())) }.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() + } + + internal 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-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt new file mode 100644 index 00000000..2dbe8689 --- /dev/null +++ b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt @@ -0,0 +1,28 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.data.crypto + +import com.proton.Gopenpgp.crypto.Key + +/** Wraps a Gopenpgp [Key] to implement [KeyPair]. */ +public class GPGKeyPair(private val key: Key) : KeyPair { + + init { + if (!key.isPrivate) throw KeyPairException.PrivateKeyUnavailableException + } + + override fun getPrivateKey(): ByteArray { + return key.armor().encodeToByteArray() + } + + override fun getPublicKey(): ByteArray { + return key.armoredPublicKey.encodeToByteArray() + } + + override fun getKeyId(): String { + return key.hexKeyID + } +} diff --git a/scripts/boot-emulator.sh b/scripts/boot-emulator.sh new file mode 100755 index 00000000..abd73efa --- /dev/null +++ b/scripts/boot-emulator.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Boots an emulator that exactly matches the one in our CI. It is recommended +# to use this as the target device for android tests. + +set -euo pipefail + +[ -n "${ANDROID_SDK_ROOT:-}" ] || { + echo "ANDROID_SDK_ROOT must be set to use this script" + exit + 1 +} +[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 30"; } + +API_LEVEL="${ANDROID_API_LEVEL:-30}" + +echo no | "${ANDROID_SDK_ROOT}"/cmdline-tools/latest/bin/avdmanager create avd \ + --force \ + -n "Pixel_XL_API_${API_LEVEL}" \ + --abi 'google_apis/x86' \ + --package "system-images;android-${API_LEVEL};google_apis;x86" \ + --device 'pixel_xl' + +"${ANDROID_SDK_ROOT}"/emulator/emulator \ + -avd "Pixel_XL_API_${API_LEVEL}" \ + -no-window \ + -gpu swiftshader_indirect \ + -noaudio \ + -no-boot-anim diff --git a/scripts/setup-environment.sh b/scripts/setup-environment.sh new file mode 100755 index 00000000..30dd0c19 --- /dev/null +++ b/scripts/setup-environment.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# Installs the latest command line tools and sets up the necessary packages for an Android emulator +# for API level $ANDROID_API_LEVEL, or API 30 if unspecified. + +set -euo pipefail + +CMDLINE_TOOLS_URL_MAC="https://dl.google.com/android/repository/commandlinetools-mac-7583922_latest.zip" +CMDLINE_TOOLS_URL_LINUX="https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip" + +[ -n "${ANDROID_SDK_ROOT:-}" ] || { + echo "ANDROID_SDK_ROOT must be set to use this script" + exit + 1 +} + +if [ "$(uname)" == "Linux" ]; then + wget "${CMDLINE_TOOLS_URL_LINUX}" -O /tmp/tools.zip -o /dev/null +elif [ "$(uname)" == "Darwin" ]; then + wget "${CMDLINE_TOOLS_URL_MAC}" -O /tmp/tools.zip -o /dev/null +else + echo "This script only works on Linux and Mac" + exit 1 +fi + +[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 30"; } + +API_LEVEL="${ANDROID_API_LEVEL:-30}" + +unzip -qo /tmp/tools.zip -d "${ANDROID_SDK_ROOT}/latest" +mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" +if [ -d "${ANDROID_SDK_ROOT}/cmdline-tools" ]; then + rm -rf "${ANDROID_SDK_ROOT}/cmdline-tools" +fi +mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" +mv -v "${ANDROID_SDK_ROOT}/latest/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" + +export PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${PATH}" + +sdkmanager --install 'build-tools;30.0.3' platform-tools "platforms;android-${API_LEVEL}" +sdkmanager --install emulator +sdkmanager --install "system-images;android-${API_LEVEL};google_apis;x86" |