diff options
20 files changed, 966 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) } 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 <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 +} + 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 @@ +<?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> 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") |