mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
Merge branch 'feature/OTP_Links' into develop
This commit is contained in:
@@ -150,6 +150,13 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</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
|
||||
android:name="com.kunzisoft.keepass.activities.MagikeyboardLauncherActivity"
|
||||
|
||||
@@ -23,15 +23,17 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.search.SearchHelper
|
||||
import com.kunzisoft.keepass.magikeyboard.MagikIME
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
|
||||
/**
|
||||
* Activity to search or select entry in database,
|
||||
@@ -42,22 +44,35 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
var sharedWebDomain: String? = null
|
||||
var otpString: String? = null
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
if ("text/plain" == intent.type) {
|
||||
// Retrieve web domain
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
sharedWebDomain = Uri.parse(it).host
|
||||
// Retrieve web domain or OTP
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { extra ->
|
||||
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 -> {}
|
||||
}
|
||||
|
||||
// Build search param
|
||||
|
||||
// Build domain search param
|
||||
val searchInfo = SearchInfo().apply {
|
||||
webDomain = sharedWebDomain
|
||||
this.webDomain = sharedWebDomain
|
||||
this.otpString = otpString
|
||||
}
|
||||
SearchInfo.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain ->
|
||||
searchInfo.webDomain = concreteWebDomain
|
||||
@@ -68,6 +83,8 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun launch(searchInfo: SearchInfo) {
|
||||
|
||||
if (!searchInfo.containsOnlyNullValues()) {
|
||||
// Setting to integrate Magikeyboard
|
||||
val searchShareForMagikeyboard = PreferencesUtil.isKeyboardSearchShareEnable(this)
|
||||
|
||||
@@ -79,7 +96,18 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
|
||||
searchInfo,
|
||||
{ items ->
|
||||
// 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) {
|
||||
// Automatically populate keyboard
|
||||
val entryPopulate = items[0]
|
||||
@@ -102,7 +130,18 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
|
||||
},
|
||||
{
|
||||
// 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,
|
||||
readOnly,
|
||||
searchInfo,
|
||||
@@ -115,7 +154,17 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
|
||||
},
|
||||
{
|
||||
// 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,
|
||||
searchInfo)
|
||||
} else {
|
||||
@@ -124,6 +173,7 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,19 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
searchInfo)
|
||||
}
|
||||
|
||||
/*
|
||||
* -------------------------
|
||||
* Save Launch
|
||||
* -------------------------
|
||||
*/
|
||||
|
||||
fun launchForSaveResult(context: Context,
|
||||
searchInfo: SearchInfo) {
|
||||
EntrySelectionHelper.startActivityForSaveModeResult(context,
|
||||
Intent(context, FileDatabaseSelectActivity::class.java),
|
||||
searchInfo)
|
||||
}
|
||||
|
||||
/*
|
||||
* -------------------------
|
||||
* Keyboard Launch
|
||||
|
||||
@@ -1370,8 +1370,20 @@ class GroupActivity : LockingActivity(),
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
// Nothing with Save Info, only pass by search first
|
||||
{ searchInfo ->
|
||||
// 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 ->
|
||||
SearchHelper.checkAutoSearchInfo(activity,
|
||||
|
||||
@@ -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
|
||||
@@ -877,8 +896,11 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
searchInfo)
|
||||
onLaunchActivitySpecialMode()
|
||||
},
|
||||
{ // Save Action
|
||||
// Not directly used, a search is performed before
|
||||
{ searchInfo -> // Save Action
|
||||
PasswordActivity.launchForSaveResult(activity,
|
||||
databaseUri, keyFile,
|
||||
searchInfo)
|
||||
onLaunchActivitySpecialMode()
|
||||
},
|
||||
{ searchInfo -> // Keyboard Selection Action
|
||||
PasswordActivity.launchForKeyboardResult(activity,
|
||||
|
||||
@@ -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.security.ProtectedString
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@@ -125,7 +126,22 @@ class EntryInfo : Parcelable {
|
||||
}
|
||||
|
||||
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
|
||||
val scheme = searchInfo.webScheme
|
||||
val webScheme = if (scheme.isNullOrEmpty()) "http" else scheme
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.kunzisoft.keepass.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -35,6 +37,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
|
||||
get() {
|
||||
return if (webDomain == null) null else field
|
||||
}
|
||||
var otpString: String? = null
|
||||
|
||||
constructor()
|
||||
|
||||
@@ -42,6 +45,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
|
||||
applicationId = toCopy?.applicationId
|
||||
webDomain = toCopy?.webDomain
|
||||
webScheme = toCopy?.webScheme
|
||||
otpString = toCopy?.otpString
|
||||
}
|
||||
|
||||
private constructor(parcel: Parcel) {
|
||||
@@ -51,6 +55,8 @@ class SearchInfo : ObjectNameResource, Parcelable {
|
||||
webDomain = if (readDomain.isNullOrEmpty()) null else readDomain
|
||||
val readScheme = parcel.readString()
|
||||
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
|
||||
val readOtp = parcel.readString()
|
||||
otpString = if (readOtp.isNullOrEmpty()) null else readOtp
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
@@ -61,14 +67,23 @@ class SearchInfo : ObjectNameResource, Parcelable {
|
||||
parcel.writeString(applicationId ?: "")
|
||||
parcel.writeString(webDomain ?: "")
|
||||
parcel.writeString(webScheme ?: "")
|
||||
parcel.writeString(otpString ?: "")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -80,6 +95,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
|
||||
if (applicationId != other.applicationId) return false
|
||||
if (webDomain != other.webDomain) return false
|
||||
if (webScheme != other.webScheme) return false
|
||||
if (otpString != other.otpString) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -88,11 +104,12 @@ class SearchInfo : ObjectNameResource, Parcelable {
|
||||
var result = applicationId?.hashCode() ?: 0
|
||||
result = 31 * result + (webDomain?.hashCode() ?: 0)
|
||||
result = 31 * result + (webScheme?.hashCode() ?: 0)
|
||||
result = 31 * result + (otpString?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return webDomain ?: applicationId ?: ""
|
||||
return otpString ?: webDomain ?: applicationId ?: ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -216,13 +216,17 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
|
||||
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(), "")
|
||||
}
|
||||
|
||||
fun replaceBase32Chars(parameter: String): String {
|
||||
// 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) {
|
||||
parameterNewSize += 'A'
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
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 java.net.URLEncoder
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@@ -49,6 +49,9 @@ object OtpEntryFields {
|
||||
private const val ENCODER_URL_PARAM = "encoder"
|
||||
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)
|
||||
private const val SEED_KEY = "key"
|
||||
private const val DIGITS_KEY = "size"
|
||||
@@ -91,7 +94,25 @@ object OtpEntryFields {
|
||||
// HOTP fields from KeePass 2
|
||||
if (parseHOTPFromField(getField, 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
|
||||
}
|
||||
|
||||
@@ -104,8 +125,8 @@ object OtpEntryFields {
|
||||
*/
|
||||
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val otpPlainText = getField(OTP_FIELD)
|
||||
if (otpPlainText != null && otpPlainText.isNotEmpty()) {
|
||||
val uri = Uri.parse(replaceSpaceChars(otpPlainText))
|
||||
if (otpPlainText != null && otpPlainText.isNotEmpty() && isOTPUri(otpPlainText)) {
|
||||
val uri = Uri.parse(removeSpaceChars(otpPlainText))
|
||||
|
||||
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
|
||||
Log.e(TAG, "Invalid or missing scheme in uri")
|
||||
@@ -135,12 +156,19 @@ object OtpEntryFields {
|
||||
}
|
||||
|
||||
val nameParam = validateAndGetNameInPath(uri.path)
|
||||
if (nameParam != null && nameParam.isNotEmpty())
|
||||
otpElement.name = nameParam
|
||||
if (nameParam != null && nameParam.isNotEmpty()) {
|
||||
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)
|
||||
if (issuerParam != null && issuerParam.isNotEmpty())
|
||||
otpElement.issuer = issuerParam
|
||||
otpElement.issuer = removeLineChars(issuerParam)
|
||||
|
||||
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
|
||||
if (secretParam != null && secretParam.isNotEmpty()) {
|
||||
@@ -211,15 +239,15 @@ object OtpEntryFields {
|
||||
}
|
||||
val issuer =
|
||||
if (title != null && title.isNotEmpty())
|
||||
replaceCharsForUrl(title)
|
||||
encodeParameter(title)
|
||||
else
|
||||
replaceCharsForUrl(otpElement.issuer)
|
||||
encodeParameter(otpElement.issuer)
|
||||
val accountName =
|
||||
if (username != null && username.isNotEmpty())
|
||||
replaceCharsForUrl(username)
|
||||
encodeParameter(username)
|
||||
else
|
||||
replaceCharsForUrl(otpElement.name)
|
||||
val uriString = StringBuilder("otpauth://$otpAuthority/$issuer:$accountName" +
|
||||
encodeParameter(otpElement.name)
|
||||
val uriString = StringBuilder("otpauth://$otpAuthority/$issuer%3A$accountName" +
|
||||
"?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" +
|
||||
"&$counterOrPeriodLabel=$counterOrPeriodValue" +
|
||||
"&$DIGITS_URL_PARAM=${otpElement.digits}" +
|
||||
@@ -233,8 +261,8 @@ object OtpEntryFields {
|
||||
return Uri.parse(uriString.toString())
|
||||
}
|
||||
|
||||
private fun replaceCharsForUrl(parameter: String): String {
|
||||
return URLEncoder.encode(replaceSpaceChars(parameter), "UTF-8")
|
||||
private fun encodeParameter(parameter: String): String {
|
||||
return Uri.encode(OtpElement.removeLineChars(parameter))
|
||||
}
|
||||
|
||||
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
|
||||
val name = path.substring(1).trim { it <= ' ' }
|
||||
return if (name.isEmpty()) {
|
||||
null // only white spaces.
|
||||
null
|
||||
} else name
|
||||
}
|
||||
|
||||
@@ -338,7 +366,7 @@ object OtpEntryFields {
|
||||
/**
|
||||
* 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,
|
||||
buildOtpUri(otpElement, title, username).toString()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user