Remove OpenKeychain code and leave TODOs for missing functionality
This commit is contained in:
parent
a6bcdd1d9d
commit
bcf33e90a5
39 changed files with 61 additions and 1978 deletions
|
@ -48,7 +48,6 @@ dependencies {
|
|||
implementation(projects.coroutineUtils)
|
||||
implementation(projects.cryptoPgpainless)
|
||||
implementation(projects.formatCommon)
|
||||
implementation(projects.openpgpKtx)
|
||||
implementation(projects.passgen.diceware)
|
||||
implementation(projects.passgen.random)
|
||||
implementation(projects.uiCompose)
|
||||
|
@ -85,7 +84,6 @@ dependencies {
|
|||
implementation(libs.thirdparty.logcat)
|
||||
implementation(libs.thirdparty.modernAndroidPrefs)
|
||||
implementation(libs.thirdparty.plumber)
|
||||
implementation(libs.thirdparty.sshauth)
|
||||
implementation(libs.thirdparty.sshj) { exclude(group = "org.bouncycastle") }
|
||||
implementation(libs.thirdparty.bouncycastle.bcprov)
|
||||
implementation(libs.thirdparty.bouncycastle.bcpkix)
|
||||
|
|
|
@ -95,28 +95,12 @@
|
|||
android:label="@string/action_settings"
|
||||
android:parentActivityName=".ui.passwords.PasswordStore" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.crypto.PasswordCreationActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/new_password_title"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.crypto.PasswordCreationActivityV2"
|
||||
android:exported="false"
|
||||
android:label="@string/new_password_title"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.crypto.DecryptActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.crypto.GetKeyIdsActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/NoBackgroundThemeM3" />
|
||||
|
||||
<service
|
||||
android:name=".util.services.ClipboardService"
|
||||
android:exported="false"
|
||||
|
@ -150,10 +134,6 @@
|
|||
android:exported="false"
|
||||
android:label="@string/pref_ssh_keygen_title"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.autofill.AutofillDecryptActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/NoBackgroundThemeM3" />
|
||||
<activity
|
||||
android:name=".ui.autofill.AutofillDecryptActivityV2"
|
||||
android:exported="false"
|
||||
|
|
|
@ -1,269 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
package app.passwordstore.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 android.widget.Toast
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.passwordstore.data.passfile.PasswordEntry
|
||||
import app.passwordstore.util.autofill.AutofillPreferences
|
||||
import app.passwordstore.util.autofill.AutofillResponseBuilder
|
||||
import app.passwordstore.util.autofill.DirectoryStructure
|
||||
import app.passwordstore.util.extensions.OPENPGP_PROVIDER
|
||||
import app.passwordstore.util.extensions.asLog
|
||||
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 java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority.ERROR
|
||||
import logcat.logcat
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||
import org.openintents.openpgp.IOpenPgpService2
|
||||
import org.openintents.openpgp.OpenPgpError
|
||||
|
||||
@RequiresApi(26)
|
||||
@AndroidEntryPoint
|
||||
class AutofillDecryptActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_FILE_PATH = "app.passwordstore.autofill.oreo.EXTRA_FILE_PATH"
|
||||
private const val EXTRA_SEARCH_ACTION = "app.passwordstore.autofill.oreo.EXTRA_SEARCH_ACTION"
|
||||
|
||||
private var decryptFileRequestCode = 1
|
||||
|
||||
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
|
||||
return Intent(context, AutofillDecryptActivity::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, AutofillDecryptActivity::class.java).apply {
|
||||
putExtra(EXTRA_SEARCH_ACTION, false)
|
||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
decryptFileRequestCode++,
|
||||
intent,
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
},
|
||||
)
|
||||
.intentSender
|
||||
}
|
||||
}
|
||||
|
||||
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
|
||||
|
||||
private val decryptInteractionRequiredAction =
|
||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (continueAfterUserInteraction != null) {
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
continueAfterUserInteraction?.resume(data)
|
||||
} else {
|
||||
continueAfterUserInteraction?.resumeWithException(
|
||||
Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
|
||||
)
|
||||
}
|
||||
continueAfterUserInteraction = null
|
||||
}
|
||||
}
|
||||
|
||||
private var continueAfterUserInteraction: Continuation<Intent>? = null
|
||||
private lateinit var directoryStructure: DirectoryStructure
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val filePath =
|
||||
intent?.getStringExtra(EXTRA_FILE_PATH)
|
||||
?: run {
|
||||
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val clientState =
|
||||
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
|
||||
?: run {
|
||||
logcat(ERROR) { "AutofillDecryptActivity 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)
|
||||
logcat { action.toString() }
|
||||
lifecycleScope.launch {
|
||||
val credentials = decryptCredential(File(filePath))
|
||||
if (credentials == null) {
|
||||
setResult(RESULT_CANCELED)
|
||||
} else {
|
||||
val fillInDataset =
|
||||
AutofillResponseBuilder.makeFillInDataset(
|
||||
this@AutofillDecryptActivity,
|
||||
credentials,
|
||||
clientState,
|
||||
action
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
setResult(
|
||||
RESULT_OK,
|
||||
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
||||
)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) { finish() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeOpenPgpApi(
|
||||
data: Intent,
|
||||
input: InputStream,
|
||||
output: OutputStream
|
||||
): Intent {
|
||||
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
||||
val openPgpService =
|
||||
suspendCoroutine<IOpenPgpService2> { cont ->
|
||||
openPgpServiceConnection =
|
||||
OpenPgpServiceConnection(
|
||||
this,
|
||||
OPENPGP_PROVIDER,
|
||||
object : OpenPgpServiceConnection.OnBound {
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
cont.resume(service)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
.also { it.bindToService() }
|
||||
}
|
||||
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
|
||||
openPgpServiceConnection?.unbindFromService()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? {
|
||||
val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY }
|
||||
runCatching { file.inputStream() }
|
||||
.onFailure { e ->
|
||||
logcat(ERROR) { e.asLog("File to decrypt not found") }
|
||||
return null
|
||||
}
|
||||
.onSuccess { encryptedInput ->
|
||||
val decryptedOutput = ByteArrayOutputStream()
|
||||
runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) }
|
||||
.onFailure { e ->
|
||||
logcat(ERROR) { e.asLog("OpenPgpApi ACTION_DECRYPT_VERIFY failed") }
|
||||
return null
|
||||
}
|
||||
.onSuccess { result ->
|
||||
return when (
|
||||
val resultCode =
|
||||
result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)
|
||||
) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val entry =
|
||||
withContext(Dispatchers.IO) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
passwordEntryFactory.create(decryptedOutput.toByteArray())
|
||||
}
|
||||
AutofillPreferences.credentialsFromStoreEntry(
|
||||
this,
|
||||
file,
|
||||
entry,
|
||||
directoryStructure
|
||||
)
|
||||
}
|
||||
.getOrElse { e ->
|
||||
logcat(ERROR) { e.asLog("Failed to parse password entry") }
|
||||
return null
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val pendingIntent: PendingIntent =
|
||||
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
||||
runCatching {
|
||||
val intentToResume =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<Intent> { cont ->
|
||||
continueAfterUserInteraction = cont
|
||||
decryptInteractionRequiredAction.launch(
|
||||
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
decryptCredential(file, intentToResume)
|
||||
}
|
||||
.getOrElse { e ->
|
||||
logcat(ERROR) {
|
||||
e.asLog("OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction")
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> {
|
||||
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
|
||||
if (error != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
"Error from OpenKeyChain: ${error.message}",
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
.show()
|
||||
}
|
||||
logcat(ERROR) {
|
||||
"OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}"
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
logcat(ERROR) { "Unrecognized OpenPgpApi result: $resultCode" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -15,7 +15,6 @@ import androidx.annotation.RequiresApi
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.os.bundleOf
|
||||
import app.passwordstore.data.repo.PasswordRepository
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivity
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivityV2
|
||||
import app.passwordstore.util.autofill.AutofillMatcher
|
||||
import app.passwordstore.util.autofill.AutofillPreferences
|
||||
|
@ -114,9 +113,9 @@ class AutofillSaveActivity : AppCompatActivity() {
|
|||
bundleOf(
|
||||
"REPO_PATH" to repo.absolutePath,
|
||||
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
||||
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
||||
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
||||
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to
|
||||
PasswordCreationActivityV2.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
||||
PasswordCreationActivityV2.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
||||
PasswordCreationActivityV2.EXTRA_GENERATE_PASSWORD to
|
||||
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -5,12 +5,9 @@
|
|||
|
||||
package app.passwordstore.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.view.WindowManager
|
||||
|
@ -19,33 +16,20 @@ import androidx.annotation.StringRes
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.injection.prefs.SettingsPreferences
|
||||
import app.passwordstore.util.extensions.OPENPGP_PROVIDER
|
||||
import app.passwordstore.util.extensions.asLog
|
||||
import app.passwordstore.util.extensions.clipboard
|
||||
import app.passwordstore.util.extensions.getString
|
||||
import app.passwordstore.util.extensions.snackbar
|
||||
import app.passwordstore.util.extensions.unsafeLazy
|
||||
import app.passwordstore.util.features.Features
|
||||
import app.passwordstore.util.services.ClipboardService
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import logcat.LogPriority.ERROR
|
||||
import logcat.LogPriority.INFO
|
||||
import logcat.logcat
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||
import org.openintents.openpgp.IOpenPgpService2
|
||||
import org.openintents.openpgp.OpenPgpError
|
||||
|
||||
@Suppress("Registered")
|
||||
@AndroidEntryPoint
|
||||
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
|
||||
open class BasePgpActivity : AppCompatActivity() {
|
||||
|
||||
/** Full path to the repository */
|
||||
val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! }
|
||||
|
@ -63,20 +47,6 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
|
|||
/** [SharedPreferences] instance used by subclasses to persist settings */
|
||||
@SettingsPreferences @Inject lateinit var settings: SharedPreferences
|
||||
|
||||
@Inject lateinit var features: Features
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
@ -87,124 +57,6 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
|
|||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
|
||||
/**
|
||||
* [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) {
|
||||
logcat(ERROR) { e.asLog("Callers must handle their own exceptions") }
|
||||
throw e
|
||||
}
|
||||
|
||||
/** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */
|
||||
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
|
||||
if (true) return
|
||||
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 {
|
||||
logcat(INFO) { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
|
||||
return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
logcat(ERROR) { "onError getErrorId: ${error.errorId}" }
|
||||
logcat(ERROR) { "onError getMessage: ${error.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
|
||||
* [showSnackbar] as false.
|
||||
|
@ -251,7 +103,6 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
|
|||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "APS/BasePgpActivity"
|
||||
const val EXTRA_FILE_PATH = "FILE_PATH"
|
||||
const val EXTRA_REPO_PATH = "REPO_PATH"
|
||||
|
||||
|
|
|
@ -1,227 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package app.passwordstore.ui.crypto
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.data.passfile.PasswordEntry
|
||||
import app.passwordstore.data.password.FieldItem
|
||||
import app.passwordstore.databinding.DecryptLayoutBinding
|
||||
import app.passwordstore.ui.adapters.FieldItemAdapter
|
||||
import app.passwordstore.util.extensions.unsafeLazy
|
||||
import app.passwordstore.util.extensions.viewBinding
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.runCatching
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.ByteArrayOutputStream
|
||||
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.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority.ERROR
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||
import org.openintents.openpgp.IOpenPgpService2
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
|
||||
|
||||
private val relativeParentPath by unsafeLazy { 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)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
bindToOpenKeychain(this)
|
||||
title = name
|
||||
with(binding) {
|
||||
setContentView(root)
|
||||
passwordCategory.text = relativeParentPath
|
||||
passwordFile.text = name
|
||||
passwordFile.setOnLongClickListener {
|
||||
copyTextToClipboard(name)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler, menu)
|
||||
passwordEntry?.let { entry ->
|
||||
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
|
||||
}
|
||||
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
super.onBound(service)
|
||||
decryptAndVerify()
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
logcat(ERROR) { e.asLog() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
)
|
||||
}
|
||||
|
||||
@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.Main) {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotNull(api).executeApi(data, inputStream, outputStream)
|
||||
}
|
||||
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 entry = passwordEntryFactory.create(outputStream.toByteArray())
|
||||
|
||||
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
|
||||
copyPasswordToClipboard(entry.password)
|
||||
}
|
||||
|
||||
passwordEntry = entry
|
||||
invalidateOptionsMenu()
|
||||
|
||||
val items = arrayListOf<FieldItem>()
|
||||
if (!entry.password.isNullOrBlank()) {
|
||||
items.add(FieldItem.createPasswordField(entry.password!!))
|
||||
}
|
||||
|
||||
if (entry.hasTotp()) {
|
||||
items.add(FieldItem.createOtpField(entry.totp.first()))
|
||||
}
|
||||
|
||||
if (!entry.username.isNullOrBlank()) {
|
||||
items.add(FieldItem.createUsernameField(entry.username!!))
|
||||
}
|
||||
|
||||
entry.extraContent.forEach { (key, value) ->
|
||||
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||
}
|
||||
|
||||
val adapter =
|
||||
FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) }
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.itemAnimator = null
|
||||
|
||||
if (entry.hasTotp()) {
|
||||
entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope)
|
||||
}
|
||||
}
|
||||
.onFailure { e -> logcat(ERROR) { e.asLog() } }
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package app.passwordstore.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.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.runCatching
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority.ERROR
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
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) {
|
||||
logcat(ERROR) { e.asLog() }
|
||||
}
|
||||
|
||||
/** Get the Key ids from OpenKeychain */
|
||||
private fun getKeyIds(data: Intent = Intent()) {
|
||||
data.action = OpenPgpApi.ACTION_GET_KEY_IDS
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val result = withContext(Dispatchers.IO) { checkNotNull(api).executeApi(data, null, null) }
|
||||
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 -> logcat(ERROR) { e.asLog() } }
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,617 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package app.passwordstore.ui.crypto
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
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
|
||||
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.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.data.passfile.PasswordEntry
|
||||
import app.passwordstore.data.repo.PasswordRepository
|
||||
import app.passwordstore.databinding.PasswordCreationActivityBinding
|
||||
import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment
|
||||
import app.passwordstore.ui.dialogs.OtpImportDialogFragment
|
||||
import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment
|
||||
import app.passwordstore.util.autofill.AutofillPreferences
|
||||
import app.passwordstore.util.autofill.DirectoryStructure
|
||||
import app.passwordstore.util.crypto.GpgIdentifier
|
||||
import app.passwordstore.util.extensions.asLog
|
||||
import app.passwordstore.util.extensions.base64
|
||||
import app.passwordstore.util.extensions.commitChange
|
||||
import app.passwordstore.util.extensions.getString
|
||||
import app.passwordstore.util.extensions.isInsideRepository
|
||||
import app.passwordstore.util.extensions.snackbar
|
||||
import app.passwordstore.util.extensions.unsafeLazy
|
||||
import app.passwordstore.util.extensions.viewBinding
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
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.android.material.snackbar.Snackbar
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.LuminanceSource
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority.ERROR
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
|
||||
|
||||
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 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 imageImportAction =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri ->
|
||||
if (imageUri == null) {
|
||||
snackbar(message = getString(R.string.otp_import_failure))
|
||||
return@registerForActivityResult
|
||||
}
|
||||
val bitmap =
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri))
|
||||
.copy(Bitmap.Config.ARGB_8888, true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
|
||||
}
|
||||
val intArray = IntArray(bitmap.width * bitmap.height)
|
||||
// copy pixel data from the Bitmap into the 'intArray' array
|
||||
bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||
val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
|
||||
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
|
||||
val reader = QRCodeReader()
|
||||
runCatching {
|
||||
val result = reader.decode(binaryBitmap)
|
||||
val text = result.text
|
||||
val currentExtras = binding.extraContent.text.toString()
|
||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||
binding.extraContent.append("\n$text")
|
||||
else binding.extraContent.append(text)
|
||||
snackbar(message = getString(R.string.otp_import_success))
|
||||
binding.otpImportButton.isVisible = false
|
||||
}
|
||||
.onFailure { 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) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
snackbar(
|
||||
message = getString(R.string.gpg_key_select_mandatory),
|
||||
length = Snackbar.LENGTH_LONG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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@PasswordCreationActivity
|
||||
) { 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_from_file),
|
||||
getString(R.string.otp_import_manual_entry),
|
||||
)
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setItems(items) { _, index ->
|
||||
when (index) {
|
||||
0 ->
|
||||
otpImportAction.launch(
|
||||
IntentIntegrator(this@PasswordCreationActivity)
|
||||
.setOrientationLocked(false)
|
||||
.setBeepEnabled(false)
|
||||
.setDesiredBarcodeFormats(QR_CODE)
|
||||
.createScanIntent()
|
||||
)
|
||||
1 -> imageImportAction.launch("image/*")
|
||||
2 -> 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@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 =
|
||||
passwordEntryFactory.create("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
listOf(binding.filename, binding.extraContent).forEach {
|
||||
it.doAfterTextChanged { updateViewState() }
|
||||
}
|
||||
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_DICEWARE ->
|
||||
DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateViewState() =
|
||||
with(binding) {
|
||||
// Use PasswordEntry to parse extras for username
|
||||
val entry =
|
||||
passwordEntryFactory.create("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(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)
|
||||
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
|
||||
val gpgIdentifiers =
|
||||
gpgIdentifierFile
|
||||
.readLines()
|
||||
.filter { it.isNotBlank() }
|
||||
.map { line ->
|
||||
GpgIdentifier.fromString(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, false)
|
||||
|
||||
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.Main) {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotNull(api).executeApi(encryptionIntent, inputStream, outputStream)
|
||||
}
|
||||
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!
|
||||
// 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(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 = passwordEntryFactory.create(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@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@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) {
|
||||
logcat(ERROR) { e.asLog("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 {
|
||||
logcat(ERROR) { e.asLog() }
|
||||
}
|
||||
}
|
||||
}
|
||||
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_DICEWARE = "diceware"
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -134,7 +134,6 @@ class PasswordCreationActivityV2 : BasePgpActivity() {
|
|||
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) {
|
||||
|
|
|
@ -19,7 +19,7 @@ import app.passwordstore.R
|
|||
import app.passwordstore.databinding.FragmentPwgenDicewareBinding
|
||||
import app.passwordstore.injection.prefs.PasswordGeneratorPreferences
|
||||
import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivity
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivityV2
|
||||
import app.passwordstore.util.extensions.getString
|
||||
import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH
|
||||
import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR
|
||||
|
@ -58,8 +58,8 @@ class DicewarePasswordGeneratorDialogFragment : DialogFragment() {
|
|||
setTitle(R.string.pwgen_title)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
|
||||
PasswordCreationActivityV2.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivityV2.RESULT to "${binding.passwordText.text}")
|
||||
)
|
||||
}
|
||||
setNeutralButton(R.string.dialog_cancel) { _, _ -> }
|
||||
|
|
|
@ -5,60 +5,21 @@
|
|||
package app.passwordstore.ui.dialogs
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.data.repo.PasswordRepository
|
||||
import app.passwordstore.ui.crypto.BasePgpActivity
|
||||
import app.passwordstore.ui.crypto.GetKeyIdsActivity
|
||||
import app.passwordstore.ui.passwords.PasswordStore
|
||||
import app.passwordstore.util.extensions.commitChange
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.launch
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
|
||||
class FolderCreationDialogFragment : DialogFragment() {
|
||||
|
||||
private lateinit var newFolder: File
|
||||
|
||||
private val keySelectAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
val gpgIdentifierFile = File(newFolder, ".gpg-id")
|
||||
gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
|
||||
if (PasswordRepository.repository != null) {
|
||||
lifecycleScope.launch {
|
||||
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
|
||||
requireActivity()
|
||||
.commitChange(
|
||||
getString(
|
||||
R.string.git_commit_gpg_id,
|
||||
BasePgpActivity.getLongName(
|
||||
gpgIdentifierFile.parentFile!!.absolutePath,
|
||||
repoPath,
|
||||
gpgIdentifierFile.name
|
||||
)
|
||||
),
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||
alertDialogBuilder.setTitle(R.string.title_create_folder)
|
||||
|
@ -89,12 +50,16 @@ class FolderCreationDialogFragment : DialogFragment() {
|
|||
if (folderNameViewContainer.error != null) return
|
||||
newFolder.mkdirs()
|
||||
(requireActivity() as PasswordStore).refreshPasswordList(newFolder)
|
||||
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
|
||||
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
return
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
// TODO(msfjarvis): Restore this functionality
|
||||
/*
|
||||
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
|
||||
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
return
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
*/
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -13,7 +13,7 @@ import androidx.core.os.bundleOf
|
|||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import app.passwordstore.databinding.FragmentManualOtpEntryBinding
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivity
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivityV2
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class OtpImportDialogFragment : DialogFragment() {
|
||||
|
@ -24,8 +24,8 @@ class OtpImportDialogFragment : DialogFragment() {
|
|||
builder.setView(binding.root)
|
||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
|
||||
PasswordCreationActivityV2.OTP_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivityV2.RESULT to getTOTPUri(binding))
|
||||
)
|
||||
}
|
||||
val dialog = builder.create()
|
||||
|
|
|
@ -26,7 +26,7 @@ import app.passwordstore.passgen.random.NoCharactersIncludedException
|
|||
import app.passwordstore.passgen.random.PasswordGenerator
|
||||
import app.passwordstore.passgen.random.PasswordLengthTooShortException
|
||||
import app.passwordstore.passgen.random.PasswordOption
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivity
|
||||
import app.passwordstore.ui.crypto.PasswordCreationActivityV2
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.github.michaelbull.result.runCatching
|
||||
|
@ -72,8 +72,8 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
|
|||
setTitle(R.string.pwgen_title)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
|
||||
PasswordCreationActivityV2.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivityV2.RESULT to "${binding.passwordText.text}")
|
||||
)
|
||||
}
|
||||
setNeutralButton(R.string.dialog_cancel) { _, _ -> }
|
||||
|
|
|
@ -18,7 +18,6 @@ import app.passwordstore.util.git.operation.PullOperation
|
|||
import app.passwordstore.util.git.operation.PushOperation
|
||||
import app.passwordstore.util.git.operation.ResetToRemoteOperation
|
||||
import app.passwordstore.util.git.operation.SyncOperation
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import app.passwordstore.util.settings.GitSettings
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
import com.github.michaelbull.result.Err
|
||||
|
@ -42,7 +41,7 @@ import net.schmizz.sshj.userauth.UserAuthException
|
|||
* git-related tasks and makes sense to be held here.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
abstract class BaseGitActivity : ContinuationContainerActivity() {
|
||||
abstract class BaseGitActivity : AppCompatActivity() {
|
||||
|
||||
/** Enum of possible Git operations than can be run through [launchGitOperation]. */
|
||||
enum class GitOp {
|
||||
|
|
|
@ -62,7 +62,6 @@ class GitServerConfigActivity : BaseGitActivity() {
|
|||
when (newAuthMode) {
|
||||
AuthMode.SshKey -> check(binding.authModeSshKey.id)
|
||||
AuthMode.Password -> check(binding.authModePassword.id)
|
||||
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
|
||||
AuthMode.None -> check(View.NO_ID)
|
||||
}
|
||||
addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
|
@ -72,7 +71,6 @@ class GitServerConfigActivity : BaseGitActivity() {
|
|||
}
|
||||
when (checkedId) {
|
||||
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
|
||||
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
|
||||
binding.authModePassword.id -> newAuthMode = AuthMode.Password
|
||||
View.NO_ID -> newAuthMode = AuthMode.None
|
||||
}
|
||||
|
@ -215,12 +213,10 @@ class GitServerConfigActivity : BaseGitActivity() {
|
|||
with(binding) {
|
||||
if (isHttps) {
|
||||
authModeSshKey.isVisible = false
|
||||
authModeOpenKeychain.isVisible = false
|
||||
authModePassword.isVisible = true
|
||||
if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.check(View.NO_ID)
|
||||
} else {
|
||||
authModeSshKey.isVisible = true
|
||||
authModeOpenKeychain.isVisible = true
|
||||
authModePassword.isVisible = true
|
||||
if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id)
|
||||
}
|
||||
|
|
|
@ -11,13 +11,11 @@ import android.os.Looper
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.edit
|
||||
import app.passwordstore.ui.crypto.BasePgpActivity
|
||||
import app.passwordstore.ui.crypto.DecryptActivity
|
||||
import app.passwordstore.ui.crypto.DecryptActivityV2
|
||||
import app.passwordstore.ui.passwords.PasswordStore
|
||||
import app.passwordstore.util.auth.BiometricAuthenticator
|
||||
import app.passwordstore.util.auth.BiometricAuthenticator.Result
|
||||
import app.passwordstore.util.extensions.sharedPrefs
|
||||
import app.passwordstore.util.features.Feature
|
||||
import app.passwordstore.util.features.Features
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
|
|
@ -5,65 +5,21 @@
|
|||
|
||||
package app.passwordstore.ui.onboarding.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.edit
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.data.repo.PasswordRepository
|
||||
import app.passwordstore.databinding.FragmentKeySelectionBinding
|
||||
import app.passwordstore.ui.crypto.GetKeyIdsActivity
|
||||
import app.passwordstore.util.extensions.commitChange
|
||||
import app.passwordstore.util.extensions.finish
|
||||
import app.passwordstore.util.extensions.sharedPrefs
|
||||
import app.passwordstore.util.extensions.snackbar
|
||||
import app.passwordstore.util.extensions.unsafeLazy
|
||||
import app.passwordstore.util.extensions.viewBinding
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
|
||||
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
|
||||
|
||||
private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs }
|
||||
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
||||
|
||||
private val gpgKeySelectAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
||||
}
|
||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||
requireActivity()
|
||||
.commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
|
||||
}
|
||||
}
|
||||
finish()
|
||||
} else {
|
||||
requireActivity()
|
||||
.snackbar(
|
||||
message = getString(R.string.gpg_key_select_mandatory),
|
||||
length = Snackbar.LENGTH_LONG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.selectKey.setOnClickListener {
|
||||
gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
// TODO(msfjarvis): Restore this functionality
|
||||
// gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package app.passwordstore.util.crypto
|
||||
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
|
||||
|
||||
sealed class GpgIdentifier {
|
||||
data class KeyId(val id: Long) : GpgIdentifier()
|
||||
data class UserId(val email: String) : GpgIdentifier()
|
||||
|
||||
companion object {
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
fun fromString(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 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 KeyId(keyId.toLong())
|
||||
}
|
||||
|
||||
return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,16 +4,15 @@
|
|||
*/
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.util.extensions.unsafeLazy
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.eclipse.jgit.api.RebaseCommand
|
||||
import org.eclipse.jgit.api.ResetCommand
|
||||
import org.eclipse.jgit.lib.RepositoryState
|
||||
|
||||
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) :
|
||||
GitOperation(callingActivity) {
|
||||
class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(callingActivity) {
|
||||
|
||||
private val merging = repository.repositoryState == RepositoryState.MERGING
|
||||
private val resetCommands =
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.eclipse.jgit.api.Git
|
||||
import org.eclipse.jgit.api.GitCommand
|
||||
|
||||
|
@ -14,7 +14,7 @@ import org.eclipse.jgit.api.GitCommand
|
|||
* @param uri URL to clone the repository from
|
||||
* @param callingActivity the calling activity
|
||||
*/
|
||||
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) :
|
||||
class CloneOperation(callingActivity: AppCompatActivity, uri: String) :
|
||||
GitOperation(callingActivity) {
|
||||
|
||||
override val commands: Array<GitCommand<out Any>> =
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.eclipse.jgit.api.GitCommand
|
||||
|
||||
/**
|
||||
|
@ -13,7 +13,7 @@ import org.eclipse.jgit.api.GitCommand
|
|||
* achieve the best compression.
|
||||
*/
|
||||
class GcOperation(
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
callingActivity: AppCompatActivity,
|
||||
) : GitOperation(callingActivity) {
|
||||
|
||||
override val requiresAuth: Boolean = false
|
||||
|
|
|
@ -6,6 +6,7 @@ package app.passwordstore.util.git.operation
|
|||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import app.passwordstore.R
|
||||
import app.passwordstore.data.repo.PasswordRepository
|
||||
|
@ -14,7 +15,6 @@ import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
|
|||
import app.passwordstore.util.auth.BiometricAuthenticator
|
||||
import app.passwordstore.util.auth.BiometricAuthenticator.Result.*
|
||||
import app.passwordstore.util.git.GitCommandExecutor
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import app.passwordstore.util.git.sshj.SshAuthMethod
|
||||
import app.passwordstore.util.git.sshj.SshKey
|
||||
import app.passwordstore.util.git.sshj.SshjSessionFactory
|
||||
|
@ -74,7 +74,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
|||
protected val git = Git(repository)
|
||||
protected val remoteBranch = hiltEntryPoint.gitSettings().branch
|
||||
private val authActivity
|
||||
get() = callingActivity as ContinuationContainerActivity
|
||||
get() = callingActivity as AppCompatActivity
|
||||
|
||||
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) :
|
||||
CredentialsProvider() {
|
||||
|
@ -213,7 +213,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
|||
// error, allowing users to make the SSH key selection.
|
||||
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
||||
AuthMode.Password -> {
|
||||
val httpsCredentialProvider =
|
||||
HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
|
||||
|
|
|
@ -4,11 +4,11 @@
|
|||
*/
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.eclipse.jgit.api.GitCommand
|
||||
|
||||
class PullOperation(
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
callingActivity: AppCompatActivity,
|
||||
rebase: Boolean,
|
||||
) : GitOperation(callingActivity) {
|
||||
|
||||
|
|
|
@ -4,11 +4,10 @@
|
|||
*/
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.eclipse.jgit.api.GitCommand
|
||||
|
||||
class PushOperation(callingActivity: ContinuationContainerActivity) :
|
||||
GitOperation(callingActivity) {
|
||||
class PushOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) {
|
||||
|
||||
override val commands: Array<GitCommand<out Any>> =
|
||||
arrayOf(
|
||||
|
|
|
@ -4,11 +4,10 @@
|
|||
*/
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.eclipse.jgit.api.ResetCommand
|
||||
|
||||
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) :
|
||||
GitOperation(callingActivity) {
|
||||
class ResetToRemoteOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) {
|
||||
|
||||
override val commands =
|
||||
arrayOf(
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
*/
|
||||
package app.passwordstore.util.git.operation
|
||||
|
||||
import app.passwordstore.util.git.sshj.ContinuationContainerActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class SyncOperation(
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
callingActivity: AppCompatActivity,
|
||||
rebase: Boolean,
|
||||
) : GitOperation(callingActivity) {
|
||||
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
package app.passwordstore.util.git.sshj
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import net.schmizz.sshj.common.DisconnectReason
|
||||
import net.schmizz.sshj.userauth.UserAuthException
|
||||
|
||||
/** Workaround for https://msfjarvis.dev/aps/issue/1164 */
|
||||
open class ContinuationContainerActivity : AppCompatActivity {
|
||||
|
||||
constructor() : super()
|
||||
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
|
||||
|
||||
var stashedCont: Continuation<Intent>? = null
|
||||
|
||||
val continueAfterUserInteraction =
|
||||
registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||
stashedCont?.let { cont ->
|
||||
stashedCont = null
|
||||
val data = result.data
|
||||
if (data != null) cont.resume(data)
|
||||
else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,225 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
package app.passwordstore.util.git.sshj
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.passwordstore.util.extensions.OPENPGP_PROVIDER
|
||||
import app.passwordstore.util.extensions.sharedPrefs
|
||||
import app.passwordstore.util.settings.PreferenceKeys
|
||||
import java.io.Closeable
|
||||
import java.security.PublicKey
|
||||
import java.security.interfaces.ECKey
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.logcat
|
||||
import net.schmizz.sshj.common.DisconnectReason
|
||||
import net.schmizz.sshj.common.KeyType
|
||||
import net.schmizz.sshj.userauth.UserAuthException
|
||||
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
|
||||
import org.openintents.ssh.authentication.ISshAuthenticationService
|
||||
import org.openintents.ssh.authentication.SshAuthenticationApi
|
||||
import org.openintents.ssh.authentication.SshAuthenticationApiError
|
||||
import org.openintents.ssh.authentication.SshAuthenticationConnection
|
||||
import org.openintents.ssh.authentication.request.KeySelectionRequest
|
||||
import org.openintents.ssh.authentication.request.Request
|
||||
import org.openintents.ssh.authentication.request.SigningRequest
|
||||
import org.openintents.ssh.authentication.request.SshPublicKeyRequest
|
||||
import org.openintents.ssh.authentication.response.KeySelectionResponse
|
||||
import org.openintents.ssh.authentication.response.Response
|
||||
import org.openintents.ssh.authentication.response.SigningResponse
|
||||
import org.openintents.ssh.authentication.response.SshPublicKeyResponse
|
||||
|
||||
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
|
||||
KeyProvider, Closeable {
|
||||
|
||||
companion object {
|
||||
|
||||
suspend fun prepareAndUse(
|
||||
activity: ContinuationContainerActivity,
|
||||
block: (provider: OpenKeychainKeyProvider) -> Unit
|
||||
) {
|
||||
withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ApiResponse {
|
||||
data class Success(val response: Response) : ApiResponse()
|
||||
data class GeneralError(val exception: Exception) : ApiResponse()
|
||||
data class NoSuchKey(val exception: Exception) : ApiResponse()
|
||||
}
|
||||
|
||||
private val context = activity.applicationContext
|
||||
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
|
||||
private val preferences = context.sharedPrefs
|
||||
private lateinit var sshServiceApi: SshAuthenticationApi
|
||||
|
||||
private var keyId
|
||||
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
|
||||
set(value) {
|
||||
preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
|
||||
}
|
||||
private var publicKey: PublicKey? = null
|
||||
private var privateKey: OpenKeychainPrivateKey? = null
|
||||
|
||||
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
||||
prepare()
|
||||
use(block)
|
||||
}
|
||||
|
||||
private suspend fun prepare() {
|
||||
sshServiceApi = suspendCoroutine { cont ->
|
||||
sshServiceConnection.connect(
|
||||
object : SshAuthenticationConnection.OnBound {
|
||||
override fun onBound(sshAgent: ISshAuthenticationService) {
|
||||
logcat { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
|
||||
cont.resume(SshAuthenticationApi(context, sshAgent))
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (keyId == null) {
|
||||
selectKey()
|
||||
}
|
||||
check(keyId != null)
|
||||
fetchPublicKey()
|
||||
makePrivateKey()
|
||||
}
|
||||
|
||||
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
|
||||
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
|
||||
is ApiResponse.Success -> {
|
||||
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
||||
val sshPublicKey = response.sshPublicKey!!
|
||||
publicKey =
|
||||
parseSshPublicKey(sshPublicKey)
|
||||
?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
||||
}
|
||||
is ApiResponse.NoSuchKey ->
|
||||
if (isRetry) {
|
||||
throw sshPublicKeyResponse.exception
|
||||
} else {
|
||||
// Allow the user to reselect an authentication key and retry
|
||||
selectKey()
|
||||
fetchPublicKey(true)
|
||||
}
|
||||
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selectKey() {
|
||||
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
||||
is ApiResponse.Success ->
|
||||
keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
||||
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
||||
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeApiRequest(
|
||||
request: Request,
|
||||
resultOfUserInteraction: Intent? = null
|
||||
): ApiResponse {
|
||||
logcat { "executeRequest($request) called" }
|
||||
val result =
|
||||
withContext(Dispatchers.Main) {
|
||||
// If the request required user interaction, the data returned from the
|
||||
// PendingIntent
|
||||
// is used as the real request.
|
||||
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
|
||||
}
|
||||
return parseResult(request, result).also { logcat { "executeRequest($request): $it" } }
|
||||
}
|
||||
|
||||
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
|
||||
return when (
|
||||
result.getIntExtra(
|
||||
SshAuthenticationApi.EXTRA_RESULT_CODE,
|
||||
SshAuthenticationApi.RESULT_CODE_ERROR
|
||||
)
|
||||
) {
|
||||
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
|
||||
ApiResponse.Success(
|
||||
when (request) {
|
||||
is KeySelectionRequest -> KeySelectionResponse(result)
|
||||
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
|
||||
is SigningRequest -> SigningResponse(result)
|
||||
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
|
||||
}
|
||||
)
|
||||
}
|
||||
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val pendingIntent: PendingIntent =
|
||||
result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
|
||||
val resultOfUserInteraction: Intent =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCoroutine { cont ->
|
||||
activity.stashedCont = cont
|
||||
activity.continueAfterUserInteraction.launch(
|
||||
IntentSenderRequest.Builder(pendingIntent).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
executeApiRequest(request, resultOfUserInteraction)
|
||||
}
|
||||
else -> {
|
||||
val error =
|
||||
result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
||||
val exception =
|
||||
UserAuthException(
|
||||
DisconnectReason.UNKNOWN,
|
||||
"Request ${request::class.simpleName} failed: ${error?.message}"
|
||||
)
|
||||
when (error?.error) {
|
||||
SshAuthenticationApiError.NO_AUTH_KEY,
|
||||
SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
|
||||
else -> ApiResponse.GeneralError(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePrivateKey() {
|
||||
check(keyId != null && publicKey != null)
|
||||
privateKey =
|
||||
object : OpenKeychainPrivateKey {
|
||||
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
|
||||
when (
|
||||
val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))
|
||||
) {
|
||||
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
|
||||
is ApiResponse.GeneralError -> throw signingResponse.exception
|
||||
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
||||
}
|
||||
|
||||
override fun getAlgorithm() = publicKey!!.algorithm
|
||||
override fun getParams() = (publicKey as? ECKey)?.params
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
activity.lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
|
||||
}
|
||||
sshServiceConnection.disconnect()
|
||||
}
|
||||
|
||||
override fun getPrivate() = privateKey
|
||||
|
||||
override fun getPublic() = publicKey
|
||||
|
||||
override fun getType(): KeyType = KeyType.fromKey(publicKey)
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
package app.passwordstore.util.git.sshj
|
||||
|
||||
import com.hierynomus.sshj.key.KeyAlgorithm
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.PrivateKey
|
||||
import java.security.interfaces.ECKey
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.schmizz.sshj.common.Buffer
|
||||
import net.schmizz.sshj.common.Factory
|
||||
import net.schmizz.sshj.signature.Signature
|
||||
import org.openintents.ssh.authentication.SshAuthenticationApi
|
||||
|
||||
interface OpenKeychainPrivateKey : PrivateKey, ECKey {
|
||||
|
||||
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
|
||||
|
||||
override fun getFormat() = null
|
||||
override fun getEncoded() = null
|
||||
}
|
||||
|
||||
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
|
||||
Factory.Named<KeyAlgorithm> by factory {
|
||||
|
||||
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
|
||||
}
|
||||
|
||||
class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) :
|
||||
KeyAlgorithm by keyAlgorithm {
|
||||
|
||||
private val hashAlgorithm =
|
||||
when (keyAlgorithm.keyAlgorithm) {
|
||||
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
|
||||
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
|
||||
"ssh-rsa",
|
||||
"ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
|
||||
// Other algorithms don't use this value, but it has to be valid.
|
||||
else -> SshAuthenticationApi.SHA512
|
||||
}
|
||||
|
||||
override fun newSignature() =
|
||||
OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
||||
}
|
||||
|
||||
class OpenKeychainWrappedSignature(
|
||||
private val wrappedSignature: Signature,
|
||||
private val hashAlgorithm: Int
|
||||
) : Signature by wrappedSignature {
|
||||
|
||||
private val data = ByteArrayOutputStream()
|
||||
|
||||
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
|
||||
|
||||
override fun initSign(prvkey: PrivateKey?) {
|
||||
if (prvkey is OpenKeychainPrivateKey) {
|
||||
bridgedPrivateKey = prvkey
|
||||
} else {
|
||||
wrappedSignature.initSign(prvkey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(H: ByteArray?) {
|
||||
if (bridgedPrivateKey != null) {
|
||||
data.write(H!!)
|
||||
} else {
|
||||
wrappedSignature.update(H)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(H: ByteArray?, off: Int, len: Int) {
|
||||
if (bridgedPrivateKey != null) {
|
||||
data.write(H!!, off, len)
|
||||
} else {
|
||||
wrappedSignature.update(H, off, len)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sign(): ByteArray? =
|
||||
if (bridgedPrivateKey != null) {
|
||||
runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
|
||||
} else {
|
||||
wrappedSignature.sign()
|
||||
}
|
||||
|
||||
override fun encode(signature: ByteArray?): ByteArray? =
|
||||
if (bridgedPrivateKey != null) {
|
||||
require(signature != null) { "OpenKeychain signature must not be null" }
|
||||
val encodedSignature = Buffer.PlainBuffer(signature)
|
||||
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the
|
||||
// name
|
||||
// later.
|
||||
encodedSignature.readString()
|
||||
encodedSignature.readBytes().also {
|
||||
bridgedPrivateKey = null
|
||||
data.reset()
|
||||
}
|
||||
} else {
|
||||
wrappedSignature.encode(signature)
|
||||
}
|
||||
}
|
|
@ -232,16 +232,15 @@ class SshjConfig : ConfigImpl() {
|
|||
private fun initKeyAlgorithms() {
|
||||
keyAlgorithms =
|
||||
listOf(
|
||||
KeyAlgorithms.SSHRSACertV01(),
|
||||
KeyAlgorithms.EdDSA25519(),
|
||||
KeyAlgorithms.ECDSASHANistp521(),
|
||||
KeyAlgorithms.ECDSASHANistp384(),
|
||||
KeyAlgorithms.ECDSASHANistp256(),
|
||||
KeyAlgorithms.RSASHA512(),
|
||||
KeyAlgorithms.RSASHA256(),
|
||||
KeyAlgorithms.SSHRSA(),
|
||||
)
|
||||
.map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
|
||||
KeyAlgorithms.SSHRSACertV01(),
|
||||
KeyAlgorithms.EdDSA25519(),
|
||||
KeyAlgorithms.ECDSASHANistp521(),
|
||||
KeyAlgorithms.ECDSASHANistp384(),
|
||||
KeyAlgorithms.ECDSASHANistp256(),
|
||||
KeyAlgorithms.RSASHA512(),
|
||||
KeyAlgorithms.RSASHA256(),
|
||||
KeyAlgorithms.SSHRSA(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initRandomFactory() {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
package app.passwordstore.util.git.sshj
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.passwordstore.util.git.operation.CredentialFinder
|
||||
import app.passwordstore.util.settings.AuthMode
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
|
@ -41,10 +42,9 @@ import org.eclipse.jgit.transport.SshSessionFactory
|
|||
import org.eclipse.jgit.transport.URIish
|
||||
import org.eclipse.jgit.util.FS
|
||||
|
||||
sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
|
||||
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
sealed class SshAuthMethod(val activity: AppCompatActivity) {
|
||||
class Password(activity: AppCompatActivity) : SshAuthMethod(activity)
|
||||
class SshKey(activity: AppCompatActivity) : SshAuthMethod(activity)
|
||||
}
|
||||
|
||||
abstract class InteractivePasswordFinder : PasswordFinder {
|
||||
|
@ -157,14 +157,6 @@ private class SshjSession(
|
|||
AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
||||
ssh.auth(username, pubkeyAuth, passwordAuth)
|
||||
}
|
||||
is SshAuthMethod.OpenKeychain -> {
|
||||
runBlocking {
|
||||
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
|
||||
val openKeychainAuth = AuthPublickey(provider)
|
||||
ssh.auth(username, openKeychainAuth, passwordAuth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
|
|
@ -37,7 +37,6 @@ enum class Protocol(val pref: String) {
|
|||
enum class AuthMode(val pref: String) {
|
||||
SshKey("ssh-key"),
|
||||
Password("username/password"),
|
||||
OpenKeychain("OpenKeychain"),
|
||||
None("None"),
|
||||
;
|
||||
|
||||
|
@ -156,7 +155,7 @@ constructor(
|
|||
)
|
||||
return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
|
||||
val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
|
||||
val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
|
||||
val validSshAuth = listOf(AuthMode.Password, AuthMode.SshKey)
|
||||
when {
|
||||
newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
|
||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
|
||||
|
|
|
@ -89,12 +89,6 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:text="@string/connection_mode_basic_authentication" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/auth_mode_open_keychain"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/connection_mode_openkeychain" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp"
|
||||
tools:context="app.passwordstore.ui.crypto.DecryptActivity">
|
||||
tools:context="app.passwordstore.ui.crypto.DecryptActivityV2">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:id="@+id/password_category"
|
||||
|
|
|
@ -26,9 +26,11 @@
|
|||
<requestFocus />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- TODO(msfjarvis): Restore this functionality -->
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
android:id="@+id/set_gpg_key"
|
||||
android:layout_width="0dp"
|
||||
android:visibility="gone"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/new_folder_set_gpg_key"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="false"
|
||||
tools:context="app.passwordstore.ui.crypto.PasswordCreationActivity">
|
||||
tools:context="app.passwordstore.ui.crypto.PasswordCreationActivityV2">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package app.passwordstore.util.crypto
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GpgIdentifierTest {
|
||||
|
||||
@Test
|
||||
fun parseHexKeyIdWithout0xPrefix() {
|
||||
val identifier = GpgIdentifier.fromString("79E8208280490C77")
|
||||
assertNotNull(identifier)
|
||||
assertTrue { identifier is GpgIdentifier.KeyId }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseHexKeyId() {
|
||||
val identifier = GpgIdentifier.fromString("0x79E8208280490C77")
|
||||
assertNotNull(identifier)
|
||||
assertTrue { identifier is GpgIdentifier.KeyId }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseValidEmail() {
|
||||
val identifier = GpgIdentifier.fromString("john.doe@example.org")
|
||||
assertNotNull(identifier)
|
||||
assertTrue { identifier is GpgIdentifier.UserId }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseEmailWithoutTLD() {
|
||||
val identifier = GpgIdentifier.fromString("john.doe@example")
|
||||
assertNotNull(identifier)
|
||||
assertTrue { identifier is GpgIdentifier.UserId }
|
||||
}
|
||||
}
|
|
@ -87,7 +87,6 @@ thirdparty-nonfree-googlePlayAuthApiPhone = "com.google.android.gms:play-service
|
|||
thirdparty-nonfree-sentry = "io.sentry:sentry-android:6.2.1"
|
||||
thirdparty-pgpainless = "org.pgpainless:pgpainless-core:1.3.1"
|
||||
thirdparty-plumber = { module = "com.squareup.leakcanary:plumber-android-startup", version.ref = "leakcanary" }
|
||||
thirdparty-sshauth = "com.github.open-keychain.open-keychain:sshauthentication-api:5.7.5"
|
||||
# TODO: Remove the explicit bcpkix dependency when upgrading this to a BC 1.71 compatible version
|
||||
thirdparty-sshj = "com.hierynomus:sshj:0.33.0"
|
||||
thirdparty-whatthestack = "com.github.haroldadmin:WhatTheStack:1.0.0-alpha04"
|
||||
|
|
|
@ -91,10 +91,7 @@ dependencyResolutionManagement {
|
|||
}
|
||||
exclusiveContent {
|
||||
forRepository { maven("https://jitpack.io") }
|
||||
filter {
|
||||
includeModule("com.github.haroldadmin", "WhatTheStack")
|
||||
includeModule("com.github.open-keychain.open-keychain", "sshauthentication-api")
|
||||
}
|
||||
filter { includeModule("com.github.haroldadmin", "WhatTheStack") }
|
||||
}
|
||||
exclusiveContent {
|
||||
forRepository { maven("https://storage.googleapis.com/r8-releases/raw") }
|
||||
|
|
Loading…
Reference in a new issue