From cc5ce485b1a4f774101f0cb660766c8bc8b0880b Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sat, 28 Sep 2024 00:08:11 +0200 Subject: [PATCH] Add support for importing FreeOTP 2 backups I've held off on this in the past, because I was concerned about the security issues related to Java object deserialization. To circumvent that, I've written a parser that understands just enough of the Java Object Serialization format to parse FreeOTP 2 backups. Unfortunately there are a number of issues in FreeOTP 2 that may result in corrupt backups. The importer warns the user about this and tries to salvage as many entries as possible. --- .../aegis/importers/DatabaseImporter.java | 2 +- .../aegis/importers/FreeOtpImporter.java | 353 +++++++++++++++++- .../aegis/importers/FreeOtpPlusImporter.java | 2 +- .../aegis/ui/tasks/PBKDFTask.java | 14 + app/src/main/res/values/strings.xml | 4 +- .../aegis/importers/DatabaseImporterTest.java | 46 ++- .../aegis/importers/freeotp_v2_api23.xml | Bin 0 -> 3488 bytes .../aegis/importers/freeotp_v2_api25.xml | Bin 0 -> 3539 bytes .../aegis/importers/freeotp_v2_api27.xml | Bin 0 -> 3535 bytes .../aegis/importers/freeotp_v2_api34.xml | Bin 0 -> 3542 bytes 10 files changed, 409 insertions(+), 12 deletions(-) create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api23.xml create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api25.xml create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api27.xml create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api34.xml diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java index e29fd7ee..1be462f5 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java @@ -40,7 +40,7 @@ public abstract class DatabaseImporter { _importers.add(new Definition("Bitwarden", BitwardenImporter.class, R.string.importer_help_bitwarden, false)); _importers.add(new Definition("Duo", DuoImporter.class, R.string.importer_help_duo, true)); _importers.add(new Definition("Ente Auth", EnteAuthImporter.class, R.string.importer_help_ente_auth, false)); - _importers.add(new Definition("FreeOTP (1.x)", FreeOtpImporter.class, R.string.importer_help_freeotp, true)); + _importers.add(new Definition("FreeOTP", FreeOtpImporter.class, R.string.importer_help_freeotp, true)); _importers.add(new Definition("FreeOTP+ (JSON)", FreeOtpPlusImporter.class, R.string.importer_help_freeotp_plus, true)); _importers.add(new Definition("Google Authenticator", GoogleAuthImporter.class, R.string.importer_help_google_authenticator, true)); _importers.add(new Definition("Microsoft Authenticator", MicrosoftAuthImporter.class, R.string.importer_help_microsoft_authenticator, true)); diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java index 3fc7be18..8b96299b 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java @@ -4,26 +4,53 @@ import android.content.Context; import android.content.pm.PackageManager; import android.util.Xml; +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.ContextHelper; import com.beemdevelopment.aegis.otp.HotpInfo; 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.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; import com.beemdevelopment.aegis.util.PreferenceParser; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.topjohnwu.superuser.io.SuFile; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1Sequence; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; +import java.io.BufferedInputStream; +import java.io.DataInputStream; 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.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; public class FreeOtpImporter extends DatabaseImporter { private static final String _subPath = "shared_prefs/tokens.xml"; @@ -40,6 +67,24 @@ public class FreeOtpImporter extends DatabaseImporter { @Override public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try (BufferedInputStream bufInStream = new BufferedInputStream(stream); + DataInputStream dataInStream = new DataInputStream(bufInStream)) { + + dataInStream.mark(2); + int magic = dataInStream.readUnsignedShort(); + dataInStream.reset(); + + if (magic == SerializedHashMapParser.MAGIC) { + return readV2(dataInStream); + } else { + return readV1(bufInStream); + } + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } + + private DecryptedStateV1 readV1(InputStream stream) throws DatabaseImporterException { try { XmlPullParser parser = Xml.newPullParser(); parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); @@ -52,16 +97,184 @@ public class FreeOtpImporter extends DatabaseImporter { entries.add(new JSONObject(entry.Value)); } } - return new State(entries); + return new DecryptedStateV1(entries); } catch (XmlPullParserException | IOException | JSONException e) { throw new DatabaseImporterException(e); } } - public static class State extends DatabaseImporter.State { - private List _entries; + private EncryptedState readV2(DataInputStream stream) throws DatabaseImporterException { + try { + Map entries = SerializedHashMapParser.parse(stream); + JSONObject mkObj = new JSONObject(entries.get("masterKey")); + return new EncryptedState(mkObj, entries); + } catch (IOException | JSONException | SerializedHashMapParser.ParseException e) { + throw new DatabaseImporterException(e); + } + } - public State(List entries) { + public static class EncryptedState extends State { + private static final int MASTER_KEY_SIZE = 32 * 8; + + private final String _mkAlgo; + private final String _mkCipher; + private final byte[] _mkCipherText; + private final byte[] _mkParameters; + private final byte[] _mkToken; + private final byte[] _mkSalt; + private final int _mkIterations; + private final Map _entries; + + private EncryptedState(JSONObject mkObj, Map entries) + throws DatabaseImporterException, JSONException { + super(true); + + _mkAlgo = mkObj.getString("mAlgorithm"); + if (!_mkAlgo.equals("PBKDF2withHmacSHA1") && !_mkAlgo.equals("PBKDF2withHmacSHA512")) { + throw new DatabaseImporterException(String.format("Unexpected master key KDF: %s", _mkAlgo)); + } + JSONObject keyObj = mkObj.getJSONObject("mEncryptedKey"); + _mkCipher = keyObj.getString("mCipher"); + if (!_mkCipher.equals("AES/GCM/NoPadding")) { + throw new DatabaseImporterException(String.format("Unexpected master key cipher: %s", _mkCipher)); + } + _mkCipherText = toBytes(keyObj.getJSONArray("mCipherText")); + _mkParameters = toBytes(keyObj.getJSONArray("mParameters")); + _mkToken = keyObj.getString("mToken").getBytes(StandardCharsets.UTF_8); + _mkSalt = toBytes(mkObj.getJSONArray("mSalt")); + _mkIterations = mkObj.getInt("mIterations"); + _entries = entries; + } + + public State decrypt(char[] password) throws DatabaseImporterException { + PBKDFTask.Params params = new PBKDFTask.Params(_mkAlgo, MASTER_KEY_SIZE, password, _mkSalt, _mkIterations); + SecretKey passKey = PBKDFTask.deriveKey(params); + return decrypt(passKey); + } + + public State decrypt(SecretKey passKey) throws DatabaseImporterException { + byte[] masterKeyBytes; + try { + byte[] nonce = parseNonce(_mkParameters); + IvParameterSpec spec = new IvParameterSpec(nonce); + Cipher cipher = Cipher.getInstance(_mkCipher); + cipher.init(Cipher.DECRYPT_MODE, passKey, spec); + cipher.updateAAD(_mkToken); + masterKeyBytes = cipher.doFinal(_mkCipherText); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | + IllegalBlockSizeException | InvalidKeyException | + InvalidAlgorithmParameterException | IOException e) { + throw new DatabaseImporterException(e); + } + + SecretKey masterKey = new SecretKeySpec(masterKeyBytes, 0, masterKeyBytes.length, "AES"); + return new DecryptedStateV2(_entries, masterKey); + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.importer_warning_title_freeotp2) + .setMessage(R.string.importer_warning_message_freeotp2) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, password -> { + PBKDFTask.Params params = getKeyDerivationParams(password, _mkAlgo); + PBKDFTask task = new PBKDFTask(context, key -> { + try { + State state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog1 -> listener.onCanceled()); + }) + .create()); + } + + private PBKDFTask.Params getKeyDerivationParams(char[] password, String algo) { + return new PBKDFTask.Params(algo, MASTER_KEY_SIZE, password, _mkSalt, _mkIterations); + } + } + + public static class DecryptedStateV2 extends DatabaseImporter.State { + private final Map _entries; + private final SecretKey _masterKey; + + public DecryptedStateV2(Map entries, SecretKey masterKey) { + super(false); + _entries = entries; + _masterKey = masterKey; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = new Result(); + + for (Map.Entry entry : _entries.entrySet()) { + if (entry.getKey().endsWith("-token") || entry.getKey().equals("masterKey")) { + continue; + } + + try { + JSONObject encObj = new JSONObject(entry.getValue()); + String tokenKey = String.format("%s-token", entry.getKey()); + JSONObject tokenObj = new JSONObject(_entries.get(tokenKey)); + + VaultEntry vaultEntry = convertEntry(encObj, tokenObj); + result.addEntry(vaultEntry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } catch (JSONException ignored) { + } + } + + return result; + } + + private VaultEntry convertEntry(JSONObject encObj, JSONObject tokenObj) + throws DatabaseImporterEntryException { + try { + JSONObject keyObj = new JSONObject(encObj.getString("key")); + String cipherName = keyObj.getString("mCipher"); + if (!cipherName.equals("AES/GCM/NoPadding")) { + throw new DatabaseImporterException(String.format("Unexpected cipher: %s", cipherName)); + } + byte[] cipherText = toBytes(keyObj.getJSONArray("mCipherText")); + byte[] parameters = toBytes(keyObj.getJSONArray("mParameters")); + byte[] token = keyObj.getString("mToken").getBytes(StandardCharsets.UTF_8); + + byte[] nonce = parseNonce(parameters); + IvParameterSpec spec = new IvParameterSpec(nonce); + Cipher cipher = Cipher.getInstance(cipherName); + cipher.init(Cipher.DECRYPT_MODE, _masterKey, spec); + cipher.updateAAD(token); + byte[] secretBytes = cipher.doFinal(cipherText); + + JSONArray secretArray = new JSONArray(); + for (byte b : secretBytes) { + secretArray.put(b); + } + tokenObj.put("secret", secretArray); + + return DecryptedStateV1.convertEntry(tokenObj); + } catch (DatabaseImporterException | JSONException | NoSuchAlgorithmException | + NoSuchPaddingException | InvalidAlgorithmParameterException | + InvalidKeyException | BadPaddingException | IllegalBlockSizeException | + IOException e) { + throw new DatabaseImporterEntryException(e, tokenObj.toString()); + } + } + } + + public static class DecryptedStateV1 extends DatabaseImporter.State { + private final List _entries; + + public DecryptedStateV1(List entries) { super(false); _entries = entries; } @@ -116,6 +329,23 @@ public class FreeOtpImporter extends DatabaseImporter { } } + private static byte[] parseNonce(byte[] parameters) throws IOException { + ASN1Primitive prim = ASN1Sequence.fromByteArray(parameters); + if (prim instanceof ASN1OctetString) { + return ((ASN1OctetString) prim).getOctets(); + } + + if (prim instanceof ASN1Sequence) { + for (ASN1Encodable enc : (ASN1Sequence) prim) { + if (enc instanceof ASN1OctetString) { + return ((ASN1OctetString) enc).getOctets(); + } + } + } + + throw new IOException("Unable to find nonce in parameters"); + } + private static byte[] toBytes(JSONArray array) throws JSONException { byte[] bytes = new byte[array.length()]; for (int i = 0; i < array.length(); i++) { @@ -123,4 +353,119 @@ public class FreeOtpImporter extends DatabaseImporter { } return bytes; } + private static class SerializedHashMapParser { + private static final int MAGIC = 0xaced; + private static final int VERSION = 5; + private static final long SERIAL_VERSION_UID = 362498820763181265L; + + private static final byte TC_NULL = 0x70; + private static final byte TC_CLASSDESC = 0x72; + private static final byte TC_OBJECT = 0x73; + private static final byte TC_STRING = 0x74; + + private SerializedHashMapParser() { + + } + + public static Map parse(DataInputStream inStream) + throws IOException, ParseException { + Map map = new HashMap<>(); + + // Read/validate the magic number and version + int magic = inStream.readUnsignedShort(); + int version = inStream.readUnsignedShort(); + if (magic != MAGIC || version != VERSION) { + throw new ParseException("Not a serialized Java Object"); + } + + // Read the class descriptor info for HashMap + byte b = inStream.readByte(); + if (b != TC_OBJECT) { + throw new ParseException("Expected an object, found: " + b); + } + b = inStream.readByte(); + if (b != TC_CLASSDESC) { + throw new ParseException("Expected a class desc, found: " + b); + } + parseClassDescriptor(inStream); + + // Not interested in the capacity of the map + inStream.readInt(); + // Read the number of elements in the HashMap + int size = inStream.readInt(); + + // Parse each key-value pair in the map + for (int i = 0; i < size; i++) { + String key = parseStringObject(inStream); + String value = parseStringObject(inStream); + map.put(key, value); + } + + return map; + } + + private static void parseClassDescriptor(DataInputStream inputStream) + throws IOException, ParseException { + // Check whether we're dealing with a HashMap and a version we support + String className = parseUTF(inputStream); + if (!className.equals(HashMap.class.getName())) { + throw new ParseException(String.format("Unexpected class name: %s", className)); + } + long serialVersionUID = inputStream.readLong(); + if (serialVersionUID != SERIAL_VERSION_UID) { + throw new ParseException(String.format("Unexpected serial version UID: %d", serialVersionUID)); + } + + // Read past all of the fields in the class + byte fieldDescriptor = inputStream.readByte(); + if (fieldDescriptor == TC_NULL) { + return; + } + int totalFieldSkip = 0; + int fieldCount = inputStream.readUnsignedShort(); + for (int i = 0; i < fieldCount; i++) { + char fieldType = (char) inputStream.readByte(); + parseUTF(inputStream); + switch (fieldType) { + case 'F': // float (4 bytes) + case 'I': // int (4 bytes) + totalFieldSkip += 4; + break; + default: + throw new ParseException(String.format("Unexpected field type: %s", fieldType)); + } + } + inputStream.skipBytes(totalFieldSkip); + + // Not sure what these bytes are, just skip them + inputStream.skipBytes(4); + } + + private static String parseStringObject(DataInputStream inputStream) + throws IOException, ParseException { + byte objectType = inputStream.readByte(); + if (objectType != TC_STRING) { + throw new ParseException(String.format("Expected a string object, found: %d", objectType)); + } + + int length = inputStream.readUnsignedShort(); + byte[] strBytes = new byte[length]; + inputStream.readFully(strBytes); + + return new String(strBytes, StandardCharsets.UTF_8); + } + + private static String parseUTF(DataInputStream inputStream) throws IOException { + int length = inputStream.readUnsignedShort(); + byte[] strBytes = new byte[length]; + inputStream.readFully(strBytes); + return new String(strBytes, StandardCharsets.UTF_8); + } + + private static class ParseException extends Exception { + public ParseException(String message) { + super(message); + } + } + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java index cd15d7ca..02cbd199 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java @@ -46,7 +46,7 @@ public class FreeOtpPlusImporter extends DatabaseImporter { entries.add(array.getJSONObject(i)); } - state = new FreeOtpImporter.State(entries); + state = new FreeOtpImporter.DecryptedStateV1(entries); } catch (IOException | JSONException e) { throw new DatabaseImporterException(e); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/PBKDFTask.java b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/PBKDFTask.java index da05e58c..4de6ebcb 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/PBKDFTask.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/PBKDFTask.java @@ -3,6 +3,11 @@ package com.beemdevelopment.aegis.ui.tasks; import android.content.Context; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptoUtils; + +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator; +import org.bouncycastle.crypto.params.KeyParameter; import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; @@ -31,6 +36,15 @@ public class PBKDFTask extends ProgressDialogTask { public static SecretKey deriveKey(Params params) { try { + // Some older versions of Android (< 26) do not support PBKDF2withHmacSHA512, so use + // BouncyCastle's implementation instead. + if (params.getAlgorithm().equals("PBKDF2withHmacSHA512")) { + PKCS5S2ParametersGenerator gen = new PKCS5S2ParametersGenerator(new SHA512Digest()); + gen.init(CryptoUtils.toBytes(params.getPassword()), params.getSalt(), params.getIterations()); + byte[] key = ((KeyParameter) gen.generateDerivedParameters(params.getKeySize())).getKey(); + return new SecretKeySpec(key, "AES"); + } + SecretKeyFactory factory = SecretKeyFactory.getInstance(params.getAlgorithm()); KeySpec spec = new PBEKeySpec(params.getPassword(), params.getSalt(), params.getIterations(), params.getKeySize()); SecretKey key = factory.generateSecret(spec); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bab03455..f129b8b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -530,8 +530,10 @@ Supply a copy of /data/data/com.blizzard.messenger/shared_prefs/com.blizzard.messenger.authenticator_preferences.xml, located in the internal storage directory of Battle.net Authenticator. Supply a copy of /data/data/com.duosecurity.duomobile/files/duokit/accounts.json, located in the internal storage directory of DUO. Supply an Ente Auth export file. Currently only unencrypted files are supported. - Supply a copy of /data/data/org.fedorahosted.freeotp/shared_prefs/tokens.xml, located in the internal storage directory of FreeOTP (1.x). + FreeOTP 2: Supply a backup file.\nFreeOTP 1.x: Supply a copy of /data/data/org.fedorahosted.freeotp/shared_prefs/tokens.xml, located in the internal storage directory of FreeOTP. Supply a FreeOTP+ export file. + FreeOTP 2 compatibility + There are a number of issues in FreeOTP 2 that can result in corrupt backups. Aegis will try to salvage as many entries as possible, but it\'s possible that some or even all of them fail to import. Only database files from Google Authenticator v5.10 and prior are supported.\n\nSupply a copy of /data/data/com.google.android.apps.authenticator2/databases/databases, located in the internal storage directory of Google Authenticator. Supply a copy of /data/data/com.azure.authenticator/databases/PhoneFactor, located in the internal storage directory of Microsoft Authenticator. Supply a plain text file with a Google Authenticator URI on each line. 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 146381a5..959252c6 100644 --- a/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java +++ b/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java @@ -216,21 +216,57 @@ public class DatabaseImporterTest { } @Test - public void testImportFreeOtp() throws IOException, DatabaseImporterException, OtpInfoException { + public void testImportFreeOtpV1() throws IOException, DatabaseImporterException, OtpInfoException { List entries = importPlain(FreeOtpImporter.class, "freeotp.xml"); - checkImportedFreeOtpEntries(entries); + checkImportedFreeOtpEntriesV1(entries); + } + + @Test + public void testImportFreeOtpV2Api23() throws IOException, DatabaseImporterException, OtpInfoException { + List entries = importEncrypted(FreeOtpImporter.class, "freeotp_v2_api23.xml", encryptedState -> { + final char[] password = "test".toCharArray(); + return ((FreeOtpImporter.EncryptedState) encryptedState).decrypt(password); + }); + checkImportedEntries(entries); + } + + @Test + public void testImportFreeOtpV2Api25() throws IOException, DatabaseImporterException, OtpInfoException { + List entries = importEncrypted(FreeOtpImporter.class, "freeotp_v2_api25.xml", encryptedState -> { + final char[] password = "test".toCharArray(); + return ((FreeOtpImporter.EncryptedState) encryptedState).decrypt(password); + }); + checkImportedEntries(entries); + } + + @Test + public void testImportFreeOtpV2Api27() throws IOException, DatabaseImporterException, OtpInfoException { + List entries = importEncrypted(FreeOtpImporter.class, "freeotp_v2_api27.xml", encryptedState -> { + final char[] password = "test".toCharArray(); + return ((FreeOtpImporter.EncryptedState) encryptedState).decrypt(password); + }); + checkImportedEntries(entries); + } + + @Test + public void testImportFreeOtpV2Api34() throws IOException, DatabaseImporterException, OtpInfoException { + List entries = importEncrypted(FreeOtpImporter.class, "freeotp_v2_api34.xml", encryptedState -> { + final char[] password = "test".toCharArray(); + return ((FreeOtpImporter.EncryptedState) encryptedState).decrypt(password); + }); + checkImportedEntries(entries); } @Test public void testImportFreeOtpPlus() throws IOException, DatabaseImporterException, OtpInfoException { List entries = importPlain(FreeOtpPlusImporter.class, "freeotp_plus.json"); - checkImportedFreeOtpEntries(entries); + checkImportedFreeOtpEntriesV1(entries); } @Test public void testImportFreeOtpPlusInternal() throws IOException, DatabaseImporterException, OtpInfoException { List entries = importPlain(FreeOtpPlusImporter.class, "freeotp_plus_internal.xml", true); - checkImportedFreeOtpEntries(entries); + checkImportedFreeOtpEntriesV1(entries); } @Test @@ -423,7 +459,7 @@ public class DatabaseImporterTest { } } - private void checkImportedFreeOtpEntries(List entries) throws OtpInfoException { + private void checkImportedFreeOtpEntriesV1(List entries) throws OtpInfoException { for (VaultEntry entry : entries) { // for some reason, FreeOTP adds -1 to the counter VaultEntry entryVector = getEntryVectorBySecret(entry.getInfo().getSecret()); diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api23.xml b/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api23.xml new file mode 100644 index 0000000000000000000000000000000000000000..553192dee92e12ad016fcffd3e1bd1e62c24d97f GIT binary patch literal 3488 zcmb`K-EQ1O6vsD06(158y+HyAsWMzeGp)xSkL}z*+NPvbg-D51BvJ{lKblSVBkVdf zQRo}-5{M_@mUlqB0>3j}hjgo^E0rjY?D;rz=6}xroPU1%dvIehAKW}HekpFBwxjXw z-C}X{xH!4-{$IcU@ySnrzBkx-F!*RZE2;-Y+0N!?CtrO%7`*skFc{q8`M4c?nTBx@ zhE1p|8|B*8Wu~(-skDhrlSg%9E1R|(pI!vd>+@hMxHt?Z_eLj2_53i{It;dV_7CrT zbMNuto$qFQMOBTa&kh51x#plgYgzYWri?L4JEu~4jg>ZGq;#I~5-}3yDo$1IRGc$n zWQ|ig%T;2Pwnnj0C%M7|A5q8@1D1VGWhzSjQdXy|WHgGNiVJ(iyqMH&Jzt0m7Ne<> zS$2jhVROinC>>`iVKBC0iIW`1$QikcIMFz7>|pl1p86~APKt7WcRMlBVem3|353Hu ztg~u3)Wgg*+LleKv$)K42)30Q+A^-IcH@h8gAl}0m4F-$0#X#I1oUG3^p}u7nN@se zk^D8F6$-ot1anqdAQq&(4Y9)jUpnWCEb)M2mr=*HIm<-mdKe`(z^!w@GXfYPmav%+ z01dV|E@Iv?pSU%_+>EPX*4U`lwkTu3Omm$NBddo^S|#Rd9$9jE83$jkKn+Bx6HzQYBEP{L!bxUl z?8cz%2;YJv)~Qpjix(`E;Ifr+=Di7(kO^Y1hv8;w!ZK=N$dWp%Z5$b$Mb>Ilr>;rD z#5Hl$ljV4oEN>vl{dziE;m2~Qr^n-BF`KgFq@IsvRj@_5>8WzCq{`+x(d|9^^{QuN z2&ONIoM5|mH0@d2F?oA5zjd#e7FDt0?kf|$(|7CXaWNT*-z&7{TeAJlR=swHxU@ z?IZpga%+f(-n*Y)_pV27#T()`oDNqUx*Y6%+8K|Iiz8O`@JXsH9qi%za8fMj2aoFW zcH^ImV6siOn2*|{2~~aX?xXt;q8EHEJGwNG$3fQi}VYmkn&YbdIjY!!f2{uB+XTM zB(5{(J@;yYN~h&Sj+5nyTtLd7UKYq0ffY==BFYmo6>X0r5u2EP5VzHc}eFQYQ zHRyqV`c#D3I_0t5`21a4Oeo@2An$HSy}~O8S--;g|J6IOu>A)WyMZxUGlA>ve;HwiDf&W8vXl8P0=EiOos`fD=IZ0zq1 s$}9zB>H02y3w+X>rMdfPf|M0g04cfa1rvmWSvV;T{|)~fbkLps3x1V``v3p{ literal 0 HcmV?d00001 diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api25.xml b/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api25.xml new file mode 100644 index 0000000000000000000000000000000000000000..0d459fbef1c7bdea3ccb4fc2f8363a9090aef407 GIT binary patch literal 3539 zcmbtW%WfP+6rBM@5F{3CP$U*aEw8Mqr0db$)!qaraj=O3mTW0VOeCrw6OS_wGt(xv zO#Xh!@uRq5B zyZHOG>1|q@7qyNPX=0s8s}dt!l2~aIRSbt!oR`MSX7;L{HoadiB0qjPi?*VJ-EEzu zk%-FK>9ncm(N+?Rs2aT-HH&B~6VYg~IIZV9XALHAkLGtD`Khlw7PK$+rq^DKeNm4w z`(Zsj_LI>RbDQ&%8Y8>k9qvbOn%>4bxil?Q z|MjD1gZtmk_I*{2rY{GPcsu8?K5H=V2bttT+E}PesMrdZ37Moqsu-U_sa)h3u{yj^ z+F~dXiN*wt@iY+*voeum&eRH6JNGW4Wc(haC4ZtvT#Pp zTA4!Hq06MJ3y`mpG}qeJNmX@h|8$k@L}?Jx5$rl1-x6F*i|gdJgB`IuNCHX{YwFE4u`qT<2sQ=oB_TnO{Gr&Uul%hGv&&xuw8cJtJ$?;>EK(^ z$HD&N@0ZfK9P0A<2F0WeUP|YNMjG#GV^e3cGHLo> z-cTGWRsyALQjs}IKq=HJYE)*H8i521WRr^&Pa3ib%D^XtA(d)NI>B?M1cp?MN0U)A z@E^=JB8>$^g;1$*sB>0#zFH#OBEb@Va)0g=YhfTfs7O0upjpJh$10VmP9tSuhG}g| zIrQGa2aT1*P+3_ST^G72Qk54S$;VeoR$!>3IDKCskL&4diQ>zlj^eK5I&lDwYZR}e z-lqS>%_xTHfL%JAw88giYk0`Cuu9~3f&t>$(Z*mL0{$m+S`_%y_)3cem-NNJI_SiD z7DJZP(y$8DAP~sS+-h#Qg#v|=e#tCCN{EFGxh)C}A7mb`y21isUzhZ5tm80EfAeM_ z;`B(GWhgVCh2(Rj$2$a%K?gty?CzpgpeQ5fWmx>2>b>jLfD`wqMQf} zQ9D#6;13wv)rdlEyr{5TsrG78Sf;oSMWPW6+El!HEF!Lj>E=4#(@Ew%U!-K-dG>$G zXAj-P>5_iBu}(mpM=muDwF6!=0gK$W~|-v3tcrGp)@ zjNx*za0D4$0`O*uP!EJ3e|QlnVD~^Pg)73oXkqk73FAfSvz%J*g(t@XU4VjVq7tZ4 z#Dqt;MFMRwE{35`xGxO)1u}ovbS(88C{ literal 0 HcmV?d00001 diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api27.xml b/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api27.xml new file mode 100644 index 0000000000000000000000000000000000000000..9f4b19b0cae89469ee67c94f9949a8e09ef4bf17 GIT binary patch literal 3535 zcmbtXU2oh(6x|e6v`9Sgf)D~!S$RS;t;h3WJ1>y72?{{Cq_9NpjX z^P{K!g1bmd;IzAlV9}%D$x)bL#pc%jgYOg3l@DVv%t{j8UbyE{3%gsTNtv zH!ZjkEb~+tBWxy8Sfxg&TyQIuxb`d0Mb5`qDD04!=fJ<`XMP+&(j544sKOosaA7Mo zkHrAKV6T-(u$xPSOJM<2*bA4ER*OU^p&a)+n7#;;maOO;@4MLA*Td-q`>U8g7~JOQ2+)ZfgJ3I31?j`xR00$7kXoU^A+IeBjB!saO@zhG1tu{=cwoR{ z796I$2{z`HtbKl7_txVd>XPKgXqQ*e~Q6}TT6gGcE^!o~*^l3;N~To!L&HOJNP ziiahOb(U@e9^ zKu`UAN;D^7Hk?+`2AJ-U9dyX@ICjdWM(Uy{q%k3uSqP~NCebR(V?8KshwRs@$Xd1P zuw`#z198QqSVDI(*x}n64UhfN6@YZbg1QTUM4zlI8%}{NGFtE zYAOunG&8N5n^ujeNR$vKpy(3C3Zes1f*e4vpjE6+EMc0G0MUVv<^mE$vCZEjGj1U) zO$lr34iubnjR_V(7*Nn0RcsZRS$Anw>eQ98QiE8UfwIy&A4?xhC`?hfkotNpx*>~= zI%+5*RT3JDM~62cjKL3;5{%*g%~hzLBnP?+zENJlHeRW!5NV1x3MVaDlCTIxiGxj% z!#tweL@{L*c3|7U z=@{3bnQaF~>4zq(*g&DrVspX_;}{Ax-OQE`eUimm)^)llzrHAAUy0)YfiC9xO;^L@ z<3kEq=q9-t(-4`14(tDF@}-N{;}s;aUi<21G)f?2!+$JnZFj@#^26?!?H)x3T*3)W(`V7AUfbFZ;D z#w!-SVnf)Sa2<~*3yV})U!^YJyhB8cAadAULn9h0q0w+TE(1a!IbfH04j}=l{6bW! zrZL1kRUCg9Hz1hth}IFrAzHB;{uQAy#R`H$^~m27j&Ni1uLV+rJCZhf8oef+3!sc? KR+LpZeDxm~a*}ia literal 0 HcmV?d00001 diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api34.xml b/app/src/test/resources/com/beemdevelopment/aegis/importers/freeotp_v2_api34.xml new file mode 100644 index 0000000000000000000000000000000000000000..c17a5023dd600e23ec1d74249733383197115691 GIT binary patch literal 3542 zcmbuB-))mON=k?-vHm=^Be*H~P{yu;G5&!S< zeA4vpB`(jy(w5o|3!{_V6x!z2=&H<%Bq_^bTpQiYUe(j4H$D&C_~k6v3J!O-qcjUt zP>o)Wnnkdct6;QPoYnK4H;ueMc>E)8+o8Sb($Lrq>M;@Dxkv5ig(_&? zp4PlN`r+sxc-QpqOx&WW=TGam&8>~|V6x5L^HFm=A?D!W)5lMu*ZkU@xRP{I6S3N4 zXIjqRo;G#GtYC{tk4C4*^_&^oJBJUxeYF4J``Ll3s?qeNgtu>xYDxOJPOJ)*2{RQL zrEQ{2s&t+!hD})T#7grfi&Y+}I90|ZD#;nQN~fXHCMuLIlunq;0$HSN>X*tHF&t25 ztQRqXw=rV{Po{FKid+@)j*x`iC6fJE$|?zql_f93_J*wz^McF|+#Cdf2k@UKM3bj7 zkz&41*@H!*T(*^2jO#XuNcPf2s4O38tcX-5lLe22;})mj9l!Tj+cl%vlnqQMe+(YF zu}3Q9^uPfKEFQNIWaLI3WD%w+F+OL8IEEqZB2`79lE?$X!7X`afJYh}&cg$+It3ci z(tsj|BFM^N%nRya^E$QKY+NYTPQ(VkEvjv(FvS*&VQoUG@V;(%h|kUUH% zTOc3dmM!@%qL#WsvFl03053@1ybj6D##c83(jt50q2)<4!4x>LO2)m{B<&|KN&RAP ztpf_e1Y%KOiPwR8o~l$J6n_<~@E~Imx%~DfCX#}O7+Q+;aLVptt4xz^WCk;F6=baa zW|7MHN_OiMV=ncQv0w#GtspeVTqYPpfck_^cq zpWNRa#ZXv;e^|k~vb?nN>p*;KT;ftY;+pY*Z562U`f;{pU HpEv&is>7C4 literal 0 HcmV?d00001