From 289d5409a55220d47fc1051fdea5e61e73f5ada6 Mon Sep 17 00:00:00 2001 From: NepNep21 <43792621+NepNep21@users.noreply.github.com> Date: Thu, 2 Mar 2023 17:46:45 -0300 Subject: [PATCH] Add Authenticator Pro encrypted import support --- app/src/main/AndroidManifest.xml | 1 + .../importers/AuthenticatorProImporter.java | 295 ++++++++++++++++++ .../aegis/importers/DatabaseImporter.java | 1 + app/src/main/res/values/strings.xml | 1 + .../aegis/importers/DatabaseImporterTest.java | 33 ++ .../aegis/importers/authpro_encrypted.bin | Bin 0 -> 1332 bytes .../aegis/importers/authpro_internal.db | Bin 0 -> 40960 bytes .../aegis/importers/authpro_plain.json | 1 + 8 files changed, 332 insertions(+) create mode 100644 app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorProImporter.java create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_encrypted.bin create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_internal.db create mode 100644 app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_plain.json diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index debc5516..724e935c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -146,6 +146,7 @@ + diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorProImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorProImporter.java new file mode 100644 index 00000000..bd6d4d54 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorProImporter.java @@ -0,0 +1,295 @@ +package com.beemdevelopment.aegis.importers; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.*; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import javax.crypto.*; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import java.io.*; +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.List; + +public class AuthenticatorProImporter extends DatabaseImporter { + private static final String _HEADER = "AuthenticatorPro"; + private static final int _ITERATIONS = 64000; + private static final int _KEY_SIZE = 32 * Byte.SIZE; + private static final String _PKG_NAME = "me.jmh.authenticatorpro"; + private static final String _PKG_DB_PATH = "files/proauth.db3"; + + private enum Algorithm { + SHA1, + SHA256, + SHA512 + } + + public AuthenticatorProImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException { + return getAppPath(_PKG_NAME, _PKG_DB_PATH); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + return isInternal ? readInternal(stream) : readExternal(stream); + } + + private State readInternal(InputStream stream) throws DatabaseImporterException { + List entries = new SqlImporterHelper(requireContext()).read(SqlEntry.class, stream, "authenticator"); + return new SqlState(entries); + } + + private State readExternal(InputStream stream) throws DatabaseImporterException { + byte[] data; + try { + data = IOUtils.readAll(stream); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + + try { + return new JsonState(new JSONObject(new String(data, StandardCharsets.UTF_8))); + } catch (JSONException e) { + return readEncrypted(new DataInputStream(new ByteArrayInputStream(data))); + } + } + + private EncryptedState readEncrypted(DataInputStream stream) throws DatabaseImporterException { + try { + byte[] headerBytes = new byte[_HEADER.getBytes(StandardCharsets.UTF_8).length]; + stream.readFully(headerBytes); + String header = new String(headerBytes, StandardCharsets.UTF_8); + if (!header.equals(_HEADER)) { + throw new DatabaseImporterException("Invalid encryption header: " + header); + } + int saltSize = 20; + byte[] salt = new byte[saltSize]; + stream.readFully(salt); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + int ivSize = cipher.getBlockSize(); + byte[] iv = new byte[ivSize]; + stream.readFully(iv); + return new EncryptedState(cipher, salt, iv, IOUtils.readAll(stream)); + } catch (UTFDataFormatException e) { + throw new DatabaseImporterException("Encryption header does not exist"); + } catch (IOException | NoSuchPaddingException | NoSuchAlgorithmException e) { + throw new DatabaseImporterException(e); + } + } + + private static VaultEntry fromAny( + int type, + String issuer, + String username, + byte[] secret, + Algorithm algo, + int digits, + int period, + int counter, + Object obj + ) throws OtpInfoException, DatabaseImporterEntryException { + OtpInfo info; + switch (type) { + case 1: + info = new HotpInfo(secret, algo.name(), digits, counter); + break; + case 2: + info = new TotpInfo(secret, algo.name(), digits, period); + break; + case 4: + info = new SteamInfo(secret, algo.name(), digits, period); + break; + default: + throw new DatabaseImporterEntryException("Unsupported otp type: " + type, obj.toString()); + } + + return new VaultEntry(info, username, issuer); + } + + private static VaultEntry convertEntry(JSONObject authenticator) throws JSONException, EncodingException, OtpInfoException, DatabaseImporterEntryException { + int type = authenticator.getInt("Type"); + String issuer = authenticator.getString("Issuer"); + Object nullableUsername = authenticator.get("Username"); + String username = nullableUsername == JSONObject.NULL ? "" : nullableUsername.toString(); + byte[] secret = Base32.decode(authenticator.getString("Secret")); + Algorithm algo = Algorithm.values()[authenticator.getInt("Algorithm")]; + int digits = authenticator.getInt("Digits"); + int period = authenticator.getInt("Period"); + int counter = authenticator.getInt("Counter"); + + return fromAny(type, issuer, username, secret, algo, digits, period, counter, authenticator); + } + + static class EncryptedState extends State { + private final Cipher _cipher; + private final byte[] _salt; + private final byte[] _iv; + private final byte[] _data; + + public EncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) { + super(true); + _cipher = cipher; + _salt = salt; + _iv = iv; + _data = data; + } + + public JsonState decrypt(char[] password) throws NoSuchAlgorithmException, + InvalidKeySpecException, + InvalidAlgorithmParameterException, + InvalidKeyException, + IllegalBlockSizeException, + BadPaddingException, + JSONException { + KeySpec spec = new PBEKeySpec(password, _salt, _ITERATIONS, _KEY_SIZE); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + SecretKey key = keyFactory.generateSecret(spec); + _cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(_iv)); + byte[] decrypted = _cipher.doFinal(_data); + return new JsonState(new JSONObject(new String(decrypted, StandardCharsets.UTF_8))); + } + + @Override + public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, password -> { + try { + listener.onStateDecrypted(decrypt(password)); + } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException | JSONException | + InvalidKeyException | BadPaddingException | InvalidKeySpecException | + NoSuchAlgorithmException e) { + listener.onError(e); + } + }, dialog -> listener.onCanceled()); + } + } + + private static class JsonState extends State { + private final JSONObject _obj; + + public JsonState(JSONObject obj) { + super(false); + _obj = obj; + } + + @Override + public Result convert() throws DatabaseImporterException { + try { + return convertThrowing(); + } catch (OtpInfoException | EncodingException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + private Result convertThrowing() throws JSONException, OtpInfoException, EncodingException { + Result ret = new Result(); + JSONArray authenticators = _obj.getJSONArray("Authenticators"); + for (int i = 0; i < authenticators.length(); i++) { + JSONObject authenticator = authenticators.getJSONObject(i); + try { + ret.addEntry(convertEntry(authenticator)); + } catch (DatabaseImporterEntryException e) { + ret.addError(e); + } + } + + return ret; + } + } + + private static class SqlState extends State { + private final List _entries; + + public SqlState(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result ret = new Result(); + for (SqlEntry entry : _entries) { + try { + ret.addEntry(entry.convert()); + } catch (DatabaseImporterEntryException e) { + ret.addError(e); + } catch (OtpInfoException e) { + throw new DatabaseImporterException(e); + } + } + + return ret; + } + } + + private static class SqlEntry extends SqlImporterHelper.Entry { + private final int _type; + private final String _issuer; + private final String _username; + private final byte[] _secret; + private final Algorithm _algo; + private final int _digits; + private final int _period; + private final int _counter; + public SqlEntry(Cursor cursor) { + super(cursor); + _type = SqlImporterHelper.getInt(cursor, "type"); + _issuer = SqlImporterHelper.getString(cursor, "issuer"); + _username = SqlImporterHelper.getString(cursor, "username"); + String secret = SqlImporterHelper.getString(cursor, "secret"); + try { + _secret = Base32.decode(secret); + } catch (EncodingException e) { + throw new SQLiteException(secret); // Rethrown upstream as DatabaseImporterException + } + _algo = Algorithm.values()[SqlImporterHelper.getInt(cursor, "algorithm")]; + _digits = SqlImporterHelper.getInt(cursor, "digits"); + _period = SqlImporterHelper.getInt(cursor, "period"); + _counter = SqlImporterHelper.getInt(cursor, "counter"); + } + + // Used when logging unsupported otp types + @SuppressLint("DefaultLocale") + @NotNull + @Override + public String toString() { + return String.format( + "Type: %d, Issuer: %s, Username: %s, Secret: %s, Algo: %s, Digits: %d, Period: %d, Counter: %d", + _type, + _issuer, + _username, + Base32.encode(_secret), + _algo.name(), + _digits, + _period, + _counter + ); + } + + public VaultEntry convert() throws DatabaseImporterEntryException, OtpInfoException { + return fromAny(_type, _issuer, _username, _secret, _algo, _digits, _period, _counter, this); + } + } +} 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 90363b0c..f3a9de4d 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java @@ -33,6 +33,7 @@ public abstract class DatabaseImporter { _importers.add(new Definition("Aegis", AegisImporter.class, R.string.importer_help_aegis, false)); _importers.add(new Definition("andOTP", AndOtpImporter.class, R.string.importer_help_andotp, false)); _importers.add(new Definition("Authenticator Plus", AuthenticatorPlusImporter.class, R.string.importer_help_authenticator_plus, false)); + _importers.add(new Definition("Authenticator Pro", AuthenticatorProImporter.class, R.string.importer_help_authenticator_pro, true)); _importers.add(new Definition("Authy", AuthyImporter.class, R.string.importer_help_authy, true)); _importers.add(new Definition("Battle.net Authenticator", BattleNetImporter.class, R.string.importer_help_battle_net_authenticator, true)); _importers.add(new Definition("Bitwarden", BitwardenImporter.class, R.string.importer_help_bitwarden, false)); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31b99132..78f0051a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -457,6 +457,7 @@ Supply a 2FAS Authenticator backup file. Supply an Aegis export/backup file. Supply an Authenticator Plus export file obtained through Settings -> Backup & Restore -> Export as Text and HTML. + Supply an Authenticator Pro export file obtained through Settings -> Back up -> Back up to encrypted file (recommended). Supply a copy of /data/data/com.authy.authy/shared_prefs/com.authy.storage.tokens.authenticator.xml, located in the internal storage directory of Authy. Supply an andOTP export/backup file. Supply a Bitwarden export/backup file. Encrypted files are not supported. 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 9b00db22..b8099e8e 100644 --- a/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java +++ b/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java @@ -21,13 +21,20 @@ import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; import com.google.common.collect.Lists; +import org.json.JSONException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; import java.io.IOException; import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; import java.util.Arrays; import java.util.List; @@ -142,6 +149,32 @@ public class DatabaseImporterTest { checkImportedTotpAuthenticatorEntries(entries); } + @Test + public void testImportAuthProEncrypted() throws DatabaseImporterException, IOException, OtpInfoException { + List entries = importEncrypted(AuthenticatorProImporter.class, "authpro_encrypted.bin", state -> { + char[] password = "test".toCharArray(); + try { + return ((AuthenticatorProImporter.EncryptedState) state).decrypt(password); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | InvalidAlgorithmParameterException | + InvalidKeyException | IllegalBlockSizeException | BadPaddingException | JSONException e) { + throw new DatabaseImporterException(e); + } + }); + checkImportedEntries(entries); + } + + @Test + public void testImportAuthProInternal() throws DatabaseImporterException, IOException, OtpInfoException { + List entries = importPlain(AuthenticatorProImporter.class, "authpro_internal.db", true); + checkImportedEntries(entries); + } + + @Test + public void testImportAuthProPlain() throws DatabaseImporterException, IOException, OtpInfoException { + List entries = importPlain(AuthenticatorProImporter.class, "authpro_plain.json"); + checkImportedEntries(entries); + } + @Test public void testImportAuthy() throws IOException, DatabaseImporterException, OtpInfoException { List entries = importPlain(AuthyImporter.class, "authy_plain.xml"); diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_encrypted.bin b/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_encrypted.bin new file mode 100644 index 0000000000000000000000000000000000000000..25858829537f958a32974cd2bf87f7138ff4f00a GIT binary patch literal 1332 zcmV-41uyrB^qj(fGg=FM_tG(ua!I3K6IB!|`gq2O^2gQE1Qm|`H5T+_Q za4;d?gz#pABlqi<7LO3fP_HZbV)jR#Deu;6;}T`lzO|2WnzilJiZGEo z{lu;)6r9@Agks2d7Z-i}s$4F9B*U18+x^RurPGgxlsrn;_z z+g`0+s`xx&h4lJ>U(X~+#amuQ9L;`eLwV814AdCp4M)8jR>^lPrJqCBKp!BgXWIf2 zH@E#tL`1(mmHyI!S-d!Czn^=rR(WILW-T@S`}oS3HMq!kY|NEey|eUrwEyf6pBv9A za<4;D!$IQ2FLcn?WnMp{zpte}j=AL$@6?%3_46w83S?(y0**|hoQ{@Um80DI=d+hA zVl^~{>XHY5vbkIRha9p^$)L6Stu!CnN)LZZr=qT%0-H zx;_0caNpR}xB*;>`og08s>Juu{ve6hVV4s0$mfGW&-&h9ut(G;$LsJBk{NF$h$iOw zA(SsY*m;Ai3v@25`+D{3bu;E(I{wbmQiLV|)+D3xolIq3YW*VCqS8GrPq6(u_|0=x z7$OL08UG|SrSUE{oP*9ueLXerzd_iha-7*jZKT=v8hmqQLE(?-vrNz7|C?9O!1=I} z;hlFZP>Gx)YFH^Us*BsASZht#jj`j}{VNQ?PkZd4uN6kC^+GK-eR*5+Cfvz0vsOQ{ zmt@mSn8_nkh&2*N_nyIQ)wq703% zD^CZ`V|jkhr6Y|~ZU_(Jy=|XBEqsII;(Lqeeb`z{`QR4rwEQMzghD8re7k(shjM_5 z!mZ#+$gg0bYJO4uk2;rLgm>FD9x}4CT4GI1xq)%XCuTHS?MCLzLt_%PAP-E{8GcJc zuS$ZpjAJB%`NJJOPG+-=&Vw-jB&Ygab}Kn~RI^r<6jt17lED&7F3iu9&xLqN7If@2 zuDJ<wQKuc%tT#l>BnNE12$rbM%|MF`Z&yNZ8W7}t8<~) zB)WJt@`5}Sl)dRnlF4M+r8x_eVn8os?a}nU%08R%KU86|Ja$@Te33 literal 0 HcmV?d00001 diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_internal.db b/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_internal.db new file mode 100644 index 0000000000000000000000000000000000000000..10d689f0d1597b07a8b75962a7b795793f7d0a00 GIT binary patch literal 40960 zcmeI)%WmRU7zc3M5NvY6mD&nTr632X3P_{2;SyT4l?o0xfWZU^kZh;}p2Ra?Q)8-T zN!w9hVIH7QQK_qLJ1@}}=(ek>$DR=I1dp<-g#Jh*_PO|c=XX9(I8M?!lXY7Yo|)EI zwS_eG9>p-!H-bP>6hofdquuW zzWZ|L^-f~vm0vfMLjVF0fB*y_009U<;QtjcI5v84K+g|t^=hOIW>ec7>jTqR9)-nb zG2bok-Vxu_{oarw8+QHWrhP(-}wvC$`E2G`# z2uTM|hdZj(dP_B4=*D$i&<(P*nk6LXp#U4*->2vOH(7eOEYoK69rxbzdX*?R{+~x_2CPL|$*YeKYoi zGt{qjd+I5gXqIl0_lK3B2j+|{DhYC>>>=|POek7A^keqe^p8BJ0rGCjIq#@@U zqWC}gSNym9)5zbE-^eK*5P$##AOHafKmY;|fB*y_0D;E{ME<2L>R_)%aWu`*%)~Nn z(-^Dz=vYyzh4ZGEliEc|Y&5GqIm_SvDXql56U9B`PMLClbYrT$g zR;xEIo2gQ+l4(lityns=8>Zfm<|&${8Lrzc*3_wKWIOqqobIKw?NhN>=rrX{DkDmT zs&c__vBXzjZjnw0`*|u%dU1K(5=7NdhiXAHo~vWssGfH!6{VKTrpu@8`bq1uBi5^O zPAZq#SSl4Hougz#GN~|ECff+J3fT{tUMNbf-0QZoQvJBuE;P|E-6RQ|-UCu;oO zfB*y_009U<00Izz00bZa0SG`~g9Y3_@W4PIeP4FV8=00bZa0SG_<0uX=z1R(I{ z@BeZCADI9F2tWV=5P$##AOHafKmY;|*lYpZ|KIHO#q1ye0SG_<0uX=z1Rwwb2tWV= zjsQOYBM%?|0SG_<0uX=z1Rwwb2tWV=n=gR>|F`*Tj2S`z0uX=z1Rwwb2tWV=5P$## eaQ`1U009U<00Izz00bZa0SG_<0ub1If&Ty&#$9p% literal 0 HcmV?d00001 diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_plain.json b/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_plain.json new file mode 100644 index 00000000..064e1778 --- /dev/null +++ b/app/src/test/resources/com/beemdevelopment/aegis/importers/authpro_plain.json @@ -0,0 +1 @@ +{"Authenticators":[{"Type":2,"Icon":null,"Issuer":"Deno","Username":"Mason","Secret":"4SJHB4GSD43FZBAI7C2HLRJGPQ","Pin":null,"Algorithm":0,"Digits":6,"Period":30,"Counter":0,"Ranking":0},{"Type":2,"Icon":null,"Issuer":"SPDX","Username":"James","Secret":"5OM4WOOGPLQEF6UGN3CPEOOLWU","Pin":null,"Algorithm":1,"Digits":7,"Period":20,"Counter":0,"Ranking":0},{"Type":2,"Icon":null,"Issuer":"Airbnb","Username":"Elijah","Secret":"7ELGJSGXNCCTV3O6LKJWYFV2RA","Pin":null,"Algorithm":2,"Digits":8,"Period":50,"Counter":0,"Ranking":0},{"Type":1,"Icon":null,"Issuer":"Issuu","Username":"James","Secret":"YOOMIXWS5GN6RTBPUFFWKTW5M4","Pin":null,"Algorithm":0,"Digits":6,"Period":30,"Counter":1,"Ranking":0},{"Type":1,"Icon":null,"Issuer":"Air Canada","Username":"Benjamin","Secret":"KUVJJOM753IHTNDSZVCNKL7GII","Pin":null,"Algorithm":1,"Digits":7,"Period":30,"Counter":50,"Ranking":0},{"Type":1,"Icon":null,"Issuer":"WWE","Username":"Mason","Secret":"5VAML3X35THCEBVRLV24CGBKOY","Pin":null,"Algorithm":2,"Digits":8,"Period":30,"Counter":10300,"Ranking":0},{"Type":4,"Icon":null,"Issuer":"Boeing","Username":"Sophia","Secret":"JRZCL47CMXVOQMNPZR2F7J4RGI","Pin":null,"Algorithm":0,"Digits":5,"Period":30,"Counter":0,"Ranking":0}],"Categories":[],"AuthenticatorCategories":[],"CustomIcons":[]} \ No newline at end of file