Add KeyPair and KeyManager to manage keys in the app (#1487)

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Aditya Wasan 2021-08-17 04:14:43 +05:30 committed by GitHub
parent 9982562dc4
commit b7abd561f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 673 additions and 8 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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
}
}
}

View file

@ -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;
}

View file

@ -6,3 +6,5 @@ plugins {
kotlin("jvm")
`aps-plugin`
}
dependencies { implementation(libs.thirdparty.kotlinResult) }

View file

@ -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")
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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"
}

View file

@ -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-----

View file

@ -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"
}
}

View file

@ -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
}
}

29
scripts/boot-emulator.sh Executable file
View file

@ -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

42
scripts/setup-environment.sh Executable file
View file

@ -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"