Add KeyPair
and KeyManager
to manage keys in the app (#1487)
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
9982562dc4
commit
b7abd561f5
18 changed files with 673 additions and 8 deletions
86
.github/workflows/pull_request.yml
vendored
86
.github/workflows/pull_request.yml
vendored
|
@ -2,13 +2,9 @@ on: [pull_request]
|
||||||
|
|
||||||
name: Check pull request
|
name: Check pull request
|
||||||
jobs:
|
jobs:
|
||||||
test-pr:
|
unit-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
#- name: Auto-cancel redundant workflow run
|
|
||||||
# uses: technote-space/auto-cancel-redundant-workflow@f9dfa1c127a520e4d71b92892850f861fb861206
|
|
||||||
|
|
||||||
- name: Check if relevant files have changed
|
- name: Check if relevant files have changed
|
||||||
uses: actions/github-script@a3e7071a34d7e1f219a8a4de9a5e0a34d1ee1293
|
uses: actions/github-script@a3e7071a34d7e1f219a8a4de9a5e0a34d1ee1293
|
||||||
id: service-changed
|
id: service-changed
|
||||||
|
@ -43,8 +39,6 @@ jobs:
|
||||||
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
|
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
#with:
|
|
||||||
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
|
||||||
|
|
||||||
- name: Copy CI gradle.properties
|
- name: Copy CI gradle.properties
|
||||||
if: ${{ steps.service-changed.outputs.result == 'true' }}
|
if: ${{ steps.service-changed.outputs.result == 'true' }}
|
||||||
|
@ -62,3 +56,81 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: Test report
|
name: Test report
|
||||||
path: app/build/reports
|
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
|
||||||
|
|
|
@ -121,6 +121,7 @@ internal fun TestedExtension.configureCommonAndroidOptions() {
|
||||||
sourceSets {
|
sourceSets {
|
||||||
named("main") { java.srcDirs("src/main/kotlin") }
|
named("main") { java.srcDirs("src/main/kotlin") }
|
||||||
named("test") { java.srcDirs("src/test/kotlin") }
|
named("test") { java.srcDirs("src/test/kotlin") }
|
||||||
|
named("androidTest") { java.srcDirs("src/androidTest/kotlin") }
|
||||||
}
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
|
@ -128,6 +129,8 @@ internal fun TestedExtension.configureCommonAndroidOptions() {
|
||||||
resources.excludes.add("**/*.txt")
|
resources.excludes.add("**/*.txt")
|
||||||
resources.excludes.add("**/*.kotlin_module")
|
resources.excludes.add("**/*.kotlin_module")
|
||||||
resources.excludes.add("**/plugin.properties")
|
resources.excludes.add("**/plugin.properties")
|
||||||
|
resources.excludes.add("**/META-INF/AL2.0")
|
||||||
|
resources.excludes.add("**/META-INF/LGPL2.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
|
@ -21,7 +21,10 @@ internal fun Project.configureSlimTests() {
|
||||||
if (providers.gradleProperty(SLIM_TESTS_PROPERTY).forUseAtConfigurationTime().isPresent) {
|
if (providers.gradleProperty(SLIM_TESTS_PROPERTY).forUseAtConfigurationTime().isPresent) {
|
||||||
// disable unit test tasks on the release build type for Android Library projects
|
// disable unit test tasks on the release build type for Android Library projects
|
||||||
extensions.findByType<LibraryAndroidComponentsExtension>()?.run {
|
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
|
// 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().withBuildType(BuildType.RELEASE.name)) { it.enableUnitTest = false }
|
||||||
beforeVariants(selector().withFlavor(FlavorDimensions.FREE to ProductFlavors.NON_FREE)) {
|
beforeVariants(selector().withFlavor(FlavorDimensions.FREE to ProductFlavors.NON_FREE)) {
|
||||||
it.enableUnitTest = false
|
it.enableUnitTest = false
|
||||||
|
it.enableAndroidTest = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 interface class dev/msfjarvis/aps/data/crypto/CryptoHandler {
|
||||||
public abstract fun canHandle (Ljava/lang/String;)Z
|
public abstract fun canHandle (Ljava/lang/String;)Z
|
||||||
public abstract fun decrypt (Ljava/lang/String;[B[B)[B
|
public abstract fun decrypt (Ljava/lang/String;[B[B)[B
|
||||||
public abstract fun encrypt (Ljava/lang/String;[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,3 +6,5 @@ plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
`aps-plugin`
|
`aps-plugin`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies { implementation(libs.thirdparty.kotlinResult) }
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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 final class dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler : dev/msfjarvis/aps/data/crypto/CryptoHandler {
|
||||||
public fun <init> ()V
|
public fun <init> ()V
|
||||||
public fun canHandle (Ljava/lang/String;)Z
|
public fun canHandle (Ljava/lang/String;)Z
|
||||||
|
|
|
@ -2,14 +2,28 @@
|
||||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.library")
|
id("com.android.library")
|
||||||
kotlin("android")
|
kotlin("android")
|
||||||
`aps-plugin`
|
`aps-plugin`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
testApplicationId = "dev.msfjarvis.aps.cryptopgp.test"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.cryptoCommon)
|
api(projects.cryptoCommon)
|
||||||
|
implementation(libs.androidx.annotation)
|
||||||
implementation(libs.aps.gopenpgp)
|
implementation(libs.aps.gopenpgp)
|
||||||
implementation(libs.dagger.hilt.core)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
18
crypto-pgp/src/androidTest/res/raw/private_key
Normal file
18
crypto-pgp/src/androidTest/res/raw/private_key
Normal 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-----
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
29
scripts/boot-emulator.sh
Executable 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
42
scripts/setup-environment.sh
Executable 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"
|
Loading…
Reference in a new issue