diff options
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/ui/crypto')
4 files changed, 1159 insertions, 0 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt new file mode 100644 index 00000000..e75d6f0a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt @@ -0,0 +1,310 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.app.PendingIntent +import android.content.ClipData +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.format.DateUtils +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.util.services.ClipboardService +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clipboard +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.snackbar +import java.io.File +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@Suppress("Registered") +open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { + + /** + * Full path to the repository + */ + val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! } + + /** + * Full path to the password file being worked on + */ + val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! } + + /** + * Name of the password file + * + * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org + */ + val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension } + + /** + * Get the timestamp for when this file was last modified. + */ + val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) { + getLastChangedString( + intent.getLongExtra( + "LAST_CHANGED_TIMESTAMP", + -1L + ) + ) + } + + /** + * [SharedPreferences] instance used by subclasses to persist settings + */ + val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs } + + /** + * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. + */ + private var serviceConnection: OpenPgpServiceConnection? = null + var api: OpenPgpApi? = null + + /** + * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with + * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. + */ + private var previousListener: OpenPgpServiceConnection.OnBound? = null + + /** + * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots + * or recent apps screen. + */ + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + tag(TAG) + } + + /** + * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This + * is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not + * leaking things. + */ + @CallSuper + override fun onDestroy() { + super.onDestroy() + serviceConnection?.unbindFromService() + previousListener = null + } + + /** + * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted + * by the [OPENPGP_PROVIDER] package being missing. + */ + override fun onResume() { + super.onResume() + previousListener?.let { bindToOpenKeychain(it) } + } + + /** + * Sets up [api] once the service is bound. Downstream consumers must call super this to + * initialize [api] + */ + @CallSuper + override fun onBound(service: IOpenPgpService2) { + api = OpenPgpApi(this, service) + } + + /** + * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle + * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super. + */ + override fun onError(e: Exception) { + e(e) { "Callers must handle their own exceptions" } + throw e + } + + /** + * Method for subclasses to initiate binding with [OpenPgpServiceConnection]. + */ + fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { + val installed = runCatching { + packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) + true + }.getOr(false) + if (!installed) { + previousListener = onBoundListener + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.openkeychain_not_installed_title)) + .setMessage(getString(R.string.openkeychain_not_installed_message)) + .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) + setPackage("com.android.vending") + } + startActivity(intent) + } + } + .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) + } + startActivity(intent) + } + } + .setOnCancelListener { finish() } + .show() + return + } else { + previousListener = null + serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { + it.bindToService() + } + } + } + + /** + * Handle the case where OpenKeychain returns that it needs to interact with the user + * + * @param result The intent returned by OpenKeychain + */ + fun getUserInteractionRequestIntent(result: Intent): IntentSender { + i { "RESULT_CODE_USER_INTERACTION_REQUIRED" } + return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender + } + + /** + * Gets a relative string describing when this shape was last changed + * (e.g. "one hour ago") + */ + private fun getLastChangedString(timeStamp: Long): CharSequence { + if (timeStamp < 0) { + throw RuntimeException() + } + + return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) + } + + /** + * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses + * can use this when they want to default to sane error handling. + */ + fun handleError(result: Intent) { + val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + when (error.errorId) { + OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { + snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) + } + OpenPgpError.NO_USER_IDS -> { + snackbar(message = getString(R.string.openpgp_error_no_user_ids)) + } + else -> { + snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) + e { "onError getErrorId: ${error.errorId}" } + e { "onError getMessage: ${error.message}" } + } + } + } + } + + /** + * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing + * [showSnackbar] as false. + */ + fun copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text + ) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clipboard.setPrimaryClip(clip) + if (showSnackbar) { + snackbar(message = resources.getString(snackbarTextRes)) + } + } + + /** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to + * hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a + * way of clearing the clipboard. + */ + fun copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password, showSnackbar = false) + + val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 + + if (clearAfter != 0) { + val service = Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(service) + } else { + startService(service) + } + snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) + } else { + snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) + } + } + + companion object { + + private const val TAG = "APS/BasePgpActivity" + const val KEY_PWGEN_TYPE_CLASSIC = "classic" + const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + + /** + * Gets the relative path to the repository + */ + fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + + /** + * Gets the Parent path, relative to the repository + */ + fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") + } + + /** + * /path/to/store/social/facebook.gpg -> social/facebook + */ + @JvmStatic + fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt new file mode 100644 index 00000000..8f36cd25 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt @@ -0,0 +1,260 @@ +/* + * Copyright © 2014-2020 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.graphics.Typeface +import android.os.Bundle +import android.text.method.PasswordTransformationMethod +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.DecryptLayoutBinding +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.ByteArrayOutputStream +import java.io.File +import kotlin.time.ExperimentalTime +import kotlin.time.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 + +class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(DecryptLayoutBinding::inflate) + + private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) } + private var passwordEntry: PasswordEntry? = null + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> decryptAndVerify(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + passwordLastChanged.run { + runCatching { + text = resources.getString(R.string.last_changed, lastChangedString) + }.onFailure { + visibility = View.GONE + } + } + } + } + + 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.isNotEmpty()) { + 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) + } + return super.onOptionsItemSelected(item) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + decryptAndVerify() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * 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(60.seconds) + 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?.extraContent) + 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))) + } + + @OptIn(ExperimentalTime::class) + private fun decryptAndVerify(receivedIntent: Intent? = null) { + if (api == null) { + bindToOpenKeychain(this) + return + } + val data = receivedIntent ?: Intent() + data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY + + val inputStream = File(fullPath).inputStream() + val outputStream = ByteArrayOutputStream() + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + startAutoDismissTimer() + runCatching { + val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true) + val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") + val entry = PasswordEntry(outputStream) + + passwordEntry = entry + invalidateOptionsMenu() + + with(binding) { + if (entry.password.isEmpty()) { + passwordTextContainer.visibility = View.GONE + } else { + passwordTextContainer.visibility = View.VISIBLE + passwordText.typeface = monoTypeface + passwordText.setText(entry.password) + if (!showPassword) { + passwordText.transformationMethod = PasswordTransformationMethod.getInstance() + } + passwordTextContainer.setOnClickListener { copyPasswordToClipboard(entry.password) } + passwordText.setOnClickListener { copyPasswordToClipboard(entry.password) } + } + + if (entry.hasExtraContent()) { + if (entry.extraContentWithoutAuthData.isNotEmpty()) { + extraContentContainer.visibility = View.VISIBLE + extraContent.typeface = monoTypeface + extraContent.setText(entry.extraContentWithoutAuthData) + if (!showExtraContent) { + extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + } + extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + } + + if (entry.hasUsername()) { + usernameText.typeface = monoTypeface + usernameText.setText(entry.username) + usernameTextContainer.setEndIconOnClickListener { copyTextToClipboard(entry.username) } + usernameTextContainer.visibility = View.VISIBLE + } else { + usernameTextContainer.visibility = View.GONE + } + + if (entry.hasTotp()) { + otpTextContainer.visibility = View.VISIBLE + otpTextContainer.setEndIconOnClickListener { + copyTextToClipboard( + otpText.text.toString(), + snackbarTextRes = R.string.clipboard_otp_copied_text + ) + } + launch(Dispatchers.IO) { + // Calculate the actual remaining time for the first pass + // then return to the standard 30 second affair. + val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod) + withContext(Dispatchers.Main) { + otpText.setText(entry.calculateTotpCode() + ?: "Error") + } + delay(remainingTime.seconds) + repeat(Int.MAX_VALUE) { + val code = entry.calculateTotpCode() ?: "Error" + withContext(Dispatchers.Main) { + otpText.setText(code) + } + delay(30.seconds) + } + } + } + } + } + + if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { + copyPasswordToClipboard(entry.password) + } + }.onFailure { e -> + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt new file mode 100644 index 00000000..f49537aa --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt @@ -0,0 +1,76 @@ +/* + * Copyright © 2014-2020 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 androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpUtils +import org.openintents.openpgp.IOpenPgpService2 + +class GetKeyIdsActivity : BasePgpActivity() { + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null || result.resultCode == RESULT_CANCELED) { + setResult(RESULT_CANCELED, result.data) + finish() + return@registerForActivityResult + } + getKeyIds(result.data!!) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + getKeyIds() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Get the Key ids from OpenKeychain + */ + private fun getKeyIds(data: Intent = Intent()) { + data.action = OpenPgpApi.ACTION_GET_KEY_IDS + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, null, null) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { + OpenPgpUtils.convertKeyIdToHex(it) + } ?: emptyList() + val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) + setResult(RESULT_OK, keyResult) + finish() + }.onFailure { e -> + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt new file mode 100644 index 00000000..403f6e2a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt @@ -0,0 +1,513 @@ +/* + * Copyright © 2014-2020 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.os.Bundle +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +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 dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment +import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding +import dev.msfjarvis.aps.util.settings.PreferenceKeys +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.viewBinding +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import me.msfjarvis.openpgpktx.util.OpenPgpUtils + +class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + + private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } + private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) } + private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } + private var oldCategory: String? = null + private var copy: Boolean = false + private var encryptionIntent: Intent = Intent() + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> encrypt(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + 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)) + } + } + + private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + withContext(Dispatchers.IO) { + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + } + commitChange(getString( + R.string.git_commit_gpg_id, + getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + )).onSuccess { + encrypt(encryptionIntent) + } + } + } + } + } + + private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + 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 { + otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + } + + 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@PasswordCreationActivity) == + 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 = PasswordEntry("PASSWORD\n${extraContent.text}") + 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) + finish() + } + 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() { + 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 = PasswordEntry("PLACEHOLDER\n${extraContent.text}") + encryptUsername.apply { + if (visibility != View.VISIBLE) + return@apply + val hasUsernameInFileName = filename.text.toString().isNotBlank() + val hasUsernameInExtras = entry.hasUsername() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + otpImportButton.isVisible = !entry.hasTotp() + } + + private sealed class GpgIdentifier { + data class KeyId(val id: Long) : GpgIdentifier() + data class UserId(val email: String) : GpgIdentifier() + } + + @OptIn(ExperimentalUnsignedTypes::class) + private fun parseGpgIdentifier(identifier: String): GpgIdentifier? { + if (identifier.isEmpty()) return null + // Match long key IDs: + // FF22334455667788 or 0xFF22334455667788 + val maybeLongKeyId = identifier.removePrefix("0x").takeIf { + it.matches("[a-fA-F0-9]{16}".toRegex()) + } + if (maybeLongKeyId != null) { + val keyId = maybeLongKeyId.toULong(16) + return GpgIdentifier.KeyId(keyId.toLong()) + } + + // Match fingerprints: + // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 + val maybeFingerprint = identifier.removePrefix("0x").takeIf { + it.matches("[a-fA-F0-9]{40}".toRegex()) + } + if (maybeFingerprint != null) { + // Truncating to the long key ID is not a security issue since OpenKeychain only accepts + // non-ambiguous key IDs. + val keyId = maybeFingerprint.takeLast(16).toULong(16) + return GpgIdentifier.KeyId(keyId.toLong()) + } + + return OpenPgpUtils.splitUserId(identifier).email?.let { GpgIdentifier.UserId(it) } + } + + /** + * Encrypts the password and the extra content + */ + private fun encrypt(receivedIntent: Intent? = null) { + 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) + } + + encryptionIntent = receivedIntent ?: Intent() + encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT + + // pass enters the key ID into `.gpg-id`. + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) + if (gpgIdentifierFile == null) { + snackbar(message = resources.getString(R.string.failed_to_find_key_id)) + return@with + } + val gpgIdentifiers = gpgIdentifierFile.readLines() + .filter { it.isNotBlank() } + .map { line -> + parseGpgIdentifier(line) ?: run { + // The line being empty means this is most likely an empty `.gpg-id` file + // we created. Skip the validation so we can make the user add a real ID. + if (line.isEmpty()) return@run + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { + snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) + } else { + snackbar(message = resources.getString(R.string.invalid_gpg_id)) + } + return@with + } + } + if (gpgIdentifiers.isEmpty()) { + gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java)) + return@with + } + val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray() + if (keyIds.isNotEmpty()) { + encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds) + } + val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray() + if (userIds.isNotEmpty()) { + encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds) + } + + encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true) + + val content = "$editPass\n$editExtra" + val inputStream = ByteArrayInputStream(content.toByteArray()) + val outputStream = ByteArrayOutputStream() + + 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.IO) { + api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val file = File(path) + // If we're not editing, this file should not already exist! + if (!editing && file.exists()) { + snackbar(message = getString(R.string.password_creation_duplicate_error)) + return@executeApiAsync + } + + if (!file.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@executeApiAsync + } + + file.outputStream().use { + it.write(outputStream.toByteArray()) + } + + //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 = PasswordEntry(content) + 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@PasswordCreationActivity) + .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@executeApiAsync + } + } + + 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@PasswordCreationActivity) + .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) + } + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } + } + + companion object { + + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + 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" + } +} |
