diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java
index 4db3a94c..60c75213 100644
--- a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java
+++ b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java
@@ -12,6 +12,7 @@ import com.beemdevelopment.aegis.encoding.Base32;
import com.beemdevelopment.aegis.otp.HotpInfo;
import com.beemdevelopment.aegis.otp.SteamInfo;
import com.beemdevelopment.aegis.otp.TotpInfo;
+import com.beemdevelopment.aegis.otp.YandexInfo;
import com.beemdevelopment.aegis.ui.MainActivity;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultManager;
@@ -68,7 +69,8 @@ public class OverallTest extends AegisTest {
generateEntry(TotpInfo.class, "Frank", "Google"),
generateEntry(HotpInfo.class, "John", "GitHub"),
generateEntry(TotpInfo.class, "Alice", "Office 365"),
- generateEntry(SteamInfo.class, "Gaben", "Steam")
+ generateEntry(SteamInfo.class, "Gaben", "Steam"),
+ generateEntry(YandexInfo.class, "Ivan", "Yandex")
);
for (VaultEntry entry : entries) {
addEntry(entry);
@@ -171,6 +173,8 @@ public class OverallTest extends AegisTest {
otpType = "Steam";
} else if (entry.getInfo() instanceof TotpInfo) {
otpType = "TOTP";
+ } else if (entry.getInfo() instanceof YandexInfo) {
+ otpType = "Yandex";
} else {
throw new RuntimeException(String.format("Unexpected entry type: %s", entry.getInfo().getClass().getSimpleName()));
}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java
index 5c5afec0..22dea186 100644
--- a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java
+++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java
@@ -14,18 +14,7 @@ public class HOTP {
public static OTP generateOTP(byte[] secret, String algo, int digits, long counter)
throws NoSuchAlgorithmException, InvalidKeyException {
- SecretKeySpec key = new SecretKeySpec(secret, "RAW");
-
- // encode counter in big endian
- byte[] counterBytes = ByteBuffer.allocate(8)
- .order(ByteOrder.BIG_ENDIAN)
- .putLong(counter)
- .array();
-
- // calculate the hash of the counter
- Mac mac = Mac.getInstance(algo);
- mac.init(key);
- byte[] hash = mac.doFinal(counterBytes);
+ byte[] hash = getHash(secret, algo, counter);
// truncate hash to get the HTOP value
// http://tools.ietf.org/html/rfc4226#section-5.4
@@ -37,4 +26,20 @@ public class HOTP {
return new OTP(otp, digits);
}
+
+ public static byte[] getHash(byte[] secret, String algo, long counter)
+ throws NoSuchAlgorithmException, InvalidKeyException {
+ SecretKeySpec key = new SecretKeySpec(secret, "RAW");
+
+ // encode counter in big endian
+ byte[] counterBytes = ByteBuffer.allocate(8)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putLong(counter)
+ .array();
+
+ // calculate the hash of the counter
+ Mac mac = Mac.getInstance(algo);
+ mac.init(key);
+ return mac.doFinal(counterBytes);
+ }
}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java
new file mode 100644
index 00000000..6689ede0
--- /dev/null
+++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java
@@ -0,0 +1,83 @@
+package com.beemdevelopment.aegis.crypto.otp;
+
+import androidx.annotation.NonNull;
+
+import com.beemdevelopment.aegis.util.YandexUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+public class YAOTP {
+ private static final int EN_ALPHABET_LENGTH = 26;
+ private final long _code;
+ private final int _digits;
+
+ private YAOTP(long code, int digits) {
+ _code = code;
+ _digits = digits;
+ }
+
+ public static YAOTP generateOTP(byte[] secret, byte[] pin, int digits, String otpAlgo, long period)
+ throws NoSuchAlgorithmException, InvalidKeyException, IOException {
+ long seconds = System.currentTimeMillis() / 1000;
+ return generateOTP(secret, pin, digits, otpAlgo, seconds, period);
+ }
+
+ public static YAOTP generateOTP(byte[] secret, byte[] pin, int digits, String otpAlgo, long seconds, long period)
+ throws NoSuchAlgorithmException, InvalidKeyException, IOException {
+
+ long counter = (long) Math.floor((double) seconds / period);
+
+ try (ByteArrayOutputStream pinWithHashStream =
+ new ByteArrayOutputStream(pin.length + secret.length)) {
+
+ pinWithHashStream.write(pin);
+ pinWithHashStream.write(secret, 0, YandexUtils.APPROVED_SECRET_LENGTH);
+
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] keyHash = md.digest(pinWithHashStream.toByteArray());
+
+ if (keyHash[0] == 0) {
+ keyHash = Arrays.copyOfRange(keyHash, 1, keyHash.length);
+ }
+
+ byte[] periodHash = HOTP.getHash(keyHash, otpAlgo, counter);
+ int offset = periodHash[periodHash.length - 1] & 0xf;
+
+ periodHash[offset] &= 0x7f;
+ long otp = ByteBuffer.wrap(periodHash)
+ .order(ByteOrder.BIG_ENDIAN)
+ .getLong(offset);
+
+ return new YAOTP(otp, digits);
+ }
+ }
+
+ public long getCode() {
+ return _code;
+ }
+
+ public int getDigits() {
+ return _digits;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ long code = _code % (long) Math.pow(EN_ALPHABET_LENGTH, _digits);
+ char[] chars = new char[_digits];
+
+ for (int i = _digits - 1; i >= 0; i--) {
+ chars[i] = (char) ('a' + (code % EN_ALPHABET_LENGTH));
+ code /= EN_ALPHABET_LENGTH;
+ }
+
+ return new String(chars);
+ }
+}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java
index fbfd044d..b2207628 100644
--- a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java
+++ b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java
@@ -26,42 +26,6 @@ public class GoogleAuthInfo implements Serializable {
_issuer = issuer;
}
- public OtpInfo getOtpInfo() {
- return _info;
- }
-
- public Uri getUri() {
- Uri.Builder builder = new Uri.Builder();
- builder.scheme(SCHEME);
-
- if (_info instanceof TotpInfo) {
- if (_info instanceof SteamInfo) {
- builder.authority("steam");
- } 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()));
- }
-
- builder.appendQueryParameter("digits", Integer.toString(_info.getDigits()));
- builder.appendQueryParameter("algorithm", _info.getAlgorithm(false));
- builder.appendQueryParameter("secret", Base32.encode(_info.getSecret()));
-
- if (_issuer != null && !_issuer.equals("")) {
- builder.path(String.format("%s:%s", _issuer, _accountName));
- builder.appendQueryParameter("issuer", _issuer);
- } else {
- builder.path(_accountName);
- }
-
- return builder.build();
- }
-
public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException {
Uri uri = Uri.parse(s);
if (uri == null) {
@@ -84,12 +48,13 @@ public class GoogleAuthInfo implements Serializable {
byte[] secret;
try {
- secret = parseSecret(encodedSecret);
+ secret = uri.getHost().equals(YandexInfo.OTP_SCHEMA_ID) ? parseYandexSecret(encodedSecret) : parseSecret(encodedSecret);
} catch (EncodingException e) {
throw new GoogleAuthInfoException(uri, "Bad secret", e);
}
OtpInfo info;
+ String issuer = "";
try {
String type = uri.getHost();
if (type == null) {
@@ -122,10 +87,15 @@ public class GoogleAuthInfo implements Serializable {
hotpInfo.setCounter(Long.parseLong(counter));
info = hotpInfo;
break;
+ case YandexInfo.OTP_SCHEMA_ID:
+ String pinValue = uri.getQueryParameter("pin");
+ info = pinValue != null ? new YandexInfo(secret, parseSecret(pinValue)) : new YandexInfo(secret);
+ issuer = info.getType();
+ break;
default:
throw new GoogleAuthInfoException(uri, String.format("Unsupported OTP type: %s", type));
}
- } catch (OtpInfoException | NumberFormatException e) {
+ } catch (OtpInfoException | NumberFormatException | EncodingException e) {
throw new GoogleAuthInfoException(uri, e);
}
@@ -134,7 +104,6 @@ public class GoogleAuthInfo implements Serializable {
String label = path != null && path.length() > 0 ? path.substring(1) : "";
String accountName = "";
- String issuer = "";
if (label.contains(":")) {
// a label can only contain one colon
@@ -151,7 +120,9 @@ public class GoogleAuthInfo implements Serializable {
// label only contains the account name
// grab the issuer's info from the 'issuer' parameter if it's present
String issuerParam = uri.getQueryParameter("issuer");
- issuer = issuerParam != null ? issuerParam : "";
+ if (issuer.isEmpty()) {
+ issuer = issuerParam != null ? issuerParam : "";
+ }
accountName = label;
}
@@ -180,6 +151,19 @@ public class GoogleAuthInfo implements Serializable {
return Base32.decode(s);
}
+ /**
+ * When arrives from Yandex site QR code - there will always be 26 symbols (secret only)
+ * If it arrives from Aegis Export - it can be 42 (if was manually created)
+ * Just to be sure, let's check secret length (until final RFC comes up)
+ */
+ public static byte[] parseYandexSecret(String s) throws EncodingException {
+ if (s.length() == YandexInfo.SECRET_LENGTH || s.length() == YandexInfo.SECRET_FULL_LENGTH) {
+ return parseSecret(s);
+ } else {
+ throw new EncodingException(new Throwable("Length differs from expected"));
+ }
+ }
+
public static Export parseExportUri(String s) throws GoogleAuthInfoException {
Uri uri = Uri.parse(s);
if (uri == null) {
@@ -260,7 +244,7 @@ public class GoogleAuthInfo implements Serializable {
default:
throw new GoogleAuthInfoException(uri, String.format("Unsupported algorithm: %d", params.getType().ordinal()));
}
- } catch (OtpInfoException e){
+ } catch (OtpInfoException e) {
throw new GoogleAuthInfoException(uri, e);
}
@@ -279,6 +263,48 @@ public class GoogleAuthInfo implements Serializable {
return new Export(infos, payload.getBatchId(), payload.getBatchIndex(), payload.getBatchSize());
}
+ public OtpInfo getOtpInfo() {
+ return _info;
+ }
+
+ 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.OTP_SCHEMA_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()));
+ }
+
+ 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).getPinBytes()));
+ }
+
+ if (_issuer != null && !_issuer.equals("")) {
+ builder.path(String.format("%s:%s", _issuer, _accountName));
+ builder.appendQueryParameter("issuer", _issuer);
+ } else {
+ builder.path(_accountName);
+ }
+
+ return builder.build();
+ }
+
public String getIssuer() {
return _issuer;
}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java
index a3408610..52f035fa 100644
--- a/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java
+++ b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java
@@ -115,6 +115,10 @@ public abstract class OtpInfo implements Serializable {
case HotpInfo.ID:
info = new HotpInfo(secret, algo, digits, obj.getLong("counter"));
break;
+ case YandexInfo.ID:
+ byte[] pin = Base32.decode(obj.getString("pin"));
+ info = new YandexInfo(secret, pin);
+ break;
default:
throw new OtpInfoException("unsupported otp type: " + type);
}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java
new file mode 100644
index 00000000..04ebf107
--- /dev/null
+++ b/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java
@@ -0,0 +1,89 @@
+package com.beemdevelopment.aegis.otp;
+
+import com.beemdevelopment.aegis.crypto.otp.YAOTP;
+import com.beemdevelopment.aegis.encoding.Base32;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.Locale;
+
+public class YandexInfo extends TotpInfo {
+ public static final String DEFAULT_ALGORITHM = "SHA256";
+ public static final int DIGITS = 8;
+
+ public static final int SECRET_LENGTH = 26;
+ public static final int SECRET_FULL_LENGTH = 42;
+ public static final String ID = "yandex";
+ public static final String OTP_SCHEMA_ID = "yaotp";
+
+ private byte[] _pin;
+
+ public YandexInfo(byte[] secret) throws OtpInfoException {
+ super(secret, DEFAULT_ALGORITHM, DIGITS, TotpInfo.DEFAULT_PERIOD);
+ }
+
+ public YandexInfo(byte[] secret, byte[] pin) throws OtpInfoException {
+ super(secret, DEFAULT_ALGORITHM, DIGITS, TotpInfo.DEFAULT_PERIOD);
+ this._pin = pin;
+ }
+
+ @Override
+ public String getOtp() {
+ try {
+ YAOTP otp = YAOTP.generateOTP(getSecret(), _pin, getDigits(), getAlgorithm(true), getPeriod());
+ return otp.toString();
+ } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public String getPin() {
+ return _pin != null ? new String(_pin, StandardCharsets.UTF_8) : "";
+ }
+
+ public byte[] getPinBytes() {
+ return _pin;
+ }
+
+ @Override
+ public String getTypeId() {
+ return ID;
+ }
+
+ @Override
+ public String getType() {
+ String id = getTypeId();
+ return id.substring(0, 1).toUpperCase(Locale.ROOT) + id.substring(1);
+ }
+
+ @Override
+ public JSONObject toJson() {
+ JSONObject result = super.toJson();
+ try {
+ result.put("pin", Base32.encode(getPinBytes()));
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof YandexInfo)) return false;
+
+ YandexInfo that = (YandexInfo) o;
+ return super.equals(o) && Arrays.equals(_pin, that._pin);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode() + Arrays.hashCode(_pin);
+ }
+}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java
index 090ed81e..a3b86b92 100644
--- a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java
+++ b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java
@@ -32,6 +32,7 @@ import androidx.documentfile.provider.DocumentFile;
import com.amulyakhare.textdrawable.TextDrawable;
import com.avito.android.krop.KropView;
import com.beemdevelopment.aegis.R;
+import com.beemdevelopment.aegis.crypto.CryptoUtils;
import com.beemdevelopment.aegis.encoding.Base32;
import com.beemdevelopment.aegis.encoding.EncodingException;
import com.beemdevelopment.aegis.helpers.DropdownHelper;
@@ -46,6 +47,7 @@ import com.beemdevelopment.aegis.otp.OtpInfo;
import com.beemdevelopment.aegis.otp.OtpInfoException;
import com.beemdevelopment.aegis.otp.SteamInfo;
import com.beemdevelopment.aegis.otp.TotpInfo;
+import com.beemdevelopment.aegis.otp.YandexInfo;
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog;
import com.beemdevelopment.aegis.ui.glide.IconLoader;
@@ -53,6 +55,7 @@ import com.beemdevelopment.aegis.ui.tasks.ImportFileTask;
import com.beemdevelopment.aegis.ui.views.IconAdapter;
import com.beemdevelopment.aegis.util.Cloner;
import com.beemdevelopment.aegis.util.IOUtils;
+import com.beemdevelopment.aegis.util.YandexUtils;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultManager;
import com.bumptech.glide.Glide;
@@ -101,6 +104,8 @@ public class EditEntryActivity extends AegisActivity {
private TextInputEditText _textDigits;
private TextInputLayout _textDigitsLayout;
private TextInputEditText _textSecret;
+ private TextInputEditText _textYandexPin;
+ private LinearLayout _textYandexPinLayout;
private TextInputEditText _textUsageCount;
private TextInputEditText _textNote;
@@ -153,6 +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);
_textUsageCount = findViewById(R.id.text_usage_count);
_textNote = findViewById(R.id.text_note);
_dropdownType = findViewById(R.id.dropdown_type);
@@ -166,12 +173,20 @@ public class EditEntryActivity extends AegisActivity {
// if this is NOT a manually entered entry, move the "Secret" field from basic to advanced settings
if (!_isNew || (_isNew && !_isManual)) {
+ int secretIndex = 0;
LinearLayout layoutSecret = findViewById(R.id.layout_secret);
LinearLayout layoutBasic = findViewById(R.id.layout_basic);
LinearLayout layoutAdvanced = findViewById(R.id.layout_advanced);
layoutBasic.removeView(layoutSecret);
- layoutAdvanced.addView(layoutSecret, 0);
- ((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0;
+ if (!_isNew) {
+ secretIndex = 1;
+ layoutBasic.removeView(_textYandexPinLayout);
+ layoutAdvanced.addView(_textYandexPinLayout, 0);
+ ((LinearLayout.LayoutParams) _textYandexPinLayout.getLayoutParams()).topMargin = 0;
+ } else {
+ ((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0;
+ }
+ layoutAdvanced.addView(layoutSecret, secretIndex);
if (_isNew && !_isManual) {
setViewEnabled(layoutAdvanced, false);
@@ -206,6 +221,7 @@ public class EditEntryActivity extends AegisActivity {
_textNote.setText(_origEntry.getNote());
OtpInfo info = _origEntry.getInfo();
+
if (info instanceof TotpInfo) {
_textPeriodCounterLayout.setHint(R.string.period_hint);
_textPeriodCounter.setText(Integer.toString(((TotpInfo) info).getPeriod()));
@@ -225,7 +241,13 @@ public class EditEntryActivity extends AegisActivity {
_dropdownType.setText(_origEntry.getInfo().getType(), false);
_dropdownAlgo.setText(_origEntry.getInfo().getAlgorithm(false), false);
+
+ if (info instanceof YandexInfo) {
+ _textYandexPin.setText(((YandexInfo) info).getPin());
+ }
+
updateAdvancedFieldStatus(_origEntry.getInfo().getTypeId());
+ updatePinFieldVisibility(_origEntry.getInfo().getTypeId());
String group = _origEntry.getGroup();
setGroup(group);
@@ -254,11 +276,18 @@ public class EditEntryActivity extends AegisActivity {
_textPeriodCounter.setText(String.valueOf(HotpInfo.DEFAULT_COUNTER));
_textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS));
break;
+ case YandexInfo.ID:
+ _dropdownAlgo.setText(YandexInfo.DEFAULT_ALGORITHM, false);
+ _textPeriodCounterLayout.setHint(R.string.period_hint);
+ _textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD));
+ _textDigits.setText(String.valueOf(YandexInfo.DIGITS));
+ break;
default:
throw new RuntimeException(String.format("Unsupported OTP type: %s", type));
}
updateAdvancedFieldStatus(type);
+ updatePinFieldVisibility(type);
});
_iconView.setOnClickListener(v -> {
@@ -290,12 +319,17 @@ public class EditEntryActivity extends AegisActivity {
}
private void updateAdvancedFieldStatus(String otpType) {
- boolean enabled = !otpType.equals(SteamInfo.ID) && (!_isNew || _isManual);
+ boolean enabled = !otpType.equals(SteamInfo.ID) && !otpType.equals(YandexInfo.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);
+ }
+
private void setGroup(String groupName) {
int pos = 0;
if (groupName != null) {
@@ -627,6 +661,14 @@ public class EditEntryActivity extends AegisActivity {
String type = _dropdownType.getText().toString();
String algo = _dropdownAlgo.getText().toString();
+ String lowerCasedType = type.toLowerCase(Locale.ROOT);
+
+ if (lowerCasedType.equals(YandexInfo.ID)) {
+ int pinLength = _textYandexPin.length();
+ if (pinLength < 4) {
+ throw new ParseException("PIN is a required field. Min 4 digits.");
+ }
+ }
int digits;
try {
@@ -664,6 +706,12 @@ public class EditEntryActivity extends AegisActivity {
}
info = new HotpInfo(secret, algo, digits, counter);
break;
+ case YandexInfo.OTP_SCHEMA_ID:
+ case YandexInfo.ID:
+ YandexUtils.validateSecret(secret);
+ byte[] pin = CryptoUtils.toBytes(_textYandexPin.getText().toString().toCharArray());
+ info = new YandexInfo(secret, pin);
+ break;
default:
throw new RuntimeException(String.format("Unsupported OTP type: %s", type));
}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java
index 2007d29c..04013d64 100644
--- a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java
+++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java
@@ -22,6 +22,7 @@ import com.beemdevelopment.aegis.otp.HotpInfo;
import com.beemdevelopment.aegis.otp.OtpInfo;
import com.beemdevelopment.aegis.otp.SteamInfo;
import com.beemdevelopment.aegis.otp.TotpInfo;
+import com.beemdevelopment.aegis.otp.YandexInfo;
import com.beemdevelopment.aegis.ui.glide.IconLoader;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.bumptech.glide.Glide;
@@ -238,7 +239,7 @@ public class EntryHolder extends RecyclerView.ViewHolder {
OtpInfo info = _entry.getInfo();
String otp = info.getOtp();
- if (!(info instanceof SteamInfo)) {
+ if (!(info instanceof SteamInfo || info instanceof YandexInfo)) {
otp = formatCode(otp);
}
diff --git a/app/src/main/java/com/beemdevelopment/aegis/util/YandexUtils.java b/app/src/main/java/com/beemdevelopment/aegis/util/YandexUtils.java
new file mode 100644
index 00000000..208fdf29
--- /dev/null
+++ b/app/src/main/java/com/beemdevelopment/aegis/util/YandexUtils.java
@@ -0,0 +1,89 @@
+package com.beemdevelopment.aegis.util;
+
+import com.beemdevelopment.aegis.otp.OtpInfoException;
+import com.beemdevelopment.aegis.otp.YandexInfo;
+
+public class YandexUtils {
+ private static final char CHECKSUM_POLY = 0b1_1000_1111_0011;
+ public static final int APPROVED_SECRET_LENGTH = 16;
+
+ private YandexUtils() {
+ }
+
+ private static int getNumberOfLeadingZeros(char value) {
+ if (value == 0) return 16;
+
+ int n = 0;
+ if ((value & 0xFF00) == 0) {
+ n += 8;
+ value <<= 8;
+ }
+ if ((value & 0xF000) == 0) {
+ n += 4;
+ value <<= 4;
+ }
+ if ((value & 0xC000) == 0) {
+ n += 2;
+ value <<= 2;
+ }
+ if ((value & 0x8000) == 0) {
+ n++;
+ }
+
+ return n;
+ }
+
+ /**
+ * Java implementation of ChecksumIsValid
+ * from https://github.com/norblik/KeeYaOtp/blob/dev/KeeYaOtp/Core/Secret.cs
+ */
+ public static void validateSecret(byte[] secret) throws OtpInfoException {
+ /*
+ When secret comes from QR code - we can't test it,
+ cause it's only 16 byte long.
+ */
+ if (secret.length == APPROVED_SECRET_LENGTH) return;
+
+ if (secret.length != YandexInfo.SECRET_LENGTH)
+ throw new OtpInfoException("Wrong secret size");
+
+ char originalChecksum = (char) ((secret[secret.length - 2] & 0x0F) << 8 | secret[secret.length - 1] & 0xff);
+
+ char accum = 0;
+ int accumBits = 0;
+
+ int inputTotalBitsAvailable = secret.length * 8 - 12;
+ int inputIndex = 0;
+ int inputBitsAvailable = 8;
+
+ while (inputTotalBitsAvailable > 0) {
+ int requiredBits = 13 - accumBits;
+ if (inputTotalBitsAvailable < requiredBits) requiredBits = inputTotalBitsAvailable;
+
+ while (requiredBits > 0) {
+ int curInput = (secret[inputIndex] & (1 << inputBitsAvailable) - 1) & 0xff;
+ int bitsToRead = Math.min(requiredBits, inputBitsAvailable);
+
+ curInput >>= inputBitsAvailable - bitsToRead;
+ accum = (char) (accum << bitsToRead | curInput);
+
+ inputTotalBitsAvailable -= bitsToRead;
+ requiredBits -= bitsToRead;
+ inputBitsAvailable -= bitsToRead;
+ accumBits += bitsToRead;
+
+ if (inputBitsAvailable == 0) {
+ inputIndex += 1;
+ inputBitsAvailable = 8;
+ }
+ }
+
+ if (accumBits == 13) accum ^= CHECKSUM_POLY;
+ accumBits = 16 - getNumberOfLeadingZeros(accum);
+ }
+
+ if (accum != originalChecksum) {
+ throw new OtpInfoException("Secret is corrupted. Checksum is not valid");
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_baseline_fiber_pin_24.xml b/app/src/main/res/drawable/ic_baseline_fiber_pin_24.xml
new file mode 100644
index 00000000..957a249f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_fiber_pin_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_edit_entry.xml b/app/src/main/res/layout/activity_edit_entry.xml
index 885d06ba..5d214982 100644
--- a/app/src/main/res/layout/activity_edit_entry.xml
+++ b/app/src/main/res/layout/activity_edit_entry.xml
@@ -45,13 +45,12 @@
android:id="@+id/krop_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_centerHorizontal="true"
- android:layout_centerVertical="true"
+ android:layout_centerInParent="true"
android:visibility="invisible"
app:krop_aspectX="1"
app:krop_aspectY="1"
app:krop_offset="70dp"
- app:krop_overlayColor="#aadddddd" >
+ app:krop_overlayColor="#aadddddd">
+
+
+
+
+
+
+
+
TOTP
- HOTP
- Steam
+ - Yandex
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e50b47c9..22ee5630 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -15,6 +15,7 @@
Discard
Save
Issuer
+ PIN (4–16 digits)
Suggested
Usage count
diff --git a/app/src/test/java/com/beemdevelopment/aegis/crypto/otp/YAOTPTest.java b/app/src/test/java/com/beemdevelopment/aegis/crypto/otp/YAOTPTest.java
new file mode 100644
index 00000000..7d48528e
--- /dev/null
+++ b/app/src/test/java/com/beemdevelopment/aegis/crypto/otp/YAOTPTest.java
@@ -0,0 +1,53 @@
+package com.beemdevelopment.aegis.crypto.otp;
+
+import static org.junit.Assert.assertEquals;
+
+import com.beemdevelopment.aegis.crypto.CryptoUtils;
+import com.beemdevelopment.aegis.encoding.Base32;
+import com.beemdevelopment.aegis.encoding.EncodingException;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+public class YAOTPTest {
+
+ private static final Vector[] TEST_CASES = new Vector[]{
+ new Vector("5239", "6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY", 1641559648L, "umozdicq"),
+ new Vector("7586", "LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI", 1581064020L, "oactmacq"),
+ new Vector("7586", "LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI", 1581090810L, "wemdwrix"),
+ new Vector("5210481216086702", "JBGSAU4G7IEZG6OY4UAXX62JU4AAAAAAHTSG4HXU3M", 1581091469L, "dfrpywob"),
+ new Vector("5210481216086702", "JBGSAU4G7IEZG6OY4UAXX62JU4AAAAAAHTSG4HXU3M", 1581093059L, "vunyprpd"),
+ };
+
+ @Test
+ public void validateYaOtp() throws InvalidKeyException, NoSuchAlgorithmException, IOException {
+ for (Vector testCase : TEST_CASES) {
+ YAOTP otp = YAOTP.generateOTP(
+ Base32.decode(testCase.secret.substring(0, 26)),
+ CryptoUtils.toBytes(testCase.pin.toCharArray()),
+ 8,
+ "HmacSHA256",
+ testCase.timestamp,
+ 30
+ );
+ assertEquals(testCase.expected, otp.toString());
+ }
+ }
+
+ public static class Vector {
+ public String pin;
+ public String secret;
+ public long timestamp;
+ public String expected;
+
+ public Vector(String pin, String secret, long timestamp, String expected) {
+ this.pin = pin;
+ this.secret = secret;
+ this.timestamp = timestamp;
+ this.expected = expected;
+ }
+ }
+}
diff --git a/app/src/test/java/com/beemdevelopment/aegis/util/YandexUtilsTest.java b/app/src/test/java/com/beemdevelopment/aegis/util/YandexUtilsTest.java
new file mode 100644
index 00000000..aec675b6
--- /dev/null
+++ b/app/src/test/java/com/beemdevelopment/aegis/util/YandexUtilsTest.java
@@ -0,0 +1,35 @@
+package com.beemdevelopment.aegis.util;
+
+import static org.junit.Assert.assertThrows;
+
+import com.beemdevelopment.aegis.encoding.Base32;
+import com.beemdevelopment.aegis.encoding.EncodingException;
+import com.beemdevelopment.aegis.otp.OtpInfoException;
+
+import org.junit.Test;
+
+public class YandexUtilsTest {
+
+ private static final String[] vectors = new String[]{
+ "LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI", // correct
+ "LA2V6KMCGYMWWVEW64RNP3JA3I", // secret from QR - no validation
+ "AA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI", // first letter is different
+ "AA2V6KMCGJA3IAAAAAAHTSG4HRZPI" // size is wrong
+ };
+
+ @Test(expected = Test.None.class)
+ public void testValidationOk() throws EncodingException, OtpInfoException {
+ YandexUtils.validateSecret(getBase32Vector(0));
+ YandexUtils.validateSecret(getBase32Vector(1));
+ }
+
+ @Test
+ public void testYandexSecretValidation() {
+ assertThrows(OtpInfoException.class, () -> YandexUtils.validateSecret(getBase32Vector(2)));
+ assertThrows(OtpInfoException.class, () -> YandexUtils.validateSecret(getBase32Vector(3)));
+ }
+
+ private byte[] getBase32Vector(int vectorIndex) throws EncodingException {
+ return Base32.decode(vectors[vectorIndex]);
+ }
+}