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
@@ -44,6 +44,10 @@
android:name=".ui.proxy.ProxySelectorActivity"
android:windowSoftInputMode="adjustResize" />
+
+
+
+
@@ -128,6 +136,9 @@
+
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()
+ 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)
}
diff --git a/crypto-common/api/crypto-common.api b/crypto-common/api/crypto-common.api
new file mode 100644
index 00000000..7493379c
--- /dev/null
+++ b/crypto-common/api/crypto-common.api
@@ -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
+}
+
diff --git a/crypto-common/build.gradle.kts b/crypto-common/build.gradle.kts
new file mode 100644
index 00000000..c1f3eef8
--- /dev/null
+++ b/crypto-common/build.gradle.kts
@@ -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`
+}
diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoHandler.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoHandler.kt
new file mode 100644
index 00000000..453613a4
--- /dev/null
+++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoHandler.kt
@@ -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
+}
diff --git a/crypto-pgp/api/crypto-pgp.api b/crypto-pgp/api/crypto-pgp.api
new file mode 100644
index 00000000..2164360c
--- /dev/null
+++ b/crypto-pgp/api/crypto-pgp.api
@@ -0,0 +1,7 @@
+public final class dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler : dev/msfjarvis/aps/data/crypto/CryptoHandler {
+ public fun ()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
+}
+
diff --git a/crypto-pgp/build.gradle.kts b/crypto-pgp/build.gradle.kts
new file mode 100644
index 00000000..493062b6
--- /dev/null
+++ b/crypto-pgp/build.gradle.kts
@@ -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)
+}
diff --git a/crypto-pgp/src/main/AndroidManifest.xml b/crypto-pgp/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..f72b702d
--- /dev/null
+++ b/crypto-pgp/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler.kt b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler.kt
new file mode 100644
index 00000000..5d14b160
--- /dev/null
+++ b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler.kt
@@ -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"
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dca7c641..77ae2d0a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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"
# 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-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.0"
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 37ce4d98..bcb9fae4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,6 +8,10 @@ include(":app")
include(":autofill-parser")
+include(":crypto-common")
+
+include(":crypto-pgp")
+
include(":format-common")
include(":openpgp-ktx")