aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/com/zeapo/pwdstore/git
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/git')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt144
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt67
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt117
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt152
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt265
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt182
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt18
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt49
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt56
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt55
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt36
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt22
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt98
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt216
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt31
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt15
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt23
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt23
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt37
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt191
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt93
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt336
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt276
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt196
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
-}