Merge branch 'feature/TOTP' into develop

This commit is contained in:
J-Jamet
2019-11-10 14:33:35 +01:00
32 changed files with 1706 additions and 44 deletions

7
.gitignore vendored
View File

@@ -38,6 +38,13 @@ proguard/
# Android Studio captures folder # Android Studio captures folder
captures/ captures/
# Eclipse/VS Code
.project
.settings/*
*/.project
*/.classpath
*/.settings/*
# Intellij # Intellij
*.iml *.iml
.idea/workspace.xml .idea/workspace.xml

View File

@@ -6,7 +6,7 @@ apply plugin: 'kotlin-kapt'
android { android {
compileSdkVersion 28 compileSdkVersion 28
buildToolsVersion '28.0.3' buildToolsVersion '28.0.3'
ndkVersion "20.0.5594570" ndkVersion "20.1.5948944"
defaultConfig { defaultConfig {
applicationId "com.kunzisoft.keepass" applicationId "com.kunzisoft.keepass"
@@ -107,6 +107,8 @@ dependencies {
// Apache Commons Collections // Apache Commons Collections
implementation 'commons-collections:commons-collections:3.2.1' implementation 'commons-collections:commons-collections:3.2.1'
implementation 'org.apache.commons:commons-io:1.3.2' implementation 'org.apache.commons:commons-io:1.3.2'
// Apache Commons Codec
implementation 'commons-codec:commons-codec:1.11'
// Base64 // Base64
implementation 'biz.source_code:base64coder:2010-12-19' implementation 'biz.source_code:base64coder:2010-12-19'
// Icon pack // Icon pack

View File

@@ -24,15 +24,16 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.os.Handler 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.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.Toast 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.R
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
import com.kunzisoft.keepass.activities.lock.LockingHideActivity import com.kunzisoft.keepass.activities.lock.LockingHideActivity
@@ -58,6 +59,7 @@ class EntryActivity : LockingHideActivity() {
private var titleIconView: ImageView? = null private var titleIconView: ImageView? = null
private var historyView: View? = null private var historyView: View? = null
private var entryContentsView: EntryContentsView? = null private var entryContentsView: EntryContentsView? = null
private var entryProgress: ProgressBar? = null
private var toolbar: Toolbar? = null private var toolbar: Toolbar? = null
private var mDatabase: Database? = null private var mDatabase: Database? = null
@@ -101,6 +103,7 @@ class EntryActivity : LockingHideActivity() {
historyView = findViewById(R.id.history_container) historyView = findViewById(R.id.history_container)
entryContentsView = findViewById(R.id.entry_contents) entryContentsView = findViewById(R.id.entry_contents)
entryContentsView?.applyFontVisibilityToFields(PreferencesUtil.fieldFontIsInVisibility(this)) entryContentsView?.applyFontVisibilityToFields(PreferencesUtil.fieldFontIsInVisibility(this))
entryProgress = findViewById(R.id.entry_progress)
// Init the clipboard helper // Init the clipboard helper
clipboardHelper = ClipboardHelper(this) 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?.assignURL(entry.url)
entryContentsView?.assignComment(entry.notes) entryContentsView?.assignComment(entry.notes)

View File

@@ -29,6 +29,7 @@ import android.view.View
import android.widget.ScrollView import android.widget.ScrollView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import com.kunzisoft.keepass.R 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.GeneratePasswordDialogFragment
import com.kunzisoft.keepass.activities.dialogs.IconPickerDialogFragment import com.kunzisoft.keepass.activities.dialogs.IconPickerDialogFragment
import com.kunzisoft.keepass.activities.lock.LockingHideActivity 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_CREATE_ENTRY_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService 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.settings.PreferencesUtil
import com.kunzisoft.keepass.timeout.TimeoutHelper import com.kunzisoft.keepass.timeout.TimeoutHelper
import com.kunzisoft.keepass.utils.MenuUtil import com.kunzisoft.keepass.utils.MenuUtil
@@ -47,7 +50,8 @@ import java.util.*
class EntryEditActivity : LockingHideActivity(), class EntryEditActivity : LockingHideActivity(),
IconPickerDialogFragment.IconPickerListener, IconPickerDialogFragment.IconPickerListener,
GeneratePasswordDialogFragment.GeneratePasswordListener { GeneratePasswordDialogFragment.GeneratePasswordListener,
SetOTPDialogFragment.CreateOtpListener {
private var mDatabase: Database? = null private var mDatabase: Database? = null
@@ -164,7 +168,9 @@ class EntryEditActivity : LockingHideActivity(),
saveView = findViewById(R.id.entry_edit_save) saveView = findViewById(R.id.entry_edit_save)
saveView?.setOnClickListener { saveEntry() } saveView?.setOnClickListener { saveEntry() }
entryEditContentsView?.allowCustomField(mNewEntry?.allowCustomFields() == true) { addNewCustomField() } entryEditContentsView?.allowCustomField(mNewEntry?.allowCustomFields() == true) {
addNewCustomField()
}
// Verify the education views // Verify the education views
entryEditActivityEducation = EntryEditActivityEducation(this) entryEditActivityEducation = EntryEditActivityEducation(this)
@@ -197,7 +203,7 @@ class EntryEditActivity : LockingHideActivity(),
notes = newEntry.notes notes = newEntry.notes
for (entry in newEntry.customFields.entries) { for (entry in newEntry.customFields.entries) {
post { post {
addNewCustomField(entry.key, entry.value) putCustomField(entry.key, entry.value)
} }
} }
} }
@@ -217,7 +223,7 @@ class EntryEditActivity : LockingHideActivity(),
password = entryView.password password = entryView.password
notes = entryView.notes notes = entryView.notes
entryView.customFields.forEach { customField -> 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 * Add a new customized field view and scroll to bottom
*/ */
private fun addNewCustomField() { private fun addNewCustomField() {
entryEditContentsView?.addNewCustomField() entryEditContentsView?.addEmptyCustomField()
} }
/** /**
@@ -304,6 +310,7 @@ class EntryEditActivity : LockingHideActivity(),
val inflater = menuInflater val inflater = menuInflater
inflater.inflate(R.menu.database_lock, menu) inflater.inflate(R.menu.database_lock, menu)
MenuUtil.contributionMenuInflater(inflater, menu) MenuUtil.contributionMenuInflater(inflater, menu)
inflater.inflate(R.menu.edit_entry, menu)
entryEditActivityEducation?.let { entryEditActivityEducation?.let {
Handler().post { performedNextEducation(it) } Handler().post { performedNextEducation(it) }
@@ -314,7 +321,7 @@ class EntryEditActivity : LockingHideActivity(),
private fun performedNextEducation(entryEditActivityEducation: EntryEditActivityEducation) { private fun performedNextEducation(entryEditActivityEducation: EntryEditActivityEducation) {
val passwordView = entryEditContentsView?.generatePasswordView val passwordView = entryEditContentsView?.generatePasswordView
val addNewFieldView = entryEditContentsView?.addNewFieldView val addNewFieldView = entryEditContentsView?.addNewFieldButton
val generatePasswordEducationPerformed = passwordView != null val generatePasswordEducationPerformed = passwordView != null
&& entryEditActivityEducation.checkAndPerformedGeneratePasswordEducation( && entryEditActivityEducation.checkAndPerformedGeneratePasswordEducation(
@@ -350,12 +357,28 @@ class EntryEditActivity : LockingHideActivity(),
return true 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() android.R.id.home -> finish()
} }
return super.onOptionsItemSelected(item) 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) { override fun iconPicked(bundle: Bundle) {
IconPickerDialogFragment.getIconStandardFromBundle(bundle)?.let { icon -> IconPickerDialogFragment.getIconStandardFromBundle(bundle)?.let { icon ->
temporarilySaveAndShowSelectedIcon(icon) temporarilySaveAndShowSelectedIcon(icon)

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -18,7 +18,6 @@ class FieldsAdapter(context: Context) : RecyclerView.Adapter<FieldsAdapter.Field
var fields: MutableList<Field> = ArrayList() var fields: MutableList<Field> = ArrayList()
var onItemClickListener: OnItemClickListener? = null var onItemClickListener: OnItemClickListener? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldViewHolder {
val view = inflater.inflate(R.layout.keyboard_popup_fields_item, parent, false) val view = inflater.inflate(R.layout.keyboard_popup_fields_item, parent, false)
return FieldViewHolder(view) return FieldViewHolder(view)

View File

@@ -19,7 +19,7 @@
*/ */
package com.kunzisoft.keepass.crypto.keyDerivation 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.IOException
import java.io.Serializable import java.io.Serializable

View File

@@ -23,7 +23,7 @@ class ExtraFieldCursor : MatrixCursor(arrayOf(
} }
fun populateExtraFieldInEntry(pwEntry: PwEntryV4) { fun populateExtraFieldInEntry(pwEntry: PwEntryV4) {
pwEntry.addExtraField(getString(getColumnIndex(COLUMN_LABEL)), pwEntry.putExtraField(getString(getColumnIndex(COLUMN_LABEL)),
ProtectedString(getInt(getColumnIndex(COLUMN_PROTECTION)) > 0, ProtectedString(getInt(getColumnIndex(COLUMN_PROTECTION)) > 0,
getString(getColumnIndex(COLUMN_VALUE)))) getString(getColumnIndex(COLUMN_VALUE))))
} }

View File

@@ -5,6 +5,8 @@ import android.os.Parcelable
import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.Field import com.kunzisoft.keepass.model.Field
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields
import java.util.* import java.util.*
import kotlin.collections.ArrayList 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 label Label of field, must be unique
* @param value Value of field * @param value Value of field
*/ */
fun addExtraField(label: String, value: ProtectedString) { fun putExtraField(label: String, value: ProtectedString) {
pwEntryV4?.addExtraField(label, value) pwEntryV4?.putExtraField(label, value)
}
fun getOtpElement(): OtpElement? {
return OtpEntryFields.parseFields { key ->
customFields[key]?.toString()
}
} }
fun startToManageFieldReferences(db: PwDatabaseV4) { 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 { fun getEntryInfo(database: Database?, raw: Boolean = false): EntryInfo {
val entryInfo = EntryInfo() val entryInfo = EntryInfo()
if (raw) if (raw)
@@ -318,6 +330,10 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
entryInfo.customFields.add( entryInfo.customFields.add(
Field(entry.key, entry.value)) 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) if (!raw)
database?.stopManageEntry(this) database?.stopManageEntry(this)
return entryInfo return entryInfo

View File

@@ -21,7 +21,7 @@ package com.kunzisoft.keepass.database.element
import android.content.res.Resources import android.content.res.Resources
import com.kunzisoft.keepass.R 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 // 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 // since we won't do arithmetic on these values (also unlikely to

View File

@@ -26,7 +26,7 @@ import com.kunzisoft.keepass.crypto.engine.AesEngine
import com.kunzisoft.keepass.crypto.engine.ChaCha20Engine import com.kunzisoft.keepass.crypto.engine.ChaCha20Engine
import com.kunzisoft.keepass.crypto.engine.CipherEngine import com.kunzisoft.keepass.crypto.engine.CipherEngine
import com.kunzisoft.keepass.crypto.engine.TwofishEngine import com.kunzisoft.keepass.crypto.engine.TwofishEngine
import com.kunzisoft.keepass.database.ObjectNameResource import com.kunzisoft.keepass.utils.ObjectNameResource
import java.util.UUID import java.util.UUID

View File

@@ -270,7 +270,7 @@ class PwEntryV4 : PwEntry<UUID, UUID, PwGroupV4, PwEntryV4>, PwNodeV4Interface {
fields.clear() fields.clear()
} }
fun addExtraField(label: String, value: ProtectedString) { fun putExtraField(label: String, value: ProtectedString) {
fields[label] = value fields[label] = value
} }

View File

@@ -742,7 +742,7 @@ class ImporterV4(private val streamDir: File,
return KdbContext.Entry return KdbContext.Entry
} else if (ctx == KdbContext.EntryString && name.equals(PwDatabaseV4XML.ElemString, ignoreCase = true)) { } else if (ctx == KdbContext.EntryString && name.equals(PwDatabaseV4XML.ElemString, ignoreCase = true)) {
if (ctxStringName != null && ctxStringValue != null) if (ctxStringName != null && ctxStringValue != null)
ctxEntry?.addExtraField(ctxStringName!!, ctxStringValue!!) ctxEntry?.putExtraField(ctxStringName!!, ctxStringValue!!)
ctxStringName = null ctxStringName = null
ctxStringValue = null ctxStringValue = null

View File

@@ -100,19 +100,24 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
val popupFieldsView = LayoutInflater.from(context) val popupFieldsView = LayoutInflater.from(context)
.inflate(R.layout.keyboard_popup_fields, FrameLayout(context)) .inflate(R.layout.keyboard_popup_fields, FrameLayout(context))
popupCustomKeys?.dismiss() dismissCustomKeys()
popupCustomKeys = PopupWindow(context).apply {
popupCustomKeys = PopupWindow(context) width = WindowManager.LayoutParams.WRAP_CONTENT
popupCustomKeys?.width = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT
popupCustomKeys?.height = WindowManager.LayoutParams.WRAP_CONTENT softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
popupCustomKeys?.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED
popupCustomKeys?.inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED contentView = popupFieldsView
popupCustomKeys?.contentView = popupFieldsView }
val recyclerView = popupFieldsView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.keyboard_popup_fields_list) val recyclerView = popupFieldsView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.keyboard_popup_fields_list)
fieldsAdapter = FieldsAdapter(this) fieldsAdapter = FieldsAdapter(this)
fieldsAdapter?.onItemClickListener = object : FieldsAdapter.OnItemClickListener { fieldsAdapter?.onItemClickListener = object : FieldsAdapter.OnItemClickListener {
override fun onItemClick(item: Field) { 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) currentInputConnection.commitText(item.protectedValue.toString(), 1)
} }
} }
@@ -129,6 +134,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
} }
private fun assignKeyboardView() { private fun assignKeyboardView() {
dismissCustomKeys()
if (keyboardView != null) { if (keyboardView != null) {
if (entryInfoKey != null) { if (entryInfoKey != null) {
if (keyboardEntry != null) { if (keyboardEntry != null) {
@@ -138,7 +144,6 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
} else { } else {
if (keyboard != null) { if (keyboard != null) {
hideEntryInfo() hideEntryInfo()
dismissCustomKeys()
keyboardView?.keyboard = keyboard keyboardView?.keyboard = keyboard
} }
} }
@@ -251,6 +256,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
KEY_FIELDS -> { KEY_FIELDS -> {
if (entryInfoKey != null) { if (entryInfoKey != null) {
fieldsAdapter?.fields = entryInfoKey!!.customFields fieldsAdapter?.fields = entryInfoKey!!.customFields
fieldsAdapter?.notifyDataSetChanged()
} }
popupCustomKeys?.showAtLocation(keyboardView, Gravity.END or Gravity.TOP, 0, 0) popupCustomKeys?.showAtLocation(keyboardView, Gravity.END or Gravity.TOP, 0, 0)
} }

View File

@@ -2,8 +2,9 @@ package com.kunzisoft.keepass.model
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import com.kunzisoft.keepass.otp.OtpElement
import java.util.ArrayList import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
import java.util.*
class EntryInfo : Parcelable { class EntryInfo : Parcelable {
@@ -14,6 +15,7 @@ class EntryInfo : Parcelable {
var url: String = "" var url: String = ""
var notes: String = "" var notes: String = ""
var customFields: MutableList<Field> = ArrayList() var customFields: MutableList<Field> = ArrayList()
var otpModel: OtpModel? = null
constructor() constructor()
@@ -25,6 +27,7 @@ class EntryInfo : Parcelable {
url = parcel.readString() ?: url url = parcel.readString() ?: url
notes = parcel.readString() ?: notes notes = parcel.readString() ?: notes
parcel.readList(customFields, Field::class.java.classLoader) parcel.readList(customFields, Field::class.java.classLoader)
otpModel = parcel.readParcelable(OtpModel::class.java.classLoader) ?: otpModel
} }
override fun describeContents(): Int { override fun describeContents(): Int {
@@ -39,6 +42,7 @@ class EntryInfo : Parcelable {
parcel.writeString(url) parcel.writeString(url)
parcel.writeString(notes) parcel.writeString(notes)
parcel.writeArray(customFields.toTypedArray()) parcel.writeArray(customFields.toTypedArray())
parcel.writeParcelable(otpModel, flags)
} }
fun containsCustomFieldsProtected(): Boolean { fun containsCustomFieldsProtected(): Boolean {
@@ -49,6 +53,18 @@ class EntryInfo : Parcelable {
return customFields.any { !it.protectedValue.isProtected } 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 { companion object {
@JvmField @JvmField

View File

@@ -9,7 +9,7 @@ class Field : Parcelable {
var name: String = "" var name: String = ""
var protectedValue: ProtectedString = ProtectedString() var protectedValue: ProtectedString = ProtectedString()
constructor(name: String, value: ProtectedString) { constructor(name: String, value: ProtectedString = ProtectedString()) {
this.name = name this.name = name
this.protectedValue = value this.protectedValue = value
} }
@@ -28,6 +28,21 @@ class Field : Parcelable {
dest.writeParcelable(protectedValue, flags) 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 { companion object {
@JvmField @JvmField

View 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)
}
}
}

View 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)
}
}
}

View 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
}
}

View 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);
}
}

View File

@@ -27,7 +27,7 @@ import android.view.ViewGroup
import android.widget.RadioButton import android.widget.RadioButton
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.database.ObjectNameResource import com.kunzisoft.keepass.utils.ObjectNameResource
import java.util.ArrayList import java.util.ArrayList

View File

@@ -17,7 +17,7 @@
* along with KeePass DX. If not, see <http://www.gnu.org/licenses/>. * 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 import android.content.res.Resources

View File

@@ -27,6 +27,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager 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.EntryVersioned
import com.kunzisoft.keepass.database.element.PwDate import com.kunzisoft.keepass.database.element.PwDate
import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpType
import java.util.* import java.util.*
class EntryContentsView @JvmOverloads constructor(context: Context, class EntryContentsView @JvmOverloads constructor(context: Context,
@@ -54,6 +57,13 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
private val passwordView: TextView private val passwordView: TextView
private val passwordActionView: ImageView 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 urlContainerView: View
private val urlView: TextView private val urlView: TextView
@@ -93,6 +103,11 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
passwordView = findViewById(R.id.entry_password) passwordView = findViewById(R.id.entry_password)
passwordActionView = findViewById(R.id.entry_password_action_image) 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) urlContainerView = findViewById(R.id.entry_url_container)
urlView = findViewById(R.id.entry_url) 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?) { fun assignURL(url: String?) {
if (url != null && url.isNotEmpty()) { if (url != null && url.isNotEmpty()) {
urlContainerView.visibility = View.VISIBLE urlContainerView.visibility = View.VISIBLE

View File

@@ -17,7 +17,6 @@ import com.kunzisoft.keepass.icons.IconDrawableFactory
import com.kunzisoft.keepass.icons.assignDatabaseIcon import com.kunzisoft.keepass.icons.assignDatabaseIcon
import com.kunzisoft.keepass.icons.assignDefaultDatabaseIcon import com.kunzisoft.keepass.icons.assignDefaultDatabaseIcon
import com.kunzisoft.keepass.model.Field import com.kunzisoft.keepass.model.Field
import java.util.HashMap
class EntryEditContentsView @JvmOverloads constructor(context: Context, class EntryEditContentsView @JvmOverloads constructor(context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@@ -37,7 +36,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
val generatePasswordView: View val generatePasswordView: View
private val entryCommentView: EditText private val entryCommentView: EditText
private val entryExtraFieldsContainer: ViewGroup private val entryExtraFieldsContainer: ViewGroup
val addNewFieldView: View val addNewFieldButton: View
private var iconColor: Int = 0 private var iconColor: Int = 0
@@ -56,7 +55,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
generatePasswordView = findViewById(R.id.entry_edit_generate_button) generatePasswordView = findViewById(R.id.entry_edit_generate_button)
entryCommentView = findViewById(R.id.entry_edit_notes) entryCommentView = findViewById(R.id.entry_edit_notes)
entryExtraFieldsContainer = findViewById(R.id.entry_edit_advanced_container) 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 // Retrieve the textColor to tint the icon
val taIconColor = context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColor)) 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) { fun allowCustomField(allow: Boolean, action: () -> Unit) {
addNewFieldView.apply { addNewFieldButton.apply {
if (allow) { if (allow) {
visibility = View.VISIBLE visibility = View.VISIBLE
setOnClickListener { action.invoke() } 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 { val entryEditCustomField = EntryEditCustomField(context).apply {
setData(name, value)
setFontVisibility(fontInVisibility) setFontVisibility(fontInVisibility)
requestFocus() requestFocus()
} }
entryExtraFieldsContainer.addView(entryEditCustomField) 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 * Validate or not the entry form
* *

View File

@@ -66,13 +66,14 @@ class EntryEditCustomField @JvmOverloads constructor(context: Context,
protectionCheckView = findViewById(R.id.protection) protectionCheckView = findViewById(R.id.protection)
} }
fun setData(label: String?, value: ProtectedString?) { fun setData(label: String?, value: ProtectedString?, fontInVisibility: Boolean) {
if (label != null) if (label != null)
labelView.text = label labelView.text = label
if (value != null) { if (value != null) {
valueView.setText(value.toString()) valueView.setText(value.toString())
protectionCheckView.isChecked = value.isProtected protectionCheckView.isChecked = value.isProtected
} }
setFontVisibility(fontInVisibility)
} }
/** /**

View 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>

View File

@@ -72,6 +72,17 @@
tools:targetApi="lollipop"> tools:targetApi="lollipop">
</androidx.appcompat.widget.Toolbar> </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.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View 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>

View File

@@ -105,6 +105,38 @@
android:tint="?attr/colorAccent" /> android:tint="?attr/colorAccent" />
</RelativeLayout> </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 --> <!-- URL -->
<LinearLayout <LinearLayout
android:id="@+id/entry_url_container" android:id="@+id/entry_url_container"

View 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>

View File

@@ -87,6 +87,14 @@
<string name="entry_password">Password</string> <string name="entry_password">Password</string>
<string name="entry_save">Save</string> <string name="entry_save">Save</string>
<string name="entry_title">Title</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_url">URL</string>
<string name="entry_user_name">Username</string> <string name="entry_user_name">Username</string>
<string name="error_arc4">The ARCFOUR stream cipher is not supported.</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_file_not_create">Could not create file:</string>
<string name="error_invalid_db">Could not read database.</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_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_no_name">Enter a name.</string>
<string name="error_nokeyfile">Select a keyfile.</string> <string name="error_nokeyfile">Select a keyfile.</string>
<string name="error_out_of_memory">No memory to load your entire database.</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_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_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_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_name">Field name</string>
<string name="field_value">Field value</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> <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="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="download">Download</string>
<string name="contribute">Contribute</string> <string name="contribute">Contribute</string>
<!-- Algorithms --> <!-- Encryption Algorithms -->
<string name="encryption_rijndael">Rijndael (AES)</string> <string name="encryption_rijndael">Rijndael (AES)</string>
<string name="encryption_twofish">Twofish</string> <string name="encryption_twofish">Twofish</string>
<string name="encryption_chacha20">ChaCha20</string> <string name="encryption_chacha20">ChaCha20</string>

View File

@@ -316,6 +316,8 @@
<style name="KeepassDXStyle.TextAppearance.LabelTextStyle" parent="KeepassDXStyle.TextAppearance"> <style name="KeepassDXStyle.TextAppearance.LabelTextStyle" parent="KeepassDXStyle.TextAppearance">
<item name="android:textColor">?attr/colorAccent</item> <item name="android:textColor">?attr/colorAccent</item>
<item name="android:textSize">12sp</item> <item name="android:textSize">12sp</item>
<item name="android:paddingLeft">4dp</item>
<item name="android:paddingRight">4dp</item>
</style> </style>
<style name="KeepassDXStyle.TextAppearance.LabelTableTextStyle" parent="KeepassDXStyle.TextAppearance"> <style name="KeepassDXStyle.TextAppearance.LabelTableTextStyle" parent="KeepassDXStyle.TextAppearance">
<item name="android:textSize">12sp</item> <item name="android:textSize">12sp</item>