From 774fda83ac276a635e3402034b1eedbd10be916f Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Tue, 9 Mar 2021 14:53:11 +0530 Subject: all: reformat with ktfmt Signed-off-by: Harsh Shandilya --- .../dev/msfjarvis/aps/util/git/ErrorMessages.kt | 71 ++- .../msfjarvis/aps/util/git/GitCommandExecutor.kt | 159 ++++--- .../java/dev/msfjarvis/aps/util/git/GitLogModel.kt | 52 ++- .../aps/util/git/operation/BreakOutOfDetached.kt | 69 +-- .../aps/util/git/operation/CloneOperation.kt | 5 +- .../aps/util/git/operation/CredentialFinder.kt | 133 +++--- .../aps/util/git/operation/GitOperation.kt | 293 +++++++------ .../aps/util/git/operation/PullOperation.kt | 41 +- .../aps/util/git/operation/PushOperation.kt | 5 +- .../util/git/operation/ResetToRemoteOperation.kt | 23 +- .../aps/util/git/operation/SyncOperation.kt | 27 +- .../util/git/sshj/ContinuationContainerActivity.kt | 27 +- .../aps/util/git/sshj/OpenKeychainKeyProvider.kt | 286 ++++++------ .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 104 ++--- .../java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt | 480 +++++++++++---------- .../dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt | 434 +++++++++---------- .../aps/util/git/sshj/SshjSessionFactory.kt | 235 +++++----- 17 files changed, 1210 insertions(+), 1234 deletions(-) (limited to 'app/src/main/java/dev/msfjarvis/aps/util/git') diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt index 44fd6d3f..04809fdd 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt @@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R import java.net.UnknownHostException /** - * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. + * 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!! + override val message = super.message!! - companion object { + companion object { - private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) - } + 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) { + /** 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_rebase_fail_error) - object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error) - } + object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error) + object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error) + } - /** - * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. - */ - sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + /** 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 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) - } + 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 + 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/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt index 87f1a8fd..4546aee1 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt @@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent import org.eclipse.jgit.transport.RemoteRefUpdate class GitCommandExecutor( - private val activity: FragmentActivity, - private val operation: GitOperation, + private val activity: FragmentActivity, + private val operation: GitOperation, ) { - suspend fun execute(): Result { - 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() - } - if (result.rebaseResult != null) { - if (!result.rebaseResult.status.isSuccessful) { - throw PullException.PullRebaseFailed - } - } else if (result.mergeResult != null) { - if (!result.mergeResult.mergeStatus.isSuccessful) { - throw PullException.PullMergeFailed - } - } - } - 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 -> { - } - } - } - } + suspend fun execute(): Result { + 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() } + if (result.rebaseResult != null) { + if (!result.rebaseResult.status.isSuccessful) { + throw PullException.PullRebaseFailed + } + } else if (result.mergeResult != null) { + if (!result.mergeResult.mergeStatus.isSuccessful) { + throw PullException.PullMergeFailed + } + } + } + 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) } - else -> { - withContext(Dispatchers.IO) { - command.call() - } + } + 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 -> {} } + } } - }.also { - snackbar.dismiss() + } + else -> { + withContext(Dispatchers.IO) { command.call() } + } } + } } + .also { snackbar.dismiss() } + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt index f8f2af1c..1ba65086 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt @@ -15,41 +15,37 @@ import org.eclipse.jgit.api.Git import org.eclipse.jgit.revwalk.RevCommit private fun commits(): Iterable { - 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() - } + 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. + * 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 by lazy(LazyThreadSafetyMode.NONE) { - commits().map { - GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) - }.toMutableList() - } - val size = cache.size + // 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 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) - } + 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/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt index 4b3f0154..196d6d48 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt @@ -13,44 +13,45 @@ import org.eclipse.jgit.lib.RepositoryState class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - private val merging = repository.repositoryState == RepositoryState.MERGING - private val resetCommands = arrayOf( - // 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), + private val merging = repository.repositoryState == RepositoryState.MERGING + private val resetCommands = + arrayOf( + // 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 val commands by lazy(LazyThreadSafetyMode.NONE) { - if (merging) { - // We need to run some non-command operations first - repository.writeMergeCommitMsg(null) - repository.writeMergeHeads(null) - arrayOf( - // reset hard back to our local HEAD - git.reset().setMode(ResetCommand.ResetType.HARD), - *resetCommands, - ) - } else { - arrayOf( - // abort the rebase - git.rebase().setOperation(RebaseCommand.Operation.ABORT), - *resetCommands, - ) - } + override val commands by lazy(LazyThreadSafetyMode.NONE) { + if (merging) { + // We need to run some non-command operations first + repository.writeMergeCommitMsg(null) + repository.writeMergeHeads(null) + arrayOf( + // reset hard back to our local HEAD + git.reset().setMode(ResetCommand.ResetType.HARD), + *resetCommands, + ) + } else { + arrayOf( + // abort the rebase + git.rebase().setOperation(RebaseCommand.Operation.ABORT), + *resetCommands, + ) } + } - override fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) { - 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 + override fun preExecute() = + if (!git.repository.repositoryState.isRebasing && !merging) { + 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 + true } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt index 3efc9088..a5a4f5d0 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt @@ -16,7 +16,8 @@ import org.eclipse.jgit.api.GitCommand */ class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) { - override val commands: Array> = arrayOf( - Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), + override val commands: Array> = + arrayOf( + Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt index 0833d935..40869cf2 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt @@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys import kotlin.coroutines.Continuation import kotlin.coroutines.resume -class CredentialFinder( - val callingActivity: FragmentActivity, - val authMode: AuthMode -) : InteractivePasswordFinder() { +class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() { - override fun askForPassword(cont: Continuation, 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) + override fun askForPassword(cont: Continuation, 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(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = dialogView.findViewById(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(R.id.git_auth_credential) - show() + @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) + val credentialLayout = dialogView.findViewById(R.id.git_auth_passphrase_layout) + val editCredential = dialogView.findViewById(R.id.git_auth_credential) + editCredential.setHint(hintRes) + val rememberCredential = dialogView.findViewById(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) } } - } else { - cont.resume(storedCredential) + cont.resume(credential) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } + setOnCancelListener { cont.resume(null) } + create() + } + .run { + requestInputFocusOnView(R.id.git_auth_credential) + show() } + } else { + cont.resume(storedCredential) } + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt index 128ca578..0d0dac9c 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt @@ -50,170 +50,167 @@ import org.eclipse.jgit.transport.URIish */ abstract class GitOperation(protected val callingActivity: FragmentActivity) { - abstract val commands: Array> - 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 + abstract val commands: Array> + 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 } - private fun getSshKey(make: Boolean) { - runCatching { - val intent = if (make) { - Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java) - } else { - Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java) - } - callingActivity.startActivity(intent) - }.onFailure { e -> - e(e) - } - } + override fun supports(vararg items: CredentialItem) = + items.all { it is CredentialItem.Username || it is CredentialItem.Password } - private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { - sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) - commands.filterIsInstance>().forEach { command -> - command.setTransportConfigCallback { transport: Transport -> - (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory - credentialsProvider?.let { transport.credentialsProvider = it } - } - command.setTimeout(CONNECT_TIMEOUT) - } + override fun reset(uri: URIish?) { + cachedPassword?.fill(0.toChar()) + cachedPassword = null } - - /** - * Executes the GitCommand in an async task. - */ - suspend fun execute(): Result { - if (!preExecute()) { - return Ok(Unit) + } + + private fun getSshKey(make: Boolean) { + runCatching { + val intent = + if (make) { + Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java) + } else { + Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java) } - val operationResult = GitCommandExecutor( - callingActivity, - this, - ).execute() - postExecute() - return operationResult + callingActivity.startActivity(intent) } - - 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() + .onFailure { e -> e(e) } + } + + private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { + sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) + commands.filterIsInstance>().forEach { command -> + command.setTransportConfigCallback { transport: Transport -> + (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory + credentialsProvider?.let { transport.credentialsProvider = it } + } + command.setTimeout(CONNECT_TIMEOUT) } + } - suspend fun executeAfterAuthentication(authMode: AuthMode): Result { - when (authMode) { - AuthMode.SshKey -> if (SshKey.exists) { - if (SshKey.mustAuthenticate) { - val result = withContext(Dispatchers.Main) { - suspendCoroutine { 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)) + /** Executes the GitCommand in an async task. */ + suspend fun execute(): Result { + 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 { + when (authMode) { + AuthMode.SshKey -> + if (SshKey.exists) { + if (SshKey.mustAuthenticate) { + val result = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { + if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it) + } } - } else { - onMissingSshKeyFile() - // This would correctly cancel the operation but won't surface a user-visible - // error, allowing users to make the SSH key selection. + } + 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() + } } - AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) - AuthMode.Password -> { - val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) - } - AuthMode.None -> { - } + } 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)) } - return execute() + 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 + /** Called before execution of the Git operation. Return false to cancel. */ + open fun preExecute() = true - private suspend fun postExecute() { - withContext(Dispatchers.IO) { - sshSessionFactory?.close() - } - } + private suspend fun postExecute() { + withContext(Dispatchers.IO) { sshSessionFactory?.close() } + } - companion object { + companion object { - /** - * Timeout in seconds before [TransportCommand] will abort a stalled IO operation. - */ - private const val CONNECT_TIMEOUT = 10 - } + /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */ + private const val CONNECT_TIMEOUT = 10 + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt index e517aac0..394b7cb4 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt @@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity import org.eclipse.jgit.api.GitCommand class PullOperation( - callingActivity: ContinuationContainerActivity, - rebase: Boolean, + callingActivity: ContinuationContainerActivity, + rebase: Boolean, ) : 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> = 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(rebase).setRemote("origin"), + /** + * 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> = + 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(rebase).setRemote("origin"), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt index de07087e..a9f168ad 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt @@ -9,7 +9,8 @@ import org.eclipse.jgit.api.GitCommand class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - override val commands: Array> = arrayOf( - git.push().setPushAll().setRemote("origin"), + override val commands: Array> = + arrayOf( + git.push().setPushAll().setRemote("origin"), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt index 80848602..dccd69b0 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt @@ -9,15 +9,18 @@ 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), + 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/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt index fc63ce0c..589c6305 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt @@ -7,20 +7,21 @@ package dev.msfjarvis.aps.util.git.operation import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity class SyncOperation( - callingActivity: ContinuationContainerActivity, - rebase: Boolean, + callingActivity: ContinuationContainerActivity, + rebase: Boolean, ) : 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(rebase).setRemote("origin"), - // Push it all back - git.push().setPushAll().setRemote("origin"), + 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(rebase).setRemote("origin"), + // Push it all back + git.push().setPushAll().setRemote("origin"), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt index 4085c0c5..523ff5b6 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt @@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.userauth.UserAuthException -/** - * Workaround for https://msfjarvis.dev/aps/issue/1164 - */ +/** Workaround for https://msfjarvis.dev/aps/issue/1164 */ open class ContinuationContainerActivity : AppCompatActivity { - constructor() : super() - constructor(@LayoutRes layoutRes: Int) : super(layoutRes) + constructor() : super() + constructor(@LayoutRes layoutRes: Int) : super(layoutRes) - var stashedCont: Continuation? = null + var stashedCont: Continuation? = 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)) - } + 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/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt index 23f780c3..acb7d8d7 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt @@ -38,162 +38,170 @@ 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 { +class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : + KeyProvider, Closeable { - companion object { + companion object { - suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { - withContext(Dispatchers.Main) { - OpenKeychainKeyProvider(activity) - }.prepareAndUse(block) - } + 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 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 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)) } - } - 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) + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") } - 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 - } + if (keyId == null) { + selectKey() } - - 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" } + 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 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(SshAuthenticationApi.EXTRA_ERROR) - val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") - when (error?.error) { - SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) - else -> ApiResponse.GeneralError(exception) - } - } - } - } - - private fun makePrivateKey() { - check(keyId != null && publicKey != null) - privateKey = object : OpenKeychainPrivateKey { - override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = - when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { - is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature - is ApiResponse.GeneralError -> throw signingResponse.exception - is ApiResponse.NoSuchKey -> throw signingResponse.exception - } - - override fun getAlgorithm() = publicKey!!.algorithm - override fun getParams() = (publicKey as? ECKey)?.params - } + 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 } - - override fun close() { - activity.lifecycleScope.launch { - withContext(Dispatchers.Main) { - activity.continueAfterUserInteraction.unregister() + } + + 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(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) } - sshServiceConnection.disconnect() + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = + object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + override fun getParams() = (publicKey as? ECKey)?.params + } + } + + override fun close() { + activity.lifecycleScope.launch { + withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() } } + sshServiceConnection.disconnect() + } - override fun getPrivate() = privateKey + override fun getPrivate() = privateKey - override fun getPublic() = publicKey + override fun getPublic() = publicKey - override fun getType(): KeyType = KeyType.fromKey(publicKey) + override fun getType(): KeyType = KeyType.fromKey(publicKey) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt index e64f9909..0ab01ba5 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm import java.io.ByteArrayOutputStream import java.security.PrivateKey import java.security.interfaces.ECKey -import java.security.interfaces.ECPrivateKey -import java.security.spec.ECParameterSpec import kotlinx.coroutines.runBlocking import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Factory @@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi interface OpenKeychainPrivateKey : PrivateKey, ECKey { - suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray - override fun getFormat() = null - override fun getEncoded() = null + override fun getFormat() = null + override fun getEncoded() = null } -class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : Factory.Named by factory { +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : + Factory.Named by factory { - override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) + 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 + 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) + override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) } -class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature { +class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : + Signature by wrappedSignature { - private val data = ByteArrayOutputStream() + private val data = ByteArrayOutputStream() - private var bridgedPrivateKey: OpenKeychainPrivateKey? = null + private var bridgedPrivateKey: OpenKeychainPrivateKey? = null - override fun initSign(prvkey: PrivateKey?) { - if (prvkey is OpenKeychainPrivateKey) { - bridgedPrivateKey = prvkey - } else { - wrappedSignature.initSign(prvkey) - } + 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?) { + 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 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) - } + override fun sign(): ByteArray? = + if (bridgedPrivateKey != null) { + runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) } } else { - wrappedSignature.sign() + 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() - } + 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) + wrappedSignature.encode(signature) } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt index 793bbd28..37414707 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt @@ -51,286 +51,288 @@ 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) } + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } } private val KeyStore.sshPrivateKey - get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey private val KeyStore.sshPublicKey - get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + 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() + 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)}" + 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 - } + 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") } - - 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) + } + .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 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"), - ; + private val context: Context + get() = Application.instance.applicationContext - companion object { + private val privateKeyFile + get() = File(context.filesDir, ".ssh_key") + private val publicKeyFile + get() = File(context.filesDir, ".ssh_key.pub") - 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 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 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 - } + 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 + } - 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)) + private enum class Type(val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), - // 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")) + // Behaves like `Imported`, but allows to view the public key. + LegacyGenerated("legacy_generated"), + ; - type = Type.Imported - } + companion object { - @Deprecated("To be used only in Migrations.kt") - fun useLegacyKey(isGenerated: Boolean) { - type = if (isGenerated) Type.LegacyGenerated else Type.Imported + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] } - - @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() + } + + 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() } - - @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() - } + if (publicKeyFile.isFile) { + publicKeyFile.delete() } - - @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 + 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() + } } - 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() + @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() } - - // 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 { + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + delete() - override fun getPublic(): PublicKey = runCatching { - androidKeystore.sshPublicKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) - } + 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) } - override fun getPrivate(): PrivateKey = runCatching { - androidKeystore.sshPrivateKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) - } + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - override fun getType(): KeyType = KeyType.fromKey(public) + type = Type.KeystoreWrappedEd25519 } - 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) + 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 + } - 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) + 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) - } + override fun getType(): KeyType = KeyType.fromKey(public) + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt index c3339676..e93787f4 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt @@ -33,250 +33,240 @@ 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) + // 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) + 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) - } - } + private class TimberLogger(name: String) : AbstractLogger(name) { - override fun getLogger(name: String): Logger { - return TimberLogger(name) - } - - override fun getLogger(clazz: Class<*>): Logger { - return TimberLogger(clazz.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") -class SshjConfig : ConfigImpl() { - - init { - loggerFactory = TimberLoggerFactory - keepAliveProvider = KeepAliveProvider.HEARTBEAT - version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" - - initKeyExchangeFactories() - initKeyAlgorithms() - initRandomFactory() - initFileKeyProviderFactories() - initCipherFactories() - initCompressionFactories() - initMACFactories() + override fun t(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).v(t, message.fix(), *args) } - private fun initKeyExchangeFactories() { - keyExchangeFactories = listOf( - Curve25519SHA256.Factory(), - FactoryLibSsh(), - ECDHNistP.Factory521(), - ECDHNistP.Factory384(), - ECDHNistP.Factory256(), - 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(), - ) + override fun d(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).d(t, message.fix(), *args) } - private fun initKeyAlgorithms() { - keyAlgorithms = listOf( - KeyAlgorithms.SSHRSACertV01(), - KeyAlgorithms.EdDSA25519(), - KeyAlgorithms.ECDSASHANistp521(), - KeyAlgorithms.ECDSASHANistp384(), - KeyAlgorithms.ECDSASHANistp256(), - KeyAlgorithms.RSASHA512(), - KeyAlgorithms.RSASHA256(), - KeyAlgorithms.SSHRSA(), - ).map { - OpenKeychainWrappedKeyAlgorithmFactory(it) - } + override fun i(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).i(t, message.fix(), *args) } - private fun initRandomFactory() { - randomFactory = SingletonRandomFactory(JCERandom.Factory()) + override fun w(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).w(t, message.fix(), *args) } - private fun initFileKeyProviderFactories() { - fileKeyProviderFactories = listOf( - OpenSSHKeyV1KeyFile.Factory(), - PKCS8KeyFile.Factory(), - PKCS5KeyFile.Factory(), - OpenSSHKeyFile.Factory(), - PuTTYKeyFile.Factory(), - ) + 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) + } - private fun initCipherFactories() { - cipherFactories = listOf( - GcmCiphers.AES128GCM(), - GcmCiphers.AES256GCM(), - BlockCiphers.AES256CTR(), - BlockCiphers.AES192CTR(), - BlockCiphers.AES128CTR(), - ) - } + override fun getLogger(clazz: Class<*>): Logger { + return TimberLogger(clazz.name) + } +} - private fun initMACFactories() { - macFactories = listOf( - Macs.HMACSHA2512Etm(), - Macs.HMACSHA2256Etm(), - Macs.HMACSHA2512(), - Macs.HMACSHA2256(), - ) - } +class SshjConfig : ConfigImpl() { - private fun initCompressionFactories() { - compressionFactories = listOf( - NoneCompression.Factory(), - ) - } + 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(), + ECDHNistP.Factory521(), + ECDHNistP.Factory384(), + ECDHNistP.Factory256(), + 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.ECDSASHANistp521(), + KeyAlgorithms.ECDSASHANistp384(), + KeyAlgorithms.ECDSASHANistp256(), + KeyAlgorithms.RSASHA512(), + KeyAlgorithms.RSASHA256(), + KeyAlgorithms.SSHRSA(), + ) + .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( + GcmCiphers.AES128GCM(), + GcmCiphers.AES256GCM(), + 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/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt index 1062fb0e..95676d7f 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt @@ -40,158 +40,155 @@ 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) + 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 + private var isRetry = false - abstract fun askForPassword(cont: Continuation, isRetry: Boolean) + abstract fun askForPassword(cont: Continuation, isRetry: Boolean) - final override fun reqPassword(resource: Resource<*>?): CharArray { - val password = runBlocking(Dispatchers.Main) { - suspendCoroutine { cont -> - askForPassword(cont, isRetry) - } - } - isRetry = true - return password?.toCharArray() - ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - } + final override fun reqPassword(resource: Resource<*>?): CharArray { + val password = runBlocking(Dispatchers.Main) { suspendCoroutine { cont -> askForPassword(cont, isRetry) } } + isRetry = true + return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } - final override fun shouldRetry(resource: Resource<*>?) = true + final override fun shouldRetry(resource: Resource<*>?) = true } class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() { - private var currentSession: SshjSession? = null + 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 - } - } + 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() - } + 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) + 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}" } } +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 + uri } - override fun exec(commandName: String?, timeout: Int): Process { - if (currentCommand != null) { - w { "Killing old command" } - disconnect() + 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) + } } - val session = ssh.startSession() - currentCommand = session - return SshjProcess(session.exec(commandName), timeout.toLong()) + } } + return this + } - /** - * 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() + 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 waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } - override fun destroy() = command.close() + override fun destroy() = command.close() - override fun getOutputStream(): OutputStream = command.outputStream + override fun getOutputStream(): OutputStream = command.outputStream - override fun getErrorStream(): InputStream = command.errorStream + override fun getErrorStream(): InputStream = command.errorStream - override fun exitValue(): Int = command.exitStatus + override fun exitValue(): Int = command.exitStatus - override fun getInputStream(): InputStream = command.inputStream + override fun getInputStream(): InputStream = command.inputStream } -- cgit v1.2.3