summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2021-07-11 22:52:26 +0530
committerGitHub <noreply@github.com>2021-07-11 17:22:26 +0000
commit6e4ffe290265ef8b3cd82f5ab6a7eb8c0157bf6a (patch)
tree13739c1079e5cd4ce609cdbb4501450b2eabecb4 /app
parent9c388e49748084bdac3fb277ea507b80b9c7c33a (diff)
Add initial implementation of Gopenpgp-backed PGP (#1441)
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle.kts1
-rw-r--r--app/src/main/AndroidManifest.xml11
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt31
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt7
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt155
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt177
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt432
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt11
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt6
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt9
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt9
11 files changed, 845 insertions, 4 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c6440adb..a5716989 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -76,6 +76,7 @@ dependencies {
implementation(libs.androidx.annotation)
coreLibraryDesugaring(libs.android.desugarJdkLibs)
implementation(projects.autofillParser)
+ implementation(projects.cryptoPgp)
implementation(projects.formatCommon)
implementation(projects.openpgpKtx)
implementation(libs.androidx.activity.ktx)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a2c6003c..98d28f0a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -45,6 +45,10 @@
android:windowSoftInputMode="adjustResize" />
<activity
+ android:name=".ui.crypto.GopenpgpDecryptActivity"
+ android:exported="true" />
+
+ <activity
android:name=".ui.main.LaunchActivity"
android:configChanges="orientation|screenSize"
android:label="@string/app_name"
@@ -91,6 +95,10 @@
android:label="@string/new_password_title"
android:windowSoftInputMode="adjustResize" />
+ <activity android:name=".ui.crypto.GopenpgpPasswordCreationActivity"
+ android:label="@string/new_password_title"
+ android:windowSoftInputMode="adjustResize" />
+
<activity
android:name=".ui.crypto.DecryptActivity"
android:windowSoftInputMode="adjustResize" />
@@ -129,6 +137,9 @@
android:name=".ui.autofill.AutofillDecryptActivity"
android:theme="@style/NoBackgroundTheme" />
<activity
+ android:name=".ui.autofill.GopenpgpAutofillDecryptActivity"
+ android:theme="@style/NoBackgroundTheme" />
+ <activity
android:name=".ui.autofill.AutofillFilterView"
android:configChanges="orientation|keyboardHidden"
android:theme="@style/DialogLikeTheme"
diff --git a/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt b/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt
new file mode 100644
index 00000000..fdd37bf3
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/injection/crypto/CryptoHandlerModule.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.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>
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
index afa2a6a0..1d5160e2 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
@@ -29,6 +29,7 @@ import com.github.androidpasswordstore.autofillparser.FormOrigin
import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.password.PasswordItem
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.AutofillPreferences
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
@@ -220,7 +221,11 @@ class AutofillFilterView : AppCompatActivity() {
AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
// intent?.extras? is checked to be non-null in onCreate
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)
+ }
)
}
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt
new file mode 100644
index 00000000..1c9f19d7
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt
new file mode 100644
index 00000000..b2e710f8
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt
@@ -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 = ""
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt
new file mode 100644
index 00000000..2228a758
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt
@@ -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 = ""
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
index 892289af..38c97f36 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
@@ -38,6 +38,7 @@ import dev.msfjarvis.aps.data.password.PasswordItem
import dev.msfjarvis.aps.data.repo.PasswordRepository
import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName
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.dialogs.BasicBottomSheet
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.settings.DirectorySelectionActivity
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.extensions.base64
import dev.msfjarvis.aps.util.extensions.commitChange
@@ -422,7 +424,14 @@ class PasswordStore : BaseGitActivity() {
val authDecryptIntent = item.createAuthEnabledIntent(this)
val decryptIntent =
(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)
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt b/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt
new file mode 100644
index 00000000..09544267
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt
@@ -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
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
index ea03bb23..80891f3a 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
@@ -25,6 +25,8 @@ import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
+import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity
+import dev.msfjarvis.aps.util.FeatureFlags
import java.io.File
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
@@ -68,7 +70,12 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
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)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
index 9dabf914..2f3568c4 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
@@ -25,6 +25,8 @@ import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
+import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity
+import dev.msfjarvis.aps.util.FeatureFlags
import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
@@ -56,7 +58,12 @@ class AutofillResponseBuilder(form: FillableForm) {
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
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)
}