From 7e7c5317b1b3a6ab221905d90ec5a14c88daacc0 Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Wed, 29 Jan 2020 18:48:28 +0100 Subject: [PATCH] Add support for importing from TOTP Authenticator This is by far the most ridiculous format/'security' I've seen so far. --- .../aegis/encoding/Base64.java | 6 + .../aegis/importers/DatabaseImporter.java | 2 + .../DatabaseImporterEntryException.java | 5 + .../importers/TotpAuthenticatorImporter.java | 235 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 5 files changed, 251 insertions(+) create mode 100644 app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java index 91613eab..b6dbe4b9 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java @@ -2,6 +2,8 @@ package com.beemdevelopment.aegis.encoding; import com.google.common.io.BaseEncoding; +import java.nio.charset.StandardCharsets; + public class Base64 { private Base64() { @@ -15,6 +17,10 @@ public class Base64 { } } + public static byte[] decode(byte[] s) throws EncodingException { + return decode(new String(s, StandardCharsets.UTF_8)); + } + public static String encode(byte[] data) { return BaseEncoding.base64().encode(data); } 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 f72711bb..57f2a83c 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.put("FreeOTP+", FreeOtpPlusImporter.class); _importers.put("Google Authenticator", GoogleAuthImporter.class); _importers.put("Steam", SteamImporter.class); + _importers.put("TOTP Authenticator", TotpAuthenticatorImporter.class); _importers.put("WinAuth", WinAuthImporter.class); _appImporters = new LinkedHashMap<>(); @@ -41,6 +42,7 @@ public abstract class DatabaseImporter { _appImporters.put("FreeOTP+", FreeOtpPlusImporter.class); _appImporters.put("Google Authenticator", GoogleAuthImporter.class); _appImporters.put("Steam", SteamImporter.class); + _appImporters.put("TOTP Authenticator", TotpAuthenticatorImporter.class); } public DatabaseImporter(Context context) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java index 7aff6df4..9df0be86 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java @@ -3,6 +3,11 @@ package com.beemdevelopment.aegis.importers; public class DatabaseImporterEntryException extends Exception { private String _text; + public DatabaseImporterEntryException(String message, String text) { + super(message); + _text = text; + } + public DatabaseImporterEntryException(Throwable cause, String text) { super(cause); _text = text; diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java new file mode 100644 index 00000000..cd21e3d2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java @@ -0,0 +1,235 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.util.Xml; + +import androidx.appcompat.app.AlertDialog; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.Dialogs; +import com.beemdevelopment.aegis.util.PreferenceParser; +import com.beemdevelopment.aegis.vault.VaultEntry; + +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.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +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.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class TotpAuthenticatorImporter extends DatabaseImporter { + private static final String _subPath = "shared_prefs/TOTP_Authenticator_Preferences.xml"; + private static final String _pkgName = "com.authenticator.authservice2"; + + // WARNING: DON'T DO THIS IN YOUR OWN CODE + // this is a hardcoded password and nonce, used solely to decrypt TOTP Authenticator backups + private static final char[] PASSWORD = "TotpAuthenticator".toCharArray(); + private static final byte[] IV = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + private static final String PREF_KEY = "STATIC_TOTP_CODES_LIST"; + + public TotpAuthenticatorImporter(Context context) { + super(context); + } + + @Override + protected String getAppPkgName() { + return _pkgName; + } + + @Override + protected String getAppSubPath() { + return _subPath; + } + + @Override + public State read(FileReader reader) throws DatabaseImporterException { + try { + if (reader.isInternal()) { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(reader.getStream(), null); + parser.nextTag(); + + String data = null; + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (entry.Name.equals(PREF_KEY)) { + data = entry.Value; + } + } + + if (data == null) { + throw new DatabaseImporterException(String.format("Key %s not found in shared preference file", PREF_KEY)); + } + + List entries = parse(data); + return new DecryptedState(entries); + } else { + byte[] base64 = reader.readAll(); + byte[] cipherText = Base64.decode(base64); + return new EncryptedState(cipherText); + } + } catch (IOException | XmlPullParserException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + private static List parse(String data) throws JSONException { + JSONArray array = new JSONArray(data); + + List entries = new ArrayList<>(); + for (int i = 0; i < array.length(); ++i) { + String s = array.getString(i); + entries.add(new JSONObject(s)); + } + + return entries; + } + + public static class EncryptedState extends DatabaseImporter.State { + private byte[] _data; + + public EncryptedState(byte[] data) { + super(true); + _data = data; + } + + private DecryptedState decrypt(char[] password) throws DatabaseImporterException { + try { + // WARNING: DON'T DO THIS IN YOUR OWN CODE + // this is not a secure way to derive a key from a password + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + byte[] keyBytes = hash.digest(CryptoUtils.toBytes(password)); + SecretKey key = new SecretKeySpec(keyBytes, "AES"); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding"); + IvParameterSpec spec = new IvParameterSpec(IV); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + byte[] bytes = cipher.doFinal(_data); + JSONObject obj = new JSONObject(new String(bytes, StandardCharsets.UTF_8)); + JSONArray keys = obj.names(); + + List entries = new ArrayList<>(); + if (keys != null && keys.length() > 0) { + entries = parse((String) keys.get(0)); + } + + return new DecryptedState(entries); + } catch (NoSuchAlgorithmException + | NoSuchPaddingException + | InvalidAlgorithmParameterException + | InvalidKeyException + | BadPaddingException + | IllegalBlockSizeException + | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showSecureDialog(new AlertDialog.Builder(context) + .setMessage(R.string.choose_totpauth_importer) + .setPositiveButton(R.string.yes, (dialog, which) -> { + Dialogs.showPasswordInputDialog(context, password -> { + decrypt(password, listener); + }); + }) + .setNegativeButton(R.string.no, (dialog, which) -> { + decrypt(PASSWORD, listener); + }) + .create()); + } + + private void decrypt(char[] password, DecryptListener listener) { + try { + DecryptedState state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private List _objs; + + private DecryptedState(List objs) { + super(false); + _objs = objs; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (JSONObject obj : _objs) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + int base = obj.getInt("base"); + String secretString = obj.getString("key"); + + byte[] secret; + switch (base) { + case 16: + secret = Hex.decode(secretString); + break; + case 32: + secret = Base32.decode(secretString); + break; + case 64: + secret = Base64.decode(secretString); + break; + default: + throw new DatabaseImporterEntryException(String.format("Unsupported secret encoding: base %d", base), obj.toString()); + } + + TotpInfo info = new TotpInfo(secret, "SHA1", 6, 30); + String name = obj.optString("name"); + String issuer = obj.optString("issuer"); + + return new VaultEntry(info, name, issuer); + } catch (JSONException | OtpInfoException | EncodingException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ada120c2..d9e77d8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,8 @@ A change in your device\'s security settings has been detected. Please go to \"Aegis -> Settings -> Biometrics\" and re-enable biometric unlock. It\'s been a while since you\'ve entered your password. Do you still remember it? + Yes + No Unlock Biometrics Advanced @@ -122,6 +124,7 @@ New format (v0.6.3 or newer) Old format (v0.6.2 or older) Which format does the andOTP backup file have? + Is this TOTP Authenticator backup encrypted with a password? Select the application you\'d like to import from Select your desired theme Select your desired view mode