diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java
index 2ac34db4..ebf42373 100644
--- a/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java
+++ b/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java
@@ -6,20 +6,43 @@ import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
+import androidx.lifecycle.Lifecycle;
+import com.beemdevelopment.aegis.R;
+import com.beemdevelopment.aegis.encoding.Base32;
+import com.beemdevelopment.aegis.encoding.Base64;
+import com.beemdevelopment.aegis.encoding.EncodingException;
+import com.beemdevelopment.aegis.helpers.ContextHelper;
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
import com.beemdevelopment.aegis.otp.GoogleAuthInfoException;
-import com.beemdevelopment.aegis.otp.OtpInfo;
+import com.beemdevelopment.aegis.otp.OtpInfoException;
+import com.beemdevelopment.aegis.otp.SteamInfo;
+import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
+import com.beemdevelopment.aegis.ui.tasks.Argon2Task;
import com.beemdevelopment.aegis.util.IOUtils;
+import com.beemdevelopment.aegis.util.JsonUtils;
import com.beemdevelopment.aegis.vault.VaultEntry;
+import com.google.common.base.Objects;
import com.topjohnwu.superuser.io.SuFile;
+import org.bouncycastle.crypto.params.Argon2Parameters;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
public class ProtonAuthenticatorImporter extends DatabaseImporter {
@@ -38,12 +61,94 @@ public class ProtonAuthenticatorImporter extends DatabaseImporter {
String contents = new String(IOUtils.readAll(stream), UTF_8);
JSONObject json = new JSONObject(contents);
+ if (json.has("salt") && json.has("content")) {
+ int version = json.getInt("version");
+ if (version != 1) {
+ throw new DatabaseImporterException(String.format("Unsupported version: %d", version));
+ }
+ byte[] salt = Base64.decode(json.getString("salt"));
+ byte[] content = Base64.decode(json.getString("content"));
+ return new EncryptedState(salt, content);
+ }
+
return new DecryptedState(json);
} catch (JSONException | IOException e) {
throw new DatabaseImporterException(e);
}
}
+ public static class EncryptedState extends DatabaseImporter.State {
+ private static final int KEY_SIZE = 32;
+ private static final int MEMORY_KB = 19 * 1024;
+ private static final int ITERATIONS = 2;
+ private static final int PARALLELISM = 1;
+ private static final int NONCE_SIZE = 12;
+ private static final int TAG_SIZE = 16;
+ private static final byte[] AAD = "proton.authenticator.export.v1".getBytes(UTF_8);
+
+ private final byte[] _salt;
+ private final byte[] _content;
+
+ private EncryptedState(byte[] salt, byte[] content) {
+ super(true);
+ _salt = salt;
+ _content = content;
+ }
+
+ public DecryptedState decrypt(char[] password) throws DatabaseImporterException {
+ Argon2Task.Params params = getKeyDerivationParams(password);
+ SecretKey key = Argon2Task.deriveKey(params);
+ return decrypt(key);
+ }
+
+ private DecryptedState decrypt(SecretKey key) throws DatabaseImporterException {
+ try {
+ byte[] nonce = Arrays.copyOfRange(_content, 0, NONCE_SIZE);
+ byte[] ct = Arrays.copyOfRange(_content, NONCE_SIZE, _content.length);
+
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_SIZE * 8, nonce));
+ cipher.updateAAD(AAD);
+ byte[] plaintext = cipher.doFinal(ct);
+
+ return new DecryptedState(new JSONObject(new String(plaintext, UTF_8)));
+ } catch (BadPaddingException | JSONException e) {
+ throw new DatabaseImporterException(e);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
+ | InvalidAlgorithmParameterException | IllegalBlockSizeException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void decrypt(Context context, DecryptListener listener) {
+ Dialogs.showPasswordInputDialog(context, R.string.enter_password_proton_message, password -> {
+ Argon2Task.Params params = getKeyDerivationParams(password);
+ Argon2Task task = new Argon2Task(context, key -> {
+ try {
+ DecryptedState state = decrypt(key);
+ listener.onStateDecrypted(state);
+ } catch (DatabaseImporterException e) {
+ listener.onError(e);
+ }
+ });
+ Lifecycle lifecycle = ContextHelper.getLifecycle(context);
+ task.execute(lifecycle, params);
+ }, dialog -> listener.onCanceled());
+ }
+
+ private Argon2Task.Params getKeyDerivationParams(char[] password) {
+ Argon2Parameters argon2Params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
+ .withVersion(Argon2Parameters.ARGON2_VERSION_13)
+ .withIterations(ITERATIONS)
+ .withParallelism(PARALLELISM)
+ .withMemoryAsKB(MEMORY_KB)
+ .withSalt(_salt)
+ .build();
+ return new Argon2Task.Params(password, argon2Params, KEY_SIZE);
+ }
+ }
+
public static class DecryptedState extends DatabaseImporter.State {
private final JSONObject _json;
@@ -76,16 +181,22 @@ public class ProtonAuthenticatorImporter extends DatabaseImporter {
private static @NonNull VaultEntry convertEntry(@NonNull JSONObject entry) throws DatabaseImporterEntryException {
try {
JSONObject content = entry.getJSONObject("content");
- String name = content.getString("name");
+ String name = JsonUtils.optString(content, "name");
+ if (name == null) {
+ name = "";
+ }
String uriString = content.getString("uri");
Uri uri = Uri.parse(uriString);
try {
- GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri);
- OtpInfo otp = info.getOtpInfo();
+ if (Objects.equal(uri.getScheme(), "steam") && uri.getHost() != null) {
+ SteamInfo otp = new SteamInfo(Base32.decode(uri.getHost()));
+ return new VaultEntry(otp, name, "Steam");
+ }
- return new VaultEntry(otp, name, info.getIssuer());
- } catch (GoogleAuthInfoException e) {
+ GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri);
+ return new VaultEntry(info.getOtpInfo(), name, info.getIssuer());
+ } catch (GoogleAuthInfoException | OtpInfoException | EncodingException e) {
throw new DatabaseImporterEntryException(e, uriString);
}
} catch (JSONException e) {
@@ -93,4 +204,4 @@ public class ProtonAuthenticatorImporter extends DatabaseImporter {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c29a1911..09381374 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -195,6 +195,7 @@
Biweekly
Monthly
Quarterly
+ It looks like this Proton Authenticator backup is encrypted. Please enter the password below.
It looks like this 2FAS backup is encrypted. Please enter the password below.
It looks like your Authy tokens are encrypted. Please close Aegis, open Authy and unlock the tokens with your password. Instead, Aegis can also attempt to decrypt your Authy tokens for you, if you enter your password below.
Please enter the import password
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 d1b7beb6..1747e65c 100644
--- a/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java
+++ b/app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java
@@ -396,6 +396,25 @@ public class DatabaseImporterTest {
checkImportedEntries(entries);
}
+ @Test
+ public void testImportProtonAuthenticatorEncrypted() throws IOException, DatabaseImporterException, OtpInfoException {
+ List entries = importEncrypted(ProtonAuthenticatorImporter.class, "proton_authenticator_encrypted.json", encryptedState -> {
+ final char[] password = "test".toCharArray();
+ return ((ProtonAuthenticatorImporter.EncryptedState) encryptedState).decrypt(password);
+ });
+ for (VaultEntry entry : entries) {
+ // Proton Authenticator forgets the name and issuer of Steam entries
+ if (entry.getInfo().getTypeId().equals(SteamInfo.ID)) {
+ VaultEntry entryVector = getEntryVectorBySecret(entry.getInfo().getSecret());
+ entryVector.setName("");
+ entryVector.setIssuer("Steam");
+ checkImportedEntry(entryVector, entry);
+ } else {
+ checkImportedEntry(entry);
+ }
+ }
+ }
+
private List importPlain(Class extends DatabaseImporter> type, String resName)
throws IOException, DatabaseImporterException {
return importPlain(type, resName, false);
diff --git a/app/src/test/resources/com/beemdevelopment/aegis/importers/proton_authenticator_encrypted.json b/app/src/test/resources/com/beemdevelopment/aegis/importers/proton_authenticator_encrypted.json
new file mode 100644
index 00000000..40d52811
--- /dev/null
+++ b/app/src/test/resources/com/beemdevelopment/aegis/importers/proton_authenticator_encrypted.json
@@ -0,0 +1 @@
+{"version":1,"salt":"sEXuJLVIa3jqN6tO0MqWWw==","content":"gGNkWhT158vSo5q/6M/BvlaSjSuMbNWkXp8y3Jjp0oWY9N9N9DtG+HNb+AJ1ySyw5G4zbk+g4aDRxu8+/JcFCxA5FSMCBgqvMVAWWZcCMj1BGl9qN6XAgoNDcYVWHkV5MyUudZowkVSsZfKS+Fal70KKYMRJ1+oyUzclrFlSPL6SaAy/O9fD3juEkRVlnDcUqSzo1Pw1u+qqvek4qCatbNyIiPzt+m7cHqEb+WJF4bbddd8vt2vA63uM+2GG9/+veomL149DENw9NpnwgB0/Kx914+DxF8LwFoBC4mqCon6AkYPSFkY+nqyRDwEZ9xscY25xptYLTOLlHNwwrBBQvS+gwK8Ou/RkM3GgCxW1FQxNCXLAfH0ixrIDf9Es1qvH1BqdeNeY47TxSuqZp0nIYhS3uD/q5a2DTiJPOjBpA/xzg8yvMvU1vV8mgn+bxDSDfIUmScCLBlUTkKu9ZoCKXg618AxdPxu+EKqAEYTWMDDD1BqyURbdVznlx4rZukwMVJmcMcVTZfZfX8bfa7IQ1pssESIfluPLGYewO8MKW5mfVhcCynJdhWDF8rYmgMZFCMbM65LjDLVM8AoPbIKud6g1mxKvl37UBPI4Q6n1yOTe+IJwJv68eVIvvp//hIxp0XhiDTBbgcN8GnkpO47ssYYXNmpnrTGbaPC3iDGX6VKGWsIwvGdtmlb6EILusDapVoax9lAPjEwLj1qAVAetQoGxTeRhBGE+bPwD7zCbRuHzgc8wTEVJrthfr8bh1+/RD1c0P9uZfG7vXOMzNhTRjCf0A+R6U+qx4Jnv3nuPMQtSG0nQOa4tewsdnmNfPkYb8fjJSU5j1JXHoaX1dP0p3GuL1hbE78I61DY/9Gq43XgaROoRXWtt2FdxHJ7TrnE3R9xP5Vp8lBMXHgGzIic+ScRSpOkruxEpqwNd1L9w3CtmhvufAG0yB+1mZYdphnZquoKQ5LWOfkJTR2uEr4doDFQRQBgJ0GTNe+24Xt98EnVLMoPfuLg6A3f9GLuuWwccKvHzEgZ8+A8IJDThVS4REnKBNEQqUqKcr3hUaT9jBPabTZGuoWpRy97JMYQZnC2ZI6pB9SzUkLbz/gOeR+Ok+qzMhp5accKXbekq2eDkzafv"}
\ No newline at end of file