Skip to content

Commit 762b820

Browse files
committed
Add support for importing encrypted Proton Authenticator exports
1 parent 59d5c64 commit 762b820

4 files changed

Lines changed: 139 additions & 7 deletions

File tree

app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,43 @@
66
import android.net.Uri;
77

88
import androidx.annotation.NonNull;
9+
import androidx.lifecycle.Lifecycle;
910

11+
import com.beemdevelopment.aegis.R;
12+
import com.beemdevelopment.aegis.encoding.Base32;
13+
import com.beemdevelopment.aegis.encoding.Base64;
14+
import com.beemdevelopment.aegis.encoding.EncodingException;
15+
import com.beemdevelopment.aegis.helpers.ContextHelper;
1016
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
1117
import com.beemdevelopment.aegis.otp.GoogleAuthInfoException;
12-
import com.beemdevelopment.aegis.otp.OtpInfo;
18+
import com.beemdevelopment.aegis.otp.OtpInfoException;
19+
import com.beemdevelopment.aegis.otp.SteamInfo;
20+
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
21+
import com.beemdevelopment.aegis.ui.tasks.Argon2Task;
1322
import com.beemdevelopment.aegis.util.IOUtils;
23+
import com.beemdevelopment.aegis.util.JsonUtils;
1424
import com.beemdevelopment.aegis.vault.VaultEntry;
25+
import com.google.common.base.Objects;
1526
import com.topjohnwu.superuser.io.SuFile;
1627

28+
import org.bouncycastle.crypto.params.Argon2Parameters;
1729
import org.json.JSONArray;
1830
import org.json.JSONException;
1931
import org.json.JSONObject;
2032

2133
import java.io.IOException;
2234
import java.io.InputStream;
35+
import java.security.InvalidAlgorithmParameterException;
36+
import java.security.InvalidKeyException;
37+
import java.security.NoSuchAlgorithmException;
38+
import java.util.Arrays;
39+
40+
import javax.crypto.BadPaddingException;
41+
import javax.crypto.Cipher;
42+
import javax.crypto.IllegalBlockSizeException;
43+
import javax.crypto.NoSuchPaddingException;
44+
import javax.crypto.SecretKey;
45+
import javax.crypto.spec.GCMParameterSpec;
2346

2447
public class ProtonAuthenticatorImporter extends DatabaseImporter {
2548

@@ -38,12 +61,94 @@ protected SuFile getAppPath() {
3861
String contents = new String(IOUtils.readAll(stream), UTF_8);
3962
JSONObject json = new JSONObject(contents);
4063

64+
if (json.has("salt") && json.has("content")) {
65+
int version = json.getInt("version");
66+
if (version != 1) {
67+
throw new DatabaseImporterException(String.format("Unsupported version: %d", version));
68+
}
69+
byte[] salt = Base64.decode(json.getString("salt"));
70+
byte[] content = Base64.decode(json.getString("content"));
71+
return new EncryptedState(salt, content);
72+
}
73+
4174
return new DecryptedState(json);
4275
} catch (JSONException | IOException e) {
4376
throw new DatabaseImporterException(e);
4477
}
4578
}
4679

80+
public static class EncryptedState extends DatabaseImporter.State {
81+
private static final int KEY_SIZE = 32;
82+
private static final int MEMORY_KB = 19 * 1024;
83+
private static final int ITERATIONS = 2;
84+
private static final int PARALLELISM = 1;
85+
private static final int NONCE_SIZE = 12;
86+
private static final int TAG_SIZE = 16;
87+
private static final byte[] AAD = "proton.authenticator.export.v1".getBytes(UTF_8);
88+
89+
private final byte[] _salt;
90+
private final byte[] _content;
91+
92+
private EncryptedState(byte[] salt, byte[] content) {
93+
super(true);
94+
_salt = salt;
95+
_content = content;
96+
}
97+
98+
public DecryptedState decrypt(char[] password) throws DatabaseImporterException {
99+
Argon2Task.Params params = getKeyDerivationParams(password);
100+
SecretKey key = Argon2Task.deriveKey(params);
101+
return decrypt(key);
102+
}
103+
104+
private DecryptedState decrypt(SecretKey key) throws DatabaseImporterException {
105+
try {
106+
byte[] nonce = Arrays.copyOfRange(_content, 0, NONCE_SIZE);
107+
byte[] ct = Arrays.copyOfRange(_content, NONCE_SIZE, _content.length);
108+
109+
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
110+
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_SIZE * 8, nonce));
111+
cipher.updateAAD(AAD);
112+
byte[] plaintext = cipher.doFinal(ct);
113+
114+
return new DecryptedState(new JSONObject(new String(plaintext, UTF_8)));
115+
} catch (BadPaddingException | JSONException e) {
116+
throw new DatabaseImporterException(e);
117+
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
118+
| InvalidAlgorithmParameterException | IllegalBlockSizeException e) {
119+
throw new RuntimeException(e);
120+
}
121+
}
122+
123+
@Override
124+
public void decrypt(Context context, DecryptListener listener) {
125+
Dialogs.showPasswordInputDialog(context, R.string.enter_password_proton_message, password -> {
126+
Argon2Task.Params params = getKeyDerivationParams(password);
127+
Argon2Task task = new Argon2Task(context, key -> {
128+
try {
129+
DecryptedState state = decrypt(key);
130+
listener.onStateDecrypted(state);
131+
} catch (DatabaseImporterException e) {
132+
listener.onError(e);
133+
}
134+
});
135+
Lifecycle lifecycle = ContextHelper.getLifecycle(context);
136+
task.execute(lifecycle, params);
137+
}, dialog -> listener.onCanceled());
138+
}
139+
140+
private Argon2Task.Params getKeyDerivationParams(char[] password) {
141+
Argon2Parameters argon2Params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
142+
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
143+
.withIterations(ITERATIONS)
144+
.withParallelism(PARALLELISM)
145+
.withMemoryAsKB(MEMORY_KB)
146+
.withSalt(_salt)
147+
.build();
148+
return new Argon2Task.Params(password, argon2Params, KEY_SIZE);
149+
}
150+
}
151+
47152
public static class DecryptedState extends DatabaseImporter.State {
48153
private final JSONObject _json;
49154

@@ -76,21 +181,27 @@ public DecryptedState(@NonNull JSONObject json) {
76181
private static @NonNull VaultEntry convertEntry(@NonNull JSONObject entry) throws DatabaseImporterEntryException {
77182
try {
78183
JSONObject content = entry.getJSONObject("content");
79-
String name = content.getString("name");
184+
String name = JsonUtils.optString(content, "name");
185+
if (name == null) {
186+
name = "";
187+
}
80188
String uriString = content.getString("uri");
81189

82190
Uri uri = Uri.parse(uriString);
83191
try {
84-
GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri);
85-
OtpInfo otp = info.getOtpInfo();
192+
if (Objects.equal(uri.getScheme(), "steam") && uri.getHost() != null) {
193+
SteamInfo otp = new SteamInfo(Base32.decode(uri.getHost()));
194+
return new VaultEntry(otp, name, "Steam");
195+
}
86196

87-
return new VaultEntry(otp, name, info.getIssuer());
88-
} catch (GoogleAuthInfoException e) {
197+
GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri);
198+
return new VaultEntry(info.getOtpInfo(), name, info.getIssuer());
199+
} catch (GoogleAuthInfoException | OtpInfoException | EncodingException e) {
89200
throw new DatabaseImporterEntryException(e, uriString);
90201
}
91202
} catch (JSONException e) {
92203
throw new DatabaseImporterEntryException(e, entry.toString());
93204
}
94205
}
95206
}
96-
}
207+
}

app/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@
195195
<string name="password_reminder_freq_biweekly">Biweekly</string>
196196
<string name="password_reminder_freq_monthly">Monthly</string>
197197
<string name="password_reminder_freq_quarterly">Quarterly</string>
198+
<string name="enter_password_proton_message">It looks like this Proton Authenticator backup is encrypted. Please enter the password below.</string>
198199
<string name="enter_password_2fas_message">It looks like this 2FAS backup is encrypted. Please enter the password below.</string>
199200
<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>
200201
<string name="enter_password_aegis_title">Please enter the import password</string>

app/src/test/java/com/beemdevelopment/aegis/importers/DatabaseImporterTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,25 @@ public void testImportProtonAuthenticator() throws IOException, DatabaseImporter
396396
checkImportedEntries(entries);
397397
}
398398

399+
@Test
400+
public void testImportProtonAuthenticatorEncrypted() throws IOException, DatabaseImporterException, OtpInfoException {
401+
List<VaultEntry> entries = importEncrypted(ProtonAuthenticatorImporter.class, "proton_authenticator_encrypted.json", encryptedState -> {
402+
final char[] password = "test".toCharArray();
403+
return ((ProtonAuthenticatorImporter.EncryptedState) encryptedState).decrypt(password);
404+
});
405+
for (VaultEntry entry : entries) {
406+
// Proton Authenticator forgets the name and issuer of Steam entries
407+
if (entry.getInfo().getTypeId().equals(SteamInfo.ID)) {
408+
VaultEntry entryVector = getEntryVectorBySecret(entry.getInfo().getSecret());
409+
entryVector.setName("");
410+
entryVector.setIssuer("Steam");
411+
checkImportedEntry(entryVector, entry);
412+
} else {
413+
checkImportedEntry(entry);
414+
}
415+
}
416+
}
417+
399418
private List<VaultEntry> importPlain(Class<? extends DatabaseImporter> type, String resName)
400419
throws IOException, DatabaseImporterException {
401420
return importPlain(type, resName, false);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}

0 commit comments

Comments
 (0)