diff options
| author | Harsh Shandilya <me@msfjarvis.dev> | 2020-12-05 06:07:18 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-05 06:07:18 +0530 |
| commit | 5e66d99c852ea67a88b650c03b0e8d55e83eccde (patch) | |
| tree | aee323a08253fe7b863976eff92a1bd412bd1430 /app/src/main/java/com/zeapo/pwdstore/git | |
| parent | 8eb55f18a11d6b2f155c7c9d48ca833313ee13db (diff) | |
| download | HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.tar.gz HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.zip | |
Refactor package structure (#1233)
* idea: default test runner to Gradle
* Kick off package structure revamp
* Reparent all classes under dev.msfjarvis.aps
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/git')
24 files changed, 0 insertions, 2698 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt deleted file mode 100644 index b47647c1..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import com.github.ajalt.timberkt.d -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.andThen -import com.github.michaelbull.result.mapError -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.operation.BreakOutOfDetached -import com.zeapo.pwdstore.git.operation.CloneOperation -import com.zeapo.pwdstore.git.operation.PullOperation -import com.zeapo.pwdstore.git.operation.PushOperation -import com.zeapo.pwdstore.git.operation.ResetToRemoteOperation -import com.zeapo.pwdstore.git.operation.SyncOperation -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.sharedPrefs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.userauth.UserAuthException - -/** - * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related - * tasks and makes sense to be held here. - */ -abstract class BaseGitActivity : ContinuationContainerActivity() { - - /** - * Enum of possible Git operations than can be run through [launchGitOperation]. - */ - enum class GitOp { - BREAK_OUT_OF_DETACHED, - CLONE, - PULL, - PUSH, - RESET, - SYNC, - } - - /** - * Attempt to launch the requested Git operation. - * @param operation The type of git operation to launch - */ - suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> { - if (GitSettings.url == null) { - return Err(IllegalStateException("Git url is not set!")) - } - if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) { - // If the server does not support multiple SSH channels per connection, we cannot run - // a sync operation without reconnecting and thus break sync into its two parts. - return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } - } - val op = when (operation) { - GitOp.CLONE -> CloneOperation(this, GitSettings.url!!) - GitOp.PULL -> PullOperation(this) - GitOp.PUSH -> PushOperation(this) - GitOp.SYNC -> SyncOperation(this) - GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this) - GitOp.RESET -> ResetToRemoteOperation(this) - } - return op.executeAfterAuthentication(GitSettings.authMode).mapError { throwable -> - val err = rootCauseException(throwable) - if (err.message?.contains("cannot open additional channels") == true) { - GitSettings.useMultiplexing = false - SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.") - } else { - err - } - } - } - - fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { - finish() - } - - suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { - val error = rootCauseException(err) - if (!isExplicitlyUserInitiatedError(error)) { - getEncryptedGitPrefs().edit { - remove(PreferenceKeys.HTTPS_PASSWORD) - } - sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - d(error) - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@BaseGitActivity).run { - setTitle(resources.getString(R.string.jgit_error_dialog_title)) - setMessage(ErrorMessages[error]) - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - setOnDismissListener { - onPromptDone() - } - show() - } - } - } else { - onPromptDone() - } - } - - /** - * Check if a given [Throwable] is the result of an error caused by the user cancelling the - * operation. - */ - private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { - var cause: Throwable? = throwable - while (cause != null) { - if (cause is SSHException && - cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) - return true - cause = cause.cause - } - return false - } - - /** - * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no - * longer found. - */ - private fun rootCauseException(throwable: Throwable): Throwable { - var rootCause = throwable - // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions. - // Also, SSHJ's UserAuthException about exhausting available authentication methods hides - // more useful exceptions. - while ((rootCause is org.eclipse.jgit.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || - (rootCause is UserAuthException && - rootCause.message == "Exhausted available authentication methods"))) { - rootCause = rootCause.cause ?: break - } - return rootCause - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt b/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt deleted file mode 100644 index 89676c7a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git - -import android.os.RemoteException -import androidx.annotation.StringRes -import com.zeapo.pwdstore.Application -import com.zeapo.pwdstore.R -import java.net.UnknownHostException - -/** - * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. - */ -sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) { - - override val message = super.message!! - - companion object { - - private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) - } - - /** - * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. - */ - sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { - - object PullRebaseFailed : PullException(R.string.git_pull_fail_error) - } - - /** - * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. - */ - sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { - - object NonFastForward : PushException(R.string.git_push_nff_error) - object RemoteRejected : PushException(R.string.git_push_other_error) - class Generic(message: String) : PushException(R.string.git_push_generic_error, message) - } -} - -object ErrorMessages { - - operator fun get(throwable: Throwable?): String { - val resources = Application.instance.resources - if (throwable == null) return resources.getString(R.string.git_unknown_error) - return when (val rootCause = rootCause(throwable)) { - is GitException -> rootCause.message - is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) - else -> throwable.message ?: resources.getString(R.string.git_unknown_error) - } - } - - private fun rootCause(throwable: Throwable): Throwable { - var cause = throwable - while (cause.cause != null) { - if (cause is GitException) break - val nextCause = cause.cause!! - if (nextCause is RemoteException) break - cause = nextCause - } - return cause - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt deleted file mode 100644 index a86e8c54..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git - -import android.widget.Toast -import androidx.fragment.app.FragmentActivity -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.runCatching -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.GitException.PullException -import com.zeapo.pwdstore.git.GitException.PushException -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.operation.GitOperation -import com.zeapo.pwdstore.utils.snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.eclipse.jgit.api.CommitCommand -import org.eclipse.jgit.api.PullCommand -import org.eclipse.jgit.api.PushCommand -import org.eclipse.jgit.api.RebaseResult -import org.eclipse.jgit.api.StatusCommand -import org.eclipse.jgit.lib.PersonIdent -import org.eclipse.jgit.transport.RemoteRefUpdate - -class GitCommandExecutor( - private val activity: FragmentActivity, - private val operation: GitOperation, -) { - - suspend fun execute(): Result<Unit, Throwable> { - val snackbar = activity.snackbar( - message = activity.resources.getString(R.string.git_operation_running), - length = Snackbar.LENGTH_INDEFINITE, - ) - // Count the number of uncommitted files - var nbChanges = 0 - return runCatching { - for (command in operation.commands) { - when (command) { - is StatusCommand -> { - val res = withContext(Dispatchers.IO) { - command.call() - } - nbChanges = res.uncommittedChanges.size - } - is CommitCommand -> { - // the previous status will eventually be used to avoid a commit - if (nbChanges > 0) { - withContext(Dispatchers.IO) { - val name = GitSettings.authorName.ifEmpty { "root" } - val email = GitSettings.authorEmail.ifEmpty { "localhost" } - val identity = PersonIdent(name, email) - command.setAuthor(identity).setCommitter(identity).call() - } - } - } - is PullCommand -> { - val result = withContext(Dispatchers.IO) { - command.call() - } - val rr = result.rebaseResult - if (rr.status == RebaseResult.Status.STOPPED) { - throw PullException.PullRebaseFailed - } - } - is PushCommand -> { - val results = withContext(Dispatchers.IO) { - command.call() - } - for (result in results) { - // Code imported (modified) from Gerrit PushOp, license Apache v2 - for (rru in result.remoteUpdates) { - when (rru.status) { - RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward - RemoteRefUpdate.Status.REJECTED_NODELETE, - RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, - RemoteRefUpdate.Status.NON_EXISTING, - RemoteRefUpdate.Status.NOT_ATTEMPTED, - -> throw PushException.Generic(rru.status.name) - RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { - throw if ("non-fast-forward" == rru.message) { - PushException.RemoteRejected - } else { - PushException.Generic(rru.message) - } - } - RemoteRefUpdate.Status.UP_TO_DATE -> { - withContext(Dispatchers.Main) { - Toast.makeText( - activity.applicationContext, - activity.applicationContext.getString(R.string.git_push_up_to_date), - Toast.LENGTH_SHORT - ).show() - } - } - else -> { - } - } - } - } - } - else -> { - withContext(Dispatchers.IO) { - command.call() - } - } - } - } - }.also { - snackbar.dismiss() - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt deleted file mode 100644 index 8c6c7d59..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.core.os.postDelayed -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.log.GitLogActivity -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.viewBinding -import kotlinx.coroutines.launch -import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.lib.Repository - -class GitConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitConfigBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (GitSettings.authorName.isEmpty()) - binding.gitUserName.requestFocus() - else - binding.gitUserName.setText(GitSettings.authorName) - binding.gitUserEmail.setText(GitSettings.authorEmail) - setupTools() - binding.saveButton.setOnClickListener { - val email = binding.gitUserEmail.text.toString().trim() - val name = binding.gitUserName.text.toString().trim() - if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.invalid_email_dialog_text)) - .setPositiveButton(getString(R.string.dialog_ok), null) - .show() - } else { - GitSettings.authorEmail = email - GitSettings.authorName = name - Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - /** - * Sets up the UI components of the tools section. - */ - private fun setupTools() { - val repo = PasswordRepository.getRepository(null) - if (repo != null) { - binding.gitHeadStatus.text = headStatusMsg(repo) - // enable the abort button only if we're rebasing - val isRebasing = repo.repositoryState.isRebasing - binding.gitAbortRebase.isEnabled = isRebasing - binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f - } - binding.gitLog.setOnClickListener { - runCatching { - startActivity(Intent(this, GitLogActivity::class.java)) - }.onFailure { ex -> - e(ex) { "Failed to start GitLogActivity" } - } - } - binding.gitAbortRebase.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold( - success = { - MaterialAlertDialogBuilder(this@GitConfigActivity).run { - setTitle(resources.getString(R.string.git_abort_and_push_title)) - setMessage(resources.getString( - R.string.git_break_out_of_detached_success, - GitSettings.branch, - "conflicting-${GitSettings.branch}-...", - )) - setOnDismissListener() { finish() } - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - show() - } - }, - failure = { err -> - promptOnErrorHandler(err) { - finish() - } - }, - ) - } - } - binding.gitResetToRemote.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.RESET).fold( - success = ::finishOnSuccessHandler, - failure = { err -> - promptOnErrorHandler(err) { - finish() - } - }, - ) - } - } - } - - /** - * Returns a user-friendly message about the current state of HEAD. - * - * The state is recognized to be either pointing to a branch or detached. - */ - private fun headStatusMsg(repo: Repository): String { - return runCatching { - val headRef = repo.getRef(Constants.HEAD) - if (headRef.isSymbolic) { - val branchName = headRef.target.name - val shortBranchName = Repository.shortenRefName(branchName) - getString(R.string.git_head_on_branch, shortBranchName) - } else { - val commitHash = headRef.objectId.abbreviate(8).name() - getString(R.string.git_head_detached, commitHash) - } - }.getOrElse { ex -> - e(ex) { "Error getting HEAD reference" } - getString(R.string.git_head_missing) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt deleted file mode 100644 index af6540a4..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.MenuItem -import android.view.View -import androidx.core.os.postDelayed -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.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.ActivityGitCloneBinding -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.Protocol -import com.zeapo.pwdstore.ui.dialogs.BasicBottomSheet -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.snackbar -import com.zeapo.pwdstore.utils.viewBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Activity that encompasses both the initial clone as well as editing the server config for future - * changes. - */ -class GitServerConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitCloneBinding::inflate) - - private lateinit var newAuthMode: AuthMode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isClone = intent?.extras?.getBoolean("cloning") ?: false - if (isClone) { - binding.saveButton.text = getString(R.string.clone_button) - } - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - newAuthMode = GitSettings.authMode - - binding.authModeGroup.apply { - 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 { _, _, _ -> - when (checkedButtonId) { - binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey - binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain - binding.authModePassword.id -> newAuthMode = AuthMode.Password - View.NO_ID -> newAuthMode = AuthMode.None - } - } - } - - binding.serverUrl.setText(GitSettings.url.also { - if (it.isNullOrEmpty()) return@also - setAuthModes(it.startsWith("http://") || it.startsWith("https://")) - }) - binding.serverBranch.setText(GitSettings.branch) - - binding.serverUrl.doOnTextChanged { text, _, _, _ -> - if (text.isNullOrEmpty()) return@doOnTextChanged - setAuthModes(text.startsWith("http://") || text.startsWith("https://")) - } - - binding.saveButton.setOnClickListener { - val newUrl = binding.serverUrl.text.toString().trim() - // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` - // in the beginning will cause the port to be seen as part of the path. Let users know - // about it and offer a quickfix. - if (newUrl.contains(PORT_REGEX)) { - if (newUrl.startsWith("https://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.https_scheme_with_port_title) - .setMessageRes(R.string.https_scheme_with_port_message) - .setPositiveButtonClickListener { - binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } else if (!newUrl.startsWith("ssh://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.ssh_scheme_needed_message) - .setPositiveButtonClickListener { - @Suppress("SetTextI18n") - binding.serverUrl.setText("ssh://$newUrl") - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - } - when (val updateResult = GitSettings.updateConnectionSettingsIfValid( - newAuthMode = newAuthMode, - newUrl = binding.serverUrl.text.toString().trim(), - newBranch = binding.serverBranch.text.toString().trim())) { - GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { - Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() - } - - is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { - when (updateResult.newProtocol) { - Protocol.Https -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_https) - .setPositiveButtonClickListener { - } - .build() - .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") - Protocol.Ssh -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_ssh) - .setPositiveButtonClickListener { - } - .build() - .show(supportFragmentManager, "SSH_MISSING_USERNAME") - } - } - GitSettings.UpdateConnectionSettingsResult.Valid -> { - if (isClone && PasswordRepository.getRepository(null) == null) - PasswordRepository.initialize() - if (!isClone) { - Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } else { - cloneRepository() - } - } - is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { - val message = getString( - R.string.git_server_config_save_auth_mode_mismatch, - updateResult.newProtocol, - updateResult.validModes.joinToString(", "), - ) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun setAuthModes(isHttps: Boolean) = 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) - } - } - - /** - * Clones the repository, the directory exists, deletes it - */ - private fun cloneRepository() { - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) - val localDirFiles = localDir.listFiles() ?: emptyArray() - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if (localDir.exists() && localDirFiles.isNotEmpty() && - !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialog, _ -> - runCatching { - lifecycleScope.launch { - val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE) - withContext(Dispatchers.IO) { - localDir.deleteRecursively() - } - snackbar.dismiss() - launchGitOperation(GitOp.CLONE).fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { err -> - promptOnErrorHandler(err) { - finish() - } - } - ) - } - }.onFailure { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> - dialog.cancel() - } - .show() - } else { - runCatching { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { - localDir.deleteRecursively() - } - }.onFailure { e -> - e(e) - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - lifecycleScope.launch { - launchGitOperation(GitOp.CLONE).fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { promptOnErrorHandler(it) }, - ) - } - } - } - - companion object { - private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() - - fun createCloneIntent(context: Context): Intent { - return Intent(context, GitServerConfigActivity::class.java).apply { - putExtra("cloning", true) - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt deleted file mode 100644 index 27ceb5cb..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.config - -import androidx.core.content.edit -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.Application -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.getEncryptedProxyPrefs -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import org.eclipse.jgit.transport.URIish - -enum class Protocol(val pref: String) { - Ssh("ssh://"), - Https("https://"), - ; - - companion object { - - private val map = values().associateBy(Protocol::pref) - fun fromString(type: String?): Protocol { - return map[type ?: return Ssh] - ?: throw IllegalArgumentException("$type is not a valid Protocol") - } - } -} - -enum class AuthMode(val pref: String) { - SshKey("ssh-key"), - Password("username/password"), - OpenKeychain("OpenKeychain"), - None("None"), - ; - - companion object { - - private val map = values().associateBy(AuthMode::pref) - fun fromString(type: String?): AuthMode { - return map[type ?: return SshKey] - ?: throw IllegalArgumentException("$type is not a valid AuthMode") - } - } -} - -object GitSettings { - - private const val DEFAULT_BRANCH = "master" - - private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } - private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } - private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } - - var authMode - get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) - private set(value) { - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) - } - } - var url - get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) - private set(value) { - require(value != null) - if (value == url) - return - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_URL, value) - } - if (PasswordRepository.isInitialized) - PasswordRepository.addRemote("origin", value, true) - // When the server changes, remote password, multiplexing support and host key file - // should be deleted/reset. - useMultiplexing = true - encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } - File("${Application.instance.filesDir}/.host_key").delete() - } - var authorName - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) - } - } - var authorEmail - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) - } - } - var branch - get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH - private set(value) { - settings.edit { - putString(PreferenceKeys.GIT_BRANCH_NAME, value) - } - } - var useMultiplexing - get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) - set(value) { - settings.edit { - putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) - } - } - - var proxyHost - get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_HOST, value) - } - } - - var proxyPort - get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) - set(value) { - proxySettings.edit { - putInt(PreferenceKeys.PROXY_PORT, value) - } - } - - var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_USERNAME, value) - } - } - - var proxyPassword - get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_PASSWORD, value) - } - } - - sealed class UpdateConnectionSettingsResult { - class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() - class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult() - object Valid : UpdateConnectionSettingsResult() - object FailedToParseUrl : UpdateConnectionSettingsResult() - } - - fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { - val parsedUrl = runCatching { - URIish(newUrl) - }.getOrElse { - return UpdateConnectionSettingsResult.FailedToParseUrl - } - val newProtocol = when (parsedUrl.scheme) { - in listOf("http", "https") -> Protocol.Https - in listOf("ssh", null) -> Protocol.Ssh - else -> return UpdateConnectionSettingsResult.FailedToParseUrl - } - if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank()) - return UpdateConnectionSettingsResult.MissingUsername(newProtocol) - - val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) - val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) - when { - newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { - return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) - } - newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { - return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) - } - } - - url = newUrl - authMode = newAuthMode - branch = newBranch - return UpdateConnectionSettingsResult.Valid - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt deleted file mode 100644 index d2425592..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import java.util.Date - -/** - * Basic information about a git commit. - * - * @property hash full-length hash of the commit object. - * @property shortMessage the commit's short message (i.e. title line). - * @property authorName name of the commit's author without email address. - * @property time time when the commit was created. - */ -data class GitCommit(val hash: String, val shortMessage: String, val authorName: String, val time: Date) diff --git a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt deleted file mode 100644 index 1e616bae..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import android.os.Bundle -import android.view.MenuItem -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import com.zeapo.pwdstore.databinding.ActivityGitLogBinding -import com.zeapo.pwdstore.git.BaseGitActivity -import com.zeapo.pwdstore.utils.viewBinding - -/** - * Displays the repository's git commits in git-log fashion. - * - * It provides basic information about each commit by way of a non-interactive RecyclerView. - */ -class GitLogActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitLogBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - createRecyclerView() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun createRecyclerView() { - binding.gitLogRecyclerView.apply { - setHasFixedSize(true) - addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) - adapter = GitLogAdapter() - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt deleted file mode 100644 index a15e7f7e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.databinding.GitLogRowLayoutBinding -import java.text.DateFormat -import java.util.Date - -private fun shortHash(hash: String): String { - return hash.substring(0 until 8) -} - -private fun stringFrom(date: Date): String { - return DateFormat.getDateTimeInstance().format(date) -} - -/** - * @see GitLogActivity - */ -class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() { - - private val model = GitLogModel() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val commit = model.get(position) - if (commit == null) { - e { "There is no git commit for view holder at position $position." } - return - } - viewHolder.bind(commit) - } - - override fun getItemCount() = model.size - - class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) { - - fun bind(commit: GitCommit) = with(binding) { - gitLogRowMessage.text = commit.shortMessage - gitLogRowHash.text = shortHash(commit.hash) - gitLogRowTime.text = stringFrom(commit.time) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt deleted file mode 100644 index 15b9f64d..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.hash -import com.zeapo.pwdstore.utils.time -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.revwalk.RevCommit - -private fun commits(): Iterable<RevCommit> { - val repo = PasswordRepository.getRepository(null) - if (repo == null) { - e { "Could not access git repository" } - return listOf() - } - return runCatching { - Git(repo).log().call() - }.getOrElse { e -> - e(e) { "Failed to obtain git commits" } - listOf() - } -} - -/** - * Provides [GitCommit]s from a git-log of the password git repository. - * - * All commits are acquired on the first request to this object. - */ -class GitLogModel { - - // All commits are acquired here at once. Acquiring the commits in batches would not have been - // entirely sensible because the amount of computation required to obtain commit number n from - // the log includes the amount of computation required to obtain commit number n-1 from the log. - // This is because the commit graph is walked from HEAD to the last commit to obtain. - // Additionally, tests with 1000 commits in the log have not produced a significant delay in the - // user experience. - private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) { - commits().map { - GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) - }.toMutableList() - } - val size = cache.size - - fun get(index: Int): GitCommit? { - if (index >= size) e { "Cannot get git commit with index $index. There are only $size." } - return cache.getOrNull(index) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt deleted file mode 100644 index f7759ada..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.RebaseCommand - -class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - override val commands = arrayOf( - // abort the rebase - git.rebase().setOperation(RebaseCommand.Operation.ABORT), - // git checkout -b conflict-branch - git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), - // push the changes - git.push().setRemote("origin"), - // switch back to ${gitBranch} - git.checkout().setName(remoteBranch), - ) - - override fun preExecute() = if (!git.repository.repositoryState.isRebasing) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - }.show() - false - } else { - true - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt deleted file mode 100644 index 2a94a283..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand - -/** - * Creates a new clone operation - * - * @param uri URL to clone the repository from - * @param callingActivity the calling activity - */ -class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) { - - override val commands: Array<GitCommand<out Any>> = arrayOf( - Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt deleted file mode 100644 index c34af18a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.zeapo.pwdstore.git.operation - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import androidx.annotation.StringRes -import androidx.core.content.edit -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.FragmentActivity -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 com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.requestInputFocusOnView -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume - -class CredentialFinder( - val callingActivity: FragmentActivity, - val authMode: AuthMode -) : InteractivePasswordFinder() { - - override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { - val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() - val credentialPref: String - @StringRes val messageRes: Int - @StringRes val hintRes: Int - @StringRes val rememberRes: Int - @StringRes val errorRes: Int - when (authMode) { - AuthMode.SshKey -> { - credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE - messageRes = R.string.passphrase_dialog_text - hintRes = R.string.ssh_keygen_passphrase - rememberRes = R.string.git_operation_remember_passphrase - errorRes = R.string.git_operation_wrong_passphrase - } - AuthMode.Password -> { - // Could be either an SSH or an HTTPS password - credentialPref = PreferenceKeys.HTTPS_PASSWORD - messageRes = R.string.password_dialog_text - hintRes = R.string.git_operation_hint_password - rememberRes = R.string.git_operation_remember_password - errorRes = R.string.git_operation_wrong_password - } - else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") - } - if (isRetry) - gitOperationPrefs.edit { remove(credentialPref) } - val storedCredential = gitOperationPrefs.getString(credentialPref, null) - if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) { - credentialLayout.error = callingActivity.resources.getString(errorRes) - // Reset error when user starts entering a password - editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } - } - MaterialAlertDialogBuilder(callingActivity).run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { - putString(credentialPref, credential) - } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> - cont.resume(null) - } - setOnCancelListener { - cont.resume(null) - } - create() - }.run { - requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential) - show() - } - } else { - cont.resume(storedCredential) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt deleted file mode 100644 index 5649e8de..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import android.content.Intent -import android.widget.Toast -import androidx.fragment.app.FragmentActivity -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.git.GitCommandExecutor -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import com.zeapo.pwdstore.git.sshj.SshAuthMethod -import com.zeapo.pwdstore.git.sshj.SshKey -import com.zeapo.pwdstore.git.sshj.SshjSessionFactory -import com.zeapo.pwdstore.utils.BiometricAuthenticator -import com.zeapo.pwdstore.utils.PasswordRepository -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.userauth.password.PasswordFinder -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.api.TransportCommand -import org.eclipse.jgit.errors.UnsupportedCredentialItem -import org.eclipse.jgit.transport.CredentialItem -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.SshTransport -import org.eclipse.jgit.transport.Transport -import org.eclipse.jgit.transport.URIish - -/** - * Creates a new git operation - * - * @param callingActivity the calling activity - */ -abstract class GitOperation(protected val callingActivity: FragmentActivity) { - - abstract val commands: Array<GitCommand<out Any>> - private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") - private var sshSessionFactory: SshjSessionFactory? = null - - protected val repository = PasswordRepository.getRepository(null)!! - protected val git = Git(repository) - protected val remoteBranch = GitSettings.branch - private val authActivity get() = callingActivity as ContinuationContainerActivity - - private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { - - private var cachedPassword: CharArray? = null - - override fun isInteractive() = true - - override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { - for (item in items) { - when (item) { - is CredentialItem.Username -> item.value = uri?.user - is CredentialItem.Password -> { - item.value = cachedPassword?.clone() - ?: passwordFinder.reqPassword(null).also { - cachedPassword = it.clone() - } - } - else -> UnsupportedCredentialItem(uri, item.javaClass.name) - } - } - return true - } - - override fun supports(vararg items: CredentialItem) = items.all { - it is CredentialItem.Username || it is CredentialItem.Password - } - - override fun reset(uri: URIish?) { - cachedPassword?.fill(0.toChar()) - cachedPassword = null - } - } - - private fun getSshKey(make: Boolean) { - runCatching { - // Ask the UserPreference to provide us with the ssh-key - val intent = Intent(callingActivity.applicationContext, UserPreference::class.java) - intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key") - callingActivity.startActivity(intent) - }.onFailure { e -> - e(e) - } - } - - private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { - sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) - commands.filterIsInstance<TransportCommand<*, *>>().forEach { command -> - command.setTransportConfigCallback { transport: Transport -> - (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory - credentialsProvider?.let { transport.credentialsProvider = it } - } - command.setTimeout(CONNECT_TIMEOUT) - } - } - - /** - * Executes the GitCommand in an async task. - */ - suspend fun execute(): Result<Unit, Throwable> { - if (!preExecute()) { - return Ok(Unit) - } - val operationResult = GitCommandExecutor( - callingActivity, - this, - ).execute() - postExecute() - return operationResult - } - - private fun onMissingSshKeyFile() { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> - getSshKey(false) - } - .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> - getSshKey(true) - } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - }.show() - } - - suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { - when (authMode) { - AuthMode.SshKey -> if (SshKey.exists) { - if (SshKey.mustAuthenticate) { - val result = withContext(Dispatchers.Main) { - suspendCoroutine<BiometricAuthenticator.Result> { cont -> - BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { - if (it !is BiometricAuthenticator.Result.Failure) - cont.resume(it) - } - } - } - when (result) { - is BiometricAuthenticator.Result.Success -> { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - is BiometricAuthenticator.Result.Cancelled -> { - return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - is BiometricAuthenticator.Result.Failure -> { - throw IllegalStateException("Biometric authentication failures should be ignored") - } - else -> { - // There is a chance we succeed if the user recently confirmed - // their screen lock. Doing so would have a potential to confuse - // users though, who might deduce that the screen lock - // protection is not effective. Hence, we fail with an error. - Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show() - callingActivity.finish() - } - } - } else { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - } else { - onMissingSshKeyFile() - // This would correctly cancel the operation but won't surface a user-visible - // 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)) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) - } - AuthMode.None -> { - } - } - return execute() - } - - /** - * Called before execution of the Git operation. - * Return false to cancel. - */ - open fun preExecute() = true - - private suspend fun postExecute() { - withContext(Dispatchers.IO) { - sshSessionFactory?.close() - } - } - - companion object { - - /** - * Timeout in seconds before [TransportCommand] will abort a stalled IO operation. - */ - private const val CONNECT_TIMEOUT = 10 - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt deleted file mode 100644 index 4dcc3ec4..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.GitCommand - -class PullOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - /** - * The story of why the pull operation is committing files goes like this: Once upon a time when - * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made - * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. - * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] - * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, - * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly - * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. - */ - override val commands: Array<GitCommand<out Any>> = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(true).setRemote("origin"), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt deleted file mode 100644 index 28d7a6fa..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.GitCommand - -class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - override val commands: Array<GitCommand<out Any>> = arrayOf( - git.push().setPushAll().setRemote("origin"), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt deleted file mode 100644 index ad0fb823..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.ResetCommand - -class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - override val commands = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Fetch everything from the origin remote - git.fetch().setRemote("origin"), - // Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch - git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), - // Force-create $remoteBranch if it doesn't exist. This covers the case where you switched - // branches from 'master' to anything else. - git.branchCreate().setName(remoteBranch).setForce(true), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt deleted file mode 100644 index 8c1ec1fa..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity - -class SyncOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - override val commands = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(true).setRemote("origin"), - // Push it all back - git.push().setPushAll().setRemote("origin"), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt deleted file mode 100644 index 32ee64a8..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.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)) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt deleted file mode 100644 index 97774d92..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import android.app.PendingIntent -import android.content.Intent -import androidx.activity.result.IntentSenderRequest -import androidx.core.content.edit -import com.github.ajalt.timberkt.d -import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.Closeable -import java.security.PublicKey -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -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) { - d { "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 { - d { "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 { - d { "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 close() { - activity.continueAfterUserInteraction.unregister() - sshServiceConnection.disconnect() - } - - override fun getPrivate() = privateKey - - override fun getPublic() = publicKey - - override fun getType() = KeyType.fromKey(publicKey) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt deleted file mode 100644 index 97b587fd..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import com.hierynomus.sshj.key.KeyAlgorithm -import java.io.ByteArrayOutputStream -import java.security.PrivateKey -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 { - - 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) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt deleted file mode 100644 index 52297133..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt +++ /dev/null @@ -1,336 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import android.content.Context -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.OpenableColumns -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyInfo -import android.security.keystore.KeyProperties -import android.util.Base64 -import androidx.core.content.edit -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.MasterKey -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.Application -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import java.io.IOException -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.PrivateKey -import java.security.PublicKey -import javax.crypto.SecretKey -import javax.crypto.SecretKeyFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import net.i2p.crypto.eddsa.EdDSAPrivateKey -import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable -import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Buffer -import net.schmizz.sshj.common.KeyType -import net.schmizz.sshj.userauth.keyprovider.KeyProvider - -private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" -private const val KEYSTORE_ALIAS = "sshkey" -private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" - -private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) { - KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } -} - -private val KeyStore.sshPrivateKey - get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey - -private val KeyStore.sshPublicKey - get() = getCertificate(KEYSTORE_ALIAS)?.publicKey - -fun parseSshPublicKey(sshPublicKey: String): PublicKey? { - val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) - if (sshKeyParts.size < 2) - return null - return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() -} - -fun toSshPublicKey(publicKey: PublicKey): String { - val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData - val keyType = KeyType.fromKey(publicKey) - return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" -} - -object SshKey { - - val sshPublicKey - get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null - val canShowSshPublicKey - get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) - val exists - get() = type != null - val mustAuthenticate: Boolean - get() { - return runCatching { - if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) - return false - when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { - is PrivateKey -> { - val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) - return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired - } - is SecretKey -> { - val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) - (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired - } - else -> throw IllegalStateException("SSH key does not exist in Keystore") - } - }.getOrElse { error -> - // It is fine to swallow the exception here since it will reappear when the key is - // used for SSH authentication and can then be shown in the UI. - d(error) - false - } - } - - private val context: Context - get() = Application.instance.applicationContext - - private val privateKeyFile - get() = File(context.filesDir, ".ssh_key") - private val publicKeyFile - get() = File(context.filesDir, ".ssh_key.pub") - - private var type: Type? - get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) - set(value) = context.sharedPrefs.edit { - putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) - } - - private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) - else - false - } - - private enum class Type(val value: String) { - Imported("imported"), - KeystoreNative("keystore_native"), - KeystoreWrappedEd25519("keystore_wrapped_ed25519"), - - // Behaves like `Imported`, but allows to view the public key. - LegacyGenerated("legacy_generated"), - ; - - companion object { - - fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] - } - } - - enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { - Rsa(KeyProperties.KEY_ALGORITHM_RSA, { - setKeySize(3072) - setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) - setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - }), - Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { - setKeySize(256) - setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) - setDigests(KeyProperties.DIGEST_SHA256) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(isStrongBoxSupported) - } - }), - } - - private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) - // Remove Tink key set used by AndroidX's EncryptedFile. - context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { - clear() - } - if (privateKeyFile.isFile) { - privateKeyFile.delete() - } - if (publicKeyFile.isFile) { - publicKeyFile.delete() - } - context.getEncryptedGitPrefs().edit { - remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - } - type = null - } - - fun import(uri: Uri) { - // First check whether the content at uri is likely an SSH private key. - val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) - ?.use { cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) - } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - - // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. - if (fileSize > 100_000 || fileSize == 0) - throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) - - val sshKeyInputStream = context.contentResolver.openInputStream(uri) - ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.bufferedReader().readLines() - - // The file must have more than 2 lines, and the first and last line must have private key - // markers. - if (lines.size < 2 || - !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || - !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) - ) - throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) - - // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - - type = Type.Imported - } - - @Deprecated("To be used only in Migrations.kt") - fun useLegacyKey(isGenerated: Boolean) { - type = if (isGenerated) Type.LegacyGenerated else Type.Imported - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { - MasterKey.Builder(context, KEYSTORE_ALIAS).run { - setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - setRequestStrongBoxBacked(true) - setUserAuthenticationRequired(requireAuthentication, 15) - build() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { - EncryptedFile.Builder(context, - privateKeyFile, - getOrCreateWrappingMasterKey(requireAuthentication), - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { - setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) - build() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { - delete() - - val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) - // Generate the ed25519 key pair and encrypt the private key. - val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() - encryptedPrivateKeyFile.openFileOutput().use { os -> - os.write((keyPair.private as EdDSAPrivateKey).seed) - } - - // Write public key in SSH format to .ssh_key.pub. - publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - - type = Type.KeystoreWrappedEd25519 - } - - fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { - delete() - - // Generate Keystore-backed private key. - val parameterSpec = KeyGenParameterSpec.Builder( - KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN - ).run { - apply(algorithm.applyToSpec) - if (requireAuthentication) { - setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) - } else { - @Suppress("DEPRECATION") - setUserAuthenticationValidityDurationSeconds(30) - } - } - build() - } - val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { - initialize(parameterSpec) - generateKeyPair() - } - - // Write public key in SSH format to .ssh_key.pub. - publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - - type = Type.KeystoreNative - } - - fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) { - Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) - Type.KeystoreNative -> KeystoreNativeKeyProvider - Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider - null -> null - } - - private object KeystoreNativeKeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = runCatching { - androidKeystore.sshPublicKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) - } - - override fun getPrivate(): PrivateKey = runCatching { - androidKeystore.sshPrivateKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) - } - - override fun getType(): KeyType = KeyType.fromKey(public) - } - - private object KeystoreWrappedEd25519KeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = runCatching { - parseSshPublicKey(sshPublicKey!!)!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get the public key for wrapped ed25519 key", error) - } - - override fun getPrivate(): PrivateKey = runCatching { - // The current MasterKey API does not allow getting a reference to an existing one - // without specifying the KeySpec for a new one. However, the value for passed here - // for `requireAuthentication` is not used as the key already exists at this point. - val encryptedPrivateKeyFile = runBlocking { - getOrCreateWrappedPrivateKeyFile(false) - } - val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } - EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)) - }.getOrElse { error -> - e(error) - throw IOException("Failed to unwrap wrapped ed25519 key", error) - } - - override fun getType(): KeyType = KeyType.fromKey(public) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt deleted file mode 100644 index cd280db5..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import com.github.ajalt.timberkt.Timber -import com.github.ajalt.timberkt.d -import com.github.michaelbull.result.runCatching -import com.hierynomus.sshj.key.KeyAlgorithms -import com.hierynomus.sshj.transport.cipher.BlockCiphers -import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory -import com.hierynomus.sshj.transport.mac.Macs -import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile -import java.security.Security -import net.schmizz.keepalive.KeepAliveProvider -import net.schmizz.sshj.ConfigImpl -import net.schmizz.sshj.common.LoggerFactory -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.transport.compression.NoneCompression -import net.schmizz.sshj.transport.kex.Curve25519SHA256 -import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh -import net.schmizz.sshj.transport.kex.DHGexSHA256 -import net.schmizz.sshj.transport.kex.ECDHNistP -import net.schmizz.sshj.transport.random.JCERandom -import net.schmizz.sshj.transport.random.SingletonRandomFactory -import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile -import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.slf4j.Logger -import org.slf4j.Marker - - -fun setUpBouncyCastleForSshj() { - // Replace the Android BC provider with the Java BouncyCastle provider since the former does - // not include all the required algorithms. - // Note: This may affect crypto operations in other parts of the application. - val bcIndex = Security.getProviders().indexOfFirst { - it.name == BouncyCastleProvider.PROVIDER_NAME - } - if (bcIndex == -1) { - // No Android BC found, install Java BC at lowest priority. - Security.addProvider(BouncyCastleProvider()) - } else { - // Replace Android BC with Java BC, inserted at the same position. - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 - runCatching { Class.forName("sun.security.jca.Providers") } - Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) - } - d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } - // Prevent sshj from forwarding all cryptographic operations to BC. - SecurityUtils.setRegisterBouncyCastle(false) - SecurityUtils.setSecurityProvider(null) -} - -private abstract class AbstractLogger(private val name: String) : Logger { - - abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) - - override fun getName() = name - - override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled - override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled - override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled - override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled - override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled - - override fun trace(msg: String) = t(msg) - override fun trace(format: String, arg: Any?) = t(format, null, arg) - override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) - override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) - override fun trace(msg: String, t: Throwable?) = t(msg, t) - override fun trace(marker: Marker, msg: String) = trace(msg) - override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) - override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - trace(format, arg1, arg2) - - override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = - trace(format, *arguments) - - override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) - - override fun debug(msg: String) = d(msg) - override fun debug(format: String, arg: Any?) = d(format, null, arg) - override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) - override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) - override fun debug(msg: String, t: Throwable?) = d(msg, t) - override fun debug(marker: Marker, msg: String) = debug(msg) - override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) - override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - debug(format, arg1, arg2) - - override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = - debug(format, *arguments) - - override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) - - override fun info(msg: String) = i(msg) - override fun info(format: String, arg: Any?) = i(format, null, arg) - override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) - override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) - override fun info(msg: String, t: Throwable?) = i(msg, t) - override fun info(marker: Marker, msg: String) = info(msg) - override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) - override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - info(format, arg1, arg2) - - override fun info(marker: Marker?, format: String, vararg arguments: Any?) = - info(format, *arguments) - - override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) - - override fun warn(msg: String) = w(msg) - override fun warn(format: String, arg: Any?) = w(format, null, arg) - override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) - override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) - override fun warn(msg: String, t: Throwable?) = w(msg, t) - override fun warn(marker: Marker, msg: String) = warn(msg) - override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) - override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - warn(format, arg1, arg2) - - override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = - warn(format, *arguments) - - override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) - - override fun error(msg: String) = e(msg) - override fun error(format: String, arg: Any?) = e(format, null, arg) - override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) - override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) - override fun error(msg: String, t: Throwable?) = e(msg, t) - override fun error(marker: Marker, msg: String) = error(msg) - override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) - override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - error(format, arg1, arg2) - - override fun error(marker: Marker?, format: String, vararg arguments: Any?) = - error(format, *arguments) - - override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) -} - -object TimberLoggerFactory : LoggerFactory { - private class TimberLogger(name: String) : AbstractLogger(name) { - - // We defer the log level checks to Timber. - override fun isTraceEnabled() = true - override fun isDebugEnabled() = true - override fun isInfoEnabled() = true - override fun isWarnEnabled() = true - override fun isErrorEnabled() = true - - // Replace slf4j's "{}" format string style with standard Java's "%s". - // The supposedly redundant escape on the } is not redundant. - @Suppress("RegExpRedundantEscape") - private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") - - override fun t(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).v(t, message.fix(), *args) - } - - override fun d(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).d(t, message.fix(), *args) - } - - override fun i(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).i(t, message.fix(), *args) - } - - override fun w(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).w(t, message.fix(), *args) - } - - override fun e(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).e(t, message.fix(), *args) - } - } - - override fun getLogger(name: String): Logger { - return TimberLogger(name) - } - - override fun getLogger(clazz: Class<*>): Logger { - return TimberLogger(clazz.name) - } - -} - -class SshjConfig : ConfigImpl() { - - init { - loggerFactory = TimberLoggerFactory - keepAliveProvider = KeepAliveProvider.HEARTBEAT - version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" - - initKeyExchangeFactories() - initKeyAlgorithms() - initRandomFactory() - initFileKeyProviderFactories() - initCipherFactories() - initCompressionFactories() - initMACFactories() - } - - private fun initKeyExchangeFactories() { - keyExchangeFactories = listOf( - Curve25519SHA256.Factory(), - FactoryLibSsh(), - DHGexSHA256.Factory(), - // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get - // rsa-sha2-* key types to work with some servers (e.g. GitHub). - ExtInfoClientFactory(), - ) - } - - private fun initKeyAlgorithms() { - keyAlgorithms = listOf( - KeyAlgorithms.SSHRSACertV01(), - KeyAlgorithms.EdDSA25519(), - KeyAlgorithms.RSASHA512(), - KeyAlgorithms.RSASHA256(), - KeyAlgorithms.SSHRSA(), - KeyAlgorithms.ECDSASHANistp521(), - KeyAlgorithms.ECDSASHANistp384(), - KeyAlgorithms.ECDSASHANistp256(), - ).map { - OpenKeychainWrappedKeyAlgorithmFactory(it) - } - } - - private fun initRandomFactory() { - randomFactory = SingletonRandomFactory(JCERandom.Factory()) - } - - private fun initFileKeyProviderFactories() { - fileKeyProviderFactories = listOf( - OpenSSHKeyV1KeyFile.Factory(), - PKCS8KeyFile.Factory(), - PKCS5KeyFile.Factory(), - OpenSSHKeyFile.Factory(), - PuTTYKeyFile.Factory(), - ) - } - - - private fun initCipherFactories() { - cipherFactories = listOf( - BlockCiphers.AES256CTR(), - BlockCiphers.AES192CTR(), - BlockCiphers.AES128CTR(), - ) - } - - private fun initMACFactories() { - macFactories = listOf( - Macs.HMACSHA2512Etm(), - Macs.HMACSHA2256Etm(), - Macs.HMACSHA2512(), - Macs.HMACSHA2256(), - ) - } - - private fun initCompressionFactories() { - compressionFactories = listOf( - NoneCompression.Factory(), - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt deleted file mode 100644 index c36f27b3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import android.util.Base64 -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.w -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.operation.CredentialFinder -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.concurrent.TimeUnit -import kotlin.coroutines.Continuation -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Buffer.PlainBuffer -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.common.SSHRuntimeException -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.connection.channel.direct.Session -import net.schmizz.sshj.transport.verification.FingerprintVerifier -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import net.schmizz.sshj.userauth.method.AuthPassword -import net.schmizz.sshj.userauth.method.AuthPublickey -import net.schmizz.sshj.userauth.password.PasswordFinder -import net.schmizz.sshj.userauth.password.Resource -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.RemoteSession -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) -} - -abstract class InteractivePasswordFinder : PasswordFinder { - - private var isRetry = false - - abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) - - final override fun reqPassword(resource: Resource<*>?): CharArray { - val password = runBlocking(Dispatchers.Main) { - suspendCoroutine<String?> { cont -> - askForPassword(cont, isRetry) - } - } - isRetry = true - return password?.toCharArray() - ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - } - - final override fun shouldRetry(resource: Resource<*>?) = true -} - -class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() { - - private var currentSession: SshjSession? = null - - override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession { - return currentSession ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { - d { "New SSH connection created" } - currentSession = it - } - } - - fun close() { - currentSession?.close() - } -} - -private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { - if (!hostKeyFile.exists()) { - return HostKeyVerifier { _, _, key -> - val digest = runCatching { - SecurityUtils.getMessageDigest("SHA-256") - }.getOrElse { e -> - throw SSHRuntimeException(e) - } - digest.update(PlainBuffer().putPublicKey(key).compactData) - val digestData = digest.digest() - val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" - d { "Trusting host key on first use: $hostKeyEntry" } - hostKeyFile.writeText(hostKeyEntry) - true - } - } else { - val hostKeyEntry = hostKeyFile.readText() - d { "Pinned host key: $hostKeyEntry" } - return FingerprintVerifier.getInstance(hostKeyEntry) - } -} - -private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession { - - private lateinit var ssh: SSHClient - private var currentCommand: Session? = null - - private val uri = if (uri.host.contains('@')) { - // URIish's String constructor cannot handle '@' in the user part of the URI and the URL - // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus - // need to patch everything up ourselves. - d { "Before fixup: user=${uri.user}, host=${uri.host}" } - val userPlusHost = "${uri.user}@${uri.host}" - val realUser = userPlusHost.substringBeforeLast('@') - val realHost = userPlusHost.substringAfterLast('@') - uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } - } else { - uri - } - - fun connect(): SshjSession { - ssh = SSHClient(SshjConfig()) - ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) - ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) - if (!ssh.isConnected) - throw IOException() - val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) - when (authMethod) { - is SshAuthMethod.Password -> { - ssh.auth(username, passwordAuth) - } - is SshAuthMethod.SshKey -> { - val pubkeyAuth = 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 - } - - override fun exec(commandName: String?, timeout: Int): Process { - if (currentCommand != null) { - w { "Killing old command" } - disconnect() - } - val session = ssh.startSession() - currentCommand = session - return SshjProcess(session.exec(commandName), timeout.toLong()) - } - - /** - * Kills the current command if one is running and returns the session into a state where `exec` - * can be called. - * - * Note that this does *not* disconnect the session. Unfortunately, the function has to be - * called `disconnect` to override the corresponding abstract function in `RemoteSession`. - */ - override fun disconnect() { - currentCommand?.close() - currentCommand = null - } - - fun close() { - disconnect() - ssh.close() - } -} - -private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() { - - override fun waitFor(): Int { - command.join(timeout, TimeUnit.SECONDS) - command.close() - return exitValue() - } - - override fun destroy() = command.close() - - override fun getOutputStream(): OutputStream = command.outputStream - - override fun getErrorStream(): InputStream = command.errorStream - - override fun exitValue(): Int = command.exitStatus - - override fun getInputStream(): InputStream = command.inputStream -} |
