mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
Fix TOTP retrievement and add HOTP fields
This commit is contained in:
@@ -20,13 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.kunzisoft.keepass.otp;
|
package com.kunzisoft.keepass.otp;
|
||||||
|
|
||||||
|
import org.apache.commons.codec.DecoderException;
|
||||||
import org.apache.commons.codec.binary.Base32;
|
import org.apache.commons.codec.binary.Base32;
|
||||||
|
import org.apache.commons.codec.binary.Base64;
|
||||||
|
import org.apache.commons.codec.binary.Hex;
|
||||||
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import java.nio.charset.Charset;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -38,12 +43,13 @@ public class OtpEntryFields {
|
|||||||
|
|
||||||
private static final String TAG = OtpEntryFields.class.getName();
|
private static final String TAG = OtpEntryFields.class.getName();
|
||||||
|
|
||||||
|
// Field from KeePassXC
|
||||||
|
private static final String OTP_FIELD = "otp";
|
||||||
|
|
||||||
|
// URL parameters (https://github.com/google/google-authenticator/wiki/Key-Uri-Format)
|
||||||
private static final String OTP_SCHEME = "otpauth";
|
private static final String OTP_SCHEME = "otpauth";
|
||||||
|
private static final String TOTP_AUTHORITY = "totp"; // time-based
|
||||||
private static final String TOTP = "totp"; // time-based
|
private static final String HOTP_AUTHORITY = "hotp"; // counter-based
|
||||||
private static final String HOTP = "hotp"; // counter-based
|
|
||||||
|
|
||||||
// URL parameters
|
|
||||||
private static final String ISSUER_URL_PARAM = "issuer";
|
private static final String ISSUER_URL_PARAM = "issuer";
|
||||||
private static final String SECRET_URL_PARAM = "secret";
|
private static final String SECRET_URL_PARAM = "secret";
|
||||||
private static final String DIGITS_URL_PARAM = "digits";
|
private static final String DIGITS_URL_PARAM = "digits";
|
||||||
@@ -51,13 +57,21 @@ public class OtpEntryFields {
|
|||||||
private static final String ENCODER_URL_PARAM = "encoder";
|
private static final String ENCODER_URL_PARAM = "encoder";
|
||||||
private static final String COUNTER_URL_PARAM = "counter";
|
private static final String COUNTER_URL_PARAM = "counter";
|
||||||
|
|
||||||
// Key-values
|
// Key-values (maybe from plugin or old KeePassXC)
|
||||||
private static final String SEED_KEY = "key";
|
private static final String SEED_KEY = "key";
|
||||||
private static final String DIGITS_KEY = "size";
|
private static final String DIGITS_KEY = "size";
|
||||||
private static final String STEP_KEY = "step";
|
private static final String STEP_KEY = "step";
|
||||||
|
|
||||||
// Default values
|
// HmacOtp KeePass2 values (https://keepass.info/help/base/placeholders.html#hmacotp)
|
||||||
private static final int DEFAULT_HOTP_COUNTER = 0;
|
private static final String HMACOTP_SECRET_KEY = "HmacOtp-Secret";
|
||||||
|
private static final String HMACOTP_SECRET_HEX_KEY = "HmacOtp-Secret-Hex";
|
||||||
|
private static final String HMACOTP_SECRET_BASE32_KEY = "HmacOtp-Secret-Base32";
|
||||||
|
private static final String HMACOTP_SECRET_BASE64_KEY = "HmacOtp-Secret-Base64";
|
||||||
|
private static final String HMACOTP_SECRET_COUNTER_KEY = "HmacOtp-Counter";
|
||||||
|
|
||||||
|
// Custom fields (maybe from plugin)
|
||||||
|
private static final String TOTP_SEED_FIELD = "TOTP Seed";
|
||||||
|
private static final String TOTP_SETTING_FIELD = "TOTP Settings";
|
||||||
|
|
||||||
public enum OtpType {
|
public enum OtpType {
|
||||||
UNDEFINED,
|
UNDEFINED,
|
||||||
@@ -78,8 +92,8 @@ public class OtpEntryFields {
|
|||||||
public static TokenType getFromString(@Nullable String tokenType) {
|
public static TokenType getFromString(@Nullable String tokenType) {
|
||||||
if (tokenType == null)
|
if (tokenType == null)
|
||||||
return Default;
|
return Default;
|
||||||
switch (tokenType) {
|
switch (tokenType.toLowerCase()) {
|
||||||
case "S":
|
case "s":
|
||||||
case "steam":
|
case "steam":
|
||||||
return Steam;
|
return Steam;
|
||||||
default:
|
default:
|
||||||
@@ -87,7 +101,8 @@ public class OtpEntryFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Default values
|
||||||
|
private static final int DEFAULT_HOTP_COUNTER = 0;
|
||||||
private static final int DEFAULT_STEP = 30;
|
private static final int DEFAULT_STEP = 30;
|
||||||
|
|
||||||
// Logical breakdown of key=value regex. the final string is as follows:
|
// Logical breakdown of key=value regex. the final string is as follows:
|
||||||
@@ -97,27 +112,31 @@ public class OtpEntryFields {
|
|||||||
private static final String validKeyValueRegex =
|
private static final String validKeyValueRegex =
|
||||||
validKeyValuePair + "&(" + validKeyValuePair + ")*";
|
validKeyValuePair + "&(" + validKeyValuePair + ")*";
|
||||||
|
|
||||||
private static final String OTP_FIELD = "otp";
|
|
||||||
private static final String TOTP_SEED_FIELD = "TOTP Seed";
|
|
||||||
private static final String TOTP_SETTING_FIELD = "TOTP Settings";
|
|
||||||
|
|
||||||
private EntryVersioned entry;
|
private EntryVersioned entry;
|
||||||
|
|
||||||
private OtpType type = OtpType.UNDEFINED; // ie : HOTP or TOTP
|
private OtpType type = OtpType.UNDEFINED; // ie : HOTP or TOTP
|
||||||
private TokenType tokenType = TokenType.Default; // ie : default or Steam
|
private TokenType tokenType = TokenType.Default; // ie : default or Steam
|
||||||
private String name = ""; // ie : user@email.com
|
private String name = ""; // ie : user@email.com
|
||||||
private String issuer = ""; // ie : Gitlab
|
private String issuer = ""; // ie : Gitlab
|
||||||
private String secretString = "";
|
|
||||||
private byte[] secret;
|
private byte[] secret;
|
||||||
private int counter = DEFAULT_HOTP_COUNTER; // ie : 5 - only for HOTP
|
private int counter = DEFAULT_HOTP_COUNTER; // ie : 5 - only for HOTP
|
||||||
private int step = 30; // ie : 30 seconds - only for TOTP
|
private int step = DEFAULT_STEP; // ie : 30 seconds - only for TOTP
|
||||||
private int digits = TokenType.Default.digits; // ie : 6 - number of digits generated
|
private int digits = TokenType.Default.digits; // ie : 6 - number of digits generated
|
||||||
|
|
||||||
public OtpEntryFields(EntryVersioned entry) {
|
public OtpEntryFields(EntryVersioned entry) {
|
||||||
this.entry = entry;
|
this.entry = entry;
|
||||||
if (!parseOtpFromUrl()) {
|
|
||||||
parseTOTPFromField();
|
// OTP (HOTP/TOTP) from URL and field from KeePassXC
|
||||||
}
|
boolean parse = parseOtpUri();
|
||||||
|
// TOTP from key values (maybe plugin or old KeePassXC)
|
||||||
|
if (!parse)
|
||||||
|
parse = parseTotpKeyValues();
|
||||||
|
// TOTP from custom field
|
||||||
|
if (!parse)
|
||||||
|
parse = parseTOTPFromField();
|
||||||
|
// HOTP fields from KeePass 2
|
||||||
|
if (!parse)
|
||||||
|
parseHOTPFromField();
|
||||||
}
|
}
|
||||||
|
|
||||||
public OtpType getType() {
|
public OtpType getType() {
|
||||||
@@ -162,10 +181,14 @@ public class OtpEntryFields {
|
|||||||
this.issuer = issuer;
|
this.issuer = issuer;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setSecret(@NonNull String secret) {
|
private void setBase32Secret(@NonNull String secret) {
|
||||||
this.secret = new Base32().decode(secret.getBytes());
|
this.secret = new Base32().decode(secret.getBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setBase64Secret(@NonNull String secret) {
|
||||||
|
this.secret = new Base64().decode(secret.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
private void setCounter(int counter) {
|
private void setCounter(int counter) {
|
||||||
if (counter < 0) {
|
if (counter < 0) {
|
||||||
this.counter = counter;
|
this.counter = counter;
|
||||||
@@ -200,68 +223,74 @@ public class OtpEntryFields {
|
|||||||
* <p>otpauth://totp/user@example.com?secret=FFF...
|
* <p>otpauth://totp/user@example.com?secret=FFF...
|
||||||
*
|
*
|
||||||
* <p>otpauth://hotp/user@example.com?secret=FFF...&counter=123
|
* <p>otpauth://hotp/user@example.com?secret=FFF...&counter=123
|
||||||
*
|
|
||||||
* @param uri The URI containing the secret key
|
|
||||||
*/
|
*/
|
||||||
private boolean parseOtpUri(Uri uri) {
|
private boolean parseOtpUri() {
|
||||||
if (uri.getScheme() == null
|
String otpPlainText = getField(OTP_FIELD);
|
||||||
|| !OTP_SCHEME.equals(uri.getScheme().toLowerCase())) {
|
if (otpPlainText != null
|
||||||
Log.e(TAG, "Invalid or missing scheme in uri");
|
&& !otpPlainText.isEmpty()) {
|
||||||
return false;
|
Uri uri = Uri.parse(otpPlainText);
|
||||||
}
|
|
||||||
|
|
||||||
final String authority = uri.getAuthority();
|
if (uri.getScheme() == null
|
||||||
if (TOTP.equals(authority)) {
|
|| !OTP_SCHEME.equals(uri.getScheme().toLowerCase())) {
|
||||||
|
Log.e(TAG, "Invalid or missing scheme in uri");
|
||||||
type = OtpType.TOTP;
|
return false;
|
||||||
|
|
||||||
} else if (HOTP.equals(authority)) {
|
|
||||||
|
|
||||||
type = OtpType.HOTP;
|
|
||||||
String counterParameter = uri.getQueryParameter(COUNTER_URL_PARAM);
|
|
||||||
if (counterParameter != null) {
|
|
||||||
try {
|
|
||||||
counter = Integer.parseInt(counterParameter);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
Log.e(TAG, "Invalid counter in uri");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
final String authority = uri.getAuthority();
|
||||||
Log.e(TAG, "Invalid or missing authority in uri");
|
if (TOTP_AUTHORITY.equals(authority)) {
|
||||||
return false;
|
type = OtpType.TOTP;
|
||||||
|
|
||||||
|
} else if (HOTP_AUTHORITY.equals(authority)) {
|
||||||
|
type = OtpType.HOTP;
|
||||||
|
|
||||||
|
String counterParameter = uri.getQueryParameter(COUNTER_URL_PARAM);
|
||||||
|
if (counterParameter != null) {
|
||||||
|
try {
|
||||||
|
setCounter(Integer.parseInt(counterParameter));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
Log.e(TAG, "Invalid counter in uri");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Invalid or missing authority in uri");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String nameParam = validateAndGetNameInPath(uri.getPath());
|
||||||
|
if (nameParam != null && !nameParam.isEmpty())
|
||||||
|
setName(nameParam);
|
||||||
|
|
||||||
|
String issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM);
|
||||||
|
if (issuerParam != null && !issuerParam.isEmpty())
|
||||||
|
setIssuer(issuerParam);
|
||||||
|
|
||||||
|
String secretParam = uri.getQueryParameter(SECRET_URL_PARAM);
|
||||||
|
if (secretParam != null && !secretParam.isEmpty())
|
||||||
|
setBase32Secret(secretParam);
|
||||||
|
|
||||||
|
String encoderParam = uri.getQueryParameter(ENCODER_URL_PARAM);
|
||||||
|
if (encoderParam != null && !encoderParam.isEmpty()) {
|
||||||
|
tokenType = TokenType.getFromString(encoderParam);
|
||||||
|
setDigits(tokenType.digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
String digitsParam = uri.getQueryParameter(DIGITS_URL_PARAM);
|
||||||
|
if (digitsParam != null && !digitsParam.isEmpty())
|
||||||
|
setDigits(toInt(digitsParam));
|
||||||
|
|
||||||
|
String counterParam = uri.getQueryParameter(COUNTER_URL_PARAM);
|
||||||
|
if (counterParam != null && !counterParam.isEmpty())
|
||||||
|
setCounter(toInt(counterParam));
|
||||||
|
|
||||||
|
String stepParam = uri.getQueryParameter(PERIOD_URL_PARAM);
|
||||||
|
if (stepParam != null && !stepParam.isEmpty())
|
||||||
|
setStep(toInt(stepParam));
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
String nameParam = validateAndGetNameInPath(uri.getPath());
|
|
||||||
if (nameParam != null && !nameParam.isEmpty())
|
|
||||||
setName(nameParam);
|
|
||||||
|
|
||||||
String issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM);
|
|
||||||
if (issuerParam != null && !issuerParam.isEmpty())
|
|
||||||
setIssuer(issuerParam);
|
|
||||||
|
|
||||||
String secretParam = uri.getQueryParameter(SECRET_URL_PARAM);
|
|
||||||
if (secretParam != null && !secretParam.isEmpty())
|
|
||||||
setSecret(secretParam);
|
|
||||||
|
|
||||||
String encoderParam = uri.getQueryParameter(ENCODER_URL_PARAM);
|
|
||||||
if (encoderParam != null && !encoderParam.isEmpty())
|
|
||||||
setDigits(TokenType.getFromString(encoderParam).digits);
|
|
||||||
|
|
||||||
String digitsParam = uri.getQueryParameter(DIGITS_URL_PARAM);
|
|
||||||
if (digitsParam != null && !digitsParam.isEmpty())
|
|
||||||
setDigits(toInt(digitsParam));
|
|
||||||
|
|
||||||
String counterParam = uri.getQueryParameter(COUNTER_URL_PARAM);
|
|
||||||
if (counterParam != null && !counterParam.isEmpty())
|
|
||||||
setCounter(toInt(counterParam));
|
|
||||||
|
|
||||||
String stepParam = uri.getQueryParameter(PERIOD_URL_PARAM);
|
|
||||||
if (stepParam != null && !stepParam.isEmpty())
|
|
||||||
setStep(toInt(stepParam));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String validateAndGetNameInPath(String path) {
|
private static String validateAndGetNameInPath(String path) {
|
||||||
@@ -276,58 +305,83 @@ public class OtpEntryFields {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean parseOtpKeyValues(String plainText) {
|
private boolean parseTotpKeyValues() {
|
||||||
if (Pattern.matches(validKeyValueRegex, plainText)) {
|
String plainText = getField(OTP_FIELD);
|
||||||
// KeeOtp string format
|
if (plainText != null
|
||||||
HashMap<String, String> query = breakDownKeyValuePairs(plainText);
|
&& !plainText.isEmpty()) {
|
||||||
|
if (Pattern.matches(validKeyValueRegex, plainText)) {
|
||||||
|
// KeeOtp string format
|
||||||
|
HashMap<String, String> query = breakDownKeyValuePairs(plainText);
|
||||||
|
|
||||||
String secretString = query.get(SEED_KEY);
|
String secretString = query.get(SEED_KEY);
|
||||||
if (secretString == null)
|
if (secretString == null)
|
||||||
secretString = "";
|
secretString = "";
|
||||||
setSecret(secretString);
|
setBase32Secret(secretString);
|
||||||
setDigits(toInt(query.get(DIGITS_KEY)));
|
setDigits(toInt(query.get(DIGITS_KEY)));
|
||||||
setStep(toInt(query.get(STEP_KEY)));
|
setStep(toInt(query.get(STEP_KEY)));
|
||||||
return true;
|
|
||||||
} else {
|
type = OtpType.TOTP;
|
||||||
// Malformed
|
return true;
|
||||||
return false;
|
} else {
|
||||||
|
// Malformed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return false;
|
||||||
|
|
||||||
private boolean parseOtpFromUrl() {
|
|
||||||
String otpPlainText = getField(OTP_FIELD);
|
|
||||||
if (otpPlainText == null
|
|
||||||
|| otpPlainText.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseOtpUri(Uri.parse(otpPlainText))
|
|
||||||
|| parseOtpKeyValues(otpPlainText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean parseTOTPFromField() {
|
private boolean parseTOTPFromField() {
|
||||||
String seedField = getField(TOTP_SEED_FIELD);
|
String seedField = getField(TOTP_SEED_FIELD);
|
||||||
String settingsField = getField(TOTP_SETTING_FIELD);
|
if (seedField == null) {
|
||||||
if (seedField == null || settingsField == null) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
setBase32Secret(seedField);
|
||||||
|
|
||||||
|
String settingsField = getField(TOTP_SETTING_FIELD);
|
||||||
|
if (settingsField != null) {
|
||||||
|
// Regex match, sync with OtpTokenGenerator.shortNameToEncoder
|
||||||
|
Pattern pattern = Pattern.compile("(\\d+);((?:\\d+)|S)");
|
||||||
|
Matcher matcher = pattern.matcher(settingsField);
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
// malformed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setStep(toInt(matcher.group(1)));
|
||||||
|
setDigits(TokenType.getFromString(matcher.group(2)).digits);
|
||||||
|
}
|
||||||
|
|
||||||
type = OtpType.TOTP;
|
type = OtpType.TOTP;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Regex match, sync with OtpTokenGenerator.shortNameToEncoder
|
private boolean parseHOTPFromField() {
|
||||||
Pattern pattern = Pattern.compile("(\\d+);((?:\\d+)|S)");
|
String secretField = getField(HMACOTP_SECRET_KEY);
|
||||||
Matcher matcher = pattern.matcher(settingsField);
|
String secretHexField = getField(HMACOTP_SECRET_HEX_KEY);
|
||||||
if (!matcher.matches()) {
|
String secretBase32Field = getField(HMACOTP_SECRET_BASE32_KEY);
|
||||||
// malformed
|
String secretBase64Field = getField(HMACOTP_SECRET_BASE64_KEY);
|
||||||
|
if (secretField != null)
|
||||||
|
secret = secretField.getBytes(Charset.forName("UTF-8"));
|
||||||
|
else if (secretHexField != null) {
|
||||||
|
try {
|
||||||
|
secret = Hex.decodeHex(secretHexField);
|
||||||
|
} catch (DecoderException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (secretBase32Field != null)
|
||||||
|
setBase32Secret(secretBase32Field);
|
||||||
|
else if (secretBase64Field != null)
|
||||||
|
setBase64Secret(secretBase64Field);
|
||||||
|
else
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
String secretCounterField = getField(HMACOTP_SECRET_COUNTER_KEY);
|
||||||
|
if (secretCounterField != null) {
|
||||||
|
setCounter(toInt(secretCounterField));
|
||||||
}
|
}
|
||||||
|
|
||||||
step = toInt(matcher.group(1));
|
type = OtpType.HOTP;
|
||||||
|
|
||||||
String encodingType = matcher.group(2);
|
|
||||||
digits = TokenType.getFromString(encodingType).digits;
|
|
||||||
|
|
||||||
secretString = seedField;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user