mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
Merge branch 'feature/TOTP' into develop
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -38,6 +38,13 @@ proguard/
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# Eclipse/VS Code
|
||||
.project
|
||||
.settings/*
|
||||
*/.project
|
||||
*/.classpath
|
||||
*/.settings/*
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
|
||||
@@ -6,7 +6,7 @@ apply plugin: 'kotlin-kapt'
|
||||
android {
|
||||
compileSdkVersion 28
|
||||
buildToolsVersion '28.0.3'
|
||||
ndkVersion "20.0.5594570"
|
||||
ndkVersion "20.1.5948944"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.kunzisoft.keepass"
|
||||
@@ -107,6 +107,8 @@ dependencies {
|
||||
// Apache Commons Collections
|
||||
implementation 'commons-collections:commons-collections:3.2.1'
|
||||
implementation 'org.apache.commons:commons-io:1.3.2'
|
||||
// Apache Commons Codec
|
||||
implementation 'commons-codec:commons-codec:1.11'
|
||||
// Base64
|
||||
implementation 'biz.source_code:base64coder:2010-12-19'
|
||||
// Icon pack
|
||||
|
||||
@@ -24,15 +24,16 @@ import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
||||
import com.kunzisoft.keepass.activities.lock.LockingHideActivity
|
||||
@@ -58,6 +59,7 @@ class EntryActivity : LockingHideActivity() {
|
||||
private var titleIconView: ImageView? = null
|
||||
private var historyView: View? = null
|
||||
private var entryContentsView: EntryContentsView? = null
|
||||
private var entryProgress: ProgressBar? = null
|
||||
private var toolbar: Toolbar? = null
|
||||
|
||||
private var mDatabase: Database? = null
|
||||
@@ -101,6 +103,7 @@ class EntryActivity : LockingHideActivity() {
|
||||
historyView = findViewById(R.id.history_container)
|
||||
entryContentsView = findViewById(R.id.entry_contents)
|
||||
entryContentsView?.applyFontVisibilityToFields(PreferencesUtil.fieldFontIsInVisibility(this))
|
||||
entryProgress = findViewById(R.id.entry_progress)
|
||||
|
||||
// Init the clipboard helper
|
||||
clipboardHelper = ClipboardHelper(this)
|
||||
@@ -220,6 +223,17 @@ class EntryActivity : LockingHideActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
//Assign OTP field
|
||||
entryContentsView?.assignOtp(entry.getOtpElement(), entryProgress,
|
||||
View.OnClickListener {
|
||||
entry.getOtpElement()?.let { otpElement ->
|
||||
clipboardHelper?.timeoutCopyToClipboard(
|
||||
otpElement.token,
|
||||
getString(R.string.copy_field, getString(R.string.entry_otp))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
entryContentsView?.assignURL(entry.url)
|
||||
entryContentsView?.assignComment(entry.notes)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import android.view.View
|
||||
import android.widget.ScrollView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.dialogs.SetOTPDialogFragment
|
||||
import com.kunzisoft.keepass.activities.dialogs.GeneratePasswordDialogFragment
|
||||
import com.kunzisoft.keepass.activities.dialogs.IconPickerDialogFragment
|
||||
import com.kunzisoft.keepass.activities.lock.LockingHideActivity
|
||||
@@ -39,6 +40,8 @@ import com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import com.kunzisoft.keepass.utils.MenuUtil
|
||||
@@ -47,7 +50,8 @@ import java.util.*
|
||||
|
||||
class EntryEditActivity : LockingHideActivity(),
|
||||
IconPickerDialogFragment.IconPickerListener,
|
||||
GeneratePasswordDialogFragment.GeneratePasswordListener {
|
||||
GeneratePasswordDialogFragment.GeneratePasswordListener,
|
||||
SetOTPDialogFragment.CreateOtpListener {
|
||||
|
||||
private var mDatabase: Database? = null
|
||||
|
||||
@@ -164,7 +168,9 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
saveView = findViewById(R.id.entry_edit_save)
|
||||
saveView?.setOnClickListener { saveEntry() }
|
||||
|
||||
entryEditContentsView?.allowCustomField(mNewEntry?.allowCustomFields() == true) { addNewCustomField() }
|
||||
entryEditContentsView?.allowCustomField(mNewEntry?.allowCustomFields() == true) {
|
||||
addNewCustomField()
|
||||
}
|
||||
|
||||
// Verify the education views
|
||||
entryEditActivityEducation = EntryEditActivityEducation(this)
|
||||
@@ -197,7 +203,7 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
notes = newEntry.notes
|
||||
for (entry in newEntry.customFields.entries) {
|
||||
post {
|
||||
addNewCustomField(entry.key, entry.value)
|
||||
putCustomField(entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +223,7 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
password = entryView.password
|
||||
notes = entryView.notes
|
||||
entryView.customFields.forEach { customField ->
|
||||
addExtraField(customField.name, customField.protectedValue)
|
||||
putExtraField(customField.name, customField.protectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +249,7 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
* Add a new customized field view and scroll to bottom
|
||||
*/
|
||||
private fun addNewCustomField() {
|
||||
entryEditContentsView?.addNewCustomField()
|
||||
entryEditContentsView?.addEmptyCustomField()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,6 +310,7 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
val inflater = menuInflater
|
||||
inflater.inflate(R.menu.database_lock, menu)
|
||||
MenuUtil.contributionMenuInflater(inflater, menu)
|
||||
inflater.inflate(R.menu.edit_entry, menu)
|
||||
|
||||
entryEditActivityEducation?.let {
|
||||
Handler().post { performedNextEducation(it) }
|
||||
@@ -314,7 +321,7 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
|
||||
private fun performedNextEducation(entryEditActivityEducation: EntryEditActivityEducation) {
|
||||
val passwordView = entryEditContentsView?.generatePasswordView
|
||||
val addNewFieldView = entryEditContentsView?.addNewFieldView
|
||||
val addNewFieldView = entryEditContentsView?.addNewFieldButton
|
||||
|
||||
val generatePasswordEducationPerformed = passwordView != null
|
||||
&& entryEditActivityEducation.checkAndPerformedGeneratePasswordEducation(
|
||||
@@ -350,12 +357,28 @@ class EntryEditActivity : LockingHideActivity(),
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.menu_add_otp -> {
|
||||
// Retrieve the current otpElement if exists
|
||||
// and open the dialog to set up the OTP
|
||||
SetOTPDialogFragment.build(mEntry?.getOtpElement()?.otpModel)
|
||||
.show(supportFragmentManager, "addOTPDialog")
|
||||
return true
|
||||
}
|
||||
|
||||
android.R.id.home -> finish()
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onOtpCreated(otpElement: OtpElement) {
|
||||
// Update the otp field with otpauth:// url
|
||||
val otpField = OtpEntryFields.buildOtpField(otpElement,
|
||||
mEntry?.title, mEntry?.username)
|
||||
entryEditContentsView?.putCustomField(otpField.name, otpField.protectedValue)
|
||||
mEntry?.putExtraField(otpField.name, otpField.protectedValue)
|
||||
}
|
||||
|
||||
override fun iconPicked(bundle: Bundle) {
|
||||
IconPickerDialogFragment.getIconStandardFromBundle(bundle)?.let { icon ->
|
||||
temporarilySaveAndShowSelectedIcon(icon)
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package com.kunzisoft.keepass.activities.dialogs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.Spinner
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.kunzisoft.keepass.BuildConfig
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.model.OtpModel
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_HOTP_COUNTER
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_OTP_DIGITS
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_TOTP_PERIOD
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_HOTP_COUNTER
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_OTP_DIGITS
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_TOTP_PERIOD
|
||||
import com.kunzisoft.keepass.otp.OtpTokenType
|
||||
import com.kunzisoft.keepass.otp.OtpType
|
||||
import com.kunzisoft.keepass.otp.TokenCalculator
|
||||
|
||||
class SetOTPDialogFragment : DialogFragment() {
|
||||
|
||||
private var mCreateOTPElementListener: CreateOtpListener? = null
|
||||
|
||||
private var mOtpElement: OtpElement = OtpElement()
|
||||
|
||||
private var otpTypeSpinner: Spinner? = null
|
||||
private var otpTokenTypeSpinner: Spinner? = null
|
||||
private var otpSecretContainer: TextInputLayout? = null
|
||||
private var otpSecretTextView: EditText? = null
|
||||
private var otpPeriodContainer: TextInputLayout? = null
|
||||
private var otpPeriodTextView: EditText? = null
|
||||
private var otpCounterContainer: TextInputLayout? = null
|
||||
private var otpCounterTextView: EditText? = null
|
||||
private var otpDigitsContainer: TextInputLayout? = null
|
||||
private var otpDigitsTextView: EditText? = null
|
||||
private var otpAlgorithmSpinner: Spinner? = null
|
||||
|
||||
private var otpTypeAdapter: ArrayAdapter<OtpType>? = null
|
||||
private var otpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||
private var totpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||
private var hotpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||
private var otpAlgorithmAdapter: ArrayAdapter<TokenCalculator.HashAlgorithm>? = null
|
||||
|
||||
private var mManualEvent = false
|
||||
private var touchListener = View.OnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
mManualEvent = true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
private var mSecretWellFormed = false
|
||||
private var mCounterWellFormed = true
|
||||
private var mPeriodWellFormed = true
|
||||
private var mDigitsWellFormed = true
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
// Verify that the host activity implements the callback interface
|
||||
try {
|
||||
// Instantiate the NoticeDialogListener so we can send events to the host
|
||||
mCreateOTPElementListener = context as CreateOtpListener
|
||||
} catch (e: ClassCastException) {
|
||||
// The activity doesn't implement the interface, throw exception
|
||||
throw ClassCastException(context.toString()
|
||||
+ " must implement " + CreateOtpListener::class.java.name)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
|
||||
// Retrieve OTP model from instance state
|
||||
if (savedInstanceState != null) {
|
||||
if (savedInstanceState.containsKey(KEY_OTP)) {
|
||||
savedInstanceState.getParcelable<OtpModel>(KEY_OTP)?.let { otpModel ->
|
||||
mOtpElement = OtpElement(otpModel)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
arguments?.apply {
|
||||
if (containsKey(KEY_OTP)) {
|
||||
getParcelable<OtpModel?>(KEY_OTP)?.let { otpModel ->
|
||||
mOtpElement = OtpElement(otpModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity?.let { activity ->
|
||||
val root = activity.layoutInflater.inflate(R.layout.fragment_set_otp, null) as ViewGroup?
|
||||
otpTypeSpinner = root?.findViewById(R.id.setup_otp_type)
|
||||
otpTokenTypeSpinner = root?.findViewById(R.id.setup_otp_token_type)
|
||||
otpSecretContainer = root?.findViewById(R.id.setup_otp_secret_label)
|
||||
otpSecretTextView = root?.findViewById(R.id.setup_otp_secret)
|
||||
otpAlgorithmSpinner = root?.findViewById(R.id.setup_otp_algorithm)
|
||||
otpPeriodContainer= root?.findViewById(R.id.setup_otp_period_label)
|
||||
otpPeriodTextView = root?.findViewById(R.id.setup_otp_period)
|
||||
otpCounterContainer= root?.findViewById(R.id.setup_otp_counter_label)
|
||||
otpCounterTextView = root?.findViewById(R.id.setup_otp_counter)
|
||||
otpDigitsContainer = root?.findViewById(R.id.setup_otp_digits_label)
|
||||
otpDigitsTextView = root?.findViewById(R.id.setup_otp_digits)
|
||||
|
||||
// To fix init element
|
||||
otpTypeSpinner?.setOnTouchListener(touchListener)
|
||||
otpTokenTypeSpinner?.setOnTouchListener(touchListener)
|
||||
otpAlgorithmSpinner?.setOnTouchListener(touchListener)
|
||||
otpSecretTextView?.setOnTouchListener(touchListener)
|
||||
otpPeriodTextView?.setOnTouchListener(touchListener)
|
||||
otpCounterTextView?.setOnTouchListener(touchListener)
|
||||
otpDigitsTextView?.setOnTouchListener(touchListener)
|
||||
|
||||
// HOTP / TOTP Type selection
|
||||
val otpTypeArray = OtpType.values()
|
||||
otpTypeAdapter = ArrayAdapter<OtpType>(activity,
|
||||
android.R.layout.simple_spinner_item, otpTypeArray).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
otpTypeSpinner?.adapter = otpTypeAdapter
|
||||
|
||||
// Otp Token type selection
|
||||
val hotpTokenTypeArray = OtpTokenType.getHotpTokenTypeValues()
|
||||
hotpTokenTypeAdapter = ArrayAdapter(activity,
|
||||
android.R.layout.simple_spinner_item, hotpTokenTypeArray).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
val totpTokenTypeArray = OtpTokenType.getTotpTokenTypeValues(BuildConfig.CLOSED_STORE)
|
||||
totpTokenTypeAdapter = ArrayAdapter(activity,
|
||||
android.R.layout.simple_spinner_item, totpTokenTypeArray).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
otpTokenTypeAdapter = hotpTokenTypeAdapter
|
||||
otpTokenTypeSpinner?.adapter = otpTokenTypeAdapter
|
||||
|
||||
// OTP Algorithm
|
||||
val otpAlgorithmArray = TokenCalculator.HashAlgorithm.values()
|
||||
otpAlgorithmAdapter = ArrayAdapter<TokenCalculator.HashAlgorithm>(activity,
|
||||
android.R.layout.simple_spinner_item, otpAlgorithmArray).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
otpAlgorithmSpinner?.adapter = otpAlgorithmAdapter
|
||||
|
||||
// Set the default value of OTP element
|
||||
upgradeType()
|
||||
upgradeTokenType()
|
||||
upgradeParameters()
|
||||
|
||||
attachListeners()
|
||||
|
||||
val builder = AlertDialog.Builder(activity)
|
||||
builder.apply {
|
||||
setTitle(R.string.entry_setup_otp)
|
||||
setView(root)
|
||||
.setPositiveButton(android.R.string.ok) {_, _ -> }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
}
|
||||
}
|
||||
|
||||
return builder.create()
|
||||
}
|
||||
return super.onCreateDialog(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
(dialog as AlertDialog).getButton(Dialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
if (mSecretWellFormed
|
||||
&& mCounterWellFormed
|
||||
&& mPeriodWellFormed
|
||||
&& mDigitsWellFormed) {
|
||||
mCreateOTPElementListener?.onOtpCreated(mOtpElement)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachListeners() {
|
||||
// Set Type listener
|
||||
otpTypeSpinner?.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (mManualEvent) {
|
||||
(parent?.selectedItem as OtpType?)?.let {
|
||||
mOtpElement.type = it
|
||||
upgradeTokenType()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set type token listener
|
||||
otpTokenTypeSpinner?.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (mManualEvent) {
|
||||
(parent?.selectedItem as OtpTokenType?)?.let {
|
||||
mOtpElement.tokenType = it
|
||||
upgradeParameters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set algorithm spinner
|
||||
otpAlgorithmSpinner?.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (mManualEvent) {
|
||||
(parent?.selectedItem as TokenCalculator.HashAlgorithm?)?.let {
|
||||
mOtpElement.algorithm = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set secret in OtpElement
|
||||
otpSecretTextView?.addTextChangedListener(object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (mManualEvent) {
|
||||
s?.toString()?.let { userString ->
|
||||
try {
|
||||
mOtpElement.setBase32Secret(userString)
|
||||
otpSecretContainer?.error = null
|
||||
} catch (exception: Exception) {
|
||||
otpSecretContainer?.error = getString(R.string.error_otp_secret_key)
|
||||
}
|
||||
mSecretWellFormed = otpSecretContainer?.error == null
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
// Set counter in OtpElement
|
||||
otpCounterTextView?.addTextChangedListener(object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (mManualEvent) {
|
||||
s?.toString()?.toLongOrNull()?.let {
|
||||
try {
|
||||
mOtpElement.counter = it
|
||||
otpCounterContainer?.error = null
|
||||
} catch (exception: Exception) {
|
||||
otpCounterContainer?.error = getString(R.string.error_otp_counter,
|
||||
MIN_HOTP_COUNTER, MAX_HOTP_COUNTER)
|
||||
}
|
||||
mCounterWellFormed = otpCounterContainer?.error == null
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
// Set period in OtpElement
|
||||
otpPeriodTextView?.addTextChangedListener(object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (mManualEvent) {
|
||||
s?.toString()?.toIntOrNull()?.let {
|
||||
try {
|
||||
mOtpElement.period = it
|
||||
otpPeriodContainer?.error = null
|
||||
} catch (exception: Exception) {
|
||||
otpPeriodContainer?.error = getString(R.string.error_otp_period,
|
||||
MIN_TOTP_PERIOD, MAX_TOTP_PERIOD)
|
||||
}
|
||||
mPeriodWellFormed = otpPeriodContainer?.error == null
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
// Set digits in OtpElement
|
||||
otpDigitsTextView?.addTextChangedListener(object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (mManualEvent) {
|
||||
s?.toString()?.toIntOrNull()?.let {
|
||||
try {
|
||||
mOtpElement.digits = it
|
||||
otpDigitsContainer?.error = null
|
||||
} catch (exception: Exception) {
|
||||
otpDigitsContainer?.error = getString(R.string.error_otp_digits,
|
||||
MIN_OTP_DIGITS, MAX_OTP_DIGITS)
|
||||
}
|
||||
mDigitsWellFormed = otpDigitsContainer?.error == null
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun upgradeType() {
|
||||
otpTypeSpinner?.setSelection(OtpType.values().indexOf(mOtpElement.type))
|
||||
}
|
||||
|
||||
private fun upgradeTokenType() {
|
||||
when (mOtpElement.type) {
|
||||
OtpType.HOTP -> {
|
||||
otpPeriodContainer?.visibility = View.GONE
|
||||
otpCounterContainer?.visibility = View.VISIBLE
|
||||
otpTokenTypeSpinner?.adapter = hotpTokenTypeAdapter
|
||||
otpTokenTypeSpinner?.setSelection(OtpTokenType
|
||||
.getHotpTokenTypeValues().indexOf(mOtpElement.tokenType))
|
||||
}
|
||||
OtpType.TOTP -> {
|
||||
otpPeriodContainer?.visibility = View.VISIBLE
|
||||
otpCounterContainer?.visibility = View.GONE
|
||||
otpTokenTypeSpinner?.adapter = totpTokenTypeAdapter
|
||||
otpTokenTypeSpinner?.setSelection(OtpTokenType
|
||||
.getTotpTokenTypeValues().indexOf(mOtpElement.tokenType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upgradeParameters() {
|
||||
otpAlgorithmSpinner?.setSelection(TokenCalculator.HashAlgorithm.values()
|
||||
.indexOf(mOtpElement.algorithm))
|
||||
otpSecretTextView?.setText(mOtpElement.getBase32Secret())
|
||||
otpCounterTextView?.setText(mOtpElement.counter.toString())
|
||||
otpPeriodTextView?.setText(mOtpElement.period.toString())
|
||||
otpDigitsTextView?.setText(mOtpElement.digits.toString())
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelable(KEY_OTP, mOtpElement.otpModel)
|
||||
}
|
||||
|
||||
interface CreateOtpListener {
|
||||
fun onOtpCreated(otpElement: OtpElement)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_OTP = "KEY_OTP"
|
||||
|
||||
fun build(otpModel: OtpModel? = null): SetOTPDialogFragment {
|
||||
return SetOTPDialogFragment().apply {
|
||||
if (otpModel != null) {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(KEY_OTP, otpModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ class FieldsAdapter(context: Context) : RecyclerView.Adapter<FieldsAdapter.Field
|
||||
var fields: MutableList<Field> = ArrayList()
|
||||
var onItemClickListener: OnItemClickListener? = null
|
||||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldViewHolder {
|
||||
val view = inflater.inflate(R.layout.keyboard_popup_fields_item, parent, false)
|
||||
return FieldViewHolder(view)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.crypto.keyDerivation
|
||||
|
||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
||||
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.Serializable
|
||||
|
||||
@@ -23,7 +23,7 @@ class ExtraFieldCursor : MatrixCursor(arrayOf(
|
||||
}
|
||||
|
||||
fun populateExtraFieldInEntry(pwEntry: PwEntryV4) {
|
||||
pwEntry.addExtraField(getString(getColumnIndex(COLUMN_LABEL)),
|
||||
pwEntry.putExtraField(getString(getColumnIndex(COLUMN_LABEL)),
|
||||
ProtectedString(getInt(getColumnIndex(COLUMN_PROTECTION)) > 0,
|
||||
getString(getColumnIndex(COLUMN_VALUE))))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.model.Field
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@@ -249,12 +251,18 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an extra field to the list (standard or custom)
|
||||
* Update or add an extra field to the list (standard or custom)
|
||||
* @param label Label of field, must be unique
|
||||
* @param value Value of field
|
||||
*/
|
||||
fun addExtraField(label: String, value: ProtectedString) {
|
||||
pwEntryV4?.addExtraField(label, value)
|
||||
fun putExtraField(label: String, value: ProtectedString) {
|
||||
pwEntryV4?.putExtraField(label, value)
|
||||
}
|
||||
|
||||
fun getOtpElement(): OtpElement? {
|
||||
return OtpEntryFields.parseFields { key ->
|
||||
customFields[key]?.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun startToManageFieldReferences(db: PwDatabaseV4) {
|
||||
@@ -302,6 +310,10 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
|
||||
------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve generated entry info,
|
||||
* Remove parameter fields and add auto generated elements in auto custom fields
|
||||
*/
|
||||
fun getEntryInfo(database: Database?, raw: Boolean = false): EntryInfo {
|
||||
val entryInfo = EntryInfo()
|
||||
if (raw)
|
||||
@@ -318,6 +330,10 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
|
||||
entryInfo.customFields.add(
|
||||
Field(entry.key, entry.value))
|
||||
}
|
||||
// Add otpElement to generate token
|
||||
entryInfo.otpModel = getOtpElement()?.otpModel
|
||||
// Replace parameter fields by generated OTP fields
|
||||
entryInfo.customFields = OtpEntryFields.generateAutoFields(entryInfo.customFields)
|
||||
if (!raw)
|
||||
database?.stopManageEntry(this)
|
||||
return entryInfo
|
||||
|
||||
@@ -21,7 +21,7 @@ package com.kunzisoft.keepass.database.element
|
||||
|
||||
import android.content.res.Resources
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
||||
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||
|
||||
// Note: We can get away with using int's to store unsigned 32-bit ints
|
||||
// since we won't do arithmetic on these values (also unlikely to
|
||||
|
||||
@@ -26,7 +26,7 @@ import com.kunzisoft.keepass.crypto.engine.AesEngine
|
||||
import com.kunzisoft.keepass.crypto.engine.ChaCha20Engine
|
||||
import com.kunzisoft.keepass.crypto.engine.CipherEngine
|
||||
import com.kunzisoft.keepass.crypto.engine.TwofishEngine
|
||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
||||
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ class PwEntryV4 : PwEntry<UUID, UUID, PwGroupV4, PwEntryV4>, PwNodeV4Interface {
|
||||
fields.clear()
|
||||
}
|
||||
|
||||
fun addExtraField(label: String, value: ProtectedString) {
|
||||
fun putExtraField(label: String, value: ProtectedString) {
|
||||
fields[label] = value
|
||||
}
|
||||
|
||||
|
||||
@@ -742,7 +742,7 @@ class ImporterV4(private val streamDir: File,
|
||||
return KdbContext.Entry
|
||||
} else if (ctx == KdbContext.EntryString && name.equals(PwDatabaseV4XML.ElemString, ignoreCase = true)) {
|
||||
if (ctxStringName != null && ctxStringValue != null)
|
||||
ctxEntry?.addExtraField(ctxStringName!!, ctxStringValue!!)
|
||||
ctxEntry?.putExtraField(ctxStringName!!, ctxStringValue!!)
|
||||
ctxStringName = null
|
||||
ctxStringValue = null
|
||||
|
||||
|
||||
@@ -100,19 +100,24 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
val popupFieldsView = LayoutInflater.from(context)
|
||||
.inflate(R.layout.keyboard_popup_fields, FrameLayout(context))
|
||||
|
||||
popupCustomKeys?.dismiss()
|
||||
|
||||
popupCustomKeys = PopupWindow(context)
|
||||
popupCustomKeys?.width = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
popupCustomKeys?.height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
popupCustomKeys?.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
||||
popupCustomKeys?.inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED
|
||||
popupCustomKeys?.contentView = popupFieldsView
|
||||
dismissCustomKeys()
|
||||
popupCustomKeys = PopupWindow(context).apply {
|
||||
width = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
||||
inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED
|
||||
contentView = popupFieldsView
|
||||
}
|
||||
|
||||
val recyclerView = popupFieldsView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.keyboard_popup_fields_list)
|
||||
fieldsAdapter = FieldsAdapter(this)
|
||||
fieldsAdapter?.onItemClickListener = object : FieldsAdapter.OnItemClickListener {
|
||||
override fun onItemClick(item: Field) {
|
||||
if (entryInfoKey?.isAutoGeneratedField(item) == true) {
|
||||
entryInfoKey?.doForAutoGeneratedField(item) { valueGenerated ->
|
||||
currentInputConnection.commitText(valueGenerated, 1)
|
||||
}
|
||||
} else
|
||||
currentInputConnection.commitText(item.protectedValue.toString(), 1)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +134,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
}
|
||||
|
||||
private fun assignKeyboardView() {
|
||||
dismissCustomKeys()
|
||||
if (keyboardView != null) {
|
||||
if (entryInfoKey != null) {
|
||||
if (keyboardEntry != null) {
|
||||
@@ -138,7 +144,6 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
} else {
|
||||
if (keyboard != null) {
|
||||
hideEntryInfo()
|
||||
dismissCustomKeys()
|
||||
keyboardView?.keyboard = keyboard
|
||||
}
|
||||
}
|
||||
@@ -251,6 +256,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
KEY_FIELDS -> {
|
||||
if (entryInfoKey != null) {
|
||||
fieldsAdapter?.fields = entryInfoKey!!.customFields
|
||||
fieldsAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
popupCustomKeys?.showAtLocation(keyboardView, Gravity.END or Gravity.TOP, 0, 0)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package com.kunzisoft.keepass.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
import java.util.ArrayList
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||
import java.util.*
|
||||
|
||||
class EntryInfo : Parcelable {
|
||||
|
||||
@@ -14,6 +15,7 @@ class EntryInfo : Parcelable {
|
||||
var url: String = ""
|
||||
var notes: String = ""
|
||||
var customFields: MutableList<Field> = ArrayList()
|
||||
var otpModel: OtpModel? = null
|
||||
|
||||
constructor()
|
||||
|
||||
@@ -25,6 +27,7 @@ class EntryInfo : Parcelable {
|
||||
url = parcel.readString() ?: url
|
||||
notes = parcel.readString() ?: notes
|
||||
parcel.readList(customFields, Field::class.java.classLoader)
|
||||
otpModel = parcel.readParcelable(OtpModel::class.java.classLoader) ?: otpModel
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
@@ -39,6 +42,7 @@ class EntryInfo : Parcelable {
|
||||
parcel.writeString(url)
|
||||
parcel.writeString(notes)
|
||||
parcel.writeArray(customFields.toTypedArray())
|
||||
parcel.writeParcelable(otpModel, flags)
|
||||
}
|
||||
|
||||
fun containsCustomFieldsProtected(): Boolean {
|
||||
@@ -49,6 +53,18 @@ class EntryInfo : Parcelable {
|
||||
return customFields.any { !it.protectedValue.isProtected }
|
||||
}
|
||||
|
||||
fun isAutoGeneratedField(field: Field): Boolean {
|
||||
return field.name == OTP_TOKEN_FIELD
|
||||
}
|
||||
|
||||
fun doForAutoGeneratedField(field: Field, action: (valueGenerated: String) -> Unit) {
|
||||
otpModel?.let {
|
||||
if (field.name == OTP_TOKEN_FIELD) {
|
||||
action.invoke(OtpElement(it).token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
|
||||
@@ -9,7 +9,7 @@ class Field : Parcelable {
|
||||
var name: String = ""
|
||||
var protectedValue: ProtectedString = ProtectedString()
|
||||
|
||||
constructor(name: String, value: ProtectedString) {
|
||||
constructor(name: String, value: ProtectedString = ProtectedString()) {
|
||||
this.name = name
|
||||
this.protectedValue = value
|
||||
}
|
||||
@@ -28,6 +28,21 @@ class Field : Parcelable {
|
||||
dest.writeParcelable(protectedValue, flags)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Field
|
||||
|
||||
if (name != other.name) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return name.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
|
||||
95
app/src/main/java/com/kunzisoft/keepass/model/OtpModel.kt
Normal file
95
app/src/main/java/com/kunzisoft/keepass/model/OtpModel.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
package com.kunzisoft.keepass.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpTokenType
|
||||
import com.kunzisoft.keepass.otp.OtpType
|
||||
import com.kunzisoft.keepass.otp.TokenCalculator
|
||||
import com.kunzisoft.keepass.otp.TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||
|
||||
class OtpModel() : Parcelable {
|
||||
|
||||
var type: OtpType = OtpType.TOTP // ie : HOTP or TOTP
|
||||
var tokenType: OtpTokenType = OtpTokenType.RFC6238
|
||||
var name: String = "OTP" // ie : user@email.com
|
||||
var issuer: String = "None" // ie : Gitlab
|
||||
var secret: ByteArray? = null // Seed
|
||||
var counter: Long = TokenCalculator.HOTP_INITIAL_COUNTER // ie : 5 - only for HOTP
|
||||
var period: Int = TokenCalculator.TOTP_DEFAULT_PERIOD // ie : 30 seconds - only for TOTP
|
||||
var digits: Int = TokenCalculator.OTP_DEFAULT_DIGITS
|
||||
var algorithm: TokenCalculator.HashAlgorithm = OTP_DEFAULT_ALGORITHM
|
||||
|
||||
constructor(parcel: Parcel) : this() {
|
||||
val typeRead = parcel.readInt()
|
||||
type = OtpType.values()[typeRead]
|
||||
tokenType = OtpTokenType.values()[parcel.readInt()]
|
||||
name = parcel.readString() ?: name
|
||||
issuer = parcel.readString() ?: issuer
|
||||
secret = parcel.createByteArray() ?: secret
|
||||
counter = parcel.readLong()
|
||||
period = parcel.readInt()
|
||||
digits = parcel.readInt()
|
||||
algorithm = TokenCalculator.HashAlgorithm.values()[parcel.readInt()]
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as OtpElement
|
||||
|
||||
if (type != other.type) return false
|
||||
// Token type is important only if it's a TOTP
|
||||
if (type == OtpType.TOTP && tokenType != other.tokenType) return false
|
||||
if (secret == null || other.secret == null) return false
|
||||
if (!secret!!.contentEquals(other.secret!!)) return false
|
||||
// Counter only for HOTP
|
||||
if (type == OtpType.HOTP && counter != other.counter) return false
|
||||
// Step only for TOTP
|
||||
if (type == OtpType.TOTP && period != other.period) return false
|
||||
if (digits != other.digits) return false
|
||||
if (algorithm != other.algorithm) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = type.hashCode()
|
||||
result = 31 * result + tokenType.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + issuer.hashCode()
|
||||
result = 31 * result + (secret?.contentHashCode() ?: 0)
|
||||
result = 31 * result + counter.hashCode()
|
||||
result = 31 * result + period
|
||||
result = 31 * result + digits
|
||||
result = 31 * result + algorithm.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeInt(type.ordinal)
|
||||
parcel.writeInt(tokenType.ordinal)
|
||||
parcel.writeString(name)
|
||||
parcel.writeString(issuer)
|
||||
parcel.writeByteArray(secret)
|
||||
parcel.writeLong(counter)
|
||||
parcel.writeInt(period)
|
||||
parcel.writeInt(digits)
|
||||
parcel.writeInt(algorithm.ordinal)
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<OtpModel> {
|
||||
override fun createFromParcel(parcel: Parcel): OtpModel {
|
||||
return OtpModel(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<OtpModel?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
215
app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
Normal file
215
app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
Normal file
@@ -0,0 +1,215 @@
|
||||
package com.kunzisoft.keepass.otp
|
||||
|
||||
import com.kunzisoft.keepass.model.OtpModel
|
||||
import org.apache.commons.codec.DecoderException
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.codec.binary.Hex
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
data class OtpElement(var otpModel: OtpModel = OtpModel()) {
|
||||
|
||||
var type
|
||||
get() = otpModel.type
|
||||
set(value) {
|
||||
otpModel.type = value
|
||||
if (type == OtpType.HOTP) {
|
||||
if (!OtpTokenType.getHotpTokenTypeValues().contains(tokenType))
|
||||
tokenType = OtpTokenType.RFC4226
|
||||
}
|
||||
if (type == OtpType.TOTP) {
|
||||
if (!OtpTokenType.getTotpTokenTypeValues().contains(tokenType))
|
||||
tokenType = OtpTokenType.RFC6238
|
||||
}
|
||||
}
|
||||
|
||||
var tokenType
|
||||
get() = otpModel.tokenType
|
||||
set(value) {
|
||||
otpModel.tokenType = value
|
||||
when (tokenType) {
|
||||
OtpTokenType.RFC4226 -> {
|
||||
otpModel.algorithm = TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||
otpModel.digits = TokenCalculator.OTP_DEFAULT_DIGITS
|
||||
otpModel.counter = TokenCalculator.HOTP_INITIAL_COUNTER
|
||||
}
|
||||
OtpTokenType.RFC6238 -> {
|
||||
otpModel.algorithm = TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||
otpModel.digits = TokenCalculator.OTP_DEFAULT_DIGITS
|
||||
otpModel.period = TokenCalculator.TOTP_DEFAULT_PERIOD
|
||||
}
|
||||
OtpTokenType.STEAM -> {
|
||||
otpModel.algorithm = TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||
otpModel.digits = TokenCalculator.STEAM_DEFAULT_DIGITS
|
||||
otpModel.period = TokenCalculator.TOTP_DEFAULT_PERIOD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var name
|
||||
get() = otpModel.name
|
||||
set(value) {
|
||||
otpModel.name = value
|
||||
}
|
||||
|
||||
var issuer
|
||||
get() = otpModel.issuer
|
||||
set(value) {
|
||||
otpModel.issuer = value
|
||||
}
|
||||
|
||||
var secret
|
||||
get() = otpModel.secret
|
||||
private set(value) {
|
||||
otpModel.secret = value
|
||||
}
|
||||
|
||||
var counter
|
||||
get() = otpModel.counter
|
||||
set(value) {
|
||||
otpModel.counter = if (value < MIN_HOTP_COUNTER || value > MAX_HOTP_COUNTER) {
|
||||
TokenCalculator.HOTP_INITIAL_COUNTER
|
||||
throw NumberFormatException()
|
||||
} else value
|
||||
}
|
||||
|
||||
var period
|
||||
get() = otpModel.period
|
||||
set(value) {
|
||||
otpModel.period = if (value < MIN_TOTP_PERIOD || value > MAX_TOTP_PERIOD) {
|
||||
TokenCalculator.TOTP_DEFAULT_PERIOD
|
||||
throw NumberFormatException()
|
||||
} else value
|
||||
}
|
||||
|
||||
var digits
|
||||
get() = otpModel.digits
|
||||
set(value) {
|
||||
otpModel.digits = if (value < MIN_OTP_DIGITS|| value > MAX_OTP_DIGITS) {
|
||||
TokenCalculator.OTP_DEFAULT_DIGITS
|
||||
throw NumberFormatException()
|
||||
} else value
|
||||
}
|
||||
|
||||
var algorithm
|
||||
get() = otpModel.algorithm
|
||||
set(value) {
|
||||
otpModel.algorithm = value
|
||||
}
|
||||
|
||||
fun setUTF8Secret(secret: String) {
|
||||
if (secret.isNotEmpty())
|
||||
otpModel.secret = secret.toByteArray(Charset.forName("UTF-8"))
|
||||
else
|
||||
throw DecoderException()
|
||||
}
|
||||
|
||||
fun setHexSecret(secret: String) {
|
||||
if (secret.isNotEmpty())
|
||||
otpModel.secret = Hex.decodeHex(secret)
|
||||
else
|
||||
throw DecoderException()
|
||||
}
|
||||
|
||||
fun getBase32Secret(): String {
|
||||
return otpModel.secret?.let {
|
||||
Base32().encodeAsString(it)
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
fun setBase32Secret(secret: String) {
|
||||
if (secret.isNotEmpty() && checkBase32Secret(secret))
|
||||
otpModel.secret = Base32().decode(secret.toByteArray())
|
||||
else
|
||||
throw DecoderException()
|
||||
}
|
||||
|
||||
fun setBase64Secret(secret: String) {
|
||||
if (secret.isNotEmpty() && checkBase64Secret(secret))
|
||||
otpModel.secret = Base64().decode(secret.toByteArray())
|
||||
else
|
||||
throw DecoderException()
|
||||
}
|
||||
|
||||
val token: String
|
||||
get() {
|
||||
if (secret == null)
|
||||
return ""
|
||||
return when (type) {
|
||||
OtpType.HOTP -> TokenCalculator.HOTP(secret, counter, digits, algorithm)
|
||||
OtpType.TOTP -> when (tokenType) {
|
||||
OtpTokenType.STEAM -> TokenCalculator.TOTP_Steam(secret, period, digits, algorithm)
|
||||
else -> TokenCalculator.TOTP_RFC6238(secret, period, digits, algorithm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val secondsRemaining: Int
|
||||
get() = otpModel.period - (System.currentTimeMillis() / 1000 % otpModel.period).toInt()
|
||||
|
||||
fun shouldRefreshToken(): Boolean {
|
||||
return secondsRemaining == otpModel.period
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_HOTP_COUNTER = 1
|
||||
const val MAX_HOTP_COUNTER = Long.MAX_VALUE
|
||||
|
||||
const val MIN_TOTP_PERIOD = 1
|
||||
const val MAX_TOTP_PERIOD = 60
|
||||
|
||||
const val MIN_OTP_DIGITS = 4
|
||||
const val MAX_OTP_DIGITS = 18
|
||||
|
||||
fun checkBase32Secret(secret: String): Boolean {
|
||||
return (Pattern.matches("^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$", secret))
|
||||
}
|
||||
|
||||
fun checkBase64Secret(secret: String): Boolean {
|
||||
return (Pattern.matches("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", secret))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class OtpType {
|
||||
HOTP, // counter based
|
||||
TOTP; // time based
|
||||
}
|
||||
|
||||
enum class OtpTokenType {
|
||||
RFC4226, // HOTP
|
||||
RFC6238, // TOTP
|
||||
|
||||
// Proprietary
|
||||
STEAM; // TOTP Steam
|
||||
|
||||
override fun toString(): String {
|
||||
return when (this) {
|
||||
STEAM -> "steam"
|
||||
else -> super.toString()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getFromString(tokenType: String): OtpTokenType {
|
||||
return when (tokenType.toLowerCase(Locale.ENGLISH)) {
|
||||
"s", "steam" -> STEAM
|
||||
"hotp" -> RFC4226
|
||||
else -> RFC6238
|
||||
}
|
||||
}
|
||||
|
||||
fun getTotpTokenTypeValues(getProprietaryElements: Boolean = true): Array<OtpTokenType> {
|
||||
return if (getProprietaryElements)
|
||||
arrayOf(RFC6238, STEAM)
|
||||
else
|
||||
arrayOf(RFC6238)
|
||||
}
|
||||
|
||||
fun getHotpTokenTypeValues(): Array<OtpTokenType> {
|
||||
return arrayOf(RFC4226)
|
||||
}
|
||||
}
|
||||
}
|
||||
386
app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
Normal file
386
app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
Normal file
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePass DX.
|
||||
*
|
||||
* KeePass DX is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
* General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* KeePass DX is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with KeePass DX. If not,
|
||||
* see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* This code is based on KeePassXC code
|
||||
* https://github.com/keepassxreboot/keepassxc/blob/master/src/totp/totp.cpp
|
||||
* https://github.com/keepassxreboot/keepassxc/blob/master/src/core/Entry.cpp
|
||||
*/
|
||||
package com.kunzisoft.keepass.otp
|
||||
|
||||
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.TokenCalculator.*
|
||||
import java.lang.Exception
|
||||
import java.lang.StringBuilder
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object OtpEntryFields {
|
||||
|
||||
private val TAG = OtpEntryFields::class.java.name
|
||||
|
||||
// Field from KeePassXC
|
||||
private const val OTP_FIELD = "otp"
|
||||
|
||||
// URL parameters (https://github.com/google/google-authenticator/wiki/Key-Uri-Format)
|
||||
private const val OTP_SCHEME = "otpauth"
|
||||
private const val TOTP_AUTHORITY = "totp" // time-based
|
||||
private const val HOTP_AUTHORITY = "hotp" // counter-based
|
||||
private const val ALGORITHM_URL_PARAM = "algorithm"
|
||||
private const val ISSUER_URL_PARAM = "issuer"
|
||||
private const val SECRET_URL_PARAM = "secret"
|
||||
private const val DIGITS_URL_PARAM = "digits"
|
||||
private const val PERIOD_URL_PARAM = "period"
|
||||
private const val ENCODER_URL_PARAM = "encoder"
|
||||
private const val COUNTER_URL_PARAM = "counter"
|
||||
|
||||
// Key-values (maybe from plugin or old KeePassXC)
|
||||
private const val SEED_KEY = "key"
|
||||
private const val DIGITS_KEY = "size"
|
||||
private const val STEP_KEY = "step"
|
||||
|
||||
// HmacOtp KeePass2 values (https://keepass.info/help/base/placeholders.html#hmacotp)
|
||||
private const val HMACOTP_SECRET_FIELD = "HmacOtp-Secret"
|
||||
private const val HMACOTP_SECRET_HEX_FIELD = "HmacOtp-Secret-Hex"
|
||||
private const val HMACOTP_SECRET_BASE32_FIELD = "HmacOtp-Secret-Base32"
|
||||
private const val HMACOTP_SECRET_BASE64_FIELD = "HmacOtp-Secret-Base64"
|
||||
private const val HMACOTP_SECRET_COUNTER_FIELD = "HmacOtp-Counter"
|
||||
|
||||
// Custom fields (maybe from plugin)
|
||||
private const val TOTP_SEED_FIELD = "TOTP Seed"
|
||||
private const val TOTP_SETTING_FIELD = "TOTP Settings"
|
||||
|
||||
// Token field, use dynamically to generate OTP token
|
||||
const val OTP_TOKEN_FIELD = "OTP Token"
|
||||
|
||||
// Logical breakdown of key=value regex. the final string is as follows:
|
||||
// [^&=\s]+=[^&=\s]+(&[^&=\s]+=[^&=\s]+)*
|
||||
private const val validKeyValue = "[^&=\\s]+"
|
||||
private const val validKeyValuePair = "$validKeyValue=$validKeyValue"
|
||||
private const val validKeyValueRegex = "$validKeyValuePair&($validKeyValuePair)*"
|
||||
|
||||
/**
|
||||
* Parse fields of an entry to retrieve an OtpElement
|
||||
*/
|
||||
fun parseFields(getField: (id: String) -> String?): OtpElement? {
|
||||
val otpElement = OtpElement()
|
||||
// OTP (HOTP/TOTP) from URL and field from KeePassXC
|
||||
if (parseOTPUri(getField, otpElement))
|
||||
return otpElement
|
||||
// TOTP from key values (maybe plugin or old KeePassXC)
|
||||
if (parseTOTPKeyValues(getField, otpElement))
|
||||
return otpElement
|
||||
// TOTP from custom field
|
||||
if (parseTOTPFromField(getField, otpElement))
|
||||
return otpElement
|
||||
// HOTP fields from KeePass 2
|
||||
if (parseHOTPFromField(getField, otpElement))
|
||||
return otpElement
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a secret value from a URI. The format will be:
|
||||
*
|
||||
* otpauth://totp/user@example.com?secret=FFF...
|
||||
*
|
||||
* otpauth://hotp/user@example.com?secret=FFF...&counter=123
|
||||
*/
|
||||
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val otpPlainText = getField(OTP_FIELD)
|
||||
if (otpPlainText != null && otpPlainText.isNotEmpty()) {
|
||||
val uri = Uri.parse(replaceChars(otpPlainText))
|
||||
|
||||
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
|
||||
Log.e(TAG, "Invalid or missing scheme in uri")
|
||||
return false
|
||||
}
|
||||
|
||||
val authority = uri.authority
|
||||
if (TOTP_AUTHORITY == authority) {
|
||||
otpElement.type = OtpType.TOTP
|
||||
|
||||
} else if (HOTP_AUTHORITY == authority) {
|
||||
otpElement.type = OtpType.HOTP
|
||||
|
||||
val counterParameter = uri.getQueryParameter(COUNTER_URL_PARAM)
|
||||
if (counterParameter != null) {
|
||||
try {
|
||||
otpElement.counter = counterParameter.toLongOrNull() ?: HOTP_INITIAL_COUNTER
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.e(TAG, "Invalid counter in uri")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Log.e(TAG, "Invalid or missing authority in uri")
|
||||
return false
|
||||
}
|
||||
|
||||
val nameParam = validateAndGetNameInPath(uri.path)
|
||||
if (nameParam != null && nameParam.isNotEmpty())
|
||||
otpElement.name = nameParam
|
||||
|
||||
val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM)
|
||||
if (issuerParam != null && issuerParam.isNotEmpty())
|
||||
otpElement.issuer = issuerParam
|
||||
|
||||
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
|
||||
if (secretParam != null && secretParam.isNotEmpty()) {
|
||||
try {
|
||||
otpElement.setBase32Secret(secretParam)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve OTP secret.", exception)
|
||||
}
|
||||
}
|
||||
|
||||
val encoderParam = uri.getQueryParameter(ENCODER_URL_PARAM)
|
||||
if (encoderParam != null && encoderParam.isNotEmpty())
|
||||
otpElement.tokenType = OtpTokenType.getFromString(encoderParam)
|
||||
|
||||
val digitsParam = uri.getQueryParameter(DIGITS_URL_PARAM)
|
||||
if (digitsParam != null && digitsParam.isNotEmpty())
|
||||
otpElement.digits = try {
|
||||
digitsParam.toIntOrNull() ?: OTP_DEFAULT_DIGITS
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve OTP digits.", exception)
|
||||
OTP_DEFAULT_DIGITS
|
||||
}
|
||||
|
||||
val counterParam = uri.getQueryParameter(COUNTER_URL_PARAM)
|
||||
if (counterParam != null && counterParam.isNotEmpty())
|
||||
otpElement.counter = try {
|
||||
counterParam.toLongOrNull() ?: HOTP_INITIAL_COUNTER
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve HOTP counter.", exception)
|
||||
HOTP_INITIAL_COUNTER
|
||||
}
|
||||
|
||||
val stepParam = uri.getQueryParameter(PERIOD_URL_PARAM)
|
||||
if (stepParam != null && stepParam.isNotEmpty())
|
||||
otpElement.period = try {
|
||||
stepParam.toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve TOTP period.", exception)
|
||||
TOTP_DEFAULT_PERIOD
|
||||
}
|
||||
|
||||
val algorithmParam = uri.getQueryParameter(ALGORITHM_URL_PARAM)
|
||||
if (algorithmParam != null && algorithmParam.isNotEmpty()) {
|
||||
otpElement.algorithm = HashAlgorithm.fromString(algorithmParam)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun buildOtpUri(otpElement: OtpElement, title: String?, username: String?): Uri {
|
||||
val counterOrPeriodLabel: String
|
||||
val counterOrPeriodValue: String
|
||||
val otpAuthority: String
|
||||
|
||||
when (otpElement.type) {
|
||||
OtpType.TOTP -> {
|
||||
counterOrPeriodLabel = PERIOD_URL_PARAM
|
||||
counterOrPeriodValue = otpElement.period.toString()
|
||||
otpAuthority = TOTP_AUTHORITY
|
||||
}
|
||||
else -> {
|
||||
counterOrPeriodLabel = COUNTER_URL_PARAM
|
||||
counterOrPeriodValue = otpElement.counter.toString()
|
||||
otpAuthority = HOTP_AUTHORITY
|
||||
}
|
||||
}
|
||||
val issuer =
|
||||
if (title != null && title.isNotEmpty())
|
||||
replaceCharsForUrl(title)
|
||||
else
|
||||
replaceCharsForUrl(otpElement.issuer)
|
||||
val accountName =
|
||||
if (username != null && username.isNotEmpty())
|
||||
replaceCharsForUrl(username)
|
||||
else
|
||||
replaceCharsForUrl(otpElement.name)
|
||||
val uriString = StringBuilder("otpauth://$otpAuthority/$issuer:$accountName" +
|
||||
"?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" +
|
||||
"&$counterOrPeriodLabel=$counterOrPeriodValue" +
|
||||
"&$DIGITS_URL_PARAM=${otpElement.digits}" +
|
||||
"&$ISSUER_URL_PARAM=$issuer")
|
||||
if (otpElement.tokenType == OtpTokenType.STEAM) {
|
||||
uriString.append("&$ENCODER_URL_PARAM=${otpElement.tokenType}")
|
||||
} else {
|
||||
uriString.append("&$ALGORITHM_URL_PARAM=${otpElement.algorithm}")
|
||||
}
|
||||
|
||||
return Uri.parse(uriString.toString())
|
||||
}
|
||||
|
||||
private fun replaceCharsForUrl(parameter: String): String {
|
||||
return URLEncoder.encode(replaceChars(parameter), "UTF-8")
|
||||
}
|
||||
|
||||
private fun replaceChars(parameter: String): String {
|
||||
return parameter.replace("([\\r|\\n|\\t|\\s|\\u00A0]+)", "")
|
||||
}
|
||||
|
||||
private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val plainText = getField(OTP_FIELD)
|
||||
if (plainText != null && plainText.isNotEmpty()) {
|
||||
if (Pattern.matches(validKeyValueRegex, plainText)) {
|
||||
try {
|
||||
// KeeOtp string format
|
||||
val query = breakDownKeyValuePairs(plainText)
|
||||
|
||||
var secretString = query[SEED_KEY]
|
||||
if (secretString == null)
|
||||
secretString = ""
|
||||
otpElement.setBase32Secret(secretString)
|
||||
otpElement.digits = query[DIGITS_KEY]?.toIntOrNull() ?: OTP_DEFAULT_DIGITS
|
||||
otpElement.period = query[STEP_KEY]?.toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||
|
||||
otpElement.type = OtpType.TOTP
|
||||
return true
|
||||
} catch (exception: Exception) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Malformed
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun parseTOTPFromField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val seedField = getField(TOTP_SEED_FIELD) ?: return false
|
||||
try {
|
||||
otpElement.setBase32Secret(seedField)
|
||||
|
||||
val settingsField = getField(TOTP_SETTING_FIELD)
|
||||
if (settingsField != null) {
|
||||
// Regex match, sync with shortNameToEncoder
|
||||
val pattern = Pattern.compile("(\\d+);((?:\\d+)|S)")
|
||||
val matcher = pattern.matcher(settingsField)
|
||||
if (!matcher.matches()) {
|
||||
// malformed
|
||||
return false
|
||||
}
|
||||
otpElement.period = matcher.group(1).toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||
otpElement.tokenType = OtpTokenType.getFromString(matcher.group(2))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
return false
|
||||
}
|
||||
otpElement.type = OtpType.TOTP
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseHOTPFromField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val secretField = getField(HMACOTP_SECRET_FIELD)
|
||||
val secretHexField = getField(HMACOTP_SECRET_HEX_FIELD)
|
||||
val secretBase32Field = getField(HMACOTP_SECRET_BASE32_FIELD)
|
||||
val secretBase64Field = getField(HMACOTP_SECRET_BASE64_FIELD)
|
||||
try {
|
||||
when {
|
||||
secretField != null -> otpElement.setUTF8Secret(secretField)
|
||||
secretHexField != null -> otpElement.setHexSecret(secretHexField)
|
||||
secretBase32Field != null -> otpElement.setBase32Secret(secretBase32Field)
|
||||
secretBase64Field != null -> otpElement.setBase64Secret(secretBase64Field)
|
||||
else -> return false
|
||||
}
|
||||
|
||||
val secretCounterField = getField(HMACOTP_SECRET_COUNTER_FIELD)
|
||||
if (secretCounterField != null) {
|
||||
otpElement.counter = secretCounterField.toLongOrNull() ?: HOTP_INITIAL_COUNTER
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
return false
|
||||
}
|
||||
|
||||
otpElement.type = OtpType.HOTP
|
||||
return true
|
||||
}
|
||||
|
||||
private fun validateAndGetNameInPath(path: String?): String? {
|
||||
if (path == null || !path.startsWith("/")) {
|
||||
return null
|
||||
}
|
||||
// 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.
|
||||
} else name
|
||||
}
|
||||
|
||||
private fun breakDownKeyValuePairs(pairs: String): HashMap<String, String> {
|
||||
val elements = pairs.split("&".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
val output = HashMap<String, String>()
|
||||
for (element in elements) {
|
||||
val pair = element.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
output[pair[0]] = pair[1]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Otp field from an OtpElement
|
||||
*/
|
||||
fun buildOtpField(otpElement: OtpElement, title: String?, username: String?): Field {
|
||||
return Field(OTP_FIELD, ProtectedString(true,
|
||||
buildOtpUri(otpElement, title, username).toString()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build new generated fields in a new list from [fieldsToParse] in parameter,
|
||||
* Remove parameters fields use to generate auto fields
|
||||
*/
|
||||
fun generateAutoFields(fieldsToParse: MutableList<Field>): MutableList<Field> {
|
||||
val newCustomFields: MutableList<Field> = ArrayList(fieldsToParse)
|
||||
// Remove parameter fields
|
||||
val otpField = Field(OTP_FIELD)
|
||||
val totpSeedField = Field(TOTP_SEED_FIELD)
|
||||
val totpSettingField = Field(TOTP_SETTING_FIELD)
|
||||
val hmacOtpSecretField = Field(HMACOTP_SECRET_FIELD)
|
||||
val hmacOtpSecretHewField = Field(HMACOTP_SECRET_HEX_FIELD)
|
||||
val hmacOtpSecretBase32Field = Field(HMACOTP_SECRET_BASE32_FIELD)
|
||||
val hmacOtpSecretBase64Field = Field(HMACOTP_SECRET_BASE64_FIELD)
|
||||
val hmacOtpSecretCounterField = Field(HMACOTP_SECRET_COUNTER_FIELD)
|
||||
newCustomFields.remove(otpField)
|
||||
newCustomFields.remove(totpSeedField)
|
||||
newCustomFields.remove(totpSettingField)
|
||||
newCustomFields.remove(hmacOtpSecretField)
|
||||
newCustomFields.remove(hmacOtpSecretHewField)
|
||||
newCustomFields.remove(hmacOtpSecretBase32Field)
|
||||
newCustomFields.remove(hmacOtpSecretBase64Field)
|
||||
newCustomFields.remove(hmacOtpSecretCounterField)
|
||||
// Empty auto generated OTP Token field
|
||||
if (fieldsToParse.contains(otpField)
|
||||
|| fieldsToParse.contains(totpSeedField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretHewField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretBase32Field)
|
||||
|| fieldsToParse.contains(hmacOtpSecretBase64Field)
|
||||
)
|
||||
newCustomFields.add(Field(OTP_TOKEN_FIELD))
|
||||
return newCustomFields
|
||||
}
|
||||
}
|
||||
132
app/src/main/java/com/kunzisoft/keepass/otp/TokenCalculator.java
Normal file
132
app/src/main/java/com/kunzisoft/keepass/otp/TokenCalculator.java
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePass DX.
|
||||
*
|
||||
* KeePass DX is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
* General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* KeePass DX is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with KeePass DX. If not,
|
||||
* see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* This code is based on andOTP code
|
||||
* https://github.com/andOTP/andOTP/blob/master/app/src/main/java/org/shadowice/flocke/andotp/
|
||||
* Utilities/TokenCalculator.java
|
||||
*/
|
||||
package com.kunzisoft.keepass.otp;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class TokenCalculator {
|
||||
public static final int TOTP_DEFAULT_PERIOD = 30;
|
||||
public static final long HOTP_INITIAL_COUNTER = 1;
|
||||
public static final int OTP_DEFAULT_DIGITS = 6;
|
||||
public static final int STEAM_DEFAULT_DIGITS = 5;
|
||||
public static final HashAlgorithm OTP_DEFAULT_ALGORITHM = HashAlgorithm.SHA1;
|
||||
|
||||
private static final char[] STEAMCHARS = new char[] {
|
||||
'2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C',
|
||||
'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q',
|
||||
'R', 'T', 'V', 'W', 'X', 'Y'
|
||||
};
|
||||
|
||||
public enum HashAlgorithm {
|
||||
SHA1, SHA256, SHA512;
|
||||
|
||||
static HashAlgorithm fromString(String hashString) {
|
||||
String hash = hashString.replace("[^a-zA-Z0-9]", "").toUpperCase();
|
||||
try {
|
||||
return valueOf(hash);
|
||||
} catch (Exception e) {
|
||||
return OTP_DEFAULT_ALGORITHM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] generateHash(HashAlgorithm algorithm, byte[] key, byte[] data)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
String algo = "Hmac" + algorithm.toString();
|
||||
|
||||
Mac mac = Mac.getInstance(algo);
|
||||
mac.init(new SecretKeySpec(key, algo));
|
||||
|
||||
return mac.doFinal(data);
|
||||
}
|
||||
|
||||
public static int TOTP_RFC6238(byte[] secret, int period, long time, int digits, HashAlgorithm algorithm) {
|
||||
int fullToken = TOTP(secret, period, time, algorithm);
|
||||
int div = (int) Math.pow(10, digits);
|
||||
|
||||
return fullToken % div;
|
||||
}
|
||||
|
||||
public static String TOTP_RFC6238(byte[] secret, int period, int digits, HashAlgorithm algorithm) {
|
||||
return formatTokenString(TOTP_RFC6238(secret, period, System.currentTimeMillis() / 1000, digits, algorithm), digits);
|
||||
}
|
||||
|
||||
public static String TOTP_Steam(byte[] secret, int period, int digits, HashAlgorithm algorithm) {
|
||||
int fullToken = TOTP(secret, period, System.currentTimeMillis() / 1000, algorithm);
|
||||
|
||||
StringBuilder tokenBuilder = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < digits; i++) {
|
||||
tokenBuilder.append(STEAMCHARS[fullToken % STEAMCHARS.length]);
|
||||
fullToken /= STEAMCHARS.length;
|
||||
}
|
||||
|
||||
return tokenBuilder.toString();
|
||||
}
|
||||
|
||||
public static String HOTP(byte[] secret, long counter, int digits, HashAlgorithm algorithm) {
|
||||
int fullToken = HOTP(secret, counter, algorithm);
|
||||
int div = (int) Math.pow(10, digits);
|
||||
|
||||
return formatTokenString(fullToken % div, digits);
|
||||
}
|
||||
|
||||
private static int TOTP(byte[] key, int period, long time, HashAlgorithm algorithm) {
|
||||
return HOTP(key, time / period, algorithm);
|
||||
}
|
||||
|
||||
private static int HOTP(byte[] key, long counter, HashAlgorithm algorithm) {
|
||||
int r = 0;
|
||||
|
||||
try {
|
||||
byte[] data = ByteBuffer.allocate(8).putLong(counter).array();
|
||||
byte[] hash = generateHash(algorithm, key, data);
|
||||
|
||||
int offset = hash[hash.length - 1] & 0xF;
|
||||
|
||||
int binary = (hash[offset] & 0x7F) << 0x18;
|
||||
binary |= (hash[offset + 1] & 0xFF) << 0x10;
|
||||
binary |= (hash[offset + 2] & 0xFF) << 0x08;
|
||||
binary |= (hash[offset + 3] & 0xFF);
|
||||
|
||||
r = binary;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
public static String formatTokenString(int token, int digits) {
|
||||
NumberFormat numberFormat = NumberFormat.getInstance(Locale.ENGLISH);
|
||||
numberFormat.setMinimumIntegerDigits(digits);
|
||||
numberFormat.setGroupingUsed(false);
|
||||
|
||||
return numberFormat.format(token);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import android.view.ViewGroup
|
||||
import android.widget.RadioButton
|
||||
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
||||
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||
|
||||
import java.util.ArrayList
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with KeePass DX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.database
|
||||
package com.kunzisoft.keepass.utils
|
||||
|
||||
import android.content.res.Resources
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -36,6 +37,8 @@ import com.kunzisoft.keepass.adapters.EntryHistoryAdapter
|
||||
import com.kunzisoft.keepass.database.element.EntryVersioned
|
||||
import com.kunzisoft.keepass.database.element.PwDate
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpType
|
||||
import java.util.*
|
||||
|
||||
class EntryContentsView @JvmOverloads constructor(context: Context,
|
||||
@@ -54,6 +57,13 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
|
||||
private val passwordView: TextView
|
||||
private val passwordActionView: ImageView
|
||||
|
||||
private val otpContainerView: View
|
||||
private val otpLabelView: TextView
|
||||
private val otpView: TextView
|
||||
private val otpActionView: ImageView
|
||||
|
||||
private var otpRunnable: Runnable? = null
|
||||
|
||||
private val urlContainerView: View
|
||||
private val urlView: TextView
|
||||
|
||||
@@ -93,6 +103,11 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
|
||||
passwordView = findViewById(R.id.entry_password)
|
||||
passwordActionView = findViewById(R.id.entry_password_action_image)
|
||||
|
||||
otpContainerView = findViewById(R.id.entry_otp_container)
|
||||
otpLabelView = findViewById(R.id.entry_otp_label)
|
||||
otpView = findViewById(R.id.entry_otp)
|
||||
otpActionView = findViewById(R.id.entry_otp_action_image)
|
||||
|
||||
urlContainerView = findViewById(R.id.entry_url_container)
|
||||
urlView = findViewById(R.id.entry_url)
|
||||
|
||||
@@ -199,6 +214,56 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
|
||||
}
|
||||
}
|
||||
|
||||
fun assignOtp(otpElement: OtpElement?,
|
||||
otpProgressView: ProgressBar?,
|
||||
onClickListener: OnClickListener) {
|
||||
otpContainerView.removeCallbacks(otpRunnable)
|
||||
|
||||
if (otpElement != null) {
|
||||
otpContainerView.visibility = View.VISIBLE
|
||||
|
||||
if (otpElement.token.isEmpty()) {
|
||||
otpView.text = context.getString(R.string.error_invalid_OTP)
|
||||
otpActionView.setColorFilter(ContextCompat.getColor(context, R.color.grey_dark))
|
||||
assignOtpCopyListener(null)
|
||||
} else {
|
||||
assignOtpCopyListener(onClickListener)
|
||||
otpView.text = otpElement.token
|
||||
otpLabelView.text = otpElement.type.name
|
||||
|
||||
when (otpElement.type) {
|
||||
// Only add token if HOTP
|
||||
OtpType.HOTP -> {
|
||||
otpProgressView?.visibility = View.GONE
|
||||
}
|
||||
// Refresh view if TOTP
|
||||
OtpType.TOTP -> {
|
||||
otpProgressView?.apply {
|
||||
max = otpElement.period
|
||||
progress = otpElement.secondsRemaining
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
otpRunnable = Runnable {
|
||||
if (otpElement.shouldRefreshToken()) {
|
||||
otpView.text = otpElement.token
|
||||
}
|
||||
otpProgressView?.progress = otpElement.secondsRemaining
|
||||
otpContainerView.postDelayed(otpRunnable, 1000)
|
||||
}
|
||||
otpContainerView.post(otpRunnable)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
otpContainerView.visibility = View.GONE
|
||||
otpProgressView?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun assignOtpCopyListener(onClickListener: OnClickListener?) {
|
||||
otpActionView.setOnClickListener(onClickListener)
|
||||
}
|
||||
|
||||
fun assignURL(url: String?) {
|
||||
if (url != null && url.isNotEmpty()) {
|
||||
urlContainerView.visibility = View.VISIBLE
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.kunzisoft.keepass.icons.IconDrawableFactory
|
||||
import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||
import com.kunzisoft.keepass.icons.assignDefaultDatabaseIcon
|
||||
import com.kunzisoft.keepass.model.Field
|
||||
import java.util.HashMap
|
||||
|
||||
class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@@ -37,7 +36,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
||||
val generatePasswordView: View
|
||||
private val entryCommentView: EditText
|
||||
private val entryExtraFieldsContainer: ViewGroup
|
||||
val addNewFieldView: View
|
||||
val addNewFieldButton: View
|
||||
|
||||
private var iconColor: Int = 0
|
||||
|
||||
@@ -56,7 +55,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
||||
generatePasswordView = findViewById(R.id.entry_edit_generate_button)
|
||||
entryCommentView = findViewById(R.id.entry_edit_notes)
|
||||
entryExtraFieldsContainer = findViewById(R.id.entry_edit_advanced_container)
|
||||
addNewFieldView = findViewById(R.id.entry_edit_add_new_field)
|
||||
addNewFieldButton = findViewById(R.id.entry_edit_add_new_field)
|
||||
|
||||
// Retrieve the textColor to tint the icon
|
||||
val taIconColor = context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColor))
|
||||
@@ -138,7 +137,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
||||
}
|
||||
|
||||
fun allowCustomField(allow: Boolean, action: () -> Unit) {
|
||||
addNewFieldView.apply {
|
||||
addNewFieldButton.apply {
|
||||
if (allow) {
|
||||
visibility = View.VISIBLE
|
||||
setOnClickListener { action.invoke() }
|
||||
@@ -166,17 +165,42 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new view to fill in the information of the customized field
|
||||
* Add a new view to fill in the information of the customized field and focus it
|
||||
*/
|
||||
fun addNewCustomField(name: String = "", value: ProtectedString = ProtectedString(false, "")) {
|
||||
fun addEmptyCustomField() {
|
||||
val entryEditCustomField = EntryEditCustomField(context).apply {
|
||||
setData(name, value)
|
||||
setFontVisibility(fontInVisibility)
|
||||
requestFocus()
|
||||
}
|
||||
entryExtraFieldsContainer.addView(entryEditCustomField)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom field or create a new one if doesn't exists
|
||||
*/
|
||||
fun putCustomField(name: String,
|
||||
value: ProtectedString = ProtectedString()) {
|
||||
var updateField = false
|
||||
for (i in 0..entryExtraFieldsContainer.childCount) {
|
||||
try {
|
||||
val extraFieldView = entryExtraFieldsContainer.getChildAt(i) as EntryEditCustomField?
|
||||
if (extraFieldView?.label == name) {
|
||||
extraFieldView.setData(name, value, fontInVisibility)
|
||||
updateField = true
|
||||
break
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
// Simply ignore when child view is not a custom field
|
||||
}
|
||||
}
|
||||
if (!updateField) {
|
||||
val entryEditCustomField = EntryEditCustomField(context).apply {
|
||||
setData(name, value, fontInVisibility)
|
||||
}
|
||||
entryExtraFieldsContainer.addView(entryEditCustomField)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate or not the entry form
|
||||
*
|
||||
|
||||
@@ -66,13 +66,14 @@ class EntryEditCustomField @JvmOverloads constructor(context: Context,
|
||||
protectionCheckView = findViewById(R.id.protection)
|
||||
}
|
||||
|
||||
fun setData(label: String?, value: ProtectedString?) {
|
||||
fun setData(label: String?, value: ProtectedString?, fontInVisibility: Boolean) {
|
||||
if (label != null)
|
||||
labelView.text = label
|
||||
if (value != null) {
|
||||
valueView.setText(value.toString())
|
||||
protectionCheckView.isChecked = value.isProtected
|
||||
}
|
||||
setFontVisibility(fontInVisibility)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
9
app/src/main/res/drawable/ic_av_timer_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_av_timer_white_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11,17c0,0.55 0.45,1 1,1s1,-0.45 1,-1 -0.45,-1 -1,-1 -1,0.45 -1,1zM11,3v4h2L13,5.08c3.39,0.49 6,3.39 6,6.92 0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-1.68 0.59,-3.22 1.58,-4.42L12,13l1.41,-1.41 -6.8,-6.8v0.02C4.42,6.45 3,9.05 3,12c0,4.97 4.02,9 9,9 4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9h-1zM18,12c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1 0.45,1 1,1 1,-0.45 1,-1zM6,12c0,0.55 0.45,1 1,1s1,-0.45 1,-1 -0.45,-1 -1,-1 -1,0.45 -1,1z"/>
|
||||
</vector>
|
||||
@@ -72,6 +72,17 @@
|
||||
tools:targetApi="lollipop">
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
<ProgressBar
|
||||
android:visibility="gone"
|
||||
android:id="@+id/entry_progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:indeterminate="false"
|
||||
android:progress="10"
|
||||
android:max="30"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp" />
|
||||
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
|
||||
183
app/src/main/res/layout/fragment_set_otp.xml
Normal file
183
app/src/main/res/layout/fragment_set_otp.xml
Normal file
@@ -0,0 +1,183 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:padding="@dimen/default_margin"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:importantForAutofill="noExcludeDescendants"
|
||||
tools:targetApi="o">
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/card_view_otp_selection"
|
||||
android:layout_margin="4dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="4dp">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/default_margin"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" >
|
||||
<TextView
|
||||
android:id="@+id/setup_otp_type_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:text="@string/otp_type"
|
||||
style="@style/KeepassDXStyle.TextAppearance.LabelTextStyle"/>
|
||||
|
||||
<!-- HOTP / TOTP -->
|
||||
<androidx.appcompat.widget.AppCompatSpinner
|
||||
android:id="@+id/setup_otp_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@+id/setup_otp_type_label"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatSpinner
|
||||
android:id="@+id/setup_otp_token_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="48dp"
|
||||
app:layout_constraintTop_toBottomOf="@+id/setup_otp_type_label"
|
||||
app:layout_constraintStart_toEndOf="@+id/setup_otp_type"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!-- Secret -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/setup_otp_secret_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:passwordToggleEnabled="true"
|
||||
app:passwordToggleTint="?attr/colorAccent">
|
||||
<androidx.appcompat.widget.AppCompatEditText
|
||||
android:id="@+id/setup_otp_secret"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:importantForAccessibility="no"
|
||||
android:importantForAutofill="no"
|
||||
android:hint="@string/otp_secret"
|
||||
tools:targetApi="jelly_bean" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/card_view_otp_advanced"
|
||||
android:layout_margin="4dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="4dp">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/default_margin"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Algorithm -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/otp_algorithm"
|
||||
style="@style/KeepassDXStyle.TextAppearance.LabelTextStyle"/>
|
||||
<androidx.appcompat.widget.AppCompatSpinner
|
||||
android:id="@+id/setup_otp_algorithm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
<!-- Period / Counter -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/setup_otp_period_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="visible"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/vertical_guideline">
|
||||
<androidx.appcompat.widget.AppCompatEditText
|
||||
android:id="@+id/setup_otp_period"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberSigned"
|
||||
android:importantForAccessibility="no"
|
||||
android:importantForAutofill="no"
|
||||
android:hint="@string/otp_period"
|
||||
tools:text="30"
|
||||
android:maxLength="2"
|
||||
android:digits="0123456789"
|
||||
tools:targetApi="jelly_bean" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/setup_otp_counter_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/vertical_guideline">
|
||||
<androidx.appcompat.widget.AppCompatEditText
|
||||
android:id="@+id/setup_otp_counter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberSigned"
|
||||
android:importantForAccessibility="no"
|
||||
android:importantForAutofill="no"
|
||||
android:hint="@string/otp_counter"
|
||||
tools:text="1"
|
||||
tools:targetApi="jelly_bean" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/vertical_guideline"
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_percent="0.5" />
|
||||
|
||||
<!-- Digits -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/setup_otp_digits_label"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/vertical_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
<androidx.appcompat.widget.AppCompatEditText
|
||||
android:id="@+id/setup_otp_digits"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberSigned"
|
||||
android:importantForAccessibility="no"
|
||||
android:importantForAutofill="no"
|
||||
android:hint="@string/otp_digits"
|
||||
tools:text="6"
|
||||
android:maxLength="2"
|
||||
android:digits="0123456789"
|
||||
tools:targetApi="jelly_bean" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -105,6 +105,38 @@
|
||||
android:tint="?attr/colorAccent" />
|
||||
</RelativeLayout>
|
||||
|
||||
<!-- OTP -->
|
||||
<RelativeLayout
|
||||
android:id="@+id/entry_otp_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone">
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:id="@+id/entry_otp_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/entry_otp"
|
||||
style="@style/KeepassDXStyle.TextAppearance.LabelTextStyle" />
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:id="@+id/entry_otp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/entry_otp_label"
|
||||
android:layout_toLeftOf="@+id/entry_otp_action_image"
|
||||
android:layout_toStartOf="@+id/entry_otp_action_image"
|
||||
android:textIsSelectable="true"
|
||||
style="@style/KeepassDXStyle.TextAppearance.TextEntryItem" />
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/entry_otp_action_image"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_below="@+id/entry_otp_label"
|
||||
android:src="@drawable/ic_content_copy_white_24dp"
|
||||
android:tint="?attr/colorAccent" />
|
||||
</RelativeLayout>
|
||||
|
||||
<!-- URL -->
|
||||
<LinearLayout
|
||||
android:id="@+id/entry_url_container"
|
||||
|
||||
27
app/src/main/res/menu/edit_entry.xml
Normal file
27
app/src/main/res/menu/edit_entry.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
|
||||
This file is part of KeePass DX.
|
||||
|
||||
KeePass DX is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
KeePass DX is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with KeePass DX. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item android:id="@+id/menu_add_otp"
|
||||
android:icon="@drawable/ic_av_timer_white_24dp"
|
||||
android:title="@string/entry_setup_otp"
|
||||
android:orderInCategory="91"
|
||||
app:showAsAction="ifRoom" />
|
||||
</menu>
|
||||
@@ -87,6 +87,14 @@
|
||||
<string name="entry_password">Password</string>
|
||||
<string name="entry_save">Save</string>
|
||||
<string name="entry_title">Title</string>
|
||||
<string name="entry_setup_otp">Setup One-Time Password</string>
|
||||
<string name="otp_type">OTP type</string>
|
||||
<string name="otp_secret">Secret</string>
|
||||
<string name="otp_period">Period (seconds)</string>
|
||||
<string name="otp_counter">Counter</string>
|
||||
<string name="otp_digits">Digits</string>
|
||||
<string name="otp_algorithm">Algorithm</string>
|
||||
<string name="entry_otp">OTP</string>
|
||||
<string name="entry_url">URL</string>
|
||||
<string name="entry_user_name">Username</string>
|
||||
<string name="error_arc4">The ARCFOUR stream cipher is not supported.</string>
|
||||
@@ -94,6 +102,7 @@
|
||||
<string name="error_file_not_create">Could not create file:</string>
|
||||
<string name="error_invalid_db">Could not read database.</string>
|
||||
<string name="error_invalid_path">Make sure the path is correct.</string>
|
||||
<string name="error_invalid_OTP">Invalid OTP secret.</string>
|
||||
<string name="error_no_name">Enter a name.</string>
|
||||
<string name="error_nokeyfile">Select a keyfile.</string>
|
||||
<string name="error_out_of_memory">No memory to load your entire database.</string>
|
||||
@@ -112,6 +121,10 @@
|
||||
<string name="error_copy_entry_here">You can not copy an entry here.</string>
|
||||
<string name="error_copy_group_here">You can not copy a group here.</string>
|
||||
<string name="error_create_database_file">Unable to create database with this password and key file.</string>
|
||||
<string name="error_otp_secret_key">Secret key lust be in Base32 format.</string>
|
||||
<string name="error_otp_counter">Counter must be between %1$d and %2$d.</string>
|
||||
<string name="error_otp_period">Period must be between %1$d and %2$d seconds.</string>
|
||||
<string name="error_otp_digits">Token must contains %1$d to %2$d digits.</string>
|
||||
<string name="field_name">Field name</string>
|
||||
<string name="field_value">Field value</string>
|
||||
<string name="file_not_found_content">Could not find file. Try reopening it from your file browser.</string>
|
||||
@@ -400,7 +413,7 @@
|
||||
<string name="html_text_dev_feature_upgrade">Do not forget to keep your app up to date by installing new versions.</string>
|
||||
<string name="download">Download</string>
|
||||
<string name="contribute">Contribute</string>
|
||||
<!-- Algorithms -->
|
||||
<!-- Encryption Algorithms -->
|
||||
<string name="encryption_rijndael">Rijndael (AES)</string>
|
||||
<string name="encryption_twofish">Twofish</string>
|
||||
<string name="encryption_chacha20">ChaCha20</string>
|
||||
|
||||
@@ -316,6 +316,8 @@
|
||||
<style name="KeepassDXStyle.TextAppearance.LabelTextStyle" parent="KeepassDXStyle.TextAppearance">
|
||||
<item name="android:textColor">?attr/colorAccent</item>
|
||||
<item name="android:textSize">12sp</item>
|
||||
<item name="android:paddingLeft">4dp</item>
|
||||
<item name="android:paddingRight">4dp</item>
|
||||
</style>
|
||||
<style name="KeepassDXStyle.TextAppearance.LabelTableTextStyle" parent="KeepassDXStyle.TextAppearance">
|
||||
<item name="android:textSize">12sp</item>
|
||||
|
||||
Reference in New Issue
Block a user