Skip to content

Commit

Permalink
Add support for importing FreeOTP 2 backups
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alexbakker committed Sep 28, 2024
1 parent 08d900c commit 3bed287
Show file tree
Hide file tree
Showing 10 changed files with 410 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public State read(InputStream stream, boolean isInternal) throws DatabaseImporte
entries.add(array.getJSONObject(i));
}

state = new FreeOtpImporter.State(entries);
state = new FreeOtpImporter.DecryptedStateV1(entries);
} catch (IOException | JSONException e) {
throw new DatabaseImporterException(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
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;
Expand Down Expand Up @@ -31,10 +36,18 @@ protected SecretKey doInBackground(Params... args) {

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);
return new SecretKeySpec(key.getEncoded(), "AES");
return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES");
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">Supply a FreeOTP 2 backup file. For FreeOTP 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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,21 +216,57 @@ public void testImportBitwardenCsv() throws IOException, DatabaseImporterExcepti
}

@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
Expand Down Expand Up @@ -423,7 +459,7 @@ private void checkImportedTotpAuthenticatorEntries(List<VaultEntry> entries) thr
}
}

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());
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 comments on commit 3bed287

Please sign in to comment.