diff --git a/.gitignore b/.gitignore
index be230663e..b6de83e58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,13 @@ proguard/
# Android Studio captures folder
captures/
+# Eclipse/VS Code
+.project
+.settings/*
+*/.project
+*/.classpath
+*/.settings/*
+
# Intellij
*.iml
.idea/workspace.xml
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
deleted file mode 100644
index 52ccfe1a4..000000000
--- a/CONTRIBUTORS
+++ /dev/null
@@ -1,45 +0,0 @@
-Original author:
-Brian Pellin
-
-Achim Weimert
-Johan Berts - search patches
-Mike Mohr - Better native code for aes and sha
-Tobias Selig - icon support
-Tolga Onbay, Dirk Bergstrom - password generator
-Space Cowboy - holo theme
-josefwells
-Nicholas FitzRoy-Dale - auto launch intents
-yulin2 - responsiveness improvements
-Tadashi Saito
-vhschlenker
-bumper314 - Samsung multiwindow support
-Hans Cappelle - fingerprint sensor integration
-Jeremy Jamet - Keepass DX Material Design - Patches
-
-Translations:
-Diego Pierotto - Italian
-Laurent, Norman Obry, Nam, Bruno Parmentier, Credomo - French
-Maciej Bieniek, cod3r - Polish
-Максим Сёмочкин, i.nedoboy, filimonic, bboa - Russian
-MaWi, rvs2008, meviox, MaDill, EdlerProgrammierer, Jan Thomas - German
-yslandro - Norwegian Nynorsk
-王科峰 - Chinese
-Typhoon - Slovak
-Masahiro Inamura - Japanese
-Matsuu Takuto - Japanese
-Carlos Schlyter - Portugese (Brazil)
-YSmhXQDd6Z - Portugese (Portugal)
-andriykopanytsia - Ukranian
-intel, Zoltán Antal - Hungarian
-H Vanek - Czech
-jipanos - Spanish
-Erik Fdevriendt, Erik Jan Meijer - Dutch
-Frederik Svarre - Danish
-Oriol Garrote - Catalan
-Mika Takala - Finnish
-Niclas Burgren - Swedish
-Raimonds - Latvian
-dgarciabad - Basque
-Arthur Zamarin - Hebrew
-RaptorTFX - Greek
-zygimantus - Lithuanian
diff --git a/app/build.gradle b/app/build.gradle
index 4e3978343..23b18b516 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -106,6 +106,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
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt
index 6a54c6b1d..5419ea0cd 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt
@@ -49,6 +49,7 @@ import com.kunzisoft.keepass.timeout.TimeoutHelper
import com.kunzisoft.keepass.utils.MenuUtil
import com.kunzisoft.keepass.utils.UriUtil
import com.kunzisoft.keepass.view.EntryContentsView
+import com.kunzisoft.keepass.totp.TotpSettings
class EntryActivity : LockingHideActivity() {
@@ -59,6 +60,7 @@ class EntryActivity : LockingHideActivity() {
private var mEntry: EntryVersioned? = null
private var mShowPassword: Boolean = false
+ private var mTotpSettings: TotpSettings? = null
private var clipboardHelper: ClipboardHelper? = null
private var firstLaunchOfActivity: Boolean = false
@@ -97,6 +99,9 @@ class EntryActivity : LockingHideActivity() {
// Update last access time.
mEntry?.touch(modified = false, touchParents = false)
+ // Init TOTP
+ mTotpSettings = TotpSettings(mEntry)
+
// Retrieve the textColor to tint the icon
val taIconColor = theme.obtainStyledAttributes(intArrayOf(R.attr.colorAccent))
iconColor = taIconColor.getColor(0, Color.BLACK)
@@ -206,6 +211,15 @@ class EntryActivity : LockingHideActivity() {
}
}
+ mTotpSettings?.let { totpSettings ->
+ entryContentsView?.assignTotp(totpSettings, View.OnClickListener {
+ clipboardHelper?.timeoutCopyToClipboard(
+ totpSettings.token,
+ getString(R.string.copy_field, getString(R.string.entry_totp))
+ )
+ })
+ }
+
entryContentsView?.assignURL(entry.url)
entryContentsView?.assignComment(entry.notes)
diff --git a/app/src/main/java/com/kunzisoft/keepass/totp/TotpGenerator.java b/app/src/main/java/com/kunzisoft/keepass/totp/TotpGenerator.java
new file mode 100644
index 000000000..008d6c8d8
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/totp/TotpGenerator.java
@@ -0,0 +1,105 @@
+/*
+ * 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 .
+ *
+ * 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.totp;
+
+import java.nio.ByteBuffer;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+public final class TotpGenerator {
+
+ private static final char[] STEAM_CHARS =
+ 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'};
+ private static final String ALGORITHM = "HmacSHA1";
+
+ private static byte[] generateHash(byte[] key, byte[] data)
+ throws NoSuchAlgorithmException, InvalidKeyException {
+
+ Mac mac = Mac.getInstance(ALGORITHM);
+ mac.init(new SecretKeySpec(key, ALGORITHM));
+
+ return mac.doFinal(data);
+ }
+
+ public static int TOTP_RFC6238(byte[] secret, int period, long time, int digits) {
+ int fullToken = TOTP(secret, period, time);
+ int div = (int) Math.pow(10, digits);
+
+ return fullToken % div;
+ }
+
+ public static String TOTP_RFC6238(byte[] secret, int period, int digits) {
+ int token = TOTP_RFC6238(secret, period, System.currentTimeMillis() / 1000, digits);
+
+ return String.format("%0" + digits + "d", token);
+ }
+
+ public static String TOTP_Steam(byte[] secret, int period, int digits) {
+ int fullToken = TOTP(secret, period, System.currentTimeMillis() / 1000);
+
+ StringBuilder tokenBuilder = new StringBuilder();
+
+ for (int i = 0; i < digits; i++) {
+ tokenBuilder.append(STEAM_CHARS[fullToken % STEAM_CHARS.length]);
+ fullToken /= STEAM_CHARS.length;
+ }
+
+ return tokenBuilder.toString();
+ }
+
+ public static String HOTP(byte[] secret, long counter, int digits) {
+ int fullToken = HOTP(secret, counter);
+ int div = (int) Math.pow(10, digits);
+
+ return String.format("%0" + digits + "d", fullToken % div);
+ }
+
+ private static int TOTP(byte[] key, int period, long time) {
+ return HOTP(key, time / period);
+ }
+
+ private static int HOTP(byte[] key, long counter) {
+ int r = 0;
+
+ try {
+ byte[] data = ByteBuffer.allocate(8).putLong(counter).array();
+ byte[] hash = generateHash(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;
+ }
+
+}
diff --git a/app/src/main/java/com/kunzisoft/keepass/totp/TotpSettings.java b/app/src/main/java/com/kunzisoft/keepass/totp/TotpSettings.java
new file mode 100644
index 000000000..a92be272f
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/totp/TotpSettings.java
@@ -0,0 +1,222 @@
+/*
+ * 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 .
+ *
+ * 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.totp;
+
+import org.apache.commons.codec.binary.Base32;
+import android.net.Uri;
+import android.util.Patterns;
+import java.util.HashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.kunzisoft.keepass.database.element.EntryVersioned;
+import com.kunzisoft.keepass.database.element.security.ProtectedString;
+
+public class TotpSettings {
+
+ private enum EntryType {
+ None, OTP, SeedAndSettings,
+ }
+
+ private enum TokenType {
+ Default, Steam
+ }
+
+ private static final int DEFAULT_STEP = 30;
+ private static final int DEFAULT_DIGITS = 6;
+ private static final int STEAM_DIGITS = 5;
+
+ // Logical breakdown of key=value regex. the final string is as follows:
+ // [^&=\s]+=[^&=\s]+(&[^&=\s]+=[^&=\s]+)*
+ private static final String validKeyValue = "[^&=\\s]+";
+ private static final String validKeyValuePair = validKeyValue + "=" + validKeyValue;
+ private static final String validKeyValueRegex =
+ validKeyValuePair + "&(" + validKeyValuePair + ")*";
+
+ private static final String OTP_FIELD = "otp";
+ private static final String SEED_FIELD = "TOTP Seed";
+ private static final String SETTING_FIELD = "TOTP Settings";
+
+ private EntryVersioned entry;
+ private String seed;
+ private byte[] secret;
+ private int step;
+ private int digits;
+ private EntryType entryType;
+ private TokenType tokenType;
+
+ public TotpSettings(EntryVersioned entry) {
+ this.entry = entry;
+ if (parseOtp() || parseSeedAndSettings()) {
+ secret = new Base32().decode(seed.getBytes());
+ } else {
+ entryType = EntryType.None;
+ }
+ }
+
+ public void setSettings(String seed, int digits, int step) {
+ // TODO: Implement a way to set TOTP from device
+ }
+
+ public boolean isConfigured() {
+ return entryType != EntryType.None;
+ }
+
+ public String getToken() {
+ if (entryType == EntryType.None) {
+ return "";
+ }
+ switch (tokenType) {
+ case Steam:
+ return TotpGenerator.TOTP_Steam(secret, step, digits);
+ default:
+ return TotpGenerator.TOTP_RFC6238(secret, step, digits);
+ }
+ }
+
+ public int getSecondsRemaining() {
+ return step - (int) ((System.currentTimeMillis() / 1000) % step);
+ }
+
+ public boolean shouldRefreshToken() {
+ return getSecondsRemaining() == step;
+ }
+
+ private boolean parseSeedAndSettings() {
+ String seedField = getField(SEED_FIELD);
+ String settingsField = getField(SETTING_FIELD);
+ if (seedField == null || settingsField == null) {
+ return false;
+ }
+
+ // Regex match, sync with TotpGenerator.shortNameToEncoder
+ Pattern pattern = Pattern.compile("(\\d+);((?:\\d+)|S)");
+ Matcher matcher = pattern.matcher(settingsField);
+ if (!matcher.matches()) {
+ // malformed
+ return false;
+ }
+
+ step = toInt(matcher.group(1));
+
+ String encodingType = matcher.group(2);
+ digits = getDigitsForType(encodingType);
+
+ seed = seedField;
+ entryType = EntryType.SeedAndSettings;
+ return true;
+ }
+
+ private boolean parseOtp() {
+ String key = getField(OTP_FIELD);
+ if (key == null) {
+ return false;
+ }
+
+ Uri url = null;
+ if (isValidUrl(key)) {
+ url = Uri.parse(key);
+ }
+ boolean useEncoder = false;
+
+ if (url != null && url.getScheme().equals("otpauth")) {
+ // Default OTP url format
+
+ seed = url.getQueryParameter("secret");
+ digits = toInt(url.getQueryParameter("digits"));
+ step = toInt(url.getQueryParameter("period"));
+
+ String encName = url.getQueryParameter("encoder");
+ digits = getDigitsForType(encName);
+ } else if (Pattern.matches(validKeyValueRegex, key)) {
+ // KeeOtp string format
+ HashMap query = breakDownKeyValuePairs(key);
+
+ seed = query.get("key");
+ digits = toInt(query.get("size"));
+ step = toInt(query.get("step"));
+ } else {
+ // Malformed
+ return false;
+ }
+
+ if (digits == 0) {
+ digits = DEFAULT_DIGITS;
+ }
+
+ if (step <= 0 || step > 60) {
+ step = DEFAULT_STEP;
+ }
+
+ entryType = EntryType.OTP;
+ return true;
+ }
+
+ private String getField(String id) {
+ ProtectedString field = entry.getCustomFields().get(id);
+ if (field != null) {
+ return field.toString();
+ }
+ return null;
+ }
+
+ private boolean isValidUrl(String url) {
+ return Patterns.WEB_URL.matcher(url).matches();
+ }
+
+ private int toInt(String value) {
+ if (value == null) {
+ return 0;
+ }
+ try {
+ return Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ private HashMap breakDownKeyValuePairs(String pairs) {
+ String[] elements = pairs.split("&");
+ HashMap output = new HashMap();
+ for (String element : elements) {
+ String[] pair = element.split("=");
+ output.put(pair[0], pair[1]);
+ }
+ return output;
+ }
+
+ private int getDigitsForType(String encodingType) {
+ int digitType = toInt(encodingType);
+ if (digitType != 0) {
+ tokenType = TokenType.Default;
+ return digitType;
+ }
+ switch (encodingType) {
+ case "S":
+ case "steam":
+ tokenType = TokenType.Steam;
+ return 5;
+ default:
+ tokenType = TokenType.Default;
+ return DEFAULT_DIGITS;
+ }
+ }
+}
diff --git a/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt b/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt
index d2d36a523..bd5b540e2 100644
--- a/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt
@@ -20,6 +20,7 @@ package com.kunzisoft.keepass.view
import android.content.Context
import android.graphics.Color
+import android.os.Handler
import androidx.core.content.ContextCompat
import android.text.method.PasswordTransformationMethod
import android.util.AttributeSet
@@ -33,6 +34,7 @@ import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.database.element.security.ProtectedString
import java.text.DateFormat
import java.util.*
+import com.kunzisoft.keepass.totp.TotpSettings
class EntryContentsView @JvmOverloads constructor(context: Context,
var attrs: AttributeSet? = null,
@@ -50,6 +52,11 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
private val passwordView: TextView
private val passwordActionView: ImageView
+ private val totpContainerView: View
+ private val totpView: TextView
+ private val totpActionView: ImageView
+ private var totpCurrentToken: String = ""
+
private val urlContainerView: View
private val urlView: TextView
@@ -87,6 +94,10 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
passwordView = findViewById(R.id.entry_password)
passwordActionView = findViewById(R.id.entry_password_action_image)
+ totpContainerView = findViewById(R.id.entry_totp_container);
+ totpView = findViewById(R.id.entry_totp);
+ totpActionView = findViewById(R.id.entry_totp_action_image);
+
urlContainerView = findViewById(R.id.entry_url_container)
urlView = findViewById(R.id.entry_url)
@@ -185,6 +196,38 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
}
}
+ fun assignTotp(settings: TotpSettings, onClickListener: OnClickListener) {
+ if (settings.isConfigured) {
+ totpContainerView.visibility = View.VISIBLE
+
+ val totp = settings.token
+ if (totp.isEmpty()) {
+ totpView.text = context.getString(R.string.error_invalid_TOTP)
+ totpActionView
+ .setColorFilter(ContextCompat.getColor(context, R.color.grey_dark))
+ assignTotpCopyListener(null)
+ } else {
+ assignTotpCopyListener(onClickListener)
+ totpCurrentToken = settings.token
+ val totpHandler = Handler()
+ totpHandler.post(object : Runnable {
+ override fun run() {
+ if (settings.shouldRefreshToken()) {
+ totpCurrentToken = settings.token
+ }
+ totpView.text = context.getString(R.string.entry_totp_format,
+ totpCurrentToken, settings.secondsRemaining)
+ totpHandler.postDelayed(this, 1000)
+ }
+ })
+ }
+ }
+ }
+
+ fun assignTotpCopyListener(onClickListener: OnClickListener?) {
+ totpActionView.setOnClickListener(onClickListener)
+ }
+
fun assignURL(url: String?) {
if (url != null && url.isNotEmpty()) {
urlContainerView.visibility = View.VISIBLE
diff --git a/app/src/main/res/layout/view_entry_contents.xml b/app/src/main/res/layout/view_entry_contents.xml
index 506510098..e0d19daca 100644
--- a/app/src/main/res/layout/view_entry_contents.xml
+++ b/app/src/main/res/layout/view_entry_contents.xml
@@ -105,6 +105,38 @@
android:tint="?attr/colorAccent" />
+
+
+
+
+
+
+
Password
Save
Title
+ TOTP
+ %1$s (%2$d)
URL
Username
The ARCFOUR stream cipher is not supported.
@@ -94,6 +96,7 @@
Could not create file:
Could not read database.
Make sure the path is correct.
+ Invalid TOTP secret.
Enter a name.
Select a keyfile.
No memory to load your entire database.