Add initial implementation of Gopenpgp-backed PGP (#1441)

This commit is contained in:
Harsh Shandilya 2021-07-11 22:52:26 +05:30 committed by GitHub
parent 9c388e4974
commit 6e4ffe2902
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 966 additions and 4 deletions

View file

@ -76,6 +76,7 @@ dependencies {
implementation(libs.androidx.annotation)
coreLibraryDesugaring(libs.android.desugarJdkLibs)
implementation(projects.autofillParser)
implementation(projects.cryptoPgp)
implementation(projects.formatCommon)
implementation(projects.openpgpKtx)
implementation(libs.androidx.activity.ktx)

View file

@ -44,6 +44,10 @@
android:name=".ui.proxy.ProxySelectorActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.crypto.GopenpgpDecryptActivity"
android:exported="true" />
<activity
android:name=".ui.main.LaunchActivity"
android:configChanges="orientation|screenSize"
@ -91,6 +95,10 @@
android:label="@string/new_password_title"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".ui.crypto.GopenpgpPasswordCreationActivity"
android:label="@string/new_password_title"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.crypto.DecryptActivity"
android:windowSoftInputMode="adjustResize" />
@ -128,6 +136,9 @@
<activity
android:name=".ui.autofill.AutofillDecryptActivity"
android:theme="@style/NoBackgroundTheme" />
<activity
android:name=".ui.autofill.GopenpgpAutofillDecryptActivity"
android:theme="@style/NoBackgroundTheme" />
<activity
android:name=".ui.autofill.AutofillFilterView"
android:configChanges="orientation|keyboardHidden"

View file

@ -0,0 +1,31 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.injection.crypto
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import dev.msfjarvis.aps.data.crypto.CryptoHandler
import dev.msfjarvis.aps.data.crypto.GopenpgpCryptoHandler
/**
* This module adds all [CryptoHandler] implementations into a Set which makes it easier to build
* generic UIs which are not tied to a specific implementation because of injection.
*/
@Module
@InstallIn(SingletonComponent::class)
object CryptoHandlerModule {
@Provides
@IntoSet
fun providePgpCryptoHandler(): CryptoHandler {
return GopenpgpCryptoHandler()
}
}
/** Typealias for a [Set] of [CryptoHandler] instances injected by Dagger. */
typealias CryptoSet = Set<@JvmSuppressWildcards CryptoHandler>

View file

@ -29,6 +29,7 @@ import com.github.androidpasswordstore.autofillparser.FormOrigin
import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.password.PasswordItem
import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding
import dev.msfjarvis.aps.util.FeatureFlags
import dev.msfjarvis.aps.util.autofill.AutofillMatcher
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
@ -220,7 +221,11 @@ class AutofillFilterView : AppCompatActivity() {
AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
// intent?.extras? is checked to be non-null in onCreate
decryptAction.launch(
if (FeatureFlags.ENABLE_GOPENPGP) {
GopenpgpAutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
} else {
AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
}
)
}
}

View file

@ -0,0 +1,155 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.ui.autofill
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching
import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.aps.injection.crypto.CryptoSet
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
import dev.msfjarvis.aps.ui.crypto.GopenpgpDecryptActivity
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@RequiresApi(Build.VERSION_CODES.O)
@AndroidEntryPoint
class GopenpgpAutofillDecryptActivity : AppCompatActivity() {
companion object {
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
private var decryptFileRequestCode = 1
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
return Intent(context, GopenpgpAutofillDecryptActivity::class.java).apply {
putExtras(forwardedExtras)
putExtra(EXTRA_SEARCH_ACTION, true)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
}
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent =
Intent(context, GopenpgpAutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
return PendingIntent.getActivity(
context,
decryptFileRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
)
.intentSender
}
}
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
@Inject lateinit var cryptos: CryptoSet
private lateinit var directoryStructure: DirectoryStructure
override fun onStart() {
super.onStart()
val filePath =
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
e { "GopenpgpAutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
e { "GopenpgpAutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
d { action.toString() }
lifecycleScope.launch {
val credentials = decryptCredential(File(filePath))
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
val fillInDataset =
AutofillResponseBuilder.makeFillInDataset(
this@GopenpgpAutofillDecryptActivity,
credentials,
clientState,
action
)
withContext(Dispatchers.Main) {
setResult(
RESULT_OK,
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
)
}
}
withContext(Dispatchers.Main) { finish() }
}
}
private suspend fun decryptCredential(file: File): Credentials? {
runCatching { file.inputStream() }
.onFailure { e ->
e(e) { "File to decrypt not found" }
return null
}
.onSuccess { encryptedInput ->
runCatching {
val crypto = cryptos.first { it.canHandle(file.absolutePath) }
withContext(Dispatchers.IO) {
crypto.decrypt(
GopenpgpDecryptActivity.PRIV_KEY,
GopenpgpDecryptActivity.PASS.toByteArray(charset = Charsets.UTF_8),
encryptedInput.readBytes()
)
}
}
.onFailure { e ->
e(e) { "Decryption with Gopenpgp failed" }
return null
}
.onSuccess { result ->
return runCatching {
val entry = passwordEntryFactory.create(lifecycleScope, result)
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
}
.getOrElse { e ->
e(e) { "Failed to parse password entry" }
return null
}
}
}
return null
}
}

View file

@ -0,0 +1,177 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.ui.crypto
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.passfile.PasswordEntry
import dev.msfjarvis.aps.data.password.FieldItem
import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
import dev.msfjarvis.aps.injection.crypto.CryptoSet
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
import dev.msfjarvis.aps.util.extensions.unsafeLazy
import dev.msfjarvis.aps.util.extensions.viewBinding
import java.io.File
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@AndroidEntryPoint
class GopenpgpDecryptActivity : BasePgpActivity() {
private val binding by viewBinding(DecryptLayoutBinding::inflate)
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
@Inject lateinit var cryptos: CryptoSet
private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) }
private var passwordEntry: PasswordEntry? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
title = name
with(binding) {
setContentView(root)
passwordCategory.text = relativeParentPath
passwordFile.text = name
passwordFile.setOnLongClickListener {
copyTextToClipboard(name)
true
}
}
decrypt()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.pgp_handler, menu)
passwordEntry?.let { entry ->
if (menu != null) {
menu.findItem(R.id.edit_password).isVisible = true
if (!entry.password.isNullOrBlank()) {
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
menu.findItem(R.id.copy_password).isVisible = true
}
}
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onBackPressed()
R.id.edit_password -> editPassword()
R.id.share_password_as_plaintext -> shareAsPlaintext()
R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
* information leaks from stale activities.
*/
@OptIn(ExperimentalTime::class)
private fun startAutoDismissTimer() {
lifecycleScope.launch {
delay(Duration.seconds(60))
finish()
}
}
/**
* Edit the current password and hide all the fields populated by encrypted data so that when the
* result triggers they can be repopulated with new data.
*/
private fun editPassword() {
val intent = Intent(this, PasswordCreationActivity::class.java)
intent.putExtra("FILE_PATH", relativeParentPath)
intent.putExtra("REPO_PATH", repoPath)
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentString)
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
startActivity(intent)
finish()
}
private fun shareAsPlaintext() {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
type = "text/plain"
}
// Always show a picker to give the user a chance to cancel
startActivity(
Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))
)
}
private fun decrypt() {
lifecycleScope.launch {
// TODO(msfjarvis): native methods are fallible, add error handling once out of testing
val message = withContext(Dispatchers.IO) { File(fullPath).readBytes() }
val result =
withContext(Dispatchers.IO) {
val crypto = cryptos.first { it.canHandle(fullPath) }
crypto.decrypt(
PRIV_KEY,
PASS.toByteArray(charset = Charsets.UTF_8),
message,
)
}
startAutoDismissTimer()
val entry = passwordEntryFactory.create(lifecycleScope, result)
passwordEntry = entry
invalidateOptionsMenu()
val items = arrayListOf<FieldItem>()
val adapter = FieldItemAdapter(emptyList(), true) { text -> copyTextToClipboard(text) }
if (!entry.password.isNullOrBlank()) {
items.add(FieldItem.createPasswordField(entry.password!!))
}
if (entry.hasTotp()) {
lifecycleScope.launch {
items.add(FieldItem.createOtpField(entry.totp.value))
entry.totp.collect { code ->
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
}
}
}
if (!entry.username.isNullOrBlank()) {
items.add(FieldItem.createUsernameField(entry.username!!))
}
entry.extraContent.forEach { (key, value) ->
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
}
binding.recyclerView.adapter = adapter
adapter.updateItems(items)
}
}
companion object {
// TODO(msfjarvis): source these from storage and user input
const val PRIV_KEY = ""
const val PASS = ""
}
}

View file

@ -0,0 +1,432 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.ui.crypto
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.text.InputType
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.core.content.edit
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.zxing.integration.android.IntentIntegrator
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
import dev.msfjarvis.aps.injection.crypto.CryptoSet
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment
import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
import dev.msfjarvis.aps.util.extensions.base64
import dev.msfjarvis.aps.util.extensions.commitChange
import dev.msfjarvis.aps.util.extensions.getString
import dev.msfjarvis.aps.util.extensions.isInsideRepository
import dev.msfjarvis.aps.util.extensions.snackbar
import dev.msfjarvis.aps.util.extensions.unsafeLazy
import dev.msfjarvis.aps.util.extensions.viewBinding
import dev.msfjarvis.aps.util.settings.PreferenceKeys
import java.io.File
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@AndroidEntryPoint
class GopenpgpPasswordCreationActivity : BasePgpActivity() {
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
@Inject lateinit var cryptos: CryptoSet
private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
private val shouldGeneratePassword by unsafeLazy {
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
}
private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
private var oldCategory: String? = null
private var copy: Boolean = false
private val otpImportAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
binding.otpImportButton.isVisible = false
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
val contents = "${intentResult.contents}\n"
val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
binding.extraContent.append("\n$contents")
else binding.extraContent.append(contents)
snackbar(message = getString(R.string.otp_import_success))
} else {
snackbar(message = getString(R.string.otp_import_failure))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
bindToOpenKeychain(this)
title =
if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
with(binding) {
setContentView(root)
generatePassword.setOnClickListener { generatePassword() }
otpImportButton.setOnClickListener {
supportFragmentManager.setFragmentResultListener(
OTP_RESULT_REQUEST_KEY,
this@GopenpgpPasswordCreationActivity
) { requestKey, bundle ->
if (requestKey == OTP_RESULT_REQUEST_KEY) {
val contents = bundle.getString(RESULT)
val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
binding.extraContent.append("\n$contents")
else binding.extraContent.append(contents)
}
}
val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
if (hasCamera) {
val items =
arrayOf(
getString(R.string.otp_import_qr_code),
getString(R.string.otp_import_manual_entry)
)
MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity)
.setItems(items) { _, index ->
when (index) {
0 ->
otpImportAction.launch(
IntentIntegrator(this@GopenpgpPasswordCreationActivity)
.setOrientationLocked(false)
.setBeepEnabled(false)
.setDesiredBarcodeFormats(QR_CODE)
.createScanIntent()
)
1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
}
}
.show()
} else {
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
}
}
directoryInputLayout.apply {
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
isEnabled = true
} else {
setBackgroundColor(getColor(android.R.color.transparent))
}
val path = getRelativePath(fullPath, repoPath)
// Keep empty path field visible if it is editable.
if (path.isEmpty() && !isEnabled) visibility = View.GONE
else {
directory.setText(path)
oldCategory = path
}
}
if (suggestedName != null) {
filename.setText(suggestedName)
} else {
filename.requestFocus()
}
// Allow the user to quickly switch between storing the username as the filename or
// in the encrypted extras. This only makes sense if the directory structure is
// FileBased.
if (suggestedName == null &&
AutofillPreferences.directoryStructure(this@GopenpgpPasswordCreationActivity) ==
DirectoryStructure.FileBased
) {
encryptUsername.apply {
visibility = View.VISIBLE
setOnClickListener {
if (isChecked) {
// User wants to enable username encryption, so we add it to the
// encrypted extras as the first line.
val username = filename.text.toString()
val extras = "username:$username\n${extraContent.text}"
filename.text?.clear()
extraContent.setText(extras)
} else {
// User wants to disable username encryption, so we extract the
// username from the encrypted extras and use it as the filename.
val entry =
passwordEntryFactory.create(
lifecycleScope,
"PASSWORD\n${extraContent.text}".encodeToByteArray()
)
val username = entry.username
// username should not be null here by the logic in
// updateViewState, but it could still happen due to
// input lag.
if (username != null) {
filename.setText(username)
extraContent.setText(entry.extraContentWithoutAuthData)
}
}
}
}
listOf(filename, extraContent).forEach {
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
}
suggestedPass?.let {
password.setText(it)
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
suggestedExtra?.let { extraContent.setText(it) }
if (shouldGeneratePassword) {
generatePassword()
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
updateViewState()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
setResult(RESULT_CANCELED)
onBackPressed()
}
R.id.save_password -> {
copy = false
encrypt()
}
R.id.save_and_copy_password -> {
copy = true
encrypt()
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
private fun generatePassword() {
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
requestKey,
bundle ->
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
binding.password.setText(bundle.getString(RESULT))
}
}
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
KEY_PWGEN_TYPE_CLASSIC ->
PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
KEY_PWGEN_TYPE_XKPASSWD ->
XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
}
}
private fun updateViewState() =
with(binding) {
// Use PasswordEntry to parse extras for username
val entry =
passwordEntryFactory.create(
lifecycleScope,
"PLACEHOLDER\n${extraContent.text}".encodeToByteArray()
)
encryptUsername.apply {
if (visibility != View.VISIBLE) return@apply
val hasUsernameInFileName = filename.text.toString().isNotBlank()
val hasUsernameInExtras = !entry.username.isNullOrBlank()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
isChecked = hasUsernameInExtras
}
otpImportButton.isVisible = !entry.hasTotp()
}
/** Encrypts the password and the extra content */
private fun encrypt() {
with(binding) {
val editName = filename.text.toString().trim()
val editPass = password.text.toString()
val editExtra = extraContent.text.toString()
if (editName.isEmpty()) {
snackbar(message = resources.getString(R.string.file_toast_text))
return@with
} else if (editName.contains('/')) {
snackbar(message = resources.getString(R.string.invalid_filename_text))
return@with
}
if (editPass.isEmpty() && editExtra.isEmpty()) {
snackbar(message = resources.getString(R.string.empty_toast_text))
return@with
}
if (copy) {
copyPasswordToClipboard(editPass)
}
val content = "$editPass\n$editExtra"
val path =
when {
// If we allowed the user to edit the relative path, we have to consider it here
// instead
// of fullPath.
directoryInputLayout.isEnabled -> {
val editRelativePath = directory.text.toString().trim()
if (editRelativePath.isEmpty()) {
snackbar(message = resources.getString(R.string.path_toast_text))
return
}
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
return
}
"${passwordDirectory.path}/$editName.gpg"
}
else -> "$fullPath/$editName.gpg"
}
lifecycleScope.launch(Dispatchers.Main) {
runCatching {
val crypto = cryptos.first { it.canHandle(path) }
val result =
withContext(Dispatchers.IO) { crypto.encrypt(PUB_KEY, content.encodeToByteArray()) }
val file = File(path)
// If we're not editing, this file should not already exist!
// Additionally, if we were editing and the incoming and outgoing
// filenames differ, it means we renamed. Ensure that the target
// doesn't already exist to prevent an accidental overwrite.
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()
) {
snackbar(message = getString(R.string.password_creation_duplicate_error))
return@runCatching
}
if (!file.isInsideRepository()) {
snackbar(message = getString(R.string.message_error_destination_outside_repo))
return@runCatching
}
withContext(Dispatchers.IO) { file.outputStream().use { it.write(result) } }
// associate the new password name with the last name's timestamp in
// history
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
val timestamp = preference.getString(oldFilePathHash)
if (timestamp != null) {
preference.edit {
remove(oldFilePathHash)
putString(file.absolutePath.base64(), timestamp)
}
}
val returnIntent = Intent()
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
if (shouldGeneratePassword) {
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
val entry = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray())
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
val username = entry.username ?: directoryStructure.getUsernameFor(file)
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
}
if (directoryInputLayout.isVisible &&
directoryInputLayout.isEnabled &&
oldFileName != null
) {
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
if (oldFile.path != file.path && !oldFile.delete()) {
setResult(RESULT_CANCELED)
MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity)
.setTitle(R.string.password_creation_file_fail_title)
.setMessage(
getString(R.string.password_creation_file_delete_fail_message, oldFileName)
)
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
return@runCatching
}
}
val commitMessageRes =
if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
lifecycleScope.launch {
commitChange(
resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName))
)
.onSuccess {
setResult(RESULT_OK, returnIntent)
finish()
}
}
}
.onFailure { e ->
if (e is IOException) {
e(e) { "Failed to write password file" }
setResult(RESULT_CANCELED)
MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity)
.setTitle(getString(R.string.password_creation_file_fail_title))
.setMessage(getString(R.string.password_creation_file_write_fail_message))
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
} else {
e(e)
}
}
}
}
}
companion object {
private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
const val RESULT = "RESULT"
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
const val RETURN_EXTRA_NAME = "NAME"
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
const val RETURN_EXTRA_USERNAME = "USERNAME"
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
const val EXTRA_FILE_NAME = "FILENAME"
const val EXTRA_PASSWORD = "PASSWORD"
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
const val EXTRA_EDITING = "EDITING"
// TODO(msfjarvis): source this from storage
const val PUB_KEY = ""
}
}

View file

@ -38,6 +38,7 @@ import dev.msfjarvis.aps.data.password.PasswordItem
import dev.msfjarvis.aps.data.repo.PasswordRepository
import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName
import dev.msfjarvis.aps.ui.crypto.DecryptActivity
import dev.msfjarvis.aps.ui.crypto.GopenpgpDecryptActivity
import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity
import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet
import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment
@ -46,6 +47,7 @@ import dev.msfjarvis.aps.ui.git.base.BaseGitActivity
import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity
import dev.msfjarvis.aps.ui.settings.DirectorySelectionActivity
import dev.msfjarvis.aps.ui.settings.SettingsActivity
import dev.msfjarvis.aps.util.FeatureFlags
import dev.msfjarvis.aps.util.autofill.AutofillMatcher
import dev.msfjarvis.aps.util.extensions.base64
import dev.msfjarvis.aps.util.extensions.commitChange
@ -422,7 +424,14 @@ class PasswordStore : BaseGitActivity() {
val authDecryptIntent = item.createAuthEnabledIntent(this)
val decryptIntent =
(authDecryptIntent.clone() as Intent).setComponent(
ComponentName(this, DecryptActivity::class.java)
ComponentName(
this,
if (FeatureFlags.ENABLE_GOPENPGP) {
GopenpgpDecryptActivity::class.java
} else {
DecryptActivity::class.java
}
)
)
startActivity(decryptIntent)

View file

@ -0,0 +1,6 @@
package dev.msfjarvis.aps.util
/** Naive feature flagging functionality to allow merging incomplete features */
object FeatureFlags {
const val ENABLE_GOPENPGP = false
}

View file

@ -25,6 +25,8 @@ import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity
import dev.msfjarvis.aps.util.FeatureFlags
import java.io.File
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
@ -68,7 +70,12 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
val intentSender =
if (FeatureFlags.ENABLE_GOPENPGP) {
GopenpgpAutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
} else {
AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
}
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
}

View file

@ -25,6 +25,8 @@ import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity
import dev.msfjarvis.aps.util.FeatureFlags
import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
@ -56,7 +58,12 @@ class AutofillResponseBuilder(form: FillableForm) {
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
val intentSender =
if (FeatureFlags.ENABLE_GOPENPGP) {
GopenpgpAutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
} else {
AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
}
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
}

View file

@ -0,0 +1,6 @@
public abstract interface class dev/msfjarvis/aps/data/crypto/CryptoHandler {
public abstract fun canHandle (Ljava/lang/String;)Z
public abstract fun decrypt (Ljava/lang/String;[B[B)[B
public abstract fun encrypt (Ljava/lang/String;[B)[B
}

View file

@ -0,0 +1,8 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
plugins {
kotlin("jvm")
`aps-plugin`
}

View file

@ -0,0 +1,25 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.data.crypto
/** Generic interface to implement cryptographic operations on top of. */
public interface CryptoHandler {
/**
* Decrypt the given [ciphertext] using a [privateKey] and [passphrase], returning a [ByteArray]
* corresponding to the decrypted plaintext.
*/
public fun decrypt(privateKey: String, passphrase: ByteArray, ciphertext: ByteArray): ByteArray
/**
* Encrypt the given [plaintext] to the provided [publicKey], returning the encrypted ciphertext
* as a [ByteArray]
*/
public fun encrypt(publicKey: String, plaintext: ByteArray): ByteArray
/** Given a [fileName], return whether this instance can handle it. */
public fun canHandle(fileName: String): Boolean
}

View file

@ -0,0 +1,7 @@
public final class dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler : dev/msfjarvis/aps/data/crypto/CryptoHandler {
public fun <init> ()V
public fun canHandle (Ljava/lang/String;)Z
public fun decrypt (Ljava/lang/String;[B[B)[B
public fun encrypt (Ljava/lang/String;[B)[B
}

View file

@ -0,0 +1,15 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
plugins {
id("com.android.library")
kotlin("android")
`aps-plugin`
}
dependencies {
api(projects.cryptoCommon)
implementation(libs.aps.gopenpgp)
implementation(libs.dagger.hilt.core)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
~ SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
-->
<manifest package="dev.msfjarvis.aps.cryptopgp"></manifest>

View file

@ -0,0 +1,49 @@
/*
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package dev.msfjarvis.aps.data.crypto
import com.proton.Gopenpgp.crypto.Crypto
import com.proton.Gopenpgp.helper.Helper
import javax.inject.Inject
/** Gopenpgp backed implementation of [CryptoHandler]. */
public class GopenpgpCryptoHandler @Inject constructor() : CryptoHandler {
/**
* Decrypt the given [ciphertext] using the given PGP [privateKey] and corresponding [passphrase].
*/
override fun decrypt(
privateKey: String,
passphrase: ByteArray,
ciphertext: ByteArray,
): ByteArray {
// Decode the incoming cipher into a string and try to guess if it's armored.
val cipherString = ciphertext.decodeToString()
val isArmor = cipherString.startsWith("-----BEGIN PGP MESSAGE-----")
val message =
if (isArmor) {
Crypto.newPGPMessageFromArmored(cipherString)
} else {
Crypto.newPGPMessage(ciphertext)
}
return Helper.decryptBinaryMessageArmored(
privateKey,
passphrase,
message.armored,
)
}
override fun encrypt(publicKey: String, plaintext: ByteArray): ByteArray {
return Helper.encryptBinaryMessage(
publicKey,
plaintext,
)
}
override fun canHandle(fileName: String): Boolean {
return fileName.split('.').last() == "gpg"
}
}

View file

@ -72,6 +72,7 @@ dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt
android-desugarJdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
# First-party libraries
aps-gopenpgp = "com.github.android-password-store:gopenpgp:0.1.5"
aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:1.0.0"
aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.0"

View file

@ -8,6 +8,10 @@ include(":app")
include(":autofill-parser")
include(":crypto-common")
include(":crypto-pgp")
include(":format-common")
include(":openpgp-ktx")