Add MOTP support

This commit is contained in:
elena 2022-07-26 23:56:04 +01:00
parent 043b17909c
commit 3441deee64
11 changed files with 323 additions and 47 deletions

View file

@ -0,0 +1,54 @@
package com.beemdevelopment.aegis.crypto.otp;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.beemdevelopment.aegis.encoding.Hex;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class MOTP {
private final String _code;
private final int _digits;
private MOTP(String code, int digits) {
_code = code;
_digits = digits;
}
@NonNull
public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin)
throws NoSuchAlgorithmException {
return generateOTP(secret, algo, digits, period, pin, System.currentTimeMillis() / 1000);
}
@NonNull
public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin, long time)
throws NoSuchAlgorithmException {
long timeBasedCounter = time / period;
String secretAsString = Hex.encode(secret);
String toDigest = timeBasedCounter + secretAsString + pin;
String code = getDigest(algo, toDigest.getBytes(StandardCharsets.UTF_8));
return new MOTP(code, digits);
}
@VisibleForTesting
@NonNull
protected static String getDigest(String algo, byte[] toDigest) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance(algo);
byte[] digest = md.digest(toDigest);
return Hex.encode(digest);
}
@NonNull
@Override
public String toString() {
return _code.substring(0, _digits);
}
}

View file

@ -6,6 +6,7 @@ import com.beemdevelopment.aegis.GoogleAuthProtos;
import com.beemdevelopment.aegis.encoding.Base32;
import com.beemdevelopment.aegis.encoding.Base64;
import com.beemdevelopment.aegis.encoding.EncodingException;
import com.beemdevelopment.aegis.encoding.Hex;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.Serializable;
@ -37,7 +38,7 @@ public class GoogleAuthInfo implements Serializable {
public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException {
String scheme = uri.getScheme();
if (scheme == null || !scheme.equals(SCHEME)) {
if (scheme == null || !(scheme.equals(SCHEME) || scheme.equals(MotpInfo.SCHEME))) {
throw new GoogleAuthInfoException(uri, String.format("Unsupported protocol: %s", scheme));
}
@ -49,7 +50,7 @@ public class GoogleAuthInfo implements Serializable {
byte[] secret;
try {
secret = parseSecret(encodedSecret);
secret = (scheme.equals(MotpInfo.SCHEME)) ? Hex.decode(encodedSecret) : parseSecret(encodedSecret);
} catch (EncodingException e) {
throw new GoogleAuthInfoException(uri, "Bad secret", e);
}
@ -57,7 +58,7 @@ public class GoogleAuthInfo implements Serializable {
OtpInfo info;
String issuer = "";
try {
String type = uri.getHost();
String type = (scheme.equals(MotpInfo.SCHEME)) ? MotpInfo.ID : uri.getHost();
if (type == null) {
throw new GoogleAuthInfoException(uri, String.format("Host not present in URI: %s", uri.toString()));
}
@ -97,6 +98,9 @@ public class GoogleAuthInfo implements Serializable {
info = new YandexInfo(secret, pin);
issuer = info.getType();
break;
case MotpInfo.ID:
info = new MotpInfo(secret);
break;
default:
throw new GoogleAuthInfoException(uri, String.format("Unsupported OTP type: %s", type));
}
@ -261,30 +265,36 @@ public class GoogleAuthInfo implements Serializable {
public Uri getUri() {
Uri.Builder builder = new Uri.Builder();
builder.scheme(SCHEME);
if (_info instanceof TotpInfo) {
if (_info instanceof SteamInfo) {
builder.authority("steam");
} else if (_info instanceof YandexInfo) {
builder.authority(YandexInfo.HOST_ID);
} else {
builder.authority("totp");
}
builder.appendQueryParameter("period", Integer.toString(((TotpInfo) _info).getPeriod()));
} else if (_info instanceof HotpInfo) {
builder.authority("hotp");
builder.appendQueryParameter("counter", Long.toString(((HotpInfo) _info).getCounter()));
if (_info instanceof MotpInfo) {
builder.scheme(MotpInfo.SCHEME);
builder.appendQueryParameter("secret", Hex.encode(_info.getSecret()));
} else {
throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass()));
}
builder.scheme(SCHEME);
builder.appendQueryParameter("digits", Integer.toString(_info.getDigits()));
builder.appendQueryParameter("algorithm", _info.getAlgorithm(false));
builder.appendQueryParameter("secret", Base32.encode(_info.getSecret()));
if (_info instanceof TotpInfo) {
if (_info instanceof SteamInfo) {
builder.authority("steam");
} else if (_info instanceof YandexInfo) {
builder.authority(YandexInfo.HOST_ID);
} else {
builder.authority("totp");
}
builder.appendQueryParameter("period", Integer.toString(((TotpInfo) _info).getPeriod()));
} else if (_info instanceof HotpInfo) {
builder.authority("hotp");
builder.appendQueryParameter("counter", Long.toString(((HotpInfo) _info).getCounter()));
} else {
throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass()));
}
if (_info instanceof YandexInfo) {
builder.appendQueryParameter("pin", Base32.encode(((YandexInfo) _info).getPin()));
builder.appendQueryParameter("digits", Integer.toString(_info.getDigits()));
builder.appendQueryParameter("algorithm", _info.getAlgorithm(false));
builder.appendQueryParameter("secret", Base32.encode(_info.getSecret()));
if (_info instanceof YandexInfo) {
builder.appendQueryParameter("pin", Base32.encode(((YandexInfo) _info).getPin()));
}
}
if (_issuer != null && !_issuer.equals("")) {

View file

@ -0,0 +1,95 @@
package com.beemdevelopment.aegis.otp;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.beemdevelopment.aegis.crypto.otp.MOTP;
import org.json.JSONException;
import org.json.JSONObject;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
public class MotpInfo extends TotpInfo {
public static final String ID = "motp";
public static final String SCHEME = "motp";
public static final String ALGORITHM = "MD5";
public static final int PERIOD = 10;
public static final int DIGITS = 6;
private String _pin;
public MotpInfo(@NonNull byte[] secret) throws OtpInfoException {
this(secret, null);
}
public MotpInfo(byte[] secret, String pin) throws OtpInfoException {
super(secret, ALGORITHM, DIGITS, PERIOD);
setPin(pin);
}
@Override
public String getOtp() {
if (_pin == null) {
throw new IllegalStateException("PIN must be set before generating an OTP");
}
try {
MOTP otp = MOTP.generateOTP(getSecret(), getAlgorithm(false), getDigits(), getPeriod(), getPin());
return otp.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
@Override
public String getOtp(long time) {
if (_pin == null) {
throw new IllegalStateException("PIN must be set before generating an OTP");
}
try {
MOTP otp = MOTP.generateOTP(getSecret(), getAlgorithm(false), getDigits(), getPeriod(), getPin(), time);
return otp.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
@Override
public String getTypeId() {
return ID;
}
@Override
public JSONObject toJson() {
JSONObject result = super.toJson();
try {
result.put("pin", getPin());
} catch (JSONException e) {
throw new RuntimeException(e);
}
return result;
}
@Nullable
public String getPin() {
return _pin;
}
public void setPin(@NonNull String pin) {
this._pin = pin;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof MotpInfo)) {
return false;
}
MotpInfo info = (MotpInfo) o;
return super.equals(o) && Objects.equals(getPin(), info.getPin());
}
}

View file

@ -70,7 +70,8 @@ public abstract class OtpInfo implements Serializable {
}
public static boolean isAlgorithmValid(String algorithm) {
return algorithm.equals("SHA1") || algorithm.equals("SHA256") || algorithm.equals("SHA512");
return algorithm.equals("SHA1") || algorithm.equals("SHA256") ||
algorithm.equals("SHA512") || algorithm.equals("MD5");
}
public void setAlgorithm(String algorithm) throws OtpInfoException {
@ -118,6 +119,9 @@ public abstract class OtpInfo implements Serializable {
case YandexInfo.ID:
info = new YandexInfo(secret, obj.getString("pin"));
break;
case MotpInfo.ID:
info = new MotpInfo(secret, obj.getString("pin"));
break;
default:
throw new OtpInfoException("unsupported otp type: " + type);
}

View file

@ -34,6 +34,7 @@ import com.avito.android.krop.KropView;
import com.beemdevelopment.aegis.R;
import com.beemdevelopment.aegis.encoding.Base32;
import com.beemdevelopment.aegis.encoding.EncodingException;
import com.beemdevelopment.aegis.encoding.Hex;
import com.beemdevelopment.aegis.helpers.DropdownHelper;
import com.beemdevelopment.aegis.helpers.EditTextHelper;
import com.beemdevelopment.aegis.helpers.IconViewHelper;
@ -42,6 +43,7 @@ import com.beemdevelopment.aegis.icons.IconPack;
import com.beemdevelopment.aegis.icons.IconType;
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
import com.beemdevelopment.aegis.otp.HotpInfo;
import com.beemdevelopment.aegis.otp.MotpInfo;
import com.beemdevelopment.aegis.otp.OtpInfo;
import com.beemdevelopment.aegis.otp.OtpInfoException;
import com.beemdevelopment.aegis.otp.SteamInfo;
@ -102,8 +104,8 @@ public class EditEntryActivity extends AegisActivity {
private TextInputEditText _textDigits;
private TextInputLayout _textDigitsLayout;
private TextInputEditText _textSecret;
private TextInputEditText _textYandexPin;
private LinearLayout _textYandexPinLayout;
private TextInputEditText _textPin;
private LinearLayout _textPinLayout;
private TextInputEditText _textUsageCount;
private TextInputEditText _textNote;
@ -156,8 +158,8 @@ public class EditEntryActivity extends AegisActivity {
_textDigits = findViewById(R.id.text_digits);
_textDigitsLayout = findViewById(R.id.text_digits_layout);
_textSecret = findViewById(R.id.text_secret);
_textYandexPin = findViewById(R.id.text_yandex_pin);
_textYandexPinLayout = findViewById(R.id.layout_yandex_pin);
_textPin = findViewById(R.id.text_pin);
_textPinLayout = findViewById(R.id.layout_pin);
_textUsageCount = findViewById(R.id.text_usage_count);
_textNote = findViewById(R.id.text_note);
_dropdownType = findViewById(R.id.dropdown_type);
@ -178,9 +180,9 @@ public class EditEntryActivity extends AegisActivity {
layoutBasic.removeView(layoutSecret);
if (!_isNew) {
secretIndex = 1;
layoutBasic.removeView(_textYandexPinLayout);
layoutAdvanced.addView(_textYandexPinLayout, 0);
((LinearLayout.LayoutParams) _textYandexPinLayout.getLayoutParams()).topMargin = 0;
layoutBasic.removeView(_textPinLayout);
layoutAdvanced.addView(_textPinLayout, 0);
((LinearLayout.LayoutParams) _textPinLayout.getLayoutParams()).topMargin = 0;
} else {
((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0;
}
@ -233,7 +235,7 @@ public class EditEntryActivity extends AegisActivity {
byte[] secretBytes = _origEntry.getInfo().getSecret();
if (secretBytes != null) {
String secretString = Base32.encode(secretBytes);
String secretString = (info instanceof MotpInfo) ? Hex.encode(secretBytes) : Base32.encode(secretBytes);
_textSecret.setText(secretString);
}
@ -241,7 +243,9 @@ public class EditEntryActivity extends AegisActivity {
_dropdownAlgo.setText(_origEntry.getInfo().getAlgorithm(false), false);
if (info instanceof YandexInfo) {
_textYandexPin.setText(((YandexInfo) info).getPin());
_textPin.setText(((YandexInfo) info).getPin());
} else if (info instanceof MotpInfo) {
_textPin.setText(((MotpInfo) info).getPin());
}
updateAdvancedFieldStatus(_origEntry.getInfo().getTypeId());
@ -280,6 +284,12 @@ public class EditEntryActivity extends AegisActivity {
_textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD));
_textDigits.setText(String.valueOf(YandexInfo.DIGITS));
break;
case MotpInfo.ID:
_dropdownAlgo.setText(MotpInfo.ALGORITHM, false);
_textPeriodCounterLayout.setHint(R.string.period_hint);
_textPeriodCounter.setText(String.valueOf(MotpInfo.PERIOD));
_textDigits.setText(String.valueOf(MotpInfo.DIGITS));
break;
default:
throw new RuntimeException(String.format("Unsupported OTP type: %s", type));
}
@ -317,15 +327,17 @@ public class EditEntryActivity extends AegisActivity {
}
private void updateAdvancedFieldStatus(String otpType) {
boolean enabled = !otpType.equals(SteamInfo.ID) && !otpType.equals(YandexInfo.ID) && (!_isNew || _isManual);
boolean enabled = !otpType.equals(SteamInfo.ID) && !otpType.equals(YandexInfo.ID)
&& !otpType.equals(MotpInfo.ID) && (!_isNew || _isManual);
_textDigitsLayout.setEnabled(enabled);
_textPeriodCounterLayout.setEnabled(enabled);
_dropdownAlgoLayout.setEnabled(enabled);
}
private void updatePinFieldVisibility(String otpType) {
boolean visible = otpType.equals(YandexInfo.ID);
_textYandexPinLayout.setVisibility(visible ? View.VISIBLE : View.GONE);
boolean visible = otpType.equals(YandexInfo.ID) || otpType.equals(MotpInfo.ID);
_textPinLayout.setVisibility(visible ? View.VISIBLE : View.GONE);
_textPin.setHint(otpType.equals(MotpInfo.ID) ? R.string.motp_pin : R.string.yandex_pin);
}
private void setGroup(String groupName) {
@ -662,11 +674,14 @@ public class EditEntryActivity extends AegisActivity {
String algo = _dropdownAlgo.getText().toString();
String lowerCasedType = type.toLowerCase(Locale.ROOT);
if (lowerCasedType.equals(YandexInfo.ID)) {
int pinLength = _textYandexPin.length();
if (lowerCasedType.equals(YandexInfo.ID) || lowerCasedType.equals(MotpInfo.ID)) {
int pinLength = _textPin.length();
if (pinLength < 4) {
throw new ParseException("PIN is a required field. Must have a minimum length of 4 digits.");
}
if (pinLength != 4 && lowerCasedType.equals(MotpInfo.ID)) {
throw new ParseException("PIN must have a length of 4 digits.");
}
}
int digits;
@ -679,12 +694,18 @@ public class EditEntryActivity extends AegisActivity {
byte[] secret;
try {
String secretString = new String(EditTextHelper.getEditTextChars(_textSecret));
secret = GoogleAuthInfo.parseSecret(secretString);
secret = (lowerCasedType.equals(MotpInfo.ID)) ?
Hex.decode(secretString) : GoogleAuthInfo.parseSecret(secretString);
if (secret.length == 0) {
throw new ParseException("Secret cannot be empty");
}
} catch (EncodingException e) {
throw new ParseException("Secret is not valid base32.");
String exceptionMessage = (lowerCasedType.equals(MotpInfo.ID)) ?
"Secret is not valid hexadecimal" : "Secret is not valid base32.";
throw new ParseException(exceptionMessage);
}
OtpInfo info;
@ -706,7 +727,10 @@ public class EditEntryActivity extends AegisActivity {
info = new HotpInfo(secret, algo, digits, counter);
break;
case YandexInfo.ID:
info = new YandexInfo(secret, _textYandexPin.getText().toString());
info = new YandexInfo(secret, _textPin.getText().toString());
break;
case MotpInfo.ID:
info = new MotpInfo(secret, _textPin.getText().toString());
break;
default:
throw new RuntimeException(String.format("Unsupported OTP type: %s", type));