diff options
| author | Harsh Shandilya <me@msfjarvis.dev> | 2020-12-05 06:07:18 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-05 06:07:18 +0530 |
| commit | 5e66d99c852ea67a88b650c03b0e8d55e83eccde (patch) | |
| tree | aee323a08253fe7b863976eff92a1bd412bd1430 /app/src/main/java/dev/msfjarvis/aps/util | |
| parent | 8eb55f18a11d6b2f155c7c9d48ca833313ee13db (diff) | |
| download | HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.tar.gz HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.zip | |
Refactor package structure (#1233)
* idea: default test runner to Gradle
* Kick off package structure revamp
* Reparent all classes under dev.msfjarvis.aps
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util')
47 files changed, 5341 insertions, 0 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt new file mode 100644 index 00000000..4b987ebe --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt @@ -0,0 +1,78 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.auth + +import android.app.KeyguardManager +import androidx.annotation.StringRes +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.R + +object BiometricAuthenticator { + + private const val TAG = "BiometricAuthenticator" + private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + + sealed class Result { + data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() + data class Failure(val code: Int?, val message: CharSequence) : Result() + object HardwareUnavailableOrDisabled : Result() + object Cancelled : Result() + } + + fun canAuthenticate(activity: FragmentActivity): Boolean { + return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS + } + + fun authenticate( + activity: FragmentActivity, + @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, + callback: (Result) -> Unit + ) { + val authCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } + callback(when (errorCode) { + BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { + Result.Cancelled + } + BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { + Result.HardwareUnavailableOrDisabled + } + else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) + }) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error))) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(Result.Success(result.cryptoObject)) + } + } + val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true + if (canAuthenticate(activity) || deviceHasKeyguard) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(dialogTitleRes)) + .setAllowedAuthenticators(validAuthenticators) + .build() + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo) + } else { + callback(Result.HardwareUnavailableOrDisabled) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt new file mode 100644 index 00000000..2b0d56a3 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt @@ -0,0 +1,189 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.github.michaelbull.result.fold +import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity +import dev.msfjarvis.aps.ui.autofill.AutofillFilterView +import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity +import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity +import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity +import java.io.File + +/** + * Implements [AutofillResponseBuilder]'s methods for API 30 and above + */ +@RequiresApi(Build.VERSION_CODES.R) +class Api30AutofillResponseBuilder(form: FillableForm) { + + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makeIntentDataset( + context: Context, + action: AutofillAction, + intentSender: IntentSender, + metadata: DatasetMetadata, + imeSpec: InlinePresentationSpec?, + ): Dataset { + return Dataset.Builder(makeRemoteView(context, metadata)).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + if (imeSpec != null) { + val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) + if (inlinePresentation != null) { + setInlinePresentation(inlinePresentation) + } + } + build() + } + } + + private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + } + + private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null + val metadata = makeSearchAndFillMetadata(context) + val intentSender = + AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) + } + + private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null + val metadata = makeGenerateAndFillMetadata(context) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) + } + + + private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val metadata = makeFillOtpFromSmsMetadata(context) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + imeSpec: InlinePresentationSpec? + ): Dataset { + val metadata = makeWarningMetadata(context) + // If the user decides to trust the new publisher, they can choose reset the list of + // matches. In this case we need to immediately show a new `FillResponse` as if the app were + // autofilled for the first time. This `FillResponse` needs to be returned as a result from + // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. + val fillResponseAfterReset = makeFillResponse(context, null, emptyList()) + val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, publisherChangedException, fillResponseAfterReset + ) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + } + + private fun makePublisherChangedResponse( + context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? { + var datasetCount = 0 + val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + } + makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + /** + * Creates and returns a suitable [FillResponse] to the Autofill framework. + */ + fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) { + AutofillMatcher.getMatchesFor(context, formOrigin).fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) + }, + failure = { e -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) + } + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt new file mode 100644 index 00000000..52f74087 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt @@ -0,0 +1,193 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.content.SharedPreferences +import android.widget.Toast +import androidx.core.content.edit +import com.github.ajalt.timberkt.Timber.e +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash +import dev.msfjarvis.aps.R +import java.io.File + +private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" +private val Context.autofillAppMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) + +private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches" +private val Context.autofillWebMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE) + +private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences { + return when (formOrigin) { + is FormOrigin.App -> autofillAppMatches + is FormOrigin.Web -> autofillWebMatches + } +} + +class AutofillPublisherChangedException(val formOrigin: FormOrigin) : + Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") { + + init { + require(formOrigin is FormOrigin.App) + } +} + +/** + * Manages "matches", i.e., associations between apps or websites and Password Store entries. + */ +class AutofillMatcher { + + companion object { + + private const val MAX_NUM_MATCHES = 10 + + private const val PREFERENCE_PREFIX_TOKEN = "token;" + private fun tokenKey(formOrigin: FormOrigin.App) = + "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}" + + private const val PREFERENCE_PREFIX_MATCHES = "matches;" + private fun matchesKey(formOrigin: FormOrigin) = + "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}" + + private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { + return when (formOrigin) { + is FormOrigin.Web -> false + is FormOrigin.App -> { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + val storedCertificatesHash = + context.autofillAppMatches.getString(tokenKey(formOrigin), null) + ?: return false + val hashHasChanged = certificatesHash != storedCertificatesHash + if (hashHasChanged) { + e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } + true + } else { + false + } + } + } + } + + private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { + if (formOrigin is FormOrigin.App) { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + context.autofillAppMatches.edit { + putString(tokenKey(formOrigin), certificatesHash) + } + } + // We don't need to store a hash for FormOrigin.Web since it can only originate from + // browsers we trust to verify the origin. + } + + /** + * Get all Password Store entries that have already been associated with [formOrigin] by the + * user. + * + * If [formOrigin] represents an app and that app's certificates have changed since the + * first time the user associated an entry with it, an [AutofillPublisherChangedException] + * will be thrown. + */ + fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> { + if (hasFormOriginHashChanged(context, formOrigin)) { + return Err(AutofillPublisherChangedException(formOrigin)) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + return Ok(matchedFiles.filter { it.exists() }.also { validFiles -> + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) + } + }) + } + + fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { + context.matchPreferences(formOrigin).edit { + remove(matchesKey(formOrigin)) + if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) + } + } + + /** + * Associates the store entry [file] with [formOrigin], such that future Autofill responses + * to requests from this app or website offer this entry as a dataset. + * + * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of + * Android may crash when too many datasets are offered. + */ + fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { + if (!file.exists()) return + if (hasFormOriginHashChanged(context, formOrigin)) { + // This should never happen since we already verified the publisher in + // getMatchesFor. + e { "App publisher changed between getMatchesFor and addMatchFor" } + throw AutofillPublisherChangedException(formOrigin) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + val newFiles = setOf(file.absoluteFile).union(matchedFiles) + if (newFiles.size > MAX_NUM_MATCHES) { + Toast.makeText( + context, + context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), + Toast.LENGTH_LONG + ).show() + return + } + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) + } + storeFormOriginHash(context, formOrigin) + d { "Stored match for $formOrigin" } + } + + /** + * Goes through all existing matches and updates their associated entries by using + * [moveFromTo] as a lookup table and deleting the matches for files in [delete]. + */ + fun updateMatches(context: Context, moveFromTo: Map<File, File> = emptyMap(), delete: Collection<File> = emptyList()) { + val deletePathList = delete.map { it.absolutePath } + val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath } + .mapKeys { it.key.absolutePath } + for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { + for ((key, value) in prefs.all) { + if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue + // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were + // created with `putStringSet`. + @Suppress("UNCHECKED_CAST") + val oldMatches = value as? Set<String> + if (oldMatches == null) { + w { "Failed to read matches for $key" } + continue + } + // Delete all matches for file locations that are going to be overwritten, then + // transfer matches over to the files at their new locations. + val newMatches = + oldMatches.asSequence() + .minus(deletePathList) + .minus(oldNewPathMap.values) + .map { match -> + val newPath = oldNewPathMap[match] ?: return@map match + d { "Updating match for $key: $match --> $newPath" } + newPath + }.toSet() + if (newMatches != oldMatches) + prefs.edit { putStringSet(key, newMatches) } + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt new file mode 100644 index 00000000..aa70bacb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt @@ -0,0 +1,140 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.github.androidpasswordstore.autofillparser.Credentials +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.services.getDefaultUsername +import java.io.File +import java.nio.file.Paths + +enum class DirectoryStructure(val value: String) { + EncryptedUsername("encrypted_username"), + FileBased("file"), + DirectoryBased("directory"); + + /** + * Returns the username associated to [file], following the convention of the current + * [DirectoryStructure]. + * + * Examples: + * - * --> null (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) + * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) + * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) + */ + fun getUsernameFor(file: File): String? = when (this) { + EncryptedUsername -> null + FileBased -> file.nameWithoutExtension + DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension + } + + /** + * Returns the origin identifier associated to [file], following the convention of the current + * [DirectoryStructure]. + * + * At least one of [DirectoryStructure.getIdentifierFor] and + * [DirectoryStructure.getAccountPartFor] will always return a non-null result. + * + * Examples: + * - work/example.org.gpg --> example.org (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> example.org (FileBased) + * - example.org.gpg --> example.org (FileBased, fallback) + * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) + * - Temporary PIN.gpg --> null (DirectoryBased) + */ + fun getIdentifierFor(file: File): String? = when (this) { + EncryptedUsername -> file.nameWithoutExtension + FileBased -> file.parentFile?.name ?: file.nameWithoutExtension + DirectoryBased -> file.parentFile?.parent + } + + /** + * Returns the path components of [file] until right before the component that contains the + * origin identifier according to the current [DirectoryStructure]. + * + * Examples: + * - work/example.org.gpg --> work (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> work (FileBased) + * - example.org/john@doe.org.gpg --> null (FileBased) + * - john@doe.org.gpg --> null (FileBased) + * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) + * - example.org/john@doe.org/password.gpg --> null (DirectoryBased) + */ + fun getPathToIdentifierFor(file: File): String? = when (this) { + EncryptedUsername -> file.parent + FileBased -> file.parentFile?.parent + DirectoryBased -> file.parentFile?.parentFile?.parent + } + + /** + * Returns the path component of [file] following the origin identifier according to the current + * [DirectoryStructure] (without file extension). + * + * At least one of [DirectoryStructure.getIdentifierFor] and + * [DirectoryStructure.getAccountPartFor] will always return a non-null result. + * + * Examples: + * - * --> null (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) + * - example.org.gpg --> null (FileBased, fallback) + * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) + * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) + */ + fun getAccountPartFor(file: File): String? = when (this) { + EncryptedUsername -> null + FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } + DirectoryBased -> file.parentFile?.let { parentFile -> + "${parentFile.name}/${file.nameWithoutExtension}" + } ?: file.nameWithoutExtension + } + + @RequiresApi(Build.VERSION_CODES.O) + fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { + EncryptedUsername -> "/" + FileBased -> sanitizedIdentifier + DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() + } + + fun getSaveFileName(username: String?, identifier: String) = when (this) { + EncryptedUsername -> identifier + FileBased -> username + DirectoryBased -> "password" + } + + companion object { + + const val PREFERENCE = "oreo_autofill_directory_structure" + private val DEFAULT = FileBased + + private val reverseMap = values().associateBy { it.value } + fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT + } +} + +object AutofillPreferences { + + fun directoryStructure(context: Context): DirectoryStructure { + val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null) + return DirectoryStructure.fromValue(value) + } + + fun credentialsFromStoreEntry( + context: Context, + file: File, + entry: PasswordEntry, + directoryStructure: DirectoryStructure + ): Credentials { + // Always give priority to a username stored in the encrypted extras + val username = entry.username + ?: directoryStructure.getUsernameFor(file) + ?: context.getDefaultUsername() + return Credentials(username, entry.password, entry.calculateTotpCode()) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt new file mode 100644 index 00000000..eecfc81b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt @@ -0,0 +1,205 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.github.michaelbull.result.fold +import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity +import dev.msfjarvis.aps.ui.autofill.AutofillFilterView +import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity +import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity +import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillResponseBuilder(form: FillableForm) { + + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makeIntentDataset( + context: Context, + action: AutofillAction, + intentSender: IntentSender, + metadata: DatasetMetadata, + ): Dataset { + return Dataset.Builder(makeRemoteView(context, metadata)).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + build() + } + } + + private fun makeMatchDataset(context: Context, file: File): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) + } + + + private fun makeSearchDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null + val metadata = makeSearchAndFillMetadata(context) + val intentSender = + AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) + } + + private fun makeGenerateDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null + val metadata = makeGenerateAndFillMetadata(context) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) + } + + private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val metadata = makeFillOtpFromSmsMetadata(context) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + ): Dataset { + val metadata = makeWarningMetadata(context) + // If the user decides to trust the new publisher, they can choose reset the list of + // matches. In this case we need to immediately show a new `FillResponse` as if the app were + // autofilled for the first time. This `FillResponse` needs to be returned as a result from + // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. + val fillResponseAfterReset = makeFillResponse(context, emptyList()) + val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, publisherChangedException, fillResponseAfterReset + ) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) + } + + private fun makePublisherChangedResponse( + context: Context, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? { + var datasetCount = 0 + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file)?.let { + datasetCount++ + addDataset(it) + } + } + makeSearchDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeGenerateDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context)?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) + } + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + /** + * Creates and returns a suitable [FillResponse] to the Autofill framework. + */ + fun fillCredentials(context: Context, callback: FillCallback) { + AutofillMatcher.getMatchesFor(context, formOrigin).fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, matchedFiles)) + }, + failure = { e -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, e)) + } + ) + } + + companion object { + + fun makeFillInDataset( + context: Context, + credentials: Credentials, + clientState: Bundle, + action: AutofillAction + ): Dataset { + val scenario = AutofillScenario.fromClientState(clientState) + // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even + // though they are rarely shown. + // FIXME: We should clone the original dataset here and add the credentials to be filled + // in. Otherwise, the entry in the cached list of datasets will be overwritten by the + // fill-in dataset without any visual representation. This causes it to be missing from + // the Autofill suggestions shown after the user clears the filled out form fields. + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Dataset.Builder() + } else { + Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) + } + return builder.run { + if (scenario != null) fillWith(scenario, action, credentials) + else e { "Failed to recover scenario from client state" } + build() + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt new file mode 100644 index 00000000..6055c837 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt @@ -0,0 +1,113 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.InlinePresentation +import android.view.View +import android.widget.RemoteViews +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.DrawableRes +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.io.File + +data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) + +fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { + return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { + setTextViewText(R.id.title, metadata.title) + if (metadata.subtitle != null) { + setTextViewText(R.id.summary, metadata.subtitle) + } else { + setViewVisibility(R.id.summary, View.GONE) + } + if (metadata.iconRes != Resources.ID_NULL) { + setImageViewResource(R.id.icon, metadata.iconRes) + } else { + setViewVisibility(R.id.icon, View.GONE) + } + } +} + +@SuppressLint("RestrictedApi") +fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + return null + + if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) + return null + + val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0) + val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { + setTitle(metadata.title) + if (metadata.subtitle != null) + setSubtitle(metadata.subtitle) + setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title) + setStartIcon(Icon.createWithResource(context, metadata.iconRes)) + build().slice + } + + return InlinePresentation(slice, imeSpec, false) +} + + +fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { + val directoryStructure = AutofillPreferences.directoryStructure(context) + val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) + val title = directoryStructure.getIdentifierFor(relativeFile) + ?: directoryStructure.getAccountPartFor(relativeFile)!! + val subtitle = directoryStructure.getAccountPartFor(relativeFile) + return DatasetMetadata( + title, + subtitle, + R.drawable.ic_person_black_24dp + ) +} + +fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_search_in_store), + null, + R.drawable.ic_search_black_24dp +) + +fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_generate_password), + null, + R.drawable.ic_autofill_new_password +) + +fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_fill_otp_from_sms), + null, + R.drawable.ic_autofill_sms +) + +fun makeEmptyMetadata() = DatasetMetadata( + "PLACEHOLDER", + "PLACEHOLDER", + R.mipmap.ic_launcher +) + +fun makeWarningMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), + context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), + R.drawable.ic_warning_red_24dp +) + +fun makeHeaderMetadata(title: String) = DatasetMetadata( + title, + null, + 0 +) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt new file mode 100644 index 00000000..308c5966 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt @@ -0,0 +1,179 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.extensions + +import android.app.KeyguardManager +import android.content.ClipboardManager +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.util.Base64 +import android.util.TypedValue +import android.view.View +import android.view.autofill.AutofillManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.IdRes +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.github.ajalt.timberkt.d +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.git.operation.GitOperation + +/** + * Extension function for [AlertDialog] that requests focus for the + * view whose id is [id]. Solution based on a StackOverflow + * answer: https://stackoverflow.com/a/13056259/297261 + */ +fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) { + setOnShowListener { + findViewById<T>(id)?.apply { + setOnFocusChangeListener { v, _ -> + v.post { + context.getSystemService<InputMethodManager>() + ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) + } + } + requestFocus() + } + } +} + +/** + * Get an instance of [AutofillManager]. Only + * available on Android Oreo and above + */ +val Context.autofillManager: AutofillManager? + @RequiresApi(Build.VERSION_CODES.O) + get() = getSystemService() + +/** + * Get an instance of [ClipboardManager] + */ +val Context.clipboard + get() = getSystemService<ClipboardManager>() + +/** + * Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at + * each call site + */ +fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") + +/** + * Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP + * proxy. + */ +fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy") + +/** + * Get an instance of [EncryptedSharedPreferences] with the given [fileName] + */ +private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { + val masterKeyAlias = MasterKey.Builder(applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + applicationContext, + fileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} + +/** + * Get an instance of [KeyguardManager] + */ +val Context.keyguardManager: KeyguardManager + get() = getSystemService()!! + +/** + * Get the default [SharedPreferences] instance + */ +val Context.sharedPrefs: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(applicationContext) + + +/** + * Resolve [attr] from the [Context]'s theme + */ +fun Context.resolveAttribute(attr: Int): Int { + val typedValue = TypedValue() + this.theme.resolveAttribute(attr, typedValue, true) + return typedValue.data +} + +/** + * Commit changes to the store from a [FragmentActivity] using + * a custom implementation of [GitOperation] + */ +suspend fun FragmentActivity.commitChange( + message: String, +): Result<Unit, Throwable> { + if (!PasswordRepository.isGitRepo()) { + return Ok(Unit) + } + return object : GitOperation(this@commitChange) { + override val commands = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If anything changed, that is. + git.commit().setAll(true).setMessage(message), + ) + + override fun preExecute(): Boolean { + d { "Committing with message: '$message'" } + return true + } + }.execute() +} + +/** + * Check if [permission] has been granted to the app. + */ +fun FragmentActivity.isPermissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} + +/** + * Show a [Snackbar] in a [FragmentActivity] and correctly + * anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton] + * if one exists in the [view] + */ +fun FragmentActivity.snackbar( + view: View = findViewById(android.R.id.content), + message: String, + length: Int = Snackbar.LENGTH_SHORT, +): Snackbar { + val snackbar = Snackbar.make(view, message, length) + snackbar.anchorView = findViewById(R.id.fab) + snackbar.show() + return snackbar +} + +/** + * Simplifies the common `getString(key, null) ?: defaultValue` case slightly + */ +fun SharedPreferences.getString(key: String): String? = getString(key, null) + +/** + * Convert this [String] to its [Base64] representation + */ +fun String.base64(): String { + return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt new file mode 100644 index 00000000..dbfc0f63 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt @@ -0,0 +1,90 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.extensions + +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.io.File +import java.util.Date +import org.eclipse.jgit.lib.ObjectId +import org.eclipse.jgit.revwalk.RevCommit + +/** + * The default OpenPGP provider for the app + */ +const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" + +/** + * Clears the given [flag] from the value of this [Int] + */ +fun Int.clearFlag(flag: Int): Int { + return this and flag.inv() +} + +/** + * Checks if this [Int] contains the given [flag] + */ +infix fun Int.hasFlag(flag: Int): Boolean { + return this and flag == flag +} + +/** + * Checks whether this [File] is a directory that contains [other]. + */ +fun File.contains(other: File): Boolean { + if (!isDirectory) + return false + if (!other.exists()) + return false + val relativePath = runCatching { + other.relativeTo(this) + }.getOrElse { + return false + } + // Direct containment is equivalent to the relative path being equal to the filename. + return relativePath.path == other.name +} + +/** + * Checks if this [File] is in the password repository directory as given + * by [PasswordRepository.getRepositoryDirectory] + */ +fun File.isInsideRepository(): Boolean { + return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) +} + +/** + * Recursively lists the files in this [File], skipping any directories it encounters. + */ +fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() + +/** + * Unique SHA-1 hash of this commit as hexadecimal string. + * + * @see RevCommit.getId + */ +val RevCommit.hash: String + get() = ObjectId.toString(id) + +/** + * Time this commit was made with second precision. + * + * @see RevCommit.commitTime + */ +val RevCommit.time: Date + get() { + val epochSeconds = commitTime.toLong() + val epochMilliseconds = epochSeconds * 1000 + return Date(epochMilliseconds) + } + +/** + * Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending + * and stripped of any empty lines. + */ +fun String.splitLines(): Array<String> { + return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt new file mode 100644 index 00000000..642e0f22 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt @@ -0,0 +1,36 @@ +package dev.msfjarvis.aps.util.extensions + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import dev.msfjarvis.aps.R + +/** + * Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. + */ +fun Fragment.isPermissionGranted(permission: String): Boolean { + return requireActivity().isPermissionGranted(permission) +} + +/** + * Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] + */ +fun Fragment.finish() = requireActivity().finish() + +/** + * Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment] + * to the fragment backstack + */ +fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) { + commit { + beginTransaction() + addToBackStack(destinationFragment.tag) + setCustomAnimations( + R.animator.slide_in_left, + R.animator.slide_out_left, + R.animator.slide_in_right, + R.animator.slide_out_right) + replace(containerViewId, destinationFragment) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt new file mode 100644 index 00000000..03342d31 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt @@ -0,0 +1,65 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.extensions + + +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * Imported from https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c + */ +class FragmentViewBindingDelegate<T : ViewBinding>( + val fragment: Fragment, + val viewBindingFactory: (View) -> T +) : ReadOnlyProperty<Fragment, T> { + + private var binding: T? = null + + init { + fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + }) + } + } + }) + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } + + val lifecycle = fragment.viewLifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") + } + + return viewBindingFactory(thisRef.requireView()).also { this.binding = it } + } +} + +fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) = + FragmentViewBindingDelegate(this, viewBindingFactory) + +inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) = + lazy(LazyThreadSafetyMode.NONE) { + bindingInflater.invoke(layoutInflater) + } 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 new file mode 100644 index 00000000..44eb11e1 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt @@ -0,0 +1,67 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import android.os.RemoteException +import androidx.annotation.StringRes +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.R +import java.net.UnknownHostException + +/** + * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. + */ +sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) { + + override val message = super.message!! + + companion object { + + private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) + } + + /** + * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. + */ + sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + + object PullRebaseFailed : PullException(R.string.git_pull_fail_error) + } + + /** + * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. + */ + sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + + object NonFastForward : PushException(R.string.git_push_nff_error) + object RemoteRejected : PushException(R.string.git_push_other_error) + class Generic(message: String) : PushException(R.string.git_push_generic_error, message) + } +} + +object ErrorMessages { + + operator fun get(throwable: Throwable?): String { + val resources = Application.instance.resources + if (throwable == null) return resources.getString(R.string.git_unknown_error) + return when (val rootCause = rootCause(throwable)) { + is GitException -> rootCause.message + is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) + else -> throwable.message ?: resources.getString(R.string.git_unknown_error) + } + } + + private fun rootCause(throwable: Throwable): Throwable { + var cause = throwable + while (cause.cause != null) { + if (cause is GitException) break + val nextCause = cause.cause!! + if (nextCause is RemoteException) break + cause = nextCause + } + return cause + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt new file mode 100644 index 00000000..429ea2c5 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt @@ -0,0 +1,117 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.git.GitException.PullException +import dev.msfjarvis.aps.util.git.GitException.PushException +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.git.operation.GitOperation +import dev.msfjarvis.aps.util.extensions.snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.CommitCommand +import org.eclipse.jgit.api.PullCommand +import org.eclipse.jgit.api.PushCommand +import org.eclipse.jgit.api.RebaseResult +import org.eclipse.jgit.api.StatusCommand +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.transport.RemoteRefUpdate + +class GitCommandExecutor( + private val activity: FragmentActivity, + private val operation: GitOperation, +) { + + suspend fun execute(): Result<Unit, Throwable> { + val snackbar = activity.snackbar( + message = activity.resources.getString(R.string.git_operation_running), + length = Snackbar.LENGTH_INDEFINITE, + ) + // Count the number of uncommitted files + var nbChanges = 0 + return runCatching { + for (command in operation.commands) { + when (command) { + is StatusCommand -> { + val res = withContext(Dispatchers.IO) { + command.call() + } + nbChanges = res.uncommittedChanges.size + } + is CommitCommand -> { + // the previous status will eventually be used to avoid a commit + if (nbChanges > 0) { + withContext(Dispatchers.IO) { + val name = GitSettings.authorName.ifEmpty { "root" } + val email = GitSettings.authorEmail.ifEmpty { "localhost" } + val identity = PersonIdent(name, email) + command.setAuthor(identity).setCommitter(identity).call() + } + } + } + is PullCommand -> { + val result = withContext(Dispatchers.IO) { + command.call() + } + val rr = result.rebaseResult + if (rr.status == RebaseResult.Status.STOPPED) { + throw PullException.PullRebaseFailed + } + } + is PushCommand -> { + val results = withContext(Dispatchers.IO) { + command.call() + } + for (result in results) { + // Code imported (modified) from Gerrit PushOp, license Apache v2 + for (rru in result.remoteUpdates) { + when (rru.status) { + RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward + RemoteRefUpdate.Status.REJECTED_NODELETE, + RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, + RemoteRefUpdate.Status.NON_EXISTING, + RemoteRefUpdate.Status.NOT_ATTEMPTED, + -> throw PushException.Generic(rru.status.name) + RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { + throw if ("non-fast-forward" == rru.message) { + PushException.RemoteRejected + } else { + PushException.Generic(rru.message) + } + } + RemoteRefUpdate.Status.UP_TO_DATE -> { + withContext(Dispatchers.Main) { + Toast.makeText( + activity.applicationContext, + activity.applicationContext.getString(R.string.git_push_up_to_date), + Toast.LENGTH_SHORT + ).show() + } + } + else -> { + } + } + } + } + } + else -> { + withContext(Dispatchers.IO) { + command.call() + } + } + } + } + }.also { + snackbar.dismiss() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt new file mode 100644 index 00000000..6e8e1c0c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt @@ -0,0 +1,18 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import java.util.Date + +/** + * Basic information about a git commit. + * + * @property hash full-length hash of the commit object. + * @property shortMessage the commit's short message (i.e. title line). + * @property authorName name of the commit's author without email address. + * @property time time when the commit was created. + */ +data class GitCommit(val hash: String, val shortMessage: String, val authorName: String, val time: Date) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt new file mode 100644 index 00000000..f6bbd55d --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.extensions.hash +import dev.msfjarvis.aps.util.extensions.time +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.revwalk.RevCommit + +private fun commits(): Iterable<RevCommit> { + val repo = PasswordRepository.getRepository(null) + if (repo == null) { + e { "Could not access git repository" } + return listOf() + } + return runCatching { + Git(repo).log().call() + }.getOrElse { e -> + e(e) { "Failed to obtain git commits" } + listOf() + } +} + +/** + * Provides [GitCommit]s from a git-log of the password git repository. + * + * All commits are acquired on the first request to this object. + */ +class GitLogModel { + + // All commits are acquired here at once. Acquiring the commits in batches would not have been + // entirely sensible because the amount of computation required to obtain commit number n from + // the log includes the amount of computation required to obtain commit number n-1 from the log. + // This is because the commit graph is walked from HEAD to the last commit to obtain. + // Additionally, tests with 1000 commits in the log have not produced a significant delay in the + // user experience. + private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) { + commits().map { + GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) + }.toMutableList() + } + val size = cache.size + + fun get(index: Int): GitCommit? { + if (index >= size) e { "Cannot get git commit with index $index. There are only $size." } + return cache.getOrNull(index) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt new file mode 100644 index 00000000..1aff34de --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt @@ -0,0 +1,36 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.RebaseCommand + +class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands = arrayOf( + // abort the rebase + git.rebase().setOperation(RebaseCommand.Operation.ABORT), + // git checkout -b conflict-branch + git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), + // push the changes + git.push().setRemote("origin"), + // switch back to ${gitBranch} + git.checkout().setName(remoteBranch), + ) + + override fun preExecute() = if (!git.repository.repositoryState.isRebasing) { + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) + .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)) + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + }.show() + false + } else { + true + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt new file mode 100644 index 00000000..e1dd6760 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt @@ -0,0 +1,22 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand + +/** + * Creates a new clone operation + * + * @param uri URL to clone the repository from + * @param callingActivity the calling activity + */ +class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) { + + override val commands: Array<GitCommand<out Any>> = arrayOf( + Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt new file mode 100644 index 00000000..173b7a50 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt @@ -0,0 +1,98 @@ +package dev.msfjarvis.aps.util.git.operation + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import androidx.annotation.StringRes +import androidx.core.content.edit +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class CredentialFinder( + val callingActivity: FragmentActivity, + val authMode: AuthMode +) : InteractivePasswordFinder() { + + override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { + val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() + val credentialPref: String + @StringRes val messageRes: Int + @StringRes val hintRes: Int + @StringRes val rememberRes: Int + @StringRes val errorRes: Int + when (authMode) { + AuthMode.SshKey -> { + credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE + messageRes = R.string.passphrase_dialog_text + hintRes = R.string.ssh_keygen_passphrase + rememberRes = R.string.git_operation_remember_passphrase + errorRes = R.string.git_operation_wrong_passphrase + } + AuthMode.Password -> { + // Could be either an SSH or an HTTPS password + credentialPref = PreferenceKeys.HTTPS_PASSWORD + messageRes = R.string.password_dialog_text + hintRes = R.string.git_operation_hint_password + rememberRes = R.string.git_operation_remember_password + errorRes = R.string.git_operation_wrong_password + } + else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") + } + if (isRetry) + gitOperationPrefs.edit { remove(credentialPref) } + val storedCredential = gitOperationPrefs.getString(credentialPref, null) + if (storedCredential == null) { + val layoutInflater = LayoutInflater.from(callingActivity) + + @SuppressLint("InflateParams") + val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) + val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) + val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) + editCredential.setHint(hintRes) + val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) + rememberCredential.setText(rememberRes) + if (isRetry) { + credentialLayout.error = callingActivity.resources.getString(errorRes) + // Reset error when user starts entering a password + editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } + } + MaterialAlertDialogBuilder(callingActivity).run { + setTitle(R.string.passphrase_dialog_title) + setMessage(messageRes) + setView(dialogView) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val credential = editCredential.text.toString() + if (rememberCredential.isChecked) { + gitOperationPrefs.edit { + putString(credentialPref, credential) + } + } + cont.resume(credential) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> + cont.resume(null) + } + setOnCancelListener { + cont.resume(null) + } + create() + }.run { + requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential) + show() + } + } else { + cont.resume(storedCredential) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt new file mode 100644 index 00000000..44292fc6 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt @@ -0,0 +1,216 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.util.git.GitCommandExecutor +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import dev.msfjarvis.aps.util.git.sshj.SshAuthMethod +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.git.sshj.SshjSessionFactory +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.data.repo.PasswordRepository +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.userauth.password.PasswordFinder +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.errors.UnsupportedCredentialItem +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.URIish + +/** + * Creates a new git operation + * + * @param callingActivity the calling activity + */ +abstract class GitOperation(protected val callingActivity: FragmentActivity) { + + abstract val commands: Array<GitCommand<out Any>> + private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") + private var sshSessionFactory: SshjSessionFactory? = null + + protected val repository = PasswordRepository.getRepository(null)!! + protected val git = Git(repository) + protected val remoteBranch = GitSettings.branch + private val authActivity get() = callingActivity as ContinuationContainerActivity + + private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { + + private var cachedPassword: CharArray? = null + + override fun isInteractive() = true + + override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { + for (item in items) { + when (item) { + is CredentialItem.Username -> item.value = uri?.user + is CredentialItem.Password -> { + item.value = cachedPassword?.clone() + ?: passwordFinder.reqPassword(null).also { + cachedPassword = it.clone() + } + } + else -> UnsupportedCredentialItem(uri, item.javaClass.name) + } + } + return true + } + + override fun supports(vararg items: CredentialItem) = items.all { + it is CredentialItem.Username || it is CredentialItem.Password + } + + override fun reset(uri: URIish?) { + cachedPassword?.fill(0.toChar()) + cachedPassword = null + } + } + + private fun getSshKey(make: Boolean) { + runCatching { + // Ask the UserPreference to provide us with the ssh-key + val intent = Intent(callingActivity.applicationContext, UserPreference::class.java) + intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key") + callingActivity.startActivity(intent) + }.onFailure { e -> + e(e) + } + } + + private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { + sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) + commands.filterIsInstance<TransportCommand<*, *>>().forEach { command -> + command.setTransportConfigCallback { transport: Transport -> + (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory + credentialsProvider?.let { transport.credentialsProvider = it } + } + command.setTimeout(CONNECT_TIMEOUT) + } + } + + /** + * Executes the GitCommand in an async task. + */ + suspend fun execute(): Result<Unit, Throwable> { + if (!preExecute()) { + return Ok(Unit) + } + val operationResult = GitCommandExecutor( + callingActivity, + this, + ).execute() + postExecute() + return operationResult + } + + private fun onMissingSshKeyFile() { + MaterialAlertDialogBuilder(callingActivity) + .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) + .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) + .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> + getSshKey(false) + } + .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> + getSshKey(true) + } + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + // Finish the blank GitActivity so user doesn't have to press back + callingActivity.finish() + }.show() + } + + suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { + when (authMode) { + AuthMode.SshKey -> if (SshKey.exists) { + if (SshKey.mustAuthenticate) { + val result = withContext(Dispatchers.Main) { + suspendCoroutine<BiometricAuthenticator.Result> { cont -> + BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { + if (it !is BiometricAuthenticator.Result.Failure) + cont.resume(it) + } + } + } + when (result) { + is BiometricAuthenticator.Result.Success -> { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + is BiometricAuthenticator.Result.Cancelled -> { + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + is BiometricAuthenticator.Result.Failure -> { + throw IllegalStateException("Biometric authentication failures should be ignored") + } + else -> { + // There is a chance we succeed if the user recently confirmed + // their screen lock. Doing so would have a potential to confuse + // users though, who might deduce that the screen lock + // protection is not effective. Hence, we fail with an error. + Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show() + callingActivity.finish() + } + } + } else { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + } else { + onMissingSshKeyFile() + // This would correctly cancel the operation but won't surface a user-visible + // error, allowing users to make the SSH key selection. + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) + AuthMode.Password -> { + val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) + registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) + } + AuthMode.None -> { + } + } + return execute() + } + + /** + * Called before execution of the Git operation. + * Return false to cancel. + */ + open fun preExecute() = true + + private suspend fun postExecute() { + withContext(Dispatchers.IO) { + sshSessionFactory?.close() + } + } + + companion object { + + /** + * Timeout in seconds before [TransportCommand] will abort a stalled IO operation. + */ + private const val CONNECT_TIMEOUT = 10 + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt new file mode 100644 index 00000000..7bee775a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt @@ -0,0 +1,31 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +class PullOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + /** + * The story of why the pull operation is committing files goes like this: Once upon a time when + * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made + * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. + * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] + * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, + * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly + * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. + */ + override val commands: Array<GitCommand<out Any>> = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(true).setRemote("origin"), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt new file mode 100644 index 00000000..31e5fcb7 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt @@ -0,0 +1,15 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands: Array<GitCommand<out Any>> = arrayOf( + git.push().setPushAll().setRemote("origin"), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt new file mode 100644 index 00000000..9c1fb01a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.ResetCommand + +class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Fetch everything from the origin remote + git.fetch().setRemote("origin"), + // Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch + git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), + // Force-create $remoteBranch if it doesn't exist. This covers the case where you switched + // branches from 'master' to anything else. + git.branchCreate().setName(remoteBranch).setForce(true), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt new file mode 100644 index 00000000..512d6b48 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity + +class SyncOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(true).setRemote("origin"), + // Push it all back + git.push().setPushAll().setRemote("origin"), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt new file mode 100644 index 00000000..8bcdad05 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt @@ -0,0 +1,37 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.userauth.UserAuthException + +/** + * Workaround for https://msfjarvis.dev/aps/issue/1164 + */ +open class ContinuationContainerActivity : AppCompatActivity { + + constructor() : super() + constructor(@LayoutRes layoutRes: Int) : super(layoutRes) + + var stashedCont: Continuation<Intent>? = null + + val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + stashedCont?.let { cont -> + stashedCont = null + val data = result.data + if (data != null) + cont.resume(data) + else + cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + } +} 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 new file mode 100644 index 00000000..3b0b2549 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt @@ -0,0 +1,191 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.result.IntentSenderRequest +import androidx.core.content.edit +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.Closeable +import java.security.PublicKey +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.openintents.ssh.authentication.ISshAuthenticationService +import org.openintents.ssh.authentication.SshAuthenticationApi +import org.openintents.ssh.authentication.SshAuthenticationApiError +import org.openintents.ssh.authentication.SshAuthenticationConnection +import org.openintents.ssh.authentication.request.KeySelectionRequest +import org.openintents.ssh.authentication.request.Request +import org.openintents.ssh.authentication.request.SigningRequest +import org.openintents.ssh.authentication.request.SshPublicKeyRequest +import org.openintents.ssh.authentication.response.KeySelectionResponse +import org.openintents.ssh.authentication.response.Response +import org.openintents.ssh.authentication.response.SigningResponse +import org.openintents.ssh.authentication.response.SshPublicKeyResponse + +class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable { + + companion object { + + suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { + withContext(Dispatchers.Main) { + OpenKeychainKeyProvider(activity) + }.prepareAndUse(block) + } + } + + private sealed class ApiResponse { + data class Success(val response: Response) : ApiResponse() + data class GeneralError(val exception: Exception) : ApiResponse() + data class NoSuchKey(val exception: Exception) : ApiResponse() + } + + private val context = activity.applicationContext + private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) + private val preferences = context.sharedPrefs + private lateinit var sshServiceApi: SshAuthenticationApi + + private var keyId + get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) + set(value) { + preferences.edit { + putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) + } + } + private var publicKey: PublicKey? = null + private var privateKey: OpenKeychainPrivateKey? = null + + private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { + prepare() + use(block) + } + + private suspend fun prepare() { + sshServiceApi = suspendCoroutine { cont -> + sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { + override fun onBound(sshAgent: ISshAuthenticationService) { + d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) + } + + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") + } + }) + } + + if (keyId == null) { + selectKey() + } + check(keyId != null) + fetchPublicKey() + makePrivateKey() + } + + private suspend fun fetchPublicKey(isRetry: Boolean = false) { + when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { + is ApiResponse.Success -> { + val response = sshPublicKeyResponse.response as SshPublicKeyResponse + val sshPublicKey = response.sshPublicKey!! + publicKey = parseSshPublicKey(sshPublicKey) + ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") + } + is ApiResponse.NoSuchKey -> if (isRetry) { + throw sshPublicKeyResponse.exception + } else { + // Allow the user to reselect an authentication key and retry + selectKey() + fetchPublicKey(true) + } + is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception + } + } + + private suspend fun selectKey() { + when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { + is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId + is ApiResponse.GeneralError -> throw keySelectionResponse.exception + is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception + } + } + + private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { + d { "executeRequest($request) called" } + val result = withContext(Dispatchers.Main) { + // If the request required user interaction, the data returned from the PendingIntent + // is used as the real request. + sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! + } + return parseResult(request, result).also { + d { "executeRequest($request): $it" } + } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success(when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + }) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + activity.stashedCont = cont + activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) + } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR) + val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) + } + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + } + } + + override fun close() { + activity.continueAfterUserInteraction.unregister() + sshServiceConnection.disconnect() + } + + override fun getPrivate() = privateKey + + override fun getPublic() = publicKey + + override fun getType() = KeyType.fromKey(publicKey) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt new file mode 100644 index 00000000..272a04dd --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -0,0 +1,93 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import com.hierynomus.sshj.key.KeyAlgorithm +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.signature.Signature +import org.openintents.ssh.authentication.SshAuthenticationApi + +interface OpenKeychainPrivateKey : PrivateKey { + + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + + override fun getFormat() = null + override fun getEncoded() = null +} + +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory { + + override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) +} + +class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm { + + private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) { + "rsa-sha2-512" -> SshAuthenticationApi.SHA512 + "rsa-sha2-256" -> SshAuthenticationApi.SHA256 + "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 + // Other algorithms don't use this value, but it has to be valid. + else -> SshAuthenticationApi.SHA512 + } + + override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) +} + +class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature { + + private val data = ByteArrayOutputStream() + + private var bridgedPrivateKey: OpenKeychainPrivateKey? = null + + override fun initSign(prvkey: PrivateKey?) { + if (prvkey is OpenKeychainPrivateKey) { + bridgedPrivateKey = prvkey + } else { + wrappedSignature.initSign(prvkey) + } + } + + override fun update(H: ByteArray?) { + if (bridgedPrivateKey != null) { + data.write(H!!) + } else { + wrappedSignature.update(H) + } + } + + override fun update(H: ByteArray?, off: Int, len: Int) { + if (bridgedPrivateKey != null) { + data.write(H!!, off, len) + } else { + wrappedSignature.update(H, off, len) + } + } + + override fun sign(): ByteArray? = if (bridgedPrivateKey != null) { + runBlocking { + bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) + } + } else { + wrappedSignature.sign() + } + + override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) { + require(signature != null) { "OpenKeychain signature must not be null" } + val encodedSignature = Buffer.PlainBuffer(signature) + // We need to drop the algorithm name and extract the raw signature since SSHJ adds the name + // later. + encodedSignature.readString() + encodedSignature.readBytes().also { + bridgedPrivateKey = null + data.reset() + } + } else { + wrappedSignature.encode(signature) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt new file mode 100644 index 00000000..352465e0 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt @@ -0,0 +1,336 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.io.IOException +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider + +private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" +private const val KEYSTORE_ALIAS = "sshkey" +private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" + +private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) { + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } +} + +private val KeyStore.sshPrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + +private val KeyStore.sshPublicKey + get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + +fun parseSshPublicKey(sshPublicKey: String): PublicKey? { + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + if (sshKeyParts.size < 2) + return null + return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() +} + +fun toSshPublicKey(publicKey: PublicKey): String { + val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData + val keyType = KeyType.fromKey(publicKey) + return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" +} + +object SshKey { + + val sshPublicKey + get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null + val canShowSshPublicKey + get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) + val exists + get() = type != null + val mustAuthenticate: Boolean + get() { + return runCatching { + if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) + return false + when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { + is PrivateKey -> { + val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired + } + is SecretKey -> { + val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired + } + else -> throw IllegalStateException("SSH key does not exist in Keystore") + } + }.getOrElse { error -> + // It is fine to swallow the exception here since it will reappear when the key is + // used for SSH authentication and can then be shown in the UI. + d(error) + false + } + } + + private val context: Context + get() = Application.instance.applicationContext + + private val privateKeyFile + get() = File(context.filesDir, ".ssh_key") + private val publicKeyFile + get() = File(context.filesDir, ".ssh_key.pub") + + private var type: Type? + get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) + set(value) = context.sharedPrefs.edit { + putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) + } + + private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + else + false + } + + private enum class Type(val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), + + // Behaves like `Imported`, but allows to view the public key. + LegacyGenerated("legacy_generated"), + ; + + companion object { + + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] + } + } + + enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { + Rsa(KeyProperties.KEY_ALGORITHM_RSA, { + setKeySize(3072) + setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + }), + Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { + setKeySize(256) + setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(isStrongBoxSupported) + } + }), + } + + private fun delete() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) + // Remove Tink key set used by AndroidX's EncryptedFile. + context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { + clear() + } + if (privateKeyFile.isFile) { + privateKeyFile.delete() + } + if (publicKeyFile.isFile) { + publicKeyFile.delete() + } + context.getEncryptedGitPrefs().edit { + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + } + type = null + } + + fun import(uri: Uri) { + // First check whether the content at uri is likely an SSH private key. + val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + // Cursor returns only a single row. + cursor.moveToFirst() + cursor.getInt(0) + } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + + // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. + if (fileSize > 100_000 || fileSize == 0) + throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) + + val sshKeyInputStream = context.contentResolver.openInputStream(uri) + ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + val lines = sshKeyInputStream.bufferedReader().readLines() + + // The file must have more than 2 lines, and the first and last line must have private key + // markers. + if (lines.size < 2 || + !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || + !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + ) + throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) + + // At this point, we are reasonably confident that we have actually been provided a private + // key and delete the old key. + delete() + // Canonicalize line endings to '\n'. + privateKeyFile.writeText(lines.joinToString("\n")) + + type = Type.Imported + } + + @Deprecated("To be used only in Migrations.kt") + fun useLegacyKey(isGenerated: Boolean) { + type = if (isGenerated) Type.LegacyGenerated else Type.Imported + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + MasterKey.Builder(context, KEYSTORE_ALIAS).run { + setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + setRequestStrongBoxBacked(true) + setUserAuthenticationRequired(requireAuthentication, 15) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + EncryptedFile.Builder(context, + privateKeyFile, + getOrCreateWrappingMasterKey(requireAuthentication), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { + setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + delete() + + val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) + // Generate the ed25519 key pair and encrypt the private key. + val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() + encryptedPrivateKeyFile.openFileOutput().use { os -> + os.write((keyPair.private as EdDSAPrivateKey).seed) + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreWrappedEd25519 + } + + fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { + delete() + + // Generate Keystore-backed private key. + val parameterSpec = KeyGenParameterSpec.Builder( + KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN + ).run { + apply(algorithm.applyToSpec) + if (requireAuthentication) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) + } else { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds(30) + } + } + build() + } + val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreNative + } + + fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) { + Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) + Type.KeystoreNative -> KeystoreNativeKeyProvider + Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider + null -> null + } + + private object KeystoreNativeKeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = runCatching { + androidKeystore.sshPublicKey!! + }.getOrElse { error -> + e(error) + throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getPrivate(): PrivateKey = runCatching { + androidKeystore.sshPrivateKey!! + }.getOrElse { error -> + e(error) + throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } + + private object KeystoreWrappedEd25519KeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = runCatching { + parseSshPublicKey(sshPublicKey!!)!! + }.getOrElse { error -> + e(error) + throw IOException("Failed to get the public key for wrapped ed25519 key", error) + } + + override fun getPrivate(): PrivateKey = runCatching { + // The current MasterKey API does not allow getting a reference to an existing one + // without specifying the KeySpec for a new one. However, the value for passed here + // for `requireAuthentication` is not used as the key already exists at this point. + val encryptedPrivateKeyFile = runBlocking { + getOrCreateWrappedPrivateKeyFile(false) + } + val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)) + }.getOrElse { error -> + e(error) + throw IOException("Failed to unwrap wrapped ed25519 key", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt new file mode 100644 index 00000000..8402d232 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt @@ -0,0 +1,275 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import com.github.ajalt.timberkt.Timber +import com.github.ajalt.timberkt.d +import com.github.michaelbull.result.runCatching +import com.hierynomus.sshj.key.KeyAlgorithms +import com.hierynomus.sshj.transport.cipher.BlockCiphers +import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory +import com.hierynomus.sshj.transport.mac.Macs +import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import java.security.Security +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.sshj.ConfigImpl +import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.transport.compression.NoneCompression +import net.schmizz.sshj.transport.kex.Curve25519SHA256 +import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh +import net.schmizz.sshj.transport.kex.DHGexSHA256 +import net.schmizz.sshj.transport.random.JCERandom +import net.schmizz.sshj.transport.random.SingletonRandomFactory +import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile +import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.slf4j.Logger +import org.slf4j.Marker + + +fun setUpBouncyCastleForSshj() { + // Replace the Android BC provider with the Java BouncyCastle provider since the former does + // not include all the required algorithms. + // Note: This may affect crypto operations in other parts of the application. + val bcIndex = Security.getProviders().indexOfFirst { + it.name == BouncyCastleProvider.PROVIDER_NAME + } + if (bcIndex == -1) { + // No Android BC found, install Java BC at lowest priority. + Security.addProvider(BouncyCastleProvider()) + } else { + // Replace Android BC with Java BC, inserted at the same position. + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 + runCatching { Class.forName("sun.security.jca.Providers") } + Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) + } + d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } + // Prevent sshj from forwarding all cryptographic operations to BC. + SecurityUtils.setRegisterBouncyCastle(false) + SecurityUtils.setSecurityProvider(null) +} + +private abstract class AbstractLogger(private val name: String) : Logger { + + abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) + + override fun getName() = name + + override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled + override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled + override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled + override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled + override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled + + override fun trace(msg: String) = t(msg) + override fun trace(format: String, arg: Any?) = t(format, null, arg) + override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) + override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) + override fun trace(msg: String, t: Throwable?) = t(msg, t) + override fun trace(marker: Marker, msg: String) = trace(msg) + override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) + override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + trace(format, arg1, arg2) + + override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = + trace(format, *arguments) + + override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) + + override fun debug(msg: String) = d(msg) + override fun debug(format: String, arg: Any?) = d(format, null, arg) + override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) + override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) + override fun debug(msg: String, t: Throwable?) = d(msg, t) + override fun debug(marker: Marker, msg: String) = debug(msg) + override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) + override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + debug(format, arg1, arg2) + + override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = + debug(format, *arguments) + + override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) + + override fun info(msg: String) = i(msg) + override fun info(format: String, arg: Any?) = i(format, null, arg) + override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) + override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) + override fun info(msg: String, t: Throwable?) = i(msg, t) + override fun info(marker: Marker, msg: String) = info(msg) + override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) + override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + info(format, arg1, arg2) + + override fun info(marker: Marker?, format: String, vararg arguments: Any?) = + info(format, *arguments) + + override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) + + override fun warn(msg: String) = w(msg) + override fun warn(format: String, arg: Any?) = w(format, null, arg) + override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) + override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) + override fun warn(msg: String, t: Throwable?) = w(msg, t) + override fun warn(marker: Marker, msg: String) = warn(msg) + override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) + override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + warn(format, arg1, arg2) + + override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = + warn(format, *arguments) + + override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) + + override fun error(msg: String) = e(msg) + override fun error(format: String, arg: Any?) = e(format, null, arg) + override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) + override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) + override fun error(msg: String, t: Throwable?) = e(msg, t) + override fun error(marker: Marker, msg: String) = error(msg) + override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) + override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + error(format, arg1, arg2) + + override fun error(marker: Marker?, format: String, vararg arguments: Any?) = + error(format, *arguments) + + override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) +} + +object TimberLoggerFactory : LoggerFactory { + private class TimberLogger(name: String) : AbstractLogger(name) { + + // We defer the log level checks to Timber. + override fun isTraceEnabled() = true + override fun isDebugEnabled() = true + override fun isInfoEnabled() = true + override fun isWarnEnabled() = true + override fun isErrorEnabled() = true + + // Replace slf4j's "{}" format string style with standard Java's "%s". + // The supposedly redundant escape on the } is not redundant. + @Suppress("RegExpRedundantEscape") + private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") + + override fun t(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).v(t, message.fix(), *args) + } + + override fun d(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).d(t, message.fix(), *args) + } + + override fun i(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).i(t, message.fix(), *args) + } + + override fun w(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).w(t, message.fix(), *args) + } + + override fun e(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).e(t, message.fix(), *args) + } + } + + override fun getLogger(name: String): Logger { + return TimberLogger(name) + } + + override fun getLogger(clazz: Class<*>): Logger { + return TimberLogger(clazz.name) + } + +} + +class SshjConfig : ConfigImpl() { + + init { + loggerFactory = TimberLoggerFactory + keepAliveProvider = KeepAliveProvider.HEARTBEAT + version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" + + initKeyExchangeFactories() + initKeyAlgorithms() + initRandomFactory() + initFileKeyProviderFactories() + initCipherFactories() + initCompressionFactories() + initMACFactories() + } + + private fun initKeyExchangeFactories() { + keyExchangeFactories = listOf( + Curve25519SHA256.Factory(), + FactoryLibSsh(), + DHGexSHA256.Factory(), + // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get + // rsa-sha2-* key types to work with some servers (e.g. GitHub). + ExtInfoClientFactory(), + ) + } + + private fun initKeyAlgorithms() { + keyAlgorithms = listOf( + KeyAlgorithms.SSHRSACertV01(), + KeyAlgorithms.EdDSA25519(), + KeyAlgorithms.RSASHA512(), + KeyAlgorithms.RSASHA256(), + KeyAlgorithms.SSHRSA(), + KeyAlgorithms.ECDSASHANistp521(), + KeyAlgorithms.ECDSASHANistp384(), + KeyAlgorithms.ECDSASHANistp256(), + ).map { + OpenKeychainWrappedKeyAlgorithmFactory(it) + } + } + + private fun initRandomFactory() { + randomFactory = SingletonRandomFactory(JCERandom.Factory()) + } + + private fun initFileKeyProviderFactories() { + fileKeyProviderFactories = listOf( + OpenSSHKeyV1KeyFile.Factory(), + PKCS8KeyFile.Factory(), + PKCS5KeyFile.Factory(), + OpenSSHKeyFile.Factory(), + PuTTYKeyFile.Factory(), + ) + } + + + private fun initCipherFactories() { + cipherFactories = listOf( + BlockCiphers.AES256CTR(), + BlockCiphers.AES192CTR(), + BlockCiphers.AES128CTR(), + ) + } + + private fun initMACFactories() { + macFactories = listOf( + Macs.HMACSHA2512Etm(), + Macs.HMACSHA2256Etm(), + Macs.HMACSHA2512(), + Macs.HMACSHA2256(), + ) + } + + private fun initCompressionFactories() { + compressionFactories = listOf( + NoneCompression.Factory(), + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt new file mode 100644 index 00000000..7cd39653 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt @@ -0,0 +1,197 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.util.Base64 +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.git.operation.CredentialFinder +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer.PlainBuffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.common.SSHRuntimeException +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.FingerprintVerifier +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.method.AuthPassword +import net.schmizz.sshj.userauth.method.AuthPublickey +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.RemoteSession +import org.eclipse.jgit.transport.SshSessionFactory +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS + +sealed class SshAuthMethod(val activity: ContinuationContainerActivity) { + class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity) + class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity) + class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity) +} + +abstract class InteractivePasswordFinder : PasswordFinder { + + private var isRetry = false + + abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) + + final override fun reqPassword(resource: Resource<*>?): CharArray { + val password = runBlocking(Dispatchers.Main) { + suspendCoroutine<String?> { cont -> + askForPassword(cont, isRetry) + } + } + isRetry = true + return password?.toCharArray() + ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } + + final override fun shouldRetry(resource: Resource<*>?) = true +} + +class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() { + + private var currentSession: SshjSession? = null + + override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession { + return currentSession + ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { + d { "New SSH connection created" } + currentSession = it + } + } + + fun close() { + currentSession?.close() + } +} + +private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { + if (!hostKeyFile.exists()) { + return HostKeyVerifier { _, _, key -> + val digest = runCatching { + SecurityUtils.getMessageDigest("SHA-256") + }.getOrElse { e -> + throw SSHRuntimeException(e) + } + digest.update(PlainBuffer().putPublicKey(key).compactData) + val digestData = digest.digest() + val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" + d { "Trusting host key on first use: $hostKeyEntry" } + hostKeyFile.writeText(hostKeyEntry) + true + } + } else { + val hostKeyEntry = hostKeyFile.readText() + d { "Pinned host key: $hostKeyEntry" } + return FingerprintVerifier.getInstance(hostKeyEntry) + } +} + +private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession { + + private lateinit var ssh: SSHClient + private var currentCommand: Session? = null + + private val uri = if (uri.host.contains('@')) { + // URIish's String constructor cannot handle '@' in the user part of the URI and the URL + // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus + // need to patch everything up ourselves. + d { "Before fixup: user=${uri.user}, host=${uri.host}" } + val userPlusHost = "${uri.user}@${uri.host}" + val realUser = userPlusHost.substringBeforeLast('@') + val realHost = userPlusHost.substringAfterLast('@') + uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } + } else { + uri + } + + fun connect(): SshjSession { + ssh = SSHClient(SshjConfig()) + ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) + ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) + if (!ssh.isConnected) + throw IOException() + val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) + when (authMethod) { + is SshAuthMethod.Password -> { + ssh.auth(username, passwordAuth) + } + is SshAuthMethod.SshKey -> { + val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) + ssh.auth(username, pubkeyAuth, passwordAuth) + } + is SshAuthMethod.OpenKeychain -> { + runBlocking { + OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider -> + val openKeychainAuth = AuthPublickey(provider) + ssh.auth(username, openKeychainAuth, passwordAuth) + } + } + } + } + return this + } + + override fun exec(commandName: String?, timeout: Int): Process { + if (currentCommand != null) { + w { "Killing old command" } + disconnect() + } + val session = ssh.startSession() + currentCommand = session + return SshjProcess(session.exec(commandName), timeout.toLong()) + } + + /** + * Kills the current command if one is running and returns the session into a state where `exec` + * can be called. + * + * Note that this does *not* disconnect the session. Unfortunately, the function has to be + * called `disconnect` to override the corresponding abstract function in `RemoteSession`. + */ + override fun disconnect() { + currentCommand?.close() + currentCommand = null + } + + fun close() { + disconnect() + ssh.close() + } +} + +private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() { + + override fun waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } + + override fun destroy() = command.close() + + override fun getOutputStream(): OutputStream = command.outputStream + + override fun getErrorStream(): InputStream = command.errorStream + + override fun exitValue(): Int = command.exitStatus + + override fun getInputStream(): InputStream = command.inputStream +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt new file mode 100644 index 00000000..f3dae627 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.proxy + +import dev.msfjarvis.aps.util.settings.GitSettings +import java.io.IOException +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI + +/** + * Utility class for [Proxy] handling. + */ +object ProxyUtils { + + private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" + private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" + + /** + * Set the default [Proxy] and [Authenticator] for the app based on user provided settings. + */ + fun setDefaultProxy() { + ProxySelector.setDefault(object : ProxySelector() { + override fun select(uri: URI?): MutableList<Proxy> { + val host = GitSettings.proxyHost + val port = GitSettings.proxyPort + return if (host == null || port == -1) { + mutableListOf(Proxy.NO_PROXY) + } else { + mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + } + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { + if (uri == null || sa == null || ioe == null) { + throw IllegalArgumentException("Arguments can't be null.") + } + } + }) + val user = GitSettings.proxyUsername ?: "" + val password = GitSettings.proxyPassword ?: "" + if (user.isEmpty() || password.isEmpty()) { + System.clearProperty(HTTP_PROXY_USER_PROPERTY) + System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) + } else { + System.setProperty(HTTP_PROXY_USER_PROPERTY, user) + System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) + } + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + return if (requestorType == RequestorType.PROXY) { + PasswordAuthentication(user, password.toCharArray()) + } else { + null + } + } + }) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt new file mode 100644 index 00000000..ae26c7e3 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt @@ -0,0 +1,139 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import android.content.Context +import androidx.core.content.edit +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clearFlag +import dev.msfjarvis.aps.util.extensions.hasFlag + +enum class PasswordOption(val key: String) { + NoDigits("0"), + NoUppercaseLetters("A"), + NoAmbiguousCharacters("B"), + FullyRandom("s"), + AtLeastOneSymbol("y"), + NoLowercaseLetters("L") +} + +object PasswordGenerator { + + const val DEFAULT_LENGTH = 16 + + const val DIGITS = 0x0001 + const val UPPERS = 0x0002 + const val SYMBOLS = 0x0004 + const val NO_AMBIGUOUS = 0x0008 + const val LOWERS = 0x0020 + + const val DIGITS_STR = "0123456789" + const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" + const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" + + /** + * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for + * generated passwords. + */ + fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { + ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { + for (possibleOption in PasswordOption.values()) + putBoolean(possibleOption.key, possibleOption in options) + putInt("length", targetLength) + } + return true + } + + fun isValidPassword(password: String, pwFlags: Int): Boolean { + if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) + return false + if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) + return false + if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) + return false + if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) + return false + if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) + return false + return true + } + + /** + * Generates a password using the preferences set by [setPrefs]. + */ + @Throws(PasswordGeneratorException::class) + fun generate(ctx: Context): String { + val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + var numCharacterCategories = 0 + + var phonemes = true + var pwgenFlags = DIGITS or UPPERS or LOWERS + + for (option in PasswordOption.values()) { + if (prefs.getBoolean(option.key, false)) { + when (option) { + PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) + PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) + PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) + PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS + PasswordOption.FullyRandom -> phonemes = false + PasswordOption.AtLeastOneSymbol -> { + numCharacterCategories++ + pwgenFlags = pwgenFlags or SYMBOLS + } + } + } else { + // The No* options are false, so the respective character category will be included. + when (option) { + PasswordOption.NoDigits, + PasswordOption.NoUppercaseLetters, + PasswordOption.NoLowercaseLetters -> { + numCharacterCategories++ + } + PasswordOption.NoAmbiguousCharacters, + PasswordOption.FullyRandom, + // Since AtLeastOneSymbol is not negated, it is counted in the if branch. + PasswordOption.AtLeastOneSymbol -> { + } + } + } + } + + val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) + if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error)) + } + if (length < numCharacterCategories) { + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error)) + } + if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { + phonemes = false + pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) + } + // Experiments show that phonemes may require more than 1000 iterations to generate a valid + // password if the length is not at least 6. + if (length < 6) { + phonemes = false + } + + var password: String? + var iterations = 0 + do { + if (iterations++ > 1000) + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded)) + password = if (phonemes) { + RandomPhonemesGenerator.generate(length, pwgenFlags) + } else { + RandomPasswordGenerator.generate(length, pwgenFlags) + } + } while (password == null) + return password + } + + class PasswordGeneratorException(string: String) : Exception(string) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt new file mode 100644 index 00000000..aae8d987 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt @@ -0,0 +1,33 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import java.security.SecureRandom + +private val secureRandom = SecureRandom() + +/** + * Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive). + */ +fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound) + +/** + * Returns `true` and `false` with probablity 50% each. + */ +fun secureRandomBoolean() = secureRandom.nextBoolean() + +/** + * Returns `true` with probability [percentTrue]% and `false` with probability + * `(100 - [percentTrue])`%. + */ +fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { + require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } + require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } + return secureRandomNumber(100) < percentTrue +} + +fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)] +fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)] +fun String.secureRandomCharacter() = this[secureRandomNumber(length)] diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt new file mode 100644 index 00000000..f6341087 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt @@ -0,0 +1,45 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import dev.msfjarvis.aps.util.extensions.hasFlag + +object RandomPasswordGenerator { + + /** + * Generates a random password of length [targetLength], taking the following flags in [pwFlags] + * into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not + * set, the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase + * letter; if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase + * letter; if not set, the password will not contain any lowercase letters. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + val bank = listOfNotNull( + PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, + PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, + PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, + PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, + ).joinToString("") + + var password = "" + while (password.length < targetLength) { + val candidate = bank.secureRandomCharacter() + if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + candidate in PasswordGenerator.AMBIGUOUS_STR) { + continue + } + password += candidate + } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt new file mode 100644 index 00000000..408974d5 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt @@ -0,0 +1,169 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import dev.msfjarvis.aps.util.extensions.hasFlag +import java.util.Locale + +object RandomPhonemesGenerator { + + private const val CONSONANT = 0x0001 + private const val VOWEL = 0x0002 + private const val DIPHTHONG = 0x0004 + private const val NOT_FIRST = 0x0008 + + private val elements = arrayOf( + Element("a", VOWEL), + Element("ae", VOWEL or DIPHTHONG), + Element("ah", VOWEL or DIPHTHONG), + Element("ai", VOWEL or DIPHTHONG), + Element("b", CONSONANT), + Element("c", CONSONANT), + Element("ch", CONSONANT or DIPHTHONG), + Element("d", CONSONANT), + Element("e", VOWEL), + Element("ee", VOWEL or DIPHTHONG), + Element("ei", VOWEL or DIPHTHONG), + Element("f", CONSONANT), + Element("g", CONSONANT), + Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("h", CONSONANT), + Element("i", VOWEL), + Element("ie", VOWEL or DIPHTHONG), + Element("j", CONSONANT), + Element("k", CONSONANT), + Element("l", CONSONANT), + Element("m", CONSONANT), + Element("n", CONSONANT), + Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("o", VOWEL), + Element("oh", VOWEL or DIPHTHONG), + Element("oo", VOWEL or DIPHTHONG), + Element("p", CONSONANT), + Element("ph", CONSONANT or DIPHTHONG), + Element("qu", CONSONANT or DIPHTHONG), + Element("r", CONSONANT), + Element("s", CONSONANT), + Element("sh", CONSONANT or DIPHTHONG), + Element("t", CONSONANT), + Element("th", CONSONANT or DIPHTHONG), + Element("u", VOWEL), + Element("v", CONSONANT), + Element("w", CONSONANT), + Element("x", CONSONANT), + Element("y", CONSONANT), + Element("z", CONSONANT) + ) + + private class Element(str: String, val flags: Int) { + + val upperCase = str.toUpperCase(Locale.ROOT) + val lowerCase = str.toLowerCase(Locale.ROOT) + val length = str.length + val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } + } + + /** + * Generates a random human-readable password of length [targetLength], taking the following + * flags in [pwFlags] into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not + * set, the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase + * letter; if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase + * letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any + * lowercase characters; if both are not set, an exception is thrown. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) + + var password = "" + + var isStartOfPart = true + var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + var previousFlags = 0 + + while (password.length < targetLength) { + // First part: Add a single letter or pronounceable pair of letters in varying case. + + val candidate = elements.secureRandomElement() + + // Reroll if the candidate does not fulfill the current requirements. + if (!candidate.flags.hasFlag(nextBasicType) || + (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || + // Don't let a diphthong that starts with a vowel follow a vowel. + (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || + // Don't add multi-character candidates if we would go over the targetLength. + (password.length + candidate.length > targetLength) || + (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) { + continue + } + + // At this point the candidate could be appended to the password, but we still have + // to determine the case. If no upper case characters are required, we don't add + // any. + val useUpperIfBothCasesAllowed = + (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) + password += if (pwFlags hasFlag PasswordGenerator.UPPERS && + (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) { + candidate.upperCase + } else { + candidate.lowerCase + } + + // We ensured above that we will not go above the target length. + check(password.length <= targetLength) + if (password.length == targetLength) + break + + // Second part: Add digits and symbols with a certain probability (if requested) if + // they would not directly follow the first character in a pronounceable part. + + if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && + secureRandomBiasedBoolean(30)) { + var randomDigit: Char + do { + randomDigit = secureRandomNumber(10).toString(10).first() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + randomDigit in PasswordGenerator.AMBIGUOUS_STR) + + password += randomDigit + // Begin a new pronounceable part after every digit. + isStartOfPart = true + nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + previousFlags = 0 + continue + } + + if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && + secureRandomBiasedBoolean(20)) { + var randomSymbol: Char + do { + randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + randomSymbol in PasswordGenerator.AMBIGUOUS_STR) + password += randomSymbol + // Continue the password generation as if nothing was added. + } + + // Third part: Determine the basic type of the next character depending on the letter + // we just added. + nextBasicType = when { + candidate.flags.hasFlag(CONSONANT) -> VOWEL + previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || + secureRandomBiasedBoolean(60) -> CONSONANT + else -> VOWEL + } + previousFlags = candidate.flags + isStartOfPart = false + } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt new file mode 100644 index 00000000..83274171 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt @@ -0,0 +1,9 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgenxkpwd + +enum class CapsType { + lowercase, UPPERCASE, TitleCase, Sentence, As_iS +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt new file mode 100644 index 00000000..4bd1a6e6 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt @@ -0,0 +1,142 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgenxkpwd + +import android.content.Context +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.PasswordGeneratorException +import dev.msfjarvis.aps.util.pwgen.secureRandomCharacter +import dev.msfjarvis.aps.util.pwgen.secureRandomElement +import dev.msfjarvis.aps.util.pwgen.secureRandomNumber +import java.util.Locale + +class PasswordBuilder(ctx: Context) { + + private var numSymbols = 0 + private var isAppendSymbolsSeparator = false + private var context = ctx + private var numWords = 3 + private var maxWordLength = 9 + private var minWordLength = 5 + private var separator = "." + private var capsType = CapsType.Sentence + private var prependDigits = 0 + private var numDigits = 0 + private var isPrependWithSeparator = false + private var isAppendNumberSeparator = false + + fun setNumberOfWords(amount: Int) = apply { + numWords = amount + } + + fun setMinimumWordLength(min: Int) = apply { + minWordLength = min + } + + fun setMaximumWordLength(max: Int) = apply { + maxWordLength = max + } + + fun setSeparator(separator: String) = apply { + this.separator = separator + } + + fun setCapitalization(capitalizationScheme: CapsType) = apply { + capsType = capitalizationScheme + } + + @JvmOverloads + fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { + prependDigits = numDigits + isPrependWithSeparator = addSeparator + } + + @JvmOverloads + fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply { + this.numDigits = numDigits + isAppendNumberSeparator = addSeparator + } + + @JvmOverloads + fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply { + this.numSymbols = numSymbols + isAppendSymbolsSeparator = addSeparator + } + + private fun generateRandomNumberSequence(totalNumbers: Int): String { + val numbers = StringBuilder(totalNumbers) + for (i in 0 until totalNumbers) { + numbers.append(secureRandomNumber(10)) + } + return numbers.toString() + } + + private fun generateRandomSymbolSequence(numSymbols: Int): String { + val numbers = StringBuilder(numSymbols) + for (i in 0 until numSymbols) { + numbers.append(SYMBOLS.secureRandomCharacter()) + } + return numbers.toString() + } + + @OptIn(ExperimentalStdlibApi::class) + fun create(): Result<String, Throwable> { + val wordBank = mutableListOf<String>() + val password = StringBuilder() + + if (prependDigits != 0) { + password.append(generateRandomNumberSequence(prependDigits)) + if (isPrependWithSeparator) { + password.append(separator) + } + } + return runCatching { + val dictionary = XkpwdDictionary(context) + val words = dictionary.words + for (wordLength in minWordLength..maxWordLength) { + wordBank.addAll(words[wordLength] ?: emptyList()) + } + + if (wordBank.size == 0) { + throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)) + } + + for (i in 0 until numWords) { + val candidate = wordBank.secureRandomElement() + val s = when (capsType) { + CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault()) + CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate + CapsType.TitleCase -> candidate.capitalize(Locale.getDefault()) + CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault()) + CapsType.As_iS -> candidate + } + password.append(s) + if (i + 1 < numWords) { + password.append(separator) + } + } + if (numDigits != 0) { + if (isAppendNumberSeparator) { + password.append(separator) + } + password.append(generateRandomNumberSequence(numDigits)) + } + if (numSymbols != 0) { + if (isAppendSymbolsSeparator) { + password.append(separator) + } + password.append(generateRandomSymbolSequence(numSymbols)) + } + password.toString() + } + } + + companion object { + + private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt new file mode 100644 index 00000000..95e65bcb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt @@ -0,0 +1,40 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgenxkpwd + +import android.content.Context +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File + +class XkpwdDictionary(context: Context) { + + val words: Map<Int, List<String>> + + init { + val prefs = context.sharedPrefs + val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" + val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) + + val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && + uri.isNotEmpty() && customDictFile.canRead()) { + customDictFile.readLines() + } else { + context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() + } + + words = lines.asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.contains(' ') } + .groupBy { it.length } + } + + companion object { + + const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt new file mode 100644 index 00000000..46363420 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt @@ -0,0 +1,183 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ClipData +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clipboard +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ClipboardService : Service() { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_CLEAR -> { + clearClipboard() + stopForeground(true) + stopSelf() + return super.onStartCommand(intent, flags, startId) + } + + ACTION_START -> { + val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) + + if (time == 0) { + stopSelf() + } + + createNotification(time) + scope.launch { + withContext(Dispatchers.IO) { + startTimer(time) + } + withContext(Dispatchers.Main) { + clearClipboard() + stopForeground(true) + stopSelf() + } + } + return START_NOT_STICKY + } + } + } + + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun clearClipboard() { + val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) + val clipboard = clipboard + + if (clipboard != null) { + scope.launch { + d { "Clearing the clipboard" } + val clip = ClipData.newPlainText("pgp_handler_result_pm", "") + clipboard.setPrimaryClip(clip) + if (deepClear) { + withContext(Dispatchers.IO) { + repeat(20) { + val count = (it * 500).toString() + clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) + } + } + } + } + } else { + d { "Cannot get clipboard manager service" } + } + } + + private suspend fun startTimer(showTime: Int) { + var current = 0 + while (scope.isActive && current < showTime) { + // Block for 1s or until cancel is signalled + current++ + delay(1000) + } + } + + private fun createNotification(clearTime: Int) { + val clearTimeMs = clearTime * 1000L + val clearIntent = Intent(this, ClipboardService::class.java).apply { + action = ACTION_CLEAR + } + val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } else { + PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } + val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + createNotificationApi23(pendingIntent) + } else { + createNotificationApi24(pendingIntent, clearTimeMs) + } + + createNotificationChannel() + startForeground(1, notification) + } + + private fun createNotificationApi23(pendingIntent: PendingIntent): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.tap_clear_clipboard)) + .setSmallIcon(R.drawable.ic_action_secure_24dp) + .setContentIntent(pendingIntent) + .setUsesChronometer(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.tap_clear_clipboard)) + .setSmallIcon(R.drawable.ic_action_secure_24dp) + .setContentIntent(pendingIntent) + .setUsesChronometer(true) + .setChronometerCountDown(true) + .setShowWhen(true) + .setWhen(System.currentTimeMillis() + clearTimeMs) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService<NotificationManager>() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + d { "Failed to create notification channel" } + } + } + } + + companion object { + + const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER" + const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME" + private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" + private const val CHANNEL_ID = "NotificationService" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt new file mode 100644 index 00000000..3b431525 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt @@ -0,0 +1,145 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.services + +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.FixedSaveCallback +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList +import com.github.androidpasswordstore.autofillparser.passwordValue +import com.github.androidpasswordstore.autofillparser.recoverNodes +import com.github.androidpasswordstore.autofillparser.usernameValue +import dev.msfjarvis.aps.BuildConfig +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity +import dev.msfjarvis.aps.util.autofill.Api30AutofillResponseBuilder +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.hasFlag +import dev.msfjarvis.aps.util.extensions.sharedPrefs + +@RequiresApi(Build.VERSION_CODES.O) +class OreoAutofillService : AutofillService() { + + companion object { + + // TODO: Provide a user-configurable denylist + private val DENYLISTED_PACKAGES = listOf( + BuildConfig.APPLICATION_ID, + "android", + "com.android.settings", + "com.android.settings.intelligence", + "com.android.systemui", + "com.oneplus.applocker", + "org.sufficientlysecure.keychain", + ) + + private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L + } + + override fun onCreate() { + super.onCreate() + cachePublicSuffixList(applicationContext) + } + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + val structure = request.fillContexts.lastOrNull()?.structure ?: run { + callback.onSuccess(null) + return + } + if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess(FillResponse.Builder().run { + disableAutofill(DISABLE_AUTOFILL_DURATION_MS) + build() + }) + } else { + callback.onSuccess(null) + } + return + } + val formToFill = FillableForm.parseAssistStructure( + this, structure, + isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, + getCustomSuffixes(), + ) ?: run { + d { "Form cannot be filled" } + callback.onSuccess(null) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback) + } else { + AutofillResponseBuilder(formToFill).fillCredentials(this, callback) + } + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + // SaveCallback's behavior and feature set differs based on both target and device SDK, so + // we replace it with a wrapper that works the same in all situations. + @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback) + val structure = request.fillContexts.lastOrNull()?.structure ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported)) + return + } + val clientState = request.clientState ?: run { + e { "Received save request without client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure) + ?: run { + e { "Failed to recover client state or nodes from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val formOrigin = FormOrigin.fromBundle(clientState) ?: run { + e { "Failed to recover form origin from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + + val username = scenario.usernameValue + val password = scenario.passwordValue ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match)) + return + } + callback.onSuccess( + AutofillSaveActivity.makeSaveIntentSender( + this, + credentials = Credentials(username, password, null), + formOrigin = formOrigin + ) + ) + } +} + +fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + +fun Context.getCustomSuffixes(): Sequence<String> { + return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + ?.splitToSequence('\n') + ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } + ?: emptySequence() +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt new file mode 100644 index 00000000..9164aa46 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt @@ -0,0 +1,158 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.documentfile.provider.DocumentFile +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.TimeZone + +class PasswordExportService : Service() { + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_EXPORT_PASSWORD -> { + val uri = intent.getParcelableExtra<Uri>("uri") + if (uri != null) { + val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) + + if (targetDirectory != null) { + createNotification() + exportPasswords(targetDirectory) + stopSelf() + return START_NOT_STICKY + } + } + } + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + /** + * Exports passwords to the given directory. + * + * Recursively copies the existing password store to an external directory. + * + * @param targetDirectory directory to copy password directory to. + */ + private fun exportPasswords(targetDirectory: DocumentFile) { + + val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory()) + val sourcePassDir = DocumentFile.fromFile(repositoryDirectory) + + d { "Copying ${repositoryDirectory.path} to $targetDirectory" } + + val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + LocalDateTime + .now() + .format(DateTimeFormatter.ISO_DATE_TIME) + } else { + String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z"))) + } + + val passDir = targetDirectory.createDirectory("password_store_$dateString") + + if (passDir != null) { + copyDirToDir(sourcePassDir, passDir) + } + } + + /** + * Copies a password file to a given directory. + * + * Note: this does not preserve last modified time. + * + * @param passwordFile password file to copy. + * @param targetDirectory target directory to copy password. + */ + private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) { + val sourceInputStream = contentResolver.openInputStream(passwordFile.uri) + val name = passwordFile.name + val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!) + if (targetPasswordFile?.exists() == true) { + val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri) + + if (destOutputStream != null && sourceInputStream != null) { + sourceInputStream.copyTo(destOutputStream, 1024) + + sourceInputStream.close() + destOutputStream.close() + } + } + } + + /** + * Recursively copies a directory to a destination. + * + * @param sourceDirectory directory to copy from. + * @param targetDirectory directory to copy to. + */ + private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) { + sourceDirectory.listFiles().forEach { file -> + if (file.isDirectory) { + // Create new directory and recurse + val newDir = targetDirectory.createDirectory(file.name!!) + copyDirToDir(file, newDir!!) + } else { + copyFileToDir(file, targetDirectory) + } + } + } + + private fun createNotification() { + createNotificationChannel() + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.exporting_passwords)) + .setSmallIcon(R.drawable.ic_round_import_export) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + startForeground(2, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService<NotificationManager>() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + d { "Failed to create notification channel" } + } + } + } + + companion object { + + const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD" + private const val CHANNEL_ID = "NotificationService" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt new file mode 100644 index 00000000..864cbf81 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt @@ -0,0 +1,181 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.settings + +import androidx.core.content.edit +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.getEncryptedProxyPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import org.eclipse.jgit.transport.URIish + +enum class Protocol(val pref: String) { + Ssh("ssh://"), + Https("https://"), + ; + + companion object { + + private val map = values().associateBy(Protocol::pref) + fun fromString(type: String?): Protocol { + return map[type ?: return Ssh] + ?: throw IllegalArgumentException("$type is not a valid Protocol") + } + } +} + +enum class AuthMode(val pref: String) { + SshKey("ssh-key"), + Password("username/password"), + OpenKeychain("OpenKeychain"), + None("None"), + ; + + companion object { + + private val map = values().associateBy(AuthMode::pref) + fun fromString(type: String?): AuthMode { + return map[type ?: return SshKey] + ?: throw IllegalArgumentException("$type is not a valid AuthMode") + } + } +} + +object GitSettings { + + private const val DEFAULT_BRANCH = "master" + + private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } + private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } + private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } + + var authMode + get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) + private set(value) { + settings.edit { + putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) + } + } + var url + get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) + private set(value) { + require(value != null) + if (value == url) + return + settings.edit { + putString(PreferenceKeys.GIT_REMOTE_URL, value) + } + if (PasswordRepository.isInitialized) + PasswordRepository.addRemote("origin", value, true) + // When the server changes, remote password, multiplexing support and host key file + // should be deleted/reset. + useMultiplexing = true + encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } + File("${Application.instance.filesDir}/.host_key").delete() + } + var authorName + get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" + set(value) { + settings.edit { + putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) + } + } + var authorEmail + get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" + set(value) { + settings.edit { + putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) + } + } + var branch + get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH + private set(value) { + settings.edit { + putString(PreferenceKeys.GIT_BRANCH_NAME, value) + } + } + var useMultiplexing + get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) + set(value) { + settings.edit { + putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) + } + } + + var proxyHost + get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) + set(value) { + proxySettings.edit { + putString(PreferenceKeys.PROXY_HOST, value) + } + } + + var proxyPort + get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) + set(value) { + proxySettings.edit { + putInt(PreferenceKeys.PROXY_PORT, value) + } + } + + var proxyUsername + get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + set(value) { + proxySettings.edit { + putString(PreferenceKeys.PROXY_USERNAME, value) + } + } + + var proxyPassword + get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) + set(value) { + proxySettings.edit { + putString(PreferenceKeys.PROXY_PASSWORD, value) + } + } + + sealed class UpdateConnectionSettingsResult { + class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() + class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult() + object Valid : UpdateConnectionSettingsResult() + object FailedToParseUrl : UpdateConnectionSettingsResult() + } + + fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { + val parsedUrl = runCatching { + URIish(newUrl) + }.getOrElse { + return UpdateConnectionSettingsResult.FailedToParseUrl + } + val newProtocol = when (parsedUrl.scheme) { + in listOf("http", "https") -> Protocol.Https + in listOf("ssh", null) -> Protocol.Ssh + else -> return UpdateConnectionSettingsResult.FailedToParseUrl + } + if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank()) + return UpdateConnectionSettingsResult.MissingUsername(newProtocol) + + val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) + val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) + when { + newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) + } + newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) + } + } + + url = newUrl + authMode = newAuthMode + branch = newBranch + return UpdateConnectionSettingsResult.Valid + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt new file mode 100644 index 00000000..fc506995 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt @@ -0,0 +1,114 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("DEPRECATION") + +package dev.msfjarvis.aps.util.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.net.URI + +fun runMigrations(context: Context) { + val sharedPrefs = context.sharedPrefs + migrateToGitUrlBasedConfig(sharedPrefs) + migrateToHideAll(sharedPrefs) + migrateToSshKey(context, sharedPrefs) +} + +private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) { + val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) + ?: return + i { "Migrating to URL-based Git config" } + val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" + val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" + val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: "" + val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) + + // Whether we need the leading ssh:// depends on the use of a custom port. + val hostnamePart = serverHostname.removePrefix("ssh://") + val url = when (protocol) { + Protocol.Ssh -> { + val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" + val portPart = + if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" + if (portPart.isEmpty()) { + "$userPart$hostnamePart:$serverPath" + } else { + // Only absolute paths are supported with custom ports. + if (!serverPath.startsWith('/')) + null + else + // We have to specify the ssh scheme as this is the only way to pass a custom + // port. + "ssh://$userPart$hostnamePart$portPart$serverPath" + } + } + Protocol.Https -> { + val portPart = + if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort" + val pathPart = serverPath.trimStart('/', ':') + val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart" + val url = when { + urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme + urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https") + else -> "https://$urlWithFreeEntryScheme" + } + runCatching { + if (URI(url).rawAuthority != null) + url + else + null + }.get() + } + } + + sharedPrefs.edit { + remove(PreferenceKeys.GIT_REMOTE_LOCATION) + remove(PreferenceKeys.GIT_REMOTE_PORT) + remove(PreferenceKeys.GIT_REMOTE_SERVER) + remove(PreferenceKeys.GIT_REMOTE_USERNAME) + remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) + } + if (url == null || GitSettings.updateConnectionSettingsIfValid( + newAuthMode = GitSettings.authMode, + newUrl = url, + newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) { + e { "Failed to migrate to URL-based Git config, generated URL is invalid" } + } +} + +private fun migrateToHideAll(sharedPrefs: SharedPreferences) { + sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return + val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + sharedPrefs.edit { + remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) + putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) + } +} + +private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) { + val privateKeyFile = File(context.filesDir, ".ssh_key") + if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && + !SshKey.exists && + privateKeyFile.exists()) { + // Currently uses a private key imported or generated with an old version of Password Store. + // Generated keys come with a public key which the user should still be able to view after + // the migration (not possible for regular imported keys), hence the special case. + val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) + SshKey.useLegacyKey(isGeneratedKey) + sharedPrefs.edit { + remove(PreferenceKeys.USE_GENERATED_KEY) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt new file mode 100644 index 00000000..f5a639f0 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.settings + +import android.content.Context +import android.content.SharedPreferences +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.getString + +enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) { + + FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> + (p1.type + p1.name) + .compareTo(p2.type + p2.name, ignoreCase = true) + }), + + INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> + p1.name.compareTo(p2.name, ignoreCase = true) + }), + + RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem -> + val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) + val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) + when { + timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) + timeP1 != null && timeP2 == null -> return@Comparator -1 + timeP1 == null && timeP2 != null -> return@Comparator 1 + else -> p1.name.compareTo(p2.name, ignoreCase = true) + } + }), + + FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> + (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) + }); + + companion object { + + @JvmStatic + fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { + return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt new file mode 100644 index 00000000..198be889 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt @@ -0,0 +1,85 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.settings + +object PreferenceKeys { + + const val APP_THEME = "app_theme" + const val APP_VERSION = "app_version" + const val AUTOFILL_ENABLE = "autofill_enable" + const val BIOMETRIC_AUTH = "biometric_auth" + const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" + const val CLEAR_SAVED_PASS = "clear_saved_pass" + const val COPY_ON_DECRYPT = "copy_on_decrypt" + const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" + const val EXPORT_PASSWORDS = "export_passwords" + const val FILTER_RECURSIVELY = "filter_recursively" + const val GENERAL_SHOW_TIME = "general_show_time" + const val GIT_CONFIG = "git_config" + const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" + const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" + const val GIT_EXTERNAL = "git_external" + const val GIT_EXTERNAL_REPO = "git_external_repo" + const val GIT_REMOTE_AUTH = "git_remote_auth" + const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_LOCATION = "git_remote_location" + const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_PORT = "git_remote_port" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" + const val GIT_DELETE_REPO = "git_delete_repo" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_SERVER = "git_remote_server" + const val GIT_REMOTE_URL = "git_remote_url" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_USERNAME = "git_remote_username" + const val GIT_SERVER_INFO = "git_server_info" + const val GIT_BRANCH_NAME = "git_branch" + const val HTTPS_PASSWORD = "https_password" + const val LENGTH = "length" + const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" + const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" + const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" + const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict" + const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict" + const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" + const val PREF_SELECT_EXTERNAL = "pref_select_external" + const val REPOSITORY_INITIALIZED = "repository_initialized" + const val REPO_CHANGED = "repo_changed" + const val SEARCH_ON_START = "search_on_start" + const val SHOW_EXTRA_CONTENT = "show_extra_content" + + @Deprecated( + message = "Use SHOW_HIDDEN_CONTENTS instead", + replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") + ) + const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" + const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" + const val SORT_ORDER = "sort_order" + const val SHOW_PASSWORD = "show_password" + const val SSH_KEY = "ssh_key" + const val SSH_KEYGEN = "ssh_keygen" + const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" + const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" + const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" + const val SSH_SEE_KEY = "ssh_see_key" + + @Deprecated("To be used only in Migrations.kt") + const val USE_GENERATED_KEY = "use_generated_key" + + const val PROXY_SETTINGS = "proxy_settings" + const val PROXY_HOST = "proxy_host" + const val PROXY_PORT = "proxy_port" + const val PROXY_USERNAME = "proxy_username" + const val PROXY_PASSWORD = "proxy_password" +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt new file mode 100644 index 00000000..551b2e5d --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt @@ -0,0 +1,73 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.runCatching +import java.nio.ByteBuffer +import java.util.Locale +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.and +import org.apache.commons.codec.binary.Base32 + +object Otp { + + private val BASE_32 = Base32() + private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() + + init { + check(STEAM_ALPHABET.size == 26) + } + + fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching { + val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" + val decodedSecret = BASE_32.decode(secret) + val secretKey = SecretKeySpec(decodedSecret, algo) + val digest = Mac.getInstance(algo).run { + init(secretKey) + doFinal(ByteBuffer.allocate(8).putLong(counter).array()) + } + // Least significant 4 bits are used as an offset into the digest. + val offset = (digest.last() and 0xf).toInt() + // Extract 32 bits at the offset and clear the most significant bit. + val code = digest.copyOfRange(offset, offset + 4) + code[0] = (0x7f and code[0].toInt()).toByte() + val codeInt = ByteBuffer.wrap(code).int + check(codeInt > 0) + if (digits == "s") { + // Steam + var remainingCodeInt = codeInt + buildString { + repeat(5) { + append(STEAM_ALPHABET[remainingCodeInt % 26]) + remainingCodeInt /= 26 + } + } + } else { + // Base 10, 6 to 10 digits + val numDigits = digits.toIntOrNull() + when { + numDigits == null -> { + return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric")) + } + numDigits < 6 -> { + return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long")) + } + numDigits > 10 -> { + return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long")) + } + else -> { + // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one + // always being 0, 1, or 2. Pad with leading zeroes. + val codeStringBase10 = codeInt.toString(10).padStart(10, '0') + check(codeStringBase10.length == 10) + codeStringBase10.takeLast(numDigits) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt new file mode 100644 index 00000000..fb43980c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt @@ -0,0 +1,32 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +/** + * Defines a class that can extract relevant parts of a TOTP URL for use by the app. + */ +interface TotpFinder { + + /** + * Get the TOTP secret from the given extra content. + */ + fun findSecret(content: String): String? + + /** + * Get the number of digits required in the final OTP. + */ + fun findDigits(content: String): String + + /** + * Get the TOTP timeout period. + */ + fun findPeriod(content: String): Long + + /** + * Get the algorithm for the TOTP secret. + */ + fun findAlgorithm(content: String): String +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt new file mode 100644 index 00000000..21910a3a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +import android.net.Uri + +/** + * [Uri] backed TOTP URL parser. + */ +class UriTotpFinder : TotpFinder { + + override fun findSecret(content: String): String? { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0])) { + return Uri.parse(line).getQueryParameter("secret") + } + if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) { + return line.split(": *".toRegex(), 2).toTypedArray()[1] + } + } + return null + } + + override fun findDigits(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && + Uri.parse(line).getQueryParameter("digits") != null) { + return Uri.parse(line).getQueryParameter("digits")!! + } + } + return "6" + } + + override fun findPeriod(content: String): Long { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && + Uri.parse(line).getQueryParameter("period") != null) { + val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() + if (period != null && period > 0) + return period + } + } + return 30 + } + + override fun findAlgorithm(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && + Uri.parse(line).getQueryParameter("algorithm") != null) { + return Uri.parse(line).getQueryParameter("algorithm")!! + } + } + return "sha1" + } + + companion object { + + val TOTP_FIELDS = arrayOf( + "otpauth://totp", + "totp:" + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt new file mode 100644 index 00000000..6cd47b4b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt @@ -0,0 +1,474 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.viewmodel + +import android.app.Application +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.selection.SelectionPredicates +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.selection.StorageStrategy +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.text.Collator +import java.util.Locale +import java.util.Stack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.yield +import me.zhanghai.android.fastscroll.PopupTextProvider + +private fun File.toPasswordItem() = if (isFile) + PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) +else + PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) + +private fun PasswordItem.fuzzyMatch(filter: String): Int { + var i = 0 + var j = 0 + var score = 0 + var bonus = 0 + var bonusIncrement = 0 + + val toMatch = longName + + while (i < filter.length && j < toMatch.length) { + when { + filter[i].isWhitespace() -> i++ + filter[i].toLowerCase() == toMatch[j].toLowerCase() -> { + i++ + bonusIncrement += 1 + bonus += bonusIncrement + score += bonus + } + else -> { + bonus = 0 + bonusIncrement = 0 + } + } + j++ + } + return if (i == filter.length) score else 0 +} + +private val CaseInsensitiveComparator = Collator.getInstance().apply { + strength = Collator.PRIMARY +} + +private fun PasswordItem.Companion.makeComparator( + typeSortOrder: PasswordSortOrder, + directoryStructure: DirectoryStructure +): Comparator<PasswordItem> { + return when (typeSortOrder) { + PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } + // In order to let INDEPENDENT not distinguish between items based on their type, we simply + // declare them all equal at this stage. + PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } + PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator + } + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { + directoryStructure.getIdentifierFor(it.file) + }) + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { + directoryStructure.getUsernameFor(it.file) + }) +} + +val PasswordItem.stableId: String + get() = file.absolutePath + +enum class FilterMode { + NoFilter, + StrictDomain, + Fuzzy +} + +enum class SearchMode { + RecursivelyInSubdirectories, + InCurrentDirectoryOnly +} + +enum class ListMode { + FilesOnly, + DirectoriesOnly, + AllEntries +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { + + private var _updateCounter = 0 + private val updateCounter: Int + get() = _updateCounter + + private fun forceUpdateOnNextSearchAction() { + _updateCounter++ + } + + private val root + get() = PasswordRepository.getRepositoryDirectory() + private val settings by lazy(LazyThreadSafetyMode.NONE) { application.sharedPrefs } + private val showHiddenContents + get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) + private val defaultSearchMode + get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { + SearchMode.RecursivelyInSubdirectories + } else { + SearchMode.InCurrentDirectoryOnly + } + + private val typeSortOrder + get() = PasswordSortOrder.getSortOrder(settings) + private val directoryStructure + get() = AutofillPreferences.directoryStructure(getApplication()) + private val itemComparator + get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure) + + private data class SearchAction( + val baseDirectory: File, + val filter: String, + val filterMode: FilterMode, + val searchMode: SearchMode, + val listMode: ListMode, + // This counter can be increased to force a reexecution of the search action even if all + // other arguments are left unchanged. + val updateCounter: Int + ) + + private fun makeSearchAction( + baseDirectory: File, + filter: String, + filterMode: FilterMode, + searchMode: SearchMode, + listMode: ListMode + ): SearchAction { + return SearchAction( + baseDirectory = baseDirectory, + filter = filter, + filterMode = filterMode, + searchMode = searchMode, + listMode = listMode, + updateCounter = updateCounter + ) + } + + private fun updateSearchAction(action: SearchAction) = + action.copy(updateCounter = updateCounter) + + private val searchAction = MutableLiveData( + makeSearchAction( + baseDirectory = root, + filter = "", + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = ListMode.AllEntries + ) + ) + private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() + + data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean) + + val searchResult = searchActionFlow + .mapLatest { searchAction -> + val listResultFlow = when (searchAction.searchMode) { + SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory) + SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) + } + val prefilteredResultFlow = when (searchAction.listMode) { + ListMode.FilesOnly -> listResultFlow.filter { it.isFile } + ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } + ListMode.AllEntries -> listResultFlow + } + val filterModeToUse = + if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode + val passwordList = when (filterModeToUse) { + FilterMode.NoFilter -> { + prefilteredResultFlow + .map { it.toPasswordItem() } + .toList() + .sortedWith(itemComparator) + } + FilterMode.StrictDomain -> { + check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" } + val regex = generateStrictDomainRegex(searchAction.filter) + if (regex != null) { + prefilteredResultFlow + .filter { absoluteFile -> + regex.containsMatchIn(absoluteFile.relativeTo(root).path) + } + .map { it.toPasswordItem() } + .toList() + .sortedWith(itemComparator) + } else { + emptyList() + } + } + FilterMode.Fuzzy -> { + prefilteredResultFlow + .map { + val item = it.toPasswordItem() + Pair(item.fuzzyMatch(searchAction.filter), item) + } + .filter { it.first > 0 } + .toList() + .sortedWith( + compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy( + itemComparator + ) { it.second }) + .map { it.second } + } + } + SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + }.asLiveData(Dispatchers.IO) + + private fun shouldTake(file: File) = with(file) { + if (showHiddenContents) return true + if (isDirectory) { + !isHidden + } else { + !isHidden && file.extension == "gpg" + } + } + + private fun listFiles(dir: File): Flow<File> { + return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow() + } + + private fun listFilesRecursively(dir: File): Flow<File> { + return dir + // Take top directory even if it is hidden. + .walkTopDown().onEnter { file -> file == dir || shouldTake(file) } + .asFlow() + // Skip the root directory + .drop(1) + .map { + yield() + it + } + .filter { file -> shouldTake(file) } + } + + private val _currentDir = MutableLiveData(root) + val currentDir = _currentDir as LiveData<File> + + data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) + + private val navigationStack = Stack<NavigationStackEntry>() + + fun navigateTo( + newDirectory: File = root, + listMode: ListMode = ListMode.AllEntries, + recyclerViewState: Parcelable? = null, + pushPreviousLocation: Boolean = true + ) { + if (!newDirectory.exists()) return + require(newDirectory.isDirectory) { "Can only navigate to a directory" } + if (pushPreviousLocation) { + navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState)) + } + searchAction.postValue( + makeSearchAction( + filter = "", + baseDirectory = newDirectory, + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = listMode + ) + ) + _currentDir.postValue(newDirectory) + } + + val canNavigateBack + get() = navigationStack.isNotEmpty() + + /** + * Navigate back to the last location on the [navigationStack] and restore a cached scroll + * position if possible. + * + * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. + */ + fun navigateBack(): Parcelable? { + if (!canNavigateBack) return null + val (oldDir, oldRecyclerViewState) = navigationStack.pop() + navigateTo(oldDir, pushPreviousLocation = false) + return oldRecyclerViewState + } + + fun reset() { + navigationStack.clear() + forceUpdateOnNextSearchAction() + navigateTo(pushPreviousLocation = false) + } + + fun search( + filter: String, + baseDirectory: File? = null, + filterMode: FilterMode = FilterMode.Fuzzy, + searchMode: SearchMode? = null, + listMode: ListMode = ListMode.AllEntries + ) { + require(baseDirectory?.isDirectory != false) { "Can only search in a directory" } + searchAction.postValue( + makeSearchAction( + filter = filter, + baseDirectory = baseDirectory ?: _currentDir.value!!, + filterMode = filterMode, + searchMode = searchMode ?: defaultSearchMode, + listMode = listMode + ) + ) + } + + fun forceRefresh() { + forceUpdateOnNextSearchAction() + searchAction.postValue(updateSearchAction(searchAction.value!!)) + } + + companion object { + + @VisibleForTesting + fun generateStrictDomainRegex(domain: String): Regex? { + // Valid domains do not contain path separators. + if (domain.contains('/')) + return null + // Matches the start of a path component, which is either the start of the + // string or a path separator. + val prefix = """(?:^|/)""" + val escapedFilter = Regex.escape(domain.replace("/", "")) + // Matches either the filter literally or a strict subdomain of the filter term. + // We allow a lot of freedom in what a subdomain is, as long as it is not an + // email address. + val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)""" + // Matches the end of a path component, which is either the literal ".gpg" or a + // path separator. + val suffix = """(?:\.gpg|/)""" + // Match any relative path with a component that is a subdomain of the filter. + return Regex(prefix + subdomain + suffix) + } + } +} + +private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() { + + override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = + oldItem.file.absolutePath == newItem.file.absolutePath + + override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = + oldItem == newItem +} + +open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( + private val layoutRes: Int, + private val viewHolderCreator: (view: View) -> T, + private val viewHolderBinder: T.(item: PasswordItem) -> Unit +) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider { + + fun <T : ItemDetailsLookup<String>> makeSelectable( + recyclerView: RecyclerView, + itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T + ) { + selectionTracker = SelectionTracker.Builder( + "SearchableRepositoryAdapter", + recyclerView, + itemKeyProvider, + itemDetailsLookupCreator(recyclerView), + StorageStrategy.createStringStorage() + ).withSelectionPredicate(SelectionPredicates.createSelectAnything()).build().apply { + addObserver(object : SelectionTracker.SelectionObserver<String>() { + override fun onSelectionChanged() { + this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( + requireSelectionTracker().selection + ) + } + }) + } + } + + private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null + open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> { + check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" } + onItemClickedListener = listener + return this + } + + private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null + open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> { + check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" } + onSelectionChangedListener = listener + return this + } + + private val itemKeyProvider = object : ItemKeyProvider<String>(SCOPE_MAPPED) { + override fun getKey(position: Int) = getItem(position).stableId + + override fun getPosition(key: String) = + (0 until itemCount).firstOrNull { getItem(it).stableId == key } + ?: RecyclerView.NO_POSITION + } + + private var selectionTracker: SelectionTracker<String>? = null + fun requireSelectionTracker() = selectionTracker!! + + private val selectedFiles + get() = requireSelectionTracker().selection.map { File(it) } + + fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } + + fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) + + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { + val view = LayoutInflater.from(parent.context) + .inflate(layoutRes, parent, false) + return viewHolderCreator(view) + } + + final override fun onBindViewHolder(holder: T, position: Int) { + val item = getItem(position) + holder.apply { + viewHolderBinder.invoke(this, item) + selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } + itemView.setOnClickListener { + // Do not emit custom click events while the user is selecting items. + if (selectionTracker?.hasSelection() != true) + onItemClickedListener?.invoke(holder, item) + } + } + } + + final override fun getPopupText(position: Int): String { + return getItem(position).name[0].toString().toUpperCase(Locale.getDefault()) + } +} |
