Add initial implementation of Gopenpgp-backed PGP (#1441)
This commit is contained in:
parent
9c388e4974
commit
6e4ffe2902
20 changed files with 966 additions and 4 deletions
|
@ -76,6 +76,7 @@ dependencies {
|
||||||
implementation(libs.androidx.annotation)
|
implementation(libs.androidx.annotation)
|
||||||
coreLibraryDesugaring(libs.android.desugarJdkLibs)
|
coreLibraryDesugaring(libs.android.desugarJdkLibs)
|
||||||
implementation(projects.autofillParser)
|
implementation(projects.autofillParser)
|
||||||
|
implementation(projects.cryptoPgp)
|
||||||
implementation(projects.formatCommon)
|
implementation(projects.formatCommon)
|
||||||
implementation(projects.openpgpKtx)
|
implementation(projects.openpgpKtx)
|
||||||
implementation(libs.androidx.activity.ktx)
|
implementation(libs.androidx.activity.ktx)
|
||||||
|
|
|
@ -44,6 +44,10 @@
|
||||||
android:name=".ui.proxy.ProxySelectorActivity"
|
android:name=".ui.proxy.ProxySelectorActivity"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.crypto.GopenpgpDecryptActivity"
|
||||||
|
android:exported="true" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.main.LaunchActivity"
|
android:name=".ui.main.LaunchActivity"
|
||||||
android:configChanges="orientation|screenSize"
|
android:configChanges="orientation|screenSize"
|
||||||
|
@ -91,6 +95,10 @@
|
||||||
android:label="@string/new_password_title"
|
android:label="@string/new_password_title"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
|
<activity android:name=".ui.crypto.GopenpgpPasswordCreationActivity"
|
||||||
|
android:label="@string/new_password_title"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.crypto.DecryptActivity"
|
android:name=".ui.crypto.DecryptActivity"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
@ -128,6 +136,9 @@
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.autofill.AutofillDecryptActivity"
|
android:name=".ui.autofill.AutofillDecryptActivity"
|
||||||
android:theme="@style/NoBackgroundTheme" />
|
android:theme="@style/NoBackgroundTheme" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.autofill.GopenpgpAutofillDecryptActivity"
|
||||||
|
android:theme="@style/NoBackgroundTheme" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.autofill.AutofillFilterView"
|
android:name=".ui.autofill.AutofillFilterView"
|
||||||
android:configChanges="orientation|keyboardHidden"
|
android:configChanges="orientation|keyboardHidden"
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.injection.crypto
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
import dev.msfjarvis.aps.data.crypto.CryptoHandler
|
||||||
|
import dev.msfjarvis.aps.data.crypto.GopenpgpCryptoHandler
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module adds all [CryptoHandler] implementations into a Set which makes it easier to build
|
||||||
|
* generic UIs which are not tied to a specific implementation because of injection.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CryptoHandlerModule {
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun providePgpCryptoHandler(): CryptoHandler {
|
||||||
|
return GopenpgpCryptoHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typealias for a [Set] of [CryptoHandler] instances injected by Dagger. */
|
||||||
|
typealias CryptoSet = Set<@JvmSuppressWildcards CryptoHandler>
|
|
@ -29,6 +29,7 @@ import com.github.androidpasswordstore.autofillparser.FormOrigin
|
||||||
import dev.msfjarvis.aps.R
|
import dev.msfjarvis.aps.R
|
||||||
import dev.msfjarvis.aps.data.password.PasswordItem
|
import dev.msfjarvis.aps.data.password.PasswordItem
|
||||||
import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding
|
import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding
|
||||||
|
import dev.msfjarvis.aps.util.FeatureFlags
|
||||||
import dev.msfjarvis.aps.util.autofill.AutofillMatcher
|
import dev.msfjarvis.aps.util.autofill.AutofillMatcher
|
||||||
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
|
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
|
||||||
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
||||||
|
@ -220,7 +221,11 @@ class AutofillFilterView : AppCompatActivity() {
|
||||||
AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
|
AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
|
||||||
// intent?.extras? is checked to be non-null in onCreate
|
// intent?.extras? is checked to be non-null in onCreate
|
||||||
decryptAction.launch(
|
decryptAction.launch(
|
||||||
AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
|
if (FeatureFlags.ENABLE_GOPENPGP) {
|
||||||
|
GopenpgpAutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
|
||||||
|
} else {
|
||||||
|
AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
package dev.msfjarvis.aps.ui.autofill
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.autofill.AutofillManager
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.ajalt.timberkt.d
|
||||||
|
import com.github.ajalt.timberkt.e
|
||||||
|
import com.github.androidpasswordstore.autofillparser.AutofillAction
|
||||||
|
import com.github.androidpasswordstore.autofillparser.Credentials
|
||||||
|
import com.github.michaelbull.result.getOrElse
|
||||||
|
import com.github.michaelbull.result.onFailure
|
||||||
|
import com.github.michaelbull.result.onSuccess
|
||||||
|
import com.github.michaelbull.result.runCatching
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.msfjarvis.aps.injection.crypto.CryptoSet
|
||||||
|
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||||
|
import dev.msfjarvis.aps.ui.crypto.GopenpgpDecryptActivity
|
||||||
|
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
|
||||||
|
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
|
||||||
|
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class GopenpgpAutofillDecryptActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
|
||||||
|
private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
|
||||||
|
|
||||||
|
private var decryptFileRequestCode = 1
|
||||||
|
|
||||||
|
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
|
||||||
|
return Intent(context, GopenpgpAutofillDecryptActivity::class.java).apply {
|
||||||
|
putExtras(forwardedExtras)
|
||||||
|
putExtra(EXTRA_SEARCH_ACTION, true)
|
||||||
|
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
|
||||||
|
val intent =
|
||||||
|
Intent(context, GopenpgpAutofillDecryptActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_SEARCH_ACTION, false)
|
||||||
|
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
decryptFileRequestCode++,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
)
|
||||||
|
.intentSender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
@Inject lateinit var cryptos: CryptoSet
|
||||||
|
|
||||||
|
private lateinit var directoryStructure: DirectoryStructure
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
val filePath =
|
||||||
|
intent?.getStringExtra(EXTRA_FILE_PATH)
|
||||||
|
?: run {
|
||||||
|
e { "GopenpgpAutofillDecryptActivity started without EXTRA_FILE_PATH" }
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val clientState =
|
||||||
|
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
|
||||||
|
?: run {
|
||||||
|
e { "GopenpgpAutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
|
||||||
|
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
|
||||||
|
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||||
|
d { action.toString() }
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val credentials = decryptCredential(File(filePath))
|
||||||
|
if (credentials == null) {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
} else {
|
||||||
|
val fillInDataset =
|
||||||
|
AutofillResponseBuilder.makeFillInDataset(
|
||||||
|
this@GopenpgpAutofillDecryptActivity,
|
||||||
|
credentials,
|
||||||
|
clientState,
|
||||||
|
action
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
setResult(
|
||||||
|
RESULT_OK,
|
||||||
|
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun decryptCredential(file: File): Credentials? {
|
||||||
|
runCatching { file.inputStream() }
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e) { "File to decrypt not found" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
.onSuccess { encryptedInput ->
|
||||||
|
runCatching {
|
||||||
|
val crypto = cryptos.first { it.canHandle(file.absolutePath) }
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
crypto.decrypt(
|
||||||
|
GopenpgpDecryptActivity.PRIV_KEY,
|
||||||
|
GopenpgpDecryptActivity.PASS.toByteArray(charset = Charsets.UTF_8),
|
||||||
|
encryptedInput.readBytes()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e) { "Decryption with Gopenpgp failed" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
.onSuccess { result ->
|
||||||
|
return runCatching {
|
||||||
|
val entry = passwordEntryFactory.create(lifecycleScope, result)
|
||||||
|
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
||||||
|
}
|
||||||
|
.getOrElse { e ->
|
||||||
|
e(e) { "Failed to parse password entry" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.ui.crypto
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.msfjarvis.aps.R
|
||||||
|
import dev.msfjarvis.aps.data.passfile.PasswordEntry
|
||||||
|
import dev.msfjarvis.aps.data.password.FieldItem
|
||||||
|
import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
|
||||||
|
import dev.msfjarvis.aps.injection.crypto.CryptoSet
|
||||||
|
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||||
|
import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
|
||||||
|
import dev.msfjarvis.aps.util.extensions.unsafeLazy
|
||||||
|
import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class GopenpgpDecryptActivity : BasePgpActivity() {
|
||||||
|
|
||||||
|
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||||
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
@Inject lateinit var cryptos: CryptoSet
|
||||||
|
private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) }
|
||||||
|
|
||||||
|
private var passwordEntry: PasswordEntry? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
title = name
|
||||||
|
with(binding) {
|
||||||
|
setContentView(root)
|
||||||
|
passwordCategory.text = relativeParentPath
|
||||||
|
passwordFile.text = name
|
||||||
|
passwordFile.setOnLongClickListener {
|
||||||
|
copyTextToClipboard(name)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decrypt()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.pgp_handler, menu)
|
||||||
|
passwordEntry?.let { entry ->
|
||||||
|
if (menu != null) {
|
||||||
|
menu.findItem(R.id.edit_password).isVisible = true
|
||||||
|
if (!entry.password.isNullOrBlank()) {
|
||||||
|
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
|
||||||
|
menu.findItem(R.id.copy_password).isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> onBackPressed()
|
||||||
|
R.id.edit_password -> editPassword()
|
||||||
|
R.id.share_password_as_plaintext -> shareAsPlaintext()
|
||||||
|
R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
|
||||||
|
else -> return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
|
||||||
|
* information leaks from stale activities.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun startAutoDismissTimer() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
delay(Duration.seconds(60))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit the current password and hide all the fields populated by encrypted data so that when the
|
||||||
|
* result triggers they can be repopulated with new data.
|
||||||
|
*/
|
||||||
|
private fun editPassword() {
|
||||||
|
val intent = Intent(this, PasswordCreationActivity::class.java)
|
||||||
|
intent.putExtra("FILE_PATH", relativeParentPath)
|
||||||
|
intent.putExtra("REPO_PATH", repoPath)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentString)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareAsPlaintext() {
|
||||||
|
val sendIntent =
|
||||||
|
Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
// Always show a picker to give the user a chance to cancel
|
||||||
|
startActivity(
|
||||||
|
Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decrypt() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// TODO(msfjarvis): native methods are fallible, add error handling once out of testing
|
||||||
|
val message = withContext(Dispatchers.IO) { File(fullPath).readBytes() }
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val crypto = cryptos.first { it.canHandle(fullPath) }
|
||||||
|
crypto.decrypt(
|
||||||
|
PRIV_KEY,
|
||||||
|
PASS.toByteArray(charset = Charsets.UTF_8),
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
startAutoDismissTimer()
|
||||||
|
|
||||||
|
val entry = passwordEntryFactory.create(lifecycleScope, result)
|
||||||
|
passwordEntry = entry
|
||||||
|
invalidateOptionsMenu()
|
||||||
|
|
||||||
|
val items = arrayListOf<FieldItem>()
|
||||||
|
val adapter = FieldItemAdapter(emptyList(), true) { text -> copyTextToClipboard(text) }
|
||||||
|
if (!entry.password.isNullOrBlank()) {
|
||||||
|
items.add(FieldItem.createPasswordField(entry.password!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.hasTotp()) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
items.add(FieldItem.createOtpField(entry.totp.value))
|
||||||
|
entry.totp.collect { code ->
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.username.isNullOrBlank()) {
|
||||||
|
items.add(FieldItem.createUsernameField(entry.username!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.extraContent.forEach { (key, value) ->
|
||||||
|
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
adapter.updateItems(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// TODO(msfjarvis): source these from storage and user input
|
||||||
|
const val PRIV_KEY = ""
|
||||||
|
const val PASS = ""
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,432 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.ui.crypto
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.InputType
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.ajalt.timberkt.e
|
||||||
|
import com.github.michaelbull.result.onFailure
|
||||||
|
import com.github.michaelbull.result.onSuccess
|
||||||
|
import com.github.michaelbull.result.runCatching
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.msfjarvis.aps.R
|
||||||
|
import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
|
||||||
|
import dev.msfjarvis.aps.injection.crypto.CryptoSet
|
||||||
|
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||||
|
import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment
|
||||||
|
import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
|
||||||
|
import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment
|
||||||
|
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
|
||||||
|
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
||||||
|
import dev.msfjarvis.aps.util.extensions.base64
|
||||||
|
import dev.msfjarvis.aps.util.extensions.commitChange
|
||||||
|
import dev.msfjarvis.aps.util.extensions.getString
|
||||||
|
import dev.msfjarvis.aps.util.extensions.isInsideRepository
|
||||||
|
import dev.msfjarvis.aps.util.extensions.snackbar
|
||||||
|
import dev.msfjarvis.aps.util.extensions.unsafeLazy
|
||||||
|
import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
|
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class GopenpgpPasswordCreationActivity : BasePgpActivity() {
|
||||||
|
|
||||||
|
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||||
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
@Inject lateinit var cryptos: CryptoSet
|
||||||
|
|
||||||
|
private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||||
|
private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
|
||||||
|
private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
|
||||||
|
private val shouldGeneratePassword by unsafeLazy {
|
||||||
|
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||||
|
}
|
||||||
|
private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
|
||||||
|
private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||||
|
private var oldCategory: String? = null
|
||||||
|
private var copy: Boolean = false
|
||||||
|
|
||||||
|
private val otpImportAction =
|
||||||
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
binding.otpImportButton.isVisible = false
|
||||||
|
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
|
||||||
|
val contents = "${intentResult.contents}\n"
|
||||||
|
val currentExtras = binding.extraContent.text.toString()
|
||||||
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||||
|
binding.extraContent.append("\n$contents")
|
||||||
|
else binding.extraContent.append(contents)
|
||||||
|
snackbar(message = getString(R.string.otp_import_success))
|
||||||
|
} else {
|
||||||
|
snackbar(message = getString(R.string.otp_import_failure))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
bindToOpenKeychain(this)
|
||||||
|
title =
|
||||||
|
if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
|
||||||
|
with(binding) {
|
||||||
|
setContentView(root)
|
||||||
|
generatePassword.setOnClickListener { generatePassword() }
|
||||||
|
otpImportButton.setOnClickListener {
|
||||||
|
supportFragmentManager.setFragmentResultListener(
|
||||||
|
OTP_RESULT_REQUEST_KEY,
|
||||||
|
this@GopenpgpPasswordCreationActivity
|
||||||
|
) { requestKey, bundle ->
|
||||||
|
if (requestKey == OTP_RESULT_REQUEST_KEY) {
|
||||||
|
val contents = bundle.getString(RESULT)
|
||||||
|
val currentExtras = binding.extraContent.text.toString()
|
||||||
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||||
|
binding.extraContent.append("\n$contents")
|
||||||
|
else binding.extraContent.append(contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
|
||||||
|
if (hasCamera) {
|
||||||
|
val items =
|
||||||
|
arrayOf(
|
||||||
|
getString(R.string.otp_import_qr_code),
|
||||||
|
getString(R.string.otp_import_manual_entry)
|
||||||
|
)
|
||||||
|
MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity)
|
||||||
|
.setItems(items) { _, index ->
|
||||||
|
when (index) {
|
||||||
|
0 ->
|
||||||
|
otpImportAction.launch(
|
||||||
|
IntentIntegrator(this@GopenpgpPasswordCreationActivity)
|
||||||
|
.setOrientationLocked(false)
|
||||||
|
.setBeepEnabled(false)
|
||||||
|
.setDesiredBarcodeFormats(QR_CODE)
|
||||||
|
.createScanIntent()
|
||||||
|
)
|
||||||
|
1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
directoryInputLayout.apply {
|
||||||
|
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
||||||
|
isEnabled = true
|
||||||
|
} else {
|
||||||
|
setBackgroundColor(getColor(android.R.color.transparent))
|
||||||
|
}
|
||||||
|
val path = getRelativePath(fullPath, repoPath)
|
||||||
|
// Keep empty path field visible if it is editable.
|
||||||
|
if (path.isEmpty() && !isEnabled) visibility = View.GONE
|
||||||
|
else {
|
||||||
|
directory.setText(path)
|
||||||
|
oldCategory = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (suggestedName != null) {
|
||||||
|
filename.setText(suggestedName)
|
||||||
|
} else {
|
||||||
|
filename.requestFocus()
|
||||||
|
}
|
||||||
|
// Allow the user to quickly switch between storing the username as the filename or
|
||||||
|
// in the encrypted extras. This only makes sense if the directory structure is
|
||||||
|
// FileBased.
|
||||||
|
if (suggestedName == null &&
|
||||||
|
AutofillPreferences.directoryStructure(this@GopenpgpPasswordCreationActivity) ==
|
||||||
|
DirectoryStructure.FileBased
|
||||||
|
) {
|
||||||
|
encryptUsername.apply {
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
setOnClickListener {
|
||||||
|
if (isChecked) {
|
||||||
|
// User wants to enable username encryption, so we add it to the
|
||||||
|
// encrypted extras as the first line.
|
||||||
|
val username = filename.text.toString()
|
||||||
|
val extras = "username:$username\n${extraContent.text}"
|
||||||
|
|
||||||
|
filename.text?.clear()
|
||||||
|
extraContent.setText(extras)
|
||||||
|
} else {
|
||||||
|
// User wants to disable username encryption, so we extract the
|
||||||
|
// username from the encrypted extras and use it as the filename.
|
||||||
|
val entry =
|
||||||
|
passwordEntryFactory.create(
|
||||||
|
lifecycleScope,
|
||||||
|
"PASSWORD\n${extraContent.text}".encodeToByteArray()
|
||||||
|
)
|
||||||
|
val username = entry.username
|
||||||
|
|
||||||
|
// username should not be null here by the logic in
|
||||||
|
// updateViewState, but it could still happen due to
|
||||||
|
// input lag.
|
||||||
|
if (username != null) {
|
||||||
|
filename.setText(username)
|
||||||
|
extraContent.setText(entry.extraContentWithoutAuthData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listOf(filename, extraContent).forEach {
|
||||||
|
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestedPass?.let {
|
||||||
|
password.setText(it)
|
||||||
|
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
suggestedExtra?.let { extraContent.setText(it) }
|
||||||
|
if (shouldGeneratePassword) {
|
||||||
|
generatePassword()
|
||||||
|
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateViewState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
onBackPressed()
|
||||||
|
}
|
||||||
|
R.id.save_password -> {
|
||||||
|
copy = false
|
||||||
|
encrypt()
|
||||||
|
}
|
||||||
|
R.id.save_and_copy_password -> {
|
||||||
|
copy = true
|
||||||
|
encrypt()
|
||||||
|
}
|
||||||
|
else -> return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatePassword() {
|
||||||
|
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
|
||||||
|
requestKey,
|
||||||
|
bundle ->
|
||||||
|
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
||||||
|
binding.password.setText(bundle.getString(RESULT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
|
||||||
|
KEY_PWGEN_TYPE_CLASSIC ->
|
||||||
|
PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
|
||||||
|
KEY_PWGEN_TYPE_XKPASSWD ->
|
||||||
|
XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateViewState() =
|
||||||
|
with(binding) {
|
||||||
|
// Use PasswordEntry to parse extras for username
|
||||||
|
val entry =
|
||||||
|
passwordEntryFactory.create(
|
||||||
|
lifecycleScope,
|
||||||
|
"PLACEHOLDER\n${extraContent.text}".encodeToByteArray()
|
||||||
|
)
|
||||||
|
encryptUsername.apply {
|
||||||
|
if (visibility != View.VISIBLE) return@apply
|
||||||
|
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||||
|
val hasUsernameInExtras = !entry.username.isNullOrBlank()
|
||||||
|
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||||
|
isChecked = hasUsernameInExtras
|
||||||
|
}
|
||||||
|
otpImportButton.isVisible = !entry.hasTotp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypts the password and the extra content */
|
||||||
|
private fun encrypt() {
|
||||||
|
with(binding) {
|
||||||
|
val editName = filename.text.toString().trim()
|
||||||
|
val editPass = password.text.toString()
|
||||||
|
val editExtra = extraContent.text.toString()
|
||||||
|
|
||||||
|
if (editName.isEmpty()) {
|
||||||
|
snackbar(message = resources.getString(R.string.file_toast_text))
|
||||||
|
return@with
|
||||||
|
} else if (editName.contains('/')) {
|
||||||
|
snackbar(message = resources.getString(R.string.invalid_filename_text))
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editPass.isEmpty() && editExtra.isEmpty()) {
|
||||||
|
snackbar(message = resources.getString(R.string.empty_toast_text))
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copy) {
|
||||||
|
copyPasswordToClipboard(editPass)
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = "$editPass\n$editExtra"
|
||||||
|
val path =
|
||||||
|
when {
|
||||||
|
// If we allowed the user to edit the relative path, we have to consider it here
|
||||||
|
// instead
|
||||||
|
// of fullPath.
|
||||||
|
directoryInputLayout.isEnabled -> {
|
||||||
|
val editRelativePath = directory.text.toString().trim()
|
||||||
|
if (editRelativePath.isEmpty()) {
|
||||||
|
snackbar(message = resources.getString(R.string.path_toast_text))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
|
||||||
|
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
|
||||||
|
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
"${passwordDirectory.path}/$editName.gpg"
|
||||||
|
}
|
||||||
|
else -> "$fullPath/$editName.gpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
runCatching {
|
||||||
|
val crypto = cryptos.first { it.canHandle(path) }
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) { crypto.encrypt(PUB_KEY, content.encodeToByteArray()) }
|
||||||
|
val file = File(path)
|
||||||
|
// If we're not editing, this file should not already exist!
|
||||||
|
// Additionally, if we were editing and the incoming and outgoing
|
||||||
|
// filenames differ, it means we renamed. Ensure that the target
|
||||||
|
// doesn't already exist to prevent an accidental overwrite.
|
||||||
|
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()
|
||||||
|
) {
|
||||||
|
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
||||||
|
return@runCatching
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.isInsideRepository()) {
|
||||||
|
snackbar(message = getString(R.string.message_error_destination_outside_repo))
|
||||||
|
return@runCatching
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { file.outputStream().use { it.write(result) } }
|
||||||
|
|
||||||
|
// associate the new password name with the last name's timestamp in
|
||||||
|
// history
|
||||||
|
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||||
|
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
|
||||||
|
val timestamp = preference.getString(oldFilePathHash)
|
||||||
|
if (timestamp != null) {
|
||||||
|
preference.edit {
|
||||||
|
remove(oldFilePathHash)
|
||||||
|
putString(file.absolutePath.base64(), timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val returnIntent = Intent()
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
|
||||||
|
|
||||||
|
if (shouldGeneratePassword) {
|
||||||
|
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
||||||
|
val entry = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray())
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
||||||
|
val username = entry.username ?: directoryStructure.getUsernameFor(file)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directoryInputLayout.isVisible &&
|
||||||
|
directoryInputLayout.isEnabled &&
|
||||||
|
oldFileName != null
|
||||||
|
) {
|
||||||
|
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
||||||
|
if (oldFile.path != file.path && !oldFile.delete()) {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity)
|
||||||
|
.setTitle(R.string.password_creation_file_fail_title)
|
||||||
|
.setMessage(
|
||||||
|
getString(R.string.password_creation_file_delete_fail_message, oldFileName)
|
||||||
|
)
|
||||||
|
.setCancelable(false)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||||
|
.show()
|
||||||
|
return@runCatching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val commitMessageRes =
|
||||||
|
if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
|
||||||
|
lifecycleScope.launch {
|
||||||
|
commitChange(
|
||||||
|
resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName))
|
||||||
|
)
|
||||||
|
.onSuccess {
|
||||||
|
setResult(RESULT_OK, returnIntent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
if (e is IOException) {
|
||||||
|
e(e) { "Failed to write password file" }
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity)
|
||||||
|
.setTitle(getString(R.string.password_creation_file_fail_title))
|
||||||
|
.setMessage(getString(R.string.password_creation_file_write_fail_message))
|
||||||
|
.setCancelable(false)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||||
|
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||||
|
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
|
||||||
|
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
|
||||||
|
const val RESULT = "RESULT"
|
||||||
|
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
|
||||||
|
const val RETURN_EXTRA_NAME = "NAME"
|
||||||
|
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
|
||||||
|
const val RETURN_EXTRA_USERNAME = "USERNAME"
|
||||||
|
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
|
||||||
|
const val EXTRA_FILE_NAME = "FILENAME"
|
||||||
|
const val EXTRA_PASSWORD = "PASSWORD"
|
||||||
|
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
|
||||||
|
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
|
||||||
|
const val EXTRA_EDITING = "EDITING"
|
||||||
|
// TODO(msfjarvis): source this from storage
|
||||||
|
const val PUB_KEY = ""
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,6 +38,7 @@ import dev.msfjarvis.aps.data.password.PasswordItem
|
||||||
import dev.msfjarvis.aps.data.repo.PasswordRepository
|
import dev.msfjarvis.aps.data.repo.PasswordRepository
|
||||||
import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName
|
import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName
|
||||||
import dev.msfjarvis.aps.ui.crypto.DecryptActivity
|
import dev.msfjarvis.aps.ui.crypto.DecryptActivity
|
||||||
|
import dev.msfjarvis.aps.ui.crypto.GopenpgpDecryptActivity
|
||||||
import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
|
import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
|
||||||
import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
|
import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
|
||||||
import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment
|
import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment
|
||||||
|
@ -46,6 +47,7 @@ import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
|
||||||
import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity
|
import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity
|
||||||
import dev.msfjarvis.aps.ui.settings.DirectorySelectionActivity
|
import dev.msfjarvis.aps.ui.settings.DirectorySelectionActivity
|
||||||
import dev.msfjarvis.aps.ui.settings.SettingsActivity
|
import dev.msfjarvis.aps.ui.settings.SettingsActivity
|
||||||
|
import dev.msfjarvis.aps.util.FeatureFlags
|
||||||
import dev.msfjarvis.aps.util.autofill.AutofillMatcher
|
import dev.msfjarvis.aps.util.autofill.AutofillMatcher
|
||||||
import dev.msfjarvis.aps.util.extensions.base64
|
import dev.msfjarvis.aps.util.extensions.base64
|
||||||
import dev.msfjarvis.aps.util.extensions.commitChange
|
import dev.msfjarvis.aps.util.extensions.commitChange
|
||||||
|
@ -422,7 +424,14 @@ class PasswordStore : BaseGitActivity() {
|
||||||
val authDecryptIntent = item.createAuthEnabledIntent(this)
|
val authDecryptIntent = item.createAuthEnabledIntent(this)
|
||||||
val decryptIntent =
|
val decryptIntent =
|
||||||
(authDecryptIntent.clone() as Intent).setComponent(
|
(authDecryptIntent.clone() as Intent).setComponent(
|
||||||
ComponentName(this, DecryptActivity::class.java)
|
ComponentName(
|
||||||
|
this,
|
||||||
|
if (FeatureFlags.ENABLE_GOPENPGP) {
|
||||||
|
GopenpgpDecryptActivity::class.java
|
||||||
|
} else {
|
||||||
|
DecryptActivity::class.java
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
startActivity(decryptIntent)
|
startActivity(decryptIntent)
|
||||||
|
|
6
app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt
Normal file
6
app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package dev.msfjarvis.aps.util
|
||||||
|
|
||||||
|
/** Naive feature flagging functionality to allow merging incomplete features */
|
||||||
|
object FeatureFlags {
|
||||||
|
const val ENABLE_GOPENPGP = false
|
||||||
|
}
|
|
@ -25,6 +25,8 @@ import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
|
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
|
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
||||||
|
import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity
|
||||||
|
import dev.msfjarvis.aps.util.FeatureFlags
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
|
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
|
||||||
|
@ -68,7 +70,12 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
|
||||||
): Dataset? {
|
): Dataset? {
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||||
val metadata = makeFillMatchMetadata(context, file)
|
val metadata = makeFillMatchMetadata(context, file)
|
||||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
val intentSender =
|
||||||
|
if (FeatureFlags.ENABLE_GOPENPGP) {
|
||||||
|
GopenpgpAutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
|
} else {
|
||||||
|
AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
|
}
|
||||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,8 @@ import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
|
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
|
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
||||||
|
import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity
|
||||||
|
import dev.msfjarvis.aps.util.FeatureFlags
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@ -56,7 +58,12 @@ class AutofillResponseBuilder(form: FillableForm) {
|
||||||
private fun makeMatchDataset(context: Context, file: File): Dataset? {
|
private fun makeMatchDataset(context: Context, file: File): Dataset? {
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||||
val metadata = makeFillMatchMetadata(context, file)
|
val metadata = makeFillMatchMetadata(context, file)
|
||||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
val intentSender =
|
||||||
|
if (FeatureFlags.ENABLE_GOPENPGP) {
|
||||||
|
GopenpgpAutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
|
} else {
|
||||||
|
AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
|
}
|
||||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
6
crypto-common/api/crypto-common.api
Normal file
6
crypto-common/api/crypto-common.api
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
8
crypto-common/build.gradle.kts
Normal file
8
crypto-common/build.gradle.kts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
`aps-plugin`
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.data.crypto
|
||||||
|
|
||||||
|
/** Generic interface to implement cryptographic operations on top of. */
|
||||||
|
public interface CryptoHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt the given [ciphertext] using a [privateKey] and [passphrase], returning a [ByteArray]
|
||||||
|
* corresponding to the decrypted plaintext.
|
||||||
|
*/
|
||||||
|
public fun decrypt(privateKey: String, passphrase: ByteArray, ciphertext: ByteArray): ByteArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt the given [plaintext] to the provided [publicKey], returning the encrypted ciphertext
|
||||||
|
* as a [ByteArray]
|
||||||
|
*/
|
||||||
|
public fun encrypt(publicKey: String, plaintext: ByteArray): ByteArray
|
||||||
|
|
||||||
|
/** Given a [fileName], return whether this instance can handle it. */
|
||||||
|
public fun canHandle(fileName: String): Boolean
|
||||||
|
}
|
7
crypto-pgp/api/crypto-pgp.api
Normal file
7
crypto-pgp/api/crypto-pgp.api
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
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
|
||||||
|
public fun decrypt (Ljava/lang/String;[B[B)[B
|
||||||
|
public fun encrypt (Ljava/lang/String;[B)[B
|
||||||
|
}
|
||||||
|
|
15
crypto-pgp/build.gradle.kts
Normal file
15
crypto-pgp/build.gradle.kts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* 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`
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.cryptoCommon)
|
||||||
|
implementation(libs.aps.gopenpgp)
|
||||||
|
implementation(libs.dagger.hilt.core)
|
||||||
|
}
|
6
crypto-pgp/src/main/AndroidManifest.xml
Normal file
6
crypto-pgp/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
~ SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
|
||||||
|
-->
|
||||||
|
|
||||||
|
<manifest package="dev.msfjarvis.aps.cryptopgp"></manifest>
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* 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.Crypto
|
||||||
|
import com.proton.Gopenpgp.helper.Helper
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Gopenpgp backed implementation of [CryptoHandler]. */
|
||||||
|
public class GopenpgpCryptoHandler @Inject constructor() : CryptoHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt the given [ciphertext] using the given PGP [privateKey] and corresponding [passphrase].
|
||||||
|
*/
|
||||||
|
override fun decrypt(
|
||||||
|
privateKey: String,
|
||||||
|
passphrase: ByteArray,
|
||||||
|
ciphertext: ByteArray,
|
||||||
|
): ByteArray {
|
||||||
|
// Decode the incoming cipher into a string and try to guess if it's armored.
|
||||||
|
val cipherString = ciphertext.decodeToString()
|
||||||
|
val isArmor = cipherString.startsWith("-----BEGIN PGP MESSAGE-----")
|
||||||
|
val message =
|
||||||
|
if (isArmor) {
|
||||||
|
Crypto.newPGPMessageFromArmored(cipherString)
|
||||||
|
} else {
|
||||||
|
Crypto.newPGPMessage(ciphertext)
|
||||||
|
}
|
||||||
|
return Helper.decryptBinaryMessageArmored(
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
message.armored,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encrypt(publicKey: String, plaintext: ByteArray): ByteArray {
|
||||||
|
return Helper.encryptBinaryMessage(
|
||||||
|
publicKey,
|
||||||
|
plaintext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(fileName: String): Boolean {
|
||||||
|
return fileName.split('.').last() == "gpg"
|
||||||
|
}
|
||||||
|
}
|
|
@ -72,6 +72,7 @@ dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt
|
||||||
android-desugarJdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
|
android-desugarJdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
|
||||||
|
|
||||||
# First-party libraries
|
# First-party libraries
|
||||||
|
aps-gopenpgp = "com.github.android-password-store:gopenpgp:0.1.5"
|
||||||
aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:1.0.0"
|
aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:1.0.0"
|
||||||
aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.0"
|
aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.0"
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,10 @@ include(":app")
|
||||||
|
|
||||||
include(":autofill-parser")
|
include(":autofill-parser")
|
||||||
|
|
||||||
|
include(":crypto-common")
|
||||||
|
|
||||||
|
include(":crypto-pgp")
|
||||||
|
|
||||||
include(":format-common")
|
include(":format-common")
|
||||||
|
|
||||||
include(":openpgp-ktx")
|
include(":openpgp-ktx")
|
||||||
|
|
Loading…
Reference in a new issue