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.