mirror of
https://github.com/beemdevelopment/Aegis.git
synced 2025-04-22 23:09:13 +00:00
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.
This commit is contained in:
parent
08d900c0c0
commit
cc5ce485b1
10 changed files with 409 additions and 12 deletions
|
@ -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));
|
||||
|
|
|
@ -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<JSONObject> _entries;
|
||||
private EncryptedState readV2(DataInputStream stream) throws DatabaseImporterException {
|
||||
try {
|
||||
Map<String, String> 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<JSONObject> 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<String, String> _entries;
|
||||
|
||||
private EncryptedState(JSONObject mkObj, Map<String, String> 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<String, String> _entries;
|
||||
private final SecretKey _masterKey;
|
||||
|
||||
public DecryptedStateV2(Map<String, String> entries, SecretKey masterKey) {
|
||||
super(false);
|
||||
_entries = entries;
|
||||
_masterKey = masterKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result convert() throws DatabaseImporterException {
|
||||
Result result = new Result();
|
||||
|
||||
for (Map.Entry<String, String> 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<JSONObject> _entries;
|
||||
|
||||
public DecryptedStateV1(List<JSONObject> 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<String, String> parse(DataInputStream inStream)
|
||||
throws IOException, ParseException {
|
||||
Map<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<PBKDFTask.Params, SecretKey> {
|
|||
|
||||
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);
|
||||
|
|
|
@ -530,8 +530,10 @@
|
|||
<string name="importer_help_battle_net_authenticator">Supply a copy of <b>/data/data/com.blizzard.messenger/shared_prefs/com.blizzard.messenger.authenticator_preferences.xml</b>, located in the internal storage directory of Battle.net Authenticator.</string>
|
||||
<string name="importer_help_duo">Supply a copy of <b>/data/data/com.duosecurity.duomobile/files/duokit/accounts.json</b>, located in the internal storage directory of DUO.</string>
|
||||
<string name="importer_help_ente_auth">Supply an Ente Auth export file. Currently only unencrypted files are supported.</string>
|
||||
<string name="importer_help_freeotp">Supply a copy of <b>/data/data/org.fedorahosted.freeotp/shared_prefs/tokens.xml</b>, located in the internal storage directory of FreeOTP (1.x).</string>
|
||||
<string name="importer_help_freeotp">FreeOTP 2: Supply a backup file.\nFreeOTP 1.x: Supply a copy of <b>/data/data/org.fedorahosted.freeotp/shared_prefs/tokens.xml</b>, located in the internal storage directory of FreeOTP.</string>
|
||||
<string name="importer_help_freeotp_plus">Supply a FreeOTP+ export file.</string>
|
||||
<string name="importer_warning_title_freeotp2">FreeOTP 2 compatibility</string>
|
||||
<string name="importer_warning_message_freeotp2">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.</string>
|
||||
<string name="importer_help_google_authenticator"><b>Only database files from Google Authenticator v5.10 and prior are supported</b>.\n\nSupply a copy of <b>/data/data/com.google.android.apps.authenticator2/databases/databases</b>, located in the internal storage directory of Google Authenticator.</string>
|
||||
<string name="importer_help_microsoft_authenticator">Supply a copy of <b>/data/data/com.azure.authenticator/databases/PhoneFactor</b>, located in the internal storage directory of Microsoft Authenticator.</string>
|
||||
<string name="importer_help_plain_text">Supply a plain text file with a Google Authenticator URI on each line.</string>
|
||||
|
|
|
@ -216,21 +216,57 @@ public class DatabaseImporterTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testImportFreeOtp() throws IOException, DatabaseImporterException, OtpInfoException {
|
||||
public void testImportFreeOtpV1() throws IOException, DatabaseImporterException, OtpInfoException {
|
||||
List<VaultEntry> entries = importPlain(FreeOtpImporter.class, "freeotp.xml");
|
||||
checkImportedFreeOtpEntries(entries);
|
||||
checkImportedFreeOtpEntriesV1(entries);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testImportFreeOtpV2Api23() throws IOException, DatabaseImporterException, OtpInfoException {
|
||||
List<VaultEntry> 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<VaultEntry> 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<VaultEntry> 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<VaultEntry> 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<VaultEntry> entries = importPlain(FreeOtpPlusImporter.class, "freeotp_plus.json");
|
||||
checkImportedFreeOtpEntries(entries);
|
||||
checkImportedFreeOtpEntriesV1(entries);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testImportFreeOtpPlusInternal() throws IOException, DatabaseImporterException, OtpInfoException {
|
||||
List<VaultEntry> 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<VaultEntry> entries) throws OtpInfoException {
|
||||
private void checkImportedFreeOtpEntriesV1(List<VaultEntry> entries) throws OtpInfoException {
|
||||
for (VaultEntry entry : entries) {
|
||||
// for some reason, FreeOTP adds -1 to the counter
|
||||
VaultEntry entryVector = getEntryVectorBySecret(entry.getInfo().getSecret());
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Add table
Reference in a new issue