Merge branch 'feature/OTP_Links' into develop

This commit is contained in:
J-Jamet
2020-11-12 15:15:43 +01:00
9 changed files with 249 additions and 80 deletions

View File

@@ -150,6 +150,13 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="otpauth" android:host="totp" />
<data android:scheme="otpauth" android:host="hotp" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name="com.kunzisoft.keepass.activities.MagikeyboardLauncherActivity" android:name="com.kunzisoft.keepass.activities.MagikeyboardLauncherActivity"

View File

@@ -23,15 +23,17 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.database.element.Database import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.search.SearchHelper import com.kunzisoft.keepass.database.search.SearchHelper
import com.kunzisoft.keepass.magikeyboard.MagikIME import com.kunzisoft.keepass.magikeyboard.MagikIME
import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.UriUtil
/** /**
* Activity to search or select entry in database, * Activity to search or select entry in database,
@@ -42,22 +44,35 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
var sharedWebDomain: String? = null var sharedWebDomain: String? = null
var otpString: String? = null
when (intent?.action) { when (intent?.action) {
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
if ("text/plain" == intent.type) { if ("text/plain" == intent.type) {
// Retrieve web domain // Retrieve web domain or OTP
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { extra ->
sharedWebDomain = Uri.parse(it).host if (OtpEntryFields.isOTPUri(extra))
otpString = extra
else
sharedWebDomain = Uri.parse(extra).host
} }
} }
} }
Intent.ACTION_VIEW -> {
// Retrieve OTP
intent.dataString?.let { extra ->
if (OtpEntryFields.isOTPUri(extra))
otpString = extra
}
}
else -> {} else -> {}
} }
// Build search param
// Build domain search param
val searchInfo = SearchInfo().apply { val searchInfo = SearchInfo().apply {
webDomain = sharedWebDomain this.webDomain = sharedWebDomain
this.otpString = otpString
} }
SearchInfo.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain -> SearchInfo.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain ->
searchInfo.webDomain = concreteWebDomain searchInfo.webDomain = concreteWebDomain
@@ -68,6 +83,8 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
} }
private fun launch(searchInfo: SearchInfo) { private fun launch(searchInfo: SearchInfo) {
if (!searchInfo.containsOnlyNullValues()) {
// Setting to integrate Magikeyboard // Setting to integrate Magikeyboard
val searchShareForMagikeyboard = PreferencesUtil.isKeyboardSearchShareEnable(this) val searchShareForMagikeyboard = PreferencesUtil.isKeyboardSearchShareEnable(this)
@@ -79,7 +96,18 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
searchInfo, searchInfo,
{ items -> { items ->
// Items found // Items found
if (searchShareForMagikeyboard) { if (searchInfo.otpString != null) {
if (!readOnly) {
GroupActivity.launchForSaveResult(this,
searchInfo,
false)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
}
} else if (searchShareForMagikeyboard) {
if (items.size == 1) { if (items.size == 1) {
// Automatically populate keyboard // Automatically populate keyboard
val entryPopulate = items[0] val entryPopulate = items[0]
@@ -102,7 +130,18 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
}, },
{ {
// Show the database UI to select the entry // Show the database UI to select the entry
if (readOnly || searchShareForMagikeyboard) { if (searchInfo.otpString != null) {
if (!readOnly) {
GroupActivity.launchForSaveResult(this,
searchInfo,
false)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
}
} else if (readOnly || searchShareForMagikeyboard) {
GroupActivity.launchForKeyboardSelectionResult(this, GroupActivity.launchForKeyboardSelectionResult(this,
readOnly, readOnly,
searchInfo, searchInfo,
@@ -115,7 +154,17 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
}, },
{ {
// If database not open // If database not open
if (searchShareForMagikeyboard) { if (searchInfo.otpString != null) {
if (!readOnly) {
FileDatabaseSelectActivity.launchForSaveResult(this,
searchInfo)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
}
} else if (searchShareForMagikeyboard) {
FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this, FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this,
searchInfo) searchInfo)
} else { } else {
@@ -124,6 +173,7 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
} }
} }
) )
}
finish() finish()
} }
} }

View File

@@ -468,6 +468,19 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
searchInfo) searchInfo)
} }
/*
* -------------------------
* Save Launch
* -------------------------
*/
fun launchForSaveResult(context: Context,
searchInfo: SearchInfo) {
EntrySelectionHelper.startActivityForSaveModeResult(context,
Intent(context, FileDatabaseSelectActivity::class.java),
searchInfo)
}
/* /*
* ------------------------- * -------------------------
* Keyboard Launch * Keyboard Launch

View File

@@ -1370,8 +1370,20 @@ class GroupActivity : LockingActivity(),
} }
) )
}, },
{ { searchInfo ->
// Nothing with Save Info, only pass by search first // Save info used with OTP
if (!readOnly) {
GroupActivity.launchForSaveResult(activity,
searchInfo,
false)
onLaunchActivitySpecialMode()
} else {
Toast.makeText(activity.applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
onCancelSpecialMode()
}
}, },
{ searchInfo -> { searchInfo ->
SearchHelper.checkAutoSearchInfo(activity, SearchHelper.checkAutoSearchInfo(activity,

View File

@@ -795,6 +795,25 @@ open class PasswordActivity : SpecialModeActivity() {
} }
} }
/*
* -------------------------
* Save Launch
* -------------------------
*/
@Throws(FileNotFoundException::class)
fun launchForSaveResult(activity: Activity,
databaseFile: Uri,
keyFile: Uri?,
searchInfo: SearchInfo) {
buildAndLaunchIntent(activity, databaseFile, keyFile) { intent ->
EntrySelectionHelper.startActivityForSaveModeResult(
activity,
intent,
searchInfo)
}
}
/* /*
* ------------------------- * -------------------------
* Keyboard Launch * Keyboard Launch
@@ -877,8 +896,11 @@ open class PasswordActivity : SpecialModeActivity() {
searchInfo) searchInfo)
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
}, },
{ // Save Action { searchInfo -> // Save Action
// Not directly used, a search is performed before PasswordActivity.launchForSaveResult(activity,
databaseUri, keyFile,
searchInfo)
onLaunchActivitySpecialMode()
}, },
{ searchInfo -> // Keyboard Selection Action { searchInfo -> // Keyboard Selection Action
PasswordActivity.launchForKeyboardResult(activity, PasswordActivity.launchForKeyboardResult(activity,

View File

@@ -28,6 +28,7 @@ import com.kunzisoft.keepass.database.element.icon.IconImage
import com.kunzisoft.keepass.database.element.icon.IconImageStandard import com.kunzisoft.keepass.database.element.icon.IconImageStandard
import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
@@ -125,7 +126,22 @@ class EntryInfo : Parcelable {
} }
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo) { fun saveSearchInfo(database: Database?, searchInfo: SearchInfo) {
searchInfo.webDomain?.let { webDomain -> searchInfo.otpString?.let { otpString ->
// Replace the OTP field
OtpEntryFields.parseOTPUri(otpString)?.let { otpElement ->
if (title.isEmpty())
title = otpElement.issuer
if (username.isEmpty())
username = otpElement.name
// Add OTP field
val mutableCustomFields = customFields as ArrayList<Field>
val otpField = OtpEntryFields.buildOtpField(otpElement, null, null)
if (mutableCustomFields.contains(otpField)) {
mutableCustomFields.remove(otpField)
}
mutableCustomFields.add(otpField)
}
} ?: searchInfo.webDomain?.let { webDomain ->
// If unable to save web domain in custom field or URL not populated, save in URL // If unable to save web domain in custom field or URL not populated, save in URL
val scheme = searchInfo.webScheme val scheme = searchInfo.webScheme
val webScheme = if (scheme.isNullOrEmpty()) "http" else scheme val webScheme = if (scheme.isNullOrEmpty()) "http" else scheme

View File

@@ -2,8 +2,10 @@ package com.kunzisoft.keepass.model
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.net.Uri
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.ObjectNameResource import com.kunzisoft.keepass.utils.ObjectNameResource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -35,6 +37,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() { get() {
return if (webDomain == null) null else field return if (webDomain == null) null else field
} }
var otpString: String? = null
constructor() constructor()
@@ -42,6 +45,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
applicationId = toCopy?.applicationId applicationId = toCopy?.applicationId
webDomain = toCopy?.webDomain webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme webScheme = toCopy?.webScheme
otpString = toCopy?.otpString
} }
private constructor(parcel: Parcel) { private constructor(parcel: Parcel) {
@@ -51,6 +55,8 @@ class SearchInfo : ObjectNameResource, Parcelable {
webDomain = if (readDomain.isNullOrEmpty()) null else readDomain webDomain = if (readDomain.isNullOrEmpty()) null else readDomain
val readScheme = parcel.readString() val readScheme = parcel.readString()
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
val readOtp = parcel.readString()
otpString = if (readOtp.isNullOrEmpty()) null else readOtp
} }
override fun describeContents(): Int { override fun describeContents(): Int {
@@ -61,14 +67,23 @@ class SearchInfo : ObjectNameResource, Parcelable {
parcel.writeString(applicationId ?: "") parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "") parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "") parcel.writeString(webScheme ?: "")
parcel.writeString(otpString ?: "")
} }
override fun getName(resources: Resources): String { override fun getName(resources: Resources): String {
otpString?.let { otpString ->
OtpEntryFields.parseOTPUri(otpString)?.let { otpElement ->
return "${otpElement.type} (${Uri.decode(otpElement.issuer)}:${Uri.decode(otpElement.name)})"
}
}
return toString() return toString()
} }
fun containsOnlyNullValues(): Boolean { fun containsOnlyNullValues(): Boolean {
return applicationId == null && webDomain == null && webScheme == null return applicationId == null
&& webDomain == null
&& webScheme == null
&& otpString == null
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -80,6 +95,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
if (applicationId != other.applicationId) return false if (applicationId != other.applicationId) return false
if (webDomain != other.webDomain) return false if (webDomain != other.webDomain) return false
if (webScheme != other.webScheme) return false if (webScheme != other.webScheme) return false
if (otpString != other.otpString) return false
return true return true
} }
@@ -88,11 +104,12 @@ class SearchInfo : ObjectNameResource, Parcelable {
var result = applicationId?.hashCode() ?: 0 var result = applicationId?.hashCode() ?: 0
result = 31 * result + (webDomain?.hashCode() ?: 0) result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0) result = 31 * result + (webScheme?.hashCode() ?: 0)
result = 31 * result + (otpString?.hashCode() ?: 0)
return result return result
} }
override fun toString(): String { override fun toString(): String {
return webDomain ?: applicationId ?: "" return otpString ?: webDomain ?: applicationId ?: ""
} }
companion object { companion object {

View File

@@ -216,13 +216,17 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
return secret.isNotEmpty() && checkBase64Secret(secret) return secret.isNotEmpty() && checkBase64Secret(secret)
} }
fun replaceSpaceChars(parameter: String): String { fun removeLineChars(parameter: String): String {
return parameter.replace("[\\r|\\n|\\t|\\u00A0]+".toRegex(), "")
}
fun removeSpaceChars(parameter: String): String {
return parameter.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "") return parameter.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "")
} }
fun replaceBase32Chars(parameter: String): String { fun replaceBase32Chars(parameter: String): String {
// Add 'A' at end if not Base32 length // Add 'A' at end if not Base32 length
var parameterNewSize = replaceSpaceChars(parameter.toUpperCase(Locale.ENGLISH)) var parameterNewSize = removeSpaceChars(parameter.toUpperCase(Locale.ENGLISH))
while (parameterNewSize.length % 8 != 0) { while (parameterNewSize.length % 8 != 0) {
parameterNewSize += 'A' parameterNewSize += 'A'
} }

View File

@@ -24,9 +24,9 @@ import android.net.Uri
import android.util.Log import android.util.Log
import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.Field import com.kunzisoft.keepass.model.Field
import com.kunzisoft.keepass.otp.OtpElement.Companion.replaceSpaceChars import com.kunzisoft.keepass.otp.OtpElement.Companion.removeLineChars
import com.kunzisoft.keepass.otp.OtpElement.Companion.removeSpaceChars
import com.kunzisoft.keepass.otp.TokenCalculator.* import com.kunzisoft.keepass.otp.TokenCalculator.*
import java.net.URLEncoder
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
@@ -49,6 +49,9 @@ object OtpEntryFields {
private const val ENCODER_URL_PARAM = "encoder" private const val ENCODER_URL_PARAM = "encoder"
private const val COUNTER_URL_PARAM = "counter" private const val COUNTER_URL_PARAM = "counter"
// OTPauth URI
private const val REGEX_OTP_AUTH = "^(?:otpauth://([ht]otp)/)(?:(?:([^:?#]*): *)?([^:?#]*))(?:\\?([^#]+))$"
// Key-values (maybe from plugin or old KeePassXC) // Key-values (maybe from plugin or old KeePassXC)
private const val SEED_KEY = "key" private const val SEED_KEY = "key"
private const val DIGITS_KEY = "size" private const val DIGITS_KEY = "size"
@@ -91,7 +94,25 @@ object OtpEntryFields {
// HOTP fields from KeePass 2 // HOTP fields from KeePass 2
if (parseHOTPFromField(getField, otpElement)) if (parseHOTPFromField(getField, otpElement))
return otpElement return otpElement
return null
}
/**
* Tell if [otpUri] is a valid Otp URI
*/
fun isOTPUri(otpUri: String): Boolean {
if (Pattern.matches(REGEX_OTP_AUTH, otpUri))
return true
return false
}
/**
* Get OtpElement from [otpUri]
*/
fun parseOTPUri(otpUri: String): OtpElement? {
val otpElement = OtpElement()
if (parseOTPUri({ key -> if (key == OTP_FIELD) otpUri else null }, otpElement))
return otpElement
return null return null
} }
@@ -104,8 +125,8 @@ object OtpEntryFields {
*/ */
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean { private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
val otpPlainText = getField(OTP_FIELD) val otpPlainText = getField(OTP_FIELD)
if (otpPlainText != null && otpPlainText.isNotEmpty()) { if (otpPlainText != null && otpPlainText.isNotEmpty() && isOTPUri(otpPlainText)) {
val uri = Uri.parse(replaceSpaceChars(otpPlainText)) val uri = Uri.parse(removeSpaceChars(otpPlainText))
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) { if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
Log.e(TAG, "Invalid or missing scheme in uri") Log.e(TAG, "Invalid or missing scheme in uri")
@@ -135,12 +156,19 @@ object OtpEntryFields {
} }
val nameParam = validateAndGetNameInPath(uri.path) val nameParam = validateAndGetNameInPath(uri.path)
if (nameParam != null && nameParam.isNotEmpty()) if (nameParam != null && nameParam.isNotEmpty()) {
otpElement.name = nameParam val userIdArray = nameParam.split(":", "%3A")
if (userIdArray.size > 1) {
otpElement.issuer = removeLineChars(userIdArray[0])
otpElement.name = removeLineChars(userIdArray[1])
} else {
otpElement.name = removeLineChars(nameParam)
}
}
val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM) val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM)
if (issuerParam != null && issuerParam.isNotEmpty()) if (issuerParam != null && issuerParam.isNotEmpty())
otpElement.issuer = issuerParam otpElement.issuer = removeLineChars(issuerParam)
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM) val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
if (secretParam != null && secretParam.isNotEmpty()) { if (secretParam != null && secretParam.isNotEmpty()) {
@@ -211,15 +239,15 @@ object OtpEntryFields {
} }
val issuer = val issuer =
if (title != null && title.isNotEmpty()) if (title != null && title.isNotEmpty())
replaceCharsForUrl(title) encodeParameter(title)
else else
replaceCharsForUrl(otpElement.issuer) encodeParameter(otpElement.issuer)
val accountName = val accountName =
if (username != null && username.isNotEmpty()) if (username != null && username.isNotEmpty())
replaceCharsForUrl(username) encodeParameter(username)
else else
replaceCharsForUrl(otpElement.name) encodeParameter(otpElement.name)
val uriString = StringBuilder("otpauth://$otpAuthority/$issuer:$accountName" + val uriString = StringBuilder("otpauth://$otpAuthority/$issuer%3A$accountName" +
"?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" + "?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" +
"&$counterOrPeriodLabel=$counterOrPeriodValue" + "&$counterOrPeriodLabel=$counterOrPeriodValue" +
"&$DIGITS_URL_PARAM=${otpElement.digits}" + "&$DIGITS_URL_PARAM=${otpElement.digits}" +
@@ -233,8 +261,8 @@ object OtpEntryFields {
return Uri.parse(uriString.toString()) return Uri.parse(uriString.toString())
} }
private fun replaceCharsForUrl(parameter: String): String { private fun encodeParameter(parameter: String): String {
return URLEncoder.encode(replaceSpaceChars(parameter), "UTF-8") return Uri.encode(OtpElement.removeLineChars(parameter))
} }
private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean { private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
@@ -321,7 +349,7 @@ object OtpEntryFields {
// path is "/name", so remove leading "/", and trailing white spaces // path is "/name", so remove leading "/", and trailing white spaces
val name = path.substring(1).trim { it <= ' ' } val name = path.substring(1).trim { it <= ' ' }
return if (name.isEmpty()) { return if (name.isEmpty()) {
null // only white spaces. null
} else name } else name
} }
@@ -338,7 +366,7 @@ object OtpEntryFields {
/** /**
* Build Otp field from an OtpElement * Build Otp field from an OtpElement
*/ */
fun buildOtpField(otpElement: OtpElement, title: String?, username: String?): Field { fun buildOtpField(otpElement: OtpElement, title: String? = null, username: String? = null): Field {
return Field(OTP_FIELD, ProtectedString(true, return Field(OTP_FIELD, ProtectedString(true,
buildOtpUri(otpElement, title, username).toString())) buildOtpUri(otpElement, title, username).toString()))
} }