Merge pull request #1812 from alexbakker/proton-encrypted
build / build (push) Canceled after 0s
build / test (push) Canceled after 0s
codeql / analyze (push) Canceled after 0s
crowdin / upload-sources (push) Canceled after 0s

Add support for importing encrypted Proton Authenticator exports
This commit is contained in:
Michael Schättgen
2026-07-16 12:50:56 +02:00
committed by GitHub
4 changed files with 139 additions and 7 deletions
@@ -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 {
}
}
}
}
}
+1
View File
@@ -195,6 +195,7 @@
<string name="password_reminder_freq_biweekly">Biweekly</string>
<string name="password_reminder_freq_monthly">Monthly</string>
<string name="password_reminder_freq_quarterly">Quarterly</string>
<string name="enter_password_proton_message">It looks like this Proton Authenticator backup is encrypted. Please enter the password below.</string>
<string name="enter_password_2fas_message">It looks like this 2FAS backup is encrypted. Please enter the password below.</string>
<string name="enter_password_authy_message">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.</string>
<string name="enter_password_aegis_title">Please enter the import password</string>
@@ -396,6 +396,25 @@ public class DatabaseImporterTest {
checkImportedEntries(entries);
}
@Test
public void testImportProtonAuthenticatorEncrypted() throws IOException, DatabaseImporterException, OtpInfoException {
List<VaultEntry> 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<VaultEntry> importPlain(Class<? extends DatabaseImporter> type, String resName)
throws IOException, DatabaseImporterException {
return importPlain(type, resName, false);
@@ -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"}