From fcb7bf032b00781b61e1c8bd46ec4a4357eb7bc8 Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Thu, 10 Feb 2022 20:58:06 +0100 Subject: [PATCH] Add support for importing 2FAS schema v2 backups --- .../aegis/importers/TwoFASImporter.java | 113 ++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + .../aegis/importers/DatabaseImporterTest.java | 41 ++++++- .../2fas_authenticator_encrypted.2fas | 11 ++ .../importers/2fas_authenticator_plain.2fas | 76 ++++++++++++ 5 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_encrypted.2fas create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_plain.2fas diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java index 6a76474a..f13b3db3 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java @@ -2,12 +2,17 @@ package com.beemdevelopment.aegis.importers; import android.content.Context; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptoUtils; import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Base64; import com.beemdevelopment.aegis.encoding.EncodingException; import com.beemdevelopment.aegis.otp.OtpInfo; import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.util.JsonUtils; import com.beemdevelopment.aegis.vault.VaultEntry; import com.topjohnwu.superuser.io.SuFile; @@ -18,10 +23,27 @@ import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; import java.util.ArrayList; import java.util.List; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + public class TwoFASImporter extends DatabaseImporter { + private static final int ITERATION_COUNT = 10_000; + private static final int KEY_SIZE = 256; // bits + public TwoFASImporter(Context context) { super(context); } @@ -37,26 +59,96 @@ public class TwoFASImporter extends DatabaseImporter { String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); JSONObject obj = new JSONObject(json); int version = obj.getInt("schemaVersion"); - if (version > 1) { + if (version > 2) { throw new DatabaseImporterException(String.format("Unsupported schema version: %d", version)); } - JSONArray array = obj.getJSONArray("services"); - List entries = new ArrayList<>(); - for (int i = 0; i < array.length(); i++) { - entries.add(array.getJSONObject(i)); + String encryptedString = JsonUtils.optString(obj, "servicesEncrypted"); + if (encryptedString == null) { + JSONArray array = obj.getJSONArray("services"); + List entries = arrayToList(array); + return new DecryptedState(entries); + } + + String[] parts = encryptedString.split(":"); + if (parts.length < 3) { + throw new DatabaseImporterException(String.format("Unexpected format of encrypted data (parts: %d)", parts.length)); } - return new TwoFASImporter.State(entries); + byte[] data = Base64.decode(parts[0]); + byte[] salt = Base64.decode(parts[1]); + byte[] iv = Base64.decode(parts[2]); + return new EncryptedState(data, salt, iv); } catch (IOException | JSONException e) { throw new DatabaseImporterException(e); } } - public static class State extends DatabaseImporter.State { + private static List arrayToList(JSONArray array) throws JSONException { + List list = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + list.add(array.getJSONObject(i)); + } + + return list; + } + + public static class EncryptedState extends State { + private final byte[] _data; + private final byte[] _salt; + private final byte[] _iv; + + private EncryptedState(byte[] data, byte[] salt, byte[] iv) { + super(true); + _data = data; + _salt = salt; + _iv = iv; + } + + private SecretKey deriveKey(char[] password) + throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(password, _salt, ITERATION_COUNT, KEY_SIZE); + SecretKey key = factory.generateSecret(spec); + return new SecretKeySpec(key.getEncoded(), "AES"); + } + + public DecryptedState decrypt(char[] password) throws DatabaseImporterException { + try { + SecretKey key = deriveKey(password); + Cipher cipher = CryptoUtils.createDecryptCipher(key, _iv); + byte[] decrypted = cipher.doFinal(_data); + String json = new String(decrypted, StandardCharsets.UTF_8); + return new DecryptedState(arrayToList(new JSONArray(json))); + } catch (BadPaddingException | JSONException e) { + throw new DatabaseImporterException(e); + } catch (NoSuchAlgorithmException + | InvalidKeySpecException + | InvalidAlgorithmParameterException + | NoSuchPaddingException + | InvalidKeyException + | IllegalBlockSizeException e) { + throw new RuntimeException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_2fas_message, 0, password -> { + try { + DecryptedState state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }, dialog -> listener.onCanceled()); + } + } + + public static class DecryptedState extends DatabaseImporter.State { private final List _entries; - public State(List entries) { + public DecryptedState(List entries) { super(false); _entries = entries; } @@ -83,8 +175,11 @@ public class TwoFASImporter extends DatabaseImporter { JSONObject info = obj.getJSONObject("otp"); String issuer = info.getString("issuer"); String name = info.optString("account"); + int digits = info.optInt("digits", TotpInfo.DEFAULT_DIGITS); + int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD); + String algorithm = info.optString("algorithm", TotpInfo.DEFAULT_ALGORITHM); - OtpInfo otp = new TotpInfo(secret); + OtpInfo otp = new TotpInfo(secret, algorithm, digits, period); return new VaultEntry(otp, name, issuer); } catch (OtpInfoException | JSONException | EncodingException e) { throw new DatabaseImporterEntryException(e, obj.toString()); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6af9d300..1c30e727 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ The password is incorrect A change in your device\'s security settings has been detected. Please go to \"Aegis -> Settings -> Security -> Biometric unlock\" to disable and re-enable biometric unlock. Please enter your password. We occasionally ask you to do this so that don\'t forget it. + It looks like this 2FAS backup is encrypted. Please enter the password below. It looks like your Authy tokens are encrypted. Please close Aegis, open Authy and unlock the tokens with your password. Instead, Aegis can also attempt to decrypt your Authy tokens for you, if you enter your password below. Please enter the import password diff --git a/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java b/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java index b2138059..5ba589e5 100644 --- a/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java +++ b/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java @@ -1,5 +1,10 @@ package com.beemdevelopment.aegis.importers; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + import android.content.Context; import android.os.Build; @@ -27,11 +32,6 @@ import java.io.InputStream; import java.util.Arrays; import java.util.List; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - @Config(sdk = { Build.VERSION_CODES.P }) @RunWith(RobolectricTestRunner.class) public class DatabaseImporterTest { @@ -233,7 +233,7 @@ public class DatabaseImporterTest { } @Test - public void testImportTwoFASAuthenticator() throws DatabaseImporterException, IOException, OtpInfoException { + public void testImportTwoFASAuthenticatorSchema1() throws DatabaseImporterException, IOException, OtpInfoException { List entries = importPlain(TwoFASImporter.class, "2fas_authenticator.json"); for (VaultEntry entry : entries) { // 2FAS Authenticator doesn't support HOTP, different hash algorithms, periods or digits, so fix those up here @@ -243,6 +243,21 @@ public class DatabaseImporterTest { } } + @Test + public void testImportTwoFASAuthenticatorSchema2Plain() throws DatabaseImporterException, IOException, OtpInfoException { + List entries = importPlain(TwoFASImporter.class, "2fas_authenticator_plain.2fas"); + checkImportedTwoFASEntries(entries); + } + + @Test + public void testImportTwoFASAuthenticatorSchema2Encrypted() throws DatabaseImporterException, IOException, OtpInfoException { + List entries = importEncrypted(TwoFASImporter.class, "2fas_authenticator_encrypted.2fas", encryptedState -> { + final char[] password = "test".toCharArray(); + return ((TwoFASImporter.EncryptedState) encryptedState).decrypt(password); + }); + checkImportedTwoFASEntries(entries); + } + private List importPlain(Class type, String resName) throws IOException, DatabaseImporterException { return importPlain(type, resName, false); @@ -285,6 +300,20 @@ public class DatabaseImporterTest { return result.getEntries(); } + private void checkImportedTwoFASEntries(List entries) throws OtpInfoException { + for (VaultEntry entry : entries) { + // 2FAS Authenticator doesn't support certain features, so fix those entries up here + VaultEntry entryVector = getEntryVectorBySecret(entry.getInfo().getSecret()); + OtpInfo info = entryVector.getInfo(); + int period = TotpInfo.DEFAULT_PERIOD; + if (info instanceof TotpInfo) { + period = ((TotpInfo) info).getPeriod(); + } + entryVector.setInfo(new TotpInfo(info.getSecret(), info.getAlgorithm(false), info.getDigits(), period)); + checkImportedEntry(entryVector, entry); + } + } + private void checkImportedAuthyEntries(List entries) throws OtpInfoException { for (VaultEntry entry : entries) { // Authy doesn't support different hash algorithms or periods, so fix those up here diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_encrypted.2fas b/app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_encrypted.2fas new file mode 100644 index 00000000..8979e560 --- /dev/null +++ b/app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_encrypted.2fas @@ -0,0 +1,11 @@ +{ + "services": [], + "updatedAt": 1644513883359, + "schemaVersion": 2, + "appVersionCode": 3080200, + "appVersionName": "3.8.2", + "appOrigin": "android", + "groups": [], + "servicesEncrypted": "5UGWlz8mZARCfCtbDgwAr0pjWORfch9aK+El1Eed9Axi6cQcaJKPTfwlZ3XDS7rGRZbw0yt9fs2ALp3SBcfTAVAHyw3sE9kyQobPieTtdQLeUrOp8JT6HrfGQKwpuZHLGmSmXXjWPmX8cBnESYijg8Es5J6pnsgCkOIMluJndkD8BI3HK8tcVWfk28UA2LZrvtLXY8W3\/+MSL0qJrjMsldqC6+NszCuWAJpqDDPZK9CyD1DbxTYplTHm+UlJWzdNpB0v1VVtGFHhRbrrRuG0BKXXN65nkw9aAUXpSSvgGgNGHZMwsmBVy\/192gKN\/pR8c31gqP4Yv959mjAGob1Wme1SaE0Oc4RGuOM3H8PINSf6acQSH1P3EnIjLm79snANmKW24B78CDvFeVcr9AupfbOYE7Tr2jJ23Gp06r73Jb55beZ5OYKwYGw\/EyzeV6qNd6A2e\/D98gTpyFP5Tcvxh3NWIMmc\/hzUZ449ZbZ1D3daxZN5pjkNHdb6616tpjtj1uxQTQBxwkAU6Gb0PrPasUtHD+QDLL5r1MvO4LRdiFWWrD2aTlhf2OOrrdsG4L6\/CZWgf\/TnyAy7f\/gCpdJ\/VVyESixlb7+AlqlZhOltQNOWd7j+aWUTxhmLpQ55vXOL5slYtibuQkSRVwL6CvY9SB5DpBYp7Yq5tD3ydV+S8S1imk+lq9PcxUO5Ex1TbwXVFucwuRKLlAA1TILIvMHJIyu1Zc8U409E1BThUxKI2S+t3fA9cl3ahTb8hmI1tGojLnwL+9OAIoIoTqZSd\/EWeECpqEadveboGuhdZsCXoi8UYaEYXrtApaGJ+V27POUDQbIALkgqJO0XVpSfeogPvyw08olOs36aUIfsDZ2Uwc8G9\/pg3u1KPeIf87SJO+HSGnoYMw0BjRXwbOuMANoJQrSbm5c34iOklJ7W0IDIIgPfVPKrJaE+D2pzbm0rBnh4\/p8w8an1V3y5b1kEsDWqU0uWI4zToToBrbqVxnEvQEvWqAa2\/MWCUC1QMy+t3KcZCl9wEWiNQ7JuHdY0WnWWjXRCq2AG3tIcqYsidX3MX8ku+Gyl+VqIthyB1RLPDQiNnZOIsfO2fscIfipuPSIDZMsaB4x04KAfhBG1RJHtFWAjgGrjPK\/u6WGydGABxAgNv\/CjmjgKx4ZzIksvkvARiDtcypy09pJz0FsbGEUr7q2vv7VXCHYprhM=:sOQnCCWbD76OERGCIMeG3Y1pg1skE4yKkqCg0iKW3HKyfPeOEEQ+xqSIhyNJiW4Q6HPYa4tYl2zViNv+JK\/pj65fpSEna55myY101Gx2gF7VUjuj1ujd+iz\/ib17DAedbMRu2IY\/bxjJ295yAX+iKxQZSBSt4MwXKCtT1\/HyfTNNmkCSblXQpfhuN0WiwFWGoQB1IPFVwKejD0JknBviVtbFCMg9VPvNYXGk7hyhOh2KnRrNQVGGYwGqoZI20lg1VK0Nn0E6eDc8MMD0ljUHKWQWGDRsriojCA4beaxtWTdtHHy2LBA5kiOv6nb62O5TsInscTxcJ\/2CBUJOVY+eJQ==:fCOmxT+T1J6nT5OX", + "reference": "EIqX5g2b2C5gXoDSmKs\/j6Xby2Opz7rTqDWCO\/AxKAiniUVWV15U5f9\/fpAxr4dygurCZ5ctrj7NSmbAVAF\/FfPyMaGFqT028yVmt499nm8kLqeq64Yx77oQ\/ADeh70cR4VmVfsC6DxPeSCLRQHcUNRm+7y8kEQ8sPu9QRSxVoU\/lEy2fltQSByqgzk3eIjsBxVrHgVG4mVqV6yLT47GLbBdh6CiZiqkNx9pUXhhXR0ej1Sp7tF0hXuZTh7+CTZvfJp2QA\/KA77F9Aa\/u1N1b8+5br8\/legiMjsMvVmKCXyyKzm6+Yt7VdrapKucyB+zJpH6IsTyxQpBsUV4cFGgqJhnX4qfoxzcWV6KqdFi22E=:sOQnCCWbD76OERGCIMeG3Y1pg1skE4yKkqCg0iKW3HKyfPeOEEQ+xqSIhyNJiW4Q6HPYa4tYl2zViNv+JK\/pj65fpSEna55myY101Gx2gF7VUjuj1ujd+iz\/ib17DAedbMRu2IY\/bxjJ295yAX+iKxQZSBSt4MwXKCtT1\/HyfTNNmkCSblXQpfhuN0WiwFWGoQB1IPFVwKejD0JknBviVtbFCMg9VPvNYXGk7hyhOh2KnRrNQVGGYwGqoZI20lg1VK0Nn0E6eDc8MMD0ljUHKWQWGDRsriojCA4beaxtWTdtHHy2LBA5kiOv6nb62O5TsInscTxcJ\/2CBUJOVY+eJQ==:+W6Bn5NEHwBE8S32" +} \ No newline at end of file diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_plain.2fas b/app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_plain.2fas new file mode 100644 index 00000000..8719a72e --- /dev/null +++ b/app/src/test/resources/com/beemdevelopment/aegis/importers/2fas_authenticator_plain.2fas @@ -0,0 +1,76 @@ +{ + "services": [ + { + "name": "Deno", + "secret": "4SJHB4GSD43FZBAI7C2HLRJGPQ", + "updatedAt": 1644513743278, + "type": "Unknown", + "otp": { + "label": "Deno:Mason", + "account": "Mason", + "issuer": "Deno", + "digits": 6, + "period": 30, + "algorithm": "SHA1" + }, + "order": { + "position": 0 + } + }, + { + "name": "Airbnb", + "secret": "7ELGJSGXNCCTV3O6LKJWYFV2RA", + "updatedAt": 1644513778354, + "type": "Unknown", + "otp": { + "label": "Airbnb:Elijah", + "account": "Elijah", + "issuer": "Airbnb", + "digits": 8, + "period": 50, + "algorithm": "SHA512" + }, + "order": { + "position": 1 + } + }, + { + "name": "Issuu", + "secret": "YOOMIXWS5GN6RTBPUFFWKTW5M4", + "updatedAt": 1644513786834, + "type": "Unknown", + "otp": { + "label": "Issuu:James", + "account": "James", + "issuer": "Issuu", + "digits": 6, + "algorithm": "SHA1" + }, + "order": { + "position": 2 + } + }, + { + "name": "WWE", + "secret": "5VAML3X35THCEBVRLV24CGBKOY", + "updatedAt": 1644513801596, + "type": "Unknown", + "otp": { + "label": "WWE:Mason", + "account": "Mason", + "issuer": "WWE", + "digits": 8, + "algorithm": "SHA512" + }, + "order": { + "position": 3 + } + } + ], + "updatedAt": 1644513857707, + "schemaVersion": 2, + "appVersionCode": 3080200, + "appVersionName": "3.8.2", + "appOrigin": "android", + "groups": [] +} \ No newline at end of file