diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java index 09ff1577..35ceb8e8 100644 --- a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java @@ -28,6 +28,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 447b6945..6af9d300 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]); + } +}