Skip to content

Commit

Permalink
Add support for new Authenticator Pro backup format
Browse files Browse the repository at this point in the history
This adds support for Authenticator Pro's latest backup format changes.
The format of the content itself has not changed as far as I can tell, but
they do use a different cipher and KDF now: AES GCM and Argon2id,
respectively.

The memory cost is statically set at 64MiB. I suspect that this may
cause OOM situations on some lower-end devices, but we'll see, not much
we can do about that right now without making more changes.
  • Loading branch information
alexbakker committed Sep 7, 2023
1 parent 27e56d6 commit 9cabd9f
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
import com.beemdevelopment.aegis.otp.SteamInfo;
import com.beemdevelopment.aegis.otp.TotpInfo;
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
import com.beemdevelopment.aegis.ui.tasks.Argon2Task;
import com.beemdevelopment.aegis.ui.tasks.PBKDFTask;
import com.beemdevelopment.aegis.util.IOUtils;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.topjohnwu.superuser.io.SuFile;

import org.bouncycastle.crypto.params.Argon2Parameters;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
Expand All @@ -44,9 +46,8 @@
import javax.crypto.spec.IvParameterSpec;

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 HEADER = "AUTHENTICATORPRO";
private static final String HEADER_LEGACY = "AuthenticatorPro";
private static final String PKG_NAME = "me.jmh.authenticatorpro";
private static final String PKG_DB_PATH = "files/proauth.db3";

Expand Down Expand Up @@ -90,24 +91,19 @@ private static State readExternal(InputStream stream) throws DatabaseImporterExc
}
}

private static EncryptedState readEncrypted(DataInputStream stream) throws DatabaseImporterException {
private static State 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 file header");
switch (header) {
case HEADER:
return EncryptedState.parseHeader(stream);
case HEADER_LEGACY:
return LegacyEncryptedState.parseHeader(stream);
default:
throw new DatabaseImporterException("Invalid file 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("Invalid file header");
} catch (IOException | NoSuchPaddingException | NoSuchAlgorithmException e) {
Expand All @@ -130,6 +126,13 @@ private static OtpInfo parseOtpInfo(int type, byte[] secret, Algorithm algo, int
}

static class EncryptedState extends State {
private static final int KEY_SIZE = 32;
private static final int MEMORY_COST = 16; // 2^16 KiB = 64 MiB
private static final int PARALLELISM = 4;
private static final int ITERATIONS = 3;
private static final int SALT_SIZE = 16;
private static final int IV_SIZE = 12;

private final Cipher _cipher;
private final byte[] _salt;
private final byte[] _iv;
Expand All @@ -143,6 +146,81 @@ public EncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) {
_data = data;
}

public JsonState decrypt(char[] password) throws DatabaseImporterException {
Argon2Task.Params params = getKeyDerivationParams(password);
SecretKey key = Argon2Task.deriveKey(params);
return decrypt(key);
}

public JsonState decrypt(SecretKey key) throws DatabaseImporterException {
try {
_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)));
} catch (InvalidAlgorithmParameterException | IllegalBlockSizeException
| JSONException | InvalidKeyException | BadPaddingException e) {
throw new DatabaseImporterException(e);
}
}

@Override
public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException {
Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> {
Argon2Task.Params params = getKeyDerivationParams(password);
Argon2Task task = new Argon2Task(context, key -> {
try {
AuthenticatorProImporter.JsonState 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)
.withIterations(ITERATIONS)
.withParallelism(PARALLELISM)
.withMemoryPowOfTwo(MEMORY_COST)
.withSalt(_salt)
.build();
return new Argon2Task.Params(password, argon2Params, KEY_SIZE);
}

private static EncryptedState parseHeader(DataInputStream stream)
throws IOException, NoSuchPaddingException, NoSuchAlgorithmException {
byte[] salt = new byte[SALT_SIZE];
stream.readFully(salt);

byte[] iv = new byte[IV_SIZE];
stream.readFully(iv);

Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
return new EncryptedState(cipher, salt, iv, IOUtils.readAll(stream));
}
}

static class LegacyEncryptedState extends State {
private static final int ITERATIONS = 64000;
private static final int KEY_SIZE = 32 * Byte.SIZE;
private static final int SALT_SIZE = 20;

private final Cipher _cipher;
private final byte[] _salt;
private final byte[] _iv;
private final byte[] _data;

public LegacyEncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) {
super(true);
_cipher = cipher;
_salt = salt;
_iv = iv;
_data = data;
}

public JsonState decrypt(char[] password) throws DatabaseImporterException {
PBKDFTask.Params params = getKeyDerivationParams(password);
SecretKey key = PBKDFTask.deriveKey(params);
Expand Down Expand Up @@ -180,6 +258,18 @@ public void decrypt(Context context, DecryptListener listener) throws DatabaseIm
private PBKDFTask.Params getKeyDerivationParams(char[] password) {
return new PBKDFTask.Params("PBKDF2WithHmacSHA1", KEY_SIZE, password, _salt, ITERATIONS);
}

private static LegacyEncryptedState parseHeader(DataInputStream stream)
throws IOException, NoSuchPaddingException, NoSuchAlgorithmException {
byte[] salt = new byte[SALT_SIZE];
stream.readFully(salt);

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
int ivSize = cipher.getBlockSize();
byte[] iv = new byte[ivSize];
stream.readFully(iv);
return new LegacyEncryptedState(cipher, salt, iv, IOUtils.readAll(stream));
}
}

private static class JsonState extends State {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.beemdevelopment.aegis.ui.tasks;

import android.content.Context;

import com.beemdevelopment.aegis.R;

import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
import org.bouncycastle.crypto.params.Argon2Parameters;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

public class Argon2Task extends ProgressDialogTask<Argon2Task.Params, SecretKey> {
private final Callback _cb;

public Argon2Task(Context context, Callback cb) {
super(context, context.getString(R.string.unlocking_vault));
_cb = cb;
}

@Override
protected SecretKey doInBackground(Params... args) {
setPriority();

Params params = args[0];
return deriveKey(params);
}

public static SecretKey deriveKey(Params params) {
Argon2BytesGenerator gen = new Argon2BytesGenerator();
gen.init(params.getArgon2Params());

byte[] key = new byte[params.getKeySize()];
gen.generateBytes(params.getPassword(), key);
return new SecretKeySpec(key, 0, key.length, "AES");
}

@Override
protected void onPostExecute(SecretKey key) {
super.onPostExecute(key);
_cb.onTaskFinished(key);
}

public interface Callback {
void onTaskFinished(SecretKey key);
}

public static class Params {
private final char[] _password;
private final Argon2Parameters _argon2Params;
private final int _keySize;

public Params(char[] password, Argon2Parameters argon2Params, int keySize) {
_password = password;
_argon2Params = argon2Params;
_keySize = keySize;
}

public char[] getPassword() {
return _password;
}

public Argon2Parameters getArgon2Params() {
return _argon2Params;
}

public int getKeySize() {
return _keySize;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,16 @@ public void testImportTotpAuthenticatorInternal() throws IOException, DatabaseIm
public void testImportAuthProEncrypted() throws DatabaseImporterException, IOException, OtpInfoException {
List<VaultEntry> entries = importEncrypted(AuthenticatorProImporter.class, "authpro_encrypted.bin", state -> {
char[] password = "test".toCharArray();
try {
return ((AuthenticatorProImporter.EncryptedState) state).decrypt(password);
} catch (DatabaseImporterException e) {
throw new DatabaseImporterException(e);
}
return ((AuthenticatorProImporter.EncryptedState) state).decrypt(password);
});
checkImportedEntries(entries);
}

@Test
public void testImportAuthProEncryptedLegacy() throws DatabaseImporterException, IOException, OtpInfoException {
List<VaultEntry> entries = importEncrypted(AuthenticatorProImporter.class, "authpro_encrypted_legacy.bin", state -> {
char[] password = "test".toCharArray();
return ((AuthenticatorProImporter.LegacyEncryptedState) state).decrypt(password);
});
checkImportedEntries(entries);
}
Expand Down
Binary file not shown.
Binary file not shown.

0 comments on commit 9cabd9f

Please sign in to comment.