Add support for importing 2FAS schema v2 backups

pull/904/head
Alexander Bakker 3 years ago
parent acfb70c267
commit fcb7bf032b

@ -2,12 +2,17 @@ package com.beemdevelopment.aegis.importers;
import android.content.Context; 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.Base32;
import com.beemdevelopment.aegis.encoding.Base64;
import com.beemdevelopment.aegis.encoding.EncodingException; import com.beemdevelopment.aegis.encoding.EncodingException;
import com.beemdevelopment.aegis.otp.OtpInfo; import com.beemdevelopment.aegis.otp.OtpInfo;
import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.otp.OtpInfoException;
import com.beemdevelopment.aegis.otp.TotpInfo; import com.beemdevelopment.aegis.otp.TotpInfo;
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.util.IOUtils;
import com.beemdevelopment.aegis.util.JsonUtils;
import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.vault.VaultEntry;
import com.topjohnwu.superuser.io.SuFile; import com.topjohnwu.superuser.io.SuFile;
@ -18,10 +23,27 @@ import org.json.JSONObject;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; 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.ArrayList;
import java.util.List; 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 { 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) { public TwoFASImporter(Context context) {
super(context); super(context);
} }
@ -37,26 +59,96 @@ public class TwoFASImporter extends DatabaseImporter {
String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8);
JSONObject obj = new JSONObject(json); JSONObject obj = new JSONObject(json);
int version = obj.getInt("schemaVersion"); int version = obj.getInt("schemaVersion");
if (version > 1) { if (version > 2) {
throw new DatabaseImporterException(String.format("Unsupported schema version: %d", version)); throw new DatabaseImporterException(String.format("Unsupported schema version: %d", version));
} }
JSONArray array = obj.getJSONArray("services"); String encryptedString = JsonUtils.optString(obj, "servicesEncrypted");
List<JSONObject> entries = new ArrayList<>(); if (encryptedString == null) {
for (int i = 0; i < array.length(); i++) { JSONArray array = obj.getJSONArray("services");
entries.add(array.getJSONObject(i)); List<JSONObject> 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) { } catch (IOException | JSONException e) {
throw new DatabaseImporterException(e); throw new DatabaseImporterException(e);
} }
} }
public static class State extends DatabaseImporter.State { private static List<JSONObject> arrayToList(JSONArray array) throws JSONException {
List<JSONObject> 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<JSONObject> _entries; private final List<JSONObject> _entries;
public State(List<JSONObject> entries) { public DecryptedState(List<JSONObject> entries) {
super(false); super(false);
_entries = entries; _entries = entries;
} }
@ -83,8 +175,11 @@ public class TwoFASImporter extends DatabaseImporter {
JSONObject info = obj.getJSONObject("otp"); JSONObject info = obj.getJSONObject("otp");
String issuer = info.getString("issuer"); String issuer = info.getString("issuer");
String name = info.optString("account"); 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); return new VaultEntry(otp, name, issuer);
} catch (OtpInfoException | JSONException | EncodingException e) { } catch (OtpInfoException | JSONException | EncodingException e) {
throw new DatabaseImporterEntryException(e, obj.toString()); throw new DatabaseImporterEntryException(e, obj.toString());

@ -119,6 +119,7 @@
<string name="invalid_password">The password is incorrect</string> <string name="invalid_password">The password is incorrect</string>
<string name="invalidated_biometrics">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.</string> <string name="invalidated_biometrics">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.</string>
<string name="password_reminder">Please enter your password. We occasionally ask you to do this so that don\'t forget it.</string> <string name="password_reminder">Please enter your password. We occasionally ask you to do this so that don\'t forget it.</string>
<string name="enter_password_2fas_message">It looks like this 2FAS backup is encrypted. Please enter the password below.</string>
<string name="enter_password_authy_message">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.</string> <string name="enter_password_authy_message">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.</string>
<string name="enter_password_aegis_title">Please enter the import password</string> <string name="enter_password_aegis_title">Please enter the import password</string>

@ -1,5 +1,10 @@
package com.beemdevelopment.aegis.importers; 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.content.Context;
import android.os.Build; import android.os.Build;
@ -27,11 +32,6 @@ import java.io.InputStream;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; 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 }) @Config(sdk = { Build.VERSION_CODES.P })
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
public class DatabaseImporterTest { public class DatabaseImporterTest {
@ -233,7 +233,7 @@ public class DatabaseImporterTest {
} }
@Test @Test
public void testImportTwoFASAuthenticator() throws DatabaseImporterException, IOException, OtpInfoException { public void testImportTwoFASAuthenticatorSchema1() throws DatabaseImporterException, IOException, OtpInfoException {
List<VaultEntry> entries = importPlain(TwoFASImporter.class, "2fas_authenticator.json"); List<VaultEntry> entries = importPlain(TwoFASImporter.class, "2fas_authenticator.json");
for (VaultEntry entry : entries) { for (VaultEntry entry : entries) {
// 2FAS Authenticator doesn't support HOTP, different hash algorithms, periods or digits, so fix those up here // 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<VaultEntry> entries = importPlain(TwoFASImporter.class, "2fas_authenticator_plain.2fas");
checkImportedTwoFASEntries(entries);
}
@Test
public void testImportTwoFASAuthenticatorSchema2Encrypted() throws DatabaseImporterException, IOException, OtpInfoException {
List<VaultEntry> entries = importEncrypted(TwoFASImporter.class, "2fas_authenticator_encrypted.2fas", encryptedState -> {
final char[] password = "test".toCharArray();
return ((TwoFASImporter.EncryptedState) encryptedState).decrypt(password);
});
checkImportedTwoFASEntries(entries);
}
private List<VaultEntry> importPlain(Class<? extends DatabaseImporter> type, String resName) private List<VaultEntry> importPlain(Class<? extends DatabaseImporter> type, String resName)
throws IOException, DatabaseImporterException { throws IOException, DatabaseImporterException {
return importPlain(type, resName, false); return importPlain(type, resName, false);
@ -285,6 +300,20 @@ public class DatabaseImporterTest {
return result.getEntries(); return result.getEntries();
} }
private void checkImportedTwoFASEntries(List<VaultEntry> 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<VaultEntry> entries) throws OtpInfoException { private void checkImportedAuthyEntries(List<VaultEntry> entries) throws OtpInfoException {
for (VaultEntry entry : entries) { for (VaultEntry entry : entries) {
// Authy doesn't support different hash algorithms or periods, so fix those up here // Authy doesn't support different hash algorithms or periods, so fix those up here

@ -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"
}

@ -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": []
}
Loading…
Cancel
Save